| name | threejs-fundamentals |
| description | Three.js scene setup, cameras, renderer, Object3D hierarchy, coordinate systems. Use when setting up 3D scenes, creating cameras, configuring renderers, managing object hierarchies, or working with transforms. In HULLBREAKER use it only to extend the one existing rig — `src/render/scene.js` (renderer/scene/camera/lights), `src/render/camera.js` (the operator-frozen FAR camera), `src/main.js` (the single frame loop) — never to stand up a second one. |
HULLBREAKER guardrails (read before using anything below)
This repository already owns a renderer, a scene, a camera, a light rig, and a
frame loop. Everything after this section is upstream three.js reference,
written for a blank project. Read it for API detail; apply it only through the
files named here.
1. The rig exists — extend it, never re-create it.
src/render/scene.js is the only place new THREE.WebGLRenderer, new THREE.Scene, the shipped new THREE.PerspectiveCamera, and the light rig
(HemisphereLight + DirectionalLight) are constructed. It exports
renderer, scene, camera, and HIDE. Every other render module imports
from it (src/render/level.js, bullets.js, hostiles.js, limb.js,
transform.js, player.js, capsules.js, mods.js, hook.js, tower.js).
The single frame loop is frame() in src/main.js (~line 378): one
requestAnimationFrame, one renderer.render(scene, camera), then
updateHUD(). A second RAF loop, a second renderer.render call, or a second
canvas appended to document.body is a defect, not a style choice.
Check yourself with grep -rn "new THREE.WebGLRenderer" src/ — one hit is correct.
2. Resize and the sim's screen edges are one operation.
src/main.js (~line 89) registers addEventListener('resize', handleResize);
handleResize() lives in src/render/camera.js and updates camera.aspect,
calls updateProjectionMatrix(), resizes the renderer, and re-runs
calibrateEdges(), which unprojects the frustum edges and pushes them into the
simulation via setEdges() (src/sim/edges.js). Upstream's "Responsive
Canvas" recipe omits that last step; using it verbatim desynchronizes sim
collision/spawn edges from what the player sees. Change the camera → call
calibrateEdges().
3. The camera pose is law, not taste.
docs/decisions.md entry 7 (2026-07-30, "View-scale verdict: FAR is the
default; bullets don't turn corners") makes FAR the default view — RIG ≈ 3.7%
of screen height, matching concept board 13's 3–5% range — with ?view=near
required to stay byte-identical to the pre-view-scale camera. The code that
implements it: VIEW_ID in src/mode.js (line 78, unrecognized/absent
?view= resolves to 'far'), activeCameraDepth() / syncCamera() in
src/render/camera.js, and the CONFIG.camera + CONFIG.viewScales tables in
src/config.js (lines ~11 and ~29). Editing fov, x/y/z, lookX/lookY, or a
depthMult re-tunes the judged default view. That needs a new operator
decision recorded in docs/decisions.md before you touch it — never a
re-litigation of entry 7. tools/pathcheck.mjs also asserts a player
screen-height bound ("player under 9 percent of screen height"), which will
fail loudly on an accidental retune, but passing it is not permission.
4. Fog is owned, not free-form.
scene.fog is created in src/render/scene.js from CONFIG.palette.bg +
CONFIG.fog, then its near/far are re-derived every frame by
calibrateEdges() in src/render/camera.js (view pull-back shift, ?g1=1
limb haze) and by src/render/transform.js in the transform slice. Setting
scene.fog from a new module is either overwritten next frame or silently
breaks contrast at depth. Add your band to those owners instead.
5. Layer purity is statically enforced — nothing below may enter src/pure/ or src/sim/.
tools/pathcheck.mjs (lines ~95–138) strips comments and then rejects, in
src/config.js, src/pure/*.js and src/sim/*.js, any match of
\b(THREE|document|window|renderer|scene|addEventListener|requestAnimationFrame|innerWidth|innerHeight|devicePixelRatio|performance)\b,
and rejects any import outside the layer allowlist (pure: ../config.js or
./x.js; sim: also ../mode.js and ../pure/x.js). So THREE.Vector3,
THREE.MathUtils, THREE.Clock, window.innerWidth — all fine in
src/render/, src/ui/, src/main.js; all an instant process.exit(1) in
pure/sim. Sim→render crossings go through the view.* hooks in
src/sim/bridge.js; render modules register with installView({...}). The one
documented exception, already in the code, is src/render/camera.js calling
setEdges() directly (see the contract comment at the top of
src/sim/bridge.js). Gate: node tools/pathcheck.mjs must exit 0.
6. Determinism outranks convenience.
Seeded randomness only, via mulberry32 in src/pure/rng.js.
THREE.MathUtils.randFloat / randInt are unseeded and must never produce a
value the simulation reads; the same goes for Math.random, Date.now,
performance.now, and THREE.Clock. performance.now() in src/main.js
(line 377) is the host layer and is fine — the sim advances on the dt handed
to update(dt) plus gameMs in src/sim/time.js, and ?fixeddt= exists for
reproducible runs. A THREE.Clock driving a purely cosmetic render effect is
legal in src/render/, provided nothing sim-side ever reads it. The simulation
is strictly 2D (s, y); Vector3/Matrix4/Quaternion are render-only
tools, and polyAt() in src/pure/path.js is what maps (s, y) onto 3D.
7. Colors come from the palette table.
CONFIG.palette in src/config.js (~line 482) holds bg, ground,
catwalk, player, gun, the hostile/tell colors, per-weapon shots, and
the overlay tints; src/render/scene.js reads it for the background and fog.
Add a named token there instead of inlining a hex in a render module. Honest
state of the guards as of this install: pathcheck asserts every weapon in
CONFIG.weapons has a CONFIG.palette.shots entry (tools/pathcheck.mjs
~line 2294), but there is no general raw-color-literal guard in the main
checkout — this one is discipline plus review, not a machine gate. A palette
token module (src/render/palette.js) is in flight in a concurrent lane; if
that file exists when you read this, import from it rather than adding
literals.
8. Static anatomy: what you may animate is a short list.
docs/decisions.md entry 3 (CP3 verdict, 2026-07-30): the creature's anatomy
is monumental and static during turns and transitions — RIG and the camera
move, the next stretch pre-exists and is revealed, never assembled. Only
doors, access plates, vent covers, shutters, traps, and Crown mechanisms may
move. So Object3D transform animation on hull/limb/anatomy meshes to "bring
in" the next band is a rule violation regardless of how good it looks. The
camera-side reveal is already built: the two-detent yaw ritual in
syncCamera() (src/render/camera.js) plus towerPose()
(src/render/tower.js). The zip-assembly choreography
(view.level.zipperColumn in src/sim/bridge.js, implemented in
src/render/level.js) is retained-but-retired for the body per entry 3's
addendum — keep it extractable for traps/emplacements, do not extend it for
anatomy.
9. No build step, no npm, addons only via three/addons/.
three.js 0.170.0 arrives from the CDN import map in index.html (lines 46–53),
which maps exactly two specifiers: "three" and "three/addons/". Never run
npm install for the game (dev-only deps are allowed under tools/*/ with
their own package.json, e.g. tools/playtest). Any upstream snippet
importing three/examples/jsm/… will 404 here — it is corrected to
three/addons/… below. Nothing in src/ imports an addon today.
10. The house pooling pattern already exists.
Upstream's "object pooling" tip is solved: src/render/scene.js exports HIDE
(a zero-scale Matrix4), and src/render/bullets.js, level.js, limb.js,
and transform.js use THREE.InstancedMesh + setMatrixAt(i, HIDE) +
instanceMatrix.needsUpdate = true to park unused instances. Extend that
pattern instead of inventing a pool. Disposal precedent lives in
src/render/hostiles.js (scene.remove(c.mesh) + c.mat.dispose()) and
src/render/capsules.js.
11. Anything not already shipped needs an operator decision first.
The following are not sanctioned in this repo today and are not yours to
introduce on your own judgment: a second camera or render pass
(ArrayCamera, CubeCamera, render targets), an OrthographicCamera view,
shadow maps (renderer.shadowMap is untouched — grep -rn "shadowMap" src/
returns nothing), tone-mapping or color-space changes (ACESFilmicToneMapping
is already set in src/render/scene.js; changing it changes every judged
screenshot), new scene lights, asset loaders (GLTFLoader/TextureLoader — no
loader ships today), and any change to the view scale. Each one needs a
decision recorded in docs/decisions.md before implementation. Prototype work
that has been sanctioned ships behind an off-by-default query flag.
12. You do not get to judge how it looks.
Machine gates never judge fun. node tools/pathcheck.mjs (exit 0),
index.html?selftest=1 (SELFTEST PASS in the title), and the bot playtest
(tools/playtest/README.md) are evidence only. Any visual change — camera,
fog, palette, lighting, silhouette — is a feel question for the operator: post
a checkpoint packet under "Operator checkpoint queue" in SPRINT.md (line 308)
with an exact URL and 3–5 questions, and never self-declare the result good.
Judge screenshots against docs/concept-art/README.md boards 13/14 and the
visual invariants, not against your taste.
Three.js Fundamentals
Quick Start
import * as THREE from "three";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.(renderer.);
geometry = .(, , );
material = .({ : });
cube = .(geometry, material);
scene.(cube);
scene.( .(, ));
dirLight = .(, );
dirLight..(, , );
scene.(dirLight);
camera.. = ;
() {
(animate);
cube.. += ;
cube.. += ;
renderer.(scene, camera);
}
();
.(, {
camera. = . / .;
camera.();
renderer.(., .);
});
Core Classes
Scene
Container for all 3D objects, lights, and cameras.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.background = texture;
scene.background = cubeTexture;
scene.environment = envMap;
scene.fog = new THREE.Fog(0xffffff, 1, 100);
scene.fog = new THREE.FogExp2(0xffffff, 0.02);
Cameras
PerspectiveCamera - Most common, simulates human eye.
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
OrthographicCamera - No perspective distortion, good for 2D/isometric.
const aspect = window.innerWidth / window.innerHeight;
const frustumSize = 10;
const camera = new THREE.OrthographicCamera(
(frustumSize * aspect) / -2,
(frustumSize * aspect) / 2,
frustumSize / 2,
frustumSize / -2,
0.1,
1000,
);
ArrayCamera - Multiple viewports with sub-cameras.
const cameras = [];
for (let i = 0; i < 4; i++) {
const subcamera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
subcamera.viewport = new THREE.Vector4(
Math.floor(i % 2) * 0.5,
Math.floor(i / 2) * 0.5,
0.5,
0.5,
);
cameras.push(subcamera);
}
const arrayCamera = new THREE.ArrayCamera(cameras);
CubeCamera - Renders environment maps for reflections.
const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256);
const cubeCamera = new THREE.CubeCamera(0.1, 1000, cubeRenderTarget);
scene.add(cubeCamera);
material.envMap = cubeRenderTarget.texture;
cubeCamera.position.copy(reflectiveMesh.position);
cubeCamera.update(renderer, scene);
WebGLRenderer
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector("#canvas"),
antialias: true,
alpha: true,
powerPreference: "high-performance",
preserveDrawingBuffer: true,
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.;
renderer.. = ;
renderer.. = .;
renderer.(, );
renderer.(scene, camera);
Object3D
Base class for all 3D objects. Mesh, Group, Light, Camera all extend Object3D.
const obj = new THREE.Object3D();
obj.position.set(x, y, z);
obj.rotation.set(x, y, z);
obj.quaternion.set(x, y, z, w);
obj.scale.set(x, y, z);
obj.getWorldPosition(targetVector);
obj.getWorldQuaternion(targetQuaternion);
obj.getWorldDirection(targetVector);
obj.add(child);
obj.remove(child);
obj.parent;
obj.children;
obj.visible = false;
obj.layers.set(1);
obj.layers.enable(2);
obj.layers.disable(0);
obj.traverse(() => {
(child.) child...();
});
obj. = ;
obj.();
obj.();
Group
Empty container for organizing objects.
const group = new THREE.Group();
group.add(mesh1);
group.add(mesh2);
scene.add(group);
group.position.x = 5;
group.rotation.y = Math.PI / 4;
Mesh
Combines geometry and material.
const mesh = new THREE.Mesh(geometry, material);
const mesh = new THREE.Mesh(geometry, [material1, material2]);
mesh.geometry;
mesh.material;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.frustumCulled = true;
mesh.renderOrder = 10;
Coordinate System
Three.js uses a right-handed coordinate system:
- +X points right
- +Y points up
- +Z points toward viewer (out of screen)
HULLBREAKER: the simulation never lives here. It is strictly 2D in (s, y);
polyAt() in src/pure/path.js maps the scroll cursor s onto the 3D
polyline for rendering only, and src/render/camera.js derives the camera's
world position from it. Collision, physics, aiming and spawning stay in
(s, y) — see the determinism rule in CLAUDE.md.
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);
Math Utilities
Vector3
const v = new THREE.Vector3(x, y, z);
v.set(x, y, z);
v.copy(otherVector);
v.clone();
v.add(v2);
v.sub(v2);
v.multiply(v2);
v.multiplyScalar(2);
v.divideScalar(2);
v.normalize();
v.negate();
v.clamp(min, max);
v.lerp(target, alpha);
v.length();
v.lengthSq();
v.distanceTo(v2);
v.dot(v2);
v.cross(v2);
v.angleTo(v2);
v.applyMatrix4(matrix);
v.applyQuaternion(q);
v.project(camera);
v.unproject(camera);
Matrix4
const m = new THREE.Matrix4();
m.identity();
m.copy(other);
m.clone();
m.makeTranslation(x, y, z);
m.makeRotationX(theta);
m.makeRotationY(theta);
m.makeRotationZ(theta);
m.makeRotationFromQuaternion(q);
m.makeScale(x, y, z);
m.compose(position, quaternion, scale);
m.decompose(position, quaternion, scale);
m.multiply(m2);
m.premultiply(m2);
m.invert();
m.transpose();
m.makePerspective(left, right, top, bottom, near, far);
m.makeOrthographic(left, right, top, bottom, near, far);
m.lookAt(eye, target, up);
Quaternion
const q = new THREE.Quaternion();
q.setFromEuler(euler);
q.setFromAxisAngle(axis, angle);
q.setFromRotationMatrix(matrix);
q.multiply(q2);
q.slerp(target, t);
q.normalize();
q.invert();
Euler
const euler = new THREE.Euler(x, y, z, "XYZ");
euler.setFromQuaternion(q);
euler.setFromRotationMatrix(m);
Color
const color = new THREE.Color(0xff0000);
const color = new THREE.Color("red");
const color = new THREE.Color("rgb(255, 0, 0)");
const color = new THREE.Color("#ff0000");
color.setHex(0x00ff00);
color.setRGB(r, g, b);
color.setHSL(h, s, l);
color.lerp(otherColor, alpha);
color.multiply(otherColor);
color.multiplyScalar(2);
MathUtils
THREE.MathUtils.clamp(value, min, max);
THREE.MathUtils.lerp(start, end, alpha);
THREE.MathUtils.mapLinear(value, inMin, inMax, outMin, outMax);
THREE.MathUtils.degToRad(degrees);
THREE.MathUtils.radToDeg(radians);
THREE.MathUtils.randFloat(min, max);
THREE.MathUtils.randInt(min, max);
THREE.MathUtils.smoothstep(x, min, max);
THREE.MathUtils.smootherstep(x, min, max);
Common Patterns
Proper Cleanup
function dispose() {
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
mesh.material.forEach((m) => m.dispose());
} else {
mesh.material.dispose();
}
texture.dispose();
scene.remove(mesh);
renderer.dispose();
}
Clock for Animation
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
mesh.rotation.y += delta * 0.5;
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
Responsive Canvas
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
window.addEventListener("resize", onWindowResize);
Loading Manager
const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => console.log("Started loading");
manager.onLoad = () => console.log("All loaded");
manager.onProgress = (url, loaded, total) => console.log(`${loaded}/${total}`);
manager.onError = (url) => console.error(`Error loading ${url}`);
const textureLoader = new THREE.TextureLoader(manager);
const gltfLoader = new GLTFLoader(manager);
Performance Tips
- Limit draw calls: Merge geometries, use instancing, atlas textures
- Frustum culling: Enabled by default, ensure bounding boxes are correct
- LOD (Level of Detail): Use
THREE.LOD for distance-based mesh switching
- Object pooling: Reuse objects instead of creating/destroying
- Avoid
getWorldPosition in loops: Cache results
HULLBREAKER: instancing and pooling are already the house pattern —
THREE.InstancedMesh + setMatrixAt(i, HIDE) + instanceMatrix.needsUpdate
in src/render/bullets.js, level.js, limb.js, transform.js, with HIDE
exported from src/render/scene.js. Extend those meshes rather than adding new
draw calls. Note that a swept THREE.LOD changes silhouette readability at the
FAR default view (decisions.md entry 7), which is an operator feel question,
not a free optimization.
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
const merged = mergeGeometries([geo1, geo2, geo3]);
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0);
lod.addLevel(medDetailMesh, 50);
lod.addLevel(lowDetailMesh, 100);
scene.add(lod);
See Also
threejs-geometry - Geometry creation and manipulation
threejs-materials - Material types and properties
threejs-lighting - Light types and shadows
HULLBREAKER: those sibling skills exist in the upstream pack but are only
available here if separately installed under .claude/skills/; check that
directory before referring to one. Whatever they say, the guardrails at the top
of this file still bind — docs/decisions.md is law, node tools/pathcheck.mjs
must exit 0, and the operator is the only judge of how anything looks.