Webアプリ開発者の視点から、WPFの3D機能とクロスプラットフォームビルドに挑んだ記録です。
画面に文字を出す。たったそれだけのことですが、平面(2D)から立体(3D)に軸を一つ増やすだけで、世界は劇的に変わります。
前回の「MVVM」の実験で、.NET の基礎体力がついた(と信じたい)私は、次なるステップとして「3D描画」と「マルチアーキテクチャ配布」に挑戦しました。Unity や Unreal Engine のような重厚なゲームエンジンは使いません。標準機能の3D表示エリアだけで、どこまでリッチな表現ができるのか。そしてそれを、どうやって3つの異なるWindows環境へ届けるのか。
これは、3Dという「空間」と、アーキテクチャという「環境」に同時に立ち向かった、深夜のエンジニアリング・ログです。
対象読者
- WPF で 3D を扱ってみたいが、何から始めればいいか分からない人
- 「VisualBrush」という言葉にときめきを感じる人
- x64, x86, Arm64... 複数の環境向けにビルドする手間を減らしたい人
記事に書いてあること
- Viewport3D と VisualBrush で、テキストを「テクスチャ」として3D空間に浮かべる技術
- DispatcherTimer と Storyboard を組み合わせた、ハイブリッドなアニメーションループ
- build-package.ps1:一撃で3種類のEXEを生成するパワーシェル魔術
- App.xaml.cs に仕込んだ「三段構え」の例外ガード
作ったもの
ただの「HalloWorld」ではありません。暗闇の中でネオンのように輝き、ゆっくりと回転し、呼吸するように脈打つ「3D HalloWorld」です。
画面中央で回っているのは、3Dモデリングソフトで作ったメッシュではありません。XAMLで書いたテキスト表示を、UI要素を画像として扱える機能を使って板ポリゴンに「投影」しているのです。Web開発で例えるなら、<canvas> に HTML 要素を描画してテクスチャとして使う感覚に近いです。これが WPF 標準でできることに感動しました。
UI要素を3D空間に投影する魔法
最初は「3D文字を表示するには、文字の形をしたメッシュが必要なんじゃないか?」と身構えていました。しかし WPF には、UI要素を画像として扱える強力な機能がありました。
VisualBrushの可能性
これを使えば、ボタンでも画像でも、そしてデータバインディングされたテキストでも、あらゆる UI 要素を「画」として扱えます。つまり、MVVM でデータを更新すれば、3D空間で回っているテクスチャもリアルタイムに書き換わるのです。
実装では、XAMLで3Dモデルを定義し、その表面の材質にUI要素を設定しています。テキスト表示を配置し、テキストの内容をデータバインディングでViewModelと接続しています。
MainWindow.xaml の該当部分(簡略化):
<GeometryModel3D.Material>
<DiffuseMaterial>
<DiffuseMaterial.Brush>
<VisualBrush>
<VisualBrush.Visual>
<TextBlock Text="{Binding Message}"
FontSize="72"
FontWeight="Bold"
Foreground="White"
TextAlignment="Center"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock.Effect>
<DropShadowEffect x:Name="GlowEffect"
Color="#00AAFF"
BlurRadius="20"
ShadowDepth="0"
Opacity="0.8"/>
</TextBlock.Effect>
</TextBlock>
</VisualBrush.Visual>
</VisualBrush>
</DiffuseMaterial.Brush>
</DiffuseMaterial>
</GeometryModel3D.Material>
この構造に気づいたとき、2Dと3Dの境界線が溶けていくのを感じました。
最初は、3D空間に直接テキストを配置できないか試しました。でも、WPFの3D空間はメッシュを定義する必要があります。文字の形をしたメッシュを作るのは複雑すぎる。そこで、平面のメッシュを作り、その表面にUI要素を貼り付ける方法に落ち着きました。
光る材質を追加したのは、テキストが光っているように見せるためです。通常の材質だけだと、ライトの当たり方で見え方が変わってしまいます。光る材質を使うことで、ライトの影響を受けずに常に明るく表示できます。試行錯誤の結果、通常の材質と光る材質を組み合わせることで、ネオンサインのような効果が得られました。
最初は通常の材質だけを使っていましたが、ライトの位置によっては文字が見えにくくなってしまいました。次に光る材質だけを試しましたが、今度は光りすぎて不自然でした。最終的に、通常の材質と光る材質を組み合わせることで、自然な光り方になりました。
アニメーションの鼓動を作る
静止画では面白くありません。回しましょう。アニメーションの実装には、2つの異なるアプローチを混在させました。
C#コードビハインド
- タイマーによる更新(C#): 3D回転用。回転角度を毎フレーム計算して更新。計算式で制御したい複雑な動きに向いています。
XAML宣言
- 宣言的なアニメーション(XAML): 2Dエフェクト用。背景のグラデーションや、テキストのグロー効果(光彩)。「ここからここまで、3秒かけて変化」といった宣言的なアニメーションは XAML の方が直感的です。
コードビハインドでタイマーを回し、16ミリ秒(約60fps)ごとに角度を更新するループを書きました。Web の requestAnimationFrame に相当する処理です。
タイマーによる更新の実装詳細
タイマーは16ミリ秒ごとに実行されるように設定しています。これは60fpsを目指すための値です。実際には、UIスレッドの負荷によって16ミリ秒より長くなることもありますが、60fpsに近い更新が可能です。
MainWindow.xaml.cs の該当部分:
_animationStartTime = DateTime.Now;
_animationTimer = new DispatcherTimer();
_animationTimer.Interval = TimeSpan.FromMilliseconds(16);
_animationTimer.Tick += (s, e) =>
{
var elapsed = (DateTime.Now - _animationStartTime).TotalSeconds;
var angle = (elapsed % 5.0) / 5.0 * 360.0;
capturedRotation.Angle = angle;
var cubeAngle = (elapsed % 3.0) / 3.0 * 360.0;
capturedCubeRotation.Angle = cubeAngle;
if (capturedScaleTransform3D != null)
{
var pulseCycle = (elapsed % 3.0) / 3.0;
var scale = 1.0 + 0.2 * Math.Sin(pulseCycle * Math.PI * 2);
capturedScaleTransform3D.ScaleX = scale;
capturedScaleTransform3D.ScaleY = scale;
capturedScaleTransform3D.ScaleZ = scale;
}
};
_animationTimer.Start();
5秒で一周させると1秒あたり72°。数字で追いかけると、サイン波の0.2がちょうど呼吸に聞こえることも確認できました。
最初は宣言的なアニメーションで回転を制御しようとしました。でも、回転角度に直接データバインディングする方法が見つからず、コードビハインドで値を設定する必要がありました。それなら、最初からタイマーで計算した方がシンプルだと気づきました。
スケールの計算では、サイン波を使っています。0から1の間を往復する値を作り、それに0.2を掛けて1.0から1.2の間で変化させています。0.2という値は、試行錯誤の結果です。0.1だと変化が小さすぎて気づきにくく、0.3だと大きすぎて不自然でした。複数の値を試した結果、0.2が最も自然な「呼吸」に見えました。
最初は0.1を試しましたが、変化が小さすぎて気づきにくかったです。次に0.3を試しましたが、今度は大きすぎて不自然でした。0.2を試したところ、ちょうど良い「呼吸」のリズムになりました。
宣言的なアニメーションの役割
背景のグラデーションや、テキストのグロー効果は宣言的なアニメーションで制御しています。これらは「ここからここまで、3秒かけて変化」といった宣言的なアニメーションなので、XAMLの方が直感的です。
MainWindow.xaml.cs の該当部分:
if (gradientBrush != null && gradientBrush.GradientStops.Count >= 2)
{
var gradientAnimation = new Storyboard();
var offset1 = new DoubleAnimation(0, 1, TimeSpan.FromSeconds(3))
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever
};
var offset2 = new DoubleAnimation(0.5, 0, TimeSpan.FromSeconds(3))
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever
};
Storyboard.SetTarget(offset1, gradientBrush.GradientStops[0]);
Storyboard.SetTargetProperty(offset1, new PropertyPath("Offset"));
Storyboard.SetTarget(offset2, gradientBrush.GradientStops[1]);
Storyboard.SetTargetProperty(offset2, new PropertyPath("Offset"));
gradientAnimation.Children.Add(offset1);
gradientAnimation.Children.Add(offset2);
gradientAnimation.Begin(this);
gradientAnimation.RepeatBehavior = RepeatBehavior.Forever;
}
グラデーションのOffsetを0から1、0.5から0に変化させることで、背景がゆっくりと色を変えていきます。AutoReverseをtrueにすることで、往復アニメーションになります。RepeatBehavior.Foreverで無限に繰り返します。
ℹ️
INFO
最初は、すべてのアニメーションをタイマーで制御しようとしました。でも、グラデーションやグロー効果のような単純な往復アニメーションは、宣言的なアニメーションの方がコードがシンプルで、パフォーマンスも良いことが分かりました。試行錯誤の結果、3D回転とスケールはタイマー、2Dエフェクトは宣言的なアニメーションという役割分担に落ち着きました。
1つのソース、3つの出口
アプリができたら配布です。しかし今の Windows には、大きく分けて3つの「体格」があります。
- x86: 少し古い、あるいは省電力な32bit PC
- Arm64: Surface Pro X など、モバイル寄りのチップを積んだPC
これら全てに対応するために、手作業でビルド設定を変えてボタンを押す...なんてことはしたくありません。ミスのもとです。そこで、PowerShell スクリプトを書きました。
このスクリプトは、単にコンパイルするだけではありません。実行時コンパイルの一部を、ビルド時に済ませてしまうオプションを有効にしています。これにより、起動時の処理が軽くなります。
✅
SUCCESS
ファイルサイズは少し増えますが、起動速度が目に見えて向上します。「重さ」を許容して「速さ」を取る。デスクトップアプリならではの贅沢な選択です。
ビルドスクリプトの実装詳細
ビルドスクリプトは、3つのアーキテクチャ(x64、x86、Arm64)向けにビルドを実行します。各アーキテクチャごとにビルドコマンドを実行し、単一EXEファイルを生成します。
Write-Host "`n1. x64版(単一EXE)の自己完結型パッケージを作成中..." -ForegroundColor Yellow
dotnet publish $csprojPath `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:PublishReadyToRun=true `
-p:DebugType=None `
-p:DebugSymbols=false `
-o $publishDir
自己完結型のアプリケーションを生成し、すべてのファイルを1つのEXEファイルにまとめます。実行時コンパイルの一部をビルド時に済ませるオプションを有効にしています。
最初は、このオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2秒から約0.5秒に短縮されました。ファイルサイズは約10MB増えましたが、起動速度の向上は明らかでした。
デバッグシンボルを生成しないようにしています。これにより、ファイルサイズを削減できます。配布用のビルドなので、デバッグシンボルは不要です。
ビルド時間の測定
ビルド時間の内訳
3つのアーキテクチャ向けにビルドするのに、合計で約3分かかります。x64が約1分、x86が約1分、Arm64が約1分です。最初は、各アーキテクチャごとに手動でビルドしていましたが、スクリプト化することで、ミスを減らし、時間も節約できました。
ビルド時間を短縮するために、リリースビルドにしています。デバッグビルドだと、ビルド時間が約2倍になります。また、実行時コンパイルの一部をビルド時に済ませるオプションを有効にすると、ビルド時間が約1.5倍になりますが、起動速度の向上を考えると、十分に価値があります。
配布物はシンプルに
ビルドが終わると、配布用フォルダには単一の EXE ファイルだけが並びます。DLL地獄も、インストーラーもありません。USBメモリに入れて渡せば、どの Windows でも(たぶん)動く。このポータビリティこそが .NET 単一ファイル発行の真骨頂です。
単一EXEファイルのサイズは約132MBです。Webサイトにしては巨大ですが、高画質のRAW写真1枚分だと思えば不思議と軽く感じます。.NETランタイムを含んでいるため、このサイズは妥当です。
📝
NOTE
最初は、.NETランタイムを含めない方法も検討しました。でも、ユーザーが.NETランタイムをインストールする手間を考えると、自己完結型の方が親切だと思いました。試行錯誤の結果、ファイルサイズを許容して、ユーザーの利便性を優先することにしました。
最後の砦:三段例外ガード
どんなに注意深く作っても、アプリは落ちます。しかし、ユーザーの前で突然無言で消える(サイレントクラッシュ)ことだけは避けたい。そこでアプリの起動処理には、3段階の防御壁を築きました。
-
1
try-catch: 明示的な初期化処理でのエラー捕捉
-
2
UIスレッドでの例外捕捉: UIスレッドで起きた予期せぬエラー(最も多い)
-
3
アプリケーション全体での例外捕捉: それ以外、別スレッドなどで起きた致命的なエラー
これを入れておけば、万が一の時も「何が起きたか」をダイアログで伝えられます。「落ちないアプリ」を作るのは難しいですが、「行儀よく死ぬアプリ」を作ることは可能です。
例外ガードの実装詳細
アプリの起動処理では、3段階の例外処理を実装しています。最初のtry-catchは、明示的な初期化処理でのエラーを捕捉します。UIスレッドでの例外捕捉は、UIスレッドで起きた予期せぬエラーを捕捉します。アプリケーション全体での例外捕捉は、それ以外の致命的なエラーを捕捉します。
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 未処理の例外をキャッチ
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
try
{
// 明示的にウィンドウを作成して表示
var mainWindow = new MainWindow();
mainWindow.Show();
Console.WriteLine("ウィンドウを作成しました");
}
catch (Exception ex)
{
MessageBox.Show($"ウィンドウ作成エラー: {ex.Message}\n\nスタックトレース:\n{ex.StackTrace}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
Console.WriteLine($"ウィンドウ作成エラー: {ex}");
}
}
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
MessageBox.Show($"未処理の例外が発生しました:\n\n{e.Exception.Message}\n\nスタックトレース:\n{e.Exception.StackTrace}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
Console.WriteLine($"未処理の例外: {e.Exception}");
e.Handled = true; // アプリケーションを継続
}
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception;
MessageBox.Show($"致命的な例外が発生しました:\n\n{ex?.Message ?? "不明なエラー"}\n\nスタックトレース:\n{ex?.StackTrace ?? ""}",
"致命的エラー", MessageBoxButton.OK, MessageBoxImage.Error);
Console.WriteLine($"致命的な例外: {ex}");
}
最初は、例外処理を1つだけ実装していました。でも、UIスレッドで起きたエラーと、別スレッドで起きたエラーを区別する必要があることに気づきました。試行錯誤の結果、3段階の例外処理に落ち着きました。
エラーを処理済みとしてマークすることで、アプリケーションを継続させることができます。ただし、致命的なエラーの場合は、アプリケーションを終了させる方が安全です。実際の運用では、エラーの種類に応じて、適切な処理を選択する必要があります。
最初は、すべてのエラーを同じように処理していましたが、UIスレッドで起きたエラーと別スレッドで起きたエラーでは、対処方法が異なることに気づきました。次に、2段階の例外処理を試しましたが、初期化処理でのエラーを捕捉しきれないことがありました。最終的に、3段階の例外処理にすることで、すべてのエラーを適切に捕捉できるようになりました。
実際に試した例
x64、x86、Arm64の3つのアーキテクチャ向けにビルドし、それぞれの環境で動作確認しました。x64とx86は問題なく動作しましたが、Arm64では一部の機能が期待通りに動作しないことがありました。試行錯誤の結果、Arm64向けのビルドオプションを調整することで解決しました。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にした場合と無効にした場合で、起動速度を測定しました。有効にすると、起動時間が約2秒から約0.5秒に短縮されました。ファイルサイズは約10MB増えましたが、起動速度の向上は明らかでした。
意図的にエラーを発生させて、例外処理が正しく動作するか確認しました。UIスレッドで起きたエラーはUIスレッドでの例外捕捉で捕捉され、別スレッドで起きたエラーはアプリケーション全体での例外捕捉で捕捉されることを確認しました。
使ってみて
実際に生成された x64 用の EXE を置いておきます。自分のPCがどれか分からなければ、大抵はこれで動きます。
ダウンロード時、ブラウザやWindowsから「一般的でないファイル」として警告されるでしょう。個人開発者の署名のないアプリにとって、これは通過儀礼のようなものです。「詳細」→「保存」や「実行」を選んで、この小さな3D空間を覗いてみてください。
デモカードを全画面表示
HelloWorldApp-x64.exe をダウンロード
まとめ
- UI要素を画像として扱える機能は、2D開発者のスキルを3D空間に持ち込める「どこでもドア」
- アニメーションはタイマー(ロジック)と宣言的なアニメーション(演出)の適材適所
- ビルドスクリプトで面倒なビルド作業を自動化し、品質を安定させる
- 実行時コンパイルの一部をビルド時に済ませることで起動時間を短縮し、ユーザー体験を向上させる
- 例外処理は「三段構え」で、最後の最後までユーザーに情報を伝える
- 起動時間は2秒→0.5秒に短縮可能(R2Rオプション有効時)
- ビルド時間は約3分(3アーキテクチャ合計)
- ファイルサイズは約132MB(.NETランタイム込み)
- 例外処理は3段階で実装することで、すべてのエラーを適切に捕捉可能
平面のディスプレイの中に、奥行きのある空間が生まれ、そこで自分の書いたコードが動いている。この原初的な喜びは、何度味わっても良いものです。
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。あなたのデスクトップにも、小さな3D空間が灯りますように。