<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Three.js at 60 fps | Zubin Khavarian</title><description>Every part of Zubin Khavarian&apos;s series &quot;Three.js at 60 fps&quot;, as it publishes.</description><link>https://zubin.dev/</link><language>en-us</language><atom:link href="https://zubin.dev/rss/threejs-60fps.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Mon, 12 Oct 2026 10:00:00 GMT</lastBuildDate><image><url>https://zubin.dev/avatar.png</url><title>Three.js at 60 fps | Zubin Khavarian</title><link>https://zubin.dev/</link></image><item><title>Hundreds of Draw Calls Down to a Handful with BatchedMesh</title><link>https://zubin.dev/blog/batchedmesh-draw-calls/</link><guid isPermaLink="true">https://zubin.dev/blog/batchedmesh-draw-calls/</guid><description>How Typo&apos;s Voyage draws every tile and every 3D letter through four three.js BatchedMeshes, poses a whole treadmill on the GPU with one vec4 a frame, and what breaks when you move instances in the vertex shader.</description><pubDate>Mon, 12 Oct 2026 10:00:00 GMT</pubDate><content:encoded><![CDATA[<p>A level in <a href="https://typos-voyage.edclub.io/" rel="noopener noreferrer" target="_blank">Typo’s Voyage<span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> is a long runway of tiles. Stones, logs, walls, spikes, traps, bridges, boats, cannons and treadmills, and floating above them, a 3D letter for every character the player has to type. A long level has hundreds of pieces on screen at once.</p>
<p>The obvious way to build that in three.js is one <code>THREE.Mesh</code> per tile and one per letter. It works. It also means hundreds of draw calls every frame, and on the school Chromebooks many students play on, that’s where the frame budget goes.</p>
<p>Today every tile and every letter goes through four <code>BatchedMesh</code> objects: one for opaque tiles, one for transparent ones, one for water ripples and one for text.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: One mesh each: a dense row of tick marks, one draw call per tile and per letter, hundreds in total. Batched: four boxes, one each for opaque tiles, transparent tiles, water ripples and letters, each drawn in one call. <a href="https://zubin.dev/blog/batchedmesh-draw-calls/">See the drawing in the post.</a></em></p></div><figcaption>One mesh per tile and per letter, versus four BatchedMeshes carrying all of them.</figcaption></figure>
<p>This post is how that works, and the handful of things that surprised us along the way.</p>
<h2 id="why-hundreds-of-meshes-hurt">Why hundreds of meshes hurt</h2>
<p>A draw call isn’t expensive because the GPU minds drawing a small mesh. It’s expensive because of everything the CPU does around it. Every <code>Object3D</code> in the scene gets its world matrix recomputed, gets frustum-culled, gets sorted and pushed into a render list, and then three.js binds its program, uploads its uniforms and binds its buffers before issuing the draw. Multiply that by several hundred and you’ve spent a good chunk of your 16 milliseconds on bookkeeping before the GPU has done anything interesting.</p>
<p>The fix for “lots of the same object” in three.js has long been <code>InstancedMesh</code>. That’s not quite our situation, though.</p>
<h2 id="batchedmesh-not-instancedmesh">BatchedMesh, not InstancedMesh</h2>
<p><code>InstancedMesh</code> draws one geometry many times. That’s perfect for a forest of identical trees. Our tiles are a stone, then a log, then a wall, then spikes: different shapes, in no particular pattern.</p>
<p><code>BatchedMesh</code>, added to three.js at the end of 2023, is built for exactly that. You give it several geometries, it packs them into one shared vertex and index buffer, and each instance says which of those geometries it is. It’s all drawn with a single material.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Left, InstancedMesh: one geometry, a box, repeated as six copies. Right, BatchedMesh: one buffer holding four different geometries, stone, log, wall and spikes, and a row of seven instances, each pointing at whichever geometry it uses, all drawn with one material. <a href="https://zubin.dev/blog/batchedmesh-draw-calls/">See the drawing in the post.</a></em></p></div><figcaption>InstancedMesh repeats one shape. BatchedMesh packs many shapes into one buffer, and each instance picks which one it is.</figcaption></figure>
<p>The API is small. You size the buffers up front, add each geometry once, then add as many instances as you like:</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>const</span><span> batch</span><span> =</span><span> new</span><span> THREE</span><span>.</span><span>BatchedMesh</span><span>(</span><span>maxInstances</span><span>,</span><span> maxVertices</span><span>,</span><span> maxIndices</span><span>,</span><span> material</span><span>);</span></span>
<span class="line"></span>
<span class="line"><span>const</span><span> stone</span><span> =</span><span> batch</span><span>.</span><span>addGeometry</span><span>(</span><span>stoneGeometry</span><span>);</span></span>
<span class="line"><span>const</span><span> log</span><span> =</span><span> batch</span><span>.</span><span>addGeometry</span><span>(</span><span>logGeometry</span><span>);</span></span>
<span class="line"></span>
<span class="line"><span>const</span><span> id</span><span> =</span><span> batch</span><span>.</span><span>addInstance</span><span>(</span><span>stone</span><span>);</span></span>
<span class="line"><span>batch</span><span>.</span><span>setMatrixAt</span><span>(</span><span>id</span><span>,</span><span> matrix</span><span>);</span></span>
<span class="line"><span>batch</span><span>.</span><span>setColorAt</span><span>(</span><span>id</span><span>,</span><span> color</span><span>);</span></span></code></pre>
<p>Where the browser supports <code>WEBGL_multi_draw</code>, three.js renders the whole batch in one multi-draw call. Where it doesn’t, it falls back to looping over the instances, which is still a big win: there’s one object in the scene graph, one program bind and one set of buffers, instead of hundreds.</p>
<p>Two things we added on top made it pleasant to work with:</p>
<ul>
<li><strong>A geometry registry.</strong> Each tile factory registers its meshes once, by name, and gets back the geometry id. Registering the same name twice returns the existing id instead of adding a duplicate.</li>
<li><strong>Instances as tiny wrappers.</strong> A tile doesn’t own a <code>Mesh</code> or even an <code>Object3D</code>. It owns an instance id, and moving it means one <code>setMatrixAt</code>. No scene graph node, no matrix-world propagation.</li>
</ul>
<p><code>addGeometry</code> also takes optional reserved vertex and index counts. We reserve a bit of headroom for letters, so a glyph can be swapped for its outlined version in place with <code>setGeometryAt</code> instead of reallocating anything.</p>
<h2 id="one-material-every-tile-type">One material, every tile type</h2>
<p>Batching only works if everything in the batch shares a material, but our tiles don’t look alike. Traps flash, fragile tiles crack, water-side tiles get a waterline, and so on.</p>
<p>So there’s one <code>MeshStandardMaterial</code> for all the opaque tiles, extended through <code>onBeforeCompile</code>. Each tile factory contributes its own chunk of GLSL, and the shared shader branches on a per-instance tile type ID. That means one shader program for every tile variant, which also keeps the <a href="https://zubin.dev/blog/shader-stutter-first-draw/">shader warm-up</a> story simple. Roll It takes the same idea further and draws its <a href="https://zubin.dev/blog/outlines-without-an-outline-pass/">ink outlines in the material</a> too, with no extra pass.</p>
<p>The type ID has to come from somewhere per instance, and this is the trick that makes the rest of the post possible. <code>setColorAt</code> doesn’t write into a vertex attribute. It writes into a floating-point RGBA texture with one texel per instance, which the vertex shader reads with the instance’s index. So an instance’s “color” is really four floats of per-instance data, and you’re free to use them for things that aren’t color at all.</p>
<h2 id="posing-a-treadmill-on-the-gpu">Posing a treadmill on the GPU</h2>
<p>The treadmill is where this paid off most. It’s a mini-game tile: a moving belt the character has to run along, built from step boards, chain sections and couplers that loop around the machine. Each slot on the belt is about 15 instances, and a default treadmill has around 257 of them.</p>
<p>Animating that the straightforward way means computing a matrix for every piece on the CPU and calling <code>setMatrixAt</code> about 257 times a frame, per treadmill, and then re-uploading the matrix texture.</p>
<p>Instead, every piece knows where it sits on the loop. Its loop position goes in the instance color’s green channel and the index of the machine it belongs to goes in blue. Both are written once, when the treadmill is built. The vertex shader reads them, reads that machine’s state from a small uniform array, and works out the piece’s position and rotation itself.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Left, the CPU writes one vec4 per treadmill machine each frame. Right, a loop of belt pieces. Each piece stores where it sits on the loop in its instance color's green channel and which machine it belongs to in the blue channel, and the vertex shader works out its position from those plus the machine's uniform. <a href="https://zubin.dev/blog/batchedmesh-draw-calls/">See the drawing in the post.</a></em></p></div><figcaption>The CPU writes one vec4 per machine. The vertex shader poses every belt piece from what&#39;s stored in its instance color.</figcaption></figure>
<p>The per-frame cost of a whole treadmill is now writing one <code>vec4</code> into that uniform array. However long you make the machine, it adds no draw calls, no material and no per-frame matrix writes.</p>
<h2 id="what-breaks-when-the-shader-moves-things">What breaks when the shader moves things</h2>
<p>Moving an instance in the vertex shader works immediately for the color pass. Then three other systems turn out to still believe the instance is sitting at its batching matrix, which for a GPU-posed piece is only its anchor.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Three panels of what breaks when the vertex shader moves an instance. Shadows: the box is drawn at its new position but its shadow stays at the old anchor; fix with a customDepthMaterial built from the same vertex code. Lighting: the box is rotated but lit as if it weren't; fix by rotating the normal too. Culling: the anchor's bounds sit outside the camera's view, so the moved piece inside the view isn't drawn; fix by padding the bounds to everywhere it can move. <a href="https://zubin.dev/blog/batchedmesh-draw-calls/">See the drawing in the post.</a></em></p></div><figcaption>Move an instance in the vertex shader and three other systems still think it&#39;s at its anchor.</figcaption></figure>
<p><strong>Shadows.</strong> Shadow maps are rendered with a separate depth material, which doesn’t include your custom vertex code, so every belt piece casts its shadow from its anchor. The fix is a <code>customDepthMaterial</code> on the batch, built from the same injected vertex chunks as the main material, so shadows come from the posed position.</p>
<p><strong>Lighting.</strong> If the shader rotates a piece but not its normal, it’s lit as if it had never turned. Rotate the normal in the same place, by injecting into the <code>beginnormal_vertex</code> chunk. The depth pass never compiles that chunk, which is fine, since shadows don’t need normals.</p>
<p><strong>Culling.</strong> <code>BatchedMesh</code> culls each instance against its bounding sphere, positioned by its matrix. A piece whose matrix says “anchor” but whose shader says “far end of the belt” gets culled whenever the anchor leaves the screen. We pad each track geometry’s bounds to cover everywhere the shader can move it.</p>
<h2 id="the-costs">The costs</h2>
<p>It isn’t free.</p>
<p><strong>Fixed ceilings.</strong> A <code>BatchedMesh</code> is sized when you create it. Our opaque batch reserves room for 10,000 instances, and that number quietly becomes an authoring limit: the treadmill’s maximum length is a budget calculated against it. Allocate too much and you waste GPU memory; too little and level construction fails.</p>
<p><strong>CPU-posed pieces need care too.</strong> A few instances still have to move on the CPU, like the boards of a fragile tile falling away. If your wrapper exposes position, rotation and scale setters, each one can trigger its own <code>setMatrixAt</code>. Compose the matrix once from reusable scratch objects and write it with a single call.</p>
<p><strong>Off-screen work.</strong> A mini-game far down the runway doesn’t need to animate at all. The mini-game tiles check whether they’re near the character first, and skip their update entirely when they aren’t.</p>
<p><strong>Manual cleanup.</strong> Instances aren’t scene graph objects, so nothing tears them down for you. When a level ends, each tile’s reference to its instance has to be cleared by hand.</p>
<p><strong>Shader glue.</strong> A stock material is easy to reason about. An <code>onBeforeCompile</code> material with contributions from a dozen tile factories, a depth variant and generated GLSL for the treadmill is not. We keep a written note in the codebase explaining where each injection point is and why.</p>
<h2 id="when-id-reach-for-it">When I’d reach for it</h2>
<ul>
<li><strong>Many objects, a few different shapes, one material:</strong> <code>BatchedMesh</code>.</li>
<li><strong>Many copies of exactly one shape:</strong> <code>InstancedMesh</code> is simpler.</li>
<li><strong>A few hundred things moving every frame:</strong> pose them in the vertex shader from a small uniform, and put their static per-instance data in the instance color.</li>
<li><strong>And if you do:</strong> fix shadows, normals and culling bounds at the same time, before you forget which of them you already did.</li>
</ul>
<p>The frame rate stopped depending on how long the level is, and level designers stopped having to think about it. Both of those were the point.</p>]]></content:encoded><dc:creator>Zubin Khavarian</dc:creator><category>threejs</category><category>game-dev</category></item><item><title>Your Shader Stutter Happens at First Draw</title><link>https://zubin.dev/blog/shader-stutter-first-draw/</link><guid isPermaLink="true">https://zubin.dev/blog/shader-stutter-first-draw/</guid><description>A three.js hitch the first time an effect appears is usually a WebGL program link. It blocks at the first draw, not at compile, and a warm-up only fixes it if it links the same program variant your frames use.</description><pubDate>Mon, 28 Sep 2026 15:00:00 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://typos-voyage.edclub.io/" rel="noopener noreferrer" target="_blank">Typo’s Voyage<span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> is a 3D typing game I work on, built on plain three.js with a postprocessing chain. Frame rate was fine. Players still reported a hitch: the first time a mine went off, the first time the character respawned, the first time a fragile tile broke. One frame would hang for a noticeable moment and then everything carried on as if nothing had happened.</p>
<p>That shape, a single long frame the first time something appears, is almost always a WebGL program being linked. The fix is well known: warm your shaders up before the level starts. What’s less well known is that the obvious warm-up can do all of its work and still leave every stutter exactly where it was.</p>
<h2 id="whats-actually-blocking">What’s actually blocking</h2>
<p>three.js creates a GPU program for a material the first time that material is rendered. It compiles the vertex and fragment shaders, then calls <code>gl.linkProgram()</code>.</p>
<p>That call usually doesn’t block. Browsers and drivers generally link in the background, so <code>linkProgram()</code> returns straight away and your frame carries on. The wait happens later, when three.js asks the program for its uniform locations. In three.js that lookup is deferred until the program’s first use, which is the first draw call that uses it. At that point the driver has to finish the link before it can answer, and your frame sits there until it does.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Two lanes over time. On the main thread, frames tick by at a steady width. A new material calls linkProgram, which returns immediately, and the link runs on the driver for several frames. Later, the first frame that draws with that material asks for its uniforms and has to wait for the link to finish, so that one frame is much longer than the rest. <a href="https://zubin.dev/blog/shader-stutter-first-draw/">See the drawing in the post.</a></em></p></div><figcaption>linkProgram() returns straight away. The bill arrives at the first draw that needs the program&#39;s uniforms.</figcaption></figure>
<p>So the stall isn’t at material creation, and it isn’t at <code>new THREE.Mesh()</code>. It lands on whichever frame first draws with that program. For an effect that only appears mid-level, that’s mid-level.</p>
<h2 id="why-you-cant-reproduce-it">Why you can’t reproduce it</h2>
<p>Chrome caches linked program binaries on disk, keyed by the shader source and the driver. The first visit pays the full link; a reload usually doesn’t. That’s why the hitch shows up for players and vanishes the moment you go looking for it.</p>
<p>Test on a fresh browser profile, and again after any shader change, because an edited shader is a new cache entry.</p>
<h2 id="the-fix-link-everything-before-the-level-starts">The fix: link everything before the level starts</h2>
<p>three.js has <code>renderer.compile(scene, camera)</code> for exactly this. It walks the scene, works out which program every material will need, and creates them all up front. Do it once the level is built, behind a loading screen or a transition, and the links land before anyone is playing.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>// after every mesh, light and environment map is in place</span></span>
<span class="line"><span>renderer</span><span>.</span><span>compile</span><span>(</span><span>scene</span><span>,</span><span> camera</span><span>);</span></span></code></pre>
<p>Two details make this more useful than it looks. <code>compile()</code> walks the scene with a plain traverse, so objects with <code>visible = false</code> are included; a hidden hammer or a parked enemy gets warmed along with everything else. And programs are shared by signature, so several materials that differ only in their colors end up on one program. Whichever of them draws first pays for the link, which is a good argument for warming the whole scene rather than guessing which feature to pre-warm.</p>
<p>It needs to run late, though. Lighting and <code>scene.environment</code> feed into the program too, so if you compile before they’re final, you warm programs your frames will never ask for.</p>
<h2 id="the-catch-the-color-space-variant">The catch: the color-space variant</h2>
<p>This is the part that cost me the most time.</p>
<p>A three.js program’s cache key includes its output color space, and that value depends on where you’re drawing. With no render target bound, three.js uses <code>renderer.outputColorSpace</code>, which is sRGB, because the result is going to the screen. With a render target bound, it uses the linear working color space, because something downstream (your post-processing) will do the final conversion.</p>
<p>If you render through an effect composer, your scene never draws straight to the canvas. Every material draws into the composer’s render target, so every program your frames use is the linear variant.</p>
<p>Now look at the warm-up. Call <code>renderer.compile()</code> with nothing bound and three.js dutifully links the sRGB variant of every material. Nothing ever draws with those. Then the first real frame asks for the linear variant, doesn’t find it, and links it right there, mid-level, exactly as before.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Three rows. Compiling with nothing bound links a standard material with sRGB output, which is never drawn. A game frame that renders into the composer's render target needs the same material with linear output, at its first draw. Compiling with the composer's target bound links that linear variant, so the key matches and it is already linked. <a href="https://zubin.dev/blog/shader-stutter-first-draw/">See the drawing in the post.</a></em></p></div><figcaption>Same material, two programs. Warm up with the render target your frames actually draw into.</figcaption></figure>
<p>The warm-up did real work, doubled your program count, and moved the stutter precisely nowhere. It also fails silently, which is the dangerous part: a warm-up that links the wrong variant looks identical to one that works.</p>
<p>The fix is to bind the target your frames actually draw into while you compile:</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>function</span><span> warmUpShaderPrograms</span><span>(</span><span>renderer</span><span>,</span><span> scene</span><span>,</span><span> camera</span><span>,</span><span> target</span><span>)</span><span> {</span></span>
<span class="line"><span>  const</span><span> previous</span><span> =</span><span> renderer</span><span>.</span><span>getRenderTarget</span><span>();</span></span>
<span class="line"><span>  renderer</span><span>.</span><span>setRenderTarget</span><span>(</span><span>target</span><span>);</span><span> // the composer&#39;s input buffer</span></span>
<span class="line"><span>  renderer</span><span>.</span><span>compile</span><span>(</span><span>scene</span><span>,</span><span> camera</span><span>);</span></span>
<span class="line"><span>  renderer</span><span>.</span><span>setRenderTarget</span><span>(</span><span>previous</span><span>);</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>In Voyage the target is the buffer the first render pass draws into. Any render target works, since the key only cares whether one is bound, but using the real one keeps the intent obvious.</p>
<h2 id="programs-that-come-and-go">Programs that come and go</h2>
<p>The warm-up fixed the first-time stutters, but a few effects still hitched every single time, not just the first.</p>
<p>These were effects that build a fresh material per event (a respawn burst, a water splash, a mine blast) and dispose it when the effect ends. three.js reference-counts programs, and when the last material using a program is disposed, the program is destroyed. So every mine blast created its material, linked its program, drew, disposed, and threw the program away again. Chrome’s binary cache makes those repeat links cheaper, but the first draw still waits.</p>
<p><code>compile()</code> can’t help either, because it only sees what’s in the scene when you call it, and these materials don’t exist yet.</p>
<p>The fix was a keep-alive: one hidden instance of each effect, parked in the scene for the whole level and never drawn. It holds the program’s reference count above zero, so the program is never destroyed, and because <code>compile()</code> ignores <code>visible</code>, the warm-up links it at load along with everything else. For it to work, the parked copy has to produce the same program key as a real one. That meant passing things like a waterline height as a uniform rather than baking it into the shader source, and pinning an explicit <code>customProgramCacheKey</code> on the one material that had its own.</p>
<p>One thing that doesn’t work: creating the material in a constructor and throwing it away. A material only acquires a program by being rendered or compiled as part of a scene. An orphaned material warms nothing.</p>
<h2 id="checking-it-without-a-profiler">Checking it without a profiler</h2>
<p>You don’t need a performance trace to see whether this worked. <code>renderer.info.programs</code> lists every program three.js currently holds, and its length makes a very honest counter. Put it on a debug overlay and watch it through a level; <a href="https://zubin.dev/open-source/">three-kit</a>’s three-meter HUD keeps draw calls, hitches and frame time on screen beside it.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>const</span><span> linked</span><span> =</span><span> renderer</span><span>.</span><span>info</span><span>.</span><span>programs</span><span>?.</span><span>length </span><span>??</span><span> 0</span><span>;</span></span></code></pre>
<p>It should climb during loading, settle, and then not move again. Every time it goes up during play, a program just linked mid-game; if it goes up and then back down, something is creating and destroying the same program per event. In Voyage it settled at 38 and blipped to 39 on every respawn, fragile tile and mine hit, until the keep-alives went in.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Chart of the linked program count over one level, with the axis starting at 30. During loading, both lines rise. Without keep-alives, the count settles at 38 and jumps to 39 and back each time a respawn, a fragile tile or a mine hit draws its effect. With keep-alives, the count settles a little higher at the end of loading and stays flat for the whole level. <a href="https://zubin.dev/blog/shader-stutter-first-draw/">See the drawing in the post.</a></em></p></div><figcaption>renderer.info.programs.length across a level. Every blip on the amber line is a program linking mid-game.</figcaption></figure>
<p>It also catches the color-space mistake. Compare the settled count against a build without the warm-up: it should be about the same total, just reached earlier. If it’s roughly double, both variants are being linked and the render-target bind has gone missing.</p>
<h2 id="compile-or-compileasync">compile or compileAsync?</h2>
<p>three.js also has <code>renderer.compileAsync()</code>, which uses the <code>KHR_parallel_shader_compile</code> extension and resolves when every program reports it’s ready. It’s the right choice when something has to wait on the result, like holding a loading screen until shaders are done.</p>
<p>I used the synchronous <code>compile()</code>. Nothing in Voyage needed to know when the links finished, since the level reveal already runs for most of a second, which is plenty of time for them to land. <code>compileAsync()</code> also works by polling every material’s program on a 10ms timer, and if you tear the scene down mid-poll (say, the player skips straight to the next lesson) the poll can find a disposed material with no program and throw from inside a timer callback, leaving the promise hanging. If you use it, make sure teardown can’t race it.</p>
<h2 id="what-a-warm-up-doesnt-cover">What a warm-up doesn’t cover</h2>
<p><code>compile()</code> warms the materials in your scene graph and nothing else.</p>
<ul>
<li><strong>Shadow depth materials</strong> aren’t prepared, so a new shadow caster can still link its depth program at first draw. In Voyage every shadow caster is on screen from the first frame, so it never mattered.</li>
<li><strong>Post-processing passes</strong> have their own materials outside the scene. If you enable a pass mid-game, it links when it first runs.</li>
<li><strong>Lights</strong> are only collected if they’re visible, and the light setup is part of the program key. Add or toggle lights after the warm-up and you can invalidate what you warmed.</li>
</ul>
<h2 id="what-id-do-on-any-threejs-project">What I’d do on any three.js project</h2>
<ul>
<li>Call <code>renderer.compile(scene, camera)</code> once the scene is fully built, with lighting and environment final.</li>
<li>Bind the render target your frames draw into while you do it.</li>
<li>Keep one parked instance of anything that creates materials per event.</li>
<li>Watch <code>renderer.info.programs.length</code> through a real play session, on a fresh browser profile.</li>
</ul>
<p>The frame rate was never the problem. The problem was a handful of single frames, each one paying for a link at the worst possible moment. Move those links behind the loading screen, make sure they’re the variants your frames actually use, and the level plays the way it did in your head.</p>]]></content:encoded><dc:creator>Zubin Khavarian</dc:creator><category>threejs</category><category>game-dev</category></item></channel></rss>