// The one GPU owner for panel transitions and persistent scene effects. // CoreText remains the renderer: while either exists PardesView draws its // complete ordinary frame into this retained bitmap, then Core Image runs the // standalone shaders/crt.ci.metal kernels over it. With neither, the view // bypasses this object entirely. import CoreGraphics import CoreImage import Darwin import Metal final class ScenePostprocessor { enum CompositeOutcome { case presented case retry case unavailable } private let colorSpace: CGColorSpace private let sceneKernel: CIKernel private let panelClearKernel: CIKernel private let panelKernel: CIKernel private let context: CIContext private var raster: CGContext? private var previousRaster: CGContext? private var rasterWidth = 0 private var rasterHeight = 0 private var rasterScale: CGFloat = 0 init?() { let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() guard let source = ScenePostprocessor.kernelSource(), let device = MTLCreateSystemDefaultDevice(), let made = try? CIKernel.kernels(withMetalString: source), let sceneKernel = made.first(where: { $0.name == "pardesScene" }), let panelClearKernel = made.first(where: { $0.name == "pardesPanelClear" }), let panelKernel = made.first(where: { $0.name == "pardesPanel" }) else { return nil } self.colorSpace = colorSpace self.sceneKernel = sceneKernel self.panelClearKernel = panelClearKernel self.panelKernel = panelKernel self.context = CIContext(mtlDevice: device, options: [ .workingColorSpace: colorSpace, .outputColorSpace: colorSpace, .cacheIntermediates: false, ]) } private static func kernelSource() -> String? { var candidates: [URL] = [] if let resources = Bundle.main.resourceURL { candidates.append(resources.appendingPathComponent("crt.ci.metal")) } // An unbundled e2e executable has no Contents/Resources. Its build // puts the asset next to the binary, and this explicit candidate keeps // that dev loop independent of Bundle's treatment of command tools. if let executable = Bundle.main.executableURL { candidates.append(executable.deletingLastPathComponent() .appendingPathComponent("crt.ci.metal")) } for url in candidates { if let source = try? String(contentsOf: url, encoding: .utf8) { return source } } return nil } /// A body-font-sized, top-left-origin CGContext matching the view's own /// coordinate system. It is retained across frames; only a resize or a /// backing-scale move reallocates the pixel storage. func renderContext(size: CGSize, scale: CGFloat) -> CGContext? { let scale = max(1, scale) let width = max(1, Int((size.width * scale).rounded(.up))) let height = max(1, Int((size.height * scale).rounded(.up))) if raster == nil || width != rasterWidth || height != rasterHeight || scale != rasterScale { let info = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue guard let next = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: info) else { return nil } next.translateBy(x: 0, y: CGFloat(height)) next.scaleBy(x: scale, y: -scale) raster = next // The old and new snapshots must have identical extents. Lazily // rebuild the old owner only if an effect actually needs it. previousRaster = nil rasterWidth = width rasterHeight = height rasterScale = scale } return raster } /// A second complete CoreText raster for the frozen old grid. It is /// intentionally a peer of `raster`, not a collection of per-pane image /// pointers: deleted PDF/image panes have already released their core /// bytes, while PardesView's frozen CGImages remain safe to draw here. func previousRenderContext(size: CGSize, scale: CGFloat) -> CGContext? { guard renderContext(size: size, scale: scale) != nil else { return nil } if previousRaster == nil { let info = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue guard let next = CGContext( data: nil, width: rasterWidth, height: rasterHeight, bitsPerComponent: 8, bytesPerRow: rasterWidth * 4, space: colorSpace, bitmapInfo: info) else { return nil } next.translateBy(x: 0, y: CGFloat(rasterHeight)) next.scaleBy(x: rasterScale, y: -rasterScale) previousRaster = next } return previousRaster } /// Run the one scene kernel and copy its result into the flipped NSView /// context. Allocation/device failures may be retried briefly. A kernel /// rejecting its fixed argument layout is permanent for this executable, /// so the caller can turn the advertised effects back off instead of /// silently spinning a display clock forever. func composite( scene: pardes_scene_s, tracks: UnsafeBufferPointer, changedCells: UnsafePointer?, changedAttachmentRects: [CGRect], gridSize: (cols: Int, rows: Int), cellSize: CGSize, ground: UInt32?, size: CGSize, into destination: CGContext ) -> CompositeOutcome { guard let raster, let sourceImage = raster.makeImage() else { return .retry } let extent = CGRect(x: 0, y: 0, width: rasterWidth, height: rasterHeight) let current = CIImage(cgImage: sourceImage, options: [.colorSpace: colorSpace]) let old: CIImage if let previousImage = previousRaster?.makeImage() { old = CIImage(cgImage: previousImage, options: [.colorSpace: colorSpace]) } else { old = current } let mask = changeMask( changedCells, attachmentRects: changedAttachmentRects, gridSize: gridSize, cellSize: cellSize) ?? CIImage(color: CIColor(red: 0, green: 0, blue: 0, alpha: 1)) .cropped(to: CGRect(x: 0, y: 0, width: 1, height: 1)) var filtered = current // Geometric targets are prepared before any presented panel is drawn. // Horizontal slide/zoom use the theme ground; a vertical opener uses // the exact old frame inside its fixed clip. Data effects and closing // tombstones leave the canonical current frame untouched underneath. let clear = ground.map { encoded in CIColor( red: CGFloat((encoded >> 16) & 0xFF) / 255, green: CGFloat((encoded >> 8) & 0xFF) / 255, blue: CGFloat(encoded & 0xFF) / 255, alpha: 1) } ?? CIColor(red: 0, green: 0, blue: 0, alpha: 0) for track in tracks { let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to guard let next = panelClearKernel.apply( extent: extent, roiCallback: { _, _ in extent }, arguments: [ filtered, old, pixelBox(content, cellSize: cellSize), clear, Float(track.effect), Float(track.phase), ]) else { return .unavailable } filtered = next } // libpardes orders moving, opening, then closing tombstones. Each pass // samples immutable old/new rasters and composes over the prior pass, // so a deleted pane can safely outlive its core Pane allocation. let pixelCell = CIVector( x: cellSize.width * rasterScale, y: cellSize.height * rasterScale) for track in tracks { let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to guard let next = panelKernel.apply( extent: extent, roiCallback: { _, _ in extent }, arguments: [ current, old, mask, filtered, pixelBox(content, cellSize: cellSize), pixelBox(track.from, cellSize: cellSize), pixelBox(track.to, cellSize: cellSize), Float(track.effect), Float(track.phase), Float(track.frame), CIVector( x: CGFloat(track.serial & 0xFFFF), y: CGFloat(track.serial >> 16)), pixelCell, CIVector( x: 0, y: extent.height - CGFloat(gridSize.rows) * pixelCell.y), ]) else { return .unavailable } filtered = next } if scene.flags != 0 { let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0 let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0 let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0 // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image // instead supplies transparent black outside its extent, which // would make ripple/glitch tear dark seams at the window edge. // Clamp the input only; the kernel still emits true black before // sampling when a destination lies outside the CRT tube. let sceneInput = filtered.clampedToExtent() guard let next = sceneKernel.apply( extent: extent, roiCallback: { _, _ in extent }, arguments: [ sceneInput, scene.time_seconds, Float(scene.frame), crt, ripple, glitch, CIVector(x: extent.width, y: extent.height), CIVector(x: extent.minX, y: extent.minY), ]) else { return .unavailable } filtered = next } guard let presented = context.createCGImage(filtered, from: extent) else { return .retry } destination.clear(CGRect(origin: .zero, size: size)) destination.saveGState() destination.translateBy(x: 0, y: size.height) destination.scaleBy(x: 1, y: -1) destination.interpolationQuality = .none destination.draw(presented, in: CGRect(origin: .zero, size: size)) destination.restoreGState() return .presented } /// Byte-per-cell mask from the core. Zig expands true to 255 so the image /// is a literal normalized 0/1 texture; sampling cell centres below keeps /// adjacent values from interpolating at pane/cell boundaries. private func changeMask( _ cells: UnsafePointer?, attachmentRects: [CGRect], gridSize: (cols: Int, rows: Int), cellSize: CGSize ) -> CIImage? { let count = gridSize.cols * gridSize.rows guard count > 0, let cells, cellSize.width > 0, cellSize.height > 0 else { return nil } var bytes = Array(UnsafeBufferPointer(start: cells, count: count)) for rect in attachmentRects where !rect.isNull && !rect.isEmpty { let x0 = max(0, Int((rect.minX / cellSize.width).rounded(.down))) let x1 = min(gridSize.cols, Int((rect.maxX / cellSize.width).rounded(.up))) let y0 = max(0, Int((rect.minY / cellSize.height).rounded(.down))) let y1 = min(gridSize.rows, Int((rect.maxY / cellSize.height).rounded(.up))) guard x0 < x1, y0 < y1 else { continue } for row in y0.. CIVector { let cellW = cellSize.width * rasterScale let cellH = cellSize.height * rasterScale let x = CGFloat(box.x) * cellW let w = CGFloat(box.w) * cellW let h = CGFloat(box.h) * cellH let y = CGFloat(rasterHeight) - (CGFloat(box.y) + CGFloat(box.h)) * cellH return CIVector(x: x, y: y, z: w, w: h) } /// The scene kernel is a destination-to-source mapping. Pointer input must /// apply that same mapping before the grid and its panel tracks see a cell, /// otherwise a visible word under ripple/glitch is not the word Look gets. static func sourcePoint( _ point: CGPoint, size: CGSize, scene: pardes_scene_s ) -> CGPoint? { guard size.width > 0, size.height > 0 else { return nil } if scene.flags == 0 { return point } var uvX = Float(point.x / size.width) var uvY = 1 - Float(point.y / size.height) // Core Image is bottom-up var ccX = uvX * 2 - 1 var ccY = uvY * 2 - 1 let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0 let radius2 = ccX * ccX + ccY * ccY let barrel = 1 + 0.016 * radius2 * crt ccX *= barrel ccY *= barrel uvX = ccX * 0.5 + 0.5 uvY = ccY * 0.5 + 0.5 // Only CRT has a black tube. Ripple/glitch without CRT sample through // the texture's clamp-to-edge policy and remain clickable there. if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil } let time = scene.time_seconds if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 { let centeredX = uvX - 0.5 let centeredY = uvY - 0.5 let radius = sqrtf(centeredX * centeredX + centeredY * centeredY) let envelope = 1 - smoothstep(0.04, 0.72, radius) let wave = sinf(radius * 42 - time * 5.2) let travel = wave * envelope * 0.0032 / max(radius, 0.00001) uvX += centeredX * travel uvY += centeredY * travel } if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 { let band = floorf(uvY * 96) let seed = noise(band, floorf(time * 13)) let tear = smoothstep(0.955, 1, seed) uvX += (noise(seed, band) - 0.5) * tear * 0.020 } uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width))) uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height))) return CGPoint( x: CGFloat(uvX) * size.width, y: CGFloat(1 - uvY) * size.height) } private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float { let t = min(max((x - edge0) / (edge1 - edge0), 0), 1) return t * t * (3 - 2 * t) } /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is /// deliberate: the shader's band decision must not move at a Double round. private static func noise(_ x: Float, _ y: Float) -> Float { var qx = fract(x * 0.1031) var qy = fract(y * 0.1031) var qz = fract(x * 0.1031) let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33) qx += d qy += d qz += d return fract((qx + qy) * qz) } private static func fract(_ x: Float) -> Float { x - floorf(x) } }