| name | threejs-geometry |
| description | Three.js geometry creation - built-in shapes, BufferGeometry, custom geometry, instancing. Use when creating 3D shapes, working with vertices, building custom meshes, or optimizing with instanced rendering. In HULLBREAKER this is render-layer material only (src/render/*.js, src/ui/, src/main.js) and it is the reference for the InstancedMesh pools that carry the 60fps-with-200+-projectiles budget - tiles, bullets, the limb bake, and transform weather. |
Three.js Geometry
HULLBREAKER guardrails (read before using anything below)
Everything after this section is upstream reference material written for a
generic three.js app. This repo is not generic. Read these eleven points
first; they override the upstream text wherever they disagree.
1. Where this material may legitimately be used
Only these files may contain THREE, geometry, or anything else below:
src/render/ — scene.js, camera.js, tower.js, level.js,
bullets.js, hostiles.js, capsules.js, player.js, mods.js,
limb.js, transform.js, fx.js, hook.js
src/ui/ — hud.js, overlay.js, tint.js, audio.js (DOM, not geometry)
src/main.js — the composition root
Never in src/pure/ or src/sim/. CLAUDE.md, Hard rules: "src/pure/
and src/sim/ never reference THREE, document, or window, and never
import upward." A vertex buffer, a Vector3, an InstancedMesh — none of it
crosses into those two directories, no matter how convenient. Sim-to-render
crossings go through the hooks in src/sim/bridge.js, whose contract says
verbatim: "A hook must never write sim state or the headless run diverges
from the played run."
2. The guard that catches you
tools/pathcheck.mjs line 107 defines
const banned = /\b(THREE|document|window|renderer|scene|addEventListener|requestAnimationFrame|innerWidth|innerHeight|devicePixelRatio|performance)\b/;
and guardLayer() (lines 111-138) runs it over src/config.js +
src/pure/*.js (pure) and src/sim/*.js (sim), after stripping comments —
so prose may name three.js, code may not. The same function enforces the
import allowlist: pure may import only ../config.js or ./sibling.js; sim
may additionally import ../mode.js and ../pure/*.js. A hit calls
process.exit(1) before the 600+ assertion suite even starts, so the
failure is a one-line message, not a FAIL list. Run node tools/pathcheck.mjs
(must exit 0) after every change.
3. Instancing is already solved here — extend it, do not reinvent it
Four working instanced pools exist. Read the closest one before writing a
fifth:
src/render/bullets.js — two InstancedMesh pools (live shots +
bend-cull departure tracers). The live pool is BULLET_MAX (= 256, exported
by src/sim/weapons.js) and is slot-indexed by the same index as the sim's
bulletPool, so no lookup and no allocation per shot. Color uploads are
gated on change (slotType[i] !== type), and setColorAt(0, ...) runs once
at module load purely to allocate instanceColor up front.
src/render/level.js — one tile InstancedMesh for the whole six-face
tower, baked once with tileBaseMats (final matrix per instance),
columnInstances (column to instance range) and faceRanges, so the corner
ritual can move columns without ever rebuilding the mesh.
src/render/limb.js — one InstancedMesh per material key, all sharing a
single BoxGeometry(1,1,1), ~800 armour pieces uploaded once and never
touched again. Its header comment states the reason: "~800 armour pieces
would otherwise be ~800 draw calls."
src/render/transform.js — the transform-slice band and vapor pools.
Two shared helpers you must use rather than duplicate:
- Hiding an instance:
HIDE from src/render/scene.js
(new THREE.Matrix4().makeScale(0, 0, 0)). Do not scene.remove(),
dispose(), or resize a pool at runtime to make something disappear.
- (s, y) to world:
towerPose(), placeOnTower(), placeSharp() in
src/render/tower.js. That module is "the one place logical (s, y) becomes
a 3D position." Computing your own curve/offset math is how the rendered
ribbon and the 2D simulation drift apart.
Note also the convention mesh.frustumCulled = false on level-spanning
instanced pools (bullets.js:21, level.js:101, limb.js:103) — a pool full
of HIDE-scaled instances has a degenerate bounding sphere and would vanish.
4. Per-frame allocation is the budget, not a style preference
docs/DESIGN.md, "Technical acceptance": "60fps target with 200+ projectiles
and the target traversal density." The shipped pattern is module-scope scratch
objects reused every frame — bullets.js holds _bm (Matrix4), _bq
(Quaternion), _be (Euler), _bs/_bv (Vector3), _shotColor (Color);
limb.js holds _m/_q/_e/_s/_v/_c/_tint; tower.js holds _pp/_pose;
hostiles.js reuses one HOUND_POSE object with the comment "sync runs per
hostile per frame, so no allocation."
Several upstream examples below allocate inside the loop (new THREE.Object3D()
per instance, new THREE.Color(...) per setColorAt, a fresh Matrix4 per
update). That is fine at bake time (level.js:116 does exactly that once,
at startup) and a GC stutter at frame time. Anything reached from a
view.* hook in src/sim/bridge.js is frame time.
5. Determinism: five examples below call Math.random()
CLAUDE.md: "randomness only via seeded src/pure/rng.js. No Math.random,
Date.now, or performance.now in src/pure/ or src/sim/." Math.random
currently appears nowhere in src/ except a comment in src/ui/audio.js:79
("ui may use Math.random, but a seeded source costs nothing"). The seeded
source is mulberry32() in src/pure/rng.js.
Render-side scatter (point clouds, debris fields, instance jitter) is not
caught by pathcheck, but the bot playtest runs --deterministic
(tools/playtest/run.mjs) and index.html?selftest=1 compares runs — a
render layer that reshuffles itself every reload makes screenshot evidence
worthless. Seed it from mulberry32 at bake time.
6. Static-anatomy rule: morph targets and assembly need a decision first
docs/decisions.md entry 3 (2026-07-30, CP3 verdict) is law: "the creature's
anatomy is monumental and static during a transition — RIG and the camera
are what move. The next stretch of world already exists and is revealed ...
never assembled, slammed, or articulated into place." Only doors, access
plates, vent covers, shutters, traps, and Crown mechanisms may move.
Concretely, from the material below:
- Morph targets, animated
position attributes, and computeVertexNormals()
in a frame loop applied to the creature's body are exactly the choppy
"assets thrown together" the operator rejected. They need a new operator
decision recorded in docs/decisions.md before shipping — not a flag, not
a "just prototyping it" merge.
- The same entry's addendum keeps zip-assembly for "traps that assemble or
different enemies that are presented later." That choreography already exists
and is deliberately preserved:
zipperColumn() and faceRevealed() in
src/render/level.js (see the block comment at lines 31-38, and the IS_G1
early-returns that make a limb refuse to assemble). Extend those for the
traps/emplacements lane instead of writing a second assembler.
7. No color literals in tokenized render files
Today the color tokens live in the palette block of src/config.js
(CONFIG.palette, from line 482) and CONFIG.limb.bg. A palette
centralization (src/render/palette.js, task T-010, commit 680c21b) is
in flight and not yet on main; when it merges, tokenized render files
read PAL.* and pathcheck asserts that scene.js, level.js, capsules.js,
bullets.js, player.js, mods.js, limb.js, transform.js, tower.js,
fx.js contain zero CONFIG.palette / CONFIG.limb.bg reads
(hostiles.js and hook.js are explicitly exempt). Either way, the rule for
new geometry is the same: no raw hex in a render file that a token already
covers. Every 0x00ff00 in the upstream examples below is placeholder, not
a permitted value. Values are also chosen against what the renderer produces
under ACES tone mapping, not against the hex — see the ladder reasoning in
src/render/limb.js:32-44.
8. Module specifiers: three and three/addons/ only
index.html ships an import map with exactly two entries, pinned to 0.170.0:
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
Upstream writes three/examples/jsm/..., which is not mapped and will
fail to resolve in the browser. Those specifiers are corrected to
three/addons/... inline below. There is no build step and no package
manager for the game — never run npm install for anything under src/;
dev-only dependencies are allowed under tools/*/ with their own
package.json (e.g. tools/playtest/package.json) and nowhere else. Nothing
in src/ currently imports an addon at all, so the first addon import is a
new runtime dependency on a second CDN file: raise it with the integrator.
9. Raycasting cannot touch the simulation
CLAUDE.md: "The simulation stays 2D (s, y); collision, physics, aiming,
and spawning never leave it." The upstream raycaster.intersectObject(instancedMesh)
recipe is legitimate only for presentation or debug tooling. Feeding an
instanceId back into hit detection, aiming, or spawn logic breaks both the
2D rule and the bridge contract, and would silently desync the headless bot
harness from the played run. src/render/camera.js calling
src/sim/edges.js#setEdges() is the one documented render-to-sim write
(see the "Known exception" note at the top of src/sim/bridge.js); do not add
a second.
10. Segment counts and silhouettes are judged at the FAR camera
docs/decisions.md entry 7 (2026-07-30): FAR is the default view, with RIG at
about 3.7% of screen height, matching concept board 13's 3-5% band
(docs/concept-art/README.md, "Visual invariants"). A 64-segment sphere that
reads as a smooth ball at ?view=near is spending triangles nobody can see at
the shipped default; conversely, detail that only survives as a silhouette
needs to survive as a silhouette. Same entry: projectiles must not curve
around bends — the departure tracers in src/render/bullets.js (bendCulled)
are presentation-only and must stay that way.
11. What you may not decide yourself
Machine gates never judge fun. Pathcheck passing says nothing about
whether new geometry reads correctly. Anything visual goes to SPRINT.md's
operator checkpoint queue as a packet with an exact URL and 3-5 questions;
never self-declare a look good. Any of the following needs an operator
decision recorded in docs/decisions.md first, and this skill does not
grant it:
- animated/morphing geometry on the creature's anatomy (entry 3),
- a new runtime dependency, including a second CDN module (
three/addons/*),
- changing the default view scale or camera framing (entry 7),
- new color roles outside the palette tokens,
- raycast-driven or otherwise 3D-derived gameplay state (2D rule),
- retuning frozen
CONFIG movement constants to accommodate a visual.
Also standing: work only inside your assigned worktree and task scope, and
never commit or push to main — tools/orch/merge-task.sh is the only path.
Everything below is the upstream threejs-geometry reference, preserved.
Inline // HULLBREAKER: comments mark places where an example as written
would violate one of the rules above. API corrections against the pinned
three.js 0.170.0 are marked // CORRECTED for 0.170.0.
Quick Start
import * as THREE from "three";
const box = new THREE.BoxGeometry(1, 1, 1);
const sphere = new THREE.SphereGeometry(0.5, 32, 32);
const plane = new THREE.PlaneGeometry(10, 10);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(box, material);
scene.add(mesh);
Built-in Geometries
Basic Shapes
new THREE.BoxGeometry(1, 1, 1, 1, 1, 1);
new THREE.SphereGeometry(1, 32, 32);
new THREE.SphereGeometry(1, 32, 32, 0, Math.PI * 2, 0, Math.PI);
new THREE.SphereGeometry(1, 32, 32, 0, Math.PI);
new THREE.PlaneGeometry(10, 10, 1, 1);
new THREE.CircleGeometry(, );
.(, , , .);
.(, , , , , );
.(, , , );
.(, , , );
.(, , , , );
.(, , , );
.(, , , , , );
.(, , , );
Advanced Shapes
new THREE.CapsuleGeometry(0.5, 1, 4, 8);
new THREE.DodecahedronGeometry(1, 0);
new THREE.IcosahedronGeometry(1, 0);
new THREE.OctahedronGeometry(1, 0);
new THREE.TetrahedronGeometry(1, 0);
const vertices = [1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1];
const indices = [2, 1, 0, 0, 3, 2, , , , , , ];
.(vertices, indices, , );
Path-Based Shapes
const points = [
new THREE.Vector2(0, 0),
new THREE.Vector2(0.5, 0),
new THREE.Vector2(0.5, 1),
new THREE.Vector2(0, 1),
];
new THREE.LatheGeometry(points, 32);
const shape = new THREE.Shape();
shape.moveTo(0, 0);
shape.lineTo(1, 0);
shape.lineTo(1, 1);
shape.lineTo(0, 1);
shape.lineTo(0, 0);
const extrudeSettings = {
steps: 2,
depth: 1,
bevelEnabled: true,
bevelThickness: ,
: ,
: ,
};
.(shape, extrudeSettings);
curve = .([
.(-, , ),
.(, , ),
.(, , ),
]);
.(curve, , , , );
Text Geometry
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
const loader = new FontLoader();
loader.load("fonts/helvetiker_regular.typeface.json", (font) => {
const geometry = new TextGeometry("Hello", {
font: font,
size: 1,
depth: 0.2,
curveSegments: 12,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelSegments: 5,
});
geometry.computeBoundingBox();
geometry.center();
const mesh = .(geometry, material);
scene.(mesh);
});
BufferGeometry
The base class for all geometries. Stores data as typed arrays for GPU efficiency.
Custom BufferGeometry
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
-1,
-1,
0,
1,
-1,
0,
1,
1,
0,
-1,
1,
0,
]);
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
const indices = new Uint16Array([
0,
1,
2,
0,
2,
3,
]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
const normals = new Float32Array([0, , , , , , , , , , , ]);
geometry.(, .(normals, ));
uvs = ([, , , , , , , ]);
geometry.(, .(uvs, ));
colors = ([
,
,
,
,
,
,
,
,
,
,
,
,
]);
geometry.(, .(colors, ));
BufferAttribute Types
new THREE.BufferAttribute(array, itemSize);
new Float32Array(count * itemSize);
new Uint16Array(count);
new Uint32Array(count);
new Uint8Array(count * itemSize);
Modifying BufferGeometry
const positions = geometry.attributes.position;
positions.setXYZ(index, x, y, z);
const x = positions.getX(index);
const y = positions.getY(index);
const z = positions.getZ(index);
positions.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
Interleaved Buffers (Advanced)
const interleavedBuffer = new THREE.InterleavedBuffer(
new Float32Array([
-1, -1, 0, 0, 0, 1, -1, 0, 1, 0, 1, 1, 0, 1, 1, -1, 1, 0, 0, 1,
]),
5,
);
geometry.setAttribute(
"position",
new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 0),
);
geometry.setAttribute(
"uv",
new THREE.InterleavedBufferAttribute(interleavedBuffer, 2, 3),
);
EdgesGeometry & WireframeGeometry
const edges = new THREE.EdgesGeometry(boxGeometry, 15);
const edgeMesh = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({ color: 0xffffff }),
);
const wireframe = new THREE.WireframeGeometry(boxGeometry);
const wireMesh = new THREE.LineSegments(
wireframe,
new THREE.LineBasicMaterial({ color: 0xffffff }),
);
Points
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(1000 * 3);
for (let i = 0; i < 1000; i++) {
positions[i * 3] = (Math.random() - 0.5) * 10;
positions[i * 3 + 1] = (Math.random() - 0.5) * 10;
positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.1,
sizeAttenuation: true,
: ,
});
points = .(geometry, material);
scene.(points);
Lines
const points = [
new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(1, 0, 0),
];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(
geometry,
new THREE.LineBasicMaterial({ color: 0xff0000 }),
);
const loop = new THREE.LineLoop(geometry, material);
const segmentsGeometry = new THREE.BufferGeometry();
segmentsGeometry.setAttribute(
"position",
new THREE.BufferAttribute(
new Float32Array([
-,
,
,
,
,
,
,
,
,
,
,
,
]),
,
),
);
segments = .(segmentsGeometry, material);
InstancedMesh
Efficiently render many copies of the same geometry.
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const count = 1000;
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * ,
);
dummy..(.() * ., .() * ., );
dummy..( + .());
dummy.();
instancedMesh.(i, dummy.);
}
instancedMesh.. = ;
instancedMesh. = .(
(count * ),
,
);
( i = ; i < count; i++) {
instancedMesh.(
i,
.(.(), .(), .()),
);
}
instancedMesh.. = ;
scene.(instancedMesh);
Update Instance at Runtime
const matrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(index, matrix);
instancedMesh.setMatrixAt(index, matrix);
instancedMesh.instanceMatrix.needsUpdate = true;
const intersects = raycaster.intersectObject(instancedMesh);
if (intersects.length > 0) {
const instanceId = intersects[0].instanceId;
}
InstancedBufferGeometry (Advanced)
For custom per-instance attributes beyond transform/color.
const geometry = new THREE.InstancedBufferGeometry();
geometry.copy(new THREE.BoxGeometry(1, 1, 1));
const offsets = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
offsets[i * 3] = Math.random() * 10;
offsets[i * 3 + 1] = Math.random() * 10;
offsets[i * 3 + 2] = Math.random() * 10;
}
geometry.setAttribute("offset", new THREE.InstancedBufferAttribute(offsets, 3));
Geometry Utilities
import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";
const merged = BufferGeometryUtils.mergeGeometries([geo1, geo2, geo3]);
const mergedWithGroups = BufferGeometryUtils.mergeGeometries([geo1, geo2], true);
geometry.computeTangents();
const interleaved = BufferGeometryUtils.interleaveAttributes([
geometry.attributes.position,
geometry.attributes.normal,
geometry.attributes.uv,
]);
Common Patterns
Center Geometry
geometry.computeBoundingBox();
geometry.center();
Scale to Fit
geometry.computeBoundingBox();
const size = new THREE.Vector3();
geometry.boundingBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
geometry.scale(1 / maxDim, 1 / maxDim, 1 / maxDim);
Clone and Transform
const clone = geometry.clone();
clone.rotateX(Math.PI / 2);
clone.translate(0, 1, 0);
clone.scale(2, 2, 2);
Morph Targets
const geometry = new THREE.BoxGeometry(1, 1, 1, 4, 4, 4);
const morphPositions = geometry.attributes.position.array.slice();
for (let i = 0; i < morphPositions.length; i += 3) {
morphPositions[i] *= 2;
morphPositions[i + 1] *= 0.5;
}
geometry.morphAttributes.position = [
new THREE.BufferAttribute(new Float32Array(morphPositions), 3),
];
const mesh = new THREE.Mesh(geometry, material);
mesh.morphTargetInfluences[0] = 0.5;
Performance Tips
- Use indexed geometry: Reuse vertices with indices
- Merge static meshes: Reduce draw calls with
mergeGeometries
- Use InstancedMesh: For many identical objects
- Choose appropriate segment counts: More segments = smoother but slower
- Dispose unused geometry:
geometry.dispose()
new THREE.SphereGeometry(1, 32, 32);
new THREE.SphereGeometry(1, 64, 64);
new THREE.SphereGeometry(1, 16, 16);
geometry.dispose();
See Also
threejs-fundamentals - Scene setup and Object3D
threejs-materials - Material types for meshes
threejs-shaders - Custom vertex manipulation