光で遊ぶノーマルマップビューワの再設計
今回アップデートした機能たち
ノーマルマップビューアを全面的に作り直し、ライトプリセット、ライトアニメーション、質感ハイライトモード、折りたたみ可能なコントローラーを統合しました。これだ。四つのプリセットはニュートラル・夕暮れ・夜間・蛍光灯で、それぞれ環境光と平行光を事前にセットします。ハイライトモードはノーマルベクトルを色で塗り分け、凹凸の向きを一瞥で読めるようになりました。
ドラッグ&ドロップ、貼り付け、ビューワ下部の「ファイルを選択」に加え、ロード中のステータスを常に表示するように更新。スマホでは左パネルが自動で閉じ、操作したいセクションだけタップで展開できます。意外だ。以前の平面リッチビューアよりも読み込み完了までの手戻りが減りました。
ライトプリセットを成立させるまで
プリセットの調整は地道でした。環境光0.5・平行光1.0・位置(-2, 2, 3)を基準にニュートラルを定義し、夕暮れでは環境光0.35、平行光1.3、色0xffb347で暖色を演出。夜間は環境光0.18、平行光0.7、色0x7fa6ffで青系の陰影を作ります。強度と位置を操作するたびに animationBasePosition を更新し、アニメーション起動時に再計算されるようにしました。
失敗した。最初はスライダーとプリセットの同期を忘れ、ボタンを押しても UI 表示が前の値のまま。updateDisplayedValues() の呼び出しタイミングをプリセット適用の最後に移動して解決しました。プリセットのボタン状態は aria-pressed を併用しているので、スクリーンリーダーでも変更が伝わります。
動く光源で粗を暴く
ライトアニメーションは orbit-horizontal, orbit-vertical, pulse の三種類です。速度スライダーは0.2〜3.0を0.1刻みで指定でき、stepLightAnimation では経過時間に速度を掛けて波形を生成しています。orbit-horizontal はXZ平面に半径を確保して周回、orbit-vertical はY軸方向に振幅を持たせ、pulse は強度を0.7〜1.3倍で往復させています。
マズい。停止時にライトが初期位置へ戻らず、再生ボタンが Disable になったまま動かなくなることがありました。stopLightAnimation に updateAnimationButtonState() を必ず通すよう追記し、resetScene() でもループを解除しています。短時間で表面の粗さや法線の暴れが見えるので、素材チェックが楽になりました。
ハイライトマップで凹凸を読む
質感ハイライトモードは、法線ベクトルをHSLで色変換し、向きと傾斜量を色と明度に割り当てています。ノーマルマップの滑らかさを保つため、カーネル[[1,2,1],[2,4,2],[1,2,1]]のガウシアンブラーで明度を平均化し、端のピクセルは境界チェック付きでサンプリングします。ハイライトテクスチャは THREE.Texture として MeshBasicMaterial で表示し、トーンマッピングを切っています。
function generateNormalMap(imageSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
const { width: w, height: h } = img;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
// 1. 明度を取り出して 3×3 のガウシアンで平滑化
const gray = new Float32Array(w * h);
const src = ctx.getImageData(0, 0, w, h).data;
for (let i = 0; i < w * h; i++) {
gray[i] = (src[i * 4] + src[i * 4 + 1] + src[i * 4 + 2]) / 3 / 255;
}
const kernel = [[1, 2, 1], [2, 4, 2], [1, 2, 1]];
const blurred = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let sum = 0;
let weight = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const sy = Math.min(h - 1, Math.max(0, y + ky));
const sx = Math.min(w - 1, Math.max(0, x + kx));
const wgt = kernel[ky + 1][kx + 1];
sum += gray[sy * w + sx] * wgt;
weight += wgt;
}
}
blurred[y * w + x] = sum / weight;
}
}
// 2. 法線ベクトルとハイライトテクスチャを生成
const normalCanvas = document.createElement('canvas');
normalCanvas.width = w;
normalCanvas.height = h;
const normalCtx = normalCanvas.getContext('2d');
const normalData = normalCtx.createImageData(w, h);
const highlightCanvas = document.createElement('canvas');
highlightCanvas.width = w;
highlightCanvas.height = h;
const highlightCtx = highlightCanvas.getContext('2d');
const highlightData = highlightCtx.createImageData(w, h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const left = blurred[idx - 1 < y * w ? idx : idx - 1];
const right = blurred[idx + 1 >= (y + 1) * w ? idx : idx + 1];
const up = blurred[Math.max(0, idx - w)];
const down = blurred[Math.min(w * h - 1, idx + w)];
let nx = (left - right) * 2;
let ny = (down - up) * 2;
let nz = 1.0;
const length = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= length;
ny /= length;
nz /= length;
// ノーマルマップ
normalData.data[idx * 4] = ((nx + 1) * 0.5) * 255;
normalData.data[idx * 4 + 1] = ((ny + 1) * 0.5) * 255;
normalData.data[idx * 4 + 2] = ((nz + 1) * 0.5) * 255;
normalData.data[idx * 4 + 3] = 255;
// ハイライト用に方向と傾斜を色で表現
const slope = Math.min(1, Math.hypot(nx, ny));
const angle = Math.atan2(ny, nx);
const hue = ((angle / (Math.PI * 2)) + 1) % 1;
const lightness = 0.35 + slope * 0.45;
const saturation = 0.7;
const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const hue2rgb = (pp, qq, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return pp + (qq - pp) * 6 * t;
if (t < 1 / 2) return qq;
if (t < 2 / 3) return pp + (qq - pp) * (2 / 3 - t) * 6;
return pp;
};
const r = hue2rgb(p, q, hue + 1 / 3);
const g = hue2rgb(p, q, hue);
const b = hue2rgb(p, q, hue - 1 / 3);
highlightData.data[idx * 4] = Math.round(r * 255);
highlightData.data[idx * 4 + 1] = Math.round(g * 255);
highlightData.data[idx * 4 + 2] = Math.round(b * 255);
highlightData.data[idx * 4 + 3] = 255;
}
}
normalCtx.putImageData(normalData, 0, 0);
highlightCtx.putImageData(highlightData, 0, 0);
resolve({
normalMap: new THREE.Texture(normalCanvas),
highlightTexture: Object.assign(new THREE.Texture(highlightCanvas), {
colorSpace: THREE.SRGBColorSpace
})
});
};
img.onerror = () => reject(new Error('ノーマルマップの生成に失敗しました。'));
img.src = imageSrc;
});
}
highlightToggle の切り替えで applyMaterialMode() が呼ばれ、現在のメッシュに MeshBasicMaterial を割り当てます。色の明度は傾斜強度、色相は方向を表すため、法線の乱れや段差が瞬時に分かります。
UIとスマホ対応の作り直し
コントローラーは data-collapsible を付与したセクションにまとめ、幅640px以下では先頭以外を自動で折りたたむようにしました。applyResponsiveCollapse が matchMedia('(max-width: 640px)') を監視し、トグルボタンの aria-expanded を更新します。ResizeObserver でビューワサイズを監視し、clientWidth と clientHeight を使って余計な加算が発生しないよう修正。結果として、モバイルでは表示領域が常に確保され、PCでも折りたたみボタンが邪魔になりません。
実写写真での検証メモ
- 24MPの風景写真では昼のニュートラル設定を基準にアニメーション速度1.4で試し、稜線のノイズが0.7のノーマル強度で収束。意外だ。滑らかさを保ったまま山肌が浮かび上がりました。
- ポートレート(F1.8, ISO200)の場合、夕暮れプリセットにスイッチし、ハイライトモードで鼻筋の向きが赤紫から黄緑に変わるのを見ながら強度0.82で調整。肌の立体感を保ちつつ陰影を強調できます。
- モノクロの工業部品写真では夜間プリセット+アニメーションOFFでハイライトモードを常用。角のエッジが黄色く表示されるため、面取りの不足がすぐ分かりました。
触って感じるポイント
- ビューワを開く(旧サイト版UIを含む)から JPEG を投げ込み、ライトプリセットとアニメーションを切り替えてみてください。
- ハイライトモードをオンにし、黄色→青へのグラデーションがどの方向を向いているか確認すると、凹凸の向きが直感的に理解できます。
- デモファイルは
articles/midori284/demo1-*.html の各ページで全画面表示できます。パラメータ調整をそのまま記事内で再現できるので、展示前のチェックにもどうぞ。
同じテーマでも、midori285: ノーマルマップと物理ベースライティングで写真を立体的に表示 の手法と比較しながら追うとライティングの考え方が整理できます。さらに、midori303: 被写体ごとにライトを巡回させる実験 では別の光制御アプローチを試しています。
今回のおさらい
- ライトプリセットとハイライトモードで質感の確認作業を高速化。
- アニメーションモード(水平周回・上下スイング・強度パルス)を追加し、凹凸の粗探しを画面内で完結。
- コントローラーを折りたたみ式にしてスマホ幅でも扱いやすくし、
ResizeObserver で表示サイズを安定化。
- ノーマル生成はガウシアンブラー+HSLマッピングで滑らかさと視覚性を両立。
さらに読みたい資料
最後まで読んでくださり、本当にありがとうございました。 * End Patch