threejs-scene
v0.7.0
Published
Deterministic imperative three.js scenes: a scoped, unidirectional module contract, an LLM-first procedural asset toolkit, fixed-step physics, explicit disposal — and the instructions, agents and enforced rules shipped inside the package.
Downloads
830
Maintainers
Readme
threejs-scene

Lightweight imperative three.js app shell: createApp + a small module
contract. Deterministic clock, seeded rng, unidirectional state flow, strict
dispose chain. The successor core of threejs-scenes v3, rebuilt small.
For coding agents
The package ships its own instructions. Not a link to docs — the files, inside node_modules,
generated from the code so they cannot describe a function that no longer takes those arguments.
npx threejs-scene doctor # check what shipped
npx threejs-scene rules SC004 # any rule, in full
npx threejs-scene modules # every module: what it does, what it provides, what it costs
npx threejs-scene agents install # six agent definitions into .claude/agents
npx threejs-scene instructions # AGENTS.md into your project root| file | what it is |
| --- | --- |
| llms.txt | every export with its real signature, grouped by import path. Generated from the built .d.ts |
| llm/AGENTS.md | how to compose it: the contract, the three properties, the rules, six worked recipes |
| llm/RULES.md | the 17 enforced rules, each with its rationale and a wrong/right pair |
| llm/rules.json · llm/modules.json | the same, machine-readable |
| llm/agents/ | scene builder, module author, effect author, asset author, determinism auditor, performance auditor |
| llm/skills/threejs-scene/ | the package as a skill |
Or reach the same content in code:
import 'threejs-scene/modules/all'
import { instructions, RULES, listModules } from 'threejs-scene/llm'
const system = `${basePrompt}\n\n${instructions()}`The rules are not advice. Three things enforce them, all reporting the same ids:
Strict mode, on by default outside
NODE_ENV=production. It trapsMath.random/Date.now/performance.nowduring lifecycle calls and attributes the call site, deep-freezes the state a module is handed, diffs the scene after every build, counts per-tick allocation, and checks that every declared capability was actually published.app.violationsis the log.An ESLint plugin, shipped at
threejs-scene/eslint— seven AST rules, same ids, before the code runs.// eslint.config.mjs import threejsScene from 'threejs-scene/eslint' export default [ ...threejsScene.configs.recommended ]A contract test kit,
threejs-scene/testing.auditModule()runs a module through two full lifecycles and reports violations, undisposed GPU resources, objects stranded on the scene, and any difference between the two runs from the same seed.
npm run llms and npm run llm:assets regenerate; the :check variants fail when stale, and the
release workflow runs both, so a signature or a rule cannot ship with a doc that disagrees.
Install
npm i threejs-scene threeUse
import * as THREE from 'three'
import { createApp, defineModule } from 'threejs-scene'
import { standardLighting } from 'threejs-scene/modules/lighting'
import { orbitControls } from 'threejs-scene/modules/orbit'
interface State { speed: number }
function turbine () {
let blades: THREE.Group
return defineModule<State>({
name: 'turbine',
build (ctx) {
// ctx.root is this module's subtree; ctx.own() releases at teardown
const geometry = ctx.own(new THREE.BoxGeometry(0.2, 2, 0.05))
const material = ctx.own(new THREE.MeshStandardMaterial())
blades = ctx.own(new THREE.Group())
blades.add(new THREE.Mesh(geometry, material))
ctx.root.add(blades)
},
update (state, frame) {
blades.rotation.z += state.speed * frame.delta
},
})
}
const app = createApp<State>(canvas, {
state: { speed: 1 },
seed: 7,
loop: { fps: 0 },
clock: { mode: 'fixed', step: 1 / 120 },
use: [ standardLighting(), orbitControls() ],
})
const handle = app.use(turbine()) // handle.remove() detaches and disposes it
app.start() // or app.tick() for deterministic stepping
app.setState({ speed: 2 })
app.violations // contract breaches; empty is the goal
app.dispose()Every lifecycle method handles data in one direction, inside one scope.
Scoped. A module owns a THREE.Group (ctx.root), a random stream forked from its own id, one
state key, and a disposal ledger — and nothing else owns those. ctx.own(x) takes anything with a
dispose(), any Object3D, or a teardown function, and releases it in reverse order when the
module goes away. Teardown is the exact inverse of build whether the module remembered or not.
Unidirectional. store -> select -> update -> scene, once per simulation tick. A module writes
state with ctx.commit(patch), which is queued and applied after every module has updated — so
every module in a tick sees the same world, and no write lands halfway through one. Only a module
that declares the state key it owns may commit:
import { defineScopedModule } from 'threejs-scene'
const weather = defineScopedModule<State, 'weather'>('weather', { rain: 0 }, {
name: 'weather',
build: () => {},
update: (weather, frame, ctx) => ctx.commit({ rain: weather.rain + frame.delta * 0.1 }),
})Deterministic. Mount order is a pure function of the module list: a stable topological sort over
declared capabilities, tie-broken by order band then insertion. Same seed plus same tick sequence
reproduces the same world, headless included.
Pluggable. Modules find each other through capability tokens rather than imports, so implementations are swappable:
import { capability, CameraRig } from 'threejs-scene'
defineModule({
name: 'chase',
requires: [ CameraRig ], // orders the provider before this module
build: () => {},
start: ctx => ctx.resolve(CameraRig).aim([ 0, 1, 0 ]),
})orbitControls() and cameraRig() both provide camera-rig; a module that only needs to point the
view never learns which one it got. npx threejs-scene modules lists every module and what it
provides.
Post-processing
Post-processing is a pluggable module, same contract as lighting and orbit —
drop it into use: [ … ] and it owns the frame draw through a module render
hook (no composer wiring at the call site):
import { postProcessing, createGradePass } from 'threejs-scene/modules/post'
import { createChromaticAberration } from 'threejs-scene/modules/post/webgl/ca'
const app = createApp(canvas, {
use: [
standardLighting(),
postProcessing({
bloom: { strength: 0.8 },
depth: true, // for DOF / god rays / motion blur
effects: () => [ createChromaticAberration({ strength: 1.2 }), createGradePass({}) ],
onFrame: ({ elapsed }, ctx) => { /* drive time/camera uniforms */ },
}),
],
})
app.start()It builds an EffectComposer (RenderPass → optional UnrealBloom → your passes
→ OutputPass, tone-mapped once at OutputPass). The WebGL effect catalogue —
bloom, god rays, colour grade, DOF, CRT, glitch, chromatic aberration, film
grain, and more — lives under modules/post/ (the module plus the top-level
passes) and modules/post/webgl/ (the full set). WebGL only for now.
Each effect exists exactly once. The shared GLSL kernels — the fullscreen vertex
shader, hash, chromatic aberration, vignette, grain — live in
modules/post/shared/glsl.ts and are composed into each pass, rather than being
re-inlined per file. Passes that own a look keep it: createGradePass defaults
its baked grain and chromatic aberration to 0, so stacking it with
createFilmGrainPass/createChromaticAberration never double-applies either.
Every effect's parameters are adjustable live in the interactive demo.
Starter templates
Seven scaffolded apps — two isometric scenes, a third-person hovership racer,
a studio product display, the complete asset gallery, props compiled from
LLM-authored JSON, and a physics yard — are on the
starters page,
each showing its complete source beside the running scene. The source is
imported twice (as a module to run, and via Vite's ?raw to display), so the
code you read is provably the code you are watching.
Each one is a worked example of tying a camera to something else in the scene:
- Isometric — drag to pan, wheel/pinch to zoom. One
zoomnumber drives the frustum, the rig's elevation, the fog band and the tilt-shift blur together, so zooming in tips the camera up toward the horizon as the bokeh thickens. Panning moves the terrain's sample origin rather than the camera, which is why the map is endless in every direction. - Hovership — the ship reads the track's curvature ahead and brakes exactly as late as it can, so it runs flat out down the straights. The camera racks focus off that throttle: out to the horizon under acceleration, back onto the ship under braking, with the aperture opening through both.
- Product — the camera revolves around a product that never moves (so the key light rakes across it, which spinning the object cannot fake), and the pointer leans the rig a few degrees for parallax.
- Asset gallery — built directly from
ASSET_MANIFEST: all five textures, twelve materials, and twenty-two prop presets, labelled through one atlas. - LLM prop authoring — every object on the plinth was JSON a moment ago,
compiled by
modules/assets. Two of them came from deliberately sloppy model output; the console prints what each prop's critique would tell the model back. - Salvage yard physics — cloth, liquid and kinetics in one deterministic world: a tarp breathing in the wind, a cohesive fluid surface sloshing in a cutaway basin, and independent barrels/crates coming down a ramp.
Content: props, materials, textures
modules/assets is the content layer — plain factories rather than app modules:
import { Prop, markShared, createStandardMaterial, treeProp } from 'threejs-scene/modules/assets'
const ship = new Prop('ship')
.addPart('hull', new THREE.Mesh(hullGeometry, createStandardMaterial('metal')))
.addPart('canopy', new THREE.Mesh(domeGeometry, createStandardMaterial('glass')))
scene.add(ship)
ship.dispose() // frees every geometry/material/texture it owns, exactly onceA Prop is a THREE.Group of child meshes with a disposal contract: it owns
everything it contains unless the resource is tagged with markShared().
That makes sharing a pooled material a deliberate, visible act, and keeps
dispose() safe to call without blanking a neighbour's material.
The primary interface is synchronous and deliberately model-friendly:
import { createProp, tryCreateProp, ASSET_MANIFEST } from 'threejs-scene/modules/assets'
const barrel = createProp('barrel', { scale: 1.2 })
const crate = createProp({ preset: 'crate', options: { seed: 7 } })
const custom = tryCreateProp(modelProducedJsonOrProse) // never throws
scene.add(barrel, crate)
if (custom.prop) scene.add(custom.prop)Exact preset strings resolve first. Other strings go through forgiving JSON
extraction and validation. Preset options are described by exported parameter
specs: malformed values default/clamp and unknown keys are ignored.
CREATE_PROP_SCHEMA is the function-calling schema; ASSET_MANIFEST is a
JSON-serializable inventory of every option default, description, and tag.
Also here: eight PBR presets plus toon, procedural matcap, holographic, and
triplanar materials; five DOM-free procedural textures; shape profiles,
extrusion/lathe/path tubes, surface ribbons, twist/taper/bend/noise and topology
modifiers, merging/layout/bounds/connection/infinite-ground helpers; terse
primitive constructors (box, cyl, cone, ball, hedron, plank,
blade); owned prop registries, composites, and single- or multi-part
instancing.
Seeing a prop without a browser
validatePropSpec says whether a spec is legal and reviewProp measures
whether the result floats, detaches or buries itself — but neither says what it
looks like, which leaves a model authoring geometry reasoning blind.
rasterizeAscii draws one geometry as text, with a real z-buffer, in about ten
milliseconds and with no canvas, GPU or image file anywhere in the loop:
import { mergeParts, part, box, cyl, rasterizeAscii, auditPalette } from 'threejs-scene/modules/assets'
const geometry = mergeParts([
part(box(2, 1.2, 3), { color: '#8a5a3b' }),
part(cyl(0.1, 0.1, 1.6, 5), { at: [ 0.7, 1.1, 0 ], color: '#3d3d42' }),
])
for (const view of [ 'iso', 'right' ] as const)
console.log(rasterizeAscii(geometry, { view, cols: 72 }).lines.join('\n'))
// which palette entry each facet came from, and the height band it covers
console.log(auditPalette(geometry, { timber: '#8a5a3b', iron: '#3d3d42' }))ASCII_VIEWS names the six angles worth looking from — iso because it is the
angle an isometric scene is played at, the four elevations because they are the
ones you can measure from. Both functions read position as a triangle soup,
which is exactly what mergeParts emits; call .toNonIndexed() first on
anything indexed.
The low-poly prop kit
The same module carries the tooling a flat-shaded kit is actually built from — bake the look into the vertices, collapse a prop into one geometry, and stamp it:
import {
part, mergeParts, // primitives → one merged, vertex-coloured geometry
bakeFacetColors, applyGrime, kitMaterial,
buildKitGeometry, kitProp, // 16 ready-made wasteland props
createPlacementField, scatterInstances,
} from 'threejs-scene/modules/assets'
const material = kitMaterial() // ONE material for the whole kit
const tower = buildKitGeometry('watchtower')
// or build your own: primitives in, one draw call out
const crate = mergeParts([
part(new THREE.BoxGeometry(1, 1, 1), { at: [ 0, 0.5, 0 ], color: '#8a6a44', rng }),
part(new THREE.BoxGeometry(1.06, 0.08, 1.06), { at: [ 0, 0.12, 0 ], color: '#4a3728', rng }),
], { grime: 1 })part() transforms a primitive and bakes one jittered shade per triangle;
mergeParts() collapses them through three's own BufferGeometryUtils and
darkens toward the base (the cheapest ambient occlusion there is). The result is
one geometry per prop type, which is the only form that can be instanced.
The kit: ruined-block, crumbled-building, container, crate-stack,
watchtower, pylon, wreck-car, dead-tree, barrel-cluster, rubble-pile,
barricade, tire-stack, road-sign, crag, plus atomic barrel and crate
— each seeded, so
buildKitGeometry('crag') is the same crag every time and
buildKitGeometry('crag', { rng }) is a different one each call.
Placement is a solver, not a list
You describe the rules; it finds the coordinates:
const field = createPlacementField({ rng, extent: 64, heightAt, minHeight: 0 })
const { mesh, placed } = scatterInstances({
geometry, material, count: 40,
place: () => {
const spot = field.place({ radius: 2.7, minDistance: 10, avoidCorridor: 4.8 })
return spot && { at: [ spot.x, heightAt(spot.x, spot.z), spot.z ], rotate: [ 0, rng.next() * 6.28, 0 ] }
},
})Keep-out spacing, a corridor to stay out of, a range, a waterline — enforced by the field, not by the caller. Forty props, one draw call, per-instance tint so identical geometry does not read as identical objects.
Props from language models
modules/assets lets a small model author props. Not by writing
three.js — by filling in a tiny JSON dialect that is validated, compiled, and
then critiqued, so the model can fix its own mistakes:
import { createPropTool, generateProp, buildProp } from 'threejs-scene/modules/assets'
scene.add(buildProp({
name: 'crate',
parts: [
{ name: 'body', shape: 'box', size: [ 0.8, 0.8, 0.8 ], at: [ 0, 0.4, 0 ], color: '#8a6a44' },
{ name: 'band', shape: 'box', size: [ 0.84, 0.07, 0.84 ], at: [ 0, 0.08, 0 ],
repeat: { count: 2, mode: 'linear', offset: [ 0, 0.64, 0 ] }},
],
}))The dialect is designed around what small models get right. Units are metres,
y is up, the ground is y = 0. Every shape — box, sphere, cylinder,
cone, pyramid, prism, capsule, torus, knot, crystal, rock,
wedge, plane, disc, ring — is sized by the box it fills, so there are no
constructor signatures to remember. Rotations are in degrees. repeat
(linear / radial / mirror) covers legs, spokes and railings, so four table
legs are one part, not four chances to fat-finger a coordinate.
And on replaces the coordinate a model is least able to compute:
{ "name": "stem", "shape": "cylinder", "size": [0.09, 0.68, 0.09], "on": "base" },
{ "name": "top", "shape": "cylinder", "size": [0.8, 0.05, 0.8], "on": "stem" }Metric estimation is the measured failure — GPT-4o scores ~10-12% on object-distance and object-size on Apple's CA-VQA benchmark — while stating a relation is something even small models get right. So the model says "the top rests on the stem" and the solver computes the height before anything is built, the same split Holodeck and SceneCraft arrived at.
Four pieces, each usable on its own:
import {
validatePropSpec, // repairs "cube" → "box", "position" → "at", "0.4" → 0.4, …
buildProp, // spec → Prop, deterministic, one geometry per part
reviewProp, // measures the BUILT prop: floats? detached? buried? too heavy?
propAuthoringPrompt // the grammar + worked examples, ~2.8k chars (or 1.4k compact)
} from 'threejs-scene/modules/assets'createPropTool() packages them as a provider-agnostic tool definition — a
name, a description, a JSON Schema (CREATE_PROP_SCHEMA), and a run that never
throws — so it drops into Anthropic input_schema, OpenAI/Gemini parameters,
an ai-sdk tool, or a constrained decoder for a local model.
generateProp() closes the loop, which is where the quality actually comes
from — one shot from a 3B model is a coin flip, but the same model, told "the
prop floats 0.4m above the ground", usually fixes it next turn:
const { prop, review, attempts } = await generateProp({
brief: 'a mossy stone well',
attempts: 3,
complete: async ({ system, prompt }) => callYourModel(system, prompt), // any model, any SDK
})
scene.add(prop)
console.log(review.report)
// mossy stone well: 7 meshes, 1.4k triangles, 1.2 × 1.6 × 1.2m, sits on the groundEverything is DOM-free and GL-free: a server can validate, build, measure, and critique a prop with no canvas anywhere. See the LLM prop authoring starter for the whole path running live, sloppy model output included.
Physics: rigid bodies, cloth, and liquid
modules/physics is an optional layer over cannon-es
— the engine from three's own libraries list
that is pure ESM with no wasm and no async init, so it steps identically in a
headless test and in the browser. It is an optional peer dependency: this is
the only module that imports it.
npm i cannon-esimport { physicsWorld, addGroundPlane, addStaticBox, createCloth, createLiquid } from 'threejs-scene/modules/physics'
const physics = physicsWorld({ gravity: [ 0, -9.82, 0 ], iterations: 16 })
const app = createApp(canvas, { use: [ standardLighting(), physics ] })
addGroundPlane(physics)
physics.add(crate, { mass: 8, friction: 0.85 }) // one compound rigid body
physics.addEach(crateGroup, { mass: 5 }) // direct children move independently
const tarp = createCloth(physics, { // cloth
size: [ 4, 2.6 ], segments: 14, mass: 2.4,
at: [ -2, 3.3, -1.6 ], pin: 'top-edge', wind: [ 0, 0, 4 ],
})
const pool = createLiquid(physics, { // position-based liquid
count: 320, at: [ 4.2, 2.2, 0 ], spawn: [ 1.6, 1.1, 1.6 ],
neighborRadius: 0.24, iterations: 4, viscosity: 0.02, cohesion: 0.01, rng,
})
scene.add(tarp.mesh, pool.mesh)- Rigid / kinetic —
physics.add(object, { mass, shape, friction, restitution })binds anObject3Das one body;addEach()binds direct children separately. Descendant bounds are measured in object-local space, the body is placed at their transformed centre, andcentercan override it. Parent transforms, base pivots, rotation, and bind-time scale remain aligned. - Cloth — a grid of cannon particles held by distance constraints, in the
same solver as everything else, so a barrel dropped on the tarp moves it and
the tarp pushes back.
windis an acceleration in m/s², so behaviour does not change when you change the sheet's mass or resolution. - Liquid — cannon spheres handle gravity and rigid obstacles while a
spatial-hashed position-based solver performs four local density passes,
artificial pressure, cohesion, and XSPH viscosity. Rest density calibrates
from the spawn lattice. A live
MarchingCubessurface is the default;renderMode: 'particles'is the low-power/debug alternative. Tune throughliquid.solver; reset and disposal remain explicit.
The world is stepped at a fixed rate with an accumulator regardless of frame
rate, so the same start replays to the same pile — the determinism the rest of
the package promises survives contact with a falling object. onStep /
onAfterStep hook forces and readback around each step.
See the Salvage yard physics starter for all three running together.
Cameras
Beyond the default perspective rig, createApp({ camera }) accepts any prebuilt
camera — including the two the library ships:
import { createIsoCamera, resizeIsoCamera, aimIsoCamera, createFollowCamera } from 'threejs-scene'
const camera = createIsoCamera(aspect, { viewSize: 20, flavor: 'dimetric' })
const rig = createFollowCamera({ offset: [ 0, 2.6, -7.5 ] })
camera.userData.viewSize = 12 // zoom is a frustum change …
resizeIsoCamera(camera, aspect)
aimIsoCamera(camera, { tilt: 22 }) // … and elevation is a pose changecreateIsoCamera builds a true-iso or dimetric orthographic rig;
createFollowCamera is a damped third-person chase camera whose offset is
expressed in the target's local space, so it banks and turns with it.
rig.aim(station) re-aims a follow rig at runtime — seat, look point and both
damping half-lives — which makes swapping between camera stations a lerp of the
two presets rather than a second camera:
rig.aim({ offset: [ 0, 0.42, 0 ], lookOffset: [ 0, 0.42, 24 ], positionDamping: 0 })lookOffset is in the target's local space, so it aims down the target's nose
rather than at the target — that is what a cockpit view needs. Damping is part of
a station on purpose: exponential smoothing settles roughly speed × half-life
behind a moving target, which is the drift that gives a chase camera its weight
and is exactly what you must not have when the camera is bolted inside the hull.
Use positionDamping: 0 for anything rigidly attached. Ortho
frustums are not handled by the built-in resize (it only fixes perspective
aspect) — call resizeIsoCamera from a module resize hook. aimIsoCamera
re-aims a built rig at runtime, falling back to whatever pose it already has for
every field you omit, so a zoom handler can swing the tilt without knowing the
rest of the setup.
Surviving the device you did not test on
Three pieces for the situation where a scene works everywhere except on somebody's phone.
Read the driver's verdict before you draw. three links every program up front
but does not check the link until the program's first use — so a program that
will not link is bound to a draw anyway, every draw against it raises
INVALID_OPERATION, and the context is eventually taken away. The loss lands
seconds after load, whenever the offending material first came into view, which
is why it reads as thermal rather than as a compile error.
import { reportPrograms } from 'threejs-scene'
// After the scene is built, before the loop starts.
const halt = reportPrograms(app.ctx.renderer, {
say: message => log(message),
fail: message => log(message),
}, false)
if (!halt)
app.start()true means keep the loop stopped — the draws are what turn a refused program
into a dead context, so a scene that never starts cannot lose one, and you get
the failing material's name instead of a black screen.
The other half is the varying census, for when a driver refuses a program and
logs nothing about why. readVaryings(source) walks the #ifdefs and #defines
together, so a varying declared inside a branch the shader never takes is not
counted — flatShading wrapping vNormal in #ifndef FLAT_SHADED is exactly
that case, and counting declarations flat gets it wrong. packedRows then packs
them the way a driver does, and varyingRowLimit(gl) says how many rows this one
has to spend. censusPrograms and describeCensus are the same thing per
program, as a line you can print.
Pick a budget from what the device admits to. readQualitySignals() reads
the cheap proxies — coarse pointer, viewport size, devicePixelRatio, cores —
and describeQualitySignals turns them into one line, because a tier that is
wrong for a device is indistinguishable from a tier that is right and still too
heavy unless you can read what it was picked from. Neither chooses anything: the
mapping from signals to your budget is yours.
Remember what the device already proved. A degradation ladder that re-learns its lesson on every load pays for it with a crash each time.
import { createLadderMemory, readQualitySignals } from 'threejs-scene'
const memory = createLadderMemory({
ladder: [ 'minimal', 'mobile', 'desktop', 'ultra' ] as const,
key: 'app.tier',
build: BUILD_SHA, // a verdict is only about the code that earned it
})
const tier = memory.clamp(pickBudget(readQualitySignals()))
// …once it has run a while without dying:
memory.remember(tier)It only ever argues downward. Something that survived the top step last week may be throttled or on battery now, so a stored high-water mark would be a way to push a device past what it can currently hold.
State that outlives one mount
createStore commits a new object on every write, which is what makes change
detection cheap and what makes a captured reference go stale. Two helpers for
that.
withPath(state, path, value) is writePath without the mutation: it copies
only the spine of the path, so setting look.bloom on a state of twenty sections
copies two objects and keeps the other eighteen. It returns the same object when
the leaf already held that value, so a slider dragged across a value it is
already on wakes no subscribers.
createStateAccess(authored) answers "where does a write go" when the answer
changes over time — a url is parsed before the app mounts, a saved snapshot is
applied before it, and a context loss takes the app down and builds another one
with the values the reader had moved rather than the ones that shipped.
const access = createStateAccess(CONFIG)
access.write('look.bloom', 0.9) // pre-mount: a plain object
const app = createApp(canvas, { state: access.read(), use: [ … ] })
const release = access.adopt(app.store) // the store owns it from here
access.write('look.bloom', 0.4) // now a store commit
release() // teardown keeps the last valueopenStorage(probe) is the matching localStorage probe: it throws on access
in a sandboxed iframe or a spent private-mode quota, so a feature-detect has to be
a real read inside a try. Returns null for "remember nothing" rather than
throwing on boot in the environments least able to say why.
Layout
lib/ is grouped by function — time/ (clock, loop), state/ (store, rng,
dotted paths, storage, ownership handoff), render/ (renderer, resize, program
audit), camera/ (iso + follow rigs), quality/ (device signals, ladder
memory), lifecycle/ (dispose), input/ (pointer-gesture), app/
(composition root).
Every entry in modules/ is a folder with an index.ts — lighting/, orbit/, camera/,
input/, quality/, diagnostics/, persistence/, post/ (the post-processing module, its
passes, shared/ GLSL, and the webgl/ catalogue), assets/ (all procedural content and model
authoring), and optional physics/. all/ re-exports the lot, and is what populates the module
registry. They use only the public surface, which is the point: if the contract were not sufficient
for the built-ins, it would not be sufficient for yours.
llm/ is the payload shipped for language models — hand-written instructions and agent definitions
plus the generated rule and module catalogues. eslint/ is the flat-config plugin, and bin/ the
CLI; both are plain JavaScript, published uncompiled.
site/ is the Vite-built public site: a landing page, one interactive page that
is both the dev playground and the live post-processing demo, and the starters
gallery — all built on the real (published) package.
The frame loop is backed by
@tuomashatakka/canvas-loop-framecapper
— one shared requestAnimationFrame for the whole page; note that an fps
cap applies page-globally.
Next.js and other SSR frameworks
Every entry ships both ESM and CommonJS, so require('threejs-scene') resolves
from a server bundle. The procedural factories, manifest, validation, review,
and prop compiler in modules/assets are DOM-free and work headlessly. Creating
an app renderer or mounting one of the browser starters still touches window,
document, and WebGLRenderer, so that rendering layer must run client-side:
// app/scene.tsx
'use client'
import { createApp } from 'threejs-scene'
// … mount into a ref'd <canvas> inside useEffect, dispose on cleanup
// app/page.tsx
const Scene = dynamic(() => import('./scene'), { ssr: false })transpilePackages is not needed — the CJS condition is what server-side
resolution was missing.
One caveat worth knowing before you require(): three/addons/* is ESM-only
upstream (no require condition), and modules/post, modules/post/webgl,
modules/lighting, and the geometry surface of modules/assets import from it.
Under require() those entries need Node ≥22.12 (where require(esm) landed)
or a bundler. The root entry and modules/orbit remain addon-free and
require() cleanly anywhere.
Don't mix import and require of this package in one process — you get two
copies of every module, so class identity and instanceof stop agreeing.
Scripts
npm run dev (build the package, then serve site/ with Vite — the playground
- live demo in one) ·
npm test(vitest) ·npm run typecheck·npm run lint·npm run build(clean, then tsc →dist/ESM +dist/cjs/CommonJS) ·npm run build:site(Vite →site/dist/, the deployed site, which consumes the built package). CI builds both and publishessite/distto GitHub Pages.
npm run llms regenerates llms.txt from the built .d.ts; npm run llm:assets regenerates
llm/RULES.md, llm/rules.json, llm/modules.json and llm/index.json from the rule catalogue
and the module registry. Both have a :check variant that fails when the committed file is stale,
and the release workflow runs both — a signature or a rule cannot ship with a doc that disagrees.
The dual build is two tsc passes over the same sources — tsconfig.build.json
(ESM) and tsconfig.build.cjs.json (CommonJS) — followed by writing
dist/cjs/package.json with {"type":"commonjs"}. That marker is what makes
the dist/cjs/ subtree load as CJS inside a "type": "module" package.
prepublishOnly gates publishing on publint + attw --pack ., which catch
exports-map and type-condition regressions.
build cleans dist/ first on purpose: tsc never removes stale output, so a
renamed or deleted module would otherwise linger and keep resolving.
