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

角色消散不只是 discard:遮罩阈值与稳定边缘带

角色已经能随进度逐块消失,但画面还有两个明显问题:边缘有时只剩一条生硬的锯齿线,进度走到 1 后又留下几块亮点。继续调整噪声纹理不会解决它们,因为前者来自零宽阈值,后者来自端点语义缺失。

上一章用可暂停效果时钟驱动循环 UV;消散仍复用秒制时间和 [0,1] UV,却不能复用“周期结束后回到零”的行为。角色一旦完成死亡或传送表现,就必须停在完全裁剪状态。JavaScript 因此持有一次性 DissolveState,Shader 只消费当前 Draw 已归一化的进度与边缘宽度。

同一固定遮罩随单调进度从完整表面收敛到完全裁剪

一个差值应当决定三个区域

实验入口位于 .tmp/game-shader-webgl-lab/chapters/03-dissolve/dissolve.frag。固定遮罩把 UV 划成 16×16 单元,并产生包含 0 与 1 的八档值;这种离散输入不是美术方案,而是为了让最大值残留能够稳定复现。片元阶段只需要比较:

delta = maskValue - progress

delta < 0 的片元已被阈值越过,应当裁剪;delta 落在窄区间内时混入边缘色;其余位置保留原表面。完整片元实现如下:

#version 300 es
precision highp float;

in vec2 vUv;
uniform float uProgress;
uniform float uEdgeWidth;
uniform bool uLegacyEndpoint;
uniform bool uHardEdge;
out vec4 outColor;

float maskValue(vec2 uv) {
  vec2 cell = floor(uv * 16.0);
  return mod(cell.x * 3.0 + cell.y * 5.0, 8.0) / 7.0;
}

bool insideActor(vec2 uv) {
  vec2 p = uv * 2.0 - 1.0;
  bool head = length(p - vec2(0.0, 0.55)) < 0.22;
  bool body = abs(p.x) < 0.28
    && p.y > -0.35 && p.y < 0.42;
  bool legs = abs(p.x) < 0.24
    && p.y > -0.82 && p.y <= -0.35
    && abs(p.x) > 0.045;
  bool arms = abs(p.y - 0.08) < 0.10
    && abs(p.x) < 0.48;
  return head || body || legs || arms;
}

void main() {
  if (!insideActor(vUv)) discard;
  float progress = clamp(uProgress, 0.0, 1.0);
  if (!uLegacyEndpoint && progress >= 1.0) discard;

  float mask = maskValue(vUv);
  float delta = mask - progress;
  if (delta < 0.0) discard;

  float edgeWidth = max(
    uEdgeWidth,
    max(fwidth(mask), 1.0 / 255.0)
  );
  float edge = uHardEdge
    ? step(delta, 0.0)
    : 1.0 - smoothstep(0.0, edgeWidth, delta);

  vec3 surface = vec3(0.09, 0.26, 0.42);
  vec3 edgeColor = vec3(1.0, 0.32, 0.035);
  outColor = vec4(mix(surface, edgeColor, edge), 1.0);
}

遮罩值域必须和进度值域一起声明。本章两者都使用闭区间 [0,1],因此进度 0 表示尚未越过任何遮罩值,进度 1 表示完成。若资源导入后只覆盖 [0.2,0.8],直接沿用这套进度会让前 20% 没有变化、后 20% 又只等待端点;宿主应根据实测或约定范围重映射遮罩,不能用修改动画时长掩盖空区间。反过来,边缘宽度的单位也是遮罩值,不是 UV、像素或秒。更换遮罩分布后,同一个 0.16 可能覆盖不同面积,这正是它属于材质表现参数、而进度属于玩法状态的原因。

这里保留了两个只用于回归的开关。uHardEdge 删除过渡带,固定样本中的边缘色像素从 648 变为 0;uLegacyEndpoint 跳过完成端点判断,遮罩值恰好等于 1 的片元在 progress == 1 时仍满足 delta == 0,最终残留 4891 个亮色像素。对连续噪声来说,精确最大值可能很少出现,但过滤、量化和纹理内容都可能把样本推到端点,因此“通常看不见”不能代替完成状态的明确合同。

稳定边缘、硬阈值、正确完成与错误端点残留对照

