UWP RenderTargetBitmapが0バイトになる原因と対策:CanvasをWriteableBitmapに保存する方法

UWPでCanvasに画像を置き、その上にPolygonなどの図形を描いた結果を「そのままWriteableBitmapに保存したい」のに、RenderTargetBitmapが0バイト(実質空)になって困ることがあります。原因はほぼパターン化しており、対策も決まっています。この記事では再現条件から、確実にキャプチャしてWriteableBitmap化・PNG保存まで行う実装をまとめます。

目次

現象:Canvasの見た目を保存したいのに、RenderTargetBitmapが空になる

よくある流れは次のとおりです。

  • WriteableBitmapに読み込んだ画像を <Image> コントロールに設定する
  • そのImageを <Canvas> に載せる
  • <Polygon> や <Rectangle> で四角形(枠)を描画する
  • 最終的に「Canvasの見た目」を1枚の画像として保存したい

ところが、次のように“コードで新しく作ったCanvas”を対象に RenderTargetBitmap.RenderAsync を呼ぶと、ピクセルが取得できず0バイト相当の結果になることがあります。

var canvas2 = new Canvas();
canvas2.Children.Add(imageControl); // Imageと図形を追加
canvas2.Children.Add(polygon);

var rtb = new RenderTargetBitmap();
// canvas2.ActualWidth/ActualHeight が 0 のまま、またはそもそも描画されていない
await rtb.RenderAsync(canvas2, (int)canvas2.ActualWidth, (int)canvas2.ActualHeight);

var buffer = await rtb.GetPixelsAsync(); // 長さ0になってしまうことがある

「例外は出ないのに、結果だけ空」というのが厄介で、初見だとハマりやすいポイントです。

結論:RenderTargetBitmapは“画面上で実際にレンダリングされた要素”しかキャプチャできない

原因の本質はシンプルで、RenderTargetBitmapはXAMLのビジュアルツリー上で、実際にレンダリング(描画)されたUIElementしかキャプチャできないためです。

質問のコードでは canvas2 を生成して子要素を追加していますが、次のどちらか(または両方)が起きています。

起きていることなぜ問題になるか結果
canvas2 がビジュアルツリーに追加されていないレイアウトも描画も走らず、レンダリング対象にならないキャプチャできず、ピクセルが空になりやすい
canvas2 のサイズが確定していない(ActualWidth/ActualHeight が 0)レンダリングするべき領域が 0×0 と判断される結果が空/意図しないサイズになる

つまり、“画面に載っていないUI”をそのままRenderTargetBitmapで撮ることはできないのが基本方針です(例外的な回避策はありますが、安定性と保守性を考えるとおすすめしません)。

まず押さえるチェックポイント

0バイト問題の切り分けは、次のチェックでほぼ決着します。

チェック項目確認方法目安
canvas2 は画面に追加されているかルート要素(Gridなど)のChildrenに入れているか入っていない場合はまず追加
canvas2.ActualWidth / ActualHeight は 0 ではないかデバッグで値を確認する0ならレイアウト未完了
画像(Image.Source)は読み込み完了しているかBitmapImageなら ImageOpened を待つ未完了だと真っ白になることがある
RenderAsync の呼び出しはUIスレッドかDispatcher上で実行されているか別スレッドだと例外 or 不定

基本の対処:Canvasを一度ビジュアルツリーに追加してからキャプチャする

最も安全で、再現性が高い対処は次の流れです。

  1. キャプチャ対象のCanvas(ImageやPolygonを含む)を構築する
  2. ページ上のルート要素(例:Grid)にCanvasを追加する
  3. レイアウト完了を待ってから RenderAsync を呼ぶ
  4. 必要なら、キャプチャ後にCanvasをツリーから外す

ポイントは「追加して表示状態にする」+「レイアウト完了後に呼ぶ」の2つです。

例:ルートGridに追加してからRenderAsyncする

まず、XAML側に“キャプチャ用の置き場”を用意しておくと実装が安定します。

