Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)

  1. Napkin math before implementation.
  2. Data structures match access patterns.
  3. Static allocation at startup, explicit lifetimes.
  4. 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: mquickjs with 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

  1. Initialize platform layer and create the primary window.
  2. Initialize the rendering backend and device state.
  3. Initialize the JS runtime and register host bindings.
  4. Load the Three.js bundle and user entry module.
  5. Create a canvas and WebGL context for the app.
  6. Enter the frame loop and start requestAnimationFrame.

Frame Loop

Each frame follows a fixed, explicit order:

  1. Poll platform events (input, resize, focus).
  2. Dispatch events into the shim.
  3. Run one requestAnimationFrame tick in JS.
  4. Translate WebGL calls into native backend commands.
  5. 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.
  • requestAnimationFrame drives 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)

  • WebGLRenderingContext and WebGL2RenderingContext (subset)
  • HTMLCanvasElement with getContext, width, height
  • requestAnimationFrame and cancelAnimationFrame
  • performance.now()
  • Image, createImageBitmap
  • fetch, 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 from deps/three
  • Status legend: implemented, partial, missing

Notes:

  • The list includes WebGL2-only APIs used by Three.js. Those are marked webgl2 and 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

  1. Begin frame and clear swapchain.
  2. Apply state updates from the WebGL shim.
  3. Submit draw calls.
  4. 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() and text() as needed by Three.js loaders.
  • Binary data stays in contiguous buffers owned by the host.

Images

  • Image and createImageBitmap load 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

  • FileReader supports the subset used for drag-and-drop assets.
  • URL.createObjectURL returns a short-lived token that maps to a host buffer.
  • URL.revokeObjectURL releases 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

PlatformTest 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:

  1. Context dump — Last N WebGL calls logged
  2. JS exception — Stack trace if JS threw
  3. Screenshot — Actual render saved (screenshot tests)
  4. Diff image — Visual diff against golden (screenshot tests)
  5. 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:

  1. Write the test first

    test "gl.createBuffer returns valid handle" {
        var gl = try WebGLContext.init();
        const buf = gl.createBuffer();
        try testing.expect(buf != 0);
    }
    
  2. Watch it fail — Confirms test is testing the right thing

  3. Implement — Minimum code to pass

  4. Refactor — Clean up with passing tests as safety net

  5. 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

  1. Napkin math before code — Validate feasibility upfront
  2. Foundation before features — Core types, then behavior
  3. Test before complexity — Each phase fully tested
  4. 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
  • requestAnimationFrame shim (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:

MilestoneTest Types
M0Unit tests only
M1Unit + screenshot comparison
M2Unit + screenshot + WebGL conformance subset
M3Unit + screenshot + Three.js example tests
M4Unit + screenshot + integration + perf
M5All 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