Zum Inhalt

native/macos/KuroNativeSaver/Core/TerrainScene.swift

// TerrainScene — the vertical-slice scene. A wireframe heightfield (two chunks
// scrolling toward the camera with seam-continuous resampling on wrap), a
// starfield and drifting dust. Ported from src/engine/engine/scenes/terrain.ts.

import Metal
import simd
import Foundation

// Chunk layout, ported verbatim from terrain.ts.
enum Terrain {
    static let D: Float = 180        // chunk length (z)
    static let WIDTH: Float = 280    // chunk width (x)
    static let SEG_W = 140           // lateral segments
    static let SEG_L = 130           // longitudinal segments
    static let startA: Float = -40           // chunk A initial z
    static let startB: Float = -40 - 180     // chunk B initial z (= startA - D)
}

/// Heightfield as a pure function of LOGICAL world position (terrain.ts heightAt).
/// Invariant relied upon: neighbour chunks' logical offsets differ by exactly D,
/// so heights agree at the seam.
func terrainHeight(_ x: Float, _ z: Float) -> Float {
    sin(x * 0.33) * cos(z * 0.2) * 2.8
    + sin(x * 0.77 + z * 0.26) * 0.95
    + cos(x * 0.16 - z * 0.13) * 1.8
}

final class TerrainScene: Scene {
    var camera = Camera()
    private(set) var items: [DrawItem] = []
    let fogDensity: Float = 0.012

    private let speedPerSec: Float          // world units / second at this speed
    private let vCount: Int
    private var gridX: [Float] = []
    private var gridZ: [Float] = []

    private let chunkA: DrawItem
    private let chunkB: DrawItem
    private let dust: DrawItem
    var storm: Float = 0            // reactive-world PANIC storm (set by Renderer)
    private var logicalA = Terrain.startA
    private var logicalB = Terrain.startB
    private var posAz = Terrain.startA
    private var posBz = Terrain.startB
    private var fly: CameraFly
    private var courseT0: Double = 0
    private var courseDir: Float = 1
    private var courseRng = LCG(seed: 1)

    init(ctx: SceneContext) {
        let device = ctx.device
        // web: spd = SPEED_VALUES[speed] * 0.2 per frame @60fps → *12 per second.
        speedPerSec = ctx.speed() * 12
        fly = CameraFly(seed: (ctx.settings.seed ?? freshSeed()) &+ 4242,
                        latAmp: 12, vertAmp: 2.6, vertBase: 6.5, pitchBase: -0.2, bankScale: ctx.settings.bankStrength)

        camera.position = SIMD3(0, 6, 0)
        camera.rotation = SIMD3(-0.22, 0, 0)
        courseRng = LCG(seed: (ctx.settings.seed ?? freshSeed()) &+ 909)
        courseT0 = 8 + Double(courseRng.nextF()) * 10
        courseDir = courseRng.nextF() < 0.5 ? -1 : 1

        // --- grid local coords (shared topology for both chunks) ---
        let sw = Terrain.SEG_W, sl = Terrain.SEG_L
        let W = Terrain.WIDTH, D = Terrain.D
        var gx = [Float](); var gz = [Float]()
        gx.reserveCapacity((sw + 1) * (sl + 1)); gz.reserveCapacity((sw + 1) * (sl + 1))
        for iz in 0...sl {
            let zl = -D / 2 + D * Float(iz) / Float(sl)
            for ix in 0...sw {
                let x = -W / 2 + W * Float(ix) / Float(sw)
                gx.append(x); gz.append(zl)
            }
        }
        gridX = gx; gridZ = gz
        vCount = gx.count

        func chunkPositions(_ logical: Float) -> [Float] {
            var p = [Float](); p.reserveCapacity(gx.count * 3)
            for i in 0..<gx.count {
                p.append(gx[i]); p.append(terrainHeight(gx[i], gz[i] + logical)); p.append(gz[i])
            }
            return p
        }

        // --- line index buffer (horizontal + vertical grid edges) ---
        let stride = sw + 1
        var idx = [UInt32](); idx.reserveCapacity(sl * sw * 4)
        for iz in 0...sl {
            for ix in 0..<sw {
                let a = UInt32(iz * stride + ix); idx.append(a); idx.append(a + 1)
            }
        }
        for ix in 0...sw {
            for iz in 0..<sl {
                let a = UInt32(iz * stride + ix); idx.append(a); idx.append(a + UInt32(stride))
            }
        }
        let idxBuf = TerrainScene.buffer(device, idx)

        let posA = chunkPositions(logicalA)
        let posB = chunkPositions(logicalB)
        chunkA = DrawItem(positions: TerrainScene.buffer(device, posA), indices: idxBuf,
                          count: idx.count, vertexCount: vCount, primitive: .line,
                          model: Mathx.translation(SIMD3(0, 0, posAz)), opacity: 0.8)
        chunkB = DrawItem(positions: TerrainScene.buffer(device, posB), indices: idxBuf,
                          count: idx.count, vertexCount: vCount, primitive: .line,
                          model: Mathx.translation(SIMD3(0, 0, posBz)), opacity: 0.75)

        // --- stars + dust (point clouds, web rng order preserved) ---
        var rng = ctx.rng
        var starV = [Float](); starV.reserveCapacity(1000 * 3)
        for _ in 0..<1000 {
            starV.append((rng.nextF() - 0.5) * 400)
            starV.append(rng.nextF() * 70 + 5)
            starV.append(-(rng.nextF() * 400 + 10))
        }
        let stars = DrawItem(positions: TerrainScene.buffer(device, starV), indices: nil,
                             count: 1000, vertexCount: 1000, primitive: .point,
                             opacity: 1.0, isPoint: true, pointSizeWorld: 0.13)

        var dustV = [Float](); dustV.reserveCapacity(500 * 3)
        for _ in 0..<500 {
            dustV.append((rng.nextF() - 0.5) * 60)
            dustV.append(rng.nextF() * 18 + 1)
            dustV.append(-(rng.nextF() * 200 + 5))
        }
        dust = DrawItem(positions: TerrainScene.buffer(device, dustV), indices: nil,
                        count: 500, vertexCount: 500, primitive: .point,
                        opacity: 1.0, isPoint: true, pointSizeWorld: 0.06)

        items = [chunkA, chunkB, stars, dust]
    }

