CSSとJSで作る、好きな画像で遊べる3Dジグソーパズル

ビジュアル

CSSとJSで作る、好きな画像で遊べる3Dジグソーパズル

投稿日2026/03/23

更新日2026/3/22

Webサイトでも、ユーザーが「触って遊べる」体験を作ることができます。このサンプルではThree.jsによる3D描画と物理演算を組み合わせ、好きな画像をジグソーパズル化して遊べるインタラクティブなデモを実装しています。ドラッグ操作や吸着配置など、ゲームUIにも応用できるテクニックが詰まった内容です。

Preview プレビュー

Code コード

<div id="preview-container">
	<img src="https://kumonosu.net/wp-content/uploads/2026/03/IMG_0663.jpg" id="preview-image" alt="Puzzle Preview" crossorigin="anonymous" />
</div>
:root,
body {
	height: 100%;
	margin: 0;
	background: #0b0b0b;
	overflow: hidden;
	color: white;
	font-family: system-ui, -apple-system, sans-serif;
}
canvas {
	display: block;
}
#preview-container {
	position: absolute;
	top: 5%;
	right: 20px;
	width: 15%;
	max-width: 180px;
	background: #fff;
	border: 3px solid #fff;
	border-radius: 5px;
	box-shadow: 0 0 15px rgba(0, 0, 0, 0.7);
	z-index: 2;
}
#preview-image {
	width: 100%;
	height: auto;
	display: block;
	object-fit: cover;
}
import * as THREE from "https://esm.sh/three";
import {
	RoomEnvironment
} from "https://esm.sh/three/examples/jsm/environments/RoomEnvironment.js";
import * as CANNON from "https://esm.sh/cannon-es";
import {
	EffectComposer
} from "https://esm.sh/three/examples/jsm/postprocessing/EffectComposer.js";
import {
	RenderPass
} from "https://esm.sh/three/examples/jsm/postprocessing/RenderPass.js";
import {
	OutlinePass
} from "https://esm.sh/three/examples/jsm/postprocessing/OutlinePass.js";
import {
	OutputPass
} from "https://esm.sh/three/examples/jsm/postprocessing/OutputPass.js";
/* ======================= CONFIG ======================= */
const ROWS = 6;
const COLS = 4;
const WORLD_H = 2.4;
const TARGET_RATIO = 10 / 16;
const WORLD_W = WORLD_H * TARGET_RATIO;
const PW = WORLD_W / COLS;
const PH = WORLD_H / ROWS;
const THICK = 0.2;
const JITTER = 0.05;
const NECK_WIDTH_F = 0.3;
const TAB_SIZE_F = 0.25;
const BOARD_Z = -0.05;
const FLOOR_Y = 0.25;
/* ======================= SETUP ======================= */
const renderer = new THREE.WebGLRenderer({
	antialias: true,
	powerPreference: "high-performance"
});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping; // 画像の色を維持
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14161a);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, WORLD_H / 2, 5.5);
camera.lookAt(0, WORLD_H / 2, 0);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const outlinePass = new OutlinePass(new THREE.Vector2(innerWidth, innerHeight), scene, camera);
outlinePass.edgeStrength = 8;
outlinePass.edgeGlow = 1;
outlinePass.edgeThickness = 3;
outlinePass.visibleEdgeColor.set("#00ff00");
composer.addPass(outlinePass);
composer.addPass(new OutputPass());
/* ======================= PHYSICS ======================= */
const world = new CANNON.World({
	gravity: new CANNON.Vec3(0, -9.82, 0),
	allowSleep: true
});
const GROUP_FLOOR = 1 << 0;
const GROUP_PIECE = 1 << 1;
const GROUP_WALL = 1 << 2;
const GROUP_TRAY = 1 << 3;
const floorBody = new CANNON.Body({
	type: CANNON.Body.STATIC,
	shape: new CANNON.Plane(),
	position: new CANNON.Vec3(0, FLOOR_Y, 0)
});
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
floorBody.collisionFilterGroup = GROUP_FLOOR;
floorBody.collisionFilterMask = GROUP_PIECE;
world.addBody(floorBody);
const trayBody = new CANNON.Body({
	type: CANNON.Body.STATIC,
	shape: new CANNON.Box(new CANNON.Vec3(10, 10, 1)),
	position: new CANNON.Vec3(0, 0, BOARD_Z - 1)
});
trayBody.collisionFilterGroup = GROUP_TRAY;
trayBody.collisionFilterMask = GROUP_PIECE;
world.addBody(trayBody);
const walls = {
	left: new CANNON.Body({
		type: CANNON.Body.STATIC,
		shape: new CANNON.Box(new CANNON.Vec3(0.1, 10, 10))
	}),
	right: new CANNON.Body({
		type: CANNON.Body.STATIC,
		shape: new CANNON.Box(new CANNON.Vec3(0.1, 10, 10))
	}),
	top: new CANNON.Body({
		type: CANNON.Body.STATIC,
		shape: new CANNON.Box(new CANNON.Vec3(10, 0.1, 10))
	}),
	front: new CANNON.Body({
		type: CANNON.Body.STATIC,
		shape: new CANNON.Box(new CANNON.Vec3(10, 10, 0.1))
	})
};
Object.values(walls).forEach(w => {
	w.collisionFilterGroup = GROUP_WALL;
	w.collisionFilterMask = GROUP_PIECE;
	world.addBody(w);
});

