この記事は、Processing Advent Calendar 2021 3日目の記事です。
みんな大好きなあのドーナツを、p5.jsのクラスp5.Geometryを活用して作っていきます。
(この記事を書いた際に使ったp5.jsのバージョンは、1.4.0です)
ソースコードは下のOpenProcessingのリンクから見ることができます。
0. はじめに
0.1 p5.Geometryって何?
WebGLでは3Dモデルを描画する際に、あらかじめ作成した各頂点の位置ベクトルや法線ベクトルなどの頂点データが入った頂点バッファと、どの頂点3つを用いた3角形を描くかの情報(インデックス配列)が入ったインデックスバッファを呼び出すことで同じ形状のモデルを効率的に描画しています。
例えば、以下の頂点の位置情報が入った頂点バッファと、[ 0, 1, 2 ]の頂点3つと[ 1, 3, 2 ]の頂点3つの頂点番号の配列(インデックス配列)の入ったインデックスバッファを作成し、それを呼び出すことで、三角形2つが組み合わさって四角形を描画することができます。
頂点番号 | x座標 | y座標 | z座標 |
---|---|---|---|
0 | 0.0 | 0.0 | 0.0 |
1 | 1.0 | 0.0 | 0.0 |
2 | 0.0 | 1.0 | 0.0 |
3 | 1.0 | 1.0 | 0.0 |
p5.Geometryはp5.jsライブラリで定義されているクラスであり、この頂点バッファとインデックスバッファのもととなる頂点データやインデックス配列をクラス変数としてまとめて定義することができます。
reference : https://p5js.org/reference/#/p5.Geometry
0.2 p5.jsで立体を描画する関数
box()やsphere()などの立体を描画する関数の処理では、p5.Geometryの作成とそれをもとに作成されたバッファの呼び出しが行われています。p5.jsライブラリの立体描画のソースコードは、以下のリンクから見ることができます。
1. ドーナツ関数を書く
1.1 下準備
まずは、ドーナツ以外の背景や光の設定などを行いましょう。
作成するドーナツの代わりとして、トーラスを描画します。
function setup() {
// WebGLモードにする
createCanvas(800, 600, WEBGL);
}
function draw() {
// 背景・光の設定
background(250, 200, 200);
directionalLight(255, 255, 255, 0.5, 0.5, -1);
ambientLight(150);
// 回転するドーナツの描画
rotateX(millis() / 1000);
rotateY(millis() / 3000);
noStroke();
fill(230, 180, 100);
torus(120, 40, 60, 40); // 作成するドーナツの代わり
}
1.2 トーラス関数を読み解く
今回は、p5.jsライブラリのtorus関数をもとにドーナツ関数を作成していきます。
torus関数のソースコードは下のリンクから見ることができます。
torus関数の処理内容について、簡単に解説を行います。完全に理解できなくても大丈夫です。
torus関数は、輪の半径のradius、チューブ半径のtubeRadius、輪一周の頂点数を決めるdetailX、チューブ周り一周の頂点数を決めるdetailYの4つの引数で呼び出されます。
torus関数の各処理を見ていきましょう。
初めの4つのif文では、引数が定義されなかった時の値の代入について書かれています。
if (typeof radius === 'undefined') {
radius = 50;
} else if (!radius) {
return; // nothing to draw
}
if (typeof tubeRadius === 'undefined') {
tubeRadius = 10;
} else if (!tubeRadius) {
return; // nothing to draw
}
if (typeof detailX === 'undefined') {
detailX = 24;
}
if (typeof detailY === 'undefined') {
detailY = 16;
}
tubeRatioは、radiusとtubeRadiusの比をトーラスの形を決める値として定義しています。
const tubeRatio = (tubeRadius / radius).toPrecision(4);
次に定義されるgIdはバッファの作成、呼び出し時に使われるジオメトリの名前です。
形や頂点数が異なる場合は異なるジオメトリとして定義する必要があるため、tubeRatio、detailX、detailYを含んでいます。
全体のスケールが違っても同じジオメトリをもとに描画することができるため、radiusとtubeRadiusは含まず、tubeRatioのみを含みます。
const gId = `torus|${tubeRatio}|${detailX}|${detailY}`;
次のif文の条件は、上のgIdについて既にバッファが作成されているかをチェックしています。
バッファが作成されていない場合は作成・描画を行い、すでに作成されている場合は描画のみを行います。
if (!this._renderer.geometryInHash(gId)) {
// ・・・
if文内ではまず、各頂点の位置ベクトル、法線ベクトル、UV座標を計算するコールバック関数を定義しています。
vertices、vertexNormals、uvsはそれぞれ位置ベクトル、法線ベクトル、UV座標を格納するp5.Geometryのクラス変数です。位置ベクトルと法線ベクトルはp5.Vectorで、UV座標は0~1の数で定義します。
位置ベクトル、法線ベクトルの計算の数学的な解説はここでは割愛します。
(ヒント:thetaはトーラス中心から見た角度、phiはチューブの断面の円の中心から見た角度を指しています)
const _torus = function() {
for (let i = 0; i <= this.detailY; i++) {
const v = i / this.detailY;
const phi = 2 * Math.PI * v;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const r = 1 + tubeRatio * cosPhi;
for (let j = 0; j <= this.detailX; j++) {
const u = j / this.detailX;
const theta = 2 * Math.PI * u;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
const p = new p5.Vector(
r * cosTheta,
r * sinTheta,
tubeRatio * sinPhi
);
const n = new p5.Vector(cosPhi * cosTheta, cosPhi * sinTheta, sinPhi);
this.vertices.push(p);
this.vertexNormals.push(n);
this.uvs.push(u, v);
}
}
上で定義したコールバック関数を用いて、p5.Geometryを作成します。
detailX、detailYを引数として与えていれば、p5.Geometryのクラス関数computeFaces()でインデックス配列を自動で計算することができます。
const torusGeom = new p5.Geometry(detailX, detailY, _torus);
torusGeom.computeFaces();
computeFaces()によるインデックス配列の決め方を解説します。
このコードでは、各頂点が下の図のように並んでいる平面のY方向の端と端をつなげてチューブにし、さらにX方向の端と端をつなげることでトーラスをつくっています(XはdetailXの省略、YはdetailYの省略です)。
このとき、全ての面を描くためには全てのi(0≤i<Y)、j(0≤j<X)について、
[ i * ( X + 1 ) + j , i * ( X + 1 ) + j + 1 , ( i + 1 ) * ( X + 1 ) + j ]
[ ( i + 1 ) * ( X + 1 ) + j , i * ( X + 1 ) + j + 1 , ( i + 1 ) * ( X + 1 ) + j + 1 ]
の2つの三角形を描画する必要があります。
これを並べた配列がcomputeFaces()で計算するインデックス配列です。
_makeTriangleEdges()._edgesToVertices()ではストロークの始点ベクトル・終点ベクトルを計算しています。detailX、detailYが大きすぎる場合はストロークは描画できません。
if (detailX <= 24 && detailY <= 16) {
torusGeom._makeTriangleEdges()._edgesToVertices();
} else if (this._renderer._doStroke) {
console.log(
'Cannot draw strokes on torus object with more' +
' than 24 detailX or 16 detailY'
);
}
バッファ未作成の場合の最後の処理として、今まで計算してきたトーラスの頂点データ、インデックス配列をもとに頂点バッファ・インデックスバッファを作成します。
this._renderer.createBuffers(gId, torusGeom);
バッファが作成されたら、それをもとにトーラスを描画します。
gIdのあとの3つの引数は、x方向、y方向、z方向のスケールを表します。
this._renderer.drawBuffersScaled(gId, radius, radius, radius);
以上がトーラス関数の処理内容です。
1.3 ドーナツ関数の雛形を作る
トーラス関数をもとに、ドーナツ関数の雛形を作ります。
トーラス関数をコピペしたうえで、以下の3つの変更を加えます。
・関数の定義方法をfunction命令に変える
・torusの文字列を全てdonutに変える
・最初の2行を削除する(ユーザー定義の関数では不要なため)
function donut(radius, tubeRadius, detailX, detailY) { // 関数の定義方法をfunction命令に変える
// 最初の2行は削除する
if (typeof radius === 'undefined') {
radius = 50;
} else if (!radius) {
return; // nothing to draw
}
if (typeof tubeRadius === 'undefined') {
tubeRadius = 10;
} else if (!tubeRadius) {
return; // nothing to draw
}
if (typeof detailX === 'undefined') {
detailX = 24;
}
if (typeof detailY === 'undefined') {
detailY = 16;
}
const tubeRatio = (tubeRadius / radius).toPrecision(4);
const gId = `donut|${tubeRatio}|${detailX}|${detailY}`; // torus -> donut
if (!this._renderer.geometryInHash(gId)) {
const _donut = function() { // torus -> donut
for (let i = 0; i <= this.detailY; i++) {
const v = i / this.detailY;
const phi = 2 * Math.PI * v;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const r = 1 + tubeRatio * cosPhi;
for (let j = 0; j <= this.detailX; j++) {
const u = j / this.detailX;
const theta = 2 * Math.PI * u;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
const p = new p5.Vector(
r * cosTheta,
r * sinTheta,
tubeRatio * sinPhi
);
const n = new p5.Vector(cosPhi * cosTheta, cosPhi * sinTheta, sinPhi);
this.vertices.push(p);
this.vertexNormals.push(n);
this.uvs.push(u, v);
}
}
};
const donutGeom = new p5.Geometry(detailX, detailY, _donut); // torus -> donut
donutGeom.computeFaces(); // torus -> donut
if (detailX <= 24 && detailY <= 16) {
donutGeom._makeTriangleEdges()._edgesToVertices(); // torus -> donut
} else if (this._renderer._doStroke) {
console.log(
'Cannot draw strokes on torus object with more' +
' than 24 detailX or 16 detailY'
);
}
this._renderer.createBuffers(gId, donutGeom); // torus -> donut
}
this._renderer.drawBuffersScaled(gId, radius, radius, radius);
return this;
}
この関数を定義したうえでdraw()内のtorus関数の呼び出しをdonut関数に変更すると、変更前と同様の結果が得られます。
1.4 ドーナツの形を変更する
頂点データを計算しているのはコールバック関数_donutなので、ここを書き換えます。
あのドーナツを作るためには、中心から見た角度に応じてチューブの半径を変化させる必要があります。
今回は、以下の式・グラフのように、連続した弧を描くのにthetaに関する三角関数の絶対値を使います。
r=1 + \frac{1}{2} | sin4\theta |
チューブのもとの半径を表す変数はtubeRatioであるため、これを中心から見た角度thetaをもとに変化させた、新しいチューブ半径newTubeRatioを定義します。
newTubeRatioの計算はthetaの計算以降に行う必要があるため、元々tubeRatioを使って計算していたrをjに関するfor文内に移動させる必要があります。
const _donut = function() { // torus -> donut
for (let i = 0; i <= this.detailY; i++) {
const v = i / this.detailY;
const phi = 2 * Math.PI * v;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
// const r = 1 + tubeRatio * cosPhi;
for (let j = 0; j <= this.detailX; j++) {
const u = j / this.detailX;
const theta = 2 * Math.PI * u;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
const newTubeRatio = tubeRatio * (1 + 0.5 * abs(sin(theta * 4))); // 追加
const r = 1 + newTubeRatio * cosPhi; // 場所を移動させてtubeRatio -> newTubeRatio
const p = new p5.Vector(
r * cosTheta,
r * sinTheta,
newTubeRatio * sinPhi // tubeRatio -> newTubeRatio
);
const n = new p5.Vector(cosPhi * cosTheta, cosPhi * sinTheta, sinPhi);
this.vertices.push(p);
this.vertexNormals.push(n);
this.uvs.push(u, v);
}
}
};
上の変更を加えることで、あのドーナツを表示させることができました。
1.5 法線ベクトルを変更する
まだ法線の変更がされていないため、影の付き方が不自然です。
しかし、変更された形状に合わせた法線を決めるにはとても複雑な計算が必要になってしまいます。
そこで、p5.Geometryのクラス関数computeNormals()を使います。この関数は、ジオメトリ内の各頂点について、その頂点が含まれる面の法線ベクトルの平均を計算し、それをその頂点の法線ベクトルとして設定する関数です。
この関数はインデックス配列の定義後に使う必要があるため、computeFaces()の後ろに書きます。
function donut(radius, tubeRadius, detailX, detailY) {
// 省略
if (!this._renderer.geometryInHash(gId)) {
const _donut = function() {
for (let i = 0; i <= this.detailY; i++) {
// 省略
for (let j = 0; j <= this.detailX; j++) {
// 省略
// 削除 : const n = new p5.Vector(cosPhi * cosTheta, cosPhi * sinTheta, sinPhi);
this.vertices.push(p);
// 削除 : this.vertexNormals.push(n);
this.uvs.push(u, v);
}
}
};
const donutGeom = new p5.Geometry(detailX, detailY, _donut);
donutGeom.computeFaces();
donutGeom.computeNormals(); // 追加
// 以下省略
}
法線を計算しなおすことができましたが、つなぎ目にがたつきができてしまいました。
これは下の図上の、0とX、0とY×(X+1)、などの端同士が同じ位置にあるにかかわらず違う頂点として定義されていることによります。
よって、がたつきをなくすためにはインデックス配列を変更する必要があります。
1.6 インデックス配列を変更する
同じ位置の頂点を2度定義しないよう、下のような平面を丸めてトーラス(ドーナツ)を作ることとします。
ただし、jがX-1のときにj+1がXではなく0になる必要があるため、(j+1)%Xを用います。iについても同様です。
p5.Geometryのインデックス配列は、facesというクラス変数内に格納されています。この配列にpush()を用いてインデックス配列を入れていきます。
function donut(radius, tubeRadius, detailX, detailY) {
// 省略
if (!this._renderer.geometryInHash(gId)) {
const _donut = function() {
for (let i = 0; i < this.detailY; i++) { // <= から < に
// 省略
for (let j = 0; j < this.detailX; j++) { // <= から < に
// 省略
// ↓ 追加
this.faces.push([
i * this.detailX + j,
i * this.detailX + (j + 1) % this.detailX,
(i + 1) % this.detailY * this.detailX + j
]);
this.faces.push([
(i + 1) % this.detailY * this.detailX + j,
i * this.detailX + (j + 1) % this.detailX,
(i + 1) % this.detailY * this.detailX + (j + 1) % this.detailX
]);
}
}
};
const donutGeom = new p5.Geometry(detailX, detailY, _donut);
// 削除 : donutGeom.computeFaces();
donutGeom.computeNormals();
// 以下省略
}
おまけ:トゥーンシェーダーと組み合わせる
以下の記事で解説しているトゥーンシェーダーと組み合わせることで、よりかわいくドーナツを描画することができます。
2. 最後に
2.1 p5.Geometryを使った作例
参考として、私がp5.Geometryを使用して作った作品例のリンクを載せます。
この例では、コールバック関数を使わない形でp5.Geometryを作成しています。
2.3 あとがき
p5.Geometryを使うことでp5.js上で好きな3Dモデルを作ることができます。
この記事が、p5.Geometryへの挑戦の一助となれば幸いです。