fwidth(mask) 给出相邻片元遮罩变化的局部尺度。本实现把它与显式边缘宽度和一个 RGBA8 量级下限取最大值,避免把 smoothstep 的两个端点压成同一个值。它只能缓和当前采样尺度下的阈值跳变,不能修复高频遮罩本身的欠采样;角色缩小后仍闪烁时,需要有 mip 的遮罩纹理、降低频率或改用与像素覆盖相关的抗锯齿策略,而不是无限加宽发光边。

循环时钟必须在进入进度前终止

第二章的 EffectClock 会在周期结束后回到零,适合循环流动。若直接用它计算消散进度,完成角色会在下一周期重新出现。本章的状态入口改成饱和累加:

export class DissolveState {
  constructor(durationSeconds) {
    if (!(durationSeconds > 0) || !Number.isFinite(durationSeconds)) {
      throw new RangeError('durationSeconds must be finite and positive');
    }
    this.durationSeconds = durationSeconds;
    this.effectSeconds = 0;
    this.paused = false;
  }

  advance(deltaSeconds) {
    if (!(deltaSeconds >= 0) || !Number.isFinite(deltaSeconds)) {
      throw new RangeError('deltaSeconds must be finite and non-negative');
    }
    if (!this.paused) {
      this.effectSeconds = Math.min(
        this.durationSeconds,
        this.effectSeconds + deltaSeconds
      );
    }
    return this.effectSeconds / this.durationSeconds;
  }
}

固定持续时间为 2 秒。运行 0.7 秒后进度为 0.35,暂停 3 秒仍是 0.35;恢复并推进 1.3 秒后到达 1,再额外推进 8 秒也不会回卷。负数、非有限帧增量在状态入口被拒绝;提交前再把进度钳制到 [0,1],把零边缘宽度提升到 1/255。这样玩法时间、参数合法性和片元分区分别只有一个所有者。

消散状态、uniform 提交与片元分区的固定数据关系

完全消失不能只靠截一张图证明

测试通过真实 Chrome 的 WebGL2 编译与链接,再对同一 RGBA8 缓冲执行 gl.readPixels。四档进度的可见像素数依次为 32912、21054、12822、0;它同时证明裁剪单调推进以及完成端点没有残留。中间画面再单独统计边缘色,区分“还剩同样多片元”与“确实形成了受控边缘带”。

$ python3 $WORK_DIR/.tmp/game-shader-webgl-lab/run_chapter_03.py
exit_code=0
pass=13 fail=0 skip=0 duration=14.840s
shader_compile_link_log=""
visible_pixels=[32912,21054,12822,0]
stable_edge_pixels=648 hard_edge_pixels=0
legacy_endpoint_residual_pixels=4891
paused_progress=0.35 completed_progress_after_extra_time=1.0

这些数字来自固定分辨率、固定几何和离散遮罩,只用于验证状态与分区关系,不构成性能数据。当前路径每个片元计算一次程序遮罩并使用 discard;在早深度、MSAA、阴影 Pass 或移动 GPU 上,裁剪的实际代价需要单独测量。若角色还参与阴影、深度预通过或轮廓绘制,同一进度和遮罩合同也必须提交到那些 Pass,否则颜色已经消失,阴影或深度仍会留下完整轮廓。

边缘颜色当前直接写入线性 RGB,并随表面色混合;接入 HDR 管线时,它是否自发光、是否经过曝光与色调映射,需要在材质合同中另行决定。把橙色数值调大只会改变亮度,并不能修复阈值闪烁。透明对象还会引入混合顺序与深度写入问题,本章采用不透明裁剪,故意把这组状态留给后续透明护盾章节处理。

这个实现解决的是一次性角色消散的三件事:进度不会因暂停或周期回卷而重现,边缘宽度不会退化成未定义阈值,完成端点能确定收敛到零片元。它没有声称离散程序遮罩可以替代美术噪声,也没有覆盖半透明溶解、阴影一致性和抗锯齿的全部选择。

下一篇会保留“宿主归一化输入、Shader 只处理当前 Draw”的边界,但控制量不再来自遮罩阈值,而是世界空间法线与观察方向的夹角。届时同样需要先明确输入值域和退化条件,才能让 Fresnel 边缘光随视线变化而不是随错误空间漂移。