SpatialVideo
A sketch exported as something you can look into. The scene is built for depth rather than for a flat frame.
- Run
swift run --package-path Examples Example-3D-Geometry-SpatialVideo- Source
- Examples/3D/Geometry/SpatialVideo/Sketch.swift
import Foundation
import Ollin
/// A sketch that leaves as something you can look into.
///
/// The scene is built for depth rather than for a flat frame: a colonnade
/// running away from the camera, a slow subject turning at the middle of it,
/// and a wall far behind. Watch it on the screen and it is an ordinary 3D
/// sketch. Press S and it leaves as spatial video, which is the same motion
/// recorded from two eyes at once, and in a headset the colonnade has actual
/// distance in it.
///
/// The two parameters are the whole craft. `depth` scales how far apart the eyes
/// stand: at 1 they sit where Ollin puts them by default, far enough apart
/// that the wall at the back separates by one percent of the frame, which is
/// the figure a cinema grades to so that nothing ever asks a viewer's eyes to
/// point outward. Push it to 3 and the scene reads like a diorama seen by
/// someone thirty feet tall. `stage` moves the distance at which the two eyes
/// agree: whatever sits there lands on the screen, nearer things come out of
/// it, farther things sit behind it. Sliding it from the near pillars to the
/// back wall changes which part of the scene the viewer is looking *into*
/// rather than *out at*, and that is a composition decision, not a setting.
///
/// The near pillar is deliberately close. It is the one thing here that can be
/// made uncomfortable, and finding the point where it stops being pleasant is
/// the fastest way to learn what these two numbers do.
@main
final class SpatialVideo: Sketch {
@Param(0.25 ... 3, icon: "eye") var depth = 1.0
@Param(4 ... 26, icon: "scope") var stage = 12.0
@Param(icon: "square.stack.3d.forward.dottedline") var colonnade = true
/// What the export reads. Both numbers are in world units, and this scene
/// is drawn in meters, so the file can say so with no conversion.
override var stereoGeometry: StereoGeometry {
StereoGeometry(interocular: derivedSpacing * depth, convergence: stage)
}
/// Ollin's own spacing for this shot, which `depth` then scales. Read back
/// from the camera the same way the exporter reads it, so the parameter is a
/// multiple of the default rather than a number out of nowhere.
private var derivedSpacing: Double {
StereoGeometry(convergence: stage).resolved(for: shot).interocular
}
private var shot: Camera3D {
.perspective(eye: Vector3(0, 1.6, 16), target: Vector3(0, 1.2, -6),
fieldOfView: .pi / 3, near: 0.2, far: 400)
}
private var saved: String?
override func setup() {
seed(7)
}
override func keyPressed() {
if key == "s" || key == "S" { save() }
}
override func draw() {
background(Color(hex: 0x090C13))
camera(shot)
environment(.sunset.lightingOnly())
directionalLight(Color(hex: 0xFFF1D6), direction: Vector3(-0.4, -0.8, -0.5), intensity: 1.6)
wall()
if colonnade { pillars() }
subject()
floor()
drawCaption("eyes \(String(format: "%.3f", stereoGeometry.interocular ?? 0)) m apart · "
+ "screen at \(String(format: "%.1f", stage)) m", edge: .top)
drawCaption(saved ?? "S writes two seconds of spatial video you can open on a headset")
}
/// The colonnade: the reason the scene has depth to record. Each pair of
/// pillars is a fixed size at a growing distance, so the picture gives the
/// eye a ladder of separations to read rather than one plane.
private func pillars() {
for i in 0 ..< 9 {
let z = 9.0 - Double(i) * 4.5
let shade = 0.55 - Double(i) * 0.04
for side in [-1.0, 1.0] {
withState {
translate(side * 3.1, 1.7, z)
fill(Color(white: shade))
material(.dielectric(roughness: 0.55))
drawMesh(.cylinder(radius: 0.34, height: 3.4, segments: 24))
translate(0, 1.85, 0)
drawMesh(.box(width: 1.0, height: 0.3, depth: 1.0))
}
}
}
}
/// The subject, turning at the middle distance so there is something to
/// watch that is neither the nearest thing nor the farthest.
private func subject() {
withState {
translate(0, 1.5, -6)
rotateY(time * 0.35)
rotateX(sin(time * 0.23) * 0.35)
fill(Color(hex: 0xE8B74F))
material(.metal(roughness: 0.18))
drawMesh(.torusKnot(p: 2, q: 3, radius: 1.1, tube: 0.3, segments: 220, sides: 22))
}
}
/// The far wall, which is what the default eye spacing is measured against:
/// it is the farthest thing in the shot, so it carries the most separation.
private func wall() {
withState {
translate(0, 6, -46)
fill(Color(hex: 0x1C2438))
material(.dielectric(roughness: 0.85))
drawMesh(.box(width: 70, height: 26, depth: 0.5))
}
for i in 0 ..< 60 {
withState {
translate(random(-30, 30), random(1, 12), -45.4)
fill(Color(hue: random(0.05, 0.14), saturation: 0.5, brightness: random(0.5, 1)))
material(.dielectric(roughness: 0.4))
drawMesh(.box(width: random(0.3, 1.4), height: random(0.3, 1.0), depth: 0.2))
}
}
}
private func floor() {
withState {
translate(0, -0.1, -14)
fill(Color(hex: 0x141A26))
material(.dielectric(roughness: 0.7))
drawMesh(.box(width: 60, height: 0.2, depth: 80))
}
}
/// Write a short clip of this same sketch as spatial video.
///
/// A fresh instance is exported rather than the one on screen, because a
/// video is a run rather than a moment: it starts at its own frame zero and
/// renders on a fixed clock, so the file is the same every time. The window
/// stops for as long as that takes.
private func save() {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("piece.mov")
let clip = SpatialVideo()
clip.depth = depth
clip.stage = stage
clip.colonnade = colonnade
do {
try OllinApp.exportSpatialVideo(clip, to: url.path, frames: 60, fps: 30)
saved = "saved to \(url.path)"
} catch {
saved = "not saved: \(error)"
}
}
}