summaryrefslogtreecommitdiff
path: root/src/macos/Sources/ScenePostprocessor.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources/ScenePostprocessor.swift')
-rw-r--r--src/macos/Sources/ScenePostprocessor.swift361
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) }
+}