centralized state
This commit is contained in:
+29
-46
@@ -1,9 +1,8 @@
|
||||
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
||||
//! aus dem Renderer-Plan:
|
||||
//!
|
||||
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild.
|
||||
//! Aktuell nur ein Testmuster-Shader; Schritt 3
|
||||
//! ersetzt ihn durch die Szenen-Pipeline.
|
||||
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild
|
||||
//! ([`ScenePass`], PS1-Shader).
|
||||
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
||||
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
||||
|
||||
@@ -11,8 +10,11 @@ use std::sync::Arc;
|
||||
|
||||
use winit::window::Window;
|
||||
|
||||
pub const INTERNAL_W: u32 = 320;
|
||||
pub const INTERNAL_H: u32 = 240;
|
||||
use crate::render::math::Mat4;
|
||||
use crate::render::scene::ScenePass;
|
||||
|
||||
pub const INTERNAL_W: u32 = 480;
|
||||
pub const INTERNAL_H: u32 = 360;
|
||||
|
||||
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
|
||||
/// (auch GL-Fallback) unterstützte Depth-Format.
|
||||
@@ -28,9 +30,9 @@ pub struct Gpu {
|
||||
internal_view: wgpu::TextureView,
|
||||
depth_view: wgpu::TextureView,
|
||||
|
||||
pattern_pipeline: wgpu::RenderPipeline,
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind: wgpu::BindGroup,
|
||||
scene: ScenePass,
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
@@ -84,40 +86,8 @@ impl Gpu {
|
||||
|
||||
let (internal_view, depth_view) = make_internal_targets(&device);
|
||||
|
||||
// Pass 1: Testmuster auf das interne Target.
|
||||
let pattern_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("pattern"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("pattern.wgsl").into()),
|
||||
});
|
||||
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("pattern"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &pattern_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &pattern_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(INTERNAL_FORMAT.into())],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// Depth hängt am Pass (Schritt 3 braucht es); das Muster
|
||||
// selbst schreibt/testet nicht.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// Pass 1: die Szene.
|
||||
let scene = ScenePass::new(&device, INTERNAL_FORMAT, DEPTH_FORMAT);
|
||||
|
||||
// Pass 2: internes Target nearest-gesampelt auf die Surface.
|
||||
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -169,7 +139,7 @@ impl Gpu {
|
||||
Self {
|
||||
surface, device, queue, config,
|
||||
internal_view, depth_view,
|
||||
pattern_pipeline, blit_pipeline, blit_bind,
|
||||
scene, blit_pipeline, blit_bind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +149,21 @@ impl Gpu {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
|
||||
pub fn frame(&mut self) {
|
||||
/// Einen Frame rendern. `t` = Sekunden seit Start (treibt vorerst
|
||||
/// die Orbit-Kamera; ab Schritt 4 kommt die Kamera vom Aufrufer).
|
||||
pub fn frame(&mut self, t: f32) {
|
||||
// Hartkodierte Orbit-Kamera um den Testwürfel — macht Vertex-Snap
|
||||
// und Dither in Bewegung sichtbar. Die Flycam ersetzt sie.
|
||||
let yaw = t * 0.4;
|
||||
let eye = [2.2 * yaw.sin(), 1.3, 2.2 * yaw.cos()];
|
||||
let view = Mat4::view(eye, yaw, -0.5);
|
||||
let proj = Mat4::perspective(
|
||||
60f32.to_radians(),
|
||||
INTERNAL_W as f32 / INTERNAL_H as f32,
|
||||
0.1, 100.0,
|
||||
);
|
||||
self.scene.prepare(&self.queue, &proj.mul(&view));
|
||||
|
||||
use wgpu::CurrentSurfaceTexture as Cst;
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Cst::Success(f) | Cst::Suboptimal(f) => f,
|
||||
@@ -221,8 +205,7 @@ impl Gpu {
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pattern_pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.scene.draw(&mut pass);
|
||||
}
|
||||
|
||||
// Pass 2: Letterbox-Blit aufs Fenster.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
||||
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
|
||||
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
|
||||
//! ist nur der GPU-Pfad.)
|
||||
//!
|
||||
//! Konventionen:
|
||||
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
|
||||
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
||||
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
||||
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENT: Mat4 = Mat4([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
|
||||
pub fn mul(&self, rhs: &Mat4) -> Mat4 {
|
||||
let mut out = [[0.0f32; 4]; 4];
|
||||
for c in 0..4 {
|
||||
for r in 0..4 {
|
||||
out[c][r] = (0..4).map(|k| self.0[k][r] * rhs.0[c][k]).sum();
|
||||
}
|
||||
}
|
||||
Mat4(out)
|
||||
}
|
||||
|
||||
/// Punkt-Transformation auf der CPU — bisher nur von den Tests
|
||||
/// gebraucht; der Renderer transformiert auf der GPU.
|
||||
#[cfg(test)]
|
||||
pub fn transform(&self, v: [f32; 4]) -> [f32; 4] {
|
||||
let mut out = [0.0f32; 4];
|
||||
for r in 0..4 {
|
||||
out[r] = (0..4).map(|k| self.0[k][r] * v[k]).sum();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn translate(x: f32, y: f32, z: f32) -> Mat4 {
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[3] = [x, y, z, 1.0];
|
||||
m
|
||||
}
|
||||
|
||||
pub fn rot_x(a: f32) -> Mat4 {
|
||||
let (s, c) = a.sin_cos();
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[1] = [0.0, c, s, 0.0];
|
||||
m.0[2] = [0.0, -s, c, 0.0];
|
||||
m
|
||||
}
|
||||
|
||||
pub fn rot_y(a: f32) -> Mat4 {
|
||||
let (s, c) = a.sin_cos();
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[0] = [c, 0.0, -s, 0.0];
|
||||
m.0[2] = [s, 0.0, c, 0.0];
|
||||
m
|
||||
}
|
||||
|
||||
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
||||
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
|
||||
/// positiver Pitch hebt den Blick.
|
||||
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
||||
Mat4::rot_x(-pitch)
|
||||
.mul(&Mat4::rot_y(-yaw))
|
||||
.mul(&Mat4::translate(-pos[0], -pos[1], -pos[2]))
|
||||
}
|
||||
|
||||
/// Perspektive mit Clip-Z in [0,1]. `fovy` in Radiant.
|
||||
pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
|
||||
let f = 1.0 / (fovy * 0.5).tan();
|
||||
Mat4([
|
||||
[f / aspect, 0.0, 0.0, 0.0],
|
||||
[0.0, f, 0.0, 0.0],
|
||||
[0.0, 0.0, far / (near - far), -1.0],
|
||||
[0.0, 0.0, near * far / (near - far), 0.0],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_translates_origin_in_front() {
|
||||
// Kamera bei z=+5, Blick -Z → Origin liegt 5 vor der Kamera.
|
||||
let v = Mat4::view([0.0, 0.0, 5.0], 0.0, 0.0);
|
||||
assert!(close(v.transform([0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -5.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_yaw_quarter_turn_looks_minus_x() {
|
||||
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
|
||||
// -X liegt dann vor der Kamera.
|
||||
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
||||
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_pitch_up_looks_plus_y() {
|
||||
let v = Mat4::view([0.0, 0.0, 0.0], 0.0, std::f32::consts::FRAC_PI_2);
|
||||
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_maps_near_far_to_0_1() {
|
||||
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
||||
let n = p.transform([0.0, 0.0, -0.1, 1.0]);
|
||||
let f = p.transform([0.0, 0.0, -100.0, 1.0]);
|
||||
assert!((n[2] / n[3] - 0.0).abs() < 1e-5);
|
||||
assert!((f[2] / f[3] - 1.0).abs() < 1e-4);
|
||||
// w = Abstand vor der Kamera
|
||||
assert!((n[3] - 0.1).abs() < 1e-6 && (f[3] - 100.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
+78
-14
@@ -1,15 +1,26 @@
|
||||
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||||
//!
|
||||
//! Stand: Schritt 1+2 des Renderer-Plans (notes/renderer-plan.md) —
|
||||
//! Fenster, internes 320×240-Target mit Testmuster, Nearest-Upscale mit
|
||||
//! 4:3-Letterbox. Szene, PS1-Shader und Flycam folgen als nächste Schritte.
|
||||
//! Stand: Schritt 3 des Renderer-Plans (notes/renderer-plan.md) —
|
||||
//! internes 320×240-Target, PS1-Szenen-Pass (Pixel-Snap, affine
|
||||
//! Interpolation, RGB555+Bayer) am Testwürfel, Letterbox-Blit.
|
||||
//! Als Nächstes: Flycam (Schritt 4), dann OBJ-Szene (Schritt 5).
|
||||
//!
|
||||
//! Gleiche Schicht-Regel wie `cli`: Geschwister von `engine`, konsumiert
|
||||
//! dessen Schnittstellen (sobald hier eine Szene läuft) — nie umgekehrt.
|
||||
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||||
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
||||
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
||||
//! Welt (die Sim-Uhr läuft nicht weiter) — so sieht das Fenster nie einen
|
||||
//! anderen Zustand als die Konsole.
|
||||
//!
|
||||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||||
//! deren Schnittstellen — nie umgekehrt.
|
||||
|
||||
mod gpu;
|
||||
mod math;
|
||||
mod scene;
|
||||
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{ElementState, KeyEvent, WindowEvent};
|
||||
@@ -17,20 +28,56 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
use crate::session::{Mode, Session};
|
||||
use gpu::Gpu;
|
||||
|
||||
pub fn run() {
|
||||
pub fn run(mut session: Session) {
|
||||
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||
for line in session.start() { println!("{line}"); }
|
||||
|
||||
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
|
||||
// (winit) nicht. Es queren nur Strings die Thread-Grenze, kein Zustand
|
||||
// — der bleibt allein auf dem Main-Thread.
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match stdin.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
|
||||
Ok(_) => {
|
||||
if tx.send(buf.trim_end().to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let event_loop = EventLoop::new().expect("winit: Event-Loop");
|
||||
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor).
|
||||
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
|
||||
// pollen nebenbei den Befehls-Channel.
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let mut app = App::default();
|
||||
let mut app = App::new(session, rx);
|
||||
event_loop.run_app(&mut app).expect("winit: run");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct App {
|
||||
window: Option<Arc<Window>>,
|
||||
gpu: Option<Gpu>,
|
||||
window: Option<Arc<Window>>,
|
||||
gpu: Option<Gpu>,
|
||||
session: Session,
|
||||
/// Befehlszeilen vom stdin-Thread.
|
||||
rx: Receiver<String>,
|
||||
/// Sim-Zeit (Sekunden), läuft nur im `Free`-Modus weiter → Welt-Pause
|
||||
/// im Dialog. Treibt aktuell die Orbit-Kamera (bis Flycam, Schritt 4).
|
||||
sim_t: f32,
|
||||
last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(session: Session, rx: Receiver<String>) -> Self {
|
||||
Self { window: None, gpu: None, session, rx, sim_t: 0.0, last: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
@@ -48,6 +95,17 @@ impl ApplicationHandler for App {
|
||||
self.window = Some(window);
|
||||
}
|
||||
|
||||
/// Läuft einmal pro Loop-Durchlauf, nachdem die Events abgearbeitet
|
||||
/// sind: Konsolen-Befehle einspielen und den nächsten Frame anfordern.
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
while let Ok(line) = self.rx.try_recv() {
|
||||
let r = self.session.exec(&line);
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
if let Some(w) = &self.window { w.request_redraw(); }
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
@@ -63,9 +121,15 @@ impl ApplicationHandler for App {
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
if let Some(gpu) = &mut self.gpu { gpu.frame(); }
|
||||
// Sofort den nächsten Frame anfordern (Vsync drosselt).
|
||||
if let Some(w) = &self.window { w.request_redraw(); }
|
||||
// dt messen; im Dialog die Sim-Uhr anhalten → Welt pausiert,
|
||||
// während die Konsole den Dialog treibt.
|
||||
let now = Instant::now();
|
||||
let dt = self.last.replace(now)
|
||||
.map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||||
if matches!(self.session.mode, Mode::Free) {
|
||||
self.sim_t += dt;
|
||||
}
|
||||
if let Some(gpu) = &mut self.gpu { gpu.frame(self.sim_t); }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Testmuster für Schritt 2: 8px-Schachbrett mit Farbverlauf, damit
|
||||
// Auflösung (320×240) und Orientierung des internen Targets sichtbar
|
||||
// sind. Wird in Schritt 3 durch die Szenen-Pipeline ersetzt.
|
||||
|
||||
// Fullscreen-Dreieck ohne Vertex-Buffer: überdeckt (-1,-1)..(1,1).
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
var pos = array<vec2f, 3>(
|
||||
vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0),
|
||||
);
|
||||
return vec4f(pos[i], 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
// p.xy = Pixelkoordinaten im internen Target (0..320, 0..240).
|
||||
let checker = (u32(p.x / 8.0) + u32(p.y / 8.0)) % 2u;
|
||||
let base = select(0.35, 0.65, checker == 1u);
|
||||
// Verlauf: rot wächst nach rechts, grün nach unten.
|
||||
return vec4f(
|
||||
base * (0.5 + 0.5 * p.x / 320.0),
|
||||
base * (0.5 + 0.5 * p.y / 240.0),
|
||||
base * 0.5,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||
//!
|
||||
//! Stand Schritt 3: ein hartkodierter Testwürfel mit Vertex-Colors —
|
||||
//! Meshes aus OBJ und Texturen kommen in Schritt 5. Die Shader
|
||||
//! (scene.wgsl) sind dagegen schon die echten PS1-Shader.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::render::math::Mat4;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Vertex {
|
||||
pos: [f32; 3],
|
||||
color: [f32; 3],
|
||||
}
|
||||
|
||||
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 => Float32x3],
|
||||
};
|
||||
|
||||
pub struct ScenePass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
vbuf: wgpu::Buffer,
|
||||
ibuf: wgpu::Buffer,
|
||||
ubuf: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
impl ScenePass {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
color_format: wgpu::TextureFormat,
|
||||
depth_format: wgpu::TextureFormat,
|
||||
) -> 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())],
|
||||
}),
|
||||
// Cull aus: der Z-Buffer sortiert auch so korrekt, und die
|
||||
// irl3d-Materialien sind teils two-sided. Entscheidung pro
|
||||
// Material fällt mit dem Szenen-Loader (Schritt 5).
|
||||
primitive: wgpu::PrimitiveState::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 (verts, indices) = cube();
|
||||
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("cube vertices"),
|
||||
contents: bytemuck::cast_slice(&verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("cube indices"),
|
||||
contents: bytemuck::cast_slice(&indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("scene uniforms"),
|
||||
size: size_of::<Mat4>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("scene"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ubuf.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
Self { pipeline, vbuf, ibuf, ubuf, bind, index_count: indices.len() as u32 }
|
||||
}
|
||||
|
||||
/// Uniforms für diesen Frame hochladen — vor dem Render-Pass rufen.
|
||||
pub fn prepare(&self, queue: &wgpu::Queue, mvp: &Mat4) {
|
||||
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(mvp));
|
||||
}
|
||||
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.bind, &[]);
|
||||
pass.set_vertex_buffer(0, self.vbuf.slice(..));
|
||||
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
|
||||
pass.draw_indexed(0..self.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Einheitswürfel um den Ursprung, jede Seite eine Farbe. Der
|
||||
/// Helligkeitsverlauf über die Ecken erzeugt Gradienten, an denen
|
||||
/// Dither und affine Interpolation sichtbar werden.
|
||||
fn cube() -> (Vec<Vertex>, Vec<u16>) {
|
||||
const S: f32 = 0.5;
|
||||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||||
([0.9, 0.2, 0.2], [[ S, -S, -S], [ S, S, -S], [ S, S, S], [ S, -S, S]]), // +X
|
||||
([0.2, 0.9, 0.9], [[-S, -S, -S], [-S, S, -S], [-S, S, S], [-S, -S, S]]), // -X
|
||||
([0.2, 0.9, 0.2], [[-S, S, -S], [ S, S, -S], [ S, S, S], [-S, S, S]]), // +Y
|
||||
([0.9, 0.2, 0.9], [[-S, -S, -S], [ S, -S, -S], [ S, -S, S], [-S, -S, S]]), // -Y
|
||||
([0.3, 0.3, 0.9], [[-S, -S, S], [ S, -S, S], [ S, S, S], [-S, S, S]]), // +Z
|
||||
([0.9, 0.8, 0.2], [[-S, -S, -S], [ S, -S, -S], [ S, S, -S], [-S, S, -S]]), // -Z
|
||||
];
|
||||
const SHADE: [f32; 4] = [1.0, 0.65, 0.4, 0.65];
|
||||
|
||||
let mut verts = Vec::with_capacity(24);
|
||||
let mut idx: Vec<u16> = Vec::with_capacity(36);
|
||||
for (base, corners) in faces {
|
||||
let b = verts.len() as u16;
|
||||
for (i, pos) in corners.into_iter().enumerate() {
|
||||
verts.push(Vertex { pos, color: base.map(|c| c * SHADE[i]) });
|
||||
}
|
||||
idx.extend([b, b + 1, b + 2, b, b + 2, b + 3]);
|
||||
}
|
||||
(verts, idx)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch
|
||||
// gerechnet statt nachgestellt:
|
||||
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
|
||||
// - @interpolate(linear) → affine (nicht perspektivkorrigierte) Varyings
|
||||
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
|
||||
|
||||
struct Uniforms {
|
||||
mvp: mat4x4f,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
// linear = affin interpoliert; Vertex-Colors (und später UVs)
|
||||
// wobbeln dadurch wie auf der PS1.
|
||||
@location(0) @interpolate(linear) color: vec3f,
|
||||
};
|
||||
|
||||
// Halbe interne Auflösung (320×240). Bei umschaltbarer Auflösung später
|
||||
// in die Uniforms verschieben.
|
||||
const HALF_RES = vec2f(160.0, 120.0);
|
||||
|
||||
@vertex
|
||||
fn vs_main(@location(0) pos: vec3f, @location(1) color: vec3f) -> VsOut {
|
||||
var clip = u.mvp * vec4f(pos, 1.0);
|
||||
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
|
||||
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
|
||||
// die Division Unsinn liefern, das Hardware-Clipping übernimmt.
|
||||
if clip.w > 0.0 {
|
||||
let ndc = clip.xy / clip.w;
|
||||
clip = vec4f(round(ndc * HALF_RES) / HALF_RES * clip.w, clip.zw);
|
||||
}
|
||||
var out: VsOut;
|
||||
out.pos = clip;
|
||||
out.color = color;
|
||||
return out;
|
||||
}
|
||||
|
||||
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
|
||||
fn bayer4(px: vec2u) -> f32 {
|
||||
var m = array<u32, 16>(
|
||||
0u, 8u, 2u, 10u,
|
||||
12u, 4u, 14u, 6u,
|
||||
3u, 11u, 1u, 9u,
|
||||
15u, 7u, 13u, 5u,
|
||||
);
|
||||
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
|
||||
// Verläufe zerfallen ins typische Dither-Muster.
|
||||
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
|
||||
let c = clamp(in.color, vec3f(0.0), vec3f(1.0));
|
||||
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
|
||||
}
|
||||
Reference in New Issue
Block a user