| name | threejs-perf-loading |
| description | Performance and loading patterns for real-time Three.js/WebGL/WebGPU sites, based on modern approaches and best practices explored from top-notch studios and developers (Ivress brand site, Threejspunk cyberpunk-rain demo, igloo.inc, Noomo Agency showcase) plus live production profiling, rather than generic advice. Use when eliminating the loading-spinner-to-scene freeze/jank, hiding shader/pipeline compile cost behind a loading screen, designing an adaptive-quality or perf-budget system (including hardening it against false-positive degrades from transient stalls), attributing unexplained slow frames to a named cause (in-app frame-attribution tooling: marks buffer, GL-resource deltas, dispose tracer, boot tracer for the pre-mount window), auditing hidden render-target allocations in library material wrappers, tuning scroll/camera damping so input can't "outrun" a max speed, deciding where to spend a render-loop's cost (MRT routing, layer-split passes, on-demand shadows, GPU-resident particles, proximity gating), or building desktop/mobile quality tiers for bloom/post-processing. Complements the generic `three-best-practices` rule compendium — this skill is the "how real sites actually hide cost and avoid jank" companion, not a restatement of it.
|
Three.js Performance & Loading Patterns
Patterns drawn from studying modern Three.js/WebGPU production sites built by
top-notch studios and developers, each solving a different piece of "make a
heavy real-time scene feel instant and never stutter." This file distills the
transferable patterns, organized by what problem they solve.
Don't treat any of this as an au-courant checklist to apply wholesale — each
technique below traded something (code complexity, a stubbed physical
behavior, a hard cutoff) for its win. Read the "cost" note on each one before
copying it.
1. Loading & warmup — hide compile/decode cost behind the spinner
The classic symptom this section solves: loading bar hits 100%, then a 1-2
second freeze before the scene appears. That freeze is (almost always) the
GPU driver compiling shader pipelines on first use — WebGL/WebGPU compile
lazily, per unique material/uniform combination, the first time it's drawn.
If your loading screen only waits for asset fetch, you've hidden network
latency but not compile latency.
Fix: force every pipeline to compile while the loading UI is still up, by
actually rendering through it once. (Source: ivress-brand-site-teardown.md)
class WarmupRenderer {
async runWarmupFrame(camera) {
this._activateSection(this.warmupSectionIndex);
const culled = [];
scene.traverse((o) => {
if (o.isMesh && o.frustumCulled) { o.frustumCulled = false; culled.push(o); }
});
this.scenePass.camera = camera;
await this.postProcessing.render();
culled.forEach((o) => { o.frustumCulled = true; });
}
completeWarmup() {
this._restoreOriginalState();
requestAnimationFrame(() => requestAnimationFrame(() => queueMicrotask(() => {
emit('compileEnd');
emit('reveal', { isIn: true });
})));
}
}
Key details that make this work, not just "render some frames":
- Warm every distinct state your scroll/scene narrative visits, not just
the first view. Ivress iterates all 6 scroll sections, 3 frames each, plus
a separate overlay-scene pass — because each section activates different
materials/uniform branches that compile independently.
- Force meshes to actually draw (disable frustum culling for the warm
pass) — a culled mesh's pipeline never compiles, so warming with the real
camera framing isn't enough on its own.
- The reveal event is the gate, not the asset-load event. Loading UI
should listen for "compile done", not "assets fetched."
Per-camera compileAsync, cheaply. (Source: threejspunk-teardown.md,
§4.9) If different cameras render different layer masks (e.g. a dedicated
rain/particle camera vs. the main beauty camera), each layer combination
produces a different compiled program — warming the beauty camera doesn't
warm the rain camera's program.
await renderer.compileAsync(scene, beautyCamera);
setVisible(heavyObjects, false);
await renderTonePipelineOnce();
setVisible(heavyObjects, true);
await renderer.compileAsync(scene, rainCamera);
Browser-specific note from the same source: Safari's shader compiler is
slower/less async-friendly in practice, so that build makes Safari await
the second compile before reveal, while other browsers let it finish in the
background after the intro has already started. Don't assume compileAsync
resolving means the pipeline is actually ready on every browser.
Secondary win: use "compile done" as a general readiness signal. Once
you have a compileEnd event, route non-critical work through it instead of
firing it eagerly at load start — e.g. ivress defers loading secondary SFX
until after warmup, keeping it off the critical path for free.
Gate the loader's reveal on real rendered frames, not asset progress or
timers. (Source: live profiling of a shipped transmission-glass R3F site, 2026-08.) The
scene's first-mount block (a Suspense-gated R3F tree constructing hundreds
of objects in one commit) happens after every asset-progress signal has
already finished — useProgress reaches its final lull while the expensive
mount is still ahead, and any fixed-delay timer is a guess that breaks on
slower machines. Two attempts at timer/progress-based gating both misfired
in production (un-froze too early, or froze permanently via an effect-
cleanup bug). What worked: a useFrame inside the mounted scene counts
actually-rendered frames and fires a callback at frame ~8 — by definition
past the mount block, no guessing.
useFrame(() => {
if (framesRef.current++ === 8) onSceneFramesReady?.();
}, -999);
Companion loader-UX trick: don't hide the loader's content until that
signal — show it immediately but paused. CSS animation-play-state: paused on the animated parts freezes them at their first keyframe without
losing phase; un-pausing on the frames-ready signal reads as "the site came
alive," whereas an animation that visibly stutters through the mount
block reads as jank, and hidden-then-shown content reads as a broken flash.
Chunk one-shot procedural synthesis, and schedule it at creation, not
first use. Anything that fills a large buffer procedurally on the main
thread — a convolution-reverb impulse (a 5s stereo impulse is ~500k samples
with Math.pow + Math.random each), a noise texture, a generated mesh —
is a guaranteed one-frame stall if it runs synchronously at the moment it's
first needed. Fill it in rAF-yielded chunks (~40k samples/chunk), and kick
the fill off eagerly as soon as its owning context (AudioContext, GL
context) exists, so by first-use time it's normally already done. Keep the
synchronous path only as a fallback for "needed before the chunked fill
finished."
2. Render-loop cost discipline
These are ways to make the steady-state render loop cheaper, not the
loading path. Source: threejspunk-teardown.md almost entirely — it's the
deepest perf-engineering teardown of the four.
On-demand shadow maps. For mostly-static scenes (a static city + one sun,
not a dynamic day/night cycle), shadowMap.autoUpdate = true re-renders the
shadow map 60×/s for no reason.
renderer.shadowMap.autoUpdate = false;
function requestShadowMapUpdate(reason) {
renderer.shadowMap.needsUpdate = true;
}
This is one of the single biggest wins available for a static-lighting scene
and costs nothing when the scene genuinely doesn't need per-frame shadows.
MRT your emissive channel so bloom is selective by construction. Instead
of thresholding a blurred beauty pass to guess what should glow, write
emissive contribution to its own MRT target and have bloom read only that
target.
// Main pass fragment shader, conceptually:
layout(location = 0) out vec4 outColor;
layout(location = 1) out vec4 outEmissive;
// ...
outColor = vec4(litColor, 1.0);
outEmissive = vec4(emissiveColor, 1.0);
Bloom then samples outEmissive exclusively — neon/glow elements bloom hard
without washing out non-emissive bright surfaces, and there's no threshold
tuning fighting your beauty pass's actual brightness range.
Layer-split passes for effects that need their own culling/outputs.
Rain/particles/anything that wants a different MRT output (e.g. a
screen-space refraction offset) than the main scene: put it on its own
render layer, give it a dedicated camera synced to the main one, and disable
that layer on the beauty camera (and vice versa).
rainCamera.layers.set(RAIN_LAYER);
beautyCamera.layers.disable(RAIN_LAYER);
syncCameraTransforms(rainCamera, beautyCamera);
GPU-resident particle motion — zero CPU per-frame cost. Don't update
particle positions on the CPU and re-upload. Compute position from a time
uniform inside the vertex/compute shader instead.
// Rain drop Y position wraps in-shader; CPU only advances a single
// uniform per frame, regardless of particle count.
float y = mod(vAttrY - uTime * uSpeed * vRandSeed, uCylinderHeight);
Pair with frustumCulled = false (the particle volume is usually
camera-parented anyway, so per-object bounds checks are wasted work) and a
hand-set bounding sphere on GPU-driven emitters so culling stays meaningful
where it is used.
Proximity + hysteresis gating. Anything expensive that's only visually
relevant near the camera (a "wet car surface" shader layer, a playing video
texture) should fade/disable based on distance, with separate enter/exit
thresholds to avoid flicker at the boundary:
const fadeStart = 20, fadeEnd = 32;
const playDist = 20, pauseDist = 50;
Fuse your post-processing into as few passes as possible. A grade chain
(fog → tint → saturation → contrast → chromatic aberration → vignette →
grain) written as one fused fragment node/shader is one full-res read/write
instead of five-plus separate passes each re-reading and re-writing the
full framebuffer. Reserve genuinely separate passes for things that need a
different resolution (bloom mips) or a different input (a blurred capture
buffer) than the main grade.
Re-link the post graph, don't rebuild it, when toggling features.
post.outputNode = newGraph; post.needsUpdate = true on toggle, rather than
constructing/destroying pass objects. A disabled branch that was never
linked in costs nothing; a materially-different graph gets a single relink
instead of teardown/rebuild churn. The R3F flavor of the same rule:
@react-three/postprocessing's <EffectComposer> rebuilds its ENTIRE pass
list — disposing and re-creating the fused EffectPass's depth texture and
render targets — whenever the children prop identity changes. The effects
array must be a useMemo, with conditionals gated so disabled features
can't change the array identity; an inline-rebuilt array cost 100-386ms per
re-render frame in Safari on a production site. Same class of bug one level
down: an effect constructed in a useMemo whose deps include size/dpr
gets a fresh identity on every resize — mutate the existing effect's
resolution-dependent internals in a useLayoutEffect instead of
reconstructing it.
Audit hidden render-target allocations in library material wrappers —
especially across remounts. (Source: live profiling of a shipped
transmission-glass R3F site, 2026-08.) Library convenience components can allocate real GPU
resources you never use: drei's <MeshTransmissionMaterial> unconditionally
creates two useFBO render targets per instance, even when
transmissionSampler or a custom buffer means they're never read. Worse,
drei's useFBO(n) called with a single number sizes WIDTH to n but
defaults HEIGHT to the full viewport — so "minimizing" the resolution prop
to 16 still allocates a real 16×viewport-height target per material. When
~24 shell materials remounted together (a key flip on a mode change),
disposing + reallocating those always-unused targets cost a measured
936ms single frame. Fix was a trimmed local copy of the component with
the FBO allocation deleted (shader/uniform code kept verbatim so existing
onBeforeCompile string patches still match). The general rule: any
key-driven remount of a material/component family disposes and re-creates
its GPU resources in one frame — before shipping a mode-flip key, check
what each instance actually allocates (a dispose-tracer with call-site
stacks makes this a 5-minute question; see §6).
3. Adaptive quality — degrade predictably, don't chase every frame
Put your whole perf budget in one flat config object. Every expensive
subsystem gets a resolution scale, a frame-skip, and/or a kill switch —
not scattered if (isLowEnd) checks through the codebase.
const perfBudget = {
adaptiveDpr: true, maxPixelRatio: 1.5,
groundReflection: true, groundResolutionScale: 0.5, groundReflectionFrameSkip: 1,
bloom: true, bloomResolutionScale: 0.5,
dof: false,
smokeEnabled: true, exhaustCount: 50, ambientCount: 40,
};
The single flat object doubles as your debug/console API surface (expose
app.perf.set('groundReflection', false)) and your tuning-panel bindings —
one source of truth for every perf decision in the app.
Latch degradation one-way; don't ratchet it every frame. Sample FPS
over a rolling window (not every frame — that's noisy). On sustained bad
frames, drop quality once and stay there rather than continuously
adjusting up and down, which reads as visual flickering/instability.
if (allowAdaptiveSampling && twoConsecutiveWindowsBelow(50 )) {
forcedLow = true;
renderer.setPixelRatio(0.85);
disable(['dof', 'lensflare', 'billboardVideos']);
}
The naive version of that sampler fires false positives in production —
four hardening rules. (Source: trace analysis on the same shipped R3F site,
2026-08 — a deployed monitor built exactly to the pattern above permanently
dropped DPR because of a ~2s post-intro transient, then later nuked the
whole material tier on a 3-frame click stall. Both were confirmed false
positives: steady-state fps was fine before and after each trigger.)
- "Armed after the reveal" is not enough — discard the first 2-3 windows
after arming too. The reveal gate protects against the intro's own
heavy frames, but whatever settles immediately after it (audio start,
camera handoff, HUD reveal, deferred mounts) lands squarely in the first
sampled windows. The observed failure: first two post-arm windows at
45-47fps → degrade fires → 56-59fps for the next 18 seconds.
- A window's aggregate FPS cannot distinguish "sustained bad pacing"
from "two huge one-off frames ate the window's budget." A 3-frame
570ms click transition inside a 1s window reads as 24fps. Track
per-frame deltas and discard (not count either way) any window
containing a frame slower than ~100ms — a one-off stall is a transition,
not evidence about steady-state cost.
- Stage the degrade, cheapest lever first — because the degrade event
is itself a hitch. Swapping DPR + material mode + MSAA + composer
config in one commit rebuilds every material and resizes every render
target: a measured degrading session showed 794ms total GC (max 138ms)
vs 354ms (max 25ms) in a pinned-tier session of the same length. The
hitch can push FPS down enough to look self-reinforcing. Level 1 = DPR
only (resizes targets, rebuilds nothing); level 2 = full tier drop. And
skip evaluating the 1-2 windows right after each escalation, or the
degrade's own hitch counts toward the next escalation.
- Gate escalation on the cheaper lever having measurably failed.
Remember the fps that triggered the last escalation; if a new bad streak
arrives with fps clearly better than that, the cheap lever worked and
the new dip is a fresh transient — don't escalate. Without this gate,
the same 2-bad-windows counter that justified "drop DPR" will later
justify "drop the whole material tier" on any unrelated hiccup, trading
your hero visual feature for nothing (observed: fps unchanged at 35-36
before and after the material drop — the scene wasn't fill-bound, which
the DPR step had already proven, and the escalation ignored that
evidence).
Also: freeze/suspend the adaptive monitor for the duration of any benchmark
run — a mid-run degrade swaps the workload under the measurement and the
numbers become a useless blend of two tiers. And pin the first GPU-tier
classification in localStorage: detect-gpu is not deterministic across
reloads on privacy-masked GPUs, and a tier that flaps between reloads means
the site looks different every other visit.
UA-based feature gating at boot, separate from backend/capability
detection — some things you want off on mobile Safari specifically (not
just "any WebGL2 fallback"), because it's a known-bad combination rather
than a measured capability gap:
if (isMobile()) disable(['lensflare', 'billboards']);
if (isMobile() && (isIOS() || isSafari())) { maxPixelRatio = 1; adaptiveDpr = false; disable('smaa'); }
if (isSafari()) { disable('dof'); timestampQueriesOff = true; }
Desktop/mobile as genuinely different post-processing tiers, not just a
DPR cap. The Noomo teardown's clearest example: desktop runs a full
depth-aware raymarched volumetric glow (7 samples, 3D noise, view-ray
reconstruction from depth) as its bloom's atmosphere; mobile replaces that
entire pass with a flat blue-tint multiply on the same underlying bloom
texture — same visual identity, no depth reconstruction, no raymarch, no 3D
noise sampling at all on mobile.
mobileBloom = bloomTexture * tintColor * 0.05;
outputColor += mobileBloom;
4. Scroll/input smoothing — cap the step, not the value
The primitive: clamp the per-frame step of a damped lerp, not the target
value itself. (Source: igloo-inc-teardown.md) A plain lerp toward a
target can close an arbitrarily large gap in one frame if the raw input
delta is huge (a hard mouse-wheel notch, unlike inertia-smoothed trackpad
deltas, can do exactly this). Clamping the step guarantees a hard
"can't-outrun-this-speed" ceiling regardless of how large the input jump was.
function lerpFPSLimited(current, target, lerpFactor, maxSpeed = Infinity) {
const naive = lerpFPS(current, target, lerpFactor);
const maxStep = maxSpeed * deltaTimeRatio;
const step = clamp(naive - current, -maxStep, maxStep);
return current + step;
}
If you're in the R3F/drei ecosystem, you likely don't need to hand-roll
this: <ScrollControls damping maxSpeed> uses maath's easing.damp
under the hood, which is the same maxChange = maxSpeed * smoothTime
step-clamp. Gotcha found in practice: maxSpeed there is
offset-units/second over the whole scroll rail — a value that looks
reasonable on paper can still let one hard wheel notch close most of the
rail in under a second. Tune low enough that catch-up takes multiple
seconds regardless of raw input size, and verify with an actual hard
mouse-wheel notch (not just trackpad), since that's the input that exposes
an under-tuned cap.
When "snappy" beats "smooth": scrub an authored timeline directly instead
of layering extra damping on top. (Source: noomo-showcase-teardown.md)
If your camera/object motion comes from an authored animation clip (GLB,
timeline, whatever), consider driving it directly from smoothed scroll
progress with no second damping layer on top:
clip.time = clip.duration * smoothedScrollProgress;
The comparison that surfaced this: a scroll rail with multiple smoothing
layers stacked (target lead → capped velocity → filtered offset → rubber-
band → snap detection → the renderer's own damping) reads as soft/delayed
compared to one that only smooths the input once and lets the authored
curve's own easing carry the rest. More physical layers isn't automatically
better — it's a trade between "rich interactive rail" and "immediate,
authored feel." Pick based on whether your motion is a fixed narrative
(favor direct scrub) or a live-navigable space (favor the damped rail, and
budget for it reading softer).
5. Transmission/glass-specific: own your render target