Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/AspectFidelityTests.swift

import XCTest
import Metal
@testable import PhysarumEngine

/// Aspect fidelity of the composite viewport: a centre-spawned blob is isotropic
/// in FIELD pixels (agent motion has no aspect term), so after rendering into a
/// differently-shaped target its weighted pixel distribution must stay round —
/// σx/σy ≈ 1. Any field-to-target stretch shows up as that ratio.
final class AspectFidelityTests: XCTestCase {
    private func makeDevice() throws -> MTLDevice {
        guard let device = MTLCreateSystemDefaultDevice() else {
            throw XCTSkip("no Metal device available")
        }
        return device
    }

    /// Render a centre blob from a fieldW×fieldH sim into a targetW×targetH
    /// texture and return the brightness-weighted σx/σy of the lit pixels.
    private func blobAspect(fieldW: Int, fieldH: Int, targetW: Int, targetH: Int,
                            cover: Bool, kaleidoscope: Int = 0) throws -> Double {
        let device = try makeDevice()
        let lib = ShaderLibrary(device: device)
        var p = try TestFixtures.minimalPreset().params
        p.agentCount = 8000
        p.spawnPattern = .center
        p.species[0].depositAmount = 5
        p.trailSaturation = 10
        p.exposure = 4
        p.bloomIntensity = 0
        p.feedbackAmount = 0
        p.kaleidoscope = kaleidoscope

        let engine = try SimEngine(device: device, library: lib, width: fieldW, height: fieldH, preset: p)
        for _ in 0..<15 { engine.step() }
        let renderer = try Renderer(device: device, library: lib, width: fieldW, height: fieldH)

        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm,
                                                         width: targetW, height: targetH, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        guard let target = device.makeTexture(descriptor: d) else { throw ShaderError.compile("target") }
        try renderer.renderOffscreen(sim: engine, preset: p, into: target, cover: cover)

        var raw = [UInt8](repeating: 0, count: targetW * targetH * 4)
        target.getBytes(&raw, bytesPerRow: targetW * 4,
                        from: MTLRegionMake2D(0, 0, targetW, targetH), mipmapLevel: 0)

        var wSum = 0.0, mx = 0.0, my = 0.0
        for y in 0..<targetH {
            for x in 0..<targetW {
                let i = (y * targetW + x) * 4
                let lum = Double(raw[i]) + Double(raw[i + 1]) + Double(raw[i + 2])
                if lum > 24 { wSum += lum; mx += lum * Double(x); my += lum * Double(y) }
            }
        }
        XCTAssertGreaterThan(wSum, 0, "blob must be visible in the target")
        mx /= wSum; my /= wSum
        var vx = 0.0, vy = 0.0
        for y in 0..<targetH {
            for x in 0..<targetW {
                let i = (y * targetW + x) * 4
                let lum = Double(raw[i]) + Double(raw[i + 1]) + Double(raw[i + 2])
                if lum > 24 {
                    vx += lum * (Double(x) - mx) * (Double(x) - mx)
                    vy += lum * (Double(y) - my) * (Double(y) - my)
                }
            }
        }
        return (vx / wSum).squareRoot() / (vy / wSum).squareRoot()
    }

    func testLandscapeFieldFitIntoTallTargetIsUndistorted() throws {
        // .landscape phone field (910x512) fitted into a 2x iPhone drawable shape.
        let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864, cover: false)
        XCTAssertEqual(r, 1.0, accuracy: 0.25, "fit must keep field pixels square (σx/σy = \(r))")
    }

    func testLandscapeFieldCoverIntoTallTargetIsUndistorted() throws {
        let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864, cover: true)
        XCTAssertEqual(r, 1.0, accuracy: 0.25, "cover must keep field pixels square (σx/σy = \(r))")
    }

    func testPortraitFieldFitIntoWideTargetIsUndistorted() throws {
        // .portrait field (512x910) fitted into a landscape drawable shape.
        let r = try blobAspect(fieldW: 512, fieldH: 910, targetW: 1864, targetH: 860, cover: false)
        XCTAssertEqual(r, 1.0, accuracy: 0.25, "fit must keep field pixels square (σx/σy = \(r))")
    }

    /// Kaleidoscope folds polar coordinates — on a non-square field a UV-space
    /// fold renders every source circle as an ellipse stretched by the field
    /// aspect (the "deutlich verzerrt" report: square looks right, portrait is
    /// stretched tall, landscape wide). The fold must be aspect-corrected so a
    /// centre blob mirrored through the kaleidoscope stays round on screen.
    func testKaleidoscopeOnNonSquareFieldStaysRound() throws {
        let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864,
                               cover: false, kaleidoscope: 6)
        XCTAssertEqual(r, 1.0, accuracy: 0.25,
                       "kaleidoscope fold must be isotropic in field pixels (σx/σy = \(r))")
    }
}