絵1枚を、傾けると立体に見えるカードにする:深度マップとWebGLのシェーダ

カードを傾けると絵が立体に動く表現を、人物の切り抜きを重ねる方式から、Depth Anything V2 Small の深度マップとWebGLのシェーダで1枚をずらす方式へ作り替えた記録。コードと失敗つき。

絵1枚を、傾けると立体に見えるカードにする:深度マップとWebGLのシェーダ
目次
  1. 何を作りたかったか
  2. 最初のやり方と、駄目だった所
  3. 直した方法:深度マップでピクセルごとにずらす
  4. 深度マップを作る
  5. シェーダでずらす
  6. 値は3つだけ
  7. 手前に出す人物も同じシェーダで
  8. WebGL は1つだけ作る
  9. 測った結果と、残った弱点
  10. まとめ

傾けると奥行きが動くカードを、ブラウザだけで作った記録です。最初は人物を切り抜いて背景の上に重ねる方式で作りましたが、傾けると切り抜きの縁から二重の絵がちらっと見える問題が出ました。そこで Depth Anything V2 Small で奥行きの画像(深度マップ)を作り、WebGL のシェーダで写真1枚を奥行きに沿ってずらす方式に変えました。この記事では、その下ごしらえとシェーダの中身、決めた数値、まだ弱い所までを書きます。

元の絵、奥行きの画像、人物マスクを並べた図。奥行きは白いほど手前、黒いほど奥

何を作りたかったか

作っていたのは、人物の写真や絵をトレーディングカードの形にして、マウスや指で傾けられるようにするページです。ポケポケのように、傾けると光が走るだけでなく、「絵そのものが立体に動く」ところまで持っていきたいと考えていました。この記事で扱うのはその土台にあたる、奥行きの表現です。

目標は、iPhone の空間シーンのように、傾けたときに手前のものと奥のものが違う速さでずれて見えることでした。

同じ絵のカード4枚を右に傾けたところ。立体と飛び出すの段では、人物と背景のずれ方が違う

最初のやり方と、駄目だった所

最初の版では、人物の切り抜きを使いました。macOS の Vision(人物のセグメンテーション)で人物だけを切り抜いた画像を作り、背景の写真の上に重ねます。傾けたときは、背景と人物を別々の量だけ動かして視差を出します。仕組みは単純で、動くまでは早く作れました。

しかし、傾けて確認すると、切り抜きの縁から背景に写っていた人物の輪郭が顔を出し、人物が二重に見える瞬間がありました。背景の写真にも人物は写っているので、人物を前へずらせばずらすほど、後ろに残った人物がはみ出して見えます。背景側の人物を塗りつぶして消すこともできますが、その場合は消した部分を何かで埋める必要があり、人物の描き直しになります。顔や体を描き直すことはしないと決めていたので、この方向は取りませんでした。

つまり問題は、「人物」と「背景」を別の絵として持っていたことでした。1枚の写真のまま、場所ごとに違う量だけずらせれば、二重は原理的に起きません。

直した方法:深度マップでピクセルごとにずらす

深度マップを作る

奥行きの画像は、Depth Anything V2 Small で作りました。Hugging Face の transformers の pipeline にモデル名を渡すだけで動きます。実際の下ごしらえのスクリプト(scripts/prep_photo.py)では、次のようにしています。

from transformers import pipeline
depth = pipeline("depth-estimation",
                 model="depth-anything/Depth-Anything-V2-Small-hf")

bg = im.resize((W, h), Image.LANCZOS)          # W = 1008
d = depth(bg)["depth"].convert("L").resize((W, h), Image.LANCZOS)
d.save("depth.png")

出てくるのは、明るいほど手前、暗いほど奥を表すグレースケールの画像です。実行は uv run --with torch --with transformers の形で、ローカルで流しました。同じスクリプトが、人物の切り抜きマスク(mask.png)と顔の位置も一緒に作ります。マスクは後で使います。

シェーダでずらす

描画は WebGL のフラグメントシェーダです。要点は次の数行です(カード組み立てスクリプトから、変数名だけ整えて引用します)。

uniform sampler2D uImg, uDepth, uMask;
uniform vec2 uOff;      // 傾き(-1〜1)に STRENGTH を掛けた量
uniform float uFocus;   // 動かない奥行き

