<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tiger's Place]]></title><description><![CDATA[Tiger's Place]]></description><link>https://tigerabrodi.blog</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1662616452555/PLNLd_3Ma.png</url><title>Tiger&apos;s Place</title><link>https://tigerabrodi.blog</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 18 Apr 2026 09:44:38 GMT</lastBuildDate><atom:link href="https://tigerabrodi.blog/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What is WebGPU and Why It's Huge.]]></title><description><![CDATA[So What Is A GPU In The First Place
A GPU is a second processor with its own memory, called VRAM. Your data has to live in VRAM before the GPU can use it. Getting it there costs time.
A CPU has a few ]]></description><link>https://tigerabrodi.blog/what-is-webgpu-and-why-it-s-huge</link><guid isPermaLink="true">https://tigerabrodi.blog/what-is-webgpu-and-why-it-s-huge</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Fri, 17 Apr 2026 23:51:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/8d3b44a3-7bee-484e-aa9d-b1713f7c255f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>So What Is A GPU In The First Place</h1>
<p>A GPU is a second processor with its own memory, called VRAM. Your data has to live in VRAM before the GPU can use it. Getting it there costs time.</p>
<p>A CPU has a few smart cores. A GPU has thousands of simple ones, and they work in lockstep. Lockstep means they move together. Cores are grouped in sets of 64 to 128, and every core in a group runs the same line of code at the same tick. They only differ in the data they work on.</p>
<p>Here is the part to remember. If your shader has an if statement and half the cores take one path and half take the other, the group runs both paths one after the other. Double the time, same result. Splitting paths is slow. Doing the same work across all cores is fast.</p>
<p>Memory matters too. Each group has a small pool of fast memory right next to it. VRAM is the big pool but it is far away and slow. How you read memory decides how fast your shader runs.</p>
<h2><strong>Fixed Hardware</strong></h2>
<p>Some parts of the GPU are not programmable. They are wired in. They just run when you draw. Three worth knowing.</p>
<p><strong>Rasterizer:</strong> Turns triangles into pixels.</p>
<p><strong>Texture units:</strong> Read images from memory.</p>
<p><strong>ROPs:</strong> Write the final pixel to the screen. They also handle depth checks and blending for see-through things.</p>
<p>The catch is overdraw. If you stack lots of see-through things on top of each other, like smoke or particles, every layer goes through the ROPs. Read, mix, write back. This can slow your game down even when your shaders are simple.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/89ce134e-f092-4102-94e3-7de791c9612b.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>How Games Actually Use The GPU</h1>
<p>Every frame, work flows through the GPU in stages. Five of them matter.</p>
<p>Before anything starts, the CPU hands the GPU a list of triangles. Often millions per frame. Each triangle has three corners, called vertices. Each vertex carries some data. Position in 3D space. Color. UV, which is where to read from a texture. A normal, which is the direction the surface faces.</p>
<p>Now the pipeline runs.</p>
<p><strong>Stage 1. Vertex shader.</strong> A small program you write. It runs once per vertex, in parallel across the GPU's cores. Its job is to take the vertex from the model's own coordinate space and move it into screen space. The vertex knows where it sits inside the model. The vertex shader figures out where it sits on your screen.</p>
<p><strong>Stage 2. Rasterizer.</strong> Fixed hardware. Takes a triangle. Figures out which pixels on the screen are inside it. One triangle in. Many pixels out.</p>
<p><strong>Stage 3. Fragment shader.</strong> Another small program you write. It runs once per pixel. It reads textures, applies lighting, factors in shadows, and picks the final color for that pixel.</p>
<p><strong>Stage 4. Depth test.</strong> Is this pixel closer to the camera than whatever is already there? If yes, keep it. If no, throw it away. This is how the GPU knows a wall hides what is behind it.</p>
<p><strong>Stage 5. ROPs.</strong> Write the final pixel to the framebuffer, which is the image that becomes your screen. Mix with the existing pixel if needed, for things like glass or smoke.</p>
<p>Millions of vertices and pixels flowing through thousands of cores, every frame.</p>
<p>For the pipeline to keep running, the GPU needs to be told what to draw. That is where the trouble starts.</p>
<h1>The Draw Call Problem</h1>
<p>The GPU cannot draw anything on its own. The CPU has to tell it what to do. "Use this shader. Use this texture. Draw 1200 triangles." That instruction is called a draw call.</p>
<p>Each draw call takes the CPU a tiny bit of time. Just a few microseconds. Sounds like nothing.</p>
<p>Now draw 5000 different objects. That is 5000 draw calls. Suddenly the CPU has spent 5 to 15 milliseconds just talking to the GPU. Your whole frame budget at 60 fps is 16 milliseconds. You blew it before drawing anything.</p>
<p>This is why the CPU is usually the bottleneck in games with lots of objects, not the GPU. The GPU sits there waiting. The CPU cannot send instructions fast enough.</p>
<p>Almost every big trick in game engines, going back decades, <em>is about sending fewer draw calls, or making each one do more work.</em></p>
<h1>Instancing. The First Big Trick.</h1>
<p>You often draw the same mesh many times. A forest with thousands of trees all from the same model. An army with hundreds of soldiers from the same character. A particle system with ten thousand identical quads.</p>
<p>Instancing lets you draw the same mesh many times in one draw call. You upload the mesh once. You upload a second buffer of per instance data (positions, rotations, colors, scales).</p>
<p>You tell the GPU "draw this mesh 10 000 times, here is the list." The vertex shader reads its instance index and pulls the right data from the buffer.</p>
<p><strong>One draw call instead of ten thousand. The CPU gets its time back. The GPU runs at full speed.</strong></p>
<p>When you hear people say GPU instancing, this is what they're talking about.</p>
<h1>Compute Shaders. GPU As General Parallel Processor.</h1>
<p>A compute shader is a program that runs on the GPU but is not tied to the rendering pipeline. It does not care about triangles. It does not output pixels. It just reads and writes arbitrary buffers in parallel across thousands of threads.</p>
<p>You dispatch a grid of threads. Thousands at a time. Each thread has an index. Each thread runs the same program on different data.</p>
<p>This turned the GPU into a general purpose parallel processor. Physics simulation. Particle updates. Image processing. Neural networks. Cloth. Water. Fluid dynamics. Audio effects. Anything that fits "do the same thing to a lot of data" now lives on the GPU.</p>
<h1>Indirect Draws. The GPU Drives Its Own Work.</h1>
<p>One more trick that mattered.</p>
<p>When you draw things on screen, the CPU normally tells the GPU what to draw and how many. Something like "draw 1000 trees." That number, 1000, is baked into the instruction before the GPU ever sees it.</p>
<p>Indirect draws flip this. The CPU says "draw however many trees this buffer tells you to." The actual count lives in GPU memory. The CPU does not know it. The CPU does not care.</p>
<p>Now here is where it clicks. A compute shader can write to that buffer. So a compute shader can decide the count, and the draw call just reads whatever the compute shader wrote. The CPU is cut out of the loop.</p>
<p>This unlocks real things.</p>
<p>A compute pass can cull objects hidden behind walls, then write the count of visible ones. It can count how many grass blades are close enough to matter. It can pick LOD levels. It can kill dead particles and report how many are still alive. The draw call consumes whatever number comes out. No CPU round trip. No waiting.</p>
<p>The GPU is driving its own work. It's so fucking smart. This took me a second to understand. Compute shaders are fucking cool, but with this, it's really sick.</p>
<p>To make it click for you: <strong>The compute shader runs on the GPU. It writes a number into a buffer that lives in GPU memory. Say the number is 347. That buffer never leaves the GPU. Then the draw call happens. The CPU sent this draw call earlier, but the draw call is basically a note that says "hey GPU, when you get to this, look at buffer X and draw that many things."</strong></p>
<p>The GPU reaches that instruction. The GPU itself reads buffer X. Sees 347. Draws 347 things.</p>
<h1>WebGL days</h1>
<p>The web got WebGL in 2011, based on an older mobile graphics standard from the mid 2000s. It could run vertex and fragment shaders, do basic instancing, and render textures. It was enough to put 3D on the web for the first time.</p>
<p>But it was missing the big stuff. No compute shaders. No indirect draws. No flexible storage buffers.</p>
<p>So if you wanted 10000 animated grass blades, the wind math had to run in JavaScript on the CPU. Update every blade. Upload the buffer to the GPU. Draw. The CPU was doing work the GPU should have been doing.</p>
<h1>What WebGPU Actually Changes</h1>
<p>WebGPU is a new browser API that exposes modern GPU capabilities.</p>
<p>Real compute shaders. Arbitrary GPU programs that read and write buffers. Physics, AI, particles, image effects, all of it.</p>
<p>Storage buffers. Generic read write GPU memory. You lay data out however you want.</p>
<p>Indirect draws. The GPU decides what gets rendered.</p>
<h1>Where This Shines</h1>
<p>Instancing scaled up. A million grass blades drawn in one call, with per blade position, height, wind sample, and color all generated in a compute pass.</p>
<p>Particle systems. Fire, smoke, sparks, magic. Every particle advanced in parallel on the GPU. Tens of thousands of particles at 144 fps with the CPU doing nothing per frame.</p>
<p>Simulation. Cloth, water, fluid, flocking, crowds. Each element updated in a compute pass. The browser can do what Unity does.</p>
<p>Terrain and worlds. Streaming LOD, procedural detail, real game scenes. Not the scaled down browser version. The native version, running in a tab.</p>
]]></content:encoded></item><item><title><![CDATA[CPU Friendly JavaScript. A Visual Guide.]]></title><description><![CDATA[How A CPU Actually Runs Code
A CPU core has a clock. It ticks about 3 to 4 billion times per second. Each tick, the core tries to do one unit of work. That is where your frames come from.
Inside one c]]></description><link>https://tigerabrodi.blog/cpu-friendly-javascript-a-visual-guide</link><guid isPermaLink="true">https://tigerabrodi.blog/cpu-friendly-javascript-a-visual-guide</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Fri, 17 Apr 2026 21:35:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/b705ab74-2abe-4f0e-b3f2-0ab59723bd93.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How A CPU Actually Runs Code</h1>
<p>A CPU core has a clock. It ticks about 3 to 4 billion times per second. Each tick, the core tries to do one unit of work. That is where your frames come from.</p>
<p>Inside one core there are four parts that matter. The Control Unit reads the next instruction. The ALU (Arithmetic Logic Unit) is the calculator that does the math. Registers are tiny slots right next to the ALU holding the numbers being worked on. Caches are fast memory nearby that feed the registers.</p>
<p>All math happens in the ALU. The ALU only touches registers. So every number has to travel from RAM through the caches into a register and out. The math is fast. The travel is slow.</p>
<p>When your data is not in the cache at the moment the ALU needs it, the core stalls. A stalled core still ticks. It just does not do anything on those ticks. Wasted work. That is where most slow code actually loses.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/39b8af03-7ee8-4bca-bed0-346c096eb740.png" alt="" style="display:block;margin:0 auto" />

<h1>The Memory Hierarchy</h1>
<p>Modern CPUs can run about a hundred math operations in the time it takes to fetch one value from RAM. So the thing that makes code fast is not the math. It is keeping your data close to the ALU.</p>
<p>Caches work in chunks called cache lines, usually 64 bytes. When you touch one byte, the CPU pulls 64 bytes around it into the cache. Touch the next 63 bytes right after. Free.</p>
<p>Touch memory randomly across the heap and every access is a new cache miss. Every miss is tens of wasted ticks while the ALU waits.</p>
<p>Sequential access is fast. Random access is slow. Most of this post is about making your JavaScript sequential.</p>
<h1>SIMD. One Instruction, Many Numbers.</h1>
<p>Your CPU has special instructions called SIMD, Single Instruction Multiple Data. They do the same math on a chunk of numbers in a single tick. Usually 4 or 8 at a time on a mainstream CPU.</p>
<p>Adding two arrays of 8 floats without SIMD takes 8 ticks, one pair per tick. The same work with SIMD takes 1 tick for all 8.</p>
<p>A lane is one parallel slot inside a SIMD register. A modern CPU can pack 8 floats side by side in one register. Each float slot is a lane. One SIMD add touches all 8 lanes at once. Scalar JS math uses only lane 0. The other 7 are physically there but sitting idle.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a596125d-3672-497a-b72c-46b7ae850496.png" alt="" style="display:block;margin:0 auto" />

<p>Same CPU. Same clock. Same silicon. The SIMD lanes are there either way. Without SIMD you leave 7 of the 8 lanes idle. You pay for the whole chip and use one eighth of it.</p>
<p><strong>JavaScript does not expose SIMD directly. Modern engines auto vectorize tight loops over typed arrays sometimes.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/d3895060-2f84-4472-a514-3bc3c2772004.png" alt="" style="display:block;margin-left:auto" />

<h1>Branch Prediction</h1>
<p>Modern CPUs run a pipeline. The next instruction starts before the previous one finishes. Several are always in flight at the same time.</p>
<p>The problem comes with if statements. The CPU does not know which branch to take until the condition is evaluated. Rather than stall, it guesses. This is branch prediction. Guess right, free. Guess wrong, the CPU throws away the work it did on the wrong path and restarts. A mispredict costs around 15 ticks.</p>
<p>A condition that takes the same path almost every time is basically free. The predictor learns it. A condition that flips 50/50 every iteration forces a mispredict half the time and turns a tight loop into a stumble.</p>
<p>Fix it by removing data dependent branches from inner loops, or by sorting your data so the branches become predictable.</p>
<h1>The Big Trick. Structure of Arrays.</h1>
<p>This is the one that matters most. Every serious JS game engine, physics library, and particle system does it. Most regular JS does not. The gap in speed is an order of magnitude.</p>
<p>You have an array of objects. Each object has many fields. You loop over them doing math on a few fields.</p>
<pre><code class="language-js">type Particle = { x, y, vx, vy, color, age, life }
const particles: Array&lt;Particle&gt; = [...]

for (let i = 0; i &lt; particles.length; i++) {
  particles[i].x += particles[i].vx
  particles[i].y += particles[i].vy
}
</code></pre>
<p>This looks clean. It is also cache hostile. Each particle is a scattered object on the JS heap. When you read <code>particles[i].x</code>, the cache line you get contains x, y, vx, vy, color, age, life, and chunks of V8 metadata. You use two of those. The other 60 bytes of the cache line are dead weight for this loop.</p>
<p>Flip the layout. Same data, different memory arrangement.</p>
<pre><code class="language-js">const x = new Float32Array(count)
const y = new Float32Array(count)
const vx = new Float32Array(count)
const vy = new Float32Array(count)

for (let i = 0; i &lt; count; i++) {
  x[i] += vx[i]
  y[i] += vy[i]
}
</code></pre>
<p>Now every cache line is full of numbers you actually read. No metadata. No waste. You blaze through memory in order and the prefetcher pulls the next 64 bytes before you need them.</p>
<p>This pattern has a name. Structure of Arrays, or SoA. The opposite is Array of Structures, AoS. AoS is how most JavaScript is written. SoA is how fast JavaScript is written.</p>
<p>Every serious engine does this. Unity DOTS and ECS. Bevy. Unreal Mass. Box2D and Rapier physics. Three.js instanced meshes. They all store components in parallel typed arrays under the hood.</p>
<p>The Entity Component System pattern game engines ship with is largely a way to enforce SoA automatically. You write code that looks like "for every entity with a Position and Velocity," and the engine guarantees those components live in parallel arrays you never see. You think in terms of "the data I read together should live together," and the engine does the layout for you.</p>
<p><strong>When people say data oriented design, this is what they mean.</strong></p>
<h1>JavaScript Specifics</h1>
<p>Two things V8 cares about on top of the CPU rules.</p>
<p>Typed arrays hold raw numbers. A regular Array stores values as tagged pointers. Every element could be anything, so every read checks the type first. A Float32Array or Int32Array is a raw packed block of numbers. No type checks. Cache friendly. Use them for every hot numeric buffer.</p>
<p>Stable object shapes. V8 watches every object. <a href="https://tigerabrodi.blog/what-actually-happens-when-you-run-javascript">If you always set the same fields in the same order at construction, V8 assigns a fast hidden class and field access becomes a direct memory read.</a> Add fields later or delete them and you drop to the slow path.</p>
<pre><code class="language-js">// slow. Shape changes.
const p = { x: 0, y: 0 }
p.vx = 0
p.vy = 0

// fast. Shape is final at birth.
const p = { x: 0, y: 0, vx: 0, vy: 0 }
</code></pre>
<p>For hot code, combine these two. SoA layout, typed arrays for the numeric columns.</p>
<h1>Before And After</h1>
<p>Add two vectors of 100 000 3D positions per frame.</p>
<p>Naive AoS version.</p>
<pre><code class="language-js">type Vec3 = { x: number, y: number, z: number }
const a: Array&lt;Vec3&gt; = [...]
const b: Array&lt;Vec3&gt; = [...]
const out: Array&lt;Vec3&gt; = a.map(() =&gt; ({ x: 0, y: 0, z: 0 }))

for (let i = 0; i &lt; a.length; i++) {
  out[i].x = a[i].x + b[i].x
  out[i].y = a[i].y + b[i].y
  out[i].z = a[i].z + b[i].z
}
</code></pre>
<p>300 000 object allocations. Random cache misses every iteration. 8 to 15 ms per frame on a modern laptop.</p>
<p>SoA version with typed arrays.</p>
<pre><code class="language-js">const ax = new Float32Array(100_000)
const ay = new Float32Array(100_000)
const az = new Float32Array(100_000)
const bx = new Float32Array(100_000)
const by = new Float32Array(100_000)
const bz = new Float32Array(100_000)
const ox = new Float32Array(100_000)
const oy = new Float32Array(100_000)
const oz = new Float32Array(100_000)

for (let i = 0; i &lt; 100_000; i++) {
  ox[i] = ax[i] + bx[i]
  oy[i] = ay[i] + by[i]
  oz[i] = az[i] + bz[i]
}
</code></pre>
<p>Zero allocation in the hot loop. Nine sequential reads and three sequential writes. 0.3 to 0.8 ms. More than ten times faster for the same math.</p>
]]></content:encoded></item><item><title><![CDATA[Particle systems in games with threejs and tricks to make them look good]]></title><description><![CDATA[Particles Are Just Textured Quads
Particle systems drive fire, smoke, sparks, rain, dust, magic. They look complex. They are not. A particle system is a flat image drawn over and over with small varia]]></description><link>https://tigerabrodi.blog/particle-systems-in-games-with-threejs-and-tricks-to-make-them-look-good</link><guid isPermaLink="true">https://tigerabrodi.blog/particle-systems-in-games-with-threejs-and-tricks-to-make-them-look-good</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Fri, 17 Apr 2026 12:16:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/3b555ebb-18c5-48f4-85f9-0b4787d284d5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Particles Are Just Textured Quads</h1>
<p>Particle systems drive fire, smoke, sparks, rain, dust, magic. They look complex. They are not. A particle system is a flat image drawn over and over with small variations. That is it.</p>
<h1>What A Particle Actually Is</h1>
<p>A particle is a <strong>quad</strong>. Quad means a rectangle made of two triangles.</p>
<p>The quad has a texture on it. Usually small. Usually with transparent edges.</p>
<p>The quad always faces the camera. So when you move the camera around, the quad rotates to face you. This is called a <strong>billboard</strong>. Like a highway billboard that turns to stay readable from the road.</p>
<p>That is the whole thing. A particle is one textured billboard.</p>
<p>Now imagine 10 000 of them. Different positions. Different sizes. Different colors. Some fading out. Some rotating. That is a particle system.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/f7903c19-d8e5-4608-8c1d-0f33efa200c1.png" alt="" style="display:block;margin:0 auto" />

<h1>What A Particle System Is</h1>
<p>A particle system does three jobs. Emit. Simulate. Render.</p>
<p>Three jobs. That is the whole system.</p>
<h2>Emit</h2>
<p>You pick a <strong>shape</strong> for the emitter. Point. Box. Sphere. Cone. Mesh surface. New particles spawn from that shape.</p>
<p>You pick a <strong>rate</strong>. 100 per second. Bursts of 50 at a time. Whatever fits the effect.</p>
<p>Initial state is usually random within bounds. Velocity random in a cone. Size random in a range. Color maybe random. Life random.</p>
<p>Random with bounds is what makes particles look organic instead of robotic. Pure random is noise. Pure fixed values are robotic. The middle is life.</p>
<h2>Simulate</h2>
<p>Every frame you loop over every living particle. You update.</p>
<ul>
<li><p><strong>Position</strong>. Add velocity times deltaTime. DeltaTime means the time since the last frame, so your particle moves the same distance regardless of frame rate.</p>
</li>
<li><p><strong>Velocity</strong>. Apply gravity, drag, wind, any force you want.</p>
</li>
<li><p><strong>Age</strong>. Increase by deltaTime.</p>
</li>
<li><p>If age exceeds lifetime, kill the particle.</p>
</li>
<li><p><strong>Attributes over lifetime</strong>. Size, color, opacity often follow a curve across the particle's life.</p>
</li>
</ul>
<p>That last part is where the magic lives. A fire particle is yellow at birth. Red in the middle. Black smoke at death. Driven by a curve with three key points.</p>
<h2>Render</h2>
<p>Each living particle becomes a quad. Each quad gets the texture. Each quad gets tinted by the particle's color. Each quad scales to the particle's size.</p>
<p>Draw them all. Move on to the next frame.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/125bbf84-d668-4a15-9c5a-9e27bf557823.png" alt="" style="display:block;margin:0 auto" />

<h1>Blend Modes. The Most Important Choice</h1>
<p>A particle is transparent where its texture is transparent. How the transparent parts combine with what is behind them matters a lot. This is the <strong>blend mode</strong>.</p>
<p>Two blend modes matter for games. Additive and alpha.</p>
<h2>Additive Blending</h2>
<p><strong>Additive</strong> means the particle's color is added to whatever is behind it. The math is <code>final = src + dst</code>. Source is the particle color. Destination is what is already there.</p>
<p>Black plus anything is that anything. So black reads as fully transparent with no alpha channel needed.</p>
<p>Bright colors stacked on bright colors push toward white. Fire looks like fire because the overlaps get brighter, not darker.</p>
<p>Use additive for fire. Sparks. Magic. Lightning. Lasers. Anything that emits light.</p>
<h2>Alpha Blending</h2>
<p><strong>Alpha</strong> means the particle has an alpha channel. Alpha is a fourth value per pixel that says how opaque that pixel is. 0 is fully transparent. 1 is fully opaque.</p>
<p>The math is <code>final = src.rgb * src.a + dst.rgb * (1 - src.a)</code>. Mix between particle color and scene color, weighted by alpha.</p>
<p>Alpha blended particles can be any color including black. Overlaps look like overlaps. No brightness push.</p>
<p>Use alpha for smoke. Fog. Dust. Clouds. Rain. Anything solid-ish that does not emit light.</p>
<h2>Why This Matters</h2>
<p>Pick the wrong blend mode and your fire looks like grey paper. Your smoke looks like fire. Particle feel lives in the blend mode.</p>
<h1>The Sorting Problem</h1>
<p>Alpha blended particles have a painful trick.</p>
<p>If you draw a near particle first, then a far particle behind it, the far one either fails the depth test and disappears, or draws on top incorrectly because it writes wrong depth. Either way looks broken.</p>
<p>Solution. Sort particles by distance to the camera every frame. Draw furthest first. Nearest last. This is called <strong>back to front sorting</strong>.</p>
<p>Additive particles do not need sorting. The math is commutative. <code>a + b + c = c + b + a</code>. Order does not change the pile of brightness.</p>
<p>In a game with tens of thousands of alpha particles the sort has to happen every frame. On the CPU that chokes above a few thousand particles. On the GPU you use a <strong>bitonic sort</strong>, a parallel sort algorithm that fits GPU threads well, and the cost stays flat.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a412ebe1-152a-4bfb-bd11-7b33d03d2623.png" alt="" style="display:block;margin:0 auto" />

