three.jsでGPGPUライフゲームを実装しました。
GPGPUの部分にはthree.jsのサンプルでも使われているGPUComputationRendererを使っています。GPUComputationRendererの使い方はこの記事を参考にしました。
GPGPUでライフゲームを実装するときに最も簡単な方法は描画する面と同じサイズのテクスチャを用意して計算する方法です。これだと、レンダリングするときにuv座標をそのまま利用することで対応した位置の値をとってくることができます。以前、three.jsを使わずにWebGLでライフゲームを実装したときにはこの方法を利用しました。
しかしながら、テクスチャのサイズは2のべき乗が理想とされ、それ以外のサイズを用いると利用に制限がかかります(参考)。今回は描画面を任意の大きさにしつつ、GPGPU計算に利用するテクスチャは2のべき乗になるようにしています。
そのためにまず、描画面のすべての計算点を十分格納できるテクスチャのサイズを計算し、その値を用いてGPUComputationRendererオブジェクトを作成します。
const planeWidth = 640;
const planeHeight = 480;
let textureWidth = 0;
for (let i = 1, l = planeWidth * planeHeight; ; i++) {
const w = Math.pow(2.0, i);
if (w * w > l) {
textureWidth = w;
break;
}
}
const gpuCompute = new GPUComputationRenderer(textureWidth, textureWidth, renderer);
ライフゲームの各セルの次状態を計算するためのシェーダーは以下のようになっています。ここでは近傍のセルの値を参照しなければなりませんが、今回の実装ではテクスチャ上の隣の点が描画面上の隣のセルに対応した点になっているわけではありません。
そのため、convertFromTexCoordToPosCoord
でテクスチャ上の位置を描画面上の位置に変換した上で近傍の位置を計算し、convertFromPosCoordToTexCoord
でテクスチャ上の位置に戻すという処理をおこなっています。
また、テクスチャ上のすべての点が使用されるわけではないので、main関数のはじめでそのチェックを行っています。
// "uniform vec2 resolution" is automatically added by GPUComputationRenderer as texture size
uniform vec2 planeSize;
vec2 textureSize = resolution;
bool isInPosCoordRange(in vec2 texCoord) {
float v = texCoord.x + texCoord.y * textureSize.x;
return v <= (planeSize.x * planeSize.y);
}
float div(in float a, in float b) {
return floor(a / b);
}
float modulo(in float a, in float b) {
return a - floor(a / b) * b;
}
vec2 convertFromTexCoordToPosCoord(in vec2 texCoord) {
float idx = texCoord.x + texCoord.y * textureSize.x;
return vec2(modulo(idx, planeSize.x), div(idx, planeSize.x));
}
vec2 convertFromPosCoordToTexCoord(in vec2 posCoord) {
float idx = posCoord.x + posCoord.y * planeSize.x;
return vec2(modulo(idx, textureSize.x), div(idx, textureSize.x));
}
int status(in vec2 offset) {
vec2 posCoord = convertFromTexCoordToPosCoord(floor(gl_FragCoord.xy)) + offset;
// boundary condition
posCoord.x = posCoord.x < 0.0 ? planeSize.x - 1.0 : posCoord.x;
posCoord.x = posCoord.x > planeSize.x ? 0.0 : posCoord.x;
posCoord.y = posCoord.y < 0.0 ? planeSize.y - 1.0 : posCoord.y;
posCoord.y = posCoord.y > planeSize.y ? 0.0 : posCoord.y;
vec2 texCoord = convertFromPosCoordToTexCoord(posCoord) + fract(gl_FragCoord.xy);
return int(texture2D(textureLifeGame, texCoord / textureSize.xy).x);
}
void main() {
// checks whether current position is used or not
if (!isInPosCoordRange(floor(gl_FragCoord.xy))) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
int center = status(vec2(0.0, 0.0));
int neighbor = 0;
neighbor += status(vec2(-1.0, 1.0));
neighbor += status(vec2(0.0, 1.0));
neighbor += status(vec2(1.0, 1.0));
neighbor += status(vec2(-1.0, 0.0));
neighbor += status(vec2(1.0, 0.0));
neighbor += status(vec2(-1.0, -1.0));
neighbor += status(vec2(0.0, -1.0));
neighbor += status(vec2(1.0, -1.0));
// x == 1 means cell is alive and x == 0 means cell is dead
gl_FragColor = ((center == 1 && (neighbor == 2 || neighbor == 3)) || (center == 0 && neighbor == 3)) ?
vec4(1.0, 0.0, 0.0, 0.0) : vec4(0.0);
}
描画面用のフラグメントシェーダーでも同様にGPGPUで計算した値を格納したテクスチャから値をとってくるときには描画面上の位置からテクスチャ上の位置に変換する必要があります。
uniform sampler2D textureLifeGame;
uniform vec2 planeSize;
uniform vec2 textureSize;
varying vec2 vUv;
float div(in float a, in float b) {
return floor(a / b);
}
float modulo(in float a, in float b) {
return a - floor(a / b) * b;
}
vec2 convertFromPosCoordToTexCoord(in vec2 posCoord) {
float idx = posCoord.x + posCoord.y * planeSize.x;
return vec2(modulo(idx, textureSize.x), div(idx, textureSize.x));
}
void main() {
vec2 posCoord = vUv * planeSize;
vec2 texCoord = convertFromPosCoordToTexCoord(floor(posCoord));
float v = texture2D(textureLifeGame, texCoord / textureSize).x;
// alive cells are filled with green and dead ones with black
gl_FragColor = vec4(v > 0.5 ? vec3(0.0, 1.0, 0.0) : vec3(0.0, 0.0, 0.0), 1.0);
}
全体のコードは以下のようになっています。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GPGPU Life Game</title>
<style>
body {
margin: 0;
overflow: hidden;
}
</style>
</head>
<body>
<script id="shaderLifeGame" type=x-shader/x-fragment"">
// "uniform vec2 resolution" is automatically added by GPUComputationRenderer as texture size
uniform vec2 planeSize;
vec2 textureSize = resolution;
bool isInPosCoordRange(in vec2 texCoord) {
float v = texCoord.x + texCoord.y * textureSize.x;
return v <= (planeSize.x * planeSize.y);
}
float div(in float a, in float b) {
return floor(a / b);
}
float modulo(in float a, in float b) {
return a - floor(a / b) * b;
}
vec2 convertFromTexCoordToPosCoord(in vec2 texCoord) {
float idx = texCoord.x + texCoord.y * textureSize.x;
return vec2(modulo(idx, planeSize.x), div(idx, planeSize.x));
}
vec2 convertFromPosCoordToTexCoord(in vec2 posCoord) {
float idx = posCoord.x + posCoord.y * planeSize.x;
return vec2(modulo(idx, textureSize.x), div(idx, textureSize.x));
}
int status(in vec2 offset) {
vec2 posCoord = convertFromTexCoordToPosCoord(floor(gl_FragCoord.xy)) + offset;
// boundary condition
posCoord.x = posCoord.x < 0.0 ? planeSize.x - 1.0 : posCoord.x;
posCoord.x = posCoord.x > planeSize.x ? 0.0 : posCoord.x;
posCoord.y = posCoord.y < 0.0 ? planeSize.y - 1.0 : posCoord.y;
posCoord.y = posCoord.y > planeSize.y ? 0.0 : posCoord.y;
vec2 texCoord = convertFromPosCoordToTexCoord(posCoord) + fract(gl_FragCoord.xy);
return int(texture2D(textureLifeGame, texCoord / textureSize.xy).x);
}
void main() {
// checks whether current position is used or not
if (!isInPosCoordRange(floor(gl_FragCoord.xy))) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
int center = status(vec2(0.0, 0.0));
int neighbor = 0;
neighbor += status(vec2(-1.0, 1.0));
neighbor += status(vec2(0.0, 1.0));
neighbor += status(vec2(1.0, 1.0));
neighbor += status(vec2(-1.0, 0.0));
neighbor += status(vec2(1.0, 0.0));
neighbor += status(vec2(-1.0, -1.0));
neighbor += status(vec2(0.0, -1.0));
neighbor += status(vec2(1.0, -1.0));
// x == 1 means cell is alive and x == 0 means cell is dead
gl_FragColor = ((center == 1 && (neighbor == 2 || neighbor == 3)) || (center == 0 && neighbor == 3)) ?
vec4(1.0, 0.0, 0.0, 0.0) : vec4(0.0);
}
</script>
<script id="vertexShader" type="x-shader/x-vertex">
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
uniform sampler2D textureLifeGame;
uniform vec2 planeSize;
uniform vec2 textureSize;
varying vec2 vUv;
float div(in float a, in float b) {
return floor(a / b);
}
float modulo(in float a, in float b) {
return a - floor(a / b) * b;
}
vec2 convertFromPosCoordToTexCoord(in vec2 posCoord) {
float idx = posCoord.x + posCoord.y * planeSize.x;
return vec2(modulo(idx, textureSize.x), div(idx, textureSize.x));
}
void main() {
vec2 posCoord = vUv * planeSize;
vec2 texCoord = convertFromPosCoordToTexCoord(floor(posCoord));
float v = texture2D(textureLifeGame, texCoord / textureSize).x;
// alive cells are filled with green and dead ones with black
gl_FragColor = vec4(v > 0.5 ? vec3(0.0, 1.0, 0.0) : vec3(0.0, 0.0, 0.0), 1.0);
}
</script>
<script src="./three.js"></script>
<script src="./js/controls/OrbitControls.js"></script>
<script src="./js/GPUComputationRenderer.js"></script>
<script>
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.z = 1000;
const controls = new THREE.OrbitControls(camera);
const planeWidth = 640;
const planeHeight = 480;
let textureWidth = 0;
for (let i = 1, l = planeWidth * planeHeight; ; i++) {
const w = Math.pow(2.0, i);
if (w * w > l) {
textureWidth = w;
break;
}
}
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xeeeeee);
const plane = new THREE.PlaneBufferGeometry(planeWidth, planeHeight);
const material = new THREE.ShaderMaterial({
uniforms: {
textureLifeGame: {value: null},
planeSize: {value: new THREE.Vector2(planeWidth, planeHeight)},
textureSize: {value: new THREE.Vector2(textureWidth, textureWidth)}
},
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent,
side: THREE.DoubleSide
});
scene.add(new THREE.Mesh(plane, material));
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const gpuCompute = new GPUComputationRenderer(textureWidth, textureWidth, renderer);
const textureLifeGame = gpuCompute.createTexture();
// sets initial state randomly
for (let i = 0, l = textureLifeGame.image.data.length; i < l; i += 4) {
textureLifeGame.image.data[i + 0] = Math.random() < 0.2 ? 1.0 : 0.0;
textureLifeGame.image.data[i + 1] = 0.0;
textureLifeGame.image.data[i + 2] = 0.0;
textureLifeGame.image.data[i + 3] = 0.0;
}
const variableLifeGame = gpuCompute.addVariable("textureLifeGame", document.getElementById('shaderLifeGame').textContent, textureLifeGame);
gpuCompute.setVariableDependencies(variableLifeGame, [variableLifeGame]);
variableLifeGame.material.uniforms.planeSize = {value: new THREE.Vector2(planeWidth, planeHeight)};
const error = gpuCompute.init();
if (error !== null) {
console.error(error);
}
animate();
function animate() {
requestAnimationFrame(animate);
controls.update();
gpuCompute.compute();
material.uniforms.textureLifeGame.value = gpuCompute.getCurrentRenderTarget(variableLifeGame).texture;
renderer.render(scene, camera);
}
</script>
</body>
</html>