import MetalKit
import PhysarumEngine
/// Owns the SimEngine + Renderer and drives the per-frame loop via MTKViewDelegate.
/// The field adopts the view's format: `draw` computes the target FieldSize each frame
/// and `setField` debounce-recreates the engine when it changes (rotation/resize/format).
/// Live params apply every frame; respawn params re-seed via a separate debounce.
final class RenderCoordinator: NSObject, MTKViewDelegate {
let device: MTLDevice
private let lib: ShaderLibrary
private let queue: MTLCommandQueue // ONE queue shared by sim + renderer
private var renderer: Renderer
private var sim: SimEngine
private var currentField: FieldSize
// Classic triple buffering: at most 3 frames encoded but not yet completed.
// Bounds the uniform rings in SimEngine/Renderer; replaces the old
// per-sub-step waitUntilCompleted (which serialized CPU and GPU).
private let inflight = DispatchSemaphore(value: 3)
private(set) var params: PresetParams
private var currentPresetID: String
private var respawnWork: DispatchWorkItem?
private var fieldWork: DispatchWorkItem?
private var pendingField: FieldSize?
var cover = false // aspect-fill (true) vs fit-with-bg-surround (false)
var format: SimFormat = .auto
var audioInput: AudioInput?
var audioReactive = AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
var audioTuning = AudioTuning()
/// T2b (tvOS sink): pulled each frame for phone-streamed factors when there's no local mic. A
/// closure (not a stored frame) so the render loop reads the latest without a 30 Hz SwiftUI re-render.
var remoteAudioFactorsSource: (() -> AudioFactorFrame?)?
private var autoGain = AutoGainState()
// Thermal/low-power frame-rate adaptation, recomputed via notifications and
// applied cheaply in draw (avoids a ProcessInfo query per frame).
private var targetFPS = preferredFPS(thermalState: ProcessInfo.processInfo.thermalState,
lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled)
private var powerObservers: [NSObjectProtocol] = []
private let tier: FieldTier
init?(initialField: FieldSize, tier: FieldTier = .current, initialPreset: Preset, live: PresetParams) {
guard let device = MTLCreateSystemDefaultDevice() else {
print("[Physarum] No Metal device available — playback disabled.")
return nil
}
self.device = device
self.lib = ShaderLibrary(device: device)
self.tier = tier
guard let q = device.makeCommandQueue() else {
print("[Physarum] No command queue — playback disabled.")
return nil
}
self.queue = q
do {
self.renderer = try Renderer(device: device, library: lib, width: initialField.width, height: initialField.height, queue: q)
self.sim = try Self.makeSim(device: device, lib: lib, field: initialField, preset: live, tier: tier, queue: q)
} catch {
print("[Physarum] Engine init failed: \(error) — playback disabled.")
return nil
}
self.currentField = initialField
self.params = live
self.currentPresetID = initialPreset.id
super.init()
for name in [ProcessInfo.thermalStateDidChangeNotification, .NSProcessInfoPowerStateDidChange] {
powerObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
self?.targetFPS = preferredFPS(thermalState: ProcessInfo.processInfo.thermalState,
lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled)
})
}
}
deinit {
for o in powerObservers { NotificationCenter.default.removeObserver(o) }
}
/// Build a SimEngine at `field`, with the preset's agentCount density-scaled to the area.
private static func makeSim(device: MTLDevice, lib: ShaderLibrary,
field: FieldSize, preset: PresetParams, tier: FieldTier,
queue: MTLCommandQueue) throws -> SimEngine {
var p = preset
p.agentCount = densityScaledAgentCount(preset.agentCount, fieldWidth: field.width, fieldHeight: field.height, tier: tier)
return try SimEngine(device: device, library: lib, width: field.width, height: field.height, preset: p, queue: queue)
}
/// Apply edited params: live fields immediately; respawn fields after a 250ms debounce.
func setParams(_ p: PresetParams) {
let needsRespawn = Self.isStructuralChange(p, params)
params = p
sim.updateLiveParams(p)
if needsRespawn { scheduleRespawn() }
}
/// A change that requires re-seeding the sim (vs a live look-only edit): agentCount,
/// spawnPattern and per-species populationShare can't be applied to already-living agents.
static func isStructuralChange(_ a: PresetParams, _ b: PresetParams) -> Bool {
a.agentCount != b.agentCount
|| a.spawnPattern != b.spawnPattern
|| zip(a.species, b.species).contains { $0.populationShare != $1.populationShare }
}
/// Adopt a different preset. Re-seeds immediately (+ clears the echo) only when the
/// structural params differ; a structure-holding cycler transition keeps the living
/// sim + feedback echo and just applies the new look — a seamless, restart-free change.
func switchPreset(_ preset: Preset, live: PresetParams) {
guard preset.id != currentPresetID else { return }
currentPresetID = preset.id
respawnWork?.cancel() // any in-flight respawn was scheduled against the old preset — drop it (both branches)
let structural = Self.isStructuralChange(live, params)
params = live
guard structural else { sim.updateLiveParams(live); return }
if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: live, tier: tier, queue: queue) {
sim = s
renderer.resetFeedback()
}
}
private func scheduleRespawn() {
respawnWork?.cancel()
let work = DispatchWorkItem { [weak self] in self?.respawn() }
respawnWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
}
private func respawn() {
if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: params, tier: tier, queue: queue) {
sim = s
renderer.resetFeedback()
}
}
/// Ensure the field matches `target`; debounce-recreate the renderer + sim when it
/// changes. Guarded against rescheduling for an already-pending target so a steady
/// stream of equal targets (every frame) lets the timer actually fire.
/// If the target reverts to the current field while a recreate is pending, cancel it.
func setField(_ target: FieldSize) {
if target == currentField {
// Target has reverted — cancel any pending recreate so the timer never fires
// with a stale size and produces a visible glitch.
if pendingField != nil {
fieldWork?.cancel()
fieldWork = nil
pendingField = nil
}
return
}
guard target != pendingField else { return }
pendingField = target
fieldWork?.cancel()
let work = DispatchWorkItem { [weak self] in self?.recreate(target) }
fieldWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func recreate(_ target: FieldSize) {
pendingField = nil
guard target != currentField else { return }
if let r = try? Renderer(device: device, library: lib, width: target.width, height: target.height, queue: queue),
let s = try? Self.makeSim(device: device, lib: lib, field: target, preset: params, tier: tier, queue: queue) {
renderer = r
sim = s
currentField = target
}
}
/// Drain the shared queue so all in-flight frames have completed (the `.shared`
/// sim buffers are then stable for CPU readback), then snapshot sim + echo.
/// Returns nil if the drain buffer can't be allocated (device lost) — reading
/// GPU-active buffers would corrupt the snapshot. Called on main at a user tap.
func takeSnapshot() -> LiveSnapshot? {
guard let drain = queue.makeCommandBuffer() else { return nil }
drain.commit()
drain.waitUntilCompleted()
return LiveSnapshot(sim: sim.snapshot(),
feedback: renderer.snapshotFeedback(),
field: currentField, preset: params, format: format, cover: cover)
}
/// Block until all in-flight GPU work completes. Called on tvOS before the app
/// suspends: tvOS rejects command buffers submitted in the background
/// (kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted), so the
/// render loop must be quiescent before backgrounding, not merely paused.
func drain() {
guard let cb = queue.makeCommandBuffer() else { return }
cb.commit()
cb.waitUntilCompleted()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
func draw(in view: MTKView) {
if view.preferredFramesPerSecond != targetFPS { view.preferredFramesPerSecond = targetFPS }
let ds = view.drawableSize
setField(targetFieldSize(viewWidth: Double(ds.width), viewHeight: Double(ds.height), format: format, tier: tier))
applyAudio()
// One command buffer per frame: sim sub-steps + full render, no CPU-GPU
// sync. The semaphore caps frames in flight at 3 (bounds the uniform
// rings); without a drawable the whole frame is skipped — no sim work
// for output nobody sees.
inflight.wait()
guard let desc = view.currentRenderPassDescriptor, let drawable = view.currentDrawable,
let cb = queue.makeCommandBuffer() else {
inflight.signal()
return
}
sim.encodeStep(into: cb)
renderer.encode(into: cb, sim: sim, preset: params, descriptor: desc, cover: cover)
cb.addCompletedHandler { [inflight] _ in inflight.signal() }
cb.present(drawable)
cb.commit()
}
private func applyAudio() {
let ar = audioReactive
audioInput?.setEnabled(ar.enabled)
audioInput?.setTuning(audioTuning)
if let input = audioInput, ar.enabled, input.running {
// Local mic (iPhone/iPad/Mac): derive the factors here.
let f = evaluateAudioFactors(bands: input.latestBands, reactive: ar, autoGain: &autoGain, tuning: audioTuning)
sim.audioFactors = f.agentUniform
renderer.audioPostFactors = (f.postExposure, f.postBloomIntensity, f.postSaturation, f.postContrast)
return
}
autoGain = AutoGainState() // reset peak so a local re-enable ramps fresh (matches web setAudio(null))
// No local mic: apply phone-streamed factors (tvOS sink) when present, else neutral.
if let rf = remoteAudioFactorsSource?() {
sim.audioFactors = rf.agentUniform
renderer.audioPostFactors = (rf.postExposure, rf.postBloomIntensity, rf.postSaturation, rf.postContrast)
} else {
sim.audioFactors = UniformInput.Audio()
renderer.audioPostFactors = (1, 1, 1, 1)
}
}
}