100
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

posted at

updated at

[Unity] 実用的な雪の描画

Unity 2 Advent Calendar 2015 23日目の記事です。

軽い雪を!

Unity で雪を降らせましょう。それもデモデモしたカッコイイのじゃなくて、そのままゲームに使えちゃうような、 なるべく実用的なものを。つまり軽いやつ。
サンタさんありがとう的な雪を!

GPU で描く

というわけで GPU を使うしかないですね。とはいえコンピュートとかは使いません。大抵のプラットフォームで動作するものを目指します。予め頂点を山ほど用意しておいて、頂点シェーダでビルボードにする作戦です。(まあ、よくある話なんですけど、意外とまとまったのはないような)

Snow.cs

いきなりソースコード。上から順番に解説してきます。

Snow.cs
[RequireComponent(typeof(MeshFilter),typeof(MeshRenderer))]
public class Snow : MonoBehaviour
{
    const int SNOW_NUM = 16000;
    private Vector3[] vertices_;
    private int[] triangles_;
    private Color[] colors_;
    private Vector2[] uvs_;

↑クラスを用意し、頂点その他を用意します。Unity は1ドローコールで 65000 頂点まで扱えるので、一粒4頂点の雪はその 1/4 まで出せます。とりあえずめいいっぱいの 16000 個にしときましょう。GPU が悲鳴をあげたら減らせばいいのです。

Snow.cs続き
    private float range_;
    private float rangeR_;
    private Vector3 move_ = Vector3.zero;

↑その他のパラメータです。range_ は、雪を降らせる範囲を指定します。遠くまで描いても意味ないですからね。手前だけで十分。

Snow.cs続き
    void Start ()
    {
        range_ = 16f;
        rangeR_ = 1.0f/range_;
        vertices_ = new Vector3[SNOW_NUM*4];
        for (var i = 0; i < SNOW_NUM; ++i) {
            float x = Random.Range (-range_, range_);
            float y = Random.Range (-range_, range_);
            float z = Random.Range (-range_, range_);
            var point = new Vector3(x, y, z);
            vertices_ [i*4+0] = point;
            vertices_ [i*4+1] = point;
            vertices_ [i*4+2] = point;
            vertices_ [i*4+3] = point;
        }

↑ここから Start 関数です。範囲内のランダムな点に4頂点をまとめます。

Snow.cs続き
        triangles_ = new int[SNOW_NUM * 6];
        for (int i = 0; i < SNOW_NUM; ++i) {
            triangles_[i*6+0] = i*4+0;
            triangles_[i*6+1] = i*4+1;
            triangles_[i*6+2] = i*4+2;
            triangles_[i*6+3] = i*4+2;
            triangles_[i*6+4] = i*4+1;
            triangles_[i*6+5] = i*4+3;
        }

↑上の4頂点を矩形にするように結んで面にします。三角形ふたつずつですね。

Snow.cs続き
        uvs_ = new Vector2[SNOW_NUM*4];
        for (var i = 0; i < SNOW_NUM; ++i) {
            uvs_ [i*4+0] = new Vector2 (0f, 0f);
            uvs_ [i*4+1] = new Vector2 (1f, 0f);
            uvs_ [i*4+2] = new Vector2 (0f, 1f);
            uvs_ [i*4+3] = new Vector2 (1f, 1f);
        }

↑UV座標です。テクスチャを張るので当然必要なのですが、今回はテクスチャ座標以外にも使用します。この後のシェーダ編で触れます。

Snow.cs続き
        Mesh mesh = new Mesh ();
        mesh.name = "MeshSnowFlakes";
        mesh.vertices = vertices_;
        mesh.triangles = triangles_;
        mesh.colors = colors_;
        mesh.uv = uvs_;
        mesh.bounds = new Bounds(Vector3.zero, Vector3.one * 99999999);
        var mf = GetComponent<MeshFilter> ();
        mf.sharedMesh = mesh;
    }

↑メッシュに格納します。bounds にすごい値を入れているのは、Frustom Culling されないため。Unity は Frustom Culling を無効にできないんです。
ここまでが Start 関数でした。かなり重い処理なので、間違っても Update に入れないように。

Snow.cs続き、最後
    void LateUpdate ()
    {
        var target_position = Camera.main.transform.TransformPoint(Vector3.forward * range_);
        var mr = GetComponent<Renderer> ();
        mr.material.SetFloat("_Range", range_);
        mr.material.SetFloat("_RangeR", rangeR_);
        mr.material.SetFloat("_Size", 0.1f);
        mr.material.SetVector("_MoveTotal", move_);
        mr.material.SetVector("_CamUp", Camera.main.transform.up);
        mr.material.SetVector("_TargetPosition", target_position);
    }
}

