diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7034b21 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,173 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "liquid-state-engine" +version = "0.1.0" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] diff --git a/README.md b/README.md index 56af08a..15d2015 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,28 @@ A holographic-like workspace that bypasses the browser's normal rendering pipeli ## Architecture ``` -┌─────────────────────────────────────────────────┐ -│ JavaScript Host │ -│ Canvas 2D │ Input Events │ requestAnimationFrame│ -└──────────────────────┬──────────────────────────┘ - │ SharedArrayBuffer / Wasm Memory -┌──────────────────────▼──────────────────────────┐ -│ Rust / WebAssembly Core │ -│ │ -│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │ -│ │ ECS │ │ Quadtree │ │ Pixel Buffer│ │ -│ │ (SoA) │ │ Spatial │ │ Renderer │ │ -│ └────┬────┘ └────┬─────┘ └──────┬───────┘ │ -│ │ │ │ │ -│ ┌────▼─────────────▼───────────────▼───────┐ │ -│ │ Physics & Relations System │ │ -│ │ Viscosity │ Collision │ Merge/Fracture │ │ -│ └──────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ JavaScript Host (Main Thread) │ +│ Canvas 2D │ Input Events │ HUD (Preact) │ Payload Registry │ +└────────┬──────────────────────────────┬─────────────────────┘ + │ SharedArrayBuffer │ postMessage + │ (COOP/COEP required) │ (fallback) +┌────────▼──────────────────────────────▼─────────────────────┐ +│ Web Worker (optional) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Rust / WebAssembly Core │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ +│ │ │ ECS(SoA) │ │ Quadtree │ │ Software Renderer │ │ │ +│ │ └────┬─────┘ └────┬─────┘ │ + Dirty Rects │ │ │ +│ │ │ │ └────────┬──────────┘ │ │ +│ │ ┌────▼──────────────▼─────────────────▼─────────┐ │ │ +│ │ │ Physics (Symplectic Euler + Spring Drag) │ │ │ +│ │ │ Relations (Merge OR / Fracture decompose) │ │ │ +│ │ │ Event Queue (spawn/despawn/merge/fracture) │ │ │ +│ │ └───────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ ``` ## Key Features @@ -36,6 +40,37 @@ A holographic-like workspace that bypasses the browser's normal rendering pipeli - **Dirty Rectangles** — Only redraws changed screen regions. - **Bitwise Logic** — Merge (OR) and Fracture (decompose) in 1 CPU clock cycle. - **Liquid Physics** — Viscosity, momentum, boundary reflection for fluid motion. +- **Data Payload System** — Each node carries an opaque payload (text, number, JSON, array). Rules determine how payloads combine on merge or split on fracture. +- **Draw Mode** — Drag a path on canvas to create nodes with custom payloads. +- **Spring-Damped Drag** — Pin nodes to cursor via Hooke's law spring + damping. +- **Glassmorphism HUD** — Preact overlay with translucent panels, blur, mode switcher, inspector, and physics config. +- **Web Worker** — Offload physics/rendering to a background thread via SharedArrayBuffer + Atomics or transfer-based postMessage fallback. +- **Drag & Drop** — Drop files/text onto canvas to create nodes with detected payloads. + +## Data Payload System + +Each node in the engine is a "shell" — a physical entity with position, velocity, and radius. The payload system (`web/payload.js`) maintains a separate `Map` on the JS side. Payloads are arbitrary: + +```js +{ type: 'text', value: 'hello world', label: 'greeting' } +{ type: 'number', value: 42, label: 'answer' } +{ type: 'json', value: { x: 10, y: 20 }, label: 'point' } +{ type: 'array', value: [1, 2, 3], label: 'list' } +{ type: 'composite', value: { items: [...] }, label: 'mixed' } +``` + +Events emitted from Wasm (`merge`, `fracture`, `spawn`, `despawn`) are decoded and applied to the payload registry automatically. + +### Rule Engine (`web/rules.js`) + +Pure functions that determine how payloads combine or split: + +| Operation | text | number | json | array | mixed | +|-----------|------|--------|------|-------|-------| +| **Merge** | Concatenate with `\n` | Sum (configurable) | Shallow merge, conflict → composite | Concatenate items | Wrap in composite | +| **Fracture** | Split by word/token | Divide equally | Each key/value → node | Each element → node | Generic split | + +Numeric merge reducer is configurable at runtime: sum (default), average, or product. ## Quick Start @@ -54,45 +89,74 @@ cargo install wasm-pack # Build WebAssembly module npm run build:wasm -# Start dev server +# Start dev server (with COOP/COEP headers for SharedArrayBuffer) npm run serve # Open http://localhost:8080/web/ +# Example page: http://localhost:8080/examples/data-workflow.html ``` -### Interactions +### COOP/COEP Headers -- **Click empty space** — Spawn new nodes (burst pattern) -- **Click + Drag node** — Apply force (fluid drag) -- **Double-click node** — Fracture into components +For the Web Worker SharedArrayBuffer path to work, the server must send: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +The included `scripts/serve.js` sets these automatically. If you use another server, ensure these headers are present. Without them, the engine falls back to transfer-based postMessage or single-threaded mode. ## Project Structure ``` -├── Cargo.toml # Rust project config (wasm-bindgen) -├── package.json # Build scripts +├── Cargo.toml # Rust project config (wasm-bindgen) +├── package.json # Build & test scripts ├── src/ -│ ├── lib.rs # Engine entry point (exposed to JS) -│ ├── ecs.rs # Entity Component System (SoA arrays) -│ ├── quadtree.rs # Spatial partitioning -│ ├── renderer.rs # CPU pixel buffer + dirty rectangles -│ ├── physics.rs # Kinematics (viscosity, velocity, bounds) -│ └── relations.rs # Bitwise merge/fracture logic +│ ├── lib.rs # Engine entry point + event queue (exposed to JS) +│ ├── ecs.rs # Entity Component System (SoA arrays, free-list) +│ ├── quadtree.rs # Spatial partitioning (recursive quadrant) +│ ├── renderer.rs # CPU pixel buffer + dirty rectangles +│ ├── physics.rs # Symplectic Euler, spring drag, viscosity, bounds +│ └── relations.rs # Bitwise merge/fracture logic + events ├── web/ -│ ├── index.html # Single-page canvas host -│ └── main.js # JS host layer (canvas, input, render loop) +│ ├── index.html # Single-page canvas host + HUD mount +│ ├── main.js # JS host (canvas, input, render loop, payload) +│ ├── payload.js # Payload registry (Map) +│ ├── rules.js # Generic rule engine (merge/fracture per type) +│ ├── worker.js # Web Worker (owns Wasm engine) +│ ├── worker-engine.js # Worker adapter (SharedArrayBuffer + fallback) +│ └── hud/ +│ ├── app.js # Preact + htm glassmorphism HUD +│ └── style.css # Glassmorphism panel styles +├── examples/ +│ └── data-workflow.html # Demo: JSON/number/text datasets +├── scripts/ +│ └── serve.js # Dev server with COOP/COEP headers └── .cargo/ - └── config.toml # Wasm target + SIMD flags + └── config.toml # Wasm target + SIMD flags ``` +## Interactions + +- **Click empty space** — Burst-spawn 50 new nodes +- **Click + Drag node** — Spring-damped drag (pin to cursor) +- **Double-click node** — Fracture into components +- **Draw mode** — Drag path on empty canvas, then enter label/payload for new node +- **Drag-and-drop** — Drop files or text onto canvas to create payload nodes +- **HUD mode buttons** — Switch between Select / Draw modes +- **HUD toolbar** — Configure numeric merge reducer, toggle gravity, adjust viscosity + ## Development Phases -- [x] Phase 1: Foundation & Memory (ECS, Cargo setup) -- [x] Phase 2: Space & Rendering (Quadtree, Software Renderer, Dirty Rects) -- [x] Phase 3: Interaction (Input, Physics, Fracture gestures) -- [x] Phase 4: Relational Logic (Bitmask collision, Merge/Fracture) -- [ ] Phase 5: Web Workers (offload physics to background thread) -- [ ] Phase 6: Visual Effects (Glassmorphism, glow, trails) +| Phase | Feature | Status | +|-------|---------|--------| +| 1 | Foundation & Memory (ECS, Cargo setup) | Done | +| 2 | Space & Rendering (Quadtree, Software Renderer, Dirty Rects) | Done | +| 3 | Interaction (Input, Physics, Fracture gestures) | Done | +| 4 | Relational Logic (Bitmask collision, Merge/Fracture) | Done | +| 5 | Holographic Core (Payload system, Rule engine, HUD, Worker, Spring drag, Tests) | Done | +| 6 | Visual Effects (Glassmorphism trails, glow, particle FX) | Next | ## License diff --git a/examples/data-workflow.html b/examples/data-workflow.html new file mode 100644 index 0000000..0dd5b04 --- /dev/null +++ b/examples/data-workflow.html @@ -0,0 +1,96 @@ + + + + + + LSE — Data Workflow Demo + + + + + +
+
0
+
0
+
0x0
+
loading...
+
+
+ +
+ + + + +
+ + + + + + diff --git a/package.json b/package.json index eca675b..1ea0abe 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "liquid-state-engine", - "version": "0.1.0", + "version": "0.2.0", "description": "Interactive Visual Computing Platform - CPU-only, DOM-free rendering engine powered by Rust/WebAssembly", "private": true, "scripts": { "build:wasm": "wasm-pack build --target web --out-dir pkg", "build:wasm:release": "wasm-pack build --target web --out-dir pkg --release", - "dev": "npx http-server ./web -p 8080 --cors -c-1", - "serve": "npx http-server . -p 8080 --cors -c-1 -a 0.0.0.0", + "check": "cargo check --target wasm32-unknown-unknown", + "test:rust": "cargo test --target x86_64-pc-windows-msvc", + "test:rust:linux": "cargo test", + "serve": "node scripts/serve.js", "clean": "rm -rf pkg target" }, "keywords": [ diff --git a/scripts/serve.js b/scripts/serve.js new file mode 100644 index 0000000..d7bf153 --- /dev/null +++ b/scripts/serve.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Liquid-State Engine — Dev Server + * + * Serves the web/ directory with COOP/COEP headers required for + * SharedArrayBuffer support in Web Workers. + * + * Required headers: + * Cross-Origin-Opener-Policy: same-origin + * Cross-Origin-Embedder-Policy: require-corp + * + * Usage: + * node scripts/serve.js [port] + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = process.argv[2] || 8080; +const ROOT = path.resolve(__dirname, '..', 'web'); +const PKG = path.resolve(__dirname, '..', 'pkg'); + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.wasm': 'application/wasm', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +function serveFile(res, filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mime = MIME[ext] || 'application/octet-stream'; + + try { + const stat = fs.statSync(filePath); + const data = fs.readFileSync(filePath); + res.writeHead(200, { + 'Content-Type': mime, + 'Content-Length': stat.size, + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cache-Control': 'no-cache', + }); + res.end(data); + } catch { + res.writeHead(404); + res.end('Not Found'); + } +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + let urlPath = url.pathname; + + // Default to index.html + if (urlPath === '/' || urlPath === '') { + urlPath = '/index.html'; + } + + // Remove leading slash + const relPath = urlPath.replace(/^\/+/, ''); + + // First try web/ directory, then pkg/ (for wasm module) + let filePath = path.join(ROOT, relPath); + if (!fs.existsSync(filePath)) { + filePath = path.join(PKG, relPath.replace(/^web\//, '')); + } + // Also try parent pkg/ for ../pkg/ imports + if (!fs.existsSync(filePath) && relPath.startsWith('pkg/')) { + filePath = path.resolve(__dirname, '..', relPath); + } + + serveFile(res, filePath); +}); + +server.listen(PORT, () => { + console.log(`Liquid-State Engine dev server running at http://localhost:${PORT}/`); + console.log(` COOP: same-origin`); + console.log(` COEP: require-corp`); + console.log(` Root: ${ROOT}`); + console.log(` SharedArrayBuffer: ENABLED`); +}); diff --git a/src/ecs.rs b/src/ecs.rs index 8fa4200..cd0db19 100644 --- a/src/ecs.rs +++ b/src/ecs.rs @@ -6,12 +6,15 @@ //! //! Supports up to `max_entities` nodes with O(1) spawn/despawn via //! a free-list allocator. +//! +//! The `alive_list` enables O(active) iteration (skipping dead slots), +//! which is critical for performance when capacity >> active count. /// The ECS World: holds all component arrays and entity lifecycle state. pub struct World { /// Maximum number of entities this world can hold. pub max_entities: usize, - + // --- Lifecycle --- /// Whether each entity slot is alive. pub alive: Vec, @@ -19,6 +22,9 @@ pub struct World { free_list: Vec, /// Number of currently active entities. active: usize, + /// Compact list of alive entity IDs for fast iteration. + /// spawn() appends, despawn() does swap-remove (O(1)). + alive_list: Vec, // --- Position Components (SoA) --- pub pos_x: Vec, @@ -66,6 +72,7 @@ impl World { alive: vec![false; max_entities], free_list, active: 0, + alive_list: Vec::with_capacity(max_entities), pos_x: vec![0.0; max_entities], pos_y: vec![0.0; max_entities], @@ -111,6 +118,7 @@ impl World { self.bitmask[id] = bitmask; self.radius[id] = radius; self.mass[id] = (bitmask.count_ones() as f32).max(1.0); + self.alive_list.push(id as u32); self.active += 1; id } else { @@ -119,6 +127,7 @@ impl World { } /// Despawn an entity, returning its slot to the free list. + /// Uses swap-remove from alive_list for O(1) removal. pub fn despawn(&mut self, id: usize) { if id < self.max_entities && self.alive[id] { self.alive[id] = false; @@ -128,6 +137,11 @@ impl World { self.force_y[id] = 0.0; self.free_list.push(id); self.active -= 1; + + // Swap-remove from alive_list + if let Some(pos) = self.alive_list.iter().position(|&x| x == id as u32) { + self.alive_list.swap_remove(pos); + } } } @@ -148,4 +162,84 @@ impl World { pub fn capacity(&self) -> usize { self.max_entities } + + /// Iterate over the compact alive entity IDs (skip dead slots). + /// Returns a slice of u32 entity IDs, all guaranteed to be alive. + pub fn alive_iter(&self) -> &[u32] { + &self.alive_list + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spawn_despawn_round_trip() { + let mut world = World::new(100); + assert_eq!(world.active_count(), 0); + + let id = world.spawn(10.0, 20.0, 1.0, -2.0, 255, 128, 64, 200, 0b101, 8.0); + assert!(id != usize::MAX); + assert_eq!(world.active_count(), 1); + assert!(world.alive[id]); + assert_eq!(world.pos_x[id], 10.0); + assert_eq!(world.pos_y[id], 20.0); + assert_eq!(world.bitmask[id], 0b101); + assert_eq!(world.mass[id], 2.0); + assert_eq!(world.alive_list.len(), 1); + assert_eq!(world.alive_list[0], id as u32); + + world.despawn(id); + assert_eq!(world.active_count(), 0); + assert!(!world.alive[id]); + assert_eq!(world.alive_list.len(), 0); + + // Re-spawn in the freed slot + let id2 = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 4.0); + assert_eq!(id2, id); + assert_eq!(world.active_count(), 1); + assert_eq!(world.alive_list.len(), 1); + } + + #[test] + fn spawn_at_capacity_returns_max() { + let mut world = World::new(5); + for _ in 0..5 { + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 1.0); + assert!(id != usize::MAX); + } + let overflow = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 1.0); + assert_eq!(overflow, usize::MAX); + } + + #[test] + fn force_accumulation_and_mass() { + let mut world = World::new(10); + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0b1111, 5.0); + assert_eq!(world.mass[id], 4.0); + + world.apply_force(id, 10.0, 20.0); + world.apply_force(id, 5.0, -5.0); + assert_eq!(world.force_x[id], 15.0); + assert_eq!(world.force_y[id], 15.0); + } + + #[test] + fn alive_list_tracks_correctly() { + let mut world = World::new(100); + let _id1 = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 1, 5.0); + let id2 = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 1, 5.0); + let _id3 = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 1, 5.0); + + assert_eq!(world.alive_iter().len(), 3); + + // Remove the middle one + world.despawn(id2); + assert_eq!(world.alive_iter().len(), 2); + + // The alive_list should contain id1 and id3 (order may vary due to swap_remove) + let alive: Vec = world.alive_iter().to_vec(); + alive.iter().for_each(|&id| assert!(world.alive[id as usize])); + } } diff --git a/src/lib.rs b/src/lib.rs index 70380ca..a64fc7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ //! Liquid-State Engine: Interactive Visual Computing Platform -//! +//! //! A CPU-only, DOM-free rendering engine that bypasses the browser's //! normal rendering pipeline. Uses a single as a holographic //! display surface with direct pixel manipulation via WebAssembly. @@ -9,52 +9,72 @@ pub mod quadtree; pub mod renderer; pub mod physics; pub mod relations; +pub mod spatial_grid; use wasm_bindgen::prelude::*; use ecs::World; use quadtree::Quadtree; +use spatial_grid::SpatialGrid; use renderer::SoftwareRenderer; use physics::PhysicsSystem; use relations::RelationSystem; +/// Event kind constants (packed u32 format). +pub const EVENT_MERGE: u32 = 0; +pub const EVENT_FRACTURE: u32 = 1; +pub const EVENT_SPAWN: u32 = 2; +pub const EVENT_DESPAWN: u32 = 3; + +/// Node count threshold to switch from quadtree to spatial grid. +const GRID_SWITCH_THRESHOLD: u32 = 2000; + +/// Pre-allocated event queue capacity. +const EVENT_CAPACITY: usize = 4096; + /// The main engine struct that orchestrates all subsystems. /// This is the single entry point exposed to JavaScript. #[wasm_bindgen] pub struct LiquidEngine { world: World, quadtree: Quadtree, + grid: SpatialGrid, renderer: SoftwareRenderer, physics: PhysicsSystem, relations: RelationSystem, canvas_width: u32, canvas_height: u32, frame_count: u64, + events: Vec, + /// Scratch buffer for spatial queries (reused each frame). + query_scratch: Vec, + /// Currently active spatial index: true = quadtree, false = grid. + use_quadtree: bool, } #[wasm_bindgen] impl LiquidEngine { - /// Create a new Liquid-State Engine instance. - /// - /// # Arguments - /// * `width` - Canvas width in pixels - /// * `height` - Canvas height in pixels - /// * `max_nodes` - Maximum number of entities supported (pre-allocated) #[wasm_bindgen(constructor)] pub fn new(width: u32, height: u32, max_nodes: u32) -> LiquidEngine { + let mut events = Vec::with_capacity(EVENT_CAPACITY); + // Pre-fill to avoid reallocation during runtime + events.reserve(EVENT_CAPACITY); + LiquidEngine { world: World::new(max_nodes as usize), quadtree: Quadtree::new(0.0, 0.0, width as f32, height as f32, 8, 4), + grid: SpatialGrid::new(width as f32, height as f32), renderer: SoftwareRenderer::new(width, height), physics: PhysicsSystem::new(), relations: RelationSystem::new(), canvas_width: width, canvas_height: height, frame_count: 0, + events, + query_scratch: Vec::with_capacity(512), + use_quadtree: true, } } - /// Spawn a new node entity with position, velocity, color, and bitmask. - /// Returns the entity ID, or u32::MAX if capacity is full. pub fn spawn_node( &mut self, x: f32, y: f32, @@ -63,82 +83,111 @@ impl LiquidEngine { bitmask: u32, radius: f32, ) -> u32 { - self.world.spawn(x, y, vx, vy, r, g, b, a, bitmask, radius) as u32 + let id = self.world.spawn(x, y, vx, vy, r, g, b, a, bitmask, radius); + if id != usize::MAX { + self.events.push(EVENT_SPAWN); + self.events.push(0); + self.events.push(1); + self.events.push(id as u32); + } + id as u32 } - /// Remove a node by entity ID. pub fn remove_node(&mut self, id: u32) { - self.world.despawn(id as usize); + let idx = id as usize; + if idx < self.world.max_entities && self.world.alive[idx] { + self.events.push(EVENT_DESPAWN); + self.events.push(1); + self.events.push(0); + self.events.push(id); + } + self.world.despawn(idx); } - /// Get the current number of active nodes. pub fn node_count(&self) -> u32 { self.world.active_count() as u32 } - /// Apply an external force (e.g., from user drag) to a specific node. pub fn apply_force(&mut self, id: u32, fx: f32, fy: f32) { self.world.apply_force(id as usize, fx, fy); } - /// Main simulation tick - advances physics, resolves collisions, - /// processes merge/fracture logic, and renders to pixel buffer. - /// - /// # Arguments - /// * `dt` - Delta time in seconds since last frame pub fn tick(&mut self, dt: f32) { self.frame_count += 1; - // 1. Physics update: apply velocities, viscosity, bounds + // 0. Clear event queue (keep capacity) + self.events.clear(); + + // 1. Physics update (uses alive_list internally) self.physics.update(&mut self.world, dt, self.canvas_width as f32, self.canvas_height as f32); - // 2. Rebuild Quadtree with current positions - self.quadtree.clear(); + // 2. Auto-select spatial index based on node count let count = self.world.active_count(); - for i in 0..count { - if self.world.alive[i] { - let x = self.world.pos_x[i]; - let y = self.world.pos_y[i]; - self.quadtree.insert(i as u32, x, y); - } + if count > GRID_SWITCH_THRESHOLD as usize { + self.use_quadtree = false; + } else if count < (GRID_SWITCH_THRESHOLD as usize / 2) { + self.use_quadtree = true; } - // 3. Collision detection & relational logic (merge/fracture) - self.relations.process(&mut self.world, &self.quadtree); + // 3. Rebuild spatial index + let alive = self.world.alive_iter(); + if self.use_quadtree { + self.quadtree.clear(); + for &id_u32 in alive { + let i = id_u32 as usize; + self.quadtree.insert(id_u32, self.world.pos_x[i], self.world.pos_y[i]); + } + // 4. Collision detection via quadtree + self.relations.process(&mut self.world, &self.quadtree, &mut self.events); + } else { + self.grid.clear(); + for &id_u32 in alive { + let i = id_u32 as usize; + self.grid.insert(id_u32, self.world.pos_x[i], self.world.pos_y[i]); + } + // When using grid, relations still queries via quadtree for now + // (The relations system uses Quadtree. In a future refactor, + // we'd add a trait-based dispatch. For now, rebuild quadtree + // for relations but use grid for pick queries.) + self.quadtree.clear(); + for &id_u32 in alive { + let i = id_u32 as usize; + self.quadtree.insert(id_u32, self.world.pos_x[i], self.world.pos_y[i]); + } + self.relations.process(&mut self.world, &self.quadtree, &mut self.events); + } - // 4. Render to pixel buffer (dirty rectangles) + // 5. Render to pixel buffer (uses alive_list internally) self.renderer.render(&self.world); } - /// Get a pointer to the pixel buffer for JavaScript to read. - /// The buffer is RGBA format, width*height*4 bytes. pub fn pixel_buffer_ptr(&self) -> *const u8 { self.renderer.buffer_ptr() } - /// Get the pixel buffer length in bytes. pub fn pixel_buffer_len(&self) -> u32 { self.renderer.buffer_len() as u32 } - /// Get pointer to the dirty rectangle data. - /// Format: [x, y, width, height] as u32 values, or [0,0,0,0] if no dirty region. pub fn dirty_rect_ptr(&self) -> *const u32 { self.renderer.dirty_rect_ptr() } - /// Check if there is a dirty region that needs redrawing. pub fn has_dirty_region(&self) -> bool { self.renderer.has_dirty_region() } - /// Find the node at a given screen position (for hit testing / picking). - /// Returns the entity ID or u32::MAX if nothing found. - pub fn pick_node_at(&self, x: f32, y: f32) -> u32 { - let candidates = self.quadtree.query(x, y, 1.0); - for &id in &candidates { + pub fn pick_node_at(&mut self, x: f32, y: f32) -> u32 { + self.query_scratch.clear(); + if self.use_quadtree { + self.quadtree.query(x, y, 1.0, &mut self.query_scratch); + } else { + self.grid.query(x, y, 1.0, &mut self.query_scratch); + } + + for &id in &self.query_scratch { let idx = id as usize; - if idx < self.world.active_count() && self.world.alive[idx] { + if idx < self.world.max_entities && self.world.alive[idx] { let dx = self.world.pos_x[idx] - x; let dy = self.world.pos_y[idx] - y; let r = self.world.radius[idx]; @@ -150,28 +199,137 @@ impl LiquidEngine { u32::MAX } - /// Initiate a fracture operation on a node (split into components). pub fn fracture_node(&mut self, id: u32) { - self.relations.fracture(&mut self.world, id as usize); + self.relations.fracture(&mut self.world, id as usize, &mut self.events); } - /// Get canvas width. - pub fn width(&self) -> u32 { - self.canvas_width + pub fn width(&self) -> u32 { self.canvas_width } + pub fn height(&self) -> u32 { self.canvas_height } + pub fn frame_count(&self) -> u64 { self.frame_count } + + pub fn clear_dirty(&mut self) { + self.renderer.clear_dirty(); } - /// Get canvas height. - pub fn height(&self) -> u32 { - self.canvas_height + // ---- Event Queue API ---- + + pub fn event_count(&self) -> u32 { + self.events.len() as u32 } - /// Get frame count since engine creation. - pub fn frame_count(&self) -> u64 { - self.frame_count + pub fn event_ptr(&self) -> *const u32 { + self.events.as_ptr() } - /// Reset the dirty region flag after JS has read the pixel data. - pub fn clear_dirty(&mut self) { - self.renderer.clear_dirty(); + pub fn drain_events(&mut self) { + self.events.clear(); + } + + // ---- Pinned Drag API ---- + + pub fn pin_node(&mut self, id: u32, cursor_x: f32, cursor_y: f32) { + self.physics.pin_node(&mut self.world, id as usize, cursor_x, cursor_y); + } + + pub fn unpin_node(&mut self, id: u32) { + self.physics.unpin_node(&mut self.world, id as usize); + } + + pub fn update_pin_target(&mut self, id: u32, cursor_x: f32, cursor_y: f32) { + self.physics.update_pin_target(&mut self.world, id as usize, cursor_x, cursor_y); + } + + // ---- Physics Settings ---- + + pub fn set_viscosity(&mut self, v: f32) { self.physics.viscosity = v; } + pub fn set_gravity(&mut self, g: f32) { self.physics.gravity_y = g; } + + // ---- Double-Buffer & Viewport ---- + + pub fn swap_buffers(&mut self) { + self.renderer.swap_buffers(); + } + + pub fn set_viewport(&mut self, x: f32, y: f32, scale: f32) { + self.renderer.set_viewport(x, y, scale); + } +} + +// ---- Performance Benchmark (native-only, not wasm) ---- + +#[cfg(test)] +mod bench_tests { + use super::*; + + #[test] + fn bench_10k_nodes_300_frames() { + let mut engine = LiquidEngine::new(1920, 1080, 10000); + + // Spawn 10000 nodes with SAME bitmask (no merging during benchmark) + let mask = 0b001u32; + for i in 0..10000u32 { + let x = ((i.wrapping_mul(7919)) % 1920) as f32; + let y = ((i.wrapping_mul(6271)) % 1080) as f32; + let vx = ((i.wrapping_mul(31)) as f32 % 60.0) - 30.0; + let vy = ((i.wrapping_mul(47)) as f32 % 60.0) - 30.0; + let id = engine.spawn_node(x, y, vx, vy, 255, 60, 60, 255, mask, 4.0 + (i as f32 % 4.0)); + if id == 0xFFFFFFFF { + break; + } + } + + // Warm up: 2 frames to stabilize allocations + engine.tick(0.016); + engine.tick(0.016); + + // Benchmark: 300 frames + let start = std::time::Instant::now(); + for _ in 0..300 { + engine.tick(0.016); + } + let elapsed = start.elapsed(); + let ms_per_frame = elapsed.as_millis() as f64 / 300.0; + + println!("=== PERFORMANCE BENCHMARK ==="); + println!(" Nodes: {}", engine.node_count()); + println!(" Frames: 300"); + println!(" Total time: {:.2} ms", elapsed.as_millis()); + println!(" Per frame: {:.2} ms", ms_per_frame); + println!(" Equivalent FPS: {:.0}", 1000.0 / ms_per_frame); + println!("============================="); + + // Must sustain ≥ 60 FPS (≤ 16.67 ms per frame) + assert!(ms_per_frame < 16.0, "Performance target: < 16ms/frame (60 FPS), got {:.2}ms", ms_per_frame); + } + + #[test] + fn bench_3k_nodes_baseline() { + let mut engine = LiquidEngine::new(1920, 1080, 5000); + + let mask = 0b001u32; + for i in 0..3000u32 { + let x = ((i.wrapping_mul(7919)) % 1920) as f32; + let y = ((i.wrapping_mul(6271)) % 1080) as f32; + let vx = ((i.wrapping_mul(31)) as f32 % 40.0) - 20.0; + let vy = ((i.wrapping_mul(47)) as f32 % 40.0) - 20.0; + engine.spawn_node(x, y, vx, vy, 200, 200, 200, 255, mask, 5.0); + } + + engine.tick(0.016); + engine.tick(0.016); + + let start = std::time::Instant::now(); + for _ in 0..300 { + engine.tick(0.016); + } + let elapsed = start.elapsed(); + let ms_per_frame = elapsed.as_millis() as f64 / 300.0; + + println!("=== 3K Baseline ==="); + println!(" Per frame: {:.2} ms", ms_per_frame); + println!("==================="); + + // Should be very fast at 3K + assert!(ms_per_frame < 10.0); } } diff --git a/src/physics.rs b/src/physics.rs index 0277eeb..9daa5c5 100644 --- a/src/physics.rs +++ b/src/physics.rs @@ -1,107 +1,230 @@ //! Physics / Kinematics System //! //! Handles velocity integration, viscosity (drag), boundary reflection, -//! and force application. Designed for smooth, "liquid-like" motion -//! that gives nodes a sense of weight and fluidity. +//! and force application. Designed for smooth, "liquid-like" motion. +//! +//! Iterates the compact alive_list to skip dead slots. +//! SIMD f32x4 processing of 4 entities in parallel when available. use crate::ecs::World; /// Physics system configuration and state. pub struct PhysicsSystem { - /// Viscosity coefficient (0.0 = no drag, 1.0 = frozen). - /// Acts as a damping force proportional to velocity. pub viscosity: f32, - - /// Coefficient of restitution for boundary bouncing (0.0-1.0). pub bounce: f32, - - /// Global gravity (pixels/sec^2). Set to 0 for zero-g mode. pub gravity_y: f32, - - /// Maximum velocity magnitude (speed limit). pub max_speed: f32, + pub spring_stiffness: f32, + pub spring_damping: f32, + + pinned: Vec, + pin_target_x: Vec, + pin_target_y: Vec, } impl PhysicsSystem { - /// Create a new physics system with default liquid-like parameters. pub fn new() -> Self { PhysicsSystem { - viscosity: 0.02, // Light drag for fluid feel - bounce: 0.7, // Moderate bounce off walls - gravity_y: 0.0, // No gravity by default (floating workspace) - max_speed: 1000.0, // Reasonable speed cap + viscosity: 0.02, + bounce: 0.7, + gravity_y: 0.0, + max_speed: 1000.0, + spring_stiffness: 300.0, + spring_damping: 8.0, + pinned: Vec::new(), + pin_target_x: Vec::new(), + pin_target_y: Vec::new(), } } - /// Update all entity positions based on velocity, forces, and constraints. - /// - /// Integration method: Semi-implicit Euler (Symplectic Euler) - /// - First update velocity from forces - /// - Then update position from new velocity - /// This is more stable than explicit Euler for oscillatory systems. - pub fn update(&self, world: &mut World, dt: f32, bounds_w: f32, bounds_h: f32) { - for i in 0..world.max_entities { - if !world.alive[i] { - continue; - } - - let mass = world.mass[i]; - let inv_mass = if mass > 0.0 { 1.0 / mass } else { 1.0 }; + fn ensure_pin_capacity(&mut self, max_entities: usize) { + if self.pinned.len() < max_entities { + self.pinned.resize(max_entities, false); + self.pin_target_x.resize(max_entities, 0.0); + self.pin_target_y.resize(max_entities, 0.0); + } + } - // Apply accumulated forces -> acceleration -> velocity - let ax = world.force_x[i] * inv_mass; - let ay = world.force_y[i] * inv_mass + self.gravity_y; + pub fn pin_node(&mut self, world: &mut World, id: usize, cursor_x: f32, cursor_y: f32) { + if id < world.max_entities && world.alive[id] { + self.ensure_pin_capacity(world.max_entities); + self.pinned[id] = true; + self.pin_target_x[id] = cursor_x; + self.pin_target_y[id] = cursor_y; + } + } - world.vel_x[i] += ax * dt; - world.vel_y[i] += ay * dt; + pub fn unpin_node(&mut self, world: &mut World, id: usize) { + if id < world.max_entities { + self.ensure_pin_capacity(world.max_entities); + self.pinned[id] = false; + } + } - // Apply viscosity (velocity-dependent drag) - world.vel_x[i] *= 1.0 - self.viscosity; - world.vel_y[i] *= 1.0 - self.viscosity; + pub fn update_pin_target(&mut self, world: &mut World, id: usize, cursor_x: f32, cursor_y: f32) { + if id < world.max_entities && self.pinned.len() > id { + self.pin_target_x[id] = cursor_x; + self.pin_target_y[id] = cursor_y; + } + } - // Clamp speed - let speed_sq = world.vel_x[i] * world.vel_x[i] + world.vel_y[i] * world.vel_y[i]; - if speed_sq > self.max_speed * self.max_speed { - let speed = speed_sq.sqrt(); - let scale = self.max_speed / speed; - world.vel_x[i] *= scale; - world.vel_y[i] *= scale; + /// Update all entity positions using Symplectic Euler integration. + /// Uses alive_list for O(active) iteration. + /// SIMD path processes 4 entities in parallel lanes; scalar tail for remainder. + pub fn update(&mut self, world: &mut World, dt: f32, bounds_w: f32, bounds_h: f32) { + self.ensure_pin_capacity(world.max_entities); + + // Snapshot the alive IDs to release immutable borrow on world + let alive: Vec = world.alive_iter().to_vec(); + let len = alive.len(); + let mut i = 0usize; + + // SIMD path: process 4 at a time + #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + { + use core::arch::wasm32::*; + let dt_v = f32x4_splat(dt); + let max_spd = self.max_speed; + let bounce = self.bounce; + let grav = self.gravity_y; + let stiff = self.spring_stiffness; + let damp = self.spring_damping; + let visc = self.viscosity; + + while i + 4 <= len { + let id0 = alive[i] as usize; + let id1 = alive[i + 1] as usize; + let id2 = alive[i + 2] as usize; + let id3 = alive[i + 3] as usize; + + // Load SoA into SIMD lanes + let f_x = f32x4(world.force_x[id0], world.force_x[id1], world.force_x[id2], world.force_x[id3]); + let f_y = f32x4(world.force_y[id0], world.force_y[id1], world.force_y[id2], world.force_y[id3]); + let mass = f32x4(world.mass[id0], world.mass[id1], world.mass[id2], world.mass[id3]); + + // inv_mass = 1/mass + let inv_mass = f32x4_div(f32x4_splat(1.0), mass); + + // Acceleration from forces: ax = fx * inv_mass, ay = fy * inv_mass + gravity + let grav_v = f32x4_splat(grav); + let ax = f32x4_mul(f_x, inv_mass); + let ay = f32x4_add(f32x4_mul(f_y, inv_mass), grav_v); + + // Integrate velocities + let nvx = f32x4_add( + f32x4(world.vel_x[id0], world.vel_x[id1], world.vel_x[id2], world.vel_x[id3]), + f32x4_mul(ax, dt_v), + ); + let nvy = f32x4_add( + f32x4(world.vel_y[id0], world.vel_y[id1], world.vel_y[id2], world.vel_y[id3]), + f32x4_mul(ay, dt_v), + ); + + // Store intermediate velocities back (spring forces handled below) + world.vel_x[id0] = f32x4_extract_lane::<0>(nvx); + world.vel_x[id1] = f32x4_extract_lane::<1>(nvx); + world.vel_x[id2] = f32x4_extract_lane::<2>(nvx); + world.vel_x[id3] = f32x4_extract_lane::<3>(nvx); + world.vel_y[id0] = f32x4_extract_lane::<0>(nvy); + world.vel_y[id1] = f32x4_extract_lane::<1>(nvy); + world.vel_y[id2] = f32x4_extract_lane::<2>(nvy); + world.vel_y[id3] = f32x4_extract_lane::<3>(nvy); + + // Scalar spring + finish for each + for &id in &[id0, id1, id2, id3] { + apply_spring(world, self, id, stiff, damp, dt); + apply_viscosity(world, id, visc); + speed_clamp(world, id, max_spd); + integrate(world, id, dt); + boundary_bounce(world, id, bounds_w, bounds_h, bounce); + clear_forces(world, id); + } + + i += 4; } + } + + // Scalar tail + while i < len { + let id = alive[i] as usize; + scalar_update(world, self, id, dt, bounds_w, bounds_h); + i += 1; + } + } +} - // Integrate position - world.pos_x[i] += world.vel_x[i] * dt; - world.pos_y[i] += world.vel_y[i] * dt; +fn scalar_update(world: &mut World, phys: &PhysicsSystem, id: usize, dt: f32, bounds_w: f32, bounds_h: f32) { + let mass = world.mass[id]; + let inv_mass = if mass > 0.0 { 1.0 / mass } else { 1.0 }; - // Boundary collision (reflect with energy loss) - let r = world.radius[i]; + let ax = world.force_x[id] * inv_mass; + let ay = world.force_y[id] * inv_mass + phys.gravity_y; - if world.pos_x[i] - r < 0.0 { - world.pos_x[i] = r; - world.vel_x[i] = -world.vel_x[i] * self.bounce; - } else if world.pos_x[i] + r > bounds_w { - world.pos_x[i] = bounds_w - r; - world.vel_x[i] = -world.vel_x[i] * self.bounce; - } + world.vel_x[id] += ax * dt; + world.vel_y[id] += ay * dt; - if world.pos_y[i] - r < 0.0 { - world.pos_y[i] = r; - world.vel_y[i] = -world.vel_y[i] * self.bounce; - } else if world.pos_y[i] + r > bounds_h { - world.pos_y[i] = bounds_h - r; - world.vel_y[i] = -world.vel_y[i] * self.bounce; - } + apply_spring(world, phys, id, phys.spring_stiffness, phys.spring_damping, dt); + apply_viscosity(world, id, phys.viscosity); + speed_clamp(world, id, phys.max_speed); + integrate(world, id, dt); + boundary_bounce(world, id, bounds_w, bounds_h, phys.bounce); + clear_forces(world, id); +} - // Clear forces for next frame (forces are impulse-based) - world.force_x[i] = 0.0; - world.force_y[i] = 0.0; +fn apply_spring(world: &mut World, phys: &PhysicsSystem, id: usize, stiff: f32, damp: f32, dt: f32) { + if id < phys.pinned.len() && phys.pinned[id] { + let inv_mass = if world.mass[id] > 0.0 { 1.0 / world.mass[id] } else { 1.0 }; + let ox = phys.pin_target_x[id] - world.pos_x[id]; + let oy = phys.pin_target_y[id] - world.pos_y[id]; + let fx = stiff * ox - damp * world.vel_x[id]; + let fy = stiff * oy - damp * world.vel_y[id]; + world.vel_x[id] += fx * inv_mass * dt; + world.vel_y[id] += fy * inv_mass * dt; + } +} - // Stop very slow entities (avoid micro-jitter) - if world.vel_x[i].abs() < 0.01 { - world.vel_x[i] = 0.0; - } - if world.vel_y[i].abs() < 0.01 { - world.vel_y[i] = 0.0; - } - } +fn apply_viscosity(world: &mut World, id: usize, viscosity: f32) { + world.vel_x[id] *= 1.0 - viscosity; + world.vel_y[id] *= 1.0 - viscosity; +} + +fn speed_clamp(world: &mut World, id: usize, max_speed: f32) { + let speed_sq = world.vel_x[id] * world.vel_x[id] + world.vel_y[id] * world.vel_y[id]; + if speed_sq > max_speed * max_speed { + let speed = speed_sq.sqrt(); + let scale = max_speed / speed; + world.vel_x[id] *= scale; + world.vel_y[id] *= scale; } } + +fn integrate(world: &mut World, id: usize, dt: f32) { + world.pos_x[id] += world.vel_x[id] * dt; + world.pos_y[id] += world.vel_y[id] * dt; +} + +fn boundary_bounce(world: &mut World, id: usize, bounds_w: f32, bounds_h: f32, bounce: f32) { + let r = world.radius[id]; + if world.pos_x[id] - r < 0.0 { + world.pos_x[id] = r; + world.vel_x[id] = -world.vel_x[id] * bounce; + } else if world.pos_x[id] + r > bounds_w { + world.pos_x[id] = bounds_w - r; + world.vel_x[id] = -world.vel_x[id] * bounce; + } + if world.pos_y[id] - r < 0.0 { + world.pos_y[id] = r; + world.vel_y[id] = -world.vel_y[id] * bounce; + } else if world.pos_y[id] + r > bounds_h { + world.pos_y[id] = bounds_h - r; + world.vel_y[id] = -world.vel_y[id] * bounce; + } +} + +fn clear_forces(world: &mut World, id: usize) { + world.force_x[id] = 0.0; + world.force_y[id] = 0.0; + // Stop micro-jitter + if world.vel_x[id].abs() < 0.01 { world.vel_x[id] = 0.0; } + if world.vel_y[id].abs() < 0.01 { world.vel_y[id] = 0.0; } +} diff --git a/src/quadtree.rs b/src/quadtree.rs index 5c15fd5..eb238e6 100644 --- a/src/quadtree.rs +++ b/src/quadtree.rs @@ -3,8 +3,12 @@ //! Divides 2D space into recursive quadrants for efficient spatial queries. //! Reduces collision detection from O(N^2) to approximately O(N log N). //! -//! The quadtree is rebuilt every frame from scratch (cheaper than maintaining -//! incremental updates with fast-moving objects). +//! **Zero-allocation design:** +//! - All node storage is pre-allocated in an arena (Vec with reserved capacity). +//! - `clear()` resets `arena_len` to 1 without deallocating. +//! - Points per leaf use a fixed-size inline array ([QTPoint; 8]) plus an +//! optional overflow pointer into a shared overflow pool. +//! - Query methods accept a `&mut Vec` scratch buffer to avoid allocation. /// A point stored in the quadtree: entity ID + position. #[derive(Clone, Copy)] @@ -17,10 +21,10 @@ struct QTPoint { /// Axis-aligned bounding box for quadtree regions. #[derive(Clone, Copy)] struct AABB { - x: f32, // left - y: f32, // top - w: f32, // width - h: f32, // height + x: f32, + y: f32, + w: f32, + h: f32, } impl AABB { @@ -37,63 +41,94 @@ impl AABB { } } +/// Maximum points stored inline per QTNode before subdivision or overflow. +const INLINE_CAP: usize = 8; + +/// Overflow pool entry for nodes exceeding INLINE_CAP. +struct OverflowEntry { + next: usize, + point: QTPoint, +} + /// A node in the quadtree (either leaf or internal). struct QTNode { boundary: AABB, - points: Vec, - capacity: usize, + /// Inline point storage (no heap for small counts). + inline_points: [QTPoint; INLINE_CAP], + inline_count: u8, + /// Head index into the overflow pool linked list. + overflow_head: usize, + capacity: u8, divided: bool, - // Children indices in the nodes pool nw: usize, ne: usize, sw: usize, se: usize, } -/// The Quadtree spatial index. +/// Empty QTNode for arena initialization. +const EMPTY_QTNODE: QTNode = QTNode { + boundary: AABB { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }, + inline_points: [QTPoint { id: 0, x: 0.0, y: 0.0 }; INLINE_CAP], + inline_count: 0, + overflow_head: 0, + capacity: 0, + divided: false, + nw: 0, ne: 0, sw: 0, se: 0, +}; + +/// The Quadtree spatial index (arena-based, zero-alloc on clear). pub struct Quadtree { + /// Pre-allocated node pool (arena). clear() resets len to 1. nodes: Vec, + /// Pre-allocated overflow pool for points beyond INLINE_CAP. + overflows: Vec, + /// Free list for overflow entries (stack of indices). + overflow_free: Vec, max_depth: u32, - node_capacity: usize, - root_boundary: AABB, } +/// Estimated max nodes in quadtree for pre-allocation. +/// For 10K entities with capacity 8, worst case ~ 1250 leaf nodes. +/// With subdivision, 4x overhead = ~5000 nodes max. +const MAX_QT_NODES: usize = 8192; + impl Quadtree { /// Create a new quadtree covering the specified region. - /// - /// # Arguments - /// * `x`, `y` - Top-left corner of the region - /// * `w`, `h` - Width and height of the region - /// * `max_depth` - Maximum recursion depth - /// * `node_capacity` - Max points per leaf before subdivision + /// Pre-allocates arena for zero runtime allocation. pub fn new(x: f32, y: f32, w: f32, h: f32, max_depth: u32, node_capacity: usize) -> Self { let boundary = AABB { x, y, w, h }; - let root = QTNode { - boundary, - points: Vec::with_capacity(node_capacity), - capacity: node_capacity, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }; + let cap = node_capacity.min(INLINE_CAP) as u8; + let mut root = EMPTY_QTNODE; + root.boundary = boundary; + root.capacity = cap; + + let mut nodes = Vec::with_capacity(MAX_QT_NODES); + nodes.push(root); + Quadtree { - nodes: vec![root], + nodes, + overflows: Vec::with_capacity(MAX_QT_NODES * 4), + overflow_free: Vec::with_capacity(MAX_QT_NODES * 4), max_depth, - node_capacity, - root_boundary: boundary, } } /// Clear and reset the quadtree for a new frame. + /// Does NOT deallocate — just resets arena length to 1 (root only). pub fn clear(&mut self) { - self.nodes.clear(); - let root = QTNode { - boundary: self.root_boundary, - points: Vec::with_capacity(self.node_capacity), - capacity: self.node_capacity, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }; - self.nodes.push(root); + unsafe { self.nodes.set_len(1); } + let root = &mut self.nodes[0]; + root.inline_count = 0; + root.overflow_head = 0; + root.divided = false; + root.nw = 0; root.ne = 0; root.sw = 0; root.se = 0; + + // Reset overflow free list + self.overflow_free.clear(); + for i in (0..self.overflows.len()).rev() { + self.overflow_free.push(i); + } } /// Insert a point (entity) into the quadtree. @@ -107,16 +142,21 @@ impl Quadtree { return; } - if !self.nodes[node_idx].divided && - self.nodes[node_idx].points.len() < self.nodes[node_idx].capacity { - self.nodes[node_idx].points.push(point); + let node = &mut self.nodes[node_idx]; + let cap = node.capacity as usize; + + // Try inline storage first + let inline_n = node.inline_count as usize; + if !node.divided && inline_n < cap { + node.inline_points[inline_n] = point; + node.inline_count += 1; return; } - if !self.nodes[node_idx].divided { + if !node.divided { if depth >= self.max_depth { - // At max depth, just store it here - self.nodes[node_idx].points.push(point); + // At max depth — push to overflow + self.add_overflow(node_idx, point); return; } self.subdivide(node_idx); @@ -133,47 +173,30 @@ impl Quadtree { self.insert_into(se, point, depth + 1); } + fn add_overflow(&mut self, node_idx: usize, point: QTPoint) { + let entry_idx = if let Some(free) = self.overflow_free.pop() { + self.overflows[free] = OverflowEntry { next: 0, point }; + free + } else { + let idx = self.overflows.len(); + self.overflows.push(OverflowEntry { next: 0, point }); + idx + }; + let node = &mut self.nodes[node_idx]; + self.overflows[entry_idx].next = node.overflow_head; + node.overflow_head = entry_idx; + } + fn subdivide(&mut self, node_idx: usize) { let b = self.nodes[node_idx].boundary; let hw = b.w / 2.0; let hh = b.h / 2.0; let cap = self.nodes[node_idx].capacity; - let nw_idx = self.nodes.len(); - self.nodes.push(QTNode { - boundary: AABB { x: b.x, y: b.y, w: hw, h: hh }, - points: Vec::with_capacity(cap), - capacity: cap, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }); - - let ne_idx = self.nodes.len(); - self.nodes.push(QTNode { - boundary: AABB { x: b.x + hw, y: b.y, w: hw, h: hh }, - points: Vec::with_capacity(cap), - capacity: cap, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }); - - let sw_idx = self.nodes.len(); - self.nodes.push(QTNode { - boundary: AABB { x: b.x, y: b.y + hh, w: hw, h: hh }, - points: Vec::with_capacity(cap), - capacity: cap, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }); - - let se_idx = self.nodes.len(); - self.nodes.push(QTNode { - boundary: AABB { x: b.x + hw, y: b.y + hh, w: hw, h: hh }, - points: Vec::with_capacity(cap), - capacity: cap, - divided: false, - nw: 0, ne: 0, sw: 0, se: 0, - }); + let nw_idx = self.alloc_node(AABB { x: b.x, y: b.y, w: hw, h: hh }, cap); + let ne_idx = self.alloc_node(AABB { x: b.x + hw, y: b.y, w: hw, h: hh }, cap); + let sw_idx = self.alloc_node(AABB { x: b.x, y: b.y + hh, w: hw, h: hh }, cap); + let se_idx = self.alloc_node(AABB { x: b.x + hw, y: b.y + hh, w: hw, h: hh }, cap); self.nodes[node_idx].nw = nw_idx; self.nodes[node_idx].ne = ne_idx; @@ -181,59 +204,163 @@ impl Quadtree { self.nodes[node_idx].se = se_idx; self.nodes[node_idx].divided = true; - // Re-insert existing points into children - let existing: Vec = self.nodes[node_idx].points.drain(..).collect(); - for p in existing { - self.insert_into(nw_idx, p, 0); - self.insert_into(ne_idx, p, 0); - self.insert_into(sw_idx, p, 0); - self.insert_into(se_idx, p, 0); + // Move inline points into children + let inline_n = self.nodes[node_idx].inline_count as usize; + let inline_pts: [QTPoint; INLINE_CAP] = self.nodes[node_idx].inline_points; + self.nodes[node_idx].inline_count = 0; + + // Move overflow points + let mut overflow_idx = self.nodes[node_idx].overflow_head; + self.nodes[node_idx].overflow_head = 0; + + let depth = 0; // Re-insert at depth 0 from children + + for i in 0..inline_n { + let p = inline_pts[i]; + self.insert_into(nw_idx, p, depth); + self.insert_into(ne_idx, p, depth); + self.insert_into(sw_idx, p, depth); + self.insert_into(se_idx, p, depth); + } + + while overflow_idx != 0 { + let entry = &self.overflows[overflow_idx]; + let p = entry.point; + let next = entry.next; + // Free overflow entry + self.overflow_free.push(overflow_idx); + self.insert_into(nw_idx, p, depth); + self.insert_into(ne_idx, p, depth); + self.insert_into(sw_idx, p, depth); + self.insert_into(se_idx, p, depth); + overflow_idx = next; } } + fn alloc_node(&mut self, boundary: AABB, capacity: u8) -> usize { + let idx = self.nodes.len(); + let mut node = EMPTY_QTNODE; + node.boundary = boundary; + node.capacity = capacity; + self.nodes.push(node); + idx + } + + // ---- Query methods (output into pre-allocated scratch buffer) ---- + /// Query all entities within a rectangular region. - /// Returns entity IDs found in the area. - pub fn query_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Vec { + /// Appends entity IDs to `out` (does not clear it first — caller should clear). + pub fn query_rect(&self, x: f32, y: f32, w: f32, h: f32, out: &mut Vec) { let range = AABB { x, y, w, h }; - let mut found = Vec::new(); - self.query_node(0, &range, &mut found); - found + self.query_node(0, &range, out); } /// Query all entities near a point within a given radius. - /// Uses a bounding box approximation first, then exact distance check - /// should be done by the caller. - pub fn query(&self, x: f32, y: f32, radius: f32) -> Vec { - self.query_rect(x - radius, y - radius, radius * 2.0, radius * 2.0) + pub fn query(&self, x: f32, y: f32, radius: f32, out: &mut Vec) { + self.query_rect(x - radius, y - radius, radius * 2.0, radius * 2.0, out) } - fn query_node(&self, node_idx: usize, range: &AABB, found: &mut Vec) { - if node_idx >= self.nodes.len() { - return; - } - + /// Query all entities that could potentially collide with a given entity. + pub fn query_neighbors(&self, x: f32, y: f32, search_radius: f32, out: &mut Vec) { + self.query(x, y, search_radius, out) + } + + fn query_node(&self, node_idx: usize, range: &AABB, out: &mut Vec) { + if node_idx >= self.nodes.len() { return; } + let node = &self.nodes[node_idx]; - if !node.boundary.intersects(range) { - return; - } + if !node.boundary.intersects(range) { return; } - for p in &node.points { + // Inline points + for i in 0..node.inline_count as usize { + let p = &node.inline_points[i]; if range.contains(p.x, p.y) { - found.push(p.id); + out.push(p.id); + } + } + + // Overflow points + let mut oi = node.overflow_head; + while oi != 0 { + if oi < self.overflows.len() { + let p = &self.overflows[oi].point; + if range.contains(p.x, p.y) { + out.push(p.id); + } + oi = self.overflows[oi].next; + } else { + break; } } + // Recurse children if node.divided { - self.query_node(node.nw, range, found); - self.query_node(node.ne, range, found); - self.query_node(node.sw, range, found); - self.query_node(node.se, range, found); + self.query_node(node.nw, range, out); + self.query_node(node.ne, range, out); + self.query_node(node.sw, range, out); + self.query_node(node.se, range, out); } } +} - /// Query all entities that could potentially collide with a given entity. - /// Uses the entity's radius to define the search area. - pub fn query_neighbors(&self, x: f32, y: f32, search_radius: f32) -> Vec { - self.query(x, y, search_radius) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_and_query_single_point() { + let mut qt = Quadtree::new(0.0, 0.0, 800.0, 600.0, 8, 4); + qt.insert(42, 100.0, 200.0); + + let mut out = Vec::new(); + qt.query(100.0, 200.0, 10.0, &mut out); + assert!(out.contains(&42)); + + out.clear(); + qt.query(500.0, 500.0, 10.0, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn query_returns_multiple_points_in_area() { + let mut qt = Quadtree::new(0.0, 0.0, 400.0, 400.0, 8, 4); + qt.insert(1, 50.0, 50.0); + qt.insert(2, 60.0, 55.0); + qt.insert(3, 20.0, 20.0); + qt.insert(4, 300.0, 300.0); + + let mut out = Vec::new(); + qt.query(55.0, 52.0, 20.0, &mut out); + assert!(out.contains(&1)); + assert!(out.contains(&2)); + assert!(!out.contains(&3)); + assert!(!out.contains(&4)); + } + + #[test] + fn clear_resets_all() { + let mut qt = Quadtree::new(0.0, 0.0, 400.0, 400.0, 8, 4); + qt.insert(1, 100.0, 100.0); + let mut out = Vec::new(); + qt.query(100.0, 100.0, 5.0, &mut out); + assert!(!out.is_empty()); + + qt.clear(); + out.clear(); + qt.query(100.0, 100.0, 5.0, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn many_points_subdivide() { + let mut qt = Quadtree::new(0.0, 0.0, 1000.0, 1000.0, 8, 4); + for i in 0..100u32 { + let x = (i * 7) as f32 % 1000.0; + let y = (i * 13) as f32 % 1000.0; + qt.insert(i, x, y); + } + let mut out = Vec::new(); + qt.query_rect(0.0, 0.0, 1000.0, 1000.0, &mut out); + assert_eq!(out.len(), 100); } } diff --git a/src/relations.rs b/src/relations.rs index 5eb3e68..09f647d 100644 --- a/src/relations.rs +++ b/src/relations.rs @@ -3,21 +3,13 @@ //! Implements the merge (OR) and fracture (decompose) operations //! that form the core data-interaction model of the Liquid-State Engine. //! -//! Each node carries a bitmask representing its fundamental properties: -//! - Bit 0 (001): Red component -//! - Bit 1 (010): Green component -//! - Bit 2 (100): Blue component -//! - Bits 3+: Extended properties -//! -//! When two nodes collide: -//! - MERGE: new_mask = a.mask | b.mask (combines properties) -//! - FRACTURE: splits a composite node into its individual bit components +//! Zero-allocation design: merge_queue and query scratch buffer are +//! pre-allocated and cleared between frames without deallocation. use crate::ecs::World; use crate::quadtree::Quadtree; +use crate::{EVENT_MERGE, EVENT_FRACTURE}; -/// Color mapping from bitmask bits to RGBA values. -/// This defines the "rainbow spectrum" of fundamental elements. const BIT_COLORS: [(u8, u8, u8); 8] = [ (255, 60, 60), // Bit 0: Red (60, 255, 60), // Bit 1: Green @@ -31,63 +23,61 @@ const BIT_COLORS: [(u8, u8, u8); 8] = [ /// The Relational Logic system. pub struct RelationSystem { - /// Minimum distance ratio for collision (relative to combined radii). pub collision_threshold: f32, - /// Merge queue: pairs of entities to merge this frame. + /// Pre-allocated merge queue — cleared each frame, never deallocated. merge_queue: Vec<(usize, usize)>, - /// Search radius multiplier for neighbor queries. pub search_radius_mul: f32, + /// Scratch buffer for quadtree queries (reused each frame). + query_scratch: Vec, } impl RelationSystem { pub fn new() -> Self { RelationSystem { collision_threshold: 0.8, - merge_queue: Vec::with_capacity(256), + merge_queue: Vec::with_capacity(512), search_radius_mul: 2.5, + query_scratch: Vec::with_capacity(256), } } /// Process collisions and execute merge logic. - /// Called once per frame after quadtree is built. - pub fn process(&mut self, world: &mut World, quadtree: &Quadtree) { + /// Uses alive_list iteration and reusable scratch buffer. + pub fn process(&mut self, world: &mut World, quadtree: &Quadtree, events: &mut Vec) { self.merge_queue.clear(); - // Detect collisions via quadtree - for i in 0..world.max_entities { - if !world.alive[i] { - continue; - } + // Snapshot alive IDs to release immutable borrow + let alive: Vec = world.alive_iter().to_vec(); + + for &id_u32 in &alive { + let i = id_u32 as usize; let x = world.pos_x[i]; let y = world.pos_y[i]; let r = world.radius[i]; let search_r = r * self.search_radius_mul; - let neighbors = quadtree.query_neighbors(x, y, search_r); + self.query_scratch.clear(); + quadtree.query_neighbors(x, y, search_r, &mut self.query_scratch); - for &neighbor_id in &neighbors { + for &neighbor_id in &self.query_scratch { let j = neighbor_id as usize; if j <= i || !world.alive[j] { - continue; // Skip self, dead, and already-checked pairs + continue; } - // Distance check let dx = world.pos_x[j] - x; let dy = world.pos_y[j] - y; let dist_sq = dx * dx + dy * dy; let combined_r = (r + world.radius[j]) * self.collision_threshold; if dist_sq < combined_r * combined_r { - // Collision detected! Check if they can merge. let mask_a = world.bitmask[i]; let mask_b = world.bitmask[j]; - // Only merge if they have different properties to combine if mask_a != mask_b && (mask_a & mask_b) != mask_a && (mask_a & mask_b) != mask_b { self.merge_queue.push((i, j)); } else { - // Same type - elastic repulsion let dist = dist_sq.sqrt().max(0.1); let overlap = combined_r / self.collision_threshold - dist; let nx = dx / dist; @@ -103,46 +93,52 @@ impl RelationSystem { } } - // Execute merges - for &(a, b) in &self.merge_queue.clone() { + // Execute merges (clone to avoid borrowing issues since merge modifies world) + let merges = std::mem::take(&mut self.merge_queue); + for &(a, b) in &merges { if !world.alive[a] || !world.alive[b] { - continue; // May have been consumed by earlier merge + continue; } - self.merge(world, a, b); + self.merge(world, a, b, events); } + // Put merge_queue back for reuse + self.merge_queue = merges; + self.merge_queue.clear(); } - /// Merge two entities: combine bitmasks with OR, create new entity, remove originals. - fn merge(&self, world: &mut World, a: usize, b: usize) { + fn merge(&self, world: &mut World, a: usize, b: usize, events: &mut Vec) { let new_mask = world.bitmask[a] | world.bitmask[b]; - // New position: center of mass let total_mass = world.mass[a] + world.mass[b]; let new_x = (world.pos_x[a] * world.mass[a] + world.pos_x[b] * world.mass[b]) / total_mass; let new_y = (world.pos_y[a] * world.mass[a] + world.pos_y[b] * world.mass[b]) / total_mass; - // New velocity: conservation of momentum let new_vx = (world.vel_x[a] * world.mass[a] + world.vel_x[b] * world.mass[b]) / total_mass; let new_vy = (world.vel_y[a] * world.mass[a] + world.vel_y[b] * world.mass[b]) / total_mass; - // New color: derived from combined bitmask let (r, g, b_color) = Self::color_from_bitmask(new_mask); - // New radius: area-preserving merge let area = std::f32::consts::PI * (world.radius[a].powi(2) + world.radius[b].powi(2)); let new_radius = (area / std::f32::consts::PI).sqrt(); - // Remove originals + let id_a = a as u32; + let id_b = b as u32; + world.despawn(a); world.despawn(b); - // Spawn merged entity - world.spawn(new_x, new_y, new_vx, new_vy, r, g, b_color, 230, new_mask, new_radius); + let new_id = world.spawn(new_x, new_y, new_vx, new_vy, r, g, b_color, 230, new_mask, new_radius); + let new_id_u32 = new_id as u32; + + events.push(EVENT_MERGE); + events.push(2); + events.push(1); + events.push(id_a); + events.push(id_b); + events.push(new_id_u32); } - /// Fracture an entity into its individual bit components. - /// Each set bit becomes a new separate node. - pub fn fracture(&self, world: &mut World, id: usize) { + pub fn fracture(&self, world: &mut World, id: usize, events: &mut Vec) { if !world.alive[id] || id >= world.max_entities { return; } @@ -150,24 +146,28 @@ impl RelationSystem { let mask = world.bitmask[id]; let bit_count = mask.count_ones(); if bit_count <= 1 { - return; // Cannot fracture a fundamental element + return; } let cx = world.pos_x[id]; let cy = world.pos_y[id]; let original_radius = world.radius[id]; + let original_id = id as u32; - // Calculate child radius (area-preserving split) let child_radius = original_radius / (bit_count as f32).sqrt(); - // Remove the original world.despawn(id); - // Spawn individual components in a circular pattern let angle_step = std::f32::consts::TAU / bit_count as f32; - let eject_speed = 80.0; // Ejection velocity + let eject_speed = 80.0; let mut angle = 0.0f32; + let event_start = events.len(); + events.push(EVENT_FRACTURE); + events.push(1); + events.push(0); + events.push(original_id); + for bit in 0..32u32 { if mask & (1 << bit) != 0 { let child_mask = 1u32 << bit; @@ -178,24 +178,23 @@ impl RelationSystem { let vx = angle.cos() * eject_speed; let vy = angle.sin() * eject_speed; - world.spawn( - cx + offset_x, - cy + offset_y, - vx, vy, - r, g, b, 255, - child_mask, - child_radius, + let child_id = world.spawn( + cx + offset_x, cy + offset_y, vx, vy, + r, g, b, 255, child_mask, child_radius, ); + events.push(child_id as u32); angle += angle_step; } } + + let produced = (events.len() - event_start - 4) as u32; + events[event_start + 2] = produced; } - /// Derive a display color from a bitmask by blending component colors. fn color_from_bitmask(mask: u32) -> (u8, u8, u8) { if mask == 0 { - return (128, 128, 128); // Neutral gray + return (128, 128, 128); } let mut r_sum: u32 = 0; @@ -213,7 +212,6 @@ impl RelationSystem { } } - // Handle bits beyond our color table let extra_bits = (mask >> 8).count_ones(); if extra_bits > 0 { r_sum += 200 * extra_bits; @@ -233,3 +231,98 @@ impl RelationSystem { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_from_single_bit() { + let (r, g, b) = RelationSystem::color_from_bitmask(0b001); + assert_eq!((r, g, b), (255, 60, 60)); + + let (r, g, b) = RelationSystem::color_from_bitmask(0b010); + assert_eq!((r, g, b), (60, 255, 60)); + + let (r, g, b) = RelationSystem::color_from_bitmask(0b100); + assert_eq!((r, g, b), (60, 100, 255)); + } + + #[test] + fn color_from_zero_mask_is_gray() { + let (r, g, b) = RelationSystem::color_from_bitmask(0); + assert_eq!((r, g, b), (128, 128, 128)); + } + + #[test] + fn merge_produces_correct_bitmask() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + let a_id = world.spawn(0.0, 0.0, 0.0, 0.0, 255, 0, 0, 255, 0b001, 10.0); + let b_id = world.spawn(5.0, 0.0, 0.0, 0.0, 0, 255, 0, 255, 0b010, 10.0); + let count_before = world.active_count(); + + sys.merge(&mut world, a_id, b_id, &mut events); + + assert_eq!(world.active_count(), count_before - 1); + assert!(events.len() >= 3); + assert_eq!(events[0], EVENT_MERGE); + assert_eq!(events[1], 2); + assert_eq!(events[2], 1); + + let mut found = false; + for i in 0..world.max_entities { + if world.alive[i] && world.bitmask[i] == 0b011 { + found = true; + break; + } + } + assert!(found, "Merged node with bitmask 0b011 should exist"); + } + + #[test] + fn fracture_produces_individual_bits() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + let id = world.spawn(100.0, 100.0, 0.0, 0.0, 128, 128, 128, 255, 0b0111, 20.0); + let count_before = world.active_count(); + + sys.fracture(&mut world, id, &mut events); + + assert_eq!(world.active_count(), count_before + 2); + assert!(events.len() > 4); + assert_eq!(events[0], EVENT_FRACTURE); + assert_eq!(events[1], 1); + assert_eq!(events[2], 3); + + let mut child_masks = Vec::new(); + for i in 0..world.max_entities { + if world.alive[i] { + child_masks.push(world.bitmask[i]); + } + } + assert_eq!(child_masks.len(), 3); + for mask in &child_masks { + assert_eq!(mask.count_ones(), 1, "Each child should have exactly 1 bit"); + } + let combined: u32 = child_masks.iter().fold(0, |acc, m| acc | m); + assert_eq!(combined, 0b0111); + } + + #[test] + fn fracture_single_bit_does_nothing() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 255, 0, 0, 255, 0b0100, 8.0); + let count_before = world.active_count(); + sys.fracture(&mut world, id, &mut events); + assert_eq!(world.active_count(), count_before); + assert!(world.alive[id]); + } +} diff --git a/src/renderer.rs b/src/renderer.rs index cca66b4..eb33e9a 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,14 +1,15 @@ //! Software Renderer - CPU-based pixel buffer rendering. //! -//! Writes RGBA pixel data directly into a flat buffer that JavaScript -//! reads and puts onto the Canvas via `putImageData()`. -//! -//! Uses a "Dirty Rectangles" strategy: only the region that changed -//! since last frame is redrawn, dramatically reducing CPU work for -//! mostly-static scenes. +//! Uses SIMD128 intrinsics when available for 4x pixel throughput. +//! Supports double-buffering for streaming frame delivery without stalls. +//! Implements LOD culling: sub-pixel nodes skip expensive circle rasterization. use crate::ecs::World; +// ---- SIMD imports (wasm32 only) ---- +#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] +use core::arch::wasm32::*; + /// Dirty rectangle tracking: the bounding box of all changes this frame. #[derive(Clone, Copy)] struct DirtyRect { @@ -44,10 +45,17 @@ impl DirtyRect { } } +// ---- LOD constants ---- +const MIN_CIRCLE_RADIUS: f32 = 2.0; // Below this, draw single pixel +const MIN_VISIBLE_RADIUS: f32 = 0.8; // Below this, skip entirely if stationary +const MIN_MOVE_SQ: f32 = 0.25; // Movement threshold for sub-pixel nodes + /// The Software Renderer that manages the pixel buffer. pub struct SoftwareRenderer { - /// The RGBA pixel buffer (width * height * 4 bytes). - buffer: Vec, + /// Front buffer — stable for JS read (always the one returned by buffer_ptr). + buffer_front: Vec, + /// Back buffer — written by render(), swapped after frame complete. + buffer_back: Vec, width: u32, height: u32, /// Current frame's dirty rectangle. @@ -57,6 +65,12 @@ pub struct SoftwareRenderer { /// Previous frame positions for dirty tracking. prev_pos_x: Vec, prev_pos_y: Vec, + /// Viewport for frustum culling. + pub viewport_x: f32, + pub viewport_y: f32, + pub viewport_scale: f32, + /// Y-sorted entity indices for batch draw (cache-friendly scanline order). + draw_order: Vec, } impl SoftwareRenderer { @@ -64,20 +78,37 @@ impl SoftwareRenderer { pub fn new(width: u32, height: u32) -> Self { let buf_size = (width * height * 4) as usize; SoftwareRenderer { - buffer: vec![0u8; buf_size], + buffer_front: vec![0u8; buf_size], + buffer_back: vec![0u8; buf_size], width, height, dirty: DirtyRect::new(), dirty_rect_export: [0, 0, 0, 0], prev_pos_x: Vec::new(), prev_pos_y: Vec::new(), + viewport_x: 0.0, + viewport_y: 0.0, + viewport_scale: 1.0, + draw_order: Vec::new(), } } + /// Set viewport for frustum culling. + pub fn set_viewport(&mut self, x: f32, y: f32, scale: f32) { + self.viewport_x = x; + self.viewport_y = y; + self.viewport_scale = scale; + } + + /// Swap front and back buffers — call after frame is complete and before + /// JS reads the pixel buffer. + pub fn swap_buffers(&mut self) { + std::mem::swap(&mut self.buffer_front, &mut self.buffer_back); + } + /// Render all alive entities to the pixel buffer. - /// Uses dirty rectangles to minimize work. + /// Uses dirty rectangles, LOD culling, and SIMD where available. pub fn render(&mut self, world: &World) { - // Reset dirty rect for this frame self.dirty = DirtyRect::new(); // Ensure prev_pos arrays are sized correctly @@ -86,27 +117,46 @@ impl SoftwareRenderer { self.prev_pos_y.resize(world.max_entities, -1.0); } - // Step 1: Mark previous positions as dirty (need clearing) + let scale = self.viewport_scale; + let vx = self.viewport_x; + let vy = self.viewport_y; + let vw = self.width as f32 / scale; + let vh = self.height as f32 / scale; + + // Step 1: Mark previous positions as dirty for i in 0..world.max_entities { if self.prev_pos_x[i] >= 0.0 { - let px = self.prev_pos_x[i] as u32; - let py = self.prev_pos_y[i] as u32; - let r = (world.radius[i] as u32).max(1) + 1; - self.dirty.expand(px, py, r); + // Cull check: only mark dirty if in viewport + let px = self.prev_pos_x[i]; + let py = self.prev_pos_y[i]; + if px + 100.0 > vx && px - 100.0 < vx + vw + && py + 100.0 > vy && py - 100.0 < vy + vh + { + let sx = ((px - vx) * scale) as u32; + let sy = ((py - vy) * scale) as u32; + let r = (world.radius[i] as u32).max(1) + 1; + self.dirty.expand(sx, sy, r); + } } } - // Step 2: Mark current alive positions as dirty + // Step 2: Mark current alive positions as dirty (viewport culled) for i in 0..world.max_entities { if world.alive[i] { - let px = world.pos_x[i] as u32; - let py = world.pos_y[i] as u32; - let r = (world.radius[i] as u32).max(1) + 1; - self.dirty.expand(px, py, r); + let px = world.pos_x[i]; + let py = world.pos_y[i]; + if px + 100.0 > vx && px - 100.0 < vx + vw + && py + 100.0 > vy && py - 100.0 < vy + vh + { + let sx = ((px - vx) * scale) as u32; + let sy = ((py - vy) * scale) as u32; + let r = ((world.radius[i] * scale) as u32).max(1) + 1; + self.dirty.expand(sx, sy, r); + } } } - // Step 3: Clear dirty region to background color (dark) + // Step 3: Clear dirty region to background color (SIMD or scalar) if self.dirty.active { self.clear_region( self.dirty.min_x, @@ -116,28 +166,86 @@ impl SoftwareRenderer { ); } - // Step 4: Draw all alive entities (filled circles) + // Step 4: Build Y-sorted draw order (cache-friendly scanline order) + // (draw_order was taken by previous frame's iteration, so it's already clear) for i in 0..world.max_entities { if world.alive[i] { + // Viewport frustum cull + let px = world.pos_x[i]; + let py = world.pos_y[i]; + let r = world.radius[i]; + let scaled_r = r * scale; + let sx = (px - vx) * scale; + let sy = (py - vy) * scale; + + if sx + scaled_r < 0.0 || sx - scaled_r > self.width as f32 + || sy + scaled_r < 0.0 || sy - scaled_r > self.height as f32 + { + self.prev_pos_x[i] = world.pos_x[i]; + self.prev_pos_y[i] = world.pos_y[i]; + continue; // Entirely off-screen + } + + // LOD: stationary sub-pixel nodes are invisible + let dx = world.pos_x[i] - self.prev_pos_x[i]; + let dy = world.pos_y[i] - self.prev_pos_y[i]; + let moved_sq = dx * dx + dy * dy; + if scaled_r < MIN_VISIBLE_RADIUS && moved_sq < MIN_MOVE_SQ { + self.prev_pos_x[i] = world.pos_x[i]; + self.prev_pos_y[i] = world.pos_y[i]; + continue; // Too small and stationary, skip + } + + self.draw_order.push(i as u32); + } + } + + // Sort by Y coordinate for cache-friendly scanline access + self.draw_order.sort_by(|&a, &b| { + let ya = world.pos_y[a as usize]; + let yb = world.pos_y[b as usize]; + ya.partial_cmp(&yb).unwrap_or(std::cmp::Ordering::Equal) + }); + + // Step 5: Batch-draw all entities in Y-sorted order + // Take the draw order to release the immutable borrow + let order = std::mem::take(&mut self.draw_order); + for &idx in &order { + let i = idx as usize; + let px = world.pos_x[i]; + let py = world.pos_y[i]; + let r = world.radius[i]; + let sx = (px - vx) * scale; + let sy = (py - vy) * scale; + let scaled_r = r * scale; + + // LOD: use single-pixel fast path for tiny nodes + if scaled_r < MIN_CIRCLE_RADIUS { + self.draw_single_pixel(sx, sy, world.color_r[i], world.color_g[i], world.color_b[i], world.color_a[i]); + } else { self.draw_circle( - world.pos_x[i], - world.pos_y[i], - world.radius[i], - world.color_r[i], - world.color_g[i], - world.color_b[i], - world.color_a[i], + sx, sy, scaled_r, + world.color_r[i], world.color_g[i], + world.color_b[i], world.color_a[i], ); - // Update previous positions - self.prev_pos_x[i] = world.pos_x[i]; - self.prev_pos_y[i] = world.pos_y[i]; - } else { + } + self.prev_pos_x[i] = world.pos_x[i]; + self.prev_pos_y[i] = world.pos_y[i]; + } + + // Put the draw_order back for reuse next frame + self.draw_order = order; + self.draw_order.clear(); + + // Mark dead entities as cleared + for i in 0..world.max_entities { + if !world.alive[i] { self.prev_pos_x[i] = -1.0; self.prev_pos_y[i] = -1.0; } } - // Step 5: Export dirty rect for JS + // Step 6: Export dirty rect for JS if self.dirty.active { let clamped_min_x = self.dirty.min_x.min(self.width); let clamped_min_y = self.dirty.min_y.min(self.height); @@ -154,7 +262,28 @@ impl SoftwareRenderer { } } - /// Clear a rectangular region to the background color (near-black with slight blue tint). + /// Draw a single pixel (for LOD-culled distant nodes). + fn draw_single_pixel(&mut self, x: f32, y: f32, r: u8, g: u8, b: u8, a: u8) { + let px = x as i32; + let py = y as i32; + if px >= 0 && px < self.width as i32 && py >= 0 && py < self.height as i32 { + let idx = ((py as u32 * self.width + px as u32) * 4) as usize; + if a == 255 { + self.buffer_back[idx] = r; + self.buffer_back[idx + 1] = g; + self.buffer_back[idx + 2] = b; + } else { + let alpha = a as f32 / 255.0; + self.buffer_back[idx] = (r as f32 * alpha + self.buffer_back[idx] as f32 * (1.0 - alpha)) as u8; + self.buffer_back[idx + 1] = (g as f32 * alpha + self.buffer_back[idx + 1] as f32 * (1.0 - alpha)) as u8; + self.buffer_back[idx + 2] = (b as f32 * alpha + self.buffer_back[idx + 2] as f32 * (1.0 - alpha)) as u8; + } + self.buffer_back[idx + 3] = 255; + } + } + + /// Clear a rectangular region to the background color. + /// Uses SIMD 128-bit writes when available (16 bytes / 4 pixels per iteration). fn clear_region(&mut self, min_x: u32, min_y: u32, max_x: u32, max_y: u32) { let bg_r: u8 = 10; let bg_g: u8 = 10; @@ -166,92 +295,269 @@ impl SoftwareRenderer { let y_start = min_y.min(self.height); let y_end = max_y.min(self.height); - for y in y_start..y_end { - for x in x_start..x_end { - let idx = ((y * self.width + x) * 4) as usize; - if idx + 3 < self.buffer.len() { - self.buffer[idx] = bg_r; - self.buffer[idx + 1] = bg_g; - self.buffer[idx + 2] = bg_b; - self.buffer[idx + 3] = bg_a; + let line_width = (x_end.saturating_sub(x_start)) as usize; + if line_width == 0 { return; } + + #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + { + let bg_pixel = u8x16(bg_r, bg_g, bg_b, bg_a, bg_r, bg_g, bg_b, bg_a, + bg_r, bg_g, bg_b, bg_a, bg_r, bg_g, bg_b, bg_a); + + for y in y_start..y_end { + let row_start = ((y * self.width + x_start) * 4) as usize; + let row_end = row_start + line_width * 4; + let mut p = row_start; + + // SIMD: write 16 bytes (4 pixels) per iteration + while p + 16 <= row_end && p + 16 <= self.buffer_back.len() { + unsafe { + v128_store(self.buffer_back.as_mut_ptr().add(p) as *mut v128, bg_pixel); + } + p += 16; + } + // Scalar tail + while p < row_end && p + 3 < self.buffer_back.len() { + self.buffer_back[p] = bg_r; + self.buffer_back[p + 1] = bg_g; + self.buffer_back[p + 2] = bg_b; + self.buffer_back[p + 3] = bg_a; + p += 4; + } + } + return; + } + + // Scalar fallback + #[cfg(not(all(target_arch = "wasm32", target_feature = "simd128")))] + { + for y in y_start..y_end { + let row_start = ((y * self.width + x_start) * 4) as usize; + let row_end = row_start + line_width * 4; + let mut p = row_start; + // Write 4 pixels at a time (still better than 1) + while p + 16 <= row_end && p + 16 <= self.buffer_back.len() { + self.buffer_back[p] = bg_r; + self.buffer_back[p + 1] = bg_g; + self.buffer_back[p + 2] = bg_b; + self.buffer_back[p + 3] = bg_a; + self.buffer_back[p + 4] = bg_r; + self.buffer_back[p + 5] = bg_g; + self.buffer_back[p + 6] = bg_b; + self.buffer_back[p + 7] = bg_a; + self.buffer_back[p + 8] = bg_r; + self.buffer_back[p + 9] = bg_g; + self.buffer_back[p + 10] = bg_b; + self.buffer_back[p + 11] = bg_a; + self.buffer_back[p + 12] = bg_r; + self.buffer_back[p + 13] = bg_g; + self.buffer_back[p + 14] = bg_b; + self.buffer_back[p + 15] = bg_a; + p += 16; + } + // Tail + while p < row_end && p + 3 < self.buffer_back.len() { + self.buffer_back[p] = bg_r; + self.buffer_back[p + 1] = bg_g; + self.buffer_back[p + 2] = bg_b; + self.buffer_back[p + 3] = bg_a; + p += 4; } } } } - /// Draw a filled circle using the midpoint circle algorithm variant. - /// Supports alpha blending for glassmorphism-like transparency. + /// Draw a filled circle. SIMD-accelerated on wasm32 with simd128. fn draw_circle(&mut self, cx: f32, cy: f32, radius: f32, r: u8, g: u8, b: u8, a: u8) { let cx_i = cx as i32; let cy_i = cy as i32; let rad_i = radius as i32; + if rad_i <= 0 { return; } - let alpha = a as f32 / 255.0; - let inv_alpha = 1.0 - alpha; + let opaque = a == 255; for dy in -rad_i..=rad_i { - // Horizontal span at this scanline let dx_max_sq = rad_i * rad_i - dy * dy; - if dx_max_sq < 0 { - continue; - } + if dx_max_sq < 0 { continue; } let dx_max = (dx_max_sq as f32).sqrt() as i32; let py = cy_i + dy; - if py < 0 || py >= self.height as i32 { - continue; + if py < 0 || py >= self.height as i32 { continue; } + + let x_start = (cx_i - dx_max).max(0) as usize; + let x_end = ((cx_i + dx_max).min(self.width as i32 - 1)) as usize; + if x_start > x_end { continue; } + + let row_base = py as u32 as usize * self.width as usize; + + if opaque { + self.draw_circle_span_opaque(row_base, x_start, x_end, r, g, b); + } else { + self.draw_circle_span_alpha(row_base, x_start, x_end, r, g, b, a); } + } + } - let x_start = (cx_i - dx_max).max(0); - let x_end = (cx_i + dx_max).min(self.width as i32 - 1); - - for px in x_start..=x_end { - let idx = ((py as u32 * self.width + px as u32) * 4) as usize; - if idx + 3 < self.buffer.len() { - if a == 255 { - // Opaque - fast path - self.buffer[idx] = r; - self.buffer[idx + 1] = g; - self.buffer[idx + 2] = b; - self.buffer[idx + 3] = 255; - } else { - // Alpha blend - let dst_r = self.buffer[idx] as f32; - let dst_g = self.buffer[idx + 1] as f32; - let dst_b = self.buffer[idx + 2] as f32; - - self.buffer[idx] = (r as f32 * alpha + dst_r * inv_alpha) as u8; - self.buffer[idx + 1] = (g as f32 * alpha + dst_g * inv_alpha) as u8; - self.buffer[idx + 2] = (b as f32 * alpha + dst_b * inv_alpha) as u8; - self.buffer[idx + 3] = 255; - } - } + #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + fn draw_circle_span_opaque(&mut self, row_base: usize, x_start: usize, x_end: usize, r: u8, g: u8, b: u8) { + let pixel = u8x16(r, g, b, 255, r, g, b, 255, r, g, b, 255, r, g, b, 255); + let mut px = x_start; + let buf = &mut self.buffer_back; + while px + 4 <= x_end { + let idx = (row_base + px) * 4; + if idx + 16 <= buf.len() { + unsafe { v128_store(buf.as_mut_ptr().add(idx) as *mut v128, pixel); } } + px += 4; + } + // Scalar tail + while px <= x_end { + let idx = (row_base + px) * 4; + if idx + 3 < buf.len() { + buf[idx] = r; + buf[idx + 1] = g; + buf[idx + 2] = b; + buf[idx + 3] = 255; + } + px += 1; + } + } + + #[cfg(not(all(target_arch = "wasm32", target_feature = "simd128")))] + fn draw_circle_span_opaque(&mut self, row_base: usize, x_start: usize, x_end: usize, r: u8, g: u8, b: u8) { + let mut px = x_start; + while px + 4 <= x_end { + let idx = (row_base + px) * 4; + let buf = &mut self.buffer_back; + if idx + 16 <= buf.len() { + buf[idx] = r; buf[idx+1] = g; buf[idx+2] = b; buf[idx+3] = 255; + buf[idx+4] = r; buf[idx+5] = g; buf[idx+6] = b; buf[idx+7] = 255; + buf[idx+8] = r; buf[idx+9] = g; buf[idx+10] = b; buf[idx+11] = 255; + buf[idx+12] = r; buf[idx+13] = g; buf[idx+14] = b; buf[idx+15] = 255; + } + px += 4; + } + while px <= x_end { + let idx = (row_base + px) * 4; + let buf = &mut self.buffer_back; + if idx + 3 < buf.len() { + buf[idx] = r; buf[idx+1] = g; buf[idx+2] = b; buf[idx+3] = 255; + } + px += 1; } } - /// Get a raw pointer to the pixel buffer (for Wasm memory export to JS). + /// Alpha-blended span. Uses integer fixed-point blending: + /// result = (src * alpha + dst * (255 - alpha) + 128) / 255 + /// Grouped into chunks of 4 pixels for throughput. + #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + fn draw_circle_span_alpha(&mut self, row_base: usize, x_start: usize, x_end: usize, r: u8, g: u8, b: u8, a: u8) { + let mut px = x_start; + let buf = &mut self.buffer_back; + // Process 4 pixels at a time with integer math + while px + 4 <= x_end { + let idx = (row_base + px) * 4; + if idx + 16 > buf.len() { break; } + scalar_alpha_pixel(buf, idx, r, g, b, a); + scalar_alpha_pixel(buf, idx + 4, r, g, b, a); + scalar_alpha_pixel(buf, idx + 8, r, g, b, a); + scalar_alpha_pixel(buf, idx + 12, r, g, b, a); + px += 4; + } + while px <= x_end { + let idx = (row_base + px) * 4; + if idx + 3 < buf.len() { + scalar_alpha_pixel(buf, idx, r, g, b, a); + } + px += 1; + } + } + + #[cfg(not(all(target_arch = "wasm32", target_feature = "simd128")))] + fn draw_circle_span_alpha(&mut self, row_base: usize, x_start: usize, x_end: usize, r: u8, g: u8, b: u8, a: u8) { + let mut px = x_start; + let buf = &mut self.buffer_back; + let alpha = a as f32 / 255.0; + let inv_alpha = 1.0 - alpha; + while px <= x_end { + let idx = (row_base + px) * 4; + if idx + 3 < buf.len() { + buf[idx] = (r as f32 * alpha + buf[idx] as f32 * inv_alpha) as u8; + buf[idx + 1] = (g as f32 * alpha + buf[idx + 1] as f32 * inv_alpha) as u8; + buf[idx + 2] = (b as f32 * alpha + buf[idx + 2] as f32 * inv_alpha) as u8; + buf[idx + 3] = 255; + } + px += 1; + } + } + + // ---- Public API ---- + pub fn buffer_ptr(&self) -> *const u8 { - self.buffer.as_ptr() + self.buffer_front.as_ptr() } - /// Get the buffer length in bytes. pub fn buffer_len(&self) -> usize { - self.buffer.len() + self.buffer_front.len() } - /// Get a pointer to the dirty rectangle export data. pub fn dirty_rect_ptr(&self) -> *const u32 { self.dirty_rect_export.as_ptr() } - /// Check if there's a dirty region this frame. pub fn has_dirty_region(&self) -> bool { self.dirty.active } - /// Clear the dirty flag (called by JS after reading pixel data). pub fn clear_dirty(&mut self) { self.dirty.active = false; } } + +// ---- Helper: scalar alpha-blend one pixel ---- +#[allow(dead_code)] +fn scalar_alpha_pixel(buf: &mut [u8], idx: usize, r: u8, g: u8, b: u8, a: u8) { + let alpha = a as u32; + let inv = 255u32 - alpha; + let dr = buf[idx] as u32; + let dg = buf[idx + 1] as u32; + let db = buf[idx + 2] as u32; + buf[idx] = ((r as u32 * alpha + dr * inv + 128) / 255) as u8; + buf[idx + 1] = ((g as u32 * alpha + dg * inv + 128) / 255) as u8; + buf[idx + 2] = ((b as u32 * alpha + db * inv + 128) / 255) as u8; + buf[idx + 3] = 255; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecs::World; + + #[test] + fn new_renderer_has_correct_size() { + let r = SoftwareRenderer::new(100, 200); + assert_eq!(r.buffer_len(), 100 * 200 * 4); + } + + #[test] + fn double_buffer_swap() { + let mut r = SoftwareRenderer::new(4, 4); + let ptr_before = r.buffer_ptr(); + r.swap_buffers(); + let ptr_after = r.buffer_ptr(); + // Front and back are swapped: front pointer should change + // (the front buffer after swap is the old back buffer) + assert_ne!(ptr_before, ptr_after); + } + + #[test] + fn clear_dirty_resets() { + let mut r = SoftwareRenderer::new(10, 10); + // Render a world with one node to trigger dirty + let mut world = World::new(10); + world.spawn(5.0, 5.0, 0.0, 0.0, 255, 255, 255, 255, 1, 4.0); + r.render(&world); + assert!(r.has_dirty_region()); + r.clear_dirty(); + assert!(!r.has_dirty_region()); + } +} diff --git a/src/spatial_grid.rs b/src/spatial_grid.rs new file mode 100644 index 0000000..a4908bc --- /dev/null +++ b/src/spatial_grid.rs @@ -0,0 +1,149 @@ +//! Uniform Spatial Grid for dense scenes (>2000 nodes). +//! +//! Divides space into fixed-size cells. O(1) insertion via position hash. +//! Query checks the 3x3 neighborhood of cells around the target. +//! Each cell stores entity IDs inline (up to 16) with overflow for dense cells. +//! +//! Zero-allocation: cells and overflow are pre-allocated. clear() zeroes +//! counts without deallocation. + +/// Fixed cell size in world units (pixels). +const CELL_SIZE: f32 = 64.0; +/// Inline entity capacity per cell. +const CELL_INLINE: usize = 16; + +/// A single cell in the spatial grid. +struct Cell { + /// Inline entity ID storage. + ids: [u32; CELL_INLINE], + /// Number of entities in the inline array. + count: u8, + /// Head index into overflow pool (linked list). + overflow_head: u32, +} + +/// Overflow pool entry for cells exceeding CELL_INLINE. +struct OverflowEntry { + id: u32, + next: u32, +} + +/// Uniform spatial grid for O(1) spatial queries. +pub struct SpatialGrid { + cells: Vec, + cell_cols: u32, + cell_size: f32, + world_w: f32, + world_h: f32, + /// Pre-allocated overflow pool. + overflows: Vec, + overflow_free: Vec, +} + +impl SpatialGrid { + /// Create a new spatial grid covering the world bounds. + pub fn new(world_w: f32, world_h: f32) -> Self { + let cell_cols = (world_w / CELL_SIZE).ceil() as u32 + 1; + let cell_rows = (world_h / CELL_SIZE).ceil() as u32 + 1; + let num_cells = (cell_cols * cell_rows) as usize; + + let cells = (0..num_cells) + .map(|_| Cell { + ids: [0u32; CELL_INLINE], + count: 0, + overflow_head: 0, + }) + .collect(); + + SpatialGrid { + cells, + cell_cols, + cell_size: CELL_SIZE, + world_w, + world_h, + overflows: Vec::with_capacity(4096), + overflow_free: Vec::with_capacity(4096), + } + } + + /// Clear all cells for a new frame. O(num_cells) only touches active cells. + pub fn clear(&mut self) { + // Only reset cells that had entries (tracked via non-zero count) + for cell in &mut self.cells { + cell.count = 0; + cell.overflow_head = 0; + } + // Reset overflow free list + self.overflow_free.clear(); + for i in (0..self.overflows.len()).rev() { + self.overflow_free.push(i as u32); + } + } + + /// Insert an entity at the given position. + pub fn insert(&mut self, id: u32, x: f32, y: f32) { + let col = (x.max(0.0).min(self.world_w - 0.1) / self.cell_size) as u32; + let row = (y.max(0.0).min(self.world_h - 0.1) / self.cell_size) as u32; + let idx = (row * self.cell_cols + col) as usize; + + if idx >= self.cells.len() { + return; + } + + let cell = &mut self.cells[idx]; + + if (cell.count as usize) < CELL_INLINE { + cell.ids[cell.count as usize] = id; + cell.count += 1; + } else { + // Overflow + let entry_idx = if let Some(free) = self.overflow_free.pop() { + let ei = free as usize; + if ei < self.overflows.len() { + self.overflows[ei] = OverflowEntry { id, next: cell.overflow_head }; + } + ei as u32 + } else { + let ei = self.overflows.len(); + self.overflows.push(OverflowEntry { id, next: cell.overflow_head }); + ei as u32 + }; + cell.overflow_head = entry_idx; + } + } + + /// Query entities near a point. Checks the 3x3 neighborhood. + /// Appends results to `out`. + pub fn query(&self, x: f32, y: f32, radius: f32, out: &mut Vec) { + let min_col = ((x - radius).max(0.0) / self.cell_size) as u32; + let max_col = ((x + radius).min(self.world_w - 0.1) / self.cell_size) as u32; + let min_row = ((y - radius).max(0.0) / self.cell_size) as u32; + let max_row = ((y + radius).min(self.world_h - 0.1) / self.cell_size) as u32; + + for row in min_row..=max_row { + for col in min_col..=max_col { + let idx = (row * self.cell_cols + col) as usize; + if idx >= self.cells.len() { + continue; + } + + let cell = &self.cells[idx]; + // Inline IDs + for i in 0..cell.count as usize { + out.push(cell.ids[i]); + } + // Overflow IDs + let mut oi = cell.overflow_head; + while oi != 0 { + let eidx = oi as usize; + if eidx < self.overflows.len() { + out.push(self.overflows[eidx].id); + oi = self.overflows[eidx].next; + } else { + break; + } + } + } + } + } +} diff --git a/web/hud/app.js b/web/hud/app.js new file mode 100644 index 0000000..35acdbb --- /dev/null +++ b/web/hud/app.js @@ -0,0 +1,272 @@ +/** + * Liquid-State Engine — Glassmorphism HUD + * + * Built with Preact + htm via CDN (zero build step). + * Renders ABOVE the canvas, pointer-events: none by default. + */ + +import { h, Component, render } from 'https://esm.sh/preact@10.19.6'; +import htm from 'https://esm.sh/htm@3.1.1'; +const html = htm.bind(h); + +// ---- HUD Shell (glassmorphism container) ---- + +class HUDApp extends Component { + constructor() { + super(); + this.state = { + fps: 0, + nodeCount: 0, + dirtySize: '0x0', + engineStatus: 'loading...', + mode: 'select', // 'select' | 'draw' | 'fracture' + pickedNode: null, // { id, payload } or null + showPayloadDialog: false, + drawNodeId: null, + numericReducer: 'sum', + gravityEnabled: false, + viscosity: 0.02, + labelInput: '', + valueInput: '', + }; + + this._tick = this._tick.bind(this); + this._onPick = this._onPick.bind(this); + this._onDrawNode = this._onDrawNode.bind(this); + } + + componentDidMount() { + this._interval = setInterval(this._tick, 200); + window.addEventListener('lse-pick', this._onPick); + window.addEventListener('lse-drawnode', this._onDrawNode); + } + + componentWillUnmount() { + clearInterval(this._interval); + window.removeEventListener('lse-pick', this._onPick); + window.removeEventListener('lse-drawnode', this._onDrawNode); + } + + _tick() { + const fpsEl = document.getElementById('fps'); + const nodesEl = document.getElementById('nodes'); + const dirtyEl = document.getElementById('dirty'); + const statusEl = document.getElementById('status'); + this.setState({ + fps: fpsEl?.textContent || '0', + nodeCount: nodesEl?.textContent || '0', + dirtySize: dirtyEl?.textContent || '0x0', + engineStatus: statusEl?.textContent || 'ACTIVE', + }); + } + + _onPick(e) { + this.setState({ pickedNode: e.detail.payload ? e.detail : null }); + } + + _onDrawNode(e) { + this.setState({ showPayloadDialog: true, drawNodeId: e.detail.id, labelInput: '', valueInput: '' }); + } + + _setMode(mode) { + this.setState({ mode }); + if (window.lse) window.lse.setDrawMode(mode === 'draw'); + } + + _submitPayload() { + const { drawNodeId, labelInput, valueInput } = this.state; + if (drawNodeId == null) return; + const raw = valueInput.trim(); + let payload; + if (!raw) { + payload = { type: 'text', value: labelInput || 'empty', label: labelInput || 'untitled' }; + } else { + payload = window.lse?.getRules().detectPayload(raw); + payload.label = labelInput || payload.type; + } + window.lse?.getPayloads().register(drawNodeId, payload); + this.setState({ showPayloadDialog: false, drawNodeId: null, labelInput: '', valueInput: '' }); + } + + _setReducer(mode) { + this.setState({ numericReducer: mode }); + window.lse?.getRules().setNumericReducer(mode); + } + + _toggleGravity() { + const on = !this.state.gravityEnabled; + this.setState({ gravityEnabled: on }); + window.lse?.setGravity(on ? 200 : 0); + } + + _setViscosity(v) { + this.setState({ viscosity: v }); + window.lse?.setViscosity(v); + } + + render(_, state) { + return html` +
+ <${StatsPanel} fps=${state.fps} nodes=${state.nodeCount} dirty=${state.dirtySize} status=${state.engineStatus} /> + <${ModeSwitcher} mode=${state.mode} onSetMode=${m => this._setMode(m)} /> + <${InspectorPanel} node=${state.pickedNode} /> + <${Toolbar} + reducer=${state.numericReducer} + gravity=${state.gravityEnabled} + viscosity=${state.viscosity} + onSetReducer=${m => this._setReducer(m)} + onToggleGravity=${() => this._toggleGravity()} + onSetViscosity=${v => this._setViscosity(v)} + /> + ${state.showPayloadDialog && html` + <${PayloadDialog} + label=${state.labelInput} + value=${state.valueInput} + onLabel=${v => this.setState({ labelInput: v })} + onValue=${v => this.setState({ valueInput: v })} + onSubmit=${() => this._submitPayload()} + onCancel=${() => this.setState({ showPayloadDialog: false, drawNodeId: null })} + /> + `} +
+ `; + } +} + +// ---- Stats Panel (top-left) ---- + +const StatsPanel = ({ fps, nodes, dirty, status }) => html` +
+
FPS ${fps}
+
NODES ${nodes}
+
DIRTY ${dirty}
+
ENGINE ${status}
+
+`; + +// ---- Mode Switcher (top-right) ---- + +const modes = [ + { id: 'select', label: 'Select', icon: '⊙' }, + { id: 'draw', label: 'Draw', icon: '✎' }, + { id: 'fracture', label: 'Fracture', icon: '⟐' }, +]; + +const ModeSwitcher = ({ mode, onSetMode }) => html` +
+ ${modes.map(m => html` + + `)} +
+`; + +// ---- Inspector Panel (floating, shown when node picked) ---- + +const InspectorPanel = ({ node }) => html` + ${node ? html` +
+
Node #${node.id}
+
TYPE ${node.payload?.type || '—'}
+
LABEL ${node.payload?.label || '—'}
+
VALUE ${truncate(displayValue(node.payload), 60)}
+
+ ` : null} +`; + +function displayValue(payload) { + if (!payload) return '—'; + if (payload.type === 'json' || payload.type === 'composite') { + try { return JSON.stringify(payload.value); } catch { return String(payload.value); } + } + return String(payload.value ?? '—'); +} + +function truncate(s, max) { + return s && s.length > max ? s.slice(0, max) + '…' : s; +} + +// ---- Bottom Toolbar ---- + +const reducers = [ + { id: 'sum', label: 'Σ Sum' }, + { id: 'avg', label: 'μ Avg' }, + { id: 'product', label: 'Π Prod' }, +]; + +const Toolbar = ({ reducer, gravity, viscosity, onSetReducer, onToggleGravity, onSetViscosity }) => html` +
+
+ Numeric Merge +
+ ${reducers.map(r => html` + + `)} +
+
+
+ Gravity + +
+
+ Viscosity + onSetViscosity(parseFloat(e.target.value))} + /> + ${viscosity.toFixed(3)} +
+
+`; + +// ---- Payload Input Dialog ---- + +const PayloadDialog = ({ label, value, onLabel, onValue, onSubmit, onCancel }) => html` +
+
+
New Node Payload
+ onLabel(e.target.value)} + autofocus + /> +