はじめてのHoloLensアプリ ~SharpDX編~
はじめに
なんかいつの間にかHoloLens2のエミュレータがリリースされていましたね!
僕も初心にもどってキューブを表示するアプリから作ってみようと思ったので、その過程を残しておきます。
プロジェクトテンプレートを実行してみる
MicrosoftよりWindows Mixed Realityアプリ用のプロジェクトテンプレートが提供されています。HoloLensエミュレータのインストーラに同梱されています。
一番下のチェックボックスだけ選択すれば、プロジェクトテンプレートだけのインストールも可能です。テンプレートのインストール後は、VSでのプロジェクト作成時にHolographic DirectX 11 App
というテンプレートが選択できるようになります。
C++/CX + DirectX
とC# + SharpDX
の二つが選択可能です。今回はC# + SharpDX
を選択してみました。
プロジェクトをHoloLensにデプロイして実行してみると、カラフルなキューブがくるくる回っているのが見えるかと思います。
せっかくなので、テンプレートのコードが何をやっているのか見てみようと思います。ただ、そこそこのコード量があるので、本エントリではHoloLens特有の箇所だけ抜き出してまとめています。内容に興味がある方は、プロジェクトテンプレートの中身を見ながら本エントリを読むのがいいと思います。
ウィンドウを表示する
なにはともあれ、描画対象がなければグラフィックスAPIを試してみることができませんので、まずはウィンドウを作成します。
まっさらから作る場合は、プロジェクトテンプレートBlank App(Universal Windows)
を使って新規プロジェクトを作成し、App.xamlとMainPage.xamlを削除します。そして新しく以下のクラスを追加します。
namespace FirstHoloLensApp
{
internal class Program
{
[MTAThread]
public static void Main()
{
var exclusiveViewApplicationSource = new AppViewSource();
CoreApplication.Run(exclusiveViewApplicationSource);
}
}
internal class AppViewSource: IFrameworkViewSource
{
public IFrameworkView CreateView()
{
return new AppView();
}
}
internal class AppView : IFrameworkView, IDisposable
{
public void Initialize(CoreApplicationView applicationView)
{
Debug.WriteLine("Initialize");
}
public void SetWindow(CoreWindow window)
{
Debug.WriteLine("SetWindow");
}
public void Load(string entryPoint)
{
Debug.WriteLine("Load");
}
public void Run()
{
Debug.WriteLine("Run");
}
public void Uninitialize()
{
Debug.WriteLine("Uninitialize");
}
public void Dispose()
{
Debug.WriteLine("Dispose");
}
}
}
この状態で実行してみると、一瞬ウィンドウが表示されてすぐに消えてしまいます。デバッグメッセージからInitialize
→SetWindow
→Load
→Run
→Uninitialize
の順番で実装したメソッドが動いていることがわかります。
公式のリファレンスを見てみると、CoreApplication.Run
はアプリのビューを提供するファクトリを引数から受け取ってビューを生成してアプリを動かすメソッドのようです。
IFrameworkSource
にはCreateView
メソッドが定義されているので、このメソッドがCoreApplication.Run
を実行したときに動作するのでしょう。IFrameworkSource.CreateView
はIFrameworkView
インタフェースを実装したクラスのインスタンスを返すメソッドで、このインタフェースにはウィンドウのライフサイクルイベントをフックして動作するメソッドが定義されています。このメソッドの中にいろいろ書いてあげればやりたいことができそうです。
初期化
SharpDXの導入
プロジェクトテンプレートに初めからNugetで定義されています。ただ、プロジェクトテンプレートのものはv3系とちょっと古く、2019年4月11日時点で最新のものはv4.2.0
となります。こちらにアップデートしても問題ありませんが、一か所だけ修正の必要があります。
DeviceResource.csの122行目付近を以下のように修正します。
public void InitializeUsingHolographicSpace()
{
// The holographic space might need to determine which adapter supports
// holograms, in which case it will specify a non-zero PrimaryAdapterId.
int shiftPos = sizeof(uint);
ulong id = (ulong)holographicSpace.PrimaryAdapterId.LowPart | (((ulong)holographicSpace.PrimaryAdapterId.HighPart) << shiftPos);
// When a primary adapter ID is given to the app, the app should find
// the corresponding DXGI adapter and use it to create Direct3D devices
// and device contexts. Otherwise, there is no restriction on the DXGI
// adapter the app can use.
if (id != 0)
{
// Create the DXGI factory.
using (var dxgiFactory4 = new SharpDX.DXGI.Factory4())
{
// Retrieve the adapter specified by the holographic space.
// 修正点
dxgiAdapter = dxgiFactory4.GetAdapterByLuid((long) id) as Adapter3;
/* v3.0.0
IntPtr adapterPtr;
dxgiFactory4.EnumAdapterByLuid((long)id, InteropStatics.IDXGIAdapter3, out adapterPtr);
if (adapterPtr != IntPtr.Zero)
{
dxgiAdapter = new SharpDX.DXGI.Adapter3(adapterPtr);
}
*/
}
}
else
{
this.RemoveAndDispose(ref dxgiAdapter);
}
CreateDeviceResources();
holographicSpace.SetDirect3D11Device(d3dInteropDevice);
}
空間座標系(Spatial Coordinate System)の定義
HoloLensを使って物理空間上にバーチャルなオブジェクトを配置するためには、基準となる座標系(空間座標系)が必要となります。Unityで作ったHoloLensアプリケーションの場合は、アプリを起動したときのデバイスの位置と姿勢によってこの空間座標系が決まります。UWPプロジェクトでアプリを作る場合は開発者自身がAPIを使って空間座標系を定義する必要があります。
空間座標系を定義するためにはSpatialLocator
クラスを利用します。SpatialLocator
クラスはデバイスのポジショントラッキングの状況をウォッチし、そのトラッキング状況から空間座標系を作る機能を持っています。このSpatialLocator
クラスはHolographicDisplay
クラスから取得できます。(HolographicDisplay
はデバイスのディスプレイに関するメタデータを提供するクラスです)
internal class HolographicApp1Main : IDisposable
{
private SpatialLocator spatialLocator;
private SpatialStationaryFrameOfReference stationaryReferenceFrame;
// 空間座標系を定義する
private void OnHolographicDisplayIsAvailableChanged(Object o, Object args)
{
SpatialLocator spatialLocator = null;
if (canGetDefaultHolographicDisplay)
{
var defaultHolographicDisplay = HolographicDisplay.GetDefault();
if (defaultHolographicDisplay != null)
{
spatialLocator = defaultHolographicDisplay.SpatialLocator;
}
}
else
{
spatialLocator = SpatialLocator.GetDefault();
}
if (this.spatialLocator != spatialLocator)
{
this.stationaryReferenceFrame = null;
if (spatialLocator != null)
{
this.spatialLocator = spatialLocator;
// ポジショントラッキングの状態が変化したときのイベント
this.spatialLocator.LocatabilityChanged += OnLocatabilityChanged;
// 空間座標系を取得
stationaryReferenceFrame = this.spatialLocator.CreateStationaryFrameOfReferenceAtCurrentLocation();
}
}
}
}
プロジェクトテンプレートを真似して、アプリケーションの基盤となるHolographicMainクラスを作成しました。エントリ上では省略していますが、コンストラクタから空間座標系を定義する上記のメソッドを呼び出すようにしています。
SpatialLocator.CreateStationaryFrameOfReferenceAtCurrentLocation
メソッドは、このメソッドを呼び出した時点のデバイスの位置と姿勢をもとに空間座標系を作るメソッドです。Unity製アプリと同様、デバイスの位置が空間座標系における原点となり、視線方向やジャイロによってX-Y-Z軸が決定されます。
また、このメソッドには幾つかオーバロードがあり、位置や姿勢のオフセットを指定して自由に空間座標系を定義することも可能です。
【参考】
Coordinate systems
SpatialLocator Class
HolographicDisplay Class
Direct3Dデバイスの初期化
DirectXをかじったことがある人にはおなじみかもしれませんが、DirectXを使う場合は何よりもまずDirect3Dデバイスの初期化を行う必要があります。デバイスの初期化についてはデスクトップアプリと違いはありませんが、WinMRアプリではカラーバッファを書き込むバックバッファが、デスクトップアプリのものと異なってくるのでそちらを考慮する必要があります。
デスクトップアプリなどでDirectXを使う場合は、スワップチェーンからバックバッファを取得して画面をレンダリングしていました。しかし、HoloLensアプリをはじめとするWinMRのアプリではスワップチェーンの代わりにHolographicSpace
というクラスを通じてバックバッファを取得して画面をレンダリングします。そのため、Direct3Dデバイスを初期化したタイミングで、HolographicSpace
とデバイスを関連付ける必要があります。
プロジェクトテンプレートではDirect3Dデバイスを管理するDeviceResource
クラスを用意しています。
internal class DeviceResources : Disposer
{
private Device3 d3dDevice;
private DeviceContext3 d3dContext;
private SharpDX.DXGI.Adapter3 dxgiAdapter;
private IDirect3DDevice d3dInteropDevice;
private HolographicSpace holographicSpace = null;
private FeatureLevel d3dFeatureLevel;
public void SetHolographicSpace(HolographicSpace holographicSpace)
{
this.holographicSpace = holographicSpace;
InitializeUsingHolographicSpace();
}
public void InitializeUsingHolographicSpace()
{
CreateDeviceResources();
holographicSpace.SetDirect3D11Device(d3dInteropDevice);
}
private void CreateDeviceResources()
{
DisposeDeviceAndContext();
DeviceCreationFlags creationFlags = DeviceCreationFlags.BgraSupport;
FeatureLevel[] featureLevels =
{
FeatureLevel.Level_12_1,
FeatureLevel.Level_12_0,
FeatureLevel.Level_11_1,
FeatureLevel.Level_11_0,
FeatureLevel.Level_10_1,
FeatureLevel.Level_10_0
};
using (var device = new Device(DriverType.Hardware, creationFlags, featureLevels))
{
d3dDevice = this.ToDispose(device.QueryInterface<Device3>());
}
d3dFeatureLevel = d3dDevice.FeatureLevel;
d3dContext = this.ToDispose(d3dDevice.ImmediateContext3);
using (var dxgiDevice = d3dDevice.QueryInterface<SharpDX.DXGI.Device3>())
{
IntPtr pUnknown;
UInt32 hr = InteropStatics.CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.NativePointer, out pUnknown);
if (hr == 0)
{
d3dInteropDevice = (IDirect3DDevice)Marshal.GetObjectForIUnknown(pUnknown);
Marshal.Release(pUnknown);
}
dxgiAdapter = this.ToDispose(dxgiDevice.Adapter.QueryInterface<SharpDX.DXGI.Adapter3>());
}
var options = d3dDevice.CheckD3D113Features3();
if (options.VPAndRTArrayIndexFromAnyShaderFeedingRasterizer)
{
d3dDeviceSupportsVprt = true;
}
}
}
デバイスとデバイスコンテキストの作成についてはデスクトップアプリと違いがありません。しかしHolographicSpace
に対してDirect3DデバイスはIDXGIDevice
型として関連付けする必要があるので、WinRTとの相互運用のためのIDirect3DDevice
にキャストしてセットしています。
さて、肝心のHolographicSpace
のインスタンスをどうやって用意するかですが、こちらはUWPのCoreWindow
から作成することができます。CoreWindow
のインスタンスは、ウィンドウ表示のところで説明したSetWindow
メソッドの引数に渡されるので、そのタイミングでHolographicSpace
のインスタンスを作りDirect3Dデバイスの初期化も行います。
public void SetWindow(CoreWindow window)
{
holographicSpace = HolographicSpace.CreateForCoreWindow(window);
deviceResources.SetHolographicSpace(holographicSpace);
main.SetHolographicSpace(holographicSpace);
}
このHolographicSpace
クラスがどういうものかというと、HoloLensアプリを3Dアプリ(2D XAMLアプリとの対比という意味での)化したり、Spatial Reasoning(空間推論と訳すことにします)を制御するもののようです。この空間推論が何なのかはドキュメントだけではちょっとわからなかったのですが、おそらくポジショントラッキングに関するものだと思います。のちほど説明しますが、ある特定のタイミングでのカメラの位置や姿勢といったポーズ情報を取得できるHolographicFrame
というクラスが存在するのですが、このクラスのインスタンスはHolographicSpace.CreateNextFrame
というメソッドから取得できます(取得したポーズ情報を使ってどのようにレンダリングするかは後述)。
【参考】
Getting a HolographicSpace
HolographicSpace Class
キューブの初期化
画面に表示するカラフルなキューブについてはSpinningCubeRenderer
にて管理しています。こちらについてはデスクトップアプリと比べて特筆すべき内容はありませんので詳細は割愛します。概要だけ書くと、コンストラクト時に以下の内容を実行しています。
- 頂点レイアウトの定義
- 頂点/ピクセルシェーダのロードとコンパイル
- キューブの頂点と頂点カラーの定義、およびそのバッファの作成
- 頂点インデックスの定義、およびそのインデックスのバッファの作成
- ワールド変換行列のコンスタントバッファの作成
更新
フレームごとの更新処理はアプリケーションの基盤として作成したHolographicApp1MainクラスのUpdateメソッドで行うこととしています。このメソッドをAppView
のRun
メソッドの中でループを作ってその中から呼び出します。
ポーズ情報を取得するため、HolographicSpace
からHlographicFrame
を取得します。
public HolographicFrame Update()
{
HolographicFrame holographicFrame = holographicSpace.CreateNextFrame();
HolographicFramePrediction prediction = holographicFrame.CurrentPrediction;
// バックバッファの取得
deviceResources.EnsureCameraResources(holographicFrame, prediction);
// 中略...
return holographicFrame
}
バックバッファの取得(最初だけ)
WinMRではレンダリングのためにスワップチェーンを使いません。ではどうやってバックバッファを取得するのかというと、HolographicCameraRenderingParameters
クラスのDirect3D11BackBuffer
というプロパティから取得します。このクラスのインスタンスはHolographicFrame.GetRenderingParameters
メソッドから取得できます。バックバッファの取得時にレンダーターゲットビューやデプスステンシルビューの作成も行います。
public void CreateResourcesForBackBuffer(
DeviceResources deviceResources,
ref HolographicCameraRenderingParameters cameraParameters
)
{
var device = deviceResources.D3DDevice;
IDirect3DSurface surface = cameraParameters.Direct3D11BackBuffer;
InteropStatics.IDirect3DDxgiInterfaceAccess surfaceDxgiInterfaceAccess = surface as InteropStatics.IDirect3DDxgiInterfaceAccess;
IntPtr pResource = surfaceDxgiInterfaceAccess.GetInterface(InteropStatics.ID3D11Resource);
if ((null == d3dBackBuffer) || (d3dBackBuffer.NativePointer != pResource))
{
this.RemoveAndDispose(ref d3dBackBuffer);
this.RemoveAndDispose(ref d3dRenderTargetView);
d3dBackBuffer = this.ToDispose(new SharpDX.Direct3D11.Texture2D(pResource));
d3dRenderTargetView = this.ToDispose(new RenderTargetView(device, d3dBackBuffer));
Texture2DDescription backBufferDesc = BackBufferTexture2D.Description;
dxgiFormat = backBufferDesc.Format;
Size currentSize = holographicCamera.RenderTargetSize;
if (d3dRenderTargetSize != currentSize)
{
d3dRenderTargetSize = HolographicCamera.RenderTargetSize;
this.RemoveAndDispose(ref d3dDepthStencilView);
}
}
// デプスステンシルビューの作成は省略
// ビュープロジェクション変換行列用のコンスタントバッファの作成
if (null == viewProjectionConstantBuffer)
{
ViewProjectionConstantBuffer viewProjectionConstantBufferData = new ViewProjectionConstantBuffer();
viewProjectionConstantBuffer = this.ToDispose(SharpDX.Direct3D11.Buffer.Create(
device,
BindFlags.ConstantBuffer,
ref viewProjectionConstantBufferData));
}
}
なおVRデバイス同様、HoloLensにも右目/左目用と2枚のディスプレイがありますので、それぞれのディスプレイに描画してあげる必要があります。また立体視のため、ビュー変換、プロジェクション変換のパラメータが左右で異なってきます。ですのでビュープロジェクション変換行列用のコンスタントバッファもそれを考慮した構成となっています。
internal struct ViewProjectionConstantBuffer
{
public Matrix4x4 viewProjectionLeft;
public Matrix4x4 viewProjectionRight;
}
【参考】
HolographicCameraRenderingParameters Class
キューブの更新
こちらも特別なことはやっていないので詳細は割愛します。やっていることは以下の通りです。
- 経過時間に応じて姿勢(回転量)を計算
- 計算結果をもとにワールド変換行列(モデル座標系からワールド座標系に変換する行列)を計算
- 計算した行列をコンスタントバッファにアップする
描画
毎フレーム呼び出されるHolographicApp1MainのRenderメソッドは以下の通りとなっています。
public bool Render(HolographicFrame holographicFrame)
{
if (timer.FrameCount == 0)
{
return false;
}
// ポーズ情報を取得する
holographicFrame.UpdateCurrentPrediction();
HolographicFramePrediction prediction = holographicFrame.CurrentPrediction;
return deviceResources.UseHolographicCameraResources(
(Dictionary<uint, CameraResources> cameraResourceDictionary) =>
{
bool atLeastOneCameraRendered = false;
foreach (var cameraPose in prediction.CameraPoses)
{
CameraResources cameraResources = cameraResourceDictionary[cameraPose.HolographicCamera.Id];
var context = deviceResources.D3DDeviceContext;
var renderTargetView = cameraResources.BackBufferRenderTargetView;
var depthStencilView = cameraResources.DepthStencilView;
// 画面のクリアについては省略
if (stationaryReferenceFrame != null)
{
// ポーズ情報からビュープロジェクション変換行列を取得する
cameraResources.UpdateViewProjectionBuffer(deviceResources, cameraPose, stationaryReferenceFrame.CoordinateSystem);
}
bool cameraActive = cameraResources.AttachViewProjectionBuffer(deviceResources);
if (cameraActive)
{
spinningCubeRenderer.Render();
}
atLeastOneCameraRendered = true;
}
return atLeastOneCameraRendered;
});
}
描画前に画面クリアのためレンダーターゲットビューとデプスステンシルビューをクリアし、その後ビュープロジェクション変換行列を取得しています。
ポーズ情報はHolographicFramePrediction
クラスのCameraPoses
プロパティ(HolographicCameraPose
型)から取得できます。このプロパティと、初期に設定した空間座標系についての情報を使ってビュープロジェクション変換行列を取得します。
public void UpdateViewProjectionBuffer(
DeviceResources deviceResources,
HolographicCameraPose cameraPose,
SpatialCoordinateSystem coordinateSystem
)
{
d3dViewport.X = (float)cameraPose.Viewport.Left;
d3dViewport.Y = (float)cameraPose.Viewport.Top;
d3dViewport.Width = (float)cameraPose.Viewport.Width;
d3dViewport.Height = (float)cameraPose.Viewport.Height;
d3dViewport.MinDepth = 0;
d3dViewport.MaxDepth = 1;
// プロジェクション変換行列の取得
HolographicStereoTransform cameraProjectionTransform = cameraPose.ProjectionTransform;
// ビュー変換行列の取得
HolographicStereoTransform? viewTransformContainer = cameraPose.TryGetViewTransform(coordinateSystem);
ViewProjectionConstantBuffer viewProjectionConstantBufferData = new ViewProjectionConstantBuffer();
bool viewTransformAcquired = viewTransformContainer.HasValue;
if (viewTransformAcquired)
{
HolographicStereoTransform viewCoordinateSystemTransform = viewTransformContainer.Value;
// 両目それぞれのビュープロジェクション変換行列を計算
viewProjectionConstantBufferData.viewProjectionLeft = Matrix4x4.Transpose(
viewCoordinateSystemTransform.Left * cameraProjectionTransform.Left
);
viewProjectionConstantBufferData.viewProjectionRight = Matrix4x4.Transpose(
viewCoordinateSystemTransform.Right * cameraProjectionTransform.Right
);
}
var context = deviceResources.D3DDeviceContext;
if (context == null || viewProjectionConstantBuffer == null || !viewTransformAcquired)
{
framePending = false;
}
else
{
// コンスタントバッファにアップ
context.UpdateSubresource(ref viewProjectionConstantBufferData, viewProjectionConstantBuffer);
framePending = true;
}
}
プロジェクション変換行列はニアクリップ、ファークリップ、FOVで決まるのでフレーム毎に変化しません。ビュー変換行列は(3Dグラフィックスでいうところの)カメラオブジェクトのポーズによって変わります。このカメラオブジェクトのポーズは、現実におけるHoloLensのポーズと紐づきます。位置については最初に設定した空間座標系に基づく位置情報が必要となります。そのため、ビュー行列を取得するためのメソッドHolographicCamera.TryGetViewTransform
が用意されており、このメソッドの引数に空間座標系を渡すことができるようになっています。
【参考】
HolographicFramePrediction
キューブの描画
描画負荷を下げるため、インスタンシングでレンダリングします。
public void Render()
{
if (!this.loadingComplete)
{
return;
}
var context = this.deviceResources.D3DDeviceContext;
int stride = SharpDX.Utilities.SizeOf<VertexPositionColor>();
int offset = 0;
var bufferBinding = new SharpDX.Direct3D11.VertexBufferBinding(this.vertexBuffer, stride, offset);
context.InputAssembler.SetVertexBuffers(0, bufferBinding);
context.InputAssembler.SetIndexBuffer(
this.indexBuffer,
SharpDX.DXGI.Format.R16_UInt, // Each index is one 16-bit unsigned integer (short).
0);
context.InputAssembler.PrimitiveTopology = SharpDX.Direct3D.PrimitiveTopology.TriangleList;
context.InputAssembler.InputLayout = this.inputLayout;
context.VertexShader.SetShader(this.vertexShader, null, 0);
context.VertexShader.SetConstantBuffers(0, this.modelConstantBuffer);
if (!this.usingVprtShaders)
{
context.GeometryShader.SetShader(this.geometryShader, null, 0);
}
context.PixelShader.SetShader(this.pixelShader, null, 0);
context.DrawIndexedInstanced(
indexCount, // Index count per instance.
2, // Instance count.
0, // Start index location.
0, // Base vertex location.
0 // Start instance location.
);
}
【参考】
Unity の XR 向けシングルパスステレオレンダリングについて調べてみた - 凹みTips
シェーダ
シェーダはとってもシンプルです。レンダーターゲットアレイを使うため、セマンティクスだけ注意です。
cbuffer ModelConstantBuffer : register(b0)
{
float4x4 model;
};
cbuffer ViewProjectionConstantBuffer : register(b1)
{
float4x4 viewProjection[2];
};
struct VertexShaderInput
{
min16float3 pos : POSITION;
min16float3 color : COLOR0;
uint instId : SV_InstanceID;
};
struct VertexShaderOutput
{
min16float4 pos : SV_POSITION;
min16float3 color : COLOR0;
// The render target array index is set here in the vertex shader.
uint viewId : SV_RenderTargetArrayIndex;
};
VertexShaderOutput main(VertexShaderInput input)
{
VertexShaderOutput output;
float4 pos = float4(input.pos, 1.0f);
int idx = input.instId % 2;
pos = mul(pos, model);
pos = mul(pos, viewProjection[idx]);
output.pos = (min16float4)pos;
output.color = input.color;
output.viewId = idx;
return output;
}
インスタンスIDを使ってビュープロジェクション変換行列の配列から行列を取得して、左目と右目それぞれの座標を計算しています。
struct PixelShaderInput
{
min16float4 pos : SV_POSITION;
min16float3 color : COLOR0;
};
min16float4 main(PixelShaderInput input) : SV_TARGET
{
return min16float4(input.color, 1.0f);
}
頂点カラーをそのまま出力するシンプルなピクセルシェーダ。キューブの初期化時に各頂点に異なる色を持たせているので、各ピクセルの色は線形補間されてカラフルな色になります。
なお、Immersive Headset用途向けかと思いますが、GPUインスタンシングをサポートしていないグラフィックデバイス向けに、ジオメトリシェーダで同じことをやるシェーダも用意されています。
まとめ
Unityなら5分で出来る。
というのはさておき、空間座標系の設定、バックバッファの取得、ステレオレンダリングというところを押さえておけば、それほど変わったことはしていないんだな、という印象を受けました。