Three.js + TypeScriptで3Dアニメーション表示
内容
- これはTypeScriptからThree.jsを使うサンプルである。
-
z=f(x,y,t)
という関数の描画を目指す。 - 単に3次元グラフを表示したいのなら、plotly.jsなどのグラフ用のライブラリを使った方が簡単である。つまり実用的なプログラムの紹介ではなく、Three.jsの使い方のための記事である。
プロジェクトの用意
プロジェクト作成
pnpm create vite@latest
cd プロジェクト名
pnpm add -D three @types/three
"Select a framework"には Vanilla
、"Select a variant"には TypeScript
を選ぶ。
プロジェクトを空に
-
public
をフォルダごと削除 -
src
内のmain.ts
以外のファイルを全て削除 -
src/main.ts
の中身を空に -
index.html
では以下のように変更
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Three.jsで3Dアニメーション表示</title>
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
</head>
<body style="margin: 0; overflow: hidden;">
<script type="module" src="/src/main.ts"></script>
</body>
</html>
viewport
の設定はThree.js公式のサンプルを参考にしている。
最小サンプルで動作確認
import * as THREE from 'three';
const width = window.innerWidth;
const height = window.innerHeight;
const renderer = new THREE.WebGLRenderer(50, width / height);
renderer.setSize(width, height);
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera();
camera.position.set(1, 2, 3);
camera.lookAt(0, 0, 0);
const scene = new THREE.Scene();
const cube = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshNormalMaterial()
);
scene.add(cube);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const delta = clock.getDelta();
cube.rotation.x += 2*Math.PI*delta/10;
renderer.render(scene, camera);
});
Three.jsの動作で絶対に必要なものは、描画される世界scene
、撮影するカメラcamera
、画面に描画するrenderer
の3つである。
表示するオブジェクトのMaterialにはMeshNormalMaterial
を使っている。これは法線ベクトルを色に変換するMaterialで、オブジェクトの向きを視覚化するのに便利である。例えば、MeshBasicMaterialとかMeshLambertMaterialを使うのなら、光源の設定も必要となる。
THREE.Clock
のgetDelta
メソッドで、前回のsetAnimationLoop
からの経過時間が取得できる。これを使って、アニメーションの速度を調整している。これでFPSの値に依らず一定の速さでアニメーションさせることができる。今回の例では、10秒かけて回転するようにしている。
定型部分を分離して使いやすく
Three.jsの表示部分だけ別のファイルthreeController.ts
に分離して、オブジェクトの作成と更新部分だけをmain.ts
に残すこととする。FPSを表示するStats
や、マウスでカメラを操作できるOrbitControls
も使えるようにしたり、少しだけパワーアップしてみた。
import * as THREE from 'three';
import Stats from 'three/examples/jsm/libs/stats.module.js';
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls';
export type InitParams = {
scene:THREE.Scene;
camera:THREE.Camera;
};
export type RenderParams = {
scene:THREE.Scene;
camera:THREE.Camera;
delta:number;
time:number;
};
type ThreeControllerOptions = {
initFunction:(params:InitParams)=>void,
renderFunction:(params:RenderParams)=>void,
useStats?: boolean,
useOrbitControls?: boolean,
clearColor?: THREE.ColorRepresentation,
};
export const threeController = ({
initFunction,
renderFunction,
useStats = true,
useOrbitControls = true,
clearColor = 0xEEEEEE,
}:ThreeControllerOptions) => {
const width = window.innerWidth;
const height = window.innerHeight;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setClearColor(clearColor);
renderer.setSize(width, height);
const canvas = renderer.domElement;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, width / height);
camera.position.set(1, 2, 3);
camera.lookAt(scene.position);
document.body.appendChild(canvas);
window.addEventListener('resize', ()=>{
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = width;
canvas.height = height;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
},false);
const clock = new THREE.Clock();
const stats = useStats ? new Stats() : null;
if (stats) {
stats.showPanel(0);
stats.dom.style.position = 'absolute';
document.body.appendChild(stats.dom);
}
const controls = useOrbitControls? new OrbitControls(camera, canvas) : null;
initFunction({scene, camera});
renderer.setAnimationLoop((()=>{
stats?.update();
controls?.update();
renderFunction({scene, camera, delta:clock.getDelta(), time:clock.elapsedTime});
renderer.render(scene, camera);
}));
};
import * as THREE from 'three';
import type {InitParams, RenderParams } from './threeController';
import {threeController } from './threeController';
const cube = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshNormalMaterial()
);
function initFunction({scene}:InitParams){
scene.add(cube);
}
function renderFunction({delta}:RenderParams){
cube.rotation.x += 2*Math.PI*delta/10;
}
threeController({initFunction, renderFunction});
3D曲面の描画
Mesh
のgeometryの頂点座標を直接変更することで、3D曲面をアニメーション描画できる。mesh.geometry.getAttribute('position')
で頂点が取り出せるが、インデックスが直感的にでは無いので、使いやすいように、配列に保存してみた。
mesh.geometry.getAttribute('position').needsUpdate = true
で、頂点座標が変更されたことをThree.jsに通知する。また、mesh.geometry.computeVertexNormals()
で、頂点法線を再計算する必要がある。
import * as THREE from 'three';
import type {InitParams, RenderParams } from './threeController';
import {threeController } from './threeController';
// 描画する平面の用意
const segments = 30;
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1, segments, segments),
new THREE.MeshNormalMaterial()
);
mesh.rotation.x = -Math.PI / 2;
mesh.frustumCulled = false; //カメラの画角内に入っているかをチェックしない
// planeのgeometryの頂点座標とインデックスを取り出しておく
const position = mesh.geometry.getAttribute('position');
const points: { i: number; x: number; y: number }[] = [];
for (let i = 0; i < position.count; i++) {
points.push({ i: i, x: position.getX(i), y: position.getY(i) });
}
//頂点座標を指定する関数
const surfaceZ = (time: number, x: number, y: number): number => {
return 0.1 * Math.sin(10 * (x ** 2 + y ** 2) - time * 5);
};
function initFunction({scene}:InitParams){
scene.add(mesh);
}
function renderFunction({time}:RenderParams){
// planeのgeometryから取り出したpositionを直接変更する
for (const { i, x, y } of points) {
position.setZ(i, surfaceZ(time, x, y));
}
// この2行がポイント!
position.needsUpdate = true;
mesh.geometry.computeVertexNormals();
}
threeController({initFunction, renderFunction});
Discussion