// CityScene — low flight down a procedural neon-wireframe city corridor with
// blinking windows + rooftop beacons. Ported from scenes/city.ts. Geometry is
// BATCHED per chunk (all building edges / windows / beacons into a few buffers)
// to keep draw-call count low. Layout via our LCG (web-seed parity is a non-goal).
import Metal
import simd
import Foundation
final class CityScene: Scene {
var camera = Camera()
private(set) var items: [DrawItem] = []
let fogDensity: Float = 0.008
private let speedPerSec: Float
private let altTarget: Float
private var fly: CameraFly
private var dustItem: DrawItem! // IUO: assigned mid-init (after buildChunk calls)
private static let CL: Float = 320, SW: Float = 22, BS: Float = 22
private struct Chunk { var z: Float; let items: [DrawItem] }
private var chunks: [Chunk] = []
// window blink buckets (shared phase/speed per bucket)
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
altTarget = ctx.settings.cityAltitude.value
// banking weave down the corridor (latAmp keeps it between the buildings ~±30)
fly = CameraFly(seed: (ctx.settings.seed ?? freshSeed()) &+ 5151,
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))
func appendBoxEdges(_ buf: inout [Float], _ cx: Float, _ cy: Float, _ cz: Float, _ w: Float, _ h: Float, _ d: Float) {
let x0 = cx - w / 2, x1 = cx + w / 2, y0 = cy - h / 2, y1 = cy + h / 2, z0 = cz - d / 2, z1 = cz + d / 2
let c: [(Float, Float, Float)] = [
(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),
(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1),
]
let e = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
for (a, b) in e {
buf.append(c[a].0); buf.append(c[a].1); buf.append(c[a].2)
buf.append(c[b].0); buf.append(c[b].1); buf.append(c[b].2)
}
}
func appendWinQuad(_ buf: inout [Float], _ x: Float, _ y: Float, _ z: Float) {
let hw: Float = 0.23, hh: Float = 0.28
let v: [(Float, Float)] = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, -hh), (hw, hh), (-hw, hh)]
for (dx, dy) in v { buf.append(x + dx); buf.append(y + dy); buf.append(z) }
}
func buildChunk(_ seedOff: Int32, _ z0: Float) -> Chunk {
var r = LCG(seed: baseSeed &+ seedOff)
let half = CityScene.CL / 2
var ground = [Float](), streets = [Float](), edges = [Float](), antennas = [Float]()
var winBuckets = [[Float]](repeating: [], count: bucketCount)
var beacons = [Float]()
var wb = 0 // window bucket round-robin
// ground grid (plane 220 x CL, 22 x 44)
let gw: Float = 220, segX = 11, segZ = 22
for i in 0...segX {
let x = -gw / 2 + gw * Float(i) / Float(segX)
ground.append(x); ground.append(0); ground.append(-half)
ground.append(x); ground.append(0); ground.append(half)
}
for i in 0...segZ {
let z = -half + CityScene.CL * Float(i) / Float(segZ)
ground.append(-gw / 2); ground.append(0); ground.append(z)
ground.append(gw / 2); ground.append(0); ground.append(z)
}
// street lines
for x in [-CityScene.SW * 0.55, -CityScene.SW * 0.27, 0, CityScene.SW * 0.27, CityScene.SW * 0.55] {
streets.append(Float(x)); streets.append(0.08); streets.append(-half)
streets.append(Float(x)); streets.append(0.08); streets.append(half)
}
var zc = -half + 18
while zc < half {
streets.append(-CityScene.SW * 0.6); streets.append(0.08); streets.append(zc)
streets.append(CityScene.SW * 0.6); streets.append(0.08); streets.append(zc)
zc += 36
}
// buildings
let nb = Int(CityScene.CL / CityScene.BS)
for b in 0..<nb {
let bz = -half + Float(b) * CityScene.BS + CityScene.BS / 2
for side in [Float(-1), Float(1)] {
let nBld = 1 + (r.nextF() > 0.45 ? 1 : 0)
for i in 0..<nBld {
let w = 10 + r.nextF() * 22, h = 14 + r.nextF() * 78, d = 10 + r.nextF() * 16
let gap = 3 + r.nextF() * 13
let xp = side * (CityScene.SW + gap + w / 2)
let zp = bz + (nBld > 1 ? (Float(i) - 0.5) * CityScene.BS * 0.42 : 0) + (r.nextF() - 0.5) * 4
appendBoxEdges(&edges, xp, h / 2, zp, w, h, d)
if h > 40 && r.nextF() > 0.45 {
let tw = w * 0.56, td = d * 0.56, th = h * 0.2
appendBoxEdges(&edges, xp, h + th / 2, zp, tw, th, td)
}
if h > 26 && r.nextF() > 0.4 {
let ah = 5 + r.nextF() * 20
antennas.append(xp); antennas.append(h); antennas.append(zp)
antennas.append(xp); antennas.append(h + ah); antennas.append(zp)
beacons.append(xp); beacons.append(h + ah); beacons.append(zp)
}
let fl = Int(h / 4.2), wc = max(1, Int(w / 3.2))
for f in 0..<fl {
for c in 0..<wc where r.nextF() < 0.35 {
let wx = xp - w / 2 + 0.55 + Float(c) * (w / Float(wc))
let wy = 2 + Float(f) * 4.2
let wz = zp + (r.nextF() > 0.5 ? d / 2 + 0.04 : -d / 2 - 0.04)
appendWinQuad(&winBuckets[wb % bucketCount], wx, wy, wz)
wb += 1
}
}
}
}
}
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)
}
var its: [DrawItem] = [line(ground, 0.12), line(streets, 0.25), line(edges, 0.5), line(antennas, 0.88)]
// window buckets (solid triangles, animated opacity)
for k in 0..<bucketCount {
let a = winBuckets[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 // some window buckets crossfade to enemy with 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))
}
// beacons
if !beacons.isEmpty {
its.append(DrawItem(positions: Geo.buffer(device, beacons), indices: nil, count: beacons.count / 3,
vertexCount: beacons.count / 3, primitive: .point, opacity: 0.9,
isPoint: true, pointSizeWorld: 0.5))
}
// model offset: chunk z translate set per frame
return Chunk(z: z0, items: its)
}
chunks = [buildChunk(42, -160), buildChunk(137, -480)]
for c in chunks { for it in c.items { it.model = Mathx.translation(SIMD3(0, 0, c.z)) } }
// stars + dust (direct in world, static)
var sv = [Float](), dv = [Float]()
for _ in 0..<700 { sv.append((base.nextF() - 0.5) * 360); sv.append(base.nextF() * 120 + 20); sv.append(-(base.nextF() * 500 + 40)) }
for _ in 0..<400 { dv.append((base.nextF() - 0.5) * 80); dv.append(base.nextF() * 40 + 2); dv.append(-(base.nextF() * 240 + 5)) }
let stars = DrawItem(positions: Geo.buffer(device, sv), indices: nil, count: 700, vertexCount: 700,
primitive: .point, opacity: 1.0, isPoint: true, pointSizeWorld: 0.14)
dustItem = DrawItem(positions: Geo.buffer(device, dv), indices: nil, count: 400, vertexCount: 400,
primitive: .point, opacity: 0.7, isPoint: true, pointSizeWorld: 0.065)
items = chunks.flatMap { $0.items } + [stars, dustItem]
}
func update(t: Double, dt: Double) {
let tf = Float(t)
let spd = speedPerSec * Float(dt)
// Stream dust past the camera (z) so it flies by instead of hanging at a fixed distance.
let dptr = dustItem.positions.contents().bindMemory(to: Float.self, capacity: 400 * 3)
for i in 0..<400 {
var dz = dptr[i * 3 + 2] + spd
if dz > 10 { dz -= 245 }
dptr[i * 3 + 2] = dz
}
for i in chunks.indices {
chunks[i].z += spd
if chunks[i].z > CityScene.CL / 2 + 8 { chunks[i].z -= CityScene.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)
}
}