Destruction
The signature voxlap moment: carve a tunnel under an overhang and the unsupported rock does not hang there politely — it breaks off, falls, and bursts into rubble where it lands. roxlap’s destruction pipeline reproduces that loop on top of pieces earlier chapters covered — edits, sprites (chapter 7), particles (chapter 8) — with two engine parts of its own:
- Island detection (
roxlap-scene): after any carve,detect_islandsfinds the voxel regions that no longer connect to support. It floods the chunk format’s RLE runs directly (one run is one search node — no dense decode), 6-connected, seeded from the carve’s bounding box. A region that reaches a bedrock-anchored column bottom is supported; one that grows past the budget is presumed supported (a cheap early exit); everything else comes back as anIsland— its voxels, colours and bounds. DebrisSystem(roxlap-render): the crumble loop. It extracts an island from its grid, optionally fractures it per material, registers each piece as a falling sprite at the exact world pose of the voxels it replaced, integrates the fall, and reports impacts for the shatter. Like the particle system it is host-owned and split into a pure simulation half (spawn_island/update— unit-testable, no renderer) and a facade half (sync— batched sprite updates).
The snippets below come from a runnable, windowless example that prints what each stage does:
cargo run -p roxlap-render --example book_destruction
Detecting what came loose
A scene with something to lose — a floor, an anchored pillar, a beam cantilevered off it, crystal grown near the tip:
// A stone cantilever over a floor: a pillar anchored to the floor
// (and through it to the format's bedrock at the column bottoms —
// the support the detector floods toward), a beam sticking out
// sideways, and a crystal cluster grown near the beam's tip.
// z is DOWN: the floor is the high-z slab, the rock rises toward
// smaller z.
let mut scene = Scene::new();
let grid = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(grid).expect("grid just added");
g.set_rect(IVec3::new(0, 0, 200), IVec3::new(63, 63, 255), Some(STONE));
g.set_rect(
IVec3::new(30, 30, 160),
IVec3::new(31, 31, 199),
Some(STONE),
);
g.set_rect(
IVec3::new(32, 30, 158),
IVec3::new(48, 31, 159),
Some(STONE),
);
g.set_rect(
IVec3::new(44, 30, 154),
IVec3::new(47, 31, 157),
Some(CRYSTAL),
);
g.bake(BakeMode::Directional);
Carve, then ask. Detection is a separate call on purpose: hosts decide when it runs (the cave demo runs it on its background carve worker, right after the carve, on the same chunk):
// Shoot the beam off at its root, then ask what came loose: a
// budgeted span-BFS floods every region the carve touched;
// whatever cannot reach bedrock-anchored ground (and stays under
// the budget) comes back as an `Island` — its voxels, colours and
// bounds. The pillar survives (still anchored); the beam's tip
// and its crystal do not.
let g = scene.grid_mut(grid).expect("grid");
g.set_sphere(IVec3::new(33, 30, 158), 4, None);
let islands = detect_islands(
g,
IVec3::new(29, 26, 154), // the carve's bbox, any corner order
IVec3::new(37, 34, 162),
DEFAULT_ISLAND_BUDGET,
);
for isl in &islands {
println!(
"island: {} voxels, bbox {:?}..{:?}",
isl.voxels.len(),
isl.bbox.0,
isl.bbox.1
);
}
Two properties matter in practice:
- The budget is a design knob, not just a guard. A detached region
bigger than
budgetvoxels stays put —DEFAULT_ISLAND_BUDGET(4096) means shooting the single support out from under a whole gallery will not drop the gallery. Raise it if your game wants building-sized collapses; the flood’s cost isO(min(region, budget))per component, so the worst case is priced in advance. - Support means the format’s bedrock. Every materialised chunk’s column bottom (local z = 255) is uncarvable by construction, so a region touching it can never fall — this holds on stacked-chunk grids too.
Falling
// Hand each island to the debris system. `spawn_island` extracts
// the voxels from the grid (one carve + one incremental re-bake;
// re-mip stays the caller's job, like any edit), splits them per
// the fracture tables — rock into rounded Voronoi lumps, crystal
// into sharp plates that keep their emissive material — and
// registers every fragment as a falling body at the exact world
// pose of the voxels it replaced.
let mut debris = DebrisSystem::new();
debris.set_fracture_patterns(
&[(CRYSTAL.rgb_part(), CRYSTAL_MATERIAL)],
&[
(0, FracturePattern::Chunks { cell: 6 }),
(CRYSTAL_MATERIAL, FracturePattern::Shards { plates: 3 }),
],
);
for isl in islands {
debris.spawn_island(&mut scene, grid, isl, BakeMode::Directional);
}
println!("falling fragments: {}", debris.debris_count());
spawn_island does the irreversible part: the island’s voxels leave
the grid (a coalesced carve plus one incremental re-bake — re-mip is
your job, exactly as for your own edits; fold the island’s bbox into
whatever remip_bbox call your carve path already makes, or distant
mips keep drawing the rock that just fell). The sprite appears at the
island’s world_pivot, so nothing visually jumps — the voxels become
a falling body in place.
Fracture is data, keyed by material: Chunks { cell } breaks matter
into rounded jittered-Voronoi lumps (stone), Shards { plates }
slices it with near-parallel planes (glass, crystal); unmapped
materials fall Whole. A mixed island splits per material group, and
with the colour→material map installed the fragment sprites register
with it — a crystal shard keeps its translucent, emissive material
and glows all the way down (chapter 6). Fragments get a
small outward drift (fracture_impulse) so a broken slab visibly
comes apart instead of falling as a stack.
Physics is deliberately voxlap-simple and deterministic: vertical gravity with a terminal clamp, a cosmetic spin hashed from the island’s position (identical scenes crumble identically), and collision against the scene’s solid voxels with the descent marched in substeps — a fast body cannot skip through a one-voxel shelf on a slow frame. The AABB is the island’s unrotated box; the spin never affects collision.
Landing and shattering
// Per frame a windowed host calls `debris.tick(renderer, &scene,
// dt)` and shatters each drained impact into a colour-true
// particle burst (`ParticleSystem::voxel_debris(&hit.burst_sites(),
// …)`). The windowless half is `update` + `drain_impacts`: each
// landed fragment reports where it hit, how fast, and the
// world-space burst sites — one per voxel, in the voxel's own
// colour — a particle system scatters.
for _ in 0..600 {
debris.update(&scene, 1.0 / 60.0);
for hit in debris.drain_impacts() {
println!(
"impact at z={:.1} @ {:.1} u/s -> {} burst sites",
hit.pos.z,
hit.speed,
hit.burst_sites().len(),
);
}
}
assert_eq!(debris.debris_count(), 0, "everything landed");
A windowed host wires the impact to the particle system from
chapter 8 — burst_sites() hands back one world-space
site per island voxel, in that voxel’s own colour, positioned where
the body landed:
for hit in debris.drain_impacts() {
particles.voxel_debris(&hit.burst_sites(), from, 4.0..9.0, &burst_def);
}
The burst is the same machinery carve_debris uses for bullet
craters, so crater debris and crumble debris look and behave like the
same rock. Feed hit.pos to your impact sound while you are at it —
the cave demo routes it through the same occlusion-shaded boom as a
bullet hit (chapter 9).
The cave demo’s wiring
roxlap-cave-demo shows the full production shape (chapter
15): detection runs on the background carve worker
(same thread that already carves, relights and re-mips the chunk
clone), the extraction happens there too so the batch’s re-mip covers
it, and the main thread only spawns the returned islands and ticks the
system. Rock is mapped to Chunks, crystals to Shards, and
ROXLAP_NO_CRUMBLE=1 switches the whole thing back to plain carves.