9. ライティング パート2:鏡面反射とポイントライト

第4章では、平らな三角形をアンビエントライトとディレクショナルライトで照らしました。
平らな三角形には法線が1つしか無いので、全体が同じ明るさになり、ライティングが行われているのか殆ど分かりませんでした。
ライティングが面白くなるのは曲面の上で、其処では全てのピクセルが少しずつ違う方向を向いています。

其の為、此の章では第5章のプレイグラウンドに戻り、三角形を1枚も使わずに球を描きます。
フラグメントシェーダーが全てのピクセルについて、球の何処にいて、其処で表面がどちらを向いているのかを計算します。
其の上で、照らされた物体を切り抜きではなく「物」に見せる2つの要素を追加します:

  • 鏡面反射のハイライト:光沢のある物に見える小さな明るい点です。先ずフォンのモデルで、次に殆ど全ての人が使うブリン・フォンで作ります。
  • ポイントライト:電球の様に位置を持つ光です。向きはピクセル毎に変わり、明るさは距離と共に弱くなります。

最後には、光が球の周りを回ったり、マウスを追い掛けたりします。
変更するのは shader/playground.glsl だけです。
テスターを起動したまま、編集する度にRキーを押して下さい。

9.1 三角形を使わない球

9.1.1 表面を見つける

第5章の中心を原点にした座標から始めます。yは−1から1で、xとyの1単位は同じ長さです:

vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

原点を中心とする半径rの球を想像し、其れを非常に遠くから−z軸に沿って真っ直ぐ見ているとします。
円‖𝐩‖<rの中の各ピクセル𝐩=(px,py)からは、球の手前半分の点が丁度1つ見えます。
其の点のxとyはピクセルのxとyと同じなので、求めるのはzだけです。
原点を中心とする球の点は全て、中心から距離rにあります(線型代数の第3章):

px2+py2+z2=r2⇒z=r2−px2−py2

正の平方根を取るのは、其れがあたし達の方を向いている半分だからです(+zは見る人の方を向いています)。

球を横から見た図。x軸上のピクセルp x、其の真上の球面上の点Pと其の高さz、中心からPまでの半径r、Pから外向きの法線n

球の法線は、全ての法線の中で一番簡単です。中心から真っ直ぐ外を向いています。
中心から点へのベクトルは𝐏=(px,py,z)で、其の長さはrなので、rで割れば正規化出来ます:

𝐧^=𝐏r

数字で確かめてみましょう。r=0.8、ピクセル𝐩=(0.288,0.384)とします。
px2+py2=0.082944+0.147456=0.2304なので、z=0.64−0.2304=0.4096=0.64です。
法線は(0.288,0.384,0.64)/0.8=(0.36,0.48,0.8)で、確かに0.362+0.482+0.82=0.1296+0.2304+0.64=1です。
此のピクセルは、後の計算例でも使います。

平らな物を描きながら、ピクセル毎に表面を計算して3Dの物体の振りをさせる此の技は、インポスターと呼ばれます。
ゲームでは遠くの木やパーティクルに、分子ビューアーでは何千もの完璧な球を平らな四角形として描くのに使われます。

9.1.2 法線を表示する

playground.glsl の main を次の物に置き換えて下さい(上のユニフォームはそのままです):

const float RADIUS = 0.8; // 球の半径

void main() {
  // 中心が原点、縦が-1〜1の座標(第5章)
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  float px = 2.0 / uResolution.y; // 1ピクセルの大きさ

  // 球面上の点と法線(球の外ではz = 0にしておく)
  float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
  vec3 pos = vec3(p, z);
  vec3 n = pos / RADIUS;

  vec3 sphere = n * 0.5 + 0.5; // 法線(-1〜1)を色(0〜1)にする
  vec3 background = vec3(0.1);

  // 球の縁を1ピクセル分滑らかにする(第6章)
  float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
  out_color = vec4(mix(background, sphere, mask), 1.0);
}

dot(p, p) はpx2+py2、つまり長さの2乗で、length が計算する平方根はありません。
円の外ではr2−px2−py2が負になり、GLSLでは負の数の sqrt は未定義です(GPUによってNaNになったり、0になったり、何にでもなり得ます)。
max(..., 0.0) で全ての場所で値を正しく保ちます。どうせ其れらのピクセルはマスクで捨てられます。
if を使う代わりに、全てのピクセルで球を計算してから第6章のアンチエイリアス付きのマスクで混ぜるので、縁が滑らかになります。

色は第5章のデバッグの技です。法線の成分は−1から1なので、n * 0.5 + 0.5 で0から1にすれば目に見えます。

法線で色を付けた球。法線が+xを向く右側は赤っぽく、上は緑っぽく、法線が此方を向く中央は青紫

右端は赤っぽく(x = 1)、上は緑っぽく(y = 1)、中央は法線が(0, 0, 1)なので(0.5, 0.5, 1)の青紫になります。
此れは正に法線マップの見た目で、第12章で又会います。

9.1.3 全てを1つの空間で

ライティングには幾つかのベクトルが必要です。法線、光への向き、目への向きです。
決して破ってはいけない唯一のルールは、其れらが全て同じ座標空間にある事です。
本格的な3Dプログラムでは、普通はワールド空間かビュー空間にあり(線型代数の第11章)、第13章で其れをきちんと設定します。

