Unityで壁に沿うスキャンエフェクトを作ってみる

はじめに


このブログでは、Meshに沿ってスキャンしているようなエフェクトが必要となったので、走査線を表示するシェーダーとMeshに沿うように表示される機能を作成方法を紹介します。

またこのスキャンエフェクト表示はMetaQuestコントローラーを使ってVRでも動作可能な形にしています。

シェーダー編


シェーダーは走査線が一定間隔で発生して、下から上に走査線が走り、それ以外の部分は透明になっているものを作ります。

イメージは画像の通りです。

シェーダーの作成


新規でUnlitシェーダーを作成して、以下のコードを貼り付けます。

その後、新規でマテリアルを作成してシェーダーを"Custom/ScanEffect"に設定すれば完成です。

  1. Shader "Custom/ScanEffect"
  2. {
  3.     Properties
  4.     {
  5.         _ScanColor ("Scan Color", Color) = (1,0,0,1) // 走査線の色を赤に
  6.         _ScanSpeed ("Scan Speed", Float) = 1.0
  7.         _LineWidth ("Line Width", Range(0.01, 0.2)) = 0.05
  8.         _LineCount ("Line Count", Float) = 3.0
  9.         _DepthOffset ("Depth Offset", Range(0.0001, 0.1)) = 0.01
  10.         _LineIntensity ("Line Intensity", Range(0.1, 2.0)) = 1.0 // 走査線の強度
  11.     }
  12.     
  13.     SubShader
  14.     {
  15.         Tags { "Queue"="Transparent+1" "RenderType"="Transparent" }
  16.         LOD 100
  17.         
  18.         ZWrite Off
  19.         ZTest LEqual
  20.         Offset -1, -1
  21.         
  22.         Blend SrcAlpha OneMinusSrcAlpha
  23.         Cull Back
  24.         
  25.         Pass
  26.         {
  27.             CGPROGRAM
  28.             #pragma vertex vert
  29.             #pragma fragment frag
  30.             #pragma multi_compile_instancing
  31.             
  32.             #include "UnityCG.cginc"
  33.             
  34.             struct appdata
  35.             {
  36.                 float4 vertex : POSITION;
  37.                 float2 uv : TEXCOORD0;
  38.                 float3 normal : NORMAL;
  39.                 UNITY_VERTEX_INPUT_INSTANCE_ID
  40.             };
  41.             
  42.             struct v2f
  43.             {
  44.                 float2 uv : TEXCOORD0;
  45.                 float4 vertex : SV_POSITION;
  46.                 UNITY_VERTEX_OUTPUT_STEREO
  47.             };
  48.             
  49.             float4 _ScanColor;
  50.             float _ScanSpeed;
  51.             float _LineWidth;
  52.             float _LineCount;
  53.             float _DepthOffset;
  54.             float _LineIntensity;
  55.             
  56.             v2f vert (appdata v)
  57.             {
  58.                 v2f o;
  59.                 UNITY_SETUP_INSTANCE_ID(v);
  60.                 UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
  61.                 
  62.                 float3 offsetVertex = v.vertex.xyz + v.normal * _DepthOffset;
  63.                 o.vertex = UnityObjectToClipPos(float4(offsetVertex, 1));
  64.                 o.uv = v.uv;
  65.                 return o;
  66.             }
  67.             
  68.             float scanLine(float2 uv, float offset)
  69.             {
  70.                 float pos = frac(uv.y * _LineCount - _Time.y * _ScanSpeed + offset);
  71.                 return smoothstep(0, _LineWidth, pos) * smoothstep(_LineWidth * 2, _LineWidth, pos);
  72.             }
  73.             
  74.             fixed4 frag (v2f i) : SV_Target
  75.             {
  76.                 UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
  77.                 
  78.                 float scan = 0;
  79.                 for(float j = 0; j < 1; j += 0.25)
  80.                 {
  81.                     scan += scanLine(i.uv, j);
  82.                 }
  83.                 
  84.                 // 走査線のみを表示し、背景を透明に
  85.                 float4 col = _ScanColor;
  86.                 col.a = scan * _LineIntensity;
  87.                 
  88.                 return col;
  89.             }
  90.             ENDCG
  91.         }
  92.     }
  93. }

シェーダーの機能解説


