🎄

Webでパーティクルクリスマスツリーを表示させてみる

2024/12/24に公開

アドベントカレンダー 23 日目を担当します、Somahc です。ちょっと遅れちゃいました、すみません。

クリスマスも間近ということで、Web 状での 3D 表現に強いライブラリ Three.js を使い、クリスマスツリーをパーティクル状に Web 上で表示させてみたいと思います。完成形は ↓ みたいな感じです。マウスで回転したり拡大縮小できたりします。

完成イメージ

リポジトリ
https://github.com/Somahc/xmastree-zenn

CG という領域上どうしても横文字が多く分かりづらくなってしまいましたが、「こんなこともできるんだ~」と知ってもらえるだけで幸いです!
私自身 Three.js 初心者なので少しおかしいところがあるかもですが、よろしくお願いします。

クリスマスツリーのモデリング

まずはクリスマスツリーのモデリングを行います。今回はフリーのモデリングソフトである Blender を使って簡易的に作成してみました。頂点の位置にパーティクルが表示されるので、ループカットを使っていい感じに頂点が全体的にできるように調整しました。

3D モデルのエクスポートには Three.js も対応している glTF(GL Transmission Format)と呼ばれる形式を選択します。glTF はプラットフォームに依存しない 3D モデルのファイル形式で、PNG や MP4 のような立ち位置を目指して作られたそうです。

Vite プロジェクトのセットアップ

今回は vite を使ってプロジェクトを作成しました。

$ npm create vite@latest

でプロジェクトを作成したのち、Three.js の追加を行います。今回は TypeScript を使うので、Three.js の型情報も追加します。

npm install three
npm install @types/three

これで準備完了です。

実際に表示させる

早速ツリーを表示させます。まずは index.html を以下のように書き換えます。
<canvas class="webgl"></canvas>というところに実際にツリーが描画されます。

index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
    </head>
    <body>
        <script type="module" src="src/main.ts"></script>
        <canvas class="webgl"></canvas>
    </body>
</html>

srcディレクトリ配下にある main.ts を以下の内容に書き換えます。

src/main.ts
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/Addons.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import vertexShader from "./shaders/vertex.glsl?raw";
import fragmentShader from "./shaders/fragment.glsl?raw";

const canvas = document.querySelector("canvas.webgl") as HTMLCanvasElement;

// シーン
const scene = new THREE.Scene();

scene.background = new THREE.Color(0x111111);

const sizes = {
    width: window.innerWidth,
    height: window.innerHeight,
    pixelRatio: Math.min(window.devicePixelRatio, 2),
};

let particles: {
    geometry: THREE.BufferGeometry;
    material: THREE.ShaderMaterial;
    points: THREE.Points | null;
} | null = null;

const loader = new GLTFLoader();

loader.load("src/models/xmastree.glb", (gltf) => {

    const position = (gltf.scene.children[0] as THREE.Mesh).geometry.attributes
        .position;

    const treePosition = new THREE.BufferAttribute(position.array, 3);

    particles = {
        geometry: new THREE.BufferGeometry(),
        material: new THREE.ShaderMaterial({
            vertexShader: vertexShader,
            fragmentShader: fragmentShader,
            uniforms: {
                uSize: new THREE.Uniform(0.25),
                uResolution: new THREE.Uniform(
                    new THREE.Vector2(
                        sizes.width * sizes.pixelRatio,
                        sizes.height * sizes.pixelRatio
                    )
                ),
            },
            blending: THREE.AdditiveBlending,
            depthWrite: false,
        }),
        points: null,
    };

    // Geometry
    particles.geometry = new THREE.BufferGeometry();
    particles.geometry.setAttribute("position", treePosition);

    // Points
    particles.points = new THREE.Points(particles.geometry, particles.material);
    scene.add(particles.points);
});

