アプリのNSImageViewをラスタライズするには、AppKitのペアになったビットマップキャッシュメソッドを使い、生成されたピクセル寸法を保持します。Metal向けに予測可能なバイト列が必要な場合は、そのビットマップを明示的に定義したCore Graphicsバッファに再描画します。

このガイドでは、メモリ内だけで生成された合成画像を扱います。目的は、NSImageViewでの表示を再利用可能なラスターとして取得し、位置ずれやピクセルの繰り返しを診断し、必要に応じてラスターをMetalテクスチャに転送することです。

ビューの表示結果が必要か、サイズを厳密に指定した画像が必要かを選ぶ

画像と、それを表示するビューは、それぞれ異なる役割を担います。ビューはスケーリングモードを適用し、画像をフレームに収めます。デフォルトはscaleProportionallyDownであるため、元の画像を取り出しても、ビューでの表示結果が再現されるとは限りません。AppleのimageScalingのドキュメントで、この動作が説明されています。

APIを選ぶ前に、必要な出力を決めてください。

必要な出力 適切な出発点 寸法の契約
イメージビューによる描画結果 AppKitのビットマップキャッシュメソッド 返されたビットマップのピクセル寸法を確認する
寸法を厳密に指定した合成描画 ビットマップCGContextに直接描画する 整数のピクセル幅と高さを明示する
Metalテクスチャ向けの予測可能なバイト列 定義済みのビットマップコンテキストにラスターを再描画する ピクセル形式、ストライド、所有者を明示する

Appleによると、bitmapImageRepForCachingDisplay(in:)は要求されたビュー領域に適した表現を作成します。その表現をcacheDisplay(in:to:)に渡してください。Appleは、ピクセル形式の互換性を保つため、この組み合わせを特に推奨しています。2つ目のメソッドは、ビューとその子孫を描画します。ビットマップの割り当てに関するドキュメント、ビューのキャッシュに関するドキュメント。

これらのメソッドには、任意の出力解像度を指定する引数はありません。出力を厳密に192 × 128ピクセルにする必要がある場合は、合成描画をそのサイズに直接レンダリングしてください。小さいビューのラスターを後から拡大しても、ラスタライズ時に失われた詳細は戻せません。これはビューのキャッシュ機能に別の機能があるということではなく、2つの出力契約が異なるためです。

ポイント、ピクセル、矩形を区別する

ローカル描画領域全体を要求する場合はview.boundsを使います。ビューのboundsはビュー自身の座標系を表し、frameはスーパービューの座標系における位置とサイズを表します。そのため、ローカル領域を要求するメソッドにframeを渡すと、意図しないオフセットが生じることがあります。Appleのboundsに関するドキュメント。

サイズ契約の例として、192 × 128のソースビットマップを、論理サイズ96 × 64の画像として表示する場合を考えてください。両方の値を保持します。前者はサンプル数を表し、後者は表示サイズを表します。NSImageのイニシャライザーではサイズを明示できます。代わりにゼロを指定すると、ソース画像のピクセル寸法が採用されます。Appleの画像イニシャライザーに関するドキュメント。

既存のビューのバッキングジオメトリを診断するときは、ローカル矩形をバッキングストア座標に変換するconvertToBacking(_:)を使います。実際に返されたラスターについては、pixelsWideとpixelsHigh、またはそのCGImageの寸法を確認します。Appleのドキュメントによると、NSBitmapImageRepのcgImageは表現と同じピクセル寸法です。バッキング座標への変換に関するドキュメント、ビットマップのCGImageに関するドキュメント。

返された寸法の代わりに、想定した2×の倍率を使わないでください。要求したローカル矩形、返されたピクセル幅と高さ、意図する論理サイズをまとめて記録します。これらを比較すれば、結果が拡大、切り抜き、位置ずれして見えるときに、具体的な原因を調べられます。

合成ラスター化の一連の例を作る

次のObjective-Cプログラムは、非対称なテスト画像を生成し、メモリ内のイメージビューに設定してビューをラスタライズした後、結果をCore Graphicsのビットマップに正規化します。ファイルは作成しません。コマンドライン開発ツールをインストールしたMacで、RasterDemo.mとして保存してください。

clang -fobjc-arc RasterDemo.m -framework Cocoa -o RasterDemo
./RasterDemo

正規化コンテキストでは、8ビットの各コンポーネント、最上位位置のプリマルチプライドアルファ、リトルエンディアンの32ビットストレージを使います。この設定により、BGRAのバイト順になります。つまり、メモリ上では最下位の青コンポーネントが先頭になります。これはAppleによるpremultipliedFirstの定義とbyteOrder32Littleの定義に基づいています。

#import <Cocoa/Cocoa.h>

