Three.js Production Post-Mortem

The First Hit Froze the Game: Finding a One-Frame Three.js Stutter

Peak Hopper ran smoothly until the player touched an enemy. The first hit paused for a fraction of a second, then every later hit looked normal. That pattern gave us the most useful clue.

AdvancedThree.jsPerformance11 min read

Production context

This article studies decisions visible in Peak Hopper. Repository source: biggames/peak-hopper/src/main.js. The excerpts are shortened to expose one engineering decision at a time.

The Symptom Was More Specific Than 'Low FPS'

Average frame rate was not the problem. Exploration, jumping, vegetation, and ordinary enemy movement remained smooth. The visible pause happened at the first damage event after loading a level. Once that first event had occurred, repeated contact no longer produced the same delay. A normal FPS counter can miss this because a half-second average hides one unusually long frame.

That distinction matters. Continuous low FPS usually points toward too much recurring work: too many draw calls, excessive pixel ratio, expensive shadows, or a heavy update loop. A one-time spike points toward lazy initialization, shader compilation, asset decoding, first-use DOM layout, or a burst of allocations that wakes garbage collection.

Why a Tiny New Light Can Recompile a Large Scene

Three.js builds WebGL programs from material features and scene conditions. Light count, shadow use, fog, skinning, texture maps, transparency, and clipping can all influence the resulting shader variant. Adding a PointLight for a hit flash looks harmless in JavaScript, but it can change the program required by many MeshStandardMaterial objects in view.

Compilation is commonly deferred until a variant is actually rendered. The first frame containing the new condition pays the cost; later frames reuse the program cache. This is why a first-hit freeze can disappear during repeated testing and return after a full page reload.

// Tempting, but it changes the active light configuration.
function flashHit(position) {
  const light = new THREE.PointLight(0xff3344, 5, 8);
  light.position.copy(position);
  scene.add(light);
  setTimeout(() => scene.remove(light), 80);
}

Keep Feedback Visible but Shader-Stable

The fix was not to remove feedback. Damage still needs a sound, a visible flash, health loss, and motion response. The fix was to prepare feedback before combat and animate properties that do not alter shader structure. Peak Hopper reuses a pre-composited HTML overlay for the full-screen damage flash and avoids creating a new light during projectile or contact events.

For a 3D model flash, an existing emissive uniform or a pre-created overlay material works well. The important part is that the material and light configuration already exists during loading. Runtime code then changes opacity, color, or intensity inside a known program instead of asking the renderer for a new variant at the worst possible moment.

const damageFlash = document.querySelector("#damage-flash");

function takeDamage() {
  playerHealth = Math.max(0, playerHealth - 1);
  invincibilityTimer = 1.5;
  damageFlash.style.opacity = "1";
  requestAnimationFrame(() => {
    damageFlash.style.opacity = "0";
  });
}

The Second Suspect: First-Use Allocations

Damage numbers are another common spike source. Creating a canvas, drawing text, uploading it as a CanvasTexture, constructing a SpriteMaterial, and adding a Sprite performs several allocations and at least one GPU upload. Doing that for the first time during combat combines browser layout, canvas initialization, texture work, and JavaScript allocation in one frame.

The production choice depends on frequency. Rare boss labels can be created on demand behind a short telegraph. Repeated damage numbers should use a pool of sprites or an atlas prepared at startup. Particle geometry and materials should also be shared, while only positions, lifetimes, and opacity change during play.

const hitPool = Array.from({ length: 16 }, () => ({
  sprite: createPreparedDamageSprite(),
  active: false,
  life: 0,
}));

function showDamage(position) {
  const hit = hitPool.find((entry) => !entry.active);
  if (!hit) return;
  hit.active = true;
  hit.life = 0.8;
  hit.sprite.position.copy(position);
  hit.sprite.visible = true;
}

A Repeatable Diagnostic Sequence

Start from a clean reload, open the Performance panel, and record only the first interaction. Mark the damage function with performance.mark or console.time so the long frame can be aligned with application code. Inspect whether the browser reports shader compilation, texture upload, layout, event handling, or garbage collection around that marker.

Then remove one category at a time. Keep health loss but disable visuals. Restore the overlay but disable particles. Restore particles with preallocated resources. A binary search through the event path is more reliable than lowering every graphics setting, because global quality changes can hide the spike without identifying its cause.

What We Changed in Our Engineering Rules

Combat-critical resources now belong to loading and setup, not the collision callback. Hit overlays, projectile geometry, shared materials, common sound nodes, and frequent text effects are created before the player can trigger them. New dynamic lights require a deliberate reason and a test from a clean reload.

The broader design lesson is that responsiveness is part of feedback quality. A spectacular flash that freezes the exact frame of impact weakens the hit instead of strengthening it. Stable timing, clear health change, knockback, and a short invulnerability window communicate damage better than an expensive effect assembled at first contact.

Previous: How We Built Chronos DriftNext: Camera as a game mechanic