Zum Inhalt

apple/PhysarumApp/Sources/Player/RenderCoordinator.swift

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)
        }
    }
}