低血量效果为何不该写进每个材质:离屏颜色与全屏 Pass
生命值降到 18% 后,画面需要同时出现暗角、降饱和与缓慢脉冲。把公式加入角色材质,天空、地形和粒子不会响应;把它放到最终画面末尾,血条与文字又一起变暗。问题不在于还缺哪个颜色公式,而在于低血量反馈究竟应该读取哪一层颜色,以及 UI 是否属于它的输入。

第九篇已经把世界距离与高度雾混入场景颜色。本篇直接接收这份完成材质与雾处理的结果:不透明与透明场景先进入离屏颜色纹理,低血量全屏 Pass 读取它,HUD 最后叠加。前九篇分别约束对象、UV、空间和绘制状态;最后一篇把同一原则提升到帧内合成顺序——玩法宿主拥有生命值与时间,Shader 只拥有当前全屏 Pass 的表现计算。
低血量参数不能变成材质的玩法状态
生命值是玩法状态,不能让 Shader 自行累减,也不能让每个材质各保存一份。宿主在提交前把 health 约束到 [0,1],再用阈值 0.35 映射危险强度:
// .tmp/game-shader-webgl-lab/chapters/10-low-health-post/app.js
export function normalizeLowHealthState(input) {
const health = Number.isFinite(input.health)
? Math.min(Math.max(input.health, 0), 1)
: 1;
const threshold = Number.isFinite(input.threshold)
? Math.min(Math.max(input.threshold, 0.05), 0.95)
: 0.35;
const timeSeconds = Number.isFinite(input.timeSeconds) && input.timeSeconds >= 0
? input.timeSeconds % 4096
: 0;
const danger = Math.min(
Math.max((threshold - health) / threshold, 0),
1
);
return { health, threshold, timeSeconds, danger };
}
满血时危险强度严格为 0,正式场景颜色不受影响;生命值 0.18、阈值 0.35 时,强度为 0.4857142857142857。负生命值钳到 0,超过 1 钳到 1,非有限输入退化为满血。这个退化方向很重要:一次损坏的遥测或未初始化字段不应把整帧永久压黑。
脉冲继续复用第二篇建立的秒制效果时间,而不是帧计数。固定 2.2 s 与 2.5 s 的中心像素四通道差值为 7,证明时间确实进入表现路径;它不代表脉冲频率适合所有游戏。暂停菜单是否冻结效果,仍由宿主决定是否推进 timeSeconds,Shader 不读取墙钟。
全屏三角形只读取场景颜色
探针为每个 760×330 结果面板创建同尺寸 RGBA8 纹理与 Framebuffer。场景 Pass 先写入该目标,随后绑定 Drawing Buffer,用全屏三角形采样 uSceneColor。离屏尺寸取实际渲染像素,不取 CSS 布局尺寸;动态分辨率发生变化时,纹理、视口与宽高比必须作为同一帧资源规格一起更新。
创建纹理成功还不代表它已经可以被后处理读取。宿主必须把纹理挂到颜色附件,检查 Framebuffer 完整性,并在开始场景 Pass 时同时绑定目标与匹配视口;任一条件失败都应保留原有渲染路径或跳过效果,不能继续采样一个规格不完整的候选目标。本章探针在 makeTarget 中检查 FRAMEBUFFER_COMPLETE,因此截图所用的场景颜色确实来自已经完成附件绑定的目标,而不是浏览器默认缓冲的偶然残留。
全屏阶段也有一条容易被隐藏的读写边界:作为 uSceneColor 的纹理不能同时继续作为当前颜色附件。正式路径先结束场景 Draw,再切回 Drawing Buffer,最后绑定场景纹理进行采样。若渲染器需要把结果继续交给 Bloom、色调映射或下一项后处理,就应使用另一目标并显式交换输入输出;在同一纹理上原地读写没有定义良好的逐像素顺序。
// .tmp/game-shader-webgl-lab/chapters/10-low-health-post/post.frag
#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uSceneColor;
uniform float uDanger;
uniform float uTimeSeconds;
uniform float uAspect;
out vec4 outColor;
void main() {
vec3 scene = texture(uSceneColor, vUv).rgb;
float pulse = 0.82 + 0.18 * sin(uTimeSeconds * 5.0);
float strength = uDanger * pulse;
float luminance = dot(scene, vec3(0.2126, 0.7152, 0.0722));
vec3 desaturated = mix(scene, vec3(luminance), strength * 0.62);
vec2 centered = vUv - 0.5;
centered.x *= uAspect;
float radius = length(centered);
float vignette = min(
smoothstep(0.22, 0.72, radius) * strength,
0.72
);
vec3 dangerColor = mix(
desaturated,
desaturated * vec3(0.92, 0.38, 0.34),
strength * 0.48
);
outColor = vec4(dangerColor * (1.0 - vignette), 1.0);
}
暗角半径在归一化 UV 中计算时,X 轴必须乘实际宽高比;否则 760×330 目标上的圆形距离场会被拉成椭圆。固定边缘样本在正确与错误宽高比之间产生 27 的四通道差值。暗角还被限制为最多 0.72,避免低血量与脉冲峰值叠加后把边缘乘到接近全黑。这里采用一次场景纹理采样、一个全屏 Pass,没有 GPU 计时,因此只能陈述 Pass 与采样形状,不能声称比逐材质实现更快。
选择全屏三角形而不是两三角形矩形,是为了让覆盖整个视口时不产生内部共享对角线;本章没有用这个选择推导性能收益。顶点阶段只从裁剪空间位置生成 [0,1] UV,片元阶段也不需要对象矩阵、法线或深度。若危险反馈以后要避开角色轮廓、按距离衰减或只作用于世界区域,就必须把深度、遮罩或对象标识作为新的、尺寸匹配的输入纹理,不能让当前 Shader 从颜色反猜场景语义。
满血与 18% 生命值画面的 RGB 亮度和分别为 56,256,432 与 41,847,142。它们是固定 RGBA8 缓冲的行为指纹,不是视觉质量或光度指标。若项目使用 HDR 场景颜色,低血量处理应明确位于曝光、Bloom 与色调映射的哪一侧;直接照搬本章常量不能证明 HDR 管线正确。
UI 顺序是一条可回归的颜色边界
正确路径在低血量 Pass 完成后才绘制血条。固定 UI 像素为 [245,209,46,255];把 UI 提前写进离屏纹理后,同一点变为 [162,130,48,255],差值为 164。两张截图看起来都“有血条”,像素回读却明确证明后者已被降饱和、染红并乘上暗角。

