この記事は Grimoire.js Advent Calendar 2016 12日目の記事です。
めっちゃおくれてごめんなさい;;
grimoire.jsで、メッシュのポジションを動的に変えたいのは簡単です。
gr(() => {
var target = gr('#main')('.target');
setTimeout(() => {
target.setAttribute('position', '1,2,3');
}, 1000);
});
なんてノリで書けば、1秒後に#mainキャンバスの.targetなメッシュを(x,y,z)=(1,2,3)な点に移動出来ます。多分。
けど、形状を変更させたいときは話が別です。例えば、最初は半径3,高さ8の円柱だけどアニメーションで半径8,高さ3な感じに変形させたいときなんかはこんな簡単な話ではなくなってきてしまいます
どう解決するのか
解決方法はぱっと思いつくだけでも2つはあります。
1つは正直にシェーダー書くことです。gpuにやらせるべきことは正直にそうさせるのがどう考えても良いですね。
ただし、今回は僕がシェーダーに関する知識が皆無なのと、シェーダーを書かないという縛りの上のgrimoire.jsでそれが実現不可能でないことを示したい。なので毎フレームに頂点などの形状を変形させる方法を採用します
その解決策ですが、grimoire.jsに用意されている コンポーネント を使用します。
コンポーネントって?
公式サイトのTutorialにしっかりまとまって書いてあります。
が、最新版のgrimoireでは、破壊的にメソッド名などをかえまくったらしいのでちょっとそこはあてにならないかもしれないです。
コンポーネントとは、一口でいうなら『メッシュにくっつける機能の部分』です。
公式サイトにある例を挙げます
キューブを回転させたい。そんなとき、まずはキューブを表示しておいて、あらかじめ「回転させる」という機能のコンポーネントを作っておいてからそのキューブに適用してます。もちろんつくったコンポーネントは再利用可能だし、attributeも設定することができます
やってく
どうせなら見た目てきにまあまあ良さそうなものを使いたいので、『四角いタンバリンを叩いた表面の状態のシミュレーション』とかをレンダリングしていきます。
実際に作ってみたらこんな感じになりました。
下にアクセスしてもソースと結果を確認できます:
https://grimoiregl.github.io/grimoire.gl-example/#wave-simulation
方針
<mesh class="target" geometry="wave">
<mesh.components>
<GeometryUpdator />
</mesh.components>
</mesh>
このGeometryUpdatorのattributeを変更したい場合は
gr('#main')('.target').first()
.getComponent("GeometryUpdator")
.setAttribute("frame", 50);
な感じに書けば、frame=50に変わるのでこれを利用します。
要するに、あとでsetAttributeを利用して時間が経つにつれてframeの値を大きくしていきます。
あとは頂点のアップデートをさせるだけですね。
必要ライブラリの読み込み
いつものgrimoire-preset-basic.jsだけ読めてればいいので普通です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>grimoire js</title>
<style>
html {
height: 100%;
}
body {
margin: 0;
height: 100%;
}
</style>
</head>
<body>
<script type="text/javascript" src="./static/grimoire-preset-basic.min.js"></script>
<script type="text/goml" src="index.goml" id="main"></script>
<script type="text/javascript" src="index.js"></script>
</body>
</html>
gomlの用意
これに関してもさっき解説した通りですね
<goml width="fit" height="fit">
<geometry type="wave-grid" name="wave" frame="3"></geometry>
<scene>
<camera rotation="-25d,0d,0d">
<camera.components>
<MouseCameraControl center="0,0,0" moveSpeed="10"/>
</camera.components>
</camera>
<mesh class="target" geometry="wave" color="black" targetBuffer="wireframe" position="0,-4,-10" scale="3">
<mesh.components>
<GeometryUpdator/>
</mesh.components>
</mesh>
</scene>
</goml>
mesh作る
GeometryFactory.addType()
でジオメトリを自作します。
第一引数がGeometry名、第二引数がgeometryに付けるattributeのコンフィグ、第三引数でどんな形状かをきめる式を入れます
形状の決め方については、基本的には
- 手動で書く
- GeometryBuilder.buildという、単純図形を作るために用意されたメソッドを使う
の2つがあるらしいです。前者を使う必要はないので今回は後者でいきます。
const GeometryFactory = gr.lib.fundamental.Geometry.GeometryFactory;
const GeometryBuilder = gr.lib.fundamental.Geometry.GeometryBuilder;
const GeometryUtility = gr.lib.fundamental.Geometry.GeometryUtility;
GeometryFactory.addType("wave-grid", {}, (gl, attrs) => {
const positions = positionsFrames[attrs.frame];
const faces = facesFrames[attrs.frame];
return GeometryBuilder.build(gl, {
indices: {
default: {
generator: function*() {
yield* faces;
},
topology: WebGLRenderingContext.TRIANGLES
},
wireframe: {
generator: function*(){
yield* GeometryUtility.linesFromTriangles(faces)
},
topology: WebGLRenderingContext.LINES
}
},
vertices: {
pos: {
size: {
position: 3
},
count: positions.length,
getGenerators: ()=>{
return {
position: function*() {
for (let i = 0; i < positions.length; i++) {
yield* positions[i];
}
}
};
}
},
main: {
size: {
normal: 3,
texCoord: 2
},
count: positions.length,
getGenerators: () => {
return {
normal: function*() {
while (true) {
yield 1;
}
},
texCoord: function*() {
while (true) yield 0;
}
};
}
},
},
})
});
indicesで頂点たちをどうつなぐか決めます。
topology: WebGLRenderingContext.TRIANGLES
を使うと三角形で結ぼうとするので、結ぶ点の順を渡します。flattenした配列をyield*するのが注意点ですね
ついでにwireframeについても上記のようにすると勝手に付けてくれます。
次にverticesで、頂点に関する情報を決めます。
posでどこに配置するか決めます。が、実際にはposはあとで更新処理をするので、getGeneratorは適当な値を入れても問題ないと思います。多分。countはまともなものを入れないとですけどね
mainで法線とか決めますが怠惰で適当な値をいれちゃってます
Componentを作る
公式サイトのTutorialにある通り、gr.registerComponent()
を使います。
gr.registerComponent("GeometryUpdator", {
attributes: {
frame: {
converter: "Number",
default: 0
}
},
$awake: function(){
this.geometry = this.node.getAttribute("geometry");
let positions = positionsFrames.map((positions) => {
return new Float32Array(Array.prototype.concat.apply([], positions))
});
this.getAttributeRaw("frame").watch((v)=>{
this.geometry.vertices.pos.update(positions[v]);
}, true);
}
});
もちろん第一引数はcomponent名、第二で内容の設定で、
attributesでcomponentのattributeの設定します。
第二引数のプロパティには、$awake
と$update
が使用出来ます。
$awake
はコンポーネントのinitialize時に呼ばれます。
$update
は毎フレームで呼ばれます。
$update
を使っても良いんですが、今回は外部からattributeの更新を取って、更新があり次第にpositionの更新をする方針でいきたいので$awake
でコンポーネントに対してイベントリスナを付けてます。
実際にthis.getAttributeRaw("frame").watch()
で、frameが更新されたときがとれます。
this.geometry.vertices.pos.update()
がここで使えるのがポイントの一つですね。
componentのattributeを更新する
gr(() => {
let target = gr('#main')('.target').single().getComponent("GeometryUpdator");
renderLoop((i) => {
target.setAttribute("frame", i/3|0);
});
});
function renderLoop(cb) {
let renderCount = 0;
(function () {
window.requestAnimationFrame(arguments.callee);
cb(renderCount++);
})();
}
見ての通りです。これで最初にあげた問題はクリアしきりましたね
ジオメトリ作成の作業工程
論点ではなかったから略してたけど一応ソースだけ貼っときます
const waveSimulation = (() => {
const dx = 0.01;
const dt = 0.0005;
const conductivity = 5;
const mew = conductivity * dt / dx;
const lattice = 41;
const tMax = 1000;
const p = (v) => v * v; //pow
return () => {
let mapLog = [];
let u0 = createMap(lattice);
let u1 = createMap(lattice);
let u2;
for (let k = 0; k < tMax; k++) {
u2 = createMap(lattice);
for (let i = 1; i < lattice-1; i++) for (let j = 1; j < lattice-1; j++) {
u2[i][j] =
k === 0 ? (hitSpace(i, j) ? 4 : 0) :
k === 1 ? (1-2*mew)*u1[i][j] + p(mew)/2*(u1[i+1][j]+u1[i-1][j]+u1[i][j+1]+u1[i][j-1]) :
/*k>=2*/ 2*(1-2*p(mew))*u1[i][j] + p(mew)*(u1[i+1][j]+u1[i-1][j]+u1[i][j+1]+u1[i][j-1]) - u0[i][j];
}
for (let i = 0; i < lattice; i++) {
u2[i][0] = 0;
u2[i][lattice-1] = 0;
}
for (var j = 0; j < lattice; j++) {
u2[0][j] = 0;
u2[lattice-1][j] = 0;
}
mapLog.push(u2);
u0 = u1.concat();
u1 = u2.concat();
}
return mapLog;
};
function hitSpace(x, y) {
return p(x-(lattice-1)/2) + p(y-(lattice-1)/2) <= p(lattice/10);
}
function createMap(size, fn) {
return Array.from({length:size}).map(_=> Array.from({length:size}).map(_=> 0));
}
})();
3次元配列を返していて、waveSimulation[frame][i][j]
でframe数目の(i,j)要素の高さがとれます。
この関数はわざわざ波動方程式をもとに毎フレームのポジションを計算してます。
ちょっとした数学的なことはここ(http://www.slideshare.net/yusa3)に用意しました
let positionsFrames = [];
let facesFrames = [];
const simulation = waveSimulation();
simulation.forEach((o, i) => {
const data = simulation[i];
let faces = [];
data.forEach((ary, x) => { if (x !== data.length-1) // xの右端以外
ary.forEach((z, y) => { if (y !== ary.length-1) // yの下端以外
faces.push([x+y*data.length, x+y*data.length+1, x+(y+1)*data.length]);
});
});
data.forEach((ary, x) => { if (x !== 0) // xの左端以外
ary.forEach((z, y) => { if (y !== ary.length-1) // yの下端以外
faces.push([x+y*data.length, x+(y+1)*data.length, x+(y+1)*data.length-1]);
});
});
faces = Array.prototype.concat.apply([], faces);
let positions = [];
data.forEach((ary, x) => ary.forEach((y, z) => {
positions.push([(x-20)/10,y/10,-(z-20)/10]);
}));
positionsFrames.push(positions);
facesFrames.push(faces);
});
めんどくさく書いてるけど、要するに三角形の結び方とpositionを使いやすい形式になおしてるだけです
展望
近いうちにシェーダーで実装できたらadvent calendarの終盤で飛び入りして記事書くかも?