🐡

【Vision Pro】イマーシブビデオプレイヤー開発における色の取り扱い

に公開

VisionProのイマーシブビデオ再生プレイヤーを作成している背景から自分が気づいた色周りの知見をまとめました。

なお、本記事で執筆している内容は筆者の個人的な実験結果から得られた推測を含みます。
公式ドキュメントで明文化されていない内容も記載されているので注意してください。

ピクセルフォーマットと色について

まず、各ピクセルがどのようにして色を創出するかを説明します。

RGB方式

赤、緑、青の基本色の組み合わせで様々な色を表現します。例えば、

  • (255, 0, 0) は純粋な赤
  • (0, 255, 0) は純粋な緑
  • (255, 255, 255) は白
  • (0, 0, 0) は黒

Q. 本当にRGBだけで全ての色を表現できるのか?

A. 厳密には全ての色を再現できるわけではありません。通常のピクセルはRGBそれぞれに8bitの階調を持ちます。つまり、RGBそれぞれ256段階の強さを組み合わせることで、

256 × 256 × 256 = 約1,670万色

の色が再現可能です。なお、RGBそれぞれに10bitを使用すると、

1024 × 1024 × 1024 = 1,073,741,824色(約10億7,374万色)

まで表現できます。

こちらのサイトでRGB/8bitで再現できる色とカラーコードが確認できます。
https://www.lab-nemoto.jp/www/leaflet_edu/else/ColorMaker.html


YUV方式

Y(輝度)、U(青色差)、V(赤色差)の3成分で色を表現します。少し分かりにくい方のために、役割を簡単にまとめると以下の通りです。

  • Y(輝度): 明るさ情報(白黒テレビの白黒部分に相当)
  • U(青色差): どれくらい青っぽいかを表す
  • V(赤色差): どれくらい赤っぽいかを表す

RGB同様、8bitの場合はそれぞれ0~255の範囲で表現します。

  • 赤いリンゴ(RGB: 255, 0, 0)
    • Y(輝度): 約54
    • U(青色差): 約99
    • V(赤色差): 約255

Q. 色の表現なのに緑色が出てこないのはなぜか?

A. 実はYUVのままでは緑に限らず色を表現できないため、最終的にはディスプレイ投影時にこのYUV値をRGBに逆変換して表示します。

緑色の例(Rec.709近似)

RGB: (0, 255, 0) を以下の式に当てはめると:

Y = 0.2126·R + 0.7152·G + 0.0722·B  
U = –0.1146·R – 0.3854·G + 0.5389·B + 128  
V =  0.6340·R – 0.4542·G – 0.0458·B + 128  

––– R=0, G=255, B=0 を代入 –––

Y  ≈ 0.7152 × 255           ≈ 182  
U  ≈ –0.3854 × 255 + 128    ≈  29  
V  ≈ –0.4542 × 255 + 128    ≈  12  

得られたYUV値をディスプレイ投影時にRGBの逆変換式に戻すと、緑色が正しく再現されます。


なぜYUVに変換するのか?

最終的にはRGBに戻すのに、なぜYUVに変換する必要があるのでしょうか?その目的はデータ量の削減です。

YUV420の例

  • 2×2ピクセル(4画素)を1ブロックとして扱う
  • Y(輝度)は4画素すべてをサンプリング
  • U/V(色差)は4画素の平均値を1画素分だけサンプリング

これにより、4+4+4=12画素分の情報を4+1+1=6画素分に節約できます。

具体例: 赤・青・黄・緑の4画素の場合

  • 各色のYUV値(BT.601近似)
    • (255, 0, 0) → U≈99、V≈255
    • (0, 0, 255) → U≈255、V≈116
    • (255, 255, 0) → U≈0、 V≈140
    • (0, 255, 0) → U≈30、 V≈12
  • U/Vの平均値
    • U_block = (99 + 255 + 0 + 30) ÷ 4 ≈ 96
    • V_block = (255 + 116 + 140 + 12) ÷ 4 ≈ 131

Q. 色差情報を平均化しても画質への影響はないのか?

A. 人間の目は輝度情報に敏感ですが、色差情報には鈍感なため、平均化しても視覚的な影響はほとんどありません。

YUV420のイメージ図:
https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F675511%2F5ad6a9be-2833-e794-118b-40d45baffdd6.png

YUV422、YUV444など他のサンプリング方式もありますが、詳細は省略します。


