From d35efef7110bed9501916252c21f4985d4ec94f3 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Fri, 16 Oct 2026 23:08:28 +0300 Subject: [PATCH] =?UTF-8?q?A13=20(=D0=BF=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D0=B2=D0=B8=D0=BD=D0=B0):=20=D1=82?= =?UTF-8?q?=D1=83=D0=BC=D0=B0=D0=BD=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D0=BC=D0=B8=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Начал пункт «туман» с чтения данных: в Source параметры тумана лежат в env_fog_controller (её можно менять в игре), а если её нет — в worldspawn. Разобраны fogcolor (R G B, 0..255 либо 0..1), fogstart, fogend, fogmaxdensity и farz; приоритет у контроллера. Туман хранится в World как Option и печатается в лог сборки мира; у Fog есть fraction(distance) — линейная доля от start к end с потолком плотности, как в движке. При end <= start тумана нет вовсе (None) — шейдер его тогда не рисует. Замер на gm_construct: туман есть — цвет (0,557 0,643 0,698), start 1024, end 250000, max_density 1,0, farz -1. То есть дальний серо-синий оттенок у карты задан, и после подключения шейдера он появится. Тест fog_parses_controller_and_worldspawn: приоритет контроллера, разбор worldspawn, поведение fraction, отсутствие тумана. Все 23 сьюта и 28 картовых тестов зелёные. Следующий шаг — отдать туман в шейдер мира. --- crates/client/src/menu.rs | 1 + crates/engine/src/world.rs | 131 +++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/crates/client/src/menu.rs b/crates/client/src/menu.rs index ad3c0d9..1e0af08 100644 --- a/crates/client/src/menu.rs +++ b/crates/client/src/menu.rs @@ -50,6 +50,7 @@ fn empty_world() -> World { bounds_min: Vec3::splat(-1.0), bounds_max: Vec3::splat(1.0), stats: Default::default(), + fog: None, ambient: [0.62, 0.66, 0.74], skybox: None, disp_tris: Vec::new(), diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index f95198a..911257f 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -287,6 +287,8 @@ pub struct World { /// /// Задаёт нижнюю границу освещённости: у теней есть цвет, а не чёрнота. pub ambient: [f32; 3], + /// Туман карты (`env_fog_controller` или `worldspawn`), если задан. + pub fog: Option, /// Скайбокс (грани в порядке ft, bk, rt, lf, up, dn). pub skybox: Option, /// Треугольники displacement'ов в мировых координатах (для коллизии). @@ -357,6 +359,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { bounds_min: Vec3::splat(f32::MAX), bounds_max: Vec3::splat(f32::MIN), stats: WorldStats::default(), + fog: None, ambient: [0.62, 0.66, 0.74], skybox: None, disp_tris: Vec::new(), @@ -813,6 +816,10 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { // задаёт цвет теней. Без неё тени были чисто чёрными, и мир выглядел // вырезанным из бумаги. world.ambient = map_ambient(&entities); + world.fog = map_fog(&entities); + if let Some(fog) = world.fog { + tracing::info!(?fog, "туман карты"); + } let suns = world .lights .iter() @@ -2580,6 +2587,94 @@ fn parse_angles(text: &str) -> (f32, f32, f32) { /// (на gm_construct `_ambient 114 136 148 420`). Нормируем по среднему, иначе /// сумма источников выбьет карту в белое; ограничиваем сверху единицей, потому /// что это добавка, а не основной свет. +/// Туман карты — `env_fog_controller` или ключи `worldspawn`. +/// +/// Source держит параметры тумана в сущности `env_fog_controller` (у неё их +/// можно менять в игре), а если её нет — в `worldspawn`. Значения: `fogcolor` +/// («R G B», обычно 0..255), `fogstart`/`fogend` в юнитах, `fogmaxdensity` +/// (0..1) и `farz` (дальняя плоскость). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Fog { + /// Цвет тумана (0..1). + pub color: [f32; 3], + /// Начало и конец тумана в юнитах. + pub start: f32, + pub end: f32, + /// Максимальная плотность (0..1). + pub max_density: f32, + /// Дальняя плоскость отсечения (`farz`), 0 — обычная. + pub far_z: f32, +} + +impl Fog { + /// Туман включён: движок рисует его только при `end > start`. + pub fn is_enabled(&self) -> bool { + self.end > self.start + } + + /// Доля тумана на расстоянии — линейно от `start` к `end`, с потолком + /// плотности. Как в движке: за `end` дальше не густеет. + pub fn fraction(&self, distance: f32) -> f32 { + if !self.is_enabled() || distance <= self.start { + return 0.0; + } + let span = (self.end - self.start).max(1e-3); + ((distance - self.start) / span).clamp(0.0, 1.0) * self.max_density + } +} + +/// Цвет «R G B» из ключей карты: 0..255, но принимаем и 0..1. +fn parse_fog_color(text: &str) -> Option<[f32; 3]> { + let parts: Vec = text + .split_whitespace() + .filter_map(|value| value.parse::().ok()) + .collect(); + if parts.len() < 3 { + return None; + } + let scale = if parts[0] > 1.5 || parts[1] > 1.5 || parts[2] > 1.5 { + 1.0 / 255.0 + } else { + 1.0 + }; + Some([parts[0] * scale, parts[1] * scale, parts[2] * scale]) +} + +fn fog_key(entity: &formats::KvDocument, name: &str) -> Option { + entity.get_str(name)?.trim().parse::().ok() +} + +/// Разбирает туман карты: приоритет у `env_fog_controller`, иначе `worldspawn`. +/// +/// `None` — туман не задан: тогда шейдер его не рисует вовсе. +pub fn map_fog(entities: &[formats::KvDocument]) -> Option { + let controller = entities + .iter() + .find(|entity| entity.get_str("classname") == Some("env_fog_controller")); + let source = controller.or_else(|| { + entities + .iter() + .find(|entity| entity.get_str("classname") == Some("worldspawn")) + })?; + let start = fog_key(source, "fogstart").unwrap_or(0.0); + let end = fog_key(source, "fogend").unwrap_or(0.0); + if end <= start { + return None; + } + Some(Fog { + color: source + .get_str("fogcolor") + .and_then(parse_fog_color) + .unwrap_or([0.5, 0.6, 0.7]), + start, + end, + max_density: fog_key(source, "fogmaxdensity") + .unwrap_or(1.0) + .clamp(0.0, 1.0), + far_z: fog_key(source, "farz").unwrap_or(0.0), + }) +} + fn map_ambient(entities: &[formats::KvDocument]) -> [f32; 3] { const FALLBACK: [f32; 3] = [0.62, 0.66, 0.74]; for kv in entities { @@ -3510,6 +3605,38 @@ mod tests { /// Отбрасывать её нельзя: сравнение с GMod показало, что с плоским /// коэффициентом тоннель выходил 165,8 при эталонных 92,9, а с экспонентой /// 86,8. + #[test] + fn fog_parses_controller_and_worldspawn() { + // Приоритет у `env_fog_controller`, иначе — ключи `worldspawn`. + let text = "{\"classname\" \"worldspawn\"\n\"fogcolor\" \"100 120 140\"\n\ + \"fogstart\" \"500\"\n\"fogend\" \"4000\"}\n\ + {\"classname\" \"env_fog_controller\"\n\"fogcolor\" \"200 210 220\"\n\ + \"fogstart\" \"1000\"\n\"fogend\" \"2000\"\n\ + \"fogmaxdensity\" \"0.5\"}\n"; + let kvs = formats::KvDocument::parse_blocks(text).expect("KV"); + let fog = map_fog(&kvs).expect("туман есть"); + assert!((fog.color[0] - 200.0 / 255.0).abs() < 1e-3); + assert!((fog.start - 1000.0).abs() < 0.01 && (fog.end - 2000.0).abs() < 0.01); + assert!((fog.max_density - 0.5).abs() < 0.01); + assert!(fog.is_enabled()); + // До начала — ноль, в середине — половина от плотности, дальше — потолок. + assert_eq!(fog.fraction(500.0), 0.0); + assert!((fog.fraction(1500.0) - 0.25).abs() < 0.01); + assert!((fog.fraction(9999.0) - 0.5).abs() < 0.01); + + // Контроллера нет — берём worldspawn. + let only_world = "{\"classname\" \"worldspawn\"\n\"fogcolor\" \"10 20 30\"\n\ + \"fogstart\" \"100\"\n\"fogend\" \"900\"}\n"; + let kvs = formats::KvDocument::parse_blocks(only_world).expect("KV"); + let fog = map_fog(&kvs).expect("туман из worldspawn"); + assert!(fog.start == 100.0 && fog.end == 900.0); + assert!((fog.max_density - 1.0).abs() < 1e-6); + + // Туман не задан — `None`: шейдер его не рисует вовсе. + let none = formats::KvDocument::parse_blocks("{\"classname\" \"worldspawn\"}").expect("KV"); + assert!(map_fog(&none).is_none()); + } + #[test] fn lightmap_exponent_byte_makes_shadow_darker() { use formats::bsp::geometry::{Face, TexInfo}; @@ -3672,6 +3799,7 @@ mod tests { }], bounds_min: Vec3::splat(-100.0), bounds_max: Vec3::splat(100.0), + fog: None, ambient: [0.62, 0.66, 0.74], prop_meshes: vec![PropMesh { positions: vec![[1.0, 2.0, 3.0]], @@ -3727,6 +3855,7 @@ mod tests { }], bounds_min: Vec3::splat(-100.0), bounds_max: Vec3::splat(100.0), + fog: None, ambient: [0.62, 0.66, 0.74], prop_meshes: vec![PropMesh { positions: vec![[1.0, 0.0, 0.0]], @@ -3784,6 +3913,7 @@ mod tests { bounds_min: Vec3::ZERO, bounds_max: Vec3::ZERO, stats: WorldStats::default(), + fog: None, ambient: [0.62, 0.66, 0.74], skybox: None, disp_tris: Vec::new(), @@ -3834,6 +3964,7 @@ mod tests { bounds_min: Vec3::ZERO, bounds_max: Vec3::ZERO, stats: WorldStats::default(), + fog: None, ambient: [0.62, 0.66, 0.74], skybox: None, disp_tris: Vec::new(),