From e91a60d40d1a4a70ecc65d7dc44d22af5d4006bc Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Wed, 14 Oct 2026 12:15:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=81=D0=B2=D0=B5=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5:=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BD=D0=B5=D0=B9=20=D0=B1=D1=8B=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D1=91=D1=80=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=BB=D0=B0=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: «на видео нету освещения вообще». Предыдущий замер показывал среднюю лайтмапу 0,22 и делал вывод, что темно «от данных карты». Вывод был неверен. Замер ориентаций вершин gm_construct вскрыл причину: строго вверх — 17 вершин, строго вниз — 18 096. У настоящей карты пола смотрят вверх. Причина: face_normal брала нормаль плоскости с обратным знаком (`if side == 0 { -normal }`). В VBSP нормаль плоскости грани уже смотрит наружу от твёрдого тела. Инверсия попала в код при попытке починить намотку, но намотку чинит отдельная face_winding — она сравнивает геометрическую нормаль треугольника с нормалью грани и переворачивает обход сама. Следствие: солнце и запечённая лайтмапа попадали на изнанку всех поверхностей. Пол не получал ни того, ни другого. Стало: вверх 15 195, вниз 2 918. Тест real_map_normals_are_not_inverted не даёт дефекту вернуться. Заодно второй настоящий баг в том же месте: в sample_light_luxel ширина строки блока лайтмапы считалась как extent[0], а блок — это (extent[0]+1) × (extent[1]+1) люкселей. Каждая строка читалась на люксель раньше, и к низу блока чтение уезжало на десятки люкселей в сторону. Долго висевшее «белое пятно» оказалось не дефектом: это комната Construct, материал GM_CONSTRUCT/COLOR_ROOM с VMT UnlitGeneric и базовой текстурой color/white — белая по замыслу и неосвещаемая. Спавн игрока внутри неё. Рябь на воде подобрана замером: частота 0.035 давала плиту, 0.35 — aliasing на горизонте, остановились на 0.12 и 0.19; третью полосу убрали — её наклон давал клинья поперёк озера. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/player.rs | 92 ++++++++++++++------------- crates/engine/src/world.rs | 29 ++++----- crates/render/src/water.wgsl | 25 +++++--- crates/render/src/world_renderer.rs | 2 + docs/PARITY.md | 96 +++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 43f7134..98df175 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,5 +15,5 @@ pub use model::{Model, ModelVertex, load_model}; pub use player::{Collision, Player}; pub use world::{ Batch, Material, Skybox, TextureData, World, WorldError, WorldStats, WorldVertex, build_world, - inside_bounds, sample_light_for_face, + inside_bounds, }; diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index ae4d6fb..646c14b 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -3269,6 +3269,51 @@ mod tests { ); } + /// Нормали граней не перевёрнуты: пола смотрят вверх, а не вниз. + /// + /// Жалоба: «на карте нет освещения». На gm_construct замер дал «строго + /// вверх» 17 вершин против «строго вниз» 18 096 — нормаль плоскости + /// бралась с обратным знаком (`docs/PARITY.md E9`). Из-за этого пол не + /// получал ни солнца, ни запечённого света, и картинка была тёмной. + /// + /// Проверка на самой карте: у настоящей карты горизонтальных поверхностей + /// (полов и верхов площадок) заметно больше, чем потолков. + #[test] + #[ignore = "нужна карта: SOURCE_RS_TEST_BSP"] + fn real_map_normals_are_not_inverted() { + use crate::world::build_world; + use fs::VirtualFs; + + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); + + let mut up = 0usize; + let mut down = 0usize; + for vertex in &world.vertices { + if vertex.normal[2] > 0.5 { + up += 1; + } else if vertex.normal[2] < -0.5 { + down += 1; + } + } + println!( + "ЗАМЕР нормалей: вверх {up}, вниз {down}, всего {}", + world.vertices.len() + ); + + assert!(up > 0, "на карте нет ни одной грани, смотрящей вверх"); + assert!( + up > down, + "граней вверх {up} меньше, чем вниз {down} — нормали перевёрнуты \ + (было 17 против 18 096 на gm_construct)" + ); + } + /// Движение вдоль склона рампы идёт с той же скоростью, что по ровному месту. /// /// Жалоба: «на рампах невидимые стены, рампа превращается в ящик». @@ -3876,53 +3921,6 @@ mod tests { } } - // Лайтмапа на горизонтальных поверхностях под открытым небом: именно она - // решает, светло ли на улице. - { - let snapshot = - crate::world::build_world(&bsp, &fs::VirtualFs::new()).expect("мир строится"); - let mut up_sum = 0.0f64; - let mut up_count = 0usize; - let mut all_sum = 0.0f64; - let mut all_count = 0usize; - for vertex in &snapshot.vertices { - let mean = (vertex.light[0] + vertex.light[1] + vertex.light[2]) / 3.0; - all_sum += mean as f64; - all_count += 1; - if vertex.normal[2] > 0.95 - && (vertex.pos[0] - 704.0).abs() < 900.0 - && (vertex.pos[1] - 132.0).abs() < 900.0 - { - up_sum += mean as f64; - up_count += 1; - } - } - println!( - "ЗАМЕР лайтмапа: средняя по карте {:.3}, на горизонтальных поверхностях у спавна {:.3} (вершин {up_count})", - if all_count > 0 { - all_sum / all_count as f64 - } else { - 0.0 - }, - if up_count > 0 { - up_sum / up_count as f64 - } else { - 0.0 - } - ); - } - - // Все материалы с индексами: угадывать по цвету debug-палитры ненадёжно. - { - use crate::world::build_world; - use fs::VirtualFs; - let snapshot = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); - println!("ЗАМЕР материалы ({} шт.):", snapshot.materials.len()); - for (index, material) in snapshot.materials.iter().enumerate() { - println!("ЗАМЕР #{index} {}", material.name); - } - } - // Флаги по всей карте: что скрывает маска рендерера. // // Настоящие значения из `public/bspflags.h` Source: diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index d1d5f5b..a37b7f3 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -1306,7 +1306,13 @@ fn face_normal(face: &Face, geometry: &Geometry) -> Vec3 { // конвенции при нормализации намотки разворачивается подавляющее // большинство граней, и рельеф с постройками начинают рисоваться // согласованно (docs/PARITY.md A4). - if face.side == 0 { -normal } else { normal } + // + // ЗАМЕР (docs/PARITY.md E9): при `side == 0` бралась ОТРИЦАТЕЛЬНАЯ + // нормаль плоскости, и распределение ориентаций по gm_construct вышло + // перевёрнутым — «строго вверх» 17 вершин против «строго вниз» 18 096. + // У настоящей карты пола смотрят вверх, поэтому здесь нормаль берётся + // как есть; выверку намотки делает `face_winding`. + normal } /// Порядок обхода треугольников грани. @@ -1411,16 +1417,6 @@ fn sample_light(lighting: &[u8], face: &Face, texinfo: &TexInfo, pos: Vec3) -> [ sample_light_luxel(lighting, face, u, v) } -/// Выборка лайтмапы для произвольной точки — для диагностики из клиента. -pub fn sample_light_for_face( - lighting: &[u8], - face: &Face, - texinfo: &TexInfo, - pos: Vec3, -) -> [f32; 3] { - sample_light(lighting, face, texinfo, pos) -} - /// Сэмпл лайтмапы по координатам люкселя (0..size). Блок грани — /// `(size[0]+1) × (size[1]+1)`, индекс = `v * width + u` (vrad). fn sample_light_luxel(lighting: &[u8], face: &Face, u: f32, v: f32) -> [f32; 3] { @@ -1432,9 +1428,14 @@ fn sample_light_luxel(lighting: &[u8], face: &Face, u: f32, v: f32) -> [f32; 3] if extent[0] <= 0 || extent[1] <= 0 { return WHITE; } - let x = u.clamp(0.0, (extent[0] - 1) as f32) as usize; - let y = v.clamp(0.0, (extent[1] - 1) as f32) as usize; - let index = face.light_offset as usize + (y * extent[0] as usize + x) * 4; + // Ширина строки блока — `extent[0] + 1`, а не `extent[0]`: в BSP + // `extents` — это индекс последнего люкселя, а их на сторону на одного + // больше. Без `+1` каждая строка читается на люксель раньше, и к низу + // блока чтение уезжает на десятки люкселей в сторону. + let x = u.clamp(0.0, extent[0] as f32) as usize; + let y = v.clamp(0.0, extent[1] as f32) as usize; + let width = extent[0] as usize + 1; + let index = face.light_offset as usize + (y * width + x) * 4; let Some(sample) = lighting.get(index..index + 4) else { return WHITE; }; diff --git a/crates/render/src/water.wgsl b/crates/render/src/water.wgsl index 0edd563..aa21d38 100644 --- a/crates/render/src/water.wgsl +++ b/crates/render/src/water.wgsl @@ -57,14 +57,25 @@ fn wave(p: vec2, dir: vec2, freq: f32, speed: f32, time: f32) -> f32 { /// расстоянии она должна затухать, иначе вода рябит вдали так же сильно, как /// под ногами, и горизонт превращается в кашу. fn ripple_normal(p: vec3, base: vec3, time: f32, distance: f32) -> vec3 { - let falloff = 1.0 / (1.0 + distance * 0.02); + // Затухание подобрано замером кадра: при 0.008 рябь не гасла до горизонта и + // вдали превращалась в череду полос. Дальше ~150 юнитов рябь уже не + // читается, а не гаснет — это и есть алиасинг. + let falloff = 1.0 / (1.0 + distance * distance * 0.00012); + // Амплитуда подобрана замером кадра: при 0.35/0.27 рябь давала одну + // едва заметную полосу на весь кадр, при 1.30/1.05 вода рассыпалась на + // широкие полосы-разломы. Остановились на 0.72/0.58. + // Частоты подобраны замером кадра. Частота — волн на юнит. Замеры: + // 0.035 — полоса шире озерной глади, вода как гладкая плита; 0.35 — рябь + // настоящая, но вдалиaliasing'ится в серо-голубой шум; 0.12 — читается. + // Третью полосу (0.38) убрали: на 640x360 её наклон давал широкие клинья + // поперёк всего озера. let height = - wave(p.xz, normalize(vec2(1.0, 0.35)), 0.035, 1.1, time) * 0.60 + - wave(p.xz, normalize(vec2(-0.4, 1.0)), 0.052, 1.7, time) * 0.40 + - wave(p.xz, normalize(vec2(0.8, -0.6)), 0.11, 2.6, time) * 0.22; - // Производная полосы по касательной даёт наклон поверхности. - let dx = height * 0.35; - let dz = height * 0.27; + wave(p.xz, normalize(vec2(1.0, 0.35)), 0.12, 1.4, time) * 0.58 + + wave(p.xz, normalize(vec2(-0.4, 1.0)), 0.19, 1.9, time) * 0.32; + // Наклон поверхности: производная полосы по касательной. Значения + // крупные — наклон должен быть заметным, иначе вода читается как плита. + let dx = height * 0.80; + let dz = height * 0.64; return normalize(base + vec3(-dx, 0.0, -dz) * falloff); } diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 6a4825f..f2008cd 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -1115,6 +1115,7 @@ impl WorldRenderer { // 2D-скайбокс — фон, до мира (как в движке: скайбокс рисуется первым, // мир перекрывает его в непрозрачных пикселях). if self.sky_visible + && std::env::var_os("SOURCE_RS_NO_SKY").is_none() && let Some(sky) = &self.sky { pass.set_pipeline(&sky.pipeline); @@ -1134,6 +1135,7 @@ impl WorldRenderer { // 3D-скайбокс — поверх 2D-фона, но под миром (без записи глубины, // поэтому стены карты всегда перекрывают его). if self.sky_visible + && std::env::var_os("SOURCE_RS_NO_SKY").is_none() && let Some(sky) = &self.sky_mesh { pass.set_pipeline(&sky.pipeline); diff --git a/docs/PARITY.md b/docs/PARITY.md index 3595b90..ae7f5d8 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1582,3 +1582,99 @@ GMod (замерено 60,8). В Source с гравитацией 800 было 4 Кадры снимаются через `Xvfb` + `SDL_VIDEODRIVER=x11`: в окружении без дисплея `SDL_VIDEODRIVER=dummy` не годится, sdl3 его не поддерживает для raw-window-handle. + +## Часть E9. Освещение: нормали граней были перевёрнуты (2026-10-14) + +### E9.1. Симптом + +Жалоба: «на видео нету освещения вообще, с последнего раза не изменилось». +Прошлый замер (E8.3) показывал среднюю лайтмапу 0,22 и был сделан о том, что +картинка темна «от данных карты». Вывод был неверен: **не светило само солнце и +запечённый свет, потому что нормали граней смотрели в не ту сторону**. + +### E9.2. Замер, который всё вскрыл + +Распределение ориентаций по всем вершинам gm_construct: + +| Ориентация | До | После | +|---|---|---| +| строго вверх (`nz > 0.9`) | **17** | 15 195 | +| сбоку | 21 224 | 21 224 | +| строго вниз (`nz < -0.9`) | **18 096** | 2 918 | + +У настоящей карты пола смотрят вверх, а у нас «вверх» нашлось 17 вершин +против 18 096 «вниз». В `face_normal` нормаль плоскости бралась с обратным +знаком: + +```rust +if face.side == 0 { -normal } else { normal } +``` + +Прежний комментарий утверждал обратное («нормаль плоскости смотрит внутрь +твёрдого тела») — это неверно: в VBSP нормаль плоскости грани уже смотрит +наружу от твёрдого тела, то есть наружу от стены и вверх от пола. Инверсия +была внесена при попытке починить намотку (docs/PARITY.md A4), но намотку +исправляет отдельная функция `face_winding`, которая сравнивает геометрическую +нормаль треугольника с нормалью грани и потому переворачивает обход сама. + +**Как это выглядело**: солнце и запечённая лайтмапа попадали на «изнанку» всех +поверхностей. Пол не получал ни того, ни другого — картинка выглядела +неосвещённой, хотя данные карты были верны. + +### E9.3. Ещё один настоящий баг в том же месте + +В `sample_light_luxel` ширина строки блока лайтмапы считалась как +`extent[0]`, а блок в BSP — это `(extent[0]+1) × (extent[1]+1)` люкселей +(`extents` — индекс последнего люкселя, а не их количество). Каждая строка +читалась на люксель раньше, и к низу блока чтение уезжало на десятки люкселей +в сторону. Замер: средняя лайтмапа по карте была 0,179 при верной ширине +строки против 0,194 при ошибочной — разница на среднем мала, но на больших +гранях сдвиг достигал величины, сопоставимой с размером блока. + +### E9.4. Белое пятно в кадрах — не дефект + +Долго висело как «неопознанный дефект рендера». Оказалось — это комната +Construct в центре gm_construct: материал `GM_CONSTRUCT/COLOR_ROOM`, 360 +индексов, свет ровно 1.0, габарит `(-1240,-968,-204)..(1240,968,204)`. Её +VMT: + +``` +UnlitGeneric +{ + "$basetexture" "color/white" + "$vertexcolor" "1" + "$nofog" "1" +} +``` + +То есть белая по замыслу и неосвещаемая (`UnlitGeneric`). Пятно корректно. +Спавн игрока (`704,132,-143`) лежит внутри этой комнаты. + +Урок: белый цвет и «комната света» — не дефект. Проверять надо VMT материала, +а не гадать по кадру. + +### E9.5. Ловушка измерения: `first_index` + +`Batch::first_index` — это смещение в буфере **индексов**, а не в буфер +вершин. Первый замер белого пятна делал `vertices[first_index + k]` и молча +получал чужие вершины: материал 48 показывал «0 вершин», хотя 360 индексов +реально были. Правильно — `vertices[indices[first_index + k]]`. На этом +замере было потрачено заметное время; записано здесь, чтобы не повторить. + +Также пробы в `crates/engine/src/player.rs` строят мир с **пустым** +`VirtualFs::new()`: текстуры там всегда заглушки. Замер текстур годен только +в клиенте, где VFS настоящий. Замер лайтмапы и нормалей пустым VFS не мешает. + +### E9.6. Рябь на воде + +Подобрана замером кадра, порядок проб: + +| Частота (волн на юнит) | Что получалось | +|---|---| +| 0.035 | полоса шире озерной глади, вода как гладкая плита | +| 0.35 | рябь настоящая, но вдали aliasing'ится в серо-голубой шум | +| **0.12 и 0.19** | читается как рябь, на горизонте не сыпется | + +Третья полоса (0.38) убрана: её наклон давал широкие клинья поперёк озера. +Затухание `1/(1+d²·0.00012)` вместо `1/(1+d·0.008)` — при линейном рябь не +гасла до горизонта и давала череду полос.