Files
wds/src/render/gpu.rs
T
irrlichtandClaude Sonnet 5 247f7ef9fe Post-Effekte über Action::SetPost statt Engine-Zustand
Die PostChain lag als engine::post im Kern, nur damit die Konsole
`post` durch den exec-Trichter schalten konnte — Zustand, der allein
das Render-Frontend angeht und nichts mit der Simulation zu tun hat.

Jetzt kennt der Kern Post-Effekte nur als deferred Action (wie
play_sound): das `post <name> on|off`-Verb queued Action::SetPost,
die Session reicht sie als take_post()-Queue durch, das Fenster wendet
sie auf seine eigene PostChain an. Damit schalten Trigger und Ink-Tags
Effekte, die vorher nur der Konsolenbefehl erreichte.

- engine/post.rs entfällt; PostChain wandert nach render::post
- Session verliert das post-Feld, bekommt pending_post + take_post
- der `post`-Konsolenbefehl ist ein Shim wie `mode`; Ketten-Listing
  und nackter Toggle fallen weg (letzterer bewusst, vgl. 8bee832)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 22:47:32 +02:00

343 lines
14 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.
//! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
//!
//! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
//! ([`ScenePass`]).
//! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
//! Targets; jeder Effekt einzeln schaltbar.
//! Overlay (intern): das UI ([`SpritePass`]) — *nach* der Kette, damit
//! Bild-Effekte nur die Welt treffen, nie den Text.
//! Blit (Fenster): Nearest-Upscale mit 4:3-Letterbox (via Viewport).
//!
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
//! (RGB555 + Bayer-Dither, affines Mapping) bleibt.
use std::sync::Arc;
use winit::window::Window;
use crate::engine::tga::Image;
use crate::render::math::Mat4;
use crate::render::post::{Post, PostChain};
use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass;
use crate::render::ui::Ui;
pub const INTERNAL_W: u32 = 640;
pub const INTERNAL_H: u32 = 480;
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
/// (auch GL-Fallback) unterstützte Depth-Format.
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Near-/Far-Ebene der Projektion. Auch die Post-Kette bekommt sie (als
/// Uniform), damit kein Effekt-Shader die Werte abschreiben muss, um die
/// Tiefe zu linearisieren — sonst lügen sie, sobald die Kamera sich ändert.
const NEAR_FAR: [f32; 2] = [0.1, 100.0];
pub struct Gpu {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
depth_view: wgpu::TextureView,
scene: ScenePass,
post: Post,
sprite: SpritePass,
blit_pipeline: wgpu::RenderPipeline,
/// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
blit_binds: [wgpu::BindGroup; 2],
/// Nullpunkt der Shader-Zeit (Post-Uniform).
start: std::time::Instant,
}
impl Gpu {
pub fn new(
window: Arc<Window>,
display: winit::event_loop::OwnedDisplayHandle,
mesh: &Mesh,
images: &[Image],
ui_textures: &[Image],
) -> Self {
let size = window.inner_size();
// Display-Handle mitgeben: für den GL-Fallback (v.a. Wayland)
// Pflicht; Vulkan ignoriert es. `with_env` erlaubt Overrides wie
// WGPU_BACKEND=gl zum Testen des Fallback-Pfads.
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_with_display_handle(Box::new(display)).with_env(),
);
let surface = instance.create_surface(window).expect("wgpu: Surface");
let adapter = pollster::block_on(instance.request_adapter(
&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
},
)).expect("wgpu: kein Adapter");
let info = adapter.get_info();
println!("[gpu] {} ({:?}, {:?})", info.name, info.backend, info.device_type);
// Baseline-Limits/-Features: alles, was der Plan braucht, ist
// WebGPU-Kern — nichts anfordern, dann läuft es auch auf HD 5500.
let (device, queue) = pollster::block_on(
adapter.request_device(&wgpu::DeviceDescriptor::default()),
).expect("wgpu: Device");
// Nicht-sRGB-8-Bit-Surface bevorzugen: der Fragment-Shader
// quantisiert später selbst auf RGB555 — die Werte sollen
// unverändert auf den Schirm, ohne Gamma-Umkodierung beim Blit.
// Explizite Liste statt „erstes nicht-sRGB": Treiber bieten auch
// 16-Bit-Formate an, die extra Device-Features bräuchten.
let caps = surface.get_capabilities(&adapter);
let format = [wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Rgba8Unorm]
.into_iter()
.find(|f| caps.formats.contains(f))
.unwrap_or(caps.formats[0]);
println!("[gpu] Surface-Format {format:?}");
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let depth_view = make_depth_target(&device);
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
// render::run); hier wird nur hochgeladen.
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
let post = Post::new(
&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view, NEAR_FAR);
// Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new(
&device, &queue, INTERNAL_FORMAT,
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
);
// Blit: das fertige interne Bild nearest-gesampelt auf die Surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("blit"),
layout: None,
vertex: wgpu::VertexState {
module: &blit_shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &blit_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(config.format.into())],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("blit nearest"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let blit_layout = blit_pipeline.get_bind_group_layout(0);
let blit_bind = |view: &wgpu::TextureView| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit"),
layout: &blit_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
})
};
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
Self {
surface, device, queue, config,
depth_view,
scene, post, sprite, blit_pipeline, blit_binds,
start: std::time::Instant::now(),
}
}
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
}
/// Fenster-Pixel (physisch) → interne Pixel, über dieselbe Letterbox wie
/// der Blit. Außerhalb des Bildbereichs auf die Ränder geklemmt.
pub fn map_cursor(&self, win: [f32; 2]) -> [f32; 2] {
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
let ix = (win[0] - vx) / vw * INTERNAL_W as f32;
let iy = (win[1] - vy) / vh * INTERNAL_H as f32;
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
}
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
/// die Projektion gehört hierher, weil sie an der internen Auflösung
/// hängt.
pub fn frame(&mut self, view: &Mat4, ui: &Ui, post: &PostChain) {
let proj = Mat4::perspective(
60f32.to_radians(),
INTERNAL_W as f32 / INTERNAL_H as f32,
NEAR_FAR[0], NEAR_FAR[1],
);
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
self.scene.prepare(&self.queue, &proj.mul(view), half_res);
self.post.prepare(&self.queue, self.start.elapsed().as_secs_f32());
self.sprite.prepare(&self.queue, &ui.verts);
use wgpu::CurrentSurfaceTexture as Cst;
let frame = match self.surface.get_current_texture() {
Cst::Success(f) | Cst::Suboptimal(f) => f,
// Surface veraltet (Resize/Compositor): neu konfigurieren,
// diesen Frame auslassen.
Cst::Outdated | Cst::Lost => {
self.surface.configure(&self.device, &self.config);
return;
}
// Fenster verdeckt/minimiert oder Treiber-Timeout: auslassen.
Cst::Timeout | Cst::Occluded => return,
Cst::Validation => panic!("wgpu: Surface-Validation-Fehler"),
};
let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default());
// Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{
let [albedo, normal] = self.post.scene_targets();
let attach = |view| Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
});
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene"),
color_attachments: &[attach(albedo), attach(normal)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.scene.draw(&mut pass);
}
// Effekt-Kette; sie sagt, welches Target das Bild jetzt trägt.
let out = self.post.run(&mut enc, post);
// Overlay obendrauf — nach der Kette, also von keinem Effekt berührt.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("overlay"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: self.post.view(out),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.sprite.draw(&mut pass, &ui.batches);
}
// Letterbox-Blit aufs Fenster.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &surface_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Schwarz = die Letterbox-Balken.
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
pass.set_pipeline(&self.blit_pipeline);
pass.set_bind_group(0, &self.blit_binds[out], &[]);
pass.draw(0..3, 0..1);
}
self.queue.submit([enc.finish()]);
frame.present();
}
}
fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal depth"),
size: wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
// TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
depth.create_view(&Default::default())
}
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
/// Nicht-ganzzahlige Skalierung ist gewollt (volle Fensterausnutzung);
/// die leicht ungleichen Pixel passen zum CRT-Vorbild.
fn letterbox(win_w: u32, win_h: u32) -> (f32, f32, f32, f32) {
let (w, h) = (win_w as f32, win_h as f32);
let scale = (w / INTERNAL_W as f32).min(h / INTERNAL_H as f32);
let vw = INTERNAL_W as f32 * scale;
let vh = INTERNAL_H as f32 * scale;
((w - vw) * 0.5, (h - vh) * 0.5, vw, vh)
}