【Cocos Creator 3.8】ShadowBlob(丸影)の実装:アタッチするだけで「キャラの足元に自動追従する楕円影」を実現する汎用スクリプト

このコンポーネントは、3Dキャラクターの足元に「ぷにっ」とした丸影(Blob Shadow)を自動で配置するための汎用スクリプトです。
キャラ(または任意のオブジェクト)のノードにアタッチするだけで、下方向にレイキャストを飛ばし、地面の高さに合わせて影用ノードを自動生成・配置・スケールします。

外部の GameManager や別スクリプトへの依存は一切なく、このコンポーネント単体だけで完結します。影のサイズ・色・距離などはすべてインスペクタから調整可能です。


コンポーネントの設計方針

機能要件の整理

  • このコンポーネントをアタッチしたノード(= キャラなど)の「足元」に楕円影を表示する。
  • 毎フレーム、ノードの下方向にレイキャストを飛ばし、ヒットした地面の位置に影を置く。
  • 地面との距離に応じて、影の大きさ(スケール)や透明度を変化させられる。
  • 影用のノードとマテリアルは、コンポーネント内で自動生成する(外部のプレハブやマテリアルに依存しない)。
  • 必要に応じて、既存の子ノードを影として利用できるオプションも用意する(デザイナーが見た目を差し替えたい場合向け)。
  • 3D物理(PhysicsSystem)を使用し、RayCast(raycastClosest)で地面を検出する。
  • 防御的実装:PhysicsSystem が無効な場合や、Collider が存在しない場合などはログでユーザに知らせる。

外部依存をなくすためのアプローチ

  • 影ノードは、アタッチされたノードの子として自動生成する(または既存ノードを指定)。
  • 影に使用するマテリアルはコード内で Material を生成し、単純な半透明の Unlit マテリアルを構築する。
  • レイキャストは Cocos Creator 標準の PhysicsSystem のみを利用し、他のカスタムスクリプトには依存しない。
  • 必要な設定値(距離、サイズ、色など)はすべて @property でインスペクタから指定できるようにする。

インスペクタで設定可能なプロパティ設計

  • useCustomShadowNode: boolean
    – 既存の子ノードを影として利用するかどうか。
    true: customShadowNode で指定したノードを影として使用。
    false: スクリプトが自動で影用ノードを生成する。
  • customShadowNode: Node | null
    useCustomShadowNode が true の時に使用する影ノード。
    – ここに指定されたノードの位置・回転・スケールをスクリプトが制御する。
  • raycastLocalOffset: Vec3
    – レイキャストの発射位置のローカルオフセット。
    – 例: キャラの腰から足元までの中心にしたい場合などに調整。
  • raycastMaxDistance: number
    – レイキャストの最大距離(メートル)。
    – キャラからどれくらい下まで地面を探すかを指定。
    – 例: 5.0 なら、キャラの 5m 下までの地面を検出する。
  • raycastLayerMask: number
    – レイキャスト対象のレイヤーマスク(PhysicsSystem.instance.raycastClosestmask)。
    – 地面用レイヤーに合わせて設定することで、不要なオブジェクトに当たらないようにできる。
  • shadowBaseSize: Vec2
    – 影の基準サイズ(X: 幅, Y: 奥行き)。
    – 例: (1.5, 1.0) で横長の楕円影にする。
  • shadowSizeScaleByDistance: number
    – 地面との距離に応じたサイズスケール係数。
    – 0 なら距離に関わらず一定サイズ。
    – 正の値で、地面から離れるほど影が大きく(または小さく)なるようにできる。
  • shadowMinAlpha: number
    – 影の最小アルファ(0.0〜1.0)。
    – キャラが地面から離れたときの影の薄さ。
  • shadowMaxAlpha: number
    – 影の最大アルファ(0.0〜1.0)。
    – キャラが地面に接しているときの影の濃さ。
  • shadowColor: Color
    – 影の基本色(通常は黒〜濃いグレー)。
    – アルファは shadowMinAlpha/shadowMaxAlpha で制御するため、RGB のみ意識すればよい。
  • shadowVerticalOffset: number
    – 地面から影を少し浮かせるオフセット(Z-fighting 回避用)。
    – 0.01〜0.05 くらいにすると、地面と重なってチラつく現象を軽減できる。
  • alignToGroundNormal: boolean
    – 地面の法線に合わせて影の向きを傾けるかどうか。
    – 坂道や段差で影が地形に沿うようにしたい場合に true。
  • updateInEditor: boolean
    – エディタ上で再生していない状態でも影の位置を更新するかどうか。
    – true にすると、シーンを編集しながら影の位置を確認しやすくなる。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    Vec2,
    Vec3,
    Color,
    Material,
    MeshRenderer,
    primitives,
    Mesh,
    geometry,
    PhysicsSystem,
    Quat,
    director,
} from 'cc';
const { ccclass, property, executeInEditMode, requireComponent } = _decorator;