此処では全てが1つの簡単な空間にあり、其れは実はカメラのビュー空間です。xは右、yは上、zは見る人の方、画面の中央が原点で、カメラは−zの方を見ています。
平行な線に沿って見ているので(正射影のビュー)、表面のどの点からも目への向きは同じです:

𝐯^=(0,0,1)

透視投影のカメラなら、𝐯^は normalize(cameraPos - pos) になり、ピクセル毎に変わります。其れは第13章と第14章で行います。

9.2 拡散反射:ランバートの復習

第4章と線型代数の第4章で、つや消しの表面が受け取る光は、法線と光源への向きの間の角度のコサインに比例する事を見ました。
単位ベクトル同士なら、其のコサインは内積です:

diffuse=max(𝐧^⋅𝐥^,0)

向きの約束を思い出して下さい。此のシリーズでは、𝐥^は常に表面から光源への向きです。
代わりに「光が進む向き」を保存している場合は、第4章と同じ様にマイナスの符号が必要です。

今はピクセル毎に本物の法線があるので、其れを使いましょう:

const float RADIUS = 0.8;                 // 球の半径
const vec3 ALBEDO = vec3(0.8, 0.2, 0.3);  // 表面の色(第4章と同じ)
const vec3 LIGHT_COLOR = vec3(1.0);       // 光の色
const float AMBIENT = 0.1;                // アンビエントの強さ

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  float px = 2.0 / uResolution.y;

  float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
  vec3 pos = vec3(p, z);
  vec3 n = pos / RADIUS;

  // ディレクショナルライト:表面から光への向き(左上手前)
  vec3 l = normalize(vec3(-0.5, 0.6, 0.6));
  float diffuse = max(dot(n, l), 0.0);

  vec3 sphere = ALBEDO * AMBIENT + ALBEDO * LIGHT_COLOR * diffuse;
  vec3 background = vec3(0.1);

  float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
  out_color = vec4(mix(background, sphere, mask), 1.0);
}

アルベドは表面其の物の色、つまり各色チャンネルをどれ位反射するかを表す言葉です。
2つの色を成分毎に掛ける事(ALBEDO * LIGHT_COLOR)が、光と表面の相互作用です。赤い表面は白い光の下では赤を反射しますが、純粋な青い光の下では黒になります。

左上から照らされた赤い球。左上が明るく、右下に向かって滑らかに暗くなり、其処には弱いアンビエントだけが残る

もう丸く見えます。
でも粘土やチョークの玉の様です。何も光っていません。

9.3 鏡面反射のハイライト:フォンのモデル

9.3.1 考え方

光沢のある表面は、少しだけ鏡の様に振る舞います。
完璧な鏡は光を丁度1つの方向に反射し、目が丁度其の方向にある時だけ光が見えます。
光沢はあるけれど完全には滑らかでない表面(プラスチック、磨いたリンゴ、濡れた肌)は、其の方向の周りの小さな円錐に反射を広げます。
其の結果、鏡の方向が目の近くを向く所に明るい点、ハイライトが出来ます。

1975年にブイ・トゥオン・フォンが、此れの簡単なモデルを発表しました。
光源への向きを法線について鏡映して反射の向き𝐫^を求め、𝐫^が目への向き𝐯^にどれ位近いかを内積で測り、累乗して点を小さくします:

specularPhong=max(𝐫^⋅𝐯^,0)s

指数sは光沢度(シャイニネス)です。
0と1の間の数を大きな数で累乗すると、とても速く小さくなります。0.94≈0.66ですが、0.964≈0.0012です。
従って、sが大きいと鏡の方向に非常に近い方向だけが明るくなり、小さく鋭いハイライトになります。

9.3.2 反射ベクトル

反射は線型代数の第4章の公式です。入って来る向き𝐝と単位法線𝐧^に対して:

reflect(𝐝,𝐧^)=𝐝−2(𝐝⋅𝐧^)𝐧^

GLSLには reflect(I, N) として組み込まれています。
最初の引数は入射の向き、つまり光が進む向きで、表面に向かっています。
あたし達の𝐥^は表面から離れる向きなので、反転しなければなりません:reflect(-l, n)。
此のマイナスを忘れるのが此処の典型的なバグです。ハイライトが球の反対側に出たり、全く出なかったりします。

書き出すと、𝐫^=−𝐥^−2(−𝐥^⋅𝐧^)𝐧^=2(𝐧^⋅𝐥^)𝐧^−𝐥^です。

9.3.3 計算例

9.1.1のピクセル𝐧^=(0.36,0.48,0.8)、光𝐥^=(0,0.6,0.8)(上の手前)、目𝐯^=(0,0,1)を使います。

𝐧^⋅𝐥^=0.36×0+0.48×0.6+0.8×0.8=0.288+0.64=0.928𝐫^=2×0.928×(0.36,0.48,0.8)−(0,0.6,0.8)=(0.66816,0.89088,1.4848)−(0,0.6,0.8)=(0.66816,0.29088,0.6848)𝐫^⋅𝐯^=0.6848

(確認:0.668162+0.290882+0.68482=0.44644+0.08461+0.46895=1.0000。鏡は長さを変えません。)
s=8ならハイライトは0.68488≈0.048、s=32なら0.684832≈0.0000055です。
同じピクセルの拡散反射は0.928で殆ど全力ですが、光沢度32では此のピクセルはもうハイライトの外です。

