Geometry painter
Drag across any mesh and geometry grows along the stroke: quartz crystals, molten cracks, curtains of aurora silk, a glowing reef. All instanced and animated in WebGPU shaders, for three.js.
It's GeometryPainterThreeJS by mohamedachrefelouafi (MIT), made into one piece you add to a scene. Patch added strokes that follow any mesh evenly however fast they're drawn, shared instance pools so a new stroke never hitches, and painting from code. In the live demo, drag on the object to paint, drag beside it or right-drag to orbit, and scroll to zoom.
Painting: GeometryPainterThreeJS by mohamedachrefelouafi (MIT). Code here is MIT, art is CC0.
Use it in a game
Copy src/painter.ts, src/surface.ts and vendor/geometry-painter/, and alias three to three/webgpu as vite.config.mjs does, so the vendored files share your one copy. It needs three's WebGPU renderer and three-mesh-bvh.
import * as THREE from 'three/webgpu';
import { GeometryPainter } from './painter';
import { PaintSurface, samplesFromFrame, wanderingStroke } from './surface';
import { SurfacePainter } from '../vendor/geometry-painter/src/surfacePainter';
// What to paint on: position and normal, in the space of the node it hangs from. Never drawn, only picked.
const anchor = new THREE.Group();
scene.add(anchor);
anchor.add(statue);
const surface = new PaintSurface(statueGeometry);
anchor.add(surface.mesh);
const painter = new GeometryPainter(anchor, surface);
painter.mode = 'crystals'; // or 'molten', 'aurora', 'reef'
// From the pointer: a brush ring and a live trail, then a stroke on release
const brush = new SurfacePainter(renderer.domElement, camera, scene, () => [surface.mesh], anchor);
brush.onStroke = (samples) => painter.paint(samples);
// Once a frame
painter.update(dt, seconds);
Paint from code. A stroke is a list of samples, so it can come from anywhere: a spell's path, a crack spreading from where a boss landed, a vein along a cave wall.
// Through points of the frame (x and y from -1 to 1); a curve runs through them and what misses the mesh is left out
painter.paint(samplesFromFrame([[-0.3, 0.2], [0, 0.4], [0.3, 0.1]], camera, surface, anchor), { mode: 'molten' });
// A wandering stroke of its own, on the side the camera sees
painter.paint(wanderingStroke(camera, surface, anchor), { mode: 'reef', grown: true }); // grown: skip the growth
// Your own points, in the anchor's space. paint() snaps them to the surface and walks them into even steps
const n = new THREE.Vector3(0, 1, 0);
painter.paint(path.map((p) => ({ position: p, normal: n, local: p, localNormal: n })));
painter.undo();
painter.clear();
painter.remove(stroke); // paint() returns the stroke
Change it live. painter.settings holds each mode's settings. Change them and call apply(): matrices, colours and uniforms change in place, nothing is rebuilt, so a slider can be dragged freely.
painter.settings.crystals.crystalSize = 0.3;
painter.settings.crystals.palette = 'Ice';
painter.apply('crystals');
painter.seed = 7;
for (const s of painter.strokes) painter.rebuild(s); // a new seed does regrow, from the stored samples
The mode contract
A mode is anything that turns samples into a living thing. New looks plug in without touching painting, undo or clear (vendor/geometry-painter/src/modes/mode.ts):
interface SurfaceSample {
position: THREE.Vector3; // world-space hit, used only for the live preview
normal: THREE.Vector3;
local: THREE.Vector3; // the hit in the anchor's space, taken when it was picked: strokes stay glued as the object turns
localNormal: THREE.Vector3;
}
interface PaintMode<S> {
readonly id: string;
createStroke(samples: SurfaceSample[], seed: number, settings: S): StrokeInstance;
}
interface StrokeInstance {
group: THREE.Group; // add it under the anchor
update(dt: number, time: number): void;
finishGrowth(): void; // snap to fully grown
applySettings?(settings: unknown): void; // re-derive the look in place
dispose(): void;
}
The rules the four modes keep:
- Build once at each slider's maximum, cull live. A density slider hides instances by a stable rank; it never makes new geometry.
- Keep generative parameters, not baked matrices. Each instance stores its anchor, frame and randoms, so
applySettingsrecomposes everything in place. - Animate in the shader. Growth, heat, folds and the reef's pulse are TSL nodes driven by uniforms.
- Take instances from a pool (
poolSliceinmodes/instances.ts), withsetMatrixAt,setColorAtandinstanceMatrix.needsUpdateas usual. Never make an InstancedMesh per stroke (see below). - Let the painter handle lights. A
THREE.PointLightdirectly ingroupis taken out byGeometryPainter, which sums nearby ones into a fixed pool. Keep setting its intensity and colour inupdate.
To add your own mode, put it in the MODES table in src/painter.ts and add its settings to ModeSettings.
What Patch changed, and why
Measured here on an RTX 4090 in Chromium, with every pipeline warmed up first:
- A first visit is the slow one: 20 to 25 s, now under 8 s. That is a new browser profile with empty caches, the CPU at 1/4 and 30 Mbit/s (
tools/cold.mjs). Compiling shaders is most of it, and a warm cache hides it. Only what the opening shows compiles beforeready; the rest follows in the background. Every mesh and shader kept out of the opening helped: painted geometry casts no shadows, three crystal shapes and two rock shapes, one crystal material, four lights in the pool, a Lambert floor. The next-to-last step beforereadywaits forqueue.onSubmittedWorkDone(): without it the GPU was still working through its first frames, and the first frame on show took 300 ms. - A stroke took 250 ms to appear; now no frame passes 33 ms. In three's WebGPU renderer every new InstancedMesh runs the node builder again in each pass, because its buffer is part of what's built (
RenderObject's cache key has the mesh's uuid), so warming up can't help. A crystal stroke was ten instanced meshes in two passes. Strokes now share one mesh per kind of thing, and only the slices that changed are uploaded. - Changing light count recompiles every lit material.
GeometryPainterkeeps a fixed pool of four lights, dark until used. - Light must not jump between strokes. Picking the brightest lights for the pool each frame made glow patches flash on and off when strokes overlapped, because the modes' lights flicker and the order kept changing: 295 jumps in 121 frames with six cracks. Each pool light now stands in for a fixed group of nearby stroke lights, regrouped only when strokes come or go, and eases: no jumps.
- A compile keyed for the canvas does nothing for a post pass. The warm-up compiles with
scenePass.compileAsync(renderer). - Dispose the last user of a shader and three drops its pipeline. Cracks and silk have materials per stroke, so one of each stays, unseen, for good.
- Crossing zero in
emissiveIntensitycompiles the material again. Three's shader key tells 0 from anything else, so crystal glow never quite reaches it.
Strokes follow any mesh. Upstream's modes only place things at samples, so a fast drag came out sparse, and it walked crack branches over a sphere. resample walks every stroke into even 2 cm steps snapped to the surface with the bounds tree, smooths the normals along it, and splits a stroke that jumped across a gap (off the chin onto the chest) into separate runs. Branches follow the mesh through setFissureSurface.
Every change to the vendored code is listed in vendor/geometry-painter/VENDOR.md and marked Patch:.
WebGPU only. Upstream falls back to WebGL2; this block's look and warm-up are built for WebGPU. The demo plays in Patch Desktop and in Chrome or Edge 113 or later; without WebGPU it shows a message in its frame instead.
Controls
The block page talks to the demo over the patch-block message channel (lib/patch-block.js). In the frame: drag on the object to paint; drag beside it, or right-drag anywhere, to orbit; scroll to zoom. The object sways slowly while the pointer is away and holds still while it's over the frame.
For people (the block page shows these):
| id | Control | Type | Range | What it does |
|---|---|---|---|---|
mode | Mode | choice | Crystals, Molten cracks, Aurora silk, Glowing reef, each with a picture | What grows along a stroke. The demo's opening strokes change to show it, until you paint your own; yours stay as you painted them. |
object | Object | choice | Stone bust, Horse statue, Boulder, Stone orb, each with a picture | What you paint on. Clears the paint. |
size | Size | number | 0.4 to 2.2 ×, default 1 | How big what grows is, every stroke at once. |
density | Density | number | 0.2 to 2.2 ×, default 1 | How thickly it grows, every stroke at once. |
glow | Glow | number | 0.15 to 2 ×, default 1 | How brightly it burns, every stroke at once. |
Triggers: Paint for me (paint), Undo (undo), Clear (clear).
For agents (46 more, in recipe.json with every range and default): speed, colour (a turn of the colour wheel in the final picture), seed, auto (keep painting), turn (slow sway), stone, exposure, studio, backlight, bloom, and each mode's own settings: crystal_* (palette, height, clusters, shards, spread, lean, variety, pale crystals, inner glow), molten_* (width, heat, pulse, branches and their reach, embers, rock lips and their size, light spill), aurora_* (palette, height, billow, flow, rays, brightness, motes, light spill) and reef_* (palette, colony size, colonies, branching, anemone arms, bioluminescence, pulse, current, plankton, light spill). Size, Density and Glow multiply these.
Warm-up. The page shows a loading bar: demo.ts starts the helper first and fetches the heavy code after it, then reports real progress, shader by shader. Before ready, behind a veil over the canvas: the bust, the opening's crystals and cracks, the live brush and the post chain. After ready, in the background, one thing at a time: silk, the reef, then the other objects. A mode or object you ask for early moves up the queue and shows when it's compiled, so nothing freezes. Each object also sends its textures one a frame and draws its shadow once, unseen, on a layer only the shadow camera sees: those are the parts a background compile can't reach. The numbers are in the Changes section below.
Build it
npm install
npm run build # writes recipe.json, then dist/, with thumbs/ and the licences copied in
npm run dev # http://127.0.0.1:5371/
npm run thumbs # with the dev server running: thumbs/modes and thumbs/objects (needs ffmpeg)
npm run cover # with the dev server running: cover.jpg (needs ffmpeg)
dist/ is the live demo exactly as released, with index.html at the top and nothing loaded from other hosts.
| Path | What |
|---|---|
src/painter.ts | GeometryPainter: strokes, undo, clear, reseed, live settings, the light pool. The block's API |
src/surface.ts | PaintSurface, resample, samplesFromFrame, wanderingStroke |
src/demo.ts, index.html | The demo page's start: the block wiring and the loading bar, ahead of the heavy code |
src/app.ts | The demo: the camera, input, what each control does, warm-up |
src/stage.ts | The studio look (adapted from upstream's app.ts) and the objects |
src/heroes.ts | The strokes the demo opens with |
vendor/geometry-painter/ | GeometryPainterThreeJS at commit 79c7556, with its LICENSE; changes in VENDOR.md |
recipe.json, make-recipe.mjs | The release recipe (mode browser with its block section) |
thumbs/, tools/make-thumbs.mjs | The Mode and Object pictures, stills of this demo |
public/models/ | The bust and the horse statue |
lib/patch-block.js | Patch's block demo helper, as served at patch.gg/patch-block.js |
LICENCE-NOTES.md | Where every part came from |
Credits and licences
- Painting, modes and studio look: GeometryPainterThreeJS by mohamedachrefelouafi, MIT, © 2026 mohamedachrefelouafi, commit
79c7556(github.com/achrefelouafi/GeometryPainterThreeJS):vendor/geometry-painter/LICENSE. - three-mesh-bvh by Garrett Johnson, MIT. three.js, MIT.
- Marble Bust 01 and Horse Statue 01: Poly Haven, CC0.
- Red Hat Mono: The Red Hat Project Authors, SIL Open Font License 1.1.
- The painter API, the pools, the demo, the boulder: Patch, MIT. The pictures in
thumbs/: stills of this demo, Patch, CC0.
Code: MIT (LICENSE). Art: CC0. The font is OFL 1.1.