<h1>The Intersection Problem</h1>
<p>Here is where particle systems win or lose.</p>
<p>A particle quad moves through the world. Sometimes the quad passes through solid geometry. A smoke quad crosses the floor. A fire quad crosses a wall.</p>
<p>The depth test of the GPU cuts the quad at the intersection. You see a hard straight line where the quad meets the surface. Looks like a flat sticker pressed into the wall. Reveals that the quad is flat. Kills the effect.</p>
<p>This is the single biggest tell that particles are fake.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/996e3439-7cd7-4ef6-86dd-74b9de61f4a2.png" alt="" style="display:block;margin:0 auto" />

<h1>Soft Particles. The Fix.</h1>
<p><strong>Soft particles</strong> solve the intersection problem. You fade the particle's alpha toward zero as it gets close to the geometry behind it. No more hard cut. Smooth fade instead.</p>
<p>The illusion. A particle cannot pass through a wall anymore because it fades out before it touches the wall.</p>
<h2>How It Works</h2>
<p>In the particle's fragment shader you need two values.</p>
<ol>
<li><p><strong>Particle depth</strong>. The distance from the camera to this particle pixel.</p>
</li>
<li><p><strong>Scene depth</strong>. The distance from the camera to whatever solid geometry is behind this same pixel, read from the depth buffer.</p>
</li>
</ol>
<p>Subtract the two. The result is how far the particle sits in front of the wall.</p>
<p>If the distance is big, keep full alpha. If the distance is small, fade the alpha toward zero. A <strong>smoothstep</strong> function handles the curve. Smoothstep is a function that smoothly ramps from 0 to 1 between two edges with an S shape, no sharp transition.</p>
<pre><code class="language-plaintext">delta = sceneDepth - particleDepth
softAlpha = smoothstep(0, falloffRange, delta)
finalAlpha = particle.alpha * softAlpha
</code></pre>
<p><code>falloffRange</code> is a tunable number. Small values mean the fade happens right at the wall. Big values mean particles fade out even when kind of far from the wall. You tune per effect.</p>
<h2>The Depth Buffer Catch</h2>
<p>Reading from the depth buffer does not give you real world distance. It gives you a non-linear value between 0 and 1 optimized for precision near the camera. You have to <strong>linearize</strong> it. Convert back to actual view space distance using the camera's near and far planes.</p>
<p>The formula is algebra. Not scary. Three.js has a helper, in raw WebGPU you do it yourself.</p>
<p>Once you have linear depth, the math above just works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/0453c791-6f96-420e-a9c9-5c867d295e0d.png" alt="" style="display:block;margin:0 auto" />

<h1>Particles In Three.js</h1>
<p>Three.js gives you two paths.</p>
<h2>THREE.Points</h2>
<p>A built in. Each particle is a <strong>point primitive</strong>, a GPU feature where one vertex becomes a square sprite in screen space automatically.</p>
<p>Fast. Simple. No quad geometry to manage. You set positions in a buffer and Three.js draws them.</p>
<p>The downsides. Size is in pixel units, not world units, which is awkward for 3D effects that need to feel scaled. You get one size per particle. And you cannot use arbitrary geometry, only square sprites.</p>
<h2>Instanced Quad Meshes</h2>
<p>You build a quad geometry once. You instance it, drawing the same quad thousands of times with per instance data for position, size, color, rotation. Each instance is a real quad in world space.</p>
<p>More work to set up. More control. Works for arbitrary shapes, ribbons, trails, mesh particles. Better for real VFX systems.</p>
<h2>WebGPU Changes Everything</h2>
<p>Traditional Three.js particle code was CPU driven. JavaScript looped over every particle every frame, updated its position, uploaded fresh data to the GPU. Fine below 1000 particles. Painful above 2000. Dead above 5000.</p>
<p><strong>WebGPU compute shaders</strong> change the story. Compute shaders are GPU programs that run in parallel over buffers of data. Move the particle simulation into a compute shader and JavaScript never touches per particle state again. The GPU advances 100 000 particles each frame in one dispatch with no perf drop.</p>
<p>The bottleneck shifts from simulation to sorting. And sorting moves to a bitonic compute pass too.</p>
<p>This is the path a real WebGPU VFX library takes. Compute shader simulation. Instanced quad rendering. GPU sort for alpha blending. Soft particles via depth buffer fade.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/522a50c3-7390-422e-b06b-1aefcd38509c.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[What We Can Learn From Grass in Ghost of Tsushima Renders]]></title><description><![CDATA[Introduction
Ghost of Tsushima renders huge fields of grass that sway in the wind. Each blade animates on its own. Roughly 83,000 blades on screen at once. In about 2.5 milliseconds per frame.
This po]]></description><link>https://tigerabrodi.blog/what-we-can-learn-from-grass-in-ghost-of-tsushima-renders</link><guid isPermaLink="true">https://tigerabrodi.blog/what-we-can-learn-from-grass-in-ghost-of-tsushima-renders</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Thu, 16 Apr 2026 18:55:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/221584e0-dd2a-48ad-a43b-aa26a79b9510.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Ghost of Tsushima renders huge fields of grass that sway in the wind. Each blade animates on its own. Roughly 83,000 blades on screen at once. In about 2.5 milliseconds per frame.</p>
<p>This post is not about copying their exact code. It is about the core ideas behind how they did it. Principles you can steal and reuse whenever you need to render a lot of something.</p>
<p>Here's the original talk: <a href="https://www.youtube.com/watch?v=Ibe1JBF5i5Y">Procedural Grass in Ghost of Tsushima</a>.</p>
<h1>The First Big Decision. No Grass Cards.</h1>
<p>Most games use <strong>grass cards</strong> for grass. A grass card is a flat rectangle with a grass texture painted on it. Like a photo of grass taped to cardboard. You place thousands of these around the world.</p>
<p>Grass cards have two big problems.</p>
<p>One. The wind animation is stuck to the whole card. The whole card wiggles as one piece. Individual blades cannot sway independently. It looks fake up close.</p>
<p>Two. <strong>Overdraw</strong>. Overdraw is when the GPU paints the same pixel more than once in a frame. Cards overlap a lot. Most of each card is transparent. The GPU still has to process all those transparent pixels. Wasted work.</p>
<p>Sucker Punch threw cards out. Instead. They build each blade of grass from scratch using math. Every frame. On the GPU.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c4db86f3-ea9d-446e-9a32-c7dae5bccefe.png" alt="" style="display:block;margin:0 auto" />

<h1>Principle One. Build It From Math. Not From Assets.</h1>
<p>Each blade in Ghost of Tsushima is a <strong>cubic bezier curve</strong>. A cubic bezier curve is a math curve defined by 4 control points. You move those points. The curve changes shape.</p>
<ul>
<li><p>Point 1. Base of the blade. Where it meets the ground.</p>
</li>
<li><p>Point 4. Tip of the blade.</p>
</li>
<li><p>Points 2 and 3. In the middle. They control how the blade bends.</p>
</li>
</ul>
<p>Move the tip. The blade leans. Push the middle points up. The blade arches. That is it. Whole blade shape controlled by 4 positions.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/83ff007b-8482-44f0-a2cb-6c7b7ebf2296.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Why this is a huge deal.</strong></p>
<p>Cards are static. You made them once. You are stuck with them.</p>
<p>Bezier curves are live. You build the blade fresh every frame from 4 points. So.</p>
<ul>
<li><p>Want the blade to sway. Move the tip a little.</p>
</li>
<li><p>Want the player to push it. Push a control point.</p>
</li>
<li><p>Want wind to curve it. Move the middle points.</p>
</li>
<li><p>Want 50 different blade shapes. Just change the point positions.</p>
</li>
</ul>
<p>You are not animating a model. You are rebuilding the blade every frame with slightly different numbers.</p>
<h1>Principle Two. The CPU Is the Bottleneck. Talk to the GPU Less.</h1>
<p>Say you have 83,000 blades of grass. If the CPU tells the GPU "draw this blade" 83,000 separate times. The CPU dies. Not because drawing is slow. Because <strong>talking</strong> is slow.</p>
<p>Each instruction to the GPU is called a <strong>draw call</strong>. Draw calls are expensive on the CPU side. Setting up state. Sending data. Validation. All that overhead adds up fast.</p>
<p>The fix is <strong>GPU instancing</strong>. You tell the GPU once. "Draw this blade shape 83,000 times. Here is the list of positions. Go." One draw call. GPU handles the rest in parallel.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c1efcb0a-b909-488e-808d-6c53c3a3b310.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Wait. Is this not just parallelism.</strong></p>
<p>No. GPUs are parallel by default. That is just what they do. The thing instancing fixes is different. It cuts down <strong>draw call overhead</strong>. The CPU saying the same thing 83,000 times vs saying it once.</p>
<p><strong>Take away principle.</strong> When you have many copies of something. One mesh. Many positions. Instance it. Particles. Trees. Rocks. Crowds. Bullets. Tiles. All the same pattern.</p>
<h1>Principle Three. Do Not Compute What You Cannot See.</h1>
<p>Even with instancing. You do not want to render a million blades when only 83,000 are visible. That is 12x more work than needed.</p>
<p>Ghost of Tsushima throws blades away in stages. Before they ever reach the vertex shader.</p>
<ul>
<li><p><strong>Distance culling.</strong> Too far from the camera. Drop it.</p>
</li>
<li><p><strong>Frustum culling.</strong> Outside what the camera can see. Drop it. The <strong>frustum</strong> is the pyramid shape of what the camera is looking at.</p>
</li>
<li><p><strong>Occlusion culling.</strong> Hidden behind a hill or a wall. Drop it.</p>
</li>
<li><p><strong>Type culling.</strong> This spot has no grass type assigned. Drop it.</p>
</li>
<li><p><strong>Height culling.</strong> This spot has zero-height grass. Drop it.</p>
</li>
</ul>
<p>Each stage is cheaper than the next. Cheap tests first. Expensive tests only on survivors. By the time you start actually building blades. You have the real working set.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/efa697d4-fce6-4db1-a977-e2fd64b83779.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Take away principle.</strong> Culling is layered. Cheap tests kill most of the work. Expensive tests only run on what survives. This pattern applies everywhere. Physics. AI. Rendering. Audio. Pathfinding.</p>
<h1>Principle Four. Clumps. Controlled Randomness Beats Pure Randomness.</h1>
<p>Their first grass looked boring. Like a golf course. They tried adding more randomness. It just looked messy. Not natural.</p>
<p>Real fields are not random. They are <strong>clumpy</strong>. This patch got more sunlight so the grass is taller here. That spot has different soil so the grass is darker there. The variation has structure.</p>
<p>To fake this. They use a <strong>voronoi algorithm</strong>. Voronoi means dividing space into regions around points. Each blade checks which clump point it is closest to. That clump controls the blade's height. Direction. Color. Bend.</p>
<p>So instead of each blade being random by itself. Blades in the same clump share traits. The field has patches of tall grass. Patches of short grass. Patches pointing slightly different directions. It looks alive.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/4523057b-8909-49ef-bdf1-0168ac600c48.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Take away principle.</strong> Randomness alone looks fake. Structured variation looks real. If you need something to feel organic. Group it into clusters first. Then vary within and between clusters.</p>
<h1>Principle Five. Two Levels of Detail. Far Stuff Gets Cheaper.</h1>
<p>Close grass has 15 vertices per blade. Far grass has 7. That is less than half.</p>
<p>A <strong>vertex</strong> is a corner point on a piece of geometry. More vertices means smoother curves but more work. Far away you cannot see the difference. So use less.</p>
<p>Transitioning between the two is tricky. If you just snap from 15 to 7 verts. The blade shape pops. Visible. Ugly. So the high-detail version slowly <strong>blends toward</strong> the low-detail shape as it gets close to the transition distance. By the time the swap happens. Both versions look nearly identical. No pop.</p>
<p>Also. Far away tiles are twice as big but have the same number of blades. So far grass is spread out twice as far apart. To hide this. Near grass drops 3 out of every 4 blades as it gets close to the far distance. Thins out gradually. So when you cross the line. The density already matches.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/fc4315b1-5e87-47e0-83ad-347767e556a9.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Take away principle.</strong> Not everything deserves full detail. Pay for quality where the player will see it. Skimp where they will not. And always blend between detail levels. Never snap.</p>
<h1>Principle Six. One Trick For Short Grass. Fold The Verts.</h1>
<p>Here is a clever one. If the grass is short. The blade does not need all 15 vertices. A short blade looks fine with 7. So they reuse the other 8 vertices to build a <strong>second blade</strong> right next to the first one.</p>
<p>Same draw call. Same vertex budget. Twice the blades. Free density.</p>
<p>You only get this trick because the geometry is procedural. If you were using cards or premade meshes. You could not do this. The vertices are flexible. So you use them however is most useful.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a25ab310-c4f0-4a5c-a315-3f4af2016e04.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Take away principle.</strong> When you have a fixed budget. Ask if you can split it. Sometimes the thing you are building does not need the whole budget. So the leftover becomes another thing.</p>
]]></content:encoded></item><item><title><![CDATA[What Actually Happens When You Run JavaScript]]></title><description><![CDATA[The Pipeline. From Source To Running Code.
Your JavaScript does not run directly. The engine transforms it first. V8. SpiderMonkey. JavaScriptCore. They all follow roughly the same steps.
Parse. The e]]></description><link>https://tigerabrodi.blog/what-actually-happens-when-you-run-javascript</link><guid isPermaLink="true">https://tigerabrodi.blog/what-actually-happens-when-you-run-javascript</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Thu, 16 Apr 2026 16:38:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/9011a21d-2a8f-4b79-b09e-31eebde7fc25.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>The Pipeline. From Source To Running Code.</h1>
<p>Your JavaScript does not run directly. The engine transforms it first. V8. SpiderMonkey. JavaScriptCore. They all follow roughly the same steps.</p>
<p><strong>Parse.</strong> The engine reads your source code and builds an AST. AST means Abstract Syntax Tree. A tree structure that represents the code.</p>
<p><strong>Bytecode.</strong> The AST gets compiled to bytecode. Bytecode is a simpler instruction set that the engine can execute quickly. Not machine code yet. Just a middle step.</p>
<p><strong>Interpret.</strong> The engine runs the bytecode in an interpreter. This is fast to start but slow to execute long-term.</p>
<p><strong>Optimize.</strong> If a piece of code runs often. The engine compiles it to real machine code using a JIT compiler. Machine code is the actual instructions your CPU runs. Fast.</p>
<p><strong>JIT</strong> means Just-In-Time. The compiler runs while the program is running. Not ahead of time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/73cb46dd-fead-4393-b716-dc1b73271fb1.png" alt="" style="display:block;margin:0 auto" />

<h1>Hidden Classes. Why Object Shape Matters.</h1>
<p>JavaScript lets you add properties to objects whenever you want. Convenient. But it makes objects hard to optimize.</p>
<p>Engines solve this with hidden classes. Also called shapes or maps.</p>
<p>A hidden class is an internal structure that tracks what properties an object has and in what order. Every object gets one.</p>
<p>Same properties in the same order. Engine reuses the hidden class. Fast. Different orders. Different hidden classes. Slow.</p>
<p>Example.</p>
<pre><code class="language-javascript">const a = { x: 1, y: 2 };
const b = { x: 1, y: 2 };
// Same hidden class. Engine can optimize both together.

const c = { x: 1 };
c.y = 2;
const d = { y: 2 };
d.x = 1;
// Different hidden classes. Even though they end up with same properties.
</code></pre>
<p>The lesson. Initialize objects with the same shape in the same order. Every time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/0434ffb0-d10f-48ca-8d19-aa0d5e61c167.png" alt="" style="display:block;margin:0 auto" />

<h1>Inline Caches. Remembering Shapes.</h1>
<p>The hidden class lets the engine skip property lookups.</p>
<p>First time you access <code>obj.name</code>. The engine finds where <code>name</code> lives inside that hidden class. Then caches that location right next to the instruction.</p>
<p>Next time that instruction runs. If the object has the same hidden class. The engine reads directly from the cached location. No lookup.</p>
<p>This cache is called an <strong>inline cache</strong> or IC. It is one of the biggest reasons modern JavaScript is fast.</p>
<p>Every spot in your code where you access a property has its own inline cache. The cache tracks which hidden classes have shown up at that exact spot. Three possible states.</p>
<p><strong>Monomorphic.</strong> Only one hidden class has ever been seen there. The engine just checks "same shape as before? yes. grab from the known location." One check. Fastest.</p>
<p><strong>Polymorphic.</strong> A handful of different hidden classes have been seen. Usually 2 to 4. The engine now has to check each one in turn. "Is it shape A? No. Shape B? Yes. Grab from there." More checks. Slower.</p>
<p><strong>Megamorphic.</strong> Too many different hidden classes have shown up. Past the engine's limit. The cache gives up and falls back to a full generic property lookup every time. Slowest.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/3968e18c-95eb-4af2-a2df-56afe1a9ba07.png" alt="" style="display:block;margin:0 auto" />

<h1>JIT Compilation. From Warm To Hot.</h1>
<p>The engine does not optimize everything. It watches your code and tracks which functions run often. A function that runs many times is called <strong>hot</strong>.</p>
<p>When a function gets hot. The engine sends it to the optimizing compiler. The compiler makes assumptions based on what it has seen.</p>
<p>"This function always receives two numbers." "This object always has these properties in this order." "This array always contains integers."</p>
<p>Under those assumptions. It generates fast machine code. Much faster than bytecode.</p>
<p>The key thing. The optimized code is not general. It is specialized for the patterns observed.</p>
<h1>Deoptimization. When Assumptions Break.</h1>
<p>Assumptions can be wrong. You pass a string where you used to pass a number. You add a new property to an object. You mix types in an array.</p>
<p>When that happens. The optimized code is no longer valid. The engine throws it away and falls back to the bytecode interpreter. This is called <strong>deoptimization</strong>.</p>
<p>Deoptimization is expensive. The optimization work is wasted. Your code runs slower until the engine decides to reoptimize. If it does.</p>
<p>Common causes.</p>
<ul>
<li><p>Passing different types to a function that used to be consistent.</p>
</li>
<li><p>Adding or deleting properties on hot objects.</p>
</li>
<li><p>Mixing integers and floats in an array.</p>
</li>
<li><p>Using constructs the optimizer does not handle well.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c4648bdf-f701-486a-9b37-35e70ddf6256.png" alt="" style="display:block;margin:0 auto" />

<h1>Garbage Collection. How The Engine Frees Memory.</h1>
<p>You do not manually free memory in JavaScript. The engine handles it. The system that does this is the <strong>garbage collector</strong>. Or <strong>GC</strong>.</p>
<p>The GC walks a graph. It starts from things called <strong>roots</strong> and follows every reference it finds. Anything reachable from a root is live. Anything it cannot reach is garbage.</p>
<h2>What Counts As A Root</h2>
<p>Roots are the anchor points. Things the engine always treats as live.</p>
<ul>
<li><p><strong>Global and module-level variables.</strong> Things on <code>window</code>, <code>globalThis</code>, or declared at the top level of a module. Live for the lifetime of the program.</p>
</li>
<li><p><strong>Active stack variables.</strong> Local variables inside functions currently running on the call stack. Live only while their function is executing.</p>
</li>
<li><p><strong>Closure-captured variables.</strong> Values an outer scope captured that a still-reachable closure holds on to.</p>
</li>
</ul>
<p>Roots themselves are not collected. But what they point to can still become garbage. Set a global variable to null. The old value it used to point to is now unreachable. Free to collect. The root slot itself is untouched.</p>
<p>A local variable stops being a root the moment its function returns. Whatever it pointed to becomes garbage unless something else still holds a reference.</p>
<h2>Generational Collection</h2>
<p>Modern engines use <strong>generational garbage collection</strong>. Based on one observation. Most objects die young. A function creates objects. Uses them. Returns. Those objects are garbage almost immediately.</p>
<p>The GC splits memory into two zones.</p>
<p><strong>Young generation.</strong> Small. Where new objects are born. Most die here.</p>
<p><strong>Old generation.</strong> Larger. Where objects that survived a few young collections get promoted.</p>
<h2>Minor GC vs Major GC</h2>
<p><strong>Minor GC.</strong> Collects only the young generation. Happens often. Fast. Pauses are usually a few milliseconds or less.</p>
<p><strong>Major GC.</strong> Collects the old generation. Sometimes scans both generations together. Happens rarely. Slower because there is more memory to scan and more references to trace.</p>
<p>The split keeps most pauses short. You pay the long pause only when the old generation gets full.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/9731cd92-31f1-41aa-873b-2569a652e03e.png" alt="" style="display:block;margin:0 auto" />

<h1>Tips For Writing Performant JavaScript</h1>
<p>You do not need to hand-tune everything. But these habits help the engine help you.</p>
<h2>Keep Object Shapes Consistent</h2>
<p>Same properties. Same order. Every time.</p>
<pre><code class="language-javascript">// Good
const user1 = { name: "Alice", age: 30, role: "admin" };
const user2 = { name: "Bob", age: 25, role: "user" };

// Bad
const user1 = { name: "Alice", age: 30 };
user1.role = "admin"; // new hidden class
</code></pre>
<h2>Declare All Properties Upfront</h2>
<p>If a property might be missing at first. Initialize it to null. Do not add it later.</p>
<pre><code class="language-javascript">// Good
const user = { name: "Alice", age: null };
user.age = 30; // same hidden class. just a value change.

// Bad
const user = { name: "Alice" };
user.age = 30; // new hidden class
</code></pre>
<h2>Do Not Delete Properties</h2>
<p>Set them to null instead. Delete changes the hidden class. Setting to null does not.</p>
<pre><code class="language-javascript">// Good
user.age = null;

// Bad
delete user.age;
</code></pre>
<h2>Keep Argument Types Consistent</h2>
<p>If a function takes a number. Always pass a number. Mixing types causes deoptimization.</p>
<pre><code class="language-javascript">function add(a, b) {
  return a + b;
}

add(5, 10); // engine optimizes for numbers
add("hi", "bye"); // deopt. now reoptimizes for strings
</code></pre>
<h2>Do Not Mix Integers And Floats In Arrays</h2>
<p>The engine uses a different internal format for integer arrays vs float arrays. Mixing them forces a slower general format.</p>
<pre><code class="language-javascript">// Good
const ints = [1, 2, 3, 4];
const floats = [1.5, 2.5, 3.5];

// Bad
const mixed = [1, 2.5, 3, 4.5];
</code></pre>
<h2>Use Set For Membership Checks</h2>
<p><code>includes</code> on an array is O(n). <code>has</code> on a Set is O(1). Huge difference when checking membership often.</p>
<pre><code class="language-javascript">// Slow. O(n * m)
const result = list1.filter((x) =&gt; list2.includes(x));

// Fast. O(n + m)
const set = new Set(list2);
const result = list1.filter((x) =&gt; set.has(x));
</code></pre>
<h2>Use Map For Dynamic Keys</h2>
<p>If you are adding and removing keys at runtime. Use a Map. Regular objects end up with constant hidden class changes. Maps do not.</p>
<pre><code class="language-javascript">// Bad
const cache = {};
cache[userId] = data;

