[Unity] 雪に残る足跡表現とパーティクルを使った積雪表現

前回の記事はアサシンクリード3の技術解説記事を元に、出来る範囲で見た目再現できそうなところを作ってみました。今回はディスプレイスメントマッピングを使って(前回も少し試してみましたが)足跡の表現、パーティクルを使った降雪の表現、パーティクル衝突判定を使った積雪の表現に挑戦していきます。

 

 

 

環境

Unity 5.0.0f4

 

地面の準備

今回使用するディスプレイスメントマッピングというのは頂点を動かしてデコボコを表現するので、綺麗に表現するとなるとそれなりの頂点数が必要になります。なのでShader側で頂点数を増やせる機能「テッセレーション」を使っていきます。(実行できる環境が狭まると思いますが)

何はともあれPlaneを作成。

snow2_plane

Unity公式のヘルプからテッセレーション機能付きのShaderのコードをコピーしてShaderを作成します。

作成したShaderを使うマテリアルを作成します。Shaderは作成したものをセット。サンプルそのままなのでCustom/Tessellation Sampleを選択。プレビュー画面で既に頂点が移動している!

snow2_material

Planeのマテリアルへ適応。テッセレーションが適応されて分割された。Unityってすごいなー、と関心。

snow2_set_material

Base(RGB)、Disp Textureはすべて単色のテクスチャーを用意してセットしておきます。Normalmapは使用しない。

snow2_material2

これで地面が準備完了。

 

パーティクルで降雪表現

とりあえずはパーティクルで降雪っぽく見えるようにしてみます。こちらの「その1 Unityのパーティクル「Shuriken」」を見ながら雪っぽくなるように調整しました。

調整箇所は

  • Start Speed
  • Max Particles
  • Emission/Rate
  • Shape/Shape(Box)
  • Collision(Check On)
  • Collision/Bounce
  • Collision/Lifetime Loss
  • Collision/Collision Quality

を画面を見ながら大体で入力。

snow2_particle

 

パーティクルの衝突判定

Collision Module(公式サイト)

MonoBehaviour.OnParticleCollision(GameObject)(公式サイト)

の2つを見ながらパーティクルの衝突判定を覚える。

パーティクルのCollision設定は下図の通り。World + Send Collision Message を変更。

snow2_particle_collision_setting

下記のスクリプトを地面のPlaneに追加して、パーティクルの衝突判定が取れるかチェック。

using UnityEngine;
using System.Collections;
public class Footprint : MonoBehaviour
{
  void OnParticleCollision( GameObject other )
  {
    Debug.Log("OnParticleCollision");
  }
}

テスト実行してみて、コンソールに何か出力されればOK。

 

パーティクルの衝突判定からの積雪処理

まずはパーティクルが地面に当たった所の情報をスクリプトで取得します。先ほどのスクリプトに付け加えていきます。

using UnityEngine;
using System.Collections;

public class Footprint : MonoBehaviour
{
  /// <summary>
  /// 雪パーティクル
  /// </summary>
  public ParticleSystem SnowParticle;

  /// <summary>
  /// パーティクルイベント取得用
  /// </summary>
  ParticleCollisionEvent[] _collisionEvents;

  /// <summary>
  /// パーティクルの当たり判定イベント
  /// </summary>
  /// <param name="other"></param>
  void OnParticleCollision( GameObject other )
  {
    int safeLength = SnowParticle.GetSafeCollisionEventSize();
    if( _collisionEvents == null || _collisionEvents.Length < safeLength ) {
      _collisionEvents = new ParticleCollisionEvent[safeLength];
    }
    int numCollisionEvents = SnowParticle.GetCollisionEvents(gameObject, _collisionEvents);
    int i = 0;
    while( i < numCollisionEvents ) {
      Debug.LogFormat("int = {0}", _collisionEvents[i].intersection);
      Debug.LogFormat("nor = {0}", _collisionEvents[i].normal);
      Debug.LogFormat("vel = {0}", _collisionEvents[i].velocity);
      i++;
    }
  }
}

実行するとパーティクルが当たった位置、向きなどが出力されます。

※このOnParticleCollisionですが、パーティクルの設定によってはStartメソッドより前に呼ばれることがあります。メンバー変数をStartメソッドで初期化している場合は、初期化前に呼ばれることがあることを前提にコードを書く必要があるかも。

 

