🎃

Three.js + TypeScriptで3Dアニメーション表示

2025/02/02に公開

内容

  • これは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では以下のように変更
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公式のサンプルを参考にしている。

最小サンプルで動作確認

src/main.ts
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.ClockgetDeltaメソッドで、前回のsetAnimationLoopからの経過時間が取得できる。これを使って、アニメーションの速度を調整している。これでFPSの値に依らず一定の速さでアニメーションさせることができる。今回の例では、10秒かけて回転するようにしている。

定型部分を分離して使いやすく

Three.jsの表示部分だけ別のファイルthreeController.tsに分離して、オブジェクトの作成と更新部分だけをmain.tsに残すこととする。FPSを表示するStatsや、マウスでカメラを操作できるOrbitControlsも使えるようにしたり、少しだけパワーアップしてみた。

src/threeController.ts
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); 
    }));
};
src/main.ts
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()で、頂点法線を再計算する必要がある。

src/main.ts
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