大量のオブジェトを描写したいがraymarchingの負荷の大きさに泣かされてきました。それで違う方法を模索してきました。何とか使えるレベルに到達した方法ができたので記事を書いてみます。
#経緯
raymarchigでも複雑な形状をバウンディングボックスを使って負荷を軽減する方法を使っていましたが、やはり距離関数をmin()で総当たりするraymarchigでは限界があります。ここ一年くらいraymarchingを捨ててvertex shaderでメッシュを作る事をやってましたがchromeのクラッシュであきらめ、しょうがないので古いスキルだけどvertex shaderとfragment shaderで色々していたら、使える感じになったのでまとめてみました。
最初のインスパイアは、この2つのshader
https://www.shadertoy.com/view/4sSGD1
https://www.shadertoy.com/view/XssXz4
2次元の絵を重ねて奥行によって絵の大きさを変えて疑似的に3次元を表現をする方法。
以前、これに刺激をうけfragment shaderでraycastを使い平面の位置を出し、それを距離関数に入れてオブジェクトを描写する方法も試したけどボツにした経緯があります。
https://www.shadertoy.com/view/XtX3WH
これ。
今回は、この平面をvertex shaderで作りました。なのでバウンディングボックスをスライスした形状になりました。
説明用サンプルです
http://jsdo.it/gaziya/0vbt
#3D形状の作り方の流れを説明します。
vertex shaderで四角ポリゴンを作り、1列に並べます。その時、正六面体を想定しているのでポリゴンの幅と同じ長さを分割した間隔で配置します。説明用に四角ポリゴンの枠を黄色で描写しました。
ポリゴンの座標をfragment shaderに送り、この座標をraymarchigと同じく距離関数を使い距離を出します。距離が0.001以下だとraymarchigと同じ処理をして色を付けます。それ以外だとdiscardを使い描写させません。
これだと四角ポリゴンを真横から見るとオブジェクトが消えてしまうので、四角ポリゴンの正面が常に視線の方向を向くように処理します。この時の行列はジンバルロックを回避する為に任意軸で回します。すると枠が正面から見て傾く時が出てきます。これは補正をかけません。回転しても問題ない球の範囲にオブジェクトを置く事にしました。オブジェクトの大きさによりバウンディングボックスの大きさも変わります。
今はポリゴンが10枚ですが、これを30枚に増やします。
だいぶシワが無くなりました。60枚にします。
この位なら使えそうです。黄色い枠を外します。
こういう手順を踏みます。
#使い勝手の感想
raymarchingだと大量のオブジェクトを描写するにはmod()を使った折りたたみしか方法がありませんでした。この方法だと個別の位置情報が持てるオブジェクトが大量に描写できるようになりました。大量と言っても試したところではなくcorei5のノートPCで30枚スライスで作った500個がFPS60で動く程度です。バウンディングボックスの画面上の大きさとかスライス数とかが負荷に絡んでくるので一概には言えないけど、見た目には賑やかな数は動きます。この方法だとvertex shaderを経由して位置情報を持っているので、ポリゴンモデルとも同じ位置情報で同居させられます。背景は一枚の板ポリを最後面に貼り付けfragment shaderで背景を描けばraymarchigと同じ出来栄えになります。
弱点として
rayが飛ばせないので、シャドウなどのエフェクトは出来ません。
boxみたいにエッジが出るオブジェクトは低いスライス数の場合綺麗に描写されないので、round boxみたいに角を丸くしないと見栄えは上がりません。
位置情報は構造上、絶対位置と相対位置を作らないとならないので管理が大変です。
#プログラムの説明
これはwebGL2.0で書いていきます。
// javascript
var gl = canvas.getContext("webgl2") || canvas.getContext("experimental-webgl2");
gl.clearColor(0,0,0,1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
var compileShader = function(prog, src, type){
var sh = gl.createShader(type);
gl.shaderSource(sh, src.replace(/^\n/, ""));
gl.compileShader(sh);
gl.attachShader(prog, sh);
gl.deleteShader(sh);
};
var p = gl.createProgram();
compileShader(p, document.getElementById("vs").text, gl.VERTEX_SHADER);
compileShader(p, document.getElementById("fs").text, gl.FRAGMENT_SHADER);
gl.linkProgram(p);
gl.useProgram(p);
gl.uniform1f(gl.getUniformLocation(p, "aspect"), canvas.width / canvas.height);
var sliceNum = 60;
gl.uniform1f(gl.getUniformLocation(p, "sliceNum"), sliceNum);
var zero = Date.now();
var render = function() {
var time = (Date.now() - zero) * 0.001;
gl.uniform1f(gl.getUniformLocation(p, "time"),time);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 6*sliceNum);
requestAnimationFrame(render);
};
render();
見てもらってわかるとおりwebGLでVBOの設定をしていません。vertex shaderにおいてgl_VertexIDを使いポリゴンを作ってます。uniformで送られる情報はアスペクト比、時間、スライスの数です。極力GPU側に仕事をさせるように作っています。なのでjavascriptはシンプルです。描画モードはgl.TRIANGLESで四角ポリゴンなので、一枚につきポイントが6個必要になります。なのでgl.drawArraysの第3引数は6*sliceNumになります。
shaderについてはスクリプト内に説明を書きます。
// vertex shader
#version 300 es
uniform float aspect;
uniform float time;
uniform float sliceNum;
out vec3 vWorldPos;
out vec3 vLocalPos;
out vec3 vEye;
out vec3 vLight;
mat4 perspective(in float fov, in float aspect, in float near, in float far)
{
float v = 1./tan(radians(fov/2.)), u = v/aspect, w = near-far;
return mat4(u,0,0,0,0,v,0,0,0,0,(near+far)/w,-1,0,0,near*far*2./w,1);
}
mat4 lookAt(in vec3 eye, in vec3 center,in vec3 up) {
vec3 w = normalize(eye - center);
vec3 u = normalize(cross(up, w));
vec3 v = normalize(cross(w, u));
return mat4(
u.x, v.x, w.x, 0,
u.y, v.y, w.y, 0,
u.z, v.z, w.z, 0,
-dot(u, eye), -dot(v, eye), -dot(w, eye), 1);
}
mat3 rotate(in vec3 axis, in float theta)
{
axis = normalize(axis);
float x = axis.x, y = axis.y, z = axis.z;
float s = sin(theta), c = cos(theta), o = 1.0-c;
return mat3(
o*x*x+c,o*x*y+z*s,o*z*x-y*s,
o*x*y-z*s,o*y*y+c,o*y*z+x*s,
o*z*x+y*s,o*y*z-x*s,o*z*z+c);
}
// これはベクトル a にベクトル b を一致させる行列です。
mat3 matchTo(in vec3 a, in vec3 b)
{
a = normalize(a);
b = normalize(b);
return rotate(cross(b, a), acos(dot(a, b)));
}
void main()
{
// gl_VertexIDをfloatにします。
float vertexID = float(gl_VertexID) ;
// 面にIDを付けます。
float faceID = floor(vertexID / 6.0);
// vertexIDをuvにする為に加工します。
vertexID = abs(3.0 - mod(vertexID, 6.0));
// vertexIDからuvを算出
vec2 uv = vec2(mod(vertexID, 2.0), floor(vertexID / 2.0));
// uv(0.0~1.0)の範囲を(-1.0~1.0)の範囲に加工
// ここで小技を一つ
// 学校で習った数学だと定数を前に書きたくなります。
// そこを我慢してこの書き方をするのは何故か?便利だからです。
// 単純に後ろに*2.0-1.0を付ければ(0.0~1.0)が(-1.0~1.0)になります。
// 同じように単純に後ろに*0.5+0.5を付ければ(-1.0~1.0)が(0.0~1.0)になります。
vec2 coord = uv*2.0-1.0;
vec3 eye = vec3(0, 4, 3);
// この方法だと四角ポリゴンにオブジェクトを描写する為の座標(便宜上localPosします)と
// スクリーン上に配置する為の座標(便宜上worldPos)が必要になります。
// localPos.zにfractがついているのは複数のオブジェクトを扱う時用です。
// worldPosはオブジェクトの中心座標になります。
// localPosのvec3の後の * 1.5 はバウンディングボックスの大きさです。
// これは画像を見ながら数値を調整しています。
vec3 localPos = vec3(coord, fract(faceID/sliceNum)*2.0-1.0) * 1.5;
vec3 worldPos = vec3(sin(time),0,0);
// この処理で四角ポリゴンを視線方向に向かせます。
localPos = matchTo(eye-worldPos, vec3(0,0,1)) * localPos;
mat4 pMatrix = perspective(45.0, aspect, 0.1, 10.0);
mat4 vMatrix = lookAt(eye, vec3(0), vec3(0,1,0));
gl_Position = pMatrix * vMatrix * vec4(worldPos + localPos, 1);
// ライトは、ここで逆行列を使いfragment shaderに送ります。
vec3 light = normalize(vec3(1));
vLight = (inverse(vMatrix) * vec4(light, 0)).xyz;
vWorldPos = worldPos;
vLocalPos = localPos;
vEye = eye;
}
// fragment shader
#version 300 es
precision highp float;
in vec3 vWorldPos;
in vec3 vLocalPos;
in vec3 vEye;
in vec3 vLight;
out vec4 fragColor;
mat2 rotate(in float a)
{
return mat2(cos(a), sin(a), -sin(a), cos(a));
}
float sdTorus(in vec3 p, in vec2 t )
{
vec2 q = vec2(length(p.xz)-t.x,p.y);
return length(q)-t.y;
}
float map(in vec3 p)
{
return sdTorus(p, vec2(1,0.3));
}
vec3 calcNormal(in vec3 p)
{
const vec2 e = vec2(0.0001, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)));
}
void main()
{
vec3 col;
// localPos入力で距離関数を求め0.001以下だと描写して以外だとdiscardで非描写します。
// 後はraymarchingと同じ流れ。
if( map(vLocalPos)< 0.001)
{
vec3 normal = calcNormal(vLocalPos);
float dif = dot(normal, vLight);
dif = clamp((dif+0.5)*0.7, 0.3, 1.0);
float amb = max(0.7+0.3*normal.y,0.0);
float spc =
pow(
clamp(
// ここに関してはworldPosが絡んできます。
dot(reflect(normalize(vWorldPos + vLocalPos - vEye), normal),vLight),
0.0, 1.0
),
50.0
);
col = vec3(0.3,0.8,0.4);
col = col*dif*amb+spc;
col = clamp(col,0.0,1.0);
col = pow(col, vec3(0.8));
} else {
discard;
}
fragColor = vec4(col, 1);
}
おわりに
ちょっと面倒だけどraymarchigがわかっていれば難しくないと思います。
棒状のオブジェクトとかもバウンディングボックスを複数並べて対応すればコストダウンになります。
サンプル意外にも書いたモノがあるのでリンクを載せておきます。
http://jsdo.it/gaziya/wsNc
http://jsdo.it/gaziya/ykkE
http://runstant.com/gaziya/projects/395db665
http://jsdo.it/gaziya/u3p8
http://jsdo.it/gaziya/Y1PS