Ollin

LightShaping

Photometric profiles and a projected cookie, the two ways a real fixture shapes its beam.

Run
swift run --package-path Examples Example-3D-Lighting-LightShaping
Beside it
batwing.ies, downlight.ies, wallwash.ies
Source
Examples/3D/Lighting/LightShaping/Sketch.swift
import Ollin

/// Light shaping: IES photometric profiles and a projected cookie (gobo).
///
/// A plain point or spot light throws a featureless pool; a real fixture has a
/// *shape* to its throw, measured by its manufacturer and published as an IES
/// file. Loading one onto a light reproduces the fixture:
///
/// - a **downlight** over the left pedestal (a hot center with a spill ring),
/// - a **batwing** street-lamp distribution mid-floor (dark under the pole,
///   bright wings at 40 degrees, the pattern that spaces street lights),
/// - an asymmetric **wallwasher** grazing the back wall (throws forward,
///   cut off behind, so the wall lights and the room doesn't).
///
/// The fourth light is a spot with a `LightCookie`: an image projected through
/// the cone the way a stage gobo or a slide projector works. Here it's a
/// window frame drawn in code, rocking slowly on the light's `roll`, throwing
/// afternoon-window light across the floor. The three `.ies` files are bundled
/// beside this sketch and load with `IESProfile(resource:in:)`; a profile's
/// intensities are normalized (peak 1), so `intensity` still sets brightness.
@main
final class LightShaping: Sketch {

    // Parse once and keep: a profile is plain data, like a loaded mesh.
    private let downlight = try! IESProfile(resource: "downlight", in: .module)
    private let batwing = try! IESProfile(resource: "batwing", in: .module)
    private let wallwash = try! IESProfile(resource: "wallwash", in: .module)

    // The window gobo, drawn in code: 2×2 white panes behind black mullions,
    // wrapped once (the cookie resamples the image at init, so build it once).
    private let windowGobo: LightCookie = {
        var frame = Image(width: 128, height: 128, color: .black)
        for y in 0..<128 {
            for x in 0..<128 {
                let inFrame = x > 8 && x < 119 && y > 8 && y < 119
                let onMullion = abs(x - 64) < 5 || abs(y - 64) < 5
                if inFrame && !onMullion { frame[x, y] = .white }
            }
        }
        return LightCookie(frame)!
    }()

    // Flat single-color matcaps for the glowing fixture props (cached; an
    // `Image` keeps its texture).
    private let warmGlow = Image(width: 1, height: 1, color: Color(hue: 0.10, saturation: 0.35, brightness: 1.0))
    private let coolGlow = Image(width: 1, height: 1, color: Color(hue: 0.56, saturation: 0.30, brightness: 1.0))

    override func draw() {
        background(Color(hex: 0x060709))
        ambientLight(Color(white: 0.02))   // a whisper so unlit faces aren't dead black

        cameraShowcase(.sway(amplitude: 0.35, period: 42), target: Vector3(0, -0.4, 0),
                       radius: 12.5, elevation: 0.34, fieldOfView: .pi / 4)

        // --- The shaped lights (per-frame, like every light) ---

        // The downlight hangs low over the left pedestal, straight down (the
        // profile's default axis), pooling a hot disc with its spill ring.
        pointLight(Color(hue: 0.09, saturation: 0.42, brightness: 1.0),
                   at: Vector3(-3.4, 1.2, 0.6), intensity: 1.4, profile: downlight)

        // The batwing hangs mid-floor: almost nothing straight down, wings at
        // 40 degrees, so it draws a wide bright ring instead of a hotspot.
        pointLight(Color(white: 0.95),
                   at: Vector3(0.4, 2.1, 0.2), intensity: 1.1, profile: batwing)

        // The wallwasher stands off the back wall, tilted at it the way a real
        // one aims; the roll turns the profile's forward wing (azimuth 0) into
        // the tilt plane, so the throw climbs the wall and the room stays quiet.
        pointLight(Color(hue: 0.58, saturation: 0.35, brightness: 1.0),
                   at: Vector3(3.2, 2.2, 0.8), intensity: 1.6,
                   profile: wallwash, direction: Vector3(0, -0.55, -1), roll: .pi / 2)

        // The window: a warm spot from high front-left, its gobo projected
        // across the floor, rocking gently on its roll.
        spotLight(Color(hue: 0.09, saturation: 0.38, brightness: 1.0),
                  at: Vector3(-4.6, 3.4, 4.4), direction: Vector3(0.62, -0.62, -0.48),
                  coneAngle: 0.8, penumbra: 0.15, intensity: 1.35,
                  cookie: windowGobo, roll: sin(time * 0.25) * 0.12)

        // --- The set: a matte room that shows throw patterns honestly ---

        withState {
            translate(0, -1.3, 0)
            fill(Color(white: 0.62))
            material(.dielectric(roughness: 0.75))
            drawPlane(width: 18, depth: 14)
        }
        withState {
            translate(0, 1.2, -3.4)
            fill(Color(white: 0.58))
            material(.dielectric(roughness: 0.85))
            drawBox(width: 18, height: 5.0, depth: 0.25)
        }

        // A pedestal + sphere under the downlight, catching its ring.
        withState {
            translate(-3.4, -0.95, 0.6)
            fill(Color(white: 0.8))
            material(.dielectric(roughness: 0.5))
            drawBox(width: 1.1, height: 0.7, depth: 1.1)
        }
        withState {
            translate(-3.4, -0.1, 0.6)
            fill(Color(white: 0.9))
            material(.dielectric(roughness: 0.35))
            drawSphere(radius: 0.5)
        }

        // A low slab mid-floor so the batwing's dark-under-the-pole reads.
        withState {
            translate(0.4, -1.1, 0.2)
            fill(Color(white: 0.75))
            material(.dielectric(roughness: 0.6))
            drawBox(width: 1.6, height: 0.4, depth: 1.6)
        }

        // --- Glowing props marking each fixture ---

        for (position, glow) in [(Vector3(-3.4, 1.9, 0.6), warmGlow),
                                 (Vector3(0.4, 2.1, 0.2), warmGlow),
                                 (Vector3(3.2, 1.7, -1.6), coolGlow)] {
            withState {
                translate(position.x, position.y, position.z)
                fill(.white)
                matcap(glow)
                drawCylinder(radius: 0.16, height: 0.22)
            }
        }
        withState {
            translate(-4.6, 3.4, 4.4)
            fill(.white)
            matcap(warmGlow)
            drawSphere(radius: 0.14)
        }
    }
}

↑↓ move ↩ open