😚

Three.js+Texture Packerでテクスチャパッキング

2023/12/10に公開

はじめに

備忘録で書きます。
Three.jsに対して、ある程度理解のある方向けです。

テクスチャパッキングで小さなテクスチャを1枚の大きなテクスチャにまとめることで、メモリ使用量を最適化することが出来ます。

gitにサンプルも上げています。
https://github.com/AdachiSaya/threejs-texture-packer-sample

完成図

今回は10個のパーツからパズルを組み上げます。
(※左上がすべてのTextureを重ねたときの完成図になります。)

準備

記事内でTexturePackerというソフトを使用しますのでダウンロードしてください
https://www.codeandweb.com/texturepacker
※記事内で使用するTexturePackerというソフトは課金済みですので、フリープランの場合の制限を考慮していません。予めご了承ください。

環境

next.jsを使用します。

next: 14.0.3
react: ^18
node-version: 20.2.0

1.Textureの準備

gitから素材をダウンロードしてください。
https://github.com/AdachiSaya/threejs-texture-packer-sample/tree/main/_textures

TexturePackerにダウンロードした素材をすべてドラッグ&ドロップしてください。
その後、右側の設定を写真と同じにしてください。

publish sprite sheetからPNGとJSONをダウンロードしてください。
今回は「puzzle_textures」という名前を付けました。

書き出されると、指定したファイルパスに以下の2つが出力されます。

これで10枚のPNGを1枚のPNGにまとめることができました。

2.Three.jsの下準備

ベースを作成します。

main.ts

import * as THREE from 'three';
import Stats from 'stats.js';
import { OrbitControls } from 'three/examples/jsm/Addons.js';

export class Main {
	stats?: Stats;
	camera?: THREE.Camera;
	scene?: THREE.Scene;
	renderer?: THREE.Renderer;

	init(element: HTMLElement) {
		const windowWidth = window.innerWidth;
		const windowHeight = window.innerHeight;

		this.scene = new THREE.Scene();
		this.scene.background = new THREE.Color(0x000000);

		this.camera = new THREE.PerspectiveCamera(45, windowWidth / windowHeight, 0.1, 5000);
		this.camera.position.set(0, 0, 1000);
		const rad = (45 / 2) * (Math.PI / 180);
		let distance = (windowHeight / 2) / Math.tan(rad);
		this.camera.position.set(0, 0, distance);
		(this.camera as THREE.PerspectiveCamera).aspect = windowWidth / windowHeight;
		(this.camera as THREE.PerspectiveCamera).updateProjectionMatrix();

		this.renderer = new THREE.WebGLRenderer({
			alpha: true,
			antialias: true,
		});
		this.renderer.setSize(windowWidth, windowHeight);

		// ※Controller
		const controls = new OrbitControls(this.camera!, this.renderer.domElement);
		controls.enabled = true;
		controls.target.set(0, 0, 0);
		controls.update();

		// ※Stats
		this.stats = new Stats();
		this.stats.showPanel(0);
		document.body.appendChild(this.stats.dom);

		element.appendChild(this.renderer.domElement);
		requestAnimationFrame(this.update);
	}

	update = () => {
		this.stats?.begin();
		this.renderer?.render(this.scene!, this.camera!);
		this.stats?.end();
		requestAnimationFrame(this.update);
	};
}

initのタイミングで直下に引数にcanvasを作成したいdomを渡して下さい。

※はなくても良いです。
OrbitControls: 画面をくるくる回すために使用
https://threejs.org/docs/#examples/en/controls/OrbitControls

Stats: FPS表示
https://www.npmjs.com/package/stats-js

index.tsx

import { useEffect, useRef } from 'react';
import { Main } from '../assets/main';
import styles from "../styles/component/index.module.scss";

export default function Index() {
	const mainVisualRef = useRef<HTMLDivElement>(null);
	useEffect(() => {
		if (!mainVisualRef.current) return;
		const main = new Main();
		main.init(mainVisualRef.current);
	}, []);
	return (
		<div ref={mainVisualRef} className={styles.mainVisual} style={{ width: "100%", height: "100vh" }} />
	);
}

この段階でまだ画面は真っ暗です。

3.TexturePackerで作成したデータの読み込み

今回は、データを読み込むためのutilsクラス、TextureAtlasを作成しました。
コピペして使用してください。

textureAtlas.ts

import Path from "path";
import { Texture, TextureLoader, Vector2 } from "three";

interface TextureAtlasJsonData {
	filename: string;
	frame: {
		x: number,
		y: number,
		w: number,
		h: number;
	},
	rotated: boolean,
	trimmed: boolean,
	spriteSourceSize: {
		x: number,
		y: number,
		w: number,
		h: number;
	},
	sourceSize: {
		w: number,
		h: number;
	};
}

interface TexturePackerJsonData {
	frames: { [key: string]: TextureAtlasJsonData; };
	meta: {
		app: string;
		version: string;
		image: string;
		format: string;
		size: {
			w: number,
			h: number;
		},
		scale: string,
		smartupdate: string;
	};
}

export class TextureData {
	key: string;
	texture: Texture;
	data: TextureAtlasJsonData;
	width: number;
	height: number;
	offset: Vector2;
	textureWidth: number;
	textureHeight: number;
	scale: number;

	constructor(key: string, texture: Texture, data: TextureAtlasJsonData, scale: number = 1) {
		this.scale = scale;
		this.key = key;
		this.texture = texture;
		this.data = data;
		this.textureWidth = data.frame.w / this.scale;
		this.textureHeight = data.frame.h / this.scale;
		this.texture.repeat.set(data.frame.w / texture.image.width, data.frame.h / texture.image.height);
		this.texture.offset.set(
			data.frame.x / texture.image.width,
			1 - data.frame.h / texture.image.height - data.frame.y / texture.image.height
		);
		this.texture.needsUpdate = true;
		this.width = data.sourceSize.w / this.scale;
		this.height = data.sourceSize.h / this.scale;
		this.offset = new Vector2(-this.textureWidth / 2, -this.textureHeight / 2);
		this.offset.x -= data.spriteSourceSize.x / this.scale;
		this.offset.y += (data.spriteSourceSize.y / this.scale + data.spriteSourceSize.h / this.scale) - this.height;
	}

	clone() {
		return new TextureData(this.key, this.texture, this.data);
	}
}

export class TextureAtlas {
	path: string;
	json?: TexturePackerJsonData;
	textureDataList: { [key: string]: TextureData; } = {};

	constructor(path: string) {
		this.path = path;
	}

	async load() {
		return new Promise<void>(async resolve => {
			this.json = await (await fetch(this.path)).json();
			const imagePath = this.getImagePath(this.json, this.path);
			const loader = new TextureLoader();
			loader.load(
				imagePath,
				(texture: Texture) => {
					this.init(texture);
					resolve();
				},
			);
		});
	}

	protected init(texture: Texture) {
		if (!this.json) return;
		Object.keys(this.json?.frames).forEach((key: string) => {
			const t = texture.clone();
			const data = new TextureData(key, t, this.json!.frames[key], Number(this.json!.meta.scale));
			this.textureDataList[key] = data;
		});
	}

	protected getImagePath(json: any, path: string): string {
		const dir = Path.dirname(path);
		return Path.join(dir, json.meta.image);
	}

}

TextureAtlasの使い方

  1. TextureAtlasクラスのインポート
import { TextureAtlas } from './textureAtlas';
  1. TextureAtlasを新しく作成します。このとき、./publicフォルダに格納されている.jsonファイルのパスを渡します。
this.textureAtlas: TextureAtlas = new TextureAtlas("/puzzle_textures.json");
  1. TextureAtlasを使用してテクスチャをロードします。非同期処理なのでawaitを使用します。(awaitを使用しているのでファンクション名にasyncをつけてください。)
await this.textureAtlas!.load();
  1. textureAtlasからテクスチャデータの配列を取得します。(後は、このデータで良い感じにmeshを作成します。)
const textures = this.textureAtlas!.textures;

main.ts

main.tsにおけるTextureAtlasクラスの使用箇所を抜粋したものを以下に示します。

import { TextureAtlas } from './textureAtlas';
export class Main {
	// 省略
	textureAtlas: TextureAtlas = new TextureAtlas("/puzzle_textures.json");

	async init(element: HTMLElement) {
		// 省略
		await this.load();
		// 省略
	}

	async load() {
		await this.textureAtlas!.load();
		const dataList = this.textureAtlas!.textureDataList;
	}

	// 省略
}

4.画像表示

textureAtlas.textureDataListのデータをもとに画像表示を行います。
今回は画像をいい感じに表示する自作クラスを使用しています。

Image.ts

import * as THREE from 'three';
import { TextureData } from './textureAtlas';

export class Image extends THREE.Object3D {
	mesh: THREE.Mesh;
	container: THREE.Object3D;
	texture?: THREE.Texture;
	width: number = 0;
	height: number = 0;
	anchor: THREE.Vector2 = new THREE.Vector2(0.5, 0.5);
	textureData?: TextureData;

	constructor(textureData: TextureData, anchor: THREE.Vector2 = new THREE.Vector2(0.5, 0.5)) {
		super();
		this.mesh = new THREE.Mesh();
		this.container = new THREE.Object3D();
		this.container.add(this.mesh);
		this.add(this.container);
		this.init(textureData, anchor);
	}

	protected init(textureData: TextureData, anchor: THREE.Vector2 = new THREE.Vector2(0.5, 0.5)) {
		this.createMesh(textureData.texture, textureData.textureWidth, textureData.textureHeight);
		this.textureData = textureData;
		this.anchor = anchor;
		this.width = textureData.width;
		this.height = textureData.height;
		// オフセットの反映
		this.mesh.position.x = -this.textureData.offset.x - (this.width * 0.5);
		this.mesh.position.y = -this.textureData.offset.y - (this.height * 0.5);
		// アートボードの中心が回転の基準になるようにしている
		this.container.position.x = this.width * (0.5 - this.anchor.x);
		this.container.position.y = -this.height * (0.5 - this.anchor.y);

	}