UI 后合成并不表示所有界面永远免疫后处理。伤害方向提示、屏幕裂纹或世界空间姓名板可能需要与场景共同响应;可靠做法是把它们放入明确的效果层或单独 Pass,而不是把完整 HUD 移到场景目标里。合成顺序由渲染器持有,单个 UI 组件不应猜测当前是否处于低血量帧。
这个顺序还约束渲染状态恢复。低血量 Pass 关闭深度依赖并覆盖整个目标,UI Pass 随后需要自己的混合、剪裁和视口;若宿主只换 Shader 而没有恢复 Framebuffer 与 viewport,UI 可能仍被画进离屏纹理,表面上就像“UI 后合成”却不会出现在最终缓冲。探针分别把 UI 画到离屏目标与默认缓冲,正是为了让目标绑定错误进入固定回归,而不只比较两段颜色公式。
分屏与多相机则不能共享一个全局危险强度。每个视口可能对应不同玩家、不同生命值和不同离屏尺寸,提交单位必须扩展为“视图目标 + 玩法状态 + 当前效果时间”的完整记录。只有当多个视口确实属于同一玩家时才允许复用状态;纹理和 viewport 仍应逐视图绑定。这与第一篇逐角色提交 hitStrength 的原因相同,只是所有权粒度从 Draw 扩大到 View。

真实 Chrome 编译并链接两套 GLSL ES 3.00 程序,结果与失败画面共执行 16 次 Draw,再通过 gl.readPixels 检查固定区域:
$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-12-2000/run_lab.py
exit_code=0
pass=12 fail=0 skip=0 duration=21.317s
danger_at_health_18=0.4857142857142857
pulse_center_delta=7
ui_order_sample_delta=164
aspect_edge_delta=27
这条管线解决了开篇的两种遗漏:所有已经进入场景颜色的对象共享一次低血量处理,HUD 又能保持自己的颜色与可读性。它适用于单场景颜色目标上的暗角、降饱和和染色反馈,不覆盖 HDR 顺序、动态分辨率历史缓冲、多相机堆栈、分屏或需要独立处理的 UI 层。至此,系列从逐角色颜色状态走到整帧合成,始终保持同一条边界:宿主拥有状态与提交顺序,Shader 只计算被当前 Draw 或 Pass 明确交付的数据。