次に積雪処理用のコードを追加します。積雪処理はディスプレイスメントマッピング用テクスチャーの値を増加することで表現します。

using UnityEngine;
using System.Collections;

public class Footprint : MonoBehaviour
{
  /// <summary>
  /// 雪パーティクル
  /// </summary>
  public ParticleSystem SnowParticle;

  /// <summary>
  /// 雪用マテリアル
  /// </summary>
  Material SnowMaterial;
  
  /// <summary>
  /// ディスプレイスメントマップ用テクスチャー
  /// </summary>
  Texture2D DisplacementTexture;

  /// <summary>
  /// ディスプレイスメントマップ用テクスチャー更新フラグ
  /// </summary>
  bool IsUpdateDisplacementTexture;

  /// <summary>
  /// 積雪パワー
  /// </summary>
  public float AccumulatePower = 0.02f;

  /// <summary>
  /// 積雪の最大値(0~1)
  /// </summary>
  public float AccumulateLimit = 0.5f;

  /// <summary>
  /// パーティクルイベント取得用
  /// </summary>
  ParticleCollisionEvent[] _collisionEvents;

  void Start()
  {
    // 使用するマテリアルを取得
    MeshRenderer render = gameObject.GetComponent<MeshRenderer>();
    SnowMaterial = render.material;

    // ディスプレイスメントマップ用テクスチャー
    DisplacementTexture = new Texture2D(256, 256, TextureFormat.ARGB32, false);
    for( int y = 0; y < DisplacementTexture.height; y++ ) {
      for( int x = 0; x < DisplacementTexture.width; x++ ) {
        DisplacementTexture.SetPixel(x, y, new Color(0, 0, 0));
      }
    }

    SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
  }

  /// <summary>
  /// パーティクルの当たり判定イベント
  /// </summary>
  /// <param name="other"></param>
  void OnParticleCollision( GameObject other )
  {
    if( DisplacementTexture == null ) return;

    int safeLength = SnowParticle.GetSafeCollisionEventSize();
    if( _collisionEvents == null || _collisionEvents.Length < safeLength ) {
      _collisionEvents = new ParticleCollisionEvent[safeLength];
    }
    IsUpdateDisplacementTexture = false;
    int numCollisionEvents = SnowParticle.GetCollisionEvents(gameObject, _collisionEvents);
    int i = 0;
    while( i < numCollisionEvents ) {
      AccumulateSnow(_collisionEvents[i].intersection);
      i++;
    }

    // テクスチャー更新
    if( IsUpdateDisplacementTexture ) {
      //Debug.Log("Update");
      DisplacementTexture.Apply();
      SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
    }
  }

  /// <summary>
  /// 指定位置に雪を積もらせます
  /// </summary>
  /// <param name="position"></param>
  void AccumulateSnow( Vector3 position )
  {
    // 位置からヒットした場所のテクスチャーUV値の取得方法がわからないので
    // Rayを飛ばしてRaycastHitの中のテクスチャーUV値を使用することにします。
    Ray ray = new Ray(new Vector3(position.x, position.y + Vector3.up.y * 1, position.z), Vector3.down);
    RaycastHit hit;
    if( Physics.Raycast(ray, out hit, 2) != true ) {
      return;   // 地面が見つからず?
    }
    // Debug.LogFormat("tex = {0}, {1}", hit.textureCoord.x, hit.textureCoord.y);

    var tx = (int)(hit.textureCoord.x * DisplacementTexture.width);
    var ty = (int)(hit.textureCoord.y * DisplacementTexture.height);
    AccumulateSnowAdd(tx, ty, AccumulatePower);
    AccumulateSnowAdd(tx+1, ty, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx, ty+1, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx-1, ty, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx, ty-1, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx + 1, ty + 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx - 1, ty + 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx + 1, ty - 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx - 1, ty - 1, AccumulatePower / 4.0f);
    IsUpdateDisplacementTexture = true;
  }

  /// <summary>
  /// 指定位置に雪を積もらせます
  /// </summary>
  /// <param name="texX"></param>
  /// <param name="texY"></param>
  /// <param name="power"></param>
  void AccumulateSnowAdd( int texX, int texY, float power )
  {
    if( texX < 0 || texX >= DisplacementTexture.width ) return;
    if( texY < 0 || texY >= DisplacementTexture.height ) return;
    var val = DisplacementTexture.GetPixel(texX, texY);
    var dis = Mathf.Min(AccumulateLimit, val.r + power);
    DisplacementTexture.SetPixel(texX, texY, new Color(dis, dis, dis));
  }
}