window.addEventListener("resize", () => {
    // サイズの更新
    sizes.width = window.innerWidth;
    sizes.height = window.innerHeight;

    // カメラの更新
    camera.aspect = sizes.width / sizes.height;
    camera.updateProjectionMatrix();

    // レンダラーの更新
    renderer.setSize(sizes.width, sizes.height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

// カメラ
const camera = new THREE.PerspectiveCamera(
    75,
    sizes.width / sizes.height,
    0.1,
    100
);
camera.position.set(0, 0, 8 * 2);
scene.add(camera);

// 操作系
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

// レンダラー
const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// アニメーション
const tick = () => {
    // 操作系の更新
    controls.update();

    // レンダリング
    renderer.render(scene, camera);

    // tickを次のフレームでも呼ぶ
    window.requestAnimationFrame(tick);
};

tick();

少し長く複雑に見えますが、半分以上は Three.js を使うにあたっての”お約束”のようなコードです。
雑に説明すると、Three.js では物が描画される舞台となるシーンと物を映し出すカメラを用意する必要があります。描画(レンダリング)はレンダラーが担当します。
「シーン」「カメラ」「レンダラー」の部分はそれぞれコメントアウトをつけてあります。
最後に実装されている tick()関数が毎フレーム呼び、カメラの位置に応じて描画内容を更新することで、マウスのドラッグやズームアウト・インしたときの結果を描画しています。

今回特徴的なのは以下の部分です。

src/main.ts(一部)
let particles: {
    geometry: THREE.BufferGeometry;
    material: THREE.ShaderMaterial;
    points: THREE.Points | null;
} | null = null;

const loader = new GLTFLoader();

loader.load("src/models/xmastree.glb", (gltf) => {

    const position = (gltf.scene.children[0] as THREE.Mesh).geometry.attributes
        .position;

    const treePosition = new THREE.BufferAttribute(position.array, 3);

    particles = {
        geometry: new THREE.BufferGeometry(),
        material: new THREE.ShaderMaterial({
            vertexShader: vertexShader,
            fragmentShader: fragmentShader,
            uniforms: {
                uSize: new THREE.Uniform(0.25),
                uResolution: new THREE.Uniform(
                    new THREE.Vector2(
                        sizes.width * sizes.pixelRatio,
                        sizes.height * sizes.pixelRatio
                    )
                ),
            },
            blending: THREE.AdditiveBlending,
            depthWrite: false,
        }),
        points: null,
    };

    // Geometry
    particles.geometry = new THREE.BufferGeometry();
    particles.geometry.setAttribute("position", treePosition);

    // Points
    particles.points = new THREE.Points(particles.geometry, particles.material);
    scene.add(particles.points);
});

Three.js の提供する GLTFLoader()のインスタンスを生成し、load メソッドを呼び出して冒頭作成したツリーの 3D モデルをロードします。(3D モデルはsrc/models/xmastree.glbに格納しました)

コールバック部分はモデルのロードが完了次第呼ばれます。positionにモデルの頂点の位置情報を代入し、3D オブジェクトの頂点データを効率的に管理するためのクラスである BufferAttribute を利用して treePosition に頂点の座標情報を代入します。
第二引数に 3 を指定していますが、これは 1 つの頂点が持つデータの要素数を表しています。頂点座標は x, y, z 座標の 3 つから成り立つため、3 を指定しています。

これでどの位置にパーティクルを表示すればいいのかという情報(ジオメトリ)の準備ができました。

あとはどのようにパーティクルを表示すればいいのかという情報(マテリアル)を指定します。今回は自前のシェーダーを書いています。シェーダーは glsl という言語を使い、3D 空間の情報を 2D のモニターに表示するための変換処理などを記述する vertex シェーダーと各ピクセルの色などを決める fragment シェーダーの 2 つを使います。

src/shaders/vertex.glsl
uniform vec2 uResolution;
uniform float uSize;

void main()
{
    // 最終的な位置情報
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;

    // 各頂点のサイズを設定
    gl_PointSize = uSize * uResolution.y;
    gl_PointSize *= (1.0 / - viewPosition.z);
}
src/shaders/fragment.glsl
void main()
{
    vec2 uv = gl_PointCoord;
    float distanceToCenter = length(uv - 0.5);

    float alpha = 0.05 / distanceToCenter - 0.1;

    gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
}

シェーダーは私も絶賛勉強中でしっかりと理解できていないのですが、今回は vertex シェーダーで各頂点の大きさの指定や遠近感を出す処理を書いています(ほぼおまじない)。fragment シェーダーは各頂点の中心が明るく、外側に行くにつれてぼんやり透明になっていく表現を可能にしています。

これらのシェーダーのおかげで手前の頂点は大きく、奥の頂点は小さく表示されたり、各頂点がぼんやり光るような表現ができています。

長くなりましたが、こうして定義されたジオメトリ・マテリアルの情報を渡してシーンに追加することで無事ツリーが表示されます。大変ですね。。!

まとめ

3DCG はとにかく覚えることが多く学習コストは決して低くはないのですが、その分 CSS だけでは難しい Web でのリッチな表現を可能にしてくれます。この機会にそんな CG の可能性を感じてもらえたら幸いです!

参考にした神々の資料

https://threejs-journey.com/
https://www.codegrid.net/articles/2018-gltf-1/

GitHubで編集を提案

Discussion