GameTech/URP 渲染实战

案例 4.3圆形进度条与分段血条进阶

目标:做两个游戏 UI 里最常见的程序化控件:带圆头和外发光的环形进度条;带受伤拖尾、分段刻度和低血量闪烁的血条。全部由材质参数驱动,不需要任何贴图。

环形进度条

Assets/Cookbook/Ch04/C04_03_RingProgress.shader(节选:arc)
float r = length(p);
// 从 12 点方向开始、顺时针增加的角度,归一化到 [0,1)
float a01 = frac(atan2(p.x, p.y) / TWO_PI + 1.0);
float hw = _Thickness * 0.5;

float ring = abs(r - _Radius) - hw;              // 整圈环带的 SDF
// 已走过的部分:角度在进度内就用环带距离,否则用到两个“圆头”的距离
float endAngle = _Progress * TWO_PI;
float2 capStart = float2(0.0, _Radius);
float2 capEnd   = _Radius * float2(sin(endAngle), cos(endAngle));
float caps = min(length(p - capStart), length(p - capEnd)) - hw;
float arc = (a01 <= _Progress) ? ring : caps;
if (_Progress <= 0.0) arc = 1.0;                  // 进度为 0 时什么都不画
float r = length(p);
// 从 12 点方向开始、顺时针增加的角度,归一化到 [0,1)
float a01 = fract(atan(p.x, p.y) / TWO_PI + 1.0);
float hw = _Thickness * 0.5;

float ring = abs(r - _Radius) - hw;              // 整圈环带的 SDF
// 已走过的部分:角度在进度内就用环带距离,否则用到两个“圆头”的距离
float endAngle = _Progress * TWO_PI;
vec2 capStart = vec2(0.0, _Radius);
vec2 capEnd   = _Radius * vec2(sin(endAngle), cos(endAngle));
float caps = min(length(p - capStart), length(p - capEnd)) - hw;
float arc = (a01 <= _Progress) ? ring : caps;
if (_Progress <= 0.0) arc = 1.0;                  // 进度为 0 时什么都不画

三步:

  1. 角度从 12 点开始顺时针。标准的 atan2(y, x) 从 +X 轴逆时针,把参数换成 atan2(p.x, p.y),相当于交换了 x、y 轴,起点就变成 +Y 轴、方向变成顺时针。
  2. 整圈环带的距离是 abs(r − R) − 半宽:r − R 是到中心圆的有向距离,取绝对值再减半宽,就是一条有宽度的环(4.6 节的“描边”运算)。
  3. 只保留已完成的部分,并画圆头。角度在进度之内的像素用环带距离;之外的像素改用“到两个端点圆心的距离 − 半宽”,即两个圆头。这样得到的是一段带圆头的弧的精确距离场,后面可以拿它做抗锯齿和外发光。

着色时,颜色沿弧长从起点色渐变到终点色;外发光用 exp(−k·d):离弧越远越暗,指数衰减的光晕比线性衰减自然。

分段血条

Assets/Cookbook/Ch04/C04_03_HealthBar.shader(节选:layout)
// 乘以宽高比,让 SDF 在拉长的面片上仍然是“正”的(圆角不会被压扁)
float2 p = (input.uv - 0.5) * float2(_Aspect, 1.0);
float2 halfSize = float2(_Aspect * 0.5, 0.5);
float outer = SdRoundBox(p, halfSize - 0.04, 0.22);     // 外框
float inner = SdRoundBox(p, halfSize - 0.14, 0.13);     // 槽
// 槽内的横向进度 0..1
float innerHalfW = halfSize.x - 0.14;
float x01 = saturate((p.x + innerHalfW) / (2.0 * innerHalfW));
// 乘以宽高比,让 SDF 在拉长的面片上仍然是“正”的(圆角不会被压扁)
vec2 p = (uv - 0.5) * vec2(_Aspect, 1.0);
vec2 halfSize = vec2(_Aspect * 0.5, 0.5);
float outer = SdRoundBox(p, halfSize - 0.04, 0.22);     // 外框
float inner = SdRoundBox(p, halfSize - 0.14, 0.13);     // 槽
// 槽内的横向进度 0..1
float innerHalfW = halfSize.x - 0.14;
float x01 = saturate((p.x + innerHalfW) / (2.0 * innerHalfW));

血条的面片是 6:1 的长条,UV 却在两个方向上都是 0~1。如果直接在 UV 里画圆角矩形,圆角会被横向拉成椭圆。解决办法是先把坐标乘以宽高比,回到“各向同性”的空间里再求 SDF。这是在非正方形面片上画任何形状都要做的一步。

Assets/Cookbook/Ch04/C04_03_HealthBar.shader(节选:fill)
// 血量颜色:低于阈值后快速变红
half3 hpCol = lerp(_ColorLow.rgb, _ColorFull.rgb, smoothstep(_LowThreshold * 0.6, _LowThreshold * 1.6, _Health));
// 低血量闪烁:_Time 驱动;静帧时用 _FlashPhase
float flash = 0.5 + 0.5 * sin(_Time.y * _FlashSpeed + _FlashPhase);
hpCol *= (_Health < _LowThreshold) ? lerp(0.7, 1.4, flash) : 1.0;

float px = fwidth(x01);
float hpMask    = 1.0 - smoothstep(_Health - px, _Health + px, x01);
float trailMask = 1.0 - smoothstep(_Trail  - px, _Trail  + px, x01);

half3 col = _EmptyColor.rgb;
col = lerp(col, _TrailColor.rgb, trailMask);           // 先画拖尾,再盖上当前血量
col = lerp(col, hpCol, hpMask);
// 上亮下暗,做出一点体积感
col *= lerp(0.75, 1.15, smoothstep(-0.35, 0.35, p.y));
// 分段刻度:frac 重复出竖线
float seg = abs(frac(x01 * _Segments + 0.5) - 0.5) / _Segments;   // 到最近刻度的距离(x01 单位)
col *= lerp(0.55, 1.0, smoothstep(px * 0.5, px * 1.5, seg));
// 血量颜色:低于阈值后快速变红
vec3 hpCol = mix(_ColorLow.rgb, _ColorFull.rgb, smoothstep(_LowThreshold * 0.6, _LowThreshold * 1.6, _Health));
// 低血量闪烁:_Time 驱动;静帧时用 _FlashPhase
float flash = 0.5 + 0.5 * sin(_Time.y * _FlashSpeed + _FlashPhase);
hpCol *= (_Health < _LowThreshold) ? mix(0.7, 1.4, flash) : 1.0;

float px = fwidth(x01);
float hpMask    = 1.0 - smoothstep(_Health - px, _Health + px, x01);
float trailMask = 1.0 - smoothstep(_Trail  - px, _Trail  + px, x01);

vec3 col = _EmptyColor.rgb;
col = mix(col, _TrailColor.rgb, trailMask);            // 先画拖尾,再盖上当前血量
col = mix(col, hpCol, hpMask);
// 上亮下暗,做出一点体积感
col *= mix(0.75, 1.15, smoothstep(-0.35, 0.35, p.y));
// 分段刻度:fract 重复出竖线
float seg = abs(fract(x01 * _Segments + 0.5) - 0.5) / _Segments;   // 到最近刻度的距离(x01 单位)
col *= mix(0.55, 1.0, smoothstep(px * 0.5, px * 1.5, seg));

这里有三个值得学的小技巧:

图 4-7三组参数:进度 0.25 / 0.60 / 0.92,血量 0.85 / 0.50 / 0.18(拖尾分别到 0.93 / 0.68 / 0.42)。右图血量低于阈值 0.3,变红并处于闪烁的高亮相位
用在 UGUI 上这两个 Shader 可以直接给世界空间的面片(头顶血条)使用。若要用在 UGUI 的 Image 上,需要额外处理 UI 的模板测试(Stencil)、裁剪矩形(RectMask2D 的 _ClipRect)和顶点色,最简单的做法是以 Unity 内置的 UI/Default Shader 为蓝本,把这里的片元逻辑移植进去。