LensWarpChecks.swift (7113B)
1 // Compare the production Metal warp against the independent Double-precision CPU model. 2 // Takes a saved factory JSON path; opens no hardware devices. 3 import Foundation 4 import Metal 5 import simd 6 func option(_ name: String) -> String? { nil } 7 8 @main 9 struct LensWarpChecks { 10 static func main() throws { 11 let optics = try HeadsetOptics(json: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))) 12 let device = MTLCreateSystemDefaultDevice()!, queue = device.makeCommandQueue()! 13 let warp = try LensWarp(device: device, optics: optics) 14 var coordinateWorst = 0.0 15 var points: [SIMD2<Float>] = [] 16 for y in stride(from: 0, through: 2160, by: 90) { 17 for x in stride(from: 0, through: 2160, by: 90) { 18 points.append(SIMD2(Float(x) / 2160, Float(y) / 2160)) 19 } 20 } 21 for eye in 0..<2 { 22 let actual = try warp.coordinateSamples(device: device, points: points, eye: eye) 23 for (i, p) in points.enumerated() { 24 let cpu = optics.calibration.eyes[eye].distort(view: eye, u: Double(p.x), v: Double(p.y), bounds: optics.bounds[eye]) 25 for (c, expected) in [cpu.r, cpu.g, cpu.b].enumerated() { 26 let error = simd_abs(SIMD2<Double>(actual[i * 3 + c]) - expected) 27 coordinateWorst = max(coordinateWorst, error.x, error.y) 28 } 29 } 30 } 31 for gain: Float in [-1, 0, 0.5, 2, 4] { 32 for eye in 0..<2 { 33 let actual = try warp.coordinateSamples(device: device, points: points, eye: eye, redGain: gain, blueHorizontalGain: gain, blueVerticalGain: gain) 34 for (i, p) in points.enumerated() { 35 let cpu = optics.calibration.eyes[eye].distort(view: eye, u: Double(p.x), v: Double(p.y), bounds: optics.bounds[eye]) 36 let expected = [cpu.g + (cpu.r - cpu.g) * Double(gain), cpu.g, cpu.g + (cpu.b - cpu.g) * Double(gain)] 37 for c in 0..<3 { 38 let error = simd_abs(SIMD2<Double>(actual[i * 3 + c]) - expected[c]) 39 coordinateWorst = max(coordinateWorst, error.x, error.y) 40 } 41 } 42 } 43 } 44 for eye in 0..<2 { 45 let actual = try warp.coordinateSamples(device: device, points: points, eye: eye, redGain: 1.75, blueHorizontalGain: 2.25, blueVerticalGain: 1.5) 46 for (i, p) in points.enumerated() { 47 let cpu = optics.calibration.eyes[eye].distort(view: eye, u: Double(p.x), v: Double(p.y), bounds: optics.bounds[eye]) 48 let expected = [cpu.g + (cpu.r - cpu.g) * Double(Float(1.75)), cpu.g, 49 cpu.g + (cpu.b - cpu.g) * SIMD2(Double(Float(2.25)), Double(Float(1.5)))] 50 for c in 0..<3 { 51 let error = simd_abs(SIMD2<Double>(actual[i * 3 + c]) - expected[c]) 52 coordinateWorst = max(coordinateWorst, error.x, error.y) 53 } 54 } 55 } 56 precondition(coordinateWorst < 1e-5, "Float shader coordinates differ from Double model: \(coordinateWorst)") 57 print("PASS: \(points.count * 2 * 3 * 7) RGB float-coordinate pairs (six strengths and independent blue axes), max GPU/CPU error \(coordinateWorst)") 58 let w = warp.color.width, h = warp.color.height, eyeW = w / 2 59 let bytes = UnsafeMutablePointer<UInt8>.allocate(capacity: w * h * 4) 60 defer { bytes.deallocate() } 61 for y in 0..<h { 62 for x in 0..<w { 63 let p = (y * w + x) * 4 64 bytes[p] = 128 65 bytes[p + 1] = UInt8(Double(y) / Double(h - 1) * 255) 66 bytes[p + 2] = UInt8(Double(x % eyeW) / Double(eyeW - 1) * 255) 67 bytes[p + 3] = 255 68 } 69 } 70 func texture(_ width: Int, _ height: Int) -> MTLTexture { 71 let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: width, height: height, mipmapped: false) 72 d.storageMode = .shared; d.usage = .renderTarget 73 return device.makeTexture(descriptor: d)! 74 } 75 let source = texture(w, h), target = texture(4320, 2160) 76 source.replace(region: MTLRegionMake2D(0, 0, w, h), mipmapLevel: 0, withBytes: bytes, bytesPerRow: w * 4) 77 let command = queue.makeCommandBuffer()!, blit = command.makeBlitCommandEncoder()! 78 blit.copy(from: source, sourceSlice: 0, sourceLevel: 0, sourceOrigin: .init(x: 0, y: 0, z: 0), sourceSize: .init(width: w, height: h, depth: 1), to: warp.color, destinationSlice: 0, destinationLevel: 0, destinationOrigin: .init(x: 0, y: 0, z: 0)) 79 blit.endEncoding() 80 warp.encode(command, target: target) 81 command.commit(); command.waitUntilCompleted() 82 precondition(command.status == .completed, "Metal command failed") 83 let outW = target.width, outH = target.height 84 let output = UnsafeMutablePointer<UInt8>.allocate(capacity: outW * outH * 4) 85 defer { output.deallocate() } 86 target.getBytes(output, bytesPerRow: outW * 4, from: MTLRegionMake2D(0, 0, outW, outH), mipmapLevel: 0) 87 var worst = 0.0, count = 0 88 for eye in 0..<2 { 89 let model = optics.calibration.eyes[eye], bounds = optics.bounds[eye] 90 for y in stride(from: 53, to: 2160, by: 137) { 91 for x in stride(from: 29, to: 2160, by: 131) { 92 let tc = model.distort(view: eye, u: (Double(x) + 0.5) / 2160, v: (Double(y) + 0.5) / 2160, bounds: bounds) 93 func expected(_ uv: SIMD2<Double>, _ value: Double) -> Double { 94 uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1 ? 0 : value 95 } 96 let p = (y * outW + eye * 2160 + x) * 4 97 worst = max(worst, abs(Double(output[p + 2]) / 255 - expected(tc.r, tc.r.x)), 98 abs(Double(output[p + 1]) / 255 - expected(tc.g, tc.g.y)), 99 abs(Double(output[p]) / 255 - expected(tc.b, 128.0 / 255))) 100 count += 3 101 } 102 } 103 let projection = perspective(bounds: bounds, near: 0.05, far: 100) 104 for (tangent, expectedX) in [(Float(bounds.texXRange.x), Float(-1)), (Float(bounds.texXRange.y), Float(1))] { 105 let clip = projection * SIMD4(tangent * 0.05, 0, -0.05, 1) 106 precondition(abs(clip.x / clip.w - expectedX) < 1e-5, "projection X edge incorrect") 107 } 108 for (tangent, expectedY) in [(-Float(bounds.texYRange.x), Float(1)), (-Float(bounds.texYRange.y), Float(-1))] { 109 let clip = projection * SIMD4(0, tangent * 0.05, -0.05, 1) 110 precondition(abs(clip.y / clip.w - expectedY) < 1e-5, "projection Y edge incorrect") 111 } 112 } 113 precondition(worst < 0.012, "GPU warp differs from CPU model: \(worst)") 114 print("PASS: \(count) RGB channel samples, max GPU/CPU error \(worst); asymmetric projection edges correct") 115 } 116 }