// Good
const cache = new Map();
cache.set(userId, data);
</code></pre>
<h2>Use === Not ==</h2>
<p><code>==</code> does type coercion behind the scenes. <code>===</code> compares directly. Faster and more predictable. There is no reason to use <code>==</code>.</p>
<h2>Watch Out For Closures Holding Large Data</h2>
<p>A closure keeps alive everything it captures. Even if you only use a small piece. The GC cannot free the rest.</p>
<pre><code class="language-javascript">// Bad. keeps all of hugeData alive
function makeHandler() {
  const hugeData = loadHugeData();
  return () =&gt; console.log(hugeData.name);
}

// Good. only keeps the name
function makeHandler() {
  const hugeData = loadHugeData();
  const name = hugeData.name;
  return () =&gt; console.log(name);
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[A Friendly Introduction to Linear Algebra For Game Devs]]></title><description><![CDATA[What Is Linear Algebra Even?
Linear algebra is the math of vectors and matrices. That is it. The whole field.
Vectors describe things like position and direction and movement. Matrices describe transf]]></description><link>https://tigerabrodi.blog/a-friendly-introduction-to-linear-algebra-for-game-devs</link><guid isPermaLink="true">https://tigerabrodi.blog/a-friendly-introduction-to-linear-algebra-for-game-devs</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Thu, 16 Apr 2026 13:02:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/81b06f03-137a-4f80-a621-3b9a1204c733.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>What Is Linear Algebra Even?</h1>
<p>Linear algebra is the math of <strong>vectors</strong> and <strong>matrices</strong>. That is it. The whole field.</p>
<p>Vectors describe things like position and direction and movement. Matrices describe transformations. Like moving things. Rotating things. Scaling things.</p>
<p>Games are made of 3D points that need to be positioned and moved and rotated and projected onto your screen. All of that is vectors and matrices. That is why this matters.</p>
<h1>Part 1. Vectors.</h1>
<p>A vector is a list of numbers. That is the simplest definition.</p>
<p>A 2D vector has two numbers. Like <code>(3, 4)</code>. A 3D vector has three numbers. Like <code>(2, 5, 7)</code>. You can have 4D vectors and beyond but games mostly use 2D and 3D and sometimes 4D.</p>
<p>But what do those numbers mean? It depends on what you are using the vector for.</p>
<h2>Position Vectors</h2>
<p>A position vector tells you where something is in space. If your player is at <code>(10, 0, 5)</code> that means they are 10 units along the X axis. 0 units on the Y axis. And 5 units on the Z axis.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/56ee8bd2-7a51-4e31-8a40-9c5195e350fd.png" alt="" style="display:block;margin:0 auto" />

<h2>Direction Vectors</h2>
<p>A direction vector tells you which way something is pointing. Not where it is. Just which way.</p>
<p>If your character is facing forward. Their direction vector might be <code>(0, 0, 1)</code>. That means zero on X. Zero on Y. One on Z. So they are pointing down the Z axis.</p>
<p>The same numbers can be a position or a direction depending on how you use them. That is important to remember.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/0882d39d-9f71-4302-93de-36051338ca0f.png" alt="" style="display:block;margin:0 auto" />

<h1>Part 2. Basic Vector Operations.</h1>
<p>Vectors are useful because you can do math with them. Here are the operations you will use constantly.</p>
<h2>Adding Vectors</h2>
<p>You add vectors by adding each of their numbers together.</p>
<p><code>(1, 2, 3) + (4, 5, 6) = (5, 7, 9)</code></p>
<p>Why is this useful? Because adding a direction vector to a position vector moves the position in that direction.</p>
<p>If your player is at <code>(10, 0, 5)</code> and they move forward by <code>(0, 0, 1)</code> their new position is <code>(10, 0, 6)</code>. That is literally how movement works in games.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/b16c1681-5e8f-471f-b9eb-abb5ca92bb4f.png" alt="" style="display:block;margin:0 auto" />

<h2>Scaling Vectors</h2>
<p>You can multiply a vector by a single number. This is called <strong>scalar multiplication</strong>. A scalar is just a regular number. Not a vector.</p>
<p><code>2 × (1, 2, 3) = (2, 4, 6)</code></p>
<p>This makes the vector bigger or smaller while keeping its direction the same. Multiply by 2 and it is twice as long. Multiply by 0.5 and it is half as long. Multiply by -1 and it flips around pointing the opposite way.</p>
<p>This is useful for things like speed. A direction vector tells you where you are going. Multiply it by how fast you want to go. Now you have velocity.</p>
<h1>Part 3. Magnitude and Normalization.</h1>
<h2>Magnitude</h2>
<p>The magnitude of a vector is its length. How long is the arrow.</p>
<p>For a 2D vector <code>(3, 4)</code> the magnitude is 5. For a 3D vector you use the same idea. Square each number. Add them up. Take the square root. You do not need to memorize the formula. Just know that magnitude means length.</p>
<p>If a vector represents velocity. Its magnitude is the speed. If it represents a distance from A to B. Its magnitude is how far apart they are.</p>
<h2>Normalization</h2>
<p>A <strong>normalized vector</strong> is one whose length is exactly 1. Also called a <strong>unit vector</strong>.</p>
<p>You normalize a vector by dividing it by its own magnitude. The direction stays the same. Only the length changes to 1.</p>
<p>Why do this? Because when you only care about direction not distance. You want length 1. It makes math cleaner and faster.</p>
<p>Every time a game needs "which way is this thing facing" or "which direction is the light coming from" it uses normalized vectors.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/704eb00c-3809-4ec2-b0bd-df96287d754f.png" alt="" style="display:block;margin:0 auto" />

<h1>Part 4. The Dot Product.</h1>
<p>This one is huge. The dot product shows up everywhere in games.</p>
<p>The dot product takes two vectors and gives you back a single number. Not a vector. Just a number.</p>
<p>Here is what matters. That number tells you how aligned the two vectors are.</p>
<ul>
<li><p>If they point in the exact same direction. The dot product is 1.</p>
</li>
<li><p>If they are perpendicular. Ninety degrees apart. The dot product is 0.</p>
</li>
<li><p>If they point in opposite directions. The dot product is -1.</p>
</li>
<li><p>Anything in between gives you a value between -1 and 1.</p>
</li>
</ul>
<p>This only works cleanly when both vectors are normalized. Remember normalization from before? This is one of the reasons it matters.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/da7e4c65-8e7e-48ce-b04a-81838faf3866.png" alt="" style="display:block;margin:0 auto" />

<h2>Why You Care About The Dot Product</h2>
<p><strong>Lighting.</strong> To figure out how bright a surface is. You take the dot product of the surface normal and the light direction. A surface normal is a vector that points straight out from a surface. If it faces the light the dot is close to 1. Bright. If it faces away the dot is close to 0 or negative. Dark. That is literally how games calculate basic lighting.</p>
<p><strong>Is something in front of you?</strong> Take the dot product of your forward direction and the direction to the other object. If it is positive. They are in front of you. If negative. They are behind you. Super useful for AI and camera logic.</p>
<p><strong>What angle are two things at?</strong> The dot product tells you how aligned two vectors are. You can use it to check if two objects are roughly facing the same way. Or for a guard that should only see you if you are in their cone of vision.</p>
<hr />
<h1>Part 5. The Cross Product.</h1>
<p>The cross product takes two vectors and gives you back a new vector. Not a number like the dot product. An actual vector.</p>
<p>The new vector is perpendicular to both of the input vectors. Ninety degrees from both.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/8d3a2cf0-1f6d-487d-b764-d4ba1e3b542b.png" alt="" style="display:block;margin:0 auto" />

<h2>Why You Care About The Cross Product</h2>
<p><strong>Calculating normal vectors.</strong> A normal vector points straight out from a surface. Games need them for lighting and physics. If you have a triangle with three corners. You can take two of its edges as vectors. Cross product them. The result is the normal vector for that triangle. Every single 3D model uses this.</p>
<p><strong>Defining a 3D coordinate system.</strong> If you know which way is forward and which way is up for a character. You can cross product them to find which way is right. This is how character controllers and cameras work.</p>
<hr />
<h1>Part 6. Matrices.</h1>
<p>A matrix is a grid of numbers. Rows and columns.</p>
<p>A 4x4 matrix has 16 numbers arranged in 4 rows and 4 columns. That is the most common one in games.</p>
<p>Matrices look scary but they do one main thing. They transform vectors.</p>
<p>Transform means change. Move. Rotate. Scale. Any of that.</p>
<p>When you multiply a matrix by a vector you get a new vector. The matrix is the transformation. The original vector is the input. The result is the transformed vector.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a0a6e638-e945-46a1-9213-c2ddced0ceaf.png" alt="" style="display:block;margin:0 auto" />

<h2>The Three Transformations You Need</h2>
<p>There are three main kinds of transformations in games. Each one is just a specific matrix.</p>
<p><strong>Translation.</strong> Moves a point from one place to another. Adds some offset to the position. If you want to move something 5 units to the right. There is a translation matrix for that.</p>
<p><strong>Rotation.</strong> Spins a point around an axis. Rotating around the Y axis spins things like they are on a turntable. Rotating around the X axis tilts things up and down. Each rotation is its own matrix.</p>
<p><strong>Scale.</strong> Makes things bigger or smaller. A scale of 2 doubles the size. A scale of 0.5 halves it. You can even scale differently on each axis. Make something twice as tall without changing its width.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c9881069-e1a4-4fd9-b511-d907a0808ef6.png" alt="" style="display:block;margin:0 auto" />

<h1>Part 7. Combining Transformations.</h1>
<p>Here is the really cool part. You can multiply matrices together to combine transformations.</p>
<p>If you have a rotation matrix and a translation matrix. Multiply them together. Now you have a single matrix that rotates AND translates in one step.</p>
<p>This is how every 3D object in a game gets placed into the world. The game has a matrix for each object that contains all the position and rotation and scale info combined. One matrix holds everything.</p>
<h2>Order Matters</h2>
<p>One thing to watch out for. The order you multiply matrices in changes the result. Rotating then moving is different from moving then rotating.</p>
<p>Think of it like directions. Walk 10 steps forward then turn right. You end up in a different place than if you turn right first and then walk 10 steps forward. Same operations. Different order. Different result.</p>
<p><img src="align=%22center%22" alt="" /></p>
<h1>Part 8. The Big Picture. How It All Fits In A 3D Engine.</h1>
<p>Every 3D point you see on screen goes through a chain of matrix transformations before it gets drawn. This chain is called the <strong>MVP transformation</strong>. Model. View. Projection.</p>
<p><strong>Model Matrix.</strong> Takes a 3D object and places it in the world. Position. Rotation. Scale. All in one matrix per object.</p>
<p><strong>View Matrix.</strong> Takes the world and positions it relative to the camera. Like moving the whole world so the camera is at the origin looking forward.</p>
<p><strong>Projection Matrix.</strong> Takes the 3D world and squishes it down onto your 2D screen. This is where perspective happens. Things farther away look smaller.</p>
<p>Every vertex of every 3D model gets multiplied by these three matrices. Millions of times per frame. The GPU does this incredibly fast. That is why modern games can render huge detailed worlds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a4a9eebb-d906-4dd3-95aa-5b52b84633db.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[Water in Games is Not Real]]></title><description><![CDATA[Introduction
Every ocean you have sailed. Every river you crossed. Every pool you dove into. None of it was real water. It was all a trick. A very clever trick built from math and textures and a lot o]]></description><link>https://tigerabrodi.blog/water-in-games-is-not-real</link><guid isPermaLink="true">https://tigerabrodi.blog/water-in-games-is-not-real</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Thu, 16 Apr 2026 03:14:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/84ecaa1e-a459-403b-96b5-223ae37b9d6a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Every ocean you have sailed. Every river you crossed. Every pool you dove into. None of it was real water. It was all a trick. A very clever trick built from math and textures and a lot of creative cheating.</p>
<h1>Why Not Just Simulate Real Water?</h1>
<p>Real water consists of billions of molecules interacting. Simulating even a small portion in real time is too costly. Games must achieve 30 to 60 frames per second, and a true fluid simulation would exceed the frame budget.</p>
<p>So instead of simulating water. Games fake it. And they have gotten incredibly good at faking it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/603ea4a2-a68a-4fef-a3a7-51d8e1c02249.png" alt="" style="display:block;margin:0 auto" />

<h1>The Surface. It Starts With Waves.</h1>
<p>The foundation of almost all game water is a flat plane. A simple grid of triangles. To make it look like water you need to make it move. You do that with waves.</p>
<h2>Sine Waves</h2>
<p>A sine wave is a smooth curve that goes up and down forever. It has two properties you care about.</p>
<p><strong>Amplitude</strong> is how tall the wave is. A bigger amplitude means taller peaks and deeper valleys.</p>
<p><strong>Wavelength</strong> is the distance between two peaks. A shorter wavelength means the wave repeats more often. Which means more detail.</p>
<p>You feed a position on your plane into a sine function and it gives you back a height. That height moves the surface up or down. Add time to the equation and the wave starts moving.</p>
<p>One sine wave looks boring. But if you add multiple sine waves together. Each with different amplitudes and wavelengths and directions. You start getting something that looks like water. This technique is called <strong>Sum of Sines</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/e7872e46-33df-4212-8aee-f89e4dec90da.png" alt="" style="display:block;margin:0 auto" />

<h2>Sharper Peaks</h2>
<p>Basic sine waves are too smooth and round. Real ocean waves have sharper peaks and wider flat areas between them. There are modified wave equations that give you this sharper shape. One famous one is called the <strong>Gerstner wave</strong>. Another simpler approach uses an exponential function to sharpen the peaks. The idea is the same. You take your basic sine wave and reshape it so the tops are pointy and the bottoms are wide.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/d87f1071-9832-422a-a6fe-058bca34fbfe.png" alt="" style="display:block;margin:0 auto" />

<h2>Fractional Brownian Motion (FBM)</h2>
<p>This is a big one. FBM is a technique where you layer waves on top of each other in a structured way. Here is how it works.</p>
<p>You start with one big wave. Low frequency. High amplitude. This is your base shape. Then you add another wave on top. This one has higher frequency but lower amplitude. So it adds smaller detail on top of the big shape. Then you do it again. Even higher frequency. Even smaller amplitude. Finer detail.</p>
<p>You keep doing this. Each layer is called an <strong>octave</strong>. Each octave adds finer and finer detail but with less and less impact. The result is a surface that has large rolling shapes AND tiny ripples at the same time. Just like real water.</p>
<p>FBM is not just used for water. It is one of the most important algorithms in graphics. It is used to generate clouds. Terrain. Fire. Anything that needs natural-looking layered detail.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/6abd6c2e-c76c-4863-aca9-e7b6bbc0f147.png" alt="" style="display:block;margin:0 auto" />

<h2>Vertex Displacement</h2>
<p>All of these waves need to actually move the surface. The technique for this is called <strong>vertex displacement</strong>. Your water plane is a grid made of many vertices. Points in 3D space. A shader runs on the GPU and moves each vertex up or down based on the wave math. The GPU does this for every vertex every frame. It is extremely fast.</p>
<p><strong>Vertex</strong> means a corner point of a triangle in your 3D mesh. <strong>Shader</strong> means a small program that runs on the GPU. It controls how geometry is positioned and how pixels are colored. <strong>GPU</strong> means Graphics Processing Unit. The chip in your computer that is designed to do millions of small math operations at the same time.</p>
<p>The wave math runs inside a <strong>vertex shader</strong>. That is the part of the rendering pipeline where you can change the position of each vertex before it gets drawn on screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/203adc25-4eb3-49cb-adbc-808842c668ab.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Making It Look Like Water. Lighting and Shading.</h1>
<p>Moving geometry alone looks like a white blob. The magic that makes it look like water is all in the lighting.</p>
<h2>Normal Vectors</h2>
<p>To calculate how light hits a surface you need to know which direction the surface is facing at every point. That direction is called a <strong>normal vector</strong>. On a flat floor every normal points straight up. On a wavy water surface the normals tilt in all sorts of directions following the bumps and dips.</p>
<p>The cool thing about the wave math approach is that you can calculate the exact normals using calculus. You take the derivative of your wave function. That gives you precise normals without any guessing. This matters because accurate normals mean accurate lighting.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/fc20fff3-ba3d-4b3f-b4a3-1d1922fd1d6c.png" alt="" style="display:block;margin:0 auto" />

<h2>Diffuse Lighting</h2>
<p>The most basic lighting calculation. You compare the normal vector to the direction of the light. If they face each other. The surface is bright. If they face away. The surface is dark. This is called <strong>Lambertian diffuse</strong>. It gives the water its basic form and shading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/5dfe4c09-836e-4f79-81f3-a46a6972f3ea.png" alt="" style="display:block;margin:0 auto" />

<h2>Specular Highlights</h2>
<p>These are the bright sparkly spots you see where sunlight bounces off the water directly into your eyes. The calculation checks if your viewing angle lines up with the reflected light direction. If it does. You get a bright spot. This is what makes water look shiny and alive.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/e87133f8-99ea-41fd-8fd7-850a1624f7a0.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Reflections</h1>
<p>Water without reflections does not look like water. There are three main approaches games use.</p>
<p><strong>Ray Tracing</strong> traces light rays to calculate perfect reflections. Looks amazing. Very expensive. Only works on modern hardware.</p>
<p><strong>Screen Space Reflections (SSR)</strong> only reflects what is already visible on your screen. Cheap and common. But if the thing being reflected moves off screen the reflection just disappears. Easy to break. Most games use this anyway because it is fast.</p>
<p><strong>Cube Map Reflections</strong> use a pre-captured image of the environment stored on the six faces of a cube. You calculate the reflection direction from the camera and sample the cube map. This gives decent reflections cheaply. Most skybox reflections work this way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c8fbb40f-36fa-47f5-9a90-b06a6e318c53.png" alt="" style="display:block;margin:0 auto" />

<h2>The Fresnel Effect</h2>
<p>This is one of the most important visual tricks for water. Fresnel describes how the reflection strength changes based on your viewing angle.</p>
<p>Look straight down into water. You see through it clearly. Look across a lake at a shallow angle. It looks like a mirror.</p>
<p>Games blend between showing the reflection and showing what is under the water based on this angle. This single effect makes a huge difference in how real the water looks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/507afee5-dd76-4241-a1f4-a0528dd24b99.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Normal Maps. Faking the Small Stuff.</h1>
<p>You cannot add infinite wave detail through vertex displacement. At some point the triangles in your mesh are too big to show tiny ripples. This is where <strong>normal maps</strong> come in.</p>
<p>A normal map is a texture. But instead of storing color it stores direction information. Each pixel tells the shader "pretend the surface is tilted this way." This lets you add fine surface detail like small ripples and distortions without adding any actual geometry.</p>
<p>The classic water trick is to take two normal map textures and scroll them across the surface in different directions. They overlap and blend. Your brain sees complex moving ripples. In reality it is just two images sliding over a flat surface. This technique is everywhere. From Half-Life 2 to Cyberpunk 2077.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/33d1d3ec-74db-43eb-9e7c-ce4224ec783d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Caustics. Light Patterns Underwater.</h1>
<p>Caustics are those dancing bright patterns you see on the bottom of a swimming pool. They happen because the wavy water surface bends light like a bunch of tiny magnifying glasses. Some spots on the floor get extra light focused on them. Other spots get less. This creates the signature wiggly web of bright lines.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/3cefabd5-d2fc-4d9d-bc2f-0528d84a7ac9.png" alt="" style="display:block;margin:0 auto" />

<p>In games the cheapest approach is just a <strong>scrolling texture</strong>. An animated image of caustic patterns projected onto surfaces under the water. Add some distortion and it looks convincing. Subnautica uses this approach heavily across its entire ocean floor.</p>
<p>More advanced methods actually trace where the light goes after hitting the water surface and calculate where the bright spots should be. This matches the wave shapes but costs more.</p>
<hr />
<h1>Interaction. Making It Feel Real.</h1>
<p>What really sells water is how it reacts to the world.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/bcebea6a-d137-42c7-b473-3d5555016e6e.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Ripples from objects.</strong> When a character walks into water or a bullet hits the surface the game uses vertex displacement at that spot to create a ripple that spreads outward and fades.</p>
<p><strong>Splash particles.</strong> Sprite-based particle effects for splashes. Combined with ripples this gives you something like the bullet impacts in Red Dead Redemption 2.</p>
<p><strong>Foam.</strong> White foam that appears on wave crests and near shorelines. Usually a texture that is blended in based on wave height or proximity to objects.</p>
<p><strong>Buoyancy.</strong> Objects floating. The simplest version just pushes objects up when they are below the water line. More advanced versions calculate how much of the object is submerged and apply force proportionally. This gives natural bobbing behavior.</p>
<p><strong>Underwater effects.</strong> When the camera goes below the surface everything changes. Colors shift blue. Sound gets muffled. A fog effect limits visibility. The surface looks different from below. Each of these is a separate system.</p>
<hr />
<h1>The Recipe. Putting It All Together.</h1>
<p>Here is the full stack of what makes game water work.</p>
<p>A flat plane mesh with enough vertices to deform. Wave math using sum of sines or FBM to displace those vertices. Normal calculation for accurate lighting. Scrolling normal maps for fine surface detail. Diffuse and specular lighting. Fresnel-based blending between reflection and refraction. Reflections via cube maps or SSR or ray tracing. Caustic textures projected on underwater surfaces. Particle effects for splashes and foam. Interaction systems for ripples and buoyancy.</p>
<p>Every game picks and chooses from this list based on their budget and hardware targets. A mobile game might only use scrolling normal maps on a flat plane. A game like Sea of Thieves uses almost everything on this list and then some.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/f11042dd-a20a-4a40-995e-9500b552e6ec.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[Ray Marching From the Ground Up]]></title><description><![CDATA[What Is Distance.
Before anything else. You need to understand one thing. Distance.
You have two dots on a piece of paper. The distance between them is just how far apart they are. You could measure i]]></description><link>https://tigerabrodi.blog/ray-marching-from-the-ground-up</link><guid isPermaLink="true">https://tigerabrodi.blog/ray-marching-from-the-ground-up</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 20:04:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/ad8a68d6-3014-47f1-9e26-12b84ea5f69c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>What Is Distance.</h1>
<p>Before anything else. You need to understand one thing. Distance.</p>
<p>You have two dots on a piece of paper. The distance between them is just how far apart they are. You could measure it with a ruler. That is it. Nothing fancy.</p>
<p>A computer can calculate this distance with a simple formula. You do not need to understand the formula. Just know that the computer can take any two points and instantly tell you how far apart they are.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/83574af0-61b4-4947-97ed-85320794fba6.png" alt="" style="display:block;margin:0 auto" />

<h1>What Is a Distance Field.</h1>
<p>Now imagine this. You have a circle sitting in the middle of a blank space. Pick any random point in that space. The computer can tell you how far that point is from the edge of the circle. Not the center. The edge.</p>
<p>Now imagine doing that for every single point in the space. Every point gets a number. That number is its distance to the nearest surface.</p>
<p>If you color those numbers. Close to the surface is dark. Far from the surface is bright. You get something that looks like a glowing ring. Bright far away. Dark near the circle.</p>
<p>That is a distance field. Every point in space knows how far it is from the nearest thing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/8aa02ead-374f-4bb1-a8e7-dbfd7fcb7eb1.png" alt="" style="display:block;margin:0 auto" />

