Skip to content

Commit 7e789a2

Browse files
committed
refactor: extract CompositorCore — shared pipelines, uniforms, cursor textures
MetalRenderer (preview) and ExportCompositor (export) each carried their own copy of the six render-pipeline builders with the identical blend descriptor, the fullscreen quad, the normalised-RGBA cursor-texture loader, and private mirrors of every GPU uniform struct — with "must match" comments as the only thing keeping preview and export from drifting apart. CompositorCore now owns one pipeline factory (parameterised by pixel format + label prefix), one quad builder, one CursorTextureStore (asset → arrow → procedural fallback for both paths — export previously had no procedural fallback), and the single definition of each uniform struct. The two front-ends keep only what genuinely differs: drawable vs CVPixelBuffer targets and the preview's aspect-fit. Net: ~330 lines of drift-prone duplication deleted; blend-state or uniform-layout changes now apply to both paths by construction.
1 parent 9f20707 commit 7e789a2

3 files changed

Lines changed: 275 additions & 466 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import Metal
2+
import MetalKit
3+
import AppKit
4+
5+
// MARK: - Shared GPU uniforms
6+
//
7+
// One definition for both compositor front-ends. Field order + sizes must
8+
// match the structs in Shaders.metal; previously each front-end carried its
9+
// own private mirror with "must match" comments as the only enforcement —
10+
// three copies that could silently drift.
11+
12+
struct AspectUniforms { var scale: SIMD2<Float> }
13+
14+
struct ZoomUniforms {
15+
var centerUV: SIMD2<Float>
16+
var scale: Float
17+
var _pad: Float = 0
18+
}
19+
20+
struct CanvasUniforms {
21+
var contentScale: SIMD2<Float>
22+
}
23+
24+
/// All SIMD2<Float> are 8-byte aligned, so the two trailing floats pack into
25+
/// a single 8-byte slot — no explicit padding needed.
26+
struct CursorUniforms {
27+
var cursorPos: SIMD2<Float>
28+
var videoSize: SIMD2<Float>
29+
var aspectScale: SIMD2<Float>
30+
var hotspot: SIMD2<Float>
31+
var motionBlur: SIMD2<Float>
32+
var size: Float
33+
var opacity: Float
34+
}
35+
36+
struct ClickUniforms {
37+
var centerInVideoPixels: SIMD2<Float>
38+
var radiusInPixels: Float
39+
var thicknessInPixels: Float
40+
var videoSize: SIMD2<Float>
41+
var aspectScale: SIMD2<Float>
42+
var color: SIMD4<Float>
43+
}
44+
45+
// MARK: - Shared pipeline/quad factory
46+
47+
/// Shared GPU plumbing for `MetalRenderer` (live MTKView preview) and
48+
/// `ExportCompositor` (headless CVPixelBuffer render). One pipeline factory,
49+
/// one quad, and one cursor-texture loader mean the two paths cannot drift in
50+
/// blend state or shader wiring — the strongest guarantee that what the user
51+
/// previews is what the export produces. This setup used to be duplicated
52+
/// ~230 lines deep in each file.
53+
enum CompositorCore {
54+
struct Pipelines {
55+
let background: MTLRenderPipelineState
56+
let shadow: MTLRenderPipelineState
57+
let video: MTLRenderPipelineState
58+
let cursor: MTLRenderPipelineState
59+
let click: MTLRenderPipelineState
60+
let webcam: MTLRenderPipelineState
61+
}
62+
63+
/// Builds the six render pipelines against `pixelFormat`. Background is
64+
/// opaque (it overdraws the clear); everything else uses the shared
65+
/// source-alpha-over blend so masks and sprites composite identically in
66+
/// both paths.
67+
static func makePipelines(
68+
device: MTLDevice,
69+
library: MTLLibrary,
70+
pixelFormat: MTLPixelFormat,
71+
labelPrefix: String
72+
) -> Pipelines? {
73+
func make(
74+
_ vertex: String, _ fragment: String,
75+
blended: Bool, label: String
76+
) -> MTLRenderPipelineState? {
77+
guard let vertexFn = library.makeFunction(name: vertex),
78+
let fragmentFn = library.makeFunction(name: fragment) else { return nil }
79+
let desc = MTLRenderPipelineDescriptor()
80+
desc.vertexFunction = vertexFn
81+
desc.fragmentFunction = fragmentFn
82+
desc.colorAttachments[0].pixelFormat = pixelFormat
83+
if blended {
84+
desc.colorAttachments[0].isBlendingEnabled = true
85+
desc.colorAttachments[0].rgbBlendOperation = .add
86+
desc.colorAttachments[0].alphaBlendOperation = .add
87+
desc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
88+
desc.colorAttachments[0].sourceAlphaBlendFactor = .one
89+
desc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
90+
desc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
91+
}
92+
desc.label = "\(labelPrefix).\(label)"
93+
return try? device.makeRenderPipelineState(descriptor: desc)
94+
}
95+
96+
guard
97+
let background = make("background_vertex", "background_fragment", blended: false, label: "background"),
98+
let shadow = make("shadow_vertex", "shadow_fragment", blended: true, label: "shadow"),
99+
let video = make("video_vertex", "video_fragment", blended: true, label: "video"),
100+
let cursor = make("cursor_vertex", "cursor_fragment", blended: true, label: "cursor"),
101+
let click = make("click_vertex", "click_fragment", blended: true, label: "click"),
102+
let webcam = make("webcam_vertex", "webcam_fragment", blended: true, label: "webcam")
103+
else { return nil }
104+
105+
return Pipelines(
106+
background: background, shadow: shadow, video: video,
107+
cursor: cursor, click: click, webcam: webcam
108+
)
109+
}
110+
111+
/// Triangle-strip quad covering [-1,1]² with top-left-origin UVs.
112+
static func makeQuadBuffer(device: MTLDevice) -> MTLBuffer? {
113+
let quad: [SIMD4<Float>] = [
114+
SIMD4(-1, -1, 0, 1),
115+
SIMD4( 1, -1, 1, 1),
116+
SIMD4(-1, 1, 0, 0),
117+
SIMD4( 1, 1, 1, 0),
118+
]
119+
return device.makeBuffer(
120+
bytes: quad,
121+
length: MemoryLayout<SIMD4<Float>>.stride * quad.count,
122+
options: .storageModeShared
123+
)
124+
}
125+
}
126+
127+
// MARK: - Cursor sprite textures
128+
129+
/// Loads and caches cursor sprite textures with the normalised-RGBA pipeline
130+
/// both compositors require: ALWAYS re-render the asset through a fresh sRGB
131+
/// CGContext with explicit premultiplied-LAST alpha (RGBA byte order). The
132+
/// TIFF-derived CGImage otherwise comes back premultipliedFirst (ARGB), which
133+
/// MTKTextureLoader reads at face value — the cursor rendered yellow because
134+
/// what the shader thought was R/G/B/A was actually A/R/G/B.
135+
final class CursorTextureStore {
136+
private let textureLoader: MTKTextureLoader
137+
private var cache: [CursorShape: MTLTexture] = [:]
138+
139+
init(device: MTLDevice) {
140+
self.textureLoader = MTKTextureLoader(device: device)
141+
}
142+
143+
/// Named asset → arrow fallback → procedural fallback. Never returns nil
144+
/// unless even the procedural bitmap fails.
145+
func texture(for shape: CursorShape) -> MTLTexture? {
146+
if let cached = cache[shape] { return cached }
147+
let candidates = [shape.rawValue, "arrow"]
148+
for name in candidates {
149+
if let texture = loadAssetTexture(named: name) {
150+
cache[shape] = texture
151+
return texture
152+
}
153+
}
154+
if let texture = makeProceduralCursorTexture() {
155+
Log.editor.warning("Using procedural cursor — no asset texture loaded")
156+
cache[shape] = texture
157+
return texture
158+
}
159+
return nil
160+
}
161+
162+
private func loadAssetTexture(named name: String) -> MTLTexture? {
163+
guard let image = NSImage(named: name) else {
164+
Log.editor.warning("NSImage(named:) returned nil for '\(name)' — asset catalog miss")
165+
return nil
166+
}
167+
let w = max(1, Int(image.size.width))
168+
let h = max(1, Int(image.size.height))
169+
guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
170+
let ctx = CGContext(
171+
data: nil, width: w, height: h, bitsPerComponent: 8,
172+
bytesPerRow: 0, space: cs,
173+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
174+
| CGBitmapInfo.byteOrder32Big.rawValue
175+
) else {
176+
return nil
177+
}
178+
// CGContext's pixel storage is bottom-up; with flipped:false the
179+
// NSImage draws in CG's native coords, and `.origin: .bottomLeft`
180+
// tells the loader to flip on load so the cursor lands right-side-up.
181+
let nsCtx = NSGraphicsContext(cgContext: ctx, flipped: false)
182+
NSGraphicsContext.saveGraphicsState()
183+
NSGraphicsContext.current = nsCtx
184+
image.draw(in: NSRect(x: 0, y: 0, width: w, height: h),
185+
from: .zero, operation: .copy, fraction: 1.0)
186+
NSGraphicsContext.restoreGraphicsState()
187+
guard let cg = ctx.makeImage() else { return nil }
188+
189+
let opts: [MTKTextureLoader.Option: Any] = [
190+
.SRGB: false,
191+
.origin: MTKTextureLoader.Origin.bottomLeft,
192+
.generateMipmaps: false
193+
]
194+
return try? textureLoader.newTexture(cgImage: cg, options: opts)
195+
}
196+
197+
/// Chunky magenta arrow drawn into a CG bitmap so there is ALWAYS a
198+
/// cursor texture, even if every asset-catalog path fails.
199+
private func makeProceduralCursorTexture() -> MTLTexture? {
200+
let size = 128
201+
guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
202+
let ctx = CGContext(
203+
data: nil, width: size, height: size, bitsPerComponent: 8,
204+
bytesPerRow: 0, space: cs,
205+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
206+
) else {
207+
return nil
208+
}
209+
ctx.clear(CGRect(x: 0, y: 0, width: size, height: size))
210+
ctx.translateBy(x: 0, y: CGFloat(size))
211+
ctx.scaleBy(x: 1, y: -1)
212+
let path = CGMutablePath()
213+
path.move(to: CGPoint(x: 20, y: 14))
214+
path.addLine(to: CGPoint(x: 20, y: 102))
215+
path.addLine(to: CGPoint(x: 50, y: 76))
216+
path.addLine(to: CGPoint(x: 66, y: 110))
217+
path.addLine(to: CGPoint(x: 80, y: 102))
218+
path.addLine(to: CGPoint(x: 64, y: 70))
219+
path.addLine(to: CGPoint(x: 96, y: 70))
220+
path.closeSubpath()
221+
ctx.setFillColor(red: 1, green: 0, blue: 1, alpha: 1)
222+
ctx.addPath(path)
223+
ctx.fillPath()
224+
ctx.setLineWidth(4)
225+
ctx.setStrokeColor(red: 1, green: 1, blue: 1, alpha: 1)
226+
ctx.addPath(path)
227+
ctx.strokePath()
228+
guard let cg = ctx.makeImage() else { return nil }
229+
let opts: [MTKTextureLoader.Option: Any] = [
230+
.SRGB: false,
231+
.origin: MTKTextureLoader.Origin.topLeft,
232+
.generateMipmaps: false
233+
]
234+
return try? textureLoader.newTexture(cgImage: cg, options: opts)
235+
}
236+
}

0 commit comments

Comments
 (0)