Unity 2 Advent Calendar 2015 23日目の記事です。
軽い雪を!
Unity で雪を降らせましょう。それもデモデモしたカッコイイのじゃなくて、そのままゲームに使えちゃうような、 __なるべく実用的__なものを。つまり軽いやつ。
サンタさんありがとう的な雪を!
GPU で描く
というわけで GPU を使うしかないですね。とはいえコンピュートとかは使いません。大抵のプラットフォームで動作するものを目指します。予め頂点を山ほど用意しておいて、頂点シェーダでビルボードにする作戦です。(まあ、よくある話なんですけど、意外とまとまったのはないような)
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 が悲鳴をあげたら減らせばいいのです。
private float range_;
private float rangeR_;
private Vector3 move_ = Vector3.zero;
↑その他のパラメータです。range_ は、雪を降らせる範囲を指定します。遠くまで描いても意味ないですからね。手前だけで十分。
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頂点をまとめます。
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頂点を矩形にするように結んで面にします。三角形ふたつずつですね。
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座標です。テクスチャを張るので当然必要なのですが、今回はテクスチャ座標以外にも使用します。この後のシェーダ編で触れます。
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 に入れないように。
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
Shader "Custom/snow" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
}
↑テクスチャを使うよと。
SubShader {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
ZWrite Off
Cull Off
Blend SrcAlpha OneMinusSrcAlpha // alpha blending
↑透明のパスに描きます。あとは Z は書かない、backface culling しない、アルファブレンドをミックスで。
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;
↑ずらずらと決まり文句を書いて、
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 );
これがキモですね。式の意味を理解したい方はこちらをどうぞ。
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 以外の値を入れてはいけないのです。
v2f o;
o.pos = mul( UNITY_MATRIX_MVP, float4(finalposition,1));
o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord);
return o;
}
↑最後は普通に座標を出して完了。
fixed4 frag(v2f i) : SV_Target
{
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
}
↑フラグメントシェーダはテクスチャ引くだけ。
#画像を用意する
いきなり python。
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 だけが中央からの距離減衰した画像が出来上がります。
↑こんな感じ。
#実行する
さあ実行。もちろんシーンのセットアップが必要ですけど、そこは省略。まあ置くだけですよね。
出ましたー!
Batches: 7 と出てますが、再生を停止すると 6 でした。なので雪の処理単位は1です。やったね!
ちゃんと、カメラの前にカタマリが来ています。カメラがどこにいても、その前にだけ降っているわけですね。
#降らす
降ってないのでは雪とは言えません。下に落下させましょう。頂点シェーダの実装を理解して移動させる必要があります。
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 を使って循環させておきます。でないと数値が永遠に大きくなって、オーバーフローを起こしてしまいます。
#揺らす
お次は雪っぽく、フワフワさせましょう。今度は頂点シェーダに手を加えます。
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で何かするときのようにオブジェクトの概念では動かせません。「場」の概念で揺らします。つまり空間を曲げる的な。
数字は適当で、周期をずらして重ねます。位相を縦軸にしたり横軸にしたりするのも工夫ですね。調整していると、楽しくていくらでも時間が過ぎていきます。
#最後にもうひと工夫
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
#メリークリスマス!(早いけど)
ゲーム作る人に幸あれ。