此文上接WebGL 三维点光源, 如果没读建议从那里开始。
在上篇文章中我们讲到点光源,计算光源到物体表面每一点的方向,然后做与 方向光相同的运算, 也就是对光线方向和表面法向量(表面面对的方向)做点乘运算。 如果两个方向完全相同就会得到 1,应该被完全照亮。 如果两个方向垂直会得到0,相反会得到 -1。我们直接将这个值和表面的颜色相乘, 实现光照效果。
聚光灯只是做了少量修改,事实上如果你比较有创新能力的话,可能会根据之前学的东西知道聚光灯的实现方法。
你可以把点光源想象成一个点,光线从那个点照向所有方向。 实现聚光灯只需要以那个点为起点选择一个方向,作为聚光灯的方向, 然后将其他光线方向与所选方向点乘,然后随意选择一个限定范围, 然后判断光线是否在限定范围内,如果不在就不照亮。
在上方的图示中我们开一看到光线照向所有的方向,并且将每个方向的点乘结果显示在上面。 然后指定一个方向方向表示聚光灯的方向,选择一个限定(上方以度为单位)。 通过限定我们计算一个点乘限定,只需对限定值取余弦就可以得到。如果与选定聚光灯方向的点乘大于这个点乘限定,就照亮,否则不照亮。
换一种方式解释,假设限定是 20 度,我们可以将它转换为弧度,然后取余弦值得到 -1 到 1 之间的数, 暂且把它称作点乘空间。或者可以用这个表格表示限定值的转换。
限制值
角度 | 弧度 | 点乘空间
--------+---------+----------
0 | 0.0 | 1.0
22 | .38 | .93
45 | .79 | .71
67 | 1.17 | .39
90 | 1.57 | 0.0
180 | 3.14 | -1.0
我们可以只需判断
dotFromDirection = dot(surfaceToLight, -lightDirection)
if (dotFromDirection >= limitInDotSpace) {
// 使用光照
}
让我们来实现它。
首先修改上文的片段着色器。
precision mediump float;
// 从顶点着色器传入的值
varying vec3 v_normal;
varying vec3 v_surfaceToLight;
varying vec3 v_surfaceToView;
uniform vec4 u_color;
uniform float u_shininess;
+uniform vec3 u_lightDirection;
+uniform float u_limit; // 在点乘空间中
void main() {
// 因为 v_normal 是可变量,被插值过
// 所以不是单位向量,单位可以让它成为再次成为单位向量
vec3 normal = normalize(v_normal);
vec3 surfaceToLightDirection = normalize(v_surfaceToLight);
vec3 surfaceToViewDirection = normalize(v_surfaceToView);
vec3 halfVector = normalize(surfaceToLightDirection + surfaceToViewDirection);
- float light = dot(normal, surfaceToLightDirection);
+ float light = 0.0;
float specular = 0.0;
+ float dotFromDirection = dot(surfaceToLightDirection,
+ -u_lightDirection);
+ if (dotFromDirection >= u_limit) {
* light = dot(normal, surfaceToLightDirection);
* if (light > 0.0) {
* specular = pow(dot(normal, halfVector), u_shininess);
* }
+ }
gl_FragColor = u_color;
// 只将颜色部分(不包含 alpha) 和光照相乘
gl_FragColor.rgb *= light;
// 直接加上高光
gl_FragColor.rgb += specular;
}
当然我们需要找到刚才添加的全局变量的位置。
var lightDirection = [?, ?, ?];
var limit = degToRad(20);
...
var lightDirectionLocation = gl.getUniformLocation(program, "u_lightDirection");
var limitLocation = gl.getUniformLocation(program, "u_limit");
然后设置它们
gl.uniform3fv(lightDirectionLocation, lightDirection);
gl.uniform1f(limitLocation, Math.cos(limit));
这是结果
一些需要注意的细节:一个是我们在上方对 u_lightDirection
取负,
这其实是一个等价的选择,我们希望两个方向匹配的时候能指向相同的方向,
也就是需要将 surfaceToLightDirection 和聚光灯的反方向相比,
我们可以用很多种方式实现这个。可以在设置全局变量时传入反方向,
那可能是我的首选,但是我觉得那样应该叫做 u_reverseLightDirection
或 u_negativeLightDirection
而不是 u_lightDirection
。
另一件事,也许这个只是个人习惯,如果可能的话我尽量不在着色器中使用条件语句, 原因是我认为着色器并不是真的使用条件语句,如果你在着色器中使用着色器, 编译器会在代码中扩展很多 0 和 1 的乘法运算来实现,所以这里并不是真的条件语句, 它会将你的代码扩展成多种组合。我不确定现在是否还是这样,但是我们可以使用一些技巧来避免这种情况, 你自己可以选择用或不用。
有一个 GLSL 函数叫做 step
,它获取两个值,如果第二个值大于或等于第一个值就返回 1.0,
否则返回 0。用JavaScript大概可以这样表示。
function step(a, b) {
if (b >= a) {
return 1;
} else {
return 0;
}
}
让我们使用 step
避免这种情况
float dotFromDirection = dot(surfaceToLightDirection,
-u_lightDirection);
// 如果光线在聚光灯范围内 inLight 就为 1,否则为 0
float inLight = step(u_limit, dotFromDirection);
float light = inLight * dot(normal, surfaceToLightDirection);
float specular = inLight * pow(dot(normal, halfVector), u_shininess);
看起来没有什么区别,这是结果
还有一件事,现在的聚光灯效果非常粗糙和僵硬。只有在聚光灯范围内或外, 在外面就直接变黑,没有任何过渡。
要修正它我们可以使用两个限定值代替原来的一个, 一个内部限定一个外部限定。如果在内部限定内就使用 1.0, 在外部限定外面就使用 0.0,在内部和外部限定之间就使用 1.0 到 0.0 之间的插值。
这是我们实现这个的一种方式
-uniform float u_limit; // 在点乘空间中
+uniform float u_innerLimit; // 在点乘空间中
+uniform float u_outerLimit; // 在点乘空间中
...
float dotFromDirection = dot(surfaceToLightDirection,
-u_lightDirection);
- float inLight = step(u_limit, dotFromDirection);
+ float limitRange = u_innerLimit - u_outerLimit;
+ float inLight = clamp((dotFromDirection - u_outerLimit) / limitRange, 0.0, 1.0);
float light = inLight * dot(normal, surfaceToLightDirection);
float specular = inLight * pow(dot(normal, halfVector), u_shininess);
这样就行了
现在我们得到了聚光灯的效果。
需要注意的事如果 u_innerLimit
和 u_outerLimit
相等那么 limitRange
就会是 0.0 。除以 0 就会导致 undefined。这在着色器中没有什么解决办法,
所以只需要在JavaScript中确保 u_innerLimit
和 u_outerLimit
永远不要相等。
(注意:示例代码中并没有做这个。)
GLSL 也有一个函数可以稍微做一些简化,叫做 smoothstep
,和 step
相似返回一个 0 到 1
之间的值,但是它获取最大和最小边界值,返回该值在边界范围映射到 0 到 1 之间的插值。
smoothstep(lowerBound, upperBound, value)
让我们来实现它
float dotFromDirection = dot(surfaceToLightDirection,
-u_lightDirection);
- float limitRange = u_innerLimit - u_outerLimit;
- float inLight = clamp((dotFromDirection - u_outerLimit) / limitRange, 0.0, 1.0);
float inLight = smoothstep(u_outerLimit, u_innerLimit, dotFromDirection);
float light = inLight * dot(normal, surfaceToLightDirection);
float specular = inLight * pow(dot(normal, halfVector), u_shininess);
这样也可以
不同之处是 smoothstep
使用 Hermite 插值而不是线型插值,
也就是说 lowerBound
和 upperBound
之间的值插值后像下方右图所示,
线型插值是左图。
如果你觉得没什么关系的话,使用什么取决于你自己。
还有一件事就是注意当 lowerBound
大于或等于 upperBound
时 smoothstep
方法会产生 undefined。它们值相等和志强的情况一样,多出的问题是 lowerBound
大于
upperBound
时,但是对于正常的聚光灯这种情况永远不会出现。