function getVisibleBounds(zDepth) {
	const vFov = camera.fov * Math.PI / 180;
	const height = 2 * Math.tan(vFov / 2) * (camera.position.z - zDepth);
	const width = height * camera.aspect;
	return {
		width,
		height
	};
}

function updateBounds() {
	const b = getVisibleBounds(1.5);
	walls.left.position.set(-b.width / 2 - 0.1, 0, 0);
	walls.right.position.set(b.width / 2 + 0.1, 0, 0);
	walls.top.position.set(0, b.height + FLOOR_Y, 0);
	walls.front.position.set(0, 0, 3.5);
	pieces.forEach(p => {
		if (!p.isSolved) {
			const margin = 0.3;
			const limitX = b.width / 2 - margin;
			const limitY = b.height + FLOOR_Y - margin;
			if (p.body.position.x > limitX) p.body.position.x = limitX;
			if (p.body.position.x < -limitX) p.body.position.x = -limitX;
			if (p.body.position.y > limitY) p.body.position.y = limitY;
			if (p.body.position.z < BOARD_Z) p.body.position.z = BOARD_Z + 0.1;
		}
	});
}
/* ======================= BOARD ======================= */
const boardGeometry = new THREE.PlaneGeometry(WORLD_W, WORLD_H);
const boardMesh = new THREE.Mesh(boardGeometry, new THREE.MeshStandardMaterial({
	color: 0x0a0a0c,
	roughness: 0.8
}));
boardMesh.position.set(0, WORLD_H / 2, BOARD_Z);
boardMesh.receiveShadow = true;
scene.add(boardMesh);
const boardBox = new THREE.Box3().setFromObject(boardMesh);
const boardPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -BOARD_Z);
scene.add(new THREE.HemisphereLight(0xffffff, 0x2f3440, 0.9));
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
dir.position.set(3, 5, 4);
dir.castShadow = true;
dir.shadow.mapSize.set(2048, 2048);
scene.add(dir);
const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), new THREE.ShadowMaterial({
	opacity: 0.25
}));
floorMesh.rotation.x = -Math.PI / 2;
floorMesh.position.y = FLOOR_Y;
floorMesh.receiveShadow = true;
scene.add(floorMesh);
/* ======================= LOAD TEXTURE ======================= */
const imageUrl = "https://kumonosu.net/wp-content/uploads/2026/03/IMG_0663.jpg";
const textureLoader = new THREE.TextureLoader();
const mainTexture = await textureLoader.loadAsync(imageUrl);
mainTexture.colorSpace = THREE.SRGBColorSpace;
mainTexture.flipY = false;
// 表面用:元の色のまま表示
const faceMaterial = new THREE.MeshBasicMaterial({
	map: mainTexture
});
// 淵(側面)用:画像を表示しつつ、立体感を出すためライトの影響を受ける設定に変更
const sideMaterial = new THREE.MeshStandardMaterial({
	map: mainTexture, // 淵にも画像を適用
	color: 0xa4a4a4,
	roughness: 0.4,
	metalness: 0.1
});
/* ======================= EDGE CURVES ======================= */
const rand = (() => {
	const a = new Uint32Array(1);
	return () => {
		crypto.getRandomValues(a);
		return a[0] / 4294967296;
	};
})();
const alea = (min, max) => min + (max - min) * rand();
const edgeTypes = {
	horizontal: Array.from({
		length: ROWS - 1
	}, () => Array.from({
		length: COLS
	}, () => rand() < 0.5)),
	vertical: Array.from({
		length: ROWS
	}, () => Array.from({
		length: COLS - 1
	}, () => rand() < 0.5))
};