<h1>What Does Signed Mean.</h1>
<p>Here is where people get confused. But it is simple.</p>
<p>Signed just means the number can be positive or negative. Like a bank account. Positive means you have money. Negative means you owe money. The sign tells you which side you are on.</p>
<p>For a distance field it works the same way.</p>
<p>If you are outside the circle. The distance is positive. You are away from the surface.</p>
<p>If you are inside the circle. The distance is negative. You have gone past the surface.</p>
<p>If you are exactly on the edge. The distance is zero. You are right on the surface.</p>
<p>That is it. That is what signed means. Positive is outside. Negative is inside. Zero is the surface. The sign tells you which side you are on.</p>
<p>This is called a Signed Distance Function. SDF for short. A function that tells you how far you are from the nearest surface and whether you are inside or outside.</p>
<img src="https://v3b.fal.media/files/b/0a966427/106_YXguk_Cgyy0YrTnds_bbcc3NDW.png" alt="106_YXguk_Cgyy0YrTnds_bbcc3NDW.png" style="display:block;margin:0 auto" />

<h1>The Key Idea. You Only Know Distance. Nothing Else.</h1>
<p>Here is the weird part. The signed distance function only tells you one thing. How far away is the nearest surface.</p>
<p>It does not tell you where the surface is. It does not tell you what direction it is in. It does not tell you what shape it is.</p>
<p>Just the distance. That is all.</p>
<h1>How Ray Marching Works.</h1>
<p>Imagine you are standing in a dark room. You cannot see anything. But you have a special device. When you press a button it tells you how far away the nearest wall is. It does not tell you which direction the wall is. Just the distance.</p>
<p>You want to walk forward without hitting anything. Here is what you do.</p>
<p>Step 1. Press the button. It says "the nearest wall is 5 meters away." Great. You know it is safe to take 5 steps forward. No matter what direction the wall is in. You cannot hit anything within 5 meters.</p>
<p>Step 2. You walk 5 meters forward. Press the button again. Now it says "the nearest wall is 2 meters away." So you walk 2 meters forward.</p>
<p>Step 3. Press again. "0.3 meters." Walk 0.3 meters.</p>
<p>Step 4. Press again. "0.01 meters." You are basically touching the wall. You found a surface.</p>
<p>That is ray marching. You keep checking the distance. You keep stepping forward by exactly that distance. Each step is guaranteed to be safe because you cannot overshoot. The steps get smaller and smaller as you get closer. Until you hit something.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a827153e-6fb2-4343-9e3e-00705c4e3637.png" alt="" style="display:block;margin:0 auto" />

<h1>Why the Steps Are Circles.</h1>
<p>The distance is the same in every direction. So the safe zone at each step is a circle. Each circle gets smaller as you get closer to the surface.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/18610eae-d076-43d2-8527-ee198a65b739.png" alt="" style="display:block;margin:0 auto" />

<h1>Now Do That for Every Pixel.</h1>
<p>One ray finds one surface point. That gives you one pixel on your screen.</p>
<p>To render a full image. You shoot one ray for every pixel. A screen might have over two million pixels. So you shoot over two million rays. Each one marches forward step by step until it hits something or gives up.</p>
<p>The computer does all of these at the same time. GPUs are built for this. Millions of tiny tasks running in parallel.</p>
<p>When a ray hits a surface. You color that pixel. When a ray hits nothing. You color it as background. Do this for every pixel and you get a full image.</p>
<img src="https://v3b.fal.media/files/b/0a966441/xX1B4helEiPuZljtYOZPo_8WLy2X5H.png" alt="xX1B4helEiPuZljtYOZPo_8WLy2X5H.png" style="display:block;margin:0 auto" />

<h1>Building a Scene. Combining Shapes.</h1>
<p>You know how to render one shape now. But what about a scene with many shapes.</p>
<p>Here is the beautiful part. Remember. The distance function tells you how far the nearest surface is. If you have two shapes. You just check the distance to both. <strong>And take the smaller number. The smaller number is the nearest surface. That's the one which matters. It's the one you would hit first.</strong></p>
<p>In code this is just the min function. Take the minimum of two distances. Done. You now have two shapes in your scene.</p>
<p>Want 10 shapes. Take the min of all 10 distances.</p>
]]></content:encoded></item><item><title><![CDATA[Why The L System Trees Looked Wrong]]></title><description><![CDATA[Introduction
We wanted procedural trees. We kept getting trunks with leaf puffs. Pine looked acceptable. Oak. Birch. Maple. And sakura did not. They read like poles with crowns.
The important lesson i]]></description><link>https://tigerabrodi.blog/why-the-l-system-trees-looked-wrong</link><guid isPermaLink="true">https://tigerabrodi.blog/why-the-l-system-trees-looked-wrong</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 18:56:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c0bfbebc-d3fb-4394-9f1d-ef5644c07ecd.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>We wanted procedural trees. We kept getting trunks with leaf puffs. Pine looked acceptable. Oak. Birch. Maple. And sakura did not. They read like poles with crowns.</p>
<p>The important lesson is simple. The problem was not bark. The problem was not leaf textures. The problem was grammar.</p>
<h1>The Real Problem</h1>
<p>Our broadleaf presets were barely creating side branches in world space. We measured the generated segments instead of guessing.</p>
<pre><code class="language-ts">oak    { segments: 568, maxRadius: 0.29, branchishSegments: 0 }
pine   { segments: 54,  maxRadius: 0.76, branchishSegments: 40 }
birch  { segments: 406, maxRadius: 0.48, branchishSegments: 0 }
maple  { segments: 460, maxRadius: 0.48, branchishSegments: 0 }
sakura { segments: 414, maxRadius: 0.09, branchishSegments: 0 }
</code></pre>
<p>This told us the truth fast. The broadleaf trees were almost entirely vertical. They had leaves. They had bark. They did not have a real branch scaffold.</p>
<p>Pine looked better because it already had actual lateral structure. The other species were mostly pretending.</p>
<h1>The Broken Idea</h1>
<p>The old oak preset looked tree like on paper.</p>
<pre><code class="language-ts">export const OAK = {
  axiom: 'FA',
  rules: [
    { predecessor: 'A', successor: '!F[&amp;FL!A]////[&amp;FL!A]////[&amp;FL!A]' },
    { predecessor: 'F', successor: 'S//F' },
    { predecessor: 'S', successor: 'F' },
    { predecessor: 'L', successor: '[^^-F+F+F-|-F+F+F]' },
  ],
}
</code></pre>
<p>It feels expressive. It is not structurally honest. The same symbols were trying to be trunk growth. branch growth. recursive continuation. and leaf volume at the same time. The result was vertical motion with decorative foliage.</p>
<p>The renderer could not reveal branches that were never really generated.</p>
<h1>The Fix</h1>
<p>We split responsibilities. We introduced a real trunk symbol <code>T</code>. We introduced a real branch symbol <code>B</code>. Then we made trunk growth continue while branch growth peeled off to the sides.</p>
<pre><code class="language-ts">export const OAK = {
  axiom: 'T',
  rules: [
    { predecessor: 'T', successor: 'FF[+!B][-!B][//+!B][//-!B]T' },
    { predecessor: 'B', successor: 'F[+!B][-!B]F' },
  ],
}
</code></pre>
<p>This did three important things.</p>
<p>First. <code>T</code> keeps the main scaffold alive.</p>
<p>Second. <code>B</code> creates side limbs that can recurse without collapsing back into the trunk.</p>
<p>Third. The extra <code>FF</code> at the start gives each branch system some travel distance before more splitting. That makes limbs readable instead of glued into one top puff.</p>
<p>We applied the same idea to birch. maple. and sakura with different angles and decay values.</p>
<h1>The Supporting Fixes</h1>
<p>We also kept real taper per segment instead of faking radius only at major rule points.</p>
<pre><code class="language-ts">const nextRadius = state.radius * config.segmentTaper
segments.push({
  startRadius: state.radius,
  endRadius: nextRadius,
})
state.radius = nextRadius
</code></pre>
<p>That made branches feel more branch like once the scaffold existed.</p>
<p>We also added a test that checks broadleaf trees actually spread outward.</p>
<pre><code class="language-ts">expect(maxRadius).toBeGreaterThan(1.5)
expect(branchCount).toBeGreaterThan(30)
</code></pre>
<p>That matters because the bug was visual but the cause was structural. We wanted a test that protects structure.</p>
<h1>The Result</h1>
<p>After the rewrite the numbers changed hard.</p>
<pre><code class="language-ts">oak    { segments: 218, maxRadius: 2.73, branchCount: 149 }
pine   { segments: 54,  maxRadius: 0.76, branchCount: 27 }
birch  { segments: 114, maxRadius: 1.90, branchCount: 48 }
maple  { segments: 218, maxRadius: 2.72, branchCount: 152 }
sakura { segments: 218, maxRadius: 2.66, branchCount: 171 }
</code></pre>
<p>That is why the trees finally started reading like trees with branches instead of sticks with toppings.</p>
<p>Oak got scaffold limbs.</p>
<p>Maple got a real canopy.</p>
<p>Sakura got visible structure under the blossom mass.</p>
<p>Birch stayed lighter and more delicate.</p>
<p>Pine stayed good because it was already the closest to a real branching grammar.</p>
<h1>What I Want To Remember</h1>
<p>If a procedural tree looks fake then do not start by tuning leaf textures or bark maps.</p>
<p>Measure branch spread first.</p>
<p>If horizontal spread is near zero then the grammar is lying to you.</p>
<p>Fix the scaffold first.</p>
<p>Then style it.</p>
]]></content:encoded></item><item><title><![CDATA[Nanite Explained: How Modern Game Engines Render Billions of Triangles Without Exploding]]></title><description><![CDATA[Introduction
I went into a bit of a rabbithole studying how Nanite works. Here are some of the things I learned and the write-up I wish existed.
The Four Problems
Every triangle you render costs somet]]></description><link>https://tigerabrodi.blog/nanite-explained-how-modern-game-engines-render-billions-of-triangles-without-exploding</link><guid isPermaLink="true">https://tigerabrodi.blog/nanite-explained-how-modern-game-engines-render-billions-of-triangles-without-exploding</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 13:26:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/9bf07f82-cab8-4823-a27a-2ad5947e9d0b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>I went into a bit of a rabbithole studying how Nanite works. Here are some of the things I learned and the write-up I wish existed.</p>
<h1>The Four Problems</h1>
<p>Every triangle you render costs something. When you have millions of them four problems start screaming at you.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/403c10fe-248a-4acc-b8f0-ced9aa8089ab.png" alt="" style="display:block;margin:0 auto" />

<h2>Draw Calls</h2>
<p>A draw call is a command. The CPU tells the GPU "draw this object". One object. One draw call. Sounds simple.</p>
<p>The problem is the coordination. Every draw call requires the CPU and GPU to sync up. The CPU prepares the command. The GPU receives it. They handshake. This takes time. Not because the drawing is slow. But because the communication is slow.</p>
<p>1000 objects means 1000 draw calls. The CPU spends all its time just talking to the GPU instead of doing useful work. The GPU sits there waiting. This is a bottleneck. The slowest part that holds everything else back.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/74199c28-e2e3-48b5-8329-1687d204f06c.png" alt="" style="display:block;margin:0 auto" />

<h2>Overhead</h2>
<p>Overhead is everything that is NOT drawing pixels. Setting up shaders. Binding textures. Switching materials. State changes. Every time the GPU switches from one material to another there is a cost. All this extra work adds up.</p>
<p>More objects with different materials means more overhead. The GPU spends time preparing instead of rendering.</p>
<h2>Memory</h2>
<p>Triangles take space. A single vertex needs about 32 to 44 bytes. That includes its position in 3D space. Its normal direction. Its texture coordinates. Each triangle also needs 12 bytes for index data.</p>
<p>A 1 million triangle mesh uses roughly 30 to 140 megabytes depending on how it is structured. That lives in VRAM. "VRAM" is the GPU's own memory. It is fast but limited. Fill it up and performance falls off a cliff.</p>
<h2>Computation</h2>
<p>Every triangle goes through a pipeline. The vertex shader transforms it into screen space. The rasterizer figures out which pixels it covers. The fragment shader colors those pixels. Multiply that by millions of triangles and you have an enormous amount of math every single frame.</p>
<p>If you want 60 frames per second you have about 16 milliseconds to do ALL of this. For the entire scene. Every object. Every triangle. Every pixel. 16 milliseconds. That is not a lot.</p>
<h1>The Techniques We Had Before Nanite</h1>
<p>People have been fighting these four problems for decades. Here are the main tools they built.</p>
<h2>Normal Baking</h2>
<p>You start with two models. A high detail version with millions of triangles. And a low detail version with a few thousand. You "bake" the surface detail from the high model into a texture called a normal map. This texture stores the direction each tiny surface patch faces. When light hits the low model it reads the normal map and PRETENDS the surface has all that detail.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/9b511253-f7b6-4be2-bb39-986ef8dde678.png" alt="" style="display:block;margin:0 auto" />

<p>The result looks almost the same. But the GPU only processes a few thousand triangles instead of millions.</p>
<p><strong>The good:</strong> Huge geometry reduction. Low computation. Low overhead.</p>
<p><strong>The bad:</strong> Medium memory cost because you need to store the normal map textures. Medium manual labor because an artist has to create both the high and low poly versions and bake the map carefully. Also the silhouette of the object is still simple. The edges of the shape look low poly because normal maps only fake surface detail. They cannot change the actual outline.</p>
<h2>LOD. Level of Detail</h2>
<p>You create multiple versions of the same model. LOD 0 is the full detail version. LOD 1 has fewer triangles. LOD 2 even fewer. LOD 3 is very simple. The engine swaps between them based on distance from the camera.</p>
<p>Close up you see LOD 0. Far away you see LOD 3. The player never notices.</p>
<p><strong>The good:</strong> High geometry reduction. Saves a lot of GPU work for distant objects.</p>
<p><strong>The bad:</strong> HIGH manual labor. Someone has to create 3 or 4 versions of every single model. That is a lot of work across hundreds of assets. Also "popping". When the engine swaps LOD levels the model visibly changes for a frame. Players can see it. It breaks immersion. And you need to store all versions in memory.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/89b96676-f99f-4b18-9cf0-7d7154cc039c.png" alt="" style="display:block;margin:0 auto" />

<h2>Subdivision Modeling</h2>
<p>The opposite approach. You start with a simple low poly cage. The computer subdivides it. "Subdivide" means split each face into smaller faces and smooth the result. You control how many times it subdivides. More subdivisions means more detail -&gt; more triangles!</p>
<p><strong>The good.</strong> Low manual labor. You build one simple model and the computer generates the detail.</p>
<p><strong>The bad.</strong> Low geometry reduction. It ADDS triangles. It does not remove them. High computation because subdividing at runtime is expensive. It can only add detail. It cannot reduce it based on distance.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/2a5a4abb-fe73-4a3a-906d-d1fffc4052c4.png" alt="" style="display:block;margin:0 auto" />

<h2>Voxels</h2>
<p>Instead of triangles you represent the world as a 3D grid of cubes. Like Minecraft. Each cell in the grid is either filled or empty. You can vary the grid resolution. Coarse grid for far away. Fine grid for close up.</p>
<p><strong>The good:</strong> Low manual labor. Voxel worlds can be generated by code. Dynamic detail adjustment by changing grid resolution.</p>
<p><strong>The bad:</strong> High memory because you store a big 3D grid. High computation to convert voxels into something renderable. Blocky look. Sharp edges are hard to represent. Texturing is problematic because voxels do not naturally support UV coordinates.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/6a24454e-1daa-40f8-8834-df4c27da01f1.png" alt="" style="display:block;margin:0 auto" />

<h2>The Summary</h2>
<p>The first two techniques handle most problems but require a lot of manual work. The second two are more automatic but have other costs. None of them solve everything.</p>
<p>What if there was a system that handled all four problems. Automatically. No manual LOD creation. No popping. No wasted triangles.</p>
<h1>The Fastest Triangle to Render</h1>
<p>Before diving into Nanite there is one idea you need to accept.</p>
<p><strong>The fastest triangle to render is the one you never send to the GPU.</strong></p>
<p>Not "the smallest triangle". Not "the simplest triangle". The one you skip entirely. If you can figure out which triangles the player will never see and throw them away before the GPU even touches them you win. Every triangle you skip saves draw call time. Overhead. Memory. Computation. All four problems at once.</p>
<p>Think about a statue with 33 million triangles. Now put 500 of those statues in a room. That is over 16 billion triangles. You cannot render all of them. But you do not need to. Most of those triangles are either too far away to matter. Or facing away from the camera. Or hidden behind other objects.</p>
<p><strong>The entire job is figuring out which ones to skip.</strong></p>
<h1>Clustering. Organizing the Chaos</h1>
<p>Checking 33 million triangles one by one is way too slow. You need to group them.</p>
<p>A cluster is a small group of triangles. Usually around 128 of them. Instead of asking "should I render this triangle" 33 million times you ask "should I render this cluster" about 250,000 times. Much better.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/53f360b7-0f5d-4604-a8a4-5f030ef5eebf.png" alt="" style="display:block;margin:0 auto" />

<h2>Bounding Volumes</h2>
<p>Each cluster gets wrapped in a simple shape. A sphere or a box. This is called a bounding volume. It is a quick approximation of where the cluster is in space.</p>
<p>Testing "does this simple box intersect the camera view" is way faster than testing 128 individual triangles. If the box is not visible you skip all 128 triangles inside it in one check.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/ee8a1669-d5b4-42fb-809f-39aaccb8e406.png" alt="" style="display:block;margin:0 auto" />

<h2>Bounding Volume Hierarchy. BVH</h2>
<p>You can nest these bounding volumes. Wrap two clusters in a bigger box. Wrap two of those bigger boxes in an even bigger box. Keep going until you have one box that contains the whole object.</p>
<p>This creates a tree structure. At the top is the whole object. At the bottom are individual clusters.</p>
<p>To check visibility you start at the top. <strong>If the big box is not visible you skip EVERYTHING inside it.</strong> One check eliminates thousands of clusters. If it IS visible you go one level deeper and check the two medium boxes. And so on.</p>
<p>This reduces the number of checks from N to log(N). In a scene with 1000 clusters that means roughly 10 checks instead of 1000. Massive speedup.</p>
<h1>Automated LOD With Clusters</h1>
<p>Take your clusters of 128 triangles. That is LOD 0. Full detail.</p>
<p>Now take two neighboring clusters. 256 triangles total. Merge them into one group. Simplify down to 128 triangles. Split into two new clusters.</p>
<p>You went from 256 to 128. Half the detail. One LOD level up. No artist involved. Fully automatic.</p>
<p>Repeat at every level. Each level roughly halves the triangle count until you have a very coarse version of the whole mesh at the top.</p>
<p>The order matters. Merge first. Then simplify. <strong>If you simplify each cluster alone the shared edges between neighbors change independently.</strong> They no longer line up. You get cracks. Visible gaps in the mesh.</p>
<p>By merging first the shared edge is no longer a boundary. It is in the middle of the merged group. Just regular geometry. The simplification cleans it up like any other edge. No cracks.</p>
<p>But here is the problem. Two clusters can only be merged if something can reach both of them. In a tree each cluster has one parent. If two clusters have different parents nobody can merge them. Their shared boundary is stuck forever. Level after level these stuck edges pile up. The mesh fills with dense geometry that cannot be simplified. That is mesh cruft.</p>
<p>The fix is simple. <strong>Let a cluster have two parents.</strong> Both sides of any boundary can reach the shared cluster. Both sides can merge across it. Every boundary gets cleaned up. Nothing is stuck.</p>
<p>That is a DAG. A Directed Acyclic Graph. "Directed" means connections flow one way. Parent to child. "Acyclic" means no loops. The only difference from a tree is that one child can have two parents.</p>
<p>Nanite does not use a DAG because it is fancy. Merging clusters naturally creates shared children. The DAG is just what you end up with when you allow that.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/a47c03c3-020e-4e4a-b463-7f0d0f1749d4.png" alt="" style="display:block;margin:0 auto" />

<h2>Screen Space Error</h2>
<p>Every simplified mesh is slightly different from the original. That difference is the error. A fixed number measured in world units. It never changes.</p>
<p>What changes is how big that error looks on screen. 0.5 centimeters of error up close might cover 8 pixels. The player sees it. 200 meters away the same 0.5 centimeters covers less than one pixel. The player cannot see it. The monitor cannot even display it.</p>
<p>The rule. If the error is smaller than one pixel use the cheaper version.</p>
<p>Each cluster has multiple LOD levels. Each level has a known error. The engine picks the cheapest level where the error is below one pixel. Done.</p>
<p><strong>This happens per cluster. Not per object. Front of a rock might be at LOD 0. Back of the same rock at LOD 3.</strong> Each cluster picks independently every frame. Changes are always smaller than one pixel. That is why there is no popping. The switches are invisible by definition.</p>
<h1>One Giant Draw Call</h1>
<p>Traditional rendering. The CPU tells the GPU "draw this rock". Then "draw this wall". Then "draw this floor". One command per object. 1000 objects means 1000 commands. The CPU spends all its time talking.</p>
<p>Nanite packs all surviving cluster triangles into one big block of data in VRAM. Sends one command. "Draw all of this". Done.</p>
<p>The GPU does the rest. It runs the culling. Picks the LOD levels. Decides what to render. All on its own. The CPU barely participates. This works because GPUs have thousands of cores that run in parallel. Checking 250,000 clusters is 250,000 tiny tasks. Perfect GPU work. A CPU with 16 cores would choke on that. A GPU with thousands of cores eats it.</p>
<p>The only split is materials. Metal needs different shader code than wood. So triangles with different materials still need separate commands. But all the triangles within one material get batched together. Way fewer commands than one per object.</p>
<h1>Culling. Throwing Away What You Cannot See</h1>
<p>Even with automated LODs and batching you still have too many clusters. The next step is figuring out which ones to skip entirely. This is culling.</p>
<p>Culling means removing. Deciding what NOT to render. Three types. Each one catches different things.</p>
<h2>Frustum Culling</h2>
<p>The frustum is the shape of what your camera can see. It looks like a pyramid with the tip cut off. The near end is small and close to the camera. The far end is wide and far away.</p>
<p>Anything outside this shape is off screen. Do not render it. A building behind you. Gone. A tree far to the left. Gone.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/8d4d3111-88c9-4206-8396-cbc5407381d1.png" alt="" style="display:block;margin:0 auto" />

<p>You test each cluster's bounding volume against the frustum. If the bounding box does not intersect the frustum the whole cluster is skipped. Fast and simple.</p>
<p>But frustum culling has a blind spot. It keeps everything INSIDE the frustum. Even objects hidden behind a wall. You can see the wall but not the building behind it. Yet both pass the frustum test.</p>
<h2>Backface Culling</h2>
<p>Every triangle has two sides. A front face and a back face. If a triangle faces away from the camera you are looking at its back. You cannot see it. Skip it.</p>
<p>How does the GPU know which side faces you. Triangle vertices are listed in a specific order. If they appear clockwise on screen the triangle faces you. Counter clockwise means it faces away. The GPU checks this with very fast math.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/65372e19-2bf9-48ab-808f-d4f71d01f62f.png" alt="" style="display:block;margin:0 auto" />

<p>The other way to check is using the dot product. Each triangle has a normal vector pointing outward. If the dot product of the view direction and the normal is positive the triangle faces away. "Dot product" is a math operation that tells you how much two directions agree. Positive means same direction. Negative means opposite directions.</p>
<p>About half of all triangles in any scene face away from you at any given time. So backface culling alone removes roughly 50 percent of the work.</p>
<h2>Occlusion Culling</h2>
<p>The hardest one. A cluster passes the frustum test. Its triangles face the camera. But there is a wall in front of it. The player cannot see the cluster because something else blocks it. That is occlusion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/f5a84573-829f-4e96-9a05-62c9af6d2d7f.png" alt="" style="display:block;margin:0 auto" />

