10. ライティング パート3:スポットライト・ヘミスフィアライト・エリアライト

第4章で、光の種類を6つ挙げました。アンビエント、ディレクショナル、ポイント、スポット、ヘミスフィア、エリアです。
第4章で最初の2つを、第9章でブリン・フォンのハイライト付きのポイントライトを作りました。
此の章では残りの3つを、全て第9章の球と壁の上で作ります:

  • スポットライト:懐中電灯の様に、円錐の中だけを照らすポイントライトです。
  • ヘミスフィアライト:上から空の色、下から地面の色が来る、より良いアンビエントライトです。
  • エリアライト:1点ではなく面や線から来る光です。2つの違う方法で近似します。

其の後、沢山の光を1つのシーンに入れると、全てのレンダラーが解決しなければならない2つの問題にぶつかります。画像がモニターで表示出来るより明るくなる事と、此れまで計算して来た色がそもそも正しくなかった事です。
其処からトーンマッピングとガンマ補正に進みます。コードは小さいですが、此のシリーズの此の先全てにとって重要です。

殆どの作業は shader/playground.glsl で行います。
最後に main.c にキーを1つ追加します(G:ガンマ補正のオンとオフを切り替えます)。
第9章の最後のシェーダーから始めます。

10.1 沢山の光の為の準備

光が幾つもあると、各光に其々の位置、色、減衰が必要です。
其処で、第9章で材質に行った事を光にも行い、構造体を作ります。そして減衰の多項式を小さな関数に移します:

// 光源
struct Light {
  vec3 position;     // 位置
  vec3 color;        // 色と明るさ
  vec3 attenuation;  // 減衰の定数(定数項、1次、2次)
};

// 減衰の定数(第9章の表から)
const vec3 SHORT_RANGE = vec3(1.0, 0.7, 1.8);    // 近くだけを照らす
const vec3 MEDIUM_RANGE = vec3(1.0, 0.35, 0.44); // 中くらい
const vec3 LONG_RANGE = vec3(1.0, 0.09, 0.032);  // 遠くまで届く

// 距離dでの減衰(kは定数項、1次、2次)
float attenuate(float d, vec3 k) {
  return 1.0 / (k.x + k.y * d + k.z * d * d);
}

// ポイントライト
vec3 pointLight(vec3 pos, vec3 n, vec3 v, Material m, Light light) {
  vec3 toLight = light.position - pos;
  float d = length(toLight);
  vec3 l = toLight / d;
  return blinnPhong(n, v, l, m, light.color) * attenuate(d, light.attenuation);
}

// i番目のポイントライトの位置(120°ずつずれて回る)
vec3 orbit(int i) {
  float a = uTime * 1.25 + float(i) * radians(120.0);
  return vec3(0.9 * cos(a), 0.6 * sin(a), 0.7);
}

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

第9章の ATTENUATION 定数は無くなりました。3つの範囲は第9章の表から取り、各光が其の内1つを選びます。
shade は lightPos 引数が要らなくなったので、main で lightPos を計算していた #if FOLLOW_MOUSE のブロックは無くなり(マウスは10.2でスポットライトと共に戻って来ます)、2つの呼び出しは shade(spherePos, spherePos / RADIUS, SPHERE) と shade(vec3(p, WALL_Z), vec3(0.0, 0.0, 1.0), WALL) になり、印には lightPos.xy の代わりに orbit(0).xy を使います。
orbit(i) は既に番号を受け取るので、光を幾つにも出来ます。光 i は第9章と同じ楕円の上をi×120°先に進んだ所にあります。
radians(x) は度をラジアンに変換します(x⋅π180)。GLSLにはπの組み込み定数が無いので、此れが読み易い角度を書く一番簡単な方法です。

画面は第9章の最後と全く同じです。
其れがリファクタリングの要点です。構造を変え、他に何も変わっていない事を確かめます。

10.2 スポットライト

10.2.1 円錐

スポットライトは、向き𝐝^(何処を向いているか)と其の周りの円錐を持つポイントライトです。
円錐の中ではポイントライトと同じ様に照らし、外では何も照らしません。

表面の点𝐏が円錐の中にあるかどうかは、どうやって分かるのでしょうか?
既に𝐥^、つまり𝐏から光源への向きがあります。
其れを反転すると、−𝐥^は光源から𝐏への向きです。
其れと光軸𝐝^の間の角度θが、点がどれ位軸から外れているかを教えてくれます。2つの単位ベクトルなら、其のコサインは内積です(線型代数の第4章):

cosθ=(−𝐥^)⋅𝐝^

半角θmaxの円錐の中にあるのは、θ<θmaxの時です。
θ其の物を acos で計算する事はしません。
コサインは(0°から180°の間では)角度が大きくなる程小さくなるので、「角度がθmaxより小さい」は「コサインがcosθmaxより大きい」と同じで、cosθmaxは一度だけ計算する定数です。

10.2.2 柔らかい縁

硬い cosAngle > cosMax では鋭くギザギザの円になり、本物のスポットライトは其の様には見えません。縁では光が数度に渡って消えていきます。
其の為、円錐を2つ使います。
内側の円錐の中では光は全力、外側の円錐の外では0、其の間は smoothstep(第6章)で消えていきます:

cone=smoothstep(cosθout,cosθin,cosθ)

横から見たスポットライト。左の光源がdに沿って壁を向いている。半角θ inの内側の円錐は全力で照らされ、内側と外側の円錐の間では光が1から0に消え、外側の円錐の外には光が無い。消える領域の点Pと、dとマイナスlの間の角度θ

境界の順番に注目して下さい。
大きい外側の角度のコサインの方が小さい数なので、cosθoutが最初の引数です。smoothstep は最初の境界が2番目より小さくなければなりません(逆にすると未定義になる理由は第6章で説明しました)。

θin=8°、θout=12°での計算例:

cos8°≈0.99027,cos12°≈0.97815θ=5°:cos5°≈0.99619>0.99027⇒cone=1θ=10°:cos10°≈0.98481,t=0.98481−0.978150.99027−0.97815≈0.5495cone=t2(3−2t)≈0.302×1.901≈0.574θ=13°:cos13°≈0.97437<0.97815⇒cone=0

10.2.3 シェーダーで

スポットライトは pointLight を再利用し、結果に円錐の係数を掛けます:

// スポットライト
const vec3 SPOT_POS = vec3(-0.8, 0.8, 1.6);           // 位置(左上手前)
const vec3 SPOT_COLOR = vec3(1.0, 0.95, 0.8) * 1.5;   // 色と明るさ
const float COS_INNER = cos(radians(8.0));           // 内側の円錐(此処までは全力)
const float COS_OUTER = cos(radians(12.0));          // 外側の円錐(此処から外は真っ暗)

// スポットライト:ポイントライトを円錐の中だけにする
//   dir:光軸の向き(単位ベクトル)
vec3 spotLight(vec3 pos, vec3 n, vec3 v, Material m, Light light, vec3 dir) {
  vec3 l = normalize(light.position - pos);
  float cosAngle = dot(-l, dir); // 光軸と「光から表面への向き」の間の角度のコサイン
  float cone = smoothstep(COS_OUTER, COS_INNER, cosAngle);
  return pointLight(pos, n, v, m, light) * cone;
}

const float COS_INNER = cos(radians(8.0)); は使えます。定数の引数で呼ばれた組み込み関数は定数式で、コンパイラーが一度だけ計算します。
此れはmacOSの4.1を含め、あたし達が使う全てのGLSLのバージョンで動きます。

何処に向けましょうか?
時間と共に動き回る壁の上の点か、マウスを追う点に向けましょう。
向きは「目標引く位置」を正規化した物です:

// 1にするとスポットライトがマウスを追う、0にすると時間で動く
#define FOLLOW_MOUSE 0

// マウスの位置をpと同じ座標にする
vec2 mousePos() {
  return (2.0 * uMouse - uResolution) / uResolution.y;
}

// スポットライトが狙う壁の点
vec3 spotTarget() {
#if FOLLOW_MOUSE
  return vec3(mousePos(), WALL_Z);
#else
  return vec3(0.8 * sin(uTime * 0.7), 0.4 * sin(uTime * 1.1), WALL_Z);
#endif
}

速さの違う2つのサイン波(0.7と1.1)は長い間繰り返さない図形を作るので、スポットが見張りの懐中電灯の様に壁を探し回っている様に見えます。
shade では、ポイントライトの後にスポットライトを足します:

vec3 shade(vec3 pos, vec3 n, Material m) {
  vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き
  vec3 color = m.albedo * AMBIENT;
  // color += pointLight(pos, n, v, m, Light(orbit(0), LIGHT_COLOR, SHORT_RANGE));

  Light spot = Light(SPOT_POS, SPOT_COLOR, LONG_RANGE);
  vec3 spotDir = normalize(spotTarget() - SPOT_POS);
  color += spotLight(pos, n, v, m, spot, spotDir);
  return color;
}

スポットライトだけを見られる様に、一旦ポイントライトをコメントアウトしました。
スポットライトはポイントライトより遠く(壁から約2.5単位)にあるので、遠くまで届く減衰を使います。

スポットライトだけ。暗いシーンで、球の右上の壁に楕円形の光のスポットがあり、円錐が触れる球の上部に赤い部分がある

光が斜めに当たるので、壁の上のスポットは円ではなく楕円です。
そして球は光を全く遮りません。円錐が球の後ろを通っても、其処の壁は照らされたままです。
影はありません。光は其の光の方を向いている全ての点に届きます。
影には光と表面の間に何があるかを知る必要があり、其れは第14章のレイマーチングと、OpenGLシリーズのシャドウマップで出て来ます。

ポイントライトのコメントを外すと、2つの光が足し合わされます。

10.3 ヘミスフィアライト

10.3.1 向きのあるアンビエントライト

あたし達のアンビエントの項はまだ m.albedo * AMBIENT で、全ての方向から同じ量の光です。
屋外では此れは明らかに間違っています。
上の空は明るく青っぽく、下の地面はもっと暗く、草や砂や石の色をしています。
上を向いた表面は殆ど空を見て、下を向いた表面は殆ど地面を見て、垂直な壁は其々半分ずつ見ます。

ヘミスフィアライトは正に此れを、2つの色と1つの向き「上」でモデル化します:

t=12+12(𝐧^⋅𝐮^),ambient=mix(𝐜ground,𝐜sky,t)

𝐧^⋅𝐮^は−1(真下を向く)から1(真上を向く)までで、tは其れを0から1にします。
此れはただの安い技ではありません。世界の上半分全体が1つの明るさで、下半分が別の明るさなら、つや消しの表面が其れらから受け取る光は、正確に空の1+cosθ2と地面の1−cosθ2の合計です。θは法線と上の間の角度です。
其れがあたし達の式です。

上が青から下が茶色に塗られた球と、其処から外向きの法線。上ではn・upが1、横では0、下ではマイナス1。空の光は上から、地面の光は下から来る

𝐜sky=(0.15,0.2,0.3)、𝐜ground=(0.1,0.07,0.05)での計算例:

  • 上向き、𝐧^=(0,1,0):t=1で、アンビエント = 空 = (0.15,0.2,0.3)。
  • 横向き、𝐧^=(0,0,1):t=0.5で、アンビエント = (0.125,0.135,0.175)、其々半分ずつ。
  • 殆ど下向き、𝐧^=(0,−0.8,0.6):t=0.5−0.4=0.1で、アンビエント = 0.9×(0.1,0.07,0.05)+0.1×(0.15,0.2,0.3)=(0.105,0.083,0.075)。

10.3.2 シェーダーで

// ヘミスフィアライト
const vec3 SKY = vec3(0.15, 0.2, 0.3);       // 上から来る光(空)
const vec3 GROUND = vec3(0.1, 0.07, 0.05);   // 下から来る光(地面)
const vec3 UP = vec3(0.0, 1.0, 0.0);

// ヘミスフィアライト:法線が上を向く程空の色、下を向く程地面の色
vec3 hemisphere(vec3 n) {
  float t = 0.5 + 0.5 * dot(n, UP);
  return mix(GROUND, SKY, t);
}

そして shade の最初の行は次の様になります:

  vec3 color = m.albedo * hemisphere(n);

mix(a, b, t) は第3章の線型補間a(1−t)+btで、此処では2つの色の間で補間します。
AMBIENT 定数はもう要りません。

何が変わったか見る為に、アンビエントの部分だけを表示しました。左が平らなアンビエント、右がヘミスフィアです(見える様に、どちらもシーンの中より4倍明るくしています):

左:平らなアンビエント。一様に暗い赤の円と、一様に灰色の壁。右:ヘミスフィアのアンビエント。球は上がピンクがかった紫で下が暗い赤、横を向いた壁は青灰色の混ざった色

平らなアンビエントでは、光の当たらない所で球が平らな円になります。
ヘミスフィアライトなら影の中でも丸いままです。上は青っぽい空に、下は茶色っぽい地面に照らされます。
ポイントライト、スポットライト、ヘミスフィアライトを全部入れたシーンです:

ポイントライト、スポットライト、ヘミスフィアのアンビエントの球。明るいハイライト、壁のスポット、柔らかく青みがかった暗い側

10.4 沢山の光

10.4.1 定数の配列とループ

今度は赤、緑、青の3つの色付きのポイントライトを、120°ずつずらして球の周りを回らせます。
pointLight の呼び出しを3つ書く事も出来ますが、配列とループを使えば光の数は1つの定数になります:

// 回るポイントライト(赤、緑、青)
const int NUM_LIGHTS = 3;
const vec3 LIGHT_COLORS[NUM_LIGHTS] = vec3[NUM_LIGHTS](
  vec3(1.0, 0.15, 0.1) * 3.0,
  vec3(0.1, 1.0, 0.15) * 3.0,
  vec3(0.15, 0.3, 1.0) * 3.0
);

  vec3 color = m.albedo * hemisphere(n);

  for (int i = 0; i < NUM_LIGHTS; ++i) {
    Light light = Light(orbit(i), LIGHT_COLORS[i], SHORT_RANGE);
    color += pointLight(pos, n, v, m, light);
  }

(1つだけの LIGHT_COLOR 定数はもう要りません。)
vec3[NUM_LIGHTS](...) は配列コンストラクターです。型、括弧の中の大きさ、そして要素です。
配列の大きさは定数式でなければならないので、NUM_LIGHTS は const int でなければなりません。
main の印のループは、全ての光に点を描きます:

  // 光源の印(ポイントライトは点)
  float marker = 0.0;
  for (int i = 0; i < NUM_LIGHTS; ++i) {
    marker = max(marker, 1.0 - smoothstep(0.03 - px, 0.03, length(p - orbit(i).xy)));
  }

