ライティング
立体感を出すには「面の向きと光の関係」を計算する。 ここでは Lambert(拡散)/ Phong(鏡面)/ 法線マップという古典の積み上げを順に。 Three.js の Standard Material は内部でこれのPBR 拡張版を使っている。
必要な3つのベクトル
どのライティング計算にも必要な3つの単位ベクトル:
- N (Normal) — 面の法線(その面が向いている方向)
- L (Light direction) — 面 → 光源の方向
- V (View direction) — 面 → カメラの方向
さらに鏡面ハイライトには:
- R (Reflection) — L が N で反射した方向 → V と比較
- H (Half vector) — L と V の中間ベクトル → N と比較(Blinn-Phong)
頂点シェーダ側の準備
ワールド空間での法線・位置を 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) 一行。
// 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 で部分的にいじるのが現実的。
本格自作の場合のキーワード(覚えておくと便利):
- BRDF(Bidirectional Reflectance Distribution Function)
- D(Distribution)— GGX/Trowbridge-Reitz
- F(Fresnel)— Schlick の近似
- G(Geometry)— Smith の近似
- roughness² が α として使われることが多い
- F0(基本反射率)— 非金属で 0.04、金属で baseColor
ライティングは奥が深い。完全自作の PBR は数百行になり、影・IBL・トーンマッピングまで含めるとさらに増える。
Three.js で 3D を作るなら、Standard / Physical Material をベースに onBeforeCompile で必要な部分だけ書き換えるのが現実的。
純粋な GLSL を学ぶなら、LearnOpenGL の「PBR」章が定評ある。