Three.jsでWebWorkerのOffscrennCanvas(オフスクリーンキャンバス)を使ってCanvasにレンダリングするスレッドを分けてパフォーマンス向上やメインスレッドの負荷軽減を目的として実装します。
負荷軽減されることによってメインスレッドのCPUのリソースをCSS等のアニメーションなどを使うUIUXに有効活用することができ、結果的に軽量化するとができるということですね。
今回は以前の記事の ViteでThree.jsの開発環境を構築する。 で作成したThree.jsの簡易的な開発環境をWebWorker(OffscreenCanvas)に対応するように改修していきたいと思います。
もちろん、OrbitControllerなどのデバックツールも使えるようにします。
- Demo:https://sho1374k.github.io/mpa-vite-threejs-offscreencanvas/
- GitHub:https://github.com/sho1374k/mpa-vite-threejs-offscreencanvas
実装
1. Worker.jsを作成する。
ここにWorker側の処理を書いていきます。
touch src/assets/js/webgl/worker.js
現在あるController.jsをメインスレッド側のコントローラとして使っていきます。
現在は下記のような構造になっています。
.
└── src/
└── assets/
├── js/
│ ├── webgl/
│ │ ├── controller.js
│ │ ├── objects.js
│ │ ├── stage.js
│ │ └── worker.js
│ └── index.js
└── shader/
├── object.fs
└── object.vs
2. index.htmlについて
canvasとそれをラッパーする要素を追加する。
iosのアドレスバーの挙動が気に食わないので、window.innerHeightを使わずラッパー要素の解像度を使用したいためにラッパーしています。
好みだと思いますので、ラッパー要素じゃない方法でも全然いいと思います。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script type="module" src="./assets/js/index.js"></script>
</head>
<body>
<div id="webgl">
<canvas id="webgl-canvas"></canvas>
</div>
</body>
</html>
3. objects.jsについて
前回の記事の内容から変更はしていません。
import * as THREE from "three";
import ObjectVs from "../../shader/object.vs";
import ObjectFs from "../../shader/object.fs";
export class Objects {
constructor(_stage, _resolution) {
this.stage = _stage;
this.resolution = _resolution;
}
resize(_resolution) {
this.resolution = _resolution;
}
update(_time) {
const t = _time.elapsed * 0.25;
if (this.cubeMesh) {
this.cubeMesh.rotation.x += t;
this.cubeMesh.rotation.y += t;
this.cubeMesh.rotation.z += t;
}
}
init() {
const g = new THREE.BoxGeometry(1, 1, 1);
const m = new THREE.ShaderMaterial({
vertexShader: ObjectVs,
fragmentShader: ObjectFs,
uniforms: {
uTime: { value: 0.0 },
uProgress: { value: 0.0 },
},
side: THREE.DoubleSide,
});
this.cubeMesh = new THREE.Mesh(g, m);
this.stage.scene.add(this.cubeMesh);
}
}
4. stage.jsを修正する。
import * as THREE from "three";
export class Stage {
constructor(_canvas, _resolution) {
this.canvas = _canvas;
this.resolution = _resolution;
this.renderer = null;
this.camera = null;
this.scene = null;
}
resizeRenderer() {
this.renderer.setSize(this.resolution.x, this.resolution.y);
this.renderer.setPixelRatio(this.resolution.pixelRatio);
}
setRenderer() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: true,
alpha: true,
});
this.resizeRenderer();
}
resizeCamera() {
this.camera.aspect = this.resolution.x / this.resolution.y;
this.camera.updateProjectionMatrix();
}
setCamera() {
this.camera = new THREE.PerspectiveCamera();
this.camera.near = 1;
this.camera.far = 100;
this.camera.fov = 60;
this.camera.position.z = 4;
this.resizeCamera();
}
setScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color("#000");
this.scene.add(new THREE.GridHelper(1000, 100));
this.scene.add(new THREE.AxesHelper(100));
}
resize(_resolution) {
this.resolution = _resolution;
this.resizeRenderer();
this.resizeCamera();
}
init() {
this.setRenderer();
this.setCamera();
this.setScene();
}
}
this.canvas = _canvas;
は canvas要素をOffscreenCanvasに変換したものを受け取っています。
それをcanvasとしてレンダラーに割り当てることができます。
5. controller.jsを記述する。
このcontroller.jsはworker側からの通信を送受信する役目をになってもらいます。
下記がcontroller.jsのベースとなる全体コードになります。
import * as THREE from "three";
const FPS = 30;
export class Controller {
constructor() {
this.elements = {
webglWrapper: document.getElementById("webgl"),
canvas: document.getElementById("webgl-canvas"),
};
this.resolution = {
x: this.elements.webglWrapper.clientWidth,
y: this.elements.webglWrapper.clientHeight,
aspect: this.elements.webglWrapper.clientWidth / this.elements.webglWrapper.clientHeight,
devicePixelRatio: Math.min(2, window.devicePixelRatio),
};
this.time = {
now: 0,
delta: 0,
elapsed: 0,
};
this.fps = {
lastTime: 0,
frameCount: 0,
startTime: null,
nowTime: 0,
limit: FPS,
interval: 1000 / FPS,
};
this.coords = {
x: 0,
y: 0,
};
// ここでラッパー要素の解像度をcanvasにも反映させています。
this.elements.canvas.width = this.resolution.x;
this.elements.canvas.height = this.resolution.y;
// ここでcanvas要素をOffscreenCanvasに変換しています。
this.offscreenCanvas = this.elements.canvas.transferControlToOffscreen();
// WebWorkerを読み取り初期化の通信を送信します。
this.worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = (e) => {
const data = e.data;
// ここでWorker側からの通信を受け取ります。
};
window.addEventListener("resize", this.resize.bind(this), { passive: true });
window.addEventListener("mousemove", this.move.bind(this), { passive: true });
window.addEventListener("touchmove", this.move.bind(this), { passive: true });
}
move(e) {
// mousemoveやtouchmoveのイベントで取得できる座標をworker側に送信します。
this.worker.postMessage({
mode: "move",
x: e.touches ? e.touches[0].clientX : e.clientX,
y: e.touches ? e.touches[0].clientY : e.clientY,
});
}
resize() {
this.resolution.x = this.elements.webglWrapper.clientWidth;
this.resolution.y = this.elements.webglWrapper.clientHeight;
this.resolution.aspect = this.resolution.x / this.resolution.y;
// リサイズ処理もできるようにデータを送信します。
this.worker.postMessage({
mode: "resize",
resolution: this.resolution,
});
}
update(now) {
requestAnimationFrame(this.update.bind(this));
if (!this.fps.startTime) this.fps.startTime = now;
this.time.now = now;
const elapsed = now - this.fps.lastTime;
this.time.elapsed = elapsed * 0.001;
this.time.delta = (now - this.fps.startTime) * 0.001;
if (elapsed > this.fps.interval) {
this.fps.lastTime = now - (elapsed % this.fps.interval);
this.fps.frameCount++;
// console.log(this.fps.frameCount / this.time.delta); // fpsの値を確認する
// update関数も送信します。
this.worker.postMessage({
mode: "update",
time: this.time,
});
}
}
init() {
// 初期化の送信をします。
this.worker.postMessage(
{
mode: "init",
canvas: this.offscreenCanvas,
resolution: this.resolution,
coords: this.coords,
},
[this.offscreenCanvas],
);
this.update();
}
}
下記のようなコードでworkerを読み込み onmessage
でworker側からの通信をイベントとして受け取ります。
this.worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = (e) => {
const data = e.data;
// ここでWorker側からの通信を受け取ります。
};
worker側にデータを送信する場合は下記のようにしてworker側にデータを送信します。
this.worker.postMessage({
mode: "resize",
resolution: this.resolution,
});
次にOrbitControllerとStasとGUIを追加してデータを送受信できるようにしていきます。
// ・・・省略・・・
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import Stats from "three/examples/jsm/libs/stats.module";
import GUI from "three/examples/jsm/libs/lil-gui.module.min";
// ・・・省略・・・
export class Controller {
constructor() {
// ・・・省略・・・
this.controls = null;
this.camera = new THREE.PerspectiveCamera();
this.stats = null;
this.stats = new Stats();
this.stats.domElement.style = `position: fixed; top: 0; left: 0; right: initial; bottom: initial; z-index: 9999;`;
document.body.appendChild(this.stats.domElement);
this.gui = new GUI();
this.worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = (e) => {
const data = e.data;
const mode = data.mode;
if (mode === "initCamera" && this.camera != null) {
this.camera.near = data.near;
this.camera.far = data.far;
this.camera.fov = data.fov;
this.camera.aspect = data.aspect;
this.camera.position.fromArray(data.position);
this.camera.quaternion.fromArray(data.quaternion);
this.camera.updateProjectionMatrix();
} else if (mode === "resizeCamera" && this.camera != null) {
this.camera.aspect = data.aspect;
this.camera.updateProjectionMatrix();
}
};
// ・・・省略・・・
}
// ・・・省略・・・
update(now) {
// ・・・省略・・・
if (elapsed > this.fps.interval) {
// ・・・省略・・・
if (this.stats) this.stats.update();
// ・・・省略・・・
}
}
init() {
// ・・・省略・・・
this.controls = new OrbitControls(this.camera, this.elements.canvas);
this.controls.addEventListener("change", () => {
this.worker.postMessage({
mode: "updateCamera",
position: this.camera.position.toArray(),
quaternion: this.camera.quaternion.toArray(),
});
});
// ・・・省略・・・
// gui
{
const createHandler = (worker, mode) => ({
set(target, property, value) {
target[property] = value;
const tmp = { mode: mode };
tmp[property] = value;
worker.postMessage(tmp);
return true;
},
});
// scene
{
const tmp = new Proxy(
{
background: new THREE.Color("#000").convertLinearToSRGB(),
},
createHandler(this.worker, "gui-scene"),
);
const f = this.gui.addFolder("scene");
f.addColor(tmp, "background").onChange((_value) => {
tmp.background = new THREE.Color(_value);
});
}
// cube
{
const tmp = new Proxy(
{
uProgress: 0,
},
createHandler(this.worker, "gui-cube"),
);
const f = this.gui.addFolder("cube");
f.add(tmp, "uProgress", 0.0, 1.0)
.name("progress")
.onChange((_value) => {
tmp.uProgress = _value;
});
}
}
}
}
□ Stas
まずStasを追加してきます。
すごくシンプルに追加するだけです。
this.stats = new Stats();
this.stats.domElement.style = `position: fixed; top: 0; left: 0; right: initial; bottom: initial; z-index: 9999;`;
document.body.appendChild(this.stats.domElement);
□ OrbitControls
次にOrbitControlsを追加していきます。
OrbitControlsはカメラオブジェクトのデータとcanvas要素のデータが必要になります。
その中のカメラオブジェクトのデータはWorker側から送信してもらいます。
データをいれる箱を設定しておきます。
this.controls = null;
this.camera = new THREE.PerspectiveCamera();
カメラオブジェクトのデータを初期化時とリサイズ時にうけとるように mode を指定してデータを更新していきます。
this.worker.onmessage = (e) => {
const data = e.data;
const mode = data.mode;
if (mode === "initCamera" && this.camera != null) {
this.camera.near = data.near;
this.camera.far = data.far;
this.camera.fov = data.fov;
this.camera.aspect = data.aspect;
this.camera.position.fromArray(data.position);
this.camera.quaternion.fromArray(data.quaternion);
this.camera.updateProjectionMatrix();
} else if (mode === "resizeCamera" && this.camera != null) {
this.camera.aspect = data.aspect;
this.camera.updateProjectionMatrix();
}
};
その後、controller.jsのinit関数の初期化時にOrbitControlsを初期化します。
OrbitControlsに変更があるたびにWorker側にカメラオブジェクトのデータ座標データをコピーできるように送信していきます。
this.controls = new OrbitControls(this.camera, this.elements.canvas);
this.controls.addEventListener("change", () => {
this.worker.postMessage({
mode: "updateCamera",
position: this.camera.position.toArray(),
quaternion: this.camera.quaternion.toArray(),
});
});
□ GUI
次にパラメータ調整するGUIを追加します。
まずはシンプルにコンストラクターの中で初期化します。
this.gui = new GUI();
その後、controller.jsのinit関数で初期時にWorker側に更新内容を送信します。
まずはオブジェクトデータの中身の更新を感知したら送信する共通で使用する関数を作成します。
const createHandler = (worker, mode) => ({
set(target, property, value) {
target[property] = value;
const tmp = { mode: mode };
tmp[property] = value;
worker.postMessage(tmp);
return true;
},
});
前回に記事ではSceneの背景色とCubeMeshのuniform変数を更新していました。
その2つを同様に更新できるようにします。
まずは、Sceneの背景色から設定していきます。
// scene
{
const tmp = new Proxy(
{
background: new THREE.Color("#000").convertLinearToSRGB(),
},
createHandler(this.worker, "gui-scene"),
);
const f = this.gui.addFolder("scene");
f.addColor(tmp, "background").onChange((_value) => {
tmp.background = new THREE.Color(_value);
});
}
new Proxy
はオブジェクトデータや関数へのアクセスや操作にフックを追加したり、特定の振る舞いを変更したりすることができます。
なのでWokerにデータを渡す目にパラメータを更新できる箱として一時的にオブジェクトデータに格納して感知できるようにおきます。
CubeMeshの情報も更新できるように追加します。
// cube
{
const tmp = new Proxy(
{
uProgress: 0,
},
createHandler(this.worker, "gui-cube"),
);
const f = this.gui.addFolder("cube");
f.add(tmp, "uProgress", 0.0, 1.0)
.name("progress")
.onChange((_value) => {
tmp.uProgress = _value;
});
}
▼ controller.jsの全体コード ▼
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import Stats from "three/examples/jsm/libs/stats.module";
import GUI from "three/examples/jsm/libs/lil-gui.module.min";
const FPS = 30;
export class Controller {
constructor() {
this.elements = {
webglWrapper: document.getElementById("webgl"),
canvas: document.getElementById("webgl-canvas"),
};
this.resolution = {
x: this.elements.webglWrapper.clientWidth,
y: this.elements.webglWrapper.clientHeight,
aspect: this.elements.webglWrapper.clientWidth / this.elements.webglWrapper.clientHeight,
devicePixelRatio: Math.min(2, window.devicePixelRatio),
};
this.time = {
now: 0,
delta: 0,
elapsed: 0,
};
this.fps = {
lastTime: 0,
frameCount: 0,
startTime: null,
nowTime: 0,
limit: FPS,
interval: 1000 / FPS,
};
this.coords = {
x: 0,
y: 0,
};
this.elements.canvas.width = this.resolution.x;
this.elements.canvas.height = this.resolution.y;
this.offscreenCanvas = this.elements.canvas.transferControlToOffscreen();
this.controls = null;
this.camera = new THREE.PerspectiveCamera();
this.stats = null;
this.stats = new Stats();
this.stats.domElement.style = `position: fixed; top: 0; left: 0; right: initial; bottom: initial; z-index: 9999;`;
document.body.appendChild(this.stats.domElement);
this.gui = new GUI();
this.worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = (e) => {
const data = e.data;
const mode = data.mode;
if (mode === "initCamera" && this.camera != null) {
this.camera.near = data.near;
this.camera.far = data.far;
this.camera.fov = data.fov;
this.camera.aspect = data.aspect;
this.camera.position.fromArray(data.position);
this.camera.quaternion.fromArray(data.quaternion);
this.camera.updateProjectionMatrix();
} else if (mode === "resizeCamera" && this.camera != null) {
this.camera.aspect = data.aspect;
this.camera.updateProjectionMatrix();
}
};
window.addEventListener("resize", this.resize.bind(this), { passive: true });
window.addEventListener("mousemove", this.move.bind(this), { passive: true });
window.addEventListener("touchmove", this.move.bind(this), { passive: true });
}
move(e) {
this.worker.postMessage({
mode: "move",
x: e.touches ? e.touches[0].clientX : e.clientX,
y: e.touches ? e.touches[0].clientY : e.clientY,
});
}
resize() {
this.resolution.x = this.elements.webglWrapper.clientWidth;
this.resolution.y = this.elements.webglWrapper.clientHeight;
this.resolution.aspect = this.resolution.x / this.resolution.y;
this.worker.postMessage({
mode: "resize",
resolution: this.resolution,
});
}
update(now) {
requestAnimationFrame(this.update.bind(this));
if (!this.fps.startTime) this.fps.startTime = now;
this.time.now = now;
const elapsed = now - this.fps.lastTime;
this.time.elapsed = elapsed * 0.001;
this.time.delta = (now - this.fps.startTime) * 0.001;
if (elapsed > this.fps.interval) {
this.fps.lastTime = now - (elapsed % this.fps.interval);
this.fps.frameCount++;
// console.log(this.fps.frameCount / this.time.delta); // fpsの値を確認する
if (this.stats) this.stats.update();
this.worker.postMessage({
mode: "update",
time: this.time,
});
}
}
init() {
this.worker.postMessage(
{
mode: "init",
canvas: this.offscreenCanvas,
resolution: this.resolution,
coords: this.coords,
},
[this.offscreenCanvas],
);
this.controls = new OrbitControls(this.camera, this.elements.canvas);
this.controls.addEventListener("change", () => {
this.worker.postMessage({
mode: "updateCamera",
position: this.camera.position.toArray(),
quaternion: this.camera.quaternion.toArray(),
});
});
this.update();
// gui
{
const createHandler = (worker, mode) => ({
set(target, property, value) {
target[property] = value;
const tmp = { mode: mode };
tmp[property] = value;
worker.postMessage(tmp);
return true;
},
});
// scene
{
const tmp = new Proxy(
{
background: new THREE.Color("#000").convertLinearToSRGB(),
},
createHandler(this.worker, "gui-scene"),
);
const f = this.gui.addFolder("scene");
f.addColor(tmp, "background").onChange((_value) => {
tmp.background = new THREE.Color(_value);
});
}
// cube
{
const tmp = new Proxy(
{
uProgress: 0,
},
createHandler(this.worker, "gui-cube"),
);
const f = this.gui.addFolder("cube");
f.add(tmp, "uProgress", 0.0, 1.0)
.name("progress")
.onChange((_value) => {
tmp.uProgress = _value;
});
}
}
}
}
6. worker.jsのコードを実装する。
最初に作成したwoker.jsにコードを実装していきます。
▼ 全体コードになります。 ▼
import * as THREE from "three";
import { Stage } from "./stage";
import { Objects } from "./objects";
class Controller {
constructor(_canvas, _resolution, _coords) {
this.canvas = _canvas;
this.resolution = _resolution;
this.coords = _coords;
this.stage = new Stage(this.canvas, this.resolution);
this.objects = new Objects(this.stage, this.resolution);
}
move(_x, _y) {
this.coords.x = _x;
this.coords.y = _y;
}
resize(_resolution) {
this.resolution = _resolution;
this.stage.resize(_resolution);
this.objects.resize(_resolution);
}
updateOrbitControls(_position, _quaternion) {
this.stage.camera.position.fromArray(_position);
this.stage.camera.quaternion.fromArray(_quaternion);
}
update(_time) {
this.objects.update(_time);
this.stage.renderer.render(this.stage.scene, this.stage.camera);
}
init() {
this.stage.init();
this.objects.init();
}
}
let controller = null;
onmessage = async (e) => {
const data = e.data;
const mode = data.mode;
if (mode === "init") {
const canvas = data.canvas;
const resolution = data.resolution;
const coords = data.coords;
canvas.style = { width: 0, height: 0 };
controller = new Controller(canvas, resolution, coords);
controller.init();
controller.resize(data.resolution);
const camera = controller.stage.camera;
postMessage({
mode: "initCamera",
near: camera.near,
far: camera.far,
fov: camera.fov,
aspect: camera.aspect,
position: camera.position.toArray(),
quaternion: camera.quaternion.toArray(),
});
} else if (mode === "resize") {
controller.resize(e.data.resolution);
const camera = controller.stage.camera;
postMessage({
mode: "resizeCamera",
aspect: camera.aspect,
});
} else if (mode === "update") {
controller.update(e.data.time);
} else if (mode === "updateCamera") {
controller.stage.camera.position.fromArray(data.position);
controller.stage.camera.quaternion.fromArray(data.quaternion);
} else if (mode === "move") {
controller.move(data.x, data.y);
}
// --------------------------
if (mode === "gui-scene") {
if (data.color) {
controller.stage.scene.background.r = data.color.r;
controller.stage.scene.background.g = data.color.g;
controller.stage.scene.background.b = data.color.b;
}
} else if (mode === "gui-cube") {
controller.objects.cubeMesh.material.uniforms.uProgress.value = data.uProgress;
}
};
前回の記事同様にControllerクラスでThree.jsのコード全体をコントロールしています。
1つ追加されているものといえば、メインスレッドから受信したOrbitControlsの座標データをカメラにコピーする関数が増えています。
updateOrbitControls(_position, _quaternion) {
this.stage.camera.position.fromArray(_position);
this.stage.camera.quaternion.fromArray(_quaternion);
}
下の方にWorker内で通信の送受信の処理を書いています。
let controller = null;
onmessage = async (e) => {
const data = e.data;
const mode = data.mode;
if (mode === "init") {
const canvas = data.canvas;
const resolution = data.resolution;
const coords = data.coords;
canvas.style = { width: 0, height: 0 };
controller = new Controller(canvas, resolution, coords);
controller.init();
controller.resize(data.resolution);
const camera = controller.stage.camera;
postMessage({
mode: "initCamera",
near: camera.near,
far: camera.far,
fov: camera.fov,
aspect: camera.aspect,
position: camera.position.toArray(),
quaternion: camera.quaternion.toArray(),
});
} else if (mode === "resize") {
controller.resize(e.data.resolution);
const camera = controller.stage.camera;
postMessage({
mode: "resizeCamera",
aspect: camera.aspect,
});
} else if (mode === "update") {
controller.update(e.data.time);
} else if (mode === "updateCamera") {
controller.stage.camera.position.fromArray(data.position);
controller.stage.camera.quaternion.fromArray(data.quaternion);
} else if (mode === "move") {
controller.move(data.x, data.y);
}
// --------------------------
if (mode === "gui-scene") {
if (data.color) {
controller.stage.scene.background.r = data.color.r;
controller.stage.scene.background.g = data.color.g;
controller.stage.scene.background.b = data.color.b;
}
} else if (mode === "gui-cube") {
controller.objects.cubeMesh.material.uniforms.uProgress.value = data.uProgress;
}
};
基本的には通信されたmodeによって処理を条件分岐しています。
1つだけハマった箇所があります。
OffscreenCanvasに変換したcanvas要素にスタイル属性のwidthとheightを追加することです。
canvas.style = { width: 0, height: 0 };
これを追加しないとレンダラーのサイズを設定するときにスタイルデータの更新する値がなく下記のようなエラーがでてしましますので、設定する必要がありました。
TypeError: Cannot set properties of undefined (setting 'width')
あとはメインスレッド側に送信するデータをpostMessageで送信したり、受信してControllerクラスのなりふりを決めているぐらいです。
Build時のエラー
buildした際に下記のようなエラーが出ました。
error during build:
[vite:worker-import-meta-url] src/assets/shader/object.vs (1:8): Expected ';', '}' or <eof> (Note that you need plugins to import files that are not JavaScript)
内容としてimportしているshaderがbuild時にテキストデータではなくJavaScriptの構文としてコンパイルされているため構文エラーが出ています。
そのため下記のようにshaderをimportするように変更しました。
import ObjectVs from "../../shader/object.vs?raw";
import ObjectFs from "../../shader/object.fs?raw";
Viteの公式リファレンスを確認すると ?raw
を付与することでJS内で文字列としてimportしていること明示することができるそうです。
まとめ
これでWebWorker(OffscreenCanvas)を使用してthree.jsを扱うベースを作成することができました。
Web上でできることがどんどん増えてきている現状を考えるとメモリ負荷軽減の手段としてできるにこしたことはないと思いますし、将来的にはWebWorkerを使うことがあたりまえになっているかもしれないと感じました。
今回のコードはGitHubにて公開していますので、自己責任で好きにつかってください。
- Demo:https://sho1374k.github.io/mpa-vite-threejs-offscreencanvas/
- GitHub:https://github.com/sho1374k/mpa-vite-threejs-offscreencanvas