シャドウマッピング — 光源視点の深度パスと影の品質
第17章でライティングを組み立ててから、第18章で光源を増やし、第19章でモデルを読み込み、第20章で 描画先を差し替えてきました。その間ずっと、ひとつの嘘をつき続けています。物体が互いに影を落とさないという嘘です。第17章の本文にはこう書きました — 「遮蔽を扱うには『光源から見て手前に何かあるか』を 別に調べる必要があり、それがシャドウマッピング(第21章)です」。この章はその回収です。
やることは、言葉にすれば 2 行で済みます。カメラを光源の位置に置いて、もう一度シーンを 描く。ただし色ではなく深度だけを記録して、それをテクスチャとして読み返す。第20章で 「深度は読まないからレンダーバッファでいい」と判断したその前提を、この章はひっくり返します — 「読むならテクスチャ」の、いちばん分かりやすい実例です。原理は単純なのに、実際にきれいな影を 出すまでにはシャドウアクネとピーターパン現象という 2 つの厄介な症状と付き合うことになります。この章の後半は、ほとんどその話です。
この章で学ぶこと:
- 影 = 遮蔽 (occlusion) の判定。「その面に光が届くか」を初めて計算する
- 2 パス構成— ① 光源視点で深度だけを描く ② カメラ視点で各フラグメントを光源空間へ運んで深度を比べる
- 光源のカメラ。方向光なら平行投影、視錐台をシーンにどう合わせるか
- シャドウ座標 — 光源のクリップ空間 → NDC → 0〜1 のテクスチャ座標と深度
- カラーを持たない FBOと深度テクスチャ。
createDepthTargetをsrc/lib/framebuffer.tsに足す sampler2Dで自分で比べる方法と、sampler2DShadow+TEXTURE_COMPARE_MODEでハードウェアに比べさせる方法- シャドウアクネ (shadow acne)がなぜ起きるのか。バイアス、傾きに応じたバイアス、そして入れすぎたときのピーターパン現象 (peter-panning)
- 深度パスで前面をカリングする別解と、
polygonOffset - 光源の視錐台の外に出たフラグメントの扱い
- PCF (percentage-closer filtering)— 影のふちを柔らかくする。
textureOffsetとtextureSize - 解像度とエイリアス。カスケードシャドウマップ (CSM) の名前だけ
1. 影とは「光が届くか」を調べること
第17章で書いた拡散反射の式を思い出します。max(dot(N, L), 0.0)— 面の法線と、面から光源へ向かう向きの内積です。この式は「面が光のほうを向いているか」しか 見ていません。その面と光源のあいだに何かあるかどうかは、一切調べていない。 だから第18章のデモでは、球とトーラスが同じ床の上に並んでいても、互いの影がどこにもありませんでした。
影を落とすとは、この式に「ただし光が届いていれば」という条件を足すことです。そして「光が届くか」は、光源からその点まで、途中に別の面がないかという幾何の問題に還元できます。
ここで、第13章の深度テストを思い出してください。深度バッファがやっているのは 「カメラから見て、各ピクセルにいちばん手前の面はどれか」を記録することでした。 これを光源から見てやれば、そのまま「光源からいちばん手前の面 = 光が当たっている面」 の記録になります。つまり光の当たっている面の一覧表が、深度バッファという形で 手に入る。シャドウマッピングは、この気づき 1 つでできています。
2. 2 パスで解く
1 フレームを 2 回の描画に分けます。
- 深度パス— カメラを光源の位置に置いてシーンを描く。色は計算せず、深度だけを深度テクスチャに残す。これがシャドウマップ (shadow map)
- シェーディングパス— いつもどおりカメラ視点で描く。ただし各フラグメントを 光源のクリップ空間へ変換し、「自分の深度」と「シャドウマップに記録されている深度」を比べる。 記録のほうが手前なら、自分は影の中
viewportで、描画先を切り替えるたびに必ずセットで書き換わります(第20章 5 節)。デモの右下に出しているのが、パス 1 の結果そのものです。床と物体のシルエットが、光源から見た形で並んでいるのが分かります。明るいほど光源に近く、奥へ行くほど暗くなります。何も写らなかったテクセルは 深度 1.0 のまま — 光がどこまでも飛んでいける方向 — ですが、このデモでは 24 × 24 の床が光源の視錐台を覆いきっているので出てきません。上のほうが黒く潰れているのは、表示のコントラストを稼ぐためにpreview.fragがPREVIEW_DEPTH_RANGEで深度の範囲を切っているせいで、そこにも床は写っています。この 1 枚の画像が、パス 2 で影を 決めるすべての情報です。
3. 光源にカメラを置く — 方向光なら平行投影
パス 1 には光源から見たビュー行列と射影行列が要ります。カメラの作り方は 第12章・第15章と同じで、違うのは「どこに置くか」と「どの射影を使うか」だけです。
射影は光源の種類で決まります。方向光 (directional light)の光線は平行なので、 平行投影 (mat4.ortho) を使います。点光源やスポットライトは 1 点から放射状に 広がるので、透視投影 (mat4.perspective) です。第12章では平行投影を、UI・ミニマップ・設計図面のように距離で大きさが変わってほしくない 描画の道具として紹介しました。ここではそれが、物理的に正しい選択になります。
方向光には位置がありません。そこで、狙う点から光の来る方向へ十分離れた場所に カメラを置きます。u_lightDirectionは第17章の約束どおり「面から光源へ向かう」 単位ベクトルなので、そのまま足せば光源側へ進みます。
// 光源のカメラ: 方向光には位置がないので、狙う点から光の来る方へ十分離れた所に置く
vec3.scaleAndAdd(lightEye, LIGHT_TARGET, lightDirection, LIGHT_DISTANCE);
mat4.lookAt(lightView, lightEye, LIGHT_TARGET, UP);
// 方向光の光線は平行なので、光源のカメラも平行投影(3 節)
mat4.ortho(
lightProjection,
-LIGHT_EXTENT,
LIGHT_EXTENT,
-LIGHT_EXTENT,
LIGHT_EXTENT,
LIGHT_NEAR,
LIGHT_FAR,
);
mat4.multiply(lightViewProjection, lightProjection, lightView);LIGHT_EXTENTが視錐台の左右上下それぞれの広がり(中心からの距離)で、この章のデモでは 6 — つまりワールド空間で 12 × 12 の範囲だけがシャドウマップに写ります。ここが シャドウマッピングでいちばん効く調整箇所です。
- 狭くする— 同じ解像度でも 1 テクセルが小さくなる = 影が細かくなる。ただし 範囲の外に出た影は消える
- 広くする— シーン全体を覆えるが、1 テクセルが大きくなり、影のふちが階段になる
デモの読み出し行に「1 テクセル ≒ ○○ ワールド単位」と出しているのがこの値です。1024 のときは 12 / 1024 ≒ 0.012、256 のときは ≒ 0.047 で、4 倍粗くなります。nearと far のほうは、横の細かさではなく深度の細かさに効きます。 平行投影の深度は光源からの距離にそのまま比例するので、分解能は near 〜 far の全体で一様です。効くのはfar − nearの幅だけで、狭いほど同じビット数を細かく使えます。第13章で扱った「nearを小さくすると精度が悪化する」は、透視除算で深度がカメラの近くに偏る透視投影の話なので、平行投影のここには当てはまりません(点光源やスポットライトのシャドウでは透視投影に なるので、あの話がそのまま戻ってきます)。
深度パスの頂点シェーダーは、この行列を掛けるだけです。
void main() {
gl_Position = u_lightViewProjection * u_model * vec4(a_position, 1.0);
}そしてフラグメントシェーダーは空です。out変数を 1 つも持たないので色はどこにも書かれず、深度だけがラスタライザから深度バッファへ 入ります。それでも空のシェーダーを用意するのは、WebGL のプログラムが頂点シェーダーと フラグメントシェーダーの両方を持っていないとリンクできないためです。
4. ワールド座標から「シャドウ座標」へ
パス 2 では、いま塗ろうとしている点がシャドウマップのどこに写っているかを 知る必要があります。やることは第12章の座標変換そのままで、行き先がカメラではなく光源になる だけです。
- ワールド座標に
u_lightViewProjectionを掛ける → 光源のクリップ空間 wで割る → 光源から見た NDC(−1〜+1)* 0.5 + 0.5→ 0〜1。xyがテクスチャ座標、zが比べる深度
最後の変換が要るのは、NDC が −1〜+1 なのに対して、テクスチャ座標もgl_FragCoord.zに対応する深度も 0〜1 だからです。第12章で「NDC からスクリーン座標へ」の変換を扱ったときと 同じ、範囲の付け替えにすぎません。
頂点シェーダーでは、透視除算の前の 4 成分のまま渡します。
// 透視除算の前の 4 成分のまま渡す。除算はフラグメントシェーダー側で行う
v_lightSpacePosition = u_lightViewProjection * worldPosition;フラグメントシェーダーで割り、0〜1 へ写します。
// 光源のクリップ空間 → 透視除算 → NDC(-1〜+1)。
// 平行投影なので w は 1 だが、点光源・スポットに差し替えても動くよう割っておく(4 節)
vec3 ndc = v_lightSpacePosition.xyz / v_lightSpacePosition.w;
// NDC → 0〜1。xy はシャドウマップのテクスチャ座標、z が比べる相手の深度になる
vec3 shadowCoord = ndc * 0.5 + 0.5;平行投影では w は常に 1 なので、この除算は何もしません。それでも書いているのは、 光源を点光源やスポットライトに差し替えたときに、この行だけで対応できるからです (透視投影ではwがきちんと働きます)。
上下の反転は要りません。パス 1 が深度を書き込むときのフラグメントの座標 (gl_FragCoord.xy)も、パス 2 でシャドウマップを読むときのテクスチャ座標も、 どちらも左下が原点だからです。第16章 2 節で悩まされたUNPACK_FLIP_Y_WEBGLは、上の行から並んでいる画像ファイルを CPU から転送するときだけの話で、GPU が自分で描いたテクスチャには関係ありません。
5. 深度だけを描く FBO
第20章の createRenderTarget は、この用途にそのままでは使えません。カラーテクスチャが必須で、深度はレンダーバッファだったからです。レンダー バッファはシェーダーから読めないので、深度を読み返したいシャドウマップには使えません。 第20章の表で「あとで読むならテクスチャ」と書いた、その判断をここで適用します。
そこで src/lib/framebuffer.ts に、カラーを持たず、深度テクスチャだけを持つ FBOを足します。既存の RenderTarget には手を触れず、別の型として並べました。
export interface DepthTarget {
/** 描画先を差し替えるための FBO 本体。カラーアタッチメントは持たない */
framebuffer: WebGLFramebuffer;
/** DEPTH_ATTACHMENT に付いている深度テクスチャ。描き終えたらこれをサンプリングする */
texture: WebGLTexture;
/** 深度テクスチャの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
width: number;
/** 深度テクスチャの高さ(ピクセル) */
height: number;
/** 既定値を埋めた生成時のオプション */
options: Required<DepthTargetOptions>;
}深度テクスチャの作り方は、第16章・第20章のテクスチャと同じです。texStorage2Dに渡す内部フォーマットが DEPTH_COMPONENT24 になるだけで、あとは変わりません。
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0); // 作業台を 0 番に固定する(createRenderTarget と同じ)
gl.bindTexture(gl.TEXTURE_2D, texture);
// カラーと同じく texStorage2D で不変ストレージを確保する(第16・20章)
gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);フィルタの指定には、深度テクスチャ固有の制約があります。OpenGL ES 3.0 の texture completeness には、「深度形式で、かつ TEXTURE_COMPARE_MODE が NONE のとき、 拡大フィルタが NEAREST でないか、縮小フィルタが NEAREST でもNEAREST_MIPMAP_NEARESTでもなければ不完全」という専用の条項があります。TEXTURE_MIN_FILTERの既定値は NEAREST_MIPMAP_LINEAR(第16章 4 節)なので、何もしなければこの条件に引っかかります。
// 比較モードなし: MAG は NEAREST、MIN は NEAREST か NEAREST_MIPMAP_NEAREST でないと
// テクスチャが不完全になり、sampler2D で読んだ結果が (0, 0, 0, 1) になる
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);そして FBO には、深度テクスチャだけを取り付けます。framebufferTexture2Dの第 2 引数がCOLOR_ATTACHMENT0ではなくDEPTH_ATTACHMENTになっているところが、第20章との唯一の違いです。
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);これで checkFramebufferStatus は FRAMEBUFFER_COMPLETE を返します。 OpenGL ES 3.0 の whole framebuffer completeness の条件は「画像が 1 枚以上付いていること」であって、「カラーが付いていること」ではないからです。第20章の表でFRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENTを「何も付いていない」と説明したのは、まさにこの条項のことでした。
使う側は、第20章の bindRenderTarget と同じ形で書けます。
function renderShadowMap(time: number, lightMatrix: ReadonlyMat4): void {
// bindDepthTarget が viewport をシャドウマップのサイズへ切り替える。
// カラーアタッチメントが無いので、クリアするのは深度だけ
bindDepthTarget(gl, shadowMap);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(shadowProgram);
gl.uniformMatrix4fv(shadowLocations.lightViewProjection, false, lightMatrix); // transpose は常に false
forEachObject(time, (mesh) => {
gl.uniformMatrix4fv(shadowLocations.model, false, model);
drawMesh(mesh);
});
}bindDepthTargetが viewport をシャドウマップのサイズへ切り替えるところが要点です。第20章 5 節の落とし穴 — viewport は描画先を変えても自動では変わらない — がそのまま効くので、バインドと viewport をセットにする方針も引き継ぎます。クリアするのは深度だけで、カラーは存在しません。
もう 1 つ、地味ですが重要なことがあります。2 つのパスは、まったく同じ形・同じ位置のシーンを描かなければなりません。片方だけ物体を動かせば、影だけが取り残されます。この章のデモではモデル行列を組み立てる 場所をforEachObject1 か所にまとめ、両方のパスがそれを呼ぶ形にしています。
6. 深度を比べる — 自分で比べるか、ハードウェアに任せるか
パス 2 でシャドウマップをテクスチャユニットに載せる手順は、第16章のままです。深度テクスチャも、 読むときはただの画像です。
// パス 1 で描いた深度テクスチャを、テクスチャユニット 0 に載せて読む(第16章)
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, shadowMap.texture);
gl.uniform1i(sceneLocations.shadowMap, 0);比較そのものは 3 行です。
if (u_pcf == 0) {
// 記録されている「光源から見ていちばん手前の面の深度」
float closest = texture(u_shadowMap, shadowCoord.xy).r;
// 手前に別の面がある = 自分は影の中
return current > closest ? 0.0 : 1.0;
}closestが「光源から見ていちばん手前にある面の深度」、currentが「いま塗ろうとしている点の、光源から見た深度」です。currentのほうが大きい = 自分より手前に別の面がある = 自分は影の中、という判定になります。sampler2Dで深度テクスチャを読むと、深度はvec4(depth, 0.0, 0.0, 1.0)の形で返るので、.rを取ります。
もう 1 つの道 — sampler2DShadow
この比較は、GPU のテクスチャユニットに肩代わりさせることもできます。テクスチャ側でTEXTURE_COMPARE_MODEをCOMPARE_REF_TO_TEXTUREにすると、テクスチャユニットの出力が「深度そのもの」から「比較の結果」に 変わります。
compare: trueのとき)gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);GLSL 側は sampler2D ではなく sampler2DShadow で 受け取ります。GLSL ES 3.00 の texture() にはシャドウサンプラ用のオーバーロードが あり、vec3を渡すと第 3 成分が比較の参照値として使われ、 戻り値はvec4ではなく float です。
uniform highp sampler2DShadow u_shadowMap;
// texture() が「shadowCoord.z <= 記録された深度」の判定結果を float で返す。
// 比べる相手は第 3 成分。1.0 = 光が届いている / 0.0 = 影
float visibility = texture(u_shadowMap, vec3(shadowCoord.xy, shadowCoord.z - bias));TEXTURE_COMPARE_FUNCの既定値は LEQUAL で、「参照値 ≤ 記録された深度」 なら 1.0、そうでなければ 0.0 が返ります(この対応表は ES 3.0 仕様に載っています)。この方式には 利点があります。5 節で見たとおり、比較モードのときは深度テクスチャでもLINEARフィルタが許される。そして仕様には、拡大・縮小フィルタがNEAREST系でないとき「複数の深度値を参照値と 比較して結果を求めてよく、その値は比較に通った回数に比例した 0〜1 の値であるべき」と書かれて います。つまり2×2 の PCF がただで付いてくることがある、という話です。
この章のデモは sampler2D で自分で比べる方を採りました。理由は 3 つです。第一に、このサイトは仕組みを自分の手で書くことを目的にしていて、比較を隠さないほうが 後の節(バイアス・PCF)の説明とつながります。第二に、右下のプレビューでシャドウマップの中身を そのまま表示しており、比較モードを有効にしたまま sampler2D で読むと上の 「未定義」に踏み込んでしまいます。第三に、7 節のバイアスも 10 節の PCF も、比較の式が 見えていたほうが実験しやすいからです。実務ではsampler2DShadowのほうが標準的な選択で、three.js もそちらを使っています(後述の対応表)。
7. シャドウアクネ — なぜ縞模様が出るのか
デモの「バイアス: なし」を押してください。床にも柱にも、光の当たっている面に細かい縞模様が走ります。これがシャドウアクネ (shadow acne)です。物体は自分自身に影を 落としてしまっている — 「自分より手前に自分がいる」と判定されているわけです。
原因は、シャドウマップが連続した面を、テクセルという有限のマス目で記録していることにあります。1 テクセルに記録できる深度は 1 つだけなので、そのテクセルが覆う範囲の中の、 ある 1 点の深度が代表値になります。ところが受け手の面は連続していて、テクセルの中でも深度は 変わり続けます。面が光に対して寝ているほど、テクセル 1 枚が抱える深度の幅は広くなります。
対処は単純で、比べる前に自分の深度から少し引いておくだけです。この下駄をバイアス (bias)と呼びます。図の破線がそれで、どのテクセルの階段よりも手前に来れば、自己遮蔽は起きません。
ただし「少し」がいくらなのかは、面の傾きによって変わります。図から分かるとおり、必要な バイアスは階段の段差 = テクセル 1 枚あたりの深度の変化量に比例します。 面が光に正対していれば段差はほぼ 0、寝ていれば大きい。そこで、dot(N, L)を使って傾きに応じたバイアスにします。
float shadowBias(float NdotL) {
if (u_biasMode == 1) {
return u_shadowBias.x;
}
if (u_biasMode == 2) {
// 面が光に対して寝ているほど、テクセル 1 枚が抱える深度の幅が広がる(7 節)。
// 真正面(NdotL = 1)では最小値まで下がるので、接地部分が浮きにくい
return max(u_shadowBias.y * (1.0 - NdotL), u_shadowBias.z);
}
return 0.0;
}1.0 - NdotLは正対で 0、寝るほど 1 に近づく量です。これに係数kを掛け、下限として最小バイアスを噛ませます。下限が要るのは、光にほぼ正対している面でも ずれが残るからです — 記録されているのはテクセル中心の深度で、いま塗って いる点はその中心からずれた場所にいますし、2 つのパスは別々の行列演算で同じ点の深度を出しているので、丸めの結果が一致する保証もありません。値は経験的に決めるしかありませんが、目安はテクセル 1 枚ぶんの深度差です。デモのBIAS_MIN = 0.0006はちょうどその大きさで、1024 のとき 1 テクセルは 12 / 1024 ≒ 0.0117 ワールド単位、これを 深度の換算率(奥行き 19 ワールド単位が 0〜1)で割ると ≒ 0.00062 になります。
8. ピーターパン現象と、面のカリングという別解
では、バイアスを大きく入れておけば安全かというと、そうはいきません。デモの「バイアス: 固定」を 押して、柱の足元を見てください。影が柱から少し離れて、柱が浮いて見えます。 影が本体から切り離される様子が『ピーター・パン』の逸話に似ていることから、これをピーターパン現象 (peter-panning)と呼びます。
理屈は簡単で、バイアスとは「自分は本当の位置より少し手前にいることにする」という嘘だからです。 その嘘のぶんだけ、影は光の進む方向へずれます。アクネを消すのに十分なバイアスと、接地を保てるバイアスの両立が、シャドウマッピングの永遠の課題です。傾きに応じたバイアスが効くのは、まさにこの両立を 狙っているからです — 正対した面(接地部分の周辺)ではバイアスがほぼ最小値まで下がるので、 影が離れにくくなります。デモで「固定」と「傾き対応」を往復すると、柱の足元の差が見えます。
深度パスで前面をカリングする
バイアスに頼らない別解もあります。深度パスだけ、表を向いた面を捨てるという 手です。
// 深度パスだけ前面をカリングする。閉じた物体でしか使えない
gl.cullFace(gl.FRONT);
renderShadowMap(time, lightViewProjection);
gl.cullFace(gl.BACK);こうするとシャドウマップに記録されるのは、物体の裏側の面の深度になります。 受け手になる表側の面は、記録された深度より必ず手前にいるので、自己遮蔽そのものが起きません。 第13章のカリングとワインディング順が、思わぬところで効いてくる例です。
ただし制約があります。閉じた (watertight) 物体にしか使えません。この章のデモの 床は 1 枚の板なので、前面をカリングするとシャドウマップから消えてしまいます(床が影を落とさなくなるのは、この場合はむしろ都合が良いのですが)。葉っぱ 1 枚のような薄い ジオメトリでも同じことが起きます。また、裏面が表面から遠い物体では、影が物体の内側にめり込んで 見えることがあります。
polygonOffset という道具
バイアスをシェーダーではなくラスタライザ側で入れることもできます。gl.polygonOffsetは WebGL 1.0 から WebGL2 に引き継がれたコア機能で、POLYGON_OFFSET_FILLを有効にすると、ポリゴンを塗るときの深度に下駄を履かせます。
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(2.0, 4.0); // o = m × factor + r × units
renderShadowMap(time, lightViewProjection);
gl.disable(gl.POLYGON_OFFSET_FILL);加算される値は仕様で o = m × factor + r × units と定められています。mはそのポリゴンの最大深度勾配、rは深度バッファの分解能に関わる実装依存の定数です。factorのほうが 7 節の「傾きに応じたバイアス」に対応していることが、式から読み取れます。しかも傾きを ラスタライザが正確に知っているぶん、シェーダーでdot(N, L)から推定するより素直です。 この章がシェーダー側でやっているのは、バイアスの効き方を切り替えて見せるためです。
9. シャドウマップの外側
光源の視錐台は有限です。3 節で決めた 12 × 12 の範囲の外に出たフラグメントは、シャドウマップの どこにも写っていません。この扱いを決めておかないと、床の遠くが一面まっ黒になったり、端のテクセルの模様が地平線まで引き伸ばされたりします。
// 光源の視錐台の外は「影なし」と決め打つ(9 節)。far の外では z が 1 を超える
if (shadowCoord.z > 1.0) {
return 1.0;
}
// 左右上下の外は CLAMP_TO_EDGE で端のテクセルが際限なく引き伸ばされるので、
// ラップまかせにせず自分で弾く
if (any(lessThan(shadowCoord.xy, vec2(0.0))) || any(greaterThan(shadowCoord.xy, vec2(1.0)))) {
return 1.0;
}farより遠い点はshadowCoord.zが 1 を超えるので、これで弾けます。左右上下は、ラップモードでは解決できません。CLAMP_TO_EDGEにしておけば少なくとも反対側の端が折り返してくることはありませんが、端のテクセルの値が 無限に続くことに変わりはないので、範囲チェックを自分で書きます。
ここで「範囲外 = 影なし」と決め打っているのは、この章のシーンでは視錐台の外に影を落とすものが 無いからです。屋内シーンなど「範囲外 = 光が届かない」ほうが自然な場合は、逆に 0.0 を返すのが 正解になります。どちらが正しいかはシーン次第で、仕様が決めてくれる話では ありません。
10. PCF — 影のふちを柔らかくする
デモの「PCF: なし」にすると、影のふちがテクセルの階段になります。当然で、 比較の結果は 0.0 か 1.0 のどちらかしかなく、その境目はシャドウマップのテクセル境界だからです。 影のふちの解像度は、画面の解像度ではなくシャドウマップの解像度で決まります。
PCF (percentage-closer filtering)は、この階段をならす最も基本的な方法です。 考え方は「1 点で比べるのではなく、周囲の何点かで比べて、影だった割合を影の濃さにする」。3×3 なら 9 回比べて、影だった数を 9 で割ります。
// PCF: 隣り合う 9 テクセルで同じ比較をして、0/1 の平均を影の濃さにする(10 節)。
// textureSize でシャドウマップの解像度を問い合わせ、テクセル 1 枚分の幅を出す
vec2 texel = 1.0 / vec2(textureSize(u_shadowMap, 0));
float sum = 0.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float closest = texture(u_shadowMap, shadowCoord.xy + vec2(x, y) * texel).r;
sum += current > closest ? 0.0 : 1.0;
}
}
return sum / 9.0;textureSize(u_shadowMap, 0)がシャドウマップの解像度(ivec2)を返すので、その逆数がテクセル 1 枚分の幅に なります。解像度をボタンで切り替えても、この式のおかげでシェーダー側は何も直さずに済みます。「テクスチャの大きさをシェーダーが知っている」というのは、WebGL1 (GLSL ES 1.00) には なかった機能です(第16章でtexelFetchと一緒に出てきたtextureSizeが、ここで本領を発揮します)。
PCF はぼかしではありません。深度を平均してから比べるのではなく、比べてから 0/1 を平均するのが要点です。深度の平均には意味がない(手前の面と奥の面の中間の深度は、どこにも存在しない面 です)のに対し、判定結果の平均は「この画素のうち何割が影か」という、そのまま使える量になります。
細かい話をひとつ。9 節の範囲チェックが見ているのは中心の 1 点だけなので、 シャドウマップのちょうど端では、周囲 8 点のうち外へはみ出したものがCLAMP_TO_EDGEで端のテクセルを読みます。はみ出す量はテクセル 1 枚ぶんで、影響も端の 1 列だけなので、この章 では放っています。厳密にやるならサンプル点ごとに範囲を見るか、視錐台をテクセル数枚ぶん余分に 取ることになります。
9 回のサンプリングはタダではありません。影を受けるフラグメントすべてでテクスチャを 9 回読むので、 フラグメント数が多いほど効いてきます。実装では 5 点や 4 点に減らしたり、6 節のハードウェア比較 (1 回で 2×2 ぶん)と組み合わせたり、サンプル位置をピクセルごとに回転させて少ないサンプル数で ノイズに散らしたりします。three.js の現行実装がまさにこの組み合わせです(後述の対応表)。
求めた影を、色にどう効かせるか
最後に、shadowFactorが返した 0〜1 を色に反映します。掛ける相手を間違えないことが大事です。
// 影は「光が届くかどうか」なので、直接光(拡散 + 鏡面)だけに掛ける。
// 環境光にまで掛けると影の中が真っ黒になり、回り込んだ光の代役という役目を失う(第17章)
float shadow = shadowFactor(NdotL);
vec3 direct = (u_baseColor * NdotL + vec3(0.3) * specular) * shadow;
vec3 ambient = u_baseColor * 0.18;
fragColor = vec4(ambient + direct, 1.0);影が掛かるのは直接光(拡散 + 鏡面)だけです。環境光の項にまで掛けてしまうと、 影の中が真っ黒になります。第17章で環境光を、他の物に反射して回り込んできた光の代役であり 方向を持たない一律の底上げだと説明しました。その回り込んだ光は遮蔽されていても届くぶんの光です。 影を入れて初めて、環境光がなぜ必要なのかがはっきりします(現実の影が真っ黒でないのも同じ 理由です。より正しい扱いは第22章の IBL と、第28章のアンビエントオクルージョンへ続きます)。
11. 解像度とエイリアス、そしてカスケード
デモの「シャドウマップ: 256」を押すと、影のふちが目に見えて粗くなります。PCF を掛けても、 ならせるのはテクセル 1 枚ぶんの幅なので、テクセルが大きければぼけ幅も大きくなるだけです。影の品質を決める最大の要因は、結局のところ「1 テクセルがワールド空間で何メートルか」です。この値は 2 つで決まります。
- シャドウマップの解像度(テクセル数)
- 光源の視錐台が覆う範囲(3 節の
LIGHT_EXTENT)
解像度を上げるのは簡単ですが、メモリと帯域は解像度の 2 乗で増えます。2048 × 2048 のDEPTH_COMPONENT24なら 1 テクセル 24 ビットとして 1 灯あたり約 12 MB(実装が 32 ビットに詰めれば約 16 MB)で、光源が増えればそのぶん掛かります。しかも深度パスは シーン全体をもう一度描くので、ドローコールも頂点処理も光源の数だけ増えます。 描画コストの見積もりとチューニングの一般論は第35章で扱います。
では視錐台を狭くすればいいかというと、広いシーン — 屋外で遠くまで見渡すような場面 — では そもそも不可能です。ここで使われるのがカスケードシャドウマップ (cascaded shadow maps, CSM)で、カメラの視錐台を距離で数段に分け、手前ほど狭い範囲を高い密度で覆うシャドウマップを 段ごとに用意する手法です。「近くは細かく、遠くは粗く」を実現します。このサイトでは扱いません。段の切り替え位置での不連続、段ごとの視錐台のちらつき対策、シェーダー側での段の選択と、 この章 1 つぶんの分量になるためです。名前と考え方だけ覚えておいてください。
ほかにも、この章の先には広い世界があります。名前だけ挙げておきます。
- 点光源の全方位シャドウ— 6 方向にシャドウマップを描いてキューブマップに まとめます。1 面ずつ見れば、やっていることはこの章とまったく同じです
- ソフトシャドウの高度な手法— VSM (variance shadow maps) / ESM (exponential shadow maps) / PCSS (percentage-closer soft shadows)。PCF が「同じ幅でぼかす」のに対して、 PCSS は遮蔽物までの距離からぼけ幅そのものを変えることで、接地部分は鋭く、 離れるほど柔らかい、本物らしい影を作ります
- レイマーチングのソフトシャドウ— シャドウマップをまったく使わない方法も あります。距離関数でシーンを表しているなら、光源へ向かってレイを進めるだけで影が求まります (第28章)
コード全文
共通ヘルパーの追加部分と、デモの 6 ファイルです。scene.vert/scene.fragのライティングは第17章の Blinn-Phong のままで、影の判定を足しただけです(物理ベースの反射は 第22章)。
// 共通モジュール: レンダーターゲット(FBO)の生成と切り替え(第20章で導入)
//
// 「フレームバッファオブジェクトを作る → カラーテクスチャを作ってアタッチする →
// 深度レンダーバッファを作ってアタッチする → completeness を確かめる」という、
// オフスクリーン描画のたびにまったく同じ 20 行あまりを 1 か所にまとめたもの。
// 中身の API は第20章の本文で 1 つずつ生のまま書いてから、ここへ移している。
//
// 使う側の約束:
// - 描画先の切り替えは bindRenderTarget() を通す(viewport の切り替え忘れを防ぐため)
// - サイズが変わったら resizeRenderTarget() で作り直す(毎フレームではなく、変わったときだけ)
// - 要らなくなったら deleteRenderTarget() で GPU 上のリソースを解放する
//
// 第21章で DepthTarget(カラーを持たず、深度だけをテクスチャに描く FBO)を追加した。
// シャドウマップのように「深度を読み返したい」場合は RenderTarget ではなくこちらを使う。
export interface RenderTargetOptions {
/** 深度レンダーバッファをアタッチするか(既定 true)。false にすると FBO 内で深度テストが効かない */
depth?: boolean;
/**
* カラーテクスチャの拡大・縮小フィルタ(既定 gl.LINEAR)。gl.NEAREST でドット感を残せる。
* ミップマップは作らないので、指定できるのは gl.NEAREST か gl.LINEAR だけ
* (MAG_FILTER にミップ系の値を渡すと INVALID_ENUM になる)
*/
filter?: GLenum;
/**
* カラーテクスチャのサイズ付き内部フォーマット(既定 gl.RGBA8)。
* gl.RGBA16F / gl.RGBA32F へ「描く」には拡張 EXT_color_buffer_float が要る(第20章 8 節)
*/
internalFormat?: GLenum;
}
export interface RenderTarget {
/** 描画先を差し替えるための FBO 本体 */
framebuffer: WebGLFramebuffer;
/** COLOR_ATTACHMENT0 に付いているテクスチャ。描き終えたらこれをサンプリングする */
texture: WebGLTexture;
/** DEPTH_ATTACHMENT のレンダーバッファ。options.depth が false のときは null */
depth: WebGLRenderbuffer | null;
/** アタッチメントの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
width: number;
/** アタッチメントの高さ(ピクセル) */
height: number;
/** 既定値を埋めた生成時のオプション。resizeRenderTarget が同じ設定で作り直すために持つ */
options: Required<RenderTargetOptions>;
}
/**
* checkFramebufferStatus の戻り値を、そのまま読める名前に変換する。
* 数値のまま投げられても原因が分からないので、エラーメッセージと読み出し行で使う
*/
export function framebufferStatusName(gl: WebGL2RenderingContext, status: GLenum): string {
switch (status) {
case gl.FRAMEBUFFER_COMPLETE:
return 'FRAMEBUFFER_COMPLETE';
case gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
return 'FRAMEBUFFER_INCOMPLETE_ATTACHMENT';
case gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
return 'FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT';
case gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS:
return 'FRAMEBUFFER_INCOMPLETE_DIMENSIONS';
case gl.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE:
return 'FRAMEBUFFER_INCOMPLETE_MULTISAMPLE';
case gl.FRAMEBUFFER_UNSUPPORTED:
return 'FRAMEBUFFER_UNSUPPORTED';
default:
return `不明なステータス(0x${status.toString(16)})`;
}
}
/**
* オフスクリーン描画用の FBO を 1 つ作る。
* カラーは常にテクスチャ(後で読むため)、深度はレンダーバッファ(読まないため)。
* 完成しなかった場合は原因の分かるエラーを投げ、作りかけのリソースは解放する
*/
export function createRenderTarget(
gl: WebGL2RenderingContext,
width: number,
height: number,
options: RenderTargetOptions = {},
): RenderTarget {
if (width < 1 || height < 1) {
// 幅か高さが 0 のアタッチメントは completeness に落ちる(仕様上「非ゼロ」が条件)。
// その手前で、サイズ計算の誤りだと分かるエラーにしておく
throw new Error(`レンダーターゲットのサイズは 1 以上にしてください(${width}×${height})`);
}
const resolved: Required<RenderTargetOptions> = {
depth: options.depth ?? true,
filter: options.filter ?? gl.LINEAR,
internalFormat: options.internalFormat ?? gl.RGBA8,
};
// --- カラーアタッチメント: テクスチャ -------------------------------------
const texture = gl.createTexture();
// bindTexture は「いま選ばれているテクスチャユニット」に効く(第16章)。
// 呼び出し元がどのユニットを選んでいても結果が変わらないよう、作業台を 0 番に固定する
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// texStorage2D はサイズと形式が固定(immutable)な領域を 1 回で確保する(第16章)。
// レベル数 1 = ミップマップなし。中身は 0 で初期化される
gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);
// 不変ストレージのテクスチャは、宣言したレベル数までが「全部」なので、levels = 1 でも
// ミップマップ完備として扱われる(OpenGL ES 3.0 §3.8.10.4: 完備判定の最大レベルが
// levels - 1 にクランプされる)。つまり MIN_FILTER が既定の NEAREST_MIPMAP_LINEAR でも
// 第16章のような「真っ黒」にはならない。それでも明示するのは、既定のままだと縮小時に
// 段の中が最近傍で読まれ、意図しないギザギザが出るため
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, resolved.filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, resolved.filter);
// 画面に貼り直す用途では端の折り返しは邪魔にしかならないので CLAMP_TO_EDGE
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// ユニット 0 のバインドを外して返す。描画前には呼び出し側が
// activeTexture + bindTexture でバインドし直すこと(第16章)
gl.bindTexture(gl.TEXTURE_2D, null);
// --- FBO を作ってアタッチする ---------------------------------------------
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
// --- 深度アタッチメント: レンダーバッファ ---------------------------------
// 既定フレームバッファの深度はコンテキスト属性 depth が用意してくれるが(第2章)、
// FBO の深度は誰も用意してくれない。付けなければ深度テストは素通りする(第13・20章)
let depth: WebGLRenderbuffer | null = null;
if (resolved.depth) {
depth = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
// DEPTH_COMPONENT24 は OpenGL ES 3.0 がレンダーバッファでのサポートを必須にしている形式
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, width, height);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
}
// --- completeness チェック -------------------------------------------------
// 不完全な FBO への描画は INVALID_FRAMEBUFFER_OPERATION になるだけで例外は飛ばない。
// 「何も描かれない」という症状だけが残るので、作った直後に必ず確かめる
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 描画先は既定のフレームバッファへ戻しておく
if (status !== gl.FRAMEBUFFER_COMPLETE) {
deleteRenderTarget(gl, { framebuffer, texture, depth, width, height, options: resolved });
const hint =
status === gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT
? '(内部フォーマットが color-renderable でない可能性があります。' +
'float バッファには拡張 EXT_color_buffer_float が要ります)'
: '';
throw new Error(
`フレームバッファが不完全です: ${framebufferStatusName(gl, status)} ` +
`— ${width}×${height}, internalFormat=0x${resolved.internalFormat.toString(16)}${hint}`,
);
}
return { framebuffer, texture, depth, width, height, options: resolved };
}
/**
* 描画先を切り替える。target に null を渡すと画面(既定フレームバッファ)へ戻る。
* viewport も一緒に切り替えるのがこの関数の存在意義 —
* viewport は FBO のバインドでは変わらない GL の状態で、切り替え忘れが最頻出の罠
*/
export function bindRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget | null): void {
if (target === null) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
return;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
gl.viewport(0, 0, target.width, target.height);
}
/**
* サイズを変える。テクスチャもレンダーバッファもサイズは後から変えられないので、
* 中身は「同じオプションで作り直す」だけ。サイズが同じなら何もせず、渡された target を返す。
*
* **戻り値を必ず受け取り直すこと**。サイズが変わった場合、渡した target は解放済みになる。
*/
export function resizeRenderTarget(
gl: WebGL2RenderingContext,
target: RenderTarget,
width: number,
height: number,
): RenderTarget {
if (target.width === width && target.height === height) {
return target;
}
// 先に作り、成功してから古いものを消す。逆順にすると、作成に失敗したときに
// 呼び出し側が「解放済みのレンダーターゲット」を握ったまま残ってしまう
// (WebGL は解放済みハンドルを黙って無視するので、原因の分かりにくい不具合になる)
const next = createRenderTarget(gl, width, height, target.options);
deleteRenderTarget(gl, target);
return next;
}
/** FBO・テクスチャ・レンダーバッファをまとめて解放する。作り直すときは必ず先に呼ぶ */
export function deleteRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget): void {
gl.deleteFramebuffer(target.framebuffer);
gl.deleteTexture(target.texture);
if (target.depth !== null) {
gl.deleteRenderbuffer(target.depth);
}
}
// ---------------------------------------------------------------------------
// 深度ターゲット(第21章で追加)
//
// シャドウマップは「カラーを 1 枚も持たず、深度だけをテクスチャに描く FBO」。
// createRenderTarget はカラーテクスチャ必須・深度はレンダーバッファ(= 読めない)
// なので、この用途にはそのまま使えない。深度アタッチメントをレンダーバッファから
// テクスチャに差し替えたものを、別の型として足している。
// ---------------------------------------------------------------------------
export interface DepthTargetOptions {
/**
* 深度のサイズ付き内部フォーマット(既定 gl.DEPTH_COMPONENT24)。
* gl.DEPTH_COMPONENT16 / gl.DEPTH_COMPONENT32F も OpenGL ES 3.0 の必須形式
*/
internalFormat?: GLenum;
/**
* ハードウェアの深度比較を有効にするか(既定 false)。
*
* false: TEXTURE_COMPARE_MODE は NONE のまま。GLSL 側は sampler2D で読み、
* 記録された深度が vec4(depth, 0, 0, 1) の r 成分で返る。比較は自分で書く
* true : TEXTURE_COMPARE_MODE = COMPARE_REF_TO_TEXTURE + TEXTURE_COMPARE_FUNC = LEQUAL。
* GLSL 側は sampler2DShadow で読み、texture() が比較結果(0.0 / 1.0)を返す。
* 比較モードのときだけ深度テクスチャでも LINEAR が許され、複数テクセルの
* 比較結果が混ぜられうる(= 2×2 の PCF)。ただし実際に補間するかどうかは
* 実装依存で、仕様は保証していない(第21章 6 節)
*
* 同じテクスチャを「比較モードのまま sampler2D で読む」「比較モードなしで
* sampler2DShadow で読む」のは、どちらも OpenGL ES 3.0 仕様が結果を未定義と
* 定めている。この設定と GLSL 側のサンプラの型は必ずセットで合わせること
*/
compare?: boolean;
}
export interface DepthTarget {
/** 描画先を差し替えるための FBO 本体。カラーアタッチメントは持たない */
framebuffer: WebGLFramebuffer;
/** DEPTH_ATTACHMENT に付いている深度テクスチャ。描き終えたらこれをサンプリングする */
texture: WebGLTexture;
/** 深度テクスチャの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
width: number;
/** 深度テクスチャの高さ(ピクセル) */
height: number;
/** 既定値を埋めた生成時のオプション */
options: Required<DepthTargetOptions>;
}
/**
* 深度だけを描く FBO(シャドウマップ用)を 1 つ作る。カラーアタッチメントを持たない。
* 完成しなかった場合は原因の分かるエラーを投げ、作りかけのリソースは解放する
*/
export function createDepthTarget(
gl: WebGL2RenderingContext,
width: number,
height: number,
options: DepthTargetOptions = {},
): DepthTarget {
if (width < 1 || height < 1) {
throw new Error(`深度ターゲットのサイズは 1 以上にしてください(${width}×${height})`);
}
const resolved: Required<DepthTargetOptions> = {
internalFormat: options.internalFormat ?? gl.DEPTH_COMPONENT24,
compare: options.compare ?? false,
};
// --- 深度アタッチメント: レンダーバッファではなくテクスチャ -----------------
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0); // 作業台を 0 番に固定する(createRenderTarget と同じ)
gl.bindTexture(gl.TEXTURE_2D, texture);
// カラーと同じく texStorage2D で不変ストレージを確保する(第16・20章)
gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);
// フィルタは必ず明示する。TEXTURE_MIN_FILTER の既定 NEAREST_MIPMAP_LINEAR のままだと、
// 下の「NEAREST でなければ不完全」の条件に引っかかり、読んだ結果が 0 になる
if (resolved.compare) {
// 比較モード: texture() が「参照値 <= 記録された深度」の判定結果を返すようになる。
// OpenGL ES 3.0 の texture completeness は「深度形式 + COMPARE_MODE が NONE」の
// ときだけ NEAREST を要求するので、比較モードでは LINEAR を指定しても完全のまま
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
} else {
// 比較モードなし: MAG は NEAREST、MIN は NEAREST か NEAREST_MIPMAP_NEAREST でないと
// テクスチャが不完全になり、sampler2D で読んだ結果が (0, 0, 0, 1) になる
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
// 光源の視錐台の外を読んだときに、反対側の端が折り返して出てこないようにする。
// WebGL2 には CLAMP_TO_BORDER が無いので、範囲外はシェーダー側で弾く(第21章 9 節)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null);
// --- FBO を作って、深度テクスチャだけをアタッチする -------------------------
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
// カラーアタッチメントが 1 つも無いことを、描画先の指定にも反映しておく。
// completeness の条件ではない(OpenGL ES 3.0 の条件は「画像が 1 枚以上付いていること」で、
// カラーの有無は問われない)し、アタッチメントの無い draw buffer への出力は
// エラーにならず捨てられるだけ。それでも書くのは、WebGL 2.0 側に
// 「フラグメントシェーダーに対応する出力が無い draw buffer」を INVALID_OPERATION に
// しうる規定があり、NONE と明示しておけばその条件から確実に外れるため
gl.drawBuffers([gl.NONE]);
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
deleteDepthTarget(gl, { framebuffer, texture, width, height, options: resolved });
throw new Error(
`深度フレームバッファが不完全です: ${framebufferStatusName(gl, status)} ` +
`— ${width}×${height}, internalFormat=0x${resolved.internalFormat.toString(16)}`,
);
}
return { framebuffer, texture, width, height, options: resolved };
}
/**
* 描画先を深度ターゲットに切り替える。viewport も一緒に切り替える。
* 画面へ戻すときは bindRenderTarget(gl, null) を使う(戻り先の処理は 1 か所でよい)
*/
export function bindDepthTarget(gl: WebGL2RenderingContext, target: DepthTarget): void {
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
gl.viewport(0, 0, target.width, target.height);
}
/** FBO と深度テクスチャをまとめて解放する。作り直すときは必ず先に呼ぶ */
export function deleteDepthTarget(gl: WebGL2RenderingContext, target: DepthTarget): void {
gl.deleteFramebuffer(target.framebuffer);
gl.deleteTexture(target.texture);
}// 第21章: シャドウマッピング — 光源視点の深度パスと影の品質
// 第17章から第20章までのライティングは「その点に光が届くか」を一切調べていなかった。
// この章では、1 フレームを 2 パスに分けて遮蔽を調べる。
//
// パス 1(深度パス): 光源の視点でシーンを描き、深度だけを深度テクスチャに残す
// パス 2(カメラ視点): 各フラグメントを光源のクリップ空間へ運び、
// 記録された深度と自分の深度を比べる。手前に別の面があれば影
//
// 深度テクスチャ用の FBO は共通ヘルパー src/lib/framebuffer.ts に
// createDepthTarget として足した(第20章の createRenderTarget はカラー必須のため)。
import {
mat3,
mat4,
type ReadonlyMat4,
type ReadonlyVec2,
type ReadonlyVec3,
vec2,
vec3,
} from 'gl-matrix';
import {
bindDepthTarget,
bindRenderTarget,
createDepthTarget,
type DepthTarget,
deleteDepthTarget,
} from '../../lib/framebuffer';
import {
createPlane,
createSphere,
createTorus,
type Geometry,
interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import previewFragmentSource from './preview.frag?raw';
import previewVertexSource from './preview.vert?raw';
import sceneFragmentSource from './scene.frag?raw';
import sceneVertexSource from './scene.vert?raw';
import shadowFragmentSource from './shadow.frag?raw';
import shadowVertexSource from './shadow.vert?raw';
// ---------------------------------------------------------------------------
// メッシュ: ジオメトリを VAO に配線する(第13〜15章の手順そのまま)
// ---------------------------------------------------------------------------
interface Mesh {
vao: WebGLVertexArrayObject;
indexCount: number;
}
function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// 1 頂点 = 8 float(位置 3 + 法線 3 + UV 2)。stride / offset はバイト単位で、
// 数値を直書きせず BYTES_PER_ELEMENT から組み立てる(第13章)
const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
const stride = 8 * FLOAT_BYTES;
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); // a_position
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1); // a_normal
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
gl.enableVertexAttribArray(2); // a_uv
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
gl.bindVertexArray(null);
return { vao, indexCount: geometry.indices.length };
}
// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------
// カメラ定数(第3部の標準: fovy 45°・near 0.1・far 100)
const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;
// 光源のカメラ(3 節)。方向光なので平行投影。
// LIGHT_EXTENT は「シャドウマップが覆うワールド空間の半径」で、狭いほど 1 テクセルが
// 細かくなる代わりに、外へ出た影が切れる。LIGHT_NEAR / LIGHT_FAR の幅 19 が
// 深度 0〜1 に対応するので、バイアスの値もこの幅を基準に決まる(7 節)
const LIGHT_DISTANCE = 10;
const LIGHT_EXTENT = 6;
const LIGHT_NEAR = 1;
const LIGHT_FAR = 20;
// 隅のプレビューで深度のコントラストを稼ぐための範囲。
// 光源から LIGHT_DISTANCE ± LIGHT_EXTENT の距離が、深度いくつに対応するかを計算したもの
const PREVIEW_DEPTH_RANGE: ReadonlyVec2 = vec2.fromValues(
(LIGHT_DISTANCE - LIGHT_EXTENT - LIGHT_NEAR) / (LIGHT_FAR - LIGHT_NEAR),
(LIGHT_DISTANCE + LIGHT_EXTENT - LIGHT_NEAR) / (LIGHT_FAR - LIGHT_NEAR),
);
// シャドウマップの解像度(11 節)
const SHADOW_SIZES = [1024, 256] as const;
// バイアスの値。単位は深度テクスチャに入っている 0〜1 の深度なので、
// 光源視錐台の奥行き(LIGHT_FAR - LIGHT_NEAR = 19)を基準に決まる(7 節)
const BIAS_FIXED = 0.008;
const BIAS_SLOPE = 0.006; // 傾き係数 k
const BIAS_MIN = 0.0006;
const SHADOW_BIAS: ReadonlyVec3 = vec3.fromValues(BIAS_FIXED, BIAS_SLOPE, BIAS_MIN);
const BIAS_MODES = [
{ label: 'バイアス: なし', value: 0, note: 'なし(アクネが出る)' },
{ label: '固定', value: 1, note: `固定 ${BIAS_FIXED}` },
{
label: '傾き対応',
value: 2,
note: `傾き対応 max(${BIAS_SLOPE}×(1−N·L), ${BIAS_MIN})`,
},
] as const;
const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const FLOOR_Y = -1.25;
// 光源のカメラが狙う点。シーンの中心あたりに置くと視錐台を無駄なく使える
const LIGHT_TARGET: ReadonlyVec3 = vec3.fromValues(0, FLOOR_Y + 0.6, 0);
const ORBIT_TARGET: ReadonlyVec3 = vec3.fromValues(0, FLOOR_Y + 0.7, 0);
const FLOOR_COLOR: ReadonlyVec3 = vec3.fromValues(0.46, 0.48, 0.52);
const PILLAR_COLOR: ReadonlyVec3 = vec3.fromValues(0.78, 0.62, 0.4);
const TORUS_COLOR: ReadonlyVec3 = vec3.fromValues(0.36, 0.55, 0.82);
const SPHERE_COLOR: ReadonlyVec3 = vec3.fromValues(0.82, 0.42, 0.32);
// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------
function setup(
gl: WebGL2RenderingContext,
canvas: HTMLCanvasElement,
shadowControls: HTMLParagraphElement,
biasControls: HTMLParagraphElement,
pcfControls: HTMLParagraphElement,
resolutionControls: HTMLParagraphElement,
readout: HTMLParagraphElement,
): void {
// --- プログラム 3 本 -------------------------------------------------------
// shadow: 光源視点で深度だけを描く / scene: カメラ視点で影付きに描く /
// preview: シャドウマップそのものを画面の隅に出す
const shadowProgram = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, shadowVertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, shadowFragmentSource),
);
const shadowLocations = {
model: gl.getUniformLocation(shadowProgram, 'u_model'),
lightViewProjection: gl.getUniformLocation(shadowProgram, 'u_lightViewProjection'),
};
const sceneProgram = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, sceneFragmentSource),
);
const at = (name: string): WebGLUniformLocation | null =>
gl.getUniformLocation(sceneProgram, name);
const sceneLocations = {
model: at('u_model'),
view: at('u_view'),
projection: at('u_projection'),
normalMatrix: at('u_normalMatrix'),
lightViewProjection: at('u_lightViewProjection'),
lightDirection: at('u_lightDirection'),
cameraPosition: at('u_cameraPosition'),
baseColor: at('u_baseColor'),
shadowMap: at('u_shadowMap'),
shadowEnabled: at('u_shadowEnabled'),
biasMode: at('u_biasMode'),
pcf: at('u_pcf'),
shadowBias: at('u_shadowBias'),
};
const previewProgram = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, previewVertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, previewFragmentSource),
);
const previewLocations = {
shadowMap: gl.getUniformLocation(previewProgram, 'u_shadowMap'),
depthRange: gl.getUniformLocation(previewProgram, 'u_depthRange'),
};
// --- メッシュ ---------------------------------------------------------------
const floorMesh = createMesh(gl, createPlane(24, 24));
// 柱: 球を縦に引き伸ばしたもの。底が床にちょうど接するので、
// バイアスを入れすぎたときの「影が浮く」(ピーターパン現象)が見やすい
const pillarMesh = createMesh(gl, createSphere(1, 32, 24));
const torusMesh = createMesh(gl, createTorus(0.75, 0.26, 64, 32));
const sphereMesh = createMesh(gl, createSphere(0.5, 32, 16));
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
// --- シャドウマップ(深度だけの FBO) ----------------------------------------
let shadowSize: number = SHADOW_SIZES[0];
let shadowMap = createDepthTarget(gl, shadowSize, shadowSize);
function updateShadowMap(size: number): void {
if (size === shadowMap.width) {
return;
}
// 深度テクスチャはサイズを後から変えられないので作り直す。
// 先に作ってから古いものを消す(第20章 7 節と同じ理由)
const next: DepthTarget = createDepthTarget(gl, size, size);
deleteDepthTarget(gl, shadowMap);
shadowMap = next;
shadowSize = size;
}
// --- 表示の切り替え ---------------------------------------------------------
let shadowEnabled = true;
let biasMode: number = BIAS_MODES[2].value;
let pcfEnabled = true;
function addButtons<T>(
container: HTMLParagraphElement,
entries: readonly { label: string; value: T }[],
isActive: (value: T) => boolean,
onSelect: (value: T) => void,
): void {
for (const entry of entries) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = entry.label;
button.setAttribute('aria-pressed', isActive(entry.value) ? 'true' : 'false');
button.addEventListener('click', () => {
onSelect(entry.value);
for (const other of container.querySelectorAll('button')) {
other.setAttribute('aria-pressed', 'false');
}
button.setAttribute('aria-pressed', 'true');
});
container.append(button);
}
}
addButtons(
shadowControls,
[
{ label: '影あり', value: true },
{ label: '影なし(第17〜20章と同じ)', value: false },
],
(value) => value === shadowEnabled,
(value) => {
shadowEnabled = value;
},
);
addButtons(
biasControls,
BIAS_MODES,
(value) => value === biasMode,
(value) => {
biasMode = value;
},
);
addButtons(
pcfControls,
[
{ label: 'PCF: なし', value: false },
{ label: '3×3', value: true },
],
(value) => value === pcfEnabled,
(value) => {
pcfEnabled = value;
},
);
addButtons(
resolutionControls,
[
{ label: 'シャドウマップ: 1024', value: SHADOW_SIZES[0] },
{ label: '256', value: SHADOW_SIZES[1] },
],
(value) => value === shadowSize,
(value) => {
updateShadowMap(value);
},
);
// --- 軌道カメラ(第15章の簡略版) -------------------------------------------
const orbit = { theta: 0.7, phi: 0.34, radius: 8.6 };
const PHI_LIMIT = Math.PI / 2 - 0.05;
const MIN_RADIUS = 4;
const MAX_RADIUS = 16;
let dragging = false;
let activePointerId: number | null = null;
let lastX = 0;
let lastY = 0;
canvas.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
dragging = true;
activePointerId = event.pointerId;
lastX = event.clientX;
lastY = event.clientY;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', (event) => {
if (!dragging || event.pointerId !== activePointerId) return;
const speed = (2 * Math.PI) / canvas.clientHeight;
orbit.theta -= (event.clientX - lastX) * speed;
orbit.phi += (event.clientY - lastY) * speed;
// 床の下に潜らないよう、仰角の下限は 0 手前で止める
orbit.phi = Math.min(PHI_LIMIT, Math.max(0.05, orbit.phi));
lastX = event.clientX;
lastY = event.clientY;
});
function endDrag(event: PointerEvent): void {
if (event.pointerId !== activePointerId) return;
dragging = false;
activePointerId = null;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
}
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
canvas.addEventListener(
'wheel',
(event) => {
event.preventDefault();
const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
orbit.radius *= Math.exp(event.deltaY * scale * 0.001);
orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));
},
{ passive: false },
);
// --- 毎フレーム使い回す入れ物 -----------------------------------------------
const eye = vec3.create();
const model = mat4.create();
const view = mat4.create();
const projection = mat4.create();
const normalMatrix = mat3.create();
const lightDirection = vec3.create();
const lightEye = vec3.create();
const lightView = mat4.create();
const lightProjection = mat4.create();
const lightViewProjection = mat4.create();
// --- リサイズ(第4章と同じ) --------------------------------------------------
// gl.viewport はここでは呼ばない。描画先を切り替えるたびに
// bindDepthTarget / bindRenderTarget が viewport も合わせる(第20章 5 節)
function resizeIfNeeded(): void {
const dpr = Math.min(window.devicePixelRatio, 2);
const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
// --- シーンの中身: 2 つのパスで「まったく同じ形」を描く ---------------------
// 形がずれると影もずれるので、モデル行列を作る場所を 1 か所にまとめる
function forEachObject(time: number, draw: (mesh: Mesh, color: ReadonlyVec3) => void): void {
// 床: XY 平面の板を X 軸まわりに -90° 回して床にする(第14章)
mat4.fromTranslation(model, [0, FLOOR_Y, 0]);
mat4.rotateX(model, model, -Math.PI / 2);
draw(floorMesh, FLOOR_COLOR);
// 柱: 単位球を (0.45, 1.0, 0.45) に潰した楕円体。底が床に接している
mat4.fromTranslation(model, [-1.7, FLOOR_Y + 1.0, 0.5]);
mat4.scale(model, model, [0.45, 1.0, 0.45]);
draw(pillarMesh, PILLAR_COLOR);
// 宙に浮いて回るトーラス。床に落ちる影が形を変える
mat4.fromTranslation(model, [1.5, FLOOR_Y + 1.6, -0.4]);
mat4.rotateX(model, model, time * 0.35);
mat4.rotateY(model, model, time * 0.5);
draw(torusMesh, TORUS_COLOR);
// 床の上を転がる球。柱の影に入ったり出たりする
const angle = time * 0.5;
mat4.fromTranslation(model, [Math.cos(angle) * 2.0, FLOOR_Y + 0.5, Math.sin(angle) * 2.0]);
draw(sphereMesh, SPHERE_COLOR);
}
function drawMesh(mesh: Mesh): void {
gl.bindVertexArray(mesh.vao);
gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
}
// --- パス 1: 光源視点で深度だけを描く ---------------------------------------
function renderShadowMap(time: number, lightMatrix: ReadonlyMat4): void {
// bindDepthTarget が viewport をシャドウマップのサイズへ切り替える。
// カラーアタッチメントが無いので、クリアするのは深度だけ
bindDepthTarget(gl, shadowMap);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(shadowProgram);
gl.uniformMatrix4fv(shadowLocations.lightViewProjection, false, lightMatrix); // transpose は常に false
forEachObject(time, (mesh) => {
gl.uniformMatrix4fv(shadowLocations.model, false, model);
drawMesh(mesh);
});
}
// --- パス 2: カメラ視点で、影を付けて描く -----------------------------------
function renderScene(time: number, lightMatrix: ReadonlyMat4): void {
bindRenderTarget(gl, null); // 画面へ戻す。viewport も canvas のサイズへ(第20章)
gl.clearColor(0.06, 0.07, 0.09, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(sceneProgram);
// 画面へ描くときのアスペクト比は描画バッファから(第3部の規約)
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
mat4.lookAt(view, eye, ORBIT_TARGET, UP);
gl.uniformMatrix4fv(sceneLocations.view, false, view);
gl.uniformMatrix4fv(sceneLocations.projection, false, projection);
gl.uniformMatrix4fv(sceneLocations.lightViewProjection, false, lightMatrix);
gl.uniform3fv(sceneLocations.lightDirection, lightDirection);
gl.uniform3fv(sceneLocations.cameraPosition, eye);
gl.uniform1i(sceneLocations.shadowEnabled, shadowEnabled ? 1 : 0);
gl.uniform1i(sceneLocations.biasMode, biasMode);
gl.uniform1i(sceneLocations.pcf, pcfEnabled ? 1 : 0);
gl.uniform3fv(sceneLocations.shadowBias, SHADOW_BIAS);
// パス 1 で描いた深度テクスチャを、テクスチャユニット 0 に載せて読む(第16章)
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, shadowMap.texture);
gl.uniform1i(sceneLocations.shadowMap, 0);
forEachObject(time, (mesh, color) => {
// 法線行列(第17章)。柱は非一様スケールなので、逆転置でないと法線が傾く
mat3.normalFromMat4(normalMatrix, model);
gl.uniformMatrix4fv(sceneLocations.model, false, model);
gl.uniformMatrix3fv(sceneLocations.normalMatrix, false, normalMatrix);
gl.uniform3fv(sceneLocations.baseColor, color);
drawMesh(mesh);
});
}
// --- シャドウマップそのものを画面の隅に出す ---------------------------------
function renderPreview(): void {
const size = Math.max(
1,
Math.floor(Math.min(gl.drawingBufferWidth, gl.drawingBufferHeight) * 0.3),
);
const margin = Math.max(1, Math.floor(size * 0.08));
// viewport を隅の正方形にしてから全画面三角形を描くと、そこにだけ絵が出る(第2章)
gl.viewport(gl.drawingBufferWidth - size - margin, margin, size, size);
gl.disable(gl.DEPTH_TEST); // 常に手前に出す
gl.useProgram(previewProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, shadowMap.texture);
gl.uniform1i(previewLocations.shadowMap, 0);
gl.uniform2fv(previewLocations.depthRange, PREVIEW_DEPTH_RANGE);
// 頂点バッファを使わない描画。VAO は「配線なし」の状態にしておく(第6章)
gl.bindVertexArray(null);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.enable(gl.DEPTH_TEST);
// 次のフレームでこのテクスチャはまた描画先に戻るので、バインドしたままにしない
gl.bindTexture(gl.TEXTURE_2D, null);
}
// --- 読み出し行 -------------------------------------------------------------
let lastReadout = '';
function updateReadout(): void {
const bias = BIAS_MODES.find((entry) => entry.value === biasMode);
const text = shadowEnabled
? `シャドウマップ ${shadowSize}×${shadowSize} / DEPTH_COMPONENT24 / ` +
`1 テクセル ≒ ${((2 * LIGHT_EXTENT) / shadowSize).toFixed(3)} ワールド単位 / ` +
`バイアス: ${bias?.note ?? ''} / PCF: ${pcfEnabled ? '3×3(9 回比較)' : 'なし(1 回比較)'}`
: '影なし: 遮蔽を調べていない状態(第17〜20章のライティング)。深度パスの結果だけ隅に出ている';
if (text !== lastReadout) {
readout.textContent = text;
lastReadout = text;
}
}
// --- 描画ループ -------------------------------------------------------------
function frame(timestamp: DOMHighResTimeStamp): void {
resizeIfNeeded();
const time = timestamp / 1000;
// 光源をゆっくり動かす。方位角が回り、仰角も上下するので影の伸び方が変わる。
// u_lightDirection は「面から光源へ向かう」単位ベクトル(第17章の約束)
const azimuth = time * 0.16;
// 仰角は 0.62〜0.98 ラジアン。低くしすぎると影が LIGHT_EXTENT の外まで伸びて切れる
const elevation = 0.8 + Math.sin(time * 0.12) * 0.18;
vec3.set(
lightDirection,
Math.cos(azimuth) * Math.cos(elevation),
Math.sin(elevation),
Math.sin(azimuth) * Math.cos(elevation),
);
// 光源のカメラ: 方向光には位置がないので、狙う点から光の来る方へ十分離れた所に置く
vec3.scaleAndAdd(lightEye, LIGHT_TARGET, lightDirection, LIGHT_DISTANCE);
mat4.lookAt(lightView, lightEye, LIGHT_TARGET, UP);
// 方向光の光線は平行なので、光源のカメラも平行投影(3 節)
mat4.ortho(
lightProjection,
-LIGHT_EXTENT,
LIGHT_EXTENT,
-LIGHT_EXTENT,
LIGHT_EXTENT,
LIGHT_NEAR,
LIGHT_FAR,
);
mat4.multiply(lightViewProjection, lightProjection, lightView);
// カメラ(第15章)
eye[0] = ORBIT_TARGET[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
eye[1] = ORBIT_TARGET[1] + orbit.radius * Math.sin(orbit.phi);
eye[2] = ORBIT_TARGET[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
renderShadowMap(time, lightViewProjection);
renderScene(time, lightViewProjection);
renderPreview();
updateReadout();
requestAnimationFrame(frame);
}
// このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
// していない。GPU リソース解放の一般論は第35章(この章の deleteDepthTarget は
// 「解像度を切り替えるときに古いものを消す」ぶんだけを実践している)
requestAnimationFrame(frame);
}
// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------
const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const shadowControls = document.querySelector<HTMLParagraphElement>('#shadow-buttons');
const biasControls = document.querySelector<HTMLParagraphElement>('#bias-buttons');
const pcfControls = document.querySelector<HTMLParagraphElement>('#pcf-buttons');
const resolutionControls = document.querySelector<HTMLParagraphElement>('#resolution-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (
!canvas ||
!shadowControls ||
!biasControls ||
!pcfControls ||
!resolutionControls ||
!readout
) {
throw new Error('デモに必要な要素が見つかりません');
}
const gl = canvas.getContext('webgl2');
if (!gl) {
throw new Error('このブラウザは WebGL2 に対応していません');
}
setup(gl, canvas, shadowControls, biasControls, pcfControls, resolutionControls, readout);#version 300 es
// パス 1(深度パス)の頂点シェーダー。
// 光源の視点でシーンをもう一度描き、深度だけを残す。色は 1 つも計算しない。
// 使う属性は位置だけ — 法線も UV も、深度には関係がない。
layout(location = 0) in vec3 a_position;
uniform mat4 u_model;
// 光源のビュー行列と平行投影行列を CPU 側で掛け合わせたもの(3 節)。
// パス 2 でも同じ行列を使う。ここがずれると影の位置がまるごとずれる
uniform mat4 u_lightViewProjection;
void main() {
gl_Position = u_lightViewProjection * u_model * vec4(a_position, 1.0);
}#version 300 es
// パス 1(深度パス)のフラグメントシェーダー。中身は空です。
//
// out 変数が 1 つもないので、色はどこにも書き込まれません。深度のほうは
// gl_FragDepth を書かなくても、ラスタライザが補間した gl_FragCoord.z が
// そのまま深度バッファ(= 深度テクスチャ)へ入ります。
// それでも空のシェーダーが要るのは、WebGL のプログラムが頂点シェーダーと
// フラグメントシェーダーの両方を持っていないとリンクできないためです。
void main() {}#version 300 es
// パス 2(カメラ視点)の頂点シェーダー。
// 第17章の Blinn-Phong 用の varying に、「この頂点は光源のクリップ空間のどこか」を足したもの。
// 第3部の共通の属性配置(第14章と同じ): 0 = 位置, 1 = 法線, 2 = UV
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
// この章のシーンはテクスチャを貼らないので UV は使わない。宣言だけ残しているのは、
// 頂点バッファのインターリーブ配置(位置 3 + 法線 3 + UV 2)をシェーダー側からも
// 読み取れるようにするため(第20章と同じ)
layout(location = 2) in vec2 a_uv;
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix;
// パス 1 とまったく同じ行列。同じワールド座標を、同じ光源のクリップ空間へ運ぶ(4 節)
uniform mat4 u_lightViewProjection;
out vec3 v_normal;
out vec3 v_worldPosition;
out vec4 v_lightSpacePosition;
void main() {
// ライティングはワールド空間で行うので、位置と法線をワールド空間へ運ぶ(第17章)
vec4 worldPosition = u_model * vec4(a_position, 1.0);
v_worldPosition = worldPosition.xyz;
v_normal = u_normalMatrix * a_normal;
// 透視除算の前の 4 成分のまま渡す。除算はフラグメントシェーダー側で行う
v_lightSpacePosition = u_lightViewProjection * worldPosition;
gl_Position = u_projection * u_view * worldPosition;
}#version 300 es
precision highp float;
in vec3 v_normal;
in vec3 v_worldPosition;
in vec4 v_lightSpacePosition;
// u_lightDirection は「面から光源へ向かう」単位ベクトル(第17章)
uniform vec3 u_lightDirection;
uniform vec3 u_cameraPosition;
uniform vec3 u_baseColor;
// パス 1 で描いた深度テクスチャ。比較モードは使わず、記録された深度をそのまま読んで
// 自分で比べる(6 節。ハードウェアに比べさせる sampler2DShadow 版もそこで扱う)。
// highp は必須。sampler2D の既定精度は lowp で、texture() の戻り値も lowp float に
// なってしまう(絶対精度 2^-8 = 0.0039)。この章の最小バイアス 0.0006 より粗い(6 節)
uniform highp sampler2D u_shadowMap;
uniform int u_shadowEnabled; // 0: 影なし / 1: 影あり
uniform int u_biasMode; // 0: バイアスなし / 1: 固定 / 2: 傾き対応
uniform int u_pcf; // 0: 比較 1 回 / 1: 3×3 の PCF
// (固定バイアス, 傾き係数 k, 最小バイアス)。値は光源の視錐台の深度レンジに依存する(7 節)
uniform vec3 u_shadowBias;
out vec4 fragColor;
// 深度の比較に使う下駄。単位は「深度テクスチャに入っている 0〜1 の深度」
float shadowBias(float NdotL) {
if (u_biasMode == 1) {
return u_shadowBias.x;
}
if (u_biasMode == 2) {
// 面が光に対して寝ているほど、テクセル 1 枚が抱える深度の幅が広がる(7 節)。
// 真正面(NdotL = 1)では最小値まで下がるので、接地部分が浮きにくい
return max(u_shadowBias.y * (1.0 - NdotL), u_shadowBias.z);
}
return 0.0;
}
// 戻り値 0.0 = 完全に影の中 / 1.0 = 光が届いている
float shadowFactor(float NdotL) {
if (u_shadowEnabled == 0) {
return 1.0;
}
// 光源のクリップ空間 → 透視除算 → NDC(-1〜+1)。
// 平行投影なので w は 1 だが、点光源・スポットに差し替えても動くよう割っておく(4 節)
vec3 ndc = v_lightSpacePosition.xyz / v_lightSpacePosition.w;
// NDC → 0〜1。xy はシャドウマップのテクスチャ座標、z が比べる相手の深度になる
vec3 shadowCoord = ndc * 0.5 + 0.5;
// 光源の視錐台の外は「影なし」と決め打つ(9 節)。far の外では z が 1 を超える
if (shadowCoord.z > 1.0) {
return 1.0;
}
// 左右上下の外は CLAMP_TO_EDGE で端のテクセルが際限なく引き伸ばされるので、
// ラップまかせにせず自分で弾く
if (any(lessThan(shadowCoord.xy, vec2(0.0))) || any(greaterThan(shadowCoord.xy, vec2(1.0)))) {
return 1.0;
}
// 「いま塗ろうとしている点の、光源から見た深度」からバイアスを引いておく
float current = shadowCoord.z - shadowBias(NdotL);
if (u_pcf == 0) {
// 記録されている「光源から見ていちばん手前の面の深度」
float closest = texture(u_shadowMap, shadowCoord.xy).r;
// 手前に別の面がある = 自分は影の中
return current > closest ? 0.0 : 1.0;
}
// PCF: 隣り合う 9 テクセルで同じ比較をして、0/1 の平均を影の濃さにする(10 節)。
// textureSize でシャドウマップの解像度を問い合わせ、テクセル 1 枚分の幅を出す
vec2 texel = 1.0 / vec2(textureSize(u_shadowMap, 0));
float sum = 0.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float closest = texture(u_shadowMap, shadowCoord.xy + vec2(x, y) * texel).r;
sum += current > closest ? 0.0 : 1.0;
}
}
return sum / 9.0;
}
void main() {
// 方向光 1 灯の Blinn-Phong(第17章)。物理ベースの式は第22章
vec3 N = normalize(v_normal);
vec3 L = u_lightDirection;
vec3 V = normalize(u_cameraPosition - v_worldPosition);
vec3 H = normalize(L + V);
float NdotL = max(dot(N, L), 0.0);
float specular = pow(max(dot(N, H), 0.0), 48.0);
// 影は「光が届くかどうか」なので、直接光(拡散 + 鏡面)だけに掛ける。
// 環境光にまで掛けると影の中が真っ黒になり、回り込んだ光の代役という役目を失う(第17章)
float shadow = shadowFactor(NdotL);
vec3 direct = (u_baseColor * NdotL + vec3(0.3) * specular) * shadow;
vec3 ambient = u_baseColor * 0.18;
fragColor = vec4(ambient + direct, 1.0);
}#version 300 es
// 画面の隅にシャドウマップそのものを表示するための頂点シェーダー。
// 頂点バッファも VAO の配線も使わず、gl_VertexID から全画面三角形を作る(第6章)。
// 描く直前に viewport を隅の小さな正方形にしておくので、絵はそこにだけ出る(第2章)。
out vec2 v_uv;
void main() {
// gl_VertexID = 0 → (-1, -1) / 1 → (3, -1) / 2 → (-1, 3)
float x = gl_VertexID == 1 ? 3.0 : -1.0;
float y = gl_VertexID == 2 ? 3.0 : -1.0;
// viewport の中では、この uv がちょうど 0〜1 になる
v_uv = vec2(x, y) * 0.5 + 0.5;
gl_Position = vec4(x, y, 0.0, 1.0);
}#version 300 es
precision highp float;
in vec2 v_uv;
// パス 1 で描いた深度テクスチャ。scene.frag と同じものを、こちらはただの画像として読む。
// 深度を読むサンプラなので、こちらも highp を明示する(既定は lowp・6 節)
uniform highp sampler2D u_shadowMap;
// 表示のコントラストを稼ぐための深度の範囲(min, max)。光源の視錐台から計算した値
uniform vec2 u_depthRange;
out vec4 fragColor;
void main() {
// 深度テクスチャを sampler2D で読むと、深度が r 成分に入って返る
float depth = texture(u_shadowMap, v_uv).r;
// 平行投影の深度は光源からの距離に比例するので、そのまま出すと中間の灰色ばかりで
// 形が読み取れない。シーンが収まっている範囲だけを 0〜1 に引き伸ばして表示する
float shown = clamp(
(depth - u_depthRange.x) / (u_depthRange.y - u_depthRange.x),
0.0,
1.0
);
// 手前(光源に近い)ほど明るくする。何も写っていないところは深度 1.0 = 真っ暗
vec3 color = vec3(1.0 - shown);
// 枠線。canvas の背景と同じ暗さの中で、表示範囲の境界が分かるようにする
float edge = min(min(v_uv.x, v_uv.y), min(1.0 - v_uv.x, 1.0 - v_uv.y));
color = mix(vec3(0.45, 0.5, 0.58), color, step(0.012, edge));
fragColor = vec4(color, 1.0);
}three.js との対応
three.js のシャドウまわりは、この章で書いたものとよく対応します。左列の既定値と実装は、three.js のソース(タグ r185、および dev ブランチの REVISION 186dev)で確認したものです。
| three.js | この章 |
|---|---|
renderer.shadowMap.enabled(既定 false) | 「影あり / 影なし」ボタン(u_shadowEnabled) |
object.castShadow/object.receiveShadow(どちらも既定 false) | この区別は持たない。forEachObjectが描くものすべてが caster であり receiver |
DirectionalLightShadowの OrthographicCamera(-5, 5, 5, -5, 0.5, 500) | mat4.ortho(lightProjection, -6, 6, -6, 6, 1, 20)(3 節)。範囲は自分で シーンに合わせる |
SpotLightShadow の PerspectiveCamera(50, 1, 0.5, 500) | この章では扱わない(スポットなら平行投影を透視投影に差し替えるだけ) |
PointLightShadow。PerspectiveCamera(90, 1, 0.5, 500)+WebGLCubeRenderTarget+CubeDepthTextureで 6 面 | この章では扱わない。同じ考え方を 6 面に広げたもの |
light.shadow.mapSize(既定 Vector2(512, 512)) | createDepthTarget(gl, 1024, 1024)。解像度ボタンで 256 と往復できる |
シャドウマップの実体 —WebGLRenderTarget+DepthTexture(UnsignedIntType/ DepthFormat) | DepthTarget— カラーを持たない FBO + DEPTH_COMPONENT24の深度テクスチャ(5 節) |
renderer.shadowMap.type(既定 PCFShadowMap)。PCFShadowMapのときcompareFunction = LessEqualCompare+LinearFilter、BasicShadowMapのとき比較なし +NearestFilter | createDepthTargetのcompareオプション(6 節)。デモはcompare: false+sampler2Dの自前比較 |
shadowmap_pars_fragment.glsl.jsのuniform sampler2DShadow directionalShadowMap[] | uniform highp sampler2D u_shadowMap。sampler2DShadow版は 6 節で扱う |
PCF の実装 — Vogel ディスク上の 5 点をピクセルごとに回転させ、各点はハードウェア比較 (LinearFilterで 4 テクセルぶん) | 3×3 の 9 点を等間隔に読む素朴な PCF(10 節) |
PCFSoftShadowMap— dev の JSDoc で@deprecated since r186。r185 の時点で実行時にPCFShadowMapへ置き換えられ、警告が出る | 該当なし(かつての 2 段階 PCF 実装は削除済み) |
VSMShadowMap。RGFormat+ HalfFloatType に平均と分散を書き、チェビシェフ不等式で影を求める | この章では扱わない(名前だけ、11 節) |
light.shadow.bias(既定 0)。フラグメントでshadowCoord.zに加算 | u_shadowBias.x(固定バイアス)。同じく正規化深度への足し引き(7 節) |
light.shadow.normalBias(既定 0)。頂点シェーダーでワールド座標を法線方向へずらしてからシャドウ行列を掛ける | この章では扱わない別解。単位がワールド空間の距離になるぶん直感的で、 浅い入射角のアクネに効く。u_shadowBias.yの傾き対応バイアスとは狙いが同じで手段が違う |
light.shadow.radius(既定 1)。PCFShadowMapでは Vogel ディスクの半径に掛かる(BasicShadowMapのときは効かない) | PCF の広がり。この章は 3×3 の等間隔固定で、広がりはテクセル 1 枚ぶん(10 節) |
light.shadow.blurSamples(既定 8)。VSM のブラー段数で、WebGLShadowMapのVSMPassと WebGPU のShadowNodeからしか参照されない。PCF には効きません(PCF のサンプル数は上の行の とおり 5 点固定) | 該当なし。この章の PCF のサンプル数も 3×3 = 9 の固定 |
light.shadow.intensity(既定 1)。mix(1.0, shadow, intensity) | 持っていない。shadowFactorの戻り値に同じ mix を掛ければ同等 |
shadow.autoUpdate / needsUpdate | 毎フレーム深度パスを描いている。静止した光源なら省ける(第35章) |
カスケードシャドウマップ — 本体ではなく examples/jsm/csm/ | 扱わない(11 節) |
手元で動かして、壊してみる
デモのコードはsrc/lessons/21-shadow-mapping/、共通ヘルパーはsrc/lib/framebuffer.tsにあります。この章の失敗は「影がずれる」「縞が出る」「真っ黒になる」のどれかに集約されるので、 症状と原因を結び付けておくと後で助かります。
main.tsのLIGHT_EXTENTを 6 から 2 にする — シャドウマップの範囲が 4 × 4 に狭まるので、影は中央だけくっきりし、その外では消えます。9 節の「範囲外 = 影なし」がそのまま見える実験です。逆に 20 にすると、範囲は足りますが 1 テクセルが 3 倍以上粗くなり、1024 でも影の階段が見えてきますLIGHT_FARを 20 から 200 に変える — 深度の換算率が 10 倍になるので、同じバイアス値の効きが 10 分の 1 になります。「固定」でもアクネが出るようになり、7 節の「バイアスは深度の単位」が体感できますscene.fragのshadowFactorで、範囲外チェックのreturn 1.0;をreturn 0.0;に変える — シャドウマップの外が全部影になり、床の遠くが暗く落ちます。「範囲外をどちらに 倒すかはシーン次第」の意味が分かります- 同じく
shadowCoord.xyの範囲チェックを丸ごと消す —CLAMP_TO_EDGEのせいで、シャドウマップの端のテクセルの影が床の彼方まで帯になって伸びます main.tsのrenderShadowMapからgl.clear(gl.DEPTH_BUFFER_BIT);を消す — 1 フレーム目の深度が残り続けるので、影が動かなくなります(正確には、 いちばん手前に来たことのある深度が積み上がったまま残ります)renderSceneに渡すlightMatrixだけを、たとえばmat4.multiply(lightViewProjection, lightProjection, lightView)の直後にmat4.rotateY(lightViewProjection, lightViewProjection, 0.05)を挟んでずらす — 2 つのパスの行列が一致しなくなり、影が本体から完全に離れます。 「両パスで同じ行列」が守るべき約束であることの確認ですforEachObjectのトーラスの回転速度を、パス 1 とパス 2 で変えてみる(たとえばrenderShadowMapに渡すtimeだけtime + 1.0にする)— 影だけが別の時刻の形になります。5 節で「同じ形を描く」と書いた理由ですsrc/lib/framebuffer.tsのcreateDepthTargetで、比較モードなし側のフィルタをgl.NEARESTからgl.LINEARに変える — テクスチャが不完全になるので、読んだ値が(0, 0, 0, 1)になります。深度 0 = いちばん手前が記録されていることになるので、シーン全体が影になります。エラーは 1 つも出ません(5 節)- 同じ関数の
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);をgl.REPEATにして、範囲チェックも外す — 今度は反対側の端の影が折り返して出てきます。仕様上は合法な 設定で、単にアルゴリズムに合わないだけ、という例です(9 節) createDepthTargetのgl.drawBuffers([gl.NONE]);を消す — 手元の環境ではおそらく何も変わりません(5 節の落とし穴のとおり、仕様上は 「描かれないだけ」)。それでも書いておく理由も、そこに書いています- 深度パスだけ前面カリングにする —
renderShadowMapの呼び出しを 8 節のコードで挟みます。バイアスなしでもアクネが消えるのが 分かります。ただし床は板 1 枚なので、シャドウマップから消えます(右下のプレビューで確認 できます) - PCF のループを 3×3 から 5×5 に広げる(
-1 …… 1を-2 …… 2に、割る数を 9 から 25 に)— ふちがさらに柔らかくなります。256 のときの効きも比べてください scene.fragのdirectの式で、ambientにもshadowを掛けてみる(vec3 ambient = u_baseColor * 0.18 * shadow;)— 影の中が真っ黒になります。環境光が「回り込んだ光の代役」だったこと(第17章)が、影を入れて初めて はっきりします- 6 節のハードウェア比較に切り替える —
createDepthTarget(gl, size, size, { compare: true })にし、scene.fragのu_shadowMapをhighp sampler2DShadowに変え、比較をtexture(u_shadowMap, vec3(shadowCoord.xy, current))に置き換えます。PCF のループも同時に書き換えます—sampler2DShadowではtexture()の戻り値がfloatになるので、.rを付けたままだとコンパイルエラーです。sum += texture(u_shadowMap, vec3(shadowCoord.xy + vec2(x, y) * texel, current));の形になります。さらに右下のプレビューも直す必要があります— 比較モードの テクスチャをsampler2Dで読むのは未定義だからです(6 節)。手っ取り早くはrenderPreview()の呼び出しを消してください
まとめ
- 影 = 遮蔽の判定。第17章から第20章のライティングは「面が光を向いているか」しか見ておらず、 「光が届いているか」を調べていなかった
- シャドウマッピングは 2 パス。① 光源視点で深度だけを描く ② カメラ視点で各フラグメントを光源のクリップ空間へ運び、記録された深度と比べる
- 光源のカメラは、方向光なら平行投影。視錐台を狭くするほど 1 テクセルが細かくなるが、外に出た影は消える
- シャドウ座標は「光源のクリップ空間 → 透視除算 →
* 0.5 + 0.5」。行列に 畳み込む(バイアス行列)流儀もある - シャドウマップはカラーを持たない FBO+ 深度テクスチャ。ES 3.0 の completeness の条件は「画像が 1 枚以上」であって「カラーがあること」ではない。深度テクスチャは WebGL2 のコア機能で、比較モードが
NONEのときフィルタはNEAREST系でなければならない - 比較は
sampler2Dで自分で書くか、TEXTURE_COMPARE_MODE+sampler2DShadowでハードウェアに任せるか。比較モードとサンプラの型がずれると結果は未定義 - シャドウアクネは「連続した面を、テクセルという階段で記録している」ことから 来る自己遮蔽。バイアスで消せるが、入れすぎるとピーターパン現象で影が浮く。 傾きに応じたバイアス、深度パスの前面カリング、
polygonOffsetがそれぞれの対処 - バイアスの値は正規化された深度の単位。光源視錐台の
near/farを変えたら、必ず調整し直す - 視錐台の外は自分で決める。WebGL2 に
CLAMP_TO_BORDERは無いので、シェーダーで範囲チェックを書く - PCFは「深度を平均する」のではなく「比較の結果を平均する」。
textureSizeでテクセル幅を出す。textureOffsetのオフセットは定数式でなければならない - 影の品質は「1 テクセルがワールド空間で何メートルか」で決まる。広いシーンではカスケード シャドウマップ (CSM) が使われるが、このサイトでは扱わない
次章第22章「PBR — 物理ベースレンダリングと IBL」では、この章のあいだ そのままにしてきた反射の式そのものに手を入れます。第17章の Blinn-Phong は 「それらしく見える」ための経験則で、入ってきた光より多くの光を返してしまうこともある、 エネルギー的にいい加減なモデルでした。次章では Cook-Torrance の BRDF —D(法線分布)・G(幾何減衰)・F(フレネル)の 3 つの項に分けて反射を組み立てる枠組みを導入し、マテリアルを glTF と同じmetallic-roughnessで表します。そして「環境光 0.18」という乱暴な一律の近似を、周囲の環境マップから 正しく積分するIBL (image-based lighting)に置き換えます。この章で作った影は、そこでもそのまま直接光に掛かります —影は光のモデルとは独立した、遮蔽だけの話だからです。