Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/LiveEncodeTests.swift

import XCTest
import Metal
@testable import PhysarumEngine

/// Tests for the non-blocking live encode path: sim sub-steps + render share one
/// command buffer on one queue, with no CPU-GPU round-trip per sub-step.
final class LiveEncodeTests: XCTestCase {
    private func makeDevice() throws -> MTLDevice {
        guard let device = MTLCreateSystemDefaultDevice() else {
            throw XCTSkip("no Metal device available")
        }
        return device
    }

    private func headsSum(_ engine: SimEngine, cells: Int) -> Double {
        let ptr = engine.headsBuffer.contents().bindMemory(to: SIMD4<Float>.self, capacity: cells)
        var sum = 0.0
        for i in 0..<cells { sum += Double(ptr[i].x + ptr[i].y + ptr[i].z) }
        return sum
    }

    /// Characterization: after step(), `heads` holds only the LAST sub-step's
    /// deposits — running 4 sub-steps must not accumulate ~4x the single-step sum.
    /// (Deposit's read-modify-write loses some adds on cell collisions, so we
    /// compare magnitudes, not exact counts.)
    func testHeadsHoldOnlyLastSubStepDeposits() throws {
        let device = try makeDevice()
        let lib = ShaderLibrary(device: device)
        var p = try TestFixtures.minimalPreset().params
        p.agentCount = 2000
        p.spawnPattern = .random

        var p1 = p; p1.simSpeed = 1
        var p4 = p; p4.simSpeed = 4
        let e1 = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p1)
        let e4 = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p4)
        e1.step()
        e4.step()

        let s1 = headsSum(e1, cells: 64 * 64)
        let s4 = headsSum(e4, cells: 64 * 64)
        XCTAssertGreaterThan(s1, 0)
        XCTAssertGreaterThan(s4, 0)
        XCTAssertLessThan(s4, s1 * 2.0, "heads must not accumulate across sub-steps (got \(s4) vs single-step \(s1))")
    }

    /// The live path encodes sub-steps into a caller-owned command buffer on a
    /// shared queue — no commit, no waitUntilCompleted inside the engine.
    func testEncodeStepOnSharedQueueEvolvesField() throws {
        let device = try makeDevice()
        let lib = ShaderLibrary(device: device)
        guard let queue = device.makeCommandQueue() else { return XCTFail("no queue") }
        var p = try TestFixtures.minimalPreset().params
        p.agentCount = 5000
        p.spawnPattern = .center
        p.simSpeed = 3

        let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p, queue: queue)
        var last: MTLCommandBuffer?
        for _ in 0..<40 {
            guard let cb = queue.makeCommandBuffer() else { return XCTFail("no cb") }
            engine.encodeStep(into: cb)
            cb.commit()
            last = cb
        }
        last?.waitUntilCompleted()

        XCTAssertEqual(engine.frame, 120, "40 frames × simSpeed 3")
        let s = engine.pheromoneStats()
        XCTAssertFalse(s.hasNaN)
        XCTAssertGreaterThan(s.sum, 0)
    }

    /// One frame = sim sub-steps + full render encoded into a SINGLE command
    /// buffer (the live MTKView path), producing non-empty output.
    func testSimAndRenderShareOneCommandBuffer() throws {
        let device = try makeDevice()
        let lib = ShaderLibrary(device: device)
        guard let queue = device.makeCommandQueue() else { return XCTFail("no queue") }
        var p = try TestFixtures.minimalPreset().params
        p.agentCount = 5000
        p.spawnPattern = .center
        p.species[0].depositAmount = 5
        p.simSpeed = 2

        let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p, queue: queue)
        let renderer = try Renderer(device: device, library: lib, width: 64, height: 64, queue: queue)

        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 64, height: 64, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        guard let target = device.makeTexture(descriptor: d) else { return XCTFail("no texture") }

        var lastCB: MTLCommandBuffer?
        for _ in 0..<30 {
            guard let cb = queue.makeCommandBuffer() else { return XCTFail("no cb") }
            engine.encodeStep(into: cb)
            let rp = MTLRenderPassDescriptor()
            rp.colorAttachments[0].texture = target
            rp.colorAttachments[0].loadAction = .clear
            rp.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
            rp.colorAttachments[0].storeAction = .store
            renderer.encode(into: cb, sim: engine, preset: p, descriptor: rp, cover: true)
            cb.commit()
            lastCB = cb
        }
        lastCB?.waitUntilCompleted()
        XCTAssertNil(lastCB?.error, "GPU error: \(String(describing: lastCB?.error))")

        var raw = [UInt8](repeating: 0, count: 64 * 64 * 4)
        target.getBytes(&raw, bytesPerRow: 64 * 4, from: MTLRegionMake2D(0, 0, 64, 64), mipmapLevel: 0)
        let lit = raw.enumerated().contains { ($0.offset % 4) != 3 && $0.element > 8 }
        XCTAssertTrue(lit, "rendered frame must contain non-background pixels")
    }
}