&lt;Grid x:Name="RenderedGrid"&gt;
    &lt;!-- 普段の画面 --&gt;
    &lt;Grid x:Name="MainArea"&gt;
        &lt;!-- 既存UI --&gt;
    &lt;/Grid&gt;

    &lt;!-- キャプチャ用の一時ホスト(必要に応じて最前面/背面に) --&gt;
    &lt;Grid x:Name="CaptureHost" /&gt;
&lt;/Grid&gt;

次にコードビハインドでCanvasを追加し、サイズが確定してからキャプチャします。

using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Graphics.Display;
using Windows.Graphics.Imaging;
using Windows.Storage;
using Windows.Storage.Streams;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media.Imaging;

public async Task&lt;WriteableBitmap&gt; CaptureCanvasToWriteableBitmapAsync(Canvas canvas2)
{
    // 1) ビジュアルツリーに追加(ここが重要)
    CaptureHost.Children.Add(canvas2);

    // 2) レイアウト完了を待つ(ActualWidth/ActualHeight が確定するまで)
    await WaitForNonZeroSizeAsync(canvas2);

    // 3) RenderAsync(必要なら高解像度指定)
    var rtb = new RenderTargetBitmap();

    // RenderAsync(element) でもOKだが、解像度をコントロールしたい場合は幅/高さを渡す
    var scale = DisplayInformation.GetForCurrentView().RawPixelsPerViewPixel;
    int pixelWidth  = (int)Math.Round(canvas2.ActualWidth  * scale);
    int pixelHeight = (int)Math.Round(canvas2.ActualHeight * scale);

    await rtb.RenderAsync(canvas2, pixelWidth, pixelHeight);

    // 4) ピクセル取得 → WriteableBitmap へコピー
    var buffer = await rtb.GetPixelsAsync();
    var pixels = buffer.ToArray();

    var wb = new WriteableBitmap(rtb.PixelWidth, rtb.PixelHeight);
    using (var dst = wb.PixelBuffer.AsStream())
    {
        await dst.WriteAsync(pixels, 0, pixels.Length);
    }

    // 5) もう不要ならツリーから外す(後片付け)
    CaptureHost.Children.Remove(canvas2);

    return wb;
}

private static Task WaitForNonZeroSizeAsync(FrameworkElement element)
{
    // Loaded後でもActualWidth/Heightが0の瞬間があるため、「0でなくなる」まで待つ
    if (element.ActualWidth &gt; 0 &amp;&amp; element.ActualHeight &gt; 0)
    {
        return Task.CompletedTask;
    }

    var tcs = new TaskCompletionSource&lt;object&gt;();

    void Handler(object s, SizeChangedEventArgs e)
    {
        if (element.ActualWidth &gt; 0 &amp;&amp; element.ActualHeight &gt; 0)
        {
            element.SizeChanged -= Handler;
            tcs.TrySetResult(null);
        }
    }

    element.SizeChanged += Handler;

    // 念のためLoadedも待つ(SizeChangedが来ないケースに備える)
    RoutedEventHandler loadedHandler = null;
    loadedHandler = (s, e) =&gt;
    {
        element.Loaded -= loadedHandler;
        if (element.ActualWidth &gt; 0 &amp;&amp; element.ActualHeight &gt; 0)
        {
            element.SizeChanged -= Handler;
            tcs.TrySetResult(null);
        }
    };
    element.Loaded += loadedHandler;

    return tcs.Task;
}

このパターンにすると、canvas2 が“ちゃんと画面の一部として扱われる”ため、RenderTargetBitmapが空になりにくくなります。

ActualWidth/ActualHeight が 0 になりやすいタイミングと回避策

「ビジュアルツリーに追加したのに、まだActualWidth/ActualHeightが0で失敗する」ケースもあります。これはレイアウトが確定していないだけなので、呼び出しタイミングを調整すれば解決できます。

タイミング起こりやすいことおすすめの回避策
コンストラクタ直後まだレイアウトが走っておらずサイズ0Loaded/SizeChangedを待ってから実行
子要素追加直後Measure/Arrange前でサイズ未確定UpdateLayout、または次フレーム待ち
画像を差し替えた直後画像デコードが間に合わず白くなるImageOpenedを待つ/WriteableBitmapなら更新後に1回UIを通す

