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
343 lines
14 KiB
Rust
343 lines
14 KiB
Rust
//! 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)
|
||
}
|