9.3.4 シェーダーで

定数を2つと鏡面反射の項を追加します:

const float SHININESS = 32.0;             // 光沢度(大きい程ハイライトが小さく鋭い)
const float SPECULAR = 0.5;               // 鏡面反射の強さ

  vec3 l = normalize(vec3(-0.5, 0.6, 0.6));
  float diffuse = max(dot(n, l), 0.0);

  // フォンの鏡面反射
  vec3 v = vec3(0.0, 0.0, 1.0); // 表面から目への向き
  vec3 r = reflect(-l, n);      // 光が反射して進む向き
  float specular = pow(max(dot(r, v), 0.0), SHININESS);

  vec3 sphere = ALBEDO * AMBIENT
              + ALBEDO * LIGHT_COLOR * diffuse
              + LIGHT_COLOR * SPECULAR * specular;

pow(x, y) はxyを計算します。
GLSLの仕様では、x<0の時、及びx=0でy≤0の時は未定義なので、前の max(..., 0.0) は省略出来ません。

鏡面反射の項にはアルベドを掛けていない事に注目して下さい。
金属以外(プラスチック、塗装、肌、木)では、ハイライトは光其の物の反射なので、表面ではなく光の色になります。
金属は違います。反射に色が付き(金は黄色を反射します)、拡散反射は殆どありません(課題3)。

左上に小さな白いハイライトがある赤い球

急に光沢のある玉になりました。
光沢度を4、16、64、256にした同じ球です:

フォンの光沢度4、16、64、256の4つの球。ハイライトが大きく柔らかい光から小さく鋭い点に変わる

9.4 ブリン・フォン:ハーフベクトル

9.4.1 考え方

1977年にジム・ブリンが小さな変更を提案し、其れは殆どあらゆる点でより良い物でした。
光を反射して目と比べる代わりに、彼はこう問いました。光を丁度目の方に反射する法線はどれか?
其の法線は𝐥^と𝐯^の丁度真ん中を向いていて、ハーフベクトルと呼ばれます:

𝐡^=𝐥^+𝐯^‖𝐥^+𝐯^‖,specularBlinn=max(𝐧^⋅𝐡^,0)s

2つの単位ベクトルを足すと、其れらが作る菱形の対角線に沿ったベクトルになるので、丁度真ん中を向きます。正規化すれば又単位ベクトルになります。
本当の法線𝐧^が𝐡^に近ければ、表面は「光と目の間の殆ど鏡」で、ハイライトは明るくなります。

表面の1点でのベクトル。真上の法線n、左の光源へのl、右の其の鏡像r、目へのv、lとvの間のハーフベクトルh。rとvの間の角度αは、nとhの間の角度βの2倍

図の様に4つのベクトルが全て1つの平面上にある時、𝐫^と𝐯^の間の角度αは、𝐧^と𝐡^の間の角度βの丁度2倍です。
(図では𝐥^が135°、𝐯^が70°なので、𝐫^は45°、𝐡^は102.5°です。α=25°、β=12.5°。)
角度が小さいので、同じ光沢度ならブリン・フォンの方がハイライトが大きくなります。
大体の目安として、光沢度4sのブリン・フォンは、光沢度sのフォンと同じ位に見えます。β=10°でcos(20°)32≈0.137、cos(10°)128≈0.141です。

9.4.2 同じ計算例

同じピクセル、同じ光、同じ目で:

𝐥^+𝐯^=(0,0.6,0.8)+(0,0,1)=(0,0.6,1.8)‖𝐥^+𝐯^‖=0.36+3.24=3.6≈1.8974𝐡^≈(0,0.3162,0.9487)𝐧^⋅𝐡^≈0.48×0.3162+0.8×0.9487≈0.1518+0.7589=0.9107

s=8なら0.91078≈0.473、s=32なら0.910732≈0.050です。
フォンはs=8で0.048でした。ブリン・フォンで同じ値にするには、指数が約4倍必要です。
(此処での角度はフォンが46.8°、ブリン・フォンが24.4°です。4つのベクトルが1つの平面上に無いので、丁度2対1ではありません。)

9.4.3 シェーダーで

変わるのは2行です:

  // ブリン・フォンの鏡面反射
  vec3 v = vec3(0.0, 0.0, 1.0); // 表面から目への向き
  vec3 h = normalize(l + v);    // ハーフベクトル
  float specular = pow(max(dot(n, h), 0.0), SHININESS);

ブリン・フォンのハイライトの赤い球。同じ光沢度32でも、白い点はフォンよりはっきり大きく柔らかい

違いを直接見る為に、左半分がフォン、右半分がブリン・フォンの球を用意しました。どちらも光沢度32で、光は計算例と同じ(0,0.6,0.8)です(p.x < 0.0 ? phong : blinn でピクセル毎にどちらかを選びます):

1つの球で左半分がフォン、右半分がブリン・フォン、同じ光沢度。ハイライトが2つに切れていて、右の方が広い

