This commit is contained in:
2026-08-28 23:31:48 +02:00
parent 3571106251
commit 50b7b86944
19 changed files with 274 additions and 449 deletions
+3 -4
View File
@@ -2,10 +2,9 @@
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
//! dieses Modul nie).
//!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
//! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
//! entfallen still beim Aufrufer).
//!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
-102
View File
@@ -1,102 +0,0 @@
//! Flycam: freie Debug-/Entwicklungskamera (Schritt 4). Position, Yaw und
//! Pitch in f32 — Port der irl3d-Kamera-Idee, aber ohne Q16.16: an dieser
//! Kamera hängt keine Physik, und der Renderer rechnet ohnehin f32.
//!
//! Konvention wie `math::Mat4::view`: yaw=0 blickt -Z, positiver Yaw dreht
//! nach links, positiver Pitch hebt den Blick.
use crate::render::math::Mat4;
/// Knapp unter 90°: hält den Blick aus der Senkrechten, wo Yaw bedeutungslos
/// würde (und `right`/`forward` zusammenfielen).
const PITCH_LIMIT: f32 = 1.55;
pub struct Camera {
pub pos: [f32; 3],
pub yaw: f32,
pub pitch: f32,
}
impl Camera {
pub fn new(pos: [f32; 3]) -> Self {
Self { pos, yaw: 0.0, pitch: 0.0 }
}
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch. `sens` in Radiant pro Pixel.
/// Maus nach rechts dreht den Blick nach rechts (Yaw sinkt), Maus nach
/// unten senkt ihn; Pitch wird geclamped.
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sens: f32) {
self.yaw -= dx * sens;
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Blickrichtung inkl. Pitch — W/S bewegen sich hier entlang.
fn forward(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp]
}
/// Horizontales Rechts (ohne Pitch) — A/D strafen hier entlang, ohne
/// dass der Blick nach oben/unten die Bewegung kippt.
fn right(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Bewegung integrieren. `fwd`/`right`/`up` je in [-1, 1] (Tastenachsen),
/// `dist` ist die Strecke diesen Frame (Speed · dt). Hoch/runter ist
/// Welt-Y, damit Auf-/Absteigen unabhängig vom Blickwinkel bleibt.
pub fn translate(&mut self, fwd: f32, right: f32, up: f32, dist: f32) {
let f = self.forward();
let r = self.right();
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * dist;
}
self.pos[1] += up * dist;
}
pub fn view(&self) -> Mat4 {
Mat4::view(self.pos, self.yaw, self.pitch)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Die Bewegungs-Basis (`forward`) muss exakt zu der Richtung passen,
/// die `view()` als „vor der Kamera" definiert — sonst driften
/// Steuerung und Bild auseinander. Ein Punkt `d` vor der Kamera muss
/// in View-Space auf (0, 0, -d) landen.
#[test]
fn forward_matches_view() {
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let c = Camera { pos: [1.0, 2.0, 3.0], yaw, pitch };
let f = c.forward();
let ahead = [
c.pos[0] + f[0] * 5.0,
c.pos[1] + f[1] * 5.0,
c.pos[2] + f[2] * 5.0,
1.0,
];
let v = c.view().transform(ahead);
assert!(
(v[0]).abs() < 1e-4 && (v[1]).abs() < 1e-4 && (v[2] + 5.0).abs() < 1e-4,
"yaw={yaw} pitch={pitch}: {v:?}",
);
}
}
/// `right` muss horizontal sein und im View-Space nach +X zeigen
/// (sonst wären A/D vertauscht oder gekippt).
#[test]
fn right_is_horizontal_and_points_view_right() {
let c = Camera { pos: [0.0, 0.0, 0.0], yaw: 0.6, pitch: 0.3 };
let r = c.right();
assert!(r[1].abs() < 1e-6, "right darf kein Y haben: {r:?}");
let p = [r[0] * 5.0, r[1] * 5.0, r[2] * 5.0, 1.0];
let v = c.view().transform(p);
assert!(v[0] > 4.0, "right sollte nach View-+X zeigen: {v:?}");
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ impl Gpu {
[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 (Flycam);
/// 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) {
+28 -8
View File
@@ -1,10 +1,8 @@
//! 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.)
//! statt glam: nötig sind nur Multiply, View und Perspective.
//!
//! Konventionen:
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-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])
@@ -67,8 +65,7 @@ impl Mat4 {
}
/// 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.
/// herausdrehen. 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))
@@ -104,8 +101,7 @@ mod tests {
#[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.
// yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt 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]));
}
@@ -116,6 +112,30 @@ mod tests {
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
}
/// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die
/// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild
/// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d)
/// landen, `right_dir` auf +X und ohne Y-Anteil.
#[test]
fn view_matches_player_basis() {
use crate::engine::player::Player;
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let mut p = Player::new([1.0, 2.0, 3.0]);
(p.yaw, p.pitch) = (yaw, pitch);
let v = Mat4::view(p.pos, p.yaw, p.pitch);
let f = p.look_dir();
let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0];
assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]),
"look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead));
let r = p.right_dir();
assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}");
let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]);
assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}");
}
}
#[test]
fn perspective_maps_near_far_to_0_1() {
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
+35 -77
View File
@@ -1,21 +1,14 @@
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//!
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
//! Als Nächstes: OBJ-Szene (Schritt 5).
//!
//! 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 (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
//! anderen Zustand als die Konsole.
//! Welt — 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 audio;
mod camera;
mod font;
mod gpu;
mod math;
@@ -39,19 +32,13 @@ use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
use math::Mat4;
use scene::Mesh;
/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
/// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0;
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
/// folgen. Größer = weicher, aber träger.
const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6;
@@ -61,11 +48,8 @@ pub fn run(mut session: Session) {
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
// nie fatal.
// Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
// Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
let models = load_models(&assets::path("assets/maps/props"));
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
@@ -98,11 +82,10 @@ pub fn run(mut session: Session) {
}
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
// Luminanz.
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
// ist die Installation kaputt, da darf es scheitern.
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// Installation kaputt und es darf scheitern.
let load_keyed = |p: &str| {
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
};
@@ -168,8 +151,8 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len());
// Custom Properties sichtbar machen — noch konsumiert sie
// niemand, aber die Autorin sieht so, dass sie ankommen.
// Properties mitloggen: so sieht die Autorin, ob ihr Export
// `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
}
@@ -185,9 +168,6 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
models
}
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
@@ -238,8 +218,6 @@ struct App {
gpu: Option<Gpu>,
session: Session,
rx: Receiver<String>,
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
camera: Camera,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32,
input: Input,
@@ -271,15 +249,12 @@ impl App {
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self {
window: None,
gpu: None,
session,
rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y,
input: Input::default(),
last: None,
@@ -296,7 +271,7 @@ impl App {
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
fn set_grab(&mut self, grab: bool) {
let Some(window) = &self.window else { return; };
if grab {
@@ -366,7 +341,7 @@ impl ApplicationHandler for App {
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Flycam-Greifen.
// Maus-Greifen.
self.pending_click = true;
}
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
@@ -404,7 +379,7 @@ impl App {
KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = down,
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
KeyCode::Space => {
self.input.up = down;
@@ -416,38 +391,28 @@ impl App {
}
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
// dt messen; im Dialog Kamera und Sim 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());
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
// gibt es keine parallele Lock-Logik.
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); }
if fly {
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
let fwd = axis(self.input.fwd, self.input.back);
let right = axis(self.input.right, self.input.left);
let up = axis(self.input.up, self.input.down);
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
} else if play {
// Eingaben einsammeln und die Simulation in der Session treiben.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
// Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
for out in self.session.tick(&input, dt) { println!("{out}"); }
}
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// die Session an ihrem Modus. Betretens-Zonen melden ihre
// Signal-Ausgaben wie exec-Befehle.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
up: axis(self.input.up, self.input.down),
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
for out in self.session.tick(&input, dt) { println!("{out}"); }
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
// Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0;
@@ -467,11 +432,8 @@ impl App {
}
}
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// der Luft und außerhalb des Spiels folgt sie exakt.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
@@ -481,16 +443,12 @@ impl App {
self.eye_y = target_eye;
}
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
// Sicht bleibt die First-Person-Sicht.
let view = if fly {
self.camera.view()
} else {
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
};
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// nur die Sim ein, nicht die Sicht.
let p = self.session.player.pos;
let view = Mat4::view(
[p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch);
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// löst beim Klick unten das `use` aus.
+1 -1
View File
@@ -16,7 +16,7 @@
use crate::engine::model::Model;
use crate::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`).
/// Texturname für Faces ohne Material (`""`).
pub const FALLBACK_TEXTURE: &str = "placeholder";
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
+14 -8
View File
@@ -168,7 +168,7 @@ impl ScenePass {
});
let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = images.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "scene texture", true))
.collect();
Self {
@@ -196,20 +196,26 @@ impl ScenePass {
}
}
/// 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(
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
/// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
/// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
/// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
pub(crate) fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
label: &str,
mips: bool,
) -> 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 mip_level_count = match mips {
true => 32 - img.width.max(img.height).leading_zeros(),
false => 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene texture"),
label: Some(label),
size: wgpu::Extent3d {
width: img.width,
height: img.height,
@@ -251,7 +257,7 @@ fn upload_texture(
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"),
label: Some(label),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
+4 -47
View File
@@ -4,9 +4,11 @@
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU).
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
use crate::engine::tga::Image;
use crate::render::scene::upload_texture;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -134,7 +136,7 @@ impl SpritePass {
});
let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = textures.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "sprite texture", false))
.collect();
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
@@ -160,48 +162,3 @@ impl SpritePass {
}
}
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
/// Sampler, group 1) bauen.
fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
) -> wgpu::BindGroup {
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sprite texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
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,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sprite texture"),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
],
})
}
+9 -10
View File
@@ -1,12 +1,11 @@
//! UI-Overlay: **state-driven** und **immediate-mode**.
//!
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
//! einziger Trichter in den State, kein UI-Backdoor.
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
//! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
//! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
//! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
//! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
//! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
//!
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
@@ -57,7 +56,7 @@ pub(crate) fn key_luminance(img: &Image) -> Image {
pub(crate) struct Cursor {
/// Position in internen Pixeln (über die Letterbox gemappt).
pub(crate) pos: [f32; 2],
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
/// Maus gefangen (Spiel oder Noclip) → First-Person-Pointer mittig statt
/// Maus-Cursor an `pos`.
pub(crate) grabbed: bool,
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
@@ -225,12 +224,12 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
let f = &fonts.ega;
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
// Bei gefangener Maus zählt die freie Sicht → kein Panel.
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed {
match &session.mode {
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
// Spiel und Noclip: nur HUD/Cursor, kein Panel (beide laufen
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
Mode::FirstPerson | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),