What started as a polygon optimization library — 13 files, 1,704 lines of JavaScript — turned out to be something else entirely. It implements frustum culling, material management, instancing, LOD, scene traversal, animation, shadows, ambient occlusion, and fog. That is not an optimization library. That is a renderer.
The only thing it does not do is talk to the GPU directly. It uses Three.js for that — as a WebGL abstraction layer. But Three.js is 300,000 lines. The WebGL abstraction it actually provides (create buffer, compile shader, draw) is roughly 300 lines.
The abstraction is replaceable. The renderer already exists. It just needs to stop routing through Three.js.
| Three.js Feature | Our Replacement | Lines | Status |
|---|---|---|---|
| Frustum culling | culling.mjs |
154 | Built |
| Material management | material_optimizer.mjs |
138 | Built |
| Instancing | instanced.mjs |
118 | Built |
| LOD | lod.mjs |
145 | Built |
| Scene traversal | bake-static.mjs |
150 | Built |
| Animation | shader_animation.mjs |
106 | Built |
| Shadows | sdf_shadows.mjs |
140 | Built |
| Ambient occlusion | sdf_shadows.mjs |
(included) | Built |
| Fog / atmosphere | sdf_shadows.mjs |
(included) | Built |
| Our total | 13 files | 1,704 | |
| Three.js total | 300,000 |
wasm_bridge.mjs connects Lithos WASM binaries to WebGL. The WASM module cannot call WebGL directly (WASM has no DOM access). The bridge maintains handle tables — integer-indexed arrays that map WASM handle IDs to live WebGL objects.
gl_create_buffer() → returns handle 7gl_bindBuffer(7) → bridge looks up handles[7], calls gl.bindBuffer()gl_create_buffer / gl_delete_buffer — buffer lifecyclegl_create_program / gl_link — shader compilationgl_uniform* — all uniform types via typed memory readsgl_draw_arrays / gl_draw_elements — draw commandsgl_viewport / gl_clear — frame setuprenderer.ls. These are not approximations. They are the same algorithms expressed in a language that compiles to WASM or GLSL.
// Visibility frustum_cull : (bounds, mvp) → bool // culling.mjs batch_instances: (proto, transforms) → draw_cmd // instanced.mjs lod_select : (distance, levels) → mesh_ref // lod.mjs // Shading diffuse : (n, l, albedo) → vec3 // material_optimizer.mjs specular : (n, h, roughness) → vec3 // material_optimizer.mjs aces_tonemap : (hdr_color) → vec3 // ACES filmic curve // SDF Primitives sd_sphere : (p, r) → float sd_box : (p, b) → float sd_capsule : (p, a, b, r) → float smin : (a, b, k) → float // smooth union smax : (a, b, k) → float // smooth intersection // March Loop march : (ro, rd, scene_sdf) → hit_info // the kernel soft_shadow : (p, l, k) → float // sdf_shadows.mjs cone_ao : (p, n, scene_sdf) → float // sdf_shadows.mjs
fbm : (p, octaves, gain) → float // terrain, clouds terrain_h : (xz) → float // height field voronoi : (p) → (f1, f2, id) // cell noise
The JavaScript library is the specification — human-readable, immediately testable in the browser, already battle-tested across every home.
The .ls file is the source — the same algorithms expressed in Lithos composition syntax, ready for compilation to any target.
The WASM binary is the executable — zero-GC, flat memory, integer handles to WebGL. Same functions, native speed.
They are not three renderers. They are three views of the same renderer. The JS validates the .ls. The .ls compiles to the WASM. If the WASM produces different output from the JS, one of them has a bug. The spec, the source, and the binary form a self-verifying triangle.
| Aspect | JavaScript Library | renderer.ls | WASM Binary |
|---|---|---|---|
| Role | Specification | Source | Executable |
| Language | ES Modules | Lithos compositions | WASM bytecode |
| Size | 1,704 lines | ~800 lines | ~69KB binary |
| GC pressure | Normal JS GC | N/A (source) | Zero |
| Runs in | Browser (with Three.js or raw WebGL) | Lithos compiler | Browser (via wasm_bridge.mjs) |
| Purpose | Validate, iterate, debug | Compile, emit, optimize | Ship, deploy, run |
| Metric | Three.js | Raw WebGL | Lithos WASM |
|---|---|---|---|
| Bundle size | ~300KB | ~10KB | ~69KB binary |
| GC pauses | 2–5ms / 500ms | ~0.5ms | 0ms |
| Scene setup | O(tree) — traverse Object3D hierarchy | O(flat) — linear buffer scan | O(flat) — linear memory |
| Draw overhead | High — per-object state, matrix stacks, uniform uploads | Minimal — direct gl.drawArrays | Minimal — integer handle dispatch |
| Memory layout | Fragmented (JS objects + GC heap) | TypedArrays (contiguous) | Linear WASM memory (cache-friendly) |
| Startup | Parse + compile 300K lines | Parse ~10K | Instantiate 69KB (instant) |
| Dependencies | Three.js (r160 or r175) | None | None |
| Phase | Polygon Improvement | Renderer Insight | Three.js Surface Reduced |
|---|---|---|---|
| 1 — Static Batch | 456 → ~50 draw calls | Static objects = bake candidates | Scene graph traversal unnecessary |
| 2 — GPU Shaders | JS per-object work eliminated | Animation separated from position | Three.js animation system unused |
| 3 — Compute | 1 indirect draw call | GPU decides visibility | Three.js frustum culling bypassed |
| 4 — Hybrid | Best visual quality | SDF handles terrain + lighting | Three.js shadow maps, fog, AO removed |
| 5 — Renderer | Raw WebGL or WASM | Complete replacement | Three.js dependency = 0 |
Every phase of polygon optimization removed a piece of Three.js dependency. Phase 1 made the scene graph optional. Phase 2 made the animation system optional. Phase 4 made the shadow and lighting systems optional. By Phase 5, nothing remains that Three.js provides which the optimization libraries don't already handle.
The roadmap did not lead to "a better Three.js app." It led to a renderer that no longer needs Three.js. The destination was hidden in the journey.