3D 着色器:RimLight

本文将通过实现一个 RimLight 效果,来演示如何编写一个在 Cocos 可用于 3D 模型渲染的着色器(Cocos Effect) 。

RimLight 也称为“内发光”/“轮廓光”/“边缘光”(本文统一使用边缘光),是一种通过使物体的边缘发出高亮,让物体更加生动的技术。

RimLight 是菲涅尔现象1的一种应用,通过计算物体法线和视角方向的夹角的大小,调整发光的位置和颜色,是一种简单且高效的提升渲染效果的着色器。

在边缘光的计算中,视线和法线的夹角越大,则边缘光越明显。

rimlight preview

新建材质与着色器

首先参考 着色器资源 新建一个名为 rimlight.effect 的着色器,并创建一个使用该着色器的材质 rimlight.mtl

create rimlight

CCEffect

Cocos Effect 使用 YAML 作为解析器,因此 CCEffect 的写法需要遵守 YAML 的语法标准,对于这块的内容可以参考 YAML 101

在本示例中,将暂时不考虑半透明效果,此时可以将 transparent 部分删掉。

  1. # 删除如下的部分
  2. - name: transparent
  3. passes:
  4. - vert: general-vs:vert # builtin header
  5. frag: rimlight-fs:frag
  6. blendState:
  7. targets:
  8. - blend: true
  9. blendSrc: src_alpha
  10. blendDst: one_minus_src_alpha
  11. blendSrcAlpha: src_alpha
  12. blendDstAlpha: one_minus_src_alpha
  13. properties: *props

opaque 部分的 frag 函数修改为: rimlight-fs:frag,这是接下来要实现的边缘光的片元着色器部分。

  1. - name: opaque
  2. passes:
  3. - vert: general-vs:vert # builtin header
  4. frag: rimlight-fs:frag

为了方便调整边缘光的颜色,增加一个用于调整边缘光颜色的属性 rimLightColor,由于不考虑半透明,只使用该颜色的 RGB 通道:

  1. rimLightColor: { value: [1.0, 1.0, 1.0], # RGB 的默认值
  2. target: rimColor.rgb, # 绑定到 Uniform rimColor 的 RGB 通道上
  3. editor: { # 在 material 的属性检查器内的样式定义
  4. displayName: Rim Color, # 显示 Rim Color 作为显示名称
  5. type: color } } # 该字段的类型为颜色值

此时的 CCEffect 代码:

  1. CCEffect %{
  2. techniques:
  3. - name: opaque
  4. passes:
  5. - vert: general-vs:vert # builtin header
  6. frag: rimlight-fs:frag
  7. properties: &props
  8. mainTexture: { value: white }
  9. mainColor: { value: [1, 1, 1, 1], editor: { type: color } }
  10. # Rim Light 的颜色,只依赖 RGB 三个通道的分量
  11. rimLightColor: { value: [1.0, 1.0, 1.0], target: rimColor.rgb, editor: { displayName: Rim Color, type: color } }
  12. }%

注意:需要在片元着色器的 uniform Constant 内增加对应的 rimColor 字段:

  1. uniform Constant {
  2. vec4 mainColor;
  3. vec4 rimColor;
  4. };

这个绑定意味着着色器的 rimLightColor 的 RGB 分量的值会通过引擎传输到 Uniform rimColorrgb 三个分量里。

注意:引擎规定不能使用 vec3 类型的矢量来避免 implict padding,因此在使用 3 维向量(vec3)时,可选择用 4 维向量(vec4)代替。不用担心,alpha 通道会被利用起来不被浪费。

顶点着色器

通常引擎内置的顶点着色器可以满足大部分的开发需求,因此可以直接采用引擎内置的通用顶点着色器:

  1. - vert: general-vs:vert # builtin header

片元着色器

修改通过 资源管理器 创建的着色器中的片元着色器代码,将 CCProgram unlit-fs 修改为 CCProgram rimlight-fs

修改前:

  1. CCProgram unlit-fs %{
  2. precision highp float;
  3. ...
  4. }%

修改后:

  1. CCProgram rimlight-fs %{
  2. precision highp float;
  3. ...
  4. }%

在光照计算中,通常都需要计算法线和视线的夹角,而视线的计算和 摄像机位置 紧密相关。

view-direction

如上图所示,如果要计算视线,需要通过 摄像机位置 减去 物体的位置。在着色器内,想要获取 摄像机位置,需要使用 全局 Uniform 中的 cc_cameraPos,该变量存放于 cc-global 着色器片段内。通过 include 关键字,可以方便的引入整个着色器片段。

  1. #include <builtin/uniforms/cc-global> // 包含 Cocos Creator 内置全局变量

着色器代码:

  1. CCProgram rimlight-fs %{
  2. precision highp float;
  3. #include <builtin/uniforms/cc-global> // 包含 Cocos Creator 内置全局变量
  4. #include <legacy/output>
  5. #include <legacy/fog-fs>
  6. ...
  7. }

视线方向的计算是通过当前相机的位置(cc_cameraPos)减去片元着色器内由顶点着色器传入的位置信息 in vec3 v_position

  1. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向