ヒット位置からテクスチャーUV値を取得し、その部分のテクスチャー値を増加させています。あまり積もっても嫌なので、最大値以上は増やさないようにしておきました。

実行するとちょっとだけ地面が上に変形します。

accumulateSnow

いまいち見難い。なのでShaderをコピーしてディスプレイメントテクスチャの値から色を付けるようにしました。

accumulateSnow2

ぽつぽつと何かが変化しているのが判りますが、目が痛い・・・。

大げさに雪が積もるようにしているので3分~5分で結構積もります。

snow2_add

 

足跡用の当たり判定

次は足跡用の処理を作ります。足跡を付ける処理のトリガーとして、足跡コライダーを準備します。サイズは10cmの可愛らしいサイズに。(サイズに合わせた足跡付けようかと思っていたのですが力尽きたのであまり関係なし。)

※画像ではRigidbodyのFreeze Positionにチェックを付いていませんが、すべてチェックONにしておく必要がある。

snow2_foot

地面は既にメッシュコライダーが付いているはずで、あとはOnCollisionEnterメソッドで足跡コライダーが来るまで待っていればよいです。

 

足跡の表現

足跡もディスプレイスメントマッピング用テクスチャーの値を変更することで表現します。少し面倒な処理になりそうな足の向き、大きさなどは考えないことにします。これもFootprintスクリプトに追加していきます。

  /// <summary>
  /// コライダーとの当たり判定イベント
  /// </summary>
  /// <param name="collision"></param>
  void OnCollisionEnter( Collision collision )
  {
    if( collision.gameObject.name != "Foot" ) {
      return;
    }
    if( collision.contacts == null ) return;
    if( collision.contacts.Length <= 0 ) return;

    // Rayを飛ばしUV値を取得
    Ray ray = new Ray( collision.contacts[0].point + Vector3.up, Vector3.down);
    RaycastHit hit;
    if( Physics.Raycast(ray, out hit, 2, 1 << LayerMask.NameToLayer("SnowPlane")) != true ) {
      return;   // 地面が見つからず?
    }

    //Debug.LogFormat("OnCollisionEnter : {0}, {1}, {2}", collision.collider.name, collision.gameObject.name, collision.contacts[0].point );

    var tx = (int)(hit.textureCoord.x * DisplacementTexture.width);
    var ty = (int)(hit.textureCoord.y * DisplacementTexture.height);

    int w = 5;
    int h = 5;

    // 足跡のつもり。データを作成。
    float[,] footmark = new float[w,h];
    var halfx = (int)(footmark.GetUpperBound(1) / 2);
    var halfy = (int)(footmark.GetUpperBound(0) / 2);
    var inch = Mathf.Sqrt( w * w + h * h ) / 2.0f;
    for( int y = 0; y <= footmark.GetUpperBound(0); y++ ) {
      for( int x = 0; x <= footmark.GetUpperBound(1); x++ ) {
        var l = Mathf.Sqrt((halfx - x) * (halfx - x) + (halfy - y) * (halfy - y));
        footmark[y, x] = (inch - l) / 15 - Random.Range(0, 1000) / (10000.0f);
        //Debug.LogFormat("[{0}, {1}], {2} = {3}", y, x, l, footmark[y, x]);
      }
    }

    for( int y = 0; y <= footmark.GetUpperBound(0); y++ ) {
      for( int x = 0; x <= footmark.GetUpperBound(1); x++ ) {
        //Debug.LogFormat("[{0}, {1}] = {2}", y, x, footmark[y, x]);
        AccumulateSnowAdd(tx + x - halfx, ty + y - halfy, -footmark[y, x]);
      }
    }
    // テクスチャー更新
    DisplacementTexture.Apply();
    SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
  }

エディタ上で足跡コライダー付のGameObjectを地面まで動かしてみて、関数が呼ばれれば成功。

 

キャラクターの動きに適応させる

まだUnityのエディタ上でしか効果を確認できないので十字キーで歩き回るキャラクターを用意し、それに合わせて足跡が付くようにしていきます。こちらの記事「第 1 回・Unity / Mecanimでユニティちゃんを歩かせる」を参考にユニティちゃんを動かせるようにします。アニメーションに関しては、ほぼ未勉強なので詳細は書けません。今後勉強予定。