void main() {
  vec2 uv = v * uMap.xy + uMap.zw;
  vec2 p = uv;
  for (int i = 0; i < 6; i++) {
    float d = texture2D(uDepth, p).r;
    p = uv - (d - uFocus) * uOff;
  }
  vec4 c = texture2D(uImg, p);
  float al = smoothstep(.42, .6, texture2D(uMask, p).r);
  gl_FragColor = vec4(c.rgb * al, al);
}

考え方は、「その画素を見るために、写真のどこを読むか」を深度で決めることです。傾きの量 uOff に、画素の深度から uFocus を引いた値を掛けて、読む場所をずらします。uFocus と同じ深度の場所は動かず、それより手前は一方向に、奥は反対方向にずれます。手前が大きく動き、奥が逆に動くので、窓をのぞいたような見え方になります。

ループを6回回しているのは、ずらした先の深度がもとの場所の深度と違うことがあるためです。ずらした先の深度で読み直す、を繰り返すと、ずれが落ち着いた位置に近づきます。厳密な解ではなく、見た目を崩さないための簡易な近似です。

値は3つだけ

調整する値は次の3つにしました。

  • STRENGTH:ずらす量。[0.06, 0.045](横と縦で別の値)
  • FOCUS:動かない奥行き。0.45
  • ZOOM:写真の拡大率(記録に残っている調整値の名前。この記事の引用コードには出していません)

横と縦で別の値にして、縦の動きを少し小さくしています。傾きの値は、マウスや指の位置から -1〜1 に正規化して uOff = x * STRENGTH[0], y * STRENGTH[1] として渡します。

手前に出す人物も同じシェーダで

カードの枠より手前に人物が飛び出す表現では、人物マスクで同じシェーダの出力を抜いて、枠の外まで重ねます。重ねる絵は下の絵と同じ式で作った同じ絵なので、位置が合い、二重になりません。ここが、最初の方式との決定的な違いです。

WebGL は1つだけ作る

カードを8枚、9枚と並べるページでは、カードごとに WebGL のキャンバスを作ると、ブラウザの上限(Chrome では1ページ16個)に近づきます。8枚並べた時点でキャンバスは12個になっていました。そこで、WebGL のキャンバスは1つだけ作り、カードごとに2Dキャンバスへ drawImage で写す方式にしました。

ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(glc, 0, 0);   // glc = 共有のWebGLキャンバス

この方式なら、カードの枚数が増えても WebGL のコンテキストは1つです。なお、preserveDrawingBuffer: true を付けないと、コピーする前に中身が消えます。

測った結果と、残った弱点

確認は次のように行いました。

  • 平らな状態・左・右の3つの傾きで、全12枚の画像を書き出し、顔検出を通しました。検出される顔は全部1つずつで、二重に写る画像はありませんでした。
  • ブラウザのエラーは0件、色のコントラスト検査も合格でした。

弱点も残っています。

  1. 見た目の良し悪しは、この時点では目で確認できていません。数値で確認できたのは「二重にならない」ことだけです。
  2. 深度マップはあくまで写真1枚からの推定です。実際の奥行きを測った値ではないので、細部の正確さは保証できません。この点は、傾けた画像を目で見て確かめる必要があります。
  3. 人物がカードの大半を占める構図では、人物が主役になり、背景のずれが小さく見えます。立体感を強く出したい場合は、STRENGTH を上げるか、人物を小さく置く必要があります。

WebGL のキャンバスを別のキャンバスに写して画素を数える検査は、描画後にバッファが消えるため0になり、使えませんでした。検査は顔検出で代用しています。

まとめ

切り抜きを重ねる方式は、1枚の写真を別々の絵として持つため、傾けると二重が見えます。深度マップを作り、シェーダで1枚をずらす方式にすれば、手前に出す部分も同じ式から作れるので、二重が起きません。使った値は STRENGTH、FOCUS、ZOOM の3つだけで、WebGL のコンテキストは1つに共有しています。

この奥行きの上に、ホロや箔の加工を重ねる話は ホロ・箔押し・ラメをブラウザで作る に、4段階のレア度としてまとめた話は レア度を4段階で設計する に書きました。