我们不关心视线向量的长度,因此得到 viewDirection 后,通过 normalize 方法进行归一化处理:

  1. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化

cc_cameraPos 的 xyz 分量表示了相机的位置。

此时的片元着色器代码:

  1. vec4 frag(){
  2. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  3. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  4. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  5. CC_APPLY_FOG(col, v_position);
  6. return CCFragOutput(col);
  7. }

接下来需要计算法线和视角的夹角,由于使用的是内置标准顶点着色器 general-vs:vert,法线已由顶点着色器传入到片元着色器,但是没有被声明,若要在片元着色器里面使用,只需在代码中增加:

  1. in vec3 v_normal;

此时的片元着色器:

  1. CCProgram rimlight-fs %{
  2. precision highp float;
  3. #include <builtin/uniforms/cc-global>
  4. #include <legacy/output>
  5. #include <legacy/fog-fs>
  6. in vec2 v_uv;
  7. in vec3 v_normal;
  8. in vec3 v_position;
  9. ....
  10. }

法线由于管线的插值,不再处于归一化状态,因此需要对法线进行归一化处理,使用 normalize 函数进行归一化:

  1. vec3 normal = normalize(v_normal); // 重新归一化法线。

此时的 frag 函数:

  1. vec4 frag(){
  2. vec3 normal = normalize(v_normal); // 重新归一化法线。
  3. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  4. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  5. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  6. CC_APPLY_FOG(col, v_position);
  7. return CCFragOutput(col);
  8. }

这时可计算法线和视角的夹角,在线性代数里面,点积表示为两个向量的模乘以夹角的余弦值:

  1. a·b = |a|*|b|*cos(θ)

通过简单的交换律可得出:

  1. cos(θ) = a·b /(|a|*|b|)

由于法线和视角方向都已经归一化,因此他们的模为 1,点积的结果则表示为法线和视角的 cos 值。

  1. cos(θ) = a·b

将其转化为代码:

  1. dot(normal, normalizedViewDirection)

注意点积的计算可能会出现小于 0 的情况,而颜色是正值,通过 max 函数将其约束在 [0, 1] 这个范围内:

  1. max(dot(normal, normalizedViewDirection), 0.0)

此时可根据点积的结果来调整 RimLight 的颜色:

  1. float rimPower = max(dot(normal, normalizedViewDirection), 0.0);// 计算 RimLight 的亮度
  2. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  3. col.rgb += rimPower * rimColor.rgb; // 增加边缘光

着色器代码如下:

  1. vec4 frag(){
  2. vec3 normal = normalize(v_normal);// 重新归一化法线。
  3. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  4. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  5. float rimPower = max(dot(normal, normalizedViewDirection), 0.0); // 计算 RimLight 的亮度
  6. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  7. col.rgb += rimPower * rimColor.rgb; // 增加边缘光
  8. CC_APPLY_FOG(col, v_position);
  9. return CCFragOutput(col);
  10. }

可观察到物体中心比边缘更亮,这是因为边缘顶点的法线和视角的夹角更大,得到的余弦值更小。

注意:此步骤若无法观察到效果,可调整 MainColor 使其不为白色。因为默认的 MainColor 颜色是白色,遮盖了边缘光的颜色。

dot result

要调整这个结果,只需用 1 减去点积的结果即可,删除下面的代码:

float rimPower = max(dot(normal, normalizedViewDirection), 0.0);

并增加:

  1. float rimPower = 1.0 - max(dot(normal, normalizedViewDirection), 0.0);

片元着色器代码:

  1. vec4 frag(){
  2. vec3 normal = normalize(v_normal); // 重新归一化法线。
  3. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  4. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  5. float rimPower = 1.0 - max(dot(normal, normalizedViewDirection), 0.0);
  6. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  7. col.rgb += rimPower * rimColor.rgb; // 增加边缘光
  8. CC_APPLY_FOG(col, v_position);
  9. return CCFragOutput(col);
  10. }

one minus dot result

虽然可观察到边缘光效果,但是光照太强,并且不方便调整,可在着色器的 CCEffect 段内增加一个可调整的参数 rimIntensity。由于之前 rimColor 的 alpha 分量没有被使用到,因此借用该分量进行绑定可节约额外的 Uniform。

注意:写着色器时,需要避免 implict padding,关于这点可以参考: UBO 内存布局,这里使用未被使用的 alpha 通道来存储边缘光的强度可以最大限度的利用 rimColor 的字段。