ビデオレンジ(リミテッドレンジ) vs フルレンジ

  • フルレンジ(PCレンジ)
    • 8bit: 0~255をフルに使用
    • Y = 0(黒)~255(白)
    • U/V = 0~255
  • ビデオレンジ(リミテッド/TVレンジ)
    • 8bit: Y=16~235、U/V=16~240
    • 昔のテレビ信号の同期パルスやブランキング信号用に余裕を持たせた仕様

Q. リミテッドレンジにすると画質はどう変わる?

A. ビデオレンジは色域をわずかに圧縮しますが、通常の視聴では違いを感じにくいです。

なお、VisionProのAVFoundation等の標準ライブラリではビデオレンジを自動的にフルレンジへマッピングするため、一般的には意識不要ですが、Metalシェーダーを自作する場合は注意してください。


SwiftにおけるピクセルフォーマットとGPU処理

前章の知識を前提にSwiftにおけるピクセルフォーマットとGPU処理までの取り扱いを解説します。

VisionProでよく使うピクセルフォーマット

映像用

ピクセルフォーマット 内容
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange YUV420/8bit/ビデオレンジ
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange YUV420/8bit/フルレンジ
kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange YUV420/10bit/ビデオレンジ
kCVPixelFormatType_420YpCbCr10BiPlanarFullRange YUV420/10bit/フルレンジ
kCVPixelFormatType_32BGRA BGRA/8bit
kCVPixelFormatType_64ARGB ARGB/16bit

Metal用

ピクセルフォーマット 内容
bgra8Unorm BGRA/8bit(線形/リニア)
bgra8Unorm_srgb BGRA/8bit(非線形/sRGB)
bgr10a2Unorm BGR10 + A2(線形/10bit + α2bit)
rgba16Float RGBA16(浮動小数点/高ダイナミックレンジ)

YUV420ピクセルバッファからMTLTextureへの変換

SwiftでYUV420のピクセルバッファからMTLTextureへの変換方法を解説します。

  • ピクセルバッファ
    映像フレームからデコードされた生データをCPU/OSが管理するバッファとして保持するものです。
    IOSurfaceを使うことで、GPU/エンコーダ/他プロセスとzero-copyで共有メモリを連携できます。

  • MTLTexture
    GPUシェーダーからサンプリングしたりレンダーターゲットとして使用したりするピクセルメモリです。
    GPU(Metal)で扱いたいデータを保持し、描画結果を書き込むためのメモリです。

  • サンプルバッファ
    ピクセルバッファ、映像の時間情報、映像のフォーマット情報等をまとめてラップしたバッファです。

  1. デコードされたCVPixelBufferを取得します。

    // copyNextSampleBufferでサンプルバッファを取得
    let sampleBuffer = output.copyNextSampleBuffer()
    
    // MV-HEVCの場合、以下でデコード済みのピクセルバッファを取得
    guard
     let taggedBuffers = sampleBuffer.taggedBuffers,
     let buffL = taggedBuffers.first(where: {
         $0.tags.contains(.stereoView(.leftEye))
     }),
     let buffR = taggedBuffers.first(where: {
         $0.tags.contains(.stereoView(.rightEye))
     }),
     case .pixelBuffer(let pixelBufferL) = buffL.buffer,
     case .pixelBuffer(let pixelBufferR) = buffR.buffer
    else {
        return
    }
    
    // 続きのコードでは説明簡略化のためpixelBufferLとpixelBufferRは分けて考えず、pixelBufferとして扱います。
    
  2. Y(輝度)とCbCr(色差情報)をそれぞれピクセルバッファから取り出し、MTLTextureに変換する関数を定義します。

    /// ピクセルバッファの指定平面から`MTLTexture`を生成する
    private func createTexture(from pixelBuffer: CVPixelBuffer,
                               pixelFormat: MTLPixelFormat,
                               planeIndex: Int) -> MTLTexture? {
        // 指定の平面(planeIndex)の幅・高さを取得
        let width  = CVPixelBufferGetWidthOfPlane(pixelBuffer, planeIndex)
        let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, planeIndex)
    
        // CVMetalTextureはCoreVideo上のIOSurfaceとMetalテクスチャを紐付けるラッパー
        var cvMetalTexture: CVMetalTexture?
    
        // ゼロコピーでピクセルバッファをMetalテクスチャとして生成
        let status = CVMetalTextureCacheCreateTextureFromImage(
            kCFAllocatorDefault,
            textureCache!,      // 事前に作成したCVMetalTextureCache
            pixelBuffer,
            nil,                // 通常はnilで問題なし
            pixelFormat,
            width,
            height,
            planeIndex,
            &cvMetalTexture
        )
    
        // 成功したか確認。失敗時はnilを返して終了
        guard status == kCVReturnSuccess, let cvMetalTexture = cvMetalTexture else {
            return nil
        }
    
        // CVMetalTextureからMTLTextureを取得して返却
        return CVMetalTextureGetTexture(cvMetalTexture)
    }
    
  3. 手順1で取得したピクセルバッファを用いて、Y平面とCbCr平面のMTLTextureを生成します。

    // 平面インデックス0にY(輝度)情報が入っているので、8bit用のr8Unormを使用
    let yTexture = createTexture(
        from: pixelBuffer,
        pixelFormat: .r8Unorm,
        planeIndex: 0
    )
    
    // 平面インデックス1にCbCr情報が入っているので、8bit × 2チャネル用のrg8Unormを使用
    let cbCrTexture = createTexture(
        from: pixelBuffer,
        pixelFormat: .rg8Unorm,
        planeIndex: 1
    )
    

