Rockslide
Rocks tumbling down an eroded mountainside. The collider traces the same surface the mesh draws. Shows the .heightfield collider over a generated Heightfield.
- Run
swift run --package-path Examples Example-3D-Physics-Rockslide- Imports
OllinPhysics- Source
- Examples/3D/Physics/Rockslide/Sketch.swift
import Ollin
import OllinPhysics
/// A rockslide on generated ground: a diamond-square `Heightfield` grows a
/// mountainside, hydraulic rain carves its ravines, and the same field then
/// becomes solid terrain through the `.heightfield` collider, so every bounce
/// lands on exactly the surface the mesh draws. A steady feed of rocks
/// (spheres, slabs, cones) drops onto the ridge, tumbles down the gullies,
/// and piles into scree at the foot; rocks that roll off the edge fall into
/// the void and return to the top. The dice parameter regrows the whole slope.
@main
final class Rockslide: Sketch {
let world = World3D()
var land = Heightfield(columns: 2, rows: 2)
var terrain = Mesh(positions: [], indices: [])
var rocks: [Body3D] = []
var restFrames: [Int] = []
@Param(1 ... 99, icon: "dice") var terrainSeed = 12.0
private var builtSeed = 0.0
let landWidth = 14.0, landDepth = 14.0, landHeight = 4.2
let palette: [Color] = [
Color(hex: 0xE8632F), Color(hex: 0xF2A93B), Color(hex: 0xD94F70),
Color(hex: 0x8D92E0), Color(hex: 0x72BFB2),
]
override func setup() {
rebuild()
}
/// Grow, weather, and mount the mountainside: a ridge along the west edge
/// falling toward the east with fractal detail, eroded so ravines funnel
/// the rocks, then handed whole to the collider.
func rebuild() {
builtSeed = terrainSeed.rounded()
let seed = Int(builtSeed)
let bumps = Heightfield.diamondSquare(size: 129, roughness: 0.55, seed: seed)
land = Heightfield(columns: 129, rows: 129) { u, v in
// Steep near the ridge, easing into the flats where scree piles.
0.7 * pow(1 - u, 1.6) + 0.3 * bumps.value(u: u, v: v)
}
.eroded(.hydraulic(drops: 40_000), seed: seed)
.eroded(.thermal(talus: 0.012, iterations: 30))
.normalized()
terrain = terrainMesh(land)
world.removeAll()
world.addBody(.heightfield(land, width: landWidth, depth: landDepth,
height: landHeight),
at: .zero, kind: .static, friction: 0.55)
rocks.removeAll()
restFrames.removeAll()
for index in 0 ..< 16 { dropRock(stagger: index) }
}
/// A rock onto the ridge: a sphere, a slab, or a cone, staggered so the
/// feed arrives as a slide rather than one clump.
func dropRock(stagger: Int = 0) {
let collider: Collider3D
switch Int(random(0, 3)) {
case 0: collider = .sphere(radius: random(0.16, 0.28))
case 1: collider = .box(width: random(0.3, 0.5), height: random(0.22, 0.38),
depth: random(0.3, 0.5))
default: collider = .cone(height: random(0.34, 0.5), radius: random(0.2, 0.3))
}
let u = random(0.03, 0.1), v = random(0.15, 0.85)
let start = Vector3((u - 0.5) * landWidth,
land.value(u: u, v: v) * landHeight + 1.2
+ Double(stagger) * 0.55,
(v - 0.5) * landDepth)
let rock = world.addBody(collider, at: start,
rotated: random(0, .tau), axis: .unitY,
density: 2, friction: 0.45, restitution: 0.2)
rock.userData = palette[Int(random(0, Double(palette.count)))
% palette.count]
rocks.append(rock)
restFrames.append(0)
}
/// Back to the ridge after falling off the world's edge, or after resting
/// in the scree long enough for the slide to stay alive.
func recycle(_ index: Int) {
let rock = rocks[index]
world.remove(rock)
rocks.remove(at: index)
restFrames.remove(at: index)
dropRock()
}
override func draw() {
if terrainSeed.rounded() != builtSeed { rebuild() }
background(Color(hex: 0x0A0D12))
environment(.sky(turbidity: 3, sunElevation: 0.5).lightingOnly())
lightingPreset(.goldenHour)
castShadows()
cameraShowcase(.turntable(period: .tau / 0.08), radius: 17,
elevation: 0.38, fieldOfView: .pi / 4)
world.advance(by: deltaTime)
for index in (0 ..< rocks.count).reversed() {
restFrames[index] = rocks[index].velocity.length < 0.05
? restFrames[index] + 1 : 0
if rocks[index].position.y < -3 || restFrames[index] > 420 {
recycle(index)
}
}
material(.dielectric(roughness: 0.9))
fill(.white)
drawMesh(terrain)
material(.dielectric(roughness: 0.6))
for rock in rocks {
fill(rock.userData as? Color ?? .white)
withBody(rock) {
switch rock.collider {
case .sphere(let r): drawSphere(radius: r)
case .box(let w, let h, let d): drawBox(width: w, height: h, depth: d)
case .cone(let h, let r): drawCone(radius: r, height: h)
default: break
}
}
}
}
/// The field as a mesh with a height-colored texture: scrub at the
/// foot, bare rock up the slope, snow on the ridge.
private func terrainMesh(_ field: Heightfield) -> Mesh {
let ramp = Ramp([Color(hex: 0x27402C), Color(hex: 0x4A5A3C),
Color(hex: 0x77664F), Color(hex: 0x8C837A),
Color(hex: 0xD8DCE0)])
var pixels = [UInt8]()
pixels.reserveCapacity(field.values.count * 4)
for value in field.values {
let c = ramp.color(at: clamp(value, 0, 1))
pixels.append(UInt8((c.red * 255).rounded()))
pixels.append(UInt8((c.green * 255).rounded()))
pixels.append(UInt8((c.blue * 255).rounded()))
pixels.append(255)
}
let texture = Image(width: field.columns, height: field.rows,
premultipliedRGBA: pixels)
let mesh = field.mesh(width: landWidth, depth: landDepth, height: landHeight)
guard let texture else { return mesh }
return mesh.textured(texture)
}
}