function generateEdgeCurve(isTab) {
	const sign = isTab ? 1 : -1,
		jitter = alea(1 - JITTER, 1 + JITTER);
	return (t, length, perpSign) => {
		const neckWidth = length * NECK_WIDTH_F,
			tabDepth = length * TAB_SIZE_F * sign * jitter * perpSign;
		if (t < 0.5 - neckWidth / length / 2 || t > 0.5 + neckWidth / length / 2) return {
			along: t,
			perp: 0
		};
		const localT = (t - (0.5 - neckWidth / length / 2)) / (neckWidth / length);
		return {
			along: t,
			perp: 4 * Math.abs(tabDepth) * localT * (1 - localT) * Math.sign(tabDepth)
		};
	};
}
const edgeCurves = {
	horizontal: edgeTypes.horizontal.map(row => row.map(isTab => generateEdgeCurve(isTab))),
	vertical: edgeTypes.vertical.map(row => row.map(isTab => generateEdgeCurve(isTab)))
};
/* ======================= PIECE CREATION ======================= */
function createPieceShape(col, row) {
	const shape = new THREE.Shape();
	const segments = 32;

	function addCurvedEdge(x1, y1, x2, y2, curveFunc, perpSign) {
		if (!curveFunc) {
			shape.lineTo(x2, y2);
			return;
		}
		const dx = x2 - x1,
			dy = y2 - y1,
			length = Math.sqrt(dx * dx + dy * dy);
		const dirX = dx / length,
			dirY = dy / length,
			perpX = -dirY * perpSign,
			perpY = dirX * perpSign;
		for (let i = 1; i <= segments; i++) {
			const t = i / segments,
				curve = curveFunc(t, length, 1);
			shape.lineTo(x1 + dirX * curve.along * length + perpX * curve.perp, y1 + dirY * curve.along * length + perpY * curve.perp);
		}
	}
	shape.moveTo(-PW / 2, -PH / 2);
	addCurvedEdge(-PW / 2, -PH / 2, PW / 2, -PH / 2, row === ROWS - 1 ? null : edgeCurves.horizontal[row][col], 1);
	addCurvedEdge(PW / 2, -PH / 2, PW / 2, PH / 2, col === COLS - 1 ? null : edgeCurves.vertical[row][col], 1);
	addCurvedEdge(PW / 2, PH / 2, -PW / 2, PH / 2, row === 0 ? null : edgeCurves.horizontal[row - 1][col], -1);
	addCurvedEdge(-PW / 2, PH / 2, -PW / 2, -PH / 2, col === 0 ? null : edgeCurves.vertical[row][col - 1], -1);
	return shape;
}
const pieces = [];

