threejsでデジタル時計を作る その3 TriangleClock編

threejsでTHREE.InstancedBufferGeometryを使ってデジタル時計をつくったので実装内容のメモ。


## サンプル
[DEMO](https://yoshihitofujiwara.github.io/triangle-clock/index.html)
[リポジトリ](https://github.com/yoshihitofujiwara/TriangleClock)


## 実装の流れ
1. THREE.FontLoaderでフォントデータを読み込む。
2. 0から9までの文字をTHREE.TextGeometryで３Dテキストのジオメトリを生成する。
3. THREE.GeometryUtils.randomPointsInGeometryを使い、上記のジオメトリ内にランダムなポイントを生成する。
4. 上記のポイントを3Dテキストパーティクルのポジションとして利用するため、パーティクル座標管理用のオブジェクトに保持させる。
5. 表示するベースの三角形のポジションを配列に生成する。
6. THREE.InstancedBufferGeometryを生成し、ジオメトリに必要な情報（三角のポジション、座標、次に表示する文字の座標、色、回転角）を埋めていく。
7.  表示アニメーションは、シェーダーで制御するため、THREE.RawShaderMaterialでマテリアルを生成し、メッシュ生成し、シーンに追加。
8. 表示する文字が更新のタイミングで、頂点管理用オブジェクトから次に表示するジオメトリのプロパティを受け取り、シェダー側でprogress値を見てアニメーションを実行する。


## 実装に使用した主な機能
**THREE.FontLoader**
フォントデータの読込

**THREE.TextGeometry**
3Dテキストのジオメトリ生成

**THREE.Geometry**
形状データを扱う。
今回は、パーティクルの頂点情報を保持。

**THREE.GeometryUtils.randomPointsInGeometry**
ジオメトリ内にランダムなポイントを生成する

**THREE.InstancedBufferGeometry**
インスタンシング用のバッファジオメトリ。
[インスタンシング Webgl.org](https://wgld.org/d/webgl/w075.html)

**THREE.Float32BufferAttribute**
32bit 浮動小数点数型小数の配列

**THREE.InstancedBufferAttribute**
インスタンシング用BufferAttribute

**THREE.RawShaderMaterial**
RawShaderMaterialはShaderMaterialとの違いは、uniform、attribute変数が自動で挿入されないので定義する必要があります。 


## 実装コード一部抜粋
```js:javascript

import $ from "jQuery";
import RenderManeger3D from "./utils/RenderManeger3D";


/*--------------------------------------------------------------------------
	parameter
--------------------------------------------------------------------------*/
let renderManeger3D;

// 数値のパーティクル座標管理リスト
let numberList = [];

// 表示時間のパーティクルリスト（文字単位）
let particleList = [];

// font data
let fontData;

// 現在時
let now = getNow();


/*--------------------------------------------------------------------------
	init
--------------------------------------------------------------------------*/
function init() {
	renderManeger3D = new RenderManeger3D($("#canvas_container"), {
		isController: true
	});


	// 文字単位のパーティクル量(初期値)
	renderManeger3D.gui.params.particles = 4000 * 6;
	renderManeger3D.gui.params.size = 1;
	renderManeger3D.gui.params.opacity = 0.5;
	renderManeger3D.gui.params.noise = 1.5;


	// 数値のパーティクル座標管理リストの生成
	let loader = new THREE.FontLoader();
	let typeface = "./assets/fonts/helvetiker_bold.typeface.json?" + performance.now();

	loader.load(typeface, (font) => {
		fontData = font;

		// dat.gui
		renderManeger3D.gui.add(renderManeger3D.gui.params, 'particles', 1000, 100000).step(10).onChange((val) => {
			createParticle();
		});
		renderManeger3D.gui.add(renderManeger3D.gui.params, 'size', 0.1, 10).onChange((val) => {
			particleList.forEach((item, i) => {
				item.material.uniforms.size.value = val;
			});
		});
		renderManeger3D.gui.add(renderManeger3D.gui.params, 'opacity', 0.1, 1).onChange((val) => {
			particleList.forEach((item, i) => {
				item.material.uniforms.opacity.value = val;
			});
		});
		renderManeger3D.gui.add(renderManeger3D.gui.params, 'noise', 0, 5).onChange((val) => {
			particleList.forEach((item, i) => {
				item.material.uniforms.noise.value = val;
			});
		});

		// パーティクル生成
		createParticle();

		// start
		renderManeger3D.start();
	});


	// camera positon
	if (INK.isSmartPhone()) {
		renderManeger3D.camera.position.z = 360;
	} else {
		renderManeger3D.camera.position.z = 120;
	}


	// update
	renderManeger3D.event.on("update", () => {
		particleList.forEach((item, i) => {
			item.material.uniforms.time.value = renderManeger3D.time;
		});

		let _now = getNow();
		if (now != _now) {
			for (let i = 0; i < now.length; i++) {
				if (now[i] != _now[i]) {
					animate(i, +_now[i]);
				}
			}
			now = _now;
		}
	});
}


/*--------------------------------------------------------------------------
	createParticle
--------------------------------------------------------------------------*/
function createParticle(){
	for (let i = 0; i < 10; ++i) {
		numberList[i] = {};

		// TextGeometry
		numberList[i].geometry = new THREE.TextGeometry(i, {
			font: fontData,
			size: 40,
			height: 8,
			curveSegments: 10,
		});

		// ジオメトリを中点の中央に配置
		numberList[i].geometry.center();

		// Geometry パーティクル管理用
		numberList[i].particles = new THREE.Geometry();

		// TextGeometry内にランダムな頂点を追加
		numberList[i].particles.vertices = THREE.GeometryUtils.randomPointsInGeometry(numberList[i].geometry, renderManeger3D.gui.params.particles / 6);

		// 三角ポリゴンの位置情報
		numberList[i].particles.offsets = [];
		numberList[i].particles.vertices.forEach((vertex) => {
			numberList[i].particles.offsets.push(vertex.x, vertex.y, vertex.z);
		});
	}

	// パーティクル削除
	renderManeger3D.scene.remove.apply(renderManeger3D.scene, renderManeger3D.scene.children);

	// ベースの三角形
	let positions = [
		0.0, 0.5, 0,
		0.5, -0.5, 0,
		-0.5, -0.5, 0.0
	];

	// パーティクル追加
	for (let j = 0; j < now.length; ++j) {
		let offsets = numberList[+now[j]].particles.offsets.concat();
		let colors = [];
		let rotate = [];

		for (let k = 0; k < renderManeger3D.gui.params.particles / 6; k += 1) {
			colors.push(Math.random(), Math.random(), Math.random());
			rotate.push(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1 * INK.TWO_PI);
		}

		let geometry = new THREE.InstancedBufferGeometry();
		geometry.maxInstancedCount = renderManeger3D.gui.params.particles / 6;
		geometry.addAttribute('position', new THREE.Float32BufferAttribute(positions.concat(), 3));
		geometry.addAttribute('offset', new THREE.InstancedBufferAttribute(new Float32Array(offsets), 3));
		geometry.addAttribute('nextOffset', new THREE.InstancedBufferAttribute(new Float32Array(offsets), 3));
		geometry.addAttribute('color', new THREE.InstancedBufferAttribute(new Float32Array(colors), 3));
		geometry.addAttribute('rotate', new THREE.InstancedBufferAttribute(new Float32Array(rotate), 4));

		let uniforms = {
			time: { value: 1.0 },
			progress: { type: "f", value: 0 },
			size: { type: "f", value: renderManeger3D.gui.params.size },
			opacity: { type: "f", value: renderManeger3D.gui.params.opacity },
			noise: { type: "f", value: renderManeger3D.gui.params.noise },
		};

		let material = new THREE.RawShaderMaterial({
			uniforms: uniforms,
			vertexShader: require("../../shader/default.vert"),
			fragmentShader: require("../../shader/default.frag"),
			side: THREE.DoubleSide,
			blending: THREE.AdditiveBlending,
			depthTest: false,
			transparent: true
		});

		let particleSystem = new THREE.Mesh(geometry, material);

		// 文字を中央配置
		particleSystem.position.x = 34 * j - (34 * 2.55);

		// 時間管理用パーティクル
		particleList[j] = particleSystem;

		renderManeger3D.scene.add(particleSystem);
	}
}


/*--------------------------------------------------------------------------
	utils
--------------------------------------------------------------------------*/
/**
 * @method animate
 * @param {Number} index 桁数（頭から数えて）
 * @param {Number} num アニメーションする数字
 */
function animate(index, num) {
	let attributes = particleList[index].geometry.attributes;

	attributes.nextOffset.array = new Float32Array(numberList[num].particles.offsets);
	particleList[index].material.uniforms.progress.value = 0;

	attributes.offset.needsUpdate = true;
	attributes.nextOffset.needsUpdate = true;

	TweenMax.to(particleList[index].material.uniforms.progress, .6, {
		value: 1,
		ease: Expo.easeOut,
		onComplete: () => {
			attributes.offset.array = new Float32Array(numberList[num].particles.offsets);
		}
	});
}


/**
 * @method getNow 現在の時、分、秒を文字列にして返す
 * @return {String}
 */
function getNow() {
	let date = new Date();
	return zeroPadding(date.getHours()) + zeroPadding(date.getMinutes()) + zeroPadding(date.getSeconds());
}


/**
 * @method zeroPadding 1桁の場合、先頭に0を追加して2桁にする
 * @param {Number} num
 * @return {String}
 */
function zeroPadding(num) {
	let numStr = "" + num;
	if (numStr.length < 2) {
		numStr = "0" + numStr;
	}
	return numStr;
}


/*==========================================================================
	DOM READY
==========================================================================*/
$(() => {
	init();
});


```

#### Vertex Shader
現在表示する頂点位置と次の頂点位置を線形補完でポジションを決めています。
simplex noiseで頂点にノイズを加えランダムな動きと、
時間軸でポリゴンに回転加えています。


```glsl:default.vert

precision mediump float;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float progress;
uniform float time;
uniform float size;
uniform float noise;

attribute vec3 position;
attribute vec3 offset;
attribute vec3 nextOffset;
attribute vec4 rotate;
attribute vec4 color;

varying vec3 vPosition;
varying vec4 vColor;


// glsl-noise/simplex/2d: https://github.com/hughsk/glsl-noise/blob/master/simplex/2d.glsl
#pragma glslify: snoise2 = require(glsl-noise/simplex/2d);


// Quaternion
mat3 rotateQ(float angle, vec3 axis){
	vec3 a = normalize(axis);
	float s = sin(angle);
	float c = cos(angle);
	float r = 1.0 - c;
	mat3 m = mat3(
			a.x * a.x * r + c,
			a.y * a.x * r + a.z * s,
			a.z * a.x * r - a.y * s,
			a.x * a.y * r - a.z * s,
			a.y * a.y * r + c,
			a.z * a.y * r + a.x * s,
			a.x * a.z * r + a.y * s,
			a.y * a.z * r - a.x * s,
			a.z * a.z * r + c
	);
	return m;
}


void main(){
	vColor = color;

	vec4 orientation = vec4(rotate.xyz, rotate.w + time);

	vec3 newPosition = mix(offset, nextOffset, progress) + (position * size) * rotateQ(orientation.w, orientation.xyz);

	vec3 noise3D = vec3(
		snoise2(vec2(newPosition.x, time)),
		snoise2(vec2(newPosition.y, time)),
		snoise2(vec2(newPosition.z, time))
	) * noise;

	vPosition = newPosition + noise3D;

	gl_Position = projectionMatrix * modelViewMatrix * vec4( vPosition, 1.0 );
}

```

#### Fragment Shader
座標位置に応じて色を足す。

```glsl:default.frag
precision mediump float;

uniform float time;
uniform float opacity;

varying vec3 vPosition;
varying vec4 vColor;


void main() {
	vec4 color = vColor;

	color.r = vColor.r + vPosition.x / 50.0;
	color.g = vColor.g + vPosition.y / 50.0;
	color.b = vColor.b + vPosition.z / 50.0;
	color.a = opacity;

	gl_FragColor = color;
}
```


## その他Clockシリーズ
[その1 ParticleClock編](https://qiita.com/yoshihitofujiwara/items/730b0fe20c864647fbc3)
[その2 LineClock編](https://qiita.com/yoshihitofujiwara/items/e5e980f436262c5f777c)