動かせるようになったら、足跡コライダー付のGameObjectをユニティちゃんの足裏に付けます。

snow2_footcollider

地面にヒットするように少し下側にコライダーが出るようにしました。

歩かせると足跡付くよ。

snow2_footprint

最終的なソースコードはこちらになりました。

using UnityEngine;
using System.Collections;

public class Footprint : MonoBehaviour
{
  /// <summary>
  /// 雪パーティクル
  /// </summary>
  public ParticleSystem SnowParticle;

  /// <summary>
  /// 雪用マテリアル
  /// </summary>
  Material SnowMaterial;
  
  /// <summary>
  /// ディスプレイスメントマップ用テクスチャー
  /// </summary>
  Texture2D DisplacementTexture;

  /// <summary>
  /// ディスプレイスメントマップ用テクスチャー更新フラグ
  /// </summary>
  bool IsUpdateDisplacementTexture;

  /// <summary>
  /// 積雪パワー
  /// </summary>
  public float AccumulatePower = 0.02f;

  /// <summary>
  /// 積雪の最大値(0~1)
  /// </summary>
  public float AccumulateLimit = 0.5f;

  /// <summary>
  /// パーティクルイベント取得用
  /// </summary>
  ParticleCollisionEvent[] _collisionEvents;

  void Start()
  {
    // 使用するマテリアルを取得
    MeshRenderer render = gameObject.GetComponent<MeshRenderer>();
    SnowMaterial = render.material;

    // ディスプレイスメントマップ用テクスチャー
    DisplacementTexture = new Texture2D(256, 256, TextureFormat.ARGB32, false);
    for( int y = 0; y < DisplacementTexture.height; y++ ) {
      for( int x = 0; x < DisplacementTexture.width; x++ ) {
        DisplacementTexture.SetPixel(x, y, new Color(0.10f, 0.10f, 0.10f));
      }
    }

    SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
  }

  /// <summary>
  /// パーティクルの当たり判定イベント
  /// </summary>
  /// <param name="other"></param>
  void OnParticleCollision( GameObject other )
  {
    if( DisplacementTexture == null ) return;

    int safeLength = SnowParticle.GetSafeCollisionEventSize();
    if( _collisionEvents == null || _collisionEvents.Length < safeLength ) {
      _collisionEvents = new ParticleCollisionEvent[safeLength];
    }
    IsUpdateDisplacementTexture = false;
    int numCollisionEvents = SnowParticle.GetCollisionEvents(gameObject, _collisionEvents);
    int i = 0;
    while( i < numCollisionEvents ) {
      AccumulateSnow(_collisionEvents[i].intersection);
      i++;
    }

    // テクスチャー更新
    if( IsUpdateDisplacementTexture ) {
      //Debug.Log("Update");
      DisplacementTexture.Apply();
      SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
    }
  }

  /// <summary>
  /// 指定位置に雪を積もらせます
  /// </summary>
  /// <param name="position"></param>
  void AccumulateSnow( Vector3 position )
  {
    // 位置からヒットした場所のテクスチャーUV値の取得方法がわからないので
    // Rayを飛ばしてRaycastHitの中のテクスチャーUV値を使用することにします。
    Ray ray = new Ray(new Vector3(position.x, position.y + Vector3.up.y * 1, position.z), Vector3.down);
    RaycastHit hit;
    if( Physics.Raycast(ray, out hit, 20, 1 << LayerMask.NameToLayer("SnowPlane")) != true ) {
      return;   // 地面が見つからず?
    }
    //Debug.LogFormat("tex = {0}, {1}", hit.textureCoord.x, hit.textureCoord.y);

    var tx = (int)(hit.textureCoord.x * DisplacementTexture.width);
    var ty = (int)(hit.textureCoord.y * DisplacementTexture.height);
    AccumulateSnowAdd(tx, ty, AccumulatePower);
    AccumulateSnowAdd(tx+1, ty, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx, ty+1, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx-1, ty, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx, ty-1, AccumulatePower / 2.0f);
    AccumulateSnowAdd(tx + 1, ty + 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx - 1, ty + 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx + 1, ty - 1, AccumulatePower / 4.0f);
    AccumulateSnowAdd(tx - 1, ty - 1, AccumulatePower / 4.0f);
    IsUpdateDisplacementTexture = true;
  }