function buildPiece(col, row) {
	const shape = createPieceShape(col, row);
	const geometry = new THREE.ExtrudeGeometry(shape, {
		depth: THICK * 0.03,
		bevelEnabled: true,
		bevelThickness: THICK * 0.2,
		bevelSize: THICK * 0.2,
		bevelSegments: 10
	});
	const positions = geometry.getAttribute("position");
	const uvs = new Float32Array(positions.count * 2);
	for (let i = 0; i < positions.count; i++) {
		const x = positions.getX(i),
			y = positions.getY(i);
		uvs[i * 2] = (col + (x + PW / 2) / PW) / COLS;
		uvs[i * 2 + 1] = (row + (1.0 - (y + PH / 2) / PH)) / ROWS;
	}
	geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
	geometry.computeVertexNormals();
	// Material Arrayを使用: [表面, 側面]
	const mesh = new THREE.Mesh(geometry, [faceMaterial, sideMaterial]);
	mesh.castShadow = mesh.receiveShadow = true;
	const body = new CANNON.Body({
		mass: 0.15,
		sleepTimeLimit: 0.5
	});
	body.addShape(new CANNON.Box(new CANNON.Vec3(PW * 0.45, PH * 0.45, THICK / 2)));
	body.linearDamping = body.angularDamping = 0.5;
	body.collisionFilterGroup = GROUP_PIECE;
	body.collisionFilterMask = GROUP_FLOOR | GROUP_PIECE | GROUP_WALL | GROUP_TRAY;
	return {
		mesh,
		body
	};
}
for (let row = 0; row < ROWS; row++) {
	for (let col = 0; col < COLS; col++) {
		const {
			mesh,
			body
		} = buildPiece(col, row);
		const targetPos = new THREE.Vector3(-WORLD_W / 2 + PW / 2 + col * PW, WORLD_H - PH / 2 - row * PH, BOARD_Z + THICK / 2);
		body.position.set(alea(-1, 1), FLOOR_Y + 1, 1 + alea(0, 1));
		body.quaternion.setFromEuler(rand(), rand(), rand());
		world.addBody(body);
		scene.add(mesh);
		const piece = {
			mesh,
			body,
			targetPos,
			isSolved: false
		};
		pieces.push(piece);
		mesh.userData.piece = piece;
	}
}
updateBounds();
/* ======================= INTERACTION ======================= */
const raycaster = new THREE.Raycaster();
const freeDragPlane = new THREE.Plane();
const tmpVec = new THREE.Vector3();
let draggedPiece = null,
	dragOffset = new THREE.Vector3();

function onPointerDown(e) {
	const ndc = {
		x: (e.clientX / innerWidth) * 2 - 1,
		y: -(e.clientY / innerHeight) * 2 + 1
	};
	raycaster.setFromCamera(ndc, camera);
	const hits = raycaster.intersectObjects(pieces.filter(p => !p.isSolved).map(p => p.mesh));
	if (hits.length > 0) {
		draggedPiece = hits[0].object.userData.piece;
		draggedPiece.body.type = CANNON.Body.KINEMATIC;
		draggedPiece.body.collisionFilterMask = 0;
		camera.getWorldDirection(tmpVec);
		freeDragPlane.setFromNormalAndCoplanarPoint(tmpVec.negate().normalize(), hits[0].point);
		dragOffset.copy(hits[0].point).sub(draggedPiece.mesh.position);
	}
}

function onPointerMove(e) {
	if (!draggedPiece) return;
	const ndc = {
		x: (e.clientX / innerWidth) * 2 - 1,
		y: -(e.clientY / innerHeight) * 2 + 1
	};
	raycaster.setFromCamera(ndc, camera);
	const pBoard = new THREE.Vector3();
	const hitBoard = raycaster.ray.intersectPlane(boardPlane, pBoard);
	const isOverBoard = hitBoard && boardBox.containsPoint(new THREE.Vector3(pBoard.x, pBoard.y, BOARD_Z));
	const pFree = new THREE.Vector3();
	if (!isOverBoard) raycaster.ray.intersectPlane(freeDragPlane, pFree);
	const intersectionPoint = isOverBoard ? pBoard : pFree;
	if (!intersectionPoint) return;
	let newPos = intersectionPoint.sub(dragOffset);
	if (isOverBoard) {
		newPos.z = BOARD_Z + THICK / 2;
		newPos.x = Math.max(-WORLD_W / 2 + PW * 0.45, Math.min(WORLD_W / 2 - PW * 0.45, newPos.x));
		newPos.y = Math.max(PH * 0.45, Math.min(WORLD_H - PH * 0.45, newPos.y));
	} else {
		newPos.z = Math.max(BOARD_Z + THICK / 2 + 0.05, newPos.z);
	}
	draggedPiece.body.position.copy(newPos);
	draggedPiece.body.quaternion.set(0, 0, 0, 1);
	const near = newPos.distanceTo(draggedPiece.targetPos) < 0.2;
	outlinePass.selectedObjects = (isOverBoard && near) ? [draggedPiece.mesh] : [];
}