<p>To figure this out you need to know what is in front of what. That requires the depth buffer.</p>
<h2>The Depth Buffer</h2>
<p>The depth buffer is an image the GPU creates while rendering. Every pixel stores how far away the closest surface is. It is a grayscale image. Close things are dark. Far things are bright.</p>
<p>If you want to check whether a cluster is hidden you compare its depth against the depth buffer. If every pixel where the cluster would appear already has something closer then the cluster is fully hidden. Skip it.</p>
<p>But here is a problem. You need the depth buffer to decide what to cull. But you need to render things to build the depth buffer. Chicken and egg.</p>
<h2>Hierarchical Z Buffer. Hi-Z</h2>
<p>This is the trick that makes occlusion culling fast.</p>
<p>First a quick reminder. The depth buffer is an image the GPU builds while rendering. <strong>Every pixel stores how far away the closest visible surface is at that spot on screen. Keyword is visible. Only things that were actually drawn get recorded. If something was culled it is not in there.</strong> -&gt; This took me a while to grasp. This is the key thing. Visible!</p>
<p>Now the problem. Say you want to check if a cluster is hidden behind something. That cluster might cover 10,000 pixels on screen. To check properly you would have to read 10,000 depth values and compare each one. That is slow.</p>
<p>Hi-Z fixes this. You take the depth buffer and build a mipmap chain. A mipmap is a series of smaller copies of the image. Full resolution. Half. Quarter. Eighth. All the way down to one pixel.</p>
<p>But here is the key difference from normal mipmaps. Normal texture mipmaps average the pixels together. Hi-Z takes the MAXIMUM depth value. The furthest point.</p>
<p>Why maximum. This is the part that matters for occlusion.</p>
<p>Say four pixels in the depth buffer have values 5. 8. 3. 10. Those numbers are distances. Something visible was rendered at distance 5 at one pixel. Something at distance 8 at another. And so on. The max is 10. That means the FURTHEST visible thing in that group of four pixels is at distance 10. Everything else there is even closer.</p>
<p><strong>Now you have a cluster at distance 12. It is further away than 10. That means it is further away than EVERYTHING rendered in that group. Even the furthest thing there is still closer than the cluster. So the cluster is behind all of it. Fully hidden. That is occlusion. One check covered four pixels.</strong></p>
<p>Go up another mipmap level. One pixel now covers 16 original pixels. Same logic. One check covers 16 pixels. Next level. 64 pixels. Then 256. Then 1024. A cluster that covers a large area on screen can be tested with just one or two reads at a coarse mipmap level instead of thousands of reads.</p>
<p>To test a cluster you figure out how big it would be on screen. Pick the mipmap level where one pixel roughly covers that area. Read the max depth value. Compare it to the cluster's distance. Cluster is further away. It is behind everything there. Hidden. Cut it. Cluster is closer. It might be visible. Keep it.</p>
<p>This is conservative. It plays it safe. Sometimes it says "maybe visible" when the cluster is actually hidden. That is fine. You render a little extra. But it NEVER says "hidden" when the cluster is actually visible. That would mean missing objects on screen. That would be a bug. So it errs on the side of doing a little extra work rather than skipping something it should not.</p>
<h2>Nanite's Two Pass Occlusion</h2>
<p>Nanite takes this further with two passes.</p>
<p><strong>Pass 1:</strong> Use last frame's depth buffer. Test all clusters against it. Most things that were hidden last frame are still hidden this frame. This catches the majority of occluded clusters immediately.</p>
<p><strong>Pass 2:</strong> Render the clusters that survived pass 1. Build a new depth buffer. Now test any remaining uncertain clusters against this fresh depth buffer. This catches things that just moved into view or edge cases the old depth buffer missed.</p>
<p>Two passes sounds like more work. But it means almost nothing gets rendered unnecessarily. The total work is less than doing it in one pass with guesswork.</p>
<h1>The Small Triangle Problem</h1>
<p>There is one more thing Nanite had to solve.</p>
<p>The GPU processes pixels in groups of 2x2. These groups are called quads. <strong>When a triangle is so small it only covers one pixel in that quad the GPU still runs the fragment shader for all four pixels.</strong> Three of those four pixels are wasted work. Up to 75 percent waste.</p>
<p>With millions of small triangles this waste adds up fast.</p>
<p>Nanite's solution. For clusters where triangle edges are smaller than 32 pixels it uses a software rasterizer. "Software rasterizer" means custom code running on the GPU that replaces the normal hardware rasterization pipeline. This custom code knows how to handle tiny triangles efficiently. No wasted quads.</p>
<p>For larger triangles the normal hardware rasterizer runs as usual. It is already fast for big triangles.</p>
<p>This split made Nanite's rasterization three times faster.</p>
<h1>The Full Picture</h1>
<p>Nanite is not the GPU. Nanite is software built by Epic Games. A system of code that runs on the GPU and tells it exactly what to do. The GPU is the muscle. Nanite is the brain.</p>
<p>It works in two phases.</p>
<h2>Phase 1. Before The Game Runs</h2>
<p>This happens once. When the artist imports a mesh into Unreal Engine.</p>
<h3>Clustering</h3>
<p>An artist imports a 33 million triangle statue. Nanite splits it into clusters. Small groups of 128 triangles each.</p>
<h3>Building The DAG</h3>
<p>It builds the DAG. The structure where clusters can share boundaries with multiple parents. This is what allows clean simplification without cracks.</p>
<h3>Creating The LOD Levels</h3>
<p>Merge neighboring clusters. Simplify. Split into new clusters. Repeat. Each level has roughly half the triangles of the level below it. Old boundaries get cleaned up because they become interior edges after merging. No manual work. Fully automatic.</p>
<h3>Calculating The Error</h3>
<p>For each LOD level it measures how many centimeters the simplified version differs from the original. This number is baked in. It never changes.</p>
<h3>Storing The Data</h3>
<p>Two things get stored. The metadata. Bounding boxes. Error values. Facing directions. DAG connections. This is lightweight. A few megabytes. And the actual triangle data for every LOD level. This is heavy. Sits on disk.</p>
<p>Done. Never repeated.</p>
<hr />
<h2>Phase 2. Every Frame</h2>
<p>The metadata sits in RAM. Always available. The GPU runs through it and filters the clusters down step by step.</p>
<h3>Frustum Culling</h3>
<p>Is this cluster on screen. Test the bounding box against the camera frustum. Off screen. Cut it.</p>
<h3>Backface Culling</h3>
<p>Is this cluster facing the camera. Check the stored normal direction against the view direction. Facing away. Cut it.</p>
<h3>Screen Space Error</h3>
<p>Which LOD level does this cluster need. Convert the error value to pixels based on distance from camera. Pick the cheapest level where the error is below one pixel. Invisible to the player.</p>
<h3>Occlusion Culling Pass 1</h3>
<p>Was this cluster hidden last frame. Check against last frame's Hi-Z depth buffer. Further away than everything at that spot. Cut it.</p>
<h3>Occlusion Culling Pass 2</h3>
<p>Render everything that made it this far. Build a fresh depth buffer. Test uncertain clusters again. Still hidden. Cut it.</p>
<h3>Streaming</h3>
<p>Now the GPU has a small list of clusters that passed every check. Only their triangle data gets streamed from disk into VRAM. Everything else stays on disk untouched.</p>
<h3>Rasterization</h3>
<p>Clusters with tiny triangles where edges are smaller than 32 pixels go through the software rasterizer. Custom code. No wasted pixels. Bigger triangles go through the normal hardware rasterizer.</p>
<h3>The Draw Call</h3>
<p>Here is the important part. By the time the draw call happens the GPU already threw away most of the scene. The culling. The LOD selection. All of that happened before this step. The buffer only contains the clusters that passed every single check.</p>
<p>So the CPU sends one command per material. "Draw this". But "this" is not 33 million triangles. It is maybe 50,000. The GPU is not drawing everything. It is drawing the tiny fraction that actually matters. One command. Small amount of real work.</p>
<p>Frame done. 16 milliseconds. Next frame. Do it all again.</p>
<h1>When to Use Nanite. And When Not To</h1>
<p>Nanite is not the answer to everything.</p>
<p><strong>Use it for:</strong> Scenes with extremely high poly static assets. Photogrammetry scans. Rocks. Walls. Architectural models. Anything that is large. Opaque. And does not move.</p>
<p><strong>Do not use it for:</strong> Dynamic or animated objects like characters. Transparent materials. Masked materials like foliage and leaves. Very small detailed objects. In these cases traditional LOD and culling techniques still work better.</p>
]]></content:encoded></item><item><title><![CDATA[Next.js use cache: remote: A Distributed Cache in One Line]]></title><description><![CDATA[Introduction
I discovered this while digging through the Vercel docs and the Next.js cache components skill. I was trying to understand how caching actually works when you deploy to Vercel. What I fou]]></description><link>https://tigerabrodi.blog/next-js-use-cache-remote-a-distributed-cache-in-one-line</link><guid isPermaLink="true">https://tigerabrodi.blog/next-js-use-cache-remote-a-distributed-cache-in-one-line</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 00:23:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/d45c0679-6498-4d1d-9b1d-ca6e20410644.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>I discovered this while digging through the Vercel docs and the Next.js cache components skill. I was trying to understand how caching actually works when you deploy to Vercel. What I found is one of the most powerful features in Next.js 16 that not enough people are talking about.</p>
<p>You can write <code>'use cache: remote'</code> inside any async function and get a shared distributed cache across all your serverless instances. No Redis. No config. No infrastructure. Just a directive.</p>
<h1>What Cache Components Are</h1>
<p>Next.js 16 introduced Cache Components. You enable them with one line in your config:</p>
<pre><code class="language-ts">// next.config.ts
const nextConfig = {
  cacheComponents: true,
};
</code></pre>
<p>This unlocks three directives:</p>
<ul>
<li><p><code>'use cache'</code>: in-memory cache on the server</p>
</li>
<li><p><code>'use cache: remote'</code>: shared remote cache across all instances</p>
</li>
<li><p><code>'use cache: private'</code>: per-user browser cache</p>
</li>
</ul>
<p>You put these directives inside async functions. Next.js caches the return value. That is it.</p>
<h1>The Problem with Plain <code>use cache</code></h1>
<p>Plain <code>'use cache'</code> stores data in memory. On Vercel, your app runs as serverless functions. Each function instance has its own memory. When the instance shuts down, the cache is gone.</p>
<p>So this happens:</p>
<ol>
<li><p>User A hits your site. Instance A spins up. Fetches data. Caches it in memory.</p>
</li>
<li><p>User B hits your site. Instance B spins up. It has no idea about Instance A's cache. Fetches data again.</p>
</li>
<li><p>Instance A gets recycled. Cache gone.</p>
</li>
</ol>
<p>For build-time prerendered content this is fine. The cached result is baked into the static shell and served from the CDN. But for runtime data, anything that runs at request time, the in-memory cache is basically useless in a serverless environment.</p>
<h1>What <code>use cache: remote</code> Does Differently</h1>
<p><code>'use cache: remote'</code> stores the cached data in a shared key-value store that all serverless instances can access. On Vercel, this is called Runtime Cache. It lives in the same region as your function. You do not set it up. Vercel provides it automatically.</p>
<p>Now:</p>
<ol>
<li><p>User A hits your site. Instance A fetches data. Stores it in the remote cache.</p>
</li>
<li><p>User B hits your site. Instance B checks the remote cache. Finds the data. Skips the database.</p>
</li>
<li><p>Instance A gets recycled. Does not matter. The cache is in the remote store, not in memory.</p>
</li>
</ol>
<p>Every user. Every serverless instance. Same region. Same cache.</p>
<h1>How It Works in Practice</h1>
<p>Say you have a homepage with featured products from Supabase.</p>
<pre><code class="language-tsx">import { createClient } from "@/utils/supabase/server";
import { cacheTag, cacheLife } from "next/cache";

export async function getFeaturedProducts() {
  "use cache: remote";
  cacheTag("products");
  cacheLife("max");

  const supabase = await createClient();
  const { data } = await supabase
    .from("products")
    .select("*")
    .eq("featured", true);

  return data;
}
</code></pre>
<p>First request hits Supabase. Result goes into the remote cache. Every request after that skips Supabase entirely. Your database sees one request instead of thousands.</p>
<p>Use it in your page like any normal function:</p>
<pre><code class="language-tsx">export default async function HomePage() {
  const products = await getFeaturedProducts();
  return (
    &lt;section&gt;
      {products.map((p) =&gt; (
        &lt;ProductCard key={p.id} product={p} /&gt;
      ))}
    &lt;/section&gt;
  );
}
</code></pre>
<p>It does not matter what data source you use. Supabase. Prisma. Drizzle. Raw fetch. A GraphQL client. <code>'use cache: remote'</code> caches the return value of the function. It does not care how the data was fetched.</p>
<h1>Cache Forever Until You Invalidate</h1>
<p>You can cache data indefinitely. Use <code>cacheLife('max')</code> or set a custom long expiration:</p>
<pre><code class="language-tsx">cacheLife({
  stale: 31536000,
  revalidate: 31536000,
  expire: 31536000,
});
</code></pre>
<p>That is roughly one year. The cache stays until you explicitly kill it. Which brings us to the best part.</p>
<h1>Invalidation with Tags</h1>
<p>Every cached function can be tagged with <code>cacheTag()</code>. When your data changes, call <code>revalidateTag()</code> to drop the cache.</p>
<pre><code class="language-tsx">// Tag your cached functions
async function getHeroContent() {
  "use cache: remote";
  cacheTag("homepage", "hero");
  cacheLife("max");
  return db.hero.findFirst();
}

async function getFeaturedProducts() {
  "use cache: remote";
  cacheTag("homepage", "products");
  cacheLife("max");
  return db.products.findMany({ where: { featured: true } });
}

async function getTestimonials() {
  "use cache: remote";
  cacheTag("homepage", "testimonials");
  cacheLife("max");
  return db.testimonials.findMany();
}
</code></pre>
<p>Notice the trick. Each function has a shared <code>'homepage'</code> tag and a specific tag. So you can invalidate just products. Or nuke the entire homepage cache at once.</p>
<pre><code class="language-tsx">"use server";

import { revalidateTag } from "next/cache";

// Invalidate just products
export async function onProductUpdate() {
  revalidateTag("products");
}

// Invalidate the entire homepage
export async function refreshHomepage() {
  revalidateTag("homepage");
}
</code></pre>
<p>Call these from a server action, a webhook, an admin panel. The cache entry drops across every region. Next request rebuilds it fresh.</p>
<p>There are two invalidation functions:</p>
<ul>
<li><p><code>revalidateTag()</code>: background revalidation. Current request might still see stale data. Next request sees fresh data.</p>
</li>
<li><p><code>updateTag()</code>: immediate. Same request sees fresh data.</p>
</li>
</ul>
<h1>Time-Based Invalidation</h1>
<p>If you do not want to manage tags manually, use time-based expiration:</p>
<pre><code class="language-tsx">async function getTrendingPosts() {
  "use cache: remote";
  cacheLife("hours"); // revalidates every few hours
  return db.posts.findMany({ orderBy: { views: "desc" } });
}
</code></pre>
<p>Built-in profiles: <code>'minutes'</code>, <code>'hours'</code>, <code>'days'</code>, <code>'weeks'</code>, <code>'max'</code>.</p>
<p>Or go custom:</p>
<pre><code class="language-tsx">cacheLife({
  stale: 300, // 5 min. serve old data while refreshing
  revalidate: 600, // 10 min. background refresh interval
  expire: 3600, // 1 hour. hard expiration
});
</code></pre>
<p><code>stale</code> means the data is old but still served while fresh data is fetched in the background. <code>revalidate</code> is how often Next.js checks for new data behind the scenes. <code>expire</code> is the hard cutoff where the cache is completely gone.</p>
<h1>Where the Cache Actually Lives</h1>
<p>On Vercel, <code>use cache: remote</code> uses Vercel's Runtime Cache. It is a key-value store in each region where your functions run. You do not configure it.</p>
<ul>
<li><p>Shared across all users in the same region. Yes.</p>
</li>
<li><p>Shared across all serverless instances. Yes.</p>
</li>
<li><p>Shared across regions. No. Each region has its own cache.</p>
</li>
<li><p>Survives new deployments. Yes.</p>
</li>
<li><p>Shared between preview and production. No. Separate environments.</p>
</li>
</ul>
<p>The word "non-durable" in the docs means Vercel can evict entries under memory pressure. It is a cache, not a database. Your database is still the source of truth. If the cache misses, your function just re-fetches and caches again.</p>
<p>If you are self-hosting (not on Vercel), you need to configure your own cache handler via <code>cacheHandlers</code> in <code>next.config.ts</code>. You can use Redis, Memcached, DynamoDB, whatever. But on Vercel it is zero config.</p>
<h1>One Rule: No Runtime APIs Inside</h1>
<p>You cannot call <code>cookies()</code>, <code>headers()</code>, or read <code>searchParams</code> inside a <code>'use cache: remote'</code> function. These change per request. Caching them makes no sense.</p>
<p>The fix: read them outside, pass the value in as an argument.</p>
<pre><code class="language-tsx">async function ProductPrice({ productId }: { productId: string }) {
  const currency = (await cookies()).get("currency")?.value ?? "USD";
  const price = await getPrice(productId, currency);
  return (
    &lt;span&gt;
      {price} {currency}
    &lt;/span&gt;
  );
}

async function getPrice(productId: string, currency: string) {
  "use cache: remote";
  cacheTag(`price-${productId}`);
  cacheLife({ expire: 3600 });

  // Cache key = productId + currency
  // All users with same currency share this entry
  return db.products.getPrice(productId, currency);
}
</code></pre>
<p>The arguments automatically become part of the cache key. Different arguments create different cache entries. So all USD users share one entry. All EUR users share another.</p>
<h1>Smart Cache Key Design</h1>
<p>Cache on dimensions with few unique values. Filter the rest in memory.</p>
<pre><code class="language-tsx">// BAD: caching per price filter creates thousands of entries
async function getProducts(category: string, minPrice: number) {
  "use cache: remote";
  return db.products.find({ category, minPrice });
}

// GOOD: cache per category, filter price in memory
async function getProducts(category: string) {
  "use cache: remote";
  return db.products.findByCategory(category);
}

// Then in your component
const products = await getProducts("electronics");
const filtered = products.filter((p) =&gt; p.price &gt;= minPrice);
</code></pre>
<p>Same idea with user data. Do not cache per user ID. Cache per language or role or region. Fewer entries. Higher hit rate.</p>
<h1>The Three Directives Compared</h1>
<table>
<thead>
<tr>
<th></th>
<th><code>use cache</code></th>
<th><code>use cache: remote</code></th>
<th><code>use cache: private</code></th>
</tr>
</thead>
<tbody><tr>
<td>Storage</td>
<td>In-memory</td>
<td>Remote KV store</td>
<td>Browser only</td>
</tr>
<tr>
<td>Shared across users</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Shared across instances</td>
<td>No</td>
<td>Yes</td>
<td>N/A</td>
</tr>
<tr>
<td>Access cookies/headers</td>
<td>No</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Extra cost</td>
<td>None</td>
<td>Infrastructure</td>
<td>None</td>
</tr>
<tr>
<td>Best for</td>
<td>Static shell content</td>
<td>Runtime shared data</td>
<td>Per-user compliance</td>
</tr>
</tbody></table>
<h1>Why This Matters</h1>
<p>Before this, if you wanted a distributed cache in front of your database, you had to set up Redis or Memcached, write cache keys manually, handle invalidation yourself, and manage infrastructure.</p>
<p>Now you write <code>'use cache: remote'</code> and <code>cacheTag('products')</code> inside a function. Call <code>revalidateTag('products')</code> when data changes. Done. A distributed cache with declarative invalidation built into your component tree.</p>
<p>Your database goes from handling every single request to handling one request per cache lifetime per region. That is a massive reduction in load, cost, and latency.</p>
<p>It is one of the most practical features in modern web development and I think more people should know about it.</p>
]]></content:encoded></item><item><title><![CDATA[How To Implement A Level And XP System With Convex]]></title><description><![CDATA[The goal
A good level and XP system should do three things well. It should be easy to reason about. It should be hard to exploit. It should be flexible enough to support more than one reward source. T]]></description><link>https://tigerabrodi.blog/how-to-implement-a-level-and-xp-system-with-convex</link><guid isPermaLink="true">https://tigerabrodi.blog/how-to-implement-a-level-and-xp-system-with-convex</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 00:19:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/29d86bd4-ad85-4e76-b1bd-21b830569e3a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>The goal</h1>
<p>A good level and XP system should do three things well. It should be easy to reason about. It should be hard to exploit. It should be flexible enough to support more than one reward source. That means the math should be pure, and the write path should be centralized.</p>
<h1>What to store</h1>
<p>Keep the long lived progression state on the profile.</p>
<pre><code class="language-ts">profiles: defineTable({
  xp: v.number(),
  level: v.number(),
  streakCount: v.number(),
})
</code></pre>
<p><code>xp</code> is the real source of truth for progression. <code>level</code> is a cached convenience field. <code>streakCount</code> matters if your XP system includes a streak multiplier.</p>
<h1>Keep level math pure</h1>
<p>Do not calculate level progression ad hoc inside mutations. Keep the math in one pure module. This is the pattern.</p>
<pre><code class="language-ts">function getXpForNextLevel({ currentLevel }: { currentLevel: number }): number {
  return Math.floor(100 * Math.pow(1.3, currentLevel - 1))
}

function getLevelFromXp({ totalXp }: { totalXp: number }): number {
  let level = 1
  let cumulativeXp = 0

  while (true) {
    const xpForNextLevel = getXpForNextLevel({ currentLevel: level })
    if (cumulativeXp + xpForNextLevel &gt; totalXp) {
      return level
    }

    cumulativeXp += xpForNextLevel
    level += 1
  }
}

function getCurrentLevelProgress({ totalXp }: { totalXp: number }) {
  const currentLevel = getLevelFromXp({ totalXp })
  let cumulativeXpToCurrentLevel = 0

  for (let level = 1; level &lt; currentLevel; level += 1) {
    cumulativeXpToCurrentLevel += getXpForNextLevel({ currentLevel: level })
  }

  const xpIntoLevel = totalXp - cumulativeXpToCurrentLevel
  const xpForNextLevel = getXpForNextLevel({ currentLevel })

  return {
    currentLevel,
    xpIntoLevel,
    xpForNextLevel,
    percentComplete: xpForNextLevel === 0 ? 0 : xpIntoLevel / xpForNextLevel,
  }
}
</code></pre>
<p>This gives you one clear level curve and one clear progress calculation.</p>
<h1>Decide your reward sources explicitly</h1>
<p>Do not just sprinkle XP everywhere. Name the reward sources. That makes analytics, balancing, and future refactors much easier. This is the pattern.</p>
<pre><code class="language-ts">type XpAwardSource = 'word' | 'world' | 'chapter'

type XpAward = {
  source: XpAwardSource
  baseXp: number
}

const LEVEL_COMPLETION_XP = 10
const WORLD_COMPLETION_BONUS_XP = 50
const CHAPTER_COMPLETION_BONUS_XP = 150
</code></pre>
<p>Now your system is not a pile of anonymous numbers. It is a list of named rewards.</p>
<h1>Add the streak multiplier as a pure function</h1>
<p>If you want streaks to matter, keep that logic pure too.</p>
<pre><code class="language-ts">function getStreakMultiplier({ streakDays }: { streakDays: number }): number {
  if (streakDays &lt;= 2) return 1
  if (streakDays &lt;= 6) return 1.2
  if (streakDays &lt;= 29) return 1.5
  if (streakDays &lt;= 99) return 2
  if (streakDays &lt;= 364) return 2.5

  const yearsAfterFirst = Math.floor((streakDays - 365) / 365)
  return 3 + yearsAfterFirst * 0.5
}