実務では「Loadedでトリガーし、サイズが0ならSizeChangedを待つ」という二段構えが最も安定します。上の WaitForNonZeroSizeAsync はそのための小さなユーティリティです。

解像度が思ったより荒いときは“DIP→ピクセル”変換を意識する

キャプチャが成功しても、「画像サイズが小さい」「高DPI端末でぼやける」といった別の悩みが出ることがあります。原因は、XAMLのサイズ(DIP)とビットマップのサイズ(物理ピクセル)が一致しないことです。

RenderTargetBitmapの RenderAsync(element, width, height) の width/height はピクセル数として扱われます。ActualWidth はDIPなので、次のようにスケールを掛けた値を渡すと期待どおりになりやすいです。

var scale = DisplayInformation.GetForCurrentView().RawPixelsPerViewPixel;
int pixelWidth  = (int)Math.Round(canvas2.ActualWidth  * scale);
int pixelHeight = (int)Math.Round(canvas2.ActualHeight * scale);

await rtb.RenderAsync(canvas2, pixelWidth, pixelHeight);

逆に「アプリ内での見た目と同じサイズで十分」なら、RenderAsync(canvas2)(サイズ指定なし)でも問題ありません。

WriteableBitmapにしたい理由別:最適な取り回し

「最終的にWriteableBitmapが欲しい」といっても、目的によって最適解が変わります。よくある目的とおすすめの経路を整理すると、選択ミスが減ります。

やりたいことおすすめ理由
画面に結果を表示したいRenderTargetBitmapをそのままImage.Sourceへ余計なコピーが減り高速
PNG/JPEGとして保存したいGetPixelsAsync → BitmapEncoderで保存WriteableBitmapに変換せず保存できる
ピクセル編集(塗りつぶし、解析など)をしたいWriteableBitmapに変換PixelBufferに書き込み可能

「保存が目的」なら、実はWriteableBitmapに変換しなくてもOKです。次の章で“保存まで”の定番実装をまとめます。

キャプチャ結果をPNGで保存する実装例

RenderTargetBitmapで取得したピクセル(BGRA8)を、BitmapEncoderでPNGにします。FileSavePickerなどでユーザーに保存先を選ばせてもいいですし、アプリデータ領域に自動保存しても構いません。

public async Task SaveRenderTargetBitmapAsPngAsync(RenderTargetBitmap rtb, StorageFile file)
{
    var buffer = await rtb.GetPixelsAsync();
    var pixels = buffer.ToArray();

    using (IRandomAccessStream stream = await file.OpenAsync(FileAccessMode.ReadWrite))
    {
        var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream);

        encoder.SetPixelData(
            BitmapPixelFormat.Bgra8,
            BitmapAlphaMode.Premultiplied,
            (uint)rtb.PixelWidth,
            (uint)rtb.PixelHeight,
            96, 96, // DPI(必要に応じて調整)
            pixels
        );

        await encoder.FlushAsync();
    }
}

この方法だと、WriteableBitmapへのコピー工程が不要なのでメモリ効率が良く、保存だけならこちらが第一候補になります。

RenderTargetBitmap → WriteableBitmap 変換の注意点

WriteableBitmap化する場合は、ピクセルフォーマットとアルファモードを意識してください。UWPのRenderTargetBitmapは基本的にBGRA8 + Premultipliedとして扱うのが無難です。

  • BGRA8:B,G,R,A の順に1バイトずつ(合計4バイト/ピクセル)
  • Premultiplied:透明度を掛けた色が格納される(合成で自然に見える)

変換自体は前述のとおり “GetPixelsAsync → WriteableBitmap.PixelBufferへ書き込み” です。コピー時には次の点を押さえると事故が減ります。

注意点具体例対策
サイズが大きいとメモリを食う4000×4000は約64MB(4バイト/ピクセル)必要最低限の解像度にする/不要になったら参照を外す
Premultipliedを忘れると色が変に見える半透明の縁が黒ずむ等保存時はPremultiplied指定に寄せる
UI更新直後のキャプチャで古い見た目になるPolygonを追加したのに反映されていないレイアウト完了を待つ/必要なら次フレームで実行

画像が白い・図形が欠けるときの追加チェック

0バイト問題は解決したのに、出力が「白い」「一部だけ欠ける」場合は、次のパターンが多いです。

BitmapImage(URI読み込み)のデコード待ちが必要

Image.Source に BitmapImage(URIから読み込むタイプ)を使う場合、デコードが非同期で進むため、早すぎるキャプチャだと白くなります。確実にするなら ImageOpened を待ちます。

var tcs = new TaskCompletionSource&lt;object&gt;();
imageControl.ImageOpened += (s, e) =&gt; tcs.TrySetResult(null);
imageControl.ImageFailed += (s, e) =&gt; tcs.TrySetException(new Exception(e.ErrorMessage));

// Source設定
imageControl.Source = bitmapImage;

await tcs.Task; // 画像が開けたのを待つ

WriteableBitmapを直接Sourceにしている場合は比較的起きにくいですが、更新直後は念のため1回UIスレッドに制御を返してからキャプチャすると安定します(例:await Task.Yield();)。

Visibility=Collapsed はキャプチャ対象にならない

「画面に見せたくないから」といって Visibility.Collapsed にすると、そもそもレイアウトも描画もされないためキャプチャできません。キャプチャが必要な瞬間だけVisibleにする、または画面設計として“キャプチャ専用領域”を確保するのが現実的です。

Opacity=0 は“透明に描画される”ので結果も透明になる

Collapsedほど致命的ではありませんが、Opacity=0は「存在するが透明に描かれる」ため、取得結果も透明になります。ユーザーに見せたくない場合は、短時間だけ表示してすぐ外す、あるいはUI上の見えない場所に置く設計にします(ただし配置によってはクリップされるので注意してください)。

実務で使える“失敗しにくい”実装パターン

運用でトラブルが少ないのは、次の2パターンです。

パターンA:キャプチャ専用のホストに一時追加して撮る

  • CaptureHost(Grid)を常設し、キャプチャ時だけCanvasを追加
  • SizeChanged/Loadedでサイズ確定を待つ
  • RenderAsync → 保存 → Remove

画面設計に依存しにくく、最もおすすめです。

パターンB:既存Canvasをそのまま撮る(画面に既にある場合)

キャプチャしたいCanvasが既に画面上に存在するなら、新しいCanvasを作らず、そのCanvas自体をRenderAsyncの対象にするのが簡単です。余計なコピーがなく、ズレも起きにくいです。

var rtb = new RenderTargetBitmap();
await rtb.RenderAsync(ExistingCanvas);

var buffer = await rtb.GetPixelsAsync();
// あとは保存やWriteableBitmap化

補足:それでも“画面に載せずに”描きたい場合の考え方

「どうしてもビジュアルツリーに追加したくない」「ユーザーに一瞬でも見せたくない」という要件がある場合、方向性としては次のどちらかになります。

  • Win2Dなどの描画ライブラリでオフスクリーンにレンダリングして、CanvasRenderTarget等を保存する
  • 最初からWriteableBitmapへ描画(画像合成・図形描画を自前実装)して保存する

前者は学習コストがある代わりに、UIとは独立して安定します。後者は自由度が高い反面、アンチエイリアスや線の描画など“見た目の品質”を揃えるのが難しくなります。まずは本記事の「一時的にビジュアルツリーへ追加して撮る」方式で要件を満たせるか確認するのが現実的です。

まとめ:0バイト問題は“描画されていないCanvasを撮ろうとしている”のが原因

  • RenderTargetBitmapは、ビジュアルツリー上で実際に描画された要素しかキャプチャできない
  • 新しく作ったCanvasは、ルートに追加してレイアウト完了後にRenderAsyncする
  • ActualWidth/ActualHeight が 0 のまま呼ばない(Loaded/SizeChangedで待つ)
  • 保存が目的なら、GetPixelsAsync → BitmapEncoderで直接PNG保存が効率的

この流れに揃えると「Canvasに描いた図形+画像」を、見た目どおりに安定して画像化できます。特に“ホストに一時追加して撮る”は、プロジェクトが大きくなっても壊れにくいので、最初からこの形に寄せておくのがおすすめです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次