はじめに
こんにちは、今回初投稿となります arcsin16 です。
今回は現在 Windows Store で公開している HoloInkShooter で床を塗る仕組みについて簡単に説明したいと思います。
HoloInkShooterから抽出したソースコードを github にて公開していますので、ご参考まで。
概要
描画用のテクスチャを用意して、SpatialMappingのメッシュに弾が当たったら、テクスチャの該当する位置にブラシの模様を書き込んで、そのテクスチャを Shader で SpatialMapping 上に描画するようにしています。イメージとしては、床に10m×10m位のでかいテクスチャが貼られているような感じです。
で、これを実現するために実装している処理が以下の3点になります。
- 着弾位置から塗り範囲を計算する
- テクスチャにインク模様を書き込む
- Shaderでテクスチャを描画する
着弾位置から塗り範囲を計算する
private void PaintFloor(Vector3 point, Color color)
{
// ブラシの描画領域を計測(描画対象Texture上のuv座標)
var paintArea = new Vector4(
(point.x + areaSize.x / 2 - radius / 2) / areaSize.x, // 描画領域の左上のu
(point.z + areaSize.y / 2 - radius / 2) / areaSize.y, // 描画領域の左上のv
radius / areaSize.x, // 描画領域の幅(uv換算)
radius / areaSize.y); // 描画領域の高さ(uv換算)
// ブラシ描画用のMaterialにパラメータを設定
this.paintingMaterial.SetTexture("_MainTex", this.renderTextureFloor);
this.paintingMaterial.SetVector("_PaintArea", paintArea);
this.paintingMaterial.SetColor("_BrushColor", color);
// ブラシをRenderTextureに書き込む
Graphics.Blit(this.renderTextureFloor, this.renderTextureTemp, this.paintingMaterial);
Graphics.Blit(this.renderTextureTemp, this.renderTextureFloor);
}
pointが着弾位置、areaSizeが現実空間上の描画領域のサイズ(x=10m,y=10mとか)、radiusがテクスチャに書き込むブラシサイズになります。
(point.x + areaSize.x / 2 - radius / 2)で描画領域の左端を0とした場合の相対座標が求まります。これを描画領域の幅areaSize.xで割ることで、UV座標のUの値(0~1.0)が求まります。(Vについても同様に)
上記で求めたのは塗り範囲の左上の座標となりますので、後はサイズを radius / areaSize.xで求めれば、塗り範囲が求まります。
(すみません、radius(半径)って変数名はちょっと変ですね。。。)
テクスチャにインク模様を書き込む
テクスチャを動的に更新する方法としてはいくつかありますが、本アプリでは処理速度の関係から Graphics.Blit を利用しています。
// ブラシをRenderTextureに書き込む
Graphics.Blit(this.renderTextureFloor, this.renderTextureTemp, this.paintingMaterial);
Graphics.Blit(this.renderTextureTemp, this.renderTextureFloor);
Blit を使うことで Shaderを使ってテクスチャの書き込みができますので、以下の様なShaderを作成し、指定した塗り範囲にインク模様(ブラシテクスチャ)を書き込みます。
float4 frag(v2f_img i) : COLOR
{
float4 c = tex2D(_MainTex, i.uv);
float u = i.uv.x;
float v = i.uv.y;
// 描画領域変数を用意しとく
float uMin = _PaintArea.x;
float uMax = _PaintArea.x + _PaintArea.z;
float vMin = _PaintArea.y;
float vMax = _PaintArea.y + _PaintArea.w;
// 対象のピクセルのuv が 描画領域に含まれる場合、ブラシの該当する座標を参照し、
// ブラシテクスチャーの色が黒ければ 対象のピクセルの色を color に設定する。
if (uMin <= u && vMin <= v && u < uMax && v < vMax) {
float2 brushUv = float2(
frac((u - _PaintArea.x) / _PaintArea.z),
frac((v - _PaintArea.y) / _PaintArea.w));
float4 bc = tex2D(_BrushTex, brushUv);
// 対象のピクセル位置のBrush Textureの色が黒っぽければBrush Colorで塗る
if (bc.r + bc.g + bc.b < 1.5f) {
c = _BrushColor;
}
}
return c;
}
ここではパラメータで渡した塗り範囲に、ブラシテクスチャのパターンに従い色を塗るようにしています。
塗る色を外部パラメータで変更できるようにしたかったので、ブラシテクスチャのピクセルをそのまま描きだすのではなく、黒っぽいかどうか判定して、黒ければ色を塗るようにしています。
Shaderでテクスチャを描画する
以下がSpatialMappingのShaderのインク描画処理部分の抜粋になります。
// 3rd Pass. インク描画用シェーダー定義
ZWrite Off
ZTest LEqual
Blend SrcAlpha OneMinusSrcAlpha
Stencil
{
Ref[_Mask]
Comp NotEqual
}
CGPROGRAM
#pragma surface surf Standard fullforwardshadows alphatest:_Cutoff
#pragma target 5.0
#pragma only_renderers d3d11
sampler2D _MainTex;
float4 _AreaSize;
struct Input {
float2 uv_MainTex;
float3 worldPos;
float3 worldNormal;
};
void surf(Input IN, inout SurfaceOutputStandard o) {
// 範囲外
if (abs(IN.worldPos.x) > _AreaSize.x / 2 || abs(IN.worldPos.z) > _AreaSize.y / 2) {
o.Albedo = fixed3(0, 0, 0);
o.Alpha = 0;
}
// 地面
if (IN.worldNormal.y > 0.8) {
// worldPosをもとにuvを計算
float2 uv = float2(
(IN.worldPos.x + _AreaSize.x / 2) / _AreaSize.x,
(IN.worldPos.z + _AreaSize.y / 2) / _AreaSize.y);
// テクスチャの色を設定
fixed4 c = tex2D(_MainTex, uv);
o.Albedo = c.rgb;
o.Alpha = c.a;
}
// 天井、壁面
else if (IN.worldNormal.y < -1 / 1.414) {
o.Albedo = fixed3(0, 0, 0);
o.Alpha = 0;
}
}
色々と書かれていますが、実際の塗る処理は以下の部分になりまして、こちらでは"着弾位置から塗り範囲を計算する"で行ったのと同じように、塗る場所のIN.worldPosを元にテクスチャのUV座標を求めて、テクスチャの色を参照して表示しているだけです。
// worldPosをもとにuvを計算
float2 uv = float2(
(IN.worldPos.x + _AreaSize.x / 2) / _AreaSize.x,
(IN.worldPos.z + _AreaSize.y / 2) / _AreaSize.y);
// テクスチャの色を設定
fixed4 c = tex2D(_MainTex, uv);
o.Albedo = c.rgb;
o.Alpha = c.a;
(公開しているHoloInkShooterでは壁面も塗れるようにしていますが、(説明がややこしくなるので)今回は床面だけに限定しています。)
また、インク描画のためにSurface Shaderを使っているのですが、そのままでは塗った場所以外もSurface Shaderで描画されてしまい、以下のような全体的に白っぽい見た目になってしまいました。
こちらを解消するため、alphatest を有効にして、インクが塗られていない部分は描画されないようにしています。
#pragma surface surf Standard fullforwardshadows alphatest:_Cutoff
ただ、インクが塗られていない場所が描画されないと、その部分のオクルージョンが有効にならず、向こう側が透けてしまうため、オクルージョン用のパスとインクを描画するパス、ついでに走査線のようなエフェクトを表示するパスの3パスでマルチパスレンダリングを行い、最終的に以下のようなShaderとなりました。(フルのコードは[こちら] (https://github.com/arcsin16/SpatialMappingPaint/blob/master/Assets/SpatialMappingPaint/Shaders/SpatialMappingShader.shader)を参照)
Shader "Unlit/SpatialMappingShader"
{
・・・省略・・・
SubShader{
Tags{
"RenderType" = "Opaque"
"Queue" = "Geometry-1"
}
LOD 200
// 1st Pass. 凹みTips様のOcclusion用シェーダーを利用
// https://github.com/hecomi/HoloLensPlayground/blob/master/Assets/Holo_Spatial_Shading/Shaders/Occlusion.shader
UsePass "HoloLens/SpatialMapping/Occlusion/OCCLUSION"
// 2nd Pass. 走査線描画用シェーダー定義
Pass
{
ZWrite Off
ZTest LEqual
Blend SrcAlpha OneMinusSrcAlpha
Stencil
{
Ref[_Mask]
Comp NotEqual
}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 5.0
#pragma only_renderers d3d11
・・・省略・・・
ENDCG
}
// 3rd Pass. インク描画用シェーダー定義
ZWrite Off
ZTest LEqual
Blend SrcAlpha OneMinusSrcAlpha
Stencil
{
Ref[_Mask]
Comp NotEqual
}
CGPROGRAM
#pragma surface surf Standard fullforwardshadows alphatest:_Cutoff
#pragma target 5.0
#pragma only_renderers d3d11
・・・省略・・・
ENDCG
}
FallBack "Diffuse"
}
おわりに
どうやってSpatialMappingを塗れるようにするか悶々と考えて、自分なりにたどり着いた一つの方法となります。
壁面をどう塗るのか、範囲外に出ると塗れなくなるなど色々と課題はありますが、SpatialMappingを自由に塗れるようになると色々と面白い表現ができようになるんじゃないかなと。
追記(2017/07/17 01:12)
本当にごめんなさいorz githubのソースコード&説明にクリティカルな漏れが1点ありました。
というのも、通常SpatialMappingはMeshの法線ベクトルが設定されていないため、Shaderの
// 地面
if (IN.worldNormal.y > 0.8) {
の判定式が正常に動作せず、そのままでは床面の判定ができず、正常に動作しません。。。
これを回避するためには、以下の様な定期的に法線ベクトルを更新する処理を組み込み、Shaderから正常に法線ベクトルを参照できるようにする必要があります。
githubのコードを更新しましたので、取得してしまった方は更新のほどよろしくお願いいたします。あばばばば
public void Update()
{
if (!recalculatingNomals)
{
this.recalculatingNomals = true;
StartCoroutine(this.UpdateNormals());
}
}
private IEnumerator UpdateNormals()
{
yield return null;
float start = Time.realtimeSinceStartup;
List<MeshFilter> filters = SpatialMappingManager.Instance.GetMeshFilters();
for (int index = 0; index < filters.Count; index++)
{
MeshFilter filter = filters[index];
if (filter != null && filter.sharedMesh != null)
{
filter.sharedMesh.RecalculateNormals();
}
if ((Time.realtimeSinceStartup - start) > FrameTime)
{
yield return null;
start = Time.realtimeSinceStartup;
}
}
this.recalculatingNomals = false;
}