Josiahiscool73

visual debugging skill.md(sorry i hate ai)

Sep 1st, 2026
64
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 68.58 KB | None | 0 0
  1. ---
  2. name: threejs-experience-engineering
  3. 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.
  4. version: 1.2.0
  5. ---
  6.  
  7. # Three.js Experience Engineering
  8.  
  9. 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.
  10.  
  11. ## Operating instructions for an AI
  12. always have mobile support
  13. 1. **Classify the request before writing code.** Choose one of these paths:
  14. - **3D hero / art piece:** scene, camera, materials, animation, possibly postprocessing.
  15. - **Explorable or game-like world:** first-person controls, collision, interaction, UI, and a delta-time simulation.
  16. - **Model showcase / commerce / AR preview:** prefer `<model-viewer>` unless custom Three.js behavior is truly required.
  17. - **2D game:** use Canvas 2D unless real 3D adds value; do not force Three.js into a Pac-Man-style game.
  18. - **Inference-heavy app with decorative 3D:** explicitly budget GPU time and pause, simplify, or dispose of the scene while inference runs.
  19. 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.
  20. 3. **Pin one Three.js version** for core and every addon. Never combine a core build from one revision with an addon from another.
  21. 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.
  22. 5. **Use delta time for all simulation.** Clamp a resumed/background-tab frame so movement, physics, and animations do not jump.
  23. 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.
  24. 7. **Budget performance from the start.** Cap device pixel ratio, reuse objects, instance repeated geometry, throttle expensive queries, and dispose replaced worlds.
  25. 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.
  26.  
  27. ---
  28.  
  29. ## Source corpus distilled
  30.  
  31. Every supplied file is represented below. Some are direct Three.js references; others are deliberately useful boundary cases.
  32.  
  33. | Source | What to learn from it | Use with care |
  34. |---|---|---|
  35. | `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. |
  36. | `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. |
  37. | `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. |
  38. | `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. |
  39. | `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. |
  40. | `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. |
  41. | `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. |
  42. | `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. |
  43. | `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. |
  44. | `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. |
  45.  
  46. ---
  47.  
  48. ## 1. Dependency and runtime contract
  49.  
  50. ### Preferred browser-module setup
  51.  
  52. 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.
  53.  
  54. ```html
  55. <script type="importmap">
  56. {
  57. "imports": {
  58. "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
  59. "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
  60. }
  61. }
  62. </script>
  63.  
  64. <script type="module">
  65. import * as THREE from "three";
  66. import { OrbitControls } from "three/addons/controls/OrbitControls.js";
  67. import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
  68. </script>
  69. ```
  70.  
  71. ### Rules
  72.  
  73. - Use `import * as THREE from "three"` and import addons from `three/addons/...` in the same module world.
  74. - 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.
  75. - Pin versions instead of saying “latest.” Upgrade core and addons together after testing.
  76. - Use a local development server for ES modules and assets. Opening an HTML file via `file://` commonly breaks module and texture/model loading.
  77. - If WebXR is needed, use `renderer.setAnimationLoop()` rather than a separate `requestAnimationFrame` loop. Otherwise use one ordinary animation loop.
  78.  
  79. ---
  80.  
  81. ## 2. Canonical scene bootstrap
  82.  
  83. 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.
  84.  
  85. ```js
  86. import * as THREE from "three";
  87.  
  88. const canvas = document.querySelector("#webgl");
  89. const renderer = new THREE.WebGLRenderer({
  90. canvas,
  91. antialias: true,
  92. powerPreference: "high-performance"
  93. });
  94.  
  95. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  96. renderer.setSize(window.innerWidth, window.innerHeight);
  97. renderer.outputColorSpace = THREE.SRGBColorSpace;
  98. renderer.toneMapping = THREE.ACESFilmicToneMapping;
  99. renderer.toneMappingExposure = 1.0;
  100. renderer.shadowMap.enabled = true;
  101. renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  102.  
  103. const scene = new THREE.Scene();
  104. scene.background = new THREE.Color(0x101218);
  105. scene.fog = new THREE.FogExp2(0x101218, 0.025); // optional, atmospheric, and cheap
  106.  
  107. const camera = new THREE.PerspectiveCamera(
  108. 60,
  109. window.innerWidth / window.innerHeight,
  110. 0.1,
  111. 200
  112. );
  113. camera.position.set(0, 1.5, 5);
  114. camera.lookAt(0, 1, 0);
  115.  
  116. scene.add(new THREE.HemisphereLight(0xcfe5ff, 0x18200e, 1.2));
  117. const sun = new THREE.DirectionalLight(0xffffff, 2.5);
  118. sun.position.set(4, 8, 5);
  119. sun.castShadow = true;
  120. sun.shadow.mapSize.set(1024, 1024);
  121. scene.add(sun);
  122.  
  123. const mesh = new THREE.Mesh(
  124. new THREE.IcosahedronGeometry(1, 3),
  125. new THREE.MeshStandardMaterial({ color: 0x5bc0ff, roughness: 0.35, metalness: 0.25 })
  126. );
  127. mesh.castShadow = true;
  128. mesh.receiveShadow = true;
  129. scene.add(mesh);
  130.  
  131. const clock = new THREE.Clock();
  132. let raf = 0;
  133.  
  134. function resize() {
  135. const width = window.innerWidth;
  136. const height = window.innerHeight;
  137. camera.aspect = width / height;
  138. camera.updateProjectionMatrix();
  139. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  140. renderer.setSize(width, height);
  141. }
  142. window.addEventListener("resize", resize);
  143.  
  144. function update(dt, elapsed) {
  145. mesh.rotation.y += dt * 0.45;
  146. mesh.position.y = 1 + Math.sin(elapsed * 1.5) * 0.08;
  147. }
  148.  
  149. function frame() {
  150. raf = requestAnimationFrame(frame);
  151. const dt = Math.min(clock.getDelta(), 0.05); // protects against tab-resume hitches
  152. update(dt, clock.elapsedTime);
  153. renderer.render(scene, camera);
  154. }
  155. frame();
  156. ```
  157.  
  158. ### Rendering rules
  159.  
  160. - A `MeshStandardMaterial` or `MeshPhysicalMaterial` needs lights and benefits from an environment map. A `MeshBasicMaterial` does not react to lights.
  161. - Set `outputColorSpace = THREE.SRGBColorSpace` for current Three.js color management. Use tone mapping for HDR-looking lighting, emissive materials, or bloom.
  162. - Use `scene.fog` both as an aesthetic tool and as a way to hide distant low-detail geometry.
  163. - If using `EffectComposer`, render with `composer.render()` instead of also calling `renderer.render(scene, camera)` each frame.
  164. - Resize the renderer **and** every composer/render target that depends on viewport size.
  165.  
  166. ---
  167.  
  168. ## 3. Design the application as systems, not a script pile
  169.  
  170. A healthy project separates the following responsibilities:
  171.  
  172. ```text
  173. app/
  174. main.js renderer, scene, camera, lifecycle
  175. post.js EffectComposer, passes, quality settings, scene switching
  176. world.js create/rebuild/dispose environment
  177. player.js movement, collision, camera sync, head-bob
  178. interaction.js raycasts and userData actions
  179. assets.js GLTF/textures/procedural bakes/loading/fallbacks
  180. ui.js DOM HUD, menus, accessibility
  181. audio.js user-gesture initialization and sound buses
  182. shaders/ GLSL strings or shader modules
  183. ```
  184.  
  185. 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.
  186.  
  187. ### State versus visual objects
  188.  
  189. Keep a plain-data game state authoritative:
  190.  
  191. ```js
  192. const player = {
  193. x: 0, z: 6, y: 0,
  194. vx: 0, vz: 0, vy: 0,
  195. yaw: 0, pitch: 0,
  196. onGround: true
  197. };
  198. ```
  199.  
  200. 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.
  201.  
  202. For cinematic work, use explicit states such as:
  203.  
  204. ```text
  205. INTRO → IDLE → CHARGING → EXPLODING → DONE
  206. ```
  207.  
  208. 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`.
  209.  
  210. ### Scripted cinematic sequences (wake, sit, cutscenes)
  211.  
  212. 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.
  213.  
  214. ```js
  215. const easeInOut = t => (t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2);
  216.  
  217. // while a sequence runs, take over update():
  218. if (sitPhase === 1) { // sitting down over 1.2s
  219. sitT += dt;
  220. const e = easeInOut(Math.min(1, sitT / 1.2));
  221. camera.position.set(lerp(from.x, to.x, e), lerp(1.6, to.y, e), lerp(from.z, to.z, e));
  222. camera.rotation.set(lerp(from.pitch, to.pitch, e), lerpAngle(from.yaw, to.yaw, e), 0);
  223. if (sitT > 1.2) sitPhase = 2; // seated; hand control to the seated state
  224. }
  225. ```
  226.  
  227. 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.
  228.  
  229. ---
  230.  
  231. ## 4. First-person movement, collision, and interaction
  232.  
  233. The apartment, grassrooms, and expedition examples establish a practical browser-FPS pattern.
  234.  
  235. ### Camera orientation and movement
  236.  
  237. Use `YXZ` order so yaw and pitch are intuitive. In Three.js, a camera at yaw `0` looks down negative Z.
  238.  
  239. ```js
  240. camera.rotation.order = "YXZ";
  241.  
  242. const forward = new THREE.Vector3();
  243. const right = new THREE.Vector3();
  244.  
  245. function updatePlayer(dt, input) {
  246. player.yaw -= input.lookX * 0.002;
  247. player.pitch = THREE.MathUtils.clamp(
  248. player.pitch - input.lookY * 0.002,
  249. -1.4,
  250. 1.4
  251. );
  252.  
  253. forward.set(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
  254. right.set(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
  255.  
  256. let moveX = input.right;
  257. let moveZ = input.forward;
  258. const length = Math.hypot(moveX, moveZ);
  259. if (length > 1) {
  260. moveX /= length;
  261. moveZ /= length;
  262. }
  263.  
  264. const speed = 4.5;
  265. const dx = (forward.x * moveZ + right.x * moveX) * speed * dt;
  266. const dz = (forward.z * moveZ + right.z * moveX) * speed * dt;
  267.  
  268. moveWithCollision(dx, dz);
  269. camera.position.set(player.x, player.y + 1.65, player.z);
  270. camera.rotation.set(player.pitch, player.yaw, 0);
  271. }
  272. ```
  273.  
  274. - Normalize diagonal input; otherwise diagonal movement is faster.
  275. - For smoother motion, accelerate velocity toward a desired velocity with an exponential smoothing factor such as `1 - Math.exp(-accel * dt)`.
  276. - Clamp `dt`, especially before gravity, projectile, or collision calculations.
  277. - Do not create `Vector3`, `Raycaster`, or arrays every frame when a reusable temporary object will do.
  278.  
  279. ### Head-bob, sway, and footstep triggers
  280.  
  281. 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.
  282.  
  283. ```js
  284. if (isMoving) player.bobTime += dt * (running ? 7.5 : 5.5);
  285. else player.bobTime += (Math.round(player.bobTime / Math.PI) * Math.PI - player.bobTime) * dt * 6; // ease to rest
  286. const rawBob = Math.sin(player.bobTime);
  287. if (lastBob > 0 && rawBob <= 0 && isMoving) playSound(surfaceStepSound(player.x, player.z)); // zero-crossing
  288. lastBob = rawBob;
  289.  
  290. const bobY = isMoving ? rawBob * 0.04 : Math.sin(time * 0.002) * 0.01; // idle breathing when still
  291. const roll = Math.cos(player.bobTime * 0.5) * 0.012 * (isMoving ? 1 : 0);
  292. camera.position.set(player.x, 1.6 + bobY, player.z);
  293. camera.rotation.set(player.pitch, player.yaw, roll);
  294. ```
  295.  
  296. Keep amplitudes small and disable or greatly reduce bob/sway under `prefers-reduced-motion` (see §10).
  297.  
  298. ### Simple circle-versus-AABB collision
  299.  
  300. For indoor/blocky worlds, 2D collision on X/Z is often sufficient and much cheaper than a physics engine.
  301.  
  302. ```js
  303. const colliders = []; // { minX, maxX, minZ, maxZ }
  304.  
  305. function addAABB(centerX, centerZ, width, depth) {
  306. colliders.push({
  307. minX: centerX - width / 2,
  308. maxX: centerX + width / 2,
  309. minZ: centerZ - depth / 2,
  310. maxZ: centerZ + depth / 2
  311. });
  312. }
  313.  
  314. function collidesAABB(x, z, radius = 0.35) {
  315. return colliders.some(c =>
  316. x + radius > c.minX && x - radius < c.maxX &&
  317. z + radius > c.minZ && z - radius < c.maxZ
  318. );
  319. }
  320.  
  321. function moveWithCollision(dx, dz) {
  322. const nextX = player.x + dx;
  323. const nextZ = player.z + dz;
  324. if (!collidesAABB(nextX, player.z)) player.x = nextX;
  325. if (!collidesAABB(player.x, nextZ)) player.z = nextZ;
  326. }
  327. ```
  328.  
  329. 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.
  330.  
  331. ### Center-screen raycast interaction
  332.  
  333. 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.
  334.  
  335. ```js
  336. const raycaster = new THREE.Raycaster();
  337. const screenCenter = new THREE.Vector2(0, 0);
  338. const interactables = [];
  339.  
  340. function findInteractable(maxDistance = 3) {
  341. raycaster.setFromCamera(screenCenter, camera);
  342. const hit = raycaster.intersectObjects(interactables, true)[0];
  343. if (!hit || hit.distance > maxDistance) return null;
  344.  
  345. let object = hit.object;
  346. while (object && !object.userData.action) object = object.parent;
  347. return object ?? null;
  348. }
  349.  
  350. // During creation:
  351. // door.userData.action = () => openDoor();
  352. // interactables.push(door);
  353. ```
  354.  
  355. 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.
  356.  
  357. ### Pointer lock, touch, and audio
  358.  
  359. - Request pointer lock only after a user action. When it exits, show a pause/menu state rather than continuing invisible input.
  360. - Build desktop and touch input into the same normalized `input.forward`, `input.right`, `input.lookX`, and `input.lookY` values.
  361. - Use touch identifiers so the left joystick and right look zone do not steal each other’s touches.
  362. - Initialize or resume `AudioContext` from a button/tap/click. Browsers block autoplay.
  363. - Pausing on `visibilitychange` is good UX and prevents a giant delta on return.
  364.  
  365. ### A procedural Web Audio bed
  366.  
  367. `apartmentv2.html` builds a convincing soundscape entirely from synthesis—no audio files—which keeps a prototype self-contained. The reusable structure:
  368.  
  369. - **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.
  370. - **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.
  371. - **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.
  372. - **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.
  373. - **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.
  374.  
  375. ```js
  376. // send/return skeleton
  377. master = ctx.createGain(); master.connect(destinationFilter); destinationFilter.connect(ctx.destination);
  378. reverb = ctx.createConvolver(); reverb.buffer = makeImpulse(ctx, 1.6); // decaying-noise IR
  379. dryGain = ctx.createGain(); wetGain = ctx.createGain(); wetGain.gain.value = 0.22;
  380. dryGain.connect(master); reverb.connect(wetGain); wetGain.connect(master);
  381. function routeFX(node, send = 0.25) {
  382. node.connect(dryGain);
  383. const s = ctx.createGain(); s.gain.value = send; node.connect(s); s.connect(reverb);
  384. }
  385. // per-frame ambient mix (dt-smoothed toward a zone target)
  386. rainGain.gain.value += ((inRoom ? 0.035 : 0.006) - rainGain.gain.value) * dt * 1.5;
  387. ```
  388.  
  389. Provide a mute/volume control and respect it; keep the total oscillator/source count bounded.
  390.  
  391. ---
  392.  
  393. ## 5. Procedural rooms, terrain, and materials
  394.  
  395. ### Canvas-generated textures
  396.  
  397. Canvas textures are excellent for self-contained prototypes: carpet/noise, plaster, grass, gradients, UI sprites, or a simple environment map.
  398.  
  399. ```js
  400. function makeNoiseTexture(base, flecks, size = 512) {
  401. const canvas = document.createElement("canvas");
  402. canvas.width = canvas.height = size;
  403. const ctx = canvas.getContext("2d");
  404.  
  405. ctx.fillStyle = base;
  406. ctx.fillRect(0, 0, size, size);
  407. for (let i = 0; i < size * size * 0.25; i++) {
  408. ctx.globalAlpha = Math.random() * 0.16;
  409. ctx.fillStyle = flecks;
  410. ctx.fillRect(Math.random() * size, Math.random() * size, 2, 2);
  411. }
  412. ctx.globalAlpha = 1;
  413.  
  414. const texture = new THREE.CanvasTexture(canvas);
  415. texture.colorSpace = THREE.SRGBColorSpace;
  416. texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
  417. return texture;
  418. }
  419.  
  420. const texture = makeNoiseTexture("#d6cebd", "#a99e8c");
  421. texture.repeat.set(8, 5);
  422. const wallMaterial = new THREE.MeshStandardMaterial({
  423. map: texture,
  424. bumpMap: texture,
  425. bumpScale: 0.015,
  426. roughness: 0.9
  427. });
  428. ```
  429.  
  430. 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.
  431.  
  432. 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.
  433.  
  434. ### Baking a normal map from a canvas bump (Sobel)
  435.  
  436. 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).
  437.  
  438. ```js
  439. function normalFromBump(drawFn, w = 512, h = 512, strength = 2.0) {
  440. const c = document.createElement("canvas"); c.width = w; c.height = h;
  441. const ctx = c.getContext("2d"); drawFn(ctx, w, h);
  442. const src = ctx.getImageData(0, 0, w, h).data;
  443. const out = ctx.createImageData(w, h), od = out.data;
  444. const at = (x, y) => src[(((y + h) % h) * w + ((x + w) % w)) * 4] / 255; // tiles/wraps
  445. for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
  446. 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));
  447. 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));
  448. let nx = -dx*strength, ny = -dy*strength, nz = 1;
  449. const L = Math.hypot(nx, ny, nz); nx/=L; ny/=L; nz/=L;
  450. const i = (y*w + x)*4;
  451. 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;
  452. }
  453. ctx.putImageData(out, 0, 0);
  454. const t = new THREE.CanvasTexture(c);
  455. t.wrapS = t.wrapT = THREE.RepeatWrapping;
  456. return t; // assign to material.normalMap; do NOT set colorSpace = SRGB
  457. }
  458. ```
  459.  
  460. Bake procedural maps once at load, not per frame—`getImageData`/`putImageData` are expensive.
  461.  
  462. ### Deformed terrain
  463.  
  464. 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.
  465.  
  466. ```js
  467. function terrainHeight(x, z) {
  468. return Math.sin(x * 0.30) * Math.cos(z * 0.30) * 0.45
  469. + Math.sin(x * 0.10) * 0.18;
  470. }
  471.  
  472. const geometry = new THREE.PlaneGeometry(60, 80, 80, 80);
  473. geometry.rotateX(-Math.PI / 2);
  474. const positions = geometry.attributes.position;
  475.  
  476. for (let i = 0; i < positions.count; i++) {
  477. const x = positions.getX(i);
  478. const z = positions.getZ(i);
  479. positions.setY(i, terrainHeight(x, z));
  480. }
  481. geometry.computeVertexNormals();
  482.  
  483. const terrain = new THREE.Mesh(geometry, terrainMaterial);
  484. terrain.receiveShadow = true;
  485. scene.add(terrain);
  486. ```
  487.  
  488. Rules:
  489.  
  490. - `PlaneGeometry` begins in the XY plane; rotate it around X to make it a ground plane.
  491. - Recompute normals after editing vertex positions.
  492. - 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.
  493. - For a small diorama, simple primitives and fog frequently look better and run faster than a large asset pack.
  494.  
  495. ### Procedural object builders
  496.  
  497. 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.
  498.  
  499. 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.
  500.  
  501. ### Sculpting soft furnishings with vertex deformation
  502.  
  503. 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()`:
  504.  
  505. ```js
  506. function sculptPillowGeo(w, h, d, r, { dent = 0.26, lobe = 0.15, pinch = 0.5, wrinkle = 0.0045 } = {}) {
  507. const g = new RoundedBoxGeometry(w, h, d, 12, Math.min(r, h * 0.42));
  508. const p = g.attributes.position;
  509. const hw = w / 2, hd = d / 2;
  510. for (let i = 0; i < p.count; i++) {
  511. const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
  512. const dentG = Math.exp(-((x / (hw * 0.38)) ** 2)); // head hollow (center)
  513. const lobeG = Math.exp(-(((Math.abs(x) - hw * 0.58) / (hw * 0.36)) ** 2)); // twin puffy lobes
  514. const edge = smooth01((hw - Math.abs(x)) / (hw * 0.5 + 0.015))
  515. * smooth01((hd - Math.abs(z)) / (hd * 0.5 + 0.01)); // 0 at rim → 1 inside
  516. const wob = Math.sin(x / hw * 8.7 + z / hd * 4.3)
  517. * Math.sin(z / hd * 7.9 - x / hw * 2.6) * wrinkle * edge * Math.sign(y || 1);
  518. p.setY(i, y * (1 - pinch + pinch * edge) * (1 - dent * dentG) * (1 + lobe * lobeG * edge) + wob);
  519. p.setZ(i, z * (1 + 0.09 * lobeG * edge - 0.05 * dentG)); // lobes get deeper too
  520. }
  521. g.computeVertexNormals();
  522. return g;
  523. }
  524. ```
  525.  
  526. Rules of thumb:
  527.  
  528. - Use **smooth falloffs** (gaussians, smoothsteps) — never clamped linear cuts, which produce hard creases that read as low-poly, not softness.
  529. - 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.
  530. - 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.
  531. - Vertical pads (headboard cushions) can reuse the same sculptor: build in the "lying pillow" frame, then `geometry.rotateZ(Math.PI / 2)` into place.
  532. - 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).
  533.  
  534. ---
  535.  
  536. ## 6. Assets, SVG extrusion, and fallback behavior
  537.  
  538. ### GLTF/GLB loading
  539.  
  540. Use `GLTFLoader` for authored models and give the player a fallback when the network or model fails.
  541.  
  542. ```js
  543. import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
  544.  
  545. const loader = new GLTFLoader();
  546.  
  547. async function loadCharacter(url) {
  548. try {
  549. const gltf = await loader.loadAsync(url);
  550. const model = gltf.scene;
  551. model.traverse(object => {
  552. if (!object.isMesh) return;
  553. object.castShadow = true;
  554. object.receiveShadow = true;
  555. });
  556. return model;
  557. } catch (error) {
  558. console.warn("Model failed to load; using fallback.", error);
  559. return makeFallbackCharacter();
  560. }
  561. }
  562. ```
  563.  
  564. - Put model URLs behind a loading state and show useful progress/error UI when appropriate.
  565. - Model scale, origin, materials, and animation names are asset-specific; inspect them instead of guessing.
  566. - A procedural fallback makes a demo playable offline or when CORS/network delivery fails.
  567. - Dynamic crowds and fast-moving NPCs usually should not cast shadows; one shadow map can dominate the frame budget.
  568.  
  569. ### SVG → extruded 3D logo
  570.  
  571. 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.
  572.  
  573. ```js
  574. import { SVGLoader } from "three/addons/loaders/SVGLoader.js";
  575. import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
  576.  
  577. const data = new SVGLoader().parse(svgText);
  578. const shapes = data.paths.flatMap(path => SVGLoader.createShapes(path));
  579. const geometries = shapes.map(shape => new THREE.ExtrudeGeometry(shape, {
  580. depth: 0.5,
  581. bevelEnabled: true,
  582. bevelThickness: 0.04,
  583. bevelSize: 0.03,
  584. bevelSegments: 2,
  585. curveSegments: 16
  586. }));
  587.  
  588. const geometry = mergeGeometries(geometries);
  589. geometry.computeBoundingBox();
  590. const center = geometry.boundingBox.getCenter(new THREE.Vector3());
  591. geometry.translate(-center.x, -center.y, -center.z);
  592. ```
  593.  
  594. Dispose intermediate geometries after merging. Normalize size from `Box3` if the SVG needs to fit a fixed hero composition.
  595.  
  596. ---
  597.  
  598. ## 7. Instancing, particles, and shaders
  599.  
  600. ### Use `InstancedMesh` for many repeated objects
  601.  
  602. 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.
  603.  
  604. ```js
  605. const count = 4000;
  606. const geometry = new THREE.ConeGeometry(0.025, 0.8, 3);
  607. const material = new THREE.MeshStandardMaterial({ color: 0x4d7f20 });
  608. const grass = new THREE.InstancedMesh(geometry, material, count);
  609. const dummy = new THREE.Object3D();
  610.  
  611. for (let i = 0; i < count; i++) {
  612. const x = THREE.MathUtils.randFloatSpread(50);
  613. const z = THREE.MathUtils.randFloatSpread(50);
  614. dummy.position.set(x, terrainHeight(x, z), z);
  615. dummy.rotation.y = Math.random() * Math.PI;
  616. const scale = THREE.MathUtils.randFloat(0.45, 1.1);
  617. dummy.scale.set(scale, scale, scale);
  618. dummy.updateMatrix();
  619. grass.setMatrixAt(i, dummy.matrix);
  620. }
  621. grass.instanceMatrix.needsUpdate = true;
  622. scene.add(grass);
  623. ```
  624.  
  625. - Start with the native `InstancedMesh` API.
  626. - Use `InstancedBufferGeometry` and custom `InstancedBufferAttribute`s only when the vertex shader needs custom per-instance values such as a quaternion, phase, color, or ID.
  627. - Use a reusable `Object3D`, `Matrix4`, `Quaternion`, and `Vector3` while populating/updating instances.
  628. - 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.
  629.  
  630. ### A compact shader pattern
  631.  
  632. 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.
  633.  
  634. ```js
  635. const material = new THREE.ShaderMaterial({
  636. uniforms: { uTime: { value: 0 } },
  637. vertexShader: `
  638. uniform float uTime;
  639. varying vec3 vNormal;
  640. void main() {
  641. vNormal = normal;
  642. vec3 p = position + normal * sin(position.y * 5.0 + uTime) * 0.08;
  643. gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
  644. }
  645. `,
  646. fragmentShader: `
  647. varying vec3 vNormal;
  648. void main() {
  649. float lit = max(dot(normalize(vNormal), normalize(vec3(1.0, 1.0, 2.0))), 0.0);
  650. gl_FragColor = vec4(mix(vec3(0.08, 0.02, 0.2), vec3(0.0, 0.8, 1.0), lit), 1.0);
  651. }
  652. `
  653. });
  654.  
  655. // In update(dt, elapsed): material.uniforms.uTime.value = elapsed;
  656. ```
  657.  
  658. ### Advanced shader rules
  659.  
  660. - Use `ShaderMaterial` when you own the full shader. Use `material.onBeforeCompile` only when extending a built-in material is worth the maintenance cost.
  661. - `onBeforeCompile` string replacement and custom use of internal shader chunks are revision-sensitive. Pin the revision and test upgrades.
  662. - 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.
  663. - 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.
  664. - Do not put frame-random noise into a shader or JS transform unless shimmer is intentional. Temporal instability looks like a rendering bug.
  665. - Update uniforms in place (`uniform.value.copy(...)` or `.set(...)`) rather than reallocating vectors every frame.
  666.  
  667. ---
  668.  
  669. ## 8. Lighting, environment, shadows, and bloom
  670.  
  671. A dependable atmospheric baseline is:
  672.  
  673. - a low-intensity `AmbientLight` or `HemisphereLight`;
  674. - one main `DirectionalLight` or carefully budgeted `PointLight`;
  675. - restrained fog;
  676. - rough PBR materials;
  677. - emissive accents, not dozens of dynamic lights.
  678.  
  679. ### Environment reflections
  680.  
  681. 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.
  682.  
  683. ```js
  684. import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
  685.  
  686. const pmrem = new THREE.PMREMGenerator(renderer);
  687. const room = new RoomEnvironment();
  688. const environmentTarget = pmrem.fromScene(room, 0.04);
  689. scene.environment = environmentTarget.texture;
  690. room.dispose();
  691. pmrem.dispose();
  692.  
  693. // Keep environmentTarget alive while scene.environment uses its texture.
  694. // Dispose environmentTarget during teardown or before replacing the environment.
  695. ```
  696.  
  697. A real HDR environment normally gives better reflections; use a small, compressed asset and a loading state when quality demands it.
  698.  
  699. ### Postprocessing
  700.  
  701. Bloom is effective for emissive sci-fi scenes and the glassballs visual, but it costs memory and fill rate.
  702.  
  703. ```js
  704. import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
  705. import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
  706. import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
  707. import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
  708.  
  709. const composer = new EffectComposer(renderer);
  710. composer.addPass(new RenderPass(scene, camera));
  711. composer.addPass(new UnrealBloomPass(
  712. new THREE.Vector2(window.innerWidth, window.innerHeight),
  713. 0.55, 0.3, 0.75
  714. ));
  715. composer.addPass(new OutputPass());
  716.  
  717. // Per frame: composer.render();
  718. // On resize: composer.setPixelRatio(cappedDpr); composer.setSize(width, height);
  719. ```
  720.  
  721. Rules:
  722.  
  723. - Add bloom only after the unprocessed scene is correct.
  724. - Use `composer.render()`, not a second direct render.
  725. - Cap DPR even more aggressively on touch devices.
  726. - A low-resolution shadow map, fog, an emissive material, and good composition often outperform expensive bloom and many lights.
  727.  
  728. ### A custom grade pass on top of bloom
  729.  
  730. `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.
  731.  
  732. ```js
  733. import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
  734.  
  735. const GradeShader = {
  736. uniforms: { tDiffuse: { value: null }, time: { value: 0 }, aberration: { value: 0.0016 }, grain: { value: 0.045 }, vignette: { value: 1.0 } },
  737. vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
  738. fragmentShader: `
  739. uniform sampler2D tDiffuse; uniform float time, aberration, grain, vignette; varying vec2 vUv;
  740. float rand(vec2 c){ return fract(sin(dot(c, vec2(12.9898,78.233))) * 43758.5453); }
  741. void main(){
  742. vec2 d = vUv - 0.5;
  743. float r = texture2D(tDiffuse, vUv + d*aberration).r;
  744. float g = texture2D(tDiffuse, vUv).g;
  745. float b = texture2D(tDiffuse, vUv - d*aberration).b;
  746. vec3 col = vec3(r, g, b);
  747. col *= mix(1.0, smoothstep(0.95, 0.25, length(d)*1.3), vignette*0.9);
  748. col += rand(vUv + fract(time)) * grain - grain*0.5;
  749. gl_FragColor = vec4(col, 1.0);
  750. }`
  751. };
  752. const gradePass = new ShaderPass(GradeShader);
  753. gradePass.renderToScreen = true;
  754. composer.addPass(gradePass);
  755. // Per frame, before composer.render(): gradePass.uniforms.time.value = elapsed;
  756. ```
  757.  
  758. 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).
  759.  
  760. ### One composer, multiple scenes (portals / sub-worlds)
  761.  
  762. 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.
  763.  
  764. ```js
  765. const portalScene = new THREE.Scene();
  766. const renderPass = composer.passes[0]; // the RenderPass added first
  767.  
  768. function frame() {
  769. renderPass.scene = inPortal ? portalScene : scene; // swap what gets rendered
  770. composer.render();
  771. }
  772. ```
  773.  
  774. 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.
  775.  
  776. ### Toggleable quality that rebuilds passes
  777.  
  778. 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.
  779.  
  780. ```js
  781. const settings = { bloom: true, hq: true, showFps: false };
  782. let smaaPass = null;
  783.  
  784. function applyQuality() {
  785. bloomPass.enabled = settings.bloom;
  786. const pr = settings.hq ? Math.min(window.devicePixelRatio, 2) : 1;
  787. renderer.setPixelRatio(pr);
  788. composer.setPixelRatio(pr);
  789. if (settings.hq && !smaaPass) {
  790. smaaPass = new SMAAPass(window.innerWidth * pr, window.innerHeight * pr);
  791. composer.insertPass(smaaPass, composer.passes.length - 1); // keep grade pass last
  792. }
  793. if (smaaPass) smaaPass.enabled = settings.hq;
  794. onResize();
  795. }
  796. ```
  797.  
  798. ---
  799.  
  800. ## 9. Performance and lifecycle discipline
  801.  
  802. ### Frame budget
  803.  
  804. Apply these defaults before optimizing micro-details:
  805.  
  806. ```js
  807. const isTouch = navigator.maxTouchPoints > 0 || "ontouchstart" in window;
  808. const pixelRatioCap = isTouch ? 1.25 : 1.5;
  809. renderer.setPixelRatio(Math.min(window.devicePixelRatio, pixelRatioCap));
  810. ```
  811.  
  812. Then:
  813.  
  814. 1. Measure before changing behavior.
  815. 2. Reduce rendered pixels before making JavaScript obscure.
  816. 3. Instance repeated geometry.
  817. 4. Reduce shadow casters, shadow map size, light count, and postprocessing passes.
  818. 5. Avoid allocations in the hot loop.
  819. 6. Throttle noncritical raycasts, DOM updates, nametag projection, and AI decisions.
  820. 7. Pause rendering while hidden. For a GPU-heavy model inference task, park or simplify a decorative Three.js scene as `ai.html` does.
  821.  
  822. ### Rebuildable worlds
  823.  
  824. Procedural floor/theme changes should live in a dedicated root group.
  825.  
  826. ```js
  827. let world = null;
  828.  
  829. function replaceWorld(nextWorld) {
  830. if (world) disposeWorld(world);
  831. world = nextWorld;
  832. scene.add(world);
  833. }
  834.  
  835. function disposeWorld(root) {
  836. const geometries = new Set();
  837. const materials = new Set();
  838.  
  839. scene.remove(root);
  840. root.traverse(object => {
  841. if (object.geometry) geometries.add(object.geometry);
  842. const list = Array.isArray(object.material)
  843. ? object.material
  844. : object.material ? [object.material] : [];
  845. list.forEach(material => materials.add(material));
  846. });
  847.  
  848. geometries.forEach(geometry => geometry.dispose());
  849. materials.forEach(material => {
  850. // Dispose only materials owned exclusively by this world.
  851. // Shared resources should be reference-counted or disposed by their owner.
  852. if (!material.userData.shared) material.dispose();
  853. });
  854. }
  855. ```
  856.  
  857. 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.
  858.  
  859. Also dispose owned textures, render targets, postprocessing passes where applicable, controls, event listeners, and audio nodes when the entire app unmounts.
  860.  
  861. ---
  862.  
  863. ## 10. Responsive UI, accessibility, and presentation
  864.  
  865. 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.
  866.  
  867. Required practices:
  868.  
  869. - Use semantic buttons and visible focus states for actions.
  870. - Support keyboard controls as well as touch controls.
  871. - Respect `prefers-reduced-motion`; reduce automatic rotation, shake, particle drift, and rapid camera motion.
  872. - Persist player settings such as sensitivity, audio toggles, and control size responsibly (for example `localStorage`).
  873. - Offer colorblind-safe palettes or a tested filter/settings option; do not rely only on color for gameplay state.
  874. - Avoid important information only in bloom, tiny 3D labels, or sound.
  875. - Surface pointer-lock exit, loading failures, and WebGL fallback states clearly.
  876. - Keep DOM updates out of the render hot path unless their value changes or a throttled interval has elapsed.
  877.  
  878. 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.
  879.  
  880. ---
  881.  
  882. ## 11. Exporting a scene for 3D printing
  883.  
  884. The grassrooms example points toward STL export. STL includes triangles, not textures, lights, animation, UI, or material appearance.
  885.  
  886. Use a consistent ESM setup:
  887.  
  888. ```js
  889. import { STLExporter } from "three/addons/exporters/STLExporter.js";
  890.  
  891. function exportSTL(root) {
  892. scene.updateMatrixWorld(true);
  893. const exporter = new STLExporter();
  894. const result = exporter.parse(root, { binary: true });
  895. const blob = new Blob([result], { type: "application/octet-stream" });
  896. const url = URL.createObjectURL(blob);
  897. const a = document.createElement("a");
  898. a.href = url;
  899. a.download = "scene.stl";
  900. a.click();
  901. URL.revokeObjectURL(url);
  902. }
  903. ```
  904.  
  905. Before export:
  906.  
  907. - Clone or build a dedicated export group if the interactive scene contains lights, non-manifold planes, helper objects, sprites, void materials, or UI.
  908. - Ensure all transforms are current with `scene.updateMatrixWorld(true)`.
  909. - Give the model thickness; a single wall plane is not printable.
  910. - Validate manifoldness and scale in a proper mesh/print tool afterward.
  911.  
  912. ---
  913.  
  914. ## 12. AR and simple model presentation: do not overbuild
  915.  
  916. 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.
  917.  
  918. ```html
  919. <script type="module"
  920. src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js"></script>
  921.  
  922. <model-viewer
  923. id="viewer"
  924. src="/models/Astronaut.glb"
  925. alt="Astronaut model"
  926. camera-controls
  927. auto-rotate
  928. ar
  929. ar-modes="webxr scene-viewer quick-look"
  930. shadow-intensity="1">
  931. </model-viewer>
  932.  
  933. <button id="helmet">Show helmet</button>
  934. <script>
  935. document.querySelector("#helmet").addEventListener("click", () => {
  936. document.querySelector("#viewer").src = "/models/DamagedHelmet.glb";
  937. });
  938. </script>
  939. ```
  940.  
  941. 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.
  942.  
  943. ---
  944.  
  945. ## 13. What transfers from the Canvas Pac-Man implementation
  946.  
  947. Do not rewrite a fundamentally 2D tile game in Three.js merely because Three.js is available. Transfer these engineering practices instead:
  948.  
  949. - fixed logical grid / world coordinates independent of responsive display scaling;
  950. - a central mode/state machine;
  951. - fixed, named constants for speed tables and behavior;
  952. - deterministic per-frame `update(dt)` followed by `render()`;
  953. - `dt` clamping;
  954. - keyboard and touch input routed into one input model;
  955. - audio created only after gesture;
  956. - pause on visibility change;
  957. - pre-render static assets/layers when possible.
  958.  
  959. For an intentionally 3D board game, use an `OrthographicCamera`, a fixed tile-to-world scale, and the same simulation architecture.
  960.  
  961. ---
  962.  
  963. ## 14. Troubleshooting checklist
  964.  
  965. | Symptom | Likely cause and fix |
  966. |---|---|
  967. | 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. |
  968. | 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. |
  969. | Scene looks washed out or too dark | Check `outputColorSpace`, tone mapping/exposure, lights, material type, and whether a physical material needs an environment. |
  970. | Shadows detach from a shader-animated object | Match vertex deformation in a custom depth material or temporarily disable that object’s cast shadow. |
  971. | FPS collapses on mobile | Cap DPR; reduce shadow maps/lights/bloom; instance repeats; reduce geometry segments; throttle raycasts and DOM work. |
  972. | 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. |
  973. | Terrain camera floats/sinks | Use the same `terrainHeight(x, z)` for mesh deformation and player grounding. |
  974. | Raycast action never fires | Pass `recursive: true`, check range, and walk from hit child to a parent with `userData.action`. |
  975. | Audio is silent | Initialize/resume the audio context from a user gesture, check mute state, and avoid creating unlimited oscillators/nodes. |
  976. | Rebuilt levels leak memory | Remove old root groups and dispose their owned geometries/materials/textures/render targets; do not dispose shared resources too early. |
  977. | 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. |
  978. | 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. |
  979. | 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. |
  980. | 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. |
  981. | 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. |
  982. | Film grain looks like static / flickers harshly | Lower the grain uniform and advance `time` smoothly; heavy per-frame noise reads as a rendering bug. |
  983. | 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. |
  984. | 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. |
  985. | `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). |
  986. | 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). |
  987. | 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). |
  988. | Player stuck without moving at spawn | Spawn circle overlaps a collision AABB; axis-separated resolution wedges both axes. Clearance ≥ radius + ε (§16.6). |
  989. | 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). |
  990. | 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). |
  991. | 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). |
  992. | 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). |
  993. | 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). |
  994. | 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). |
  995. | 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). |
  996. | 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). |
  997.  
  998. ---
  999.  
  1000. ## 15. Definition of done
  1001.  
  1002. Before calling a Three.js implementation complete, verify all of the following:
  1003.  
  1004. - [ ] One pinned Three.js revision is used for core and addons.
  1005. - [ ] The page has a useful state if WebGL, a model, network access, or audio is unavailable.
  1006. - [ ] The renderer has a reasonable DPR cap and correct resize behavior.
  1007. - [ ] Animation and simulation use clamped delta time.
  1008. - [ ] Materials, lights, color space, tone mapping, and fog are intentional.
  1009. - [ ] Controls work with the required desktop/mobile input methods and do not trap users unexpectedly.
  1010. - [ ] Audio starts only after interaction and can be muted.
  1011. - [ ] Expensive repeated geometry is instanced or reduced.
  1012. - [ ] Raycasting/DOM updates are throttled when they are not frame-critical.
  1013. - [ ] World replacement disposes owned GPU resources without destroying shared ones.
  1014. - [ ] A paused/hidden tab does not resume with a giant movement or physics step.
  1015. - [ ] Reduced-motion, keyboard access, readable UI, and non-color-only feedback are considered.
  1016. - [ ] Postprocessing order is correct, only one pass renders to screen, and all passes/composer resize with DPR.
  1017. - [ ] Scene/sub-world switches re-point the render pass and build/dispose per-world resources cleanly.
  1018. - [ ] Procedural textures/normal maps are baked once at load, with correct color spaces and anisotropy.
  1019. - [ ] Audio ramps avoid zero for exponential ramps, reuse buffers, and expose a working mute/volume.
  1020. - [ ] 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.
  1021. - [ ] Soft furnishings (pillows, cushions, duvets, rugs) are sculpted with smooth-falloff vertex displacement — no visible box edges on anything meant to be soft.
  1022. - [ ] Scripted camera sequences are `dt`-driven and eased, and they restore input/UI when they end.
  1023. - [ ] Browser console is clean of module, CORS, shader, and WebGL warnings.
  1024. - [ ] A debug/test API (pose set/get, current target, state) plus a fast-skip URL parameter make the experience machine-verifiable.
  1025. - [ ] Visual QA was done with real screenshots at every canonical pose and state change — logic assertions alone are not evidence it looks right.
  1026. - [ ] The pinned runtime is fully self-contained (core, any split sibling files, and addons travel together).
  1027. - [ ] UI icons are inline SVG (no emoji glyphs), and tinted chip backgrounds are used with restraint.
  1028. - [ ] A scripted single-file (server-free) build exists when the deliverable must be portable.
  1029.  
  1030. ---
  1031.  
  1032. ## 16. Field report: the verified-build process playbook
  1033.  
  1034. 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.
  1035.  
  1036. ### 16.1 Pin and vendor correctly (r165+ split builds)
  1037.  
  1038. 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.
  1039.  
  1040. ### 16.2 Make the experience machine-checkable (debug API contract)
  1041.  
  1042. 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.
  1043.  
  1044. ### 16.3 Headless testing on software GL (SwiftShader)
  1045.  
  1046. - First-paint shader compilation can block the main thread for **minutes**. Never assert on wall-clock time; wait on state.
  1047. - `page.waitForFunction` defaults to `requestAnimationFrame` polling, which starves when the main thread is long-tasked — always pass `polling: <ms>`.
  1048. - Force-click entry buttons with generous timeouts; overlay animations can delay actionability.
  1049. - Long suites should run detached (`nohup node test.js > test.log &`) and print one final `RESULTS` block; tail the log between polls.
  1050. - `GPU stall due to ReadPixels` warnings are benign headless noise, not page errors.
  1051. - 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.
  1052.  
  1053. ### 16.4 Verify visuals with your own eyes (screenshot QA loop)
  1054.  
  1055. 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.
  1056.  
  1057. ### 16.5 Texture orientation on lathed / cylinder-section surfaces
  1058.  
  1059. 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
  1060.  
  1061. ```js
  1062. tex.wrapS = THREE.RepeatWrapping;
  1063. tex.repeat.x = -1;
  1064. ```
  1065.  
  1066. 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.
  1067.  
  1068. ### 16.6 Raycast hitbox engineering
  1069.  
  1070. - `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.
  1071. - 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.
  1072. - Cap hitbox heights so vertically stacked targets never overlap.
  1073. - 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.
  1074.  
  1075. ### 16.7 Night-scene / mood lighting recipes
  1076.  
  1077. - **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.
  1078. - **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.
  1079. - The hemisphere light's ground color is your shadow floor — nudge it up when ceilings fall to pure black in stills.
  1080. - `MeshBasicMaterial` screens: `material.color.setScalar(≈1.5)` pushes texel values above 1, so canvas screens read as light sources and feed bloom.
  1081. - Fog, exposure, and bloom threshold are one system — tune them together at the final poses, not in isolation.
  1082.  
  1083. ### 16.8 Camera pose conventions for cinematics
  1084.  
  1085. 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.
  1086.  
  1087. ### 16.9 UI that doesn't read as AI-made
  1088.  
  1089. - 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`.
  1090. - 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.
  1091. - The favicon is a visible artifact too — a 20-line inline-SVG mark beats a borrowed emoji.
  1092. - Careful: `innerHTML` with static SVG strings is fine; never concatenate user-supplied text into the same assignments.
  1093.  
  1094. ### 16.10 Portable single-file builds
  1095.  
  1096. When the deliverable must travel (moved folders, `file://`, no dev server), bundle the whole module graph into one inline **classic script**:
  1097.  
  1098. 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.
  1099. 2. `npx esbuild js/main.js --bundle --minify --format=iife --target=es2020 --outfile=bundle.js`
  1100. 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.
  1101. 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.
  1102. 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.
  1103.  
  1104. ### 16.11 The loop that produces quality
  1105.  
  1106. 1. Logic suite green →
  1107. 2. Screenshot sweep across canonical poses and state combinations →
  1108. 3. Art-direction pass (lights, exposure, materials, pose framing) →
  1109. 4. **Full** suite re-run (tuning breaks logic — poses/clamps are coupled) →
  1110. 5. Ship **with** the suite and debug API still in place for the next iteration.
  1111.  
  1112. Polish is a loop, not a phase. Budget tool calls for at least two full cycles.
  1113.  
  1114. ---
  1115.  
  1116. ## 17. Procedural audio composition playbook (music + SFX + space)
  1117.  
  1118. 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*.
  1119.  
  1120. ### 17.1 Compose on paper first: the music-theory decision order
  1121.  
  1122. 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.
  1123. 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', ... }]`.
  1124. 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".
  1125. 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.
  1126. 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.
  1127.  
  1128. ### 17.2 Voice recipes (oscillator cooking)
  1129.  
  1130. - **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.
  1131. - **Kick:** sine with pitch envelope 140 → 48 Hz in ~90 ms, plus a 6 ms filtered-noise click. Gain envelope ~0.25 s.
  1132. - **Hat:** noise burst high-passed ≥ 7 kHz, 30–50 ms; a touch of bandpass "ping" reads as a closed hat.
  1133. - **Snare / brush:** noise bandpassed 1.6–2.2 kHz + a 180 Hz body sine, ~120 ms.
  1134. - **Sub bass:** sine root one octave under the chord, ≤ 90 Hz.
  1135. - **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.
  1136.  
  1137. ### 17.3 The lo-fi / vintage coating
  1138.  
  1139. - **Low-pass everything.** A master ~2–5 kHz shelf-down IS the genre. Brilliance is the enemy.
  1140. - **Vinyl crackle:** Poisson-distributed one-sample pops (~30/s) + soft pink hiss around −34 dB under everything.
  1141. - **Tape wobble:** 0.3–0.4 Hz LFO → detune ±7 cents on harmonic content; ±0.3 % playbackRate on loops.
  1142. - **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.
  1143.  
  1144. ### 17.4 Seamless loops via offline rendering
  1145.  
  1146. - 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.
  1147. - 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.
  1148. - 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.
  1149. - Can't listen in CI? **Verify numerically:** peak < 0.95, RMS inside the expected band, loop-boundary continuity `|x[0] − x[n−1]|` small.
  1150.  
  1151. ### 17.5 One-shot SFX recipes
  1152.  
  1153. Pre-render each ONCE at load (OfflineAudioContext → buffer), then trigger buffer sources with per-play variation: detune ±5 %, filter frequency ±10 %, gain ±15 %.
  1154.  
  1155. | Sound | Recipe |
  1156. |---|---|
  1157. | key clack | 8 ms noise through bandpass ~1.9 kHz (Q 1.2) + 2 ms 3.2 kHz sine ping, 60 ms decay |
  1158. | switch click | 3 ms 2.4 kHz sine blip + 10 ms noise tick |
  1159. | 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 |
  1160. | servo whirr | saw sweeping 80 → 260 Hz in 0.4 s + 30 Hz tremolo + soft clip |
  1161. | cloth rustle | noise bandpassed 800 → 1600 Hz, 3–5 amplitude bumps over ~0.6 s |
  1162. | poof / thump | noise lowpassed ~700 Hz, 120 ms swell, fast decay + 70 Hz sub sine |
  1163. | power-on | rising perfect-fifth arpeggio, 3 notes 70 ms apart, saw + lowpass sweep per note |
  1164. | footstep (wood) | 40 ms low thud (sine 95 → 60 Hz) + knock (bandpass ~900 Hz) |
  1165. | footstep (rug) | same but lowpassed ~500 Hz, +30 ms longer, −8 dB |
  1166. | UI deny | two descending square blips, 220 → 174 Hz |
  1167.  
  1168. ### 17.6 Space: one reverb bus + positional panners
  1169.  
  1170. - **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.
  1171. - **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.
  1172. - **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.
  1173. - Duck ambience a few dB under music rather than mixing louder.
  1174.  
  1175. ### 17.7 Emotion tricks with the master chain
  1176.  
  1177. - **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*.
  1178. - **Slow weather:** an LFO (±1 dB, ~30 s period) on rain/wind keeps static loops alive.
  1179. - **Safety limiter:** end the chain with `DynamicsCompressorNode` so stacked one-shots never clip.
  1180.  
  1181. ### 17.8 Audio correctness checklist (the silent killers)
  1182.  
  1183. - Create/resume the `AudioContext` **only inside a user gesture**; queue anything that would fire before unlock.
  1184. - Exponential ramps never target `0` — use `0.0001`.
  1185. - Interrupting a ramp? `cancelScheduledValues` then `setTargetAtTime` — don't fight scheduled automation.
  1186. - Cap simultaneous oscillators; reach for pre-rendered buffers first.
  1187. - Mute = master gain node, not `stop()` (one-shot sources can't be restarted).
  1188. - 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.
  1189.  
  1190. ## Final principle
  1191.  
  1192. 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.
  1193.  
Advertisement
Add Comment
Please, Sign In to add comment