ライティング

立体感を出すには「面の向きと光の関係」を計算する。 ここでは Lambert(拡散)/ Phong(鏡面)/ 法線マップという古典の積み上げを順に。 Three.js の Standard Material は内部でこれのPBR 拡張版を使っている。

必要な3つのベクトル

どのライティング計算にも必要な3つの単位ベクトル:

さらに鏡面ハイライトには:

頂点シェーダ側の準備

ワールド空間での法線・位置を fragment に渡す:

// vertex
out vec3 vWorldPos;
out vec3 vWorldNormal;

void main() {
  vec4 worldPos = modelMatrix * vec4(position, 1.0);
  vWorldPos = worldPos.xyz;
  vWorldNormal = normalize(mat3(modelMatrix) * normal);
  gl_Position = projectionMatrix * viewMatrix * worldPos;
}

Lambert(拡散反射)

マットな質感の基本。面と光が真正面を向くほど明るい。 計算は max(dot(N, L), 0.0) 一行。

最小の Lambert
// fragment
uniform vec3 uLightPos;
uniform vec3 uLightColor;
uniform vec3 uBaseColor;

in vec3 vWorldPos;
in vec3 vWorldNormal;

void main() {
  vec3 N = normalize(vWorldNormal);
  vec3 L = normalize(uLightPos - vWorldPos);

  float lambert = max(dot(N, L), 0.0);

  vec3 diffuse = uBaseColor * uLightColor * lambert;

  // 環境光(暗部の底上げ)
  vec3 ambient = uBaseColor * 0.1;

  outColor = vec4(ambient + diffuse, 1.0);
}

ハーフランベルト

dot(N,L) * 0.5 + 0.5 にすると陰の部分も少し明るくなり、 漫画やキャラに合う柔らかい見た目になる。

float halfLambert = pow(dot(N, L) * 0.5 + 0.5, 2.0);

Phong(鏡面反射 = ハイライト)

プラスチックや金属のテカリ。反射方向と視線の一致度を見て鋭いハイライトを作る。

vec3 V = normalize(cameraPosition - vWorldPos);
vec3 R = reflect(-L, N);

float spec = pow(max(dot(R, V), 0.0), 32.0);   // 32 = shininess

vec3 specular = uLightColor * spec;
outColor = vec4(diffuse + specular + ambient, 1.0);

Blinn-Phong(より自然)

現代では Phong よりBlinn-Phongの方が定番。R ではなくハーフベクトル H を使う。

vec3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), shininess);

3つを合わせた完全な Phong マテリアル

// fragment
uniform vec3 uLightPos;
uniform vec3 uLightColor;
uniform vec3 uBaseColor;
uniform float uShininess;
uniform float uAmbient;

in vec3 vWorldPos;
in vec3 vWorldNormal;

void main() {
  vec3 N = normalize(vWorldNormal);
  vec3 L = normalize(uLightPos - vWorldPos);
  vec3 V = normalize(cameraPosition - vWorldPos);
  vec3 H = normalize(L + V);

  // 拡散
  float NdotL = max(dot(N, L), 0.0);
  vec3 diffuse = uBaseColor * uLightColor * NdotL;

  // 鏡面
  float spec = pow(max(dot(N, H), 0.0), uShininess);
  vec3 specular = uLightColor * spec * step(0.0, NdotL);  // 裏側でハイライトしない

  // 環境光
  vec3 ambient = uBaseColor * uAmbient;

  outColor = vec4(ambient + diffuse + specular, 1.0);
}

距離による減衰

点光源は遠くなるほど暗くなる。物理的には距離の二乗に反比例

vec3 toLight = uLightPos - vWorldPos;
float dist = length(toLight);
vec3 L = toLight / dist;

float attenuation = 1.0 / (dist * dist);
// or 簡易版(距離付き)
float attenuation = 1.0 / (1.0 + 0.1 * dist + 0.01 * dist * dist);

vec3 diffuse = uBaseColor * uLightColor * NdotL * attenuation;

距離フォールオフのカーブ調整

// 範囲に収める(範囲外で 0 になる)
float radius = 5.0;
float att = pow(clamp(1.0 - pow(dist / radius, 4.0), 0.0, 1.0), 2.0);
att /= dist * dist + 1.0;

方向光(Directional Light)

太陽光のような距離無限の並行光は、L が固定方向でいい。

uniform vec3 uLightDir;   // 既に正規化済み

vec3 L = -uLightDir;       // 「面 → 光源」方向にするため反転

スポットライト

円錐内だけ照らす。中心からの角度を見て内側で 1、外側で 0 にする:

vec3 spotDir = normalize(uSpotDirection);
vec3 L = normalize(uLightPos - vWorldPos);
float cosAngle = dot(-L, spotDir);

float inner = cos(uInnerAngle);
float outer = cos(uOuterAngle);
float spot = smoothstep(outer, inner, cosAngle);

vec3 diffuse = ... * spot;

フレネル(縁の輝き)

