Otimizar cena 3D
Antes de qualquer comando: resolva o SKILL_DIR
Todo comando abaixo roda um script que viaja junto desta skill, em
SKILL_DIR/scripts/. Defina SKILL_DIR como o caminho absoluto da pasta que
contém ESTE SKILL.md que você acabou de ler — o seu harness informou esse caminho
no resultado da leitura. Funciona em qualquer hospedeiro, sem depender de variável
de ambiente de nenhum agente específico:
~/.claude/plugins/cache/cannonball/cannonball/<v>/skills/<nome>/SKILL.md
~/.codex/skills/<nome>/SKILL.md
~/.gemini/skills/<nome>/SKILL.md
~/.agents/skills/<nome>/SKILL.md
Em todos, SKILL_DIR é a pasta do SKILL.md, e SKILL_DIR/scripts/ está ao lado.
Adaptada da
optimize-3d-scenede textura-agency/next16-claude-starter, em domínio público. O conteúdo técnico é do autor original; o que mudou aqui foi trocar as referências ao workspace privado dele porreferences/patterns.md(que já traz o código todo) e ligar o passo final ao ciclo de armadilhas do cannonball.
Toda cena three.js paga o mesmo imposto: o celular renderiza os mesmos fragmentos que uma workstation, o primeiro quadro depois que um shader aparece compila no meio do scroll, e o render loop segue rodando atrás de três seções de texto que ninguém está olhando. Esta skill corrige isso numa ordem fixa — do mais barato e de maior impacto para o resto.
Cada passo vale igual para WebGL cru e para three.js; só as primitivas de
medição do §0 mudam (three.js te dá renderer.info; numa cena crua você
instrumenta na mão — e precisa fazer isso antes de começar).
O código copiável de tudo que segue está em
references/patterns.md — tiering, ticker
compartilhado, prewarm, loop com gate de visibilidade, canvas que sobrevive à
barra de URL do iOS, suavização de scroll no toque, contagem por tier, composer,
e transformação de scroll no vertex shader. Porte de lá em vez de inventar forma
nova; os nomes de projeto que aparecem lá (helion, mycelia, stride,
clarix) são só atribuição de origem, o código está inteiro no arquivo.
Antes de escolher biblioteca, leia
${SKILL_DIR}/references/stack-webgl-react.md: ele traz o estado de
R3F, drei e Pixi no npm, e o conflito entre <ScrollControls> e Lenis que morde
exatamente neste terreno.
0. Audit before you touch anything
Never optimise blind. Establish the baseline.
First, prove the scene actually renders. A WebGL canvas that failed to initialise looks exactly like one that is merely slow — the page loads, no error reaches the console, and every number you collect below describes nothing:
python "${SKILL_DIR}/scripts/capturar.py" --url http://localhost:3000 \
--saida /tmp/base.png
The script refuses a capture with no colour variation, and it also refuses a capture whose DOM is showing a "WebGL is not supported" card — that one passes the pixel test comfortably (measured: 5 colours, brightness 68 on a red error page), which is why the pixels are the assertion of record and the DOM is the veto after it. A refusal here means there is nothing to optimise yet — fix the scene first.
One measured caveat, because it decides whether you can trust a screenshot at all:
on macOS, headless Chrome captures WebGL correctly (verified against a solid-red
test canvas). On Linux the trap is sharper than "configure a GPU": even with
software rendering working, headless Chrome captures the canvas black. That is
an upstream limitation, not a missing flag — the render succeeded and the capture
lies about it. The fix is a headed browser on a virtual display (Xvfb), not
another adapter flag. Never take a dark capture on Linux as evidence the scene is
broken; check the platform first.
If the capture proves the scene renders but renders wrong, you have a correctness bug, not a performance one — stop here and go to §15.
Then the numbers:
# what's actually in the scene
grep -rn "setPixelRatio\|requestAnimationFrame\|new THREE\..*Light\|UnrealBloom\|Points\|InstancedMesh" src/ --include=*.ts --include=*.tsx --include=*.js
Then in the running page's console:
renderer.info.render // { calls, triangles, points } — per frame
renderer.info.programs.length // shader programs; each one is a compile stall if it appears late
renderer.info.memory // { geometries, textures }
Raw WebGL (no three.js). renderer.info only exists on
THREE.WebGLRenderer. A hand-written scene has no equivalent — hook the context
before app code runs and count it yourself, or you cannot start:
// page.evaluateOnNewDocument — counts passes, vertices, and *when* programs link
const gc = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (kind, attrs) {
const ctx = gc.call(this, kind, attrs);
if (ctx && kind === "webgl") {
window.__gl = ctx;
window.__p = { draws: 0, verts: 0, frames: 0, links: [], attrs };
const draw = ctx.drawArrays.bind(ctx);
ctx.drawArrays = (m, f, c) => { window.__p.draws++; window.__p.verts += c; return draw(m, f, c); };
const clear = ctx.clear.bind(ctx); // one clear = one frame
ctx.clear = (m) => { window.__p.frames++; return clear(m); };
const link = ctx.linkProgram.bind(ctx); // §3/§14: these must all precede the loader handoff
ctx.linkProgram = (p) => { window.__p.links.push(Math.round(performance.now())); return link(p); };
}
return ctx;
};
draws/verts replace info.render, links.length replaces
programs.length (and links timestamps are what §3 is actually measured
against), gl.drawingBufferWidth/Height is the §6 check, and the captured
attrs is the §7 renderer-flags check. Full harness in references/patterns.md.
The measurement environment (get this wrong and every number below is a lie)
- Measure a production build, never the dev server. Dev invalidates §1 (the
bundler serves chunks eagerly, so the bot path looks broken when it isn't) and
§4/§5 (React Strict Mode double-mounts, doubling listener counts and halving
the apparent frame rate).
yarn build && yarn start— and kill the old server before rebuilding, or it holds the port and serves a stale manifest, and you spend an hour debugging 404s and 500s that aren't yours. - Use
waitUntil: "load"plus a fixed settle.networkidle0never fires againstnext start. - SwiftShader is not a GPU. Absolute fps out of headless Chrome is meaningless (a desktop measured 14 fps). Only counted quantities transfer: draw calls, vertices, drawing-buffer pixels, listener counts, program-link timestamps, main-thread block duration.
- A stopwatch around a draw call measures nothing.
performance.now()before and afterdrawArrays/renderer.render()times the submission — the driver records commands, it does not run them. The GPU is still working when your timer stops, so an expensive pass reads as ~0 ms and you optimise the wrong thing. This is why everything above is a counted quantity. To get real GPU milliseconds you need the GPU's own clock:EXT_disjoint_timer_query_webgl2(gl.getExtension, then a query around the pass; the result lands one or two frames later and you must discard it whenGPU_DISJOINT_EXTis set). It is unavailable in a lot of browsers, including Safari — when it is missing, say "not measurable here" and fall back to counts, never to a stopwatch. And time a whole pass, not one draw: to isolate a suspect, move it into its own pass. - To observe a §5 frame cap at all, the GPU has to stop being the limiter: shrink the viewport to ~320×240 and re-measure. If rAF fires 120×/s and the scene draws 26×/s, the cap is working.
Write the before/after numbers down. A change you cannot measure is a change you cannot defend, and every item below costs something in look.
1. Never ship the scene to a robot
A crawler or Lighthouse run gets no scene at all — not a hidden canvas, not a lazily-idle module. The three.js bundle must never be fetched, parsed or evaluated, because script evaluation time is what the audit is measuring.
- Server-side, read the UA (
isBot()inhelion/src/utils/is-bot.ts) and render a static poster instead of the scene component. - The scene component itself is a
dynamic(() => import(...), { ssr: false })client leaf, sothreelands in its own chunk and only that chunk is skipped. - Plain-HTML projects:
import()the scene module behind the same UA check.
This applies to desktop and mobile equally.
What the poster is actually for. Not layout stability — an
absolute inset-0 background canvas shifts nothing either way, so don't justify
it that way. It exists for (a) crawler and social-preview screenshots, which
otherwise capture an empty box, and (b) the no-WebGL / context-lost fallback.
Two details:
- If the camera fits to the tighter axis (per-tier framing), one landscape
poster re-crops the subject on portrait — the head goes off-frame. Export
two crops and pick with a
<picture>media query. isBot()readsheaders(), which opts the whole route out of static prerendering (○→ƒin the build output). That is a real trade-off, not a free win: state it. If the route must stay static, do the branch in middleware (rewrite bots to a/posterroute) instead.
2. Tier the device once, at construction
One module decides what "mobile" means. Everything — DPR, particle counts, bloom, frame budget, whether the pointer is even listened to — reads from it, so the values can never drift apart. Read once at construction: a device does not change tier mid-session, and rebuilding buffers on resize costs more than the mismatch is worth.
mobile = innerWidth < 768 || matchMedia("(hover: none) and (pointer: coarse)").
The coarse-pointer clause is what catches tablets and large phones.
Also expose, from the same module:
prefersReducedMotion()— an accessibility promise, honoured on every tier.isEnergySaver()—navigator.connection.saveDataordeviceMemory <= 2; the nearest web-exposed proxy for iOS Low Power Mode, which has no API.sceneShouldFreeze()— reduced motion on any device, or a mobile flagged energy-constrained. Play the entrance, then stop drawing on a settled frame. WebGL keeps the last frame on the canvas, so a frozen scene costs zero.
3. Precompute and prewarm everything during the loader
This is the rule that kills micro-freezes. After the loader hands off, the frame loop must allocate nothing, compile nothing and upload nothing. A stall on scroll — or a frozen loader — is always one of five things. The first four are GPU-shaped, which is why the fifth is the one that gets missed:
- Shader compile / link.
renderer.compile(scene, camera)(orawait renderer.compileAsync(scene, camera), which does not block the main thread) while the loader is still on screen. Every material must be in the scene graph at that moment, including ones that arevisible = false. - Program variants. Three compiles a new program when a define changes —
USE_INSTANCING,transparent, a different light count,fog. Never flip a define, a light count,material.transparentorblendingat runtime. Set the final variant at construction and drive change through uniforms only. - Texture upload. The first
renderthat samples a texture uploads it, which for a 2K PNG is a visible hitch.renderer.initTexture(tex)for every texture during the loader. - Render-target and post-pass warmup. Each
EffectComposer/WebGLRenderTargetallocates and compiles on its first use. Render one throwaway frame through the complete chain before handoff. - CPU decode / parse. Geometry decode, normal estimation, PCA fits, buffer
building — pure work, no GPU involved, and the one most often missed because
the other four are all shader-shaped. On a throttled phone it blocks for
seconds (a measured 3.9 s for 50k plane fits), and it lands while the
loader is animating, so the counter freezes and the page ignores input.
Chunking across frames keeps the loader alive; a Worker removes it from
the main thread entirely and is the better answer whenever the work is pure.
Transfer the buffers (
postMessage(msg, [buf])) in both directions so nothing is copied, and keep an inline fallback for environments without Workers.
On top of that, precompute CPU-side:
- All
BufferGeometryattributes, all particle buffers, all noise/glyph textures — built during the loader, never on a scroll boundary. - If a build loop is long enough to block (say > 8 ms), chunk it across frames and feed the loader percentage from it. The loader is for this.
- Drive the whole timeline from a single scroll-progress uniform whose value range is fully covered during warmup, so no branch in the shader is reached for the first time mid-scroll.
Then: render one frame at each keyframe of the scroll timeline during warmup (progress 0, 0.25, 0.5, 0.75, 1) into a 1×1 scratch target. Any lazily-created program, any conditional branch, any texture bound only in the finale gets touched while the loader still owns the screen.
O references/patterns.md §3 traz esse passo 1 pronto (feito depois do GLB
resolver) — copie a forma de lá e estenda aos cinco.
§1 and §3 pull against each other — check the gap, every time.
Code-splitting the scene (§1) means it cannot mount, and therefore cannot
compile or allocate, until after hydration. On a slow connection that lands
after the loader has handed off, which is exactly the stall §3 exists to
prevent — measured on Regular 3G + 4× CPU, programs linked at 5.0 s against a
curtain that lifted at 2.36 s. Neither section warns you on its own. Measure it
(§0's linkProgram timestamps vs the handoff time) and close it deliberately:
<link rel="preload"> the scene's data from the HTML so it is in flight during
parse, and if the gap survives that, gate the loader on scene-ready rather
than on a fixed duration. A time-based preloader is a promise about the network
you cannot keep.
[!warning] The
as="fetch"preload credentials trap Anas="fetch"preload is only reused when its credentials mode matches thefetch()exactly.crossorigin="anonymous"+credentials: "omit"does not match, and neither does no-attribute + default — both silently download the asset a second time, with nothing but a console warning ("…not used because the request credentials mode does not match"), and the page looks fine either way. The pair that dedupes iscrossorigin="use-credentials"+credentials: "include". Verify by counting network requests (page.on("request")), notfetchcalls.
4. Render only when visible — the loop is on-demand
The single largest saving in a scroll site. Three WebGL scenes each running
their own forever-rAF was the documented cause of scroll jank in stride.
Gate on all three:
document.hidden— a background tab paints nothing.- The section is on (or near) screen —
IntersectionObserverwith arootMarginof about one viewport so it is already warm when it arrives, or a scroll-range test likeisSceneVisible(). - The canvas is actually visible (not faded to 0 by a wrapper).
Prewarm (§3) is the deliberate exception: it runs once, before any of this.
Subscribe to one app-wide rAF ticker rather than starting a loop per scene
(helion/src/lib/animation/ticker.ts). It reference-counts, so an idle page
costs nothing, and each subscriber is throttled independently.
5. Budget the frame rate per tier
mobile: 1000/30, tablet: 1000/45, desktop: 0 (every tick). These scenes
are fill-bound, not motion-bound — the noise fields evolve slowly enough that
halving the frame rate on a phone is genuinely hard to see, and it is the single
biggest win available there. Throttle per subscriber so capping the scene does
not slow the springs and DOM animation sharing the loop.
1000/30 does not produce 30 fps. The canonical ticker skips while
time - last <= framerate, so with rAF free-running at ~120 Hz the first tick
that clears 33.3 ms lands at ~41.7 ms — 26 fps measured, not 30. It errs cheap,
so it is harmless and the budget still works; just don't quote 30 as measured
truth. If you want the stated number to match reality, fix it in one place:
budget 1000/30 - ε, or change the ticker's comparison to <. (And see §0 —
you can only observe the cap once the GPU isn't the limiter.)
6. Pixel ratio: clamp hard, and clamp the composer too
mobile → min(dpr, 0.85) // 0.75–1.0; below 1.0 is fine for soft sprites
tablet → min(max(dpr, 0.75), 1.25)
desktop → min(max(dpr, 0.75), 1.5)
A 3× phone renders 9× the fragments of a 1× screen, for no perceptible gain on a point cloud or a soft-edged shader. Go to 1.0 (not below) when the scene has hard-edged geometry — warp streaks, thin lines, crisp text in the shader — because those alias visibly.
EffectComposer owns its own render targets. If you clamp the renderer and
leave the composer at raw devicePixelRatio, you throw the entire saving away
on the post pass. Set both from the same function.
7. Cut fill, not detail — the phone dies on fill rate
In order of what actually costs:
Particle counts, per tier. Roughly a third of desktop on mobile (
vortex.ts: 460×420 desktop → 170×190 mobile). Cut the sparse end of the distribution first — the rim of a disc, the outer shell of a cloud — where it shows least. Never cut uniformly.On a pre-baked point buffer that advice has no lever — your only knob is truncating
drawArrays, and whether that is safe depends on the buffer's point ordering, which is documented nowhere. Check it before you cut: bucket the positions into deciles and compare mean coordinates. If they drift monotonically, the points are spatially ordered and drawing the first N deletes a region, not a sample (one measured file was sorted left→right — truncating would have removed half the head). In that case the only real reduction is re-sampling the asset offline into apoints-lite.bin, not a smallerdrawArrays. Say that rather than shipping a hole. Script inreferences/patterns.md.Bloom. Halve strength and radius on mobile, and skip the pass entirely when it contributes nothing:
bloomPass.enabled = bloomPass.strength > 0.001saves a full-screen chain per frame. Scale bloom by viewport height too — a look tuned on a 1440p screen blows out on a short window.Additive/transparent overdraw. Every additive halo is pure fill. Prefer fewer, larger sprites over many small ones; cap
gl_PointSize; keepdepthWrite: falseon transparents so you at least skip the depth write.Post-processing chains that render nothing. Audit them —
myceliaandhelionboth shipped three chained composers where two rendered empty layers and the final pass sampled stale targets. That was both a flicker and two wasted full-screen passes per frame. O composer que pula um passe sem contribuição está emreferences/patterns.md§9.Renderer flags.
antialias: falseon mobile (the DPR clamp and soft edges hide it; MSAA on a phone is expensive),alpha: falsewhen the canvas is opaque,stencil: false,depth: falsewhen nothing depth-tests,powerPreference: "high-performance"on desktop only.Shadow maps off on mobile.
VSMShadowMap(whatheliosuses) is the most expensive type there is. If the scene needs grounding, bake it into a texture.
8. Lights: as few as the look survives
Every real-time light multiplies the fragment cost of every lit material, and changing the light count recompiles every program. Target:
- One directional key + an environment map (IBL). A PMREM'd
RoomEnvironment(código emreferences/patterns.md§11) replaces three or four fills and looks better than any of them. - Bake rim/fill into the material — a fresnel term in
onBeforeCompilecosts a few ALU ops and reads as a light.clarixalready does this; it just also ships three real lights on top of it. - No point/spot lights on mobile unless the scene is literally about them.
- Never add or remove a light at runtime.
9. Do transforms on the GPU, not the CPU — especially scroll
Any per-object transform that scroll drives should be a uniform feeding the
vertex shader, not a JS loop mutating position/rotation per frame.
- Positions computed in the vertex shader from
aOffset/aRandomattributes + auProgressuniform. Scroll then costs one uniform write per frame, and the work scales on the GPU instead of the main thread.clarix's logo particles already do this correctly (aRandomPosition,aDelay,uProgress) — that is the shape to copy. - Set
frustumCulled = falseon anything whose positions the shader computes, or Three culls against a stale bounding sphere. - Where a whole group moves, move the
Group(one matrix), never the children. - Instancing / merged geometry for anything repeated: one draw call, per-instance data in attributes.
- CPU-side per-frame
Vector3/Matrix4allocation is a GC stall. Reuse module-scope scratch objects (clarixdoes this with_revealVec— do it everywhere). - Read
window.scrollYonce per frame inside the ticker. Never in a scroll handler that also writes styles — that is a forced layout every event.
10. Smooth the scroll progress on touch
On mobile the OS owns momentum scrolling, so window.scrollY arrives in
discrete steps and every derived value jitters — worst on a fixed, scroll-driven
scene, and compounded when the scene runs at 30 fps.
Low-pass the scroll position once, upstream, so every downstream value inherits the easing:
smoothed += (raw - smoothed) * k;
- Retention 0.75 ⇒
k = 0.25. That is the same thing said two ways, and it matcheshelion's tunedSMOOTH_LERP = 0.22— keep mobile in the 0.2–0.3 band. Lower than that reads as disconnected from the thumb once the 30 fps cap is stacked on top. - Desktop still wants a gentle
k ≈ 0.3— Lenis eases the wheel, but the scene samplesscrollYraw and steppy wheel input shows up as camera jitter. - Snap, don't crawl, on a page jump: if
|raw - smoothed| > 1.5vh, assign directly. Otherwise an anchor link takes two seconds to arrive. - Make it frame-rate independent when the tier caps fps:
k = 1 - Math.pow(1 - kBase, dt * 60).
11. Kill cursor interactivity on mobile
Unless the user explicitly asks for it. On a touch device pointer effects are either dead weight or actively wrong:
- Don't attach the
mousemovelistener at all on the mobile tier — not "attach and ignore". - Gate every pointer-driven effect on
hasPointer()(has the pointer ever moved). Ungated, an unmoved cursor resolves to NDC (0,0) — dead centre — so a repulsion field punches a hole through the middle of the scene on every touch device and every untouched page. O gate e o ease-in estão emreferences/patterns.md§7. - Drop the uniform and the branch from the mobile shader variant where it is more than a couple of ops — but set it at construction (§3.2), never toggle.
- If the user does want it on touch: drive it from
touchmove, and keep the same lerp so it doesn't snap.
12. Compress the assets
Geometry: Draco.
clarixandstridealready do; keep the decoder local (/draco/), not ongstatic—clarixfetches it from a CDN, which is a round-trip on the critical path.Textures: KTX2 / Basis (
KTX2Loader), not PNG/JPEG. This is the one that matters for GPU pressure: a compressed texture stays compressed in VRAM, so it costs a fraction of the memory and bandwidth of an equivalent PNG, which is decoded to raw RGBA on upload. On a phone that is the difference between a smooth pan and a texture-thrash stutter.npx @gltf-transform/cli optimize in.glb out.glb --texture-compress ktx2Cap texture size per tier (2048 desktop / 1024 mobile),
anisotropy = 1on mobile,generateMipmapson for anything minified,LinearFilterfor procedurally-drawn canvas textures.Resize the model itself: decimate before you optimise the renderer.
13. The details that cause "flicker on iOS"
- No
resizelistener on touch. iOS Safari firesresizeevery time the URL bar collapses during scroll; handling it rebuilds the WebGL framebuffer mid-scroll and reads as a whole-scene flash. Size the canvas once on load and accept that rotation won't reflow it. Desktop keeps an rAF-coalesced resize. (mycelia/src/lib/scene/canvas3d.ts.) - Size the canvas against the largest viewport —
h-lvh w-lvw, not100vh— so a collapsing URL bar never re-allocates the framebuffer.lvhis for the canvas, not the layout. Applying it to the content is a different bug (the bottom of the layout hides behind the URL bar), and it is the naive reading of this line: canvaslvh, contentdvh. The extra canvas bleed is clipped and invisible. - Promote the canvas wrapper to its own compositor layer —
transform-gpu backface-hidden will-change-transform. Without it a neighbouring fixed element repainting during scroll invalidates the WebGL composite on WebKit and the whole scene flickers. - All scroll/pointer listeners
{ passive: true }. - Clamp
dt:Math.min(0.05, t - last). A tab-switch return otherwise hands the scene a two-second delta and everything teleports. - Dispose on unmount: geometries, materials, textures, render targets,
renderer.dispose(), and remove every listener.
14. Verify, then write it down
Re-measure the §0 numbers and report the delta honestly:
renderer.info.render.callsand.programs.lengthbefore vs after (raw WebGL:window.__p.drawsandwindow.__p.links.length); the program count must be stable after the loader — if it grows during scroll, §3 is incomplete and the micro-freezes are still there. On a raw scene thelinkstimestamps say more than the count: every one must precede the loader handoff, or §1 has pushed compilation past it (see §3).- Re-measure on the same footing as the baseline — production build, fresh server, counted quantities only (§0). A dev-mode "after" number proves nothing.
- Frame timings on a throttled CPU (DevTools 4×/6× slowdown) across a full scroll, looking for long tasks.
- Lighthouse mobile before/after — with §1 in place the bot path should show no three.js in the JS bundle at all.
- Look at it on a real phone. Fill-rate wins are invisible in a profiler and obvious in the hand.
Then, no mesmo turno, grave o que você descobriu no acervo. Este é o passo que faz o cannonball aprender — sem ele o próximo projeto tropeça igual:
python "${SKILL_DIR}/scripts/armadilhas.py" --add <id-da-peça> \
--texto "o que travava, o número antes/depois, e o que resolveu" \
--origem <slug-do-projeto> --grau <critica|alta|media>
python "${SKILL_DIR}/scripts/indexar.py"
Como escolher o grau aqui:
critica— a página trava, fica em branco, ou o WebGL derruba a aba. Também entra o caso silencioso: o programa continua compilando durante o scroll e as micro-travadas seguem ali sem ninguém perceber na máquina de dev.alta— roda, mas a entrega fica ruim: 26 fps num celular médio, ventoinha ligada, bateria drenando, Lighthouse reprovando por causa do bundle.media— ajuste fino: um passe de bloom que não contribui, uma textura maior do que precisa.
Escreva com o número, não com adjetivo. "3 composers encadeados, 2 renderizavam camada vazia — 2 passes full-screen por quadro desperdiçados" serve; "tinha problema de performance" não serve para nada.
Se a peça otimizada veio do acervo, a armadilha fica nela. Se você construiu do
zero, ingira primeiro (kit-ingerir) e grave depois.
15. When the scene is wrong, not slow — make the pixels carry the numbers
Everything above makes a correct scene cheaper. This section is for the other failure: it renders, it is fast, and it is wrong. Use it by default for any multi-pass or mathematically non-trivial shader, and immediately whenever someone reports a visual bug.
Method adapted from
shader-debuggingin vercel-labs/vgpu (MIT). vgpu itself is WebGPU/WGSL and is not a dependency here — what transfers is the methodology, rewritten for WebGL2/GLSL.
Do not iterate by eye. That is the whole point. In the run this came from, two
rounds of by-eye fixes changed the image without fixing it, and one extraction pass
found both root bugs. A shader has no console.log; its only output is pixels, so
make the pixels carry the numbers.
1. Split the maths into pure functions
A debug shader must exercise the GLSL that actually ships, or it proves nothing
about your bug. Keep reused maths in a chunk you can #include (or concatenate)
into both the real shader and the harness; one-use maths can stay beside the entry
point. Anything the harness needs must be free of bindings — no uniform reads,
no texture samples — so it can be called with literal arguments.
2. Render the internals into an 8×1 target and read them back
One pixel per slot, one channel per value. Everything must land in [0,1] and
survive 8-bit quantization:
| Value | Encode | Decode |
|---|---|---|
| weight, fresnel, alpha | as-is | byte / 255 |
| LOD level | lod / (levels - 1) |
byte / 255 * (levels - 1) |
| direction / normal | dir * 0.5 + 0.5 |
(byte / 255 - 0.5) * 2 |
| unbounded ray | dir * 0.1 + 0.5 |
(byte / 255 - 0.5) * 10 |
| distance, thickness | d * scale, fixed scale |
byte / 255 / scale |
// 8 slots, 4 channels each: 32 numbers out of one draw
const fbo = gl.createFramebuffer();
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 8, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.viewport(0, 0, 8, 1);
gl.drawArrays(gl.TRIANGLES, 0, 3); // fullscreen triangle, debug fragment shader
const px = new Uint8Array(8 * 4);
gl.readPixels(0, 0, 8, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
// the debug fragment shader: slot index from gl_FragCoord, one meaning per slot
int slot = int(gl_FragCoord.x);
if (slot == 0) fragColor = vec4(fresnel(1.5, 0.2), fresnel(1.5, 0.5), fresnel(1.5, 1.0), 1.0);
else fragColor = vec4(lodFor(0.0) / 7.0, lodFor(0.5) / 7.0, lodFor(1.0) / 7.0, 1.0);
Give each slot exactly one meaning and comment it. A debug shader nobody can decode is worthless the next day.
3. Diff against a CPU reference, with a stated tolerance
Reimplement the same maths in JS and compare value by value. The floor is
2 / 255 ≈ 0.0078 — the quantization step of an 8-bit target. Write the
comparison to JSON (reference, gpu, maxError, tolerance, pass) so the run leaves
evidence, and exit non-zero when it fails.
2 / 255 is the floor only for a value stored in 8 bits. For a derived or
iterative quantity — a sphere tracer's hit point after N steps, an accumulated
integral — the budget comes from the algorithm's own epsilon, not from the texture
format. Say which one you used.
Need more precision than 8 bits? EXT_color_buffer_float lets you read an
RGBA32F attachment directly; without it, add an encode pass into an RGBA8
target and read that.
4. Dump every intermediate target, not just the final image
In a multi-pass chain the numbers can all be right and the image still wrong,
because a pass reads the wrong attachment. Write each intermediate to a PNG
and look at them one by one: every level of the blur pyramid (pyramid-0.png …
pyramid-7.png), every G-buffer attachment, then the composite. In the original
investigation the dumps showed the top blur levels were never selected and that
exit normals followed the camera ray instead of the refracted one — neither was
visible in the final image, both were unmistakable in the intermediates.
This is also the answer when §7's composer audit turns up a chain you don't understand: dump the targets and you can see which passes render nothing.
5. Keep the run deterministic, or it is not evidence
Two runs of the same harness must produce identical bytes. If they don't, fix that before debugging anything else.
- No clock. Pass time in as a fixed constant — never
Date.now(),performance.now()or the ticker's elapsed time. - A fixed number of warmup frames before the one you read. Always the same number, including on the "after" run.
- Jitter by pixel hash, never by frame index. A stable per-pixel rotation
breaks up banding without changing between runs:
float rot(vec2 p) { return fract(sin(dot(floor(p), vec2(12.9898, 78.233))) * 43758.5453) * 6.2831853; } - Fixed, small target sizes.
The same rule governs capturar.py: its --virtual-time-budget advances a
virtual clock, so two captures of the same page land on the same frame of the
animation and are comparable. A page that reads Date.now() or an unseeded
Math.random() escapes that, and its preview can never be a regression baseline.
Then write it down
Uma raiz encontrada por extração é armadilha de grau critica ou alta quase
sempre — ela sobreviveu a uma rodada de conserto no olho. Grave com o número que
o diff produziu, não com adjetivo:
python "${SKILL_DIR}/scripts/armadilhas.py" --add <id-da-peça> \
--texto "o valor extraído, o esperado, e onde a conta divergia" \
--origem <slug-do-projeto> --grau <critica|alta>
What not to do
- Don't drop the scene on mobile wholesale. The scene is the product; tier it.
- Don't tune by feel on a desktop. Every number here was measured on a phone.
- Don't write a new
device.tsfrom scratch. Port the one inreferences/patterns.md§1 — one module decides what "mobile" means, and every budget reads from it so the values cannot drift. - Don't ship
lil-gui(clarixships it hidden — it is still parsed and evaluated). Tree-shake it behind a dev flag. - Don't leave
console.log,Stats, or anOrbitControlsyou disabled in the production path. - Don't fix a shader by eye. Changing a constant until the image looks better moves the bug; it does not find it (§15).