↑そして毎フレームの更新です。transform の更新後に呼ばれて欲しいので、LateUpdate にしました。内容はほとんどシェーダ定数の設定だけですね。target_position は、カメラからの前方位置を計算しておき、ここを中心に雪が降ります。

  • _Range : 雪の範囲
  • _RangeR : _Range の逆数(計算効率のため)
  • _Size : 雪の粒の大きさ
  • _MoveTotal : 移動量
  • _CamUp : カメラの視線ベクトルに対して90度上のベクトル
  • _TargetPosition : 雪の範囲の中心

こんな感じ。次にシェーダ。

snow.shader

snow.shader
Shader "Custom/snow" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
    }

↑テクスチャを使うよと。

snow.shader続き
    SubShader {
        Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
        ZWrite Off
        Cull Off
        Blend SrcAlpha OneMinusSrcAlpha // alpha blending

↑透明のパスに描きます。あとは Z は書かない、backface culling しない、アルファブレンドをミックスで。

snow.shader続き
        Pass {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            #include "UnityCG.cginc"

            uniform sampler2D _MainTex;

            struct appdata_custom {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f {
                float4 pos:SV_POSITION;
                float2 uv:TEXCOORD0;
            };

            float4x4 _PrevInvMatrix;
            float3   _TargetPosition;
            float    _Range;
            float    _RangeR;
            float    _Size;
            float3   _MoveTotal;
            float3   _CamUp;

↑ずらずらと決まり文句を書いて、

snow.shader続き
            v2f vert(appdata_custom v)
            {
                float3 target = _TargetPosition;
                float3 trip;
                float3 mv = v.vertex.xyz;
                mv += _MoveTotal;
                trip = floor( ((target - mv)*_RangeR + 1) * 0.5 );
                trip *= (_Range * 2);
                mv += trip;

↑ここから頂点シェーダ。今回のメインです。常に _TargetPosition の周りに登場するような計算をします。単に座標を足すのではダメで、うまくリピートさせないといけません。
floor( ((target - mv)*_RangeR + 1) * 0.5 );
これがキモですね。式の意味を理解したい方はこちらをどうぞ。

snow.shader続き
                float3 diff = _CamUp * _Size;
                float3 finalposition;
                float3 tv0 = mv;
                {
                    float3 eyeVector = ObjSpaceViewDir(float4(tv0, 0));
                    float3 sideVector = normalize(cross(eyeVector,diff));
                    tv0 += (v.texcoord.x-0.5f)*sideVector * _Size;
                    tv0 += (v.texcoord.y-0.5f)*diff;
                    finalposition = tv0;
                }

↑ここで視線ベクトルを使ってビルボードにします。texcoord を利用して四隅を識別します。うまい具合に0と1が入っているので成り立つんですよね。なので UV には 0 と 1 以外の値を入れてはいけないのです。

snow.shader続き
                v2f o;
                o.pos = mul( UNITY_MATRIX_MVP, float4(finalposition,1));
                o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord);
                return o;
            }

↑最後は普通に座標を出して完了。

snow.shader続き、最後

            fixed4 frag(v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv);
            }

            ENDCG
        }
    }
}

↑フラグメントシェーダはテクスチャ引くだけ。

画像を用意する

いきなり python。

image.py
import Image
import ImageDraw
import math

def drawing(img):
    x,y = img.size
    cx = x/2
    cy = y/2
    if x < y:
        rad = x/2
    else:
        rad = y/2
    draw = ImageDraw.Draw(img)
    for i in range(0,x):
        for j in range(0,y):
            dist = math.sqrt((i - cx)*(i - cx) + (j - cy)*(j - cy))
            val = 0xff*(1.0 - (dist/rad));
            draw.point((i, j), (0xff,0xff,0xff,int(val)))
    return img

def make_image(screen, bgcolor, filename):
    img = Image.new('RGBA', screen, bgcolor)
    img = drawing(img)
    img.save(filename)

if __name__ == '__main__':
    screen = (64,64)

    bgcolor=(0x00,0x00,0x00,0x00)

    filename = "snow.png"

    make_image(screen, bgcolor, filename)

#EOF

丸いテクスチャぐらいは、自力で作れるようになっときたいですね。特に alpha を厳密に指定したい場合は、プログラムで作っちゃったほうが早いです。断言。
ま、python の PIL という便利なものがあればこそですが。

これを実行すると、全面が white で alpha だけが中央からの距離減衰した画像が出来上がります。
snow.png
↑こんな感じ。

実行する

さあ実行。もちろんシーンのセットアップが必要ですけど、そこは省略。まあ置くだけですよね。

snow.png

出ましたー!

stats.png

Batches: 7 と出てますが、再生を停止すると 6 でした。なので雪の処理単位は1です。やったね!

scene.png

ちゃんと、カメラの前にカタマリが来ています。カメラがどこにいても、その前にだけ降っているわけですね。

降らす

降ってないのでは雪とは言えません。下に落下させましょう。頂点シェーダの実装を理解して移動させる必要があります。

Snow.cs(LateUpdate)
    void LateUpdate ()
    {
        var target_position = Camera.main.transform.TransformPoint(Vector3.forward * range_);
        var mr = GetComponent<Renderer> ();
        mr.material.SetFloat("_Range", range_);
        mr.material.SetFloat("_RangeR", rangeR_);
        mr.material.SetFloat("_Size", 0.1f);
        mr.material.SetVector("_MoveTotal", move_);
        mr.material.SetVector("_CamUp", Camera.main.transform.up);
        mr.material.SetVector("_TargetPosition", target_position);
        float x = 0f;
        float y = -2f;
        float z = 0f;
        move_ += new Vector3(x, y, z) * Time.deltaTime;
        move_.x = Mathf.Repeat(move_.x, range_ * 2f);
        move_.y = Mathf.Repeat(move_.y, range_ * 2f);
        move_.z = Mathf.Repeat(move_.z, range_ * 2f);
    }

↑LateUpdate の最後に移動を追加しました。こんな具合で _MoveTotal に入れる移動量を制御します。落下速度は float y = -2f; なので、秒速2メートルですね。移動したら Mathf.Repeat を使って循環させておきます。でないと数値が永遠に大きくなって、オーバーフローを起こしてしまいます。

揺らす

お次は雪っぽく、フワフワさせましょう。今度は頂点シェーダに手を加えます。

snow.shader(vert)
            v2f vert(appdata_custom v)
            {
                float3 target = _TargetPosition;
                float3 trip;
                float3 mv = v.vertex.xyz;
                mv += _MoveTotal;
                trip = floor( ((target - mv)*_RangeR + 1) * 0.5 );
                trip *= (_Range * 2);
                mv += trip;

                float3 diff = _CamUp * _Size;
                float3 finalposition;
                float3 tv0 = mv;
                tv0.x += sin(mv.x*0.2) * sin(mv.y*0.3) * sin(mv.x*0.9) * sin(mv.y*0.8);
                tv0.z += sin(mv.x*0.1) * sin(mv.y*0.2) * sin(mv.x*0.8) * sin(mv.y*1.2);
                {
                    float3 eyeVector = ObjSpaceViewDir(float4(tv0, 0));
                    float3 sideVector = normalize(cross(eyeVector,diff));
                    tv0 += (v.texcoord.x-0.5f)*sideVector * _Size;
                    tv0 += (v.texcoord.y-0.5f)*diff;
                    finalposition = tv0;
                }
                v2f o;
                o.pos = mul( UNITY_MATRIX_MVP, float4(finalposition,1));
                o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord);
                return o;
            }

↑sin を重ね合わせる2行を追加しました。この雪はシェーダで作っているので、CPUで何かするときのようにオブジェクトの概念では動かせません。「場」の概念で揺らします。つまり空間を曲げる的な。
数字は適当で、周期をずらして重ねます。位相を縦軸にしたり横軸にしたりするのも工夫ですね。調整していると、楽しくていくらでも時間が過ぎていきます。

最後にもうひと工夫

Snow.cs(LateUpdate)
    void LateUpdate ()
    {
        var target_position = Camera.main.transform.TransformPoint(Vector3.forward * range_);
        var mr = GetComponent<Renderer> ();
        mr.material.SetFloat("_Range", range_);
        mr.material.SetFloat("_RangeR", rangeR_);
        mr.material.SetFloat("_Size", 0.1f);
        mr.material.SetVector("_MoveTotal", move_);
        mr.material.SetVector("_CamUp", Camera.main.transform.up);
        mr.material.SetVector("_TargetPosition", target_position);
        float x = (Mathf.PerlinNoise(0f, Time.time*0.1f)-0.5f) * 10f;
        float y = -2f;
        float z = (Mathf.PerlinNoise(Time.time*0.1f, 0f)-0.5f) * 10f;
        move_ += new Vector3(x, y, z) * Time.deltaTime;
        move_.x = Mathf.Repeat(move_.x, range_ * 2f);
        move_.y = Mathf.Repeat(move_.y, range_ * 2f);
        move_.z = Mathf.Repeat(move_.z, range_ * 2f);
    }

y で落下させるだけでなく、x と z にも値を入れます。ここは全体を動かす「風」になるので、PerlinNoise で揺らぎを入れてみましょう。PerlinNoise の入力は平面なのですが、今回はその縁だけ使わせてもらいます。

気まぐれな風が吹くようになりました。これにて完成とします。

こうなりました

動画です。30秒:https://vimeo.com/149642482

プロジェクト一式はこちらに置きました:
https://github.com/dsedb/snowflakes

メリークリスマス!(早いけど)

ゲーム作る人に幸あれ。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
100
Help us understand the problem. What are the problem?