/**
 * ShadowBlob
 * キャラの足元にレイキャストを飛ばし、地面に楕円形の影を配置する汎用コンポーネント。
 */
@ccclass('ShadowBlob')
@executeInEditMode(true)
@requireComponent(MeshRenderer)
export class ShadowBlob extends Component {

    @property({
        tooltip: '既存の子ノードを影として使用するかどうか。\ntrue: customShadowNode を使用\nfalse: 自動生成された影ノードを使用',
    })
    public useCustomShadowNode: boolean = false;

    @property({
        tooltip: 'useCustomShadowNode が true の場合に使用する影ノード。\nこのノードの位置・回転・スケールはスクリプトが制御します。',
    })
    public customShadowNode: Node | null = null;

    @property({
        tooltip: 'レイキャストの発射位置のローカルオフセット(このノードのローカル座標系)。\n通常は (0, キャラの中心高さ, 0) など。',
    })
    public raycastLocalOffset: Vec3 = new Vec3(0, 1.0, 0);

    @property({
        tooltip: 'レイキャストの最大距離(メートル)。\nこの距離以内の地面を検出します。',
        min: 0.1,
    })
    public raycastMaxDistance: number = 5.0;

    @property({
        tooltip: 'レイキャスト対象のレイヤーマスク。\n地面用レイヤーに合わせて設定してください。',
    })
    public raycastLayerMask: number = 0xffffffff;

    @property({
        tooltip: '影の基準サイズ(X: 幅, Y: 奥行き)。',
    })
    public shadowBaseSize: Vec2 = new Vec2(1.5, 1.0);

    @property({
        tooltip: '地面との距離に応じたサイズスケール係数。\n0 なら距離によらず一定サイズ。',
    })
    public shadowSizeScaleByDistance: number = 0.0;

    @property({
        tooltip: '影の最小アルファ(0.0〜1.0)。\nキャラが地面から離れたときの影の薄さ。',
        min: 0.0,
        max: 1.0,
    })
    public shadowMinAlpha: number = 0.1;

    @property({
        tooltip: '影の最大アルファ(0.0〜1.0)。\nキャラが地面に接しているときの影の濃さ。',
        min: 0.0,
        max: 1.0,
    })
    public shadowMaxAlpha: number = 0.7;

    @property({
        tooltip: '影の基本色(RGB)。アルファは shadowMinAlpha / shadowMaxAlpha で制御されます。',
    })
    public shadowColor: Color = new Color(0, 0, 0, 255);

    @property({
        tooltip: '地面から影を少し浮かせるオフセット(Z-fighting 回避用)。',
    })
    public shadowVerticalOffset: number = 0.02;

    @property({
        tooltip: '地面の法線に合わせて影の向きを傾けるかどうか。\ntrue にすると坂道に沿って影が傾きます。',
    })
    public alignToGroundNormal: boolean = true;

    @property({
        tooltip: 'エディタ上で停止中でも影の位置を更新するかどうか。\ntrue にするとシーン編集時にも影が追従します。',
    })
    public updateInEditor: boolean = true;

    private _shadowNode: Node | null = null;
    private _shadowRenderer: MeshRenderer | null = null;
    private _shadowMaterial: Material | null = null;
    private _ray: geometry.Ray = new geometry.Ray();
    private _tempWorldPos: Vec3 = new Vec3();
    private _tempDown: Vec3 = new Vec3(0, -1, 0);
    private _tempQuat: Quat = new Quat();

