ブロックで構成された3Dゲームでは、プレイヤーとカメラの間に壁が入り、キャラクターが見えなくなることがあります。
本記事では、この視認性問題に対して、Frustum(視錐台)判定とアウトライン表示を組み合わせて改善した方法を紹介します。
プロジェクト概要
- ステージがブロックで構成されている
- カメラはプレイヤーに追従し、マウス操作などは行わない
- カメラは各軸で90°ずつ回転できる
実現したいこと
- ステージ(ブロックオブジェクト)に視野をさえぎられている場合に、プレイヤーを視認できるようにしたい
- プレイヤーとカメラの間にある、描画処理を施すブロックオブジェクトも把握できるようにしたい
没案
Enum分類方式
当初は、ブロックを列挙型(Enum)で【手前、奥、天井、床、左、右】の6種類に分類し、手前に分類されたブロックを透過することでプレイヤーを視認できるように試みました。しかし、この方法ではプレイヤーとカメラの間に存在する、手前以外に分類されたブロックは透過対象にならず、十分な視認性を確保できませんでした。また、全てのブロックを6種類に分類するため、更新時に単純計算で全体のブロック数の1/6に透過処理、1/6に元の描画に戻す処理が発生します。そのため、必要なブロックのみを対象とする設計に比べて無駄な処理が多く、効率面でも課題がありました。総合的に、視認性・処理効率の両面で要件を満たしきれず、不採用としました。
シェーダー透過方式
次に、ブロックのシェーダーでピンホール型の透過処理を施す手法を試みました。この方法であれば常に手前に来るブロックを透過することができます。しかし、この方法には視認性と処理負荷の二点で問題がありました。視認性においては、透過する関係上プレイヤーとカメラ間のステージ構造が把握できず、プレイヤーのストレスになってしまいます。処理負荷においては、ブロック全体に対して毎フレームスクリーン座標変換や距離計算、条件分岐を伴うシェーダー処理が発生するため、ブロック数や解像度の増加に比例して負荷が上昇する構造となっていました。特に本作品のように多数のブロックで構成されたステージでは、画面内に映るブロック面すべてに対してピクセル単位の判定が行われるため、将来的なステージ規模拡大や描画品質向上を考慮すると、保守性・拡張性の面でも課題が残ります。
解決策
最終的に、プレイヤーとカメラ間に存在するブロックのみをFrustumで抽出し、そのブロックにアウトライン表示を行う方式を採用しました。
1. Frustumについて
初めに、Frustumとは簡単に説明を行うとカメラの見える範囲のことです(図 1)。
Frustumは6つの平面で構成された凸多面体であり、各平面の内側に存在するオブジェクトのみが「視界内」と判定されます。