球の周りの赤、緑、青の3つのポイントライト。色の付いたハイライトが3つ、其々の周りに球の上の色付きの光の溜まりがあり、光が近い所の壁は赤、緑、青に照らされている

壁の上で赤と緑の光が重なる所は黄色っぽくなります。光は単純に足し合わされます。
其れが複数の光のライティングをとても簡単にしている事であり、同時に10.6と10.7の問題の原因でもあります。

10.4.2 Cからの光のデータ:ユニフォームの配列

ゲームでは光はレベルから来るので、其のデータはCPUから来なければなりません。
ユニフォームも配列に出来ます。
代わりに色をCから渡すとこうなります(此れは例で、此の章のdiffには含まれていません)。
シェーダーでは、定数の配列が次の様になります:

const int NUM_LIGHTS = 3;
uniform vec3 uLightColors[NUM_LIGHTS]; // Cから設定する

そしてループでは uLightColors[i] を使います。
main.c では、他のユニフォームの隣に:

    // ライトの色を配列で渡す(vec3が3個 = floatが9個)
    float lightColors[] = {
      3.0f, 0.45f, 0.3f,
      0.3f, 3.0f, 0.45f,
      0.45f, 0.9f, 3.0f
    };
    glUniform3fv(glGetUniformLocation(shaderProgram, "uLightColors"), 3, lightColors);

glUniform3fv(location, count, pointer) は、配列の最初の要素から始めて、count 個のvec3(其々floatが3つ)をアップロードします。
試してみました。画面は定数の配列の時と同じです。
uLightColors を使わないシェーダーを再読み込みすると、glGetUniformLocation は−1を返し、OpenGLは位置−1のユニフォームの呼び出しを黙って無視するので、テスターは動き続けます。

ユニフォームの数には制限があり(GL_MAX_FRAGMENT_UNIFORM_COMPONENTS、OpenGL 4.xでは最低1024個のfloat)、全ての光が全てのピクセルで仕事を増やします。
其の為、実際のエンジンは光のデータをユニフォームバッファオブジェクト又はシェーダーストレージバッファに入れ、ピクセルに届き得る光だけを処理します。
其の方法はOpenGLシリーズで説明します。

10.5 エリアライト

10.5.1 何故点では足りないのか

本物の光は点ではありません。
窓、蛍光灯、テレビの画面、写真スタジオのソフトボックスには大きさがあり、其れは2つの形で見えます:

  • 柔らかい影と柔らかい光:大きな光の近くの点は、一度に沢山の方向から照らされます。
  • 光の形をしたハイライト:蛍光灯の下の光沢のある玉には、点ではなく長い筋が映ります。夜の通りの車を見てみて下さい。

エリアライトの正確な答えは積分です。光の表面の全ての点からの光を足し合わせます。
GPUは積分が出来ないので、リアルタイムのレンダラーは近似します。
シーンをチューブライト、つまり𝐀から𝐁までの光る線分で照らし、2つの古典的な近似を試します。

10.5.2 沢山のポイントライト

分かり易い方法です。線分に沿ってN個のポイントライトを置き、其々に光の1Nを与えます。
サンプルi(0からN−1まで)の位置は:

𝐒i=mix(𝐀,𝐁,i+0.5N)

+0.5で、各サンプルを線分の其の部分の始まりではなく真ん中に置きます。
表面の上でゆっくり変わる拡散反射の光には此れで上手くいきます。
鏡面反射の光には上手くいきません。各サンプルが其々小さなハイライトを作り、Nがとても大きくなければ、線ではなく点の列が見えます。
とても光沢のある球(光沢度256)の上のチューブライトのハイライトです。4サンプル、64サンプル、そして次の節の方法:

チューブライトのハイライトの3つの拡大図。4サンプルでは4つの離れた点、64サンプルでは淡い連続した線、代表点では明るくはっきりした筋

64サンプルなら正しい形になりますが、ピクセル毎に64個の光の計算が掛かります。

10.5.3 代表点

ブライアン・カリスが「Real Shading in Unreal Engine 4」(2013年)で、鏡面反射の光の為のずっと安い技を紹介しました。
ハイライトは光の反射です。
其処で反射ベクトル𝐫^=reflect(−𝐯^,𝐧^)、つまり表面が目を映す向きを求め、光の上で其の線に一番近い点を取ります。
そして其の1点を、鏡面反射の項の為の普通のポイントライトとして使います。

床の上のAからBまでのチューブライト。床の点Pから、反射ベクトルrがチューブの横を通って上に伸びる。代表点はチューブ上で其の線に一番近い点で、短い破線で線と結ばれている。チューブに沿って緑のサンプル点が4つ

一番近い点を見つけるには、表面の点𝐏を基準にします。𝐥0=𝐀−𝐏、𝐥d=𝐁−𝐀とすると、チューブの点は0≤t≤1で𝐪(t)=𝐥0+t𝐥dです。
𝐏を通る単位方向𝐫^の線からの𝐪の距離の2乗は、「長さの2乗引く線に沿った部分の2乗」です(射影、線型代数の第4章):

D(t)=𝐪⋅𝐪−(𝐪⋅𝐫^)2

最小の所では微分が0になります。
d𝐪dt=𝐥dなので:

dDdt=2𝐪⋅𝐥d−2(𝐪⋅𝐫^)(𝐥d⋅𝐫^)=0(𝐥0⋅𝐥d)+t(𝐥d⋅𝐥d)=((𝐥0⋅𝐫^)+t(𝐥d⋅𝐫^))(𝐥d⋅𝐫^)t=(𝐥0⋅𝐫^)(𝐥d⋅𝐫^)−𝐥0⋅𝐥d𝐥d⋅𝐥d−(𝐥d⋅𝐫^)2

其の後、点がチューブの上に留まる様にtを[0,1]にクランプします。
分母が0になるのは線がチューブと平行な時だけで、max(..., 1e-6) で其処での0除算を防ぎます。

10.5.4 シェーダーで

拡散反射はサンプルで、鏡面反射は代表点で計算します:

// チューブライト(AからBまでの線分の光源)
const vec3 TUBE_A = vec3(-0.5, 0.4, 0.9);
const vec3 TUBE_B = vec3(0.5, 0.4, 0.9);
const vec3 TUBE_COLOR = vec3(1.0, 0.9, 0.7) * 1.0;
const int TUBE_SAMPLES = 16;

// チューブライト(AからBまでの線分)
vec3 tubeLight(vec3 pos, vec3 n, vec3 v, Material m, vec3 a, vec3 b, vec3 lightColor) {
  // 拡散反射:線分上に並べたTUBE_SAMPLES個のポイントライトの平均
  float diffuse = 0.0;
  for (int i = 0; i < TUBE_SAMPLES; ++i) {
    float t = (float(i) + 0.5) / float(TUBE_SAMPLES);
    vec3 toLight = mix(a, b, t) - pos;
    float d = length(toLight);
    diffuse += max(dot(n, toLight / d), 0.0) * attenuate(d, MEDIUM_RANGE);
  }
  diffuse /= float(TUBE_SAMPLES);

  // 鏡面反射:反射ベクトルに一番近い線分上の点(代表点)だけを使う
  vec3 r = reflect(-v, n);
  vec3 l0 = a - pos;
  vec3 ld = b - a;
  float rd = dot(r, ld);
  float t = (dot(r, l0) * rd - dot(l0, ld)) / max(dot(ld, ld) - rd * rd, 1e-6);
  vec3 toLight = l0 + clamp(t, 0.0, 1.0) * ld;
  float d = length(toLight);
  vec3 l = toLight / d;
  float specular = 0.0;
  if (dot(n, l) > 0.0) specular = pow(max(dot(n, normalize(l + v)), 0.0), m.shininess);
  specular *= attenuate(d, MEDIUM_RANGE);

  return (m.albedo * diffuse + m.specular * specular) * lightColor;
}

