Skip to main content

ling/gfx/
mod.rs

1// src/gfx/mod.rs — unified graphics state + sub-modules.
2//
3// Sub-modules
4//   raster   — pixel-level fill_triangle / draw_line
5//   camera   — Camera3D: rotation + world→screen projection
6//   light    — Light struct + cel-shade quantiser
7//   depth    — DepthQueue: deferred draw accumulator
8//   poly     — EdgeSet (shared-edge dedup) + fan triangulation
9//   material — LingMaterial: principled BSDF + toon quantisation
10//   photon   — PhotonBuf: water-photon HDR accumulation
11//   toon     — Screen-space post-process (outlines, shadow edges, highlights)
12//   vtex     — vector texture primitives
13//   webgl    — WebGL2 backend (wasm32 only)
14
15#[cfg(target_arch = "wasm32")]
16pub mod audio_web;
17pub mod camera;
18pub mod color;
19pub mod depth;
20pub mod light;
21pub mod material;
22pub mod photon;
23pub mod poly;
24pub mod raster;
25pub mod shapes;
26pub mod toon;
27pub mod vtex;
28#[cfg(target_arch = "wasm32")]
29pub mod webgl;
30#[cfg(feature = "gpu")]
31pub mod wgpu_raster;
32
33pub use camera::Camera3D;
34pub use depth::DepthQueue;
35pub use light::Light;
36pub use material::LingMaterial;
37pub use toon::ToonConfig;
38
39/// Framebuffer pixels are 0x00RRGGBB; bit 24 tags unlit line/text ink so the
40/// toon post-process leaves it exact instead of cel-quantising it.
41pub const UNLIT: u32 = 0x0100_0000;
42pub const RGB_MASK: u32 = 0x00FF_FFFF;
43
44/// Tunable mapping for `cast_shadow`: how a blob/contact shadow's size and
45/// opacity change with the caster's height above the surface. Defaults give the
46/// natural look — small/dark/sharp when the caster touches down, growing larger,
47/// fainter and softer as it rises. Pass a negative `fade` to invert the opacity
48/// ramp (fainter when close, more opaque when far).
49#[derive(Clone, Copy)]
50pub struct ShadowParams {
51    /// Radius (px) when the caster sits on the surface (height 0).
52    pub base: f32,
53    /// Extra radius per unit of height — the shadow grows as the caster rises.
54    pub grow: f32,
55    /// Opacity at height 0 (0..1) — darkest/sharpest when touching the surface.
56    pub alpha: f32,
57    /// Opacity lost per unit of height — the shadow fades as the caster rises.
58    pub fade: f32,
59    /// Edge softness 0..1 at height 0 — feathering also widens with height.
60    pub soft: f32,
61}
62
63impl Default for ShadowParams {
64    fn default() -> Self {
65        Self { base: 14.0, grow: 0.6, alpha: 0.55, fade: 0.012, soft: 0.45 }
66    }
67}
68
69// ─── Native GfxState (minifb window + software framebuffer) ──────────────────
70
71#[cfg(not(target_arch = "wasm32"))]
72pub struct GfxState {
73    pub window: Option<minifb::Window>,
74    pub buffer: Vec<u32>,
75    /// Reusable scratch for `distort()` — avoids an 8 MB clone+alloc every frame.
76    pub distort_buf: Vec<u32>,
77    pub width: usize,
78    pub height: usize,
79    /// Current pen colour (0x00RRGGBB) set by `สีดินสอ` / `set_color`.
80    pub color: u32,
81    /// 3-D camera — set once per frame with `set_camera`.
82    pub camera: Camera3D,
83    /// Active point lights for this frame — cleared by `clear_lights`.
84    pub lights: Vec<Light>,
85    /// Ambient fill level [0..1].  Default 0.15.
86    pub ambient: f32,
87    /// Depth-sorted draw queue — flushed by `แสดงผล` / `present`.
88    pub depth_queue: DepthQueue,
89    /// Mouse position delta since last frame (pixels).
90    pub mouse_dx: f32,
91    pub mouse_dy: f32,
92    /// Previous mouse position for delta computation; NaN = no prior sample.
93    pub last_mx: f32,
94    pub last_my: f32,
95    /// When true: cursor is hidden and reset to center every frame for infinite rotation.
96    pub mouse_captured: bool,
97    /// Shading mode for 3-D shape meshes: 0 flat · 1 cel · 2 holo (default).
98    pub shade_mode: u8,
99    /// Tunable cel/holo parameters (bands, shadow tint, rim, …).
100    pub shade: ling_graphics::shading::ShadeParams,
101    /// Blend mode for pixel writes: 0 = normal (overwrite), 1 = additive.
102    pub blend: u8,
103    /// Pen opacity [0..1] for the alpha-blended fills (gradient surfaces,
104    /// shadow blobs). Set by `set_alpha`; 1.0 = fully opaque.
105    pub alpha: f32,
106    /// Anti-alias wireframe strokes (lines / edges / arcs / circle outlines).
107    /// Set by `set_antialias`; default false = crisp, opaque, aliased pixels.
108    /// When true, strokes use Xiaolin-Wu coverage blending for smooth edges.
109    pub antialias: bool,
110    /// Anti-alias `font_text`/`font_text_fill` glyphs, independent of
111    /// `antialias` (a game may want crisp pixel-hinted UI text while still
112    /// smoothing wireframe strokes). Set by `set_font_antialias`; default
113    /// false = crisp, hard-edged glyphs, matching the engine-wide default of
114    /// aliased-unless-opted-in.
115    pub font_antialias: bool,
116    /// Hue rotation (radians) applied to baked per-tri colours in
117    /// `draw_color_mesh` (.lmesh). Set by `mesh_hue`; 0 = colours as-is.
118    pub mesh_hue: f32,
119    /// Brightness gain applied with the hue rotation (2nd arg of `mesh_hue`).
120    pub mesh_hue_gain: f32,
121    /// Frame accumulation (afterimage trails): blend of the previous presented
122    /// frame into the current one at present time. 0 = off. Set by `set_frame_blur`.
123    pub frame_blur: f32,
124    /// Previous presented frame for `frame_blur` (lazily sized).
125    pub prev_frame: Vec<u32>,
126    /// Tunable height→size/opacity mapping for `cast_shadow`.
127    pub shadow: ShadowParams,
128    /// Gamma-correct compositing: blend alpha/gradients in linear light instead
129    /// of sRGB. Set by `set_color_space`; default false (legacy sRGB).
130    pub linear_blend: bool,
131    /// Interpolate gradients perceptually through OkLab. Set by
132    /// `set_gradient_space`; default true.
133    pub grad_oklab: bool,
134    /// Per-pixel depth test (true z-buffer) for the deferred queue instead of
135    /// pure painter's sort. Set by `set_depth_test`; default false.
136    pub depth_test: bool,
137    /// Z-buffer (camera-space depth per pixel); sized to width*height when
138    /// depth testing is on. Reset to +∞ on the next flush after a screen clear
139    /// (`เติม`) so it persists across a frame's multiple flushes (like
140    /// `glClear(DEPTH)`), then accumulates correct occlusion across all layers.
141    pub depth_buf: Vec<f32>,
142    /// True ⇒ the next depth flush clears the z-buffer first (set by `เติม` /
143    /// `clear_depth`). Lets the z-buffer span a frame's many `flush_3d` calls.
144    pub zbuf_needs_clear: bool,
145    /// True ⇒ `flush_post` already ran the toon post-chain this frame, so
146    /// `present` must skip it (keeps UI drawn afterwards out of the post FX).
147    pub post_done: bool,
148    /// Distance fog: triangles/lines fade toward `fog_color` from `fog_start`
149    /// to `fog_end` (camera-space depth). `fog_end <= 0` disables fog.
150    pub fog_color: u32,
151    pub fog_start: f32,
152    pub fog_end: f32,
153    /// Perf test: force flat *unlit* shading — triangle/mesh draws skip
154    /// `compute_lit_color` and use the raw pen colour. Toggle via `set_flat_shade`.
155    pub flat_shade: bool,
156    /// Pace the window to the monitor's refresh rate (`set_vsync`). Default on.
157    pub vsync: bool,
158    /// Per-frame shared-edge dedup: `draw_line_3d` skips edges already drawn.
159    pub edge_set: poly::EdgeSet,
160    /// Active material override.  When `Some`, polygon draws use the BSDF
161    /// instead of `compute_lit_color_linear`.  `None` = legacy path.
162    pub material: Option<LingMaterial>,
163    /// Optional world-space normal override for stylized surfaces.
164    pub normal_override: Option<[f32; 3]>,
165    /// Toon post-processing configuration (outlines, shadow softness, highlight).
166    pub toon: ToonConfig,
167    /// Baked local-space triangle meshes (display lists) indexed by handle.
168    /// Each entry is a flat run of `[ax,ay,az, bx,by,bz, cx,cy,cz]` local verts.
169    pub meshes: Vec<Vec<([f32; 9], u32)>>,
170    /// Active mesh capture buffer. While `Some`, `draw_triangle_3d` records raw
171    /// local coords here instead of submitting to the depth queue.
172    pub mesh_capture: Option<Vec<([f32; 9], u32)>>,
173    /// Reclaimable mesh slots (freed on keyed-cache eviction) for `mesh_register`.
174    pub mesh_free: Vec<usize>,
175    /// Keyed display-list cache (e.g. world rooms): key → mesh handle, bounded.
176    pub mesh_cache: std::collections::HashMap<i64, usize>,
177    /// Was the window OS-focused as of last frame? Used to detect focus-loss/
178    /// regain transitions (alt-tab) — see `focus_grace_frames`.
179    pub was_active: bool,
180    /// Frames remaining to suppress raw input (`key_down`/`mouse_down_*`)
181    /// after regaining focus. minifb has no WM_KILLFOCUS handler, so a key
182    /// released while another window was focused can read as still "down"
183    /// for one stale frame right after alt-tabbing back; a short grace
184    /// window after refocus swallows that instead of jerking the camera.
185    pub focus_grace_frames: u8,
186    /// True when the current window is the borderless-fullscreen one
187    /// (`fullscreen()`/전체화면, which sets HWND_TOPMOST so it covers the
188    /// taskbar). Only that window needs its topmost style dropped on
189    /// alt-tab and restored on refocus — a plain `open_window()` window was
190    /// never topmost, so leave it alone.
191    pub topmost_window: bool,
192    /// Previous-frame down-state per Win32 virtual-key code (0-255), for
193    /// edge detection when reading keyboard state via `GetAsyncKeyState`
194    /// instead of minifb's message-queue-based (`WM_KEYDOWN`) tracking. The
195    /// borderless-fullscreen/topmost window can end up visually in front
196    /// without ever actually holding real Win32 keyboard focus (Windows'
197    /// foreground-lock), in which case `WM_KEYDOWN` never arrives and typing
198    /// silently does nothing even though the window is clearly on top —
199    /// `GetAsyncKeyState` reads the global key-state table directly and
200    /// doesn't require focus, so `key_down`/`key_pressed`/`text_poll` fall
201    /// back to it while `topmost_window` is set (see `runtime/mod.rs`).
202    #[cfg(windows)]
203    pub raw_keys_prev: [bool; 256],
204    /// Time (`now_secs()`) each Win32 VK code was first observed down, for
205    /// the `GetAsyncKeyState` fallback's key-repeat in `text_poll` — holding
206    /// a key should eventually start retyping its character, same as any
207    /// normal text field, not just fire once on the initial press.
208    #[cfg(windows)]
209    pub raw_keys_down_since: [f64; 256],
210    /// Time (`now_secs()`) each Win32 VK code last emitted a character
211    /// (initial press or a repeat), so repeats can be paced at a fixed rate
212    /// once the initial hold delay has passed. See `raw_keys_down_since`.
213    #[cfg(windows)]
214    pub raw_keys_last_fire: [f64; 256],
215    /// Native window handle (HWND on Windows) of the topmost/fullscreen
216    /// window, captured when it's created. `GetAsyncKeyState` reads the
217    /// OS-wide key table regardless of which window is actually focused, so
218    /// the `topmost_window` input fallback needs this to check whether we're
219    /// really the foreground app before trusting it — otherwise alt-tabbing
220    /// away to type in another window would still feed keystrokes into the
221    /// game sitting behind it. See `window_is_foreground`.
222    #[cfg(windows)]
223    pub hwnd: isize,
224    /// Set by `quit()`/`종료()` — makes `창열림()`/`is_open()` report closed
225    /// on the next check, so a script-drawn UI element (an exit button) can
226    /// close the window the same way pressing Escape already does.
227    pub want_quit: bool,
228}
229
230#[cfg(not(target_arch = "wasm32"))]
231impl GfxState {
232    #[allow(clippy::new_without_default)]
233    pub fn new() -> Self {
234        Self {
235            window: None,
236            buffer: Vec::new(),
237            distort_buf: Vec::new(),
238            width: 0,
239            height: 0,
240            color: 0x00FF_FFFF,
241            camera: Camera3D::default(),
242            lights: Vec::new(),
243            ambient: 0.15,
244            depth_queue: DepthQueue::default(),
245            mouse_dx: 0.0,
246            mouse_dy: 0.0,
247            last_mx: f32::NAN,
248            last_my: f32::NAN,
249            mouse_captured: false,
250            shade_mode: 2,
251            shade: ling_graphics::shading::ShadeParams::default(),
252            blend: 0,
253            alpha: 1.0,
254            antialias: false,
255            font_antialias: false,
256            mesh_hue: 0.0,
257            mesh_hue_gain: 1.0,
258            frame_blur: 0.0,
259            prev_frame: Vec::new(),
260            shadow: ShadowParams::default(),
261            linear_blend: false,
262            grad_oklab: true,
263            depth_test: false,
264            depth_buf: Vec::new(),
265            zbuf_needs_clear: true,
266            post_done: false,
267            fog_color: 0x0000_0000,
268            fog_start: 0.0,
269            fog_end: 0.0,
270            flat_shade: false,
271            vsync: true,
272            edge_set: poly::EdgeSet::default(),
273            material: None,
274            normal_override: None,
275            toon: ToonConfig::default(),
276            meshes: Vec::new(),
277            mesh_capture: None,
278            mesh_free: Vec::new(),
279            mesh_cache: std::collections::HashMap::new(),
280            was_active: true,
281            focus_grace_frames: 0,
282            topmost_window: false,
283            #[cfg(windows)]
284            raw_keys_prev: [false; 256],
285            #[cfg(windows)]
286            raw_keys_down_since: [0.0; 256],
287            #[cfg(windows)]
288            raw_keys_last_fire: [0.0; 256],
289            #[cfg(windows)]
290            hwnd: 0,
291            want_quit: false,
292        }
293    }
294
295    /// True while raw input (key_down/mouse_down*) should read as released:
296    /// the window is unfocused (alt-tabbed away), or we're in the short
297    /// grace window right after regaining focus (see `focus_grace_frames`).
298    #[inline]
299    pub fn input_suppressed(&mut self) -> bool {
300        self.focus_grace_frames > 0 || !self.window.as_mut().map(|w| w.is_active()).unwrap_or(true)
301    }
302
303    /// Blend a colour toward the fog colour by camera-space `depth`.
304    #[inline]
305    pub fn fog_apply(&self, color: u32, depth: f32) -> u32 {
306        if self.fog_end <= 0.0 {
307            return color;
308        }
309        let span = self.fog_end - self.fog_start;
310        if span <= 0.0 {
311            return color;
312        }
313        let f = ((depth - self.fog_start) / span).clamp(0.0, 1.0);
314        if f <= 0.0 {
315            return color;
316        }
317        let lerp = |a: u32, b: u32| -> u32 { (a as f32 + (b as f32 - a as f32) * f) as u32 & 0xff };
318        let r = lerp((color >> 16) & 0xff, (self.fog_color >> 16) & 0xff);
319        let g = lerp((color >> 8) & 0xff, (self.fog_color >> 8) & 0xff);
320        let b = lerp(color & 0xff, self.fog_color & 0xff);
321        (r << 16) | (g << 8) | b
322    }
323
324    pub fn sync_projection(&mut self) {
325        self.camera.cx = self.width as f32 / 2.0;
326        self.camera.cy = self.height as f32 / 2.0;
327        self.camera.focal = self.height as f32;
328        self.camera.zdist = 5.0;
329    }
330
331    /// Run all enabled toon post-process passes on the pixel buffer.
332    /// Call this after `depth_queue.flush()` and before presenting to screen.
333    pub fn toon_post_process(&mut self) {
334        let w = self.width;
335        let h = self.height;
336        if self.buffer.len() < w * h {
337            return;
338        }
339        toon::apply(&self.toon, &mut self.buffer, &self.depth_buf, w, h);
340    }
341}
342
343// ─── WASM keyboard state (thread-local, accessed from JS via wasm_bindgen) ────
344
345#[cfg(target_arch = "wasm32")]
346thread_local! {
347    static WASM_KEYS_PRESSED: std::cell::RefCell<std::collections::HashSet<String>> =
348        std::cell::RefCell::new(std::collections::HashSet::new());
349    static WASM_KEYS_DOWN: std::cell::RefCell<std::collections::HashSet<String>> =
350        std::cell::RefCell::new(std::collections::HashSet::new());
351}
352
353/// Called from JavaScript when a key is pressed down
354#[cfg(target_arch = "wasm32")]
355pub fn wasm_key_down(key: &str) {
356    let key = normalize_key(key);
357    WASM_KEYS_DOWN.with(|keys_down| {
358        let mut down = keys_down.borrow_mut();
359        if !down.contains(&key) {
360            WASM_KEYS_PRESSED.with(|keys_pressed| {
361                keys_pressed.borrow_mut().insert(key.clone());
362            });
363        }
364        down.insert(key);
365    });
366}
367
368/// Called from JavaScript when a key is released
369#[cfg(target_arch = "wasm32")]
370pub fn wasm_key_up(key: &str) {
371    let key = normalize_key(key);
372    WASM_KEYS_DOWN.with(|keys_down| {
373        keys_down.borrow_mut().remove(&key);
374    });
375}
376
377/// Resume the Web Audio AudioContext after a user gesture.
378#[cfg(target_arch = "wasm32")]
379pub fn audio_resume() {
380    audio_web::resume();
381}
382
383/// Clear the per-frame pressed keys (call at start of each frame)
384#[cfg(target_arch = "wasm32")]
385pub fn wasm_clear_frame_keys() {
386    WASM_KEYS_PRESSED.with(|keys| {
387        keys.borrow_mut().clear();
388    });
389}
390
391/// Check if a key was pressed this frame
392#[cfg(target_arch = "wasm32")]
393pub fn wasm_is_key_pressed(key: &str) -> bool {
394    let key = normalize_key(key);
395    WASM_KEYS_PRESSED.with(|keys| keys.borrow().contains(&key))
396}
397
398/// Check if a key is currently held down
399#[cfg(target_arch = "wasm32")]
400pub fn wasm_is_key_down(key: &str) -> bool {
401    let key = normalize_key(key);
402    WASM_KEYS_DOWN.with(|keys| keys.borrow().contains(&key))
403}
404
405// ─── WASM mouse state (thread-local, accessed from JS via wasm_bindgen) ───────
406
407#[cfg(target_arch = "wasm32")]
408thread_local! {
409    static WASM_MOUSE_X: std::cell::Cell<f32> = std::cell::Cell::new(0.0);
410    static WASM_MOUSE_Y: std::cell::Cell<f32> = std::cell::Cell::new(0.0);
411    static WASM_MOUSE_DX: std::cell::Cell<f32> = std::cell::Cell::new(0.0);
412    static WASM_MOUSE_DY: std::cell::Cell<f32> = std::cell::Cell::new(0.0);
413    static WASM_MOUSE_LEFT: std::cell::Cell<bool> = std::cell::Cell::new(false);
414    static WASM_MOUSE_RIGHT: std::cell::Cell<bool> = std::cell::Cell::new(false);
415    static WASM_MOUSE_MIDDLE: std::cell::Cell<bool> = std::cell::Cell::new(false);
416}
417
418/// Called from JavaScript on pointer move; `x`/`y` are canvas-relative pixels.
419#[cfg(target_arch = "wasm32")]
420pub fn wasm_mouse_move(x: f32, y: f32) {
421    let dx = WASM_MOUSE_X.with(|c| x - c.replace(x));
422    let dy = WASM_MOUSE_Y.with(|c| y - c.replace(y));
423    WASM_MOUSE_DX.with(|c| c.set(c.get() + dx));
424    WASM_MOUSE_DY.with(|c| c.set(c.get() + dy));
425}
426
427/// Called from JavaScript on mousedown/mouseup. `button` follows the DOM
428/// MouseEvent.button convention: 0 = left, 1 = middle, 2 = right.
429#[cfg(target_arch = "wasm32")]
430pub fn wasm_mouse_button(button: u32, pressed: bool, x: f32, y: f32) {
431    wasm_mouse_move(x, y);
432    match button {
433        0 => WASM_MOUSE_LEFT.with(|c| c.set(pressed)),
434        1 => WASM_MOUSE_MIDDLE.with(|c| c.set(pressed)),
435        2 => WASM_MOUSE_RIGHT.with(|c| c.set(pressed)),
436        _ => {},
437    }
438}
439
440#[cfg(target_arch = "wasm32")]
441pub fn wasm_mouse_x() -> f32 {
442    WASM_MOUSE_X.with(|c| c.get())
443}
444#[cfg(target_arch = "wasm32")]
445pub fn wasm_mouse_y() -> f32 {
446    WASM_MOUSE_Y.with(|c| c.get())
447}
448#[cfg(target_arch = "wasm32")]
449pub fn wasm_mouse_dx() -> f32 {
450    WASM_MOUSE_DX.with(|c| c.get())
451}
452#[cfg(target_arch = "wasm32")]
453pub fn wasm_mouse_dy() -> f32 {
454    WASM_MOUSE_DY.with(|c| c.get())
455}
456#[cfg(target_arch = "wasm32")]
457pub fn wasm_mouse_down() -> bool {
458    WASM_MOUSE_LEFT.with(|c| c.get())
459}
460
461#[cfg(target_arch = "wasm32")]
462pub fn wasm_gamepad_button(_index: u32, _button: &str, _pressed: bool, _value: f32) {}
463#[cfg(target_arch = "wasm32")]
464pub fn wasm_gamepad_axis(_index: u32, _axis: u32, _value: f32) {}
465#[cfg(target_arch = "wasm32")]
466pub fn wasm_gamepad_connected(_index: u32, _name: &str) {}
467#[cfg(target_arch = "wasm32")]
468pub fn wasm_gamepad_disconnected(_index: u32) {}
469#[cfg(target_arch = "wasm32")]
470pub fn wasm_mouse_down_right() -> bool {
471    WASM_MOUSE_RIGHT.with(|c| c.get())
472}
473#[cfg(target_arch = "wasm32")]
474pub fn wasm_mouse_down_middle() -> bool {
475    WASM_MOUSE_MIDDLE.with(|c| c.get())
476}
477
478/// Clear the per-frame mouse delta (call at the start of each frame,
479/// alongside `wasm_clear_frame_keys`).
480#[cfg(target_arch = "wasm32")]
481pub fn wasm_clear_frame_mouse_delta() {
482    WASM_MOUSE_DX.with(|c| c.set(0.0));
483    WASM_MOUSE_DY.with(|c| c.set(0.0));
484}
485
486/// Queue a decoded mono PCM buffer for one-shot playback through Web Audio.
487#[cfg(target_arch = "wasm32")]
488pub fn wasm_play_audio_buffer(pcm_data: &[f32], sample_rate: u32) {
489    let id = audio_web::add_sample(pcm_data, 1, sample_rate);
490    if id >= 0 {
491        audio_web::play_sample(id as usize, 0.0, 0.0, 0.0, 1.0, false);
492    }
493}
494
495/// Set master output volume (0.0 to 1.0) for the Web Audio engine.
496#[cfg(target_arch = "wasm32")]
497pub fn wasm_set_master_volume(volume: f32) {
498    audio_web::set_master_volume(volume);
499}
500
501/// Normalize browser key names to match Ling's key naming convention
502#[cfg(target_arch = "wasm32")]
503fn normalize_key(key: &str) -> String {
504    match key {
505        " " => "space".to_string(),
506        "ArrowUp" => "up".to_string(),
507        "ArrowDown" => "down".to_string(),
508        "ArrowLeft" => "left".to_string(),
509        "ArrowRight" => "right".to_string(),
510        "Enter" => "enter".to_string(),
511        "Escape" => "escape".to_string(),
512        "Shift" | "ShiftLeft" | "ShiftRight" => "shift".to_string(),
513        "Control" | "ControlLeft" | "ControlRight" => "ctrl".to_string(),
514        "Alt" | "AltLeft" | "AltRight" => "alt".to_string(),
515        "Tab" => "tab".to_string(),
516        "Backspace" => "backspace".to_string(),
517        _ => key.to_lowercase(),
518    }
519}
520
521// ─── WASM GfxState (no window, no software framebuffer) ──────────────────────
522
523#[cfg(target_arch = "wasm32")]
524pub struct GfxState {
525    pub width: usize,
526    pub height: usize,
527    /// Current pen colour (0x00RRGGBB).
528    pub color: u32,
529    /// Fill / clear colour components [0..1].
530    pub fill_r: f32,
531    pub fill_g: f32,
532    pub fill_b: f32,
533    pub camera: Camera3D,
534    pub lights: Vec<Light>,
535    pub ambient: f32,
536    /// Accumulates projected screen-space draw calls; flushed to WebGL by present().
537    pub depth_queue: DepthQueue,
538    pub shade_mode: u8,
539    pub shade: ling_graphics::shading::ShadeParams,
540    /// Software framebuffer — the same CPU raster path as native. On the web,
541    /// `present()` uploads this to the canvas, so 2-D builtins render identically.
542    pub buffer: Vec<u32>,
543    /// Reusable scratch for `distort()` — avoids a per-frame clone.
544    pub distort_buf: Vec<u32>,
545    /// Blend mode for pixel writes: 0 = normal (overwrite), 1 = additive.
546    pub blend: u8,
547    /// Pen opacity [0..1] for the alpha-blended fills (mirrors native).
548    pub alpha: f32,
549    /// Hue rotation (radians) for `draw_color_mesh` baked colours (mirrors native).
550    pub mesh_hue: f32,
551    /// Brightness gain applied with the hue rotation (mirrors native).
552    pub mesh_hue_gain: f32,
553    /// Frame accumulation amount (mirrors native; unused on wasm).
554    pub frame_blur: f32,
555    /// Previous frame buffer (mirrors native; unused on wasm).
556    pub prev_frame: Vec<u32>,
557    /// Anti-alias wireframe strokes (mirrors native). Default false = aliased.
558    pub antialias: bool,
559    pub font_antialias: bool,
560    /// Tunable height→size/opacity mapping for `cast_shadow`.
561    pub shadow: ShadowParams,
562    /// Gamma-correct (linear-light) compositing — mirrors native.
563    pub linear_blend: bool,
564    /// Perceptual OkLab gradient interpolation — mirrors native.
565    pub grad_oklab: bool,
566    /// Per-pixel depth test (z-buffer) for the deferred queue — mirrors native.
567    pub depth_test: bool,
568    /// Z-buffer (camera-space depth per pixel).
569    pub depth_buf: Vec<f32>,
570    /// Mirrors native: next depth flush clears the z-buffer first.
571    pub zbuf_needs_clear: bool,
572    /// Mirrors native: `flush_post` ran the post-chain; `present` skips it.
573    pub post_done: bool,
574    /// Distance fog (mirrors native): fade toward `fog_color` from `fog_start`
575    /// to `fog_end`. `fog_end <= 0` disables fog.
576    pub fog_color: u32,
577    pub fog_start: f32,
578    pub fog_end: f32,
579    /// Perf test: force flat *unlit* shading (mirrors native).
580    pub flat_shade: bool,
581    /// Keyboard state: keys pressed this frame (cleared each frame)
582    pub keys_pressed: std::collections::HashSet<String>,
583    /// Keyboard state: keys currently held down
584    pub keys_down: std::collections::HashSet<String>,
585    /// Per-frame shared-edge dedup (mirrors native).
586    pub edge_set: poly::EdgeSet,
587    /// Active material override (mirrors native).
588    pub material: Option<LingMaterial>,
589    /// Optional world-space normal override (mirrors native).
590    pub normal_override: Option<[f32; 3]>,
591    /// Toon post-processing configuration (mirrors native).
592    pub toon: ToonConfig,
593    /// Baked local-space triangle meshes (mirrors native).
594    pub meshes: Vec<Vec<([f32; 9], u32)>>,
595    /// Active mesh capture buffer (mirrors native).
596    pub mesh_capture: Option<Vec<([f32; 9], u32)>>,
597    /// Reclaimable mesh slots (mirrors native).
598    pub mesh_free: Vec<usize>,
599    /// Keyed display-list cache (mirrors native).
600    pub mesh_cache: std::collections::HashMap<i64, usize>,
601}
602
603#[cfg(target_arch = "wasm32")]
604impl GfxState {
605    #[allow(clippy::new_without_default)]
606    pub fn new() -> Self {
607        Self {
608            width: 800,
609            height: 600,
610            color: 0x00FF_FFFF,
611            fill_r: 0.0,
612            fill_g: 0.0,
613            fill_b: 0.0,
614            camera: Camera3D::default(),
615            lights: Vec::new(),
616            ambient: 0.15,
617            depth_queue: DepthQueue::default(),
618            shade_mode: 2,
619            shade: ling_graphics::shading::ShadeParams::default(),
620            buffer: vec![0u32; 800 * 600],
621            distort_buf: Vec::new(),
622            blend: 0,
623            alpha: 1.0,
624            antialias: false,
625            font_antialias: false,
626            mesh_hue: 0.0,
627            mesh_hue_gain: 1.0,
628            frame_blur: 0.0,
629            prev_frame: Vec::new(),
630            shadow: ShadowParams::default(),
631            linear_blend: false,
632            grad_oklab: true,
633            depth_test: false,
634            depth_buf: Vec::new(),
635            zbuf_needs_clear: true,
636            post_done: false,
637            fog_color: 0x0000_0000,
638            fog_start: 0.0,
639            fog_end: 0.0,
640            flat_shade: false,
641            keys_pressed: std::collections::HashSet::new(),
642            keys_down: std::collections::HashSet::new(),
643            edge_set: poly::EdgeSet::default(),
644            material: None,
645            normal_override: None,
646            toon: ToonConfig::default(),
647            meshes: Vec::new(),
648            mesh_capture: None,
649            mesh_free: Vec::new(),
650            mesh_cache: std::collections::HashMap::new(),
651        }
652    }
653
654    /// Clear the keys_pressed set at the start of each frame
655    pub fn clear_frame_keys(&mut self) {
656        self.keys_pressed.clear();
657    }
658
659    /// Register a key press (called from JS)
660    pub fn on_key_down(&mut self, key: String) {
661        if !self.keys_down.contains(&key) {
662            self.keys_pressed.insert(key.clone());
663        }
664        self.keys_down.insert(key);
665    }
666
667    /// Register a key release (called from JS)
668    pub fn on_key_up(&mut self, key: String) {
669        self.keys_down.remove(&key);
670    }
671
672    /// Blend a colour toward the fog colour by camera-space `depth`
673    /// (identical to the native path).
674    #[inline]
675    pub fn fog_apply(&self, color: u32, depth: f32) -> u32 {
676        if self.fog_end <= 0.0 {
677            return color;
678        }
679        let span = self.fog_end - self.fog_start;
680        if span <= 0.0 {
681            return color;
682        }
683        let f = ((depth - self.fog_start) / span).clamp(0.0, 1.0);
684        if f <= 0.0 {
685            return color;
686        }
687        let lerp = |a: u32, b: u32| -> u32 { (a as f32 + (b as f32 - a as f32) * f) as u32 & 0xff };
688        let r = lerp((color >> 16) & 0xff, (self.fog_color >> 16) & 0xff);
689        let g = lerp((color >> 8) & 0xff, (self.fog_color >> 8) & 0xff);
690        let b = lerp(color & 0xff, self.fog_color & 0xff);
691        (r << 16) | (g << 8) | b
692    }
693
694    pub fn sync_projection(&mut self) {
695        self.camera.cx = self.width as f32 / 2.0;
696        self.camera.cy = self.height as f32 / 2.0;
697        self.camera.focal = self.height as f32;
698        self.camera.zdist = 5.0;
699    }
700
701    /// Run all enabled toon post-process passes (mirrors native).
702    pub fn toon_post_process(&mut self) {
703        let w = self.width;
704        let h = self.height;
705        if self.buffer.len() < w * h {
706            return;
707        }
708        toon::apply(&self.toon, &mut self.buffer, &self.depth_buf, w, h);
709    }
710}
711
712// Mesh display lists + the shared world-space triangle pipeline. Field names
713// match on both the native and wasm `GfxState`, so one impl serves both targets.
714impl GfxState {
715    /// Light, near-plane clip, project, and fan-push a world-space triangle to
716    /// the depth queue. Shared by `draw_triangle_3d` and `mesh_draw`.
717    #[inline]
718    #[allow(clippy::too_many_arguments)]
719    pub fn submit_triangle(
720        &mut self,
721        ax: f32,
722        ay: f32,
723        az: f32,
724        bx: f32,
725        by: f32,
726        bz: f32,
727        cx: f32,
728        cy: f32,
729        cz: f32,
730    ) {
731        let ux = bx - ax;
732        let uy = by - ay;
733        let uz = bz - az;
734        let vx = cx - ax;
735        let vy = cy - ay;
736        let vz = cz - az;
737        let normal = self
738            .normal_override
739            .unwrap_or([uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]);
740
741        let (c0, c1, c2) = if self.flat_shade {
742            (self.color, self.color, self.color)
743        } else if let Some(mut m) = self.material.clone() {
744            // Baked-mesh BSDF: keep each triangle's baked colour as the albedo so the
745            // model's own palette survives, but shade it with the active Principled
746            // material + scene lights (used for the companion-orb king/queen models).
747            m.albedo = self.color;
748            let cam_x = self.camera.cx;
749            let cam_y = self.camera.cy;
750            let cam_z = self.camera.zdist;
751            let amb = self.ambient;
752            let s0 = crate::gfx::material::shade(
753                &m,
754                normal,
755                [cam_x - ax, cam_y - ay, cam_z - az],
756                [ax, ay, az],
757                &self.lights,
758                amb,
759            );
760            let s1 = crate::gfx::material::shade(
761                &m,
762                normal,
763                [cam_x - bx, cam_y - by, cam_z - bz],
764                [bx, by, bz],
765                &self.lights,
766                amb,
767            );
768            let s2 = crate::gfx::material::shade(
769                &m,
770                normal,
771                [cam_x - cx, cam_y - cy, cam_z - cz],
772                [cx, cy, cz],
773                &self.lights,
774                amb,
775            );
776            (s0, s1, s2)
777        } else {
778            crate::gfx::light::compute_lit_color_vertices(
779                self.color,
780                normal,
781                [ax, ay, az],
782                [bx, by, bz],
783                [cx, cy, cz],
784                &self.lights,
785                self.ambient,
786            )
787        };
788
789        let near = -self.camera.zdist + 0.05;
790        let vw = [
791            (ax, ay, az, self.camera.depth(ax, ay, az), c0),
792            (bx, by, bz, self.camera.depth(bx, by, bz), c1),
793            (cx, cy, cz, self.camera.depth(cx, cy, cz), c2),
794        ];
795        let mut poly: [(f32, f32, f32, u32); 4] = [(0.0, 0.0, 0.0, 0); 4];
796        let mut pn = 0usize;
797        let mut ei = 0;
798        while ei < 3 {
799            let a = vw[ei];
800            let b = vw[(ei + 1) % 3];
801            let ain = a.3 > near;
802            let bin = b.3 > near;
803            if ain && pn < 4 {
804                poly[pn] = (a.0, a.1, a.2, a.4);
805                pn += 1;
806            }
807            if ain != bin && pn < 4 {
808                let tt = (near - a.3) / (b.3 - a.3);
809                poly[pn] = (
810                    a.0 + (b.0 - a.0) * tt,
811                    a.1 + (b.1 - a.1) * tt,
812                    a.2 + (b.2 - a.2) * tt,
813                    crate::gfx::light::lerp_color(a.4, b.4, tt),
814                );
815                pn += 1;
816            }
817            ei += 1;
818        }
819        if pn < 3 {
820            return;
821        }
822        let mut proj: [(f32, f32, f32, u32); 4] = [(0.0, 0.0, 0.0, 0); 4];
823        let mut pi = 0;
824        while pi < pn {
825            let (sx, sy, sz) = self.camera.project(poly[pi].0, poly[pi].1, poly[pi].2);
826            let fc = self.fog_apply(poly[pi].3, sz);
827            proj[pi] = (sx, sy, sz, fc);
828            pi += 1;
829        }
830        let mut fk = 1;
831        while fk + 1 < pn {
832            self.depth_queue.push_triangle_g_zv(
833                proj[0].0,
834                proj[0].1,
835                proj[0].2,
836                proj[0].3,
837                proj[fk].0,
838                proj[fk].1,
839                proj[fk].2,
840                proj[fk].3,
841                proj[fk + 1].0,
842                proj[fk + 1].1,
843                proj[fk + 1].2,
844                proj[fk + 1].3,
845                3,
846                self.flat_shade,
847            );
848            fk += 1;
849        }
850    }
851
852    /// Bake captured local geometry (per-triangle coords + pen colour) into a
853    /// mesh, returning its handle.
854    pub fn mesh_register(&mut self, tris: Vec<([f32; 9], u32)>) -> usize {
855        if let Some(id) = self.mesh_free.pop() {
856            self.meshes[id] = tris;
857            id
858        } else {
859            let id = self.meshes.len();
860            self.meshes.push(tris);
861            id
862        }
863    }
864
865    /// Draw a baked mesh transformed by origin `o`, right `r`, up `u`, scale `s`.
866    /// Forward axis is `r × u` so 3-D meshes baked at identity reconstruct exactly.
867    /// `use_baked_color` replays each triangle's captured colour (multi-colour
868    /// models); otherwise the current pen colour applies (e.g. tinted glyphs).
869    #[allow(clippy::too_many_arguments)]
870    pub fn mesh_draw(
871        &mut self,
872        id: usize,
873        ox: f32,
874        oy: f32,
875        oz: f32,
876        rx: f32,
877        ry: f32,
878        rz: f32,
879        ux: f32,
880        uy: f32,
881        uz: f32,
882        s: f32,
883        use_baked_color: bool,
884    ) {
885        if id >= self.meshes.len() {
886            return;
887        }
888        let fx = ry * uz - rz * uy;
889        let fy = rz * ux - rx * uz;
890        let fz = rx * uy - ry * ux;
891        let pen = self.color;
892        let mesh = std::mem::take(&mut self.meshes[id]);
893        for (t, col) in &mesh {
894            if use_baked_color {
895                self.color = *col;
896            }
897            let wx0 = ox + s * (t[0] * rx + t[1] * ux + t[2] * fx);
898            let wy0 = oy + s * (t[0] * ry + t[1] * uy + t[2] * fy);
899            let wz0 = oz + s * (t[0] * rz + t[1] * uz + t[2] * fz);
900            let wx1 = ox + s * (t[3] * rx + t[4] * ux + t[5] * fx);
901            let wy1 = oy + s * (t[3] * ry + t[4] * uy + t[5] * fy);
902            let wz1 = oz + s * (t[3] * rz + t[4] * uz + t[5] * fz);
903            let wx2 = ox + s * (t[6] * rx + t[7] * ux + t[8] * fx);
904            let wy2 = oy + s * (t[6] * ry + t[7] * uy + t[8] * fy);
905            let wz2 = oz + s * (t[6] * rz + t[7] * uz + t[8] * fz);
906            self.submit_triangle(wx0, wy0, wz0, wx1, wy1, wz1, wx2, wy2, wz2);
907        }
908        self.color = pen;
909        self.meshes[id] = mesh;
910    }
911}