Files
wds/src/render/scene.rs
T
irrlicht 56d92cdb46 Map-Loader entfernt: glTF ist das einzige 3D-Format
engine/map.rs + render/brush.rs raus, Welt kommt komplett aus .glb.
Spawn via Empty mit role=spawn statt info_player_start; testscene.glb
(Boden, Wände, Spawn) ersetzt test.map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

288 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
//!
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
use wgpu::util::DeviceExt;
use crate::engine::tga::Image;
use crate::render::math::Mat4;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Vertex {
pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2],
}
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
/// Textur gezeichnet wird.
#[derive(Clone, Copy)]
pub(crate) struct Batch {
pub(crate) texture: usize,
pub(crate) start: u32,
pub(crate) count: u32,
}
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
#[derive(Default)]
pub(crate) struct Mesh {
pub(crate) verts: Vec<Vertex>,
pub(crate) indices: Vec<u32>,
pub(crate) batches: Vec<Batch>,
}
impl Mesh {
/// Ein zweites Mesh anhängen (ein Vertex-/Index-Buffer für alles).
/// Voraussetzung: beide wurden gegen *dieselbe* Texturliste gebaut —
/// die Batch-Texturindizes bleiben dann unverändert gültig; nur die
/// Buffer-Offsets verschieben sich.
pub(crate) fn append(&mut self, other: Mesh) {
let vbase = self.verts.len() as u32;
let ibase = self.indices.len() as u32;
self.verts.extend(other.verts);
self.indices.extend(other.indices.iter().map(|i| i + vbase));
self.batches.extend(other.batches.iter().map(|b| Batch {
texture: b.texture,
start: b.start + ibase,
count: b.count,
}));
}
}
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
/// von WGSL verlangen.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniforms {
mvp: [[f32; 4]; 4],
half_res: [f32; 2],
_pad: [f32; 2],
}
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
};
pub struct ScenePass {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
ibuf: wgpu::Buffer,
ubuf: wgpu::Buffer,
uniform_bind: wgpu::BindGroup, // group 0
tex_binds: Vec<wgpu::BindGroup>, // group 1, pro Textur
batches: Vec<Batch>,
}
impl ScenePass {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
color_format: wgpu::TextureFormat,
depth_format: wgpu::TextureFormat,
mesh: &Mesh,
images: &[Image],
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("scene"),
source: wgpu::ShaderSource::Wgsl(include_str!("scene.wgsl").into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("scene"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[VERTEX_LAYOUT],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(color_format.into())],
}),
// Back-face Culling: glTF-Dreiecke sind CCW von außen
// gewickelt — die Außenflächen sind front-facing, Rückseiten
// fallen weg. (Front-Face bleibt der wgpu-Default CCW.)
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
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,
});
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("scene vertices"),
contents: bytemuck::cast_slice(&mesh.verts),
usage: wgpu::BufferUsages::VERTEX,
});
let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("scene indices"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("scene uniforms"),
size: size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene uniforms"),
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: ubuf.as_entire_binding(),
}],
});
// mag=Nearest hält nah dran die harten Texel (der knackige Look);
// min/mipmap=Linear glätten nur die Verkleinerung in der Ferne und
// blenden weich zwischen den Mip-Ebenen (kein Mip-Popping). Killt das
// Texel-Flimmern hochauflösender Texturen auf der kleinen internen
// Auflösung. Mipmaps werden in upload_texture per Box-Filter erzeugt.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("scene"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = images.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
.collect();
Self {
pipeline, vbuf, ibuf, ubuf, uniform_bind, tex_binds,
batches: mesh.batches.clone(),
}
}
/// Uniforms für diesen Frame hochladen — vor dem Render-Pass rufen.
/// `half_res` = halbe interne Auflösung (fürs Pixel-Snap im Shader).
pub fn prepare(&self, queue: &wgpu::Queue, mvp: &Mat4, half_res: [f32; 2]) {
let u = Uniforms { mvp: mvp.0, half_res, _pad: [0.0; 2] };
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&u));
}
pub fn draw(&self, pass: &mut wgpu::RenderPass) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_bind, &[]);
pass.set_vertex_buffer(0, self.vbuf.slice(..));
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint32);
for b in &self.batches {
pass.set_bind_group(1, &self.tex_binds[b.texture], &[]);
pass.draw_indexed(b.start..b.start + b.count, 0, 0..1);
}
}
}
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
) -> wgpu::BindGroup {
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
let mip_level_count = 32 - img.width.max(img.height).leading_zeros();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene texture"),
size: wgpu::Extent3d {
width: img.width,
height: img.height,
depth_or_array_layers: 1,
},
mip_level_count,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
// Nicht-sRGB: die Quantisierung im Shader erwartet rohe Werte.
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// Ebene 0 ist das Originalbild; jede weitere wird aus der vorherigen
// halbiert. `level` trägt die aktuell hochzuladenden Pixel.
let mut level = img.rgba.clone();
let (mut w, mut h) = (img.width, img.height);
for mip in 0..mip_level_count {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: mip,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&level,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(w * 4),
rows_per_image: Some(h),
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
if mip + 1 < mip_level_count {
(level, w, h) = downsample(&level, w, h);
}
}
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
],
})
}
/// Eine RGBA8-Mip-Ebene per 2×2-Box-Filter auf die nächstkleinere halbieren.
/// Gibt die neuen Pixel samt Maßen zurück. Ungerade Maße werden via `(d+1)/2`
/// aufgerundet (sonst ginge die letzte Spalte/Zeile verloren); die fehlende
/// Quell-Spalte/-Zeile wird auf den Rand geklemmt, statt den Mittelwert zu
/// verfälschen. Mittelung im (gamma-kodierten) Speicherraum — für den
/// stilisierten Look unkritisch.
fn downsample(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let mut dst = vec![0u8; (nw * nh * 4) as usize];
for y in 0..nh {
for x in 0..nw {
let (x0, y0) = (2 * x, 2 * y);
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let at = |px: u32, py: u32| ((py * w + px) * 4) as usize;
let samples = [at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)];
let di = ((y * nw + x) * 4) as usize;
for c in 0..4 {
let sum: u32 = samples.iter().map(|&s| src[s + c] as u32).sum();
dst[di + c] = ((sum + 2) / 4) as u8;
}
}
}
(dst, nw, nh)
}