From fc2e981bfc419886090c86fb6f206e22b09dd26a Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Fri, 23 Oct 2026 18:42:33 +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=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=B7=D0=B0=D0=BF=D0=B5=D1=87=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B2=D0=B5=D1=82=D0=B0=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D0=BA=D0=B0=D0=B4=20=D1=82=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользователь: «диван в светлой комнате тёмный, чуть ближе к свету — светлый, хотя пол одинаковый» и «свет обновляется резко». Причины две: 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 попадает в тень. Все сьюты зелёные. --- crates/client/src/menu.rs | 1 + crates/engine/src/world.rs | 146 +++++++++++++++++++++++++--- crates/render/src/world.wgsl | 9 +- crates/render/src/world_renderer.rs | 6 +- 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/crates/client/src/menu.rs b/crates/client/src/menu.rs index 666ad28..dcab340 100644 --- a/crates/client/src/menu.rs +++ b/crates/client/src/menu.rs @@ -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), diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index 2b8b988..c22c342 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -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, + /// Запечённый свет по центрам граней: `(точка, линейный цвет)`. + /// + /// Модели берут отсюда ambient в своей точке — тёмный угол затемняет проп, + /// как в Source, а не светит одинаково везде. + pub light_samples: Vec<(Vec3, [f32; 3])>, } /// Меш 3D-скайбокса. @@ -623,6 +675,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { 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 { 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.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 { ); } + 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 { } 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 { 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); diff --git a/crates/render/src/world.wgsl b/crates/render/src/world.wgsl index afdcc26..c2d6790 100644 --- a/crates/render/src/world.wgsl +++ b/crates/render/src/world.wgsl @@ -240,7 +240,14 @@ fn shade(in: VsOut, baked: vec3) -> vec4 { /// из-за чего на больших гранях брашей был виден перелом ровно по рёбрам. @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { - 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); } diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 0bb8f66..88211a0 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -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;