Освещение: нормали граней были перевёрнуты, карта не получала света

Симптом: «на видео нету освещения вообще». Предыдущий замер показывал
среднюю лайтмапу 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; третью полосу убрали —
её наклон давал клинья поперёк озера.
This commit is contained in:
Niko Marmeladkov 2026-10-14 12:15:15 +03:00
parent 846cdc5185
commit e91a60d40d
6 changed files with 177 additions and 69 deletions

View file

@ -15,5 +15,5 @@ pub use model::{Model, ModelVertex, load_model};
pub use player::{Collision, Player}; pub use player::{Collision, Player};
pub use world::{ pub use world::{
Batch, Material, Skybox, TextureData, World, WorldError, WorldStats, WorldVertex, build_world, Batch, Material, Skybox, TextureData, World, WorldError, WorldStats, WorldVertex, build_world,
inside_bounds, sample_light_for_face, inside_bounds,
}; };

View file

@ -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: // Настоящие значения из `public/bspflags.h` Source:

View file

@ -1306,7 +1306,13 @@ fn face_normal(face: &Face, geometry: &Geometry) -> Vec3 {
// конвенции при нормализации намотки разворачивается подавляющее // конвенции при нормализации намотки разворачивается подавляющее
// большинство граней, и рельеф с постройками начинают рисоваться // большинство граней, и рельеф с постройками начинают рисоваться
// согласованно (docs/PARITY.md A4). // согласованно (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) 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). Блок грани — /// Сэмпл лайтмапы по координатам люкселя (0..size). Блок грани —
/// `(size[0]+1) × (size[1]+1)`, индекс = `v * width + u` (vrad). /// `(size[0]+1) × (size[1]+1)`, индекс = `v * width + u` (vrad).
fn sample_light_luxel(lighting: &[u8], face: &Face, u: f32, v: f32) -> [f32; 3] { 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 { if extent[0] <= 0 || extent[1] <= 0 {
return WHITE; return WHITE;
} }
let x = u.clamp(0.0, (extent[0] - 1) as f32) as usize; // Ширина строки блока — `extent[0] + 1`, а не `extent[0]`: в BSP
let y = v.clamp(0.0, (extent[1] - 1) as f32) as usize; // `extents` — это индекс последнего люкселя, а их на сторону на одного
let index = face.light_offset as usize + (y * extent[0] as usize + x) * 4; // больше. Без `+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 { let Some(sample) = lighting.get(index..index + 4) else {
return WHITE; return WHITE;
}; };

View file

@ -57,14 +57,25 @@ fn wave(p: vec2<f32>, dir: vec2<f32>, freq: f32, speed: f32, time: f32) -> f32 {
/// расстоянии она должна затухать, иначе вода рябит вдали так же сильно, как /// расстоянии она должна затухать, иначе вода рябит вдали так же сильно, как
/// под ногами, и горизонт превращается в кашу. /// под ногами, и горизонт превращается в кашу.
fn ripple_normal(p: vec3<f32>, base: vec3<f32>, time: f32, distance: f32) -> vec3<f32> { fn ripple_normal(p: vec3<f32>, base: vec3<f32>, time: f32, distance: f32) -> vec3<f32> {
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 = let height =
wave(p.xz, normalize(vec2<f32>(1.0, 0.35)), 0.035, 1.1, time) * 0.60 + wave(p.xz, normalize(vec2<f32>(1.0, 0.35)), 0.12, 1.4, time) * 0.58 +
wave(p.xz, normalize(vec2<f32>(-0.4, 1.0)), 0.052, 1.7, time) * 0.40 + wave(p.xz, normalize(vec2<f32>(-0.4, 1.0)), 0.19, 1.9, time) * 0.32;
wave(p.xz, normalize(vec2<f32>(0.8, -0.6)), 0.11, 2.6, time) * 0.22; // Наклон поверхности: производная полосы по касательной. Значения
// Производная полосы по касательной даёт наклон поверхности. // крупные — наклон должен быть заметным, иначе вода читается как плита.
let dx = height * 0.35; let dx = height * 0.80;
let dz = height * 0.27; let dz = height * 0.64;
return normalize(base + vec3<f32>(-dx, 0.0, -dz) * falloff); return normalize(base + vec3<f32>(-dx, 0.0, -dz) * falloff);
} }

View file

@ -1115,6 +1115,7 @@ impl WorldRenderer {
// 2D-скайбокс — фон, до мира (как в движке: скайбокс рисуется первым, // 2D-скайбокс — фон, до мира (как в движке: скайбокс рисуется первым,
// мир перекрывает его в непрозрачных пикселях). // мир перекрывает его в непрозрачных пикселях).
if self.sky_visible if self.sky_visible
&& std::env::var_os("SOURCE_RS_NO_SKY").is_none()
&& let Some(sky) = &self.sky && let Some(sky) = &self.sky
{ {
pass.set_pipeline(&sky.pipeline); pass.set_pipeline(&sky.pipeline);
@ -1134,6 +1135,7 @@ impl WorldRenderer {
// 3D-скайбокс — поверх 2D-фона, но под миром (без записи глубины, // 3D-скайбокс — поверх 2D-фона, но под миром (без записи глубины,
// поэтому стены карты всегда перекрывают его). // поэтому стены карты всегда перекрывают его).
if self.sky_visible if self.sky_visible
&& std::env::var_os("SOURCE_RS_NO_SKY").is_none()
&& let Some(sky) = &self.sky_mesh && let Some(sky) = &self.sky_mesh
{ {
pass.set_pipeline(&sky.pipeline); pass.set_pipeline(&sky.pipeline);

View file

@ -1582,3 +1582,99 @@ GMod (замерено 60,8). В Source с гравитацией 800 было 4
Кадры снимаются через `Xvfb` + `SDL_VIDEODRIVER=x11`: в окружении без дисплея Кадры снимаются через `Xvfb` + `SDL_VIDEODRIVER=x11`: в окружении без дисплея
`SDL_VIDEODRIVER=dummy` не годится, sdl3 его не поддерживает для `SDL_VIDEODRIVER=dummy` не годится, sdl3 его не поддерживает для
raw-window-handle. 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)` — при линейном рябь не
гасла до горизонта и давала череду полос.