9.4.4 何故ブリン・フォンが勝ったのか

  • ディレクショナルライトでは安い。 ディレクショナルライトと一定の視線の向きなら、𝐡^は全てのピクセルで同じで、CPUで一度だけ計算する事も出来ます。reflect はピクセル毎に実行しなければなりません。
  • 途切れない。 𝐫^と𝐯^の間の角度が90°を超えると、𝐫^⋅𝐯^は負になり、フォンのハイライトは0にクランプされ、光沢度が低いと境目が見えます。𝐧^⋅𝐡^は其の範囲のずっと広い所で正のままです。
  • 現実に近い。 実際の表面のハイライトを測ると、浅い角度で見た時に引き伸ばされます(濡れた道路に映る太陽の長い筋を思い浮かべて下さい)。ブリン・フォンは其の伸びを再現しますが、フォンのハイライトは丸いままです。
  • 現代のレンダリングにも生き残っている。 今のエンジンが使う物理ベースのモデルは、表面を沢山の小さな鏡として扱い、其の内どれ位が𝐡^の方を向いているかを問います。ハーフベクトルは其の全ての中心にあります。

シェーダー以前の古いOpenGLの固定機能パイプラインもブリン・フォンを使っていて、デフォルトでは目の向きを、あたし達と同じ(0,0,1)と仮定していました。

9.4.5 後ろからの光

ブリン・フォンには罠が一つあります。
光が表面の後ろにある時、𝐧^⋅𝐥^<0で拡散反射は0ですが、特に光が表面を掠める時、𝐧^⋅𝐡^はまだ正になり得ます。
すると暗い側にハイライトが出てしまい、そんな事をする現実の物体はありません。
此処では光が上の後ろ(0,1,−0.6)から来て、問題が見え易い様に光沢度は低い2にしています(左半分がフォン、右半分がブリン・フォン):

左:上の後ろからの光で、鏡面反射の光が球の暗い下の部分に大きく漏れている。特にブリン・フォンの半分。右:チェックを入れると、影の線より下は全て暗い

直し方は、表面が少しでも光の方を向いている時だけハイライトを計算する事、if (diffuse > 0.0) です。
此の様な低い光沢度では、ハイライトが切られる所に硬い境目が見えます。現実的な光沢度なら、其の境目はハイライトが既にとても弱い所に来ます。

9.5 アンビエント・拡散反射・鏡面反射を合わせる

整理しましょう。
直ぐに光が動き、第10章では光が幾つも出て来るので、光1つ分の仕事を関数にまとめます:

const float RADIUS = 0.8;                 // 球の半径
const vec3 ALBEDO = vec3(0.8, 0.2, 0.3);  // 表面の色(第4章と同じ)
const vec3 LIGHT_COLOR = vec3(1.0);       // 光の色
const float AMBIENT = 0.1;                // アンビエントの強さ
const float SHININESS = 64.0;             // 光沢度(大きい程ハイライトが小さく鋭い)
const float SPECULAR = 0.5;               // 鏡面反射の強さ

// 1つの光からの拡散反射と鏡面反射(ブリン・フォン)
//   n:法線、v:目への向き、l:光への向き(全て単位ベクトル)
vec3 blinnPhong(vec3 n, vec3 v, vec3 l, vec3 lightColor) {
  float diffuse = max(dot(n, l), 0.0);
  float specular = 0.0;
  if (diffuse > 0.0) {
    vec3 h = normalize(l + v);
    specular = pow(max(dot(n, h), 0.0), SHININESS);
  }
  return (ALBEDO * diffuse + SPECULAR * specular) * lightColor;
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  float px = 2.0 / uResolution.y;

  float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
  vec3 pos = vec3(p, z);
  vec3 n = pos / RADIUS;
  vec3 v = vec3(0.0, 0.0, 1.0);
  vec3 l = normalize(vec3(-0.5, 0.6, 0.6));

  vec3 ambient = ALBEDO * AMBIENT;
  vec3 sphere = ambient + blinnPhong(n, v, l, LIGHT_COLOR);
  vec3 background = vec3(0.1);

  float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
  out_color = vec4(mix(background, sphere, mask), 1.0);
}

アルベド𝐚、光の色𝐜L、アンビエントの強さka、鏡面反射の強さksで、モデル全体を1つの式にすると(色同士の積は全て成分毎です):

𝐜=ka𝐚+(𝐚max(𝐧^⋅𝐥^,0)+ksmax(𝐧^⋅𝐡^,0)s)𝐜L

此の3つの部分が、「ADS」モデル(アンビエント、ディフューズ、スペキュラー)と呼ばれる物です。
3つの項を其々単独で表示した物と、其の合計です:

4つの球。アンビエントだけ(暗く平らな赤い円)、拡散反射だけ(陰影のある赤、奥側は黒)、鏡面反射だけ(黒の上の白い点)、3つを足した物

各項が他の項の弱点を補っています。
アンビエントだけでは形が無く、拡散反射だけでは奥側が真っ黒になり(現実では光が部屋の中で跳ね返って少し照らします)、鏡面反射だけではただの点です。
上の様に各項を単独で表示出来る様にシェーダーを書くのは、ライティングをデバッグする一番良い方法でもあります。

ディレクショナルライトの最終的な球。陰影のある赤に、光沢度64の鋭い白いハイライト

9.6 ポイントライト

9.6.1 ピクセル毎の向き

ディレクショナルライトは太陽の様に無限に遠くにあり、全てのピクセルが同じ𝐥^を受け取ります。
ポイントライトは電球の様に位置𝐋を持ち、全ての方向に光を出します。
すると光源への向きは、表面の点𝐏が何処にあるかに依存します:

