diff options
Diffstat (limited to 'src/macos/Sources/ScenePostprocessor.swift')
| -rw-r--r-- | src/macos/Sources/ScenePostprocessor.swift | 361 |
1 files changed, 361 insertions, 0 deletions
diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift new file mode 100644 index 00000000..cf7892b3 --- /dev/null +++ b/src/macos/Sources/ScenePostprocessor.swift @@ -0,0 +1,361 @@ +// 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<pardes_panel_track_s>, + changedCells: UnsafePointer<UInt8>?, + 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<UInt8>?, + 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..<y1 { + for col in x0..<x1 { bytes[row * gridSize.cols + col] = 255 } + } + } + return bytes.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress, + let data = CFDataCreate(nil, base, count), + let provider = CGDataProvider(data: data), + let gray = CGColorSpace(name: CGColorSpace.linearGray), + let image = CGImage( + width: gridSize.cols, height: gridSize.rows, + bitsPerComponent: 8, bitsPerPixel: 8, + bytesPerRow: gridSize.cols, space: gray, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), + provider: provider, decode: nil, + shouldInterpolate: false, intent: .defaultIntent) + else { return nil } + return CIImage(cgImage: image, options: [.colorSpace: gray]) + } + } + + /// Convert the core's top-left logical-cell box to Core Image's + /// bottom-left backing-pixel rectangle. + private func pixelBox(_ box: pardes_panel_box_s, cellSize: CGSize) -> 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) } +} |
