1. はじめに
WebGLによってダイナミックなウェブサイトを作るという要件を満たすために、Three.jsやreact-three-fiberを学習する中で画像やモデルをパーティクルにする方法を学びました。また、その実装に悪戦苦闘したため今回は忘備録を兼ねてそれをまとめようと思います。
特に、gltfのモデルの取り扱いが難しかったので、これが他の方の役に立てれば幸いです。
目次
2. Pointsクラス
Pointsクラスは、大量のパーティクルを表示するのに適したクラスです。 引数としては他のメッシュと同じようにジオメトリーとマテリアルを必要とします。boxGeometryなどの組み込みのジオメトリーを使うよりは、bufferGeometryのpositionに座標を登録してパーティクルを表示することが一般的だと思います。
パーティクルを扱う時はSpriteを使うこともあります。Spriteはどのカメラの向きに対しても常に正面を向くもので、ポリゴン数を節約することができるので多くのパーティクルを表示することに向いていますが、さらに大量にパーティクルを表現したいならばPointsを使う方がパフォーマンス的にも良いでしょう。
react-three-fiberにおける基本的な扱い方は以下の通りです。
export default function Particles() {
const positions: number[] = [];
const points = 1000;
for (let i = 0; i < points; i++) {
const x = (Math.random() - 0.5) * 10;
const y = (Math.random() - 0.5) * 10;
const z = (Math.random() - 0.5) * 10;
positions.push(x, y, z);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttributes("position", new THREE.Float32BufferAttribute(positions, 3));
return (
<points geometry={geometry}>
<pointsMaterial size={1} color="white" />
</points>
);
}
3. 二次元画像のパーティクル化
二次元画像はイメージのデータから、画像を構成するピクセルの情報を取得します。そのデータをbuffergeometryのcolorに登録することで、画像をパーティクル化させる段取りです。
具体的には以下の通りです。
"use client";
import { useEffect, useState } from "react";
import * as THREE from "three";
export default function ParticleImage({
imageSrc,
scale = 1,
threshold = 100
ratio = 2
}: {
imageSrc: string;
scale?: number;
threshold?: number;
ratio?: number;
}) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
const img = new Image();
img.src = imageSrc;
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, img.width, img.height).data;
const positions: number[] = [];
const colors: number[] = [];
for (let y = 0; y < img.height; y += ratio) {
for (let x = 0; x < img.width; x += ratio) {
const index = (y * img.width + x) * 4;
const alpha = data[index + 3];
if (alpha < threshold) continue;
positions.push(
(x - img.width / 2) * scale,
-(y - img.height / 2) * scale,
0
);
colors.push(
data[index] / 255,
data[index + 1] / 255,
data[index + 2] / 255
);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
setGeometry(geo);
};
}, [imageSrc, scale, threshold]);
if (!geometry) return null;
return (
<points rotation={[-Math.PI/20, 0, 0]} geometry={geometry}>
<pointsMaterial size={1} vertexColors={true} sizeAttenuation />
</points>
);
}
画像データの読み込みには一癖あります。
Image
クラスのインスタンスに画像のパスを指定し、画像データを変数として保持します。
そこから、ローカルの(即ちDOMには追加しないオフスクリーンの)canvas要素に描画させ、そこからピクセルのデータを取得します。
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, img.width, img.height).data;
それによって取得できるデータはRGBaのデータです。一次元データでRGBaのまとまりを表現しているので、4つの数字のまとまりごとで色を指し示しています。つまり、一つずれると色が狂ってしまうので注意です。
indexはそれまでのピクセル数を数えています。positionでx,y
の位置取りの時、そこに至るまでのピクセル数は全体の列数にyを掛けてxを足したものに等しくなります。dataでは四つの数字が一つのピクセルの色を指し示しているので、4倍する必要があります。
ポジションは、原点からの相対位置で点を打っていくイメージです。y座標が負になるのは、HTMLキャンパスのy軸が下方向に対してThree.jsのy軸が上方向で逆だからです。
フィルタリングされたピクセルの色を0から1の範囲に正規化して配列に追加します。
for (let y = 0; y < img.height; y += ratio) {
for (let x = 0; x < img.width; x += ratio) {
const index = (y * img.width + x) * 4;
const alpha = data[index + 3];
if (alpha < threshold) continue;
positions.push(
(x - img.width / 2) * scale,
-(y - img.height / 2) * scale,
0
);
colors.push(
data[index] / 255,
data[index + 1] / 255,
data[index + 2] / 255
);
}
}
それぞれをbuffergeometryに属性として登録しますが、buffergeometryの色を使いたいときはpointsMaterial
のvertexColorsをtrueにしておく必要があります。
参考
4. 3Dモデルのパーティクル化
3Dモデルは、モデルの読み込み周りで苦労が伴うかもしれません。
パーティクルにするとき、私は特にここで動作不良に苦しみました。
今回は、
1.gltfのモデルからMeshオブジェクトをとりだし、
2.そのジオメトリーのposition情報をbuffergeometryのpositionに込めなおしたうえでマージし、
3.サンプリングを行って比較的均等にパーティクルが散らばるよう
に工夫しました。
なぜわざわざbuffergeometryに込めなおしてマージしたかは次に説明します。
まずは全体のコードをお見せします。
"use client";
import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js';
import * as BGU from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { useGLTF } from '@react-three/drei';
import * as THREE from 'three';
import { useEffect, useState } from 'react';
export default function Particle3D({ url, particleCount = 100000 }: { url: string; particleCount?: number }) {
const { scene } = useGLTF(url);
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
const geometriesToMerge: THREE.BufferGeometry[] = [];
const obj = scene.getObjectByName("3d-modelobj");
if (obj == undefined) return;
for (const child of obj.children) {
if (child instanceof THREE.Mesh) {
// 各メッシュのジオメトリからposition属性のみを抽出して新しいBufferGeometryを作成
const originalGeometry = child.geometry;
const positionAttribute = originalGeometry.getAttribute("position");
if (!positionAttribute) continue;
const newGeometry = new THREE.BufferGeometry();
newGeometry.setAttribute("position", positionAttribute);
geometriesToMerge.push(newGeometry);
}
}
let combinedGeometry: THREE.BufferGeometry | null = null;
combinedGeometry = BGU.mergeGeometries(geometriesToMerge);
if (combinedGeometry.attributes.position.array.includes(NaN)) {
console.log("Failed to merge geometries.");
return;
}
// Samplerの作成とパーティクルのサンプリング
const sampler = new MeshSurfaceSampler(new THREE.Mesh(combinedGeometry)).build();
const positions = new Float32Array(particleCount * 3);
const temp = new THREE.Vector3();
for (let i = 0; i < particleCount; i++) {
sampler.sample(temp);
positions[i * 3] = temp.x;
positions[i * 3 + 1] = temp.y;
positions[i * 3 + 2] = temp.z;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
setGeometry(geo);
}, [scene, particleCount]);
if (!geometry) return null;
return (
<points geometry={geometry} position={[0, 0, 0]} scale={0.05}>
<pointsMaterial size={0.01} color="white" sizeAttenuation />
</points>
);
}
どうやら、GLTFの構造如何によっては一つのオブジェクトでできているか、パーツを組み上げて表現されているかが違うようです。
組み上げで表現されている場合、Mesh型のオブジェクトからジオメトリーを取り出してマージしなければなりませんが、私の環境では単純な結合はできませんでした。
ジオメトリーの属性が異なっていたりものによって欠落していたりする場合、どうしてもジオメトリーの結合が失敗します。
今回はジオメトリーのposition情報にしか興味がなかったので、これを取り出してbuffergeometryに入れなおしてマージしました。
もしかすると、わざわざマージしなくともposition情報をすべて一つの配列にまとめたうえでbuffergeometryに詰め込むだけでも大丈夫かもしれません。
const geometriesToMerge: THREE.BufferGeometry[] = [];
const obj = scene.getObjectByName("3d-modelobj");
if (obj == undefined) return;
for (const child of obj.children) {
if (child instanceof THREE.Mesh) {
// 各メッシュのジオメトリからposition属性のみを抽出して新しいBufferGeometryを作成
const originalGeometry = child.geometry;
const positionAttribute = originalGeometry.getAttribute("position");
if (!positionAttribute) continue;
const newGeometry = new THREE.BufferGeometry();
newGeometry.setAttribute("position", positionAttribute);
geometriesToMerge.push(newGeometry);
}
}
let combinedGeometry: THREE.BufferGeometry | null = null;
combinedGeometry = BGU.mergeGeometries(geometriesToMerge);
ジオメトリーの表面は、三角形の組み合わせで表現されています。そのためpositionは各三角形の頂点情報を保持しており、samplerはその頂点情報をもとに表面の中から点を抽出していくようなイメージになります。
実際の点の抽出はsampler.sample(temp)によって行われています。このメソッドが実行される時、tempインスタンスにランダムに取得された点の位置情報が登録されます。このため、tempからx,y,zの座標を取得することができます。
const sampler = new MeshSurfaceSampler(new THREE.Mesh(combinedGeometry)).build();
const positions = new Float32Array(particleCount * 3);
const temp = new THREE.Vector3();
for (let i = 0; i < particleCount; i++) {
sampler.sample(temp);
positions[i * 3] = temp.x;
positions[i * 3 + 1] = temp.y;
positions[i * 3 + 2] = temp.z;
}
あとは、buffergeometryにpositionを込め込めしてpointsに登録すればパーティクルの表示ができます。
参考
5. おわりに
今回の開発では、パーティクルにする手法は採用しないかもしれませんが、今回学んだことを筋立てて整理することができたのでよかったです。
特にジオメトリーの統合には悪戦苦闘したので、オブジェクトの取得の仕方から確認できてよかったです。