Here are some techniques I discovered through 14 years of shader programming:
10.02.2026 23:34 โ ๐ 165 ๐ 34 ๐ฌ 4 ๐ 1@simondev.bsky.social
Here are some techniques I discovered through 14 years of shader programming:
10.02.2026 23:34 โ ๐ 165 ๐ 34 ๐ฌ 4 ๐ 1Full video: youtu.be/phbaxNPJxss
Full course: simondev.io/lessons/game...
Once you've made it through all these steps
โข Reuse materials
โข Batch/instance
โข Optimize data
โข Cull
โข LOD/imposters
We're hitting 1 million+ trees, for very little CPU/GPU cost.
Octahedral imposters are a powerful technique, where we render the object from many angles into an atlas texture, then just show a billboard in the world.
Key detail: it responds to camera movement and lighting, but it's just smoke and mirrors.
TSL makes it easy to hook into the lighting system.
At this point, the last lever left is reducing quality.
LOD (level-of-detail) works by dropping detail with distance. Further away objects, you swap meshes (LOD0 -> LOD1 -> LOD2) and nobody notices (hopefully)
With instancing, you'll have to do this manually with an InstancedMesh for each level.
At some point itโs hard to โdraw fasterโ. So stop drawing stuff you canโt see.
Frustum culling removes anything offscreen.
Not automatic with InstancedMesh, so you can either:
โข Instance within a chunk, then cull by chunk.
โข Cull manually per-instance
Weโre at 250k+ trees now.
You can quantize way further than most people think.
Itโs possible to squeeze a ~56B vertex down to ~16B with packing + quantization.
TSL makes unpacking clean (override attributes via node API).
Source
: x.com/SebAaltonen/...
This gets us to 50k+ trees.
Now take a look at your data.
You want GPU-friendly assets, not just smaller downloads.
Meshes: weld verts, simplify, quantize
Textures: Use GPU compressed formats (like ETC1S/ UASTC)
I use my in-browser GLB optimizer to do most of this: gltf-optimizer.simondev.io
Instancing allows us to tell the GPU in a single draw call: "hey, draw this thing a zillion times".
No need for the CPU to constantly submit draw commands, which alleviates the load on the CPU and shifts the bottleneck to the GPU.
We're hitting 30k+ trees now.
To draw a lot of stuff, you want to reduce materials. Collapse different materials into a single material by packing textures into atlases.
Then you can collapse draw calls with:
โข InstancedMesh (same geo)
โข BatchedMesh (different geo)
Low-hanging fruit: stop duplicating assets.
Share geometry & materials across instances and you'll see immediate improvements in the framerate.
This change alone gets us to ~700โ800 trees at 60fps.
Draw calls are often the first hurdle.
In this example, as the trees stream in, the FPS drops, and we cap out around ~500 or so.
The first step is to make sure youโre measuring the right things. You need both CPU time and GPU time, to understand where the problems lie.
I use three-perf or the Three.js Inspector so I can see both numbers easily.
Optimization can be tricky.
Hereโs how to go from drawing a few hundred trees to virtually unlimited in Three.js, step by step.
This will be high level, but not so much that you canโt fill in the details.
#threejs
Absolutely!
27.01.2026 21:51 โ ๐ 2 ๐ 0 ๐ฌ 0 ๐ 0Full video: youtu.be/YJB1QnEmlTs
Full course: simondev.io/lessons/math
Another gotcha is damping (like camera smoothing)
Ex. if you do t = someConstant
Then: lerp(x, target, t) each frame, itโs frame-rate dependent.
People often try t = k * dt, which kinda works.
Instead compute t from deltaTime:
t = 1.0 - exp(-K * deltaTime)
or:
t = 1.0 - pow(someDecay, deltaTime)
Interpolating unit directions and rotations can be tricky.
Lerp directly interpolates between A and B, which leaves you with a non-normalized vector.
NLerp fixes this (normalize after lerp). It's fast and usually good enough for small angles.
Otherwise, slerp gives you constant angular speed.
If you need to lerp scale/zoom, then you'll want to use this transform trick.
In this case, transform to log space, lerp, and transform back.
You can see the animation on the left seems to accelerate/decelerate more abruptly than the one on the right.
Another powerful trick with lerp is to transform your inputs to another space, perform your lerp, and transform back.
This works especially well with colours:
So you've got:
โข top: lerp of rgb values
โข middle: lerp through HSV
โข bottom: lerp through OKLAB
And it's incredible the types of "shaping" (or "easing") functions that exist.
Here's a little montage of a few fun ones.
Most of lerp's power comes from shaping the t parameter.
Here, we're doing a lerp on the position. Left uses t directly, while the right uses smoothstep(t)
One just looks "smoother".
If Bezier curves have ever scared you, you may be surprised to see that one way of doing them (de Casteljau) is just a big pile of lerps.
27.01.2026 21:36 โ ๐ 5 ๐ 0 ๐ฌ 1 ๐ 0More sophisticated blending can be achieved just by using multiple lerps.
Bilinear filtering, the type used by your GPU, is just 3 lerps in a trenchcoat.
Basic uses are simple: lerp positions, colors, scalesโฆ anything that changes over time.
Itโs a quick, effective way to animate.
Starting with the basic idea, lerp allows you to smoothly blend between 2 values. Typically, we use it with a "t" value between 0 and 1.
value = lerp(a, b, t);
So you get:
โข a when t = 0
โข b when t = 1
Lerp is used everywhere in games. Itโs simple, but combined with a few small tricks it becomes incredibly powerful.
This thread is full of visual examples.
Full video on gamedev math: youtu.be/eRVRioN4GwA
08.01.2026 15:02 โ ๐ 10 ๐ 1 ๐ฌ 0 ๐ 0And of course, the dot product is the basis of diffuse lighting (Lambert).
With Lฬ as the incoming light direction:
lambert = max(0, dot(Nฬ, -Lฬ))
Letโs say youโve got a projectile, and you want to know how far it travelled along the ground this frame.
Compute the displacement vector a, then:
distanceAlongGround = dot(a, groundDirฬ)