static CGContextRef NewBitmap(size_t w, size_t h) {
    // Illustrative allocation budget, not an API dimension limit.
    if (w == 0 || h == 0 || w > 4096 || h > 4096) return NULL;
    size_t rowBytes = (w * 4 + 63) & ~(size_t)63;
    CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
    if (!space) return NULL;
    CGContextRef c = CGBitmapContextCreate(
        NULL, w, h, 8, rowBytes, space,
        kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
    CGColorSpaceRelease(space);
    return c;
}

static CGImageRef NewTestImage(void) {
    CGContextRef c = NewBitmap(192, 128);
    if (!c) return NULL;
    CGContextSetRGBFillColor(c, 1, 1, 1, 1);
    CGContextFillRect(c, CGRectMake(0, 0, 192, 128));
    CGContextSetRGBFillColor(c, 0, 0, 0, 1);
    for (size_t x = 0; x < 192; x += 16) {
        CGContextFillRect(c, CGRectMake(x, 0, 1, 128));
    }
    CGContextSetRGBFillColor(c, 0, 0.7, 0.8, 1);
    CGContextFillRect(c, CGRectMake(8, 92, 24, 20));
    CGImageRef image = CGBitmapContextCreateImage(c);
    CGContextRelease(c);
    return image;
}

static CGImageRef NewNormalizedImage(CGImageRef input) {
    size_t w = CGImageGetWidth(input);
    size_t h = CGImageGetHeight(input);
    CGContextRef c = NewBitmap(w, h);
    if (!c) return NULL;
    CGContextClearRect(c, CGRectMake(0, 0, w, h));
    CGContextSetBlendMode(c, kCGBlendModeCopy);
    CGContextSetInterpolationQuality(c, kCGInterpolationNone);
    CGContextDrawImage(c, CGRectMake(0, 0, w, h), input);
    NSLog(@"Normalized: %zu x %zu pixels; stride %zu bytes",
          w, h, CGBitmapContextGetBytesPerRow(c));
    CGImageRef result = CGBitmapContextCreateImage(c);
    CGContextRelease(c);
    return result;
}

int main(void) {
    @autoreleasepool {
        [NSApplication sharedApplication];
        CGImageRef source = NewTestImage();
        if (!source) { NSLog(@"Source allocation failed"); return 1; }
        NSImage *image = [[NSImage alloc] initWithCGImage:source
                                                  size:NSMakeSize(96, 64)];
        CGImageRelease(source);
        NSImageView *view = [[NSImageView alloc]
                            initWithFrame:NSMakeRect(0, 0, 96, 64)];
        view.image = image;
        view.imageScaling = NSImageScaleNone;
        view.imageAlignment = NSImageAlignCenter;
        view.imageFrameStyle = NSImageFrameNone;
        [view layoutSubtreeIfNeeded];
        NSRect region = view.bounds;
        NSBitmapImageRep *rep =
            [view bitmapImageRepForCachingDisplayInRect:region];
        if (!rep) { NSLog(@"View bitmap allocation failed"); return 1; }
        [view cacheDisplayInRect:region toBitmapImageRep:rep];
        CGImageRef cached = rep.CGImage;
        if (!cached) { NSLog(@"View bitmap has no CGImage"); return 1; }
        CGImageRef raster = NewNormalizedImage(cached);
        if (!raster) { NSLog(@"Normalization failed"); return 1; }
        NSImage *result = [[NSImage alloc] initWithCGImage:raster
                                                   size:region.size];
        NSLog(@"Result logical size: %@", NSStringFromSize(result.size));
        CGImageRelease(raster);
    }
    return 0;
}

このプログラムは、ビューのキャッシュがソースの192 × 128ピクセルと一致すると決めつけず、実際の出力寸法を報告します。シアンのマーカーを使うことで、向きの変化と縦線の繰り返しを区別できます。ソースと厳密に同じ解像度が必要な場合は、ビューを経由せず、NewTestImage()が返すCGImageを直接使ってください。

明示的なクリアにより、正規化前に透明な出力先ピクセルが設定されます。単一画像の描画では、出力先の矩形が塗りつぶされます。タイル状には描画されません。Appleのドキュメントによると、対応するCore Graphics描画処理は出力先の矩形に合わせてスケーリングされ、タイル化が有効でない限り画像を1つだけ描画します。Appleの画像描画に関するドキュメント。

このプログラムは確認済みですが、AppKitとCコンパイラがないLinuxの調査環境ではコンパイルできませんでした。以下で使うストライド計算は、小規模な決定的Python計算で確認しました。

ずれたり繰り返されたりするピクセルを決まった順序で診断する

次の手順は、前述のジオメトリとメモリに関する契約から導いた診断の手がかりです。症状から最初に調べる箇所を推測できますが、原因を一意に特定できるわけではありません。

  1. 要求した矩形を確認します。 イメージビュー全体を対象にする場合、キャッシュ要求とboundsを比較します。一部分を書き出す場合は、そのローカル原点も入力契約に含めます。
  2. 生バイト列より先に寸法を確認します。 キャッシュ画像の幅と高さを、その後の各処理に渡される寸法と比較します。ピクセル幅の代わりにポイント幅を使うと、バッファの解釈が変わります。
  3. 行ストライドを確認します。 各行の開始位置が想定位置から徐々に離れていく場合は、変換を調整する前に行の開始位置間の距離を調べます。
  4. 非対称マーカーで向きを確認します。 垂直反転が見られる場合は、座標規約を調べます。一定量の位置ずれが見られる場合は、原点と平行移動を調べます。
  5. 繰り返しが意図的か確認します。 タイル化を有効にする描画呼び出しがないか調べます。タイル化を明示的に行った描画は、行ストライドを変更しても直りません。
  6. 表示時のスケーリングは最後に確認します。 保存されたラスターが正しいのに表示結果が異なる場合は、出力画像の論理サイズと、受け取り側のイメージビューのスケーリング設定を比較します。

4バイトピクセルの場合、例の192 × 128バッファは1行あたり768バイトで、合計98,304バイト必要です。ただし、例の64バイト行アラインメントを使う191 × 128バッファも、ストライドは768バイトです。各行には4バイトのパディングが含まれます。191 × 4、つまり764ずつ進むと、後続行の開始位置が毎回4バイト早くなります。Appleは、コンテキストのbytesPerRowプロパティで実際のストライドを公開しています。

アドレス計算base + y * bytesPerRow + x * 4を使えるのは、ここで定義した正規化済みの4バイト形式に限られます。AppKitの任意のビットマップには、それぞれの形式契約があります。見た目だけからコンポーネントの配置を推測しないでください。

座標の変更も、意図を持って行ってください。Core Graphicsには、ユーザー空間の原点とスケールを変更する別々の操作があります。垂直反転を加える場合は、どの段階で変換するかを記録し、さらに変換を加える前にマーカーで検証します。平行移動に関するドキュメント、スケーリングに関するドキュメント。

画像を返すときやMetalへアップロードするときは所有権を保持する

この例では、AppKitの表現を生かしたまま、そのCGImageを描画します。その後、正規化コンテキストからスナップショットを作成して返します。Appleのドキュメントによると、makeImage()はコピーを作成し、内部的にはコピーオンライトが使われる場合があります。また、その後にコンテキストへ描画しても、返された画像は変わりません。これにより、スナップショットを作成した後でコンテキストを解放できます。Appleのビットマップスナップショットに関するドキュメント。

生のビットマップポインターは、ライフタイムが異なります。Appleによると、コンテキストをデータ引数なしで作成した場合でも、コンテキストが所有するビットマップデータにアクセスできます。このポインターは、コンテキストが生きている間だけ有効なものとして扱ってください。後続の処理で使う場合は、バイト列を自分で所有するストレージにコピーするか、処理が終わるまでコンテキストを生かしておきます。Appleのビットマップデータに関するドキュメント。

Metalにアップロードする場合は、正規化コンテキストがまだ有効な間に次の手順を実行します。

  1. コンテキストのピクセル幅と高さで、2次元テクスチャを作成します。ここで示すBGRAのコンポーネント配置にはbgra8Unormを使います。Appleによると、これはBGRA順の4つの8ビット正規化符号なしコンポーネントです。
  2. CPUからアップロードする場合は、ハードウェア固有の共有モードを強制せず、システムのデフォルトのストレージモードを使います。Appleのドキュメントでは、Appleシリコンでは共有がデフォルトであり、IntelベースのMacのテクスチャでは管理モードがデフォルトとされています。
  3. コンテキストのデータポインターと実際の行ストライドを使い、テクスチャ全体の領域、ミップレベル0に対してreplace(region:mipmapLevel:withBytes:bytesPerRow:)を呼び出します。
  4. コンテキストを解放する前にアップロードを完了させます。テクスチャを再利用する場合は、CPUが上書きする前に、そのテクスチャを使う先行GPU処理も完了していることを確認します。

形式とストレージの選択は、Appleのbgra8Unormに関するドキュメントとリソースストレージのガイダンスに従っています。Appleは、置換メソッドは渡されたピクセルをすぐにコピーしますが、GPU処理とは同期しないと説明しています。プライベートテクスチャには、非プライベートの一時テクスチャを作成してから、ブリットする方法がドキュメントに記載されています。Appleのテクスチャ置換に関するドキュメント。

テクスチャへのアップロードでは、渡されたバイト列が保持されます。コンポーネントの配置を一致させただけでは、レンダリングパイプラインの残りの部分までは決まりません。この例のプリマルチプライドアルファとsRGBに関する判断も、利用側の設計に反映してください。正規化は選択した形式への再描画であるため、あらゆるAppKit表現からバイト単位で同一に変換される保証として説明すべきではありません。

このタスクで確実に引き渡すには、ラスターとともに、ピクセル寸法、論理表示サイズ、行ストライド、コンポーネント形式、向きの規約、所有者を管理します。非対称な合成描画でテストし、これらに食い違いが生じた最初の段階を修正します。こうすれば、ずれや繰り返しの原因を追跡可能な契約上の問題として扱えます。

参考資料