    onLoad() {
        // PhysicsSystem の存在チェック(エディタ設定ミスの検出用)
        if (!PhysicsSystem.instance) {
            console.error('[ShadowBlob] PhysicsSystem が有効になっていません。Project Settings の Physics を確認してください。');
        }

        this._initShadowNode();
    }

    start() {
        // 実行開始時にも一度更新しておく
        this._updateShadow(0);
    }

    update(deltaTime: number) {
        // エディタ停止中に更新しない設定ならスキップ
        if (!director.isPaused()) {
            // 再生中
            this._updateShadow(deltaTime);
        } else if (this.updateInEditor) {
            // 停止中でも更新する場合
            this._updateShadow(deltaTime);
        }
    }

    /**
     * 影用ノードとマテリアルの初期化
     */
    private _initShadowNode() {
        // 影ノードの決定
        if (this.useCustomShadowNode) {
            if (!this.customShadowNode) {
                console.warn('[ShadowBlob] useCustomShadowNode が true ですが customShadowNode が設定されていません。影は表示されません。');
                this._shadowNode = null;
                this._shadowRenderer = null;
                return;
            }
            this._shadowNode = this.customShadowNode;
        } else {
            // 自動生成モード
            if (!this._shadowNode) {
                this._shadowNode = new Node('ShadowBlobNode');
                this.node.addChild(this._shadowNode);
            }
        }

        // MeshRenderer の取得または追加
        this._shadowRenderer = this._shadowNode.getComponent(MeshRenderer);
        if (!this._shadowRenderer) {
            this._shadowRenderer = this._shadowNode.addComponent(MeshRenderer);
        }

        if (!this._shadowRenderer) {
            console.error('[ShadowBlob] MeshRenderer を影ノードに追加できませんでした。');
            return;
        }

        // 影用メッシュの設定(薄い平面)
        if (!this._shadowRenderer.mesh) {
            const plane = primitives.plane(); // 1x1 の平面
            const mesh = Mesh.create(plane);
            this._shadowRenderer.mesh = mesh;
        }

        // 影用マテリアルの作成
        if (!this._shadowMaterial) {
            this._shadowMaterial = new Material();
            // Unlit & 透過用の組み込みエフェクトを使用
            this._shadowMaterial.initialize({
                effectName: 'builtin-unlit',
            });
        }

        this._shadowRenderer.material = this._shadowMaterial;

        // 初期スケール・回転
        this._shadowNode.setScale(this.shadowBaseSize.x, 1.0, this.shadowBaseSize.y);
        // 平面メッシュはデフォルトで +Z を向いているため、Y軸に対して水平になるように 90 度回転
        this._shadowNode.setRotationFromEuler(90, 0, 0);
    }