function onPointerUp() {
	if (!draggedPiece) return;
	const currentPos = new THREE.Vector3().copy(draggedPiece.body.position);
	const near = currentPos.distanceTo(draggedPiece.targetPos) < 0.2;
	if (near) {
		draggedPiece.body.type = CANNON.Body.STATIC;
		draggedPiece.body.position.copy(draggedPiece.targetPos);
		draggedPiece.body.quaternion.set(0, 0, 0, 1);
		draggedPiece.isSolved = true;
	} else {
		draggedPiece.body.type = CANNON.Body.DYNAMIC;
		draggedPiece.body.wakeUp();
	}
	draggedPiece.body.collisionFilterMask = GROUP_FLOOR | GROUP_PIECE | GROUP_WALL | GROUP_TRAY;
	outlinePass.selectedObjects = [];
	draggedPiece = null;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
const clock = new THREE.Clock();

function tick() {
	world.step(1 / 60, clock.getDelta(), 3);
	pieces.forEach(p => {
		p.mesh.position.copy(p.body.position);
		p.mesh.quaternion.copy(p.body.quaternion);
	});
	composer.render();
	requestAnimationFrame(tick);
}
tick();
window.addEventListener("resize", () => {
	camera.aspect = innerWidth / innerHeight;
	camera.updateProjectionMatrix();
	renderer.setSize(innerWidth, innerHeight);
	composer.setSize(innerWidth, innerHeight);
	updateBounds();
});

Explanation 詳しい説明

概要

このコードはCSSとJavaScriptを組み合わせ、ブラウザ上で動作する3Dジグソーパズルを実装しています。

主な技術構成:

  • Three.js:3Dレンダリング
  • cannon-es:物理演算
  • Raycaster:マウス操作検出
  • Postprocessing:アウトライン表示演出

単なる3D表示ではなく、「掴む・動かす・はめる」という操作体験を重視したインタラクションが特徴です。

仕様

好きな画像をパズル化

  • 任意の画像を読み込み可能
  • 自動分割でピース生成
  • ジグソー特有の凸凹形状を生成
  • 厚みのある3Dモデルとして描画

物理演算

  • 重力による自然な動き
  • ピース同士の衝突判定
  • 床・壁による移動制限
  • スリープ処理で負荷軽減

ドラッグ操作

  • マウスでピースを選択
  • ドラッグ中は物理挙動を制御
  • カーソル位置へ追従

自動吸着(スナップ機能)

  • 正しい位置との距離を判定
  • 一定距離で自動配置
  • 回転を補正して固定

配置ガイド表示

  • 正解位置付近でアウトライン表示
  • 配置可能場所を視覚的に案内

カスタムできるポイント

ピース数(難易度調整)

const ROWS = 6;
const COLS = 4;

使用画像の変更

const imageUrl = "画像URL";

好きな画像に差し替えるだけでパズルを変更できます。

ピースの厚み

const THICK = 0.2;

立体感を調整可能。

吸着の判定距離

distance < 0.2

値を小さくすると難易度が上がります。

ガイドカラー変更

outlinePass.visibleEdgeColor.set("#00ff00");

注意点

WebGL対応ブラウザが必要

古いブラウザでは動作しません。

モバイルでは負荷が高め

以下はパフォーマンスに影響します:

  • ピース数の増加
  • 影描画
  • ポストプロセス処理

CDN依存

外部モジュールを読み込むため、オフライン環境では動作しません。

外部画像使用時のCORS設定

環境によっては crossorigin 設定が必要です。

イラストの提供

イラストの提供はkasumi nakatakeさんです。

とても可愛い猫をありがとうございます。