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

受击闪白不能是一把全局开关:颜色混合与逐角色状态提交

两个角色共用同一套 Shader,角色 A 受击时,角色 B 也突然变白。片元公式没有写错,真正越过边界的是一个看似方便的全局 hitStrength:A 的绘制把它设为 1,B 随后的 Draw 又读到了同一个值。

受击闪白需要改变颜色,却不是材质的永久属性。纹理和 Shader 程序可以共享,hitStartSecondshitDurationSeconds 与当前强度却属于单个角色。只有把这两种生命周期拆开,批量复用材质才不会把玩法状态一起共享。

A 受击后在 0.20 秒内恢复,B 的颜色保持不变

片元阶段只做一次受限混合

实验使用 WebGL2 与 GLSL ES 3.00,输出为 RGBA8,颜色值在线性空间内参与中间计算。片元入口位于 .tmp/game-shader-webgl-lab/chapters/01-hit-flash/hit-flash.frag

#version 300 es
precision highp float;

uniform vec3 uBaseColorLinear;
uniform vec3 uFlashColorLinear;
uniform float uHitStrength;
out vec4 outColor;

void main() {
  outColor = vec4(
    mix(uBaseColorLinear, uFlashColorLinear, uHitStrength),
    1.0
  );
}

这里的 mix 只回答当前片元如何表现:强度为 0 时保留原色,为 1 时输出闪白色,中间值做线性插值。它不知道谁受击,也不应该推导持续时间。若直接把 1.4 传入,mix 会外插;RGBA8 最终虽然截到白色,HDR 中间目标却可能保留大于 1 的结果。强度归一化必须发生在宿主提交边界,而不是依赖目标格式替错误输入收尾。

宿主中的时间曲线位于同章 app.jshitStrength。固定持续时间为 0.20 秒,采用一次线性恢复:

export function hitStrength(
  effectSeconds,
  hitStartSeconds,
  hitDurationSeconds
) {
  if (hitDurationSeconds <= 0) return 0;
  const elapsed = effectSeconds - hitStartSeconds;
  if (elapsed < 0) return 0;
  return Math.max(
    0,
    Math.min(1, 1 - elapsed / hitDurationSeconds)
  );
}

hitDurationSeconds <= 0 被定义为不启用效果,避免除零产生 NaN 或无穷值;elapsed < 0 明确阻止未来事件提前闪白。开始前、结束后和重复调用都会稳定落在 [0,1]。本篇只验证线性恢复;缓入缓出可以替换曲线,但不能改变强度由角色持有、在 Draw 前归一化的边界。

共用程序不等于共用 uniform 生命周期

WebGL 的 uniform 状态挂在程序对象上,并不会随角色自动保存。正确调用顺序是:取出 A 的 EffectState,计算 A 的强度,提交 A 的基础色与 uHitStrength,绘制 A;随后用 B 的状态覆盖同一组 uniform,再绘制 B。

function drawActor(gl, locations, actor, effectSeconds) {
  const strength = hitStrength(
    effectSeconds,
    actor.effect.hitStartSeconds,
    actor.effect.hitDurationSeconds
  );
  gl.uniform3fv(locations.baseColor, actor.baseColorLinear);
  gl.uniform1f(locations.hitStrength, strength);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

drawActor(gl, locations, actorA, effectSeconds);
drawActor(gl, locations, actorB, effectSeconds);

这条顺序允许两个角色复用同一个 program、顶点格式乃至基础纹理,但每个 Draw 都必须完成自己的状态提交。把强度写入全局变量,再在一帧开头统一提交一次,会把“共享实现”偷换成“共享效果实例”。把每个角色复制成独立材质能够暂时遮住串色,却会增加材质实例和状态管理数量,仍未明确真正的所有者。

逐 Draw 提交与全局 uniform 串色的固定输入对照

关系图把成功与失败分支放在同一条调用链上。角色状态先在宿主侧计算并钳制,提交边界再把 A 的 0.60 和 B 的 0.00 分别交给同一片元程序。错误分支只有一个全局 1.00,因此两次 Draw 都得到白色。

角色状态、逐 Draw uniform 与片元混合的数据流

像素回读能区分公式正确与状态正确

只看 A 变白无法证明实现正确,因为全局 uniform 的错误方案也能让 A 达到白色。测试必须同时观察未受击的 B,并覆盖恢复过程。执行命令为:

$ python3 $WORK_DIR/.tmp/game-shader-webgl-lab/run_chapter_01.py
exit_code=0
pass=8 fail=0 skip=0 duration=14.404s

固定输入中,A 的线性基础色为 (0.08, 0.22, 0.72),B 为 (0.72, 0.16, 0.10),闪白色为 (1,1,1)。Shader 编译与链接日志为空。峰值时 A 回读 [255,255,255,255],B 仍为 [184,41,26,255];0.08 秒时强度为 0.60,A 回读 [161,175,226,255];0.20 秒时 A 精确回到 [20,56,184,255]。全局 uniform 反例则让 B 也变成 [255,255,255,255]

这些数据验证的是状态隔离与固定颜色输入,不是性能结论。当前实现每个角色至少提交基础色和强度两个 uniform,并产生一次 Draw;没有 GPU 计时,不能据此声称它比材质实例更快。若角色数量上升到需要实例化绘制,逐角色强度应进入实例属性、纹理或缓冲区,但所有权仍然不变:每个可见实例必须携带自己的效果状态。

下一种效果会复用状态边界,而不是全局时间

这个纵向切片解决了同一程序下的受击串色,也明确了零持续时间、越界强度和恢复结束的退化行为。它没有处理纹理采样、光照、HDR 色调映射或复杂曲线;颜色表示跨资源不一致时,白色混合仍可能出现错误亮度。

系列下一篇会在同一个 WebGL2 实验环境中复用 EffectState 和秒制参数,但把单次恢复改为可暂停、有限循环的效果时钟,用它驱动 UV 流动。关键交接不是某条公式,而是已经建立的边界:JavaScript 持有时间与玩法状态,Shader 只消费当前 Draw 已经归一化的表现参数。