开发环境:Cocos Creator 3.7.3
本文代码:https://github.com/foupwang/ShadersForCocosCreator
一 什么是光照
现实世界中,人能够看到物体,是因为光线照射在物体上,反射回我们的眼睛。例如:一个物体是绿色的,实际是因为这个物体会反射更多的绿光波长,而吸收了其它波长。
光线从光源发射出来后,和物体相交会有两种结果:散射
和吸收
。散射
只改变光线方向,密度和颜色不变。而吸收
只改变光线的密度和颜色,方向不变。
光线经过物体表面散射后,有两种方向:1)散射到物体内部,称为折射
或透射
;2)散射到物体外部,称为反射
。
在光照计算中,为了区分这两种不同的方向,把光分成不同的部分来计算:高光反射
部分表示物体表面如何反射光线,漫反射
部分则表示有多少光线会被折射、吸收或散射。
本文将分别介绍漫反射
和 高光反射
的光照计算公式,以及如何在Cocos Creator
引擎中使用Shader
来实现。
二 漫反射
漫反射
是光线与表面粗糙度较高的物体表面相互作用时发生的反射,这是最基本、最常见的一种反射现象。在漫反射下,光线以均匀分布的方式反射,即物体将光线向所有方向发射出去。
漫反射光照符合兰伯特定律(Lambert's law)
,即反射光线的强度与表面法线和光源方向之间夹角的余弦值成正比。计算公式如下:
$ C{diffuse} = (C{light} M_{diffuse} ) max(0, n \cdot l) $
其中,$C{light}$是光源颜色,$M{diffuse}$是材质的漫反射颜色,n
是表面法线,l
是光源方向。另外,为了防止法线和光源方向的点积结果为负值,因此限制为不小于0,防止物体被后面来的光源照亮。
那么,在哪里计算这些光照呢?在顶点着色器中计算,称为逐顶点光照
;在片元着色器中计算,称为逐像素光照
。
由于顶点数量远小于像素数量,因此逐顶点光照的计算量远小于逐像素。但是,逐顶点依赖于线性插值来得到像素光照,因此,如果光照模型中有非线性的计算,逐顶点光照会出问题。另外,某些情况下会产生明显的棱角现象。
2.1 逐顶点光照
在Cocos Shader
中声明一个属性 diffuseCol
,初始值为白色,代表材质的漫反射颜色。
properties:
diffuseCol: { value: [1, 1, 1, 1], editor: { type: color } }
Cocos Shader的详细使用,请参见Cocos引擎官方文档,本文不再赘述。
2.1.1 顶点着色器
漫反射部分的计算都在顶点着色器中进行,关键代码如下。
vec4 vert () {
vec4 pos = vec4(a_position, 1.0);
pos = cc_matProj * (cc_matView * cc_matWorld) * pos;
vec3 ambient = cc_ambientSky.xyz;
vec3 worldNormal = normalize((cc_matWorldIT * vec4(a_normal, 0.0)).xyz);
vec3 worldLightDir = normalize(cc_mainLitDir.xyz);
float satu = clamp(dot(worldNormal, worldLightDir), 0.0, 1.0);
vec3 diffuse = cc_mainLitColor.rgb * diffuseCol.rgb * satu;
vec4 color = vec4(ambient + diffuse, 1.0);
v_uv0 = a_texCoord;
v_color = color;
return pos;
}
上面代码中,通过Cocos Shader内置变量 cc_ambientSky.xyz
得到环境光颜色ambient
。
内置变量 a_normal
代表模型空间下的法线,需要转换为世界坐标空间,但不能直接用世界坐标矩阵 cc_matWorld
,因为无法保证法线的垂直性。因此,需要使用逆转置矩阵 cc_matWorldIT
,再用 normalize
方法进行归一化,得到世界坐标空间下的法线 worldNormal
。
内置变量 cc_mainLitDir
代表光源方向,归一化得到 worldLightDir
。
计算出 worldNormal
和 worldLightDir
的点积后,为防止出现负值,因此使用 clamp 限制在 0 到 1 的范围内,得到结果 satu
。
然后把点积结果satu
与光源颜色cc_mainLitColor
、材质的漫反射属性diffuseCol
相乘,得到最终的漫反射颜色diffuse
。
最后,再把环境光ambient
和漫反射颜色diffuse
相加,得到最终的光照颜色color
,之后传给片元着色器。
2.1.2 片元着色器
所有的计算都在顶点着色器中已经完成,因此片元着色器只要把颜色输出即可。
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
o *= v_color;
return o;
}
2.2 逐像素光照
对上一小节Shader稍做修改,就可以实现逐像素的漫反射效果。
具体代码请参见本文配套源码,以下只列出关键部分。
2.2.1 顶点着色器
顶点着色器不需要计算光照,只要把世界空间下的法线传给片元着色器即可。
vec4 vert () {
vec4 pos = vec4(a_position, 1.0);
pos = cc_matProj * (cc_matView * cc_matWorld) * pos;
v_normal = (cc_matWorldIT * vec4(a_normal, 0.0)).xyz;
v_uv0 = a_texCoord;
v_color = a_color;
return pos;
}
2.2.2 片元着色器
在片元着色器中计算光照模型。
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
vec3 ambient = cc_ambientSky.xyz;
vec3 worldNormal = normalize(v_normal);
vec3 worldLight = normalize(cc_mainLitDir.xyz);
float satu = clamp(dot(worldNormal, worldLight), 0.0, 1.0);
vec3 diffuse = cc_mainLitColor.rgb * diffuseCol.rgb * satu;
vec4 color = vec4(ambient + diffuse, 1.0);
o = color;
return o;
}
如上,计算方法和逐顶点
类似。逐像素
计算可以得到更平滑的光照效果。但是,在光照无法到达的区域,没有任何明暗变化,失去了模型的细节表现。因此,有一种改善方案提出来,这就是半兰伯特(Half Lambert)光照模型
。
2.3 半兰伯特光照模型
公式如下。
$ C{diffuse} = (C{light} M_{diffuse} ) ( \alpha (n \cdot l) + \beta) $
与原模型相比,半兰伯特光照模型
没有使用 max 函数来防止 n 和 l 的点积微负值,而是对其结果进行了一个 $\alpha$ 倍的缩放再加上一个 $\beta$ 偏移。
绝大多数情况下,$\alpha$ 和 $\beta$ 的值均为 0.5,即公式为:
$ C{diffuse} = (C{light} M_{diffuse} ) ( 0.5 (n \cdot l) + 0.5) $
对于模型背光面,原模型中点积结果都映射为 0,而在半兰伯特光照模型
中,不同的点积映射到不同的值,使得背光面也有明暗变化。
实现方式仅需修改上一小节Shader中的片元着色器即可。
vec4 frag () {
....
float halfLambert = dot(worldNormal, worldLight) * 0.5 + 0.5;
vec3 diffuse = cc_mainLitColor.rgb * diffuseCol.rgb * halfLambert;
vec4 color = vec4(ambient + diffuse, 1.0);
col = color;
return col;
}
如上,通过0.5的偏移,背光面的点积结果不再只是0,而是不同的值。
如下图所示,从左到右展示了逐顶点漫反射光照
、逐像素漫反射光照
和半兰伯特光照
的对比效果。
三 高光反射
高光反射,指的是当光线照射到光滑表面时,其中一部分光线以特定的方向反射,并形成明亮、集中的光斑或区域。
高光反射通常发生在镜面反射的表面上,例如金属、光洁的塑料或光面玻璃。当光线照射到这些表面时,它们经过反射后几乎不发生散射,而是沿着特定的方向进行反射,形成明亮的高光。
高光反射的计算公式是:
$ C{specular} = (C{light} M_{specular} ) max(0, v \cdot r)^m$
可见,计算高光反射需要知道4个参数:光源颜色$C{light}$,材质的高光反射系数$M{diffuse}$,视角方向v
和反射方向r
。其中,反射方向r
可以由函数reflect(i, n)
直接得到,i
是入射方向,n
是法线方向。
同样,高光反射光照的计算可以分为逐顶点和逐像素两种。
3.1 逐顶点光照
先看如何实现一个逐顶点的高光反射光照效果。
在Shader中声明以下三个属性:
properties:
diffuseCol: { value: [1, 1, 1, 1], editor: { type: color } }
specularCol: { value: [1, 1, 1, 1], editor: { type: color } }
glossVal: { value: 20, editor: { range: [8, 256] } }
其中,specularCol 表示材质的高光反射颜色,glossVal 表示高光区域的大小。
然后在顶点着色器中计算高光反射光照。
vec4 vert () {
vec4 pos = vec4(a_position, 1.0);
pos = cc_matProj * (cc_matView * cc_matWorld) * pos;
vec3 ambient = cc_ambientSky.xyz;
vec3 worldNormal = normalize((cc_matWorldIT * vec4(a_normal, 0.0)).xyz);
vec3 worldLightDir = normalize(cc_mainLitDir.xyz);
float satu1 = clamp(dot(worldNormal, worldLightDir), 0.0, 1.0);
vec3 diffuse = cc_mainLitColor.rgb * diffuseCol.rgb * satu1;
vec3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
vec3 viewDir = normalize(cc_cameraPos.xyz - (cc_matWorld * vec4(a_position, 1.0)).xyz);
float satu2 = clamp(dot(reflectDir, viewDir), 0.0, 1.0);
vec3 specular = cc_mainLitColor.rgb * specularCol.rgb * pow(satu2, glossVal);
vec4 color = vec4(ambient + diffuse + specular, 1.0) ;
v_uv0 = a_texCoord;
v_color = color;
return pos;
}
如上代码所示,漫反射光照部分的计算和上一节的代码完全一致。高光反射部分,首先计算反射方向 reflectDir,因为入射方向要求是由光源指向交点处,因此需要worldLightDir
取反再传给reflect
函数。然后,从cc_cameraPos
得到世界空间的摄像机位置,把顶点坐标从模型空间变换到世界空间下,再和cc_cameraPos
相减即可得到世界空间下的视角方向。
由此,得到了所有的4个参数,代入公式即可得到高光反射的光照部分。再和环境光、漫反射光相加就是最终的颜色。
片元着色器的代码非常简单,只需要返回顶点颜色即可。
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
o *= v_color;
return o;
}
使用逐顶点的问题是,高光反射的计算是非线性的,而顶点着色器的插值是线性的,导致高光部分明显不平滑。
3.2 逐像素光照
现在,再看看如何逐像素计算高光反射。
顶点着色器只需要计算世界空间下的法线方向和顶点坐标,并传递给片元着色器。
vec4 vert () {
vec4 pos = vec4(a_position, 1.0);
pos = cc_matProj * (cc_matView * cc_matWorld) * pos;
v_worldNormal = (cc_matWorldIT * vec4(a_normal, 0.0)).xyz;
v_worldPos = (cc_matWorld * vec4(a_position, 1.0)).xyz;
v_uv0 = a_texCoord;
v_color = a_color;
return pos;
}
在片元着色器中计算关键的光照模型:
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
vec3 ambient = cc_ambientSky.xyz;
vec3 worldNormal = normalize(v_worldNormal);
vec3 worldLightDir = normalize(cc_mainLitDir.xyz);
float satu1 = clamp(dot(worldNormal, worldLightDir), 0.0, 1.0);
vec3 diffuse = cc_mainLitColor.rgb * diffuseCol.rgb * satu1;
vec3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
vec3 viewDir = normalize(cc_cameraPos.xyz - v_worldPos);
float satu2 = clamp(dot(reflectDir, viewDir), 0.0, 1.0);
vec3 specular = cc_mainLitColor.rgb * specularCol.rgb * pow(satu2, glossVal);
vec4 color = vec4(ambient + diffuse + specular, 1.0) ;
o = color;
return o;
}
运算过程和上一小节基本相同,在此不再重复。
3.3 Blinn-Phong光照模型
还有另外一种高光反射的实现方法是Blinn光照模型
,公式如下:
$ C{specular} = (C{light} M_{specular} ) max(0, n \cdot h)^m$
可见,Blinn模型
不使用反射方向,而是引入一个新的矢量h
,它通过对视角方向v
和光照方向l
相加后再归一化得到。
实现如下,和上一小节的着色器代码基本一样,只需要修改高光反射部分的计算。
vec4 frag () {
...
vec3 viewDir = normalize(cc_cameraPos.xyz - v_worldPos);
vec3 halfDir = normalize(worldLightDir + viewDir);
float satu2 = max(0.0, dot(worldNormal, halfDir));
vec3 specular = cc_mainLitColor.rgb * specularCol.rgb * pow(satu2, glossVal);
vec4 color = vec4(ambient + diffuse + specular, 1.0) ;
o = color;
return o;
}
如下图所示,从左到右分别是逐顶点的高光反射、逐像素的高光反射和Blinn-Phong高光反射。
可以看到,Blinn-Phong模型
的高光反射部分看起来更大、更亮,实际应用中多数情况下会使用 Blinn-Phong 模型
。
四 获取源码
本文的完整示例代码:https://github.com/foupwang/ShadersForCocosCreator
更多文章,请关注微信公众号“楚游香”。
个人网站: https://www.chuyouxiang.com/