𝐭=𝐋−𝐏,d=‖𝐭‖,𝐥^=𝐭d

「目標引く始点」は、線型代数の第3章のAからBへの向きです。
どうせ距離dが必要なので(下で使います)、normalize と length を呼ぶ(同じ平方根を2回計算する)代わりに、長さを1回計算して其れで割ります。

9.6.2 減衰

電球は部屋の向こうの壁よりも、机をずっと明るく照らします。
物理的な理由は簡単です。電球を出た光は球の表面に広がり、半径dの球の面積は4πd2です。
2倍遠くでは同じ光が4倍の面積に広がるので、各点が受け取るのは4分の1です。
此れが逆2乗の法則です:

attenuation=1d2

シェーダーで其のまま使うと、問題が2つあります。
d=0で無限大になり、光のすぐ近くではモニターが表示出来る何よりもずっと明るくなります。
そして決して0にならないので、全ての光が世界中の全てのピクセルに影響します。

古典的な解決策は、此れも固定機能のOpenGL(GL_CONSTANT_ATTENUATION、GL_LINEAR_ATTENUATION、GL_QUADRATIC_ATTENUATION)から来た、3つの定数を持つ多項式です:

attenuation(d)=1kc+kld+kqd2

  • kc(定数項)は普通1で、光源の位置で減衰が1になり、1を超える事はありません。
  • kl(1次の項)は光を早めに弱くします。
  • kq(2次の項)は物理的な部分で、光源から遠い所で支配的になります。

此れらを手で選ぶ人はいません。
光の届く範囲毎の定数の此の表は、Ogre3Dエンジンのwikiから来た物で、インターネットの至る所で使われています(LearnOpenGLが広めました)。
「範囲」は、大体光が無視出来る程になるワールド単位の距離です:

範囲 kc kl kq
7 1.0 0.7 1.8
13 1.0 0.35 0.44
20 1.0 0.22 0.20
32 1.0 0.14 0.07
50 1.0 0.09 0.032
100 1.0 0.045 0.0075
200 1.0 0.022 0.0019
600 1.0 0.007 0.0002

あたし達のシーン全体は幅が約2単位なので、一番小さい(1,0.7,1.8)を使います。
計算例:

d=0.5:11+0.35+0.45=11.8≈0.556d=1:11+0.7+1.8=13.5≈0.286d=2:11+1.4+7.2=19.6≈0.104

定数1, 0.7, 1.8(速く落ちる)、1, 0.35, 0.44、1, 0.09, 0.032(ゆっくり落ちる)の減衰曲線。全て1から始まる。0の近くで無限大に跳ね上がる純粋な逆2乗1割るdの2乗と比較

3本の曲線は全て光源の位置で丁度1から始まり、遠くでは物理法則の1kqd2の様に振る舞います。
純粋な1d2(破線)を見ると、定数項が必要な理由が分かります。

9.6.3 材質、壁、ポイントライト

ポイントライトをちゃんと見る為には、球以外にも照らす物が必要です。
球の真後ろに壁を置きます。平面z=−rで、法線(0,0,1)は此方を向いています。
球の外の全てのピクセルからは、点(px,py,−r)の壁が見えます。
壁は灰色でつや消し、球は赤くて光沢があるべきなので、材質の値を構造体(struct)にまとめます:

// 表面の材質
struct Material {
  vec3 albedo;      // 表面の色
  float specular;   // 鏡面反射の強さ
  float shininess;  // 光沢度(大きい程ハイライトが小さく鋭い)
};

const float RADIUS = 0.6;        // 球の半径
const float WALL_Z = -RADIUS;    // 壁の位置(球の真後ろ)
const float AMBIENT = 0.1;       // アンビエントの強さ
const vec3 LIGHT_COLOR = vec3(1.0, 0.95, 0.85) * 3.0; // 光の色と明るさ
const vec3 ATTENUATION = vec3(1.0, 0.7, 1.8);         // 減衰の定数(定数項、1次、2次)

const Material SPHERE = Material(vec3(0.8, 0.2, 0.3), 0.5, 64.0);
const Material WALL = Material(vec3(0.7, 0.7, 0.75), 0.1, 8.0);

// 1つの光からの拡散反射と鏡面反射(ブリン・フォン)
//   n:法線、v:目への向き、l:光への向き(全て単位ベクトル)
vec3 blinnPhong(vec3 n, vec3 v, vec3 l, Material m, vec3 lightColor) {
  float diffuse = max(dot(n, l), 0.0);
  float specular = 0.0;
  if (diffuse > 0.0) {
    vec3 h = normalize(l + v);
    specular = pow(max(dot(n, h), 0.0), m.shininess);
  }
  return (m.albedo * diffuse + m.specular * specular) * lightColor;
}

// ポイントライト:光の位置から向きと減衰を求める
vec3 pointLight(vec3 pos, vec3 n, vec3 v, Material m, vec3 lightPos, vec3 lightColor) {
  vec3 toLight = lightPos - pos;
  float d = length(toLight);   // 光までの距離
  vec3 l = toLight / d;        // normalize(toLight)と同じ
  float attenuation = 1.0 / (ATTENUATION.x + ATTENUATION.y * d + ATTENUATION.z * d * d);
  return blinnPhong(n, v, l, m, lightColor) * attenuation;
}

