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

暂停后还在流动的纹理:UV 偏移、循环边界与效果时钟

暂停菜单已经盖住战斗画面,能量槽里的条纹却还在移动;恢复游戏时,它又突然跳到几秒后的相位。uv + speed * time 的计算没有错,错的是这里的 time 直接取了不会暂停的墙钟。

上一章把受击时间留在角色的 EffectState 中,Shader 只消费当前 Draw 的混合强度。纹理流动沿用同一边界:JavaScript 持有效果时间与暂停状态,片元阶段只接收本次绘制的 UV 偏移。这样暂停不是给 Shader 增加一个分支,而是停止推进产生偏移的时钟。

墙钟继续推进时,暂停前后的流动相位保持一致

位移需要方向、速度和自己的时间域

本章固定方向输入为 (3,4),归一化后是 (0.6,0.8);速度为 0.25 UV/s。效果运行 1 秒时,位移可直接复算:

offset = normalize(3,4) * 0.25 UV/s * 1 s
       = (0.15, 0.20) UV

如果不归一化,代码表达的就不再是“沿给定方向以 0.25 UV/s 移动”,而是 X 轴每秒移动 0.75 UV、Y 轴每秒移动 1.00 UV。方向向量的长度悄悄乘进了速度,同一个速度参数会因美术输入不同而失去统一量纲。

宿主入口位于 .tmp/game-shader-webgl-lab/chapters/02-uv-scroll/app.jsEffectClock 只在运行时接收非负、有限的帧增量,并把累计值限制在四秒周期内:

export class EffectClock {
  constructor(periodSeconds) {
    if (!(periodSeconds > 0)) {
      throw new RangeError('periodSeconds must be positive');
    }
    this.periodSeconds = periodSeconds;
    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 = (
        this.effectSeconds + deltaSeconds
      ) % this.periodSeconds;
    }
    return this.effectSeconds;
  }
}

export function flowOffset(
  direction,
  speedUvPerSecond,
  effectSeconds
) {
  const length = Math.hypot(...direction);
  const unit = length === 0
    ? [0, 0]
    : direction.map(value => value / length);
  const distance = speedUvPerSecond * effectSeconds;
  return unit.map(value => value * distance);
}

周期约束不只是为了数值整洁。把运行数天的墙钟转换为单精度 uniform,会逐渐丢失小增量;固定样本中,float32(16777216.00)float32(16777216.25) 已经相等,四分之一秒相位无法表达。当前效果每四秒重复,因此保留 [0,4) 的局部时间即可。若动画本身不周期,就不能随意取模,而应拆成可控的局部起点与相对时间。

这里还需要区分“暂停”与“这一帧没有更新”。暂停是玩法状态,持续多久都不得改变效果相位;漏帧则仍属于运行状态,下一次 advance 应接收调度器认可的时间增量。把 performance.now() 直接交给 Shader 会丢掉这种语义,宿主也无法决定暂停期间是否冻结、慢放或按技能时间缩放。当前接口只实现冻结和恢复,没有引入时间倍率;若后续需要慢动作,应让倍率参与 deltaSeconds 的形成,而不是回头修改 UV 公式。

periodSeconds 也不是任意常量。沿 (0.6,0.8)0.25 UV/s 移动四秒后,位移向量是 (0.6,0.8),它的长度为一个 UV 单位,但 X、Y 分量并没有分别回到整数。因此四秒周期描述的是“沿方向移动一个单位距离”的状态周期,测试条纹恰好只依赖 X 分量并按四段重复。真实二维纹理若要求两个轴同时无缝回点,周期必须由纹理平铺尺度、方向分量和速度共同确定,不能照搬本章的四秒。本文用它验证有限时钟,不把它包装成所有流动材质的通用周期。

fract 解决循环坐标,不能替采样状态做决定

完整片元入口在同章 uv-scroll.frag。本章用四条明暗带代替外部纹理,使每个像素都能从固定 UV 复算:

#version 300 es
precision highp float;

in vec2 vUv;
uniform vec2 uFlowOffset;
uniform bool uRepeat;
out vec4 outColor;