ここで、映像の解像度が1920×1080の前提で、pixelBufferのYとCbCrにはどのようなデータが入るか考えてみましょう。

  • Y 平面(Plane 0:輝度)
    • 内容:各ピクセルの輝度(Y)のみ
    • 解像度:1920×1080
    • チャンネル数:1(r8Unorm)
    • ビット深度:8 bit
    • 容量:
      1920 × 1080 ピクセル × 1 バイト/ピクセル = 2 073 600 バイト ≒ 2.07 MB
      
  • CbCr 平面(Plane 1:色差)
    • 内容:2×2 のブロックごとに1セットの Cb, Cr(インタリーブして格納)
    • 解像度:(1920÷2) × (1080÷2) = 960×540
    • チャンネル数:2(Cb → .r, Cr → .g の順で .rg8Unorm)
    • ビット深度:8 bit × 2 チャンネル = 16 bit 相当
    • 容量:
      960 × 540 ピクセル × 2 バイト/ピクセル = 1 036 800 バイト ≒ 1.04 MB
      

YとCbCrを合計したピクセルサイズは3 110 400 B(約 3.11 MB)となります。

なお、RGBフォーマットの場合は以下のようになります。

1920 × 1080 ピクセル × 1 バイト/ピクセル = 2 073 600 バイト ≒ 2.07 MB
2,073,600 × 3 (RGB) = 6,220,800 バイト

RGBの場合はYCbCrの倍の大きさになっていることがわかります。

MTLTextureからディスプレイ投影

前項で取得したMTLTextureをディスプレイ投影まで持っていくために、Metalの描画パイププラインを組む必要があります。

ここでは、ピクセルフォーマットの話にフォーカスするため、その他の細かい設定の説明は省略します。

  1. Metal描画用にMTLRenderPipelineを設定する
// 「どう描くか」を決める設計図

// グラフィックパイプラインの設定用変数を定義
 private var renderPipelineState: MTLRenderPipelineState?

(中略)

// 描画パイプライン用のピクセルフォーマットにbgra8Unormを設定(BGRA8bitの場合)
let pipelineDesc = MTLRenderPipelineDescriptor()
pipelineDesc.colorAttachments[0].pixelFormat = .bgra8Unorm
  1. Metal描画書き出し先のLowLevelTextureのピクセルフォーマットを定義する
// 「どこに描くか」を管理する入れ物

// 書き出し先のピクセルフォーマットにbgra8Unormを設定(BGRA8bitの場合)
let desc = LowLevelTexture.Descriptor(
    pixelFormat: .bgra8Unorm,
    width: width,
    height: height,
    depth: 1,
    mipmapLevelCount: 1,
    textureUsage: [.shaderRead, .shaderWrite, .renderTarget]
)

let lowLevelTexture = try await LowLevelTexture(descriptor: desc)
  1. MetalのフラグメントシェーダーでYCbCrを線形RGBに変換して手順2で作成したbgra8Unormピクセルに出力する