    /**
     * 毎フレームの影更新処理
     */
    private _updateShadow(deltaTime: number) {
        if (!this._shadowNode || !this._shadowRenderer) {
            this._initShadowNode();
            if (!this._shadowNode || !this._shadowRenderer) {
                return;
            }
        }

        if (!PhysicsSystem.instance) {
            // 物理システムが無効なら何もしない
            return;
        }

        // レイの開始位置(ワールド座標)
        const worldPos = this.node.worldPosition;
        Vec3.add(this._tempWorldPos, worldPos, this.raycastLocalOffset);

        // レイの方向(常にワールドの下方向)
        this._tempDown.set(0, -1, 0);

        geometry.Ray.fromPoints(this._ray, this._tempWorldPos, Vec3.add(new Vec3(), this._tempWorldPos, this._tempDown));

        // レイキャスト実行
        const hit = PhysicsSystem.instance.raycastClosest(this._ray, this.raycastLayerMask, this.raycastMaxDistance);

        if (!hit) {
            // 地面に当たらなければ影を非表示
            this._shadowNode.active = false;
            return;
        }

        const result = PhysicsSystem.instance.raycastClosestResult;
        if (!result) {
            this._shadowNode.active = false;
            return;
        }

        this._shadowNode.active = true;

        const hitPoint = result.hitPoint;
        const hitNormal = result.hitNormal;

        // 影の位置 = ヒット位置 + 法線方向に少しオフセット
        const shadowPos = new Vec3(
            hitPoint.x + hitNormal.x * this.shadowVerticalOffset,
            hitPoint.y + hitNormal.y * this.shadowVerticalOffset,
            hitPoint.z + hitNormal.z * this.shadowVerticalOffset,
        );
        this._shadowNode.worldPosition = shadowPos;

        // 影の回転:地面の法線に合わせるかどうか
        if (this.alignToGroundNormal) {
            // 法線を Y 軸に合わせる回転
            Quat.fromViewUp(this._tempQuat, new Vec3(0, 0, 1), hitNormal);
            this._shadowNode.worldRotation = this._tempQuat;
        } else {
            // 法線に関わらず水平に保つ
            this._shadowNode.setRotationFromEuler(90, 0, 0);
        }

        // キャラとの距離に応じたスケール・アルファの計算
        const distance = Vec3.distance(this._tempWorldPos, hitPoint);

        // サイズスケール
        let scaleFactor = 1.0 + distance * this.shadowSizeScaleByDistance;
        if (scaleFactor < 0.1) {
            scaleFactor = 0.1;
        }

        this._shadowNode.setScale(
            this.shadowBaseSize.x * scaleFactor,
            1.0,
            this.shadowBaseSize.y * scaleFactor,
        );

        // アルファ補間(距離 0〜raycastMaxDistance で shadowMaxAlpha〜shadowMinAlpha)
        const t = Math.min(Math.max(distance / this.raycastMaxDistance, 0.0), 1.0);
        const alpha = this.shadowMaxAlpha + (this.shadowMinAlpha - this.shadowMaxAlpha) * t;

        // マテリアルの色を更新
        if (this._shadowMaterial) {
            const col = new Color(
                this.shadowColor.r,
                this.shadowColor.g,
                this.shadowColor.b,
                Math.round(alpha * 255),
            );
            this._shadowMaterial.setProperty('mainColor', col);
        }
    }
}

コードのポイント解説

  • @executeInEditMode(true)
    – エディタ上(再生していない状態)でも update が呼ばれるようにし、
    updateInEditor フラグによって影の更新を制御しています。
  • @requireComponent(MeshRenderer)
    – このコンポーネントをアタッチしたノードに MeshRenderer が必ず存在するようにします。
    実際の影は子ノード側に持ちますが、最低限の 3D コンテキストがあることを保証するための防御的指定です。
  • _initShadowNode()
    useCustomShadowNode の設定に応じて、影用ノードを決定・生成します。
    – 自動生成モードでは、primitives.plane() からメッシュを作り、Unlit マテリアルを割り当てます。
    – 影ノードを水平にするために setRotationFromEuler(90, 0, 0) を行っています。
  • _updateShadow()
    – ノードのワールド座標 + オフセットから下方向にレイを飛ばし、raycastClosest で最も近い地面を検出します。
    – ヒットした位置に影を配置し、法線と距離に基づいて回転・スケール・アルファを更新します。
    – 地面がない場合は影ノードを active = false にして非表示にします。
  • distance に応じたアルファ補間
    distance / raycastMaxDistance を 0〜1 にクランプし、
    shadowMaxAlpha(近い)〜shadowMinAlpha(遠い)を線形補間しています。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を ShadowBlob.ts に変更します。
  3. 作成された ShadowBlob.ts をダブルクリックし、エディタで開きます。
  4. 中身をすべて削除し、本記事の「TypeScriptコードの実装」セクションのコードをそのまま貼り付けて保存します。

2. テスト用キャラノードの用意

ここでは簡単に、3D Box をキャラの代わりとして使います。

  1. Hierarchy パネルで右クリック → Create → 3D Object → Box を選択し、
    名前を TestCharacter に変更します。
  2. TestCharacter を選択し、Inspector で Position を (0, 2, 0) など、地面より少し上に設定します。

3. 地面(Ground)ノードの作成

  1. Hierarchy パネルで右クリック → Create → 3D Object → Plane を選択し、
    名前を Ground に変更します。
  2. Ground を選択し、Position を (0, 0, 0) に設定します。
  3. Physics 用に Collider を追加します:
    1. Ground を選択した状態で、Inspector の Add Component をクリック。
    2. Physics → BoxCollider(または PlaneCollider)を追加します。
    3. サイズ(Size)は Plane の大きさに合わせて調整してください。
  4. 必要に応じて、地面用の Layer を設定し、後で raycastLayerMask と合わせます。

