はじめに
p5.jsとGLSLでレイマーチングをやって小星形十二面体をレイマーチングで描画します。カメラはp5.jsに備え付けの物をorbitControl()で動かして使います。
コード全文
作品ページ:p5 camera raymarching
/*
p5.jsでH3で小星形正多面体を描画したら終わりでいいです
終わり
*/
let myShader, myCam;
const vsShader =
`#version 300 es
in vec3 aPosition;
out vec2 vUv;
void main(){
vUv = aPosition.xy * 2.0; // -1~1
vUv.y *= -1.0; // p5はy軸が逆なので逆にする
gl_Position = vec4(aPosition.xy*2.0, 0.0, 1.0);
}
`;
const fsShader =
`#version 300 es
precision highp float;
uniform vec3 uEye; // 目線の位置
uniform float uFov; // fov, 視野角(上下開き、デフォルト60°)
uniform float uAspect; // アスペクト比、横長さ/縦長さ(W/H)
uniform vec3 uSide; // 画面右方向
uniform vec3 uUp; // 画面下方向で、マイナスで使う
uniform vec3 uFront; // 画面手前方向...マイナスで使う。
uniform vec3 uLightDirection; // 光を使う場合。光の進む向き。マイナスで使って法線と内積を取る。
uniform vec3 uVector0; // coeffとqab, qbc, qcaから事前に計算して渡す
uniform vec3 uVector1;
in vec2 vUv;
const float MAX_DIST = 20.0; // 限界距離。これ越えたら無いとみなす。
const float THRESHOLD = 0.001; // 閾値。これより近付いたら到達とみなす。
const int ITERATION = 80; // マーチング回数限界
const vec2 EPS = vec2(0.0001, 0.0); // 法線計算用
out vec4 fragColor;
// fold用const.
const float phi = (1.0+sqrt(5.0))/2.0; //(黄金比)
// ミラーベクトル
vec3 na = vec3(1.0, 0.0, 0.0);
vec3 nb = vec3(0.0, 1.0, 0.0);
vec3 nc = vec3(-0.5, -0.8090, 0.3090);
// na,nb,ncの外積でできる領域面の境界の法線ベクトル。これで平面を作り、fold立体の面を作る。
vec3 pab = vec3(0.0, 0.0, 0.8090);
vec3 pbc = vec3(0.5, 0.0, 0.8090);
vec3 pca = vec3(0.0, 0.2697, 0.7060);
// 折り畳み処理。H3の場合は5回。具体的にはna, nb, ncのそれぞれについてそれと反対にあるときだけ面で鏡写しにする。
void foldH3(inout vec3 p){
for(int i = 0; i < 5; i++){
p -= 2.0 * min(0.0, dot(p, na)) * na;
p -= 2.0 * min(0.0, dot(p, nb)) * nb;
p -= 2.0 * min(0.0, dot(p, nc)) * nc;
}
}
// pca限定で、特別なベクトルで平面を取るもの
float foldH3CustomPolygon(vec3 p, vec3 q1, vec3 q2){
foldH3(p);
float t = dot(p - q1, q2);
return t;
}
// 総合距離関数、map.
float map(in vec3 p){
float t = foldH3CustomPolygon(p, uVector0, uVector1);
return t;
}
// 法線ベクトルの取得
vec3 calcNormal(vec3 p){
// F(x, y, z) = 0があらわす曲面の、F(x, y, z)が正になる側の
// 法線を取得するための数学的処理。具体的には偏微分、分母はカット。
vec3 n;
n.x = map(p + EPS.xyy) - map(p - EPS.xyy);
n.y = map(p + EPS.yxy) - map(p - EPS.yxy);
n.z = map(p + EPS.yyx) - map(p - EPS.yyx);
return normalize(n);
}
// マーチング
float march(vec3 ray, vec3 eye){
float h = THRESHOLD * 2.0; // 毎フレームの見積もり関数の値。
// 初期値は0.0で初期化されてほしくないのでそうでない値を与えてる。
// これがTHRESHOLDを下回れば到達とみなす
float t = 0.0;
// tはcameraからray方向に進んだ距離の累計。
// 到達ならこれが返る。失敗なら-1.0が返る。つまりresultが返る。
float result = -1.0;
for(int i = 0; i < ITERATION; i++){
if(h < THRESHOLD || t > MAX_DIST){ break; }
// tだけ進んだ位置で見積もり関数の値hを取得し、tに足す。
h = map(eye + t * ray);
t += h;
}
// t < MAX_DISTなら、h < THRESHOLDで返ったということなのでマーチング成功。
if(t < MAX_DIST){ result = t; }
return result;
}
// メイン
void main(){
// 背景色
vec3 color = vec3(0.0);
float alpha = 1.0;
// rayを計算する
vec3 ray = vec3(0.0);
// uTopだけマイナス、これで近づく形。さらにuSideで右、uUpで上に。分かりやすいね。
ray -= uFront;
ray += uSide * uAspect * tan(uFov * 0.5) * vUv.x;
ray += uUp * tan(uFov * 0.5) * vUv.y;
ray = normalize(ray);
// レイマーチング
float t = march(ray, uEye);
// tはマーチングに失敗すると-1.0が返る。
if(t > -THRESHOLD){
vec3 pos = uEye + t * ray; // 到達位置
vec3 n = calcNormal(pos); // 法線
// 明るさ。内積の値に応じて0.3を最小とし1.0まで動かす。
float diff = clamp((dot(n, -uLightDirection) + 0.5) * 0.75, 0.3, 1.0);
vec3 baseColor = vec3(t*0.1, t*0.5, t*1.4); // 距離彩色
baseColor *= diff;
// 遠くでフェードアウトするように調整する
color = mix(baseColor, color, tanh(t * 0.02));
alpha = 1.0;
}
fragColor = vec4(color, alpha); // これでOK?
}
`;
function setup() {
createCanvas(800, 600, WEBGL);
pixelDensity(1);
myShader = createShader(vsShader, fsShader);
myCam = createCamera();
myCam.camera(0,0,1.732, 0,0,0, 0,1,0);
myCam.perspective(PI/3, width/height, 0.1732, 17.32);
noStroke();
}
function draw() {
clear();
shader(myShader);
setCamera(myCam);
orbitControl(1,1,1,{freeRotation:true});
const cameraEye = createVector(myCam.eyeX, myCam.eyeY, myCam.eyeZ);
const cameraCenter = createVector(myCam.centerX, myCam.centerY, myCam.centerZ);
const cameraUp = createVector(myCam.upX, myCam.upY, myCam.upZ);
const front = cameraEye.copy().sub(cameraCenter).normalize();
const side = cameraUp.cross(front).normalize();
const up = front.cross(side).normalize();
myShader.setUniform("uEye", cameraEye.array());
myShader.setUniform("uAspect", width/height);
myShader.setUniform("uFov", PI/3);
myShader.setUniform("uFront", front.array());
myShader.setUniform("uSide", side.array());
myShader.setUniform("uUp", up.array());
myShader.setUniform("uLightDirection", front.mult(-1).array());
// 小星形十二面体用のパラメーター設定
const vector0 = createVector(0, 0.2697, 0.7060).mult(0.4);
myShader.setUniform("uVector0", vector0.array());
myShader.setUniform("uVector1", [-1/2, 0, 1.618/2]);
push();
camera(0,0,1,0,0,0,0,1,0);
ortho(-1,1,-1,1,0,1);
plane(0);
pop();
}
実行結果:
レイマーチング
レイマーチングとは距離関数を使ったGLSLにおける描画方式です。カメラの目の位置に相当する位置から、目の前のキャンバスの各ピクセルに線を伸ばして、各段階において距離を計算します。その距離だけ進んで、進んだ位置からまた距離を調べて伸ばして、それを繰り返し、到達した場合に処理を終了し、然るべく位置を決めます。すると、そのように色が付き、3D描画が成功します。
// マーチング
float march(vec3 ray, vec3 eye){
float h = THRESHOLD * 2.0; // 毎フレームの見積もり関数の値。
// 初期値は0.0で初期化されてほしくないのでそうでない値を与えてる。
// これがTHRESHOLDを下回れば到達とみなす
float t = 0.0;
// tはcameraからray方向に進んだ距離の累計。
// 到達ならこれが返る。失敗なら-1.0が返る。つまりresultが返る。
float result = -1.0;
for(int i = 0; i < ITERATION; i++){
if(h < THRESHOLD || t > MAX_DIST){ break; }
// tだけ進んだ位置で見積もり関数の値hを取得し、tに足す。
h = map(eye + t * ray);
t += h;
}
// t < MAX_DISTなら、h < THRESHOLDで返ったということなのでマーチング成功。
if(t < MAX_DIST){ result = t; }
return result;
}
map()で距離を調べています。map()の実装については割愛します。この記事のメインは、カメラです。
外と中で同じカメラを使う
カメラの設定は内部ではやっていません。外でやっています。
myShader = createShader(vsShader, fsShader);
myCam = createCamera();
myCam.camera(0,0,1.732, 0,0,0, 0,1,0);
myCam.perspective(PI/3, width/height, 0.1732, 17.32);
小さめですね。ここでカメラを設定しています。このカメラの情報を内部で用いています。
myShader.setUniform("uEye", cameraEye.array());
myShader.setUniform("uAspect", width/height);
myShader.setUniform("uFov", PI/3);
myShader.setUniform("uFront", front.array());
myShader.setUniform("uSide", side.array());
myShader.setUniform("uUp", up.array());
myShader.setUniform("uLightDirection", front.mult(-1).array());
カメラを設定するパートはこうです。
// rayを計算する
vec3 ray = vec3(0.0);
// uTopだけマイナス、これで近づく形。さらにuSideで右、uUpで上に。分かりやすいね。
ray -= uFront;
ray += uSide * uAspect * tan(uFov * 0.5) * vUv.x;
ray += uUp * tan(uFov * 0.5) * vUv.y;
ray = normalize(ray);
目の位置からrayを飛ばすイメージですが、まずフロントベクトルだけまっすぐ前に進みます。そのあとで、uvの値に応じてfovとaspectに従って縦と横に移動し、ピクセルの位置にレイが飛ぶように調整します。そのあとで、
// レイマーチング
float t = march(ray, uEye);
// tはマーチングに失敗すると-1.0が返る。
if(t > -THRESHOLD){
vec3 pos = uEye + t * ray; // 到達位置
vec3 n = calcNormal(pos); // 法線
// 明るさ。内積の値に応じて0.3を最小とし1.0まで動かす。
float diff = clamp((dot(n, -uLightDirection) + 0.5) * 0.75, 0.3, 1.0);
vec3 baseColor = vec3(t*0.1, t*0.5, t*1.4); // 距離彩色
baseColor *= diff;
// 遠くでフェードアウトするように調整する
color = mix(baseColor, color, tanh(t * 0.02));
alpha = 1.0;
}
こんな感じでeyeからrayを飛ばします。マーチングに成功した場合、$t$は正になるので、これで色や法線を調べます。法線は微分で出しています。色はいろんな設定方法があるのですが今回は目の位置から前に向かうライトベクトルを使って明るさを決めて、あとは距離に応じて青っぽくなるようにいい感じで決めています。
おわりに
レイマーチングで外のカメラを使えると、中でカメラを決めるのと違ってorbitControl()で自由に動かしたりできるので楽しいですね!ここまでお読みいただいてありがとうございました。