function calculateXpEarned({
  baseXp,
  streakMultiplier,
}: {
  baseXp: number
  streakMultiplier: number
}) {
  return Math.floor(baseXp * streakMultiplier)
}
</code></pre>
<p>This makes the multiplier easy to test and easy to rebalance later.</p>
<h1>Support multiple XP awards in one completion</h1>
<p>This is the part many systems get wrong. A single completion can trigger more than one reward. For example. A level completion reward. A world completion bonus. A chapter completion bonus. If you model XP as one number only, the logic becomes messy fast. Instead, accept an array of award items.</p>
<pre><code class="language-ts">function getXpBatchAwardOutcome({
  awards,
  currentXp,
  streakMultiplier,
}: {
  awards: ReadonlyArray&lt;XpAward&gt;
  currentXp: number
  streakMultiplier: number
}) {
  const awardsBreakdown = awards.map((award) =&gt; ({
    ...award,
    xpEarned: calculateXpEarned({
      baseXp: award.baseXp,
      streakMultiplier,
    }),
  }))

  const totalXpEarned = awardsBreakdown.reduce(
    (total, award) =&gt; total + award.xpEarned,
    0
  )

  const newXp = currentXp + totalXpEarned
  const previousLevel = getLevelFromXp({ totalXp: currentXp })
  const newLevel = getLevelFromXp({ totalXp: newXp })

  return {
    awardsBreakdown,
    previousXp: currentXp,
    newXp,
    previousLevel,
    newLevel,
    didLevelUp: newLevel &gt; previousLevel,
    levelsGained: newLevel - previousLevel,
    totalXpEarned,
  }
}
</code></pre>
<p>This is much easier to understand. It also gives the frontend a clean payload for XP animations.</p>
<h1>Centralize the write path in one internal mutation</h1>
<p>This is the most important implementation detail. Do not let every gameplay mutation patch XP by itself. Create one internal XP mutation that applies awards. This is the pattern.</p>
<pre><code class="language-ts">export const awardXp = internalMutation({
  args: {
    profileId: v.id('profiles'),
    awards: v.array(
      v.object({
        baseXp: v.number(),
        source: v.union(
          v.literal('word'),
          v.literal('world'),
          v.literal('chapter')
        ),
      })
    ),
  },
  handler: async (ctx, args) =&gt; {
    const profile = await ctx.db.get(args.profileId)
    if (!profile || profile.deletedAt) {
      throw appError({
        code: 'NOT_FOUND',
        message: 'Profile not found',
      })
    }

    const streakMultiplier = getStreakMultiplier({
      streakDays: profile.streakCount,
    })

    const outcome = getXpBatchAwardOutcome({
      awards: args.awards,
      currentXp: profile.xp,
      streakMultiplier,
    })

    await ctx.db.patch(profile._id, {
      xp: outcome.newXp,
      level: outcome.newLevel,
    })

    return outcome
  },
})
</code></pre>
<p>That keeps the write path consistent. It also makes it very hard to award XP twice by accident in different places.</p>
<h1>Why this shape is good</h1>
<p>This shape gives you a few strong properties. The math is pure. The mutation is simple. Every reward is explicit. Every reward source is trackable. The frontend gets a clean response with <code>didLevelUp</code>, <code>levelsGained</code>, and <code>totalXpEarned</code>. That is exactly what a dynamic UI needs.</p>
<h1>How to think about the algorithm</h1>
<p>The algorithm is not just math. It is product design. Here are the practical questions that matter. How fast should level one feel. How long should level growth take later. How much should streaks matter. Should world and chapter bonuses feel rare and meaningful. Should replay give zero XP. The code should make those decisions easy to change. That is another reason pure helpers are so valuable.</p>
<h1>A practical reward model</h1>
<p>A simple model that works well is this. <code>10</code> XP for a normal level. <code>50</code> bonus XP for finishing a world. <code>150</code> bonus XP for finishing a chapter. Then apply the streak multiplier once to each award item. That gives you good momentum without making the math hard to follow.</p>
<h1>Replay should not touch progression</h1>
<p>This rule should be strict. Replay is for practice. Not for farming XP. So in replay mode. No progress writes. No XP writes. No unlock changes. That keeps the system fair and keeps the mental model clean.</p>
<h1>What to test</h1>
<p>The best part of this system is that most of the hard parts are pure. Test these first. <code>getXpForNextLevel</code> <code>getLevelFromXp</code> <code>getCurrentLevelProgress</code> <code>getStreakMultiplier</code> <code>calculateXpEarned</code> <code>getXpBatchAwardOutcome</code> Those tests give you confidence before you ever touch a real mutation.</p>
<h1>The main lesson</h1>
<p>A good XP system is not one mutation. It is a small progression engine. If you keep the engine pure and keep the write path centralized, the rest of the app gets much easier to build.</p>
]]></content:encoded></item><item><title><![CDATA[How I Fixed Slow Convex Storage Assets With Convex]]></title><description><![CDATA[The problem
We had a simple problem. Query prefetch was working. Images and audio still felt cold. That meant the next screen could have its data ready, but still show an image pop in late, or start a]]></description><link>https://tigerabrodi.blog/how-i-fixed-slow-convex-storage-assets-with-convex</link><guid isPermaLink="true">https://tigerabrodi.blog/how-i-fixed-slow-convex-storage-assets-with-convex</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 00:17:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c0066818-c956-4131-a8b1-3fc6b524cbd5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>The problem</h1>
<p>We had a simple problem. Query prefetch was working. Images and audio still felt cold. That meant the next screen could have its data ready, but still show an image pop in late, or start audio late. That feels bad. It makes the app feel slower than it really is.</p>
<h1>The first thing we checked</h1>
<p>We traced the actual asset requests. The built in Convex storage URLs looked like this.</p>
<pre><code class="language-txt">https://&lt;deployment&gt;.convex.cloud/api/storage/&lt;storageId&gt;
</code></pre>
<p>Then we checked the response headers. The important detail was the cache policy. The built in storage response was not behaving like a strong public immutable asset. That was the root issue. The browser could cache it, but not in the strongest way we wanted for repeated image and audio usage.</p>
<h1>The constraint</h1>
<p>We did not want to move assets out of Convex. That is important. The goal was not to replace Convex storage. The goal was to keep assets in Convex, and serve them better.</p>
<h1>The fix in one sentence</h1>
<p>We kept assets in Convex storage, added our own cached asset route on <code>convex.site</code>, rewrote storage URLs to that route on the client, and made preloading much stricter for both images and audio.</p>
<h1>Step 1, create a cached asset route in Convex</h1>
<p>The first fix was backend. We added a custom HTTP route that reads from Convex storage and serves the file with stronger caching headers. This is the important pattern.</p>
<pre><code class="language-ts">import { httpRouter } from 'convex/server'
import type { Id } from './_generated/dataModel'
import { httpAction } from './_generated/server'

const http = httpRouter()

const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365
const CACHE_CONTROL = `public, max-age=\({ONE_YEAR_IN_SECONDS}, s-maxage=\){ONE_YEAR_IN_SECONDS}, immutable`

http.route({
  pathPrefix: '/cached-assets/',
  method: 'GET',
  handler: httpAction(async (ctx, request) =&gt; {
    const url = new URL(request.url)
    const storageId = decodeURIComponent(
      url.pathname.slice('/cached-assets/'.length)
    ) as Id&lt;'_storage'&gt;

    const metadata = await ctx.storage.getMetadata(storageId)
    if (!metadata) {
      return new Response('Asset not found', { status: 404 })
    }

    const etag = `\"${metadata.sha256}\"`
    const ifNoneMatch = request.headers.get('if-none-match')

    if (ifNoneMatch?.includes(etag)) {
      return new Response(null, {
        status: 304,
        headers: {
          'Cache-Control': CACHE_CONTROL,
          'CDN-Cache-Control': CACHE_CONTROL,
          ETag: etag,
        },
      })
    }

    const blob = await ctx.storage.get(storageId)
    if (!blob) {
      return new Response('Asset not found', { status: 404 })
    }

    return new Response(blob, {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': CACHE_CONTROL,
        'CDN-Cache-Control': CACHE_CONTROL,
        'Content-Type':
          metadata.contentType ?? blob.type ?? 'application/octet-stream',
        'Cross-Origin-Resource-Policy': 'cross-origin',
        ETag: etag,
      },
    })
  }),
})

export default http
</code></pre>
<p>This gives you a stable URL shape and proper immutable caching, while still reading from Convex storage.</p>
<h1>Step 2, normalize every Convex storage URL on the client</h1>
<p>Once we had a better route, we needed to make the app use it everywhere. The clean way is one helper.</p>
<pre><code class="language-ts">const BUILT_IN_STORAGE_PATH_PREFIX = '/api/storage/'
const CACHED_ASSET_PATH_PREFIX = '/cached-assets/'

function getCachedAssetUrl(url: string | null | undefined) {
  if (!url) {
    return null
  }

  if (
    url.startsWith('/') ||
    url.startsWith('data:') ||
    url.startsWith('blob:')
  ) {
    return url
  }

  const assetUrl = new URL(url)

  if (!assetUrl.pathname.startsWith(BUILT_IN_STORAGE_PATH_PREFIX)) {
    return url
  }

  const storageId = assetUrl.pathname.slice(BUILT_IN_STORAGE_PATH_PREFIX.length)
  const convexSiteUrl = (
    import.meta.env.VITE_CONVEX_SITE_URL as string
  ).replace(/\/$/, '')

  return `\({convexSiteUrl}\){CACHED_ASSET_PATH_PREFIX}${encodeURIComponent(storageId)}`
}
</code></pre>
<p>That helper matters a lot. Without it, you end up fixing one component at a time forever.</p>
<h1>Step 3, make image preloading strict</h1>
<p>Our old image preloader was too optimistic. It marked assets as preloaded too early. That is not enough. You want to dedupe in flight work, wait for real load, and wait for decode if possible. This is the pattern.</p>
<pre><code class="language-ts">const preloadedImagePaths = new Set&lt;string&gt;()
const inFlightImagePreloads = new Map&lt;string, Promise&lt;void&gt;&gt;()

function preloadImage(path: string) {
  const normalizedPath = getCachedAssetUrl(path)
  if (!normalizedPath) {
    return Promise.resolve()
  }

  if (preloadedImagePaths.has(normalizedPath)) {
    return Promise.resolve()
  }

  const existing = inFlightImagePreloads.get(normalizedPath)
  if (existing) {
    return existing
  }

  const preloadPromise = new Promise&lt;void&gt;((resolve) =&gt; {
    const image = new Image()
    let hasSettled = false

    function settle(didLoad: boolean) {
      if (hasSettled) return
      hasSettled = true
      inFlightImagePreloads.delete(normalizedPath)
      if (didLoad) {
        preloadedImagePaths.add(normalizedPath)
      }
      resolve()
    }

    image.decoding = 'async'
    image.addEventListener(
      'load',
      () =&gt; {
        if (typeof image.decode === 'function') {
          void image
            .decode()
            .catch(() =&gt; {})
            .finally(() =&gt; settle(true))
          return
        }

        settle(true)
      },
      { once: true }
    )

    image.addEventListener('error', () =&gt; settle(false), { once: true })
    image.src = normalizedPath
  })

  inFlightImagePreloads.set(normalizedPath, preloadPromise)
  return preloadPromise
}
</code></pre>
<h1>Step 4, preload audio as a first class thing</h1>
<p>Audio needs the same treatment. If you only solve images, the app still feels cold. This is the audio version of the same idea.</p>
<pre><code class="language-ts">const preloadedAudioUrls = new Set&lt;string&gt;()
const inFlightAudioPreloads = new Map&lt;string, Promise&lt;void&gt;&gt;()

function preloadAudioUrl(url: string) {
  const normalizedUrl = getCachedAssetUrl(url)
  if (!normalizedUrl) {
    return Promise.resolve()
  }

  if (preloadedAudioUrls.has(normalizedUrl)) {
    return Promise.resolve()
  }

  const existing = inFlightAudioPreloads.get(normalizedUrl)
  if (existing) {
    return existing
  }

  const preloadPromise = new Promise&lt;void&gt;((resolve) =&gt; {
    const audio = new Audio()
    let hasSettled = false

    function settle(didLoad: boolean) {
      if (hasSettled) return
      hasSettled = true
      inFlightAudioPreloads.delete(normalizedUrl)
      if (didLoad) {
        preloadedAudioUrls.add(normalizedUrl)
      }
      resolve()
    }

    audio.preload = 'auto'
    audio.addEventListener('canplaythrough', () =&gt; settle(true), { once: true })
    audio.addEventListener('loadeddata', () =&gt; settle(true), { once: true })
    audio.addEventListener('error', () =&gt; settle(false), { once: true })
    audio.src = normalizedUrl
    audio.load()
  })

  inFlightAudioPreloads.set(normalizedUrl, preloadPromise)
  return preloadPromise
}
</code></pre>
<h1>Step 5, make runtime playback use the same normalized URLs</h1>
<p>This part is easy to miss. If preload warms one URL and playback uses another URL, you lose the benefit. So your playback layer should normalize URLs too.</p>
<pre><code class="language-ts">function getOrCreateAudio(path: string): HTMLAudioElement {
  const cachedPath = getCachedAssetUrl(path) ?? path
  const cached = state.cache.get(cachedPath)
  if (cached) return cached

  const audio = new Audio(cachedPath)
  state.cache.set(cachedPath, audio)
  return audio
}
</code></pre>
<p>We applied the same rule in our sound manager, entry voice manager, route music controller, and celebration audio flow.</p>
<h1>Step 6, warm the network early</h1>
<p>Even with perfect preload logic, the first request still pays DNS and TLS if the browser has not warmed those origins yet. So we warmed both Convex origins early.</p>
<pre><code class="language-html">&lt;link rel="dns-prefetch" href="%VITE_CONVEX_URL%" /&gt;
&lt;link rel="preconnect" href="%VITE_CONVEX_URL%" crossorigin /&gt;
&lt;link rel="dns-prefetch" href="%VITE_CONVEX_SITE_URL%" /&gt;
&lt;link rel="preconnect" href="%VITE_CONVEX_SITE_URL%" crossorigin /&gt;
</code></pre>
<p>That does not solve everything by itself. But it helps the first useful request.</p>
<h1>Step 7, prefetch media, not just queries</h1>
<p>This was the final important lesson. Query prefetch is not enough for a media heavy app. You also need to preload the exact image and audio for the next screen. This is the pattern we used on hover.</p>
<pre><code class="language-ts">prefetchGameplayScreen(
  {
    chapterId,
    worldId,
    replayMode: false,
  },
  {
    onResult: (result) =&gt; {
      if (!result) {
        return
      }

      void preloadImages(
        [result.level.imageUrl, result.world.imageUrl].filter(
          (url): url is string =&gt; Boolean(url)
        )
      )

      void preloadAudioUrls(
        [
          result.level.wordAudioUrl,
          result.world.musicUrl,
          result.world.completionVoiceUrl,
        ].filter((url): url is string =&gt; Boolean(url))
      )
    },
  }
)
</code></pre>
<p>This is what makes the next screen actually feel warm.</p>
<h1>What changed after the fix</h1>
<p>After this change, the app started doing four things together. It warmed the connection earlier. It rewrote Convex storage URLs to a better cached Convex route. It deduped in flight image and audio preloads. It preloaded the next screen media before navigation. That combination is what mattered.</p>
<h1>What this does not solve</h1>
<p>The very first uncached request for a brand new asset can still cost real network time. That is normal. You cannot remove physics. What this fix does is remove the repeated cold feeling.</p>
<h1>The main lesson</h1>
<p>If your next screen is media heavy, query prefetch is only half the job. You need asset delivery, asset caching, and asset preload to be part of the architecture too. If you are using Convex, you can solve that cleanly without moving assets out of Convex.</p>
]]></content:encoded></item><item><title><![CDATA[How To Implement A Daily Streak System With Convex]]></title><description><![CDATA[The goal
A daily streak system sounds simple. It is not. The tricky part is not counting. The tricky part is deciding what a day means. If someone played at 10pm, then again at 2am, that is only four ]]></description><link>https://tigerabrodi.blog/how-to-implement-a-daily-streak-system-with-convex</link><guid isPermaLink="true">https://tigerabrodi.blog/how-to-implement-a-daily-streak-system-with-convex</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Wed, 15 Apr 2026 00:15:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/baa7087a-90f5-4a1b-ae3e-8b765b4e58dd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>The goal</h1>
<p>A daily streak system sounds simple. It is not. The tricky part is not counting. The tricky part is deciding what a day means. If someone played at 10pm, then again at 2am, that is only four hours later. But it is still a new local day. That should count as a streak extension. So the system needs to be local day based, not elapsed hours based.</p>
<h1>What to store</h1>
<p>Keep the streak state on the profile. That is enough for a solid version one.</p>
<pre><code class="language-ts">profiles: defineTable({
  streakCount: v.number(),
  lastStreakDate: v.optional(v.number()),
  longestStreak: v.number(),
})
</code></pre>
<p>These three fields are enough to drive the whole feature. <code>streakCount</code>, the current streak. <code>lastStreakDate</code>, when you last counted a streak day. <code>longestStreak</code>, the best streak this profile has ever had.</p>
<h1>The rule set</h1>
<p>You need clear rules. These are the rules we use. Same local day, no update. Next local day, extend by one. Miss more than one local day, mark the streak as broken, then start again at one. The return to one is important. The person came back today, so today still counts.</p>
<h1>Why local time zone matters</h1>
<p>Do not compare raw UTC dates. Do not compare elapsed hours. Do not use the server time zone. Pass the user time zone from the client and calculate the day boundary on the backend. We used <code>date-fns</code> and <code>@date-fns/tz</code> for that.</p>
<pre><code class="language-ts">import { TZDate } from '@date-fns/tz'
import { differenceInDays, startOfDay } from 'date-fns'

function getLocalDayDifference({
  lastStreakDate,
  nowMs,
  timeZone,
}: {
  lastStreakDate: number
  nowMs: number
  timeZone: string
}) {
  const lastUpdateStart = startOfDay(new TZDate(lastStreakDate, timeZone))
  const todayStart = startOfDay(new TZDate(nowMs, timeZone))
  return differenceInDays(todayStart, lastUpdateStart)
}
</code></pre>
<p>That one helper is the core of the feature.</p>
<h1>Keep the streak math pure</h1>
<p>Do not put the streak decision logic straight inside the mutation. Make it a pure helper first. That makes it easy to test and easy to trust. This is the pattern.</p>
<pre><code class="language-ts">type DailyStreakUpdateStatus = 'unchanged' | 'extended' | 'broken'

