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.

  • Frontend
  • $threejs-scenes
  • 219 lines
  • 2 files
  • ~2,350 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- threejs-scenes

Installs to ~/.agents/skills/threejs-scenes/ for every project on this machine.

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

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.

~/.agents/skills/threejs-scenes/SKILL.md

--- name: threejs-scenes description: Builds and optimises vanilla Three.js scenes - renderer setup with correct color management and tone mapping, glTF loading with Draco, KTX2 and… license: MIT ---

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

  1. Read AGENTS.md and package.json. Note the three version (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.
  2. If the project uses React, stop and use $react-three-fiber instead of mixing imperative scene code into components.
  3. Inspect the assets: format (.glb preferred), file size, triangle count, texture sizes. npx @gltf-transform/cli inspect model.glb prints 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

RuleWhy
Color textures (map, emissiveMap) get texture.colorSpace = THREE.SRGBColorSpaceOtherwise 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 materialsMeshStandardMaterial without scene.environment looks flat and dark
One tone mapper, set on the rendererDo 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 (ktx2 needs KTX-Software’s toktx on PATH; webp needs 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(), call mixer.update(dt) in the loop.

Performance budget

MetricTarget for mid-range laptop / phone
Draw calls (renderer.info.render.calls)under ~100 / ~50
Triangles (renderer.info.render.triangles)under ~1M / ~300k
Texture memory2048px max per texture on mobile; KTX2 to cut GPU memory
Pixel ratiocap at 2, drop to 1.5 or 1 when frame time is high

Techniques in order of payoff:

  1. Fewer draw calls: InstancedMesh for repeated objects (set instanceMatrix and call needsUpdate), BatchedMesh for different geometries sharing a material, merge static geometry with BufferGeometryUtils.mergeGeometries.
  2. Render on demand for viewers that sit still: render on controls change events and resize instead of every frame. Saves battery.
  3. Cheaper shadows: one shadow-casting light, tight shadow.camera bounds, shadow.mapSize 1024-2048, renderer.shadowMap.type = THREE.PCFSoftShadowMap; bake static shadows into textures or use a contact-shadow plane.
  4. Cheaper materials for distant or small objects (MeshLambertMaterial), and LOD via THREE.LOD.
  5. 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) without false writes inline styles that fight your CSS layout.
  • Large world coordinates (thousands of units) cause z-fighting; keep near/far ratio tight and recentre the scene.
  • Transparent objects sort per object, not per triangle; overlapping transparent meshes flicker. Use alphaTest for cutouts or depthWrite: false deliberately.
  • Changing material.side, transparent, or defines after first render may need material.needsUpdate = true.
  • Context loss on mobile is real: listen for webglcontextlost and 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.info numbers 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem