Unity
GPU
particle
Unity 2Day 23

[Unity] 実用的な雪の描画

More than 3 years have passed since last update.

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


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

ゲーム作る人に幸あれ。