跳至正文

如何用Cocos Shader实现基础光照

  • Cocos

开发环境: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

计算出 worldNormalworldLightDir 的点积后,为防止出现负值,因此使用 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/

标签:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注