Unityを触り始めた頃は、つい「とりあえず Update() に全部書く」実装をしがちですよね。プレイヤーの移動、カメラ制御、当たり判定、エフェクトの制御、UI の更新……気づけば 500 行を超える巨大スクリプトになってしまい、どこを触ればいいのか分からなくなる、というパターンはありがちです。
とくに「壁の裏に隠れたらシルエットを表示したい」といった、見た目だけの処理をプレイヤー本体のスクリプトにベタ書きし始めると、あっという間に God クラス化してしまいます。
そこでこの記事では、「プレイヤーが壁の裏に隠れた時だけ、壁の手前に単色のシルエットを表示する」処理を、ひとつの責務に絞ったコンポーネントとして切り出してみましょう。その名も、「Silhouette」コンポーネントです。
【Unity】壁越しでも見失わない!「Silhouette」コンポーネント
このコンポーネントは、
- カメラからプレイヤーへのレイを飛ばして「遮蔽物があるか」を判定
- 遮蔽物があれば、壁の手前に単色のシルエット用オブジェクトを表示
- 遮蔽物がなければ、シルエットを非表示
という、シルエット表示の制御だけを担当します。
プレイヤーの移動やアニメーションとは完全に切り離されているので、プレイヤー以外(敵キャラや NPC)にも簡単に流用できますし、プレハブ化しておけばレベルデザインもかなり楽になります。
フルコード:Silhouette コンポーネント
using UnityEngine;
/// <summary>
/// カメラから見て「壁の裏に隠れた時だけ」
/// 壁の手前に単色シルエットを表示するコンポーネント。
///
/// - プレイヤー(または対象キャラ)のオブジェクトにアタッチして使う前提。
/// - シルエット用のプレハブ(単色マテリアルのコピー)を別途用意し、
/// このコンポーネントからインスタンス化して表示/非表示を切り替えます。
/// </summary>
public class Silhouette : MonoBehaviour
{
[Header("参照設定")]
[SerializeField]
private Camera targetCamera;
// シルエット判定に使うカメラ。
// 未指定の場合は、Awake で Camera.main を自動取得します。
[SerializeField]
private Transform target;
// シルエット表示の対象(通常はプレイヤー自身の Transform)。
// 未指定の場合は、Awake で this.transform を自動設定します。
[SerializeField]
private GameObject silhouettePrefab;
// シルエットとして表示するプレハブ。
// - 単色のマテリアル
// - 通常の MeshRenderer / SkinnedMeshRenderer を持ったオブジェクト
// を想定しています。
[Header("レイキャスト設定")]
[SerializeField]
private LayerMask obstacleLayerMask;
// 「遮蔽物」とみなすレイヤーを指定します。
// 壁や柱などをまとめたレイヤーを用意しておくと便利です。
[SerializeField]
private float raycastRadius = 0.1f;
// SphereCast の半径。
// 少し太めにしておくと、細い柱などでも判定しやすくなります。
[SerializeField]
private float extraDistance = 0.1f;
// 壁の手前にシルエットを出すときの「少し手前に出す距離」。
// 値が大きすぎると、壁から浮いて見えるので注意。
[Header("表示設定")]
[SerializeField]
private bool hideOriginalWhenOccluded = false;
// true のとき、壁に隠れている間は本体を非表示にする。
// false のとき、本体はそのまま、シルエットだけを追加表示する。
[SerializeField]
private float silhouetteScaleMultiplier = 1.0f;
// シルエットのスケール倍率。
// 1.0 で本体と同じ大きさ、それ以上で少し大きく見せることができます。
// 内部状態
private GameObject silhouetteInstance;
private bool isOccluded; // 現在「遮蔽されているかどうか」
private Renderer[] originalRenderers;
private Renderer[] silhouetteRenderers;
private void Awake()
{
// カメラ未指定なら main カメラを自動取得
if (targetCamera == null)
{
targetCamera = Camera.main;
}
// 対象未指定なら自身を対象とする
if (target == null)
{
target = this.transform;
}
// 対象の Renderer 群をキャッシュ
originalRenderers = target.GetComponentsInChildren<Renderer>();
if (silhouettePrefab == null)
{
Debug.LogWarning(
$"[Silhouette] シルエット用プレハブが設定されていません。"{name}" ではシルエットは表示されません。");
}
else
{
// シルエットプレハブをインスタンス化して非表示にしておく
silhouetteInstance = Instantiate(silhouettePrefab, target.position, target.rotation, null);
silhouetteInstance.name = $"{target.name}_Silhouette";
// 本体と同じスケールをベースに倍率をかける
silhouetteInstance.transform.localScale = target.lossyScale * silhouetteScaleMultiplier;
silhouetteRenderers = silhouetteInstance.GetComponentsInChildren<Renderer>();
SetSilhouetteVisible(false);
}
}
private void LateUpdate()
{
// カメラまたはターゲットが欠けている場合は何もしない
if (targetCamera == null || target == null)
{
return;
}
// カメラからターゲットへの方向と距離を計算
Vector3 cameraPos = targetCamera.transform.position;
Vector3 targetPos = target.position;
Vector3 dir = (targetPos - cameraPos).normalized;
float distance = Vector3.Distance(cameraPos, targetPos);
// カメラからターゲットまでの間に「遮蔽物」があるかどうかを SphereCast で判定
bool hitObstacle = Physics.SphereCast(
origin: cameraPos,
radius: raycastRadius,
direction: dir,
out RaycastHit hitInfo,
maxDistance: distance,
layerMask: obstacleLayerMask,
queryTriggerInteraction: QueryTriggerInteraction.Ignore
);
// 遮蔽状態が変わったら表示を更新
if (hitObstacle != isOccluded)
{
isOccluded = hitObstacle;
UpdateVisibility();
}
// 遮蔽されているときは、シルエットの位置・向きを更新
if (isOccluded && silhouetteInstance != null)
{
// 壁の手前に少しだけオフセットして配置
Vector3 silhouettePos = hitInfo.point - dir * extraDistance;
silhouetteInstance.transform.position = silhouettePos;
// カメラの方向を向かせる(ビルボード風)
silhouetteInstance.transform.rotation = Quaternion.LookRotation(-dir, Vector3.up);
// ターゲットのスケール変化に追従したい場合は、毎フレーム更新してもよい
silhouetteInstance.transform.localScale = target.lossyScale * silhouetteScaleMultiplier;
}
}
/// <summary>
/// 遮蔽状態に応じて、本体とシルエットの表示を切り替える。
/// </summary>
private void UpdateVisibility()
{
if (silhouetteInstance == null)
{
// プレハブ未設定などでシルエットが存在しない場合は、
// 本体の表示だけ切り替えておく。
SetOriginalVisible(!isOccluded || !hideOriginalWhenOccluded);
return;
}
// シルエットの表示切り替え
SetSilhouetteVisible(isOccluded);
// 本体の表示切り替え
bool showOriginal = !isOccluded || !hideOriginalWhenOccluded;
SetOriginalVisible(showOriginal);
}
/// <summary>
/// 本体側の Renderer の有効/無効をまとめて切り替える。
/// </summary>
private void SetOriginalVisible(bool visible)
{
if (originalRenderers == null) return;
for (int i = 0; i < originalRenderers.Length; i++)
{
if (originalRenderers[i] == null) continue;
originalRenderers[i].enabled = visible;
}
}
/// <summary>
/// シルエット側の Renderer の有効/無効をまとめて切り替える。
/// </summary>
private void SetSilhouetteVisible(bool visible)
{
if (silhouetteInstance == null || silhouetteRenderers == null) return;
for (int i = 0; i < silhouetteRenderers.Length; i++)
{
if (silhouetteRenderers[i] == null) continue;
silhouetteRenderers[i].enabled = visible;
}
}
/// <summary>
/// デバッグ用に、Scene ビューにレイと SphereCast の範囲を可視化する。
/// </summary>
private void OnDrawGizmosSelected()
{
if (targetCamera == null || target == null) return;
Vector3 cameraPos = targetCamera.transform.position;
Vector3 targetPos = target.position;
Vector3 dir = (targetPos - cameraPos).normalized;
float distance = Vector3.Distance(cameraPos, targetPos);
// レイの線
Gizmos.color = Color.cyan;
Gizmos.DrawLine(cameraPos, targetPos);
// SphereCast の範囲をいくつかの点で描画
Gizmos.color = new Color(0f, 1f, 1f, 0.25f);
const int segments = 6;
for (int i = 0; i <= segments; i++)
{
float t = (float)i / segments;
Vector3 center = Vector3.Lerp(cameraPos, targetPos, t);
Gizmos.DrawWireSphere(center, raycastRadius);
}
}
}
使い方の手順
ここからは、具体的な使い方をステップ形式でまとめます。例として「プレイヤーキャラが壁の裏に隠れたときにシルエットを表示する」ケースを想定します。
手順① シルエット用プレハブを用意する
- Hierarchy でプレイヤーのモデル(MeshRenderer / SkinnedMeshRenderer がついているオブジェクト)を選択し、Ctrl + D で複製します。
- 複製したオブジェクトの名前を
Player_Silhouetteなどに変更します。 - Material を単色のものに差し替えます。
- Project ビューで右クリック → Create → Material。
- 色を真っ白 or 好きな色に設定し、Rendering Mode を Fade or Transparent にして少し透明度を下げると「透けたシルエット」感が出ます。
- このマテリアルを
Player_Silhouetteの MeshRenderer / SkinnedMeshRenderer に割り当てます。
Player_Silhouetteを Project ビューにドラッグしてプレハブ化し、Hierarchy 側の元オブジェクトは削除しておきます。
手順② プレイヤーに Silhouette コンポーネントをアタッチ
- Hierarchy でプレイヤーのルートオブジェクト(移動スクリプトなどがついている親)を選択します。
- Inspector で「Add Component」→
Silhouetteを追加します。 - 以下のフィールドを設定します。
- Target Camera:通常は
Main Cameraをドラッグ&ドロップ(未設定でもCamera.mainを自動取得します)。 - Target:プレイヤーの見た目のルート(モデルの親)を指定。未設定なら
this.transformが使われます。 - Silhouette Prefab:手順①で作成した
Player_Silhouetteプレハブを指定。 - Obstacle Layer Mask:壁や柱に設定したレイヤー(例:
Wall)を選択。 - Raycast Radius:0.1~0.3 くらいから調整。
- Extra Distance:0.05~0.2 くらいにして、壁から少しだけ手前に出るように。
- Hide Original When Occluded:壁に隠れている間は本体を完全に隠したい場合はチェック。
- Silhouette Scale Multiplier:1.0 で同じサイズ、1.05 くらいにすると「輪郭が少し大きく」見えて強調されます。
- Target Camera:通常は
手順③ 壁(遮蔽物)用のレイヤーを設定する
- 壁や柱のオブジェクトを選択し、Inspector の「Layer」から新規レイヤー(例:
Wall)を作成・割り当てます。 Silhouetteコンポーネントの Obstacle Layer Mask に、そのWallレイヤーを指定します。- プレイヤー自身や敵など、「シルエット対象」はこのレイヤーに含めないように注意してください(自分自身を遮蔽物として誤検出してしまいます)。
手順④ 動作確認と具体例
- プレイヤーキャラクターの例:
- プレイヤーを操作して、カメラの手前に壁が来るように動かします。
- プレイヤーが壁の裏に完全に隠れたタイミングで、壁の手前に単色シルエットが表示されれば成功です。
- 敵キャラクターの例:
- 敵のルートオブジェクトにも
Silhouetteをアタッチし、敵用のシルエットプレハブを指定します。 - プレイヤーから見て敵が壁の裏に隠れたときだけ、敵のシルエットが見えるようになります。
- これにより「敵がどこにいるか全く分からない」ストレスを軽減しつつ、チラ見せ演出もできます。
- 敵のルートオブジェクトにも
- 動く床やリフトの例:
- 動く足場の裏にプレイヤーが入り込んだときにだけシルエット表示したい場合、足場オブジェクトにも
Wallレイヤーを設定します。 - レイキャストは常にカメラからプレイヤーを見ているので、足場が動いても自動で遮蔽判定が更新されます。
- 動く足場の裏にプレイヤーが入り込んだときにだけシルエット表示したい場合、足場オブジェクトにも
メリットと応用
この Silhouette コンポーネントの良いところは、「シルエット表示」というひとつの責務に処理を閉じ込めている点です。
- プレハブ管理が楽になる:
- プレイヤー、敵、NPC それぞれに専用のシルエットプレハブを用意しておけば、見た目のバリエーションも簡単に増やせます。
- 「この敵だけは赤いシルエットにしたい」といった要望も、プレハブを差し替えるだけで済みます。
- レベルデザインがしやすい:
- 壁や柱などの遮蔽物は、単に
Wallレイヤーをつけるだけでシルエット判定に組み込まれます。 - スクリプト側で特別な処理を書く必要がないので、「このオブジェクトは遮蔽物にする / しない」をレベルデザイナーが自由に切り替えられます。
- 壁や柱などの遮蔽物は、単に
- 責務が分離されていてテストしやすい:
- プレイヤーの移動ロジックを変更しても、シルエット表示には影響しません。
- シルエットの挙動だけを単体でデバッグ&調整できるので、バグの切り分けがしやすくなります。
また、応用としては:
- 敵のシルエット色を「警戒状態」「発見状態」で変える
- シルエットが出ている間だけ、UI に「遮蔽物に隠れています」アイコンを出す
- マルチプレイで、味方だけシルエット表示する
など、ゲームの見やすさや演出を強化する方向にいくらでも広げられます。
改造案:シルエットの色をフェードさせる
例えば、「遮蔽されてから少しずつシルエットの透明度を上げる / 下げる」演出を追加したい場合、以下のようなメソッドを追加してみると面白いです。
/// <summary>
/// シルエットのマテリアルのアルファ値を一括で変更するサンプル。
/// 0 = 完全透明, 1 = 不透明。
/// </summary>
private void SetSilhouetteAlpha(float alpha)
{
if (silhouetteRenderers == null) return;
alpha = Mathf.Clamp01(alpha);
for (int i = 0; i < silhouetteRenderers.Length; i++)
{
var renderer = silhouetteRenderers[i];
if (renderer == null) continue;
// 各マテリアルに対してアルファを設定
var materials = renderer.materials; // インスタンス化される点に注意
for (int m = 0; m < materials.Length; m++)
{
if (!materials[m].HasProperty("_Color")) continue;
Color c = materials[m].color;
c.a = alpha;
materials[m].color = c;
}
}
}
LateUpdate() などで Mathf.Lerp を使って alpha を補間してあげれば、ふわっとシルエットが現れる、気持ちの良い演出になりますね。
このように、「シルエット表示」という小さな責務に切り分けたコンポーネントにしておくと、後からの改造・演出追加もやりやすくなります。巨大な PlayerController に書き足していくよりも、ずっと健全な進め方なので、ぜひコンポーネント指向で育てていきましょう。
