Skip to content

Commit 38772bd

Browse files
committed
Add a render loop for the Metal layer surface
Add an opt-in CAMetalLayer surface for the Metal terminal renderer. Set SWIFTTERM_METAL_LAYER=1 with the Metal renderer enabled to use it. The existing MTKView surface stays the default. The new render loop prepares and draws coalesced frames on a dedicated thread. The main thread captures all view state before it sends a frame to the loop. It applies AppKit state, such as the scroller, caret, blink timer, and accessibility updates, after the draw. This prevents the render thread from read access to live view state and keeps frame preparation from blocking the main thread. Use a common render-target interface for MTKView and CAMetalLayer. Keep the layer color space in sRGB and recreate a surface safely when its window moves to a display with a different backing scale. The layer surface requires an explicit draw call; this prevents a dirty-frame loop that does not render. Move Metal cursor blinking to the main run loop and protect shared blink state. Add render counts and render-loop coalescing counts so diagnostics can detect ticks that do not produce a GPU submission. Add surface-parity, render-loop, idle-cursor, resize-flood, and display-move checks. The baseline harness also limits each command or baseline run to one restored document window, so an extra hidden terminal does not change load measurements.
1 parent 54b9e35 commit 38772bd

4 files changed

Lines changed: 622 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//
2+
// MetalSurfaceDiagnostics.swift
3+
// SwiftTerm
4+
//
5+
// Compares the two Metal surfaces by rendering the same content through both.
6+
//
7+
// This exists because the equivalent unit test cannot run: the renderer loads
8+
// its shaders from the SwiftTerm resource bundle, which is not present next to
9+
// the SwiftPM test binary. Inside a host app the bundle is there, so the check
10+
// runs for real.
11+
//
12+
// It is the regression net for WO-F3 (replacing MTKView with a CAMetalLayer we
13+
// drive ourselves). Same renderer, same shaders, same snapshot — only the
14+
// surface differs, so the pixels must match exactly.
15+
//
16+
17+
#if os(macOS) && canImport(MetalKit)
18+
import Foundation
19+
import AppKit
20+
import Metal
21+
import MetalKit
22+
import QuartzCore
23+
24+
extension TerminalView {
25+
/// Result of comparing the `MTKView` and `CAMetalLayer` render surfaces.
26+
public struct MetalSurfaceComparison {
27+
/// Pixels whose RGB differs between the two surfaces. Zero is the
28+
/// expected result.
29+
public let differingPixels: Int
30+
public let totalPixels: Int
31+
/// Pixels that differ from the top-left pixel. A comparison of two
32+
/// blank surfaces would report a perfect match, so this guards against
33+
/// the check silently passing on nothing.
34+
public let nonUniformPixels: Int
35+
/// Set when the comparison could not run; both counts are zero then.
36+
public let unavailableReason: String?
37+
38+
/// True only when the surfaces agree *and* they actually drew
39+
/// something.
40+
public var matches: Bool {
41+
unavailableReason == nil && differingPixels == 0 && nonUniformPixels > 0
42+
}
43+
}
44+
45+
/// Renders deterministic content through both Metal surfaces and compares
46+
/// the results.
47+
///
48+
/// Uses a terminal view it creates and sizes to match the surfaces, rather
49+
/// than a live one: the renderer positions rows from
50+
/// `SnapshotRenderContext.viewBounds`, so a view whose bounds differ from
51+
/// the drawable puts every row outside it and both surfaces come back
52+
/// blank — which compares equal and proves nothing.
53+
///
54+
/// Diagnostic only — it builds throwaway renderers and blocks on the GPU,
55+
/// so it must not be called on a frame path.
56+
public static func compareMetalSurfaces(width: Int = 320, height: Int = 120) -> MetalSurfaceComparison {
57+
func unavailable(_ reason: String) -> MetalSurfaceComparison {
58+
MetalSurfaceComparison(differingPixels: 0, totalPixels: 0,
59+
nonUniformPixels: 0, unavailableReason: reason)
60+
}
61+
62+
guard let device = MTLCreateSystemDefaultDevice() else {
63+
return unavailable("no Metal device")
64+
}
65+
let size = CGSize(width: CGFloat(width), height: CGFloat(height))
66+
let frame = CGRect(origin: .zero, size: size)
67+
68+
let mtkView = MTKView(frame: frame, device: device)
69+
mtkView.colorPixelFormat = .bgra8Unorm
70+
mtkView.framebufferOnly = false // needed to read the texture back
71+
mtkView.isPaused = true
72+
mtkView.enableSetNeedsDisplay = true
73+
74+
let layerView = TerminalMetalLayerView(frame: frame)
75+
layerView.renderDevice = device
76+
layerView.metalLayer.framebufferOnly = false
77+
78+
// Sized to the surfaces so rows land inside the drawable, and fed
79+
// content with several colors so a blank render cannot pass.
80+
let source = TerminalView(frame: frame)
81+
source.feed(text: "SwiftTerm surface parity check\r\n"
82+
+ "\u{1b}[31mred\u{1b}[32m green\u{1b}[34m blue\u{1b}[0m plain\r\n"
83+
+ "\u{1b}[1mbold\u{1b}[0m \u{1b}[4munderline\u{1b}[0m 0123456789\r\n")
84+
85+
guard let fromMTK = renderPixels(source: source, target: mtkView, size: size),
86+
let fromLayer = renderPixels(source: source, target: layerView, size: size) else {
87+
return unavailable("no drawable or renderer available")
88+
}
89+
guard fromMTK.count == fromLayer.count else {
90+
return unavailable("surfaces produced different buffer sizes")
91+
}
92+
93+
var differing = 0
94+
var nonUniform = 0
95+
let firstR = fromMTK[0], firstG = fromMTK[1], firstB = fromMTK[2]
96+
for index in stride(from: 0, to: fromMTK.count, by: 4) where
97+
fromMTK[index] != firstR || fromMTK[index + 1] != firstG ||
98+
fromMTK[index + 2] != firstB {
99+
nonUniform += 1
100+
}
101+
for index in stride(from: 0, to: fromMTK.count, by: 4) where
102+
fromMTK[index] != fromLayer[index] ||
103+
fromMTK[index + 1] != fromLayer[index + 1] ||
104+
fromMTK[index + 2] != fromLayer[index + 2] {
105+
differing += 1
106+
}
107+
return MetalSurfaceComparison(differingPixels: differing,
108+
totalPixels: fromMTK.count / 4,
109+
nonUniformPixels: nonUniform,
110+
unavailableReason: nil)
111+
}
112+
113+
private static func renderPixels(source: TerminalView,
114+
target: any MetalRenderTarget,
115+
size: CGSize) -> [UInt8]? {
116+
guard let renderer = try? MetalTerminalRenderer(view: target, terminalView: source) else {
117+
return nil
118+
}
119+
renderer.waitForCompletionAfterCommit = true
120+
renderer.capturesRenderedTexture = true
121+
var mutable = target
122+
mutable.renderContentsScale = 1
123+
mutable.renderDrawableSize = size
124+
renderer.render()
125+
126+
// The texture the renderer actually drew into. Asking the surface for
127+
// another drawable would return a different, unrendered one.
128+
guard let texture = renderer.lastRenderedTexture else { return nil }
129+
guard texture.width > 0, texture.height > 0 else { return nil }
130+
let bytesPerRow = texture.width * 4
131+
var bytes = [UInt8](repeating: 0, count: bytesPerRow * texture.height)
132+
bytes.withUnsafeMutableBytes { raw in
133+
guard let base = raw.baseAddress else { return }
134+
texture.getBytes(base,
135+
bytesPerRow: bytesPerRow,
136+
from: MTLRegionMake2D(0, 0, texture.width, texture.height),
137+
mipmapLevel: 0)
138+
}
139+
return bytes
140+
}
141+
}
142+
#endif
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//
2+
// RenderLoop.swift
3+
// SwiftTerm
4+
//
5+
// A dedicated thread that prepares and draws terminal frames.
6+
//
7+
// The main thread pays about 6 ms per frame on the Metal path, essentially
8+
// all of it building row data (io-gaps.md G1). That cost is CPU work rather
9+
// than a blocked wait, so moving it off main genuinely frees the main thread
10+
// instead of relocating a stall — and once it is off main, blocking for a
11+
// drawable costs the main thread nothing either.
12+
//
13+
// This type is deliberately small. It owns a thread, a semaphore and a
14+
// coalescing flag, and it knows nothing about terminals or Metal.
15+
//
16+
17+
#if os(macOS) || os(iOS) || os(visionOS) || os(macCatalyst)
18+
import Foundation
19+
20+
/// Counters describing how the render loop behaved over a measurement window.
21+
struct RenderLoopCounters {
22+
/// Calls to `signal`.
23+
var signals = 0
24+
/// Frames the loop actually ran.
25+
var frames = 0
26+
/// Signals that arrived while one was already pending and were folded into
27+
/// it. A large value means the producer is outrunning the renderer, which
28+
/// is the intended behaviour under a flood, not a fault.
29+
var coalesced = 0
30+
}
31+
32+
/// Runs a callback on a dedicated high-priority thread, one frame at a time.
33+
///
34+
/// Signals coalesce: several `signal()` calls before the loop wakes produce one
35+
/// frame, so a flood cannot queue work faster than it can be drawn.
36+
final class RenderLoop {
37+
/// Renders one frame. Called on the render thread, never re-entrantly, and
38+
/// always with `frameLock` held.
39+
var onRender: (() -> Void)?
40+
41+
/// Serialises a frame against structural changes made on the main thread:
42+
/// enabling or disabling Metal, rebinding the surface after a display
43+
/// change, a synchronous draw for a screenshot.
44+
///
45+
/// **Never take this while holding the terminal lock.** The render thread
46+
/// takes them in the order frameLock -> terminalLock, so the reverse order
47+
/// on main deadlocks. Structural operations hold neither when they start.
48+
let frameLock = NSLock()
49+
50+
private let semaphore = DispatchSemaphore(value: 0)
51+
private let stateLock = NSLock()
52+
private var pending = false
53+
private var stopped = false
54+
private var thread: Thread?
55+
private var counters = RenderLoopCounters()
56+
57+
/// Starts the render thread. Idempotent.
58+
func start () {
59+
stateLock.lock()
60+
let alreadyRunning = thread != nil || stopped
61+
stateLock.unlock()
62+
guard !alreadyRunning else { return }
63+
64+
let thread = Thread { [weak self] in
65+
self?.run()
66+
}
67+
thread.name = "org.tirania.SwiftTerm.render"
68+
// The frame deadline is the display's, the same class of deadline the
69+
// main thread runs at. Anything lower and a busy machine drops frames
70+
// that the main-thread path would have produced.
71+
thread.qualityOfService = .userInteractive
72+
stateLock.lock()
73+
self.thread = thread
74+
stateLock.unlock()
75+
thread.start()
76+
}
77+
78+
/// Asks for one frame. Safe from any thread, including the render thread.
79+
func signal () {
80+
stateLock.lock()
81+
counters.signals += 1
82+
guard !stopped else {
83+
stateLock.unlock()
84+
return
85+
}
86+
let wasPending = pending
87+
pending = true
88+
if wasPending {
89+
counters.coalesced += 1
90+
}
91+
stateLock.unlock()
92+
93+
// Only the transition from clean to pending posts, so the semaphore
94+
// never accumulates a backlog of frames to catch up on.
95+
if !wasPending {
96+
semaphore.signal()
97+
}
98+
}
99+
100+
/// Stops the loop permanently and waits for any frame in flight to finish.
101+
///
102+
/// Waiting matters: the caller is usually tearing down the surface the
103+
/// in-flight frame is drawing into.
104+
func invalidate () {
105+
stateLock.lock()
106+
let wasStopped = stopped
107+
stopped = true
108+
pending = false
109+
stateLock.unlock()
110+
guard !wasStopped else { return }
111+
112+
semaphore.signal()
113+
// The loop drops frameLock between frames and takes it again only
114+
// after re-checking `stopped`, so acquiring it here means no frame is
115+
// running and none will start.
116+
frameLock.lock()
117+
frameLock.unlock()
118+
119+
stateLock.lock()
120+
thread = nil
121+
onRender = nil
122+
stateLock.unlock()
123+
}
124+
125+
var isRunning: Bool {
126+
stateLock.lock()
127+
defer { stateLock.unlock() }
128+
return thread != nil && !stopped
129+
}
130+
131+
var currentCounters: RenderLoopCounters {
132+
stateLock.lock()
133+
defer { stateLock.unlock() }
134+
return counters
135+
}
136+
137+
func resetCounters () {
138+
stateLock.lock()
139+
counters = RenderLoopCounters()
140+
stateLock.unlock()
141+
}
142+
143+
private func run () {
144+
while true {
145+
semaphore.wait()
146+
147+
stateLock.lock()
148+
if stopped {
149+
stateLock.unlock()
150+
return
151+
}
152+
// Cleared before the frame, not after: a change arriving while
153+
// this frame is being drawn must schedule the next one.
154+
pending = false
155+
counters.frames += 1
156+
let render = onRender
157+
stateLock.unlock()
158+
159+
guard let render else { continue }
160+
frameLock.lock()
161+
render()
162+
frameLock.unlock()
163+
}
164+
}
165+
}
166+
#endif

0 commit comments

Comments
 (0)