  /// <summary>
  /// 指定位置に雪を積もらせます
  /// </summary>
  /// <param name="texX"></param>
  /// <param name="texY"></param>
  /// <param name="power"></param>
  void AccumulateSnowAdd( int texX, int texY, float power )
  {
    if( texX < 0 || texX >= DisplacementTexture.width ) return;
    if( texY < 0 || texY >= DisplacementTexture.height ) return;
    var val = DisplacementTexture.GetPixel(texX, texY);
    var dis = Mathf.Max( 0, Mathf.Min(AccumulateLimit, val.r + power) );
    DisplacementTexture.SetPixel(texX, texY, new Color(dis, dis, dis));
  }

  /// <summary>
  /// コライダーとの当たり判定イベント
  /// </summary>
  /// <param name="collision"></param>
  void OnCollisionEnter( Collision collision )
  {
    if( collision.gameObject.name != "Foot" ) {
      return;
    }
    if( collision.contacts == null ) return;
    if( collision.contacts.Length <= 0 ) return;

    // Rayを飛ばしUV値を取得
    Ray ray = new Ray( collision.contacts[0].point + Vector3.up, Vector3.down);
    RaycastHit hit;
    if( Physics.Raycast(ray, out hit, 2, 1 << LayerMask.NameToLayer("SnowPlane")) != true ) {
      return;   // 地面が見つからず?
    }

    //Debug.LogFormat("OnCollisionEnter : {0}, {1}, {2}", collision.collider.name, collision.gameObject.name, collision.contacts[0].point );

    var tx = (int)(hit.textureCoord.x * DisplacementTexture.width);
    var ty = (int)(hit.textureCoord.y * DisplacementTexture.height);

    int w = 5;
    int h = 5;

    // 足跡のつもり。データを作成。
    float[,] footmark = new float[w,h];
    var halfx = (int)(footmark.GetUpperBound(1) / 2);
    var halfy = (int)(footmark.GetUpperBound(0) / 2);
    var inch = Mathf.Sqrt( w * w + h * h ) / 2.0f;
    for( int y = 0; y <= footmark.GetUpperBound(0); y++ ) {
      for( int x = 0; x <= footmark.GetUpperBound(1); x++ ) {
        var l = Mathf.Sqrt((halfx - x) * (halfx - x) + (halfy - y) * (halfy - y));
        footmark[y, x] = (inch - l) / 15 - Random.Range(0, 1000) / (10000.0f);
        //Debug.LogFormat("[{0}, {1}], {2} = {3}", y, x, l, footmark[y, x]);
      }
    }

    for( int y = 0; y <= footmark.GetUpperBound(0); y++ ) {
      for( int x = 0; x <= footmark.GetUpperBound(1); x++ ) {
        //Debug.LogFormat("[{0}, {1}] = {2}", y, x, footmark[y, x]);
        AccumulateSnowAdd(tx + x - halfx, ty + y - halfy, -footmark[y, x]);
      }
    }
    // テクスチャー更新
    DisplacementTexture.Apply();
    SnowMaterial.SetTexture("_DispTex", DisplacementTexture);
  }

}

 

デモ

ついにUnity Web PlayerがChrome 42から動かなくなったようなので、WebGL版を出力したのですがいまいち動かせない・・・。

 

まとめ

CPUの処理(テクスチャー作成) → GPU転送(Texture.Apply) → GPUで描画(Shader)のサイクルを当たり判定発生ごと処理を行っていて現実的ではないですね。各当たり判定を取ったあとRayを飛ばしているのも負荷高そうですし。

ゲームで使う場合はGPU内で完結するような処理の流れを作るのが一般的なのかな?なんて思いました。

例えば、足跡なんかは足跡用3Dモデルを準備、足跡の位置にモデルを並べてあとはディスプレイスメントマップ生成用シェーダーでレンダリングすると専用テクスチャーが出来上がる(地面から上方向を見上げて、足までのDepthテクスチャーみたいなやつ)、みたいなね。GPUへの転送データ量が「テクスチャーデータ量 > 3Dモデルのデータ量」だと思うので。

想像ですよ、想像。

 

ユニティちゃんライセンス

ユニティちゃんライセンス

このコンテンツは、『ユニティちゃんライセンス』で提供されています


希木小鳥

Diablo1でハクスラの世界に。今はBorderlands2をプレイ中。ぬるゲーマー。

あわせて読みたい