Skip to content
Felucca Online

Docs

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.

Cracks at ¼, ½ and ¾ damage, a hit flash, and a broken wall

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)): up is 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); env fades 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 quad SHAKE_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 in debris.png vanish 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_point in both Rust and WGSL), so the object splits along its cracks. A crack cell whose debris would be longer than MAX_CHUNK (0.7 world units) is cut again by a finer, differently seeded Voronoi tiling of SPLIT_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_s 149 / 60 k, mushroom_giant 70 / 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 in Blockers. Upper-storey pieces (Overhead) never blocked anything and change nothing.

Vanishing parts

An oak, a giant mushroom, a large forge and a torch: intact, breaking, scattered

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

An oak, a flower bed and a torch: intact, just broken, half a second later

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_emission 1.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_outline tags the prepass like terrain; tiny particles would be all outline), sparks add vertex_emission. Particles cast no shadows. Motion is per-entity CPU integration.

Area destruction

The demo house before and half a second after a radius-4 blast

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:

settingvalueseffect
debrisphysics / fall / offphysics: 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.
particlesfull / reduced / offat most 400 / 100 / 0 particles per break
max_debrischunksalive-chunk cap: past it the oldest chunks start sinking at once, and a break bigger than the whole cap keeps its biggest chunks
max_particlesparticlesalive-particle cap
break_budget_msmsCPU 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):

presetbreak workworst break framealive after the blast
high120 ms over 18 frames23 ms1145 chunks, 1593 particles
medium84 ms over 19 frames12 ms1145 chunks, 862 particles
low47 ms over 16 frames5 ms400 chunks
debris and particles off4 ms in 1 frame4 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 feed HitStatic. 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.
  • fracture and 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).