真横から見た面ほど反射が強くなる物理現象。 水面や金属、リムライトで使う。

vec3 V = normalize(cameraPosition - vWorldPos);
float fresnel = pow(1.0 - max(dot(N, V), 0.0), 3.0);

vec3 rim = vec3(1.0, 0.8, 0.3) * fresnel;
outColor = vec4(diffuse + rim, 1.0);

法線マップ(Normal Mapping)

頂点を増やさずに表面の細かい凹凸を表現する。 テクスチャの RGB に法線の x/y/z を 0..1 に圧縮して保存している。

シンプル(タンジェントを無視)

正確ではないが、平面状のオブジェクトでは問題ないことが多い:

vec3 nm = texture(uNormalMap, vUv).rgb;
vec3 perturb = normalize(nm * 2.0 - 1.0);

vec3 N = normalize(vNormal + perturb * 0.5);   // 大雑把

正確: TBN 行列を使う

タンジェント・バイタンジェント・法線(TBN)を使ってタンジェント空間 → ワールド空間に変換する。

// vertex
in vec4 tangent;        // GLTF の場合は attribute として持つ
out mat3 vTBN;

void main() {
  vec3 N = normalize(mat3(modelMatrix) * normal);
  vec3 T = normalize(mat3(modelMatrix) * tangent.xyz);
  vec3 B = normalize(cross(N, T) * tangent.w);
  vTBN = mat3(T, B, N);

  ...
}

// fragment
in mat3 vTBN;

vec3 normalFromMap(vec2 uv) {
  vec3 nm = texture(uNormalMap, uv).rgb * 2.0 - 1.0;
  return normalize(vTBN * nm);
}

derivative ベースで TBN を計算(attribute 不要)

頂点に tangent attribute が無い時の代替手段。dFdx/dFdy を使う:

vec3 perturbNormal(vec3 N, vec3 V, vec2 uv, vec3 mapN) {
  vec3 dpx = dFdx(V);
  vec3 dpy = dFdy(V);
  vec2 duvx = dFdx(uv);
  vec2 duvy = dFdy(uv);

  vec3 T = normalize(dpx * duvy.t - dpy * duvx.t);
  vec3 B = normalize(cross(N, T));

  return normalize(mat3(T, B, N) * mapN);
}

環境マップ(IBL の擬似版)

本格 PBR は重いが、反射ベクトルでキューブマップを引くだけでも金属やガラスの「映り込み」っぽさが出る。

uniform samplerCube uEnv;

vec3 V = normalize(cameraPosition - vWorldPos);
vec3 R = reflect(-V, N);
vec3 envColor = texture(uEnv, R).rgb;

vec3 col = mix(diffuse, envColor, metalness * 0.8);
outColor = vec4(col, 1.0);

トゥーンシェーディング(セル)

Lambert を段階的にステップ化してアニメ調に。

float NdotL = max(dot(N, L), 0.0);
float toon = floor(NdotL * 4.0) / 4.0;     // 4 段階
vec3 col = uBaseColor * toon;

さらにフレネルでアウトラインを足すと、よりアニメ的:

float fresnel = pow(1.0 - max(dot(N, V), 0.0), 5.0);
float outline = step(0.5, fresnel);
col = mix(col, vec3(0.0), outline);

影(シャドウマップ)

本格的な影はシャドウマップ(光源視点から深度を別パスで撮ってサンプリング)。 自前で書くのは大変なので、Three.js の Standard Material の機能を使うのが現実解。

どうしても自前 ShaderMaterial で影を取り扱いたい場合は、onBeforeCompile で Three.js の shadowmap_pars_fragment 等の chunk を #include する手がある。

複数光源

ライトの数だけループで足し合わせる:

struct Light {
  vec3 position;
  vec3 color;
  float intensity;
};

uniform Light uLights[3];

vec3 totalDiffuse = vec3(0.0);
for (int i = 0; i < 3; i++) {
  vec3 L = normalize(uLights[i].position - vWorldPos);
  float NdotL = max(dot(N, L), 0.0);
  totalDiffuse += uLights[i].color * uLights[i].intensity * NdotL;
}

vec3 col = uBaseColor * totalDiffuse;

PBR への入り口

Three.js の MeshStandardMaterial は内部でCook-Torrance BRDFを使った PBR ライティングを実装している。 本格的な PBR を自分で書くのは複雑なので、最初は Standard / Physical Material の挙動を勉強しつつ、 「見た目を変えたい」場面だけ onBeforeCompile で部分的にいじるのが現実的。

本格自作の場合のキーワード(覚えておくと便利):

「自前で全部書く」より「拡張する」

ライティングは奥が深い。完全自作の PBR は数百行になり、影・IBL・トーンマッピングまで含めるとさらに増える。
Three.js で 3D を作るなら、Standard / Physical Material をベースに onBeforeCompile で必要な部分だけ書き換えるのが現実的。
純粋な GLSL を学ぶなら、LearnOpenGL の「PBR」章が定評ある。