    private static func buffer<T>(_ device: MTLDevice, _ arr: [T]) -> MTLBuffer {
        arr.withUnsafeBytes {
            device.makeBuffer(bytes: $0.baseAddress!, length: $0.count, options: .storageModeShared)!
        }
    }

    private func resample(_ item: DrawItem, _ logical: Float) {
        let ptr = item.positions.contents().bindMemory(to: Float.self, capacity: vCount * 3)
        for i in 0..<vCount { ptr[i * 3 + 1] = terrainHeight(gridX[i], gridZ[i] + logical) }
    }

    func update(t: Double, dt: Double) {
        let tf = Float(t)
        let spd = speedPerSec * Float(dt)
        posAz += spd; posBz += spd
        if posAz > Terrain.D / 2 + 12 {
            posAz -= Terrain.D * 2; logicalA -= Terrain.D * 2; resample(chunkA, logicalA)
        }
        if posBz > Terrain.D / 2 + 12 {
            posBz -= Terrain.D * 2; logicalB -= Terrain.D * 2; resample(chunkB, logicalB)
        }
        chunkA.model = Mathx.translation(SIMD3(0, 0, posAz))
        chunkB.model = Mathx.translation(SIMD3(0, 0, posBz))

        let f = fly.update(t: t, forwardSpeed: speedPerSec)
        // Long banked course-change layered on the weave — reads as a sweeping turn.
        var cs: Float = 0
        let cel = t - courseT0, courseDur = 12.0
        if cel >= 0 && cel < courseDur { let s = sin(Float.pi * Float(cel / courseDur)); cs = s * s }
        else if cel >= courseDur { courseT0 = t + 10 + Double(courseRng.nextF()) * 14; courseDir = courseRng.nextF() < 0.5 ? -1 : 1 }
        let camX = f.x + courseDir * 38 * cs
        camera.position = SIMD3(camX, f.y, 0)
        camera.rotation = SIMD3(f.pitch, f.yaw + courseDir * 0.22 * cs, f.roll + courseDir * 0.18 * cs)
        // Terrain-following: never clip a hill — clamp cam.y above the heightfield under
        // + just ahead of the camera (≥2.6u clearance). Low over valleys, lifts over ridges.
        func groundAt(_ wz: Float) -> Float {
            let useA = abs(wz - posAz) <= Terrain.D / 2
            return terrainHeight(camX, wz - (useA ? posAz : posBz) + (useA ? logicalA : logicalB))
        }
        camera.position.y = max(f.y, max(groundAt(0), groundAt(-12), groundAt(-24)) + 2.6)

        dust.model = Mathx.translation(SIMD3(sin(tf * 0.07) * 4 + storm * 12 * sin(tf * 3.1), 0, 0))
        dust.pointSizeWorld = 0.13 + storm * 0.13
        // Stream dust toward + past the camera (z) so it flies by — the world forward-
        // scrolls, so static dust would sit at a constant distance from the POV.
        let dptr = dust.positions.contents().bindMemory(to: Float.self, capacity: 500 * 3)
        for i in 0..<500 {
            var dz = dptr[i * 3 + 2] + spd
            if dz > 6 { dz -= 210 }
            dptr[i * 3 + 2] = dz
        }
    }
}