A WebGL2 renderer and 3D scene editor, written from scratch in TypeScript.
No three.js, no ECS library, no math library beyond gl-matrix — the renderer,
the entity system, the loaders, and the editor are all hand-rolled. That is the
point of the project: it exists to be read, by people learning graphics
programming and by people evaluating whether I can write this kind of code.
Editing. Click to select an object; drag the gizmo's axis handles to move, rotate, or scale it. Selection is drawn with a yellow silhouette rim. Panels are dockable and rearrangeable — Scene, Objects, Inspector, Materials, and Workspace — and the layout persists across reloads.
Assets. Imports .obj and glTF 2.0 .glb models, both with hand-written
parsers. Materials are shared, reusable assets rather than per-object copies.
Scenes. Saves and loads .ffscene files (JSON, versioned, assets referenced
by id rather than embedded). Opens a workspace folder that imported asset bytes
are read from and written to, with a recent-workspaces list.
Rendering. Point lighting with textures, drawn into an offscreen framebuffer with two color attachments — which is what makes picking nearly free, see below.
npm install
npm run start:dev # dev server on :8080
npm run build # production build
npm test # 150 tests, no GL context required
npx tsc --noEmit # typecheck; strict is onNeeds a browser with WebGL2. The workspace features use the File System Access API, which today means Chromium; other browsers fall back to an origin-private filesystem and the app adapts rather than breaking (see storage below).
| Camera | right-drag to orbit · scroll to zoom |
| Tools | s select/move · c add cube |
| Gizmo | w translate · e rotate · r scale |
The world model is an entity component system, and
src/ecs/README.md explains it properly — read that first
if you're reading the code. The short version: an entity is an id, a component
is pure data in a per-type map, and a system is a plain function that queries
the world. There is no scene graph and no SceneObject class; both were
deliberately deleted in favor of composition.
The layers, and the rule that keeps them apart:
src/ecs/—World,Component,System, and the components and systems themselves.src/Renderer/— the GPU backend. It knows nothing about entities: it takes a list ofRenderables and a list of light positions. Keeping it entity-blind is what makes it testable and replaceable.src/App/App.ts— the persistent engine. Owns the world, the camera, the clock, and the system schedule. Notably does not own the canvas, so the world survives the Scene panel being closed and reopened.src/UI/— React + dockview panels.src/Controller/— input handlers. Camera controls are always-on and bound to the right mouse button; the left button belongs to whichever tool is active.src/platform/— storage backends behind one interface.
Every frame draws the scene into a framebuffer with two color attachments: the
visible image goes to one, and each object's integer entity id is written to a
second R16I "id-texture". Resolving a click is then a single-pixel readback
from that texture — no ray/triangle intersection, no CPU-side copy of the
geometry, and no re-render, because the read samples the frame that was already
drawn. The id-texture clears to -1, which reads back as "you clicked the
background."
This falls out of the ECS rather than being designed alongside it. The id in the texture is the entity id, so there's no lookup table mapping "render object" to "scene object" — they were never two different things.
The outlined object is drawn a second time, scaled 3% about its own pivot, with front faces culled so that only the sliver poking past the real mesh survives. That sliver reads as a rim. It's depth-tested like ordinary geometry, so an outline correctly disappears behind anything in front of it, and it reuses the entity's own id so clicking the rim selects the object the rim belongs to.
Scaling about the local pivot rather than the world origin is the part that's
easy to get wrong, and it has its own tests in
OutlineMath.test.ts.
Loading a file means something different in Chromium (File System Access), in
Firefox (origin-private filesystem), and in a future Electron build (real
paths). Rather than branching on the host throughout the UI, there is one
Storage interface with several implementations, and handles are opaque keys —
never paths. A path would leak the abstraction, and in Electron it would leak
straight through the contextIsolation security boundary.
The one honest leak is a capabilities object: callers read
capabilities.overwriteInPlace to decide whether "Save" can silently overwrite
the open file or has to re-prompt every time. That is a real difference in what
the platform can do, so it gets stated explicitly instead of papered over.
150 tests across 14 suites, all runnable without a GPU:
npm testThe renderer itself can't be unit-tested — Jest has no GL context — so the
boundary is drawn at what the renderer is asked to do. Systems are tested by
stubbing the Renderer and asserting the draw list and light list they build.
Everything above the GPU is tested directly: the ECS world, the math (rays,
spherical coordinates, Euler angles, outline scaling), both model loaders
including their rejection paths, scene serialization round-trips, and the
storage backends.
The glTF loader's tests are mostly about what it refuses — bad magic numbers, unsupported container and asset versions, sparse accessors, external buffer URIs, non-triangle primitives, missing normals. A loader that silently produces garbage from a malformed file is worse than one that throws.
Working today: the editor, the gizmo in all three modes, GPU picking, selection
outlines, .obj and .glb import, materials as shared assets, scene save/load,
and workspace folders with persisted panel layout.
Deliberately not done: no shadows, no PBR, no animation import, one camera only,
and ECS storage is a plain Map per component rather than packed archetype
arrays. That last one is a real performance ceiling and still the right call —
archetype storage would triple the size of World.ts to speed up scenes
containing a dozen objects.
In progress: a scene-creator roadmap adding prefab authoring and a final-render panel.
MIT — see LICENSE.