ビジュアル

Three.jsとGLSLで作る、画像が立体的に動く3Dレリーフエフェクト

投稿日2026/05/14

更新日2026/5/10

写真やイラストをWebサイトにただ貼るだけでは物足りない、もっと印象的に見せたい――そんなときに使えるのが、この3Dレリーフエフェクトです。

Three.jsとGLSLシェーダーを使い、画像の明暗差から擬似的な法線を計算することで、まるで金属板に彫り込んだレリーフのような立体感を生み出します。
マウスを動かすと光源の位置がリアルタイムに変化し、陰影が動的に変わるのが最大の特徴です。自分の画像をアップロードして試すこともでき、OrbitControlsで回転やズームも可能です。

Code コード

<div id="kumonosu-viewport">
	<label for="kumonosu-photoUpload" class="kumonosu-upload-btn">UPLOAD</label>
	<input type="file" id="kumonosu-photoUpload" accept="image/jpeg, image/png, image/jpg">
</div>
#kumonosu-viewport {
	width: 100vw;
	height: 100vh;
	overflow: hidden;
	background: #050510;
	position: relative;
	display: flex;
	align-items: center;
	justify-content: center;
}
.kumonosu-upload-btn {
	position: absolute;
	top: 20px;
	right: 20px;
	background: rgba(0, 0, 0, 0.9);
	backdrop-filter: blur(8px);
	padding: 8px 14px;
	border-radius: 30px;
	color: tomato;
	font-size: 0.7rem;
	font-family: monospace;
	cursor: pointer;
	z-index: 30;
	transition: all 0.2s ease;
	border: 1px solid rgba(255, 99, 71, 0.3);
	pointer-events: auto;
}
.kumonosu-upload-btn:hover {
	background: rgba(40, 20, 10, 0.7);
	border-color: tomato;
}
#kumonosu-photoUpload {
	display: none;
}
// esm.sh を使うことで、依存関係のエラー(Failed to resolve)を自動で解決します
import * as THREE from "https://esm.sh/three@0.128.0";
import {
	OrbitControls
} from "https://esm.sh/three@0.128.0/examples/jsm/controls/OrbitControls.js";
const container = document.getElementById('kumonosu-viewport');
const refHeight = 1.2;
let reliefMaterial = null;
let imagePlane = null;
const mouseLightDir = new THREE.Vector2(0.5, 0.5);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080818);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 3.0);
const renderer = new THREE.WebGLRenderer({
	antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
const vertexShader = `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `;
const fragmentShader = `
        uniform sampler2D uTexture;
        uniform vec2 uLightDir;
        varying vec2 vUv;
        float getLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
        void main() {
            vec2 uv = vUv;
            vec4 tex = texture2D(uTexture, uv);
            float o = 0.007;
            float lumR = getLum(texture2D(uTexture, uv + vec2(o, 0.0)).rgb);
            float lumL = getLum(texture2D(uTexture, uv - vec2(o, 0.0)).rgb);
            float lumU = getLum(texture2D(uTexture, uv + vec2(0.0, o)).rgb);
            float lumD = getLum(texture2D(uTexture, uv - vec2(0.0, o)).rgb);
            vec3 n = normalize(vec3(lumR - lumL, lumU - lumD, 1.0));
            vec3 l = normalize(vec3(uLightDir.x, uLightDir.y, 1.0));
            float d = max(0.2, dot(n, l));
            vec3 col = ((tex.rgb - 0.5) * 1.15 + 0.5) * (d + 0.3);
            if (!gl_FrontFacing) col = mix(col, vec3(1.0), 0.6);
            gl_FragColor = vec4(col * (1.0 - length(uv - 0.5) * 0.25), 1.0);
        }
    `;

function fitCamera() {
	if (!imagePlane) return;
	const {
		width,
		height
	} = imagePlane.geometry.parameters;
	const dist = (window.innerWidth / window.innerHeight >= width / height) ? (height / 2) / Math.tan((camera.fov * Math.PI / 180) / 2) : (width / (window.innerWidth / window.innerHeight) / 2) / Math.tan((camera.fov * Math.PI / 180) / 2);
	camera.position.z = dist * 1.5;
	controls.update();
}

function updatePlane(img) {
	const canvas = document.createElement('canvas');
	canvas.width = img.width;
	canvas.height = img.height;
	canvas.getContext('2d').drawImage(img, 0, 0);
	const tex = new THREE.CanvasTexture(canvas);
	const mat = new THREE.ShaderMaterial({
		uniforms: {
			uTexture: {
				value: tex
			},
			uLightDir: {
				value: mouseLightDir
			}
		},
		vertexShader,
		fragmentShader,
		side: THREE.DoubleSide
	});
	if (imagePlane) {
		scene.remove(imagePlane);
		imagePlane.geometry.dispose();
		imagePlane.material.dispose();
	}
	imagePlane = new THREE.Mesh(new THREE.PlaneGeometry(refHeight * (img.width / img.height), refHeight, 64, 64), mat);
	scene.add(imagePlane);
	reliefMaterial = mat;
	fitCamera();
}
window.addEventListener('mousemove', (e) => {
	mouseLightDir.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1).multiplyScalar(1.2);
});
window.addEventListener('resize', () => {
	camera.aspect = window.innerWidth / window.innerHeight;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
	fitCamera();
});
document.getElementById('kumonosu-photoUpload').addEventListener('change', (e) => {
	const file = e.target.files[0];
	if (!file) return;
	const reader = new FileReader();
	reader.onload = (ev) => {
		const img = new Image();
		img.onload = () => updatePlane(img);
		img.src = ev.target.result;
	};
	reader.readAsDataURL(file);
});
const defImg = new Image();
defImg.crossOrigin = "Anonymous";
defImg.onload = () => updatePlane(defImg);
// file:/// で実行する場合、外部URLの画像がセキュリティで弾かれることがあります
defImg.src = 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&q=80';

function animate() {
	controls.update();
	renderer.render(scene, camera);
	requestAnimationFrame(animate);
}
animate();

Explanation 詳しい説明

仕様

このエフェクトは、平面の画像に対してフラグメントシェーダー内で輝度ベースの法線マッピングを行い、動的なライティングで立体的なレリーフ表現を実現します。

主な機能は以下のとおりです。

  • 擬似法線マッピング: 画像の各ピクセル周辺の輝度差(水平方向・垂直方向)を取得し、そこから法線ベクトルを生成します。実際のハイトマップやノーマルマップは不要で、元画像だけで立体感を表現します。
  • マウス連動ライティング: マウスカーソルの位置を光源方向として使用し、拡散光(ディフューズ)と鏡面反射光(スペキュラー)をリアルタイム計算します。マウスを動かすと影の付き方が変化します。
  • コントラスト・ビネット処理: シェーダー内でコントラスト強調とビネット(周辺減光)を適用し、よりドラマチックな見た目にしています。
  • 画像アップロード: 右上の「UPLOAD」ボタンからJPEG/PNG画像をアップロードすると、即座にレリーフエフェクトが適用されます。
  • OrbitControls: マウスドラッグで回転、ホイールでズームイン・ズームアウトが可能です。パン操作は無効にしています。
  • レスポンシブ対応: 画像のアスペクト比と画面サイズに基づいてカメラ距離を自動計算し、画像が画面内に収まるよう調整します。リサイズ時にも再計算されます。

カスタマイズ

  • 初期画像の変更: スクリプト内のimg.srcのURLを差し替えれば、初期表示される画像を変更できます。
  • レリーフの強さ: フラグメントシェーダー内のoffset(デフォルト0.008)を大きくすると凹凸が強調され、小さくすると滑らかになります。
  • スペキュラーの輝き: pow(..., 32.0) * 0.432.0(シャープさ)と0.4(強度)を調整すると、光沢感を変えられます。
  • 暖色の光: vec3(0.12, 0.06, 0.02)の値を変えると、ライティングの色味を寒色系やニュートラルに変更できます。
  • コントラスト: contrast変数(デフォルト1.15)を変更すれば、画像全体の明暗差を調整できます。
  • ビネットの強さ: length(uv - 0.5) * 0.250.25を大きくすると周辺減光が強くなり、小さくすると均一な明るさに近づきます。
  • 背景色: CSSの#kumonosu-viewportbackgroundやThree.jsのscene.backgroundを変更すれば、背景色を変えられます。
  • 画像の基本サイズ: refHeight(デフォルト1.2)を変更すると、3D空間上での画像の表示サイズが変わります。

注意点

  • Three.jsの読み込み: unpkg.com経由でThree.js v0.128.0とOrbitControlsをimportmapで読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。
  • 画像のCORS: 初期画像はUnsplashからcrossOrigin = "Anonymous"で読み込んでいます。CORS非対応のサーバーの画像を指定するとテクスチャ生成に失敗します。ユーザーがアップロードした画像はローカル読み込みのため、CORSの問題は発生しません。
  • 裏面の表示: side: THREE.DoubleSideを設定しているため、OrbitControlsで裏側に回ると画像の裏面も表示されます。裏面は白っぽくブレンドされるよう処理されています。
  • モバイルでのライティング: マウス連動のため、タッチ操作のみの端末では光源位置が固定されたままになります。タッチ対応が必要な場合はtouchmoveイベントでの光源更新を追加してください。
  • パフォーマンス: シェーダー処理は1枚の平面に対してのみ行われるため軽量ですが、devicePixelRatioが高い端末ではピクセル数に応じた負荷がかかります。
  • アクセシビリティ: WebGLキャンバス上に描画されるため、スクリーンリーダーからは画像の内容を認識できません。代替テキストの提供を検討してください。