HTML / CSS / JS
ビジュアル
2026/03/23
2026/3/22
Webサイトでも、ユーザーが「触って遊べる」体験を作ることができます。このサンプルではThree.jsによる3D描画と物理演算を組み合わせ、好きな画像をジグソーパズル化して遊べるインタラクティブなデモを実装しています。ドラッグ操作や吸着配置など、ゲームUIにも応用できるテクニックが詰まった内容です。
<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();
});
このコードはCSSとJavaScriptを組み合わせ、ブラウザ上で動作する3Dジグソーパズルを実装しています。
主な技術構成:
単なる3D表示ではなく、「掴む・動かす・はめる」という操作体験を重視したインタラクションが特徴です。
const ROWS = 6;
const COLS = 4;
const imageUrl = "画像URL";
好きな画像に差し替えるだけでパズルを変更できます。
const THICK = 0.2;
立体感を調整可能。
distance < 0.2
値を小さくすると難易度が上がります。
outlinePass.visibleEdgeColor.set("#00ff00");
古いブラウザでは動作しません。
以下はパフォーマンスに影響します:
外部モジュールを読み込むため、オフライン環境では動作しません。
環境によっては crossorigin 設定が必要です。
イラストの提供はkasumi nakatakeさんです。
とても可愛い猫をありがとうございます。