跳到主要內容
Flow

渲染

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"]

執行緒與元件

執行緒元件工作
MainPlayerSession、DisplayOutput、DisplayBinding、MetalVideoView、MetalLayerPresenter(layer 設定)控制平面:建立 pipeline、編譯 Display Graph、更新 DisplayTarget
Flow.DemuxDemuxWorker + FFmpegDemuxer讀 packet 進 PacketQueue
Flow.VideoDecodeVideoDecodeWorker + VTVideoDecoder送 packet 給 VideoToolbox;EOF drain 時由 FrameReorder.finish 推入剩餘幀
VideoToolbox callbackFrameReorder → VideoFrameSink依 PTS 排序後推進 FrameQueue
Flow.RenderCAMetalDisplayLink → PresenterBridge → RenderLoop → VideoDisplayPipeline → FrameSubmitter每個 vsync 選幀、編碼、提交
GPU/Metal feedbackDisplay.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 它的 runtime connect;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)與 Property video.rotate 套用旋轉、鏡像與像素長寬比,並等比縮放置中於輸出(letterbox);之前的步驟皆處理未旋轉的儲存畫面(同 mpv)。算法見縮放。

輸出色彩

DisplayGraph 依來源色彩與 DisplayTarget.headroom 決定 DisplayPlan.Output 的 drawable 格式與 OutputColor;presenter 只套用,不讀來源。

來源headroomdrawabledynamicRangetoneMapping
SDR 8-bit—bgra8Unormstandardnone
SDR 10-bit—bgr10a2Unormstandardnone
HDR(PQ/HLG)1(SDR 螢幕)bgr10a2Unormstandardcompositor
HDR(PQ/HLG)> 1bgr10a2Unormhighcompositor
  • 色域與轉移函數:沿用來源的 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 先讀容器(MP4 mdcv/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 能標記的組合:

primariestransfer
BT.709、BT.2020、Display P3(smpte432)BT.709、sRGB、PQ、HLG
SMPTE C(smpte170m)、BT.470BGBT.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:#555

DisplayRuntime

Metal4DisplayRuntime(FlowMetal4):

  1. 配對:接受 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。
  2. 只用 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,故接受此代價。
  3. 合併 pass:只含內建步驟的 pass 為單一 kernel flow_display,以 function constant(matrix × range × bitDepth)特化為 12 組 pipeline;色度取樣只改變 uniforms,不另特化;可寫入任何 drawable 格式。
  4. 提交:一幀一個 command buffer(重複使用 command allocator + argument table),最多 3 幀 in-flight,以 MTLSharedEvent 控制;slot 用滿時略過該幀,不阻塞。
  5. Pipeline cache:Metal4Context.prewarm 在模組啟用時於背景建立全部 12 組 MTLComputePipelineState 並快取於記憶體,換 plan 不編 shader;prewarm 未完成時,displayPipeline(for:) 在控制平面同步編譯該組。
  6. 計時:每個 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"| R2

VideoDisplayPipeline.render

每個 skip 都計入 Metal4Statistics.recordSkip:

  1. surface.currentDestination():這個 vsync 的 drawable;沒有則 skip。
  2. submitter.acquireSlot():最多 3 幀在 GPU 上;滿了就 skip,不阻塞。
  3. 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。
  4. 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 的水平部分。
  5. 設定 argument table:uniforms、luma、chroma、drawable。
  6. submitter.begin(slot) 取得 command buffer;建不出 compute encoder 時 abandon(slot) 後 skip。
  7. 編碼 compute pass:每個 drawable 像素一個 thread,destination 內取樣來源,外面寫黑色(即黑邊)。
  8. surface.willCommit:PresentationLog.record 記錄 drawable 與 frame 的對應,掛 presented handler。
  9. 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 步驟由兩件事實現:

  1. DisplayUniforms.destination = program.destination(in: drawable):即 plan.geometry.destination 依 drawable/outputSize 的比例縮放(兩者相同時就是 plan 的值);kernel 以像素中心換算 destination 內的 (u, v),範圍外寫黑色。
  2. 硬體 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 2x2560×1440(0, 0, 2560, 1440)放大 1.33x
3024×1964 全螢幕3024×1964(0, 131, 3024, 1701)放大 1.575x,黑邊上 131、下 132 px
同上再 video.rotate = 903024×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 呼叫,因此旋轉後視窗比例不會跟著改。