A browser-runnable PS2 Emotion Engine proof-of-concept that renders a Gouraud-shaded spinning cube. The Rust/WASM core stubs out the EE CPU, VU1 vector unit, and the Graphics Synthesizer. A Next.js frontend hosts the canvas and drives a requestAnimationFrame loop with a telemetry HUD.
The PlayStation 2's rendering pipeline is famous for its unusual multi-chip architecture: the Emotion Engine (MIPS R5900) orchestrates geometry through the Vector Units (VU0/VU1), which micro-program the Graphics Synthesizer via DMA. This project models that logical pipeline in code you can actually run in a browser tab.
EE (MIPS R5900) VU1 micro-program GS (rasterizer)
┌──────────────┐ VIF1 ┌────────────────────┐ XGKICK ┌─────────────┐
│ Game logic │──DMA───▶│ Matrix transforms │────────▶│ Rasterise │
│ (stub) │ │ Gouraud lighting │ │ triangles │
└──────────────┘ └────────────────────┘ └─────────────┘
cpu.rs vu.rs gs.rs
Each component maps to a Rust module:
| PS2 Hardware | Code | Notes |
|---|---|---|
| Emotion Engine (MIPS R5900) | src/cpu.rs |
Cycle counter stub — 300k cycles/frame |
| VU1 micro-program | src/vu.rs |
Full transform + Gouraud lighting math |
| VIF1 DMA (EE → VU1 data memory) | CUBE_VERTS const |
Pre-loaded vertex data |
| GIF PATH1 (XGKICK → GS) | gs.render(&display_list) |
Direct call, no GIF tag parsing |
| Graphics Synthesizer (rasterizer) | src/gs.rs |
wgpu WebGL2 + passthrough WGSL shader |
| GS eDRAM framebuffer (640×448) | wgpu Surface |
WebGL2 renderbuffer |
| GS BGCOLOR register | LoadOp::Clear(0.03, 0.03, 0.08) |
Dark blue-black clear color |
| NTSC VBLANK interrupt | requestAnimationFrame |
Browser RAF loop |
emotion-cube/
├── .cargo/
│ └── config.toml # RUSTFLAGS: web_sys_unstable_apis + bulk-memory
├── Cargo.toml # wgpu 28, wasm-bindgen, bytemuck
├── src/
│ ├── lib.rs # EmulatorCore — wasm-bindgen public interface
│ ├── cpu.rs # EmotionEngine stub (MIPS cycle counter)
│ ├── vu.rs # VU1: Mat4, Vec4, CUBE_VERTS, execute_micro_program()
│ └── gs.rs # GraphicsSynthesizer: wgpu init, WGSL shader, render()
└── frontend/
├── package.json # Next.js 15 + React 19
├── next.config.ts # asyncWebAssembly webpack experiment
└── app/
├── layout.tsx
├── page.tsx
└── EmulatorCanvas.tsx # RAF loop, wasm init, telemetry HUD
execute_micro_program() runs every frame and mirrors what a real VU1 micro-program does:
- Build model matrix:
rotate_x(frame/360°) × rotate_y(frame/180°) - Build view matrix:
translate(0, 0, −3)(cube 3 units in front of camera) - Build projection matrix:
perspective(60°, 640/448, 0.1, 100)with [0,1] depth range (wgpu/WebGPU NDC — not OpenGL's [−1,1]) - Concatenate:
mvp = proj × view × model(5 matrix ops/frame) - Per-vertex: transform to clip space, transform normal by model only, compute
diffuse + 0.2 ambient, outputGsVertex
The PS2 GS is fixed-function — no shaders. Our WGSL shader is a pure passthrough (position and color straight through), which replicates fixed-function behavior for untextured Gouraud primitives:
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
return VertexOutput(in.position, in.color); // VU1 already did everything
}
@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
}The step_frame() call returns a JS object with:
- Emulated Cycles/Frame: always 300,000 (stub)
- VU1 Matrix Ops: cumulative count (5 per frame)
- Host FPS: measured via
performance.now()in the RAF loop
| Dimension | Accuracy |
|---|---|
| Visual output (this demo) | ~95% — same math, same pixels |
| VU1 transform + lighting math | High — equivalent float ops |
| PS2 software ABI / MIPS execution | 0% — no instruction emulation |
| Cycle accuracy | 0% — no timing model |
| Pipeline concurrency (EE ‖ VU1 ‖ GS) | 0% — fully sequential |
| GIF tag parsing / VIF packets | 0% — stubbed |
| IOP, SPU2, DMA controller | 0% — not implemented |
The logical pipeline (EE → VU1 → GS) is correctly modeled. The math is faithful. None of the actual hardware mechanics are cycle-accurate. Think of it as a PS2-inspired rendering demo rather than an emulator.
- Rust 1.87+ (
rustup target add wasm32-unknown-unknown) - Node.js 18+
- wasm-pack (
cargo install wasm-pack)
# 1. Build WASM (from repo root)
wasm-pack build --target web --out-dir frontend/public/pkg --release --no-opt
# 2. Install frontend deps & start dev server
cd frontend
npm install
npm run devOpen http://localhost:3000 (or whichever port Next.js picks).
cd frontend && npm run build && npm startNote:
npm run builduses--no-turbopack(thebuildscript inpackage.json) because Turbopack does not yet support theasyncWebAssemblywebpack experiment required to load the WASM module.
wasm-pack's bundled wasm-opt rejects memory.copy instructions unless the WASM module explicitly declares the bulk-memory feature in its feature section. Rust 1.87 emits these instructions unconditionally for large struct copies. The --no-opt flag skips wasm-opt entirely; the WASM is slightly larger (~2–3×) but functionally identical.
The WASM glue generated by wasm-pack uses new URL('emotion_cube_bg.wasm', import.meta.url) to locate the binary. If webpack bundles the JS glue, import.meta.url points to webpack's internal module ID rather than the public URL, breaking the fetch. The /* webpackIgnore: true */ comment tells webpack to leave the import alone so the browser resolves it natively against /pkg/emotion_cube.js.
wgpu's Device and Queue types are not Send in single-threaded WASM. Without this feature flag, the Rust compiler rejects storing them inside a #[wasm_bindgen] struct. The flag opts in to wgpu's "I know this is single-threaded WASM" unsafe impl.
wgpu (and WebGPU) use NDC depth range [0, 1] (DirectX convention), not OpenGL's [−1, 1]. The perspective matrix in vu.rs uses far * range_inv where range_inv = 1 / (near − far), matching wgpu's expectation.
MIT
