URP 渲染实战 / 第 4 章 Shader 里的数学工具箱与程序化图案 / 案例 4.3
案例 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 时什么都不画
三步:
- 角度从 12 点开始顺时针。标准的
atan2(y, x)从 +X 轴逆时针,把参数换成atan2(p.x, p.y),相当于交换了 x、y 轴,起点就变成 +Y 轴、方向变成顺时针。 - 整圈环带的距离是
abs(r − R) − 半宽:r − R是到中心圆的有向距离,取绝对值再减半宽,就是一条有宽度的环(4.6 节的“描边”运算)。 - 只保留已完成的部分,并画圆头。角度在进度之内的像素用环带距离;之外的像素改用“到两个端点圆心的距离 − 半宽”,即两个圆头。这样得到的是一段带圆头的弧的精确距离场,后面可以拿它做抗锯齿和外发光。
着色时,颜色沿弧长从起点色渐变到终点色;外发光用 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));
这里有三个值得学的小技巧:
- 受伤拖尾:游戏里掉血时,常见做法是血量立即减少,而一条浅色的“拖尾”慢慢追上来,让玩家看清掉了多少。Shader 只负责画:先画到
_Trail的浅色,再盖上到_Health的血量色;拖尾的“慢慢追”由脚本对_Trail做插值。 - 分段刻度:
frac(x01 * _Segments)把进度条切成若干段,到最近刻度的距离小于一个像素的地方压暗。 - 低血量闪烁:
sin(_Time.y * 速度)驱动亮度起伏。_Time在编辑器截图时几乎为 0,所以额外留了一个_FlashPhase参数,静帧时手动指定相位——这是本书所有动画类 Shader 的统一做法。

用在 UGUI 上这两个 Shader 可以直接给世界空间的面片(头顶血条)使用。若要用在 UGUI 的 Image 上,需要额外处理 UI 的模板测试(Stencil)、裁剪矩形(RectMask2D 的
_ClipRect)和顶点色,最简单的做法是以 Unity 内置的 UI/Default Shader 为蓝本,把这里的片元逻辑移植进去。