Освещение моделей от запечённого света + стабильный каскад теней
Some checks are pending
CI / checks (push) Waiting to run

Пользователь: «диван в светлой комнате тёмный, чуть ближе к свету — светлый,
хотя пол одинаковый» и «свет обновляется резко».

Причины две:
1. Модели вообще не получали свет карты: у них `lightmap_uv` указывал на белый
   тексель атласа, то есть ambient + солнце без привязки к месту. Теперь при
   сборке мира собираются `light_samples` (центр грани + средний свет её блока
   лайтмапы), а модели берут ambient в своей точке с плавным ядром по
   расстоянию (радиус 256, ядро 1/(d²+256)), с нижним порогом 0,35 — одна
   ближайшая грань давала скачки «тёмный/светлый». `lightmap_uv.x < 0` —
   метка шейдеру брать свет из вершины; свет обновляется и при движении
   (rewrite_instance и play_model_animation).
2. Каскад теней был 2600 юнитов вокруг камеры: объект у стены выходил за его
   границу и «вспыхивал» при приближении камеры. Теперь 4200 юнитов и карта
   4096² — вся игровая зона gm_construct попадает в тень.

Все сьюты зелёные.
This commit is contained in:
Niko Marmeladkov 2026-10-23 18:42:33 +03:00
parent bb35f6f0c3
commit fc2e981bfc
4 changed files with 145 additions and 17 deletions

View file

