Posted at

UnityでVJ的な作品をつくる

More than 1 year has passed since last update.

次を目標に進めていきます。


  1. シェーダをゴリゴリ書く

  2. 音楽に合わせて動かす

  3. なんとなくかっこいい感じにする

コンセプトとしては

できた動画: https://youtu.be/BglEgywaAPw

コードなど: https://github.com/n0mimono/GeoToybox


シェーダ


ワイヤフレーム その1

とりあえずワイヤフレームを表示するシェーダを書きます。

コード全体はこちら。geometry shaderの部分だけを抜き出すと

     [maxvertexcount(21)]

void geo(triangle v2g v[3], inout TriangleStream<g2f> TriStream) {
for (int i = 0; i < 3; i++) {
v2g vb = v[(i + 0) % 3];
v2g v1 = v[(i + 1) % 3];
v2g v2 = v[(i + 2) % 3];

float3 dir = normalize((v1.vertex.xyz + v2.vertex.xyz) * 0.5 - vb.vertex.xyz);

g2f o;
o.color = _Color * v[0].color;

o.vertex = UnityObjectToClipPos(float4(v1.vertex.xyz, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);

o.vertex = UnityObjectToClipPos(float4(v2.vertex.xyz, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);

o.vertex = UnityObjectToClipPos(float4(v2.vertex.xyz + dir * _Width, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);
TriStream.RestartStrip();

o.vertex = UnityObjectToClipPos(float4(v1.vertex.xyz, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);

o.vertex = UnityObjectToClipPos(float4(v1.vertex.xyz + dir * _Width, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);

o.vertex = UnityObjectToClipPos(float4(v2.vertex.xyz + dir * _Width, 1));
UNITY_TRANSFER_FOG(o,o.vertex);
TriStream.Append(o);
TriStream.RestartStrip();
}

めちゃくちゃ汚いですが、、

1個のトライアングルに対して3個の長方形(=6個のトライアングル)を三角形状に置くことでワイヤフレームを形成します。そのため、必要な頂点数は3*6=18となります(が、コード中ではmaxvertexcount(21)と書いています。特に意味はないです)。


ワイヤフレーム その2

ワイヤフレームに加えて、通常の描画表現も加えます。

コード全体はこちら。全体像としては

Shader "Wireframe/Unlit" {

Properties {
...
}

SubShader {
...

// 0: wireframe pass
Pass {
Cull Off
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off

...
}

// 1: surface pass
Pass {
Cull Back
Blend SrcAlpha OneMinusSrcAlpha

...
}

1つ目のPassではZWrite Offにしつつワイヤフレームを書き、2つ目のPassでは半透明シェーダでこれを上書きします。ユニティちゃんSDは複数のSkinedMeshRendererで構成されるため、何も考えずに半透明シェーダを適用すると描画順の関係で表示がおかしくなりますがここでは無視します。

2つ目のPassのfragment shaderは次の通り

      float halpha(float y) {

return pow(y + _HeightOffset, _HeightPower);
}

fixed4 frag (v2f i) : SV_Target {
float3 normalDir = normalize(i.normal);
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.wpos.xyz);
float NNdotV = 1 - dot(normalDir, viewDir);
float rim = pow(NNdotV, _RimPower) * _RimAmplitude;

float4 col = tex2D(_MainTex, i.uv) * i.color * _Tint;
col.rgb = col.rgb * _RimTint.a + rim * _RimTint.rgb;
col.a *= saturate(halpha(i.wpos.y - _WorldPosition.y));

return col;
}

_HeightOffsetがスクリプトから適当に与えられたとして、フラグメントのワールド座標(i.wpos.xyz)とオブジェクトのワールド座標(_WorldPosition.y、スクリプトから与える)から透明度を適当に決めます。また、表現としてキラキラさせたいのでrimエフェクトを加えています。


ワイヤフレーム その3

ボクセル的なものを作ります。

コード全体はこちら。2つ目のPassのgemetory shaderで1個のトライアングルから立方体を作り出します。

      v2f vert (appdata v) {

v2f o;
o.vertex = v.vertex;
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.wpos = mul(unity_ObjectToWorld, v.vertex);
o.color = v.color;
o.normal = v.normal;
return o;
}

#define ADD_VERT(v, n) \
o.vertex = UnityObjectToClipPos(v); \
o.normal = UnityObjectToWorldNormal(n); \
UNITY_TRANSFER_FOG(o,o.vertex); \
TriStream.Append(o);

#define ADD_TRI(p0, p1, p2, n) \
ADD_VERT(p0, n) ADD_VERT(p1, n) \
ADD_VERT(p2, n) \
TriStream.RestartStrip();

[maxvertexcount(36)]
void geo(triangle v2f v[3], inout TriangleStream<v2f> TriStream) {
float4 wpos = (v[0].wpos + v[1].wpos + v[2].wpos) / 3;
float4 vertex = (v[0].vertex + v[1].vertex + v[2].vertex) / 3;
float2 uv = (v[0].uv + v[1].uv + v[2].uv) / 3;

v2f o = v[0];
o.uv = uv;
o.wpos = wpos;
float scale = _BoxScale;

float4 v0 = float4( 1, 1, 1,1)*scale + float4(vertex.xyz,0);
float4 v1 = float4( 1, 1,-1,1)*scale + float4(vertex.xyz,0);
float4 v2 = float4( 1,-1, 1,1)*scale + float4(vertex.xyz,0);
float4 v3 = float4( 1,-1,-1,1)*scale + float4(vertex.xyz,0);
float4 v4 = float4(-1, 1, 1,1)*scale + float4(vertex.xyz,0);
float4 v5 = float4(-1, 1,-1,1)*scale + float4(vertex.xyz,0);
float4 v6 = float4(-1,-1, 1,1)*scale + float4(vertex.xyz,0);
float4 v7 = float4(-1,-1,-1,1)*scale + float4(vertex.xyz,0);

float3 n0 = float3( 1, 0, 0);
float3 n1 = float3(-1, 0, 0);
float3 n2 = float3( 0, 1, 0);
float3 n3 = float3( 0,-1, 0);
float3 n4 = float3( 0, 0, 1);
float3 n5 = float3( 0, 0,-1);

ADD_TRI(v0, v2, v3, n0);
ADD_TRI(v3, v1, v0, n0);
ADD_TRI(v5, v7, v6, n1);
ADD_TRI(v6, v4, v5, n1);

ADD_TRI(v4, v0, v1, n2);
ADD_TRI(v1, v5, v4, n2);
ADD_TRI(v7, v3, v2, n3);
ADD_TRI(v2, v6, v7, n3);

ADD_TRI(v6, v2, v0, n4);
ADD_TRI(v0, v4, v6, n4);
ADD_TRI(v5, v1, v3, n5);
ADD_TRI(v3, v7, v5, n5);
}

1個のトライアングルに対して12個のトライアングル(36個の頂点)を作ります。位置はトライアングルの中心、サイズは適当に変更できるようにします。OpenGLでは正常に動作しますが、三角形の作り方が決め打ちなので環境によってはポリゴンの表裏が逆になるかもしれません。

コード中では、vertex shaderでUnityObjectToClipPosを使わずにgeometry shaderで使っています。これはローカル座標系の立方体に対してUnityObjectToClipPosを適用したいからです。同様に、立方体の法線に対してもgeometry shader内でUnityObjectToWorldNormalを適用します。


ワイヤフレーム その4

ボクセルのシェーダと半透明のシェーダを組み合わせます。

コード全体はこちら。geometry shader内で閾値を算出して処理を分岐します。

      [maxvertexcount(36)]

void geo(triangle v2f v[3], inout TriangleStream<v2f> TriStream) {
float4 wpos = (v[0].wpos + v[1].wpos + v[2].wpos) / 3;

float th = wpos.y - _WorldPosition.y + _HeightOffset;
float scale = saturate(pow(abs(th) * _HeightAmp, _HeightPower));
if (th > 0) {
if (scale > 0.99) {
geoOri(v, TriStream);
} else {
geoTri(scale, v, TriStream);
}
} else {
geoBox(scale * _BoxScale, v, TriStream);
}
}

geoOriは先の半透明シェーダと同じです。

      void geoOri(triangle v2f v[3], inout TriangleStream<v2f> TriStream) {

...
}

半透明シェーダで境界に近い部分では三角形を縮小します。

      #define ADD_SHRINK(v, c, n, s) \

o.vertex = UnityObjectToClipPos(float4(lerp(c, v.xyz, s), v.z)); \
o.normal = UnityObjectToWorldNormal(n); \
UNITY_TRANSFER_FOG(o,o.vertex); \
TriStream.Append(o); \

void geoTri(float scale, triangle v2f v[3], inout TriangleStream<v2f> TriStream) {
float4 wpos = (v[0].wpos + v[1].wpos + v[2].wpos) / 3;
float4 vertex = (v[0].vertex + v[1].vertex + v[2].vertex) / 3;
float2 uv = (v[0].uv + v[1].uv + v[2].uv) / 3;

v2f o = v[0];
o.uv = uv;
o.wpos = wpos;

ADD_SHRINK(v[0].vertex, vertex.xyz, v[0].normal, scale)
ADD_SHRINK(v[1].vertex, vertex.xyz, v[1].normal, scale)
ADD_SHRINK(v[2].vertex, vertex.xyz, v[2].normal, scale)
TriStream.RestartStrip();
}

立方体も境界に近い部分で縮小するようにします。

      void geoBox(float scale, triangle v2f v[3], inout TriangleStream<v2f> TriStream) {

...
}


音楽に合わせる

AudioSourceGetSpectrumDataでスペクトルを、timeで再生時間がとれます。パワースペクトルの総和がそのフレームにおけるサウンドのパワーになりますが、、今回は適当に全部足したものを使います。

例えば

    float[] spec = new float[1024]

var source = GetComponent<AudioSource> ();
source.GetSpectrumData (spec, 0, FFTWindow.Hamming);

var time = source.time;
var power = spec.Sum();

これに対して

    var prop = new MaterialPropertyBlock ();

prop.SetFloat ("_SoundTime", time);
prop.SetFloat ("_BoxScale", power);

シェーダ側では

      float _UseSound; // 音楽を使う場合は1

float _SoundTime; // 再生時間
float _BoxScale; // 立方体サイズ

float4 time() {
if (_UseSound == 1) {
return float4(
_SoundTime / 20,
_SoundTime,
_SoundTime * 2,
_SoundTime * 3
);
} else {
return _Time;
}
}

_Timeのかわりにtime()を使い、_BoxScaleを立方体の位置やサイズに影響させるようにします。

実際のコードはこちら。色々あれですが大事なのは

      float3 polar(float3 v) {

float r = length(v);
float r2 = length(v.xy);
return float3(r, acos(v.z/r), sign(v.y) * acos(v.x/r2));
}

float3 rev(float3 p) {
return float3(
p.x * sin(p.y) * cos(p.z),
p.x * sin(p.y) * sin(p.z),
p.x * cos(p.y)
);
}

float3 trans(float3 v, inout float scale) {
float4 t = time();

v += snoise(v * t.x) * _Noise.z;
float3 p = polar(v);
if (_UseSound == 1) {
_Noise *= scale * 3 + sin(t.x);
}

float w0 = 1;
float w1 = 1;
float4 m = float4(
cos(p.z * w0 * _RingFreq.x + _HeightOffset + w1 * t.y),
sin(p.z * w0 * _RingFreq.y + _HeightOffset + w1 * t.y),
cos(p.y * w0 * _RingFreq.z + _HeightOffset + w1 * t.y * sin(t.x * 0.09)),
sin(p.y * w0 * _RingFreq.w + _HeightOffset + w1 * t.y * sin(t.x * 0.05))
);

p.x = m.x*m.x*m.x * _RingAmp.x + m.y*m.y*m.y * _RingAmp.y + m.z * _RingAmp.z + m.w * _RingAmp.w;
p.x += _RingOffset;
scale *= saturate(p.x) * 6 + snoise(v) * _Noise.w;

float3 q = rev(p) + _Noise.x * pow(snoise(v * _Noise.y) * 2, 3);
return lerp(q, v, scale);
}

void geoBox(float scale, triangle v2f v[3], inout TriangleStream<v2f> TriStream) {
float4 wpos = (v[0].wpos + v[1].wpos + v[2].wpos) / 3;
float4 vertex = (v[0].vertex + v[1].vertex + v[2].vertex) / 3;
float2 uv = (v[0].uv + v[1].uv + v[2].uv) / 3;

float3 d = _WorldPosition.xyz + _OriginOffset.xyz;
wpos.xyz = trans(wpos.xyz - d, scale) + d;
...
}

[maxvertexcount(36)]
void geo(triangle v2f v[3], inout TriangleStream<v2f> TriStream) {
...
geoBox(scale * _BoxScale, v, TriStream);
...
}

_BoxScaleで立方体の基本的なサイズを変更しつつ、trans()で立方体の位置とサイズを いい感じに 変換します。

自分の体を分解して結界を張り出すみさきさん


いい感じにする

シェーダとマテリアルを調整しながらいろいろ置いていきます。

Post Processing Stackでポストエフェクトをかけます。今回使ったのはBloom、Color Grading、Chromatic Aberration、Vignetteの4つです。

結果、最終的な絵面はこの通り

アプリとしてはこれで完成になります。


録画する

Recorderを使います。録画とともに 音ズレなしで録音も同時にできる 優れものです。

Flip image verticallyからチェックを外して、Output Resolutionをx1440p_QHDに設定、後はそのままです。

Start Recodingをクリックして1曲分再生されるまで待った後に、Stop Recordingすればmp4が出力されます。後はYoutubeあたりにアップロードすれば終わりです。