fragment float4 fragmentShader(ImageColorInOut in  [[stage_in]],
                               texture2d<float, access::sample> texY     [[texture(0)]],
                               texture2d<float, access::sample> texCbCr  [[texture(1)]])
{
    constexpr sampler s(address::clamp_to_edge, filter::linear);
    
    float  y  = texY.sample(s, in.texCoord).r; // Yテクスチャを取得
    float2 uv = texCbCr.sample(s, in.texCoord).rg; // CbCrテクスチャを取得
    
    y  = saturate((y  - 16./255.) * (255./219.)); // Yテクスチャをビデオレンジ→フルレンジに変換
    float cb = (uv.x - 0.5) * (255./224.); // Cbをビデオレンジ→フルレンジに変換
    float cr = (uv.y - 0.5) * (255./224.); // Crをビデオレンジ→フルレンジに変換
    
    // YCbCrからBT.709の非線形R'G'B'に変換
    float3 rgb709 = float3(
        y + 1.5748 * cr,
        y - 0.1873 * cb - 0.4681 * cr,
        y + 1.8556 * cb
    );
    
    // BT.709の非線形R'G'B'を線形R'G'B'に変換
    rgb709 = pow(rgb709, 2.2);
    
    return float4(rgb709, 1.0);
}

これでデコードフレームからMetal描画までの一連の処理が完了します。

SDRとHDR

この章ではVisionProにおけるSDRとHDRの取り扱いを解説します。

Vision Pro のディスプレイ

いきなり、最も重要なことを書きます。
Vision Pro のディスプレイはDCI‑P3であり、Rec.709ではありません。
Apple Vision Pro仕様ページ

こちらは CIE1931 色度図と呼ばれる、人間の目で知覚できる色を 2 次元平面上にマッピングした図です。
DCI‑P3 の方が Rec.709 よりも広い色域を定義していることが分かります。

CIE1931色度図

ここで、映像業界でよく使われる Rec.709 を DCI‑P3 のディスプレイに投影すると、色座標の不一致が生じます。具体例で見てみましょう。

元映像のフレームが Rec.709 で赤色(RGB=1,0,0)の情報を持っている場合

Rec.709 では赤色の色度が (0.640, 0.330) と定義されています。
これが YUV420 圧縮を経て RGB に変換されると、RGB(1,0,0) という値になります。
そして DCI‑P3 対応のディスプレイに渡されると、ディスプレイはこの RGB(1,0,0) を色度 (0.680, 0.320) と解釈します。
当初の (0.640, 0.330) からずれていることが分かります。つまり、同じ赤色でも若干強い赤に見えてしまいます。
この結果、Rec.709 の映像を DCI‑P3 のディスプレイに無理に当てはめると、全体的に彩度が強く見えるようになります。

なお、Swift の AVFoundation(ほとんどのプレイヤーが利用するライブラリ)では、上記の問題を吸収するために内部で自動的に Rec.709 → DCI‑P3 へのマッピングが行われますが、色域自体はRec709のままです。

ビット深度

ビット深度とは、文字通り「ビットの深さ(分解能)」を示します。

例えば RGB8bit では、赤・緑・青それぞれに 8bit 分の情報が割り振られます。
イメージしやすいように、赤から黒までのケースを考えてみましょう。
赤は (255, 0, 0)、黒は (0, 0, 0) です。
つまり、赤色から黒色まで変化させるには 256 段階の色階調が用意できることになります。

これを Metal が出力する正規化された 0–1 の RGB に変換すると、以下のようになります。

整数 RGB 正規化 RGB
(255, 0, 0) (1.000000, 0.0, 0.0)
(254, 0, 0) (254/255 ≈ 0.996078, 0.0, 0.0)
(253, 0, 0) (253/255 ≈ 0.992157, 0.0, 0.0)
(1, 0, 0) (1/255 ≈ 0.003922, 0.0, 0.0)
(0, 0, 0) (0.0, 0.0, 0.0)

では、10bit の場合を考えてみましょう。
赤は (1023, 0, 0)、黒は (0, 0, 0) です。

整数 RGB 正規化 RGB
(1023, 0, 0) (1.000000, 0.000000, 0.000000)
(1022, 0, 0) (1022/1023 ≈ 0.999023, 0.0, 0.0)
(1021, 0, 0) (1021/1023 ≈ 0.998047, 0.0, 0.0)
(2, 0, 0) (2/1023 ≈ 0.001955, 0.0, 0.0)
(1, 0, 0) (1/1023 ≈ 0.000978, 0.0, 0.0)
(0, 0, 0) (0.000000, 0.000000, 0.000000)

当然、8bit と比べて階調のステップ幅が細かくなっています。

Vision Pro のディスプレイへの影響

前述のとおり、Vision Pro のディスプレイは DCI‑P3 色域を採用しており、DCI-P3では10bit 深度が推奨されています。
(Rec.709 のディスプレイでは 8bit 推奨)

DCI‑P3 は Rec.709 よりも広い色域を表現できるため、8bit で表現すると バンディング(階調跳び) が発生しやすくなります。

Discussion