	protected createMesh(tex: THREE.Texture, width: number, height: number) {
		this.texture = tex;
		this.width = width;
		this.height = height;
		this.mesh.geometry = new THREE.PlaneGeometry(this.width, this.height);
		this.mesh.material = new THREE.MeshBasicMaterial({
			map: tex,
			transparent: true,
			side: THREE.FrontSide, // 裏面も表示したいときはTHREE.DoubleSideにしてください
			depthTest: false,
		});
	}
}

main.ts

main.tsにおけるImageクラスの使用箇所を抜粋したものを以下に示します。

import { Image } from './image';
export class Main {
	images: Array<Image> = [];
	
	// 省略
	
	async load() {
	await this.textureAtlas!.load();
		const dataList = this.textureAtlas!.textureDataList;
		for (const key in dataList) {
			const image = new Image(dataList[key]);
			this.images.push(image);
			this.scene!.add(image);
		}
	}
}	

※Imageクラスでは、第一引数にTextureData、第二引数にanchorを渡します。
anchorは回転の基準点を指し、デフォルトは余白込みの画像の中心(Vector2(0.5, 0.5))に設定されています。

5.すべてを結合

main.ts

import * as THREE from 'three';
import Stats from 'stats.js';
import { OrbitControls } from 'three/examples/jsm/Addons.js';
import { TextureAtlas } from './textureAtlas';
import { Image } from './image';

export class Main {
	stats?: Stats;
	camera?: THREE.Camera;
	scene?: THREE.Scene;
	renderer?: THREE.Renderer;
	images: Array<Image> = [];
	textureAtlas: TextureAtlas = new TextureAtlas("/puzzle_textures.json");

	async init(element: HTMLElement) {
		const windowWidth = window.innerWidth;
		const windowHeight = window.innerHeight;

		// Scene
		this.scene = new THREE.Scene();
		this.scene.background = new THREE.Color(0x000000);

		// Camera
		this.camera = new THREE.PerspectiveCamera(45, windowWidth / windowHeight, 0.1, 5000);
		this.camera.position.set(0, 0, 1000);
		const rad = (45 / 2) * (Math.PI / 180);
		let distance = (windowHeight / 2) / Math.tan(rad);
		this.camera.position.set(0, 0, distance);
		(this.camera as THREE.PerspectiveCamera).aspect = windowWidth / windowHeight;
		(this.camera as THREE.PerspectiveCamera).updateProjectionMatrix();

		// Renderer
		this.renderer = new THREE.WebGLRenderer({
			alpha: true,
			antialias: true,
		});
		this.renderer.setSize(windowWidth, windowHeight);

		// Controller
		const controls = new OrbitControls(this.camera!, this.renderer.domElement);
		controls.enabled = true;
		controls.target.set(0, 0, 0);
		controls.update();

		// Stats
		this.stats = new Stats();
		this.stats.showPanel(0);
		document.body.appendChild(this.stats.dom);

		await this.load();
		element.appendChild(this.renderer.domElement);
		requestAnimationFrame(this.update);
	}

	/**
	 * load
	 */
	async load() {
		await this.textureAtlas!.load();
		const dataList = this.textureAtlas!.textureDataList;
		for (const key in dataList) {
			const image = new Image(dataList[key]);
			this.images.push(image);
			this.scene!.add(image);
		}
	}

	/**
	 * update
	 */
	update = () => {
		this.stats?.begin();
		this.renderer?.render(this.scene!, this.camera!);
		this.stats?.end();
		requestAnimationFrame(this.update);
	};
}

画面にパズルが表示されました。

おまけ: 遊んでみる。

y軸を基準に回転させてみます。
(Imageクラスの第二引数anchorを変更することで、基準点が変わることも確認してみてください。)

main.ts

	async load() {
		await this.textureAtlas!.load();
		const dataList = this.textureAtlas!.textureDataList;
		for (const key in dataList) {
			const image = new Image(dataList[key]);
			// --- 追加 ---
			(image.mesh.material as THREE.MeshBasicMaterial).side = THREE.DoubleSide;
			// --- 追加 ---
			this.images.push(image);
			this.scene!.add(image);
		}
	}
	
	update = () => {
		this.stats?.begin();
		this.renderer?.render(this.scene!, this.camera!);
		// --- 追加 ---
		this.images.forEach((image, index) => {
			image.rotation.y += (0.001 * index);
		});
		// --- 追加 ---
		this.stats?.end();
		requestAnimationFrame(this.update);
	};

最後に

手順さえ踏めば、three.jsでもいい感じにテクスチャパッキングが利用できて便利です。
よろしければ、試してみてください。

なにかあれば、コメントいただけますと幸いです。

Discussion