// 表面の1点の色
vec3 shade(vec3 pos, vec3 n, Material m, vec3 lightPos) {
  vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き
  vec3 color = m.albedo * AMBIENT;
  color += pointLight(pos, n, v, m, lightPos, LIGHT_COLOR);
  return color;
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  float px = 2.0 / uResolution.y;

  vec3 lightPos = vec3(-0.7, 0.5, 0.7); // 光の位置(左上、球の手前)

  // 球
  float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
  vec3 spherePos = vec3(p, z);
  vec3 sphere = shade(spherePos, spherePos / RADIUS, SPHERE, lightPos);

  // 壁(z = WALL_Zの平面、法線は手前を向く)
  vec3 wall = shade(vec3(p, WALL_Z), vec3(0.0, 0.0, 1.0), WALL, lightPos);

  float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
  out_color = vec4(mix(wall, sphere, mask), 1.0);
}

幾つか注目して下さい:

  • 壁が沢山見える様に、球を小さく(0.6)しました。
  • 光の色は * 3.0 です。減衰で全てが暗くなるので、ポイントライトが明るく見えるには1.0より大きい値が必要です。球の一番近い点では光までの距離が約0.51で、減衰は約0.55です。
  • pointLight と shade は表面の点の位置 pos を受け取ります。此れが新しい材料で、此れまでのライティングには法線しか必要ありませんでした。
  • 定数ベクトル ATTENUATION に3つの定数を入れています。.x、.y、.z は3つの数を保存するコンパクトな方法に過ぎません。

球の左上手前のポイントライト。明るく鋭いハイライトの周りに球の上の赤い光の溜まりがあり、球の残りは暗い。灰色の壁は左上が明るく照らされ、端に向かって暗くなる

ディレクショナルライトと比べてみて下さい。
近くにある光は球の其の光に向いた側にしか届かないので、照らされた部分が小さくなっています。
そして壁は距離と共に消えていく柔らかい光の溜まりになっています。其の円が、目に見える減衰です。

9.7 光を動かす

動く光は、どんな静止画よりもずっと良く形を見せてくれます。
uTime で球の周りを回らせるか、uMouse でマウスを追わせましょう。
2つの内どちらにするかは、プリプロセッサー(第2章)で選びます。上の方に #define FOLLOW_MOUSE 0 を書き、main の中で:

// 1にするとマウスで光を動かす、0にすると時間で光が回る
#define FOLLOW_MOUSE 0

  // 光の位置(zは球より手前)
#if FOLLOW_MOUSE
  vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y; // pと同じ座標にする
  vec3 lightPos = vec3(mouse, 0.7);
#else
  vec3 lightPos = vec3(0.9 * cos(uTime * 1.25), 0.6 * sin(uTime * 1.25), 0.7);
#endif

uMouse は左下が原点のピクセル単位なので(第5章)、p と全く同じ式で変換します。
軌道は楕円(0.9cost,0.6sint)で、ウィンドウが縦より横に長いので横に広くしています。uTime * 1.25 で1周に2π/1.25≈5秒掛かります。
zの0.7で光を球の手前に保ちます。z=0.2の光は位置によっては球の中に入ってしまい、其の時は見えている表面全体が光と反対を向きます。

最後に、ライティングが何をしているか分かる様に、光の位置に小さな白い点を表示します。
main の最後の2行を置き換えて下さい:

  float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
  vec3 color = mix(wall, sphere, mask);

  // 光の位置に小さな白い点を描く
  float marker = 1.0 - smoothstep(0.03 - px, 0.03, length(p - lightPos.xy));
  color = mix(color, vec3(1.0), marker);

  out_color = vec4(color, 1.0);

(変数を dot と名付けないで下さい。GLSLではローカル変数が組み込み関数を隠せるので、同じブロックの次の dot(...) の呼び出しがコンパイルエラーになります。)

アニメーションの1コマ。光の点が球の上にあり、ハイライトと照らされた部分が球の上側にあって、壁は其の上が一番明るい

#define FOLLOW_MOUSE 1 にしてRを押すと、光がカーソルを追います:

マウスで右上に置いた光の点。ハイライトは球の右上の部分に移動した

ハイライトが決して光の点の真下に無い事に注目して下さい。
ハイライトは球の法線が光と目の真ん中を向く所にあり、目は真正面にあるので、ハイライトは光の点と球の中心の間に来ます。

9.8 此の章の新しいGLSLの機能

機能 何をするか
reflect(I, N) 𝐈−2(𝐍⋅𝐈)𝐍。Iは入って来る向き、Nは正規化されている事
pow(x, y) xy。xが0未満の時、及びx = 0でyが0以下の時は未定義
sqrt(x) x。xが0未満の時は未定義
normalize(v) 𝐯/‖𝐯‖。ゼロベクトルでは未定義
struct Name { ... }; 幾つかの値をまとめる型。.name で読む
Material(a, b, c) 全ての構造体が持つコンストラクター。値は宣言の順
const Material X = ... 定数の構造体。コンストラクターの引数が定数なので使える
#define、#if プリプロセッサー(第2章)。コンパイルの前にコードを有効・無効にする

  • reflect は「光源への向きを反射した物」とよく誤解されます。進む向きなので -l を渡して下さい。
  • GLSLの構造体は関数(メソッド)やデフォルト値を持てませんが、関数に渡したり、返したり、== で比べたり、入れ子にしたり出来ます。
  • 関数の引数はコピー(第2章の in 引数)なので、関数は呼び出し元に影響を与えずに Material m を変更出来ます。第10章で其れを使います。
  • 此処の全ては、macOSのOpenGL 4.1でも其のまま動きます。

