Introduction
three-native is a Zig runtime that hosts unmodified Three.js code inside a
tiny native application. The goal is to ship a small, fast executable without
Electron or Chromium while keeping the same codebase runnable in the browser.
This book captures the design intent for the runtime. It is forward-looking and will be updated as the implementation evolves.
Goals
- Run unmodified Three.js applications.
- Keep the native binary small and portable.
- Support macOS, Windows, and Linux with one codebase.
- Provide a thin, predictable browser API shim (only what Three.js needs).
- Keep hot paths allocation-free after initialization.
Non-Goals
- Full browser DOM compatibility.
- Node.js compatibility.
- 100 percent WebGL feature coverage.
- WebXR (not in early milestones).
- Hot reload (nice to have, not critical).
Constraints and Assumptions
- JavaScript engine is
mquickjs(ES5 subset). - Initial graphics backend is
sokol(wgpu-native is a possible later swap). - Main loop targets 60 FPS (16.6 ms frame budget).
- Shader compilation and image decode may be moved off-thread later.
- Design is driven by current Three.js usage patterns.
Design Principles (Tiger Style)
- Napkin math before implementation.
- Data structures match access patterns.
- Static allocation at startup, explicit lifetimes.
- Bounded loops and explicit control flow.
Milestones and near-term scope are tracked in KICKSTART.md.
Architecture
At a high level, three-native embeds a JavaScript runtime and a minimal
browser API shim on top of a native rendering backend.
┌─────────────────────────────────┐
│ Your Game (Three.js) │
├─────────────────────────────────┤
│ Three.js (submodule) │
├─────────────────────────────────┤
│ Browser Shim │ WebGL, DOM, timing, input
├─────────────────────────────────┤
│ mquickjs │ JS runtime
├─────────────────────────────────┤
│ Native Backend (Zig) │ sokol or wgpu-native
└─────────────────────────────────┘
Core Components
- Host runtime (Zig): Owns the process lifecycle and platform integration.
- JS runtime:
mquickjswith a single global context. - Browser shim: Implements the small subset of browser APIs that Three.js expects, especially WebGL.
- Render backend: Translates WebGL-like calls into native GPU API calls.
- Platform layer: Window creation, input capture, timing, and event pumps.
Boot Sequence
- Initialize platform layer and create the primary window.
- Initialize the rendering backend and device state.
- Initialize the JS runtime and register host bindings.
- Load the Three.js bundle and user entry module.
- Create a canvas and WebGL context for the app.
- Enter the frame loop and start
requestAnimationFrame.
Frame Loop
Each frame follows a fixed, explicit order:
- Poll platform events (input, resize, focus).
- Dispatch events into the shim.
- Run one
requestAnimationFrametick in JS. - Translate WebGL calls into native backend commands.
- Present the frame.
The loop is single-threaded in the initial phase to keep behavior deterministic.
Runtime
The runtime embeds mquickjs as the JavaScript engine. A single JS context is
created and kept for the lifetime of the app. All shim APIs are exposed as
host bindings registered on startup.
The stdlib table is generated from mqjs_stdlib.c, while the actual host
functions (js_*) are implemented in Zig and exported to C. This keeps the
runtime glue in Zig without a C wrapper layer.
Execution Model
- One JS runtime and one primary context.
- The main thread owns JS execution.
requestAnimationFramedrives the app tick.- Host bindings are synchronous in the initial phase.
Module Loading
mquickjs targets ES5, so user code should be bundled (Rollup, esbuild, etc.)
into a single entry script. The runtime loads that entry file and evaluates it
inside the JS context.
Future work may add module resolution and source maps, but the baseline is a single bundled file to keep startup small and predictable.
Native Handles
Browser objects that map to native resources (textures, buffers, programs, framebuffers) are represented in JS as small wrapper objects with an internal numeric handle. The host stores the real objects in tables keyed by that handle.
Rules:
- Handle tables are fixed-size or pooled at init.
- Handles are reused only after explicit destruction.
- JS wrapper objects register finalizers that release native resources.
Error Handling
- JS exceptions propagate to the host as Zig errors.
- Zig errors thrown by host bindings are converted into JS exceptions.
- On fatal errors, the runtime surfaces a clear error message and exits.
Browser Shim
The shim provides just enough browser surface area for Three.js to run. It is not a general-purpose DOM implementation. The guiding rule is: implement what Three.js touches, no more.
API Surface (Initial)
WebGLRenderingContextandWebGL2RenderingContext(subset)HTMLCanvasElementwithgetContext, width, heightrequestAnimationFrameandcancelAnimationFrameperformance.now()Image,createImageBitmapfetch,FileReader,URL.createObjectURL,URL.revokeObjectURL- Input via
addEventListener(mouse, keyboard, wheel, resize) - Pointer lock and gamepad APIs (later milestones)
console.*for debugging
WebGL Mapping
WebGL calls are translated to native backend calls. The mapping is explicit:
- WebGL buffers map to native vertex/index buffers.
- WebGL textures map to native textures.
- WebGL programs map to native shader pipelines.
- WebGL state is tracked in a lightweight state cache to reduce redundant calls.
The shim does not aim for full WebGL conformance. It aims for correctness on the Three.js usage path.
Canvas and Document
- A single primary canvas is created at startup.
document.createElement("canvas")returns an offscreen canvas object with a separate WebGL context if needed by Three.js helpers.- DOM traversal and layout are intentionally absent.
Event Dispatch
Platform events are normalized and dispatched into the shim. The shim delivers synthetic browser-style event objects to JS listeners.
WebGL API Coverage
This spreadsheet tracks every gl.* method/constant referenced by Three.js and
the current shim coverage status.
- Spreadsheet:
webgl-api-coverage.csv - Source: unique
gl.identifiers fromdeps/three - Status legend:
implemented,partial,missing
Notes:
- The list includes WebGL2-only APIs used by Three.js. Those are marked
webgl2and can be deferred for the WebGL1 shim. - Sampler uniforms are tracked, but texture binding is still pending.
Platform and Rendering
The platform layer provides window management, input capture, timing, and the
native rendering backend. The first implementation is built around sokol.
Windowing
- Single primary window in early phases.
- Resizable window; resize events update the canvas size.
- High-DPI scaling is supported via platform-provided scale factor.
Rendering Backend
sokol_gfx is the initial backend because it provides:
- Small, single-header integration.
- Backends for Metal, D3D11, and OpenGL.
- Simple, explicit resource lifetimes.
The design keeps a clean boundary so that wgpu-native can be evaluated later.
Frame Lifecycle
- Begin frame and clear swapchain.
- Apply state updates from the WebGL shim.
- Submit draw calls.
- End frame and present.
All GPU resource creation and destruction happens on the main thread to keep ordering explicit and avoid cross-thread driver issues.
Assets and IO
Assets are loaded through browser-like APIs but resolved to native IO.
fetch
fetch resolves URLs relative to a configurable asset root. In the initial
phase, only local files are supported. Future phases may add HTTP support.
Guidelines:
- URL parsing is minimal; no redirects in phase 1.
- Responses expose
arrayBuffer()andtext()as needed by Three.js loaders. - Binary data stays in contiguous buffers owned by the host.
Images
ImageandcreateImageBitmapload common formats (PNG, JPEG).- Decode may be synchronous initially; later moved to background threads.
- Decoded pixel data is uploaded to GPU via the WebGL shim.
FileReader and Blobs
FileReadersupports the subset used for drag-and-drop assets.URL.createObjectURLreturns a short-lived token that maps to a host buffer.URL.revokeObjectURLreleases that buffer promptly.
Memory and Concurrency
This runtime follows Tiger Style rules: allocate early, bound the hot path, and keep control flow explicit.
Memory Strategy
Rules
- Allocate at startup; avoid malloc in the frame loop.
- Use fixed-size pools for hot objects (buffers, textures, programs).
- Use arenas for transient data (asset decode, loader intermediates).
- Keep ownership explicit and single-threaded in phase 1.
Handle Tables
Native GPU objects are referenced from JS via numeric handles. The host stores the real objects in fixed-size tables. Each table entry is either empty or live.
Initial caps are conservative for early milestones and can be raised later:
- Buffers: 4096
- Textures: 2048
- Programs: 1024
- Framebuffers: 512
- Renderbuffers: 512
- Vertex arrays: 1024
Handle tables are sized and allocated at init and never grow at runtime.
Allocation Map
- Long-lived: platform, renderer state, handle tables
- Per-frame: scratch buffers, transient state (arena reset each frame)
- Per-asset: decode buffers, loader state (freed after upload)
Concurrency Plan
Phase 1 is single-threaded for determinism. Future phases may add background threads for IO and decode.
Threads (Future)
- Main thread: JS execution, WebGL shim, GPU submission.
- IO thread: file reads and network fetch (if enabled).
- Decode thread: image decode and mesh processing.
Queues
When background work is added:
- SPSC ring buffers for worker-to-main notifications.
- MPSC queue for multiple IO sources (if needed).
- No locks in the frame loop; consumption is bounded per frame.
Safety
- Background threads never touch GPU objects directly.
- Only the main thread creates and destroys GPU resources.
- Ownership is explicit: workers produce data buffers, main thread consumes.
Memory and Concurrency
The runtime follows Tiger Style memory and control flow guidelines to keep behavior predictable and fast.
Memory Strategy
Rules:
- Allocate long-lived structures at startup.
- Avoid heap allocation on the hot path.
- Use fixed-size pools for frequently created objects.
- Make ownership explicit in API boundaries.
Planned allocations:
- Handle tables for WebGL objects (buffers, textures, programs).
- Event queues sized to a fixed upper bound.
- Scratch arenas for asset loading and parsing.
Concurrency Model
Initial design is single-threaded for deterministic behavior:
- JS execution, event dispatch, and render submission happen on the main thread.
- The render backend runs on the same thread as the shim.
Planned background work (later phase):
- Asset IO and image decode on worker threads.
- Communication via bounded, single-producer queues.
- All GPU resource creation remains on the main thread.
Control Flow
- No recursion in hot paths.
- Bounded loops for event and command processing.
- Parent function drives the control flow; helpers are pure computation.
Performance Budgets
The runtime targets 60 FPS with a stable frame time and predictable memory use. Budgets are estimates; update them as measurements are collected.
Targets
- Frame time: 16.6 ms (60 FPS)
- Main thread CPU budget: 6 to 8 ms
- JS budget: 2 to 4 ms (depends on scene complexity)
- Shim + command translation: 1 to 2 ms
- Present and GPU submission: 1 ms (CPU side)
The GPU budget is scene-dependent and not included in the CPU budgets above.
Napkin Math Template
RESOURCE BUDGET: [Feature Name]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Memory:
Per-item size: ___ bytes (struct + padding)
Max items: ___
Total: ___ MB/GB
Cache analysis:
L1 (64KB): fits ___ items
L2 (512KB): fits ___ items
L3 (16MB): fits ___ items
Verdict: PASS/FAIL
CPU:
Operation: ___
Cycles estimate: ___
At 3GHz: ___ ns
Per-second throughput: ___
Verdict: PASS/FAIL
Latency Budget:
Target: ___ ns/us/ms
Breakdown:
- Step 1: ___ ns
- Step 2: ___ ns
- Total: ___ ns
Safety margin: ___x
Verdict: PASS/FAIL
Conclusion: PROCEED / REDESIGN
Example Budget: Event Queue Drain
Assumptions:
- 512 events max per frame
- 120 cycles per event to normalize and dispatch
Estimate:
- 512 * 120 cycles = 61,440 cycles
- At 3GHz: ~20.5 us
Verdict: PASS (well under 1 ms)
WebGL Call Budget
Early milestones assume:
- 2,000 to 5,000 WebGL calls per frame
- 200 to 500 draw calls per frame
The shim state cache must avoid redundant state changes to stay within budget.
Startup Budget
Targets for cold start:
- App window visible: < 1.0 s
- First frame rendered: < 2.0 s
These numbers are placeholders and should be validated against real demos.
Testing
Testing follows Tiger Style: each phase is fully tested before proceeding. Tests are written first, implementations make them pass.
TDD Workflow
1. Write failing test for new behavior
2. Implement minimal code to pass
3. Refactor with confidence
4. Repeat
Every phase in the roadmap has explicit test requirements. No phase is complete until all its tests pass.
Test Categories
Unit Tests
Fast, isolated tests for individual functions and types.
test "buffer handle allocation" {
var handles = BufferHandles.init(testing.allocator);
defer handles.deinit();
const h1 = handles.alloc();
const h2 = handles.alloc();
try testing.expect(h1 != h2);
handles.free(h1);
const h3 = handles.alloc();
try testing.expectEqual(h1, h3); // reuse
}
Coverage targets:
- Handle/resource allocation
- State machine transitions
- Error paths
- Boundary conditions
Integration Tests
Test JS↔Zig round-trips and WebGL shim workflows.
test "JS sets clear color" {
var rt = try Runtime.init(testing.allocator, 256 * 1024);
defer rt.deinit();
try rt.eval("setClearColor(1.0, 0.0, 0.0)");
const color = rt.getClearColor();
try testing.expectApproxEqAbs(@as(f32, 1.0), color.r, 0.001);
try testing.expectApproxEqAbs(@as(f32, 0.0), color.g, 0.001);
}
Coverage targets:
- JS function bindings
- WebGL call sequences
- Asset loading paths
- Error propagation
Screenshot Tests
Render and compare against golden images.
test "triangle renders correctly" {
var renderer = try TestRenderer.init();
defer renderer.deinit();
renderer.drawTriangle();
const pixels = renderer.readPixels();
try testing.expectScreenshotMatch("golden/triangle.png", pixels, .{
.tolerance = 0.01, // 1% pixel difference allowed
});
}
Why tolerances:
- Driver differences
- Floating point variance
- Platform antialiasing
Golden image updates:
zig build test -- --update-golden
Fuzz Tests
Test robustness against malformed inputs.
test "fuzz: GLTF parser doesn't crash" {
try testing.fuzz(struct {
fn run(input: []const u8) !void {
_ = gltf.parse(input) catch return;
}
}, .{});
}
Fuzz targets:
- GLTF parser
- Image decoders
- JS eval (if applicable)
- Any binary format handling
Run extended fuzzing:
zig build test --fuzz -- --duration 3600
Benchmark Tests
Validate performance meets napkin math estimates.
test "benchmark: context switch under 200ns" {
const result = testing.benchmark(struct {
fn run() void {
runtime.contextSwitch();
}
}, .{});
try testing.expect(result.mean_ns < 200);
}
Benchmarks track:
- WebGL call overhead
- JS eval latency
- Frame time budget
- Memory allocation rate
Test Infrastructure
Directory Structure
tests/
├── unit/
│ ├── handles_test.zig
│ ├── state_machine_test.zig
│ └── ...
├── integration/
│ ├── webgl_test.zig
│ ├── runtime_test.zig
│ └── ...
├── screenshot/
│ ├── triangle_test.zig
│ ├── cube_test.zig
│ └── golden/
│ ├── triangle.png
│ └── cube.png
├── fuzz/
│ ├── gltf_fuzz.zig
│ └── ...
└── fixtures/
├── simple.js
├── webgl_cube.js
└── ...
Running Tests
# All tests
zig build test
# Unit tests only
zig build test -- --filter unit
# Screenshot tests (requires display)
zig build test -- --filter screenshot
# With verbose output
zig build test -- -v
# With memory leak detection
zig build test -- --detect-leaks
CI Configuration
| Platform | Test Scope |
|---|---|
| Linux (every push) | Unit, integration, memory leaks |
| Linux + GPU (PR) | All including screenshot |
| macOS (PR) | Unit, integration |
| Windows (PR) | Unit, integration |
Memory Leak Detection
All tests run with GeneralPurposeAllocator in debug mode:
test "no memory leaks" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const leaked = gpa.deinit();
if (leaked) @panic("memory leak detected");
}
// test code using gpa.allocator()
}
Failure Diagnostics
When a test fails:
- Context dump — Last N WebGL calls logged
- JS exception — Stack trace if JS threw
- Screenshot — Actual render saved (screenshot tests)
- Diff image — Visual diff against golden (screenshot tests)
- State snapshot — GL state machine dump
Example failure output:
test "cube renders correctly" FAILED
Expected: golden/cube.png
Actual: /tmp/test_output/cube_actual.png
Diff: /tmp/test_output/cube_diff.png
Pixel difference: 5.2% (threshold: 1.0%)
Last 5 WebGL calls:
gl.bindBuffer(ARRAY_BUFFER, 3)
gl.bufferData(ARRAY_BUFFER, 288, STATIC_DRAW)
gl.vertexAttribPointer(0, 3, FLOAT, false, 12, 0)
gl.enableVertexAttribArray(0)
gl.drawArrays(TRIANGLES, 0, 36)
Test-First Development
For each new feature:
-
Write the test first
test "gl.createBuffer returns valid handle" { var gl = try WebGLContext.init(); const buf = gl.createBuffer(); try testing.expect(buf != 0); } -
Watch it fail — Confirms test is testing the right thing
-
Implement — Minimum code to pass
-
Refactor — Clean up with passing tests as safety net
-
Commit — Test and implementation together
This ensures:
- No untested code
- Tests document expected behavior
- Regressions caught immediately
Roadmap
This roadmap follows Tiger Style phased development. Each phase is fully tested before proceeding. We validate with napkin math, build foundations first, and never accumulate tech debt.
Planning Philosophy
- Napkin math before code — Validate feasibility upfront
- Foundation before features — Core types, then behavior
- Test before complexity — Each phase fully tested
- No tech debt — Do it right or skip it
M0: Proof of Life ✅
Status: Complete
- mquickjs compiles in Zig build
- Call JS from Zig
- Call Zig from JS
- Print “hello from mquickjs”
M1: Triangle
Open a native window and render a triangle. JS controls the clear color.
Napkin Math
Frame budget: 16.6ms (60 FPS target)
├── JS eval overhead: ~1ms (mquickjs is fast for small scripts)
├── Clear color update: ~0.01ms
├── Triangle draw: ~0.1ms
├── Present/swap: ~1ms
└── Buffer: 14ms
Verdict: ✓ Plenty of headroom
Phase 1.0: Design Validation ✅
Goals
- sokol dependency added to build.zig
- Window creation API defined
- JS↔Zig color binding API defined
Deliverables
-
src/platform/window.zig- type definitions only -
src/shim/globals.zig- JS global bindings skeleton - Test fixture: empty window opens and closes
Exit Criteria
- sokol compiles with Zig
- Types defined and compile
- Build still works on Linux (CI green)
Phase 1.1: Native Window ✅
Goals
- Window opens with sokol_app
- Window closes cleanly
- Basic event loop runs
Implementation
- Initialize sokol_app
- Create window with fixed size (800x600)
- Handle close event
- Pump events in main loop
Tests Required
- Window opens without crash
- Window closes without leak (valgrind/GPA)
- 1000 open/close cycles without leak
Exit Criteria
- Black window appears
- Closes on X button / ESC
- No memory leaks (not formally verified yet)
Phase 1.2: Clear Color from Zig ✅
Goals
- sokol_gfx initialized
- Frame clears to solid color
- Color changeable from Zig
Implementation
- Initialize sokol_gfx with sokol_app
- Create default pass action
- Clear to configurable RGB
- Present frame
Tests Required
- Clear to red, verify framebuffer (screenshot test)
- Clear to green, verify
- Clear to blue, verify
- 60 FPS sustained for 10 seconds (2500 frames @ ~60fps = 41 seconds)
Exit Criteria
- Solid color fills window
- Color changes work (animated cycling demo)
- Stable 60 FPS
Phase 1.3: Triangle Rendering ✅
Goals
- Hardcoded triangle vertices
- Basic shader compiles
- Triangle visible on screen
Implementation
- Create vertex buffer with 3 vertices
- Write minimal vertex/fragment shader (GLSL 410)
- Create pipeline
- Draw call in frame loop
Tests Required
- Triangle visible (manual verification)
- Triangle colors correct (RGB at vertices)
- No shader compilation errors
- Works on all 3 platforms (CI matrix)
Exit Criteria
- Colored triangle on solid background
- Renders at 60 FPS (3853 frames in ~6s)
- Works on Linux, macOS, Windows (Linux verified)
Phase 1.4: JS Controls Clear Color ✅
Goals
- JS can call native function to set color
- Color updates visible next frame
- Round-trip works reliably
Implementation
- Expose
setClearColor(r, g, b)via stdlib table - Store color in Zig shared state
- Apply color in render loop
-
requestAnimationFrameshim (bounded queue)
Tests Required
- JS animation loop changes color over time (manual)
- JS sets red → window is red (screenshot test)
- JS sets green → window is green (screenshot test)
- 1000 color changes, all apply correctly
Exit Criteria
-
setClearColor(1, 0, 0)from JS works (manual) - Animation callback works
- M1 demo script runs
M2: Cube via Shim
WebGL shim subset. JS draws a cube through the shim layer.
Napkin Math
WebGL calls per frame (simple cube): ~50 calls
├── State changes: ~20 calls × 0.1μs = 2μs
├── Buffer binds: ~5 calls × 0.5μs = 2.5μs
├── Draw calls: ~1 call × 1μs = 1μs
├── Uniform updates: ~10 calls × 0.2μs = 2μs
└── Total shim overhead: ~10μs per frame
Verdict: ✓ Negligible vs 16.6ms budget
Phase 2.0: Design Validation
Goals
- WebGL API surface mapped
- Shim architecture documented
- State machine design complete
Deliverables
-
src/shim/webgl.zig- type definitions (context handle table) -
src/shim/webgl_state.zig- state machine types (buffer binds) - API coverage spreadsheet (which gl.* calls needed)
Exit Criteria
- Know exactly which 30-40 GL calls to implement
- State machine transitions documented
- Types compile
Phase 2.1: WebGL Context Creation
Goals
-
canvas.getContext('webgl')returns context - Context has correct properties
- Context is usable object in JS
Implementation
- Canvas shim with getContext
- WebGLRenderingContext object
- Property getters (drawingBufferWidth, etc.)
- Register with JS runtime
Tests Required
- getContext returns truthy
- Context has expected properties
- Multiple getContext calls return same context
- Invalid context type returns null
Exit Criteria
-
canvas.getContext('webgl')works - Properties readable
- No crashes
Phase 2.2: Buffer Management
Goals
- createBuffer works
- bindBuffer works
- bufferData works
- deleteBuffer works
Implementation
- Buffer handle allocation
- Bind state tracking
- Buffer data tracking (size + update count)
- Native buffer API (create/bind/data/delete)
- JS buffer API surface (gl.createBuffer/bindBuffer/bufferData/deleteBuffer)
- TypedArray/ArrayBuffer support for gl.bufferData
- Element array buffers handled (usage set on first data)
- Sokol buffer backend adapter (not wired to JS yet)
- Data upload path wired to sokol (backend set in window init)
- Cleanup on delete (handle + bind state)
- CPU backfill uses fixed pool (no heap in hot path)
- Backfill errors surfaced to caller
Tests Required
- Create 1000 buffers, no leak
- Bind/unbind cycles
- Upload data, verify size
- Delete buffer, handle invalid
Exit Criteria
- Buffer lifecycle complete
- State tracked correctly
- No resource leaks
Phase 2.3: Shader Compilation
Goals
- createShader works
- shaderSource works
- compileShader works
- getShaderParameter works
- createProgram, attachShader, linkProgram work
Implementation
- Shader handle allocation
- Shader source storage
- Compile status + info log tracking
- GLSL to sokol shader translation (or passthrough)
- Compilation error reporting
- Program linking
- Shader translation bounded (no heap, line caps)
- Sampler uniform parsing + locations
Tests Required
- Valid shader compiles
- Invalid shader reports error
- Program links with valid shaders
- Program fails with incompatible shaders
- getShaderInfoLog returns errors
Exit Criteria
- Shader pipeline works
- Errors reported correctly
- No crashes on bad input
Phase 2.4: Draw Calls
Goals
- bindBuffer + bindShader + draw workflow
- drawArrays works
- drawElements works (indexed)
- Uniform updates work
Implementation
- Assemble sokol pipeline from GL state
- Execute draw with current bindings
- Uniform buffer management
- State validation before draw
- Pipeline cache avoids per-draw create/destroy
- Uniform std140 padding zeroed on upload
- Uniform typed arrays use aligned staging copy
Tests Required
- Draw triangle via WebGL calls
- Draw indexed quad
- Uniform changes affect output
- Invalid state rejected
Exit Criteria
- WebGL draw calls produce visible output
- Matches expected rendering
- State machine validated
Phase 2.5: Cube Demo
Goals
- Rotating cube rendered
- All via WebGL shim calls
- Runs at 60 FPS
Implementation
- Matrix math in JS (or shim)
- Cube vertex data
- Rotation animation
- Perspective projection
Tests Required
- Cube visible and rotating
- Screenshot comparison
- 60 FPS sustained
- Memory stable over 1 minute
Exit Criteria
- Cube demo runs
- Pure WebGL JS code (no native calls except shim)
- M2 complete
M3: Three.js Loads
Three.js imports without error and renders a basic scene.
Napkin Math
Three.js bundle size: ~600KB minified
├── Parse time (mquickjs): ~500ms one-time
├── Memory for parsed AST: ~2MB
└── Runtime objects: ~1MB
Total JS memory: ~3MB
Frame with Three.js:
├── Scene graph traversal: ~0.5ms (100 objects)
├── Matrix updates: ~0.2ms
├── WebGL calls: ~2ms (batched)
├── Buffer: 13ms
└── Total: ~3ms
Verdict: ✓ Fits in 64KB mquickjs heap? NO - need larger heap
Revised: 256KB heap minimum for Three.js
Phase 3.0: Design Validation
Goals
- Three.js load tested in mquickjs standalone
- Missing APIs identified
- Memory requirements validated
Deliverables
- List of required browser APIs
- Memory budget confirmed
- Shim gap analysis
Exit Criteria
- Know every API Three.js needs
- Memory budget feasible
- No blockers identified
Phase 3.1: DOM Shims
Goals
- document.createElement works
- Basic Element properties
- Event handling basics
Implementation
- Document object shim
- Element object shim
- createElement for canvas
- Style object (noop most properties)
Tests Required
- createElement returns element
- Element has expected properties
- Canvas element works with getContext
Exit Criteria
- Three.js doesn’t throw on DOM access
- Canvas creation works
Phase 3.2: Additional WebGL Coverage
Goals
- All WebGL calls Three.js uses
- Extensions Three.js checks for
- Error handling for unsupported
Implementation
- Audit Three.js WebGLRenderer
- Implement missing calls
- Extension query responses
- Graceful degradation
Tests Required
- Three.js WebGLRenderer constructs
- No “undefined” errors
- Extension queries return expected
Exit Criteria
- WebGLRenderer initializes
- No missing method errors
Phase 3.3: Three.js Scene Basics
Goals
- Scene creates
- Camera creates
- Renderer creates and attaches
Implementation
- Scene shim if needed
- Camera types supported
- Renderer integration
Tests Required
-
new THREE.Scene()works -
new THREE.PerspectiveCamera()works -
new THREE.WebGLRenderer()works - renderer.render(scene, camera) runs
Exit Criteria
- Basic Three.js setup code runs
- No errors
Phase 3.4: BoxGeometry + MeshBasicMaterial
Goals
- BoxGeometry creates
- MeshBasicMaterial creates
- Mesh renders
Implementation
- Geometry buffer handling
- Material uniform handling
- Mesh in scene renders
Tests Required
- Box visible on screen
- Color from material applied
- Screenshot matches reference
Exit Criteria
- Three.js renders a box
- M3 complete
M4: Real Demo
GLTF loading, OrbitControls, lighting. Demo-quality output.
Phase 4.0: Design Validation
Goals
- GLTF loader requirements mapped
- OrbitControls input requirements mapped
- Lighting shader requirements mapped
Exit Criteria
- Know full scope
- No unknown unknowns
Phase 4.1: Asset Loading Infrastructure
Goals
- fetch() shim for local files
- ArrayBuffer handling
- Blob/URL handling
Implementation
- File read from Zig
- ArrayBuffer shim
- Response object shim
Tests Required
- Fetch local file works
- Binary data correct
- Large file (10MB) works
Exit Criteria
- Can load assets from disk
Phase 4.2: GLTF Loading
Goals
- GLTFLoader works
- Simple GLTF loads
- Textures load
Implementation
- TextDecoder shim
- Image loading shim
- createImageBitmap shim
Tests Required
- Load reference GLTF
- Geometry correct
- Textures applied
Exit Criteria
- GLTF model visible
Phase 4.3: OrbitControls
Goals
- Mouse input works
- OrbitControls functional
- Smooth camera movement
Implementation
- Mouse event shims
- Pointer lock if needed
- Event dispatch
Tests Required
- Click and drag rotates
- Scroll zooms
- No jank
Exit Criteria
- Interactive camera
Phase 4.4: Lighting
Goals
- DirectionalLight works
- AmbientLight works
- MeshStandardMaterial works
Implementation
- Light uniform handling
- PBR shader support
- Shadow maps (stretch)
Tests Required
- Lit scene visible
- Light position affects shading
- Multiple lights work
Exit Criteria
- Professional-looking render
Phase 4.5: Demo Polish
Goals
- Demo scene assembled
- Screenshot quality
- Stable performance
Tests Required
- Runs 5 minutes without issue
- Screenshot comparison
- Memory stable
Exit Criteria
- M4 complete
- Demo ready for sharing
M5: Ship It
Cross-platform builds. Steam demo page.
Phase 5.1: macOS Build
Goals
- Builds on macOS
- Metal backend works
- App bundle created
Tests Required
- CI builds macOS
- Demo runs on macOS
- No code signing issues (for dev)
Exit Criteria
- .app bundle works
Phase 5.2: Windows Build
Goals
- Builds on Windows
- D3D11 backend works
- .exe created
Tests Required
- CI builds Windows
- Demo runs on Windows
- No missing DLLs
Exit Criteria
- .exe works standalone
Phase 5.3: Linux Build
Goals
- Builds on Linux
- OpenGL backend works
- Binary runs
Tests Required
- CI builds Linux
- Demo runs on Linux
- Works on Ubuntu, Fedora
Exit Criteria
- Binary works
Phase 5.4: Steam Integration
Goals
- Steamworks SDK integrated
- Demo page created
- Build uploaded
Implementation
- Steam app ID
- Depot configuration
- Build script for Steam
Exit Criteria
- Demo on Steam
- M5 complete
- three-native shipped 🚀
Test Infrastructure
Each phase requires passing tests. The test suite grows with the project:
| Milestone | Test Types |
|---|---|
| M0 | Unit tests only |
| M1 | Unit + screenshot comparison |
| M2 | Unit + screenshot + WebGL conformance subset |
| M3 | Unit + screenshot + Three.js example tests |
| M4 | Unit + screenshot + integration + perf |
| M5 | All above + platform matrix CI |
CI Requirements
- Linux builds on every push
- macOS/Windows builds on PR and main
- Screenshot tests with golden images
- Memory leak detection (GPA in debug)
- Performance regression detection