function getDailyStreakUpdate({
  currentStreakCount,
  lastStreakDate,
  longestStreak,
  nowMs,
  timeZone,
}: {
  currentStreakCount: number
  lastStreakDate: number | null | undefined
  longestStreak: number
  nowMs: number
  timeZone: string
}) {
  if (!lastStreakDate) {
    return {
      status: 'extended' as DailyStreakUpdateStatus,
      shouldUpdate: true,
      wasFirstUpdate: true,
      previousCount: currentStreakCount,
      newCount: 1,
      newLongestStreak: Math.max(longestStreak, 1),
    }
  }

  const dayDifference = getLocalDayDifference({
    lastStreakDate,
    nowMs,
    timeZone,
  })

  if (dayDifference &lt;= 0) {
    return {
      status: 'unchanged' as DailyStreakUpdateStatus,
      shouldUpdate: false,
      wasFirstUpdate: false,
      previousCount: currentStreakCount,
      newCount: currentStreakCount,
      newLongestStreak: longestStreak,
    }
  }

  const status = dayDifference === 1 ? 'extended' : 'broken'
  const newCount = status === 'extended' ? currentStreakCount + 1 : 1

  return {
    status,
    shouldUpdate: true,
    wasFirstUpdate: false,
    previousCount: currentStreakCount,
    newCount,
    newLongestStreak: Math.max(longestStreak, newCount),
  }
}
</code></pre>
<h1>The mutation</h1>
<p>Once the pure helper is right, the Convex mutation gets simple.</p>
<pre><code class="language-ts">export const syncDailyStreak = mutation({
  args: {
    timeZone: v.string(),
  },
  handler: async (ctx, args) =&gt; {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      throw appError({
        code: 'NOT_AUTHENTICATED',
        message: 'You must be signed in to update the daily streak',
      })
    }

    const user = await ctx.db.get(userId)
    const profile = user?.activeProfileId
      ? await ctx.db.get(user.activeProfileId)
      : null

    if (!profile || profile.deletedAt) {
      throw appError({
        code: 'NOT_FOUND',
        message: 'Active profile not found',
      })
    }

    const nowMs = Date.now()
    const update = getDailyStreakUpdate({
      currentStreakCount: profile.streakCount,
      lastStreakDate: profile.lastStreakDate ?? null,
      longestStreak: profile.longestStreak,
      nowMs,
      timeZone: args.timeZone,
    })

    if (!update.shouldUpdate) {
      return {
        didUpdate: false,
        status: update.status,
        previousStreakCount: update.previousCount,
        newStreakCount: update.newCount,
      }
    }

    await ctx.db.patch(profile._id, {
      streakCount: update.newCount,
      lastStreakDate: nowMs,
      longestStreak: update.newLongestStreak,
    })

    return {
      didUpdate: true,
      status: update.status,
      previousStreakCount: update.previousCount,
      newStreakCount: update.newCount,
    }
  },
})
</code></pre>
<h1>The frontend trigger</h1>
<p>You do not want to run this on every render. A good pattern is this. Wait for the first user interaction. Then sync once. Then sync again when the tab becomes visible later. That keeps the system responsive without spamming writes.</p>
<pre><code class="language-ts">useEffect(() =&gt; {
  if (hasInteracted) return

  function handleFirstInteraction() {
    setHasInteracted(true)
    void syncDailyStreak({ timeZone })
  }

  window.addEventListener('pointerdown', handleFirstInteraction, {
    once: true,
  })
  window.addEventListener('keydown', handleFirstInteraction, {
    once: true,
  })

  return () =&gt; {
    window.removeEventListener('pointerdown', handleFirstInteraction)
    window.removeEventListener('keydown', handleFirstInteraction)
  }
}, [hasInteracted, syncDailyStreak, timeZone])
</code></pre>
<h1>When to show the streak dialog</h1>
<p>Only show the dialog if the mutation says <code>didUpdate</code>. That means. No dialog on the same local day. Dialog on the first ever day. Dialog on a proper next day extension. Dialog on a broken streak that restarts at one. That part keeps the UX honest.</p>
<h1>One bug that is easy to create</h1>
<p>Do not wipe streak fields when you only meant to reset gameplay progress. This is easy to do in dev tools and admin reset helpers. If you clear <code>streakCount</code> and <code>lastStreakDate</code>, the next interaction looks like day one again. Then the dialog opens again, even on the same day. That is not a streak math bug. That is a reset helper bug.</p>
<h1>What to test</h1>
<p>The streak helper is pure, so test it first. These cases matter. First ever update returns one. Same local day returns unchanged. Next local day extends by one. Missing more than one local day returns broken and restarts at one. The 10pm to 2am case across local midnight extends correctly. That gives you confidence in the hard part before you wire UI on top.</p>
<h1>The main lesson</h1>
<p>A good daily streak system is really a local calendar system. Once you treat it that way, the implementation gets much cleaner.</p>
]]></content:encoded></item><item><title><![CDATA[Why EXR Is the Real Deal for Skyboxes]]></title><description><![CDATA[Regular images are broken for this
A PNG stores colors as numbers from 0 to 255. That is it. The brightest white and the sun are both 255. But in reality the sun is 10,000x brighter than a white wall.]]></description><link>https://tigerabrodi.blog/why-exr-is-the-real-deal-for-skyboxes</link><guid isPermaLink="true">https://tigerabrodi.blog/why-exr-is-the-real-deal-for-skyboxes</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Tue, 14 Apr 2026 21:06:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/9d586672-f3db-4f89-9a19-12206fc5e29e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Regular images are broken for this</h1>
<p>A PNG stores colors as numbers from 0 to 255. That is it. The brightest white and the sun are both 255. But in reality the sun is 10,000x brighter than a white wall. PNG cannot store that. Everything above "white" gets crushed to the same value.</p>
<p>When a 3D engine uses a PNG skybox for lighting, everything looks flat. The sun is not actually bright. The shadows are not actually dark. The light has no real range.</p>
<h1>What EXR does differently</h1>
<p>EXR stores each pixel as a real floating point number. The sun can be 50000.0. A cloud can be 2.0. A shadow can be 0.003. That is the actual spread of light in the real world, stored in the file.</p>
<p>HDR (.hdr) files do this too, but with a trick. They share one exponent across all three color channels. That means if red is very bright and blue is very dim in the same pixel, blue loses precision. It is a compromise.</p>
<p>EXR gives each channel its own full float. No sharing. No compromise. No precision loss anywhere.</p>
<h1>Why this matters</h1>
<p>In PBR rendering, the skybox is not just a background picture. It IS the light source. Every material in the scene samples it to figure out ambient lighting and reflections. Metals reflect the sky. Rough surfaces pick up its color. The skybox drives everything.</p>
<p>If the skybox has no real brightness range (PNG), all reflections and lighting look the same. A metal surface reflecting the sun looks identical to one reflecting a cloud. Wrong.</p>
<p>If the skybox has banding in subtle gradients (HDR's shared exponent), those bands show up in every reflection across the scene.</p>
<p>EXR has the full range and full precision. The lighting just works.</p>
<h1>How the GPU actually uses it</h1>
<p>Three.js takes the EXR skybox and generates blurred versions of it at different levels. Mirror-smooth surfaces sample the sharp version. Rough surfaces sample the blurry version.</p>
<p>This only works when the source has real brightness values. Blurring a PNG averages numbers between 0 and 1. The sun disappears into the blur. Blurring an EXR averages numbers between 0 and 50000. The sun still pumps light into the scene even when heavily blurred. That is what makes rough surfaces glow softly under bright sky. PNG cannot do this.</p>
<h1>EXR files are also smaller</h1>
<p>HDR files are basically uncompressed. EXR supports lossless compression (ZIP, PIZ). Better quality AND smaller files.</p>
<h2>How to use it in Three.js</h2>
<pre><code class="language-ts">import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';

const loader = new EXRLoader();
loader.load('sky.exr', (texture) =&gt; {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = texture;    // The visible sky.
  scene.environment = texture;   // Lights every PBR material in the scene.
});
</code></pre>
<p>Two lines. Every material in the scene now gets correct ambient lighting and reflections from the sky. Automatic.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Procedural Planet from Scratch: The Full Pipeline]]></title><description><![CDATA[Introduction
This is a walkthrough of every layer in a procedural 3D world generation system. Each section solves one problem. They build on each other in order. By the end you have a fully textured, ]]></description><link>https://tigerabrodi.blog/building-a-procedural-planet-from-scratch-the-full-pipeline</link><guid isPermaLink="true">https://tigerabrodi.blog/building-a-procedural-planet-from-scratch-the-full-pipeline</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Tue, 14 Apr 2026 05:08:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/637a8cd1-167d-4a0b-9310-ae5bc8af9ac6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>This is a walkthrough of every layer in a procedural 3D world generation system. Each section solves one problem. They build on each other in order. By the end you have a fully textured, threaded, planet-scale terrain with proper LOD, stitching, and depth precision.</p>
<hr />
<h1>1. Heightmap: The Starting Point</h1>
<p><strong>What:</strong> A flat grid of vertices where each vertex's height is set by sampling some height function.</p>
<p><strong>Why:</strong> Every terrain system starts here. Before noise, before LOD, before planets, you need to turn a flat plane into bumpy ground.</p>
<p><strong>How:</strong> Create a <code>PlaneGeometry</code> with a resolution (say 128x128 vertices). Loop through every vertex. For each one, sample a height function at its XZ position. Set the Y (or Z depending on orientation) to that value.</p>
<pre><code class="language-js">for (let v of plane.geometry.vertices) {
  const [height, weight] = heightGenerator.Get(v.x + offset.x, v.y + offset.y);
  v.z = height * weight;
}
plane.geometry.verticesNeedUpdate = true;
plane.geometry.computeVertexNormals();
</code></pre>
<p>The height function can be anything. An image (read pixel brightness as height), a math function, or noise. The code supports multiple height generators blended together by weight, so you can layer a heightmap image with procedural noise.</p>
<p>The world is split into chunks. Each chunk is one <code>PlaneGeometry</code> at some offset. This lets you load/unload terrain as the player moves.</p>
<hr />
<h1>2. Perlin/Simplex Noise</h1>
<p><strong>What:</strong> Replace the heightmap image with a procedural noise function that generates infinite, non-repeating terrain.</p>
<p><strong>Why:</strong> An image heightmap has fixed resolution and fixed extent. Noise is infinite. You can sample it at any coordinate and get a consistent, deterministic value. The terrain goes on forever.</p>
<p><strong>How:</strong> The noise class wraps either Perlin or Simplex noise with octaves. "Octaves" means you sample the noise multiple times at increasing frequencies and decreasing amplitudes, then add them together. This is called fractal Brownian motion (fBm).</p>
<pre><code class="language-js">Get(x, y) {
  const xs = x / this._params.scale;
  const ys = y / this._params.scale;
  let amplitude = 1.0;
  let frequency = 1.0;
  let total = 0;
  let normalization = 0;

  for (let o = 0; o &lt; this._params.octaves; o++) {
    total += amplitude * this._noise.Get(xs * frequency, ys * frequency);
    normalization += amplitude;
    amplitude *= this._params.persistence;
    frequency *= this._params.lacunarity;
  }

  total /= normalization;
  return Math.pow(total, this._params.exponentiation) * this._params.height;
}
</code></pre>
<p>The key parameters:</p>
<ul>
<li><p><strong>scale</strong>: how zoomed in/out the noise is. Larger = smoother hills.</p>
</li>
<li><p><strong>octaves</strong>: how many layers. More = more fine detail.</p>
</li>
<li><p><strong>persistence</strong>: how much each octave's amplitude shrinks. Lower = smoother.</p>
</li>
<li><p><strong>lacunarity</strong>: how much each octave's frequency increases. Higher = more detail per octave.</p>
</li>
<li><p><strong>exponentiation</strong>: raise the final value to a power. Values &gt; 1 flatten valleys and sharpen peaks. Makes terrain look more natural.</p>
</li>
</ul>
<hr />
<h1>3. Quadtree and Level of Detail (LOD)</h1>
<p><strong>What:</strong> A quadtree that subdivides space near the camera into small chunks and keeps distant areas as large chunks.</p>
<p><strong>Why:</strong> You cannot render the entire world at full resolution. A planet might need millions of chunks at max detail. LOD means: high detail near the camera, low detail far away. The quadtree decides where to split.</p>
<p><strong>How:</strong> Start with one big node covering the whole terrain. Insert the camera position. If the camera is close enough to a node and the node is bigger than the minimum size, split it into four children. Recurse. Nodes near the camera get split many times (small, detailed). Nodes far away stay large (coarse).</p>
<pre><code class="language-js">_Insert(child, pos) {
  const dist = child.center.distanceTo(pos);

  if (dist &lt; child.size.x &amp;&amp; child.size.x &gt; MIN_NODE_SIZE) {
    child.children = this._CreateChildren(child);
    for (let c of child.children) {
      this._Insert(c, pos);
    }
  }
}
</code></pre>
<p>The leaf nodes of the quadtree become terrain chunks. Each leaf gets the same vertex resolution (say 64x64), but covers a different world-space area. A small leaf near the camera covers 500m at 64x64, giving fine detail. A large leaf far away covers 8000m at 64x64, giving coarse detail. Same vertex count, different density. That is LOD.</p>
<hr />
<h1>4. Planetary LOD: From Flat to Spherical</h1>
<p><strong>What:</strong> Wrap the flat quadtree terrain onto a sphere to make a planet.</p>
<p><strong>Why:</strong> A flat terrain has edges. A planet does not. For a space game or planet-scale world, you need the terrain to wrap around a sphere.</p>
<p><strong>How:</strong> Instead of one quadtree, use six. Each one maps to one face of a cube. Each face has a transform matrix that positions and rotates it to form the six sides of a cube. This is called a "cube sphere".</p>
<pre><code class="language-js">// 6 faces: +Y, -Y, +X, -X, +Z, -Z
for (let i = 0; i &lt; 6; i++) {
  sides.push({
    transform: transforms[i],
    quadtree: new QuadTree({
      side: i,
      size: radius,
      localToWorld: transforms[i],
    }),
  });
}
</code></pre>
<p>When generating vertices, each vertex position starts as a point on the cube face, gets projected onto a sphere by normalizing it (making it unit length) and multiplying by the planet radius. Then height is added along the radial direction (outward from the planet center).</p>
<pre><code class="language-js">_P.set(xp - half, yp - half, radius);
_P.add(offset);
_P.normalize(); // Project onto unit sphere.
_D.copy(_P); // Save the radial direction.
_P.multiplyScalar(radius);

const height = generateHeight(worldPos);
_H.copy(_D);
_H.multiplyScalar(height);
_P.add(_H); // Push vertex outward by height.
</code></pre>
<p>The terrain is now spherical. The quadtree still works the same, it just operates on each cube face.</p>
<hr />
<h1>5. Texturing: Triplanar Mapping, Splatting, Blending</h1>
<p><strong>What:</strong> Apply multiple terrain textures (grass, rock, sand, snow) based on height, slope, and biome, without visible tiling.</p>
<p><strong>Why:</strong> Vertex colors look flat. Real terrain needs texture detail. But naively mapping a texture onto terrain causes visible repetition (tiling). And you need different textures at different altitudes and slopes.</p>
<p><strong>How:</strong> Three techniques combined.</p>
<p><strong>Texture splatting:</strong> Each vertex stores weights for up to 4 textures. The CPU decides which textures based on height and surface angle (slope). Steep surfaces get rock. Flat high areas get snow. Low flat areas get grass. These weights are passed to the shader as vertex attributes.</p>
<p><strong>Triplanar mapping:</strong> Instead of using UV coordinates (which stretch badly on steep surfaces), sample the texture three times, once for each axis: XY, XZ, YZ. Blend based on the surface normal direction. Steep walls use the XZ projection. Flat ground uses the XY projection. No stretching.</p>
<pre><code class="language-glsl">vec4 dx = texture(tex, pos.zy / scale);  // X-facing
vec4 dy = texture(tex, pos.xz / scale);  // Y-facing (top-down)
vec4 dz = texture(tex, pos.xy / scale);  // Z-facing

vec3 weights = abs(normal.xyz);
weights /= (weights.x + weights.y + weights.z);

return dx * weights.x + dy * weights.y + dz * weights.z;
</code></pre>
<p><strong>Texture bombing:</strong> To hide tiling repetition, the shader randomly offsets and blends the texture samples using a noise lookup. Two slightly offset samples of the same texture are blended with a smooth transition. The pattern never visibly repeats.</p>
<p>All three combine in the fragment shader: splat weights pick which textures, triplanar removes stretch, bombing hides tiling.</p>
<hr />
<h1>6. Atmospheric Scattering</h1>
<p><strong>What:</strong> A sky shader that simulates light scattering through the atmosphere, giving realistic sunsets, blue skies, and haze at the horizon.</p>
<p><strong>Why:</strong> A skybox texture is static. If the sun moves, the sky does not respond. Atmospheric scattering computes sky color physically based on the sun angle.</p>
<p><strong>How:</strong> The shader casts a ray from the camera through each pixel into the atmosphere (a shell around the planet). It marches along the ray, accumulating Rayleigh scattering (which makes the sky blue) and Mie scattering (which creates the bright glow around the sun). The result depends on: sun position, altitude, and the angle between the view direction and the sun.</p>
<p>This also adds distance fog naturally. Objects further away pick up more atmospheric haze. It makes the planet feel massive because distant terrain fades into the sky color instead of just clipping at the far plane.</p>
<hr />
<h1>7. Threading with Web Workers</h1>
<p><strong>What:</strong> Move all terrain mesh generation off the main thread into a pool of Web Workers.</p>
<p><strong>Why:</strong> Generating terrain vertices involves thousands of noise samples, normal calculations, texture weight computations. This is heavy math. Doing it on the main thread freezes the game.</p>
<p><strong>How:</strong> A <code>WorkerPool</code> manages 7 workers (one per spare CPU core). When a new chunk is needed, the terrain manager serializes the parameters into plain data (no class instances, workers cannot receive those) and enqueues it. The pool assigns it to the next free worker.</p>
<p>The worker reconstructs noise generators from the params, runs all the mesh generation math, packs the results into <code>SharedArrayBuffer</code> instances (zero-copy shared memory), and posts a "done" message. The main thread reads the shared buffers directly into Three.js geometry attributes. No copy, no stall.</p>
<pre><code class="language-js">// Worker side: pack into shared memory.
const posBuf = new Float32Array(new SharedArrayBuffer(4 * positions.length));
posBuf.set(positions);
self.postMessage({ positions: posBuf });

// Main thread: read directly.
chunk.geometry.setAttribute(
  "position",
  new THREE.Float32BufferAttribute(result.positions, 3),
);
</code></pre>
<p>The main thread never waits for workers. It renders what it has. When a chunk finishes in the background, it appears. Completely smooth.</p>
<hr />
<h1>8. Floating Origin</h1>
<p><strong>What:</strong> Periodically re-center the world around the camera so GPU coordinates stay near zero.</p>
<p><strong>Why:</strong> GPUs use 32-bit floats. At large distances from the origin (100,000+ units), precision drops. Vertices jitter, triangles flicker (z-fighting), and the scene breaks visually.</p>
<p><strong>How:</strong> Vertex positions are computed relative to the camera position, not in absolute world space. The noise function still samples at the true world coordinate (so terrain is consistent everywhere), but the actual mesh data the GPU receives is always near zero.</p>
<pre><code class="language-js">// Keep absolute position for noise sampling.
_W.copy(_P);

// Subtract camera position for GPU coordinates.
_P.sub(origin);

// Add height in world space direction.
const height = generateHeight(_W);
_P.add(heightVector);
</code></pre>
<p>This is visible in the <code>Rebuild</code> method where <code>origin</code> is the camera position passed in as a parameter. Every vertex subtracts it.</p>
<hr />
<h1>9. Logarithmic Depth Buffer</h1>
<p><strong>What:</strong> Replace the standard depth buffer encoding with a logarithmic one.</p>
<p><strong>Why:</strong> The default depth buffer distributes most of its precision near the camera's near plane. For a planet-scale world where near=1 and far=1,000,000, almost all depth precision is in the first few meters. Everything beyond is z-fighting.</p>
<p><strong>How:</strong> In the vertex shader, compute a logarithmic depth value:</p>
<pre><code class="language-glsl">vFragDepth = 1.0 + gl_Position.w;
</code></pre>
<p>In the fragment shader, write it out:</p>
<pre><code class="language-glsl">gl_FragDepth = log2(vFragDepth) * logDepthBufFC * 0.5;
</code></pre>
<p>Where <code>logDepthBufFC = 2.0 / log2(farPlane + 1.0)</code>. This spreads depth precision evenly across the entire range on a logarithmic scale. Close objects still get fine precision, but distant objects also get usable precision instead of nearly zero.</p>
<p>The cost: writing <code>gl_FragDepth</code> in the fragment shader disables the GPU's early-Z optimization. Every fragment runs the shader even if it would be occluded. This is a performance tradeoff worth making at planetary scale.</p>
<hr />
<h1>10. Mesh Stitching: Gaps, Skirts, and Edge Matching</h1>
<p><strong>What:</strong> Fix the visible cracks that appear where terrain chunks of different LOD levels meet.</p>
<p><strong>Why:</strong> A high-res chunk has 64 vertices along its edge. Its lower-res neighbour has 32. The vertices do not line up. You get cracks you can see through.</p>
<p><strong>How:</strong> Three techniques.</p>
<p><strong>Edge skirts:</strong> Each chunk generates one extra row of vertices on every side (resolution + 2 instead of resolution). These outer vertices are pushed to match their inner neighbour's position. They form a "flap" that tucks under the adjacent chunk. If there is a crack, the skirt geometry fills it.</p>
<p><strong>Edge snapping:</strong> Each chunk knows the size ratio of its neighbours (stored in a <code>neighbours</code> array). If a neighbour is half the resolution, the high-res edge vertices are lerped to match the low-res grid. Vertex 1 between vertex 0 and vertex 2 gets interpolated to lie exactly on the line between them. Both chunks now agree on the same edge geometry.</p>
<pre><code class="language-js">if (neighbours[side] &gt; 1) {
  const stride = neighbours[side];
  // For each edge vertex, find which two low-res vertices it falls between.
  // Lerp its position to match.
}
</code></pre>
<p><strong>Edge normal recomputation:</strong> Face-averaged normals at chunk borders are wrong because they only see faces on one side. The code recomputes edge normals using central differences: sample the height function at two nearby points, compute the cross product. This gives correct normals independent of chunk boundaries.</p>
<p>When a chunk's neighbour changes LOD level, only the edges need updating. That is what <code>QuickRebuild</code> does. It recomputes edge positions and normals without regenerating the entire mesh.</p>
<hr />
<h1>11. Biome Generation</h1>
<p><strong>What:</strong> Use a separate noise function to divide the world into biomes (desert, forest, tundra, etc.) that affect terrain color and texture selection.</p>
<p><strong>Why:</strong> Without biomes, the entire planet uses the same height-to-texture mapping everywhere. With biomes, different regions of the world look distinct.</p>
<p><strong>How:</strong> A second noise generator produces a biome value at each world position. This biome value is separate from the height noise. It uses different parameters (fewer octaves, larger scale) so biomes change gradually over large distances.</p>
<p>The texture splatter reads both the height and the biome value to decide which textures to apply. Low altitude in a desert biome gets sand. Low altitude in a temperate biome gets grass. The biome noise smoothly transitions between regions, and the texture blending handles the crossover.</p>
<pre><code class="language-js">const biomeGenerator = new Noise({
  octaves: 2,
  persistence: 0.5,
  lacunarity: 2.0,
  scale: 2048.0, // Very large scale. Biomes are big regions.
  seed: 2, // Different seed from terrain noise.
});

// In the texture splatter:
const biome = biomeGenerator.Get(worldX, worldY);
// Use biome + height + slope to pick texture weights.
</code></pre>
<p>The biome noise is cheap (only 2 octaves) because it does not need fine detail. It just needs to vary smoothly across the planet.</p>
<hr />
<h1>How It All Fits Together</h1>
<p>The full pipeline for rendering one frame:</p>
<ol>
<li><p><strong>Quadtree</strong> subdivides the six cube faces based on camera distance. Produces a set of leaf nodes (chunks to render).</p>
</li>
<li><p><strong>Diff</strong> against the previous frame's chunks. New chunks get sent to the <strong>worker pool</strong>.</p>
</li>
<li><p>Each <strong>worker</strong> receives plain parameters, reconstructs noise generators, generates vertex positions on the <strong>sphere</strong> surface using noise + height.</p>
</li>
<li><p>Positions are offset by the <strong>camera origin</strong> (floating origin). Heights are sampled from the <strong>biome-aware texture splatter</strong>.</p>
</li>
<li><p><strong>Edge stitching</strong> snaps borders to match lower-res neighbours. Skirts fill any remaining gaps.</p>
</li>
<li><p>Results are packed into <strong>SharedArrayBuffer</strong> and sent back zero-copy.</p>
</li>
<li><p>Main thread sets the geometry attributes and makes the chunk visible.</p>
</li>
<li><p>The <strong>terrain shader</strong> applies triplanar texturing with bombing, computes lighting, and writes <strong>logarithmic depth</strong>.</p>
</li>
<li><p><strong>Atmospheric scattering</strong> renders the sky and distance haze.</p>
</li>
</ol>
<p>Each part solves one specific problem. Together they produce a planet you can walk on, fly over, and zoom out from to see from space, all running smoothly in a browser.</p>
]]></content:encoded></item><item><title><![CDATA[PBR: What It Actually Is and How Each Map Works]]></title><description><![CDATA[Introduction
PBR stands for physically based rendering. It is a lighting model that simulates how light actually behaves in the real world.
All examples are from Patina. A moss

How light works in rea]]></description><link>https://tigerabrodi.blog/pbr-what-it-actually-is-and-how-each-map-works</link><guid isPermaLink="true">https://tigerabrodi.blog/pbr-what-it-actually-is-and-how-each-map-works</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Mon, 13 Apr 2026 15:36:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/0a6365e0-7d17-407a-abe8-0c74d8e69647.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>PBR stands for physically based rendering. It is a lighting model that simulates how light actually behaves in the real world.</p>
<p>All examples are from <a href="https://fal.ai/models/fal-ai/patina/material/playground">Patina</a>. A moss</p>
<hr />
<h1>How light works in reality</h1>
<p>Light hits a surface. Two things happen. Some light bounces off the surface immediately. That is reflection. Some light penetrates the surface, bounces around inside the material, and comes back out. That is diffuse color.</p>
<p>The ratio between these two depends on what the material is made of. That is the entire foundation of PBR.</p>
<p>PBR describes materials using a set of texture maps. Each map controls exactly one physical property. The shader combines them all into realistic lighting.</p>
<hr />
<h1>Basecolor (albedo)</h1>
<p>The color of the material itself. Nothing else. No shadows, no highlights, no reflections. Just: what color are the molecules of this thing.</p>
<p>Dirt is brown. Concrete is grey. Rust is orange-brown.</p>
<p>If you took a material into a perfectly evenly lit white room with zero shadows and zero reflections, what you see is the basecolor.</p>
<p>Under the hood: the shader multiplies this color by the diffuse lighting contribution. Light hits the surface, some penetrates, bounces around inside, comes out tinted by this color.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/67f12202-ffa3-49d9-89fb-588b753453e1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Normal map</h1>
<p>A texture where each pixel stores a direction instead of a color. The RGB values encode an XYZ vector. This vector tells the shader "pretend the surface is pointing this direction" even though the actual geometry is flat.</p>
<p>Why it matters: a flat wall with a normal map of bricks will catch light as if every brick edge and groove were actual geometry. Light hits the "grooves" at a different angle than the "faces" so you see shadows and highlights that look 3D. But the mesh is still a flat quad. Zero extra triangles.</p>
<p>Under the hood: the shader reads the normal map and replaces the geometric normal with the one from the texture. All lighting calculations use this new normal. The dot product between the light direction and the normal gives different values per pixel. The surface looks bumpy even though it is flat.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/fd1eae4a-38cc-4518-86f1-9de83b8fc141.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Roughness</h1>
<p>Think about two surfaces. A mirror and a chalkboard. Throw a laser beam at a mirror. It bounces in one tight direction. Sharp, focused reflection. Now throw that same laser at a chalkboard. The light scatters everywhere. No clear reflection. Just a soft bright spot.</p>
<p>That is roughness. It controls how scattered or focused the reflected light is.</p>
<ul>
<li><p><strong>Roughness = 0</strong>: mirror. Light bounces in one direction. Sharp reflections. Wet surfaces, polished metal, glass.</p>
</li>
<li><p><strong>Roughness = 0.5</strong>: somewhere in between. A soft, blurry highlight. Like plastic or worn leather.</p>
</li>
<li><p><strong>Roughness = 1</strong>: chalk. Light scatters in all directions. No visible reflection. Dry concrete, cloth, raw wood.</p>
</li>
</ul>
<p>Under the hood: the shader uses roughness to control the width of the specular highlight. The way to think about it is: imagine the surface at a microscopic level as millions of tiny mirrors angled randomly. Low roughness means all the tiny mirrors face the same direction (smooth surface). High roughness means the mirrors face random directions (rough surface).</p>
<p>The math (usually GGX distribution) computes how many of those microfacets happen to reflect light toward the camera. More aligned = tight bright highlight. More scattered = wide dim highlight.</p>
<pre><code class="language-glsl">// Simplified concept:
float highlight = pow(dot(normal, halfVector), sharpness);
// sharpness comes from roughness.
// Low roughness = high sharpness = tight highlight.
// High roughness = low sharpness = wide dim highlight.
</code></pre>
<p>This is also why wet things look shiny. Water fills the microscopic grooves, making the surface smoother at the micro level. Roughness goes down. Reflections appear. Dry the surface out, the grooves are exposed again, roughness goes back up.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/e44bfe91-c816-40df-8f87-87cd37322bf6.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Metalness</h1>
<p>This answers one question: does this surface conduct electricity?</p>
<p>Metals (steel, gold, copper, aluminum): yes. Non-metals (wood, dirt, plastic, skin): no.</p>
<p>Why does electrical conductivity matter for rendering? Because metals and non-metals reflect light in fundamentally different ways.</p>
<p><strong>Non-metals</strong> reflect a small amount of light (about 4%) as white or grey. The rest penetrates the surface and comes back out tinted by the basecolor. So you see the diffuse color strongly and a subtle white highlight on top.</p>
<p><strong>Metals</strong> reflect almost all light (60-90%) and that reflection is tinted by the basecolor. No light penetrates. No diffuse contribution at all. The "color" you see on a gold bar IS its reflection color, not diffuse color. Gold reflects yellow light. Copper reflects orange light.</p>
<p>So metalness switches how the shader interprets the basecolor map:</p>
<ul>
<li><p><strong>Metalness = 0</strong>: basecolor is used for diffuse. Reflections are white/grey.</p>
</li>
<li><p><strong>Metalness = 1</strong>: basecolor is used for reflection tint. No diffuse at all.</p>
</li>
</ul>
<p>Under the hood:</p>
<pre><code class="language-glsl">// Simplified:
vec3 diffuseColor = basecolor * (1.0 - metalness);
vec3 reflectionColor = mix(vec3(0.04), basecolor, metalness);

