Technique source: URP Anime Crystal Shader by CJT-Jackton, https://t.co/TmE85ga4G8, CC0-1.0 (code), commit 8cfd533b45bd819db2b3782b4fd8790064216e2f. Ported to GLSL from its Crystal shader graph (tangent-space refracted UV offset, two Delaunay noise layers, Random Range, Fresnel, Baked GI with the negated normal) and its DelaunayTriangulationNoise HLSL. Parameter values come from its Crystal_Green, Crystal_Orange and Crystal_White materials.
Environment light: Adams Place Bridge HDRI by Andreas Mischok, Poly Haven, CC0.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r170. Used for drawing, the glass comparison material and the environment reflections. It is not the source of the crystal technique.
My own code: the procedural gem meshes and their UVs, the spherical harmonics stand-in for baked GI, the scene, the debug views, camera moves, labels and video.
These gems have no transparency and no real refraction. The sparkle is two layers of triangle noise, looked up through a bent view vector and lit by the light behind the face.
Watch them next to real glass 👇
▸ The problem
A physically based glass gem in a dark scene shows very little, because it mostly shows what sits behind it. Anime gems are drawn the other way: flat colour facets, bright triangles that flash as the gem turns, a lighter rim. The goal is that drawn look on a 3D mesh, and the shader makes no attempt to be physically correct.
▸ How it works
The surface itself is black with a glossy specular, so the environment still reflects off the facets. Everything else is emission.
The view vector is taken into tangent space and refracted against (0, 0, 1) with 1 / IOR. Its xy divided by z becomes a UV offset that shifts as the view angle changes, like parallax.
That offset feeds two Delaunay triangulation noises. Each one jitters a 3 x 3 block of grid points, picks the diagonal of each quad with a parabolic lifting test, and returns the centre of the triangle the pixel falls in. A random value seeded by that centre gives every triangle its own brightness.
The two layers are multiplied, a Fresnel term is added, and the result scales the ambient light from behind the face (baked GI sampled with the negated normal) and the crystal colour.
▸ What I hit
The graph's View Direction and Normal nodes are set to tangent space, so the normal going into the refract is always (0, 0, 1). The "refraction" never enters the gem. It only slides the noise across each face.
My glass comparison first rendered almost black against the dark backdrop, which is the honest result for transmission with nothing behind it. Both sides now sit in front of the same blurred HDRI.
My first gem had flipped faces on the crown that rendered as a black band. Its flat top face also got stretched UVs from the cylindrical unwrap and turned into noise. The README warns the shader needs low-poly meshes with clean UVs, and the top face now gets a planar UV.
▸ My build
The Shader Graph and the noise HLSL are ported to GLSL inside a three.js standard material. Smoothness 0.71, albedo 0. Tangents come from screen-space derivatives because my meshes have none. Unity's baked GI is replaced by 9 spherical harmonics coefficients projected from the HDRI.
Colours, cell densities, angle offsets and strengths come from the source's green, orange and white materials, IOR 1.75. The gems are 24 to 46 triangles each.
▸ Trade-offs
It costs two noise lookups per pixel and no extra pass. Nothing behind the gem shows through, and the facets inside are painted on, so a close turning shot can read as a texture sliding over the faces. It needs low-poly meshes with sensible UVs.
▸ When to use it
Pickups, magic crystals and jewellery in a stylised or toon game. For a gem that has to show the scene behind it, use a transmission material.
Technique source: RealTimeAtmosphere by AKGWSB, https://t.co/G4wVVTXkhT, MIT license, commit a44fa0a5f0bf5dadfc83990dc5bfb2514ed239cb. Ported to GLSL from its Scattering, Helper and Raymarching shader includes (Rayleigh, Mie and ozone terms, phase functions, sphere intersection, transmittance table mapping, multi-scattering integral, sky-view raymarch), the Transmittance, MultiScattering, SkyView and AerialPerspective table shaders, the Skybox shader with its sun disk, the AerialPerspective composite, the table sizes from AtmosphereRenderFeature and the parameter defaults from AtmosphereSettings and the shipped Atmosphere asset.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r170. Used for drawing and render targets. It is not the source of the atmosphere technique.
My own code: the procedural ridge layers, their lighting, the gradient-sky baseline, the table debug view, camera, labels and video. No external models or assets.
A sunrise over seven ridge lines, built from four lookup tables. The far ridges sink into the sky because the sky and the distance haze come from the same scattering maths.
Watch the sun climb from the horizon to 40° 👇
▸ The problem
A gradient sky and linear distance fade look fine at noon and wrong at sunrise. The horizon colour is picked by hand, the fade colour never matches the sky behind it, and nothing changes when the sun moves. Real air reddens the low sun, turns blue overhead and adds haze that depends on where you look relative to the sun.
▸ How it works
The air is two scattering layers and an ozone band. Rayleigh falls off with a scale height of 8 km, Mie with 1.2 km, ozone peaks at 25 km. The planet radius is 6,360 km and the atmosphere is 60 km thick.
Four tables hold the expensive parts:
Transmittance, 256×64: how much light survives a ray, by height and angle.
Multi-scattering, 32×32: light that bounced more than once, integrated over 64 fixed directions × 32 steps, by height and sun angle.
Sky-view, 256×128: the whole sky around the camera, 32 samples per ray.
Aerial perspective, 32×32×32 stored as 1024×32: in-scattered light and transmittance for 32 depth slices out to 32 km.
The sky reads the sky-view table plus a sun disk dimmed by transmittance. Geometry is multiplied by the slice transmittance and gets the slice light added.
▸ What I hit
The aerial perspective table packs 32 slices side by side. Linear filtering bled each slice into its neighbour and drew bright stripes at the screen edges. Clamping the lookup inside the slice fixed it.
The source builds the slice rays for a 90° horizontal view. My camera is 36° vertical, so I scale the rays to its real field of view.
The multi-scattering values top out at 0.031, so the debug panel normalises that table to be visible.
▸ My build
Ported the four table passes, the scattering terms and the sky and haze composite to GLSL in three.js, with the source defaults: sun intensity 31.4, Mie anisotropy 0.8, sun disk 1°. Two tables are built once. Sky-view and aerial perspective are rebuilt every frame as the sun rises.
The mountains are seven ridge strips from 260 m to 27 km, lit by the sun through the transmittance table plus sky-view light from above. That lighting is mine.
The baseline in the split uses the same scene with a hand-tuned gradient and a linear fade to 32 km.
▸ Trade-offs
Haze stops growing past 32 km. The sky-view table spends 1.4° of elevation per row, so the horizon band is coarse. Depth slices map distance in 31 steps while slice centres sit half a slice further out, so near ridges pick up slightly more haze than their distance. Everything is single-planet, no clouds, no shadowing of the air by terrain.
▸ When to use it
Open landscapes with a moving sun, flight or driving games, any scene with distant terrain. Two small per-frame passes replace hand-tuned sky colours and give haze that matches the sky.
Technique source: Compute-Shaders-Fluid-Dynamic- by IRCSS (Shahriar Shahrabi), https://t.co/1PL0mZoCbY, MIT license, commit 03765e79d48ba7c2900532e1b9b140f5746b7ad6. Ported to WebGL2 fragment passes from its compute kernels: advection, divergence and calculate_divergence_free (Navier_Stokes), Jacobi_Solve (Solvers), AddForce_mouse, Add_constant_force_at, AddDye and Add_constant_dye (UserInput), NeuMannBoundary (Boundary_Handling) and the bilinear load in FluidDynamicUtility. The step order, the parameters and the stacked fog planes follow its VastLand fog demo (VastLandDemoMaster, the VastlandFog shader and TheVastLandFogDemo scene).
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r187. Used for drawing, lighting and shadows. It is not the source of the fluid technique.
My own code: the scene, the capsule walker, the fog source layout and wind schedule, the negative dye brush, the fog layer shaping and lighting, the scrolling noise fog and the raymarch used for comparison, the debug views, camera moves, labels and video. No external models or assets. None of the repo's models or textures are used.
Ground fog that opens when someone walks through it, curls into the gap, then slowly closes again. Underneath is a 2D fluid sim on a 256 x 256 grid, drawn as 16 flat planes.
Watch it next to plain scrolling noise 👇
▸ The problem
Scrolling a noise texture gives cheap ground fog, but nothing in the scene can touch it. A character walks through and the fog slides past as if nobody was there. A full 3D smoke sim would react, but it costs far more than a thin layer at shin height needs.
▸ How it works
The fog is treated as dye in a 2D fluid seen from above. Each step runs the stable fluids loop on the grid: add forces, diffuse the velocity, project it so nothing piles up, carry the velocity along itself, project again. Then the dye is carried by that velocity and diffused.
Projection is where the cost sits. It solves a pressure equation with 120 Jacobi iterations, and each iteration reads four neighbours per cell. Two projections plus two diffusions make 480 solver passes per step.
The walker writes into the grid in two places. A force brush adds its movement to the velocity. A second brush removes dye under its feet. Fixed fog sources keep adding dye and the dye fades a little every step, so the field settles at a level and grows back after being cut.
To draw it, the dye becomes a texture on 16 flat planes stacked 2.5 cm apart. Each plane sets its opacity from the density under it.
▸ What I hit
With the repo's force strength the wake ran at about 2.2 cells per step while the walker moved 0.87. It dragged fog straight back into the gap and the trail closed within about a metre. I cut the force to 0.14.
The source shader sets opacity to density times layer height. In my scene that made a flat milky sheet. I changed it so density also sets how tall the fog column is. Thin fog now stays near the ground and the cut shows the stones.
The repo's diffusion pass feeds its own running result back in as the right-hand side, so it acts as a small blur. I kept it as written.
▸ My build
WebGL2 has no compute shaders, so each kernel became a fragment pass that ping-pongs between float textures in three.js.
256 x 256 cells over 12.8 m, 5 cm per cell. 120 iterations per solve, one step per frame. 32 fog sources, 3 slow force streams for drift.
The top plane sits 37.5 cm above the ground. The fog lighting, the lamp shadow and the 32-step raymarch comparison are my additions.
▸ Trade-offs
The field is 2D. Fog cannot rise or spill over a ledge. It does not flow around the stones here, because I left out the repo's obstacle kernels. Removing dye under the feet is a trick: real fog would be pushed aside. Up close the flat layers can show as bands where they cut through a leg or a post. 480 solver passes per step is heavy, so the iteration count is the first thing to cut.
▸ When to use it
Low fog, mist on water or floor dust that should react to characters in a bounded area. For fog that fills a room or needs light shafts, use a volumetric method.
Technique source: VHair by kennux (Kenneth Ellersdorfer), https://t.co/jxaoArlZ8u, MIT license, commit 6a07ad19812a4de2cef132c07f4f1db6d7cd0894. The repo describes itself as an educational project that is not usable in production. Ported to JavaScript from its CPU TressFX Burst passes: CPUTFXVerlet_Burst, CPUTFXGlobalShapeConstraint_Burst, CPUTFXLocalShapeConstraints_Burst (including CalculateTransforms), CPUTFXSphereCollision_Burst and CPUTFXLengthConstraints_Burst, with the pass order, iteration counts and stiffness values of its CPU_Body_TressFX_Burst prefab.
Based on: TressFX by AMD (GPUOpen), https://t.co/d3CunPGWad, MIT license. Part of VHair's simulation code is based on TressFX (see LICENSE_TRESSFX in the VHair repo).
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r187. Used for drawing and lighting. It is not the source of the hair technique.
My own code: the procedural head and groomed style, the ribbon strand drawing and hair shading, the head motion, the debug view, camera moves, labels and video. No external models or assets. The repo's hair assets and models are not used.
6,000 strands on a turning head. On the left only gravity and length hold them, and the hair drops straight through the skull. On the right, shape constraints and two spheres keep the style.
Watch both 👇
▸ The problem
A strand as a chain of particles is easy to keep at the right length. Keeping a hairstyle is the hard part. With only gravity and length constraints every strand hangs straight down from its root, through the head and the shoulders. After two seconds two thirds of the free particles were inside the head or shoulders.
▸ How it works
Each strand is 16 particles with the root fixed to the scalp. One step runs five passes in order.
Integrate: every free particle moves by gravity times the timestep squared.
Global shape: the first four free particles are pulled toward where the groomed strand sits on the head right now. This sets the direction the hair leaves the scalp.
Local shape: walking down the strand, each segment is compared with the groomed segment, rotated into the frame of the segment before it. Both particles are nudged toward that direction. Curls survive without being pinned in space.
Sphere collision: a particle inside a sphere is moved to its surface.
Length: from root to tip, each particle is pulled back toward its rest distance from the one before.
▸ What I hit
The repo's integration pass is named Verlet, but its velocity term cancels out. The new position is the old one plus gravity, so strands have no momentum. They follow the head, sag and settle, and never swing past. I kept it as written and labelled the left side by what it does.
The length table is filled by a loop that stops one particle early, so each tip gets a rest length of zero and folds onto its neighbour. I filled in the last entry.
The length pass is a soft pull. Under gravity the segments settled 15 to 30% longer than groomed.
▸ My build
Ported the five passes to plain JavaScript on typed arrays and drew the strands with three.js as camera-facing ribbons.
6,000 strands x 16 particles = 96,000 particles. Two steps of 0.0167 s per frame. Per step: global x2, local x2, collision x1, length x2, with stiffness 30, 40 and 25, all from the repo's prefab.
With shape and collision on, at most 39 of the 90,000 free particles sat more than 2 mm inside a sphere on the frames I sampled.
The head, the groomed style and the shading are procedural and mine. The clip then drops both shape stiffness values to 0.3 and 0.08 of the prefab and raises them again.
▸ Trade-offs
Strands do not collide with each other, so the volume is only what the groom gives. Colliders are spheres only here. No momentum means no swing. The repo calls itself an educational project that is not ready for production, and part of its simulation is based on AMD's TressFX.
▸ When to use it
A readable reference for how TressFX-style constraints fit together, or short stylised hair. For long hair that must swing, the integration pass needs its velocity term back first.
Technique source: PBD-Fluid-in-Unity by Scrawk (Justin Hawkins), https://t.co/8jLG8rsRug, MIT license, commit d7ec63854161cba84c98a890c8aeaa95b7a91a6f. Ported to JavaScript from FluidSolver.compute (predict, density and pressure, density solve, velocity update, viscosity, position update), FluidSolver.cs (step order and iteration counts), GridHash.compute and GridHash.cs (cell hash and start/end table), FluidBoundary.compute (boundary weight), SmoothingKernel.cs (poly6, spiky gradient, viscosity kernels), ParticlesFromBounds.cs and the FluidBodyDemo tank setup. The volume view ports ComputeVolume.compute and the absorption raymarch of Volume.shader.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r170. Used for drawing only. It is not the source of the fluid technique.
My own code: the counting-sort replacement for the GPU sort, the baffle, the clamp-only comparison run, the density-error colouring, the backdrop scene, camera, labels and video. No external models or assets.
29,160 particles fall as an 8 m column into a tank with a baffle. Without a density constraint they fold into one flat sheet. With it they splash over the baffle and keep their volume.
Watch the two tanks side by side 👇
▸ The problem
Particles under gravity know nothing about each other. Nothing stops two of them from sitting in the same spot, so a column of water collapses into a layer one particle thick. In the left tank the measured density ends up about 21 times the rest value. A liquid has to resist being squeezed.
▸ How it works
Position Based Fluids turns "do not compress" into one constraint per particle: density over rest density minus 1 should be 0. Density is an SPH sum over neighbours within 0.32 m using the poly6 kernel.
Each iteration computes a scale factor per particle, -C / (sum of squared gradients + 60), then moves every particle by the spiky-kernel gradient weighted by its own and each neighbour's factor. Only compression is corrected, so particles can pull apart freely. After the iterations, velocity is the position change divided by the step, then smoothed by a small viscosity term.
The walls and the baffle are particles too, 23,385 of them, each with a fixed weight.
Neighbour search: every particle is hashed into a 0.32 m cell, the list is sorted by cell, and a table stores each cell's first and last index. A particle then reads only its own cell and the 26 around it.
▸ What I hit
The source's boundary pass has its neighbour sum commented out, so every wall particle gets the same weight, 1,000 / poly6(0) = 21.2. I kept it.
Particles start 0.9 diameters apart, about 27% below rest density, so the column sinks a little before the constraint ever pushes back.
The GPU version sorts with a bitonic sort. On the CPU a counting sort builds the same cell table.
▸ My build
Ported the solver kernels, cell hash, boundary setup and the demo's tank to JavaScript. It runs offline on the CPU and writes positions and density per frame, and three.js draws them. Source defaults: 1/60 s frames, 2 substeps, 2 constraint iterations, radius 0.08 m. The baffle is my addition.
The volume view splats the same kernel sum into a 205×130×55 grid at 0.08 m and marches 64 samples with absorption (0.45, 0.029, 0.018), so red fades first.
At 8 s: 1 iteration leaves the largest compression at +23.0% with 37% of particles above +5%. 2 iterations: +12.5% and 9%. 4 iterations: +6.9% and 0.1%.
▸ Trade-offs
Cost grows with iterations and neighbour count. There is no surface tension or vorticity term, so splashes break into loose spray. The volume only absorbs light: no refraction, no reflection, no foam.
▸ When to use it
Pours, splashes and sloshing in a container, a few tens of thousands of particles. For lakes and oceans a height field is far cheaper.
@kkpattern Cleaner than my single-texel check. One depth texel is yes or no, so a thin branch over the sun kills the whole flare. With several proxies, the fraction that pass gives a partial scale for a half-covered sun, same one-frame lag. How many proxies do you place?
A low sun behind a line of pines. The lens flare switches off when a trunk covers the sun and comes back when it clears, decided by one depth texel per frame.
Watch the left half keep flaring through the trees 👇
▸ The problem
A screen-space flare is a set of sprites drawn on top of the image. Nothing in that draw knows whether the sun is actually visible. Walk behind a tree and the ghosts stay on screen, floating over a dark trunk.
▸ How it works
Each frame the sun's direction is projected to a pixel. A tiny compute pass reads the depth buffer at that one pixel and converts it to linear depth. For a directional light, depth at the far plane means sky, so the sun is visible. Anything nearer means something is in front.
The answer goes back to the CPU and is used on the next frame. A small state machine then fades the flare in or out over 0.2 s instead of popping.
The elements sit on a line through the screen centre: position = centre + (sun - centre) × offset. Offsets run from -1.37 to +1, so +1 sits on the sun and negative offsets land on the far side of the centre. Size grows from 50% to 100% with the fade, and colour is multiplied by it. Sprites are added with One One blending, no depth test.
▸ What I hit
The source multiplies a distance-from-centre falloff into vertex alpha only. With One One blending alpha is never used, so the flare is just as bright with the sun at the edge as at the centre. I kept that.
While the sun is hidden, the state machine alternates between fade in and fade out every update, leaving a one-step residue. I run two updates per video frame, as a 60 fps game would, and the captured frames land on zero.
▸ My build
Ported the element layout, the 45° rotation rule for rotating sprites, the fade states and the occlusion test to three.js. The depth read is a 1×1 fragment pass with texelFetch on a depth texture, read back each frame. Offsets, scales and colours of the 8 elements come from the repo's example flare asset. The sprites themselves are procedural: glow with star streaks, rings and hexagon apertures.
▸ Trade-offs
One texel is a yes-or-no answer. A thin branch over the sun's pixel hides the whole flare, and a gap of one pixel shows all of it. The fade hides most of the popping. The test also lags one frame behind the image, and a sun half covered by a trunk counts as fully visible or fully hidden.
▸ When to use it
Directional sun or a few point lights in open scenes, on hardware where a full occlusion query per light is too much. For dense foliage, sample a small grid and average it.
Technique source: Moonflow-Lensflare-System by Moonflow-Studio (Reguluz), https://t.co/lmrVD1V02X, MIT license, commit 6e0d5f8ee9acd92b20ed0fe33840a66021cce478. Ported to three.js from MFLensFlare (screen position of the light, in-screen check, fade state machine, element layout along the axis, size and colour from the fade, the rotation rule), the LightOcclusionChecker compute shader (one depth texel to linear eye depth), the One One blend of MFLensflare.shader, and the element offsets, scales and colours of the example flare asset EXPFlareBySpriteEditor.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r170. Used for drawing, depth textures and render targets. It is not the source of the flare technique.
My own code: the procedural flare sprites, the forest scene, the no-occlusion comparison, the debug overlay, camera, labels and video. No external models or assets.
Technique source: pb_CSG by karl- (Karl Henkel), https://t.co/UKOwf8AZWb, MIT license (Copyright 2015 Karl Henkel), commit 9b35e1c90ee2d3fe1b7b881a25242f47e975d94b. Ported to JavaScript from its Node class (Build, ClipPolygons, ClipTo, Invert, AllPolygons), Plane (SplitPolygon with coplanar, front, back and spanning cases, epsilon 0.00001), Polygon, the vertex Mix and Flip helpers, the Union, Subtract and Intersect step sequences and the Model fan triangulation. pb_CSG is itself a C# port of Evan Wallace's CSG library for JavaScript (MIT), via the C++ port by Tomasz Dabrowski (MIT), as its header states.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r187. Used for drawing, lighting and shadows. It is not the source of the boolean technique.
My own code: the cube, sphere and cylinder inputs, the chained example, the split-plane debug view, colours, camera moves, labels and video. No external models or assets.
A cube, a sphere and three cylinders. Four boolean cuts on plain triangle meshes, each one a BSP tree clipping another BSP tree.
Watch the triangle count climb from 724 to 8,645 👇
▸ The problem
Two meshes that overlap are still two meshes. Draw a cube and a sphere in the same spot and you get a cube with a sphere poking out of it, with every hidden face still there. To carve a hole, merge two shapes or keep only the shared volume, you need new geometry: the faces of A that sit outside B, the faces of B that sit inside A, and every triangle cut where the two surfaces cross.
▸ How it works
Each mesh becomes a BSP tree. A node takes the first polygon of its list as its split plane, keeps the polygons that lie on that plane, and sends the rest to a front child or a back child. A polygon that crosses the plane is cut into a front piece and a back piece.
Clipping is the core move: push A's polygons down B's tree and drop whatever ends up in solid space. Invert flips every polygon and swaps front and back, which turns solid into empty.
Union clips A by B, B by A, then once more on the inverted B so coplanar faces are not kept twice. Subtract inverts A first and back at the end. Intersect inverts both. Every vertex test uses a 0.00001 tolerance.
▸ What I hit
My first port flipped vertices in place. Split pieces share vertices, so a shared one could get flipped twice, and the result came out striped with wrong normals. In the C# source a vertex is a struct and is copied by value. Copying on flip fixed it.
The sphere has 960 triangles and its BSP tree has 480 nodes in a single chain, 480 deep. A convex shape has every other face behind each face plane, so the tree never branches.
▸ My build
Ported the BSP node, plane split, polygon and vertex code to JavaScript and drew the results with three.js.
Inputs: cube 12 triangles, sphere 960, each cylinder 128.
Union 2,266 triangles. Cube minus sphere 1,754. Intersect 724.
Against the cube face at x = 0.8, the sphere's triangles sort into 66 front, 844 back and 50 spanning. The 50 spanning ones split into 50 front and 50 back pieces.
Chained: intersect 724, minus cylinder X 1,544, minus Y 4,878, minus Z 8,645.
▸ Trade-offs
Every cut adds triangles, and long thin slivers pile up along each seam. There is no split heuristic, so tree shape depends on polygon order. The output is not welded: each triangle gets its own three vertices. The tolerance is applied to an unnormalised plane, so its real size changes with triangle area.
▸ When to use it
Editor tools, level blockouts and one-off cuts at load time, where a few thousand triangles of output is fine. For repeated cuts on dense meshes every frame, plan for cleanup, or reach for a different method.
Technique source: grass-bending by elringus (Artyom Sovetnikov), https://t.co/WmFOoY1JLM, MIT license (Copyright 2018 Artyom Sovetnikov), commit 828302cf581577b906d37ba8cd783cadb6e9c44e. Ported to GLSL and JavaScript from its BendingGrass shader (the 16-slot bend loop: horizontal distance, linear bend power, bend direction, top-corner offset, and the two-pass draw with alpha test at 0.95 then alpha blend below it) and from GrassBendingManager with BenderPriorityComparer (sort by priority, lower first, first 16 sent, the rest get nothing). The repo's character model is not used.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r187. Used for drawing, instancing and shadows. It is not the source of the bending technique.
My own code: the meadow scene, procedural grass tuft, ground and stone textures, the ball, capsule and pebble movement, the bender debug rings, camera moves, labels and video. No external models or assets.
A stone ball rolls through 48,000 grass billboards. Every tuft checks up to 16 benders in its vertex shader and leans away from any it is inside.
Watch the same frame with bending off and on 👇
▸ The problem
Billboard grass is cheap because each tuft is one flat quad with no physics. That is also why a ball or a character slides straight through it. The tufts in front of the object stay upright, the object clips into them, and the scene stops reading as touched.
▸ How it works
A manager keeps a list of benders, each with a position, a bend radius and a priority. Every frame it sorts the list by priority, lower values first, and writes the first 16 into a fixed array of four floats each: x, y, z and radius.
The grass vertex shader loops over all 16 slots. For each one it takes the horizontal distance from the tuft's base to the bender. Bend power is 1 at the centre and falls linearly to 0 at the radius. The push direction is the normalised 3D vector from the bender to the tuft base, and only its x and z are used. The offset is that direction times power times tuft height, and it moves only the top two corners of the quad.
Drawing is split in two passes. Pass 1 alpha-tests at 0.95 and writes depth. Pass 2 alpha-blends the texels below 0.95 with depth writes off.
▸ What I hit
Tufts right under the ball barely move. The push vector points from the ball's centre down to the ground, so its sideways part is close to zero there. With a 0.42 m ball and a radius of 1.0, the sideways push peaks about 0.35 to 0.4 m out from the centre.
There is no memory. Tufts stand straight again as soon as the ball moves on.
Low side-on views were the heaviest shots to render. Pass 2 stacks many blended layers on each pixel there.
▸ My build
Ported the bend loop and the two-pass draw to GLSL in three.js, with instanced billboards facing the camera around the vertical axis.
48,000 tufts on a 32 by 31.5 m field, heights 0.42 to 0.72 m, procedural tuft texture.
Benders: the ball, radius 1.0, and a walking capsule, radius 0.8. In the debug shot 18 pebbles with radius 0.55 join them. That makes 20 benders, 16 get sent, and the 4 with the highest priority values bend nothing.
▸ Trade-offs
Every vertex of every tuft runs 16 loop iterations, even when no bender is near. The limit is a hard cut: a bender past slot 16 simply stops working. The sideways shift stretches a blade instead of keeping its length. Pass 2 is unsorted, so overlapping soft edges can draw in the wrong order.
▸ When to use it
Fields of billboard grass with a few characters, balls or wheels moving through them. If you want lasting trails or flattened patches, this alone will not do it, because nothing is stored between frames.
Technique source: Marching-Cubes-Terrain by Eldemarkki, https://t.co/0I1blJgDEt, MIT license, commit 97ae4d62f942c124145e02b2be8154e55d1826d7. Ported to JavaScript from its ProceduralTerrainVoxelDataCalculationJob (byte density from octave noise), MarchingCubesJob, MarchingCubesFunctions and MarchingCubesLookupTables (cube index, edge interpolation, triangle table, flat normal per triangle, degenerate triangles skipped), TerrainDeformer (EditTerrain: range 3, speed 0.1 divided by distance per frame), the PerChunkStore and ChunkUpdater chunk flagging with 16 cubed chunks, and the Triplanar Shader with the colours from its Triplanar Shading material.
Noise: 3D simplex noise after the Ashima Arts and Stefan Gustavson GLSL version (MIT), the one the source's noise call is based on, rewritten in JavaScript.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, r187. Used for drawing, lighting and shadows. It is not the source of the terrain technique.
My own code: the scripted brush path, the height field used for the comparison, the chunk debug view, camera moves, labels and video. No external models or assets.
A glowing brush digs a cave into a hillside, then builds an arch out of thin air. Underneath: byte voxels and marching cubes, re-meshed one 16-voxel chunk at a time.
Watch it next to a height field 👇
▸ The problem
Most terrain is a height field: one height per column. It can raise a hill or dig a pit, but it cannot hold anything with space underneath. A cave or an arch needs at least two surfaces in the same column, and a height field only has one.
▸ How it works
The world is a 3D grid of bytes. Each one stores how solid that point is, and the surface sits where the value crosses 0.5. Terrain starts from layered noise: value = (y - height) x 0.5, clamped to 0..1.
Marching cubes walks every cell of eight voxels. Each corner below the threshold sets one bit of an 8-bit index. A 256-entry table says which of the 12 cell edges the surface crosses, and a vertex lands on each crossed edge by linear interpolation. A second table joins those vertices into up to five triangles.
The brush is the repo's deformer: every voxel within 3 units changes by 0.1 divided by its distance, every frame. Digging raises the values and building lowers them. Any chunk whose voxels changed is flagged and fully re-meshed.
▸ What I hit
Chunks share their border voxels: each one stores 17 per side for 16 cells. An edit near a chunk corner therefore flags up to 8 chunks. While digging, 1 or 2 chunks were rebuilt per frame. While building the arch, which crosses three chunk borders, it went up to 8.
Normals are one per triangle, so the surface comes out faceted. That is how the source does it.
▸ My build
Ported the voxel generation, the marching cubes loop with its lookup tables, the deformer and the chunk flagging to JavaScript, and drew the result with three.js.
65 x 33 x 65 byte voxels in 4 x 2 x 4 chunks of 16 cubed. The starting terrain has 14,218 triangles. After the cave and the arch it has 16,142.
Over the 24 s clip, chunks were rebuilt 1,308 times in total.
For the left half of the split I wrote a plain height field that moves each column's top to the edge of the brush sphere. That is about the closest a single height per column can get.
▸ Trade-offs
Bytes keep memory small, at the cost of only 256 density steps per voxel. Re-meshing a whole chunk for a one-voxel change wastes work. Flat normals look faceted. The triplanar shader here blends three flat colours by the surface normal. It does not use textures.
▸ When to use it
Diggable or buildable terrain, caves, overhangs and editor sculpting. For a big open landscape with no overhangs, a height field is lighter and still simpler.
Technique source: driven-decals by Anatta336, https://t.co/Jls5ScQfIg, MIT license (Copyright 2019-2020 Sam Driver), commit 4a70ad783bd5c95d9d43ed9c90654251766db17c. Ported to three.js and GLSL from its MeshProjection class and the Triangle, Edge, Square and Vertex helpers (mesh selection by bounding sphere, move into decal box space, backface skip, the three kinds of kept vertices, angle sort and fan, at most 5 triangles per input triangle, UV from box x and y), the DecalMesh simple quad as the reference for the flat quad, and the Fade by angle, Fade near z bounds and Offset in viewspace shader subgraphs with their defaults (83 to 85 degrees, ZFadeDistance 0.1, offset 0.001). The limits stated in the post (slow generation, not for bullet holes, does not follow skinned or deforming meshes) are from the source README. No files were copied unchanged.
Render framework: three.js by mrdoob and contributors, https://t.co/EBYjsuzB6f, MIT license, npm package version 0.170.0. Used for rendering, shadows and raycasting. It is not the source of the decal technique.
My own code: the concrete yard scene and its procedural concrete textures, the five procedural decal textures (crack, hazard stripes, spray paint, floor marking, ramp arrows), the flat quad baseline placed at the hit point, the right-handed winding fixes, the debug wireframes and triangle counts, the camera moves, labels and video. No external models, textures or HDRI.
A flat decal quad floats off a round pillar, sinks into steps and flickers on the floor. driven-decals cuts a new mesh from the surfaces inside the decal box instead, so the paint follows every step.
Watch the wipe 👇
▸ The problem
The cheapest decal is a textured quad at a hit point. On anything that isn't flat it breaks: it hangs in the air beside a curved pillar, the next step up buries it, and on flat ground it sits at the same depth as the floor, so the two fight pixel by pixel (z-fighting).
▸ How it works
driven-decals (a Unity package) treats a decal as a box. When you place it:
1. Pick the static meshes whose bounding sphere touches the box.
2. Move every triangle into box space, where the box is a unit cube.
3. Skip triangles facing away from the projector.
4. For each triangle, keep its corners inside the cube, the points where the cube's 12 edges pierce it, and the points where its edges cross the cube's 6 faces.
5. Sort those points by angle around their centre and fan them into new triangles, at most 5 per input.
6. UV = each point's x and y in box space.
The shader then hides the leftovers. Fade by angle: surfaces at 83° to 85° from the projector fade out, since past that the texture smears. Fade near z bounds: soften where a surface leaves the front or back of the box. Vertices also move 0.1% of the way toward the camera, so the decal wins the depth test against the surface it copies.
▸ What I hit
Unity is left-handed with clockwise front faces; three.js is the opposite. I kept the source's maths in box space and fixed the edges: the backface test uses a normal oriented by the mesh's own vertex normals, and new triangles are flipped to face the projector.
Faces exactly side-on to the projector are skipped as backfaces, so to show the angle fade I tilted the projector 3.4°. Without the fade, the step risers catch streaks.
The default z fade covers 5% of the box depth at each end. On a 0.42 m box that's 2 cm, invisible in a wide shot, so the last shot uses ZFadeDistance 0.5 instead of 0.1.
▸ My build
three.js 0.170, WebGL2. Five decals: a crack across a wall corner, hazard stripes round a pillar, spray paint over steps, a floor marking, ramp arrows. Generation runs once at load, in JavaScript. For the box over the steps: 3,560 triangles tested from 7 meshes, 2,616 face the projector, 128 reach into the box, 182 triangles come out.
▸ Trade-offs
+ The decal is real geometry, lit and shadowed like any mesh
+ Drawing it costs no more than a small mesh
- Generation is slow; the source's README says not to use it for bullet holes during gameplay
- The mesh is frozen, so it won't follow skinned or deforming meshes
- Every triangle of every selected mesh is tested, even ones far outside the box
- I rendered on a software GPU, so no timings
▸ When to use it
Decals placed in level design: cracks, graffiti, signs, stains. For bullet holes or anything on animated characters, use a screen-space or deferred decal system.