@ -49,6 +49,7 @@ fn empty_world() -> World {
prop_meshes: Vec::new(),
physics: engine::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
spawn: Vec3::ZERO,
spawn_yaw: 0.0,
bounds_min: Vec3::splat(-1.0),

View file

@ -238,6 +238,34 @@ struct AtlasBlock {
}
impl LightmapAtlas {
/// Средний свет блока грани (линейный HDR, как в атласе).
///
/// Нужен моделям: у пропа нет своей лайтмапы, и без этого он одинаково
/// светится и в тёмном углу, и у окна.
pub fn face_light(&self, face_index: usize) -> Option<[f32; 3]> {
let block = self.blocks.get(face_index).copied().flatten()?;
let mut sum = [0.0f64; 3];
let mut count = 0.0f64;
for y in 0..block.height {
for x in 0..block.width {
let offset =
(((block.y + y) as usize) * self.width as usize + (block.x + x) as usize) * 4;
for (channel, total) in sum.iter_mut().enumerate() {
*total += f16_to_f32(self.texels[offset + channel]) as f64;
}
count += 1.0;
}
}
if count <= 0.0 {
return None;
}
Some([
(sum[0] / count) as f32,
(sum[1] / count) as f32,
(sum[2] / count) as f32,
])
}
/// Раскладывает лайтмапы всех граней в атлас. `None` — если данных нет
/// или карта не помещается даже в 4096² (тогда рендер откатывается на
/// вершинный свет, а не падает).
@ -408,6 +436,25 @@ fn pack_lightmap_blocks(
/// Переводит `f32` в IEEE half. Значения света неотрицательные и не больше
/// 16, но функция корректно обрабатывает и ноль, и большие числа.
/// Половина (f16) обратно в f32: нужно среднему свету блока лайтмапы.
fn f16_to_f32(value: u16) -> f32 {
let sign = (value >> 15) & 1;
let exponent = (value >> 10) & 0x1f;
let mantissa = value & 0x3ff;
let magnitude = if exponent == 0 {
(mantissa as f32) * 2f32.powi(-24)
} else if exponent == 31 {
if mantissa == 0 {
f32::INFINITY
} else {
f32::NAN
}
} else {
(1.0 + mantissa as f32 / 1024.0) * 2f32.powi(exponent as i32 - 15)
};
if sign == 1 { -magnitude } else { magnitude }
}
fn f32_to_f16(value: f32) -> u16 {
let bits = value.to_bits();
let sign = ((bits >> 16) & 0x8000) as u16;
@ -557,6 +604,11 @@ pub struct World {
pub physics: crate::physics::PhysicsWorld,
/// Исходные позиции пропов из карты — куда их возвращать при падении.
pub home_origins: Vec<Vec3>,
/// Запечённый свет по центрам граней: `(точка, линейный цвет)`.
///
/// Модели берут отсюда ambient в своей точке — тёмный угол затемняет проп,
/// как в Source, а не светит одинаково везде.
pub light_samples: Vec<(Vec3, [f32; 3])>,
}
/// Меш 3D-скайбокса.
@ -623,6 +675,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
prop_meshes: Vec::new(),
physics: crate::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
};
// Атлас лайтмап строится до геометрии: вершины получают UV блока, и
@ -784,6 +837,9 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
let inv = [1.0 / width as f32, 1.0 / height as f32];
let first_vertex = world.vertices.len() as u32;
// Центр грани — точка для ambient моделей (см. light_samples).
let mut face_center = Vec3::ZERO;
let mut face_points = 0.0f32;
for &index in &vertex_indices {
let Some(vertex) = geometry.vertices.get(index as usize) else {
continue;
@ -813,6 +869,8 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
world.bounds_min.y.min(pos.y),
world.bounds_min.z.min(pos.z),
);
face_center += pos;
face_points += 1.0;
world.bounds_max = Vec3::new(
world.bounds_max.x.max(pos.x),
world.bounds_max.y.max(pos.y),
@ -820,6 +878,13 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
);
}
if face_points > 0.0
&& let Some(atlas) = world.lightmap.as_ref()
&& let Some(color) = atlas.face_light(face_index)
{
world.light_samples.push((face_center / face_points, color));
}
let first_index = world.indices.len() as u32;
// Намотка треугольников приводится к «наружу» явно, а не берётся из
// порядка вершин в BSP. Иначе включение отсечения задних граней
@ -1209,11 +1274,10 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
}
let first_vertex = world.vertices.len() as u32;
let white_lightmap_uv = world
.lightmap
.as_ref()
.map(|atlas| atlas.white_uv())
.unwrap_or([0.0, 0.0]);
// Ambient модели — запечённый свет ближайшей грани карты: иначе проп
// одинаково светится в тёмном углу и у окна. `lightmap_uv.x < 0` —
// метка шейдеру: у модели нет атласа, брать свет из вершины.
let model_light = world.model_light_at(origin);
for vertex in &model.vertices {
let pos = crate::model::rotate_model_vector(
Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]),
@ -1231,8 +1295,8 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
pos: [pos.x, pos.y, pos.z],
normal: [normal.x, normal.y, normal.z],
uv: vertex.uv,
light: [1.0, 1.0, 1.0],
lightmap_uv: white_lightmap_uv,
light: model_light,
lightmap_uv: [-1.0, -1.0],
});
world.bounds_min = world.bounds_min.min(pos);
world.bounds_max = world.bounds_max.max(pos);
@ -2522,6 +2586,7 @@ impl World {
return false;
}
let (pitch, yaw, roll) = (angles.x, angles.y, angles.z);
let light = self.model_light_at(origin);
for i in 0..count {
let local_model = Vec3::new(
mesh.positions[i][0],
@ -2539,6 +2604,7 @@ impl World {
let vertex = &mut self.vertices[first + i];
vertex.pos = [pos.x, pos.y, pos.z];
vertex.normal = [normal.x, normal.y, normal.z];
vertex.light = light;
}
// Запоминаем переписанное: клиент зальёт эти вершины на GPU.
self.moved_ranges.push((first as u32, count as u32));
@ -2612,11 +2678,7 @@ impl World {
let (pitch, yaw, roll) = (angles.x, angles.y, angles.z);
let first_vertex = self.vertices.len() as u32;
let white_lightmap_uv = self
.lightmap
.as_ref()
.map(|atlas| atlas.white_uv())
.unwrap_or([0.0, 0.0]);
let model_light = self.model_light_at(origin);
for vertex in &model.vertices {
let pos = crate::model::rotate_model_vector(
Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]),
@ -2634,8 +2696,8 @@ impl World {
pos: [pos.x, pos.y, pos.z],
normal: [normal.x, normal.y, normal.z],
uv: vertex.uv,
light: [1.0, 1.0, 1.0],
lightmap_uv: white_lightmap_uv,
light: model_light,
lightmap_uv: [-1.0, -1.0],
});
}
let vertex_count = self.vertices.len() as u32 - first_vertex;
@ -2893,6 +2955,7 @@ impl World {
};
let mesh = std::mem::take(slot);
let identity = (Vec3::ZERO, [0.0, 0.0, 0.0, 1.0]);
let light = self.model_light_at(origin);
for i in 0..count {
let (Some(skin), Some(base)) = (mesh.skins.get(i), mesh.positions.get(i)) else {
continue;
@ -2924,6 +2987,7 @@ impl World {
crate::model::rotate_model_vector(local, angles.x, angles.y, angles.z) + origin;
if let Some(vertex) = self.vertices.get_mut(first + i) {
vertex.pos = [pos.x, pos.y, pos.z];
vertex.light = light;
}
}
self.prop_meshes[mesh_index] = mesh;
@ -2988,6 +3052,56 @@ impl World {
rewritten
}
/// Ambient модели в точке с нижним порогом.
///
/// Совсем чёрных моделей не бывает: в Source тоже есть минимальный
/// ambient, иначе проп в тёмном углу превращается в силуэт. Порог 0,35
/// оставляет предмет различимым, но тёмные комнаты остаются тёмными.
pub fn model_light_at(&self, point: Vec3) -> [f32; 3] {
const MIN: f32 = 0.35;
// Радиус влияния: ближайшие грани вокруг точки. Одна ближайшая грань
// давала скачки («у окна светлый, в шаге тёмный»), поэтому свет
// смешивается с плавным ядром по расстоянию.
const RADIUS: f32 = 256.0;
let mut sum = [0.0f32; 3];
let mut total = 0.0f32;
for (sample, color) in &self.light_samples {
let distance2 = (*sample - point).length_squared();
if distance2 > RADIUS * RADIUS {
continue;
}
// Ядро без особенности в нуле: вклад падает плавно.
let weight = 1.0 / (distance2 + 256.0);
for (channel, value) in sum.iter_mut().enumerate() {
*value += color[channel] * weight;
}
total += weight;
}
if total <= 0.0 {
return [1.0, 1.0, 1.0];
}
[
(sum[0] / total).max(MIN),
(sum[1] / total).max(MIN),
(sum[2] / total).max(MIN),
]
}
/// Запечённый свет в точке: цвет ближайшей грани с лайтмапой.
///
/// Грубая, но честная замена: у модели нет своих UV лайтмапы, поэтому
/// берём ambient той поверхности, рядом с которой она стоит.
pub fn light_at(&self, point: Vec3) -> Option<[f32; 3]> {
let mut best: Option<(f32, [f32; 3])> = None;
for (sample, color) in &self.light_samples {
let distance = (*sample - point).length_squared();
if best.is_none_or(|(best_distance, _)| distance < best_distance) {
best = Some((distance, *color));
}
}
best.map(|(_, color)| color)
}
/// Габариты пропов в мире — для коллизии игрока.
///
/// Коробка — габарит меша, повёрнутый углами пропа и сдвинутый его
@ -4950,6 +5064,7 @@ mod tests {
entities: crate::entity::EntityWorld::new(),
physics: crate::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
};
assert!(world.move_prop(0, Vec3::new(100.0, 0.0, 0.0), Vec3::ZERO));
// Локальная позиция вершины (1,2,3) плюс origin (100,0,0) = (101,2,3).
@ -5015,6 +5130,7 @@ mod tests {
entities: crate::entity::EntityWorld::new(),
physics: crate::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
};
// Поворот на 90° вокруг Z (yaw): нормаль (1,0,0) должна стать (0,1,0).
// Именно yaw, а не roll: базис модели forward/left/up, и roll вращает
@ -5056,6 +5172,7 @@ mod tests {
prop_meshes: Vec::new(),
physics: crate::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
};
assert!(
!world.move_prop(99, Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO),
@ -5111,6 +5228,7 @@ mod tests {
prop_meshes: Vec::new(),
physics: crate::physics::PhysicsWorld::new(),
home_origins: Vec::new(),
light_samples: Vec::new(),
};
let mut entity = crate::entity::Entity::new("prop_physics".into());
entity.origin = Vec3::new(0.0, 0.0, 64.0);

View file

@ -240,7 +240,14 @@ fn shade(in: VsOut, baked: vec3<f32>) -> vec4<f32> {
/// из-за чего на больших гранях брашей был виден перелом ровно по рёбрам.
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let baked = textureSample(lightmap_texture, lightmap_sampler, in.lightmap_uv).rgb;
// `lightmap_uv.x < 0` — модель: атласа у неё нет, свет пришёл в вершине
// (ambient ближайшей грани карты), поэтому он не интерполируется по
// треугольникам брашей.
let baked = select(
textureSample(lightmap_texture, lightmap_sampler, in.lightmap_uv).rgb,
in.light,
in.lightmap_uv.x < 0.0,
);
return shade(in, baked);
}

View file

@ -10,13 +10,15 @@ const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Сторона карты теней в текселях. 2048 — компромисс: 1024 даёт заметные
/// ступеньки на ближних тенях, 4096 стоит вдвое дороже на каждый кадр.
const SHADOW_SIZE: u32 = 2048;
const SHADOW_SIZE: u32 = 4096;
/// Размер uniform-блока теней: матрица (64) + параметры (16).
const SHADOW_UNIFORM_SIZE: usize = 80;
/// Радиус области, которую накрывает каскад, в юнитах.
const SHADOW_EXTENT: f32 = 2600.0;
// Каскад один и следует за камерой: при 2600 юнитов диван у стены выходил за
// его границу и «вспыхивал» при приближении камеры (жалоба пользователя).
const SHADOW_EXTENT: f32 = 4200.0;
/// Глубина в мировых единицах вдоль луча солнца.
const SHADOW_DEPTH: f32 = 6000.0;