GLSLでは float(i) がループのカウンターをfloatに変換します。i / TUBE_SAMPLES の様な割り算では int から float への自動的な変換は無く、整数の割り算になってしまいます(第2章)。
mix はベクトルにも使えます。mix(a, b, t) はAとBの間の割合tの点です。
反射に reflect(-v, n) を使っている事に注目して下さい。今回は目の向きを鏡映します。「表面は鏡に何を映しているか?」を問うているからです。

shade では、ポイントライトとスポットライトをコメントアウトしてチューブを足します:

  color += tubeLight(pos, n, v, m, TUBE_A, TUBE_B, TUBE_COLOR);

そして main で点の印の後に、第6章の sdSegment でチューブを線として描きます:

// 線分abまでの距離(第6章)
float sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a;
  vec2 ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}

  // チューブライトは線
  float tube = sdSegment(p, TUBE_A.xy, TUBE_B.xy);
  marker = max(marker, 1.0 - smoothstep(0.015 - px, 0.015, tube));

チューブライトだけ。球の上部の手前に白い線、其の下の球に長く曲がったハイライト、球の上半分に柔らかい光、壁に広く柔らかい光

ハイライトは曲がった筋です。クリスマスの飾り玉に映ったネオンサインの様に、真っ直ぐなチューブが球に映っています。
壁の光は広く柔らかく、ポイントライトの様な明るい中心がありません。

代表点はズルで、代償があります。
チューブ全体の光を一番明るい点から取るので、長いチューブではサンプルの物よりハイライトが明る過ぎます。カリスは其の為に正規化の係数を加えていますが、此処では省略します。
長方形の光(窓、画面)には、現代のエンジンはlinearly transformed cosines(LTC、Heitz他、2016年)という、より正確な方法を使いますが、此のシリーズの範囲を超えます。

10.6 エネルギーと露出オーバー

10.6.1 全てを一緒に

では shade で全てを有効にしましょう。ヘミスフィア、3つのポイントライト、スポットライト、チューブです。

  vec3 color = m.albedo * hemisphere(n);

  for (int i = 0; i < NUM_LIGHTS; ++i) {
    Light light = Light(orbit(i), LIGHT_COLORS[i], SHORT_RANGE);
    color += pointLight(pos, n, v, m, light);
  }

  Light spot = Light(SPOT_POS, SPOT_COLOR, LONG_RANGE);
  vec3 spotDir = normalize(spotTarget() - SPOT_POS);
  color += spotLight(pos, n, v, m, spot, spotDir);

  color += tubeLight(pos, n, v, m, TUBE_A, TUBE_B, TUBE_COLOR);
  return color;

トーンマッピング無しで全ての光を一度に。壁のスポットは真っ白な塊、赤い光は球の上に平らで鮮やか過ぎる赤い部分を作り、緑の光の近くの壁は明るく平らな緑

おかしく見えます。そして勉強になる形でおかしいのです。
各光は単独では大丈夫でしたが、光は足し合わされ、合計は1.0を大きく超える事がよくあります。
フレームバッファはチャンネル毎に0から1を保存するので、1を超える物は単に切り捨てられます(クランプされます)。
其れには2つの悪い影響があります:

  • ディテールが消える。 1.5も5.0も1.0になるので、スポットは作った柔らかい縁の無い、平らな白い塊になります。
  • 色が変わる。 (4,0.6,0.4)の明るい赤は(1,0.6,0.4)にクランプされます。チャンネル間の比は10対1.5対1でしたが、2.5対1.5対1になります。明るい色は淡く間違った色になり、とても明るい色は白くなります。

全ての光を暗くすれば明るい部分は直りますが、残りが暗過ぎる様になります。
写真家は此の問題をダイナミックレンジとして知っています。現実の世界の明るさの幅は、紙や画面よりずっと広いのです。

10.6.2 トーンマッピング

解決策は、計算した色を0から幾らでも大きくなれる物理的な光の量として扱い、一番最後に曲線で0から1に収める事です。
其の曲線はトーンマッピングの演算子と呼ばれます。
一番簡単な良い物は、ラインハルトの物(2002年)です:

c′=c1+c

小さな値には殆ど何もせず(0.1→0.091)、1は0.5に、3は0.75に、9は0.9になり、1に届く事は決してありません。
明るい物は圧縮されますが、より暗い物よりは明るいままなので、ディテールが残ります。
先程の明るい赤は(45,0.61.6,0.41.4)=(0.8,0.375,0.286)になり、はっきりと赤のままです。

0から5までの値の3つのトーンカーブ。min c 1のクランプは真っ直ぐ上がって1で切れる。指数の1マイナスeのマイナスc乗。ラインハルトのc割る1プラスcはもっと緩やかに上がり、ゆっくり1に近付く

main の最後、壁と球を混ぜた後の1行です:

  // トーンマッピング(ラインハルト):0〜∞を0〜1に収める
  color = color / (1.0 + color);

2つの vec3 の割り算は各成分を割るので、此れはチャンネル毎のラインハルトです。

ラインハルトのトーンマッピングで全ての光。壁のスポットには再び柔らかい縁があり、色付きの光ははっきり赤、緑、青で、平らな白い所は無い

ラインハルトは中間の明るさも暗くするので(1.0が0.5になります)、画面全体が少し灰色っぽくなります。
実際のエンジンは、写真フィルムの反応に似た形の曲線(「フィルミック」カーブ。映画業界のACESがよく使われます)と露出の係数を一緒に使います。露出はカメラの露出設定の様に、曲線の前に色に掛けます。

厳密に言えば、ブリン・フォンもエネルギーを保存していません。より鋭いハイライト(高い光沢度)は、同じ反射光がより小さな点に集まるので、より明るくなるべきです。
物理ベースの版では、其の為に鏡面反射の項にs+88πを掛けます。
此処では簡単な版のままにしますが、此れも業界が物理ベースのモデルに移った理由の一つです。

10.7 ガンマ補正

10.7.1 フレームバッファの数字の意味

最後の、そして一番分かり難い問題です。
此れまで色の値0.5を「1.0の半分の光」として扱って来ました。
然し、フレームバッファに0.5(255の内128)が入っている時、モニターは白の半分の光を出しません。
約22%しか出しません。

理由は歴史とあたし達の目です。
目は明るい色調の間の違いより、暗い色調の間の違いにずっと敏感です。
もし8ビットを光の量に均等に割り当てると、256段階の殆どが区別出来ない明るい色調に使われ、暗い色調には目に見える縞が出来ます。
其の為、画像は暗い色調により多くの段階を使う非線型の符号化、sRGB規格で保存されます。
昔のブラウン管のモニターは偶然殆ど丁度其の逆の反応を持っていたので、値を其のまま画面に送れました。今のモニターは其れをわざと真似しています。

