Three.js Engineering Devlog

A 16.67 ms Budget: Optimizing a Living Three.js World

A forest can be beautiful and still fail as a racing environment if every turn stutters. Performance work becomes manageable when one frame is treated as a budget with named owners.

AdvancedThree.jsOptimization13 min read

Production context

This article studies decisions visible in Valley Racer. Repository source: biggames/valley-racer/game.js. The excerpts are shortened to expose one engineering decision at a time.

One Frame Has Several Owners

At 60 FPS, the browser has roughly 16.67 milliseconds to process input, update gameplay, step physics, animate the environment, submit rendering work, and let the GPU finish. Missing the budget occasionally creates a stutter; missing it continuously lowers frame rate. The first task is to identify which owner is overspending.

CPU time includes JavaScript updates, scene traversal, collision queries, matrix calculation, and draw submission. GPU time includes vertices, fragments, shadows, transparency, post-processing, and texture traffic. Network and asset parsing usually belong to loading, but lazy work can leak into the first playable frames.

Pixel Ratio Is a Resolution Multiplier, Not a Quality Badge

A phone reporting devicePixelRatio 3 asks the renderer for nine times the pixels of the same CSS canvas at ratio 1. That can overwhelm a mobile GPU before a single extra tree is added. Valley Racer caps pixel density and treats it as part of the quality setting.

Reduce pixel ratio before destroying the scene's identity. Moderate resolution with strong silhouettes, stable lighting, and smooth motion usually looks better than razor-sharp foliage at an unstable frame rate. Reapply renderer size after changing the cap so the drawing buffer updates immediately.

const QUALITY = {
  low: { pixelRatio: 1, shadows: false, forest: 0.35 },
  medium: { pixelRatio: 1.35, shadows: true, forest: 0.65 },
  high: { pixelRatio: 1.75, shadows: true, forest: 1 },
};

function applyQuality(name) {
  const q = QUALITY[name];
  renderer.setPixelRatio(Math.min(devicePixelRatio, q.pixelRatio));
  renderer.setSize(viewportWidth, viewportHeight, false);
}

Count Draw Calls Before Counting Objects

Ten thousand grass blades in one InstancedMesh can be cheaper to submit than one hundred unique meshes with different materials. Draw calls are driven by geometry-material batches, shadows, transparency, and render passes, not simply object count. renderer.info.render.calls gives a useful first signal.

Instancing is ideal for repeated grass, rocks, flowers, and simple trees. Shared materials reduce state changes. Merge truly static compatible geometry, but do not create one world-sized mesh that is impossible to cull. Organize batches into spatial cells so distant regions can disappear together.

const grass = new THREE.InstancedMesh(bladeGeometry, grassMaterial, count);
const dummy = new THREE.Object3D();

for (let i = 0; i < count; i++) {
  dummy.position.copy(samples[i].position);
  dummy.rotation.y = samples[i].rotation;
  dummy.scale.setScalar(samples[i].scale);
  dummy.updateMatrix();
  grass.setMatrixAt(i, dummy.matrix);
}
grass.instanceMatrix.needsUpdate = true;

Spend Detail Near the Racing Line

Uniform density wastes detail where the player cannot inspect it. Valley Racer benefits from rich near-track vegetation, readable rocks, and strong horizon silhouettes, while distant fields can use lower density and simpler models. LOD reduces vertex detail, and distance-based cells reduce whole batches.

Gameplay objects must not disappear with decorative quality. Collision proxies for trunks, gates, and rocks should remain consistent or be generated from the same authoritative placement data. A graphics option must never make a route physically easier or harder.

Not Every System Needs 60 Updates per Second

Vehicle control and collision need frequent updates. Standings text, radar repainting, far-away AI decisions, and diagnostic labels often do not. Valley Racer gives HUD details, radar, and FPS display separate millisecond cadences so DOM and canvas work do not repeat unnecessarily every render frame.

Use elapsed-time gates rather than many unmanaged setIntervals. The main loop remains the owner, paused state is respected, and updates cannot overlap. Spread expensive background work across frames when several systems would otherwise fire together.

const cadence = { radar: 100, standings: 180, fps: 500 };

if (now - lastRadarUpdate >= cadence.radar) {
  drawRadar();
  lastRadarUpdate = now;
}
if (now - lastStandingsUpdate >= cadence.standings) {
  updateStandingsDom();
  lastStandingsUpdate = now;
}

Optimize a Repeatable Route, Not a Convenient Screenshot

Measure from a known start through the same forest turn, water crossing, and dense object region. Record average FPS, minimum frame time, long-frame count, draw calls, and memory before and after one change. A static camera looking at empty sky proves very little about racing performance.

Keep quality tiers intentionally different and test cold load as well as warm replay. If a change only helps after shaders and assets are cached, players can still experience a broken first race. Performance is finished when the slowest supported device delivers predictable input and camera response, not when a developer laptop shows a large number.

Previous: Camera as a game mechanicNext: Three.js performance course