Skip to content
Development
Skill

/threejs-performance

Measuring and optimizing three.js speed: render statistics, memory counters, fewer draw calls via merging, instancing, batching and LOD, fill rate, shadows, CPU work per frame, shader stalls and GPU compute for particles, WebGL or WebGPU with TSL. Use when a scene is slow, janks

BOOST
From plugin
threejs-skills
3.7k13 skills
Install
$ npx -y skills add cloudai-x/threejs-skills --skill threejs-performance --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/threejs-performance

Context preview

The summary Claude sees to decide when to auto-load this skill.

Measuring and optimizing three.js speed: render statistics, memory counters, fewer draw calls via merging, instancing, batching and LOD, fill rate, shadows, CPU work per frame, shader stalls and GPU compute for particles, WebGL or WebGPU with TSL. Use when a scene is slow, janks

SKILL.md

threejs-performance.SKILL.md
name: threejs-performance
description: "Measuring and optimizing three.js speed: render statistics, memory counters, fewer draw calls via merging, instancing, batching and LOD, fill rate, shadows, CPU work per frame, shader stalls and GPU compute for particles, WebGL or WebGPU with TSL. Use when a scene is slow, janks or exceeds memory."

Three.js Performance

Targets three.js r186. Examples use WebGLRenderer; the WebGPU and TSL section uses WebGPURenderer.

Quick Start

import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
scene.add(sun);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 8);

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.MeshStandardMaterial({ color: 0x4488ff }));
scene.add(mesh);

function animate() {
  mesh.rotation.y += 0.01;
  renderer.render(scene, camera);
  // the counters reset at the start of every render() call, so these cover the frame just drawn
  console.log(`${renderer.info.render.calls} calls, ${renderer.info.render.triangles} triangles, ${renderer.info.memory.textures} textures`);
}

renderer.setAnimationLoop(animate);

Measure First

Optimize what the numbers name, not what a hunch suspects. WebGLRenderer's `renderer.info` object is read after `render()`:

function animate() {
  renderer.render(scene, camera);
  console.table({
    calls: renderer.info.render.calls, // draw calls submitted by this render
    triangles: renderer.info.render.triangles,
    lines: renderer.info.render.lines,
    points: renderer.info.render.points,
    geometries: renderer.info.memory.geometries, // geometry buffers live on the GPU
    textures: renderer.info.memory.textures,
    programs: renderer.info.programs.length, // compiled shader programs in the cache
  });
}

renderer.setAnimationLoop(animate);

Details worth knowing when reading these:

  • The counters reset at the start of every `render()` call while `info.autoReset` is true (the default). Reading them before `render()` reports the previous frame, and a frame with several `render()` calls (picking, reflections, post passes) shows only the last one's numbers.
  • Shadow-map drawing runs through the same counters: each shadow-casting light re-draws its casters from the light's view inside `render()`, and those draws appear in `calls` and `triangles`. Toggling a light's `castShadow` is directly visible here.
  • `info.memory.geometries` and `info.memory.textures` count uploads, not bytes; they drop when the matching `dispose()` runs. Geometries and textures upload once and are shared by every mesh that references them.

When a frame really has several `render()` calls, accumulate over the whole frame yourself:

renderer.info.autoReset = false; // render() stops clearing the counters

function animate() {
  renderer.render(scene, camera);
  console.log(renderer.info.render.calls); // every render of this frame, summed
  renderer.info.reset(); // once per frame, after the last render
}

renderer.setAnimationLoop(animate);

`Timer` gives the frame interval; average it over a second of frames, single frames jitter too much to compare. The interval never drops below one display refresh (16.7 ms at 60 Hz), so it shows dropped frames but not headroom; time the CPU side of the frame with `performance.now()` around the work:

const timer = new THREE.Timer();
timer.connect(document); // pauses while the tab is hidden

let interval = 0;
let cpu = 0;
let frames = 0;

function animate() {
  timer.update();
  interval += timer.getDelta(); // seconds since the previous frame, never less than one display refresh
  const start = performance.now();
  renderer.render(scene, camera);
  cpu += performance.now() - start; // CPU time spent in render(); the GPU executes the commands later
  frames++;
  if (frames === 60) {
    console.log(`${((interval / frames) * 1000).toFixed(2)} ms per frame, ${(cpu / frames).toFixed(2)} ms CPU in render()`);
    interval = 0;
    cpu = 0;
    frames = 0;
  }
}

renderer.setAnimationLoop(animate);

For anything deeper than frame time, use the browser profiler — the Chrome DevTools Performance panel shows long main-thread tasks, scripting versus rendering time, and GPU work on its GPU track. The `stats-gl` package shows a live per-frame statistics overlay when you want the numbers always visible.

Draw Calls

Each visible mesh queues one draw call, or one per geometry group when the material is an array, and two settings multiply that: a `transparent` material with `side: THREE.DoubleSide` draws each mesh twice, back faces then front faces (`forceSinglePass: true` makes it one), and while any MeshPhysicalMaterial with `transmission` above 0 is in view, every opaque object is drawn a second time into the transmission buffer. Without those two, a thousand single-material meshes is a thousand draw calls; `info.render.calls` is the number to watch. The classic reducers:

**Merge static geometry.** Many meshes that never move and share one material become one mesh and one call:

import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";

const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const transform = new THREE.Matrix4();
const parts = [];
for (let i = 0; i < 200; i++) {
  const baked = rockGeometry.clone();
  transform.setPosition((Math.random() - 0.5) * 40, 0, (Math.random() - 0.5) * 40);
  baked.applyMatrix4(transform); // bake the placement into the vertices
  parts.push(baked);
}
const merged = BufferGeo
Read more
Ships withthreejs-skills

Skill files that give Claude Code current, verified knowledge of three.js, so the code it writes runs on the release you actually install. The skills target three.js r186 (three@0.186), React Three Fiber 9.8 with drei 10.7, and gltf-transform 4.5.

Get the whole plugin

Other skills on threejs-skills.