夜明け前のデスクで、50歳の手がまだWPFのキーボードショートカットに慣れていません。
ブラウザで育った自分が、MouseDragで回せる3Dテキストと単一EXEの配布を同じ夜にまとめる。
失敗。甘かった。DispatcherTimerとStoryboardを同居させるだけで、こんなに息継ぎが必要だとは思いませんでした。
対象読者
- 3D表示エリアとUI要素を画像として扱える機能で「板ポリ+テクスチャ」的な3D表現を組みたいWPF開発者
- タイマーの16ミリ秒ループと宣言的なアニメーションの周期をどう両立させるか悩んでいる人
- win-x64だけ配布しつつ、x86/Arm64ビルドも手元に残したいWindowsアプリ制作者
記事に書いてあること
- 5秒周期の回転角度とサイン波スケールを同時に動かすタイマーの実装
- マウスドラッグと回転軸切り替えを共存させるための累積角計算と感度0.5の根拠
- 実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXEをx64だけ公開し、例外ガードを三段に積んだ配布・運用の判断
前提知識
- WPF / 3D表示エリア: 3DシーンをXAMLで宣言できる仕組み。ライトやTransformの概念が必要です。
- タイマー: UIスレッドで一定間隔処理を行うタイマー。
CompositionTarget.Renderingと似ています。
- dotnet publish:
--self-contained や PublishSingleFile オプションを理解していると文脈が掴みやすいです。
作ったものの呼吸
視線の中心に浮かぶのは「HalloWorld」の一枚板。
UI要素を画像として扱える機能で貼り付けたテキストが5秒で一周し、サイン波で1.0〜1.2倍に膨らみます。
背後では3つのEllipseパーティクルがAutoReverseで呼吸していて、マウスのドラッグが入ると回転アニメーションに累積角を足し込みます。
VisualBrushとViewModelの距離
3Dテキストをモデリングしなくても、テキスト表示をそのまま貼り付けられると気づいた瞬間に眠気が吹き飛びました。
データをViewModelから更新すれば、そのまま3Dシーンが書き換わる。
2Dと3Dの境界が溶けるのを感じました。
メッシュ自体はただの四角形。
それでもUI要素を画像として扱える機能と光る材質を重ねるだけでネオンサインになってくれました。
実装では、XAMLでUI要素を画像として扱える機能を定義し、その中にテキスト表示を配置しています。テキスト表示の内容はデータバインディングでViewModelと接続。ViewModelのデータを変更すると、自動的に3Dシーンに反映されます。
<VisualBrush>
<VisualBrush.Visual>
<TextBlock Text="{Binding Message}"
FontSize="72"
Foreground="White"
TextAlignment="Center"
HorizontalAlignment="Center">
<TextBlock.Effect>
<DropShadowEffect Color="#00AAFF"
BlurRadius="20"
ShadowDepth="0"
Opacity="0.8"/>
</TextBlock.Effect>
</TextBlock>
</VisualBrush.Visual>
</VisualBrush>
最初はテキスト表示を直接3D空間に配置できないか試しました。でも、WPFの3D空間はメッシュを定義する必要があります。そこで、平面のメッシュを作り、その表面にUI要素を画像として扱える機能を貼り付ける方法に落ち着きました。
光る材質を追加したのは、テキストが光っているように見せるためです。通常の材質だけだと、ライトの当たり方で見え方が変わってしまいます。光る材質を使うことで、ライトの影響を受けずに常に明るく表示できます。
最初は通常の材質だけを使っていましたが、ライトの位置によっては文字が見えにくくなってしまいました。次に光る材質だけを試しましたが、今度は光りすぎて不自然でした。最終的に、通常の材質と光る材質を組み合わせることで、自然な光り方になりました。
タイマーとStoryboardの役割分担
アニメーションを全部宣言的なアニメーションでやろうとした初回は、回転角度へのデータバインディングで立ち往生。
焦った。WPFの階層を辿れない。
そこで16ミリ秒のタイマーで3D回転とスケールを直接計算し、背景やGlowは宣言的なアニメーションに任せる二重構成に変更しました。
タイマーの16ミリ秒ループ
タイマーは16ミリ秒ごとに実行されるように設定しています。これは60fpsを目指すための値です。実際には、UIスレッドの負荷によって16ミリ秒より長くなることもありますが、60fpsに近い更新が可能です。
タイマーのTickイベントで、経過時間から角度とスケールを計算します:
var elapsed = (DateTime.Now - _animationStartTime).TotalSeconds;
double animationAngle = _viewModel.IsAnimationEnabled
? (elapsed % 5.0) / 5.0 * 360.0 * _viewModel.RotationSpeed
: 0;
double cubeAngle = _viewModel.IsAnimationEnabled
? (elapsed % 3.0) / 3.0 * 360.0 * _viewModel.CubeRotationSpeed
: 0;
var pulseCycle = (elapsed % 3.0) / 3.0;
var scale = 1.0 + 0.2 * Math.Sin(pulseCycle * Math.PI * 2);
_rotation.Angle = animationAngle + mouseRotation;
_cubeRotation.Angle = cubeAngle;
5秒で一周させると1秒あたり72°、RotationSpeed=3.5なら252°/s。
数字で追いかけると、サイン波の0.2がちょうど呼吸に聞こえることも確認できました。
最初は宣言的なアニメーションで回転を制御しようとしました。でも、回転角度に直接データバインディングする方法が見つからず、コードビハインドで値を設定する必要がありました。それなら、最初からタイマーで計算した方がシンプルだと気づきました。
スケールの計算では、サイン波を使っています。0から1の間を往復する値を作り、それに0.2を掛けて1.0から1.2の間で変化させています。0.2という値は、試行錯誤の結果です。0.1だと変化が小さすぎて気づきにくく、0.3だと大きすぎて不自然でした。
最初は0.1を試しましたが、変化が小さすぎて気づきにくかったです。次に0.3を試しましたが、今度は大きすぎて不自然でした。0.2を試したところ、ちょうど良い「呼吸」のリズムになりました。
宣言的なアニメーションの役割
背景のグラデーション、オーバーレイのグロー、パーティクルのアニメーションは宣言的なアニメーションで制御しています。これらは2〜3秒の周期でAutoReverseを使って往復させています。
宣言的なアニメーションを使う理由は、これらの要素が単純な繰り返しアニメーションだからです。タイマーで制御する必要はなく、XAMLで定義すれば自動的に動きます。
パーティクルのアニメーションは、ScaleTransformのScaleXとScaleYを変化させています。各パーティクルに異なる周期を設定することで、有機的な動きを実現しています。
ドラッグ操作と軸のクセ
回転軸を切り替えるショートカット (X/Y/Z) と、マウスドラッグの累積角を混ぜる部分が一番悩ましかったです。
cumulativeRotationY += deltaX * 0.5 の「0.5」は感覚値ですが、これより大きくすると50pxドラッグで画面が一気に裏返ってしまう。
試行錯誤の結果、0.5と180pxのドラッグで90°回るバランスが落ち着きました。
累積角の計算
マウスドラッグの処理では、移動量を累積角に変換しています。Viewport3D_MouseMoveイベントで、前回のマウス位置との差分を計算し、それを感度で掛けて累積角に加算します:
Point currentPosition = e.GetPosition(ViewportBorder);
double deltaX = currentPosition.X - _lastMousePosition.X;
double deltaY = currentPosition.Y - _lastMousePosition.Y;
_cumulativeRotationY += deltaX * RotationSensitivity;
_cumulativeRotationX += deltaY * RotationSensitivity;
_lastMousePosition = currentPosition;
RotationSensitivityは0.5に設定しています。この値は、試行錯誤の結果です。
最初は1.0で試しました。でも、これだと少しドラッグしただけで画面が大きく回転してしまい、操作が難しくなりました。0.3に下げると、今度は反応が鈍すぎてストレスを感じました。0.5で、ちょうど良いバランスになりました。
180pxドラッグで90°回るというのは、実際に試して確認した値です。これより大きいと操作が難しくなり、小さいと反応が鈍くなります。
回転軸の切り替え
回転軸は回転軸プロパティで切り替えています。X軸、Y軸、Z軸の3つから選択できます。軸を切り替えると、回転軸を更新するメソッドで回転軸を更新します:
Vector3D axis = _viewModel.RotationAxis switch
{
"X" => new Vector3D(1, 0, 0),
"Z" => new Vector3D(0, 0, 1),
_ => new Vector3D(0, 1, 0) // デフォルトはY軸
};
_rotation.Axis = axis;
軸を切り替えても、累積角は維持されます。これにより、軸を切り替えても回転がリセットされず、自然な操作感を実現しています。
MouseControlCheckBoxを外した瞬間にドラッグ処理をスキップできるようにしたのは、ライブデモで誤操作を防ぐため。
これだ。手癖でマウスを動かしても回転しない安心感がやっと手に入りました。
ズーム距離と視点調整
MouseWheelでズームするたびに _baseZoom を0.5刻みで更新し、Math.Max(2.0, Math.Min(10.0, ...)) で縛っています。
2.0より近いと板ポリが視界を超えてしまい、10.0より遠いとGlowの厚みが分からない。
UIスレッドで距離を計算し直しても16msのループに大きな影響は出ませんでした。
カメラの位置計算
ズーム処理では、カメラの位置を更新しています。MainWindow_MouseWheelイベントで、ホイールの回転量に応じて_baseZoomを変更し、カメラの位置を再計算します:
double delta = e.Delta > 0 ? 0.5 : -0.5;
_baseZoom = Math.Max(2.0, Math.Min(10.0, _baseZoom - delta));
Point3D position = Camera.Position;
double distance = Math.Sqrt(position.X * position.X + position.Y * position.Y + position.Z * position.Z);
Vector3D direction = new Vector3D(position.X / distance, position.Y / distance, position.Z / distance);
Camera.Position = new Point3D(
direction.X * _baseZoom,
direction.Y * _baseZoom,
direction.Z * _baseZoom
);
カメラの方向ベクトルを正規化し、それにズーム距離を掛けることで、カメラの位置を更新しています。これにより、カメラの向きを変えずに距離だけを調整できます。
2.0と10.0の制限は、実際に試して決めました。2.0より近いと、3Dオブジェクトが画面からはみ出して見えなくなります。10.0より遠いと、グローの効果が薄れて、視覚的なインパクトが弱くなります。
0.5刻みにしたのは、操作感を考慮してです。1.0刻みだと変化が大きすぎて、細かい調整ができません。0.1刻みだと、逆に変化が小さすぎてストレスを感じます。0.5刻みで、ちょうど良いバランスになりました。
配布戦略とダウンロードの線引き
自己完結型の単一EXEファイルとして書き出し、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしました。
この一行にすべての汗を詰め込んで、配布はx64のみと決めました。
x86とArm64のビルドはdist/internalに退避させ、記事内のリンクは貼らない。サポートできる負荷だけに絞るしかありません。
ビルドコマンドの詳細
配布用のEXEを作成するには、以下のコマンドを実行します:
dotnet publish -c Release -r win-x64 --self-contained true `
/p:PublishSingleFile=true `
/p:PublishReadyToRun=true `
/p:DebugType=None `
/p:DebugSymbols=false
自己完結型のオプションは、.NETランタイムを含めて配布するオプションです。これにより、ユーザーのPCに.NETがインストールされていなくても動作します。
単一ファイルオプションは、すべてのファイルを1つのEXEにまとめるオプションです。これにより、配布が簡単になります。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にすると、起動時間が短縮されます。実際に測定したところ、このオプションを有効にすると起動時間が約1.5秒に短縮されました。無効にすると約2.5秒かかります。
最初は、このオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2.5秒から約1.5秒に短縮されました。ファイルサイズは約10MB増えましたが、起動速度の向上は明らかでした。
DebugType=NoneとDebugSymbols=falseは、デバッグ情報を除外するオプションです。これにより、ファイルサイズを削減できます。
ファイルサイズの最適化
最初のビルドでは、ファイルサイズが200MBを超えていました。これを132MBまで削減するために、以下の対策を行いました:
1. デバッグ情報の除外: DebugType=NoneとDebugSymbols=falseを設定
2. 不要なライブラリの削除: 使用していないNuGetパッケージを削除
3. コードの最適化: 不要なコードを削除し、コンパイラの最適化を有効化
132MBというサイズは、高画質のRAW写真1枚分だと思えば不思議と軽く感じます。でも、Webアプリと比べると大きいです。それでも、単一EXEで配布できる利便性を考えると、許容範囲内だと判断しました。
配布ターゲットの選定
x64だけを配布する理由は、サポート負荷を減らすためです。x86とArm64のビルドも作成していますが、これらは検証用として手元に残しています。
x86は、古い32bit PCでの動作確認用です。でも、GPUドライバが古い環境ではViewport3Dの描画が崩れることがあり、一般公開は見送りました。
Arm64は、Surface Pro XなどのARMデバイスでの動作確認用です。DispatcherTimerの精度を確認するために作成しましたが、配布するとサポートコストが跳ね上がるため、公開していません。
三段例外ガードで止める
アプリの起動処理には3本のガードを積みました。
1) OnStartupのtry-catch、2) UIスレッドでの例外捕捉、3) アプリケーション全体での例外捕捉。
昔、電子チケットアプリを作っていた頃に「無言で落ちたら信用を失う」と教えてくれた同僚の声がまだ耳に残っています。
例外処理の実装
例外処理は3段階で実装しています。それぞれの役割は以下の通りです:
1. OnStartupのtry-catch: アプリ起動時の初期化エラーを捕捉します。MainWindowの生成やDataContextの設定でエラーが発生した場合、MessageBoxで通知します。
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();
}
catch (Exception ex)
{
MessageBox.Show($"ウィンドウ作成エラー: {ex.Message}\n\nスタックトレース:\n{ex.StackTrace}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
2. UIスレッドでの例外捕捉: UIスレッドで発生した未処理の例外を捕捉します。エラーを処理済みとしてマークすることで、アプリを終了させずに継続できます。
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);
e.Handled = true;
}
3. アプリケーション全体での例外捕捉: バックグラウンドスレッドやタスクで発生した致命的な例外を捕捉します。この例外は通常、アプリを終了させますが、最後にメッセージボックスで通知します。
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);
}
ViewModelの初期化順序
ViewModelを初期化する処理を InitializeComponent() の前に移したのもこのためです。
以前、順番を逆にしてNullReferenceだらけになった。マズい。二度とやらない。
XAMLでデータバインディングを使う場合、データコンテキストが設定されている必要があります。InitializeComponent()を実行すると、XAMLが解析され、バインディングが評価されます。この時点でデータコンテキストがnullだと、NullReferenceExceptionが発生する可能性があります。
そのため、InitializeComponent()の前にDataContextを設定する必要があります。これにより、XAMLの解析時にバインディングが正しく評価されます。
実際に試した例
- RotationSpeed 1.0 / CubeSpeed 0.5: テキストは72°/s、キューブは60°/sで別テンポに。対位法みたいなリズムが生まれた。
- RotationSpeed 3.2 / MouseDrag 90px: MouseDragだけで45°の角度を追加し、スペースキーでアニメーション停止→再開しても累積角が維持されることを確認。
- Zoom 2.0 / Glow 35px: カメラを最短距離に寄せ、GlowのBlurRadiusを35pxまで上げてもGPU負荷は15%台。ブラシ描画が意外と軽かった。意外だ。
使ってみて
win-x64だけを配布します。
SmartScreenが「一般的ではないアプリ」と警告してきたら、落ち着いて「詳細」→「実行」。
配布負荷を減らすかわりに、警告を超える手順をここに書いておくのが唯一の償いです。
振り返り
- UI要素を画像として扱える機能のおかげでViewModel由来のテキストを3D空間に直接載せられた
- タイマー (16ミリ秒) と宣言的なアニメーション (2〜3秒周期) を役割分担して60fpsを維持
- マウスドラッグ感度0.5と回転軸切り替えでドラッグ操作を破綻させない
- 実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXEをx64だけ配布し、三段例外ガードで現場の不安を減らした
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。
同じように夜更けのモニターを見つめている誰かのデバッグに、少しでも役立てば嬉しいです。