Зеркало func_reflective_glass: отражённая камера и клип по плоскости
Some checks are pending
CI / checks (push) Waiting to run

- сборка мира: модель зеркала помечается материалом-меткой `<mirror>` до
  резолва материала (у reflectiveglass001 нет VTF, иначе грань выпадала),
  из её граней строится плоскость MirrorPlane (origin, normal);
- рендер: проход отражённой камерой в текстуру Rgba16Float, клип по плоскости
  зеркала (vec4 в uniform камеры, discard в шейдере), грань зеркала сэмплирует
  отражение по экранным координатам;
- отражение привязано на слот лайтмапы (группа 3) отдельным bind group:
  у устройства max_bind_groups = 4, пятая группа не создаётся;
- обычный проход пропускает зеркальные батчи (иначе чекер-заглушка
  перекрывала отражение), зеркало рисуется после непрозрачного мира;
- найден и исправлен пропуск слота `sun` в Camera у world.wgsl (из-за него
  fog_color читался со сдвигом).

Проверено: 25 сьютов и 29 картовых тестов зелёные; UV-градиент на грани
подтвердил плоскость и шейдер, отражение показывает стену и потолок.
This commit is contained in:
Niko Marmeladkov 2026-10-23 19:26:00 +03:00
parent fc2e981bfc
commit b564de5b25
6 changed files with 420 additions and 15 deletions

View file

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

View file

@ -64,6 +64,8 @@ struct BrushModels {
/// Диапазоны граней: `(firstface, numfaces, смещение)`. Смещение `None`
/// означает, что модель не рисуется.
ranges: Vec<(u32, u32, Option<Vec3>)>,
/// Диапазоны `func_reflective_glass`: эти грани рисуются отражением.
mirror_ranges: Vec<(u32, u32, Vec3)>,
}
impl BrushModels {
@ -78,6 +80,7 @@ impl BrushModels {
"func_clip_vphysics",
];
let mut offsets: HashMap<usize, Option<Vec3>> = HashMap::new();
let mut mirror_numbers: HashMap<usize, Vec3> = HashMap::new();
for entity in entities {
let Some(model) = entity.get_str("model") else {
continue;
@ -88,6 +91,9 @@ impl BrushModels {
else {
continue;
};
let is_mirror = entity
.get_str("classname")
.is_some_and(|class| class.eq_ignore_ascii_case("func_reflective_glass"));
let visible = entity.get_str("classname").is_some_and(|class| {
!class.starts_with("trigger_")
&& !HIDDEN.iter().any(|h| h.eq_ignore_ascii_case(class))
@ -102,15 +108,20 @@ impl BrushModels {
.and_then(parse_vec3)
.unwrap_or(Vec3::ZERO);
offsets.insert(number, visible.then_some(offset));
if is_mirror {
mirror_numbers.insert(number, offset);
}
}
let Ok(models) = bsp.lump(formats::bsp::lump::MODELS) else {
return BrushModels {
ranges: vec![(0, u32::MAX, Some(Vec3::ZERO))],
mirror_ranges: Vec::new(),
};
};
let models = models.into_owned();
let mut ranges: Vec<(u32, u32, Option<Vec3>)> = Vec::new();
let mut mirror_ranges: Vec<(u32, u32, Vec3)> = Vec::new();
for chunk in models.chunks_exact(48) {
let read = |range: core::ops::Range<usize>| -> u32 {
let mut bytes = [0u8; 4];
@ -125,6 +136,12 @@ impl BrushModels {
.copied()
.unwrap_or(Some(Vec3::ZERO));
ranges.push((first, num, offset));
let number = ranges.len() - 1;
if let Some(mirror_offset) = mirror_numbers.get(&number)
&& offset.is_some()
{
mirror_ranges.push((first, num, *mirror_offset));
}
}
if ranges.is_empty() {
// Нет данных о моделях — ведём себя как раньше, весь `LUMP_FACES`.
@ -135,11 +152,22 @@ impl BrushModels {
visible = ranges.iter().filter(|r| r.2.is_some()).count(),
"модели карты: статический мир + браш-сущности со своим смещением"
);
BrushModels { ranges }
BrushModels {
ranges,
mirror_ranges,
}
}
/// Смещение для грани: `Some(offset)` — рисуем (ноль для мира), `None` —
/// сущность невидима. Грани вне любых диапазонов считаем частью мира.
/// Грань принадлежит `func_reflective_glass` — рисуется отражением.
fn is_mirror_face(&self, face_index: usize) -> bool {
let index = face_index as u32;
self.mirror_ranges
.iter()
.any(|(first, num, _)| index >= *first && index < *first + *num)
}
fn offset_for(&self, face_index: usize) -> Option<Vec3> {
let index = face_index as u64;
for (first, num, offset) in &self.ranges {
@ -150,6 +178,9 @@ impl BrushModels {
Some(Vec3::ZERO)
}
}
/// Имя материала-метки зеркала (рендер рисует такие батчи отражением).
pub const MIRROR_MATERIAL: &str = "<mirror>";
const SURF_HINT: i32 = 0x100;
const SURF_SKIP: i32 = 0x200;
@ -210,6 +241,18 @@ pub struct WorldVertex {
pub lightmap_uv: [f32; 2],
}
/// Плоскость зеркала (`func_reflective_glass`).
///
/// Рендер строит отражённую камеру относительно неё и рисует грань зеркала
/// текстурой отражения (A27).
#[derive(Debug, Clone, Copy)]
pub struct MirrorPlane {
/// Точка на плоскости (центр зеркала).
pub origin: Vec3,
/// Нормаль плоскости (наружу, в сторону комнаты).
pub normal: Vec3,
}
/// Атлас запечённого света карты.
///
/// Раньше свет брался в вершинах: на больших гранях (пол тоннеля, бетонная
@ -604,6 +647,8 @@ pub struct World {
pub physics: crate::physics::PhysicsWorld,
/// Исходные позиции пропов из карты — куда их возвращать при падении.
pub home_origins: Vec<Vec3>,
/// Плоскости зеркал карты (`func_reflective_glass`).
pub mirrors: Vec<MirrorPlane>,
/// Запечённый свет по центрам граней: `(точка, линейный цвет)`.
///
/// Модели берут отсюда ambient в своей точке — тёмный угол затемняет проп,
@ -675,6 +720,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(),
mirrors: Vec::new(),
light_samples: Vec::new(),
};
@ -781,6 +827,43 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
// со смещением на `origin`, невидимые (клипы, триггеры, отключённые) — нет.
let brush_models = BrushModels::collect(bsp, &entities);
// Плоскости зеркал: берём самую большую грань каждого
// `func_reflective_glass` (у кисти зеркала она и есть отражающая).
for (first, num, offset) in &brush_models.mirror_ranges {
let mut best: Option<(i16, Vec3, Vec3)> = None;
let end = (*first + *num).min(geometry.faces.len() as u32);
for face_index in *first..end {
let face = &geometry.faces[face_index as usize];
if face.num_edges < 3 {
continue;
}
let Ok(indices) = geometry.face_vertex_indices(face_index as usize) else {
continue;
};
let mut center = Vec3::ZERO;
let mut count = 0.0f32;
for &index in &indices {
if let Some(vertex) = geometry.vertices.get(index as usize) {
center += Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]) + *offset;
count += 1.0;
}
}
if count == 0.0 {
continue;
}
if best.is_none_or(|(edges, _, _)| face.num_edges > edges) {
best = Some((face.num_edges, center / count, face_normal(face, &geometry)));
}
}
if let Some((_, origin, normal)) = best {
world.mirrors.push(MirrorPlane { origin, normal });
}
}
if !world.mirrors.is_empty() {
tracing::info!(mirrors = world.mirrors.len(), "зеркала карты");
}
let mut mirror_material: Option<usize> = None;
let mut resolved: Vec<Option<Resolved>> = vec![None; texdata.len()];
let mut texture_budget_left = TEXTURE_BUDGET;
let checker = checker_texture("<заглушка>");
@ -815,9 +898,31 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
&checker,
&mut world.stats,
);
let Resolved::Material(material) = resolved else {
world.stats.skipped += 1;
continue;
// Грань зеркала рисуется отражением: помечаем её отдельным материалом,
// рендер узнаёт его по имени. Делаем это ДО проверки материала: у
// `reflectiveglass001` в игре нет VTF, и материал не резолвится —
// иначе грань зеркала просто выпадала бы из мира.
let material = if brush_models.is_mirror_face(face_index) {
match mirror_material {
Some(index) => index,
None => {
let index = world.materials.len();
world.materials.push(Material {
name: MIRROR_MATERIAL.to_string(),
texture: checker.clone(),
fallback: false,
water: None,
});
mirror_material = Some(index);
index
}
}
} else {
let Resolved::Material(material) = resolved else {
world.stats.skipped += 1;
continue;
};
material
};
let vertex_indices = geometry
@ -5037,6 +5142,7 @@ mod tests {
bounds_half_extents: Vec3::ZERO,
}],
model_instances: Vec::new(),
mirrors: Vec::new(),
moved_ranges: Vec::new(),
moved_index_ranges: Vec::new(),
props: vec![PropInstance {
@ -5103,6 +5209,7 @@ mod tests {
bounds_half_extents: Vec3::ZERO,
}],
model_instances: Vec::new(),
mirrors: Vec::new(),
moved_ranges: Vec::new(),
moved_index_ranges: Vec::new(),
props: vec![PropInstance {
@ -5166,6 +5273,7 @@ mod tests {
lightmap: None,
entities: crate::entity::EntityWorld::new(),
model_instances: Vec::new(),
mirrors: Vec::new(),
moved_ranges: Vec::new(),
moved_index_ranges: Vec::new(),
props: Vec::new(),
@ -5222,6 +5330,7 @@ mod tests {
lightmap: None,
entities: crate::entity::EntityWorld::new(),
model_instances: Vec::new(),
mirrors: Vec::new(),
moved_ranges: Vec::new(),
moved_index_ranges: Vec::new(),
props: Vec::new(),

View file

@ -1455,5 +1455,11 @@ fn real_map_visual_entities() {
eprintln!("color_correction: filename={filename}");
}
}
for mirror in &world.mirrors {
eprintln!(
"зеркало: точка {:?}, нормаль {:?}",
mirror.origin, mirror.normal
);
}
assert!(!classes.is_empty(), "сущностей нет");
}

View file

@ -8,10 +8,16 @@ struct Camera {
pos: vec4<f32>,
/// Секунды с запуска — используется шейдером воды.
time: vec4<f32>,
/// Направление на солнце (заполняется в uniform, шейдер мира его не
/// использует — источник света идёт из `Lights`, но слот обязан быть).
sun: vec4<f32>,
/// Цвет тумана карты.
fog_color: vec4<f32>,
/// (`start`, `end`, `max_density`, признак «туман есть»).
fog_params: vec4<f32>,
/// Плоскость отсечения прохода отражения: xyz — нормаль, w — смещение.
/// `x == 0` — отсечения нет (основной проход).
mirror_clip: vec4<f32>,
};
const MAX_SUNS: u32 = 4u;
@ -162,6 +168,13 @@ fn attenuation(dist: f32, radius: f32) -> f32 {
/// Общий расчёт освещения: `baked` — запечённый свет (из атласа или вершин).
fn shade(in: VsOut, baked: vec3<f32>) -> vec4<f32> {
// Проход отражения: всё, что за плоскостью зеркала, не рисуем — иначе
// отражение показывает стену, на которой висит зеркало.
if (camera.mirror_clip.x != 0.0
&& dot(in.world_pos, camera.mirror_clip.xyz) < camera.mirror_clip.w)
{
discard;
}
let tex = textureSample(base_texture, base_sampler, in.uv);
let n = normalize(in.normal);
@ -257,3 +270,16 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
fn fs_main_vertex(in: VsOut) -> @location(0) vec4<f32> {
return shade(in, in.light);
}
/// Грань зеркала: берёт отражение по экранным координатам.
///
/// Текстура отражения нарисована отражённой камерой с той же проекцией, что и
/// кадр. У зеркала на слоте лайтмапы (группа 3) привязана текстура отражения,
/// поэтому сэмплируем её через те же binding'и 0/1 — отдельная группа не нужна
/// (у устройства лимит 4 группы, и все заняты).
@fragment
fn fs_mirror(in: VsOut) -> @location(0) vec4<f32> {
let dims = vec2<f32>(textureDimensions(lightmap_texture));
let uv = in.clip.xy / dims;
return vec4<f32>(textureSample(lightmap_texture, lightmap_sampler, uv).rgb, 1.0);
}

View file

@ -91,6 +91,17 @@ pub struct WorldRenderer {
fog: Option<engine::world::Fog>,
/// Отладочный слой коллизий (создаётся, только если его включат).
debug_lines: Option<crate::debug_lines::DebugLines>,
/// Плоскость зеркала карты (если есть `func_reflective_glass`).
mirror: Option<engine::world::MirrorPlane>,
/// Текстура отражения: в неё рисуется мир отражённой камерой.
mirror_texture: Option<wgpu::Texture>,
mirror_view: Option<wgpu::TextureView>,
/// Конвейер грани зеркала: сэмплирует отражение по экранным координатам.
mirror_pipeline: wgpu::RenderPipeline,
mirror_bind_group_layout: wgpu::BindGroupLayout,
mirror_bind_group: Option<wgpu::BindGroup>,
/// Батчи-зеркала (материал-метка `<mirror>`), по индексу батча.
mirror_batches: Vec<bool>,
/// HDR-конвейер: мир в Rgba16Float, затем экспозиция, ACES и bloom.
post: crate::post::PostProcess,
}
@ -393,7 +404,7 @@ impl WorldRenderer {
label: Some("camera uniform"),
// mat4 (64) + vec4 позиции + vec4 времени + vec4 солнца + vec4 тумана
// + vec4 параметров тумана
size: 144,
size: 160,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@ -739,6 +750,47 @@ impl WorldRenderer {
cache: None,
});
// Грань зеркала: сэмплирует текстуру отражения по экранным координатам.
let mirror_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mirror pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Some(vertex_layout.clone())],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_mirror"),
targets: &[Some(wgpu::ColorTargetState {
format: crate::post::HDR_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Cw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
// Полупрозрачная вода (Water-шейдер): после мира, с блендингом.
let water_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("water shader"),
@ -1202,6 +1254,50 @@ impl WorldRenderer {
let post = crate::post::PostProcess::new(device, queue, gfx.color_format(), width, height);
// Зеркало: текстура отражения по размеру экрана и bind group.
// Раскладка та же, что у лайтмапы (группа 3, texture+sampler): у
// устройства лимит 4 bind-группы, и все уже заняты.
let mirror_bind_group_layout = lightmap_layout.clone();
let mirror = world.mirrors.first().copied();
let (mirror_texture, mirror_view, mirror_bind_group) = match mirror {
Some(_) => {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("mirror reflection"),
size: wgpu::Extent3d {
width: width.max(1),
height: height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: crate::post::HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind = mirror_bind_group(device, &mirror_bind_group_layout, &view, &sampler);
(Some(texture), Some(view), Some(bind))
}
None => (None, None, None),
};
let mirror_batches: Vec<bool> = world
.batches
.iter()
.map(|batch| {
world
.materials
.get(batch.material)
.is_some_and(|material| material.name == engine::world::MIRROR_MATERIAL)
})
.collect();
tracing::info!(
mirrors = world.mirrors.len(),
mirror_batches = mirror_batches.iter().filter(|m| **m).count(),
"зеркало: ресурсы"
);
Ok(Self {
pipeline,
water_pipeline,
@ -1244,6 +1340,13 @@ impl WorldRenderer {
camera_layout: camera_layout.clone(),
color_format: gfx.color_format(),
debug_lines: None,
mirror,
mirror_texture,
mirror_view,
mirror_pipeline,
mirror_bind_group_layout,
mirror_bind_group,
mirror_batches,
post,
})
}
@ -1382,6 +1485,16 @@ impl WorldRenderer {
}
self.batches = world.batches.clone();
self.water_materials = world.materials.iter().map(|m| m.water.is_some()).collect();
self.mirror_batches = world
.batches
.iter()
.map(|batch| {
world
.materials
.get(batch.material)
.is_some_and(|material| material.name == engine::world::MIRROR_MATERIAL)
})
.collect();
}
/// Растит буферы мира под новые вершины и индексы.
@ -1494,6 +1607,32 @@ impl WorldRenderer {
self.depth_view = view;
self.post.resize(&gfx.device, &gfx.queue, width, height);
self.size = (width, height);
if self.mirror.is_some() {
let texture = gfx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("mirror reflection"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: crate::post::HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.mirror_bind_group = Some(mirror_bind_group(
&gfx.device,
&self.mirror_bind_group_layout,
&view,
&self.material_sampler,
));
self.mirror_texture = Some(texture);
self.mirror_view = Some(view);
}
}
/// Число индексов и батчей (для логов).
@ -1505,10 +1644,16 @@ impl WorldRenderer {
)
}
fn update_camera(&self, queue: &wgpu::Queue, view_proj: &Mat4, pos: math::Vec3) {
// 144 байта: матрица (64) + позиция (16) + время (16) + солнце (16)
// + туман (16) + параметры тумана (16).
let mut bytes = [0u8; 144];
fn update_camera(
&self,
queue: &wgpu::Queue,
view_proj: &Mat4,
pos: math::Vec3,
mirror_clip: Option<(math::Vec3, f32)>,
) {
// 160 байт: матрица (64) + позиция (16) + время (16) + солнце (16)
// + туман (16) + параметры тумана (16) + плоскость отражения (16).
let mut bytes = [0u8; 160];
for (i, value) in view_proj.0.iter().enumerate() {
bytes[i * 4..i * 4 + 4].copy_from_slice(&value.to_le_bytes());
}
@ -1552,6 +1697,12 @@ impl WorldRenderer {
let offset = 128 + i * 4;
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
if let Some((normal, distance)) = mirror_clip {
for (i, value) in [normal.x, normal.y, normal.z, distance].iter().enumerate() {
let offset = 144 + i * 4;
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
}
queue.write_buffer(&self.camera_buffer, 0, &bytes);
}
@ -1666,7 +1817,12 @@ impl WorldRenderer {
}
}
fn draw(&self, encoder: &mut wgpu::CommandEncoder, color_view: &wgpu::TextureView) {
fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
mirror_pass: bool,
) {
// Карта теней считается до основного пасса: вершины читают её в шейдере.
self.draw_shadow(encoder);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@ -1786,12 +1942,16 @@ impl WorldRenderer {
pass.set_bind_group(3, &self.lightmap_bind_group, &[]);
pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
for batch in &self.batches {
for (index, batch) in self.batches.iter().enumerate() {
if let Some(only) = self.only_material {
if batch.material != only {
continue;
}
}
// Грань зеркала рисуется отдельным конвейером ниже.
if self.mirror_batches.get(index).copied().unwrap_or(false) {
continue;
}
let Some(bind_group) = self.material_bind_groups.get(batch.material) else {
continue;
};
@ -1809,6 +1969,30 @@ impl WorldRenderer {
);
}
// Грань зеркала: в основном проходе рисуем отражение, в проходе
// отражения — пропускаем (иначе зеркало отражает само себя).
if !mirror_pass && self.mirror_view.is_some() {
if let Some(bind) = &self.mirror_bind_group {
pass.set_pipeline(&self.mirror_pipeline);
// На слоте лайтмапы — текстура отражения (см. fs_mirror).
pass.set_bind_group(3, bind, &[]);
for (index, batch) in self.batches.iter().enumerate() {
if !self.mirror_batches.get(index).copied().unwrap_or(false) {
continue;
}
pass.draw_indexed(
batch.first_index..batch.first_index + batch.index_count,
0,
0..1,
);
}
// Возвращаем конвейер мира и атлас лайтмапы: следующий код
// (вода) ждёт их.
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(3, &self.lightmap_bind_group, &[]);
}
}
// Вода — полупрозрачно, поверх непрозрачного мира.
pass.set_pipeline(&self.water_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
@ -1901,7 +2085,7 @@ impl WorldRenderer {
let (width, height) = gfx.size();
let aspect = width as f32 / height.max(1) as f32;
self.update_sky_visibility(camera.pos);
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos);
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos, None);
self.update_lights(&gfx.queue, camera.pos);
self.upload_text(&gfx.queue, width, height);
@ -1913,7 +2097,17 @@ impl WorldRenderer {
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("world frame"),
});
self.draw(&mut encoder, self.post.hdr_view());
if let (Some(mirror), Some(mirror_view)) = (self.mirror, self.mirror_view.as_ref()) {
let (view_proj, position) = mirror_camera(camera, mirror, aspect);
let normal = mirror.normal.normalized();
// Отсекаем всё за зеркалом: чуть впереди плоскости, чтобы не
// поймать саму грань.
let clip = Some((normal, normal.dot(mirror.origin) + 0.1));
self.update_camera(&gfx.queue, &view_proj, position, clip);
self.draw(&mut encoder, mirror_view, true);
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos, None);
}
self.draw(&mut encoder, self.post.hdr_view(), false);
self.post.resolve(&mut encoder, &view);
self.draw_text(&mut encoder, &view, wgpu::LoadOp::Load);
gfx.queue.submit(std::iter::once(encoder.finish()));
@ -1948,7 +2142,7 @@ impl WorldRenderer {
let color_view = color.create_view(&wgpu::TextureViewDescriptor::default());
let aspect = width as f32 / height.max(1) as f32;
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos);
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos, None);
self.update_lights(&gfx.queue, camera.pos);
self.upload_text(&gfx.queue, width, height);
@ -1956,7 +2150,15 @@ impl WorldRenderer {
label: Some("capture"),
});
// HDR-проход мира, затем тон-маппинг в sRGB-текстуру захвата и оверлей.
self.draw(&mut encoder, self.post.hdr_view());
if let (Some(mirror), Some(mirror_view)) = (self.mirror, self.mirror_view.as_ref()) {
let (view_proj, position) = mirror_camera(camera, mirror, aspect);
let normal = mirror.normal.normalized();
let clip = Some((normal, normal.dot(mirror.origin) + 0.1));
self.update_camera(&gfx.queue, &view_proj, position, clip);
self.draw(&mut encoder, mirror_view, true);
self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos, None);
}
self.draw(&mut encoder, self.post.hdr_view(), false);
self.post.resolve(&mut encoder, &color_view);
self.draw_text(&mut encoder, &color_view, wgpu::LoadOp::Load);
@ -2078,3 +2280,42 @@ fn create_buffer(
fn grow_capacity(capacity: usize, needed: usize) -> usize {
capacity.max(4096).saturating_mul(2).max(needed).max(4096)
}
/// Bind group отражения для грани зеркала.
fn mirror_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
view: &wgpu::TextureView,
sampler: &wgpu::Sampler,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mirror bind group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
}
/// Камера, отражённая относительно плоскости зеркала.
fn mirror_camera(
camera: &FlyCamera,
mirror: engine::world::MirrorPlane,
aspect: f32,
) -> (Mat4, Vec3) {
let normal = mirror.normal.normalized();
let reflect = |v: Vec3| v - normal * (2.0 * v.dot(normal));
let position = camera.pos - normal * (2.0 * (camera.pos - mirror.origin).dot(normal));
let forward = reflect(camera.forward());
let up = reflect(Vec3::new(0.0, 0.0, 1.0));
let view = Mat4::look_at_rh(position, position + forward, up);
let proj = Mat4::perspective(camera.fov, aspect, camera.near, camera.far);
(proj.mul(&view), position)
}

View file

@ -111,6 +111,28 @@ HL2: туда залит только `garrysmod/`, без `sourceengine/`. Пр
`env_sprite`/glow нет, `env_skypaint` не читается (небо из sky-материалов),
`env_tonemap_controller` не используется (экспозиция своя).
## 0.5. Зеркало (`func_reflective_glass`) — сделано
- в сборке мира модель зеркала помечается материалом-меткой `<mirror>`
(грани рисуются отдельным конвейером); у `reflectiveglass001` в игре нет
VTF, поэтому пометка ставится **до** резолва материала, иначе грань
выпадала из мира;
- из граней модели строится плоскость (`MirrorPlane { origin, normal }`):
у gm_construct — точка (-2064, -2067, -286), нормаль (0,1,0);
- рендер: отдельный проход отражённой камерой в текстуру Rgba16Float, клип
по плоскости зеркала (плоскость едет в uniform камеры, `discard` в шейдере);
грань зеркала сэмплирует отражение по экранным координатам. Группа 4 для
отражения не подошла: `max_bind_groups = 4`, поэтому текстура отражения
биндится на слот лайтмапы (группа 3) в отдельном bind group;
- зеркало рисуется после непрозрачного мира (обычный проход пропускает
зеркальные батчи, иначе чекер перекрывал отражение), тени от зеркала не
считаются.
Проверено кадрами: UV-градиент на грани показал, что зеркальная плоскость
занимает всю стену и шейдер работает; отражение показывает противоположную
кирпичную стену и потолок. Дальше — сглаживание границы отражения и
исключение зеркала из прохода теней.
## 1. Что просит пользователь (приоритеты)
**Ускоренный путь до Sandbox-UX** (его слова): «быстрее дойти до того, как смогу