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
Install / Use
npx skills add CloudAI-X/threejs-skills --skill threejs-performanceInstalls into whichever agent you are using.
SKILL.md
Installable skill definition
Quality Score
Category
Development & EngineeringSupported Platforms
Tags
Our assessment of threejs-performance
threejs-performance scores 91/100 on our quality scale, 1055th of 4,583 Development & Engineering skills we index (top 24%).
Its SKILL.md is 25 KB long, well organised into 11 sections with 18 code examples: a thorough specification that gives an agent plenty to work with.
With 3,371 GitHub stars, it is one of the more widely adopted skills in the catalogue.
Maintenance, license and trust
- The repository was last updated about 4 months ago. That is recent enough to be usable, but agent tooling moves fast, so check the instructions against your agent's current version.
- No license is declared. By default that means all rights are reserved: you can read it, but reusing or redistributing it is not clearly permitted. Ask the author before building on it commercially.
- Its trust signals score 86/100, with 1 caution from licensing, adoption, age or documentation. These come from repository metadata, not a code audit — read the skill file before letting an agent act on it.
Safety scan
No issues foundOur scan of the whole file found no instruction hijacking, hidden characters, credential access, data exfiltration or destructive commands. An AI review of the same text found nothing harmful.
AI review by kimi-k2.7-code on 2026-10-23. Automated pattern scan on 2026-10-23. It catches known dangerous patterns, not every risk — read a skill before letting an agent act on it.
threejs-performance compared with similar skills
All 4 of these similar skills score higher than threejs-performance; compare them before choosing.
| Skill | Score | Stars | Updated | Format |
|---|---|---|---|---|
| threejs-performance (this skill)by CloudAI-X | 91 | 3.4k | 4mo ago | SKILL.md |
| taste-skillby Leonxlnx | 100 | 101.2k | 2d ago | CLAUDE.md |
| ai-job-searchby ai-job-search-dev | 100 | 63.7k | 2d ago | CLAUDE.md |
| claude-howtoby luongnv89 | 100 | 43.4k | 2d ago | CLAUDE.md |
| algorithmic-artby anthropics | 100 | 177.9k | 30d ago | SKILL.md |
Frequently asked questions
- How do I install threejs-performance?
- Run
npx skills add CloudAI-X/threejs-skills --skill threejs-performance. The install tabs above show the steps for each supported agent. - Which AI agents does threejs-performance work with?
- It is written for Universal, as a SKILL.md file. Other agents that read the same format can often use it too.
- Is threejs-performance safe to use?
- Our scan of the whole file found no instruction hijacking, hidden characters, credential access, data exfiltration or destructive commands. An AI review of the same text found nothing harmful. It declares no license and scores 86/100 on trust signals. Skills are instructions an agent will follow, so read the file before installing it and do not approve commands you do not understand.
- Is threejs-performance still maintained?
- The repository was last updated about 4 months ago. That is recent enough to be usable, but agent tooling moves fast, so check the instructions against your agent's current version.
Skill content
View source on GitHubname: 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 whileinfo.autoResetis true (the default). Reading them beforerender()reports the previous frame, and a frame with severalrender()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 incallsandtriangles. Toggling a light'scastShadowis directly visible here. info.memory.geometriesandinfo.memory.texturescount uploads, not bytes; they drop when the matchingdispose()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 = BufferGeometryUtils.mergeGeometries(parts); // inputs need matching attributes and index state
scene.add(new THREE.Mesh(merged, new THREE.MeshNormalMaterial())); // 200 rocks, one call — but one bounding sphere for all of them
Merging trades culling granularity for the call count: the merged mesh has a single bounding sphere, so frustum culling is all-or-nothing. Keep world-sized clusters merged per cluster, not one merge for a whole level.
Instance repeats. One geometry drawn many times with per-instance transforms stays one call:
const tree = new THREE.ConeGeometry(0.5, 2, 6);
const trees = new THREE.InstancedMesh(tree, new THREE.MeshNormalMaterial(), 500);
const placement = new THREE.Object3D();
for (let i = 0; i < 500; i++) {
placement.position.set((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
placement.rotation.y = Math.random() * 6.28;
placement.updateMatrix();
trees.setMatrixAt(i, placement.matrix);
}
// culling tests one sphere around all instances (computed lazily on the first cull test), so all 500 draw whenever it touches the view;
// after moving instances later, set trees.instanceMatrix.needsUpdate = true and call trees.computeBoundingSphere()
scene.add(trees);
Batch different geometries. Many geometries sharing one material pack into shared buffers:
const material = new THREE.MeshNormalMaterial();
const batchedMesh = new THREE.BatchedMesh(500, 20000, 20000, material);
const coneId = batchedMesh.addGeometry(new THREE.ConeGeometry(0.5, 2, 6));
const boxId = batchedMesh.addGeometry(new THREE.BoxGeometry(1, 1, 1));
const placement = new THREE.Matrix4();
for (let i = 0; i < 500; i++) {
const instanceId = batchedMesh.addInstance(i % 2 ? coneId : boxId); // a geometry renders only through an instance
placement.makeTranslation((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
batchedMesh.setMatrixAt(instanceId, placement);
}
scene.add(batchedMesh);
Unlike InstancedMesh, it culls and sorts each instance on its own (perObjectFrustumCulled and sortObjects, both on by default). WebGLRenderer multi-draws these in one call where the browser supports WEBGL_multi_draw, one draw per instance otherwise; the WebGPU backend issues one draw per instance. threejs-geometry covers both APIs in depth.
Drop detail with distance. LOD keeps every level as a child and, inside every render() (its autoUpdate is on by default), shows only the level for the current camera distance, so far objects submit fewer triangles — the call count stays the same:
const detailMaterial = new THREE.MeshNormalMaterial();
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), detailMaterial), 0); // distances are camera-to-object
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 16, 8), detailMaterial), 12);
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 8, 4), detailMaterial), 30);
scene.add(lod);
Let culling skip whole calls. Frustum culling is on by default: before a mesh is queued, its geometry's bounding sphere is tested against the camera frustum, and a failed test removes the draw entirely. Two ways to lose it silently: editing vertices on the CPU without geometry.computeBoundingSphere() leaves a stale sphere — one left smaller than the moved vertices hides the mesh while it is still in view — and moving vertices in a shader leaves the CPU sphere untouched, so set mesh.frustumCulled = false on that one mesh, not scene-wide.
GPU Memory
info.memory.textures counts textures; the byte math is yours. An RGBA8 texture costs 4 bytes per pixel, and with the default mip chain WebGPURenderer's byte estimate multiplies by 1.333 — a 2048 by 2048 color map is about 22 MB of VRAM. Three levers:
- Compressed KTX2. A Basis Universal KTX2 texture (ETC1S or UASTC) is transcoded to a block-compressed format the GPU supports (ASTC, BC7, BC1 or BC3, ETC2, ETC1 or PVRTC) and stays compressed in VRAM, instead of expanding to 4 bytes per pixel like a PNG or JPEG does; when none of them fits the file (ASTC takes UASTC files only, ETC1 has no alpha, PVRTC needs power-of-two sizes), KTX2Loader falls back to uncompressed RGBA. Load it with KTX2Loader:
import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
const ktx2Loader = new KTX2Loader().detectSupport(renderer);
const map = await ktx2Loader.loadAsync("color.ktx2"); // a CompressedTexture for a Basis Universal file
map.colorSpace = THREE.SRGBColorSpace; // KTX2Loader already sets it from the file's transfer function; this line only matters for a color file tagged linear
mesh.material.map = map;
mesh.material.needsUpdate = true; // the material rendered without a map before
- Size. A 1024 texture is a quarter of a 2048 one. Author color maps at the size they appear on screen, and let mipmapping handle distance.
- Disposal.
geometry.dispose()frees its GPU buffers at once;texture.dispose()frees the GPU texture once no other undisposed texture shares it (atexture.clone()shares the image, and its GPU copy while wrap, filter, format and color space stay the same). Nothing counts meshes, so dispose only what nothing still renders — a mesh that still uses a disposed geometry or texture uploads it again on its next render:
scene.remove(mesh);
mesh.geometry.dispose(); // frees the GPU buffers now, even if another mesh still uses this geometry
// material.dispose() releases its shader programs (deleted once no material uses them), never its textures
for (const value of Object.values(mesh.material)) {
if (value?.isTexture) value.dispose();
}
mesh.material.dispose();
mesh.dispose(); // renderer-side state kept for this one object
console.log(renderer.info.memory.textures); // the count drops as each texture frees
To free a whole subtree, traverse it — object.dispose() does not visit children. Under WebGPURenderer memory.geometries and
Truncated for display — read the full file on GitHub.
Related Skills
taste-skill
101.2kAgent skills that give coding agents taste: art direction, reference images and a browser check instead of generic AI pages
ai-job-search
63.7kThe job search that runs on your machine. AI job application framework built on Claude Code: evaluate postings, tailor CVs, write cover letters, prep interviews. Fork it and own it.
claude-howto
43.4kA visual, example-driven guide to Claude Code — from basic concepts to advanced agents, with copy-paste templates that bring immediate value.
algorithmic-art
177.9kCreating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems.
Trust signals
From repository metadata: license, adoption, age and documentation. Not a code audit — see the Safety scan above for what the skill file itself contains.