良い近似として、モニターは保存された値Vに対して光の量Lを

L=V2.2

で表示し、光の量Lを正しく表示するには

V=L12.2

を保存しなければなりません。
数字で:0.52.2≈0.218なので、値0.5は白の約22%を表示します。
逆に、光の50%を表示するには0.51/2.2≈0.730、つまり255の内186が必要です。

2本の曲線。y = xの2.2分の1乗は上に曲がり、0.5を0.73にする。y = xの2.2乗は下に曲がり、0.5を0.22にする。其の間に破線の直線y = x

正確なsRGBの曲線は純粋な2.2乗とは少し違い、黒の近くに短い直線の部分があります:

V={12.92LL≤0.00313081.055L1/2.4−0.055L>0.0031308L={V/12.92V≤0.04045(V+0.0551.055)2.4V>0.04045

GLSLでは:

// 線型からsRGBへ(正確な式)
vec3 linearToSrgb(vec3 c) {
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(0.0031308, c));
}

// sRGBから線型へ(正確な式)
vec3 srgbToLinear(vec3 c) {
  vec3 lo = c / 12.92;
  vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
  return mix(lo, hi, step(0.04045, c));
}

step(edge, x)(第6章)は成分毎に境界より下で0、上で1なので、mix(lo, hi, step(...)) は if を使わずに各チャンネルで2つの式の内1つを選びます。
あたし達のシーンで両方の版を試しました。簡単な pow(c, vec3(1.0 / 2.2)) との一番大きな違いは、幾つかの暗いピクセルでの255の内9でした。
其の為プレイグラウンドでは簡単な累乗を使います。色を他のソフトウェアと正確に合わせなければならない時は、正確な関数を使って下さい。

10.7.2 何故ライティングは線型空間で行わなければならないのか

あたし達のライティングの計算は全て、数字が光の量である事を前提にしています。2つの光は足し合わされ、コサインが半分なら光も半分です。
其れが正しいのは線型の値の時だけです。
線型の値で計算して其のままモニターに送ると、全ての結果が本来より暗く、しかも間違った形で表示されます。拡散反射の値0.2は光の20%に見えるべきですが、0.22.2≈3%として表示されます。
其れが、あたし達の球の暗い側が直ぐに真っ黒に落ちてしまった理由で、まともに見せる為に沢山のアンビエントが必要だった理由です。

現代の全てのレンダラーのルールです:

  1. 目で選んだ色や画像に保存された色(sRGB)を線型の値に復号します:L=V2.2。
  2. ライティングは全て線型の値で行います。
  3. トーンマッピングします。
  4. 結果をモニター用に符号化します:V=L1/2.2。

あたし達のアルベド vec3(0.8, 0.2, 0.3) は画面で見て選んだのでsRGBの色で、線型の値では約(0.612,0.029,0.071)です。
* 3.0 の様な光の色と強さは物理的な量なので既に線型で、其のまま使います。

10.7.3 オンとオフを切り替える

比べる為に、Gキーでガンマ補正のオンとオフを切り替えられる様にします。
main.c で、reloadWasDown の隣に:

  int reloadWasDown = 0;
  int gammaOn = 1;       // ガンマ補正(Gキーで切り替え)
  int gammaWasDown = 0;

ループの中、Rキーの後に:

    // Gキーでガンマ補正を切り替える(比較用)
    int gammaDown = glfwGetKey(window, GLFW_KEY_G) == GLFW_PRESS;
    if (gammaDown && !gammaWasDown) {
      gammaOn = !gammaOn;
      printf("ガンマ補正: %s\n", gammaOn ? "オン" : "オフ");
    }
    gammaWasDown = gammaDown;

そして他のユニフォームと一緒に:

    glUniform1i(glGetUniformLocation(shaderProgram, "uGamma"), gammaOn);

第5章のRキーと全く同じ仕組みです。キーが「離れている」から「押されている」に変わった時だけ反応します。そうしないと、Gを少し押し続けるだけで毎フレーム切り替わってしまいます。
bool のユニフォームは glUniform1i で設定します。0がfalse、其れ以外はtrueです。
(英語版の此の章では、メッセージを英語にしたコードを載せていますが、プロジェクトの main.c は他のメッセージと同じく日本語です。)

シェーダーでは:

uniform bool uGamma;       // trueならガンマ補正する(Gキーで切り替え)

shade の最初に:

  vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き
  if (uGamma) m.albedo = pow(m.albedo, vec3(2.2)); // sRGBの色を線型にする

そして main の最後、トーンマッピングの後で印を描く前に:

  // ガンマ補正:線型の値をモニター用のsRGBにする
  if (uGamma) color = pow(color, vec3(1.0 / 2.2));

m はコピー(in 引数)なので、shade で m.albedo を変えても定数 SPHERE は変わりません。
pow はベクトルでは成分毎に働きます。其の時は指数もベクトル vec3(2.2) でなければなりません。

ガンマ補正は暗い値をずっと明るくするので(0.2が0.48になります)、同じ光のままではシーンが白っぽく見えてしまいます。
線型のパイプラインの為に光の値をもう一度調整しました。ポイントライトは3.0の代わりに * 2.0、スポットライトは * 1.0、チューブは * 0.6、そして空と地面はずっと暗くします:

const vec3 SKY = vec3(0.04, 0.05, 0.08);      // 上から来る光(空)
const vec3 GROUND = vec3(0.02, 0.015, 0.01);  // 下から来る光(地面)

此の再調整は普通の事です。ガンマ補正無しで調整した値は、間違った曲線の為に調整されているからです。

左:ガンマ補正オフ。球の暗い側と壁の隅は殆ど黒く、色付きの光はきつく見える。右:ガンマ補正オン。光は影に向かって緩やかに消え、球は暗い側でも丸く見え、色は滑らかに混ざる

Gを何度か押して、球の暗い側を見て下さい。
補正無しでは光が崖から落ちる様に黒くなり、補正有りでは本物の玉の上の光の様に緩やかに消えていきます。
最終的なシーンです:

最終的なシーン。球の周りを回る赤、緑、青のポイントライト、其の上の筋のハイライトを作るチューブライト、壁の上を動き回るスポットライト。全てヘミスフィアのアンビエント、トーンマッピング、ガンマ補正付き

10.7.4 ハードウェアに任せる

シェーダーで変換するのは学ぶには分かり易いですが、GPUは両方向とも無料で出来ます:

  • sRGBテクスチャ(GL_SRGB8_ALPHA8、第11章):シェーダーがサンプリングする時、GPUがフィルタリングの前に線型に復号します。其の方がより正確でもあります。
  • sRGBフレームバッファ:glEnable(GL_FRAMEBUFFER_SRGB) とsRGB対応のフレームバッファがあれば、OpenGLはシェーダーの線型の出力を書き込む時に符号化します。Vulkanでは VK_FORMAT_B8G8R8A8_SRGB の様なスワップチェーンのフォーマットを選びます。

避けるべき唯一の間違いは、2回行う事です。
フレームバッファが既に符号化しているのにシェーダーでも pow(c, 1.0 / 2.2) を掛けると、画像は白っぽく灰色に見えます。誰も符号化しなければ、暗過ぎてコントラストがきつく見えます。
画像が其のどちらかの形で「おかしい」時は、先ずガンマの流れを確認して下さい。

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

機能 何をするか
radians(x)、degrees(x) 度からラジアン(x⋅π/180)、及び其の逆
T name[N] N個の要素の配列。Nは定数式でなければならない
vec3[N](a, b, c) 配列コンストラクター。要素を順に
name.length() 配列の要素の数
uniform vec3 u[N] ユニフォームの配列。Cから glUniform3fv(loc, N, data) で設定
uniform bool u 真偽値のユニフォーム。glUniform1i で設定(0 = false)
float(i) intをfloatに変換(GLSLには自動変換が無い)
ベクトルでの step(e, x) 成分毎に、eより下で0、e以上で1
pow(v, vec3(e)) ベクトルの成分毎の累乗

  • 定数式では、定数の引数で組み込み関数を呼べます(cos(radians(8.0)) の様に)。テクスチャの関数は例外です。
  • デスクトップのGLSLでは、繰り返しの回数が変わるループも使えます。あたし達の物の様に回数が定数のループは、コンパイラーが完全に展開する事がよくあります。
  • GLSL ES 1.00(WebGL 1)のフラグメントシェーダーでは、配列の添字に定数かループのカウンターしか使えません。デスクトップのGLSLとGLSL ES 3.00はもっと緩やかです。
  • 此の章の全ては、macOSのOpenGL 4.1でも動きます。

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

  • スポットライトは至る所にあります。車のヘッドライト、街灯、舞台照明、そして何よりホラーゲームやステルスゲームの懐中電灯です。エンジンは模様のある光の為にクッキー(円錐を通して投影するテクスチャ、第11章)を、物体が光を遮る様にシャドウマップ(OpenGLシリーズ)を加えます。UnityもUnrealも、あたし達の物と全く同じ様に内側と外側の円錐の角度を設定出来ます。
  • ヘミスフィアと空のアンビエント。 Unityの「Gradient」アンビエントモードは、空、地平線、地面の色を使います。あたし達のヘミスフィアライトの3色版です。多くのエンジンは更に進んで、環境全体のアンビエントの光を球面調和関数として保存します。プローブ毎の少しの数字で、点の周りのどんな滑らかな光の分布も表せます。ヘミスフィアライトは其の考え方の一番簡単な場合です。
  • エリアライト。 Unreal Engineは、球形とチューブの光にカリスの論文の代表点の方法を使っています。UnityのHDRPや他の現代のレンダラーの長方形のエリアライトはLTCを使います。製品のコンフィギュレーターや車のゲームのスタジオ風のライティングは、柔らかく形のある反射の為にエリアライトに大きく頼っています。
  • 沢山の光。 ディファードやクラスター(Forward+)のレンダラーは、各ピクセル又は画面の各セルに届き得る光だけを計算する事で、何百、何千もの光を実用的にしています。
  • HDRとガンマ。 現代の全てのゲームは線型のHDR(16ビットの浮動小数点のフレームバッファ)でレンダリングし、最後にトーンマッピングします。調整出来る露出(「目の順応」)とフィルミックカーブを使う事が多いです。そして色を持つ全てのテクスチャはsRGBとして指定され、GPUが復号します。

10.10 まとめ

  • スポットライト:cosθ=(−𝐥^)⋅𝐝^、柔らかい縁にはsmoothstep(cosθout,cosθin,cosθ)。コサイン同士を比べ、ピクセル毎に acos を呼ばない事。
  • ヘミスフィアライト:mix(ground,sky,0.5+0.5𝐧^⋅𝐮^)。2色の空からの拡散反射の光としては正確です。
  • エリアライト:拡散反射には幾つかの点のサンプルの平均を、鏡面反射には反射ベクトルに一番近い光の上の点(代表点)を使います。
  • 光は足し合わされます。構造体、定数の配列(vec3[N](...))又はユニフォームの配列(glUniform3fv)、そしてループを使います。
  • 1を超える値は切り捨てられます。最後に、例えばラインハルトのc/(1+c)でトーンマッピングします。
  • モニターはV2.2を表示します。sRGBの色を線型に復号し、線型でライティングし、トーンマッピングし、L1/2.2で符号化します。丁度1回だけです。

10.11 課題

  1. ★ スポットライトのθin=20°、θout=30°です。θ=15°、25°、35°での円錐の係数を手で計算して下さい(cos20°≈0.9397、cos25°≈0.9063、cos30°≈0.8660を使います)。1、約0.57、0になる筈です。
  2. ★ ガンマ2.2で、白の光の25%を表示するのはどの保存値ですか?又、保存値0.25はどれ位の光を表示しますか?次にc=0.25、c=1、c=4にラインハルトを適用し、其々の結果を1/2.2で符号化して下さい。(最初の2つの答えは約0.533と0.047です。)
  3. ★★ スポットライトを見る人が持つ懐中電灯にして下さい。位置を vec3(mousePos(), 2.0) にし、真っ直ぐシーンの奥 vec3(0.0, 0.0, -1.0) に向けます。円錐は10°と14°にします。マウスを動かすと丸いスポットが球と壁の上を動き、球の上では曲面でスポットが歪む筈です。
  4. ★★ Unityのグラデーションモードの様に、ヘミスフィアライトに地平線の3つ目の色を与えて下さい。𝐧^⋅𝐮^が−1から0までは地面から地平線へ、0から1までは地平線から空へ混ぜます。明るいオレンジの地平線にすると、球の左右の縁が夕焼けの様に光る筈です。
  5. ★★ 10.4.2の様に3つのポイントライトの色をユニフォームの配列にし、Cのコードで時間と共に変えて下さい。例えば第3章の様に sin で色相を回します。光は回りながらゆっくり色を変える筈です。
  6. ★★ ラインハルトを指数の曲線1−e−kc(1.0 - exp(-exposure * color))に置き換え、マウスのx位置で0.25から4まで変わる露出を加えて下さい。画像がラインハルトの版に一番似て見える露出を探して下さい。
  7. ★★★ チューブライトを長方形の光(窓)にして下さい。拡散反射は4 × 4の格子でサンプリングし、鏡面反射の代表点は反射ベクトルと長方形の平面の交点を求めて、其の点を長方形にクランプします。球にはクリスマスの飾り玉に映った窓の様な、長方形のハイライトが見える筈です。反射ベクトルが長方形から大きく外れている時、交点をクランプするのは何故より悪い近似になるのでしょうか?

DIFFをダウンロード

ファイル: a/main.c | b/main.c

前新
@@ -262,6 +262,8 @@ int main(int argc, char **argv) {
262 glEnableVertexAttribArray(1); 262 glEnableVertexAttribArray(1);
263   263  
264 int reloadWasDown = 0; 264 int reloadWasDown = 0;
    265 int gammaOn = 1; // ガンマ補正(Gキーで切り替え)
    266 int gammaWasDown = 0;
265   267  
266 // メインレンダリングループ 268 // メインレンダリングループ
267 while (!glfwWindowShouldClose(window)) { 269 while (!glfwWindowShouldClose(window)) {
@@ -282,6 +284,14 @@ int main(int argc, char **argv) {
282 } 284 }
283 reloadWasDown = reloadDown; 285 reloadWasDown = reloadDown;
284   286  
    287 // Gキーでガンマ補正を切り替える(比較用)
    288 int gammaDown = glfwGetKey(window, GLFW_KEY_G) == GLFW_PRESS;
    289 if (gammaDown && !gammaWasDown) {
    290 gammaOn = !gammaOn;
    291 printf("ガンマ補正: %s\n", gammaOn ? "オン" : "オフ");
    292 }
    293 gammaWasDown = gammaDown;
    294  
285 // 画面をクリア 295 // 画面をクリア
286 glClearColor(0.0f, 0.0f, 0.0f, 1.0f); 296 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
287 glClear(GL_COLOR_BUFFER_BIT); 297 glClear(GL_COLOR_BUFFER_BIT);
@@ -301,6 +311,7 @@ int main(int argc, char **argv) {
301 glUniform2f(glGetUniformLocation(shaderProgram, "uResolution"), (float)gWidth, (float)gHeight); 311 glUniform2f(glGetUniformLocation(shaderProgram, "uResolution"), (float)gWidth, (float)gHeight);
302 glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"), 312 glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"),
303 (float)mouseX * scale, (float)(windowH - mouseY) * scale); 313 (float)mouseX * scale, (float)(windowH - mouseY) * scale);
    314 glUniform1i(glGetUniformLocation(shaderProgram, "uGamma"), gammaOn);
304   315  
305 // クアッドを描画 316 // クアッドを描画
306 glBindVertexArray(VAO); 317 glBindVertexArray(VAO);

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

前新
@@ -6,8 +6,9 @@ out vec4 out_color;
6 uniform float uTime; // 起動してからの秒数 6 uniform float uTime; // 起動してからの秒数
7 uniform vec2 uResolution; // 画面の大きさ(ピクセル) 7 uniform vec2 uResolution; // 画面の大きさ(ピクセル)
8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点) 8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点)
    9 uniform bool uGamma; // trueならガンマ補正する(Gキーで切り替え)
9   10  
10 // 1にするとマウスで光を動かす、0にすると時間で光が回る 11 // 1にするとスポットライトがマウスを追う、0にすると時間で動く
11 #define FOLLOW_MOUSE 0 12 #define FOLLOW_MOUSE 0
12   13  
13 // 表面の材質 14 // 表面の材質
@@ -17,15 +18,54 @@ struct Material {
17 float shininess; // 光沢度(大きい程ハイライトが小さく鋭い) 18 float shininess; // 光沢度(大きい程ハイライトが小さく鋭い)
18 }; 19 };
19   20  
    21 // 光源
    22 struct Light {
    23 vec3 position; // 位置
    24 vec3 color; // 色と明るさ
    25 vec3 attenuation; // 減衰の定数(定数項、1次、2次)
    26 };
    27  
20 const float RADIUS = 0.6; // 球の半径 28 const float RADIUS = 0.6; // 球の半径
21 const float WALL_Z = -RADIUS; // 壁の位置(球の真後ろ) 29 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   30  
26 const Material SPHERE = Material(vec3(0.8, 0.2, 0.3), 0.5, 64.0); 31 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); 32 const Material WALL = Material(vec3(0.7, 0.7, 0.75), 0.1, 8.0);
28   33  
    34 // 減衰の定数(第9章の表から)
    35 const vec3 SHORT_RANGE = vec3(1.0, 0.7, 1.8); // 近くだけを照らす
    36 const vec3 MEDIUM_RANGE = vec3(1.0, 0.35, 0.44); // 中くらい
    37 const vec3 LONG_RANGE = vec3(1.0, 0.09, 0.032); // 遠くまで届く
    38  
    39 // スポットライト
    40 const vec3 SPOT_POS = vec3(-0.8, 0.8, 1.6); // 位置(左上手前)
    41 const vec3 SPOT_COLOR = vec3(1.0, 0.95, 0.8) * 1.0; // 色と明るさ
    42 const float COS_INNER = cos(radians(8.0)); // 内側の円錐(此処までは全力)
    43 const float COS_OUTER = cos(radians(12.0)); // 外側の円錐(此処から外は真っ暗)
    44  
    45 // ヘミスフィアライト
    46 const vec3 SKY = vec3(0.04, 0.05, 0.08); // 上から来る光(空)
    47 const vec3 GROUND = vec3(0.02, 0.015, 0.01); // 下から来る光(地面)
    48 const vec3 UP = vec3(0.0, 1.0, 0.0);
    49  
    50 // 回るポイントライト(赤、緑、青)
    51 const int NUM_LIGHTS = 3;
    52 const vec3 LIGHT_COLORS[NUM_LIGHTS] = vec3[NUM_LIGHTS](
    53 vec3(1.0, 0.15, 0.1) * 2.0,
    54 vec3(0.1, 1.0, 0.15) * 2.0,
    55 vec3(0.15, 0.3, 1.0) * 2.0
    56 );
    57  
    58 // チューブライト(AからBまでの線分の光源)
    59 const vec3 TUBE_A = vec3(-0.5, 0.4, 0.9);
    60 const vec3 TUBE_B = vec3(0.5, 0.4, 0.9);
    61 const vec3 TUBE_COLOR = vec3(1.0, 0.9, 0.7) * 0.6;
    62 const int TUBE_SAMPLES = 16;
    63  
    64 // 距離dでの減衰(kは定数項、1次、2次)
    65 float attenuate(float d, vec3 k) {
    66 return 1.0 / (k.x + k.y * d + k.z * d * d);
    67 }
    68  