// metalness = 0: diffuse is full basecolor, reflection is 4% white.
// metalness = 1: diffuse is zero, reflection is basecolor.
</code></pre>
<p>The <code>0.04</code> is not arbitrary. It is the measured reflectance of most non-metal surfaces (glass, plastic, water all hover around 4% reflectance at direct viewing angles). This is called the Fresnel reflectance at normal incidence, or F0.</p>
<p>In practice, most natural materials are metalness 0. Dirt, rock, grass, wood, skin, cloth. The only things with metalness are actual metals. And metalness is almost always 0 or 1, rarely in between. A value of 0.5 does not mean "kinda metal." It usually means there is a transition zone between metal and non-metal at that pixel, like rust on steel.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/6338f7c9-fd6b-403c-9986-2179d3fa62bd.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>Height map</h1>
<p>Stores how raised or sunken each point on the surface is. White = high. Black = low.</p>
<p>Two uses:</p>
<p><strong>Parallax mapping.</strong> The shader offsets texture coordinates based on the view angle and the height value. This fakes depth without moving any geometry. Cracks look like they go into the surface. Bricks look like they stick out. It is an optical illusion computed per pixel.</p>
<p><strong>Displacement mapping.</strong> Actually move the vertices based on the height map. This requires enough geometry to displace. More triangles = more detail. This is the real deal but it is expensive.</p>
<p>For terrain: the noise function handles large-scale height (mountains and valleys). A material's height map adds small-scale detail like individual stones sticking up from rocky ground.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/c24cc4eb-d7bd-4e08-8da6-3994057f0eb6.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h1>How they all combine in the shader</h1>
<p>One lighting pass, all maps working together:</p>
<pre><code class="language-plaintext">1. Read basecolor, normal, roughness, metalness from textures.

2. Compute material properties from metalness:
   diffuseColor  = basecolor * (1.0 - metalness)
   specularColor = mix(0.04, basecolor, metalness)

3. Perturb the surface normal using the normal map.

4. Compute diffuse lighting:
   diffuse = light * diffuseColor * max(dot(N, L), 0.0)

5. Compute specular lighting using GGX:
   specular = light * specularColor * GGX(roughness, N, H, V)
   (N = normal, H = halfway vector, V = view direction)

6. Final color = diffuse + specular
</code></pre>
<p>Each map controls exactly one variable in this pipeline. Basecolor provides color. Normal adjusts the surface direction. Roughness shapes the specular highlight. Metalness routes the basecolor to either diffuse or specular. Height adds geometric detail.</p>
<p>That is PBR. Describe the physics, let the math do the rendering.</p>
<hr />
<h1>Quick reference</h1>
<table>
<thead>
<tr>
<th>Map</th>
<th>What it controls</th>
<th>Black means</th>
<th>White means</th>
</tr>
</thead>
<tbody><tr>
<td>Basecolor</td>
<td>Surface color</td>
<td>Dark material</td>
<td>Bright material</td>
</tr>
<tr>
<td>Normal</td>
<td>Surface direction per pixel</td>
<td>Flat</td>
<td>Bumpy</td>
</tr>
<tr>
<td>Roughness</td>
<td>Reflection sharpness</td>
<td>Mirror smooth</td>
<td>Completely matte</td>
</tr>
<tr>
<td>Metalness</td>
<td>Metal or not</td>
<td>Non-metal (dirt, wood)</td>
<td>Metal (steel, gold)</td>
</tr>
<tr>
<td>Height</td>
<td>Surface displacement</td>
<td>Sunken/low</td>
<td>Raised/high</td>
</tr>
</tbody></table>
]]></content:encoded></item><item><title><![CDATA[Dirty Flag. Skip Work That Does Not Matter Yet.]]></title><description><![CDATA[Introduction
Sometimes your program does expensive calculations over and over for no reason. The input changed three times but you only needed the output once. You are throwing away work. The dirty fl]]></description><link>https://tigerabrodi.blog/dirty-flag-skip-work-that-does-not-matter-yet</link><guid isPermaLink="true">https://tigerabrodi.blog/dirty-flag-skip-work-that-does-not-matter-yet</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Sun, 12 Apr 2026 14:21:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/0087393b-022f-4064-9c6e-c53daf4d99c9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Sometimes your program does expensive calculations over and over for no reason. The input changed three times but you only needed the output once. You are throwing away work. The dirty flag pattern fixes this by deferring the expensive part until someone actually needs the result.</p>
<h1>The Problem.</h1>
<p>You have some primary data. You have some derived data that is computed from it. The computation is expensive. The primary data changes often. But the derived data is not always read immediately after every change.</p>
<p>If you recompute every time the primary data changes, you waste cycles on results that get thrown away before anyone uses them.</p>
<h1>A Concrete Example. Scene Graphs.</h1>
<p>In a game, objects are often arranged in a hierarchy. A ship has a crow's nest. The crow's nest has a pirate. The pirate has a parrot. Each object stores a local transform, its position relative to its parent.</p>
<p>To render any object, you need its world transform. That means multiplying all the local transforms up the parent chain. Ship times nest times pirate times parrot.</p>
<p>Now imagine in a single frame the ship moves, the nest rocks, the pirate leans, and the parrot hops. If you recalculate world transforms eagerly after each change, the parrot's world transform gets recalculated four times. Only the last result matters. The other three are wasted.</p>
<h1>The Solution.</h1>
<p>Do not recalculate when the primary data changes. Just set a flag that says "this is out of date." When something actually needs the derived data, check the flag. If dirty, recalculate and clear it. If clean, use the cached value.</p>
<p>Setting a transform becomes two assignments. The expensive math only happens once, right when you need the result.</p>
<h1>The Code.</h1>
<pre><code class="language-typescript">class GraphNode {
  private local: Transform;
  private world: Transform;
  private dirty = true;
  private children: GraphNode[] = [];
  private mesh: Mesh | null;

  setTransform(local: Transform) {
    this.local = local;
    this.dirty = true;
    // no recalculation. just mark it.
  }

  render(parentWorld: Transform, parentDirty: boolean) {
    const dirty = this.dirty || parentDirty;

    if (dirty) {
      this.world = this.local.combine(parentWorld);
      this.dirty = false;
    }

    if (this.mesh) renderMesh(this.mesh, this.world);

    for (const child of this.children) {
      child.render(this.world, dirty);
    }
  }
}
</code></pre>
<h1>The Clever Part.</h1>
<p>When a parent moves, all its children need recalculation too. The naive approach is to recursively mark every child as dirty when the parent moves. That is slow.</p>
<p>Instead, pass <code>parentDirty</code> down the tree during the render traversal. If any ancestor was dirty, the children know they need to recalculate. No recursive marking needed. Setting a transform stays fast no matter how deep the hierarchy is.</p>
<h1>When To Use It.</h1>
<p>Two conditions must be true.</p>
<p>The primary data changes more often than the derived data is read. The pattern works by batching multiple changes into a single recalculation. If you always need the result immediately after every change, the flag adds overhead for no benefit.</p>
<p>The computation cannot be updated incrementally. If you can adjust the derived data cheaply when the primary data changes, like adding or subtracting from a running total, just do that instead. Dirty flags are for cases where you have to recompute from scratch.</p>
<h1>When To Clean The Flag.</h1>
<p>Three options depending on your situation.</p>
<p>When the result is needed. This is the most common approach. You defer until something reads the derived data. Simple and avoids all unnecessary work. The risk is that if the computation is heavy, it can cause a visible pause at the moment you need the result.</p>
<p>At a checkpoint. Save the work for a loading screen or a scene transition. The player does not notice. The risk is that you cannot guarantee the player reaches the checkpoint in time.</p>
<p>On a timer in the background. Process changes at a fixed interval. You can tune how often it runs. The risk is you might need threading or concurrency to avoid blocking the main loop.</p>
<h1>Where You See This Everywhere.</h1>
<p>Scene graphs in game engines. The example above.</p>
<p>Text editors. The "unsaved changes" dot in your title bar is a dirty flag. The primary data is the document in memory. The derived data is the file on disk. It only writes when you save.</p>
<p>Web frameworks. Angular and similar frameworks use dirty flags to track what changed in the browser and needs to be synced to the server.</p>
<p>Physics engines. A resting object gets a flag that says "nothing has touched me." It skips physics processing entirely until a force is applied. Then the flag flips and it re enters the simulation.</p>
<h1>The Risk.</h1>
<p>You have to set the flag every single time the primary data changes. Miss it in one place and you get stale data. The derived output looks correct but it is not. These bugs are hard to find.</p>
<p>The best defense is to funnel all modifications through a single function. If there is only one way to change the primary data, there is only one place you need to set the flag.</p>
<h1>One Sentence Summary.</h1>
<p>Do not redo expensive work every time something changes. Mark it dirty, and only recompute when someone actually asks for the result.</p>
]]></content:encoded></item><item><title><![CDATA[Spatial Partition. Stop Checking Everything Against Everything.]]></title><description><![CDATA[Introduction
Your game has hundreds of units on a battlefield. Each one needs to know which enemies are nearby. The naive approach: compare every unit to every other unit. That is O(n²). Double the un]]></description><link>https://tigerabrodi.blog/spatial-partition-stop-checking-everything-against-everything</link><guid isPermaLink="true">https://tigerabrodi.blog/spatial-partition-stop-checking-everything-against-everything</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Sun, 12 Apr 2026 14:18:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/732ad222-b9c6-4c76-9a3a-38a013237a77.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Your game has hundreds of units on a battlefield. Each one needs to know which enemies are nearby. The naive approach: compare every unit to every other unit. That is O(n²). Double the units and you quadruple the work. It does not scale.</p>
<p>The fix: organize objects by where they are in space. Then when you ask "what is near this point" you only look at a small slice of the world instead of the entire thing.</p>
<h1>The Problem.</h1>
<pre><code class="language-typescript">function handleMelee(units: Unit[]) {
  for (let a = 0; a &lt; units.length; a++) {
    for (let b = a + 1; b &lt; units.length; b++) {
      if (distance(units[a], units[b]) &lt; ATTACK_RANGE) {
        handleAttack(units[a], units[b]);
      }
    }
  }
}
</code></pre>
<p>Every unit checks every other unit. 100 units means ~5,000 checks. 1,000 units means ~500,000 checks. Per frame. Most of those checks are pointless because most units are nowhere near each other.</p>
<h1>The Core Idea.</h1>
<p>Divide your world into regions. Put each object in the region that matches its position. When you need to find nearby objects, only check the region the object is in and its neighbors. Skip everything else.</p>
<p>The simplest version of this: a flat grid.</p>
<h1>The Grid.</h1>
<p>Overlay a grid on your world. Each cell holds a list of the objects inside it. When checking for combat, only compare objects within the same cell.</p>
<pre><code class="language-typescript">class Grid {
  private cells: Map&lt;string, Unit[]&gt; = new Map();
  private cellSize: number;

  constructor(cellSize: number) {
    this.cellSize = cellSize;
  }

  private key(x: number, y: number): string {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    return `\({cx},\){cy}`;
  }

  add(unit: Unit) {
    const k = this.key(unit.x, unit.y);
    if (!this.cells.has(k)) this.cells.set(k, []);
    this.cells.get(k)!.push(unit);
  }

  remove(unit: Unit) {
    const k = this.key(unit.x, unit.y);
    const cell = this.cells.get(k);
    if (!cell) return;
    const i = cell.indexOf(unit);
    if (i !== -1) cell.splice(i, 1);
  }

  move(unit: Unit, newX: number, newY: number) {
    const oldKey = this.key(unit.x, unit.y);
    const newKey = this.key(newX, newY);
    unit.x = newX;
    unit.y = newY;
    if (oldKey !== newKey) {
      this.remove(unit);
      this.add(unit);
    }
  }

  getNearby(x: number, y: number): Unit[] {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const result: Unit[] = [];
    // Check this cell and all 8 neighbors.
    for (let dx = -1; dx &lt;= 1; dx++) {
      for (let dy = -1; dy &lt;= 1; dy++) {
        const cell = this.cells.get(`\({cx + dx},\){cy + dy}`);
        if (cell) result.push(...cell);
      }
    }
    return result;
  }
}
</code></pre>
<p>Now combat only compares units in the same neighborhood:</p>
<pre><code class="language-typescript">function handleMelee(grid: Grid, units: Unit[]) {
  for (const unit of units) {
    const nearby = grid.getNearby(unit.x, unit.y);
    for (const other of nearby) {
      if (other === unit) continue;
      if (distance(unit, other) &lt; ATTACK_RANGE) {
        handleAttack(unit, other);
      }
    }
  }
}
</code></pre>
<p>Instead of checking against every unit in the game, you check against the handful that are actually close. The rest do not exist as far as this code is concerned.</p>
<h1>Moving Objects.</h1>
<p>When an object moves, you need to check if it crossed a cell boundary. If it did, remove it from the old cell and add it to the new one. If it stayed in the same cell, just update its position. This is cheap: a couple of index calculations and a pointer swap.</p>
<h1>Cell Size Matters.</h1>
<p>Too large: many objects per cell, you are back to checking too many pairs.</p>
<p>Too small: lots of empty cells wasting memory, and you have to check many neighboring cells for range queries.</p>
<p>A good starting point: make cells roughly the size of your largest interaction range. If your attack distance is 20 units, make cells 20x20. That way nearby objects are always in the same cell or direct neighbors.</p>
<h1>Flat Grid vs Hierarchical.</h1>
<p>A flat grid is the simplest option. Fixed cells, fixed memory, easy to update when objects move. Works great when objects are spread fairly evenly across the world.</p>
<p>When objects clump together, a flat grid breaks down. One cell gets overloaded while most sit empty. Hierarchical structures like quadtrees solve this. They subdivide crowded areas into smaller regions and leave empty areas as one big region. They adapt to where the objects actually are.</p>
<p>The trade off: hierarchical structures are more complex to implement and more expensive to update when objects move. If your objects are spread reasonably well, a flat grid is usually enough.</p>
<h1>Common Spatial Partition Structures.</h1>
<p>Grid: simplest. Fixed cells. Good for evenly distributed objects. Basically a bucket sort extended to 2D.</p>
<p>Quadtree: subdivides crowded areas recursively into four squares. Good balance between adaptability and simplicity. Its 3D version is called an octree.</p>
<p>BSP (binary space partition): splits space with planes. Often used for static level geometry. Basically a binary search tree in multiple dimensions.</p>
<p>K-d tree: similar to BSP but alternates which axis it splits on. Good for point queries.</p>
<p>Bounding volume hierarchy: groups objects into bounding boxes, then groups those boxes into bigger boxes. Good when objects have varying sizes. Also a binary search tree at its core.</p>
<h1>When To Use It.</h1>
<p>You have many objects with positions. You frequently ask "what is near X." The naive approach is too slow. That is it. If your object count is small enough that brute force works, do not bother.</p>
<p>Also consider: if your objects move a lot, you pay a cost to keep the partition updated. Make sure the savings from faster queries outweigh the cost of maintenance.</p>
<h1>One Sentence Summary.</h1>
<p>Do not search the entire world to find what is right next to you.</p>
]]></content:encoded></item><item><title><![CDATA[Object Pool. Stop Allocating. Start Reusing.]]></title><description><![CDATA[Introduction
Allocating and freeing memory is slow. On consoles and mobile devices it is even worse because it fragments the heap. Over time your free memory turns into a scattered mess of tiny gaps t]]></description><link>https://tigerabrodi.blog/object-pool-stop-allocating-start-reusing</link><guid isPermaLink="true">https://tigerabrodi.blog/object-pool-stop-allocating-start-reusing</guid><dc:creator><![CDATA[Tiger Abrodi]]></dc:creator><pubDate>Sun, 12 Apr 2026 13:39:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60bb3fa2fffc4c13b9cd5935/d11f2a11-c62d-40f0-a676-510dd27f2657.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Allocating and freeing memory is slow. On consoles and mobile devices it is even worse because it fragments the heap. Over time your free memory turns into a scattered mess of tiny gaps that cannot fit anything useful. Your game crashes. Not because you ran out of memory, but because the free memory is in the wrong shape.</p>
<p>Object pools fix this. Allocate everything up front. Reuse it forever. No fragmentation. No allocation cost during gameplay.</p>
<h1>How It Works.</h1>
<p>At startup, create a fixed array of objects. All of them sit in memory, initialized to "not in use." When you need a new object, grab one from the pool and mark it as active. When you are done with it, mark it inactive. It goes back into the pool, ready to be reused.</p>
<p>No malloc. No free. No garbage collector. Just flipping a flag.</p>
<pre><code class="language-typescript">class Particle {
  framesLeft = 0;
  x = 0;
  y = 0;
  xVel = 0;
  yVel = 0;

  inUse() {
    return this.framesLeft &gt; 0;
  }

  init(x: number, y: number, xVel: number, yVel: number, lifetime: number) {
    this.x = x;
    this.y = y;
    this.xVel = xVel;
    this.yVel = yVel;
    this.framesLeft = lifetime;
  }

  update() {
    if (!this.inUse()) return;
    this.framesLeft--;
    this.x += this.xVel;
    this.y += this.yVel;
  }
}

class ParticlePool {
  private particles = Array.from({ length: 1000 }, () =&gt; new Particle());

  create(x: number, y: number, xVel: number, yVel: number, lifetime: number) {
    for (const p of this.particles) {
      if (!p.inUse()) {
        p.init(x, y, xVel, yVel, lifetime);
        return;
      }
    }
    // Pool full. No particle created. Player won't notice.
  }

  update() {
    for (const p of this.particles) {
      p.update();
    }
  }
}
</code></pre>
<p>All 1000 particles exist from the start. Creating one is just finding an inactive slot and writing values into it. No memory allocation happens at runtime.</p>
<h1>The Problem With Linear Search.</h1>
<p>The code above scans the entire array to find a free slot. If the pool is large and mostly full, that scan gets slow.</p>
<p>Fix. Use a free list. When a particle is inactive, its memory is not doing anything useful. Reuse that memory to store a pointer to the next free particle. This chains all free particles into a linked list, built directly inside the pool's own memory.</p>
<pre><code class="language-typescript">class ParticlePool {
  private particles = Array.from({ length: 1000 }, () =&gt; new Particle());
  private firstAvailable = 0;

  constructor() {
    // Chain every particle to the next one.
    for (let i = 0; i &lt; 999; i++) {
      this.particles[i].nextFree = i + 1;
    }
    this.particles[999].nextFree = -1; // end of list
  }

  create(x: number, y: number, xVel: number, yVel: number, lifetime: number) {
    if (this.firstAvailable === -1) return; // pool full

    const p = this.particles[this.firstAvailable];
    this.firstAvailable = p.nextFree;
    p.init(x, y, xVel, yVel, lifetime);
  }

  free(index: number) {
    this.particles[index].framesLeft = 0;
    this.particles[index].nextFree = this.firstAvailable;
    this.firstAvailable = index;
  }
}
</code></pre>
<p>Now creating and freeing are both O(1). No scanning. Just follow one pointer and you have your slot.</p>
<h1>What Happens When The Pool Is Full.</h1>
<p>Four options.</p>
<p>Make sure it never happens. Tune the pool size so it is always big enough. Best for critical objects like enemies or bosses where failing to create one would break the game.</p>
<p>Do nothing. Skip the creation. Works for particles and cosmetic effects. The screen is already full of stuff. One less sparkle is invisible.</p>
<p>Kill the least important active object. Good for sound effects. If all sound channels are in use, cut the quietest one. The new sound will mask the loss.</p>
<p>Grow the pool. Allocate more slots at runtime. Only works if your platform can afford the memory hit. Consider shrinking back later when demand drops.</p>
<h1>Watch Out For Stale State.</h1>
<p>Pooled objects are not freshly allocated. They still hold data from their previous life. If your init function misses a field, you get ghost state from a dead object leaking into a live one. These bugs are hard to find because the stale data often looks almost correct.</p>
<p>Always fully initialize every field when reusing an object. In debug builds, consider zeroing out the memory when an object is freed so stale reads are obvious.</p>
<h1>When To Use It.</h1>
<p>You create and destroy objects frequently during gameplay. Particles, bullets, sound effects, enemies.</p>
<p>Objects are the same size or close to it. Pools work best when every slot is the same size. If objects vary wildly, you waste memory padding small objects to fit the largest slot.</p>
<p>You are on a platform where fragmentation or allocation speed matters. Consoles, mobile, embedded. On a PC with a modern allocator and garbage collector, pools are less critical but still useful for hot paths.</p>
<h1>One Sentence Summary.</h1>
<p>Allocate once at startup, reuse forever, never ask the memory manager for anything during gameplay.</p>
]]></content:encoded></item></channel></rss>