Three.js Scenes skill for Codex
Correct colors, steady frames, no GPU leaks. Codex sets up the renderer with current color management and physical light units, so models stop looking washed out or pitch black. It loads glTF through self-hosted Draco, KTX2 and Meshopt decoders, keeps draw calls and triangles inside a stated budget with instancing and on-demand rendering, and disposes every geometry, material and texture so memory returns to baseline after teardown.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- threejs-scenes Installs to ~/.agents/skills/threejs-scenes/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install threejs-scenes The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project threejs-scenes Installs to .agents/skills/threejs-scenes/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/threejs-scenes.zipunzip -o threejs-scenes.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) threejs-scenes PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $threejs-scenes, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and optimises vanilla Three.js scenes - renderer setup with correct color management and tone mapping, glTF loading with Draco, KTX2 and Meshopt, physically based lighting and environment maps, shadows, raycasting, resize handling, instancing, disposal and draw-call budgets. Use when creating a 3D hero, product viewer or visualisation with three, when models look washed out or too dark, when a scene stutters on laptops or phones, or when memory grows on every route change.
Use it when
- Building a vanilla Three.js scene, viewer or visualisation
- A model renders too dark, too bright or with wrong colors
- Frame rate or memory problems in an existing Three.js page
- Preparing glTF assets for the web with compression
Use something else when
- The scene lives inside a React app: use react-three-fiber
- You need 2D charts rather than 3D: use d3-visualizations
How to check the result
Colors match a reference glTF viewer, renderer.info stays within the draw-call and triangle budget while orbiting, and memory counters return to baseline after five mount and unmount cycles.
The mistake it prevents
Copying pre-r155 lighting snippets, so lights with intensity 1 now produce an almost black scene under physical light units.
A finished scene looks right (colors match the source asset, lighting is plausible), holds 60 fps on a mid-range laptop and stays usable on a phone, survives resize and route changes without leaking GPU memory, and degrades to something sensible when WebGL is unavailable.
Before writing code
- Read
AGENTS.mdandpackage.json. Note thethreeversion (npm ls three): lighting units, color management defaults and import paths changed between r150 and r165, and older snippets on the web are wrong for current releases. - If the project uses React, stop and use
$react-three-fiberinstead of mixing imperative scene code into components. - Inspect the assets: format (
.glbpreferred), file size, triangle count, texture sizes.npx @gltf-transform/cli inspect model.glbprints all of it (needs network the first time if not installed).
Renderer and loop
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
export function createViewer(container: HTMLElement) {
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping; // or AgXToneMapping / NeutralToneMapping
renderer.toneMappingExposure = 1;
// outputColorSpace defaults to SRGBColorSpace since r152: do not change it
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1.2, 4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const resize = () => {
const { clientWidth: w, clientHeight: h } = container;
renderer.setSize(w, h, false); // false: let CSS size the canvas
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
let last = 0;
renderer.setAnimationLoop((time) => {
const dt = Math.min((time - last) / 1000, 0.1); // clamp after tab switches
last = time;
controls.update(); // required every frame with damping
update(dt);
renderer.render(scene, camera);
});
function update(_dt: number) {}
return {
scene, camera, renderer,
dispose() {
renderer.setAnimationLoop(null);
ro.disconnect();
controls.dispose();
pmrem.dispose();
disposeScene(scene);
renderer.dispose();
renderer.domElement.remove();
},
};
}
Size the canvas container with CSS (width: 100%; height: 100% on a sized parent); a
canvas inside a flex child without min-height: 0 grows forever under ResizeObserver.
Color and lighting
| Rule | Why |
|---|---|
Color textures (map, emissiveMap) get texture.colorSpace = THREE.SRGBColorSpace | Otherwise they render washed out. GLTFLoader sets this for you |
Data textures (normal, roughness, metalness, AO) stay linear (NoColorSpace) | Treating them as sRGB breaks shading |
| Lights use physical units (default since r155) | Old intensities like 1 on a PointLight now look almost black; point/spot intensity is candela with distance falloff |
| Prefer an environment map for PBR materials | MeshStandardMaterial without scene.environment looks flat and dark |
| One tone mapper, set on the renderer | Do not also tone-map in post-processing |
Lighting defaults for a product viewer: RoomEnvironment or an HDR loaded with
RGBELoader/HDRLoader through PMREM, plus one DirectionalLight for shadows. Add
hemisphere or ambient light only to lift deep shadows.
Loading glTF
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const draco = new DRACOLoader().setDecoderPath('/decoders/draco/'); // self-host, copy from three/examples/jsm/libs/draco/
const ktx2 = new KTX2Loader().setTranscoderPath('/decoders/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/chair.glb', (e) => onProgress(e.loaded / e.total));
scene.add(gltf.scene);
- Self-host decoders; CDN paths break offline and under strict CSP.
- Optimise assets offline, not at runtime:
npx @gltf-transform/cli optimize in.glb out.glb --compress meshopt --texture-compress ktx2(ktx2needs KTX-Software’stoktxon PATH;webpneeds nothing extra). Check the output visually. - Frame the model from its bounds, not hard-coded numbers:
new THREE.Box3().setFromObject(gltf.scene)then fit camera distance to the size. - Animations:
const mixer = new THREE.AnimationMixer(gltf.scene),mixer.clipAction(clip).play(), callmixer.update(dt)in the loop.
Performance budget
| Metric | Target for mid-range laptop / phone |
|---|---|
Draw calls (renderer.info.render.calls) | under ~100 / ~50 |
Triangles (renderer.info.render.triangles) | under ~1M / ~300k |
| Texture memory | 2048px max per texture on mobile; KTX2 to cut GPU memory |
| Pixel ratio | cap at 2, drop to 1.5 or 1 when frame time is high |
Techniques in order of payoff:
- Fewer draw calls:
InstancedMeshfor repeated objects (setinstanceMatrixand callneedsUpdate),BatchedMeshfor different geometries sharing a material, merge static geometry withBufferGeometryUtils.mergeGeometries. - Render on demand for viewers that sit still: render on
controlschangeevents and resize instead of every frame. Saves battery. - Cheaper shadows: one shadow-casting light, tight
shadow.camerabounds,shadow.mapSize1024-2048,renderer.shadowMap.type = THREE.PCFSoftShadowMap; bake static shadows into textures or use a contact-shadow plane. - Cheaper materials for distant or small objects (
MeshLambertMaterial), and LOD viaTHREE.LOD. - Raycast less: raycast on pointer move with throttling, against a simplified proxy
or a BVH (
three-mesh-bvh).
Interaction
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
renderer.domElement.addEventListener('pointerdown', (e) => {
const r = renderer.domElement.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, false)[0];
if (hit) select(hit.object);
});
Use the canvas rect, not window.innerWidth, or picks are offset when the canvas is
not full screen. Keep an explicit pickables array rather than raycasting the whole scene.
Disposal
Three.js does not garbage-collect GPU resources. On teardown and when swapping models:
function disposeScene(root: THREE.Object3D) {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mats = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
for (const m of mats) {
for (const v of Object.values(m)) if (v instanceof THREE.Texture) v.dispose();
m.dispose();
}
});
}
Verify with renderer.info.memory.geometries and .textures returning to their
baseline after unmounting and remounting the viewer five times.
WebGPU
WebGPURenderer from three/webgpu falls back to WebGL 2 and is required for TSL node
materials. Use it when you need compute or TSL; keep WebGLRenderer for existing scenes
with custom ShaderMaterial GLSL, which does not port automatically. Its init() is
async: await it (or use setAnimationLoop) before the first render.
Gotchas
controls.update()missing from the loop: damping never settles.setSize(w, h)withoutfalsewrites inline styles that fight your CSS layout.- Large world coordinates (thousands of units) cause z-fighting; keep
near/farratio tight and recentre the scene. - Transparent objects sort per object, not per triangle; overlapping transparent meshes
flicker. Use
alphaTestfor cutouts ordepthWrite: falsedeliberately. - Changing
material.side,transparent, or defines after first render may needmaterial.needsUpdate = true. - Context loss on mobile is real: listen for
webglcontextlostand show a reload prompt. prefers-reduced-motion: disable auto-rotate and camera fly-throughs.
Verification
- Asset colors match the reference viewer (gltf-viewer or Blender render)
-
renderer.infonumbers within budget; Performance panel shows no long frames while orbiting - Resize, fullscreen and device-pixel-ratio changes keep the image sharp and unstretched
- Mount/unmount five times: memory counters return to baseline, no console warnings
- Throttled CPU (4x) in DevTools still interactive; fallback shown when WebGL is disabled
Report draw calls, triangles, texture count and load size before and after optimisation.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Three.js Scenes"
short_description: "Correct colors, steady frames, no GPU leaks"
default_prompt: "$threejs-scenes Add a Three.js product viewer for our chair.glb with orbit controls" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.