Освещение моделей от запечённого света + стабильный каскад теней
Some checks are pending
CI / checks (push) Waiting to run
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:
parent
bb35f6f0c3
commit
fc2e981bfc
4 changed files with 145 additions and 17 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue