A remake of the spirit of KPT Bryce — the beloved 1990s 3D landscape generator — built in Rust, compiled to WASM, rendered with WebGPU, and running entirely in the browser. A Raspberry Pi 5 with 4 GB of RAM is a first-class, release-gating target: if it doesn't run there, it doesn't ship.
The workspace running on a Pi 5's own V3D GPU: Create/Edit/Sky palettes,
context-sensitive preset rail, live viewport, status bar.
- KPT Bryce: https://en.wikipedia.org/wiki/Bryce_(software)
- Bryce 7 Pro / UltraScenery 2: https://www.daz3d.com/bryce-7-pro
- A video of Bryce 1: https://www.youtube.com/watch?v=MY8GPU5osx4
- Deploy in the browser
- Has basic generative terrain generation
- Has nice UX similar to the original Bryce
- Can run on a low scale Raspi5 or similar in 4GB ram or less
- Use Browser WebGPU
- Structure, which is the part fractal noise cannot do. Every generator is a basis: they differ in the texture of the relief and are statistically the same everywhere, which is why terrain built from noise alone reads as one endless kind of place whichever knob you turn. So a second, non-fractal layer imposes a shape that knows where the middle of the map is — island (land with an edge, and a seabed that keeps going down past it), coast, rift (the structure is a line, which isotropic noise can never produce), dome, crater — plus a domain warp that bends everything the generator draws for the cost of two extra noise lookups. Warp is the cheaper half and does more: it is the difference between "procedural" and "somewhere".
- Four generators — ridged multifractal, fBm, billow, diamond-square — computed in WGSL compute shaders (validated bit-close against CPU references), regenerating live as you drag sliders.
- Erosion filter: branching gullies and ridges, evaluated per point in a single compute pass — no simulation, no droplets. Seven sliders, each one kept because it measurably changes the picture across its whole range. It is a filter, so it applies over any generator or an imported heightmap, and it is non-destructive: the parameters live in the document and re-run after every regenerate, so dragging a slider never compounds and re-rolling the seed re-rolls the gullies. It also emits a ridge map, which the material uses to lay each preset's own sediment tone down the gully floors, at an opacity you control. Implements the technique in Rune Skovbo Johansen's Fast and Gorgeous Erosion Filter. Gullies read best over a smooth base — 3–4 generator octaves.
- Bedding: flat-lying rock beds, terraced into the heights before the erosion filter, so gullies cut down through the benches rather than being laid over them. This is what a mountain is missing to be a mesa — fractal noise makes self-similar relief, and canyon country is not self-similar, it is stratigraphy. Three controls: amount, bed count (fewer means taller cliffs), and dip. Dip is the one that decides whether it reads as geology: level beds trace the terrain's own contours and look like a topographic map, and a few degrees of tilt turns the same bands into rock.
- Protect flats: the slope below which nothing is carved at all — gullies
and sediment both.
slope_onsetsets where erosion reaches full strength and is anchored at the origin, so it only reaches zero at zero: three-degree ground still carried a tenth of the gully amplitude and twice that of the alluvium, which on a plain or a seabed reads as erosion where no water ever ran. This shifts the foot of the same ramp rather than scaling it, which is what stops it terracing. - Erosion concentration: which end of the hill the water works on. Alpine relief is cut from the top, where the ice and the runoff are; badlands and canyon country are cut from the bottom, where the base level is falling and the cliffs retreat upward. Mid-slope keeps the strength the slider says either way, so the control redistributes the carving rather than adding it.
- Simulated erosion: thermal (talus-gated) and hydraulic (simplified transport) GPU kernels, dialled as amounts rather than applied by repeated clicks — the passes live in the document and re-run over a fresh base, so the sliders go back down as well as up.
- Painting, two signed brushes: height raises and carves, relief softens and sharpens. Strokes are stored as paths, not pixels, so the document stays kilobytes, the mask is resolution-independent, a whole drag is one undo step, and the generator, beds, gullies and plants all stay live underneath.
- 16-bit grayscale heightmap PNG import/export.
![]() |
![]() |
|---|---|
| Terrain Lab: generator stack + filters, viewport as live preview | After 3× hydraulic erosion — sediment fills the valleys |
- Grass, shrubs and trees, placed and drawn entirely on the GPU. A compute
pass walks a jittered grid over the map, reads the finished heightmap, and
appends what grows at each slot straight into the instance buffer — bumping
the
instanceCountword of that species' indirect-draw arguments as it goes. The render pass issues onedrawIndexedIndirectper species and never learns how many plants it drew. Nothing is read back, nothing is uploaded per frame, and dragging the density slider costs one dispatch instead of pushing tens of thousands of transforms through the wasm boundary. - Which species grow, and where, comes from the material preset and the ground. The placement kernel runs the same four-layer blend the fragment shader paints with and takes the ground-cover layer's share as its density — not an approximation of it — so plants cannot stand on painted rock, and nothing takes root under water. Two sets of thresholds tuned into agreement is how that goes wrong; the fix is to have one set. So the document carries two numbers — how much and how big — and no transforms at all.
- Small solid meshes rather than alpha-tested billboards, because V3D has no hidden-surface removal: a discarded fragment costs exactly as much as a drawn one, so a cutout quad is more expensive than the geometry it stands in for.
- A physical sky. Rayleigh, Mie and ozone, integrated through a real atmosphere. A horizon-to-zenith gradient is two colours somebody chose; it cannot know that the sky is blue because air scatters short wavelengths sixteen times more than long ones, that a sunset is red because the light has come through fifty times more air than at noon, or that twilight is blue rather than grey because ozone absorbs in the middle of the spectrum where nothing else does. A model that contains those gets every hour right at once.
- Following the shape Sébastien Hillaire published in 2020 — the technique, not the code — the scattering integral is factored into three small tables: a 256×64 transmittance table and a 32×32 multiple-scattering table that depend only on the air, and a 192×108 sky-view table rebuilt when the sun moves. So dragging the sun costs one small pass and moving the camera costs nothing. The multiple-scattering term is the one single-scattering models are missing, and without it a clear sky is far too dark away from the sun and twilight collapses to black.
- Everything else reads the same table. Ambient light on terrain, objects and plants is the sky in the direction the surface faces — so a slope facing the sunset is lit warm and the one behind it is lit blue. Aerial perspective is the sky in the direction you are looking, which is what haze physically is. Water reflects the table itself, so a reflection and the sky it reflects are the same numbers rather than two approximations of each other.
- A sky preset is therefore a sun elevation and an aerosol density, not a set of colours: the colours fall out.
- A draggable sun dome widget, wind-scrolled 2D clouds, and distance fog. Four presets (clear-day, dusk, night, overcast) are starting points — the Sky Lab designs every parameter: sun brightness and color, horizon/zenith colors, fog color and density, and cloud coverage/scale/softness. Custom skies detach from their preset and save with the document.
- Shadow mapping: terrain and objects cast and receive sun shadows.
- One
set_skycommand relights the entire scene — sun, fog, sky, objects.
![]() |
![]() |
|---|---|
| Dusk preset | Sky Lab: custom clouds, fog, and sky colors |
- The level is a control, in the Terrain Lab next to the ground it floods. It is a fraction of the terrain's own height range, so the same number means the same thing after a regenerate.
- How wide the beach is follows the shelf, which is the part a constant band gets wrong. A shallow shelf makes waves break far out and spend their energy across a broad surf zone, building a wide dissipative beach; a steep shelf delivers the same energy to the shore in one place and leaves a narrow one, or bare rock. So the sandy side of an island is the gentle side, and the cliff under the massif stays a cliff.
- The material knows about it: sand on gentle shores and under the shallows, bare rock where the land meets the water as a cliff, and a narrow wet strand that is darker and takes a specular sheen — wet ground absorbs the light that would otherwise have scattered back out of it. And altitude for the material means above the waterline, not a fraction of the whole map: without that, the moment a scene has a seabed the land is squeezed into the top of the range and an island's beaches come out at the altitude the palette reserved for alpine meadow.
- Water is a surface, not a blue plane: the shader reads the terrain underneath each fragment and colours it by how deep the water is there, so shallows are green and the depths are nearly black, and raising the level darkens the whole lake rather than just moving its edge. Fresnel-weighted sky reflection (the same horizon-to-zenith ramp the sky dome draws, so the two cannot drift), sun glint, and scrolling noise layers whose octaves fade out at the screen footprint where they stop being resolvable — the horizon stays glassy instead of ringing with moiré. Noise rather than crossing sine trains, because sine trains are periodic however many you add, and on a plane running to the horizon the repeat is plainly visible as corduroy.
- The surf line is the horizontal run to where the bed breaks the surface, not the depth: surf gathers on steep shores and a wide flooded flat stays a lagoon.
- The plane carries out past the terrain, so one object and one control is a lake below the ridges and a sea above them.
- Four-layer procedural materials blended by altitude and slope windows; presets: verdant, desert, volcanic, arctic.
- Rock, up close. Four flat colours blended by altitude and slope is a map: at any distance the bands read as contour lines, and the giveaway that sedimentary rock has been painted rather than deposited is that its bedding is perfectly horizontal and perfectly smooth. So the palettes that want it get warped bedding tint, grain, and the dark manganese-and-iron varnish that collects on faces old enough to have stopped moving. Two octaves of warp, not one — a third of a band is not enough, and the bands go back to tracing contours.
- Ambient light comes from the sky, not from a fraction of the sun. The old
flat
0.25 × sun_colourwas the single worst thing in the picture and it is invisible until you look for it: at dusk the sun is orange, so every shadow was orange too and the scene collapsed into one hue with nothing left to separate lit ground from shaded. Ambient is now a hemisphere — zenith overhead, horizon around — so an up-facing surface picks up the blue above it and a wall picks up the band it faces, with the erosion filter's own curvature map darkening the gully floors that see less of the sky.
![]() |
![]() |
|---|---|
| Desert preset retracks eroded geometry automatically | Material Lab: layer windows visualized |
- Scene graph with terrain, water planes, ground plane, primitives (sphere/cube/cylinder/cone/torus), point lights, cameras — all placed, transformed, hidden, locked, duplicated, and deleted through undoable commands (depth 256).
- Analytic click picking (ray vs. exact shapes + heightfield march), selection highlighting, arrow-key nudging.
- Starter scenes in the Create rail — Alpine, Foothills, Red Canyon, Monument, Island, Fjord, Ashfields, Moonscape. The rail used to list the four generators, which is a menu of implementation details: "ridged multifractal" is not a place, and the settings that make a canyon a canyon are spread over four panels. Each preset is ordinary commands, so it lands as one undo step and every part of it is reachable by hand afterwards; the seed survives, so switching between them shows the same land under different conditions.
- Bryce-style workspace: three mode palettes, preset rail, full-screen
Labs (live preview + Apply/Cancel via the undo stack), objects
drawer, toasts,
?shortcut overlay. - Autosave + crash recovery: the scene autosaves to origin-private OPFS storage every 60 s and on tab-hide; after a crash, the next launch offers to restore.
- Documents save/load as versioned
.burysuJSON.
The disaster drill: SIGKILL the browser, relaunch, restore the autosave.
- A REPL console (
`) and a natural-language bar (/) that both speak one command language:terrain mountains scale=large,water level=deep,move #3 to 1 0 2,list,undo— or just "mountains and a lake at dusk". The natural-language tier is a keyword engine (no LLM, microseconds, zero RAM); optional LLM backends layer on top emitting the same DSL. - Semantic, scene-relative vocabulary:
water level=valley_floorresolves to the 30th percentile of the actual terrain heights, not a fixed number. - Camera flyby:
orbit the scene,fly over the mountains,fly through the valley— or the three buttons on the top bar. Catmull-Rom paths through control points a generator places, and the valley generator reads the terrain as displayed to find the low ground. Every path is lifted clear of the ground along the whole curve, not just at its keys: the valley path deliberately puts its keys in the lowest ground on the map, and the spline between two such points otherwise sails straight through the ridge between them. - Optional LLM tiers, off by default and configured on the prompt bar. A tier only supplies actions in the same closed vocabulary; the reply is schema-validated in Rust before anything touches the scene and then compiled by the same deterministic code as the offline parser, so a bad model can be unhelpful but never wrong about your document. One repair retry, then Tier 0, which is always the floor. Your sentence and a two-line scene summary are all that leave the machine.
- Macro recorder: every discrete edit — typed, clicked, or applied in a lab
— is echoed to a transcript you can save as a replayable
.musescript and load back. - External control surface: open the app with
?repl=ws://host:portand an outside program drives the engine over WebSocket — one line in, one structured JSON result out (with thecreatedids to build-then-manipulate). Full protocol and vocabulary in docs/CONSOLE.md.
- Rust →
wasm32-unknown-unknownwithwgpu; the same crates run natively for headless GPU tests (noise/erosion kernels validated against CPU references, offscreen golden-pixel rendering). - A frozen JSON command/event/query API (FSD §6.3) is the only way anything mutates the document — the Svelte UI speaks it today, the Muse natural-language assistant will speak it next (see docs/ASSISTANT.md).
- Runs on the Pi 5's V3D GPU via WebGPU compatibility mode with a readback presentation fallback, and survives that driver's quirks by construction — see the war stories below.
Prerequisites: Rust (stable) with the wasm32-unknown-unknown target,
wasm-pack, and Node ≥ 18
(nvm install --lts works; the build script finds nvm installs).
./scripts/build-web.sh # wasm-pack + vite build into app/dist (+ size gate)
python3 -m http.server -d app/dist 8080 # open http://localhost:8080Development loop:
./scripts/check.sh # full gate: fmt, clippy, tests, wasm build, size budget,
# headless-browser smoke test
cd app && npm run dev # Vite dev server for UI work (expects app/pkg from wasm-pack)WebGPU on Linux Chromium is behind flags: enable #enable-unsafe-webgpu
(and #enable-vulkan) in chrome://flags.
The Pi needs an upstream-configured Chromium (the distro build has no working WebGPU backend) and two URL parameters:
chrome --use-angle=gles --enable-unsafe-webgpu --use-webgpu-adapter=opengles \
--enable-features=WebGPUCompatibilityMode \
'http://<host>:8080/?compat=1&present=readback'Full setup, measurements, and the reasons behind every flag: docs/pi5-findings.md and bench/PI5-SPIKE.md.
- Functional Specification (FSD) — scope, requirements, architecture, budgets, risks
- UX Plan — design language, application anatomy, labs, flows, accessibility
- Console & Control Language — how to use the REPL, the full command vocabulary,
.musemacros, and the WebSocket external-control protocol - Assistant Design — the "Muse": a REPL-first scene DSL (Poplog-inspired), text/STT intent parsing, tiered optional LLM backends, camera flyby
- Implementation Roadmap — stack decision, repo layout, milestones
- WebGPU Platform Notes — the bug catalog from bring-up: x86 vs Pi, driver bugs, workarounds, testing traps
- Pi 5 Findings — the Pi deep-dive with screenshots
| Milestone | ||
|---|---|---|
| M0 | Foundation & Pi 5 spike | ✅ |
| M1 | Terrain core (GPU noise, camera, PNG I/O) | ✅ |
| M2 | Document model, command API, undo, picking | ✅ |
| M3 | Sky, shadows, clouds, materials, erosion | ✅ |
| M4 | Svelte workspace, Labs, gizmos, autosave/recovery | 🔨 build complete; UX hallway test remaining |
| M4.6 | Landforms (bedding, concentration), water surface, debt paydown | ✅ |
| M4.7 | GPU-placed, indirectly-drawn vegetation | ✅ |
| M4.8 | Landform structure, shoreline materials, sky ambient | ✅ |
| M4.9 | Physically based atmosphere driving sky, ambient and haze | ✅ |
| M-AI | Muse assistant (Scene Intent DSL, REPL, LLM tiers) & flyby | 🔨 Tier 0, REPL, LLM tiers and flyby done; WebCodecs export remains |
| M5 | Progressive ray-traced final renderer | — |
| M6 | Hardening & 1.0 | — |
See FSD §7. In practice today: 38+ native tests (unit, GPU-vs-CPU kernel validation on any adapter including CI's lavapipe, offscreen pixel assertions), a headless-browser smoke test that catches Dawn-only shader failures, and scripted-click verification on a physical Pi 5 with screenshot pixel checks — because a running frame loop is not evidence of visible output (we learned that the hard way).
- On Pi 5 / Linux-GLES, Chromium cannot composite WebGPU canvases yet, so
the app presents through a readback path (
?present=readback) at ~30 fps; native swapchain presentation returns when the upstream fix lands. - The V3D driver misreads multi-slot uniform structs and silently drops some
draws; all shipped shaders follow defensive layout rules
(details).
diag.html(served from the app) runs a 30-test WebGPU bisect on any suspect device. - Hydraulic erosion is a simplified transport model (full water/sediment pipe model planned with renderer polish).
- Vegetation is not depth-sorted. Front-to-back ordering does matter on V3D — it has no hidden-surface removal — but a GPU radix sort per frame costs more than the overdraw it saves at this instance count. First thing to revisit if the scatter grows.
- Water does not refract what is under it — the bed's colour comes from the depth, not from reading the frame behind the surface. That needs a copy of the colour target, which the Pi tier cannot spare.






