Lesson 48 - Three.js Game Development
Three.js Performance: Measure Draw Calls, GPU Cost, and Mobile Quality
Optimization is evidence-driven design. We will identify whether the CPU, GPU, memory, or shader compilation is responsible before removing the visual details players actually notice.
1. Measure renderer work
Renderer.info exposes draw calls, triangles, and resource counts. Sample FPS over a window rather than displaying a noisy reciprocal of one frame.
let frames = 0;
let elapsed = 0;
function samplePerformance(dt) {
frames += 1;
elapsed += dt;
if (elapsed >= 0.5) {
fpsLabel.textContent = Math.round(frames / elapsed) + " FPS";
drawLabel.textContent = renderer.info.render.calls + " draws";
frames = 0;
elapsed = 0;
}
}
How this code works
Execution flow
Every frame increments sample count and elapsed time. Twice per second the code calculates average FPS, reads the current renderer draw-call count, updates compact labels, and resets only the sampling counters.
Key Three.js decisions
A half-second window is stable enough to read while still exposing spikes. renderer.info reports GPU submission structure such as calls and triangles, complementing FPS, which alone cannot identify whether CPU, GPU, or blocking work is responsible.
Adapt it
Record minimum frame time and long-frame counts during races or combat, and display the panel only in debug builds. Pair it with browser performance traces and compare quality tiers on the same route and camera.
Watch out
FPS can look acceptable while regular long frames still feel like stutter. renderer.info resets behavior depends on renderer settings, and draw calls do not measure shader complexity, fill rate, texture bandwidth, or JavaScript update cost.
2. Cap the biggest GPU multiplier
Pixel ratio multiplies both width and height. A modest cap often saves more GPU time than removing dozens of small objects, especially on phones.
function applyQuality(mode) {
const caps = { low: 1, medium: 1.35, high: 1.75 };
renderer.setPixelRatio(Math.min(devicePixelRatio, caps[mode]));
renderer.shadowMap.enabled = mode !== "low";
forest.setDensity(mode === "high" ? 1 : mode === "medium" ? 0.65 : 0.35);
water.setReflectionQuality(mode);
}
How this code works
Execution flow
applyQuality selects a pixel-ratio ceiling, resizes the renderer's effective buffer, toggles shadows, changes forest density, and forwards a matching reflection tier to water. One setting coordinates several expensive systems.
Key Three.js decisions
Device pixel ratio multiplies both dimensions, so reducing it often saves more fragments than removing a few meshes. Quality tiers preserve artistic priorities while exposing controlled switches instead of scattering mobile checks across subsystems.
Adapt it
Auto-select an initial tier from device capability, then let players override it and store their preference. Apply changes between frames, rebuild density asynchronously when possible, and keep gameplay collision independent of decorative quality.
Watch out
Calling setPixelRatio without setSize in some resize architectures may not update the buffer immediately. Never remove collision-bearing trees when reducing visual density unless matching invisible proxies remain, or graphics settings will alter gameplay.
3. Use LOD for distant models
LOD swaps a detailed model for a cheap silhouette at distance. It reduces vertex cost but does not automatically reduce draw calls unless materials and grouping are also planned.
const treeLod = new THREE.LOD();
treeLod.addLevel(detailedTree, 0);
treeLod.addLevel(simpleTree, 28);
treeLod.addLevel(treeBillboard, 65);
treeLod.autoUpdate = true;
scene.add(treeLod);
renderer.setAnimationLoop(() => {
treeLod.update(camera);
renderer.render(scene, camera);
});
How this code works
Execution flow
The LOD object registers detailed, simplified, and billboard representations at increasing camera distances. During rendering, update selects the appropriate child based on camera range before the scene is drawn.
Key Three.js decisions
THREE.LOD centralizes distance switching and keeps one world transform for all representations. A billboard preserves broad silhouette at long range, where fine geometry contributes little but still costs vertex processing.
Adapt it
Use LOD for trees, buildings, rocks, and crowd characters, and tune thresholds from actual screen size rather than arbitrary world distance. Add hysteresis or fade transitions when visible popping is distracting.
Watch out
LOD reduces triangles but each object can still produce draw calls, especially with different materials. If automatic updates are enabled, calling update manually may be redundant; choose one policy and profile large LOD counts.
4. Avoid surprise shader variants
Changing defines, light counts, skinning, fog, or shadow flags can compile a new program. Configure stable variants during loading and reuse materials during play.
const warmMaterial = baseMaterial.clone();
warmMaterial.color.offsetHSL(0.02, 0.04, 0.08);
const damagedMaterial = baseMaterial.clone();
damagedMaterial.emissive.setHex(0x441000);
renderer.compile(scene, camera);
function setDamageLook(mesh, damaged) {
mesh.material = damaged ? damagedMaterial : baseMaterial;
}
How this code works
Execution flow
Base, warm, and damaged materials are created before play, the renderer compiles the loaded scene, and runtime feedback only swaps references among known variants. No material defines or dynamic light count changes during the hit event.
Key Three.js decisions
Material clones share a predictable Standard-material feature set while allowing independent colors and emissive values. renderer.compile asks Three.js to prepare visible programs early, moving unavoidable compilation into the loading phase.
Adapt it
Precreate variants for biome tint, damage, invisibility, or selected state and warm them with representative lights, fog, shadows, and skinned models. Prefer uniforms for continuous values that do not change shader defines.
Watch out
compile only covers objects and conditions present at that moment; later enabling shadows, fog, skinning, or maps can still create programs. Unbounded material cloning grows program and state counts, so define a small deliberate variant catalogue.
5. Real Supagames example
Production source: biggames/valley-racer/game.js
Play the published example: Valley Racer
Valley Racer uses quality tiers, reduced forest density, limited pixel ratio, selective shadows, FPS display, and scene-specific object budgets. These are practical compromises from a real Three.js racing world.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.