void main() {
  vec2 movedUv = vUv + uFlowOffset;
  vec2 sampleUv = uRepeat
    ? fract(movedUv)
    : clamp(movedUv, 0.0, 1.0);
  float band = step(0.5, fract(sampleUv.x * 4.0));
  vec3 dark = vec3(0.025, 0.10, 0.18);
  vec3 bright = vec3(0.10, 0.82, 0.92);
  outColor = vec4(mix(dark, bright, band), 1.0);
}

fract 把移动后的坐标折回 [0,1),适合可平铺纹理。Clamp 对照则把越过右边界的坐标固定为 1,末端颜色被拉成一条静止接缝。相同的 (0.15,0.20) 偏移下,右边界 Repeat 回读 [26,209,235,255],Clamp 回读 [6,26,46,255]。这不是 Shader 内任选一种写法的问题:资源是否可平铺、采样器使用 Repeat 还是 Clamp,以及代码是否手动折返,必须构成一致合同。

手动 fract 和采样器 Repeat 在本章的程序条纹上得到同类循环坐标,但接入真实纹理时仍有过滤边界。双线性采样会读取坐标两侧的纹素;若图片首尾颜色不连续,即使地址模式允许重复,接缝仍可能可见。图集中的子区域更不能直接 Repeat,因为折返后的 UV 会进入整张图集的其他块。那类资源需要在子区域内重映射、留出 padding,或改用独立可平铺纹理。Shader 能保证坐标落回区间,却不能替资源制造不存在的连续边界。

相同输入下 Repeat、Clamp 与未归一化方向的画面对照

暂停是否成立,要同时观察墙钟和像素

测试不是只截取一张“正在流动”的图,而是连续推进同一个时钟。开始时效果时间为 0;运行 1 秒后偏移为 (0.15,0.20);随后暂停 3 秒,墙钟累计到 4 秒,效果时间仍为 1 秒,中心像素和偏移都不变;恢复 1 秒后,效果时间变为 2 秒,偏移连续到 (0.30,0.40)。单独推进完整四秒周期时,时间和偏移都精确回到 0。

像素断言特意同时读取中心与右边界。中心像素能证明暂停前后相位没有变化,却无法发现 Clamp,因为当前偏移下中心仍落在有效区间;右边界才会越过 1,暴露折返与钳制的差异。类似地,只检查归一化后的数字也不能证明该值确实进入绘制,必须把“状态输出”和“像素输出”放在同一次运行报告中。两者一致时,才能把问题定位到时间推进、参数提交或片元坐标中的某一层。

$ python3 $WORK_DIR/.tmp/game-shader-webgl-lab/run_chapter_02.py
exit_code=0
pass=12 fail=0 skip=0 duration=14.361s
shader_compile_link_log=""
paused: wall=4.0s effect=1.0s offset=(0.15,0.20)
resumed: effect=2.0s offset=(0.30,0.40)
full_cycle: effect=0.0s offset=(0.00,0.00)

这里验证的是时间所有权、坐标循环和固定像素结果,没有 GPU 计时,也不支持“手动 fract 比采样器 Repeat 更快”之类的性能判断。当前片元路径没有纹理读取,真实材质接入后会增加一次基础纹理采样;是否在 Shader 中折返坐标,应以资源和采样器合同为先。

暂停状态、效果时钟、Draw 提交与大墙钟精度失败的关系

暂停时仍流动的问题至此落在一个明确边界上:玩法状态决定效果时钟能否推进,宿主把归一化方向、UV 每秒速度和有限相位变成 uFlowOffset,Shader 只处理当前片元的循环坐标。零方向稳定退化为零位移,负数或非有限帧增量在状态入口被拒绝,Clamp 资源则不会被伪装成可平铺纹理。

下一篇消散效果会复用这个可暂停的秒制时钟,但不再把时间直接当作循环 UV;宿主会把它映射为单调的 progress,片元阶段再用遮罩值与进度之差决定保留、边缘着色或裁剪。周期相位与一次性进度必须从接口上分开,否则角色会在消散结束后重新出现。