// MetroScene — OSM district flyover. Swift twin of scenes/metro.ts.
//
// A hybrid of a REAL OpenStreetMap district (baked into osm-district.json at build
// time, see District.swift) and procedurally generated OSM-like blocks, so the
// flythrough is infinite: tile A is the real district, tile B its procedural
// continuation, and the two scroll past the camera in turn.
//
// Structurally a sibling of CityScene — two-tile scroll, CameraFly, blinking window
// buckets, stars/dust/skyline — but the buildings are wireframe prisms extruded from
// polygon footprints (MetroGeo) instead of axis-aligned boxes. Geometry is BATCHED per
// chunk for the same reason it is there: one buffer per material beats one mesh per
// building.
//
// Layout draws from our LCG; web-seed parity is a non-goal (same stance as CityScene).
//
// Map data © OpenStreetMap contributors, ODbL 1.0
// (https://www.openstreetmap.org/copyright).
import Metal
import simd
import Foundation
final class MetroScene: Scene {
var camera = Camera()
private(set) var items: [DrawItem] = []
let fogDensity: Float = 0.008
private let speedPerSec: Float
private var fly: CameraFly
private var dustItem: DrawItem! // IUO: assigned after the chunk builders run
private static let dustCount = 600
/// Tile length, derived from the baked district so it tiles at TRUE scale (no
/// squish); the procedural tile uses the same length. Web parity, metro.ts:29.
private static let CL: Float = max(340, Float(ceil((District.shared.meta.spanZ + 26) / 10)) * 20)
private static let SW: Float = 22 // corridor half-width kept clear for the flight path
private static let BS: Float = 22 // procedural block spacing
private struct Chunk { var z: Float; let items: [DrawItem] }
private var chunks: [Chunk] = []
private struct Bucket { let item: DrawItem; let phase: Float; let speed: Float; let hi: Float; let lo: Float }
private var buckets: [Bucket] = []
private let bucketCount = 6
init(ctx: SceneContext) {
let device = ctx.device
speedPerSec = ctx.speed() * 0.24 * 60 // web *0.24 per frame@60
let altTarget = ctx.settings.cityAltitude.value
fly = CameraFly(seed: (ctx.settings.seed ?? freshSeed()) &+ 6363,
latAmp: 8, vertAmp: 1.6, vertBase: altTarget,
pitchBase: -0.08, bankScale: ctx.settings.bankStrength)
camera.position = SIMD3(0, altTarget, 0)
camera.rotation = SIMD3(-0.08, 0, 0)
var base = ctx.rng
let baseSeed = Int32(truncatingIfNeeded: Int(base.next() * 2_000_000_000))
let CL = MetroScene.CL, SW = MetroScene.SW, half = CL / 2
// ── one chunk's geometry, batched into a handful of buffers ───────────────
struct Sink {
var ground = [Float]()
var streets = [Float]()
var edges = [Float]()
var antennas = [Float]()
var beacons = [Float]()
var wires = [Float]()
var windows: [[Float]]
var wb = 0 // window bucket round-robin
}
/// Ground plane as a line grid — the web draws a faint 240 × CL plane; the
/// wireframe renderer has no surface shader, so it becomes its grid lines.
func appendGround(_ s: inout Sink) {
let gw: Float = 240, segX = 12, segZ = 22
for i in 0...segX {
let x = -gw / 2 + gw * Float(i) / Float(segX)
s.ground.append(contentsOf: [x, 0, -half, x, 0, half])
}
for i in 0...segZ {
let z = -half + CL * Float(i) / Float(segZ)
s.ground.append(contentsOf: [-gw / 2, 0, z, gw / 2, 0, z])
}
}
/// Extrude one footprint: edges, windows into the round-robin buckets, rooftop
/// antenna + beacon, and up to two setback tiers. Mirrors extrudePrism().
func addPrism(_ s: inout Sink, _ ring: [Float], _ height: Float, _ r: inout LCG,
baseY: Float = 0, windows: Bool = true, antenna: Bool = true) {
MetroGeo.appendPrismEdges(&s.edges, ring: ring, baseY: baseY, height: height)
let (cx, cz) = MetroGeo.centroid(ring)
let topY = baseY + height
if windows {
for w in MetroGeo.facadeWindows(ring: ring, baseY: baseY, height: height, &r) {
MetroGeo.appendWindowQuad(&s.windows[s.wb % bucketCount], w)
s.wb += 1
}
}
if antenna && height > 26 && r.nextF() > 0.4 {
let ah = 5 + r.nextF() * 20
s.antennas.append(contentsOf: [cx, topY, cz, cx, topY + ah, cz])
s.beacons.append(contentsOf: [cx, topY + ah, cz])
}
// Setback tiers (geo-extrude.ts): only on the main volume, never on a tier.
if antenna && height > 40 && r.nextF() > 0.45 {
addPrism(&s, MetroGeo.shrink(ring, 0.56), height * 0.2, &r,
baseY: topY, windows: false, antenna: false)
if r.nextF() > 0.5 {
addPrism(&s, MetroGeo.shrink(ring, 0.30), height * 0.13, &r,
baseY: topY + height * 0.2, windows: false, antenna: false)
}
}
}
// ── tile A: the real baked district ───────────────────────────────────────
func buildDistrictChunk(_ seedOff: Int32) -> Sink {
var s = Sink(windows: [[Float]](repeating: [], count: bucketCount))
var r = LCG(seed: baseSeed &+ seedOff)
let d = District.shared
let scale = Float(d.meta.scale == 0 ? 0.1 : d.meta.scale)
appendGround(&s)
// Fit the district into the tile and keep the central corridor clear.
let fitLimit = CL / 2 - 16
let spanZ = max(1, Float(d.meta.spanZ))
let fit = spanZ > fitLimit ? fitLimit / spanZ : 1
for f in d.footprints {
var ring = MetroGeo.decodeRing(f.r, scale: scale)
for i in ring.indices { ring[i] *= fit }
let (cx, _) = MetroGeo.centroid(ring)
if abs(cx) < SW * 0.9 { continue } // keep the flight corridor clear
addPrism(&s, ring, max(6, Float(f.h) * fit), &r)
}
return s
}
// ── tile B: procedural OSM-like blocks ────────────────────────────────────
func buildProcChunk(_ seedOff: Int32) -> Sink {
var s = Sink(windows: [[Float]](repeating: [], count: bucketCount))
var r = LCG(seed: baseSeed &+ seedOff)
appendGround(&s)
for x in [-SW * 0.55, -SW * 0.27, 0, SW * 0.27, SW * 0.55] {
s.streets.append(contentsOf: [x, 0.08, -half, x, 0.08, half])
}
var zc = -half + 18
while zc < half {
s.streets.append(contentsOf: [-SW * 0.6, 0.08, zc, SW * 0.6, 0.08, zc])
zc += 36
}
let nb = Int(CL / MetroScene.BS)
for b in 0..<nb {
let bz = -half + Float(b) * MetroScene.BS + MetroScene.BS / 2
for side in [Float(-1), Float(1)] {
let nB = 1 + (r.nextF() > 0.5 ? 1 : 0)
for i in 0..<nB {
let baseR = 5 + r.nextF() * 9, gap = 3 + r.nextF() * 12
let h = 14 + r.nextF() * 78
let xp = side * (SW + gap + baseR)
let zp = bz + (nB > 1 ? (Float(i) - 0.5) * MetroScene.BS * 0.42 : 0) + (r.nextF() - 0.5) * 4
let ring = MetroGeo.starFootprint(&r, ox: xp, oz: zp, baseR: baseR,
aspect: 0.7 + r.nextF() * 0.7,
stretchZ: 0.7 + r.nextF() * 0.6)
addPrism(&s, ring, h, &r)
}
}
// Skybridge — the web sweeps a tube along a quadratic Bézier; batched
// wireframe means the curve itself, sampled as a line strip.
if r.nextF() > 0.72 {
let lx = -(SW + 6 + r.nextF() * 6), rx = SW + 6 + r.nextF() * 6
let by = 12 + r.nextF() * 26, my = by + 3 + r.nextF() * 12
let segs = 18
var prev = SIMD3<Float>(lx, by, bz)
for k in 1...segs {
let t = Float(k) / Float(segs), u = 1 - t
let p = SIMD3<Float>(u * u * lx + 2 * u * t * 0 + t * t * rx,
u * u * by + 2 * u * t * my + t * t * by,
bz)
s.wires.append(contentsOf: [prev.x, prev.y, prev.z, p.x, p.y, p.z])
prev = p
}
}
// Power lines: three sagging wires across the corridor.
if r.nextF() > 0.66 {
let wy = 9 + r.nextF() * 16
for wire in 0..<3 {
let yo = Float(wire) * 0.45, sag = 0.75 + r.nextF() * 0.4
for seg in 0..<10 {
let t0 = Float(seg) / 10, t1 = Float(seg + 1) / 10
s.wires.append(contentsOf: [
-SW * 0.55 + t0 * SW * 1.1, wy + yo - 4 * sag * t0 * (1 - t0), bz,
-SW * 0.55 + t1 * SW * 1.1, wy + yo - 4 * sag * t1 * (1 - t1), bz,
])
}
}
}
}
return s
}
// ── sink → DrawItems ─────────────────────────────────────────────────────
func line(_ a: [Float], _ op: Float) -> DrawItem {
DrawItem(positions: Geo.buffer(device, a.isEmpty ? [0, 0, 0, 0, 0, 0] : a), indices: nil,
count: a.count / 3, vertexCount: a.count / 3, primitive: .line, opacity: op)
}
func chunk(_ s: Sink, _ z0: Float, _ r: inout LCG) -> Chunk {
var its: [DrawItem] = [line(s.ground, 0.1), line(s.streets, 0.25),
line(s.edges, 0.5), line(s.wires, 0.22), line(s.antennas, 0.88)]
for k in 0..<bucketCount {
let a = s.windows[k]
if a.isEmpty { continue }
let it = DrawItem(positions: Geo.buffer(device, a), indices: nil, count: a.count / 3,
vertexCount: a.count / 3, primitive: .triangle, opacity: 0.9)
it.infectable = r.nextF() < 0.3 // enemy infection with rising threat
its.append(it)
buckets.append(Bucket(item: it, phase: r.nextF() * 2 * .pi,
speed: 0.15 + r.nextF() * 3.5, hi: 0.9, lo: 0.03))
}
if !s.beacons.isEmpty {
its.append(DrawItem(positions: Geo.buffer(device, s.beacons), indices: nil,
count: s.beacons.count / 3, vertexCount: s.beacons.count / 3,
primitive: .point, opacity: 0.9, isPoint: true, pointSizeWorld: 0.5))
}
return Chunk(z: z0, items: its)
}
var rA = LCG(seed: baseSeed &+ 4242), rB = LCG(seed: baseSeed &+ 1373)
chunks = [chunk(buildDistrictChunk(42), -CL / 2, &rA),
chunk(buildProcChunk(137), -CL / 2 - CL, &rB)]
for c in chunks { for it in c.items { it.model = Mathx.translation(SIMD3(0, 0, c.z)) } }
// ── stars, skyline silhouette, dust ───────────────────────────────────────
var sv = [Float]()
for _ in 0..<1200 {
sv.append((base.nextF() - 0.5) * 700)
sv.append(18 + base.nextF() * 200)
sv.append(-(base.nextF() * 800 + 60))
}
let stars = DrawItem(positions: Geo.buffer(device, sv), indices: nil, count: 1200,
vertexCount: 1200, primitive: .point, opacity: 1.0,
isPoint: true, pointSizeWorld: 0.14)
// Skyline: the web displaces a plane; batched, it is the horizon polyline.
var sil = [Float]()
let sw: Float = 550, sn = 85
var prevX = -sw / 2
var prevY = abs(sin(prevX * 0.024) * cos(prevX * 0.006)) * 76 + base.nextF() * 3
for i in 1...sn {
let x = -sw / 2 + sw * Float(i) / Float(sn)
let y = abs(sin(x * 0.024) * cos(x * 0.006)) * 76 + base.nextF() * 3
sil.append(contentsOf: [prevX, prevY, -900, x, y, -900])
sil.append(contentsOf: [x, 0, -900, x, y, -900])
prevX = x; prevY = y
}
let skyline = DrawItem(positions: Geo.buffer(device, sil), indices: nil, count: sil.count / 3,
vertexCount: sil.count / 3, primitive: .line, opacity: 0.12)
var dv = [Float]()
for _ in 0..<MetroScene.dustCount {
dv.append((base.nextF() - 0.5) * 80)
dv.append(base.nextF() * 55)
dv.append(-(base.nextF() * 700 + 20))
}
dustItem = DrawItem(positions: Geo.buffer(device, dv), indices: nil,
count: MetroScene.dustCount, vertexCount: MetroScene.dustCount,
primitive: .point, opacity: 0.7, isPoint: true, pointSizeWorld: 0.065)
items = chunks.flatMap { $0.items } + [stars, skyline, dustItem]
}
func update(t: Double, dt: Double) {
let tf = Float(t)
let spd = speedPerSec * Float(dt)
let n = MetroScene.dustCount
let dptr = dustItem.positions.contents().bindMemory(to: Float.self, capacity: n * 3)
for i in 0..<n {
var dz = dptr[i * 3 + 2] + spd
if dz > 10 { dz -= 720 }
dptr[i * 3 + 2] = dz
}
for i in chunks.indices {
chunks[i].z += spd
if chunks[i].z > MetroScene.CL / 2 + 8 { chunks[i].z -= MetroScene.CL * 2 }
let m = Mathx.translation(SIMD3(0, 0, chunks[i].z))
for it in chunks[i].items { it.model = m }
}
for b in buckets {
b.item.opacity = sin(tf * b.speed + b.phase) > 0 ? b.hi : b.lo
}
let f = fly.update(t: t, forwardSpeed: speedPerSec)
camera.position = SIMD3(f.x, max(1.8, f.y), 0) // clamp above the street
camera.rotation = SIMD3(f.pitch, f.yaw, f.roll)
}
}