在 CCEffect 内增加如下代码:

  1. rimInstensity: { value: 1.0, # 默认值为 1
  2. target: rimColor.a, # 绑定到 rimColor 的 alpha 通道
  3. editor: { # 属性检查器的样式
  4. slide: true, # 使用滑动条来作为显示样式
  5. range: [0, 10], # 滑动条的值范围
  6. step: 0.1 } # 每次点击调整按钮时,数值的变化值

此时的 CCEffect 代码:

  1. CCEffect %{
  2. techniques:
  3. - name: opaque
  4. passes:
  5. - vert: general-vs:vert # builtin header
  6. frag: rimlight-fs:frag
  7. properties: &props
  8. mainTexture: { value: white }
  9. mainColor: { value: [1, 1, 1, 1], editor: { type: color } }
  10. # Rim Light 的颜色,只依赖 rgb 三个通道的分量
  11. rimLightColor: { value: [1.0, 1.0, 1.0], target: rimColor.rgb, editor: { displayName: Rim Color, type: color } }
  12. # rimLightColor 的 alpha 通道没有被用到,复用该通道用来描述 rimLightColor 的强度。
  13. rimInstensity: { value: 1.0, target: rimColor.a, editor: {slide: true, range: [0, 10], step: 0.1}}
  14. }%

增加此属性后,材质 属性检查器 上会增加可调整的 RimIntensity:

intensity

通过 pow 函数调整边缘光,使其范围不是线性变化,可体现更好的效果,删除如下代码:

col.rgb += rimPower * rimColor.rgb;

新增下列代码:

  1. float rimInstensity = rimColor.a; // alpha 通道为亮度的指数
  2. col.rgb += pow(rimPower, rimInstensity) * rimColor.rgb; // 使用 ‘pow’ 函数对点积进行指数级修改

pow 是 GLSL 的内置函数,其形式为:pow(x, p),代表以 x 为底数,p 为指数的指数函数。

最终片元着色器代码:

  1. vec4 frag(){
  2. vec3 normal = normalize(v_normal); // 重新归一化法线。
  3. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  4. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  5. float rimPower = 1.0 - max(dot(normal, normalizedViewDirection), 0.0);// 计算 RimLight 的亮度
  6. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  7. float rimInstensity = rimColor.a; // alpha 通道为亮度的指数
  8. col.rgb += pow(rimPower, rimInstensity) * rimColor.rgb; // 增加边缘光
  9. CC_APPLY_FOG(col, v_position);
  10. return CCFragOutput(col);
  11. }

之后将材质 属性检查器 上的 rimIntensity 的值修改为 3:

设置 intensity

此时可观察到边缘光照更自然:

增加亮度调整后

通过 Rim ColorrimIntensity 可方便的调整边缘光的颜色和强度:

调整颜色值

调整颜色结果

完整的着色器代码:

  1. CCEffect %{
  2. techniques:
  3. - name: opaque
  4. passes:
  5. - vert: legacy/main-functions/general-vs:vert # builtin header
  6. frag: rimlight-fs:frag
  7. properties: &props
  8. mainTexture: { value: white }
  9. mainColor: { value: [1, 1, 1, 1], editor: { type: color } }
  10. # Rim Light 的颜色,只依赖 rgb 三个通道的分量
  11. rimLightColor: { value: [1.0, 1.0, 1.0], target: rimColor.rgb, editor: { displayName: Rim Color, type: color } }
  12. # rimLightColor 的 alpha 通道没有被用到,复用该通道用来描述 rimLightColor 的强度。
  13. rimInstensity: { value: 1.0, target: rimColor.a, editor: {slide: true, range: [0, 10], step: 0.1}}
  14. }%
  15. CCProgram rimlight-fs %{
  16. precision highp float;
  17. #include <builtin/uniforms/cc-global>
  18. #include <legacy/output>
  19. #include <legacy/fog-fs>
  20. in vec2 v_uv;
  21. in vec3 v_normal;
  22. in vec3 v_position;
  23. uniform sampler2D mainTexture;
  24. uniform Constant {
  25. vec4 mainColor;
  26. vec4 rimColor;
  27. };
  28. vec4 frag(){
  29. vec3 normal = normalize(v_normal); // 重新归一化法线。
  30. vec3 viewDirection = cc_cameraPos.xyz - v_position; // 计算视线的方向
  31. vec3 normalizedViewDirection = normalize(viewDirection); // 对视线方向进行归一化
  32. float rimPower = 1.0 - max(dot(normal, normalizedViewDirection), 0.0);// 计算 RimLight 的亮度
  33. vec4 col = mainColor * texture(mainTexture, v_uv); // 计算最终的颜色
  34. float rimInstensity = rimColor.a; // alpha 通道为亮度的指数
  35. col.rgb += pow(rimPower, rimInstensity) * rimColor.rgb; // 增加边缘光
  36. CC_APPLY_FOG(col, v_position);
  37. return CCFragOutput(col);
  38. }
  39. }%

若要让边缘光的颜色受纹理颜色的影响,可将下列代码:

  1. col.rgb += pow(rimPower, rimInstensity) * rimColor.rgb; // 增加边缘光

改为:

  1. col.rgb *= 1.0 + pow(rimPower, rimInstensity) * rimColor.rgb; // 边缘光受物体着色的影响

此时的边缘光则会受到最终纹理和顶点颜色的影响:

color

  1. 菲涅尔现象:奥古斯丁·让·菲涅耳是 18 世纪法国著名的物理学家,他提出的菲涅尔方程很好的解释了光线的反射和折射的关系。如果去观察阳光照射下的平静水面可以发现,距离观察点越远的水面反射越强烈,这种光线强度随着观察角度变化而变化的现象,被称为菲涅尔现象。fresnel