このシェーダーには以下の機能が含まれています。

  • スキャンラインの色 (_ScanColor) を指定できる。
  • スキャンの速度 (_ScanSpeed) を調整可能。
  • 線の太さ (_LineWidth) を調整可能。
  • 線の本数 (_LineCount) を設定可能。
  • 深度オフセット (_DepthOffset) により、オブジェクトの表面から少し浮かせることが可能。
  • 線の強度 (_LineIntensity) を調整可能。

これにより細かくカスタムした走査線エフェクトを表示することができます。

 

また仕組みとしては少しずつ位置をずらしながらscanLineを呼び出し、走査線以外は透過処理を行って走査線自体がループアニメーションになるように上に移動するものとなっています。

メッシュに沿う機能の作成


走査線のエフェクトはできたので次は壁や段差に沿うようにメッシュを変形させていきます。

MetaQuestコントローラーの先端からRayを発射し、壁などのコライダーにぶつかると、その地点を基準にメッシュが表示されるようにします。

新規でScanAreaVisualizerという名前のスクリプトを作成し、以下のコードを貼り付けてください。

  1. using UnityEngine;
  2. [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
  3. public class ScanAreaVisualizer : MonoBehaviour
  4. {
  5.     // Fields
  6.     [Header("Mesh Settings")]
  7.     public float scanWidth = 1.6f;
  8.     public float scanHeight = 0.9f;
  9.     public int resolution = 50; // 解像度をデフォルトで上げる
  10.     [Header("Raycast Settings")]
  11.     public float raycastDistance = 0.5f;
  12.     public LayerMask surfaceLayer;
  13.     public float rayStartOffset = 0.1f;
  14.     public int additionalRays = 2; // 追加のレイキャスト数
  15.     [Header("Surface Following")]
  16.     public float maxSurfaceAngle = 85f;
  17.     public int smoothingPasses = 1;
  18.     public float interpolationStrength = 0.5f; // 補間の強さ
  19.     public Material scanMaterial;
  20.     private MeshFilter meshFilter;
  21.     private MeshRenderer meshRenderer;
  22.     private Vector3[] smoothedVertices;
  23.     private Vector3 controllerPosition;
  24.     private Quaternion controllerRotation;
  25.     private bool controllerTransformSet = false;
  26.     // Constants
  27.     private readonly Vector2 NORMAL_SIZE = new Vector2(1.6f, 0.9f); // 16:9
  28.     private readonly Vector2 WIDE_SIZE = new Vector2(1.6f, 0.2f); // 32:9
  29.     public enum ScanSizePreset
  30.     {
  31.         Normal,
  32.         Wide
  33.     }
  34.     // Unity methods
  35.     void Start()
  36.     {
  37.         meshFilter = GetComponent<MeshFilter>();
  38.         meshRenderer = GetComponent<MeshRenderer>();
  39.         if (scanMaterial != null)
  40.             meshRenderer.material = scanMaterial;
  41.         smoothedVertices = new Vector3[(resolution + 1) * (resolution + 1)];
  42.     }
  43.     void Update()
  44.     {
  45.         // コントローラーの位置と回転が設定されていない場合は処理をスキップ
  46.         if (!controllerTransformSet) return;
  47.         // コントローラーの前方方向を計算
  48.         Vector3 rayDirection = controllerRotation * Vector3.forward;
  49.         Vector3 rayStart = controllerPosition - rayDirection * rayStartOffset;
  50.         if (Physics.Raycast(rayStart, rayDirection, out RaycastHit centerHit, raycastDistance, surfaceLayer))
  51.         {
  52.             if (Vector3.Angle(centerHit.normal, -rayDirection) < maxSurfaceAngle)
  53.             {
  54.                 // スキャンビジュアライザーの位置と回転をヒットポイントに合わせる
  55.                 transform.position = centerHit.point;
  56.                 transform.rotation = Quaternion.LookRotation(-centerHit.normal);
  57.                 UpdateMesh(centerHit.point, centerHit.normal);
  58.             }
  59.             else if (meshRenderer.enabled)
  60.             {
  61.                 meshRenderer.enabled = false;
  62.             }
  63.         }
  64.         else if (meshRenderer.enabled)
  65.         {
  66.             meshRenderer.enabled = false;
  67.         }
  68.     }
  69.     private void OnValidate()
  70.     {
  71.         raycastDistance = Mathf.Max(0.1f, raycastDistance);
  72.         resolution = Mathf.Max(2, resolution);
  73.         scanWidth = Mathf.Max(0.01f, scanWidth);
  74.         scanHeight = Mathf.Max(0.01f, scanHeight);
  75.     }
  76.     // コントローラーの位置と回転を設定するメソッド
  77.     public void SetControllerTransform(Vector3 position, Quaternion rotation)
  78.     {
  79.         controllerPosition = position;
  80.         controllerRotation = rotation;
  81.         controllerTransformSet = true;
  82.     }
  83.     // サイズプリセットに基づいてサイズを設定
  84.     public void SetScanSize(ScanSizePreset preset)
  85.     {
  86.         Vector2 newSize = GetSizeForPreset(preset);
  87.         scanWidth = newSize.x;
  88.         scanHeight = newSize.y;
  89.     }
  90.     // メッシュの中心を取得
  91.     public Vector3 GetMeshCenter()
  92.     {
  93.         // ローカル座標系での中心オフセット
  94.         Vector3 localCenterOffset = new Vector3(0f, 0f, scanHeight / 2f); // Z方向にスキャン範囲の半分をオフセット
  95.         return transform.position + transform.rotation * localCenterOffset;
  96.     }
  97.     // メッシュを更新
  98.     private void UpdateMesh(Vector3 hitPoint, Vector3 hitNormal)
  99.     {
  100.         if (!meshRenderer.enabled)
  101.             meshRenderer.enabled = true;
  102.         Mesh mesh = new Mesh();
  103.         Vector3[] vertices = new Vector3[(resolution + 1) * (resolution + 1)];
  104.         Vector3[] normals = new Vector3[(resolution + 1) * (resolution + 1)];
  105.         int[] triangles = new int[resolution * resolution * 6];
  106.         Vector2[] uvs = new Vector2[(resolution + 1) * (resolution + 1)];
  107.         float stepX = scanWidth / resolution;
  108.         float stepY = scanHeight / resolution;
  109.         Vector3 centerOffset = transform.right * (scanWidth / 2f) + transform.up * (scanHeight / 2f);
  110.         Vector3 startPos = hitPoint - centerOffset;
  111.         // マルチサンプリングのためのオフセット配列
  112.         Vector3[] sampleOffsets = new Vector3[additionalRays * additionalRays];
  113.         float offsetStep = 0.5f / additionalRays;
  114.         for (int x = 0; x < additionalRays; x++)
  115.         {
  116.             for (int y = 0; y < additionalRays; y++)
  117.             {
  118.                 sampleOffsets[x * additionalRays + y] = new Vector3(
  119.                     (x - additionalRays / 2f) * offsetStep * stepX,
  120.                     (y - additionalRays / 2f) * offsetStep * stepY,
  121.                     0
  122.                 );
  123.             }
  124.         }
  125.         // より精密な頂点配置
  126.         for (int i = 0; i <= resolution; i++)
  127.         {
  128.             for (int j = 0; j <= resolution; j++)
  129.             {
  130.                 int index = i * (resolution + 1) + j;
  131.                 Vector3 basePos = startPos + transform.right * (j * stepX) + transform.up * (i * stepY);
  132.                 // マルチサンプリングレイキャスト
  133.                 Vector3 averagePoint = Vector3.zero;
  134.                 Vector3 averageNormal = Vector3.zero;
  135.                 int hitCount = 0;
  136.                 // メインのレイキャスト
  137.                 Vector3 rayOrigin = basePos - transform.forward * rayStartOffset;
  138.                 if (Physics.Raycast(rayOrigin, transform.forward, out RaycastHit mainHit, raycastDistance, surfaceLayer))
  139.                 {
  140.                     if (Vector3.Angle(mainHit.normal, -transform.forward) < maxSurfaceAngle)
  141.                     {
  142.                         averagePoint += mainHit.point;
  143.                         averageNormal += mainHit.normal;
  144.                         hitCount++;
  145.                     }
  146.                 }
  147.                 // 追加のサンプリングレイ
  148.                 foreach (Vector3 offset in sampleOffsets)
  149.                 {
  150.                     Vector3 sampleRayOrigin = rayOrigin + transform.TransformDirection(offset);
  151.                     if (Physics.Raycast(sampleRayOrigin, transform.forward, out RaycastHit sampleHit, raycastDistance, surfaceLayer))
  152.                     {
  153.                         if (Vector3.Angle(sampleHit.normal, -transform.forward) < maxSurfaceAngle)
  154.                         {
  155.                             averagePoint += sampleHit.point;
  156.                             averageNormal += sampleHit.normal;
  157.                             hitCount++;
  158.                         }
  159.                     }
  160.                 }
  161.                 // 結果の処理
  162.                 if (hitCount > 0)
  163.                 {
  164.                     averagePoint /= hitCount;
  165.                     averageNormal = (averageNormal / hitCount).normalized;
  166.                     vertices[index] = transform.InverseTransformPoint(averagePoint);
  167.                     normals[index] = transform.InverseTransformDirection(averageNormal);
  168.                 }
  169.                 else
  170.                 {
  171.                     // 補間処理の改善
  172.                     Vector3 interpolatedPos = basePos;
  173.                     int validNeighbors = 0;
  174.                     Vector3 sumNeighbors = Vector3.zero;
  175.                     // 近傍の有効な点を探す
  176.                     if (j > 0 && vertices[index - 1] != Vector3.zero)
  177.                     {
  178.                         sumNeighbors += transform.TransformPoint(vertices[index - 1]);
  179.                         validNeighbors++;
  180.                     }
  181.                     if (i > 0 && vertices[index - (resolution + 1)] != Vector3.zero)
  182.                     {
  183.                         sumNeighbors += transform.TransformPoint(vertices[index - (resolution + 1)]);
  184.                         validNeighbors++;
  185.                     }
  186.                     if (validNeighbors > 0)
  187.                     {
  188.                         interpolatedPos = Vector3.Lerp(basePos, sumNeighbors / validNeighbors, interpolationStrength);
  189.                     }
  190.                     vertices[index] = transform.InverseTransformPoint(interpolatedPos);
  191.                     normals[index] = transform.InverseTransformDirection(-transform.forward);
  192.                 }
  193.                 uvs[index] = new Vector2(j / (float)resolution, i / (float)resolution);
  194.             }
  195.         }
  196.         // 改善されたスムージング処理
  197.         smoothedVertices = (Vector3[])vertices.Clone();
  198.         for (int pass = 0; pass < smoothingPasses; pass++)
  199.         {
  200.             SmoothVertices(ref smoothedVertices, vertices); // オリジナルの頂点情報も渡す
  201.         }
  202.         // トライアングルの設定
  203.         int tris = 0;
  204.         for (int i = 0; i < resolution; i++)
  205.         {
  206.             for (int j = 0; j < resolution; j++)
  207.             {
  208.                 int vertIndex = i * (resolution + 1) + j;
  209.                 triangles[tris] = vertIndex;
  210.                 triangles[tris + 1] = vertIndex + resolution + 1;
  211.                 triangles[tris + 2] = vertIndex + 1;
  212.                 triangles[tris + 3] = vertIndex + 1;
  213.                 triangles[tris + 4] = vertIndex + resolution + 1;
  214.                 triangles[tris + 5] = vertIndex + resolution + 2;
  215.                 tris += 6;
  216.             }
  217.         }
  218.         mesh.vertices = smoothedVertices;
  219.         mesh.triangles = triangles;
  220.         mesh.normals = normals;
  221.         mesh.uv = uvs;
  222.         meshFilter.mesh = mesh;
  223.     }
  224.     // 頂点同士のスムーズ化
  225.     private void SmoothVertices(ref Vector3[] vertices, Vector3[] originalVertices)
  226.     {
  227.         Vector3[] tempVertices = (Vector3[])vertices.Clone();
  228.         for (int i = 1; i < resolution; i++)
  229.         {
  230.             for (int j = 1; j < resolution; j++)
  231.             {
  232.                 int index = i * (resolution + 1) + j;
  233.                 Vector3 average = vertices[index] * 2.0f +
  234.                                   vertices[index - 1] +
  235.                                   vertices[index + 1] +
  236.                                   vertices[index - (resolution + 1)] +
  237.                                   vertices[index + (resolution + 1)];
  238.                 // オリジナルの頂点位置も考慮
  239.                 tempVertices[index] = Vector3.Lerp(average / 6f, originalVertices[index], 0.3f);
  240.             }
  241.         }
  242.         vertices = tempVertices;
  243.     }
  244.     // プリセットに応じたサイズを返す
  245.     private Vector2 GetSizeForPreset(ScanSizePreset preset)
  246.     {
  247.         return preset switch
  248.         {
  249.             ScanSizePreset.Wide => WIDE_SIZE,
  250.             _ => NORMAL_SIZE,
  251.         };
  252.     }
  253.     public bool IsDisplaying
  254.     {
  255.         get
  256.         {
  257.             return meshRenderer != null && meshRenderer.enabled;
  258.         }
  259.     }
  260. }

上記のコードを適当なGameObjectにアタッチして、スキャンエフェクトの影響を受けるSurface Layerとマテリアルの設定(最初に作った走査線のマテリアル)を行えばエフェクト部分は完了です。

後はこれを別のスクリプトから位置と回転情報を受け取れば完成です。

メッシュ変形の機能解説


メッシュ変形で用意されている調整可能な機能は以下の通りです。

[Header("Mesh Settings")]

  • scanWidth / scanHeight → スキャン範囲のサイズ
  • resolution → メッシュの解像度(頂点の密度)

 

[Header("Raycast Settings")]

  • raycastDistance → レイキャストの最大距離(表面との距離)
  • surfaceLayer → どのレイヤーに対してレイキャストを行うか
  • rayStartOffset → レイキャストの開始位置
  • additionalRays → 補助レイキャストの数(補間用)

 

  • public Material scanMaterial → スキャン時のマテリアル(ScanEffect.shader などを適用するため)

 

  • meshFilter / meshRendererMeshFilterMeshRenderer を取得し、スキャンメッシュを管理する
  • smoothedVertices → スムージング後の頂点データ
  • controllerPosition / controllerRotation → コントローラーの位置と回転情報
  • controllerTransformSet → コントローラーの位置・回転が設定されたかどうか

 

仕組みは以下のようになります。

初めにMeshFilterとMeshRendererを取得してマテリアルを適用します。

次に毎フレーム、コントローラーの向きにレイを飛ばし、オブジェクトの表面にヒットするか確認、表面の角度が maxSurfaceAngle以内ならスキャンエリアの位置と回転を調整してUpdateMesh() を呼び出してメッシュを再計算します。レイがヒットしなかったらmeshRendererを無効化します。

UpdateMesh()ではmeshRenderer を有効化し、頂点などを初期化した後にスキャン範囲を resolution に応じたグリッドで生成します。

そしてメッシュの形に違和感がないようにSmoothVerticesでメッシュのスムージングを行います。

 

コントローラーから位置と回転情報を送ってもらう


最後にコントローラーの位置と回転情報が取れたら正常に動きます。

データを渡す処理は以下の通りです。

  1. using UnityEngine;
  2. public class ControllerManager : MonoBehaviour
  3. {
  4.     public ScanAreaVisualizer scanVisualizer;
  5.     public GameObject VRcontroller;
  6.     
  7.     void Update()
  8.     {
  9.         // VRコントローラーの位置と回転を取得
  10.         Transform controller = VRcontroller.transform;
  11.         
  12.         // コントローラーの位置と回転をScanAreaVisualizerに設定
  13.         scanVisualizer.SetControllerTransform(controller.position, controller.rotation);
  14.     }
  15. }

上記のコードを適当なGameObjectに設定して、ScanVisualizerとコントローラーのアタッチを行うと、スキャンエフェクトが表示されます。

まとめ


走査線のエフェクトをシェーダーで作成、またメッシュも壁なや物などの形状に合わせて変形する機能も作成できました。精度を高くすると動作が重たくなりますが、サンプルコードぐらいの設定であればスタンドアロンのMetaQuestでも快適に動作が可能です。

また、注意点として全てのオブジェクトに最初からスキャンエフェクトが作用するわけではなく、自分で設定したレイヤーのオブジェクトだけこのスキャンの影響を受けるようになります。

上記ブログの内容に少しでも興味がありましたら、お気軽にご連絡ください。

弊社のエンジニアがフレンドリーに対応させていただきます。