深度マップから法線マップを生成して写真に凹凸をつけた
EXIF深度マップから法線マップ(ノーマルマップ)を生成して、DisplacementMapと組み合わせて写真に凹凸をつける。平面メッシュが立体的に変形する。
レイヤー分割(midori265, midori266)とは違う手法。1枚の平面メッシュを、深度情報で変形させる。
やりたかったこと
写真に凹凸をつけたかった。
midori265、midori266は、レイヤーに分割する方法だった。でも、レイヤーの境界が見えることがある。
1枚のメッシュを変形させれば、境界がない滑らかな立体表現ができるんじゃないか。
Three.jsのDisplacementMapとNormalMapを使うことにした。
深度マップの読み込み
midori265と同じ。EXIF UserCommentから深度マップを読み込む。
// EXIF解析
const exifData = piexif.load(binaryStr);
const userComment = exifData['Exif'][piexif.ExifIFD.UserComment];
// Base64データを取得
if (!userComment || !userComment.startsWith('DepthMapData:')) {
throw new Error('この画像には深度情報が含まれていません');
}
const depthDataUrl = userComment.substring('DepthMapData:'.length);
// テクスチャとして読み込み
const mainTexture = await loadTexture(fileUrl);
const depthTexture = await loadTexture('data:image/png;base64,' + depthDataUrl);
法線マップの生成
深度マップから法線マップを生成する。
法線マップは、ピクセルごとの法線ベクトルを色で表現したもの。深度の勾配から計算できる。
function generateNormalMap(depthTexture) {
// Canvasで深度テクスチャを取得
const canvas = document.createElement('canvas');
canvas.width = depthTexture.image.width;
canvas.height = depthTexture.image.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(depthTexture.image, 0, 0);
const depthData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const normalData = ctx.createImageData(canvas.width, canvas.height);
const strength = 5.0; // 法線の強度
// ソーベルフィルタで勾配を計算
for (let y = 1; y < canvas.height - 1; y++) {
for (let x = 1; x < canvas.width - 1; x++) {
const idx = (y * canvas.width + x) * 4;
// 周辺の深度値を取得
const tl = depthData.data[((y-1) * canvas.width + (x-1)) * 4];
const t = depthData.data[((y-1) * canvas.width + x) * 4];
const tr = depthData.data[((y-1) * canvas.width + (x+1)) * 4];
const l = depthData.data[(y * canvas.width + (x-1)) * 4];
const r = depthData.data[(y * canvas.width + (x+1)) * 4];
const bl = depthData.data[((y+1) * canvas.width + (x-1)) * 4];
const b = depthData.data[((y+1) * canvas.width + x) * 4];
const br = depthData.data[((y+1) * canvas.width + (x+1)) * 4];
// ソーベルフィルタで勾配を計算
const dx = (tr + 2*r + br) - (tl + 2*l + bl);
const dy = (bl + 2*b + br) - (tl + 2*t + tr);
// 法線ベクトルを計算
const nx = -dx / 255.0 * strength;
const ny = -dy / 255.0 * strength;
const nz = 1.0;
// 正規化
const len = Math.sqrt(nx*nx + ny*ny + nz*nz);
const normalizedX = (nx / len * 0.5 + 0.5) * 255;
const normalizedY = (ny / len * 0.5 + 0.5) * 255;
const normalizedZ = (nz / len * 0.5 + 0.5) * 255;
normalData.data[idx] = normalizedX;
normalData.data[idx + 1] = normalizedY;
normalData.data[idx + 2] = normalizedZ;
normalData.data[idx + 3] = 255;
}
}
ctx.putImageData(normalData, 0, 0);
// テクスチャとして返す
const normalTexture = new THREE.CanvasTexture(canvas);
normalTexture.minFilter = THREE.LinearFilter;
normalTexture.magFilter = THREE.LinearFilter;
return normalTexture;
}
ソーベルフィルタで深度の勾配を計算。法線ベクトルを求めて、RGBに変換。
高解像度メッシュの作成
PlaneGeometryのセグメント数を多くする。セグメントが少ないと、Displacementの変形が粗くなる。
// 高精細な平面ジオメトリ
const segmentsX = 512;
const segmentsY = Math.floor(segmentsX / aspect);
const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight, segmentsX, segmentsY);
512×Xのセグメント。かなり細かい。
最初は64×64で試した。Displacementの変形が階段状になった。滑らかじゃない。
256×256にしたら、だいぶ滑らかになった。でも、細かい凹凸が表現できない。
512×Xにしたら、十分滑らかになった。細かい凹凸もハッキリ見える。
マテリアルの設定
MeshStandardMaterialで、DisplacementMapとNormalMapを設定。
const material = new THREE.MeshStandardMaterial({
map: mainTexture,
displacementMap: depthTexture,
displacementScale: 4.0,
displacementBias: -2.0,
roughness: 0.8,
metalness: 0.2,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1.0, 1.0)
});
// 法線マップを設定
const normalMap = generateNormalMap(depthTexture);
material.normalMap = normalMap;
displacementScaleは凹凸の強さ。4.0で十分な立体感。
最初は1.0で試した。凹凸が弱すぎて、ほとんど分からない。
10.0にしたら、凹凸が強すぎた。写真が歪んで見えた。
4.0がちょうどいい。立体感がハッキリして、写真の形も保たれる。
displacementBiasは-2.0。これで、奥行きが手前から奥に向かって変化する。
ライティング
環境光とディレクショナルライトで、凹凸を強調。
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);
ライトの角度で、法線マップの効果がハッキリ見える。
最初は、正面からライトを当ててた。凹凸が分かりにくかった。
斜めから当てたら、陰影ができて、凹凸がハッキリ見えた。
ハマったところ
ソーベルフィルタの強度
法線マップを生成する時、ソーベルフィルタの強度(strength)が重要。
最初は1.0で試した。法線の変化が小さすぎて、凹凸が見えなかった。
10.0にしたら、法線が強すぎて、ライティングが不自然になった。
5.0がちょうどいい。凹凸がハッキリして、ライティングも自然。
セグメント数とパフォーマンス
512×Xのセグメントは、ポリゴン数が多い。約26万ポリゴン。
PCでは60fpsで動くけど、スマホだと約30fpsになった。
セグメント数を半分(256×X)にしたら、約7万ポリゴン。スマホでも約50fpsで動いた。
でも、凹凸が粗くなった。
結局、PCは512×X、スマホは256×Xで切り替えることにした。
// デバイス判定
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
const segmentsX = isMobile ? 256 : 512;
法線マップのエッジ処理
法線マップを生成する時、エッジのピクセルをどう処理するか。
最初は、エッジを無視してた。エッジの法線が0になって、黒い線が見えた。
エッジは隣のピクセルの値をコピーするようにした。
// エッジ処理
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
if (y === 0 || y === canvas.height - 1 || x === 0 || x === canvas.width - 1) {
// エッジは中央の値をコピー
const idx = (y * canvas.width + x) * 4;
normalData.data[idx] = 128;
normalData.data[idx + 1] = 128;
normalData.data[idx + 2] = 255;
normalData.data[idx + 3] = 255;
}
}
}
黒い線が消えた。
パフォーマンス
| 処理 | 時間 |
|------|------|
| EXIF読み込み | 約0.1秒 |
| 深度テクスチャ読み込み | 約0.2秒 |
| 法線マップ生成 | 約0.3秒 |
| メッシュ作成 | 約0.1秒 |
| 合計 | 約0.7秒 |
初回の読み込みに約0.7秒。その後は60fps(PCの場合)。
スマホだと、法線マップ生成に約0.5秒かかる。合計約0.9秒。描画は約30fps(512×X)、約50fps(256×X)。
midori265(約0.9秒)より高速。midori266(約1.6秒)より大幅に高速。
結果
深度マップから法線マップを生成して、写真に凹凸をつけることができた。
- EXIF深度マップから法線マップを生成(ソーベルフィルタ、強度5.0)
- 512×Xセグメントの高解像度メッシュ(PCの場合)
- DisplacementMap(scale 4.0、bias -2.0)とNormalMapを適用
- 環境光とディレクショナルライトで凹凸を強調
- 初回読み込み約0.7秒、その後60fps
レイヤー分割より滑らか。境界がない。
ライティングで陰影ができて、立体感がハッキリ見える。OrbitControlsで回転させると、凹凸の変化が分かる。面白い表現手法だと思う。
まとめ
今回は、深度マップから法線マップを生成して、写真に凹凸をつける実験を行いました。
ポイントは以下の3つ:
- 深度マップから法線マップを生成(ソーベルフィルタ、強度5.0)
- 高解像度メッシュ(512×Xセグメント)でDisplacementとNormalMapを適用
- ライティングで陰影を作り、凹凸を強調
セグメント数とDisplacementScaleの調整が重要だった。
法線マップを使った写真の立体表現に興味がある方の参考になれば嬉しいです。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。