(画像 1 Frustum図説)
そしてUnityにおいてFrustumはPlane配列で保持されており、要素はFrustumを構成する6つの面で、格納順は以下の通りです。
| 要素 | 内容 |
|---|---|
| 0 | 左 |
| 1 | 右 |
| 2 | 下 |
| 3 | 上 |
| 4 | 前 |
| 5 | 奥 |
このFrustumの奥Planeをプレイヤーキャラクターの奥行座標をもとに生成したPlaneに置き換え、各ブロックの Renderer.bounds に対して、GeometryUtility.TestPlanesAABB() を用いることで、Frustum内に存在するブロックのみ取得できます。通常のFrustumはカメラから遠方までを対象としますが、本実装ではFarPlaneをプレイヤー位置に再定義することで、「カメラからプレイヤーまでの区間」のみを判定対象として、不要なブロックを除外しながら効率的にオブジェクトを取得できます。
/// <summary>
/// Frustumの奥面をプレイヤーの座標をもとに作成したPlaneに置き換える
/// </summary>
public Plane[] SetFrustumFarPlaneAtPlayer()
{
// 奥面の要素番号
int farPlaneNum = 5;
// メインカメラのFrustumを取得
Plane[] frustumPlane = GeometryUtility.CalculateFrustumPlanes(m_mainCamera);
// プレイヤーの座標を取得
Vector3 playerPoint = m_playerObject.transform.position;
// 先ほどの座標をもとにPlaneを作成
Plane playerFarPlane = new Plane(-m_mainCamera.transform.forward, playerPoint);
// Frustumの奥面を作成したPlaneに変更
frustumPlane[farPlaneNum] = playerFarPlane;
return frustumPlane;
}
2. 対象のブロックを取得する
Unityでは GeometryUtility.TestPlanesAABB() を使用することで、Plane配列とAABB(Axis Aligned Bounding Box)の交差判定を行えます。
各ブロックは Renderer.bounds によってワールド座標系のAABBを取得できるため、先ほど生成したFrustum Plane群と判定することで、プレイヤーとカメラ間に存在するブロックのみ抽出できます。
この判定では、各ブロックのバウンディングボックスとFrustumを構成する6つの平面との位置関係を評価し、すべての平面の内側または交差している場合に対象として扱います。
これにより、個々のポリゴン単位ではなく、オブジェクト単位で高速に可視判定を行うことができます。
Frustumは空間の絞り込み、AABBは個々のオブジェクトとの交差判定を担っており、
この2つを組み合わせることで効率的に対象ブロックを抽出しています。
/// <summary>
/// Frustum内に存在するブロックのRendererを取得する関数
/// *今回はアウトライン化するためにRendererを取得しています
/// </summary>
public HashSet<Renderer> GetTouchObject()
{
// Frustum内に存在するオブジェクトのRendererを格納する集合
HashSet<Renderer> touchedObjectRenderers = new HashSet<Renderer>();
// 先ほど作成したFrustum関数
Plane[] planes = SetFrustumFarPlaneAtPlayer();
// ステージルートから子オブジェクト(ブロック群)のRendererを取得する
Renderer[] renderers = stageRoot.GetComponentsInChildren<Renderer>();
// ブロック全体にAABB走査を行う
// ステージ規模によってはあらかじめ走査を行う対象を絞り込んでください
foreach (Renderer renderer in renderers)
{
if (renderer == null || renderer.gameObject == null) continue;
if (GeometryUtility.TestPlanesAABB(planes, renderer.bounds))
{
touchedObjectRenderers.Add(renderer);
}
}
return touchedObjectRenderers;
}
3. 対象ブロックをアウトライン化する
最後に、アウトライン化についてですが、先ほど取得したオブジェクトのマテリアルのテクスチャを切り替えることで実現しています。
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// ブロックのテクスチャを切り替えるクラス
/// </summary>
public class BlockOutline : MonoBehaviour
{
[SerializeField]
private Texture m_outlineTexture; // アウトラインテクスチャ
private MaterialPropertyBlock m_block;
HashSet<Renderer> m_previousSelection = new HashSet<Renderer>(); // 新規取得したRendererと比較を行うための既存Rendererを保持するリスト
Dictionary<Renderer, Texture> m_originalTexture = new Dictionary<Renderer, Texture>(); // Rendererごとに元のテクスチャを保持するための辞書
void Awake()
{
m_block = new MaterialPropertyBlock();
}
/// <summary>
/// 受け取ったブロック群のテクスチャ切り替えを行う関数
/// </summary>
/// <param name="newSelection"></param>
public void UpdateOutline(HashSet<Renderer> newSelection)
{
// 既存のRendererリストの要素が最新のRendererリストの要素から外れていたら、元のテクスチャに戻す
foreach (var renderer in m_previousSelection)
{
if (renderer == null || renderer.gameObject == null) continue;
if (!newSelection.Contains(renderer))
RestoreTexture(renderer);
}
// 最新のRendererリストの要素が既存のRendererリストの要素に含まれていなければ、アウトライン化を行う
foreach (var renderer in newSelection)
{
if (renderer == null || renderer.gameObject == null) continue;
if (!m_previousSelection.Contains(renderer))
ApplyOutline(renderer);
}
// 最新のRendererリストを既存のRendererに代入する
m_previousSelection = new HashSet<Renderer>(newSelection);
}
/// <summary>
/// アウトラインテクスチャにする関数
/// </summary>
/// <param name="renderer"></param>
void ApplyOutline(Renderer renderer)
{
renderer.GetPropertyBlock(m_block);
if(!m_originalTexture.ContainsKey(renderer))
{
m_originalTexture[renderer] = renderer.sharedMaterial.GetTexture("_MainTex");
}
m_block.SetTexture("_MainTex", m_outlineTexture);
renderer.SetPropertyBlock(m_block);
}
/// <summary>
/// 元のテクスチャに戻す関数
/// </summary>
/// <param name="renderer"></param>
void RestoreTexture(Renderer renderer)
{
if (renderer == null || renderer.gameObject == null) return;
renderer.GetPropertyBlock(m_block);
if (m_originalTexture.TryGetValue(renderer, out var original))
{
if (original != null)
{
m_block.SetTexture("_MainTex", original);
renderer.SetPropertyBlock(m_block);
}
m_originalTexture.Remove(renderer);
}
}
}
※本実装では _MainTex を対象としていますが、使用するシェーダーによってはプロパティ名(例:_BaseMap など)が異なるため、適宜変更する必要があります。
結果

プレイヤーとカメラ間のブロックのみがアウトライン表示され、ステージ構造を保ったままプレイヤーの視認性を確保できていることが確認できます。
改善点
現時点の実装では全ブロックを走査しているため、ステージ規模が大きくなるとCPU負荷が増加します。そのため今後は、
- チャンク単位での空間分割
- 空間インデックス(グリッド・Octree等)
を用いて、Frustum判定前に候補を削減することでさらなる最適化を行う予定です。
また、機器スペックや処理負荷をもとに、アウトライン化方式を
- マテリアルのテクスチャ切り替え
- シェーダーでの描画
から選択可能な設計へ拡張する予定です。
視覚面においてはテクスチャ切り替え時のチラつきの軽減や、アウトラインの見やすさ向上についても改善していく予定です。
結論
この実装手法をとることにより、以下の三点を実現できました。
- プレイヤーを視認できるようになった
- ステージ構造も把握できるようになった
- 必要なオブジェクトにのみ処理を行うことで、負荷を抑えられた
単にプレイヤーを見えるようにするだけでなく、「必要なオブジェクトのみを対象とする」ことで処理負荷を抑えつつ視認性を確保する設計としました。視認性とパフォーマンスはトレードオフになりやすい要素ですが、本手法によりそのバランスを取ることができました。