4. PhysicsSystem の有効化確認

  1. メニューから Project → Project Settings を開きます。
  2. 左メニューで Physics を選択します。
  3. Enable Physics(物理を有効にする)がオンになっていることを確認します。
  4. 必要に応じて、重力やデフォルトマテリアルなどを設定しますが、本コンポーネントはレイキャストのみ使用するため、必須ではありません。

5. ShadowBlob コンポーネントのアタッチ

  1. Hierarchy で TestCharacter ノードを選択します。
  2. Inspector の下部で Add ComponentCustomShadowBlob を選択します。
  3. 追加された ShadowBlob コンポーネントのプロパティを設定します:
    • useCustomShadowNode: false(まずは自動生成モードで試す)
    • raycastLocalOffset: (0, 1.0, 0)(キャラの中心あたり)
    • raycastMaxDistance: 5.0
    • raycastLayerMask: 0xffffffff(とりあえず全てのレイヤーを対象)
    • shadowBaseSize: (1.5, 1.0)
    • shadowSizeScaleByDistance: 0.0(まずは距離によらず一定サイズ)
    • shadowMinAlpha: 0.1
    • shadowMaxAlpha: 0.7
    • shadowColor: デフォルトの黒のままで OK
    • shadowVerticalOffset: 0.02
    • alignToGroundNormal: true
    • updateInEditor: シーン編集時にも影を見たい場合は true

6. 再生して動作確認

  1. エディタ上部の Play ボタンを押してゲームを再生します。
  2. Scene ビューまたは Game ビューで TestCharacter を確認すると、足元に黒い楕円影が表示されているはずです。
  3. Hierarchy で TestCharacter の Position Y を操作して上下させてみると、影が地面に追従し、距離に応じてサイズ・透明度が変化することを確認できます(shadowSizeScaleByDistance を 0.1 などに上げると変化が分かりやすくなります)。

7. カスタム影ノードを使う例

デザイナーが独自のテクスチャやメッシュで影を作りたい場合は、useCustomShadowNode を利用します。

  1. TestCharacter の子として、任意のノード(例: PrettyShadow)を作成します。
  2. PrettyShadowMeshRenderer を追加し、好みのメッシュ・マテリアル(半透明の丸テクスチャなど)を設定します。
  3. TestCharacter の ShadowBlob コンポーネントで:
    • useCustomShadowNodetrue にする。
    • customShadowNodePrettyShadow をドラッグ&ドロップする。
  4. 再生すると、今度は PrettyShadow ノードが地面に追従して動くようになります(位置・回転・スケールは ShadowBlob が制御します)。

まとめ

本記事では、Cocos Creator 3.8.7 と TypeScript で、

  • キャラの足元に自動的に楕円影を配置する ShadowBlob コンポーネント
  • RayCast による地面検出と、距離に応じた影のスケール・透明度制御
  • 外部スクリプトやプレハブに依存しない、完全に独立した設計

を実装しました。

このコンポーネントを使えば、

  • 3D アクションゲームでキャラクターの足元に簡易的な影を付ける
  • ジャンプや落下中の高さ感を、影のサイズや濃さでプレイヤーに伝える
  • 坂道や段差のあるフィールドでも、地形に沿った影を自動で表示する

といった演出を、ノードにアタッチするだけで実現できます。

さらに、useCustomShadowNode を活用すれば、デザイナーが作った専用の影メッシュ・テクスチャをそのまま利用できるため、見た目の自由度も高く保てます。
ゲームごとにキャラごとに微妙に違う丸影を作りたい場合でも、この 1 スクリプトを共通で使い回せるのが大きなメリットです。

この ShadowBlob をベースに、

  • 影のフェードイン/フェードアウトアニメーション
  • ライト方向に応じた影の伸び(単純なプロジェクション)
  • 複数ライトやマルチレイヤーへの対応

などを拡張していくことで、よりリッチな影表現も実現できます。まずは本記事のコードをそのままプロジェクトに導入し、自分のゲームのキャラクターに「丸影」を付けてみてください。