Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- ---
- name: threejs-experience-engineering
- description: Build polished, performant, accessible Three.js scenes, interactive 3D sites, first-person web games, procedural worlds, shaders, model viewers, and lightweight AR. This skill synthesizes the supplied Three.js and adjacent web-game examples.
- version: 1.2.0
- ---
- # Three.js Experience Engineering
- Use this skill when an implementation needs an interactive 3D web experience. Favor a small, coherent architecture over a giant one-file experiment. Make the 3D scene, DOM UI, input, audio, asset loading, performance budget, and cleanup work together.
- ## Operating instructions for an AI
- always have mobile support
- 1. **Classify the request before writing code.** Choose one of these paths:
- - **3D hero / art piece:** scene, camera, materials, animation, possibly postprocessing.
- - **Explorable or game-like world:** first-person controls, collision, interaction, UI, and a delta-time simulation.
- - **Model showcase / commerce / AR preview:** prefer `<model-viewer>` unless custom Three.js behavior is truly required.
- - **2D game:** use Canvas 2D unless real 3D adds value; do not force Three.js into a Pac-Man-style game.
- - **Inference-heavy app with decorative 3D:** explicitly budget GPU time and pause, simplify, or dispose of the scene while inference runs.
- 2. **Pick one Three.js delivery style and stay with it.** Use either modern ES modules plus an import map/bundler, or legacy global scripts. Do not mix their addons casually.
- 3. **Pin one Three.js version** for core and every addon. Never combine a core build from one revision with an addon from another.
- 4. **Create the smallest working scene first:** canvas → renderer → scene → camera → one lit mesh → resize → delta-time loop. Then add assets, controls, shaders, game systems, and polish in layers.
- 5. **Use delta time for all simulation.** Clamp a resumed/background-tab frame so movement, physics, and animations do not jump.
- 6. **Keep gameplay state independent from render objects.** A player record such as `{ x, z, vx, vz, yaw }` is authoritative; the camera and meshes reflect it.
- 7. **Budget performance from the start.** Cap device pixel ratio, reuse objects, instance repeated geometry, throttle expensive queries, and dispose replaced worlds.
- 8. **Provide a graceful fallback.** A missing model, no WebGL, reduced-motion preference, failed audio permission, or a small touch device must still leave a usable page.
- ---
- ## Source corpus distilled
- Every supplied file is represented below. Some are direct Three.js references; others are deliberately useful boundary cases.
- | Source | What to learn from it | Use with care |
- |---|---|---|
- | `mysite.html` | Minimal ES-module scene; shader-driven icosahedron; uniforms; animation loop; resize handling. | It is intentionally tiny and does not include controls, loading, accessibility, or a DPR cap. |
- | `glassballs.html` | Import-map setup; `OrbitControls`; a 3,000-instance custom shader object; custom depth material; shadows; bloom composer. | Its long shader and embedded textures are revision-sensitive and expensive. Prefer simpler native materials or `InstancedMesh` until custom attributes are necessary. |
- | `ai.html` | SVG path → shapes → extrusion; bounds normalization; merged fracture geometry; `onBeforeCompile`; sprites; `InstancedMesh`; PMREM; raycasting; an interaction state machine; parking a scene to free GPU time. | It includes a large model/inference bundle. Keep authored scene code separate from generated/bundled inference code. |
- | `aithinking.html` | A compiled WebGPU/React inference application. Its bundled internals are not a clean authored scene reference. | Do not copy minified bundle code into a Three.js project. Treat it as a reminder to separate build artifacts and manage competing GPU workloads. |
- | `apartment.html` | Atmospheric first-person room; canvas-generated textures; fog and lighting; AABB collision; pointer lock; mobile joystick; raycast interaction; Web Audio. | It uses a global Three.js script. Reuse its game ideas, not its global/module architecture in a module project. Superseded by `apartmentv2.html`, which ports the same concept to modules and adds many systems. |
- | `apartmentv2.html` | The mature reference for a cinematic first-person experience: ES-module import map; postprocessing chain (`UnrealBloomPass` + a custom grade `ShaderPass` + optional `SMAAPass`); one `EffectComposer` re-pointed between a main scene and a `portalScene` sub-world; canvas bump→normal-map baking; a deep procedural Web Audio bed (convolver reverb send/return bus, pink-noise rain/wind, 60/120/180 Hz mains hum, LFO-modulated sub, transition low-pass filter, synthesized footsteps); quality toggles that rebuild passes and re-cap DPR; head-bob/sway and footstep triggering from a bob zero-crossing; scripted cinematic phase sequences (wake, sit/stand) driven by eased `dt`; and per-mini-game state machines inside portals. | It is large and single-file. Keep the system boundaries (audio, world build, portal build, update, ambient) as separate functions even in one file; do not flatten them. Its many `window._xTick` global hooks are a shortcut to avoid—prefer an explicit ticker list. |
- | `grassrooms.html` | Procedural terrain by editing plane vertices; terrain-height function shared with the player; procedural vegetation; mobile render cap; STL export intent. | Its global Three.js plus JSM `STLExporter` arrangement is fragile: JSM addons import bare `three`, so use one module setup and an import map instead. |
- | `expeditiongame.html` | A fully modular game pattern: import map; GLTF loading with fallback; themed world rebuilds; disposal; instanced animated grass; player physics; UI; settings; accessibility; input on desktop and touch. | It is feature-rich. Build and test a vertical slice before adopting all systems. Track ownership before disposing shared materials. |
- | `3dar.html` | Fast GLB switching and AR via `<model-viewer>`. | This is not a Three.js runtime. It is often the better answer for a simple viewer/AR task. |
- | `pacman.html` | A disciplined Canvas 2D game loop, fixed logical board, state machine, synthesized audio, keyboard/touch input, visibility pause, and delta clamp. | It has no Three.js. Transfer its game architecture, not its renderer, to a 3D project. |
- ---
- ## 1. Dependency and runtime contract
- ### Preferred browser-module setup
- For a standalone HTML prototype, use an import map. The following intentionally pins core and addons to the same known revision from the supplied examples. A bundler may replace this with package imports, but the rule remains the same.
- ```html
- <script type="importmap">
- {
- "imports": {
- "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
- "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
- }
- }
- </script>
- <script type="module">
- import * as THREE from "three";
- import { OrbitControls } from "three/addons/controls/OrbitControls.js";
- import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
- </script>
- ```
- ### Rules
- - Use `import * as THREE from "three"` and import addons from `three/addons/...` in the same module world.
- - If using a global `<script src=".../three.min.js">`, only use compatible non-module examples. Do **not** import a JSM addon that itself imports the bare `three` specifier unless an import map resolves it.
- - Pin versions instead of saying “latest.” Upgrade core and addons together after testing.
- - Use a local development server for ES modules and assets. Opening an HTML file via `file://` commonly breaks module and texture/model loading.
- - If WebXR is needed, use `renderer.setAnimationLoop()` rather than a separate `requestAnimationFrame` loop. Otherwise use one ordinary animation loop.
- ---
- ## 2. Canonical scene bootstrap
- Start from this reliable baseline. It contains the core renderer contract used by the supplied 3D pages, but fixes common omissions such as pixel-ratio limits and delta clamping.
- ```js
- import * as THREE from "three";
- const canvas = document.querySelector("#webgl");
- const renderer = new THREE.WebGLRenderer({
- canvas,
- antialias: true,
- powerPreference: "high-performance"
- });
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- renderer.setSize(window.innerWidth, window.innerHeight);
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
- renderer.toneMappingExposure = 1.0;
- renderer.shadowMap.enabled = true;
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
- const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x101218);
- scene.fog = new THREE.FogExp2(0x101218, 0.025); // optional, atmospheric, and cheap
- const camera = new THREE.PerspectiveCamera(
- 60,
- window.innerWidth / window.innerHeight,
- 0.1,
- 200
- );
- camera.position.set(0, 1.5, 5);
- camera.lookAt(0, 1, 0);
- scene.add(new THREE.HemisphereLight(0xcfe5ff, 0x18200e, 1.2));
- const sun = new THREE.DirectionalLight(0xffffff, 2.5);
- sun.position.set(4, 8, 5);
- sun.castShadow = true;
- sun.shadow.mapSize.set(1024, 1024);
- scene.add(sun);
- const mesh = new THREE.Mesh(
- new THREE.IcosahedronGeometry(1, 3),
- new THREE.MeshStandardMaterial({ color: 0x5bc0ff, roughness: 0.35, metalness: 0.25 })
- );
- mesh.castShadow = true;
- mesh.receiveShadow = true;
- scene.add(mesh);
- const clock = new THREE.Clock();
- let raf = 0;
- function resize() {
- const width = window.innerWidth;
- const height = window.innerHeight;
- camera.aspect = width / height;
- camera.updateProjectionMatrix();
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- renderer.setSize(width, height);
- }
- window.addEventListener("resize", resize);
- function update(dt, elapsed) {
- mesh.rotation.y += dt * 0.45;
- mesh.position.y = 1 + Math.sin(elapsed * 1.5) * 0.08;
- }
- function frame() {
- raf = requestAnimationFrame(frame);
- const dt = Math.min(clock.getDelta(), 0.05); // protects against tab-resume hitches
- update(dt, clock.elapsedTime);
- renderer.render(scene, camera);
- }
- frame();
- ```
- ### Rendering rules
- - A `MeshStandardMaterial` or `MeshPhysicalMaterial` needs lights and benefits from an environment map. A `MeshBasicMaterial` does not react to lights.
- - Set `outputColorSpace = THREE.SRGBColorSpace` for current Three.js color management. Use tone mapping for HDR-looking lighting, emissive materials, or bloom.
- - Use `scene.fog` both as an aesthetic tool and as a way to hide distant low-detail geometry.
- - If using `EffectComposer`, render with `composer.render()` instead of also calling `renderer.render(scene, camera)` each frame.
- - Resize the renderer **and** every composer/render target that depends on viewport size.
- ---
- ## 3. Design the application as systems, not a script pile
- A healthy project separates the following responsibilities:
- ```text
- app/
- main.js renderer, scene, camera, lifecycle
- post.js EffectComposer, passes, quality settings, scene switching
- world.js create/rebuild/dispose environment
- player.js movement, collision, camera sync, head-bob
- interaction.js raycasts and userData actions
- assets.js GLTF/textures/procedural bakes/loading/fallbacks
- ui.js DOM HUD, menus, accessibility
- audio.js user-gesture initialization and sound buses
- shaders/ GLSL strings or shader modules
- ```
- For a small one-file prototype, retain those boundaries as functions and named state objects. Avoid having DOM event handlers directly mutate arbitrary meshes all over the file. Keep per-frame animation work in a small set of named update functions (for example `update(dt)` and `updateAmbient(dt)`); resist attaching ad-hoc `window._somethingTick` callbacks—as `apartmentv2.html` does in places—because they scatter frame logic and complicate pausing and disposal. Prefer a single explicit list of tickers you iterate each frame.
- ### State versus visual objects
- Keep a plain-data game state authoritative:
- ```js
- const player = {
- x: 0, z: 6, y: 0,
- vx: 0, vz: 0, vy: 0,
- yaw: 0, pitch: 0,
- onGround: true
- };
- ```
- Then update `camera.position`, a visible body mesh, HUD, audio, and shaders from that state. This makes rebuilding a world, pausing a game, saving settings, and testing collisions much safer.
- For cinematic work, use explicit states such as:
- ```text
- INTRO → IDLE → CHARGING → EXPLODING → DONE
- ```
- Advance `stateTime` with `dt`, and keep transitions in one place. This is more reliable than competing `setTimeout` chains and is the pattern behind the interactive hero in `ai.html`.
- ### Scripted cinematic sequences (wake, sit, cutscenes)
- For scripted camera moves—an intro wake-up, sitting into a chair, a cutscene—use an explicit phase variable advanced by `dt` and interpolate camera/player between a captured start pose and a target pose with an easing function. This keeps a temporary loss of player control fully deterministic and interruptible, unlike stacked `setTimeout`s. `apartmentv2.html` uses this for its wake and sit/stand transitions.
- ```js
- const easeInOut = t => (t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2);
- // while a sequence runs, take over update():
- if (sitPhase === 1) { // sitting down over 1.2s
- sitT += dt;
- const e = easeInOut(Math.min(1, sitT / 1.2));
- camera.position.set(lerp(from.x, to.x, e), lerp(1.6, to.y, e), lerp(from.z, to.z, e));
- camera.rotation.set(lerp(from.pitch, to.pitch, e), lerpAngle(from.yaw, to.yaw, e), 0);
- if (sitT > 1.2) sitPhase = 2; // seated; hand control to the seated state
- }
- ```
- Interpolate yaw through a shortest-arc angle lerp, capture the pre-sequence pose so you can return to it, and restore UI/input (crosshair, interact button, pointer lock) only when the sequence ends.
- ---
- ## 4. First-person movement, collision, and interaction
- The apartment, grassrooms, and expedition examples establish a practical browser-FPS pattern.
- ### Camera orientation and movement
- Use `YXZ` order so yaw and pitch are intuitive. In Three.js, a camera at yaw `0` looks down negative Z.
- ```js
- camera.rotation.order = "YXZ";
- const forward = new THREE.Vector3();
- const right = new THREE.Vector3();
- function updatePlayer(dt, input) {
- player.yaw -= input.lookX * 0.002;
- player.pitch = THREE.MathUtils.clamp(
- player.pitch - input.lookY * 0.002,
- -1.4,
- 1.4
- );
- forward.set(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
- right.set(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
- let moveX = input.right;
- let moveZ = input.forward;
- const length = Math.hypot(moveX, moveZ);
- if (length > 1) {
- moveX /= length;
- moveZ /= length;
- }
- const speed = 4.5;
- const dx = (forward.x * moveZ + right.x * moveX) * speed * dt;
- const dz = (forward.z * moveZ + right.z * moveX) * speed * dt;
- moveWithCollision(dx, dz);
- camera.position.set(player.x, player.y + 1.65, player.z);
- camera.rotation.set(player.pitch, player.yaw, 0);
- }
- ```
- - Normalize diagonal input; otherwise diagonal movement is faster.
- - For smoother motion, accelerate velocity toward a desired velocity with an exponential smoothing factor such as `1 - Math.exp(-accel * dt)`.
- - Clamp `dt`, especially before gravity, projectile, or collision calculations.
- - Do not create `Vector3`, `Raycaster`, or arrays every frame when a reusable temporary object will do.
- ### Head-bob, sway, and footstep triggers
- A small sinusoidal bob and sway sell first-person walking cheaply. Drive a `bobTime` accumulator by `dt` and speed, then offset camera height and roll from it. Fire a footstep sound on the bob's zero-crossing rather than on a timer, so cadence tracks actual movement, and pick the clip from the surface the player is standing on.
- ```js
- if (isMoving) player.bobTime += dt * (running ? 7.5 : 5.5);
- else player.bobTime += (Math.round(player.bobTime / Math.PI) * Math.PI - player.bobTime) * dt * 6; // ease to rest
- const rawBob = Math.sin(player.bobTime);
- if (lastBob > 0 && rawBob <= 0 && isMoving) playSound(surfaceStepSound(player.x, player.z)); // zero-crossing
- lastBob = rawBob;
- const bobY = isMoving ? rawBob * 0.04 : Math.sin(time * 0.002) * 0.01; // idle breathing when still
- const roll = Math.cos(player.bobTime * 0.5) * 0.012 * (isMoving ? 1 : 0);
- camera.position.set(player.x, 1.6 + bobY, player.z);
- camera.rotation.set(player.pitch, player.yaw, roll);
- ```
- Keep amplitudes small and disable or greatly reduce bob/sway under `prefers-reduced-motion` (see §10).
- ### Simple circle-versus-AABB collision
- For indoor/blocky worlds, 2D collision on X/Z is often sufficient and much cheaper than a physics engine.
- ```js
- const colliders = []; // { minX, maxX, minZ, maxZ }
- function addAABB(centerX, centerZ, width, depth) {
- colliders.push({
- minX: centerX - width / 2,
- maxX: centerX + width / 2,
- minZ: centerZ - depth / 2,
- maxZ: centerZ + depth / 2
- });
- }
- function collidesAABB(x, z, radius = 0.35) {
- return colliders.some(c =>
- x + radius > c.minX && x - radius < c.maxX &&
- z + radius > c.minZ && z - radius < c.maxZ
- );
- }
- function moveWithCollision(dx, dz) {
- const nextX = player.x + dx;
- const nextZ = player.z + dz;
- if (!collidesAABB(nextX, player.z)) player.x = nextX;
- if (!collidesAABB(player.x, nextZ)) player.z = nextZ;
- }
- ```
- Add circles for trees, mobs, and pillars if needed. Keep collision data separate from the render mesh; an intricate visual model should not automatically be the collision model.
- ### Center-screen raycast interaction
- Use raycasting for “press E to use” or crosshair interaction. Reuse the raycaster, include children, and walk upward to the object that owns the action.
- ```js
- const raycaster = new THREE.Raycaster();
- const screenCenter = new THREE.Vector2(0, 0);
- const interactables = [];
- function findInteractable(maxDistance = 3) {
- raycaster.setFromCamera(screenCenter, camera);
- const hit = raycaster.intersectObjects(interactables, true)[0];
- if (!hit || hit.distance > maxDistance) return null;
- let object = hit.object;
- while (object && !object.userData.action) object = object.parent;
- return object ?? null;
- }
- // During creation:
- // door.userData.action = () => openDoor();
- // interactables.push(door);
- ```
- Run expensive context raycasts at a modest cadence (for example 10–15 Hz) when they only drive a prompt. Perform an immediate raycast again when the player presses the interaction key.
- ### Pointer lock, touch, and audio
- - Request pointer lock only after a user action. When it exits, show a pause/menu state rather than continuing invisible input.
- - Build desktop and touch input into the same normalized `input.forward`, `input.right`, `input.lookX`, and `input.lookY` values.
- - Use touch identifiers so the left joystick and right look zone do not steal each other’s touches.
- - Initialize or resume `AudioContext` from a button/tap/click. Browsers block autoplay.
- - Pausing on `visibilitychange` is good UX and prevents a giant delta on return.
- ### A procedural Web Audio bed
- `apartmentv2.html` builds a convincing soundscape entirely from synthesis—no audio files—which keeps a prototype self-contained. The reusable structure:
- - **A bus with a send/return reverb.** Route a `master` gain to `destination`, and give sources a dry path plus a variable send into a `ConvolverNode`. Generate the impulse response procedurally (decaying white noise) instead of shipping an IR file.
- - **Loopable noise beds.** Pre-generate one white-noise and one pink-noise `AudioBuffer` at init, then reuse them via looping `BufferSource`s filtered into rain (band-limited), wind (band-pass with an LFO on frequency), and hiss.
- - **A tonal drone.** A few detuned oscillators (e.g. 60/120/180 Hz mains hum) plus a low sub with a slow LFO on its frequency give an unsettling floor tone.
- - **One-shots via envelopes.** Synthesize hits/steps with `setValueAtTime` + `exponentialRampToValueAtTime` on a gain, never by allocating unbounded oscillators. Always ramp to a tiny value like `0.0001`, not `0`, before an exponential ramp.
- - **Diegetic mix and transitions.** Smoothly lerp bed gains toward per-zone targets each frame (louder TV buzz in the hall, rain in the room), and sweep a single `BiquadFilter` on the master to muffle everything when entering a sub-world.
- ```js
- // send/return skeleton
- master = ctx.createGain(); master.connect(destinationFilter); destinationFilter.connect(ctx.destination);
- reverb = ctx.createConvolver(); reverb.buffer = makeImpulse(ctx, 1.6); // decaying-noise IR
- dryGain = ctx.createGain(); wetGain = ctx.createGain(); wetGain.gain.value = 0.22;
- dryGain.connect(master); reverb.connect(wetGain); wetGain.connect(master);
- function routeFX(node, send = 0.25) {
- node.connect(dryGain);
- const s = ctx.createGain(); s.gain.value = send; node.connect(s); s.connect(reverb);
- }
- // per-frame ambient mix (dt-smoothed toward a zone target)
- rainGain.gain.value += ((inRoom ? 0.035 : 0.006) - rainGain.gain.value) * dt * 1.5;
- ```
- Provide a mute/volume control and respect it; keep the total oscillator/source count bounded.
- ---
- ## 5. Procedural rooms, terrain, and materials
- ### Canvas-generated textures
- Canvas textures are excellent for self-contained prototypes: carpet/noise, plaster, grass, gradients, UI sprites, or a simple environment map.
- ```js
- function makeNoiseTexture(base, flecks, size = 512) {
- const canvas = document.createElement("canvas");
- canvas.width = canvas.height = size;
- const ctx = canvas.getContext("2d");
- ctx.fillStyle = base;
- ctx.fillRect(0, 0, size, size);
- for (let i = 0; i < size * size * 0.25; i++) {
- ctx.globalAlpha = Math.random() * 0.16;
- ctx.fillStyle = flecks;
- ctx.fillRect(Math.random() * size, Math.random() * size, 2, 2);
- }
- ctx.globalAlpha = 1;
- const texture = new THREE.CanvasTexture(canvas);
- texture.colorSpace = THREE.SRGBColorSpace;
- texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
- return texture;
- }
- const texture = makeNoiseTexture("#d6cebd", "#a99e8c");
- texture.repeat.set(8, 5);
- const wallMaterial = new THREE.MeshStandardMaterial({
- map: texture,
- bumpMap: texture,
- bumpScale: 0.015,
- roughness: 0.9
- });
- ```
- Use a low bump scale when reusing a color texture as a bump map. It adds texture without requiring another asset, but it is not a physically accurate normal map.
- Set `texture.anisotropy = renderer.capabilities.getMaxAnisotropy()` on tiling surface textures (floors, walls) so they stay sharp at grazing angles, and only set `colorSpace = SRGBColorSpace` on **color** maps—leave data maps (bump, roughness, normal) in the default linear space.
- ### Baking a normal map from a canvas bump (Sobel)
- For more convincing surfaces than a bump map, generate a proper tangent-space normal map from the same procedural grayscale canvas with a Sobel filter, then feed it as `normalMap`. `apartmentv2.html` uses this for plaster and carpet. Keep the height source in linear space (do not tag it sRGB).
- ```js
- function normalFromBump(drawFn, w = 512, h = 512, strength = 2.0) {
- const c = document.createElement("canvas"); c.width = w; c.height = h;
- const ctx = c.getContext("2d"); drawFn(ctx, w, h);
- const src = ctx.getImageData(0, 0, w, h).data;
- const out = ctx.createImageData(w, h), od = out.data;
- const at = (x, y) => src[(((y + h) % h) * w + ((x + w) % w)) * 4] / 255; // tiles/wraps
- for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
- const dx = (at(x+1,y-1)+2*at(x+1,y)+at(x+1,y+1)) - (at(x-1,y-1)+2*at(x-1,y)+at(x-1,y+1));
- const dy = (at(x-1,y+1)+2*at(x,y+1)+at(x+1,y+1)) - (at(x-1,y-1)+2*at(x,y-1)+at(x+1,y-1));
- let nx = -dx*strength, ny = -dy*strength, nz = 1;
- const L = Math.hypot(nx, ny, nz); nx/=L; ny/=L; nz/=L;
- const i = (y*w + x)*4;
- od[i] = (nx*0.5+0.5)*255; od[i+1] = (ny*0.5+0.5)*255; od[i+2] = (nz*0.5+0.5)*255; od[i+3] = 255;
- }
- ctx.putImageData(out, 0, 0);
- const t = new THREE.CanvasTexture(c);
- t.wrapS = t.wrapT = THREE.RepeatWrapping;
- return t; // assign to material.normalMap; do NOT set colorSpace = SRGB
- }
- ```
- Bake procedural maps once at load, not per frame—`getImageData`/`putImageData` are expensive.
- ### Deformed terrain
- A terrain renderer and player collision must use the **same** height function. That prevents the camera from floating above or sinking into a visibly deformed floor.
- ```js
- function terrainHeight(x, z) {
- return Math.sin(x * 0.30) * Math.cos(z * 0.30) * 0.45
- + Math.sin(x * 0.10) * 0.18;
- }
- const geometry = new THREE.PlaneGeometry(60, 80, 80, 80);
- geometry.rotateX(-Math.PI / 2);
- const positions = geometry.attributes.position;
- for (let i = 0; i < positions.count; i++) {
- const x = positions.getX(i);
- const z = positions.getZ(i);
- positions.setY(i, terrainHeight(x, z));
- }
- geometry.computeVertexNormals();
- const terrain = new THREE.Mesh(geometry, terrainMaterial);
- terrain.receiveShadow = true;
- scene.add(terrain);
- ```
- Rules:
- - `PlaneGeometry` begins in the XY plane; rotate it around X to make it a ground plane.
- - Recompute normals after editing vertex positions.
- - Use a bounded placement loop for trees/flowers/pickups. Reject positions near walls, colliders, prior objects, and spawn points, but stop after a maximum number of attempts.
- - For a small diorama, simple primitives and fog frequently look better and run faster than a large asset pack.
- ### Procedural object builders
- Return a `Group` or a clear object record from builders such as `createDoor()`, `createTree()`, and `createNPC()`. Put all related meshes under it. This makes transforms, disposal, interaction, and animation predictable.
- To rotate a door around its hinge, either place the mesh inside a hinge `Group`, or translate the geometry so the pivot is at the hinge before putting it in a mesh. Do not try to fake a hinge by rotating a mesh around its center.
- ### Sculpting soft furnishings with vertex deformation
- Plain boxes are the fastest way to make a scene read as "AI-generated": real pillows, cushions, and duvets never have straight edges. Sculpt them once at build time from an **over-segmented** rounded box (segments ≥ 10 so falloffs stay smooth), then `computeVertexNormals()`:
- ```js
- function sculptPillowGeo(w, h, d, r, { dent = 0.26, lobe = 0.15, pinch = 0.5, wrinkle = 0.0045 } = {}) {
- const g = new RoundedBoxGeometry(w, h, d, 12, Math.min(r, h * 0.42));
- const p = g.attributes.position;
- const hw = w / 2, hd = d / 2;
- for (let i = 0; i < p.count; i++) {
- const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
- const dentG = Math.exp(-((x / (hw * 0.38)) ** 2)); // head hollow (center)
- const lobeG = Math.exp(-(((Math.abs(x) - hw * 0.58) / (hw * 0.36)) ** 2)); // twin puffy lobes
- const edge = smooth01((hw - Math.abs(x)) / (hw * 0.5 + 0.015))
- * smooth01((hd - Math.abs(z)) / (hd * 0.5 + 0.01)); // 0 at rim → 1 inside
- const wob = Math.sin(x / hw * 8.7 + z / hd * 4.3)
- * Math.sin(z / hd * 7.9 - x / hw * 2.6) * wrinkle * edge * Math.sign(y || 1);
- p.setY(i, y * (1 - pinch + pinch * edge) * (1 - dent * dentG) * (1 + lobe * lobeG * edge) + wob);
- p.setZ(i, z * (1 + 0.09 * lobeG * edge - 0.05 * dentG)); // lobes get deeper too
- }
- g.computeVertexNormals();
- return g;
- }
- ```
- Rules of thumb:
- - Use **smooth falloffs** (gaussians, smoothsteps) — never clamped linear cuts, which produce hard creases that read as low-poly, not softness.
- - Four cues sell "pillow": the **border pinch** (flat sewn flange at the silhouette), a **center dent** (a head was here), **twin lobes** (fill redistributes to the sides), and low-amplitude **cloth wrinkle noise** that fades where the pinch flattens the cloth.
- - Displace **once at load**, not per frame; reuse the sculpted geometry across identical objects. Give twin objects a different noise *phase* instead of regenerating geometry.
- - Vertical pads (headboard cushions) can reuse the same sculptor: build in the "lying pillow" frame, then `geometry.rotateZ(Math.PI / 2)` into place.
- - The same falloff vocabulary sculpts duvets (wrinkle sines on the top face only), rugs (spring-back squish via scaled y), and chair cushions (sitting dent + front-edge waterfall curve).
- ---
- ## 6. Assets, SVG extrusion, and fallback behavior
- ### GLTF/GLB loading
- Use `GLTFLoader` for authored models and give the player a fallback when the network or model fails.
- ```js
- import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
- const loader = new GLTFLoader();
- async function loadCharacter(url) {
- try {
- const gltf = await loader.loadAsync(url);
- const model = gltf.scene;
- model.traverse(object => {
- if (!object.isMesh) return;
- object.castShadow = true;
- object.receiveShadow = true;
- });
- return model;
- } catch (error) {
- console.warn("Model failed to load; using fallback.", error);
- return makeFallbackCharacter();
- }
- }
- ```
- - Put model URLs behind a loading state and show useful progress/error UI when appropriate.
- - Model scale, origin, materials, and animation names are asset-specific; inspect them instead of guessing.
- - A procedural fallback makes a demo playable offline or when CORS/network delivery fails.
- - Dynamic crowds and fast-moving NPCs usually should not cast shadows; one shadow map can dominate the frame budget.
- ### SVG → extruded 3D logo
- For an interactive branded object, parse SVG paths, create shapes, extrude them, then normalize their aggregate bounds. The `ai.html` source demonstrates this high-value workflow.
- ```js
- import { SVGLoader } from "three/addons/loaders/SVGLoader.js";
- import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
- const data = new SVGLoader().parse(svgText);
- const shapes = data.paths.flatMap(path => SVGLoader.createShapes(path));
- const geometries = shapes.map(shape => new THREE.ExtrudeGeometry(shape, {
- depth: 0.5,
- bevelEnabled: true,
- bevelThickness: 0.04,
- bevelSize: 0.03,
- bevelSegments: 2,
- curveSegments: 16
- }));
- const geometry = mergeGeometries(geometries);
- geometry.computeBoundingBox();
- const center = geometry.boundingBox.getCenter(new THREE.Vector3());
- geometry.translate(-center.x, -center.y, -center.z);
- ```
- Dispose intermediate geometries after merging. Normalize size from `Box3` if the SVG needs to fit a fixed hero composition.
- ---
- ## 7. Instancing, particles, and shaders
- ### Use `InstancedMesh` for many repeated objects
- The expedition grass and AI hero particles use this idea. Thousands of separate meshes create too many draw calls; one `InstancedMesh` shares one geometry and material.
- ```js
- const count = 4000;
- const geometry = new THREE.ConeGeometry(0.025, 0.8, 3);
- const material = new THREE.MeshStandardMaterial({ color: 0x4d7f20 });
- const grass = new THREE.InstancedMesh(geometry, material, count);
- const dummy = new THREE.Object3D();
- for (let i = 0; i < count; i++) {
- const x = THREE.MathUtils.randFloatSpread(50);
- const z = THREE.MathUtils.randFloatSpread(50);
- dummy.position.set(x, terrainHeight(x, z), z);
- dummy.rotation.y = Math.random() * Math.PI;
- const scale = THREE.MathUtils.randFloat(0.45, 1.1);
- dummy.scale.set(scale, scale, scale);
- dummy.updateMatrix();
- grass.setMatrixAt(i, dummy.matrix);
- }
- grass.instanceMatrix.needsUpdate = true;
- scene.add(grass);
- ```
- - Start with the native `InstancedMesh` API.
- - Use `InstancedBufferGeometry` and custom `InstancedBufferAttribute`s only when the vertex shader needs custom per-instance values such as a quaternion, phase, color, or ID.
- - Use a reusable `Object3D`, `Matrix4`, `Quaternion`, and `Vector3` while populating/updating instances.
- - If every instance is in one world-sized mesh, frustum culling can be inaccurate. Split the field into spatial chunks before turning `frustumCulled = false` indiscriminately.
- ### A compact shader pattern
- The shader hero in `mysite.html` demonstrates vertex displacement plus a time uniform. Keep the first custom shader small and own every uniform you change.
- ```js
- const material = new THREE.ShaderMaterial({
- uniforms: { uTime: { value: 0 } },
- vertexShader: `
- uniform float uTime;
- varying vec3 vNormal;
- void main() {
- vNormal = normal;
- vec3 p = position + normal * sin(position.y * 5.0 + uTime) * 0.08;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
- }
- `,
- fragmentShader: `
- varying vec3 vNormal;
- void main() {
- float lit = max(dot(normalize(vNormal), normalize(vec3(1.0, 1.0, 2.0))), 0.0);
- gl_FragColor = vec4(mix(vec3(0.08, 0.02, 0.2), vec3(0.0, 0.8, 1.0), lit), 1.0);
- }
- `
- });
- // In update(dt, elapsed): material.uniforms.uTime.value = elapsed;
- ```
- ### Advanced shader rules
- - Use `ShaderMaterial` when you own the full shader. Use `material.onBeforeCompile` only when extending a built-in material is worth the maintenance cost.
- - `onBeforeCompile` string replacement and custom use of internal shader chunks are revision-sensitive. Pin the revision and test upgrades.
- - If a vertex shader displaces or animates a shadow-casting mesh, its shadow pass needs the same displacement. Supply a compatible `customDepthMaterial` (and, if relevant, distance material) rather than accepting detached shadows.
- - The custom glassballs shader manually carries lighting and shadow data; this is advanced territory. Prefer Three’s built-in PBR lighting until a visual requirement cannot be met otherwise.
- - Do not put frame-random noise into a shader or JS transform unless shimmer is intentional. Temporal instability looks like a rendering bug.
- - Update uniforms in place (`uniform.value.copy(...)` or `.set(...)`) rather than reallocating vectors every frame.
- ---
- ## 8. Lighting, environment, shadows, and bloom
- A dependable atmospheric baseline is:
- - a low-intensity `AmbientLight` or `HemisphereLight`;
- - one main `DirectionalLight` or carefully budgeted `PointLight`;
- - restrained fog;
- - rough PBR materials;
- - emissive accents, not dozens of dynamic lights.
- ### Environment reflections
- For `MeshPhysicalMaterial` or metallic heroes, generate a valid environment texture with `PMREMGenerator`. The AI hero shows that a canvas-generated equirectangular gradient can be sufficient for a self-contained page.
- ```js
- import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
- const pmrem = new THREE.PMREMGenerator(renderer);
- const room = new RoomEnvironment();
- const environmentTarget = pmrem.fromScene(room, 0.04);
- scene.environment = environmentTarget.texture;
- room.dispose();
- pmrem.dispose();
- // Keep environmentTarget alive while scene.environment uses its texture.
- // Dispose environmentTarget during teardown or before replacing the environment.
- ```
- A real HDR environment normally gives better reflections; use a small, compressed asset and a loading state when quality demands it.
- ### Postprocessing
- Bloom is effective for emissive sci-fi scenes and the glassballs visual, but it costs memory and fill rate.
- ```js
- import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
- import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
- import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
- import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
- const composer = new EffectComposer(renderer);
- composer.addPass(new RenderPass(scene, camera));
- composer.addPass(new UnrealBloomPass(
- new THREE.Vector2(window.innerWidth, window.innerHeight),
- 0.55, 0.3, 0.75
- ));
- composer.addPass(new OutputPass());
- // Per frame: composer.render();
- // On resize: composer.setPixelRatio(cappedDpr); composer.setSize(width, height);
- ```
- Rules:
- - Add bloom only after the unprocessed scene is correct.
- - Use `composer.render()`, not a second direct render.
- - Cap DPR even more aggressively on touch devices.
- - A low-resolution shadow map, fog, an emissive material, and good composition often outperform expensive bloom and many lights.
- ### A custom grade pass on top of bloom
- `apartmentv2.html` finishes its chain with a small full-screen `ShaderPass` that does chromatic aberration, a vignette, and animated film grain. This is a cheap, high-impact way to give a scene a cinematic look without heavier effects. Keep it last and let it render to screen.
- ```js
- import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
- const GradeShader = {
- uniforms: { tDiffuse: { value: null }, time: { value: 0 }, aberration: { value: 0.0016 }, grain: { value: 0.045 }, vignette: { value: 1.0 } },
- vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
- fragmentShader: `
- uniform sampler2D tDiffuse; uniform float time, aberration, grain, vignette; varying vec2 vUv;
- float rand(vec2 c){ return fract(sin(dot(c, vec2(12.9898,78.233))) * 43758.5453); }
- void main(){
- vec2 d = vUv - 0.5;
- float r = texture2D(tDiffuse, vUv + d*aberration).r;
- float g = texture2D(tDiffuse, vUv).g;
- float b = texture2D(tDiffuse, vUv - d*aberration).b;
- vec3 col = vec3(r, g, b);
- col *= mix(1.0, smoothstep(0.95, 0.25, length(d)*1.3), vignette*0.9);
- col += rand(vUv + fract(time)) * grain - grain*0.5;
- gl_FragColor = vec4(col, 1.0);
- }`
- };
- const gradePass = new ShaderPass(GradeShader);
- gradePass.renderToScreen = true;
- composer.addPass(gradePass);
- // Per frame, before composer.render(): gradePass.uniforms.time.value = elapsed;
- ```
- Order matters: `RenderPass → UnrealBloomPass → grade ShaderPass (→ SMAAPass last)`. Advance `time` every frame so grain does not freeze, but keep grain subtle—strong per-frame noise reads as a bug (see §7).
- ### One composer, multiple scenes (portals / sub-worlds)
- To send the player into a self-contained sub-world without a second renderer or composer, keep a separate `THREE.Scene` and re-point the existing `RenderPass` at it each frame. `apartmentv2.html` uses this for its "portal" mini-worlds.
- ```js
- const portalScene = new THREE.Scene();
- const renderPass = composer.passes[0]; // the RenderPass added first
- function frame() {
- renderPass.scene = inPortal ? portalScene : scene; // swap what gets rendered
- composer.render();
- }
- ```
- Build and dispose the sub-world's contents on entry/exit (see §9), keep its own collider and interactable arrays, and run only that world's `update(dt)` while it is active. A CSS fade over the swap hides the one-frame transition. This is far cheaper and simpler than instantiating a parallel renderer/composer.
- ### Toggleable quality that rebuilds passes
- Expose a small `settings` object (e.g. `{ bloom, hq, showFps }`) and an `applyQuality()` that enables/disables passes and re-caps DPR. Create expensive passes like `SMAAPass` lazily the first time they are needed, and always call your resize handler afterward so composer/pass sizes stay correct.
- ```js
- const settings = { bloom: true, hq: true, showFps: false };
- let smaaPass = null;
- function applyQuality() {
- bloomPass.enabled = settings.bloom;
- const pr = settings.hq ? Math.min(window.devicePixelRatio, 2) : 1;
- renderer.setPixelRatio(pr);
- composer.setPixelRatio(pr);
- if (settings.hq && !smaaPass) {
- smaaPass = new SMAAPass(window.innerWidth * pr, window.innerHeight * pr);
- composer.insertPass(smaaPass, composer.passes.length - 1); // keep grade pass last
- }
- if (smaaPass) smaaPass.enabled = settings.hq;
- onResize();
- }
- ```
- ---
- ## 9. Performance and lifecycle discipline
- ### Frame budget
- Apply these defaults before optimizing micro-details:
- ```js
- const isTouch = navigator.maxTouchPoints > 0 || "ontouchstart" in window;
- const pixelRatioCap = isTouch ? 1.25 : 1.5;
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, pixelRatioCap));
- ```
- Then:
- 1. Measure before changing behavior.
- 2. Reduce rendered pixels before making JavaScript obscure.
- 3. Instance repeated geometry.
- 4. Reduce shadow casters, shadow map size, light count, and postprocessing passes.
- 5. Avoid allocations in the hot loop.
- 6. Throttle noncritical raycasts, DOM updates, nametag projection, and AI decisions.
- 7. Pause rendering while hidden. For a GPU-heavy model inference task, park or simplify a decorative Three.js scene as `ai.html` does.
- ### Rebuildable worlds
- Procedural floor/theme changes should live in a dedicated root group.
- ```js
- let world = null;
- function replaceWorld(nextWorld) {
- if (world) disposeWorld(world);
- world = nextWorld;
- scene.add(world);
- }
- function disposeWorld(root) {
- const geometries = new Set();
- const materials = new Set();
- scene.remove(root);
- root.traverse(object => {
- if (object.geometry) geometries.add(object.geometry);
- const list = Array.isArray(object.material)
- ? object.material
- : object.material ? [object.material] : [];
- list.forEach(material => materials.add(material));
- });
- geometries.forEach(geometry => geometry.dispose());
- materials.forEach(material => {
- // Dispose only materials owned exclusively by this world.
- // Shared resources should be reference-counted or disposed by their owner.
- if (!material.userData.shared) material.dispose();
- });
- }
- ```
- The key lesson from `expeditiongame.html`: removing a group from a scene does **not** release GPU resources. The key caution: do not dispose a material still used by another world or persistent player/NPC object. Track runtime-created texture ownership separately: disposing a material does **not** automatically dispose its texture maps.
- Also dispose owned textures, render targets, postprocessing passes where applicable, controls, event listeners, and audio nodes when the entire app unmounts.
- ---
- ## 10. Responsive UI, accessibility, and presentation
- Use DOM/CSS for menus, HUDs, labels, controls, menus, loading states, and form controls. Keep the WebGL canvas as one visual layer. This is more accessible and easier to style than drawing UI into the scene.
- Required practices:
- - Use semantic buttons and visible focus states for actions.
- - Support keyboard controls as well as touch controls.
- - Respect `prefers-reduced-motion`; reduce automatic rotation, shake, particle drift, and rapid camera motion.
- - Persist player settings such as sensitivity, audio toggles, and control size responsibly (for example `localStorage`).
- - Offer colorblind-safe palettes or a tested filter/settings option; do not rely only on color for gameplay state.
- - Avoid important information only in bloom, tiny 3D labels, or sound.
- - Surface pointer-lock exit, loading failures, and WebGL fallback states clearly.
- - Keep DOM updates out of the render hot path unless their value changes or a throttled interval has elapsed.
- For 3D nametags, project a world position to normalized device coordinates, hide it when behind the camera or off screen, and update at a limited rate. For a few labels, DOM overlays are usually better than text meshes.
- ---
- ## 11. Exporting a scene for 3D printing
- The grassrooms example points toward STL export. STL includes triangles, not textures, lights, animation, UI, or material appearance.
- Use a consistent ESM setup:
- ```js
- import { STLExporter } from "three/addons/exporters/STLExporter.js";
- function exportSTL(root) {
- scene.updateMatrixWorld(true);
- const exporter = new STLExporter();
- const result = exporter.parse(root, { binary: true });
- const blob = new Blob([result], { type: "application/octet-stream" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "scene.stl";
- a.click();
- URL.revokeObjectURL(url);
- }
- ```
- Before export:
- - Clone or build a dedicated export group if the interactive scene contains lights, non-manifold planes, helper objects, sprites, void materials, or UI.
- - Ensure all transforms are current with `scene.updateMatrixWorld(true)`.
- - Give the model thickness; a single wall plane is not printable.
- - Validate manifoldness and scale in a proper mesh/print tool afterward.
- ---
- ## 12. AR and simple model presentation: do not overbuild
- If the requirement is “show a GLB, let users orbit it, swap models, and launch device AR,” use `<model-viewer>` rather than recreating those capabilities with Three.js.
- ```html
- <script type="module"
- src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js"></script>
- <model-viewer
- id="viewer"
- src="/models/Astronaut.glb"
- alt="Astronaut model"
- camera-controls
- auto-rotate
- ar
- ar-modes="webxr scene-viewer quick-look"
- shadow-intensity="1">
- </model-viewer>
- <button id="helmet">Show helmet</button>
- <script>
- document.querySelector("#helmet").addEventListener("click", () => {
- document.querySelector("#viewer").src = "/models/DamagedHelmet.glb";
- });
- </script>
- ```
- Choose Three.js instead when you need a custom game world, shader, interaction system, unusual model composition, synchronized particles/physics, or a bespoke WebXR scene.
- ---
- ## 13. What transfers from the Canvas Pac-Man implementation
- Do not rewrite a fundamentally 2D tile game in Three.js merely because Three.js is available. Transfer these engineering practices instead:
- - fixed logical grid / world coordinates independent of responsive display scaling;
- - a central mode/state machine;
- - fixed, named constants for speed tables and behavior;
- - deterministic per-frame `update(dt)` followed by `render()`;
- - `dt` clamping;
- - keyboard and touch input routed into one input model;
- - audio created only after gesture;
- - pause on visibility change;
- - pre-render static assets/layers when possible.
- For an intentionally 3D board game, use an `OrthographicCamera`, a fixed tile-to-world scale, and the same simulation architecture.
- ---
- ## 14. Troubleshooting checklist
- | Symptom | Likely cause and fix |
- |---|---|
- | Blank canvas | Verify module import paths, canvas selector, renderer size, camera position/near/far planes, and browser console. Add an `AxesHelper` and a bright `MeshBasicMaterial` test mesh. |
- | Addon fails with “failed to resolve module specifier `three`” | A JSM addon needs an import map/bundler mapping for `three`, or global and module styles were mixed. Use the dependency contract above. |
- | Scene looks washed out or too dark | Check `outputColorSpace`, tone mapping/exposure, lights, material type, and whether a physical material needs an environment. |
- | Shadows detach from a shader-animated object | Match vertex deformation in a custom depth material or temporarily disable that object’s cast shadow. |
- | FPS collapses on mobile | Cap DPR; reduce shadow maps/lights/bloom; instance repeats; reduce geometry segments; throttle raycasts and DOM work. |
- | Player moves through/gets stuck in walls | Confirm collision uses the same coordinate system and radius as movement. Resolve X and Z separately for simple sliding. |
- | Terrain camera floats/sinks | Use the same `terrainHeight(x, z)` for mesh deformation and player grounding. |
- | Raycast action never fires | Pass `recursive: true`, check range, and walk from hit child to a parent with `userData.action`. |
- | Audio is silent | Initialize/resume the audio context from a user gesture, check mute state, and avoid creating unlimited oscillators/nodes. |
- | Rebuilt levels leak memory | Remove old root groups and dispose their owned geometries/materials/textures/render targets; do not dispose shared resources too early. |
- | Model loading works locally but not in production | Check CORS, MIME types, relative URLs, HTTPS, CDN availability, and whether dependent textures/DRACO/KTX2 files are delivered. |
- | Page stutters while local AI runs | Stop or lower the Three.js render loop, lower resolution, and avoid multiple heavy GPU render/inference pipelines at once. |
- | Postprocessing shows nothing / only the last pass | Confirm a `RenderPass` is first, only the final pass has `renderToScreen`/is last, and you call `composer.render()` once. When switching scenes, re-point `composer.passes[0].scene` each frame. |
- | A newly toggled pass (e.g. SMAA) looks wrong or wrong-sized | Insert it before the screen/grade pass, then call your resize handler so every pass and the composer get the current size and DPR. |
- | Baked normal/bump map looks flat, seams, or too shiny | Do not tag the height/normal source as sRGB, use `RepeatWrapping` with wrapped sampling in the Sobel pass, and bake once at load—not per frame. |
- | Film grain looks like static / flickers harshly | Lower the grain uniform and advance `time` smoothly; heavy per-frame noise reads as a rendering bug. |
- | Footsteps fire too often or drift from motion | Trigger on the bob sine's zero-crossing while moving, not on a timer, and scale `bobTime` by speed. |
- | Audio distorts, clicks, or grows over time | Ramp gains to `0.0001` (never `0`) before exponential ramps, reuse pre-generated noise buffers, and cap the number of live oscillators/sources. |
- | `three.core.js` 404, blank boot on r165+ | The build is split: `three.module.js` imports `./three.core.js`. Vendor/publish both as siblings, or bundle (see §16.1). |
- | Screen texture mirrored on a lathed/cylinder panel | `tex.wrapS = RepeatWrapping; tex.repeat.x = -1`. Beware: `MirroredRepeatWrapping` + negative repeat is an identity. Verify with temporary L/R marker glyphs (§16.5). |
- | Raycast target dead from some positions | Camera is inside the hitbox (backfaces culled) or the ray is nearly parallel to a thin box. Use thin single-sided panels facing the play space and scope `hit` lists to precise meshes (§16.6). |
- | Player stuck without moving at spawn | Spawn circle overlaps a collision AABB; axis-separated resolution wedges both axes. Clearance ≥ radius + ε (§16.6). |
- | Light near window/strip blooms into a "sun" | Point light too strong for ACES + bloom. Drop intensity/distance; let thin emissive geometry carry the glow (§16.7). |
- | Headless/software-GL test never reaches first frame | Shader compilation stalls the main thread for minutes. Wait on state with `polling` intervals, skip cinematics via URL param, never wall-clock assertions (§16.3). |
- | UI reads as template-y/AI-made | Replace emoji glyphs with inline-SVG stroke icons; drop tinted chip backgrounds; scrim floating text with shadows (§16.9). |
- | Delivered folder breaks when moved / opened directly | Module scripts + import maps need a server. Ship a scripted single-file classic-script bundle for portability (§16.10). |
- | Scene reads as "AI-generated" at a glance | Box-edged soft furnishings (sculpt pillows/cushions with smooth vertex falloffs), perfectly quantized music (humanize timing/velocity), emoji UI icons (inline SVG), everything in a tinted chip (scrim text instead). |
- | Procedural music sounds mechanical | Voice-lead the chord inversions, add 9th/maj7 extensions, jitter timing/velocity, low-pass the bus, add tape wobble + sidechain pump (§17.1–17.3). |
- | Loop has an audible click at the seam | Render the arrangement in an OfflineAudioContext with a pickup bar or fold the tail onto the head with an equal-power crossfade; verify `|x[0] − x[n−1]|` numerically (§17.4). |
- | Audio crackles/ramps zipper | Exponential ramps ending at 0 (use 0.0001), direct `.value` writes mid-stream (use `setTargetAtTime`), or fighting scheduled automation — cancel then retarget (§17.8). |
- ---
- ## 15. Definition of done
- Before calling a Three.js implementation complete, verify all of the following:
- - [ ] One pinned Three.js revision is used for core and addons.
- - [ ] The page has a useful state if WebGL, a model, network access, or audio is unavailable.
- - [ ] The renderer has a reasonable DPR cap and correct resize behavior.
- - [ ] Animation and simulation use clamped delta time.
- - [ ] Materials, lights, color space, tone mapping, and fog are intentional.
- - [ ] Controls work with the required desktop/mobile input methods and do not trap users unexpectedly.
- - [ ] Audio starts only after interaction and can be muted.
- - [ ] Expensive repeated geometry is instanced or reduced.
- - [ ] Raycasting/DOM updates are throttled when they are not frame-critical.
- - [ ] World replacement disposes owned GPU resources without destroying shared ones.
- - [ ] A paused/hidden tab does not resume with a giant movement or physics step.
- - [ ] Reduced-motion, keyboard access, readable UI, and non-color-only feedback are considered.
- - [ ] Postprocessing order is correct, only one pass renders to screen, and all passes/composer resize with DPR.
- - [ ] Scene/sub-world switches re-point the render pass and build/dispose per-world resources cleanly.
- - [ ] Procedural textures/normal maps are baked once at load, with correct color spaces and anisotropy.
- - [ ] Audio ramps avoid zero for exponential ramps, reuse buffers, and expose a working mute/volume.
- - [ ] Music is composed as data (key, tempo, voiced chord table with extensions), humanized in timing/velocity, and genre-treated (low-pass, wobble, sidechain); long loops are offline-rendered and numerically seamless.
- - [ ] Soft furnishings (pillows, cushions, duvets, rugs) are sculpted with smooth-falloff vertex displacement — no visible box edges on anything meant to be soft.
- - [ ] Scripted camera sequences are `dt`-driven and eased, and they restore input/UI when they end.
- - [ ] Browser console is clean of module, CORS, shader, and WebGL warnings.
- - [ ] A debug/test API (pose set/get, current target, state) plus a fast-skip URL parameter make the experience machine-verifiable.
- - [ ] Visual QA was done with real screenshots at every canonical pose and state change — logic assertions alone are not evidence it looks right.
- - [ ] The pinned runtime is fully self-contained (core, any split sibling files, and addons travel together).
- - [ ] UI icons are inline SVG (no emoji glyphs), and tinted chip backgrounds are used with restraint.
- - [ ] A scripted single-file (server-free) build exists when the deliverable must be portable.
- ---
- ## 16. Field report: the verified-build process playbook
- Distilled from shipping a complete first-person spatial-audio room diorama end-to-end: 20+ interactables, fully procedural audio, cinematic poses, and an automated end-to-end suite. These lessons are about *process and pitfalls* — they apply to any polished Three.js build, not one specific scene.
- ### 16.1 Pin and vendor correctly (r165+ split builds)
- Since r165 the modular build is **two files**: `three.module.js` imports `./three.core.js`. If you vendor the module file alone, the page boots to black with a plain 404 — no helpful module error. Preserve the sibling layout, and mirror the addons tree (`examples/jsm` → your `vendor/jsm`) so `three/addons/...` resolves. Prefer self-hosted vendored copies over CDN for workspace deliverables: immune to network loss and version drift. Verify the pin actually loads with a one-line smoke check *before* building the rest of the app.
- ### 16.2 Make the experience machine-checkable (debug API contract)
- Expose a small intentional API on `window`, e.g. `__app = { player: { setPose, getPose, getKeys }, debugTarget(), state, media }`, plus URL parameters: `?fast=1` to skip cinematics, `?lq=1`/`?hq=1` to force quality. This transforms verification: an automated harness (or you, later) can teleport the camera, assert which interactable is under the crosshair, fire keys, and read state — without simulating pointer-lock mouse aim, which headless drivers cannot do authentically.
- ### 16.3 Headless testing on software GL (SwiftShader)
- - First-paint shader compilation can block the main thread for **minutes**. Never assert on wall-clock time; wait on state.
- - `page.waitForFunction` defaults to `requestAnimationFrame` polling, which starves when the main thread is long-tasked — always pass `polling: <ms>`.
- - Force-click entry buttons with generous timeouts; overlay animations can delay actionability.
- - Long suites should run detached (`nohup node test.js > test.log &`) and print one final `RESULTS` block; tail the log between polls.
- - `GPU stall due to ReadPixels` warnings are benign headless noise, not page errors.
- - Note for agent workspaces: `node_modules` and browser caches may be pruned between sessions. Keep a `run.sh` that reinstalls Playwright + headless chromium and runs the suite, so recovery is one command.
- ### 16.4 Verify visuals with your own eyes (screenshot QA loop)
- A green logic suite proves nothing about art direction. Maintain a shot script that teleports through the canonical poses *and* state combinations (day/night, open/closed, on/off) and saves PNGs — then actually look at them. Tune exposure, light intensity/color, emissive levels, and material colors between passes. After every tuning pass, re-run the **full** logic suite: poses, clamps, and light-coupled state are intertwined and tuning regresses logic surprisingly often.
- ### 16.5 Texture orientation on lathed / cylinder-section surfaces
- Cylinder-section panels (curved monitors, alcoves) with θ centered on π show canvas textures mirrored. Empirical beats intuitive here: on a panel spanning θ∈[π−t/2, π+t/2] that is concave toward the camera, the de-mirror is
- ```js
- tex.wrapS = THREE.RepeatWrapping;
- tex.repeat.x = -1;
- ```
- Critical trap: `MirroredRepeatWrapping` with a negative repeat is a mathematical **identity** — mirrored wrapping maps −u back to the same texel, so it "fixes" nothing. Don't trust UV math in your head: flash a debug canvas with giant L/R glyphs, screenshot, and confirm before moving on.
- ### 16.6 Raycast hitbox engineering
- - `MeshBasicMaterial` hitboxes with `FrontSide` cull backfaces. Consequences: a ray **starting inside** a hitbox hits nothing; a ray nearly **coplanar** with a thin box sees only its backfaces. For wall-adjacent targets use thin single-sided panels facing the play space, and keep reachable camera space outside every hitbox.
- - Big decorative hitboxes (chairs, beds) swallow rays aimed past them. Scope `userData.hit` to precise child meshes, or attach the interactable to the small functional part.
- - Cap hitbox heights so vertically stacked targets never overlap.
- - Player circle vs AABB: if the spawn circle overlaps a box by even a few cm, axis-separated resolution can wedge **both** axes. Give spawns clearance ≥ radius + ε and test "can I move at spawn" explicitly.
- ### 16.7 Night-scene / mood lighting recipes
- - **Glow strips (LED, neon):** model the strip as thin emissive geometry (`emissiveIntensity ≈ 2–2.4`) and let bloom carry the glow. Add only a *small* distance-capped point light for spill. A bright point light (intensity ≥ 4) near a wall or window nukes the frame under ACES + bloom — stills read as a sunrise. A good starting dial: point intensity ≈ 1.8, decay ≈ 1.8, distance capped to the furniture it should graze.
- - **Moonlight through blinds:** one cool directional (`0x9db8ff`), PCFSoft shadows, and let slat geometry cast the stripes. Couple intensity to the blinds animation parameter every frame: `I = base(0.32) + t · boost(≈3.0)`. Warm interior fill vs. cool lunar key is the classic two-tone night shot.
- - The hemisphere light's ground color is your shadow floor — nudge it up when ceilings fall to pure black in stills.
- - `MeshBasicMaterial` screens: `material.color.setScalar(≈1.5)` pushes texel values above 1, so canvas screens read as light sources and feed bloom.
- - Fog, exposure, and bloom threshold are one system — tune them together at the final poses, not in isolation.
- ### 16.8 Camera pose conventions for cinematics
- Fix the yaw convention once — `forward = (−sin yaw, −cos yaw)`, so yaw 0 faces −z — and document it. Keep a `POSES` table (`{ pos, pitch, yaw }` per key pose) and drive sequences through eased `dt` keyframes. Script poses that **frame the strongest composition** (a lying pose should face the room's hero view, not the ceiling). Coordinate look-input clamps with pose ranges: a clamp narrower than the scripted pose snaps visibly on the first input frame.
- ### 16.9 UI that doesn't read as AI-made
- - Emoji glyphs render inconsistently across platforms and instantly mark a build. Use a small inline-SVG icon set: 24×24 viewBox, `stroke="currentColor"`, stroke-width ≈ 1.8, round caps. Swap `innerHTML` between two variants on state change (e.g. speaker on/off); tone via CSS `color`.
- - Restraint with chrome: floating text with a text-shadow scrim reads better over 3D than every chip in its own tinted translucent panel. Reserve panels for modals; make toolbar buttons borderless icons that tint on hover.
- - The favicon is a visible artifact too — a 20-line inline-SVG mark beats a borrowed emoji.
- - Careful: `innerHTML` with static SVG strings is fine; never concatenate user-supplied text into the same assignments.
- ### 16.10 Portable single-file builds
- When the deliverable must travel (moved folders, `file://`, no dev server), bundle the whole module graph into one inline **classic script**:
- 1. Shim resolution so bare imports hit the vendored pin: `node_modules/three/package.json` with `"exports": { ".": "./three.module.js", "./addons/*": "./jsm/*" }` plus the vendored files copied in.
- 2. `npx esbuild js/main.js --bundle --minify --format=iife --target=es2020 --outfile=bundle.js`
- 3. Assemble: inline `styles.css` and the bundle into the HTML, **drop the import map** (a classic script ignores it) and the `<script type="module" src=...>` tag.
- 4. IIFE/classic scripts work from `file://`; module scripts do not. Verify with a headless load of the `file://` URL and assert a zero-error boot.
- 5. Modular sources remain the source of truth; the single file is a **build artifact**. Script the assembly (`build-single.sh`) and regenerate after every edit — never patch the artifact by hand.
- ### 16.11 The loop that produces quality
- 1. Logic suite green →
- 2. Screenshot sweep across canonical poses and state combinations →
- 3. Art-direction pass (lights, exposure, materials, pose framing) →
- 4. **Full** suite re-run (tuning breaks logic — poses/clamps are coupled) →
- 5. Ship **with** the suite and debug API still in place for the next iteration.
- Polish is a loop, not a phase. Budget tool calls for at least two full cycles.
- ---
- ## 17. Procedural audio composition playbook (music + SFX + space)
- The house rule: **no audio files — everything is synthesized.** This reads as a limitation until you realize it buys perfect loops, state-reactive mixes, tiny downloads, and sounds no stock library has. The output quality comes from making *musical decisions before audio-node decisions*.
- ### 17.1 Compose on paper first: the music-theory decision order
- 1. **Key, tempo, mood before code.** "A minor, 74 bpm, sleepy" beats any node trick. Match tempo to the scene's energy and the reverb to the scene's size.
- 2. **Write chords as data, not as frequencies.** Keep MIDI note numbers and convert: `f = 440 * 2 ** ((m - 69) / 12)`. A progression is then a readable table: `[{ name: 'Am9', midi: [45, 55, 64, 67, 71] }, { name: 'Fmaj9', ... }]`.
- 3. **Voice-lead.** Choose inversions so consecutive chords share common tones or move each voice by ≤ 2 semitones. This — not synthesis quality — is the main difference between "written music" and "arpeggiated machine".
- 4. **Extensions carry the emotion.** Moods live in 7ths/9ths: `m9`, `maj9`, `6add9`. A cheap lush voicing = root+fifth low, 3rd+7th+9th on top.
- 5. **Humanize or die.** Per-note timing jitter ±8–18 ms, velocity jitter ±20 %, swing 8–12 % on 8th-note grids. Perfect quantization is the loudest "made by AI" tell in audio.
- ### 17.2 Voice recipes (oscillator cooking)
- - **Rhodes / epiano:** triangle fundamental + sine octave up, 5 ms attack, ~1.2 s decay, low sustain. Add a quiet FM "tine" partial (modulator ≈ 2.4× carrier, decays in ~80 ms) for the bell bite. Detune each note a few cents at trigger.
- - **Kick:** sine with pitch envelope 140 → 48 Hz in ~90 ms, plus a 6 ms filtered-noise click. Gain envelope ~0.25 s.
- - **Hat:** noise burst high-passed ≥ 7 kHz, 30–50 ms; a touch of bandpass "ping" reads as a closed hat.
- - **Snare / brush:** noise bandpassed 1.6–2.2 kHz + a 180 Hz body sine, ~120 ms.
- - **Sub bass:** sine root one octave under the chord, ≤ 90 Hz.
- - **Pad:** 2–3 detuned saws/triangles per note into ONE shared lowpass (~1.6 kHz), slow attack (~0.4 s). The shared filter makes it one instrument instead of stacked notes.
- ### 17.3 The lo-fi / vintage coating
- - **Low-pass everything.** A master ~2–5 kHz shelf-down IS the genre. Brilliance is the enemy.
- - **Vinyl crackle:** Poisson-distributed one-sample pops (~30/s) + soft pink hiss around −34 dB under everything.
- - **Tape wobble:** 0.3–0.4 Hz LFO → detune ±7 cents on harmonic content; ±0.3 % playbackRate on loops.
- - **Sidechain pump:** on every kick, duck pads/bass 6–9 dB, 30 ms attack, ~220 ms release. The mix "breathes" and sounds produced, not rendered.
- ### 17.4 Seamless loops via offline rendering
- - Build the **entire arrangement** inside an `OfflineAudioContext` of exactly N bars, render once at load, and `loop` the resulting buffer. Zero per-frame CPU, sample-accurate cycling, flawless sidechain envelopes.
- - Reverb tails kill seams: render starting one pickup bar early (or fold the tail samples back onto the loop head with a short equal-power crossfade) so the decay wraps inaudibly.
- - Ambience (rain/wind/hum): the **crossfade-fold** trick — synthesize noise 2× the wanted loop length, then fold the back half onto the front half with equal-power crossfades. Any non-periodic source becomes a seamless loop of arbitrary length.
- - Can't listen in CI? **Verify numerically:** peak < 0.95, RMS inside the expected band, loop-boundary continuity `|x[0] − x[n−1]|` small.
- ### 17.5 One-shot SFX recipes
- Pre-render each ONCE at load (OfflineAudioContext → buffer), then trigger buffer sources with per-play variation: detune ±5 %, filter frequency ±10 %, gain ±15 %.
- | Sound | Recipe |
- |---|---|
- | key clack | 8 ms noise through bandpass ~1.9 kHz (Q 1.2) + 2 ms 3.2 kHz sine ping, 60 ms decay |
- | switch click | 3 ms 2.4 kHz sine blip + 10 ms noise tick |
- | door creak | sine gliding ~90 → 65 Hz with slow wobble + noise through a sweeping bandpass, 0.5–0.7 s; layer a second detuned copy for wood |
- | servo whirr | saw sweeping 80 → 260 Hz in 0.4 s + 30 Hz tremolo + soft clip |
- | cloth rustle | noise bandpassed 800 → 1600 Hz, 3–5 amplitude bumps over ~0.6 s |
- | poof / thump | noise lowpassed ~700 Hz, 120 ms swell, fast decay + 70 Hz sub sine |
- | power-on | rising perfect-fifth arpeggio, 3 notes 70 ms apart, saw + lowpass sweep per note |
- | footstep (wood) | 40 ms low thud (sine 95 → 60 Hz) + knock (bandpass ~900 Hz) |
- | footstep (rug) | same but lowpassed ~500 Hz, +30 ms longer, −8 dB |
- | UI deny | two descending square blips, 220 → 174 Hz |
- ### 17.6 Space: one reverb bus + positional panners
- - **Impulse response:** stereo buffer ~1.8 s, exponentially decaying noise, ~12 ms pre-delay, a few discrete early taps → one `ConvolverNode` on a **send bus** (wet ≈ 0.22–0.3). Never a convolver per source.
- - **Positional voices:** `PannerNode` with `panningModel: 'HRTF'`, exaggerated-but-plausible distance model (ref distance ~0.6, rolloff ~1.6). Attach to moving objects by copying world matrices each frame; the listener follows the camera.
- - **Loops tied to simulation:** rain gain = f(blind openness), PC hum = f(power). Smooth every state change with `setTargetAtTime` (τ ≈ 0.12 s) — setting `.value` directly mid-stream is zipper noise.
- - Duck ambience a few dB under music rather than mixing louder.
- ### 17.7 Emotion tricks with the master chain
- - **Blanket muffle:** one master biquad lowpass ramped 20 kHz → ~900 Hz over ~600 ms when the avatar lies down / hides. One node buys instant intimacy and tells the player *the room heard them*.
- - **Slow weather:** an LFO (±1 dB, ~30 s period) on rain/wind keeps static loops alive.
- - **Safety limiter:** end the chain with `DynamicsCompressorNode` so stacked one-shots never clip.
- ### 17.8 Audio correctness checklist (the silent killers)
- - Create/resume the `AudioContext` **only inside a user gesture**; queue anything that would fire before unlock.
- - Exponential ramps never target `0` — use `0.0001`.
- - Interrupting a ramp? `cancelScheduledValues` then `setTargetAtTime` — don't fight scheduled automation.
- - Cap simultaneous oscillators; reach for pre-rendered buffers first.
- - Mute = master gain node, not `stop()` (one-shot sources can't be restarted).
- - Analyser nodes (`getByteFrequencyData`) let in-world screens visualize the *actual* mix — one analyser on the music bus, drawn into the screen's canvas texture, is a memorable detail for ~10 lines.
- ## Final principle
- Use the supplied examples as a toolbox, not a mandate to combine every feature. A convincing Three.js experience usually comes from a clear composition, stable controls, deliberate lighting, one or two memorable interactions, and disciplined performance—not from maximum geometry, maximum shaders, or maximum effects.
Advertisement
Add Comment
Please, Sign In to add comment