29 // 1つの光からの拡散反射と鏡面反射(ブリン・フォン) 69 // 1つの光からの拡散反射と鏡面反射(ブリン・フォン)
30 // n:法線、v:目への向き、l:光への向き(全て単位ベクトル) 70 // n:法線、v:目への向き、l:光への向き(全て単位ベクトル)
31 vec3 blinnPhong(vec3 n, vec3 v, vec3 l, Material m, vec3 lightColor) { 71 vec3 blinnPhong(vec3 n, vec3 v, vec3 l, Material m, vec3 lightColor) {
@@ -38,20 +78,102 @@ vec3 blinnPhong(vec3 n, vec3 v, vec3 l, Material m, vec3 lightColor) {
38 return (m.albedo * diffuse + m.specular * specular) * lightColor; 78 return (m.albedo * diffuse + m.specular * specular) * lightColor;
39 } 79 }
40   80  
41 // ポイントライト:光の位置から向きと減衰を求める 81 // ポイントライト
42 vec3 pointLight(vec3 pos, vec3 n, vec3 v, Material m, vec3 lightPos, vec3 lightColor) { 82 vec3 pointLight(vec3 pos, vec3 n, vec3 v, Material m, Light light) {
43 vec3 toLight = lightPos - pos; 83 vec3 toLight = light.position - pos;
44 float d = length(toLight); // 光までの距離 84 float d = length(toLight);
45 vec3 l = toLight / d; // normalize(toLight)と同じ 85 vec3 l = toLight / d;
46 float attenuation = 1.0 / (ATTENUATION.x + ATTENUATION.y * d + ATTENUATION.z * d * d); 86 return blinnPhong(n, v, l, m, light.color) * attenuate(d, light.attenuation);
47 return blinnPhong(n, v, l, m, lightColor) * attenuation; 87 }
    88  
    89 // スポットライト:ポイントライトを円錐の中だけにする
    90 // dir:光軸の向き(単位ベクトル)
    91 vec3 spotLight(vec3 pos, vec3 n, vec3 v, Material m, Light light, vec3 dir) {
    92 vec3 l = normalize(light.position - pos);
    93 float cosAngle = dot(-l, dir); // 光軸と「光から表面への向き」の間の角度のコサイン
    94 float cone = smoothstep(COS_OUTER, COS_INNER, cosAngle);
    95 return pointLight(pos, n, v, m, light) * cone;
    96 }
    97  
    98 // ヘミスフィアライト:法線が上を向く程空の色、下を向く程地面の色
    99 vec3 hemisphere(vec3 n) {
    100 float t = 0.5 + 0.5 * dot(n, UP);
    101 return mix(GROUND, SKY, t);
    102 }
    103  
    104 // チューブライト(AからBまでの線分)
    105 vec3 tubeLight(vec3 pos, vec3 n, vec3 v, Material m, vec3 a, vec3 b, vec3 lightColor) {
    106 // 拡散反射:線分上に並べたTUBE_SAMPLES個のポイントライトの平均
    107 float diffuse = 0.0;
    108 for (int i = 0; i < TUBE_SAMPLES; ++i) {
    109 float t = (float(i) + 0.5) / float(TUBE_SAMPLES);
    110 vec3 toLight = mix(a, b, t) - pos;
    111 float d = length(toLight);
    112 diffuse += max(dot(n, toLight / d), 0.0) * attenuate(d, MEDIUM_RANGE);
    113 }
    114 diffuse /= float(TUBE_SAMPLES);
    115  
    116 // 鏡面反射:反射ベクトルに一番近い線分上の点(代表点)だけを使う
    117 vec3 r = reflect(-v, n);
    118 vec3 l0 = a - pos;
    119 vec3 ld = b - a;
    120 float rd = dot(r, ld);
    121 float t = (dot(r, l0) * rd - dot(l0, ld)) / max(dot(ld, ld) - rd * rd, 1e-6);
    122 vec3 toLight = l0 + clamp(t, 0.0, 1.0) * ld;
    123 float d = length(toLight);
    124 vec3 l = toLight / d;
    125 float specular = 0.0;
    126 if (dot(n, l) > 0.0) specular = pow(max(dot(n, normalize(l + v)), 0.0), m.shininess);
    127 specular *= attenuate(d, MEDIUM_RANGE);
    128  
    129 return (m.albedo * diffuse + m.specular * specular) * lightColor;
    130 }
    131  
    132 // マウスの位置をpと同じ座標にする
    133 vec2 mousePos() {
    134 return (2.0 * uMouse - uResolution) / uResolution.y;
    135 }
    136  
    137 // i番目のポイントライトの位置(120°ずつずれて回る)
    138 vec3 orbit(int i) {
    139 float a = uTime * 1.25 + float(i) * radians(120.0);
    140 return vec3(0.9 * cos(a), 0.6 * sin(a), 0.7);
    141 }
    142  
    143 // スポットライトが狙う壁の点
    144 vec3 spotTarget() {
    145 #if FOLLOW_MOUSE
    146 return vec3(mousePos(), WALL_Z);
    147 #else
    148 return vec3(0.8 * sin(uTime * 0.7), 0.4 * sin(uTime * 1.1), WALL_Z);
    149 #endif
    150 }
    151  
    152 // 線分abまでの距離(第6章)
    153 float sdSegment(vec2 p, vec2 a, vec2 b) {
    154 vec2 pa = p - a;
    155 vec2 ba = b - a;
    156 float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    157 return length(pa - ba * h);
48 } 158 }
49   159  
50 // 表面の1点の色 160 // 表面の1点の色
51 vec3 shade(vec3 pos, vec3 n, Material m, vec3 lightPos) { 161 vec3 shade(vec3 pos, vec3 n, Material m) {
52 vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き 162 vec3 v = vec3(0.0, 0.0, 1.0); // 目への向き
53 vec3 color = m.albedo * AMBIENT; 163 if (uGamma) m.albedo = pow(m.albedo, vec3(2.2)); // sRGBの色を線型にする
54 color += pointLight(pos, n, v, m, lightPos, LIGHT_COLOR); 164  
    165 vec3 color = m.albedo * hemisphere(n);
    166  
    167 for (int i = 0; i < NUM_LIGHTS; ++i) {
    168 Light light = Light(orbit(i), LIGHT_COLORS[i], SHORT_RANGE);
    169 color += pointLight(pos, n, v, m, light);
    170 }
    171  
    172 Light spot = Light(SPOT_POS, SPOT_COLOR, LONG_RANGE);
    173 vec3 spotDir = normalize(spotTarget() - SPOT_POS);
    174 color += spotLight(pos, n, v, m, spot, spotDir);
    175  
    176 color += tubeLight(pos, n, v, m, TUBE_A, TUBE_B, TUBE_COLOR);
55 return color; 177 return color;
56 } 178 }
57   179  
@@ -59,27 +181,31 @@ void main() {
59 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y; 181 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
60 float px = 2.0 / uResolution.y; 182 float px = 2.0 / uResolution.y;
61   183  
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 // 球 184 // 球
71 float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0)); 185 float z = sqrt(max(RADIUS * RADIUS - dot(p, p), 0.0));
72 vec3 spherePos = vec3(p, z); 186 vec3 spherePos = vec3(p, z);
73 vec3 sphere = shade(spherePos, spherePos / RADIUS, SPHERE, lightPos); 187 vec3 sphere = shade(spherePos, spherePos / RADIUS, SPHERE);
74   188  
75 // 壁(z = WALL_Zの平面、法線は手前を向く) 189 // 壁(z = WALL_Zの平面、法線は手前を向く)
76 vec3 wall = shade(vec3(p, WALL_Z), vec3(0.0, 0.0, 1.0), WALL, lightPos); 190 vec3 wall = shade(vec3(p, WALL_Z), vec3(0.0, 0.0, 1.0), WALL);
77   191  
78 float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p)); 192 float mask = 1.0 - smoothstep(RADIUS - px, RADIUS, length(p));
79 vec3 color = mix(wall, sphere, mask); 193 vec3 color = mix(wall, sphere, mask);
80   194  
81 // 光の位置に小さな白い点を描く 195 // トーンマッピング(ラインハルト):0〜∞を0〜1に収める
82 float marker = 1.0 - smoothstep(0.03 - px, 0.03, length(p - lightPos.xy)); 196 color = color / (1.0 + color);
    197  
    198 // ガンマ補正:線型の値をモニター用のsRGBにする
    199 if (uGamma) color = pow(color, vec3(1.0 / 2.2));
    200  
    201 // 光源の印(ポイントライトは点)
    202 float marker = 0.0;
    203 for (int i = 0; i < NUM_LIGHTS; ++i) {
    204 marker = max(marker, 1.0 - smoothstep(0.03 - px, 0.03, length(p - orbit(i).xy)));
    205 }
    206 // チューブライトは線
    207 float tube = sdSegment(p, TUBE_A.xy, TUBE_B.xy);
    208 marker = max(marker, 1.0 - smoothstep(0.015 - px, 0.015, tube));
83 color = mix(color, vec3(1.0), marker); 209 color = mix(color, vec3(1.0), marker);
84   210  
85 out_color = vec4(color, 1.0); 211 out_color = vec4(color, 1.0);


コメント(0)

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