9.9 実際のゲームやエンジンでの使われ方

  • 1990〜2010年の主力。 ブリン・フォンは固定機能のOpenGL(GL_SHININESS は0〜128に制限されていました)とDirect3Dのライティングであり、PS2、Xbox 360、PS3の時代の殆どのゲームのライティングでした。最初は頂点毎、後にピクセル毎です。安くて調整し易いので、今でも多くのインディーゲームやスタイライズされたゲームが使っています。
  • 物理ベースレンダリング。 現代のエンジン(Unreal、Unity、Godot、Frostbite)はGGXの様な「PBR」のモデルを使います。見た目はブリン・フォンと違いますが、入力は此の章と全く同じです。𝐧^、𝐥^、𝐯^、そしてハーフベクトル𝐡^と𝐧^⋅𝐡^です。光沢度は「ラフネス」に、鏡面反射の色は「メタルネス」になります。先ずブリン・フォンを学ぶのが普通の道です。
  • 沢山のポイントライト。 ゲームのレベルには何百ものポイントライト(ランプ、マズルフラッシュ、光る魔法)がある事があります。其の為エンジンは各光に半径を与えて其の外では無視し、光が届くピクセルにだけ描きます(ディファードシェーディング又はクラスターシェーディング。OpenGLシリーズで少し触れます)。多項式の減衰は決して0にならないので、エンジンは半径で丁度0になる様に作られた減衰曲線を使います(課題7)。
  • インポスター。 分子ビューアー、パーティクルシステム、遠くの木は、あたし達の球と同じ様に、球や他の形をピクセル毎の表面を持つ平らな四角形として描きます。第14章(レイマーチング)では、此の考え方を3Dのシーン全体にまで広げます。

9.10 まとめ

  • −z方向に見る球では、ピクセル𝐩の下の表面の点はz=r2−px2−py2で、法線は其の点をrで割った物です。
  • ライティングのベクトルは全て1つの空間に無ければなりません。此処では正射影のカメラのビュー空間なので、𝐯^=(0,0,1)です。
  • 拡散反射:max(𝐧^⋅𝐥^,0)。𝐥^は光源の方を向きます。
  • フォン:r = reflect(-l, n) でmax(𝐫^⋅𝐯^,0)s。ブリン・フォン:h = normalize(l + v) でmax(𝐧^⋅𝐡^,0)s。同じ大きさのハイライトには約4倍の指数が必要です。
  • 鏡面反射は𝐧^⋅𝐥^>0の所だけに足します。金属以外のハイライトは表面ではなく光の色です。
  • ポイントライト:𝐥^=(𝐋−𝐏)/d、減衰は1/(kc+kld+kqd2)で、定数は範囲の表から選びます。
  • ライティングは、1項ずつテストして表示出来る小さな関数(blinnPhong、pointLight、shade)として書きます。

9.11 課題

  1. ★ 床の点で𝐧^=(0,0,1)、光の向き𝐥^=(0.6,0,0.8)、目𝐯^=(0,0,1)とします。𝐫^と、s=2及びs=4のフォンの鏡面反射を計算し、次に𝐡^と、s=2及びs=4のブリン・フォンの鏡面反射を計算して下さい。(フォンは0.64と0.4096、ブリン・フォンは0.9と0.81になる筈です。)
  2. ★ 定数(1,0.7,1.8)で、d=0.25とd=1.5の減衰を計算して下さい。次に1.8d2+0.7d+1=2を解いて、光が丁度半分になる距離を求めて下さい(約0.58)。
  3. ★★ 球を金にして下さい。拡散反射のアルベドは殆ど黒にし、ハイライトには普通の m.specular の代わりに鏡面反射の色 vec3(1.0, 0.77, 0.34) を掛けます(Material に vec3 specularColor を追加します)。暗い玉に金色のハイライトがあり、光が近い所が金色に光って見える筈です。
  4. ★★ 2つ目のポイントライトを追加して下さい。青い vec3(0.3, 0.5, 1.0) * 3.0 の光で、逆向き(-uTime)に回り、1つ目の光はマウスを追います。shade で pointLight の呼び出しを1つ足すだけです。ハイライトが2つと、壁に光の溜まりが2つ見え、重なる所は紫になる筈です。
  5. ★★ 透視投影の目を作って下さい。カメラを𝐂=(0,0,3)に置き、shade で v = normalize(C - pos) を使います。壁のハイライトが見え易い様に、壁の鏡面反射の強さを0.5、光沢度を32にします。正射影の目では壁のハイライトは光の点の真下にありますが、透視投影の目では光の点とウィンドウの中心の間(外向きに約0.74の所)に移動します。其処で壁が光をカメラに向けて鏡の様に反射するからです。
  6. ★★ 鏡面反射の項だけを表示するデバッグビューを書いて下さい。左半分がフォンで光沢度 SHININESS、右半分がブリン・フォンで光沢度 4.0 * SHININESS です。マウスで光を動かすと、球の縁の近く以外では2つの半分が殆ど完璧に一致する筈です。
  7. ★★★ Unreal Engine 4は、半径Rの光にclamp(1−(d/R)4,0,1)2d2+1という減衰を使います。R=1.5で実装して多項式と比べて下さい。d≥Rで丁度0になる事を示し、其れが何百もの光があるエンジンにとって何故重要なのか説明して下さい。何故d2ではなくd2+1を使うのでしょうか?

