渲染
Display Graph 的設計,以及從檔案讀取到畫面顯示的完整流程;以程式碼為準。總覽見 architecture.md。
概觀
Display Graph 每 vsync 最多執行一次,一進一出、不得阻塞、不依賴未來幀;由 FlowGraph 編譯成 DisplayPlan,由 FlowMetal4 執行。只含 DisplayRuntime 提供的內建步驟,融合為 1 個 compute pass(ADR 16、17)。解碼器輸出直接進 FrameQueue。
flowchart LR
Dec["VideoDecoder"] -->|"VideoFrame<br/>(IOSurface CVPixelBuffer)"| Sink["VideoFrameSink"]
Sink --> FQ["FrameQueue"]
FQ --> RL["RenderLoop<br/>(Render thread)"]
RL -->|"render(frame, at:)"| DP["DisplayPipeline<br/>(Display Graph)"]
DP --> Drawable["CAMetalDrawable"]執行緒與元件
| 執行緒 | 元件 | 工作 |
|---|---|---|
| Main | PlayerSession、DisplayOutput、DisplayBinding、MetalVideoView、MetalLayerPresenter(layer 設定) | 控制平面:建立 pipeline、編譯 Display Graph、更新 DisplayTarget |
Flow.Demux | DemuxWorker + FFmpegDemuxer | 讀 packet 進 PacketQueue |
Flow.VideoDecode | VideoDecodeWorker + VTVideoDecoder | 送 packet 給 VideoToolbox;EOF drain 時由 FrameReorder.finish 推入剩餘幀 |
| VideoToolbox callback | FrameReorder → VideoFrameSink | 依 PTS 排序後推進 FrameQueue |
Flow.Render | CAMetalDisplayLink → PresenterBridge → RenderLoop → VideoDisplayPipeline → FrameSubmitter | 每個 vsync 選幀、編碼、提交 |
| GPU/Metal feedback | Display.metal(compute kernel flow_display)、FrameSubmitter 的 commit feedback、PresentationLog | 執行 pass;commit feedback 回報 GPU 時間(Metal4Statistics,每個 connection 一份),PresentationLog 回報實際顯示時間 |
DisplayOutput 擁有 Render 執行緒、presenter、與它配對的 runtime 及 DisplayConnection、PresenterBridge,與 PlayerSession 同壽命;DisplayBinding 每次載入建立,擁有該媒體的 RenderLoop 與 DisplayPlan,負責編譯與交出 FrameRateRange。
Render 執行緒為軟即時(見執行緒分級):
CAMetalDisplayLink加入 Render 執行緒的 run loop,callback 才在 Render 執行緒執行。- drawable 取自
CAMetalDisplayLink.Update.drawable;不呼叫nextDrawable,它會阻塞到有空的 drawable(最長 1 秒)。 - drawable 數量由
CAMetalDisplayLink決定,不受maximumDrawableCount與preferredFrameLatency影響:60 Hz 以下 4 個、120 Hz 5 個(M4 Max,macOS 27.2)。 - 不等 GPU 完成:在 GPU 上的幀以 slot 限制,滿了即 skip(見
VideoDisplayPipeline.render)。
Display Graph
Runtime 介面
核心只定義 runtime 介面,不依賴任何 GPU API(ADR 12):
// FlowModuleAPI
/// 只提供身分
public protocol NodeRuntime: AnyObject, Sendable {
var id: RuntimeID { get }
}
/// 能執行 Display Graph 並提供內建步驟
public protocol DisplayRuntime: NodeRuntime {
/// 控制平面;能否畫進 presenter
func accepts(_ presenter: any Presenter) -> Bool
/// 控制平面,每次配對一次;結果持有畫進 presenter 所需的資源
func connect(_ presenter: any Presenter) throws -> any DisplayConnection
}
/// runtime 與一個 presenter 的配對;核心在其所有 pipeline 之後、於控制平面釋放
public protocol DisplayConnection: AnyObject {
/// 控制平面;plan 改變就重新呼叫,昂貴狀態(pipeline state)由 runtime 快取
func makePipeline(_ plan: DisplayPlan) throws -> any DisplayPipeline
/// 任何執行緒;此 connection 所有 pipeline 的提交、pass 數與 GPU 時間
var statistics: PlayerStatistics.Display { get }
}
public protocol DisplayPipeline: AnyObject {
/// Render 執行緒,每 vsync 最多一次;不得阻塞。幀與 plan 都沒變時核心不呼叫,畫面維持上一個 drawable
func render(_ frame: VideoFrame, at time: HostTime)
}FlowGraph(核心) | DisplayRuntime(模組) | |
|---|---|---|
| 拓撲 | 依固定順序排列內建步驟、切分 pass | — |
| 規格 | 解析色彩、套用幾何、計算 letterbox | — |
| 資源 | 規劃 intermediate、估算每幀讀寫量(DisplayPlan.cost) | 紋理快取、uniform ring、residency set(每個 connection 一份) |
| 執行 | — | 合併 pass、編碼與提交、pipeline cache、GPU 計時 |
presenter 與 runtime 由核心成對選擇(ADR 22):
- 配對:
DisplayOutput.attach依序以PresenterFactory建立 presenter,交給第一個accepts它的 runtimeconnect;connect失敗換下一個接受的 runtime,沒有 runtime 接受時換下一個 factory,全部失敗才丟allCandidatesFailed。載入時沿用這一對;runtime 已不在 registry(模組停用)時重新配對。 - 分工:presenter 只提供每個 vsync 的 destination(drawable)、套用
DisplayPlan.Output並回報顯示時間;畫進它的 GPU 資源由DisplayConnection持有。runtime 取用 presenter 的具體介面(如FlowMetal4的MetalDisplaySurface)屬模組內部。 - 統計:runtime 在 Render 執行緒與 GPU 完成的 callback 以原子計數器記錄,不上鎖、不配置;核心只在
PlayerAPI.statistics被讀取時於控制平面讀statistics(見統計)。 - 釋放:pipeline 可強參照 connection。核心在 Render 執行緒釋放 pipeline(換新,或
RenderLoop.detach),之後才由DisplayBinding、DisplayOutput在控制平面釋放 connection,所以等 GPU 只發生在控制平面。
內建步驟
flowchart LR
L[("luma plane")] --> Y2R
C[("chroma plane")] --> CU
CU["chromaUpsample<br/>bilinear · hardwareBilinear<br/>size = source"] --> Y2R["yuvToRGB<br/>matrix · range · bitDepth<br/>pointwise<br/>size = source"]
Y2R --> SC["scale<br/>bilinear · hardwareBilinear<br/>foldsIntoInputSampling<br/>size = output"]
SC --> T[("target")]- chroma 升取樣:bilinear;luma 不經此步。4:4:4 時省略。
VideoPixelFormat為雙平面 Y'CbCr:4:2:0(nv12、p010)、4:2:2(nv16、p210)、4:4:4(nv24、p410),10-bit 放在 16-bit 樣本的高位。 - YUV→RGB:依
ColorInfo的 matrix、range 與位元深度 dequantize 並轉成非線性 RGB。yuvToRGB的 matrix 只有 BT.601(smpte170m)、BT.709、BT.2020 NCL,由DisplayGraph近似:BT.470BG、FCC 為 BT.601;BT.2020 CL、ICtCp 為 BT.2020 NCL;SMPTE 240M、YCgCo、identity(RGB)、SMPTE 2085、chroma-derived 為 BT.709。 - 輸出:kernel 寫非線性 RGB,保留來源的色域與轉移函數,由系統 compositor 轉換(見輸出色彩)。
- 幾何:
scale依VideoFormat.geometry(編譯進DisplayPlan.geometry)與 Propertyvideo.rotate套用旋轉、鏡像與像素長寬比,並等比縮放置中於輸出(letterbox);之前的步驟皆處理未旋轉的儲存畫面(同 mpv)。算法見縮放。
輸出色彩
DisplayGraph 依來源色彩與 DisplayTarget.headroom 決定 DisplayPlan.Output 的 drawable 格式與 OutputColor;presenter 只套用,不讀來源。
| 來源 | headroom | drawable | dynamicRange | toneMapping |
|---|---|---|---|---|
| SDR 8-bit | — | bgra8Unorm | standard | none |
| SDR 10-bit | — | bgr10a2Unorm | standard | none |
| HDR(PQ/HLG) | 1(SDR 螢幕) | bgr10a2Unorm | standard | compositor |
| HDR(PQ/HLG) | > 1 | bgr10a2Unorm | high | compositor |
- 色域與轉移函數:沿用來源的 primaries 與 transfer,由 compositor 轉到螢幕色域,不多一個 pass。presenter 只標記下表的組合,其餘由
DisplayGraph近似(見下)。 - Tone mapping:只由一方做。
compositor時OutputColor.metadata帶來源的靜態 HDR metadata,compositor 依當前 headroom、參考模式與省電狀態對應;none時不帶。 - EDR:只在 HDR 來源且螢幕有 headroom 時開啟,因為 EDR 耗電。
headroom是螢幕可達的上限,不隨亮度變動;plan 只記dynamicRange,所以在 EDR 螢幕間移動時 plan 不變、不重建 pipeline。 - HDR metadata:
VideoFormat.hdr(mastering display、MaxCLL/MaxFALL)。FlowFFmpeg先讀容器(MP4mdcv/clli、Matroska),再讀 HEVC hvcC 內的 SEI;都沒有時用系統預設值。 - 套用:核心在新 plan 的 pipeline 建好後呼叫
Presenter.apply(plan.output);MetalLayerPresenter設CAMetalLayer的colorspace(如itur_2100_PQ)、pixelFormat、EDRMetadata(HDR10 或 HLG)與wantsExtendedDynamicRangeContent;OffscreenPresenter只套用 drawable 格式。
presenter 能標記的組合:
| primaries | transfer |
|---|---|
BT.709、BT.2020、Display P3(smpte432) | BT.709、sRGB、PQ、HLG |
SMPTE C(smpte170m)、BT.470BG | BT.709 |
其餘由 DisplayGraph 近似到最接近的組合,近似表以 golden 鎖定(Packages/Core/Tests/Kernel/FlowGraphTests/Golden/display-color.json):
- transfer:BT.601、BT.2020 與 BT.709 曲線相同;PQ、HLG、sRGB 以外一律為 BT.709。
- primaries:DCI-P3 為 Display P3;SMPTE 240M、BT.470M 為 SMPTE C;EBU 3213 為 BT.470BG;film、SMPTE 428 為 BT.709。
- 組合:SMPTE C、BT.470BG 搭 PQ/HLG 時 primaries 改為 BT.709,搭 sRGB 時 transfer 改為 BT.709。
編譯
DisplayBinding.makeDisplayPipeline → DisplayGraph.compile → DisplayConnection.makePipeline。只在來源格式、輸出尺寸、螢幕 EDR headroom 或 video.rotate 變化時重新編譯;DisplayPlan 與上次相同時不重建 pipeline。
flowchart TD
subgraph Input["DisplayGraphInput"]
VF["VideoFormat<br/>size / color / hdr / geometry"]
PF["VideoDecoder.outputFormat"]
Rot["video.rotate"]
Tgt["Presenter.target<br/>size / headroom"]
end
Input --> Compile["DisplayGraph.compile"]
Compile -->|"throw(如 emptyOutput)<br/>try? → nil"| Nil["不建 pipeline<br/>沿用既有的(載入時則尚無)"]
Compile --> Resolve["color.resolved<br/>DisplayColor(近似)<br/>geometry.rotated<br/>letterbox(displaySize, in: output)<br/>output(color, hdr, headroom)"]
Compile --> Steps["LogicalStep[]"]
Steps --> Split["PassSplitter.split"]
Split --> Plan["DisplayPlan<br/>source / output / geometry<br/>passes / intermediates / writesOutput(固定 false) / cost"]
Resolve --> Plan
Plan -->|"與上次相同則略過"| Runtime["MetalDisplayConnection.makePipeline"]
Runtime --> VDP["VideoDisplayPipeline.init"]
VDP --> Program["DisplayProgram<br/>(不支援的 plan 丟 unsupportedPlan)"]
VDP --> PSO["Metal4Context.displayPipeline(for:)<br/>快取的 compute PSO(與 drawable 格式無關)<br/>prewarm 未完成則同步編譯"]
VDP -->|"成功後"| Apply["Presenter.apply(plan.output)<br/>layer colorspace / pixelFormat / EDR"]
Apply -->|"renderThread: loop.setPipeline"| RL["RenderLoop"]DisplayProgram 只接受:1 個 pass、輸入為 luma+chroma、輸出為 target;chromaUpsample 限 bilinear,且取樣比例等於來源(4:4:4 時不得出現;其餘必須出現);yuvToRGB 的 bitDepth 須等於來源、matrix 一對一對應 DisplayMatrix;scale 限 bilinear;OutputColor 須是 LayerFormat 一對一對應的組合(見輸出色彩);runtime 不自行近似。其餘丟 unsupportedPlan,makePipeline 隨之失敗:載入時使 load 失敗,重新編譯時只記 log、保留舊 pipeline。
重新編譯觸發點:
flowchart LR
Resize["視窗大小 / scale / 螢幕(含 EDR headroom)"] --> TC["PresenterClient.displayTargetDidChange"]
TC -->|"PresenterBridge<br/>Task @MainActor"| Re["DisplayBinding.recompile"]
VR["observe(.videoRotate)"] --> Re
Load["load:pipeline = built 後同步呼叫<br/>(見建立期)"] --> Re
Re --> Rate{"FramePacing 依 target.refreshRateRange<br/>算出的 FrameRateRange 與上次不同"}
Rate -->|"是"| Pref["Flow.Render:setFrameRateRange"]
Re --> Make["makeDisplayPipeline"]
Make -->|"新 plan"| Set["setPipeline + requestRedraw"]
Make -->|"plan 相同或 compile 失敗"| Skip["不重建"]只有刷新率變化時 plan 相同,不重建 pipeline,FrameRateRange 有變才重設。視窗大小的完整路線見縮放。
Pass 切分
中間紋理的讀寫是主要成本(4K rgba16Float 每多一個 pass 約多讀寫 133 MB),故 PassSplitter 只在取樣前切開:
- 逐像素步驟接到當前 pass 的尾端,中間值留在暫存器。
- 遇到取樣步驟時,結束當前 pass 並寫出紋理,取樣步驟開新 pass。
- 硬體 bilinear 取樣(如 bilinear chroma 升取樣)視為逐像素,可放在 pass 開頭。
scale(硬體 bilinear,可折疊)不切開,直接以輸出座標取樣來源平面(同 libplacebo);先內插再做非線性步驟,屬可接受的近似。chroma 也是 bilinear 且沒有取樣步驟時,整條管線只有一個 pass。
flowchart TD
S["下一個 step"] --> K{"sampling"}
K -->|"pointwise"| J["加入當前 pass"]
K -->|"hardwareBilinear"| H{"pass 為空<br/>或 (可折疊 且 尚無 sampled)"}
H -->|"是"| J2["加入當前 pass<br/>samplesPassInput = true"]
H -->|"否"| Cut1["切 pass 後加入"]
K -->|"sampled<br/>(無內建步驟屬此類)"| Cut2["切 pass 後加入<br/>標記 hasSampled"]切出的 pass:第一個讀 luma、chroma;之後讀 texture(i-1);最後一個寫 target,其餘寫 rgba16Float intermediate。內建步驟全部併入同一 pass(4:4:4 沒有 chromaUpsample):
flowchart LR
subgraph P0["Pass 0 → target"]
direction LR
a["chromaUpsample"] --> b["yuvToRGB"] --> c["scale"]
end
luma[("luma")] --> P0
chroma[("chroma")] --> P0
P0 --> target[("drawable")]範例 plan:Packages/Core/Tests/Kernel/FlowGraphTests/Golden/nv12-down2-3x-r90.json,1920×1080 NV12、旋轉 90°,輸出 720×1280 bgra8Unorm。
flowchart LR
src["source<br/>1920×1080 nv12<br/>bt709 limited, chroma left"] --> p0["pass 0<br/>chromaUpsample → yuvToRGB → scale"]
p0 --> out["target 720×1280 bgra8Unorm<br/>destination (0,0,720,1280)"]
cost["cost<br/>passCount 1<br/>intermediateBytesPeak 0<br/>read 3,110,400 B<br/>write 3,686,400 B"]:::note
classDef note fill:#f6f6f6,color:#555DisplayRuntime
Metal4DisplayRuntime(FlowMetal4):
- 配對:接受
FlowMetal4的 presenter。connect建立MetalDisplayConnection,持有該 presenter 的FrameSubmitter(command queue、frame slot、residency set、uniform ring、SurfaceTextureCache),並把 presenter 的 destination residency set(CAMetalLayer.residencySet,或 offscreen 目標自建的)加入 queue;MetalLayerPresenter在此取得 device。 - 只用 compute:所有 pass 皆為 compute kernel,不用 render pass(ADR 17)。驅動依行程用過的 encoder 種類配置 GPU context(M4 Max:compute 160 MB、render 224 MB、兩者並用約 290 MB),單一種類最省且路徑單純。kernel 直接寫 drawable,所以
framebufferOnly = false;代價是 drawable 失去無損壓縮(IOSurface 由&BGA變為BGRA),WindowServer 合成時多讀頻寬。blit 不能寫framebufferOnly的 drawable,公開 API 也建不出 compute 可寫的壓縮 IOSurface,故接受此代價。 - 合併 pass:只含內建步驟的 pass 為單一 kernel
flow_display,以 function constant(matrix × range × bitDepth)特化為 12 組 pipeline;色度取樣只改變 uniforms,不另特化;可寫入任何 drawable 格式。 - 提交:一幀一個 command buffer(重複使用 command allocator + argument table),最多 3 幀 in-flight,以
MTLSharedEvent控制;slot 用滿時略過該幀,不阻塞。 - Pipeline cache:
Metal4Context.prewarm在模組啟用時於背景建立全部 12 組MTLComputePipelineState並快取於記憶體,換 plan 不編 shader;prewarm 未完成時,displayPipeline(for:)在控制平面同步編譯該組。 - 計時:每個 command buffer 以
MTL4CommitFeedback的 GPU 起訖時間量測,寫入 connection 的Metal4Statistics,經DisplayConnection.statistics回報。
建立期
sequenceDiagram
participant App as FlowMac (Main)
participant S as PlayerSession (Main)
participant D as DisplayOutput/DisplayBinding (Main)
participant P as MetalLayerPresenter
participant R as Flow.Render
participant B as MediaPipeline.build (背景)
App->>App: FlowApp.init:AppModel.activateModules → FlowPlayer(模組啟用,背景預熱 VideoToolbox、音訊)
App->>App: AppModel.init:FlowPlayer.makeVideoView → MetalVideoView (CAMetalLayer)
App->>S: attach(view)(視窗建立前,Task.immediate)
S->>D: DisplayOutput.attach
D->>R: RenderThread.start()
D->>P: MetalLayerPresenterFactory.make<br/>layer: bgra8、framebufferOnly = false、3 drawables、BT.709
D->>D: Metal4DisplayRuntime.accepts → connect<br/>MetalDisplayConnection:layer.device、FrameSubmitter
App->>App: VideoSurface.makeNSView 把 view 放進視窗
P->>P: view 進入視窗 → geometryDidChange → target.size
App->>S: load(LoadRequest(url))(Task.immediate)
S->>B: MediaSource → Demuxer → VTVideoDecoder ∥ AudioOutput、AudioDecoder<br/>(IOSurface + Metal-compatible CVPixelBuffer)
B->>B: clock.hold(0)、start():Demux/VideoDecode/AudioDecode threads
S->>D: DisplayOutput.bind → DisplayBinding:RenderLoop<br/>makeDisplayPipeline → DisplayGraph.compile → DisplayConnection.makePipeline
D->>P: makePipeline 成功後 Presenter.apply(plan.output):colorspace、drawable pixelFormat、EDR
D->>R: bridge.loop = loop、setPipeline、setFrameRateRange、activate → startVSync
S->>D: pipeline、binding 設定後 recompileDisplay()
S->>S: publish video.display-size<br/>= VideoFormat.geometry + video.rotate
S-->>App: observe video.display-size → window.fit(video:)
S-->>App: fileLoaded
S->>S: playback.paused 為 false 時 play()- attach 進行中開始的
load先等它完成,以免在沒有 presenter 時載入而略過影像;App 不需自己排隊,只須在 load 之前開始 attach(Task.immediate)。 target.size為 0(尚未 layout)時compile丟emptyOutput,try?轉成nil,先不建 pipeline,等第一次 layout 觸發重新編譯。Presenter.apply由DisplayBinding在makePipeline成功後呼叫,plan 相同時不重建也不再套用;在 main thread 設定 layer,不改target,所以不會再觸發重新編譯。DisplayOutput.bind期間的 target 變更(如 layout)由PresenterBridge排入的recompileDisplay處理時,binding尚未設定而略過;因此load設定binding後同步呼叫recompileDisplay()補上,plan 相同時不重建。- 視窗尺寸取自
video.display-size,不經DisplayPlan:target 尚未 layout、plan 為nil時也已知道影片尺寸。
每幀資料流
預設值見 EngineConfiguration:FrameQueue 16 格、videoFrameHighWater 5(低水位 2)、PacketQueue 每軌 1024 格、packetReadahead 64(低水位 32)、音訊高水位 0.5 s(低水位 0.25 s)。到高水位後等降到低水位才一次補滿(見執行緒模型)。
flowchart TD
subgraph Demux["Flow.Demux"]
D1["FFmpegDemuxer 讀 packet"] --> D2["PacketQueue (SPSC)<br/>補到 readahead (64)"]
end
subgraph Decode["Flow.VideoDecode"]
V1["pop packet<br/>(FrameQueue 為空,或補充中且 queued + inFlight < highWater (5);<br/>queued ≤ lowWater (2) 才開始補充)"] --> V2["VTDecompressionSessionDecodeFrame<br/>(非同步)"]
V3["EOF:drain → FrameReorder.finish<br/>剩餘幀在此執行緒 push"]
end
subgraph VT["VideoToolbox callback"]
C1["CVPixelBuffer (IOSurface)<br/>nv12 / p010"] --> C2["FrameReorder:依 PTS 排序"]
C2 --> C3["VideoFrameSink.push<br/>濾掉舊 generation;exact seek 留 candidate"]
C3 --> C4["FrameQueue.push (SPSC,16 格)"]
end
subgraph Render["Flow.Render(每個 vsync)"]
R1["CAMetalDisplayLink.Update<br/>取得 drawable"] --> R2["PresenterBridge → RenderLoop.vsync<br/>t = clock(presentationTime + latency)"]
R2 --> R3["FrameSelector.select<br/>pts ≤ t 的最新幀,margin = interval / 4"]
R3 -->|"幀或 plan 有變"| R4["VideoDisplayPipeline.render"]
R3 -->|"沒變"| R9["不畫,畫面維持"]
end
D2 --> V1
V2 --> C1
V3 --> C3
C4 --> R3
R3 -.->|"FrameQueue.pop/dropStale 後 count ≤ lowWater:喚醒 decode"| V1
C2 -.->|"framesInFlightDidDecrease(count ≤ lowWater)、舊 generation 丟棄:喚醒 decode"| V1
V1 -.->|"PacketQueue.pop 後 count ≤ packetLowWater (32):喚醒 demux"| D1
D2 -.->|"ring 由空轉非空:喚醒 decode"| V1
R4 --> G["GPU:Display.metal"]
G --> Comp["系統 compositor"]
Comp -->|"presentedHandler"| L["PresentationLog → PresenterBridge.frameDidPresent<br/>(只在 presentedTime > 0)<br/>PlaybackStatistics.presentationLatency 指數平均(1/8)"]
L -.->|"回饋到下一個 t"| R2VideoDisplayPipeline.render
每個 skip 都計入 Metal4Statistics.recordSkip:
surface.currentDestination():這個 vsync 的 drawable;沒有則 skip。submitter.acquireSlot():最多 3 幀在 GPU 上;滿了就 skip,不阻塞。submitter.planes(for:slot:):SurfaceTextureCache以 IOSurfaceID 查表,且同時檢查entry.surface === surface,直接把 IOSurface 的 plane 包成MTLTexture(8-bit:r8/rg8;10-bit:r16/rg16),zero-copy;新 texture 加入 residency set。Metal 4 不 retain 資源,slot 持有CVPixelBuffer到該幀 GPU 完成。回傳nil(非 IOSurface-backed、不支援的格式)或格式與program.pixelFormat不符時abandon(slot)後 skip。program.uniforms(...):寫入 uniform ring。內容為 drawable 中的 destination、反轉 rotation/mirror 的 luma 座標仿射,以及 chroma 尺寸比與 chroma location 偏移;除 destination 外皆為正規化座標。co-sited 時偏移0.5 − 0.5/取樣比例個 chroma texel,4:4:4 為 0;4:2:2 只取 chroma location 的水平部分。- 設定 argument table:uniforms、luma、chroma、drawable。
submitter.begin(slot)取得 command buffer;建不出 compute encoder 時abandon(slot)後 skip。- 編碼 compute pass:每個 drawable 像素一個 thread,destination 內取樣來源,外面寫黑色(即黑邊)。
surface.willCommit:PresentationLog.record記錄 drawable 與 frame 的對應,掛 presented handler。submitter.commit(slot, drawable:, passes:):commit residency →waitForDrawable→ commit →signalDrawable→signalEvent→present()。
Shader
融合後的 kernel(Modules/Metal4/Shaders/Display.metal):
flowchart LR
U["DisplayUniforms<br/>destination:輸出中的位置<br/>lumaX / lumaY:反轉 rotation、mirror<br/>chroma:尺寸比與 chroma location 偏移"] --> F["flow_display(kernel)<br/>算 luma / chroma 座標,destination 外寫黑色"]
F --> S1["bilinear 取樣 luma.r、chroma.rg<br/>= chromaUpsample + scale"]
S1 --> S2["dequantize(bitDepth, range)<br/>ycbcrToRGB(matrix)<br/>= yuvToRGB"]
S2 --> O["target.write(float4 RGB)"]
FC["function constants<br/>MATRIX / FULL_RANGE / BIT_DEPTH"] -.-> F閒置與停頓
- 每個 vsync 結尾,沒有待處理的 redraw 且畫面已無事可做(當前 generation 已上屏、或解碼已結束且
FrameQueue為空、或尚無 pipeline 但已選出當前 generation 的幀)時:暫停中即stopVSync;播放中只有 video drained(解碼已結束、佇列已空、最後一幀已顯示滿 duration)才停,即 EOF。 - vsync 停住(例如視窗被遮住)時,
RenderLoop的 fallback timer 改用catchUp依時鐘消耗到期的幀,避免 decoder 卡在highWater;暫停且已選出當前 generation 的幀、或 video drained 時停用 timer。
縮放
程式碼裡沒有任何縮放倍率。scale 步驟由兩件事實現:
DisplayUniforms.destination=program.destination(in: drawable):即plan.geometry.destination依 drawable/outputSize的比例縮放(兩者相同時就是 plan 的值);kernel 以像素中心換算 destination 內的 (u, v),範圍外寫黑色。- 硬體 bilinear 取樣:kernel 依 uniforms 算出每個像素對應的來源正規化座標,用
filter::linear取樣 luma/chroma。
因此「來源多大 → 輸出多大」完全由 destination 大小決定;除 destination 外,uniforms 不隨視窗改變。destination 外的區域塗黑(letterbox)。
destination 的算法(DisplayGraph.compile):
displaySize = geometry.rotated(by: video.rotate).displaySize(for: sourceSize) // geometry = VideoFormat.geometry
destination = letterbox(displaySize, in: outputSize) // 保持比例、置中;尺寸四捨五入、原點捨去
outputSize = Presenter.target.size // drawable 的實體像素視窗大小影響縮放的路線
sequenceDiagram
participant W as NSWindow
participant V as MetalVideoView (Main)
participant P as MetalLayerPresenter (Main)
participant B as PresenterBridge
participant S as DisplayBinding (Main)
participant R as RenderLoop (Flow.Render)
participant G as GPU
W->>V: 視窗縮放/fit(video:)/全螢幕<br/>VideoHostView autoresizing → setFrameSize、layout
Note over V: 也會因 backing scale、換螢幕觸發
V->>V: geometryDidChange<br/>pixels = convertToBacking(bounds).rounded
V->>P: viewGeometryDidChange(pixelSize, scale, screen)
P->>P: layer.contentsScale = scale<br/>layer.drawableSize = pixels ①
P->>P: update { target.size、refreshRateRange、headroom }(Mutex)
alt target 有變
P->>B: displayTargetDidChange()
B->>S: Task @MainActor { recompile() }
opt FramePacing 算出的 FrameRateRange 與上次不同
S->>R: perform { setFrameRateRange(range) }
end
S->>S: makeDisplayPipeline<br/>DisplayGraphInput.outputSize = target.size
S->>S: DisplayGraph.compile → letterbox → 新 destination
alt plan 與上次相同(例如只有刷新率變了)
S->>S: 不重建
else plan 不同
S->>S: MetalDisplayConnection.makePipeline<br/>新 DisplayProgram(outputSize, destination),PSO 取快取
S->>R: perform { setPipeline(new), requestRedraw() } ②
end
end
R->>R: 下一個 vsync(暫停中 requestRedraw 會重啟 vsync 並重畫當前幀)
R->>G: render:destination × (drawable / outputSize)有兩條路線,作用時間不同:
| ① 立即路線 | ② 正式路線 | |
|---|---|---|
| 觸發 | layer.drawableSize 在 main thread 同步更新 | target.size → displayTargetDidChange → recompileDisplay |
| 延遲 | 下一個 drawable 就是新尺寸 | 經一次 Task @MainActor 跳轉,再由 Render thread 換 pipeline |
| 效果 | 舊 plan 的 DisplayProgram.destination(in:) 把舊 destination 依 drawable/outputSize 比例拉伸到新 drawable | 依新 outputSize 重算 letterbox,destination 回到正確比例 |
| 暫停時 | 不生效:vsync 已停、幀與 plan 未變,不會 render,要等 ② | requestRedraw 重啟 vsync,以新 plan 重畫當前幀 |
兩者之間若長寬比改變,可能有一兩幀暫時變形。暫停時在 ② 之前沒有新 drawable,畫面由 Core Animation 依 contentsGravity(未設定,預設 resize)處理。一般視窗下 PlayerWindowController.fit(video:) 設了 contentAspectRatio,鎖住比例,所以這段變形與 letterbox 通常都不明顯;但載入時 plan 為 nil 則 fit 不縮放視窗也不鎖比例(見建立期)。
範例
1920×1080 影片:
| 情境 | drawable(target.size) | destination | 縮放 |
|---|---|---|---|
| 1280×720 pt 視窗,Retina 2x | 2560×1440 | (0, 0, 2560, 1440) | 放大 1.33x |
| 3024×1964 全螢幕 | 3024×1964 | (0, 131, 3024, 1701) | 放大 1.575x,黑邊上 131、下 132 px |
同上再 video.rotate = 90 | 3024×1964 | (959, 0, 1105, 1964) | displaySize 變 1080×1920,放大 1.02x,黑邊左 959、右 960 px |
不會影響縮放的變化
- 只有刷新率改變(換螢幕):
DisplayGraphInput不含刷新率,plan 相同,不重建 pipeline;refreshRateRange變化只重新計算FrameRateRange,有變才在 Flow.Render 重設。 video.rotate:不經過視窗,直接recompileDisplay;fit(video:)只在fileLoaded呼叫,因此旋轉後視窗比例不會跟著改。