Skip to main content

threejs-perf-loading

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 gatin

Zur Installation springen

Quellinformationen

Repository
vladmdgolam/agent-skills
Letzte Quellaktivität
5. August 2026 um 16:03
Erkannte Sprache von SKILL.md
Englisch
Sterne
8
Forks
1

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
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`) ```js // Simplified from the ivress renderer's warmup phase. class WarmupRenderer { async runWarmupFrame(camera) { // 1. Activate the section/state whose materials you're about to warm. this._activateSection(this.warmupSectionIndex); // 2. Disable frustum culling for this pass — you need EVERY mesh to // actually draw, including ones off-screen from this camera, or its // pipeline never compiles. const culled = []; scene.traverse((o) => { if (o.isMesh && o.frustumCulled) { o.frustumCulled = false; culled.push(o); } }); // 3. Render a handful of real frames through the FULL post-processing // chain (bloom, grade, everything) — not a stripped-down warm pass. // Uncompiled post nodes will still stall on first real use otherwise. this.scenePass.camera = camera; await this.postProcessing.render(); culled.forEach((o) => { o.frustumCulled = true; }); // 4. Advance to the next section/state and repeat until every // section's material set has drawn at least once. } completeWarmup() { this._restoreOriginalState(); // Wait 2 RAFs + a microtask — let the compositor fully settle — // THEN fire the event your loading UI listens for to fade out. requestAnimationFrame(() => requestAnimationFrame(() => queueMicrotask(() => { emit('compileEnd'); // safe to also start loading non-critical assets now 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. ```js // Two-phase warmup behind the loader. await renderer.compileAsync(scene, beautyCamera); // Temporarily hide the expensive stuff so this warm frame is cheap — // you need the DRAW to happen for compile, not a full-cost frame. setVisible(heavyObjects, false); await renderTonePipelineOnce(); setVisible(heavyObjects, true); // Layer-masked cameras need their own compile — a rain camera with // layer 2 enabled produces a different program than the beauty camera. 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. ```js // Inside the Suspense-gated scene component: 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. ```js renderer.shadowMap.autoUpdate = false; // Call this explicitly on: init, lighting changes, resize, camera-mode // switches, any editor/inspector edit. A static scene then renders its // shadow map a handful of times per session instead of every frame. 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. ```glsl // 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). ```js rainCamera.layers.set(RAIN_LAYER); beautyCamera.layers.disable(RAIN_LAYER); // beauty pass never draws rain; rain pass draws ONLY the ~1-9k particle // sprites, nothing else — and can emit its own MRT channels (e.g. a // per-drop UV offset for "refraction behind the rain drop") for free. 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. ```glsl // 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: ```js // Two different thresholds, not one — prevents boundary flapping when // the camera hovers near a single cutoff distance. const fadeStart = 20, fadeEnd = 32; // wetness layer const playDist = 20, pauseDist = 50; // billboard video (bigger gap = more hysteresis) ``` **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. ```js const perfBudget = { adaptiveDpr: true, maxPixelRatio: 1.5, groundReflection: true, groundResolutionScale: 0.5, groundReflectionFrameSkip: 1, bloom: true, bloomResolutionScale: 0.5, dof: false, // expensive effects can just ship OFF by default 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. ```js // Sample once per second, only AFTER the reveal — sampling during the // loading/intro sequence means intro hitches poison your baseline. if (allowAdaptiveSampling && twoConsecutiveWindowsBelow(50 /* fps */)) { forcedLow = true; // one-way latch, never auto-clears renderer.setPixelRatio(0.85); // one deliberate step down, not a ramp 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.) 1. **"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. 2. **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. 3. **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. 4. **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: ```js if (isMobile()) disable(['lensflare', 'billboards']); if (isMobile() && (isIOS() || isSafari())) { maxPixelRatio = 1; adaptiveDpr = false; disable('smaa'); } if (isSafari()) { disable('dof'); timestampQueriesOff = true; } // desktop Safari too ``` **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. ```js // Desktop: raymarch a volume mask from depth + noise, tint, composite. // Mobile: reuse the SAME multi-mip bloom result, skip the raymarch: mobileBloom = bloomTexture * tintColor * 0.05; // much smaller multiplier too 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. ```js function lerpFPSLimited(current, target, lerpFactor, maxSpeed = Infinity) { const naive = lerpFPS(current, target, lerpFactor); // normal frame-rate-independent lerp const maxStep = maxSpeed * deltaTimeRatio; // scale by dt vs. reference frame time const step = clamp(naive - current, -maxStep, maxStep); // clamp the STEP 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: ```js // Smooth the DOCUMENT scroll once (e.g. GSAP ScrollSmoother), then map // progress straight onto clip time — no additional lerp/spring on the // camera/object motion itself. The authored curve supplies its own easing. 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
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen