A13 (первая половина): туман карты читается в мир
Some checks are pending
CI / checks (push) Waiting to run

Начал пункт «туман» с чтения данных: в Source параметры тумана лежат в
env_fog_controller (её можно менять в игре), а если её нет — в worldspawn.
Разобраны fogcolor (R G B, 0..255 либо 0..1), fogstart, fogend,
fogmaxdensity и farz; приоритет у контроллера.

Туман хранится в World как Option<Fog> и печатается в лог сборки мира; у 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 картовых
тестов зелёные. Следующий шаг — отдать туман в шейдер мира.
This commit is contained in:
Niko Marmeladkov 2026-10-16 23:08:28 +03:00
parent 72bad60d8c
commit d35efef711
2 changed files with 132 additions and 0 deletions

View file

@ -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(),

View file

@ -287,6 +287,8 @@ pub struct World {
///
/// Задаёт нижнюю границу освещённости: у теней есть цвет, а не чёрнота.
pub ambient: [f32; 3],
/// Туман карты (`env_fog_controller` или `worldspawn`), если задан.
pub fog: Option<Fog>,
/// Скайбокс (грани в порядке ft, bk, rt, lf, up, dn).
pub skybox: Option<Skybox>,
/// Треугольники displacement'ов в мировых координатах (для коллизии).
@ -357,6 +359,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
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, WorldError> {
// задаёт цвет теней. Без неё тени были чисто чёрными, и мир выглядел
// вырезанным из бумаги.
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<f32> = text
.split_whitespace()
.filter_map(|value| value.parse::<f32>().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<f32> {
entity.get_str(name)?.trim().parse::<f32>().ok()
}
/// Разбирает туман карты: приоритет у `env_fog_controller`, иначе `worldspawn`.
///
/// `None` — туман не задан: тогда шейдер его не рисует вовсе.
pub fn map_fog(entities: &[formats::KvDocument]) -> Option<Fog> {
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(),