
Threejs Perf
- 199 installs
- 305 repo stars
- Updated May 25, 2026
- opusgamelabs/game-creator
Optimize Three.js rendering, draw calls, and frame times before shipping browser games built with the game-creator toolkit.
About
Guides Three.js performance work for opusgamelabs/game-creator projects: audit scenes, reduce draw calls, tune materials and lights, and validate FPS on target hardware before ship.
- Profiles Three.js render loops and scene graph costs
- Recommends geometry, material, and instancing optimizations
- Flags overdraw, shadow, and animation bottlenecks
- Aligns budgets with target devices and browsers
- Pairs fixes with quick before/after benchmarks
Threejs Perf by the numbers
- 199 all-time installs (skills.sh)
- +15 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #94 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/opusgamelabs/game-creator --skill threejs-perfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 199 |
|---|---|
| repo stars | ★ 305 |
| Last updated | May 25, 2026 |
| Repository | opusgamelabs/game-creator ↗ |
What it does
Optimize Three.js rendering, draw calls, and frame times before shipping browser games built with the game-creator toolkit.
Files
Three.js Performance Optimization
Performance patterns for Three.js games, backed by measured before/after numbers on Three.js r183 (headless Chromium via Playwright, Apple M1 Pro, software WebGL).
Reference Files
instancing-static.md— InstancedMesh for large static repeated objects (19,600 → 1 draw call)instancing-moving.md— Flat state buffer + batched InstancedMesh writes for moving entities (8,000 entities)templates/— Baseline vs optimized reference implementations for each pattern
When to Use This Skill
- Scene has 100+ repeated objects sharing geometry/material
- Draw calls exceed 500 and frame time is unstable
- Thousands of moving entities need per-frame transform updates
- Profile shows scene-graph traversal as a bottleneck
When NOT to Use
- Object count is low (<50 unique meshes) — simpler code wins
- Every object needs unique materials/shaders that defeat batching
- Geometry differs enough that instancing provides no batching benefit
Pattern 1: Instancing Large Static Object Sets
Problem: Forests, debris, decorations as individual Meshes = unnecessary draw calls.
Solution: One InstancedMesh per shared geometry+material combo.
Evidence: ~19,365 → 2 draw calls. Render CPU p95: 28.5ms → 0.5ms (~57× faster). Build: 39.4ms → 3.9ms. See instancing-static.md.
// Anti-pattern: one Mesh per prop
for (let i = 0; i < 19600; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, 0, z);
scene.add(mesh); // 19,600 draw calls
}
// Correct: one InstancedMesh
const im = new THREE.InstancedMesh(geometry, material, 19600);
const mat = new THREE.Matrix4();
for (let i = 0; i < 19600; i++) {
mat.makeTranslation(x, 0, z);
im.setMatrixAt(i, mat);
}
im.instanceMatrix.needsUpdate = true;
scene.add(im); // 1 draw callPattern 2: Moving Entity Update Loops
Problem: Thousands of moving actors as individual Meshes = scene-graph churn + transform propagation.
Solution: Flat entity state buffer + batched InstancedMesh.setMatrixAt() writes.
Evidence: 8,000 → 1 draw calls. Render CPU p95: 9.9ms → 0.5ms (~20× faster). Update loop p95: 1.4ms → 0.3ms. See instancing-moving.md.
// Anti-pattern: per-entity Mesh position writes
meshes.forEach((mesh, i) => {
mesh.position.x = computeX(i, tick);
mesh.position.y = computeY(i, tick);
});
// Correct: batched instance matrix writes
const mat = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
mat.makeTranslation(computeX(i, tick), computeY(i, tick), computeZ(i, tick));
instancedMesh.setMatrixAt(i, mat);
}
instancedMesh.instanceMatrix.needsUpdate = true;Decision Tree
Is the object repeated 50+ times with same geometry+material?
├── YES → Is it static (no per-frame movement)?
│ ├── YES → Pattern 1: Static InstancedMesh (instancing-static.md)
│ └── NO → Pattern 2: Moving InstancedMesh with batched writes (instancing-moving.md)
└── NO → Standard Mesh is fine. Focus on material/geometry reuse.Measured Results
Headless Chromium 147 via Playwright, Three.js r183, Apple M1 Pro, 30 warmup + 180 sample frames, median of 3 runs.
| Scenario | Metric | Baseline | Optimized | Improvement |
|---|---|---|---|---|
| Static World (19.6k cubes) | Draw calls | ~19,365 | 2 | ~9,682× |
| Static World (19.6k cubes) | Render CPU p95 | 28.5ms | 0.5ms | ~57× |
| Static World (19.6k cubes) | Build | 39.4ms | 3.9ms | ~10× |
| Moving Entities (8k wave-field) | Draw calls | 8,000 | 1 | 8,000× |
| Moving Entities (8k wave-field) | Render CPU p95 | 9.9ms | 0.5ms | ~20× |
| Moving Entities (8k wave-field) | Update loop p95 | 1.4ms | 0.3ms | ~4.7× |
Methodology notes
- CPU-side metrics are the trustworthy signal. Draw calls, render CPU p95, update loop, and build time reliably show the 1–2 order-of-magnitude win.
- FPS and frame-time p95 are unreliable in headless Chromium. Playwright's bundled Chromium uses SwiftShader (software WebGL), which bottlenecks on fragment shading of ~90 MB of visible geometry regardless of draw-call count. On real hardware WebGL, the FPS gap would be substantially larger — baseline would drop to single-digit FPS under real fill, and optimized would hit vsync cleanly.
- A benchmark passes if draw calls decreased and render CPU p95 did not regress.
Moving Entity Update Loops with InstancedMesh
Problem
Animating thousands of entities (enemies, particles, swarm members, NPCs) as independent Mesh objects creates avoidable scene-graph churn, transform propagation cost, and renderer overhead. The bottleneck shifts from GPU to CPU-side scene management.
Use When
- Many moving actors share the same geometry and material
- The simulation can write per-instance transforms in batches
- The bottleneck is update-loop overhead or scene complexity, not GPU fill rate
- Entity count exceeds ~100 with shared geometry
Avoid When
- Each entity needs unique material/shader state that defeats shared rendering
- Object counts are low enough that simpler per-Mesh code is preferable (<50)
- Entities need individual raycasting or picking (InstancedMesh raycasting requires extra work)
Anti-Patterns
- One
Meshper actor for large swarms or crowds - Per-entity object allocation inside the hot update loop
- Updating transforms through deep scene graphs when flat batched state is sufficient
- Creating/destroying Meshes per frame instead of reusing instance slots
Implementation
Baseline (Anti-Pattern): Individual Moving Meshes
import { BoxGeometry, Mesh, MeshStandardMaterial, Scene } from 'three';
const scene = new Scene();
const geometry = new BoxGeometry(0.5, 0.5, 0.5);
const material = new MeshStandardMaterial({ color: 0x4ea7d8 });
const meshes = [];
for (let i = 0; i < 8000; i++) {
const mesh = new Mesh(geometry, material);
meshes.push(mesh);
scene.add(mesh);
}
// Per-frame update: 8000 individual position writes
function update(tick) {
for (let i = 0; i < meshes.length; i++) {
meshes[i].position.x = computeX(i, tick);
meshes[i].position.y = computeY(i, tick);
meshes[i].position.z = computeZ(i, tick);
}
}Optimized: Batched InstancedMesh Writes
import { BoxGeometry, InstancedMesh, Matrix4, MeshStandardMaterial, Scene } from 'three';
const scene = new Scene();
const geometry = new BoxGeometry(0.5, 0.5, 0.5);
const material = new MeshStandardMaterial({ color: 0x4ea7d8 });
const count = 8000;
const instancedMesh = new InstancedMesh(geometry, material, count);
const matrix = new Matrix4();
// Initial placement
for (let i = 0; i < count; i++) {
matrix.makeTranslation(0, 0, 0);
instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);
// Per-frame update: batched matrix writes, single needsUpdate
function update(tick) {
for (let i = 0; i < count; i++) {
matrix.makeTranslation(
computeX(i, tick),
computeY(i, tick),
computeZ(i, tick),
);
instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true; // Once per frame
}Key Points
1. Flat state buffer: Keep entity state (position, velocity, health) in flat arrays or a typed buffer. Don't store state on Three.js objects. 2. Batch all writes, then set `needsUpdate` once: Write all instance matrices in a single loop, then set instanceMatrix.needsUpdate = true once at the end. 3. Reuse the `Matrix4`: One shared matrix for all setMatrixAt calls. Zero per-frame allocations. 4. Hide dead entities: Set instancedMesh.count to the number of active entities. Swap dead entities to the end of the buffer. 5. Rotation + scale: Use matrix.compose(position, quaternion, scale) instead of makeTranslation when entities need rotation or non-uniform scale.
Entity Pool Pattern
const MAX_ENTITIES = 10000;
let activeCount = 0;
const positions = new Float32Array(MAX_ENTITIES * 3);
const velocities = new Float32Array(MAX_ENTITIES * 3);
const instancedMesh = new THREE.InstancedMesh(geo, mat, MAX_ENTITIES);
function spawn(x, y, z, vx, vy, vz) {
if (activeCount >= MAX_ENTITIES) return;
const i = activeCount++;
positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z;
velocities[i * 3] = vx; velocities[i * 3 + 1] = vy; velocities[i * 3 + 2] = vz;
instancedMesh.count = activeCount;
}
function kill(index) {
// Swap with last active
const last = --activeCount;
positions.copyWithin(index * 3, last * 3, last * 3 + 3);
velocities.copyWithin(index * 3, last * 3, last * 3 + 3);
instancedMesh.count = activeCount;
}
function update(dt) {
const mat = new THREE.Matrix4();
for (let i = 0; i < activeCount; i++) {
positions[i * 3] += velocities[i * 3] * dt;
positions[i * 3 + 1] += velocities[i * 3 + 1] * dt;
positions[i * 3 + 2] += velocities[i * 3 + 2] * dt;
mat.makeTranslation(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
instancedMesh.setMatrixAt(i, mat);
}
instancedMesh.instanceMatrix.needsUpdate = true;
}Measured Results
Scenario: 8,000 entities in a wave-field (sin/cos of position + time), Three.js r183, headless Chromium 147 via Playwright, Apple M1 Pro, 30 warmup + 180 sample frames, median of 3 runs.
| Metric | Baseline | Optimized | Delta |
|---|---|---|---|
| Draw calls (avg) | 8,000 | 1 | 8,000× fewer |
| Render CPU p95 | 9.9ms | 0.5ms | ~20× faster |
| Update loop p95 | 1.4ms | 0.3ms | ~4.7× faster |
| Traversal p95 | 0.3ms | ~0ms | collapses to noise floor |
| Mesh count | 8,000 | 0 | -100% |
| InstancedMesh count | 0 | 1 | +1 |
Update loop shrinks because matrix.makeTranslation + setMatrixAt(i, m) bypasses per-Object3D state churn (dirty flags, matrix recompute, parent propagation). Render CPU drops by ~20× because the baseline burns ~10ms per frame just submitting 8,000 draw calls.
FPS and frame-time p95 are not cited here — Playwright's bundled Chromium runs software WebGL (SwiftShader), which bottlenecks on fragment shading regardless of draw-call count. On real hardware the FPS delta would be substantially larger.
Template Files
See templates/moving-entities-baseline.ts and templates/moving-entities-instanced.ts for drop-in reference implementations.
Instancing Large Static Object Sets
Problem
Rendering large numbers of repeated props (trees, rocks, debris, decorations) as separate Mesh objects creates unnecessary draw calls, increases renderer overhead, and destabilizes frame time before GPU throughput is the real bottleneck.
Use When
- Many objects share the same geometry (BoxGeometry, loaded GLB, etc.)
- Many objects share the same material or a material variant that can be encoded efficiently
- Per-instance transforms differ but the render path is otherwise identical
- Object count exceeds ~50 for a single geometry+material combo
Avoid When
- Every object needs materially different shaders or uniforms that defeat shared rendering
- Geometry differs enough that instancing provides no batching benefit
- Object count is too low for the complexity to matter (<20)
Anti-Patterns
- Creating one
Meshper prop for forests, debris, or large decoration sets - Treating small transform differences as a reason to skip instancing
- Rebuilding instance data every frame when transforms are mostly static
- Adding objects to deeply nested groups instead of a flat InstancedMesh
Implementation
Baseline (Anti-Pattern): Individual Meshes
import { BoxGeometry, Mesh, MeshStandardMaterial, Scene } from 'three';
const geometry = new BoxGeometry(1, 1, 1);
const material = new MeshStandardMaterial({ color: 0x7aa95c });
const scene = new Scene();
// 19,600 draw calls — one per mesh
for (let x = 0; x < 140; x++) {
for (let z = 0; z < 140; z++) {
const mesh = new Mesh(geometry, material);
mesh.position.set(x * 1.5, 0, z * 1.5);
scene.add(mesh);
}
}Optimized: Single InstancedMesh
import { BoxGeometry, InstancedMesh, Matrix4, MeshStandardMaterial, Scene } from 'three';
const geometry = new BoxGeometry(1, 1, 1);
const material = new MeshStandardMaterial({ color: 0x7aa95c });
const scene = new Scene();
const instanceCount = 140 * 140;
const matrix = new Matrix4();
const instancedMesh = new InstancedMesh(geometry, material, instanceCount);
let index = 0;
for (let x = 0; x < 140; x++) {
for (let z = 0; z < 140; z++) {
matrix.makeTranslation(x * 1.5, 0, z * 1.5);
instancedMesh.setMatrixAt(index, matrix);
index++;
}
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh); // 1 draw callKey Points
1. Share geometry AND material: InstancedMesh requires a single geometry and a single material. If you need material variants, use instanceColor or custom attributes. 2. Set `needsUpdate = true`: After writing all matrices, set instancedMesh.instanceMatrix.needsUpdate = true. Only do this once after all writes, not per-instance. 3. Pre-allocate the count: Pass the total instance count to the constructor. You can hide unused instances by setting instancedMesh.count to a lower value. 4. Use a reusable `Matrix4`: Create one Matrix4 and reuse it for all setMatrixAt calls. Don't allocate per-instance. 5. Instance colors: Use instancedMesh.setColorAt(index, color) and set instancedMesh.instanceColor.needsUpdate = true for per-instance color variation.
Per-Instance Color Example
const color = new THREE.Color();
for (let i = 0; i < count; i++) {
color.setHSL(Math.random(), 0.7, 0.5);
instancedMesh.setColorAt(i, color);
}
instancedMesh.instanceColor.needsUpdate = true;Measured Results
Scenario: 140×140 grid = 19,600 cubes, Three.js r183, headless Chromium 147 via Playwright, Apple M1 Pro, 30 warmup + 180 sample frames, median of 3 runs.
| Metric | Baseline | Optimized | Delta |
|---|---|---|---|
| Draw calls (avg) | ~19,365 | 2 | ~9,682× fewer |
| Render CPU p95 | 28.5ms | 0.5ms | ~57× faster |
| Render CPU mean | 19.6ms | 0.21ms | ~95× faster |
| Build | 39.4ms | 3.9ms | ~10× faster |
| Mesh count | 19,600 | 0 | -100% |
| InstancedMesh count | 0 | 1 | +1 |
The optimized case reports 2 draw calls (not 1) because the scene also contains a floor plane — the 19,600-cube InstancedMesh itself is a single draw call. Baseline draw calls sit at ~19,365 rather than 19,600 because individual meshes at the far edges get frustum-culled; InstancedMesh does not cull per-instance, so it's all-or-nothing.
FPS and frame-time p95 are not cited here — Playwright's bundled Chromium runs software WebGL (SwiftShader), which makes GPU-bound timing unreliable. On real hardware the FPS delta would be substantially larger.
Template Files
See templates/static-world-baseline.ts and templates/static-world-instanced.ts for drop-in reference implementations.
import { BoxGeometry, Mesh, MeshStandardMaterial, Scene } from "three";
const scene = new Scene();
const geometry = new BoxGeometry(0.5, 0.5, 0.5);
const material = new MeshStandardMaterial({ color: 0x4ea7d8 });
for (let index = 0; index < 8000; index += 1) {
const mesh = new Mesh(geometry, material);
scene.add(mesh);
}
import { BoxGeometry, InstancedMesh, Matrix4, MeshStandardMaterial, Scene } from "three";
const scene = new Scene();
const geometry = new BoxGeometry(0.5, 0.5, 0.5);
const material = new MeshStandardMaterial({ color: 0x4ea7d8 });
const entities = 8000;
const matrix = new Matrix4();
const instancedMesh = new InstancedMesh(geometry, material, entities);
for (let index = 0; index < entities; index += 1) {
matrix.makeTranslation(0, 0, 0);
instancedMesh.setMatrixAt(index, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);
import { BoxGeometry, Mesh, MeshStandardMaterial, Scene } from "three";
const geometry = new BoxGeometry(1, 1, 1);
const material = new MeshStandardMaterial({ color: 0x7aa95c });
const scene = new Scene();
for (let x = 0; x < 140; x += 1) {
for (let z = 0; z < 140; z += 1) {
const mesh = new Mesh(geometry, material);
mesh.position.set(x * 1.5, 0, z * 1.5);
scene.add(mesh);
}
}
import { BoxGeometry, InstancedMesh, Matrix4, MeshStandardMaterial, Scene } from "three";
const geometry = new BoxGeometry(1, 1, 1);
const material = new MeshStandardMaterial({ color: 0x7aa95c });
const scene = new Scene();
const instanceCount = 140 * 140;
const matrix = new Matrix4();
const instancedMesh = new InstancedMesh(geometry, material, instanceCount);
let index = 0;
for (let x = 0; x < 140; x += 1) {
for (let z = 0; z < 140; z += 1) {
matrix.makeTranslation(x * 1.5, 0, z * 1.5);
instancedMesh.setMatrixAt(index, matrix);
index += 1;
}
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);