Destructible environment
Decision #66. Static objects (walls, doors, fences, furniture, trees, items) can be hit and broken. A hit shakes the sprite and flashes it white, damage shows as cracks, and the last hit replaces the sprite with real 3D chunks that fly apart, come to rest and sink into the ground.
Everything here is client-side presentation so far: the offline demo deals the hits (left-click an object outside a battle). The server is meant to own object hit points (see Authority below).
Code: crates/uo-client/src/destruction.rs, the effect slots in
impostor.rs, the shader side in shaders/impostor.wgsl; durability in
uo_proto::objects::durability.

Durability
uo_proto::objects::durability(sprite) gives the number of hits an object
takes, from its name: stone-like walls (stone, brick, marble, dungeon, cave,
sandstone) 8, other walls 5; doors, gates and fences 6 if stone or iron,
else 4; trees, rocks and ore 6; anything else 3. Ground, floors, roofs,
stairs, docks, rugs, water, moongates, mine and trap pieces return None
and can't be destroyed: removing them would leave holes the map can't show
yet. Every Static with a durability gets a Durability { hits, max, seed }
component; seed (a hash of tile, height and sprite) fixes its crack and
fracture pattern.
A HitStatic { target, damage, from } message takes damage hits off the
target; from is the attacker's game-space position, which decides which
way the object shakes and where the chunks fly.
Hit feedback in the impostor shader
All impostors share one material (decision #64), so per-object effects live
in an effect slot: a fixed buffer of 4096 ImpostorFx { hit_time, damage, seed, push } records (binding 107). The entity's MeshTag carries
the frame record in its low 20 bits and the slot above (FX_SHIFT); slot 0
means no effect. A slot is taken on the first hit and kept while the object
is damaged (its cracks stay), and returned when it breaks. The buffer is
written only when a hit happens and keeps its size, so Bevy rewrites the
same GPU buffer and the material keeps its bind group (#32). The animation
runs in the shader from hit_time and Bevy's globals.time (the prepass
declares the globals at its view binding 1 itself).
- Flash. For
FLASH_TIME(0.07 s, about four frames) the sprite is filled with flat HDR white: the old-school hit flash. - Shake. For
SHAKE_TIME(0.35 s) every texel row slides sideways by a whole number of texels,round(SHAKE_AMP · push · up · env² · sin(2π · 11 Hz · t)):upis the row's height over the anchor relative to the sprite's top, so the object is pinned at the ground and its top wobbles most (3 texels);envfades out linearly;push(±1) starts the motion away from the attacker. The fragment shows the texel its row slid in from, and its rebuilt 3D position moves with it, so depth and lighting follow the shake. Impostors with a slot draw their quadSHAKE_MARGIN(4) texels wider on both sides; their culling bounds grow to match. The shadow shell doesn't shake (too short to notice). - Cracks. The object is cut by a seeded 3D Voronoi tiling of
CRACK_CELL(0.5 world units ≈ 11 texels) in anchor-relative space. Each border between two cells has its own random threshold and shows once the damage (1 − hits / max) passes it, so cracks spread as the object wears down. A texel within half a screen texel of a visible border is darkened (×0.2) and its normal bent towards the neighbour cell, so lighting gives the groove a lit and a shaded side; past half damage the next texel out is bent less (a chipped rim). The distance is measured in screen texels (the border distance divided by how fast it changes from one texel to the next on this surface, from its normal), so a crack is one texel wide however the surface is turned; a per-texel jitter keeps the lines jagged. From 40 % damage every cell's normal also tilts a little its own way, so the pieces shade apart before they fall. Only impostors with a slot pay for the 27-cell search.
Breaking into 3D chunks
The last hit despawns the impostor (with its shadow shell and light) and
spawns chunks built from the sprite itself (fracture), so every baked
sprite breaks without extra art:
- Every covered texel that breaks is a prism along the view ray from
its front (
height.png) to its back surface (height_back.png, decision #25), at least 1.5 texels thick, with outlier back depths clamped like the shells. Texels that glow (as in the shadow shells) or are marked indebris.pngvanish instead: leaves, flames, embers (see Vanishing parts). - The prism is cut into 2-texel slices along the ray, and each slice
joins the Voronoi cell of its middle, so deep objects split in depth too
instead of into long columns. The cells are the crack cells the shader
drew (bit-exact hash,
cell_pointin both Rust and WGSL), so the object splits along its cracks. A crack cell whose debris would be longer thanMAX_CHUNK(0.7 world units) is cut again by a finer, differently seeded Voronoi tiling ofSPLIT_CELL(0.3), so big volumes (forges, giant mushrooms, trunks) break into many small chunks; no chunk is longer than ≈ 0.75. Consecutive slices of one chunk merge into one segment. - Faces: a segment's front is the texel's surface (baked normal and albedo) when it is the frontmost one, else a cut through the inside; cuts and sides between chunks get the albedo darkened to 70 % (the broken inside), the object's outline keeps the texel colour. Side faces are skipped where the neighbour texel has a segment of the same chunk at about the same depths (within 0.75 texels).
- Counts (frame 0): stone wall ≈ 40 chunks / 16 k triangles, crate 21 /
17 k,
forge_large_s149 / 60 k,mushroom_giant70 / 21 k,tree_oak(trunk only) 19 / 5 k. - Chunks are ordinary meshes with one shared
ToonMeshMaterial(vertex colours, toon banding, outline tag), so they are lit, cast and receive shadows and get outlines like the 3D mobiles. Bevy draws nothing with a pipeline until it has compiled, so a one-triangle mesh with the same material and vertex layout sits far below the world from startup (never culled) to compile the debris pipelines before the first break. - Motion (
fall_debris): each chunk is launched outwards from the object's middle, away from the attacker and upwards (higher chunks faster), with a random spin; gravity 14 tiles/s². It bounces on the floor (restitution 0.3, horizontal friction 0.55, spin halved) and rests below 0.6 tiles/s. The floor is the object's base height within 0.9 tiles of its footprint (an upper storey's floor) and the terrain beyond. After 2.5–4 s a chunk sinks by its own size over 2 s (hidden by the terrain or floor depth) and is despawned. - The broken object stops blocking: wall/fence/door edges are unblocked in
Walls, other objects free their tile inBlockers. Upper-storey pieces (Overhead) never blocked anything and change nothing.
Vanishing parts

Some parts should not turn into rubble: foliage, flames, embers. A model
marks their materials with the custom property uo_debris = "vanish"
(object_kit.vanish(mat); set by flame(), lc_kit.ember() /
hot_coal(), nature_kit.tones() leaf tones and the leaf* palette
entries). The baker then renders one more pass and writes debris.png
(255 = vanishes; majority of the covered subsamples, has_debris in
sprite.json, see docs/sprite-format.md); models without such
materials skip the pass and get no file. fracture drops those texels, so
a tree breaks into its trunk and branches and its crown disappears, a torch
or forge loses its fire. Strongly glowing texels vanish even without a mask.
Soft plant matter is marked too: blossoms, grass, flower heads and stems,
reeds, lily pads, vines and the flowers in pots and vases, so a flower bed
leaves no rubble.
51 sprites carry a mask (trees, bushes, ferns, flowers, vines, candles,
torches, braziers, campfire, fireplaces, forges, oven, cauldrons, …); their
other maps re-baked byte-identical.
Particles

The vanishing texels burst into particles (particles.rs): each one
(sampled down to MAX_PARTICLES = 400 per break) becomes a texel-sized box
with its colour, starting at the texel's front surface.
- Leaves, petals, grass (no emission): 2 × 2 × 0.6-texel flakes thrown outwards, away from the blow and up, falling with drag (gravity 5, drag 2.5/s) and a sideways flutter while they spin; they settle on the floor (same floor rule as the chunks) and shrink away at the end of a 1.8–3.2 s life.
- Sparks (texels with emission: flames, embers, coals): 1-texel cubes
rising fast, slowed by drag, shrinking over 0.3–0.8 s; they emit their
emission colour (
vertex_emission1.6, HDR, a little bloom). - Meshes are shared per kind and colour (5 bits per channel) with vertex
colours and the debris vertex layout, so they use the debris pipelines
(no compile on the first burst) and draw instanced. Two toon materials:
both skip the outline (
no_outlinetags the prepass like terrain; tiny particles would be all outline), sparks addvertex_emission. Particles cast no shadows. Motion is per-entity CPU integration.
Area destruction

A Blast { center, radius, damage } message (magic, a catapult stone)
hits every destructible static within radius (3D distance, game space)
with damage · (1 − (d / r)²) hits, at least 1, from the centre, so the
chunks fly outwards and the survivors at the edge crack and flash. Demo:
Shift + right-click blasts the ground under the cursor (radius 2.5, 12
hits).
Break queue. A broken static is queued (BreakQueue) and stays drawn
until its debris replaces it; process_breaks fractures and spawns queued
breaks until the frame's break_budget_ms is used up (at least one per
frame), so an explosion spreads its CPU work over frames instead of
freezing one.
Quality settings
The user settings (docs/settings.md) decide how much of this is shown:
| setting | values | effect |
|---|---|---|
debris | physics / fall / off | physics: small chunks thrown apart, spinning, bouncing. fall: crack-cell chunks only (no finer split, about half as many) that drop straight down where they were, land without bouncing and sink. off: no chunks, the object just disappears. |
particles | full / reduced / off | at most 400 / 100 / 0 particles per break |
max_debris | chunks | alive-chunk cap: past it the oldest chunks start sinking at once, and a break bigger than the whole cap keeps its biggest chunks |
max_particles | particles | alive-particle cap |
break_budget_ms | ms | CPU time per frame for queued breaks |
Presets: high physics, full particles, 3000 chunks, 4000 particles, 4 ms; medium physics, reduced particles, 1200 / 1200, 3 ms; low fall, no particles, 400 chunks, 2 ms.
Measured on the demo house (UO_DESTRUCTION_BLAST=25,64,4: 37 objects
break), 2560×1600, zoom 4, on a heavily loaded machine (absolute frame
times are rough):
| preset | break work | worst break frame | alive after the blast |
|---|---|---|---|
| high | 120 ms over 18 frames | 23 ms | 1145 chunks, 1593 particles |
| medium | 84 ms over 19 frames | 12 ms | 1145 chunks, 862 particles |
| low | 47 ms over 16 frames | 5 ms | 400 chunks |
| debris and particles off | 4 ms in 1 frame | 4 ms | — |
A single job can overrun the budget: the first break of a sprite loads its sheet from disk (PNG decode) and big sprites fracture in up to 7 ms. With high settings the frame time rises noticeably while ≈ 1100 chunks and 1600 particles live (each is an entity; chunks cast shadows) and recovers as they sink after 2.5–4 s; low stays close to the baseline.
Picking
SpriteAsset::coverage keeps every frame's covered texels; the shared
controls (controls.rs) pick the frontmost destructible sprite whose covered texel is
under the cursor (largest iso::depth_key among the hits) and sends one hit
from the player's position. Hidden overhead parts can't be picked.
Testing
--destruction-test builds a row of five stone wall pieces and a crate on
open ground near the player, looks at it, damages the pieces to 0, ¼, ½ and
¾, breaks the fifth and the crate 10 frames before the screenshot frame and
hits the fourth 2 frames before it (flash). UO_DESTRUCTION_TEST=break,hit
moves those two events (frames before the screenshot frame; more than
--frames = never). UO_DESTRUCTION_ROW=tree_oak,forge_large_s,... puts
those objects in the row instead (two tiles apart) and breaks them all:
uo-client --destruction-test --screenshot out.png --frames 60 --zoom 6
UO_DESTRUCTION_TEST=999,999 uo-client --destruction-test ... # cracks only
UO_DESTRUCTION_ROW=tree_oak,mushroom_giant,forge_large_s uo-client --destruction-test ...
UO_DESTRUCTION_BLAST=25,64,4 uo-client --destruction-test --set preset=low ... # blast tile (25, 64)
UO_DESTRUCTION_BLAST=x,y,radius looks at tile (x, y) and blasts it at the
break frame instead of building the row. With RUST_LOG=uo_client::destruction=debug
every frame with queued breaks logs their count, time and the alive chunks
and particles.
Unit tests check that every breaking texel's front surface lands in
exactly one chunk, that no chunk is longer than about MAX_CHUNK, and that
the cell hash is deterministic and seeded. With no damaged objects the frame
time is unchanged within noise (village, 2560×1600, zoom 4: minimum of three
12-s runs 6.6 ms vs 6.2 ms on main on a loaded machine).
Authority and next steps
- Server. Object damage belongs to the server (or the chain for player
structures): statics need stable ids on the wire, a hit command and
ObjectDamaged { id, hits, max, from }/ObjectDestroyed { id }events that feedHitStatic. The client effects don't change. - Chunks don't collide with each other or with other objects; small dust and chip particles on every hit; a crumble sound hook.
fractureand the first sheet load of a sprite run on the main thread (the break budget only spreads them over frames); they could move to a task pool, and sheets of statics near the camera could be preloaded.- With many chunks alive: merge a break's chunks into fewer meshes, or let chunks stop casting shadows on lower settings.
- Broken walls leave upper storeys and roofs floating; buildings will need structural rules (or indestructible load-bearing pieces).
- Floors, roofs and ground are not destructible yet (holes need terrain and floor support).