PathTraced
Tune the scene live, then render the same frame offline with a path tracer.
- Run
swift run --package-path Examples Example-3D-Effects-PathTraced- Source
- Examples/3D/Effects/PathTraced/Sketch.swift
import Ollin
/// The path-traced export: tune the scene live, then render it offline.
///
/// The window shows this set through the ordinary raster pipeline, at full frame
/// rate, with every dial live. The *same sketch* exported with `--path-traced`
/// renders by tracing light paths instead: the panel's shadows soften with
/// distance, the floor picks up color bled from the spheres, every polished
/// surface mirrors the scene at any depth, the amber sphere turns to real solid
/// glass (light bends through it, its shadow glows amber instead of going black),
/// the glowing bar lights the set by its own surface, the scattered pebbles
/// (sixty-four copies of one mesh, one draw call) drop their own contact shadows and
/// stand in the floor's reflection, the hammered panel's
/// normal and roughness maps apply to every traced hit (its relief ripples the
/// reflections), and the camera's aperture turns into a real thin-lens depth of
/// field (the raster view stays sharp; the lens is the traced camera's). That is
/// the whole workflow: the live window is the viewfinder, the flag is the film back.
///
/// swift run Example-3D-Effects-PathTraced # tune live
/// swift run Example-3D-Effects-PathTraced --export out.png --path-traced 512
///
/// The trace needs an Apple-silicon (ray-tracing) GPU; elsewhere the export falls
/// back to the raster pipeline with a note. Samples: 64 sketches the light in
/// seconds and 512 carries the picture, though the two rough spheres still hold a
/// little grain there; each doubling halves it, at linearly more time. `--pt-depth`
/// caps the mirror recursion (8 is plenty).
@main
final class PathTraced: Sketch {
@Param(0...0.4, icon: "camera.aperture") var aperture = 0.12
@Param(3...14, icon: "scope") var focusOn = 7.4
/// The pebbles scattered over the floor, placed once and drawn as copies of
/// one small mesh. They are in the traced scene like anything else: each drops
/// its own soft shadow, stands in the floor's reflection, and bleeds a little
/// of its color onto its neighbors.
private let pebble = Mesh.sphere(radius: 0.13, segments: 16)
private var pebbles: [MeshInstance] = []
/// A hammered-metal relief, built in code: a staggered grid of rounded dents
/// baked into a normal map, with a matching metallic-roughness map that
/// keeps the dent floors polished and roughens the flats between them.
private static let hammered: (normal: Image, mr: Image) = {
let size = 256
let cells = 7.0
func dent(_ u: Double, _ v: Double) -> Double {
let row = (v * cells).rounded(.down)
let ox = row.truncatingRemainder(dividingBy: 2) * 0.5
var cu = (u * cells + ox).truncatingRemainder(dividingBy: 1) - 0.5
let cv = (v * cells).truncatingRemainder(dividingBy: 1) - 0.5
cu = abs(cu) > 0.5 ? cu - (cu > 0 ? 1 : -1) : cu
let d = min((cu * cu + cv * cv).squareRoot() / 0.46, 1.0)
let fall = 1 - d * d
return fall * fall
}
var nBytes = [UInt8](); nBytes.reserveCapacity(size * size * 4)
var mrBytes = [UInt8](); mrBytes.reserveCapacity(size * size * 4)
let step = 1.0 / Double(size)
for y in 0..<size {
for x in 0..<size {
let u = (Double(x) + 0.5) * step, v = (Double(y) + 0.5) * step
let strength = 1.6
let gu = (dent(u + step, v) - dent(u - step, v)) / (2 * step) * strength * step * cells
let gv = (dent(u, v + step) - dent(u, v - step)) / (2 * step) * strength * step * cells
let inv = 1 / (1 + gu * gu + gv * gv).squareRoot()
nBytes.append(UInt8(((-gu * inv) * 0.5 + 0.5) * 255))
nBytes.append(UInt8(((gv * inv) * 0.5 + 0.5) * 255))
nBytes.append(UInt8((inv * 0.5 + 0.5) * 255))
nBytes.append(255)
// Dent floors polish toward 0.1 roughness; the flats brush out to 0.5.
let rough = 0.5 - 0.4 * dent(u, v)
mrBytes.append(0)
mrBytes.append(UInt8(rough * 255))
mrBytes.append(255)
mrBytes.append(255)
}
}
return (Image(width: size, height: size, premultipliedRGBA: nBytes)!,
Image(width: size, height: size, premultipliedRGBA: mrBytes)!)
}()
override func setup() {
seed(4_120)
// Scattered over the open floor, kept clear of the sphere row so the
// shadows stay readable. Seeded, so the export renders the same set.
let seats = [Vector3(-3, 0, 0), Vector3(-1.5, 0, -1.3), Vector3(0, 0, 0),
Vector3(1.5, 0, -1.3), Vector3(3, 0, 0)]
while pebbles.count < 64 {
let p = Vector3(random(-5.2, 5.2), 0.13, random(-2.8, 2.4))
guard seats.allSatisfy({ Vector2($0.x, $0.z).distance(to: Vector2(p.x, p.z)) > 1.1 })
else { continue }
pebbles.append(MeshInstance(position: p,
rotation: Vector3(0, random(.tau), 0),
scale: Vector3(1, random(0.7, 1.0), 1),
color: Color(hue: 0.08, saturation: random(0.15, 0.35),
brightness: random(0.3, 0.6))))
}
}
override func draw() {
background(Color(hex: 0x0B0C10))
// The studio environment lights the set and fills mirror misses.
environment(.studio.intensified(to: 0.9))
// A warm softbox from the left: the traced export samples its real surface,
// so its shadows sharpen at contact and melt with distance.
rectangleLight(Color(hue: 0.08, saturation: 0.35, brightness: 1.0),
at: Vector3(-3.4, 2.6, 1.4),
direction: Vector3(0.66, -0.6, -0.46),
width: 2.6, height: 1.8, intensity: 6)
// The camera: a low viewpoint with a real aperture. `aperture` and
// `focusDistance` are read by the path-traced export only; the live view
// stays pinhole-sharp while you frame.
var cam = Camera3D(eye: Vector3(sin(time * 0.1) * 1.4, 1.5, 8.6),
target: Vector3(0, 0.55, 0),
projection: .perspective(fieldOfView: .pi / 4.2))
cam.aperture = aperture
cam.focusDistance = focusOn
camera(cam)
// A glossy dark floor: the traced render shows true reflections of the row
// and the color the spheres bleed onto it.
withState {
translate(0, -0.5, 0)
fill(Color(white: 0.35))
material(.metal(roughness: 0.12))
drawBox(width: 24, height: 1, depth: 18)
}
// The pebbles: one mesh, sixty-four placements, one draw call. A copy is in
// the traced scene the way the meshes around it are, so the export shows
// sixty-four small contact shadows and sixty-four reflections in the floor.
withState {
fill(Color(white: 0.9))
material(.dielectric(roughness: 0.35))
drawMesh(pebble, instances: pebbles)
}
// The row: chrome, colored gloss, solid amber glass, and matte side by
// side, so one export shows the whole material range under one light. The
// glass sphere is where the traced render pulls furthest ahead of the
// window: the trace bends light through the body, tints it along the
// interior path, and passes it on into the shadow. Each rests on the floor.
let finishes: [(Color, Material)] = [
(Color(white: 0.95), .metal(roughness: 0.04)),
(Color(hue: 0.02, saturation: 0.75, brightness: 0.85), .dielectric(roughness: 0.08)),
(Color(hue: 0.1, saturation: 0.4, brightness: 1.0),
.glass(thickness: 1, attenuationColor: Color(hue: 0.09, saturation: 0.75, brightness: 0.9),
attenuationDistance: 0.9)),
(Color(hue: 0.12, saturation: 0.6, brightness: 0.95), .dielectric(roughness: 0.5)),
(Color(white: 0.85), Material())
]
for (i, f) in finishes.enumerated() {
withState {
let x = -3.0 + Double(i) * 1.5
translate(x, 0.75, Double(i % 2) * -1.3)
fill(f.0)
material(f.1)
drawSphere(radius: 0.75)
}
}
// A tall polished slab behind the row, catching the set in its face.
withState {
translate(3.4, 1.3, -2.8)
rotateY(-0.35)
fill(Color(white: 0.9))
material(.metal(roughness: 0.05))
drawBox(width: 1.4, height: 3.6, depth: 0.35)
}
// A hammered-copper relief panel on the left: its normal map dents the
// shading and its metallic-roughness map polishes each dent's floor. The
// traced export reads both maps at every hit, so the relief survives into
// the floor's reflection and the light the panel bounces on the set.
withState {
translate(-4.2, 1.6, -2.2)
rotateY(0.5)
rotateX(.pi / 2)
fill(Color(hue: 0.07, saturation: 0.55, brightness: 0.9))
material(.physicallyBased(metallic: 1, roughness: 1))
drawMesh(Mesh.plane(width: 2.8, depth: 2.8)
.normalMapped(Self.hammered.normal)
.surfaceMapped(metallicRoughness: Self.hammered.mr))
}
// A cool glowing bar floating at the right edge: an emissive mesh, so in
// the traced render its own surface is a light. The trace samples it the
// way it samples the softbox, so the glow reaches the floor and the
// sphere row as smooth, soft-shadowed light rather than speckle.
withState {
translate(4.0, 0.5, 0.6)
rotateZ(0.5 * .pi)
var bar = Mesh.box(width: 0.24, height: 2.4, depth: 0.24)
bar.material = MeshMaterial(emissiveColor: Color(red: 1.4, green: 2.3, blue: 2.6))
drawMesh(bar)
}
}
}