Освещение: нормали граней были перевёрнуты, карта не получала света
Симптом: «на видео нету освещения вообще». Предыдущий замер показывал
среднюю лайтмапу 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:
parent
846cdc5185
commit
e91a60d40d
6 changed files with 177 additions and 69 deletions
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)` — при линейном рябь не
|
||||||
|
гасла до горизонта и давала череду полос.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue