Декали infodecal: квады на ближайшей грани и альфа-проход
Some checks are pending
CI / checks (push) Waiting to run

- сборка мира: для каждого infodecal берётся ближайшая плоскость грани (замер:
  у всех 19 декалей расстояние 0), центр проецируется на неё, квадрат строится
  по осям текстуры грани (32×32 юнита), материал — `decals/<имя>`;
- рендер: материалы с префиксом `decals/` идут отдельным проходом с
  ALPHA_BLENDING, без записи глубины, после непрозрачного мира; новый шейдер
  `fs_decal` берёт цвет из обычного освещения, альфу — из текстуры;
- декаль освещается ambient'ом в своей точке (`model_light_at`).
Проверено кадром: граффити с прозрачностью лежит на кирпичной стене.

25 сьютов и 29 картовых тестов зелёные.
This commit is contained in:
Niko Marmeladkov 2026-10-24 11:50:32 +03:00
parent b564de5b25
commit b347bc380f
4 changed files with 232 additions and 1 deletions

View file

@ -182,6 +182,12 @@ impl BrushModels {
pub const MIRROR_MATERIAL: &str = "<mirror>";
const SURF_HINT: i32 = 0x100;
/// Полразмера квадрата декали (юниты). В Source размер задаёт материал;
/// у нас общий разумный размер для всех трафаретов.
const DECAL_HALF_SIZE: f32 = 16.0;
/// Бюджет текстур декалей (доля от общего бюджета мира).
const DECAL_TEXTURE_BUDGET: usize = 64 * 1024 * 1024;
const SURF_SKIP: i32 = 0x200;
/// Специальные материалы, которые не рендерим.
@ -1284,6 +1290,129 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
"источники света собраны"
);
// --- декали (infodecal) ---
//
// Source проецирует декаль на поверхность по трассировке. У нас есть
// геометрия граней, поэтому берём ближайшую плоскость, проецируем на неё
// центр декали и строим квадрат по осям текстуры грани. Декали рисуются
// с альфа-блендингом (материалы `decals/...` — отдельный проход рендера).
{
let mut decal_budget = DECAL_TEXTURE_BUDGET;
let mut decal_materials: HashMap<String, usize> = HashMap::new();
let mut placed = 0usize;
for entity in &entities {
if !entity
.get_str("classname")
.is_some_and(|class| class.eq_ignore_ascii_case("infodecal"))
{
continue;
}
let Some(name) = entity.get_str("texture") else {
continue;
};
let Some(origin) = entity.get_str("origin").and_then(parse_vec3) else {
continue;
};
let name = name.replace('\\', "/");
// Ближайшая грань по плоскости.
let mut best: Option<(f32, usize, Vec3, f32)> = None;
for (face_index, face) in geometry.faces.iter().enumerate() {
if face.num_edges < 3 || face.dispinfo >= 0 {
continue;
}
let Some(plane) = geometry.planes.get(face.planenum as usize) else {
continue;
};
let normal = Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]);
let distance = (origin.dot(normal) - plane.dist).abs();
if best.is_none_or(|(best_distance, _, _, _)| distance < best_distance) {
best = Some((distance, face_index, normal, plane.dist));
}
}
let Some((distance, face_index, normal, plane_dist)) = best else {
continue;
};
// Декаль далеко от поверхности — не наша геометрия, пропускаем.
if distance > 8.0 || !normal.is_finite() {
continue;
}
let Some(face) = geometry.faces.get(face_index) else {
continue;
};
let Some(texinfo) = geometry.texinfos.get(face.texinfo.max(0) as usize) else {
continue;
};
// Оси квадрата — оси текстуры грани, спроецированные на плоскость.
let axis = |v: [f32; 4]| Vec3::new(v[0], v[1], v[2]);
let project = |v: Vec3| (v - normal * v.dot(normal)).normalized();
let mut u = project(axis(texinfo.texture_vecs[0]));
let mut v = project(axis(texinfo.texture_vecs[1]));
if u.length_squared() < 1e-6 || v.length_squared() < 1e-6 {
u = project(Vec3::new(1.0, 0.0, 0.0));
v = project(Vec3::new(0.0, 1.0, 0.0));
if u.length_squared() < 1e-6 {
u = project(Vec3::new(0.0, 1.0, 0.0));
v = project(Vec3::new(1.0, 0.0, 0.0));
}
}
// Центр — проекция точки на плоскость, чуть наружу от поверхности.
let centre = origin - normal * (origin.dot(normal) - plane_dist) + normal * 0.2;
let half = DECAL_HALF_SIZE;
let corners = [
centre - u * half - v * half,
centre + u * half - v * half,
centre + u * half + v * half,
centre - u * half + v * half,
];
let light = world.model_light_at(centre);
let first_vertex = world.vertices.len() as u32;
for (i, corner) in corners.iter().enumerate() {
let uv = match i {
0 => [0.0, 1.0],
1 => [1.0, 1.0],
2 => [1.0, 0.0],
_ => [0.0, 0.0],
};
world.vertices.push(WorldVertex {
pos: [corner.x, corner.y, corner.z],
normal: [normal.x, normal.y, normal.z],
uv,
light,
lightmap_uv: [-1.0, -1.0],
});
}
let material = *decal_materials.entry(name.clone()).or_insert_with(|| {
let texture = match resolve_basetexture(vfs, &name, 0) {
BaseTexture::Texture(base) => load_texture(vfs, &base, &mut decal_budget),
_ => None,
};
let texture = texture.unwrap_or_else(|| checker.clone());
let index = world.materials.len();
world.materials.push(Material {
name: name.clone(),
texture,
fallback: false,
water: None,
});
index
});
let first_index = world.indices.len() as u32;
world.indices.extend_from_slice(&[
first_vertex,
first_vertex + 1,
first_vertex + 2,
first_vertex,
first_vertex + 2,
first_vertex + 3,
]);
push_batch(&mut world, material, first_index);
placed += 1;
}
if placed > 0 {
tracing::info!(decals = placed, "декали карты");
}
}
// --- экземпляры пропов ---
// Пропы берутся ИЗ ПУЛА СУЩНОСТЕЙ, а не из сырого лумпа: сущность —
// единственный источник истины, иначе физика и Lua видели бы другую

View file

@ -1455,6 +1455,15 @@ fn real_map_visual_entities() {
eprintln!("color_correction: filename={filename}");
}
}
for entity in world.entities.iter() {
if entity.classname.eq_ignore_ascii_case("infodecal") {
eprintln!(
"infodecal: origin {:?}, texture {:?}",
entity.origin,
entity.key("texture")
);
}
}
for mirror in &world.mirrors {
eprintln!(
"зеркало: точка {:?}, нормаль {:?}",

View file

@ -283,3 +283,16 @@ fn fs_mirror(in: VsOut) -> @location(0) vec4<f32> {
let uv = in.clip.xy / dims;
return vec4<f32>(textureSample(lightmap_texture, lightmap_sampler, uv).rgb, 1.0);
}
/// Декаль: цвет как у мира, альфа — из текстуры (трафареты надписей).
@fragment
fn fs_decal(in: VsOut) -> @location(0) vec4<f32> {
let baked = select(
textureSample(lightmap_texture, lightmap_sampler, in.lightmap_uv).rgb,
in.light,
in.lightmap_uv.x < 0.0,
);
let color = shade(in, baked);
let alpha = textureSample(base_texture, base_sampler, in.uv).a;
return vec4<f32>(color.rgb, alpha);
}

View file

@ -102,6 +102,9 @@ pub struct WorldRenderer {
mirror_bind_group: Option<wgpu::BindGroup>,
/// Батчи-зеркала (материал-метка `<mirror>`), по индексу батча.
mirror_batches: Vec<bool>,
/// Батчи-декали (материалы `decals/...`) — рисуются с альфа-блендингом.
decal_batches: Vec<bool>,
decal_pipeline: wgpu::RenderPipeline,
/// HDR-конвейер: мир в Rgba16Float, затем экспозиция, ACES и bloom.
post: crate::post::PostProcess,
}
@ -791,6 +794,47 @@ impl WorldRenderer {
cache: None,
});
// Декали: тот же шейдер, но с альфа-блендингом и без записи глубины.
let decal_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("decal 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_decal"),
targets: &[Some(wgpu::ColorTargetState {
format: crate::post::HDR_FORMAT,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
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"),
@ -1282,6 +1326,16 @@ impl WorldRenderer {
}
None => (None, None, None),
};
let decal_batches: Vec<bool> = world
.batches
.iter()
.map(|batch| {
world
.materials
.get(batch.material)
.is_some_and(|material| material.name.starts_with("decals/"))
})
.collect();
let mirror_batches: Vec<bool> = world
.batches
.iter()
@ -1347,6 +1401,8 @@ impl WorldRenderer {
mirror_bind_group_layout,
mirror_bind_group,
mirror_batches,
decal_batches,
decal_pipeline,
post,
})
}
@ -1948,10 +2004,13 @@ impl WorldRenderer {
continue;
}
}
// Грань зеркала рисуется отдельным конвейером ниже.
// Грань зеркала и декали рисуются отдельными конвейерами ниже.
if self.mirror_batches.get(index).copied().unwrap_or(false) {
continue;
}
if self.decal_batches.get(index).copied().unwrap_or(false) {
continue;
}
let Some(bind_group) = self.material_bind_groups.get(batch.material) else {
continue;
};
@ -1969,6 +2028,27 @@ impl WorldRenderer {
);
}
// Декали: поверх непрозрачного мира, с альфа-блендингом, без записи
// глубины (иначе прозрачные края перекрывали бы друг друга).
if !mirror_pass {
pass.set_pipeline(&self.decal_pipeline);
for (index, batch) in self.batches.iter().enumerate() {
if !self.decal_batches.get(index).copied().unwrap_or(false) {
continue;
}
let Some(bind_group) = self.material_bind_groups.get(batch.material) else {
continue;
};
pass.set_bind_group(1, bind_group, &[]);
pass.draw_indexed(
batch.first_index..batch.first_index + batch.index_count,
0,
0..1,
);
}
pass.set_pipeline(&self.pipeline);
}
// Грань зеркала: в основном проходе рисуем отражение, в проходе
// отражения — пропускаем (иначе зеркало отражает само себя).
if !mirror_pass && self.mirror_view.is_some() {