DIFFをダウンロード

ファイル: a/shader/playground.glsl | b/shader/playground.glsl

前新
@@ -7,16 +7,80 @@ uniform float uTime; // 起動してからの秒数
7 uniform vec2 uResolution; // 画面の大きさ(ピクセル) 7 uniform vec2 uResolution; // 画面の大きさ(ピクセル)
8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点) 8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点)
9   9  
    10 // 1にするとマウスで光を動かす、0にすると時間で光が回る
    11 #define FOLLOW_MOUSE 0
    12  
    13 // 表面の材質
    14 struct Material {
    15 vec3 albedo; // 表面の色
    16 float specular; // 鏡面反射の強さ
    17 float shininess; // 光沢度(大きい程ハイライトが小さく鋭い)
    18 };
    19  
    20 const float RADIUS = 0.6; // 球の半径
    21 const float WALL_Z = -RADIUS; // 壁の位置(球の真後ろ)
    22 const float AMBIENT = 0.1; // アンビエントの強さ
    23 const vec3 LIGHT_COLOR = vec3(1.0, 0.95, 0.85) * 3.0; // 光の色と明るさ
    24 const vec3 ATTENUATION = vec3(1.0, 0.7, 1.8); // 減衰の定数(定数項、1次、2次)
    25  
    26 const Material SPHERE = Material(vec3(0.8, 0.2, 0.3), 0.5, 64.0);
    27 const Material WALL = Material(vec3(0.7, 0.7, 0.75), 0.1, 8.0);
    28  
    29 // 1つの光からの拡散反射と鏡面反射(ブリン・フォン)
    30 // n:法線、v:目への向き、l:光への向き(全て単位ベクトル)
    31 vec3 blinnPhong(vec3 n, vec3 v, vec3 l, Material m, vec3 lightColor) {
    32 float diffuse = max(dot(n, l), 0.0);
    33 float specular = 0.0;
    34 if (diffuse > 0.0) {
    35 vec3 h = normalize(l + v);
    36 specular = pow(max(dot(n, h), 0.0), m.shininess);
    37 }
    38 return (m.albedo * diffuse + m.specular * specular) * lightColor;
    39 }
    40  
    41 // ポイントライト:光の位置から向きと減衰を求める
    42 vec3 pointLight(vec3 pos, vec3 n, vec3 v, Material m, vec3 lightPos, vec3 lightColor) {
    43 vec3 toLight = lightPos - pos;
    44 float d = length(toLight); // 光までの距離
    45 vec3 l = toLight / d; // normalize(toLight)と同じ
    46 float attenuation = 1.0 / (ATTENUATION.x + ATTENUATION.y * d + ATTENUATION.z * d * d);
    47 return blinnPhong(n, v, l, m, lightColor) * attenuation;
    48 }
    49  
    50 // 表面の1点の色
    51 vec3 shade(vec3 pos, vec3 n, Material m, vec3 lightPos) {
    52 vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き
    53 vec3 color = m.albedo * AMBIENT;
    54 color += pointLight(pos, n, v, m, lightPos, LIGHT_COLOR);
    55 return color;
    56 }
    57  
10 void main() { 58 void main() {
11 // ピクセル座標を0〜1に正規化 59 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
12 vec2 uv = gl_FragCoord.xy / uResolution; 60 float px = 2.0 / uResolution.y;
    61  
    62 // 光の位置(zは球より手前)
    63 #if FOLLOW_MOUSE
    64 vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y; // pと同じ座標にする
    65 vec3 lightPos = vec3(mouse, 0.7);
    66 #else
    67 vec3 lightPos = vec3(0.9 * cos(uTime * 1.25), 0.6 * sin(uTime * 1.25), 0.7);
    68 #endif
    69  
    70 // 球
    71 float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
    72 vec3 spherePos = vec3(p, z);
    73 vec3 sphere = shade(spherePos, spherePos / RADIUS, SPHERE, lightPos);
    74  
    75 // 壁(z = WALL_Zの平面、法線は手前を向く)
    76 vec3 wall = shade(vec3(p, WALL_Z), vec3(0.0, 0.0, 1.0), WALL, lightPos);
13   77  
14 // 時間で動くグラデーション 78 float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
15 vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0)); 79 vec3 color = mix(wall, sphere, mask);
16   80  
17 // マウスの周りに白い円 81 // 光の位置に小さな白い点を描く
18 float d = length(gl_FragCoord.xy - uMouse); 82 float marker = 1.0 - smoothstep(0.03 - px, 0.03, length(p - lightPos.xy));
19 color = mix(color, vec3(1.0), 1.0 - smoothstep(18.0, 20.0, d)); 83 color = mix(color, vec3(1.0), marker);
20   84  
21 out_color = vec4(color, 1.0); 85 out_color = vec4(color, 1.0);
22 } 86 }


コメント(0)

まだコメントはありません。