Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/FeedbackOrientationTests.swift

import XCTest
import Metal
@testable import PhysarumEngine

/// Regression guard for the Metal feedback-echo VERTICAL FLIP (iPad-reported): the
/// fullscreen-triangle VS inverts V per Metal render→sample hop, so the ping-pong echo
/// recurrence accumulated a vertical flip and the echo ghost showed up mirrored relative
/// to the live scene. `feedback.metal` samples the scene at flipped V to cancel it
/// (Metal-only — NOT in the shared WGSL). Renders a static scene with feedback off vs on,
/// isolates the accumulated echo, and asserts it correlates with the scene, not its
/// vertical mirror. Robust to the random spawn (compares orientation, not absolute position).
final class FeedbackOrientationTests: XCTestCase {
    private func lumField(_ tex: MTLTexture) -> ([Double], Int, Int) {
        let w = tex.width, h = tex.height, bpr = w * 4
        var raw = [UInt8](repeating: 0, count: bpr * h)
        tex.getBytes(&raw, bytesPerRow: bpr, from: MTLRegionMake2D(0, 0, w, h), mipmapLevel: 0)
        var lum = [Double](repeating: 0, count: w * h)
        for p in 0..<(w * h) {
            let i = p * 4
            lum[p] = 0.114 * Double(raw[i]) + 0.587 * Double(raw[i + 1]) + 0.299 * Double(raw[i + 2])
        }
        return (lum, w, h)
    }

    func testFeedbackEchoAlignsWithSceneNotItsVerticalMirror() throws {
        guard let device = MTLCreateSystemDefaultDevice() else { throw XCTSkip("no GPU") }
        let lib = ShaderLibrary(device: device)
        var preset = try TestFixtures.minimalPreset().params
        preset.bloomIntensity = 0   // isolate the feedback echo
        let field = 192
        let r = try Renderer(device: device, library: lib, width: field, height: field)
        let s = try SimEngine(device: device, library: lib, width: field, height: field, preset: preset)
        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 384, height: 384, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]; d.storageMode = .shared
        let target = try XCTUnwrap(device.makeTexture(descriptor: d))

        for _ in 0..<120 { s.step() }   // evolve a pattern, then keep it static

        r.resetFeedback()
        preset.feedbackAmount = 0
        try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true)
        let (scene, w, h) = lumField(target)

        r.resetFeedback()
        preset.feedbackAmount = 0.6
        for _ in 0..<40 { try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true) }
        let (withFb, _, _) = lumField(target)

        // Orientation is only testable if the scene is vertically ASYMMETRIC — a
        // near-symmetric random pattern makes identity ≈ vflip and can't discriminate.
        var asym = 0.0, energy = 0.0
        for y in 0..<h { for x in 0..<w {
            asym += abs(scene[y * w + x] - scene[(h - 1 - y) * w + x])
            energy += scene[y * w + x]
        } }
        try XCTSkipIf(energy < 1 || asym / energy < 0.4,
                      "scene too vertically symmetric this run to test echo orientation")

        func corr(_ map: (Int, Int) -> Int) -> Double {
            var acc = 0.0
            for y in 0..<h { for x in 0..<w {
                let echo = max(0, withFb[y * w + x] - scene[y * w + x])
                acc += echo * scene[map(x, y)]
            } }
            return acc
        }
        let identity = corr { x, y in y * w + x }
        let vflip = corr { x, y in (h - 1 - y) * w + x }
        XCTAssertGreaterThan(identity, vflip * 1.2,
            "feedback echo correlates with the vertical MIRROR of the scene (identity=\(identity) vflip=\(vflip)) — feedback.metal scene-V flip regressed?")
    }
}