diff --git a/src/render/scene.rs b/src/render/scene.rs index 0d2f600..9d6e222 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -136,13 +136,18 @@ impl ScenePass { }], }); - // Nearest-Sampler: harte Texel, kein Filtering — PS1. + // 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 nearest"), + label: Some("scene"), address_mode_u: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat, mag_filter: wgpu::FilterMode::Nearest, - min_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); @@ -176,7 +181,8 @@ impl ScenePass { } /// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige -/// Bind-Group (Textur + Sampler, group 1) bauen. +/// 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, @@ -184,15 +190,16 @@ fn upload_texture( sampler: &wgpu::Sampler, img: &Image, ) -> wgpu::BindGroup { - let size = wgpu::Extent3d { - width: img.width, - height: img.height, - depth_or_array_layers: 1, - }; + // 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, - mip_level_count: 1, + 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. @@ -200,21 +207,32 @@ fn upload_texture( usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &img.rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(img.width * 4), - rows_per_image: Some(img.height), - }, - size, - ); + + // 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"), @@ -225,3 +243,29 @@ fn upload_texture( ], }) } + +/// 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, u32, u32) { + let (nw, nh) = ((w + 1) / 2, (h + 1) / 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) +} diff --git a/src/render/scene.wgsl b/src/render/scene.wgsl index e047add..e86b6d5 100644 --- a/src/render/scene.wgsl +++ b/src/render/scene.wgsl @@ -53,9 +53,15 @@ fn bayer4(px: vec2u) -> f32 { return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]); } +// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher +// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher. +// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr +// Schärfe, etwas mehr Flimmern); -0.5 ist ein sanfter Mittelweg. +const MIP_BIAS: f32 = -1.0; + @fragment fn fs_main(in: VsOut) -> @location(0) vec4f { - let texel = textureSample(tex, smp, in.uv); + let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS); if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt // RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →