Ollin

NormalMaps

Normal maps: per-pixel surface relief without per-pixel geometry.

Run
swift run --package-path Examples Example-3D-Materials-NormalMaps
Source
Examples/3D/Materials/NormalMaps/Sketch.swift
import Ollin

/// Normal maps: per-pixel surface relief without per-pixel geometry.
///
/// A tangent-space normal map stores a surface direction in each texel; at
/// shading time the lighting normal bends by it, so a smooth sphere catches
/// light like hammered metal, woven cloth, or engraved rings while its
/// silhouette stays a perfect circle (the giveaway, and the point: relief this
/// way costs a texture sample, not a million triangles).
///
/// The three maps here are *authored in setup*, no image files: each starts as
/// a height function (dents packed by a low-discrepancy sequence, a weave of
/// crossing sine bands, concentric rings), and its normals fall out as the
/// height's slopes, in the standard green-up encoding `(n + 1) / 2`. Attaching
/// one is `mesh.normalMapped(map)`, which also generates the per-vertex
/// tangent basis the map needs (MikkTSpace, the same basis normal-map bakers
/// target, so maps baked in other tools light the same way). The `relief` parameter
/// is the map's strength: 0 turns it off, 1 is as authored, higher exaggerates.
///
/// The fourth sphere is the bare control: same geometry, same light, no map.
@main
final class NormalMaps: Sketch {

    @Param(0...3, icon: "circle.grid.cross") var relief = 1.0

    var spheres: [Mesh] = []

    /// Turn a height function (0…1 over the unit square) into a green-up
    /// normal map: central-difference slopes, x right, y up the image.
    func normalMap(size: Int, strength: Double, height: (Double, Double) -> Double) -> Image {
        var bytes = [UInt8](repeating: 0, count: size * size * 4)
        let d = 1.0 / Double(size)
        for y in 0..<size {
            for x in 0..<size {
                let u = (Double(x) + 0.5) * d, v = (Double(y) + 0.5) * d
                let dx = (height(u + d, v) - height(u - d, v)) / (2 * d) * strength
                let dy = (height(u, v + d) - height(u, v - d)) / (2 * d) * strength
                // A slope rising down the image is +dy; green-up encodes the
                // *up*-the-image tilt.
                let len = (dx * dx + dy * dy + 1).squareRoot()
                let n = (x: -dx / len, y: dy / len, z: 1 / len)
                let i = (y * size + x) * 4
                bytes[i]     = UInt8((n.x * 0.5 + 0.5) * 255)
                bytes[i + 1] = UInt8((n.y * 0.5 + 0.5) * 255)
                bytes[i + 2] = UInt8((n.z * 0.5 + 0.5) * 255)
                bytes[i + 3] = 255
            }
        }
        return Image(width: size, height: size, premultipliedRGBA: bytes)!
    }

    override func setup() {
        // Hammered: soft dents at low-discrepancy points (pure index math, so
        // every run of this variation is identical).
        let dents = haltonPoints(count: 90, in: Rectangle(x: 0, y: 0, width: 1, height: 1))
        let hammered = normalMap(size: 512, strength: 0.16) { u, v in
            var h = 0.0
            for p in dents {
                var dx = abs(u - p.x); dx = min(dx, 1 - dx)   // tile both ways
                var dy = abs(v - p.y); dy = min(dy, 1 - dy)
                let r = 0.075, dist = (dx * dx + dy * dy).squareRoot()
                if dist < r {
                    let t = dist / r
                    h = max(h, (1 - t * t) * (1 - t * t))
                }
            }
            return h
        }
        // Woven: two crossing sine bands.
        let woven = normalMap(size: 256, strength: 0.045) { u, v in
            let a = unipolar(sin(u * 24 * .tau))
            let b = unipolar(sin(v * 24 * .tau))
            return a * b
        }
        // Engraved rings around the map center.
        let rings = normalMap(size: 512, strength: 0.09) { u, v in
            let r = ((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)).squareRoot()
            return unipolar(sin(r * 30 * .tau))
        }
        let base = Mesh.sphere(radius: 1, segments: 96, rings: 48)
        spheres = [base.normalMapped(hammered),
                   base.normalMapped(woven),
                   base.normalMapped(rings),
                   base]
    }

    override func draw() {
        background(Color(hex: 0x11141B))
        cameraShowcase(.sway(amplitude: 0.14, period: 26), target: .zero, radius: 11.2,
                       elevation: 0.14, fieldOfView: .pi / 4)

        // A warm key swinging past, a dim cool fill: relief reads by how its
        // shading slides, so the key keeps moving.
        let angle = time * 0.6
        directionalLight(Color(kelvin: 5000),
                         direction: Vector3(-cos(angle), -0.55, -sin(angle)), intensity: 1.15)
        directionalLight(Color(kelvin: 9000), direction: Vector3(0.5, 0.3, 0.4), intensity: 0.2)
        ambientLight(Color(white: 0.06))

        fill(Color(hex: 0xB9BDC7))
        let xs: [Double] = [-3.45, -1.15, 1.15, 3.45]
        for (i, var mesh) in spheres.enumerated() {
            if mesh.material != nil { mesh.material!.normalScale = relief }
            withState {
                translate(xs[i], 0.25, 0)
                drawMesh(mesh)
            }
        }
        drawLabels(at: xs)
    }

    private func drawLabels(at xs: [Double]) {
        withState {
            textFont(OutlineFont.system)
            textSize(26)
            textAlign(.center)
            noStroke()
            fill(Color(white: 0.55))
            for (name, x) in zip(["hammered", "woven", "rings", "bare"], xs) {
                if let p = project(Vector3(x, -1.35, 0)) {
                    drawText(name, at: p)
                }
            }
        }
    }
}

↑↓ move ↩ open