从零实现 GLSL 反射光照:环境光 + 平行光(漫反射 + 高光)

AI 盛行的今天,不知道手写 shader 光照还有没有必要,但不管了,学了就记录一下——过程还挺有意思的。

反射需要什么?

反射离不开两样东西:光线物体。常见的光源有三种:环境光、平行光、点光源。其中环境光不参与真正的"反射计算"(它是模拟场景中各种间接光叠加后的一个近似常量),所以最简单,先从它开始。

vec3 ambientLight(vec3 lightColor, float lightIntensity) {
  return lightColor * lightIntensity;
}

传入光线颜色和强度,直接相乘就行。接入 fragment shader:

uniform vec3 uColor;    // three.js 里传入的自定义颜色,随便设置个即可
varying vec3 vNormal;
varying vec3 vPosition;

#include "../light/ambientLight.glsl"

void main()
{
    vec3 color = uColor;

    // 光线
    vec3 light = vec3(.0);
    light += ambientLight(vec3(1.0), .02);
    color *= light;

    // Final color
    gl_FragColor = vec4(color, 1.0);
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

image

这时候可能有同学会问:环境光不需要传法线和位置,那顶部声明的 vNormalvPosition 是干什么用的?——别急,重头戏在后面:平行光

平行光:漫反射与高光

平行光的特点是:所有光线的方向完全一致,和光源的具体位置无关,只关心一个统一的传播方向(想象太阳光,光源离得极远,到达地表时可以认为所有光线互相平行)。

反射的计算可以拆成两部分:漫反射高光

  • 漫反射:均匀渐变的明暗过渡,模拟粗糙、无光泽的表面(比如粉笔、布料)。
  • 高光:一个集中的亮斑,会随视角移动,模拟光滑、有光泽表面的镜面反光点(比如塑料、金属)。

漫反射

这个原理有个专业名词叫兰伯特漫反射(Lambertian diffuse),细节不展开,记住结论就行:用法线和光照方向的点积,决定漫反射强度

补充一个关键性质:如果两个向量都是单位向量,那么 dot(a, b) = cos(θ),结果落在 -1~1 之间——夹角越小越接近 1,夹角越大(方向越不同)越接近 -1。

对应到物理直觉:物体表面法线和光线方向越平行,光越亮;法线垂直于光线方向时,基本就不可见了。这正是漫反射的写法:

float shading = dot(normalize(normal), lightDirection);
shading = max(.0, shading); // 负数没有意义,最暗就是 0

加上颜色和强度:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition) {
  vec3 lightDirection = normalize(lightPosition); // 从表面指向光源的方向

  float shading = dot(normalize(normal), lightDirection);
  shading = max(.0, shading);

  return lightColor * lightIntensity * shading;
}

image

高光

高光的位置和视角(相机)、法线、光源方向都有关系,好在这部分有现成的公式可以直接调用:reflect(I, N)

先看视线向量的计算:

vec3 viewDirection = normalize(vPosition - cameraPosition);

vPosition - cameraPosition 是"从相机指向表面点"的方向。

再看反射向量:

vec3 lightReflection = reflect(-lightDirection, normal);

这里有个容易搞混的地方:lightDirection 是"从表面指向光源"的方向,但 reflect(I, N) 要求传入的 I入射光线的传播方向,也就是"从光源射向表面"——正好和 lightDirection 相反,所以要取负号 -lightDirection,才是真正意义上"打过来的那束光"。

reflect 内部其实就是一行几何公式:

R = I - 2 * dot(N, I) * N

不是黑盒,是可以手推的。

拿到反射方向后,用同样的点积思路判断"反射光线是否正对着相机":

float specular = dot(lightReflection, -viewDirection); // 从表面指向相机的方向
specular = max(0.0, specular);
specular = pow(specular, specularPower); // 指数越大,高光点越集中越锐利

-viewDirection 才是"从表面指向相机"的方向,和 lightReflection 做点积,值越接近 1 说明反射光线越对准相机,高光越强。

image

完整代码

// directionalLight.glsl
vec3 directionalLight(
  vec3 lightColor,
  float lightIntensity,
  vec3 normal,
  vec3 lightPosition,
  vec3 viewDirection,
  float specularPower
) {
  vec3 lightDirection = normalize(lightPosition);
  vec3 lightReflection = reflect(-lightDirection, normal);

  float shading = dot(normal, lightDirection);
  shading = max(.0, shading);

  float specular = dot(lightReflection, -viewDirection);
  specular = max(0.0, specular);
  specular = pow(specular, specularPower);

  return lightColor * lightIntensity * (shading + specular);
}
// fragment.glsl
uniform vec3 uColor;

varying vec3 vNormal;
varying vec3 vPosition;

#include "../light/ambientLight.glsl"
#include "../light/directionalLight.glsl"

void main()
{
    vec3 normal = normalize(vNormal);
    vec3 viewDirection = normalize(vPosition - cameraPosition);

    vec3 color = uColor;

    vec3 light = vec3(.0);
    light += ambientLight(vec3(1.0), .02);
    light += directionalLight(
        vec3(.1, .1, 1.0),
        1.0,
        normal,
        vec3(.0, .0, 3.0),
        viewDirection,
        20.0
    );
    color *= light;

    gl_FragColor = vec4(color, 1.0);
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

小结

到这里,我们已经实现了光照模型里的三个基础分量:环境光(整体基础亮度)、漫反射(和视角无关的表面明暗)、高光(和视角强相关的镜面亮斑)。三者叠加,就是一个基本能打的光照效果了。本来还想记录点光源,篇幅差不多了,留到下个篇幅
https://threejs-journey.com/lessons/lights-shading-shaders 我是跟着journey学的

posted @ 2026-09-11 17:16  OvOGhostFace  阅读(5)  评论(0)    收藏  举报