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&gt();

        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&gt();

            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);
        }
    }
}

使い方の手順

ここからは、具体的な使い方をステップ形式でまとめます。例として「プレイヤーキャラが壁の裏に隠れたときにシルエットを表示する」ケースを想定します。

手順① シルエット用プレハブを用意する

  1. Hierarchy でプレイヤーのモデル(MeshRenderer / SkinnedMeshRenderer がついているオブジェクト)を選択し、Ctrl + D で複製します。
  2. 複製したオブジェクトの名前を Player_Silhouette などに変更します。
  3. Material を単色のものに差し替えます。
    • Project ビューで右クリック → Create → Material。
    • 色を真っ白 or 好きな色に設定し、Rendering Mode を Fade or Transparent にして少し透明度を下げると「透けたシルエット」感が出ます。
    • このマテリアルを Player_Silhouette の MeshRenderer / SkinnedMeshRenderer に割り当てます。
  4. Player_Silhouette を Project ビューにドラッグしてプレハブ化し、Hierarchy 側の元オブジェクトは削除しておきます。

手順② プレイヤーに Silhouette コンポーネントをアタッチ

  1. Hierarchy でプレイヤーのルートオブジェクト(移動スクリプトなどがついている親)を選択します。
  2. Inspector で「Add Component」→ Silhouette を追加します。
  3. 以下のフィールドを設定します。
    • 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 くらいにすると「輪郭が少し大きく」見えて強調されます。

手順③ 壁(遮蔽物)用のレイヤーを設定する

  1. 壁や柱のオブジェクトを選択し、Inspector の「Layer」から新規レイヤー(例:Wall)を作成・割り当てます。
  2. Silhouette コンポーネントの Obstacle Layer Mask に、その Wall レイヤーを指定します。
  3. プレイヤー自身や敵など、「シルエット対象」はこのレイヤーに含めないように注意してください(自分自身を遮蔽物として誤検出してしまいます)。

手順④ 動作確認と具体例

  • プレイヤーキャラクターの例
    • プレイヤーを操作して、カメラの手前に壁が来るように動かします。
    • プレイヤーが壁の裏に完全に隠れたタイミングで、壁の手前に単色シルエットが表示されれば成功です。
  • 敵キャラクターの例
    • 敵のルートオブジェクトにも 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 に書き足していくよりも、ずっと健全な進め方なので、ぜひコンポーネント指向で育てていきましょう。