本文属于系列「游戏中的 Shader:十个常用效果的实现与边界」第 4 / 4 篇

护盾为何只在轮廓发亮:用视线夹角控制 Fresnel 边缘光

护盾球壳已经发出蓝光,画面却仍像一颗均匀涂亮的实心球。继续提高颜色强度只会让正面也更亮;真正缺少的是一个随观察方向变化的控制量:正对相机的表面应接近零,接近轮廓的掠射面才逐渐增强。

上一章用 maskValue - progress 把消散表面分成裁剪、边缘与保留区域,并把进度归一化留在 JavaScript。这里沿用同一条所有权边界:宿主持有相机、对象变换和有限的艺术参数,Shader 只处理当前 Draw。不同之处是控制量不再来自纹理值域,而来自同一空间内世界法线 N 与指向相机的单位向量 V

相机横向移动时,固定护盾的亮边随视线改变

边缘因子只有在 N 与 V 同处一个空间时才有意义

正面片元的 dot(N,V) 接近 1,轮廓附近接近 0,因此基础边缘因子可以写成:

facing  = clamp(dot(N, V), 0, 1)
fresnel = pow(1 - facing, rimPower)

rimPower 并不表示亮度,而是在 [0,1] 上压缩中间值。固定输入中,幂次为 1 时有 22850 个亮边像素,幂次为 8 时只剩 3820 个;幂次越高,亮带越窄。零、负数和非有限值在宿主边界归一化为 1,超过 16 则钳制为 16,避免 pow 的退化输入进入 Shader:

export function normalizeRimPower(value) {
  return Number.isFinite(value) && value > 0
    ? Math.min(value, 16)
    : 1;
}

本章完整调用链位于 chapters/04-fresnel-rim/。顶点阶段把对象位置送入世界空间,同时用宿主提交的法线矩阵变换法线;片元阶段从相机世界位置构造 V,两端没有混入观察空间或对象空间:

// fresnel.vert
#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;

uniform mat4 uModel;
uniform mat4 uViewProjection;
uniform mat3 uNormalMatrix;
out vec3 vWorldPosition;
out vec3 vWorldNormal;

void main() {
  vec4 world = uModel * vec4(aPosition, 1.0);
  vWorldPosition = world.xyz;
  vWorldNormal = uNormalMatrix * aNormal;
  gl_Position = uViewProjection * world;
}

// fresnel.frag
#version 300 es
precision highp float;

in vec3 vWorldPosition;
in vec3 vWorldNormal;
uniform vec3 uCameraWorldPosition;
uniform float uRimPower;
out vec4 outColor;

void main() {
  vec3 N = normalize(vWorldNormal);
  vec3 V = normalize(uCameraWorldPosition - vWorldPosition);
  float facing = clamp(dot(N, V), 0.0, 1.0);
  float fresnel = pow(1.0 - facing, uRimPower);
  vec3 base = vec3(0.025, 0.08, 0.12);
  vec3 rim = vec3(0.10, 0.82, 1.0);
  outColor = vec4(base + rim * fresnel, 1.0);
}

对插值后的法线和视线再次归一化不可省略。光栅化插值不保持单位长度;相机到片元的向量长度也随位置变化。如果把未经归一化的向量直接点乘,facing 会混入距离和三角形形状,亮边便不再只表示夹角。

非均匀缩放会让错误法线矩阵显形

仅用单位球和等比缩放测试,很容易让 mat3(uModel) * normal 看起来正确。本次探针故意把对象缩放固定为 (1.55, 0.72, 1.05),再绕 Z 轴旋转 0.55 rad。位置可以直接乘模型矩阵,法线却必须乘模型矩阵线性部分的逆转置;否则变换后的法线不再垂直于变换后的切平面。

正确逆转置、错误模型矩阵与幂次边界的同输入对照

同一椭球、相机和幂次下,正确路径回读到 8202 个亮边像素,错误的模型矩阵左上 3×3 只有 7020 个。差异不是“风格稍有变化”,而是亮度判断所依赖的几何量已经失真。若对象只含旋转和等比缩放,两条路径可能接近;一旦模型允许非均匀缩放,法线矩阵就必须成为 Draw 参数合同的一部分。

宿主仍是这些矩阵的所有者。它知道对象世界变换何时变化,也知道相机世界位置属于哪一帧;顶点 Shader 不应自行猜测逆矩阵来源。若引擎已经提供世界法线或预计算法线矩阵,可以替换提交方式,但 NV 必须最终落在同一坐标空间。

宿主、顶点阶段、片元阶段与失败输入之间的数据关系

像素质心比一张居中截图更能证明视线响应

居中相机下看到左右两圈亮边,只能证明画面像 Fresnel,不能证明它随视线变化。探针保持模型变换不动,只把相机 X 坐标依次设为 -1.80+1.8,再对绿色通道大于 120 的像素计算横向质心。执行结果为:

$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-09-2000/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=11.968s
camera_bright_centroid_x=189.2,149.5,109.8
normal_matrix_bright_pixels=8202
model_3x3_bright_pixels=7020

质心随相机从左向右连续反向移动,说明亮区由 cameraWorldPosition - worldPosition 决定,而不是烘在模型 UV 或顶点色上。探针还验证了 9216 个顶点输入、Shader 编译链接无日志,以及非法幂次的宿主归一化。这里没有 GPU 计时,因此这些像素数量只用于行为判定,不能推出实现更快或更省带宽。

当前切片解决的是不透明球壳上的观察角边缘光:固定对象变换下高亮跟随相机,非均匀缩放下法线仍保持几何意义,艺术幂次也有有限正数边界。它没有处理法线贴图、蒙皮法线、透明混合或前后表面叠加。

下一章会沿用本章交付的对象到世界变换顺序,在顶点阶段加入带权重的对象空间位移。草根权重为零时必须严格不动,而法线与位置在位移后如何继续进入世界空间,将由同一条变换合同约束。透明护盾则留到第七章,再把本章 Fresnel 与剔除、深度和混合状态合并。