一封信的翻译之旅引子两个人两种语言想象一个场景。一个中国裁缝和一个法国缝纫机。裁缝说中文缝纫机只懂法语。裁缝量好了布料上每个点的位置用中文记录下来“这个点在布的右边200厘米上面150厘米的地方。”缝纫机听不懂。它需要的是另一种描述“这个点在裁剪区域的右边界处距离中心线偏上75%的位置。”像素的世界坐标就是裁缝的中文。裁剪坐标就是缝纫机的法语。坐标变换就是翻译官。这篇文章要讲的就是这个翻译官的工作细节——每一个字怎么翻每一个数怎么算为什么要这样算。一、两个坐标系的世界观1.1 像素世界坐标——“我在哪里”像素的世界坐标或本地坐标描述的是 我这个像素在Panel的坐标系中处于什么位置。 它的特点 单位是像素或者说UI单位 原点是Panel的中心通常 X轴向右为正 Y轴向上为正 范围不固定取决于UI的大小 举例一个400×300的裁剪区域 Panel的坐标系 Y轴 ↑ │ 150 ┤ · · · · · · · · · · · · · · · · │ 100 ┤ ★ 这个像素 │ 位置 (120, 100) 50 ┤ │ 0 ┼────┬────┬────┬────┬────┬────→ X轴 │ -50 ┤ │ -100 ┤ │ -150 ┤ · · · · · · · · · · · · · · · · │ -200 -100 0 100 200 ★的世界坐标 (120, 100) 这个坐标告诉我们 这个像素在Panel中心偏右120像素偏上100像素 但这个信息对裁剪来说不够直接。 裁剪需要知道的是 这个像素在裁剪区域的什么位置 是在里面在边界上还是在外面1.2 裁剪坐标——“我该不该活”裁剪坐标描述的是 我这个像素相对于裁剪区域的边界处于什么位置。 它的特点 无单位归一化的比例值 原点是裁剪区域的中心 范围被归一化到 -1 到 1 -1 和 1 恰好是裁剪区域的边界 裁剪坐标系 clipY ↑ │ 1.0 ┤─────────────────────────┐ ← 上边界 │ │ 0.5 ┤ │ │ ★ │ │ clipPos(0.6, 0.667) 0.0 ┼─────────────●───────────┤ ← 中心 │ (0,0) │ -0.5 ┤ │ │ │ -1.0 ┤─────────────────────────┘ ← 下边界 │ └──┬────┬────┬────┬────┬──→ clipX -1.0 -0.5 0 0.5 1.0 ↑ ↑ 左边界 右边界 ★的裁剪坐标 (0.6, 0.667) 这个坐标告诉我们 X方向0.6在中心偏右60%的位置还没到边界(1.0) Y方向0.667在中心偏上66.7%的位置还没到边界(1.0) 两个分量的绝对值都小于1 → 这个像素在裁剪区域内部 → 应该被显示 裁剪坐标的判断规则 ┌──────────────────────────────────────────────┐ │ │ │ |clipPos.x| 1 且 |clipPos.y| 1 │ │ → 在裁剪区域内部 → 可见 │ │ │ │ |clipPos.x| 1 或 |clipPos.y| 1 │ │ → 恰好在边界上 → 边界处理 │ │ │ │ |clipPos.x| 1 或 |clipPos.y| 1 │ │ → 在裁剪区域外部 → 不可见 │ │ │ └──────────────────────────────────────────────┘ 多么简洁 不管裁剪区域有多大、在哪里 只要看裁剪坐标是否在(-1, 1)范围内 就知道该不该显示1.3 为什么要归一化到(-1, 1)为什么不直接用像素坐标来判断 直接判断的方式 if (pixelX leftBorder pixelX rightBorder pixelY bottomBorder pixelY topBorder) { // 可见 } 这需要4个边界值4次比较。 而且不同的裁剪区域有不同的边界值。 Shader需要存储4个参数。 归一化后的方式 clipPos transform(pixelPos) if (abs(clipPos.x) 1 abs(clipPos.y) 1) { // 可见 } 只需要和1比较。 不管裁剪区域多大、在哪里判断条件永远一样。 Shader的逻辑更简单、更统一。 更重要的是——softness的计算也变得统一 dist 1.0 - abs(clipPos) fade clamp(dist * softFactor, 0, 1) 这个公式对任何大小的裁剪区域都适用。 不需要根据裁剪区域的大小调整公式。 这就是归一化的威力 把千变万化的具体数值 统一到一个标准的范围内 让后续的计算变得通用、简洁、高效。 打个比方 考试成绩 数学考了85分满分100 英语考了127分满分150 谁考得好不好直接比较。 归一化后 数学85/100 0.85 英语127/150 0.847 现在可以直接比较了 裁剪坐标做的就是同样的事情 把不同大小的裁剪区域 统一到(-1, 1)的标准范围 让比较和计算变得简单。二、变换公式的推导2.1 从直觉开始问题 已知裁剪区域的中心(cx, cy)和半宽半高(halfW, halfH) 给定一个像素的世界坐标(px, py) 求它的裁剪坐标(clipX, clipY) 直觉思路 第一步算出像素相对于裁剪中心的偏移 deltaX px - cx deltaY py - cy 如果像素恰好在裁剪中心 → delta (0, 0) 如果像素在裁剪中心右边100像素 → deltaX 100 第二步把偏移归一化到(-1, 1)范围 clipX deltaX / halfW clipY deltaY / halfH 如果像素在右边界上 → deltaX halfW → clipX 1.0 如果像素在左边界上 → deltaX -halfW → clipX -1.0 如果像素在中心 → deltaX 0 → clipX 0 合起来 clipX (px - cx) / halfW clipY (py - cy) / halfH 就这么简单 用具体数字验证 裁剪区域center(0,0), size(400,300) halfW200, halfH150 像素(120, 100) clipX (120 - 0) / 200 0.6 clipY (100 - 0) / 150 0.667 |0.6| 1, |0.667| 1 → 在内部 ✓ 像素(200, 0) clipX (200 - 0) / 200 1.0 clipY (0 - 0) / 150 0 |1.0| 1 → 恰好在右边界上 ✓ 像素(250, 0) clipX (250 - 0) / 200 1.25 clipY (0 - 0) / 150 0 |1.25| 1 → 在外部 ✓ 像素(-200, -150) clipX (-200 - 0) / 200 -1.0 clipY (-150 - 0) / 150 -1.0 恰好在左下角 ✓2.2 展开成Shader友好的形式原始公式 clipX (px - cx) / halfW clipY (py - cy) / halfH 展开 clipX px / halfW - cx / halfW clipY py / halfH - cy / halfH 整理成 乘法 加法 的形式 clipX px × (1/halfW) (-cx/halfW) clipY py × (1/halfH) (-cy/halfH) 定义 scaleX 1 / halfW scaleY 1 / halfH offsetX -cx / halfW offsetY -cy / halfH 最终 clipX px × scaleX offsetX clipY py × scaleY offsetY 向量形式 clipPos pixelPos × scale offset 打包成一个Vector4传给Shader _ClipRange0 (offsetX, offsetY, scaleX, scaleY) Shader中一行代码搞定 float2 clipPos worldPos.xy * _ClipRange0.zw _ClipRange0.xy; 为什么要展开成这种形式 原始公式clipX (px - cx) / halfW 需要1次减法 1次除法 除法在GPU上很慢 展开后clipX px × scaleX offsetX 需要1次乘法 1次加法 乘法和加法在GPU上非常快 而且这正好是一个MAD指令Multiply-Add 大多数GPU可以在一个时钟周期内完成 scale和offset是常量每帧只算一次在CPU上算好 pixelPos是变量每个像素不同 常量 × 变量 常量 一条MAD指令 这就是为什么要把公式展开成这种形式 把CPU能做的计算提前做好 留给GPU的只有最简单的乘加运算2.3 为什么offset是负的offsetX -cx / halfW 为什么有个负号 让我们用一个具体例子来理解 裁剪区域中心在 cx 100 halfW 200 offsetX -100 / 200 -0.5 现在验证——像素在裁剪中心(100, ?) clipX 100 × (1/200) (-0.5) 0.5 (-0.5) 0 裁剪中心的clipX 0 ✓ 完美 如果offset是正的错误的 offsetX 100 / 200 0.5 clipX 100 × (1/200) 0.5 0.5 0.5 1.0 裁剪中心的clipX 1.0那就在边界上了 这显然不对。 负号的直觉理解 裁剪中心在右边(cx100) → 所有像素相对于裁剪中心都要往左移 → 减去中心的偏移 → offset是负的 就像你站在一个偏右的位置看世界 世界在你眼中整体偏左了 ┌─── 世界坐标 ───────────────────────┐ │ │ │ 0 100 200 │ │ │ ● │ │ │ 裁剪中心 │ │ │ └────────────────────────────────────┘ ┌─── 裁剪坐标 ───────────────────────┐ │ │ │ -0.5 0 0.5 │ │ │ ● │ │ │ 中心 │ │ │ └────────────────────────────────────┘ 世界坐标100 → 裁剪坐标0 世界坐标0 → 裁剪坐标-0.5 世界坐标200 → 裁剪坐标0.5 整个世界被左移了0.5个单位 这个左移量就是offset -0.5三、逐像素的完整计算过程3.1 设定场景场景一个聊天窗口 裁剪区域 center (50, -30) ← 中心偏右50偏下30 size (400, 300) ← 宽400高300 softness (20, 40) ← X方向softness20Y方向softness40 halfW 200 halfH 150 裁剪区域的四个边界世界坐标 左边界 50 - 200 -150 右边界 50 200 250 下边界 -30 - 150 -180 上边界 -30 150 120 ┌─── 世界坐标系 ──────────────────────────────┐ │ │ │ 120 │ │ ┌───────────┬───────────┐ │ │ │ │ │ │ │ -150 │ ●(50,-30) │ 250 │ │ │ 裁剪中心 │ │ │ │ │ │ │ │ └───────────┴───────────┘ │ │ -180 │ │ │ │ ●(0,0) Panel原点 │ │ │ └──────────────────────────────────────────────┘ Shader参数计算 offsetX -50 / 200 -0.25 offsetY -(-30) / 150 0.2 scaleX 1 / 200 0.005 scaleY 1 / 150 0.00667 _ClipRange0 (-0.25, 0.2, 0.005, 0.00667) softFactorX 1000 / 20 50 softFactorY 1000 / 40 25 _ClipArgs0 (50, 25, 0, 0)3.2 七个像素的命运现在让我们跟踪7个不同位置的像素 看看它们各自的命运 ┌─── 裁剪区域 ──────────────────────────────┐ │ │ │ A(-100, 80) B(200, 80) │ │ ★ ★ │ │ │ │ C(50, -30) │ │ ★ 中心 │ │ │ │ D(-150, -180) E(250, -30) │ │ ★ 左下角 ★ 右边界 │ └────────────────────────────────────────────┘ F(300, -30) ★ 裁剪区域外面 G(245, -30) ★ 接近右边界在过渡带内═══════════════════════════════════════════════════ 像素A世界坐标(-100, 80) —— 裁剪区域内部偏左上 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX -100 × 0.005 (-0.25) -0.5 (-0.25) -0.75 clipY 80 × 0.00667 0.2 0.5336 0.2 0.7336 clipPos (-0.75, 0.7336) 第二步判断位置 |clipX| 0.75 1 → X方向在内部 |clipY| 0.7336 1 → Y方向在内部 → 在裁剪区域内部 第三步计算距离边界的距离 distX 1.0 - |clipX| 1.0 - 0.75 0.25 distY 1.0 - |clipY| 1.0 - 0.7336 0.2664 distX 0.25 表示距离X边界还有25%的空间 distY 0.2664 表示距离Y边界还有26.64%的空间 第四步计算fade过渡透明度 fadeX clamp(0.25 × 50, 0, 1) clamp(12.5, 0, 1) 1.0 fadeY clamp(0.2664 × 25, 0, 1) clamp(6.66, 0, 1) 1.0 第五步最终alpha alpha fadeX × fadeY 1.0 × 1.0 1.0 结论完全可见 ✓ 像素A距离所有边界都很远 fade值远超1.0被clamp到1.0 完全不受裁剪影响 ★ A(-100, 80) clipPos (-0.75, 0.73) alpha 1.0 状态完全可见 █═══════════════════════════════════════════════════ 像素C世界坐标(50, -30) —— 裁剪区域正中心 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 50 × 0.005 (-0.25) 0.25 - 0.25 0 clipY -30 × 0.00667 0.2 -0.2001 0.2 -0.0001 ≈ 0 clipPos (0, 0) 第二步判断位置 |clipX| 0 1 → 正中心 |clipY| 0 1 → 正中心 → 在裁剪区域正中心 第三步距离边界 distX 1.0 - 0 1.0 → 距离边界最远 distY 1.0 - 0 1.0 → 距离边界最远 第四步fade fadeX clamp(1.0 × 50, 0, 1) 1.0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 1.0 结论完全可见 ✓ 裁剪中心的clipPos恰好是(0, 0) 这验证了我们的变换公式是正确的 ★ C(50, -30) clipPos (0, 0) alpha 1.0 状态完全可见 █中心点═══════════════════════════════════════════════════ 像素E世界坐标(250, -30) —— 恰好在右边界上 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 250 × 0.005 (-0.25) 1.25 - 0.25 1.0 clipY -30 × 0.00667 0.2 ≈ 0 clipPos (1.0, 0) 第二步判断位置 |clipX| 1.0 1 → 恰好在右边界上 |clipY| 0 1 → Y方向在内部 第三步距离边界 distX 1.0 - 1.0 0 → 距离X边界为零 distY 1.0 - 0 1.0 第四步fade fadeX clamp(0 × 50, 0, 1) clamp(0, 0, 1) 0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 0 × 1.0 0 结论完全不可见 ✓ 恰好在边界上的像素alpha0 这是NGUI的设计选择边界上的像素不显示 ★ E(250, -30) clipPos (1.0, 0) alpha 0.0 状态不可见 ░═══════════════════════════════════════════════════ 像素G世界坐标(245, -30) —— 接近右边界在过渡带内 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 245 × 0.005 (-0.25) 1.225 - 0.25 0.975 clipY -30 × 0.00667 0.2 ≈ 0 clipPos (0.975, 0) 第二步判断位置 |clipX| 0.975 1 → 在内部但非常接近右边界 |clipY| 0 1 → Y方向在内部 第三步距离边界 distX 1.0 - 0.975 0.025 distY 1.0 - 0 1.0 第四步fade fadeX clamp(0.025 × 50, 0, 1) clamp(1.25, 0, 1) 1.0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 1.0 × 1.0 1.0 等等距离边界只有5像素但alpha还是1.0 让我们算一下过渡带的实际范围 fade从1.0变到0.0的条件 dist × softFactor 从 1.0 到 0.0 dist 从 1/softFactor 到 0 X方向 dist阈值 1/50 0.02 对应像素距离 0.02 × halfW 0.02 × 200 4像素 像素G距离边界 250 - 245 5像素 4像素 所以还没进入过渡带alpha仍然是1.0 ★ G(245, -30) clipPos (0.975, 0) alpha 1.0 状态完全可见 █刚好在过渡带外═══════════════════════════════════════════════════ 像素G2世界坐标(248, -30) —— 在过渡带内部 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 248 × 0.005 (-0.25) 1.24 - 0.25 0.99 clipY ≈ 0 clipPos (0.99, 0) 第二步距离边界 distX 1.0 - 0.99 0.01 第三步fade fadeX clamp(0.01 × 50, 0, 1) clamp(0.5, 0, 1) 0.5 第四步alpha 0.5 × 1.0 0.5 结论半透明 距离边界2像素 过渡带宽度4像素 2/4 0.5 → 恰好半透明 ★ G2(248, -30) clipPos (0.99, 0) alpha 0.5 状态半透明 ▒═══════════════════════════════════════════════════ 像素F世界坐标(300, -30) —— 裁剪区域外部 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 300 × 0.005 (-0.25) 1.5 - 0.25 1.25 clipY ≈ 0 clipPos (1.25, 0) 第二步判断位置 |clipX| 1.25 1 → 在裁剪区域外部 第三步距离边界 distX 1.0 - 1.25 -0.25 → 负数已经超出边界 第四步fade fadeX clamp(-0.25 × 50, 0, 1) clamp(-12.5, 0, 1) 0 第五步alpha 0 × 1.0 0 结论完全不可见 ✓ 超出边界50像素 dist是负数fade被clamp到0 ★ F(300, -30) clipPos (1.25, 0) alpha 0.0 状态不可见 ░═══════════════════════════════════════════════════ 像素D世界坐标(-150, -180) —— 左下角 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX -150 × 0.005 (-0.25) -0.75 (-0.25) -1.0 clipY -180 × 0.00667 0.2 -1.20 0.2 -1.0 clipPos (-1.0, -1.0) 第二步判断位置 |clipX| 1.0 1 → 恰好在左边界上 |clipY| 1.0 1 → 恰好在下边界上 → 左下角的角点 第三步距离边界 distX 1.0 - 1.0 0 distY 1.0 - 1.0 0 第四步fade fadeX clamp(0 × 50, 0, 1) 0 fadeY clamp(0 × 25, 0, 1) 0 第五步alpha 0 × 0 0 结论完全不可见 ✓ 角点上两个方向的dist都是0 alpha 0 ★ D(-150, -180) clipPos (-1.0, -1.0) alpha 0.0 状态不可见 ░角点3.3 七个像素的命运总览把7个像素的结果画在一张图上 ┌─── 裁剪区域 ──────────────────────────────────────┐ │ │ │ A(-100,80) B(200,80) │ │ █ alpha1.0 █ alpha1.0 │ │ 完全可见 完全可见 │ │ │ │ C(50,-30) │ │ █ alpha1.0 │ │ 中心完全可见 │ │ │ │ G(245) G2(248) E(250)│ │ █ ▒ ░ │ │ 1.0 0.5 0.0 │ │ │ │ D(-150,-180) │ │ ░ alpha0.0 │ │ 角点不可见 │ └────────────────────────────────────────────────────┘ F(300) ░ alpha0.0 外部不可见 █ 完全可见 (alpha 1.0) ▒ 半透明 (alpha 0.5) ░ 不可见 (alpha 0.0) 从G到E的过渡带放大看 像素位置 243 244 245 246 247 248 249 250 251 252 距离边界 7 6 5 4 3 2 1 0 -1 -2 clipPos.x: .965 .970 .975 .980 .985 .990 .995 1.0 1.005 1.01 distX: .035 .030 .025 .020 .015 .010 .005 0 -.005 -.01 fadeX: 1.0 1.0 1.0 1.0 0.75 0.5 0.25 0 0 0 显示 █ █ █ █ ▓ ▒ ░ ░ ░ ░ 过渡带宽度 4像素从246到249 在这4个像素内alpha从1.0平滑过渡到0.0 246: alpha1.0 ████████████████████ 完全不透明 247: alpha0.75 ███████████████░░░░░ 75%不透明 248: alpha0.5 ██████████░░░░░░░░░░ 50%不透明 249: alpha0.25 █████░░░░░░░░░░░░░░░ 25%不透明 250: alpha0.0 ░░░░░░░░░░░░░░░░░░░░ 完全透明四、角落的特殊处理4.1 角落处两个方向同时渐变到目前为止我们的例子都是只有一个方向接近边界。 但角落处X和Y两个方向同时接近边界。 这时候 alpha fadeX × fadeY 的乘法效果就体现出来了。 ┌─── 裁剪区域右上角放大 ──────────────────┐ │ │ │ 位置(246, 116) │ │ 距右边界4px距上边界4px │ │ │ │ clipPos (0.98, 0.973) │ │ distX 0.02, distY 0.027 │ │ fadeX clamp(0.02×50, 0, 1) 1.0 │ │ fadeY clamp(0.027×25, 0, 1) 0.675 │ │ alpha 1.0 × 0.675 0.675 │ │ │ │ 位置(248, 118) │ │ 距右边界2px距上边界2px │ │ │ │ clipPos (0.99, 0.987) │ │ distX 0.01, distY 0.013 │ │ fadeX clamp(0.01×50, 0, 1) 0.5 │ │ fadeY clamp(0.013×25, 0, 1) 0.333 │ │ alpha 0.5 × 0.333 0.167 │ │ │ └──────────────────────────────────────────────┘ 角落处的alpha fadeX × fadeY 两个方向的fade相乘 结果比任何一个方向单独的fade都小 这意味着角落处的渐变更快、更透明 视觉上形成一个平滑的圆角过渡效果 角落处的alpha分布俯视图 接近上边界 ↓ 1.0 0.8 0.5 0.2 0.0 0.8 0.64 0.4 0.16 0.0 0.5 0.4 0.25 0.1 0.0 ← 接近右边界 0.2 0.16 0.1 0.04 0.0 0.0 0.0 0.0 0.0 0.0 注意对角线上的值 (1.0, 1.0) → 1.0×1.0 1.0 (0.8, 0.8) → 0.8×0.8 0.64 ← 比0.8小 (0.5, 0.5) → 0.5×0.5 0.25 ← 比0.5小很多 (0.2, 0.2) → 0.2×0.2 0.04 ← 几乎不可见 乘法让角落处的过渡更加圆润 不是直角的过渡而是弧形的过渡 如果用min(fadeX, fadeY)代替乘法 (0.5, 0.5) → min 0.5 角落处的过渡是直角的 如果用fadeX × fadeY (0.5, 0.5) → 0.25 角落处的过渡是弧形的 NGUI选择乘法视觉效果更自然角落过渡的视觉对比 乘法fadeX × fadeY ████████████████████████ ████████████████████▓▓░░ ████████████████████▓░░░ ████████████████████░░░░ ██████████████████▓░░░░░ ████████████████▓░░░░░░░ 角落是弧形的像被砂纸打磨过 过渡柔和自然 最小值 min(fadeX, fadeY) ████████████████████████ ████████████████████████ ████████████████████████ ████████████████████░░░░ ████████████████████░░░░ ████████████████████░░░░ 角落是直角的像被刀切过 过渡生硬 实际UI中弧形过渡看起来更舒服 这就是为什么NGUI用乘法而不是min五、完整的Shader代码解读/// 完整的NGUI SoftClip片元着色器带详细注释 // CPU传来的常量 uniform float4 _ClipRange0; // (offsetX, offsetY, scaleX, scaleY) uniform float4 _ClipArgs0; // (softFactorX, softFactorY, 0, 0) // 从顶点着色器传来的数据 struct v2f { float4 pos : SV_POSITION; // 屏幕位置光栅化用 float2 uv : TEXCOORD0; // 纹理坐标采样用 float4 color : COLOR; // 顶点颜色着色用 float2 worldPos : TEXCOORD1; // 世界/本地位置裁剪用 }; float4 frag(v2f i) : SV_Target { // ═══════════════════════════════════════════ // 第一幕采样纹理 // ═══════════════════════════════════════════ // // 从图集中取出这个像素的颜色 // 乘以顶点颜色用于tint着色 // float4 color tex2D(_MainTex, i.uv) * i.color; // 此时color是如果没有裁剪这个像素应该是什么颜色 // 接下来要决定这个像素该不该显示 // ═══════════════════════════════════════════ // 第二幕坐标变换世界坐标 → 裁剪坐标 // ═══════════════════════════════════════════ // // i.worldPos 这个像素在Panel本地坐标系中的位置 // 比如 (120, 100) // // _ClipRange0.zw (scaleX, scaleY) // 比如 (0.005, 0.00667) // // _ClipRange0.xy (offsetX, offsetY) // 比如 (-0.25, 0.2) // // 变换公式clipPos worldPos × scale offset // float2 clipPos i.worldPos * _ClipRange0.zw _ClipRange0.xy; // 现在clipPos是裁剪空间坐标 // 范围(-1, 1) 裁剪区域内部 // (-1, 1)之外 裁剪区域外部 // ═══════════════════════════════════════════ // 第三幕计算到边界的距离 // ═══════════════════════════════════════════ // // abs(clipPos) 像素到裁剪中心的归一化距离 // 0 在中心 // 1 在边界上 // 1 在外部 // // 1.0 - abs(clipPos) 像素到边界的归一化距离 // 1 在中心距离边界最远 // 0 在边界上 // 0 在外部已经超出边界 // float2 dist float2(1.0, 1.0) - abs(clipPos); // ═══════════════════════════════════════════ // 第四幕计算过渡透明度 // ═══════════════════════════════════════════ // // dist × softFactor // 把距离缩放到过渡带的范围 // softFactor越大过渡带越窄 // softFactor越小过渡带越宽 // // clamp(x, 0, 1) // x 1 → 1在过渡带内部完全可见 // 0 x 1 → x在过渡带中部分可见 // x 0 → 0在裁剪区域外完全不可见 // float2 fade clamp(dist * _ClipArgs0.xy, 0.0, 1.0); // ═══════════════════════════════════════════ // 第五幕应用裁剪 // ═══════════════════════════════════════════ // // fadeX × fadeY // 两个方向都可见 → alpha不变 // 任一方向不可见 → alpha 0 // 角落处 → alpha 两个方向的乘积弧形过渡 // color.a * fade.x * fade.y; // ═══════════════════════════════════════════ // 谢幕返回最终颜色 // ═══════════════════════════════════════════ // // 这个像素的颜色已经确定 // alpha1.0 → 完全显示 // alpha0.5 → 半透明在过渡带中 // alpha0.0 → 完全透明被裁剪掉了 // return color; }六、顶点着色器中的坐标传递片元着色器需要worldPos来计算裁剪。 但worldPos从哪里来 答案从顶点着色器传过来。 顶点着色器的工作 输入顶点的本地坐标 v.vertex 输出 1. 屏幕位置 o.pos用于光栅化决定像素在屏幕上的位置 2. 纹理坐标 o.uv用于采样纹理 3. 顶点颜色 o.color用于着色 4. 世界位置 o.worldPos用于裁剪计算← 关键v2f vert(appdata v) { v2f o; // 标准的顶点变换本地坐标 → 裁剪空间屏幕 o.pos UnityObjectToClipPos(v.vertex); // 纹理坐标直接传递 o.uv v.texcoord; // 顶点颜色直接传递 o.color v.color; // ═══════════════════════════════════════ // 关键传递世界位置给片元着色器 // ═══════════════════════════════════════ // // 方式A传递本地坐标如果_ClipRange0是本地空间的 o.worldPos v.vertex.xy; // 方式B传递世界坐标如果_ClipRange0是世界空间的 // o.worldPos mul(unity_ObjectToWorld, v.vertex).xy; // // NGUI通常使用方式A // 因为Panel的裁剪参数是在本地空间定义的 return o; }顶点到像素的插值过程 一个三角形有3个顶点每个顶点都有worldPos。 三角形内部的每个像素通过重心插值获得worldPos。 三角形的三个顶点 V0 worldPos(100, 200) V1 worldPos(300, 200) V2 worldPos(200, 50) V0(100,200) ────────── V1(300,200) \ / \ ★P / \ (180,150) / \ / \ / \ / \ / \ / \/ V2(200,50) 像素P在三角形内部 它的worldPos通过重心插值计算 假设P的重心坐标是 (u0.3, v0.4, w0.3) P.worldPos V0.worldPos × 0.3 V1.worldPos × 0.4 V2.worldPos × 0.3 P.worldPos.x 100×0.3 300×0.4 200×0.3 3012060 210 P.worldPos.y 200×0.3 200×0.4 50×0.3 608015 155 P.worldPos (210, 155) 注实际的重心坐标由GPU硬件自动计算 我们不需要手动做这个插值 GPU的光栅化器会自动完成 这个插值后的worldPos就是片元着色器中 i.worldPos的值 然后片元着色器用这个值来计算裁剪 clipPos (210, 155) × _ClipRange0.zw _ClipRange0.xy七、嵌套裁剪时的坐标变换当两个Panel嵌套时像素需要同时满足两层裁剪。 外层PanelclipRange_outer (0, 0, 800, 600) 内层PanelclipRange_inner (100, 50, 400, 300) Shader需要两组参数 _ClipRange0 外层的变换参数 _ClipRange1 内层的变换参数 片元着色器中 float4 frag(v2f i) : SV_Target { float4 color tex2D(_MainTex, i.uv) * i.color; // 第一层裁剪 float2 clipPos0 i.worldPos * _ClipRange0.zw _ClipRange0.xy; float2 dist0 1.0 - abs(clipPos0); float2 fade0 clamp(dist0 * _ClipArgs0.xy, 0, 1); // 第二层裁剪 float2 clipPos1 i.worldPos * _ClipRange1.zw _ClipRange1.xy; float2 dist1 1.0 - abs(clipPos1); float2 fade1 clamp(dist1 * _ClipArgs1.xy, 0, 1); // 两层裁剪的alpha相乘 color.a * fade0.x * fade0.y * fade1.x * fade1.y; return color; } 一个像素必须同时在两个裁剪区域内才能显示。 ┌─── 外层裁剪区域 ────────────────────────────┐ │ │ │ ┌─── 内层裁剪区域 ──────────┐ │ │ │ │ │ │ │ ★ 这个像素 │ │ │ │ 同时在两个区域内 │ │ │ │ alpha fade0 × fade1 │ │ │ │ │ │ │ └──────────────────────────┘ │ │ │ │ ★ 这个像素 │ │ 在外层内但在内层外 │ │ fade1 0 → alpha 0 │ │ │ └──────────────────────────────────────────────┘ ★ 这个像素 在两个区域都外面 fade0 0, fade1 0 → alpha 0八、性能视角这个坐标变换在GPU上有多快 让我们数一下指令 // 坐标变换2个MADMultiply-Add float2 clipPos worldPos * _ClipRange0.zw _ClipRange0.xy; // → 1条SIMD指令两个分量并行计算 // 取绝对值1个ABS // 减法1个SUB float2 dist 1.0 - abs(clipPos); // → 2条指令 // 乘法1个MUL // 钳制1个CLAMP或SAT float2 fade clamp(dist * _ClipArgs0.xy, 0, 1); // → 2条指令 // 乘法1个MUL color.a * fade.x * fade.y; // → 2条指令fade.x*fade.y, 然后*color.a 总计约7条GPU指令 现代GPU每秒可以执行数十亿条指令。 7条指令的开销几乎可以忽略不计。 对比一下 采样一次纹理 tex2D() ≈ 等效几十到几百条指令 因为要等待内存访问 所以裁剪计算的开销远小于纹理采样。 这就是为什么SoftClip比TextureMask更快 SoftClip7条数学指令 TextureMask7条数学指令 1次额外纹理采样 ┌──────────────────────────────────────────┐ │ │ │ 裁剪计算的性能特点 │ │ │ │ ✓ 纯数学运算不访问内存 │ │ ✓ 所有操作都是SIMD友好的 │ │ ✓ 没有分支if/elseGPU最喜欢 │ │ ✓ 常量参数每帧只传一次 │ │ ✓ 总共约7条指令微不足道 │ │ │ │ 这就是好的Shader设计 │ │ 把能在CPU做的提前做好变换参数 │ │ 留给GPU的只有最简单的乘加运算 │ │ │ └──────────────────────────────────────────┘九、总结像素世界坐标到裁剪坐标的变换本质上就是三件事 ┌──────────────────────────────────────────────────┐ │ │ │ 1. 平移减去中心偏移 │ │ │ │ 把裁剪区域的中心对齐到原点 │ │ │ │ 像素坐标(120) - 裁剪中心(50) 偏移(70) │ │ │ │ 就像你站在裁剪区域的中心看世界 │ │ 所有东西都相对于你重新定位 │ │ │ │ 2. 缩放除以半宽半高 │ │ │ │ 把裁剪区域的大小归一化到(-1, 1) │ │ │ │ 偏移(70) ÷ 半宽(200) 归一化(0.35) │ │ │ │ 就像把一把不同长度的尺子 │ │ 都缩放到同一个标准长度 │ │ 这样比较起来就方便了 │ │ │ │ 3. 合并成一条指令 │ │ │ │ clipPos pixelPos × scale offset │ │ │ │ 把平移和缩放合并成一次乘加运算 │ │ GPU一条指令就能完成 │ │ 每秒执行数十亿次毫不费力 │ │ │ └──────────────────────────────────────────────────┘回到开头的故事。裁缝说“这个点在布的右边120厘米上面100厘米。”翻译官算了算对缝纫机说“这个点在裁剪区域内X方向偏右60%Y方向偏上67%。两个方向都没超过边界。保留它。”缝纫机咔嚓一下这个像素活了下来。每一帧数百万个像素经历同样的审判。每一个像素都被问同一个问题你在边界内吗每一个像素都用同一个公式回答。这个公式只有一行clipPos worldPos × scale offset简单到可以写在一张便签纸上。强大到可以裁剪整个世界。
像素坐标到裁剪坐标的转换奥秘
一封信的翻译之旅引子两个人两种语言想象一个场景。一个中国裁缝和一个法国缝纫机。裁缝说中文缝纫机只懂法语。裁缝量好了布料上每个点的位置用中文记录下来“这个点在布的右边200厘米上面150厘米的地方。”缝纫机听不懂。它需要的是另一种描述“这个点在裁剪区域的右边界处距离中心线偏上75%的位置。”像素的世界坐标就是裁缝的中文。裁剪坐标就是缝纫机的法语。坐标变换就是翻译官。这篇文章要讲的就是这个翻译官的工作细节——每一个字怎么翻每一个数怎么算为什么要这样算。一、两个坐标系的世界观1.1 像素世界坐标——“我在哪里”像素的世界坐标或本地坐标描述的是 我这个像素在Panel的坐标系中处于什么位置。 它的特点 单位是像素或者说UI单位 原点是Panel的中心通常 X轴向右为正 Y轴向上为正 范围不固定取决于UI的大小 举例一个400×300的裁剪区域 Panel的坐标系 Y轴 ↑ │ 150 ┤ · · · · · · · · · · · · · · · · │ 100 ┤ ★ 这个像素 │ 位置 (120, 100) 50 ┤ │ 0 ┼────┬────┬────┬────┬────┬────→ X轴 │ -50 ┤ │ -100 ┤ │ -150 ┤ · · · · · · · · · · · · · · · · │ -200 -100 0 100 200 ★的世界坐标 (120, 100) 这个坐标告诉我们 这个像素在Panel中心偏右120像素偏上100像素 但这个信息对裁剪来说不够直接。 裁剪需要知道的是 这个像素在裁剪区域的什么位置 是在里面在边界上还是在外面1.2 裁剪坐标——“我该不该活”裁剪坐标描述的是 我这个像素相对于裁剪区域的边界处于什么位置。 它的特点 无单位归一化的比例值 原点是裁剪区域的中心 范围被归一化到 -1 到 1 -1 和 1 恰好是裁剪区域的边界 裁剪坐标系 clipY ↑ │ 1.0 ┤─────────────────────────┐ ← 上边界 │ │ 0.5 ┤ │ │ ★ │ │ clipPos(0.6, 0.667) 0.0 ┼─────────────●───────────┤ ← 中心 │ (0,0) │ -0.5 ┤ │ │ │ -1.0 ┤─────────────────────────┘ ← 下边界 │ └──┬────┬────┬────┬────┬──→ clipX -1.0 -0.5 0 0.5 1.0 ↑ ↑ 左边界 右边界 ★的裁剪坐标 (0.6, 0.667) 这个坐标告诉我们 X方向0.6在中心偏右60%的位置还没到边界(1.0) Y方向0.667在中心偏上66.7%的位置还没到边界(1.0) 两个分量的绝对值都小于1 → 这个像素在裁剪区域内部 → 应该被显示 裁剪坐标的判断规则 ┌──────────────────────────────────────────────┐ │ │ │ |clipPos.x| 1 且 |clipPos.y| 1 │ │ → 在裁剪区域内部 → 可见 │ │ │ │ |clipPos.x| 1 或 |clipPos.y| 1 │ │ → 恰好在边界上 → 边界处理 │ │ │ │ |clipPos.x| 1 或 |clipPos.y| 1 │ │ → 在裁剪区域外部 → 不可见 │ │ │ └──────────────────────────────────────────────┘ 多么简洁 不管裁剪区域有多大、在哪里 只要看裁剪坐标是否在(-1, 1)范围内 就知道该不该显示1.3 为什么要归一化到(-1, 1)为什么不直接用像素坐标来判断 直接判断的方式 if (pixelX leftBorder pixelX rightBorder pixelY bottomBorder pixelY topBorder) { // 可见 } 这需要4个边界值4次比较。 而且不同的裁剪区域有不同的边界值。 Shader需要存储4个参数。 归一化后的方式 clipPos transform(pixelPos) if (abs(clipPos.x) 1 abs(clipPos.y) 1) { // 可见 } 只需要和1比较。 不管裁剪区域多大、在哪里判断条件永远一样。 Shader的逻辑更简单、更统一。 更重要的是——softness的计算也变得统一 dist 1.0 - abs(clipPos) fade clamp(dist * softFactor, 0, 1) 这个公式对任何大小的裁剪区域都适用。 不需要根据裁剪区域的大小调整公式。 这就是归一化的威力 把千变万化的具体数值 统一到一个标准的范围内 让后续的计算变得通用、简洁、高效。 打个比方 考试成绩 数学考了85分满分100 英语考了127分满分150 谁考得好不好直接比较。 归一化后 数学85/100 0.85 英语127/150 0.847 现在可以直接比较了 裁剪坐标做的就是同样的事情 把不同大小的裁剪区域 统一到(-1, 1)的标准范围 让比较和计算变得简单。二、变换公式的推导2.1 从直觉开始问题 已知裁剪区域的中心(cx, cy)和半宽半高(halfW, halfH) 给定一个像素的世界坐标(px, py) 求它的裁剪坐标(clipX, clipY) 直觉思路 第一步算出像素相对于裁剪中心的偏移 deltaX px - cx deltaY py - cy 如果像素恰好在裁剪中心 → delta (0, 0) 如果像素在裁剪中心右边100像素 → deltaX 100 第二步把偏移归一化到(-1, 1)范围 clipX deltaX / halfW clipY deltaY / halfH 如果像素在右边界上 → deltaX halfW → clipX 1.0 如果像素在左边界上 → deltaX -halfW → clipX -1.0 如果像素在中心 → deltaX 0 → clipX 0 合起来 clipX (px - cx) / halfW clipY (py - cy) / halfH 就这么简单 用具体数字验证 裁剪区域center(0,0), size(400,300) halfW200, halfH150 像素(120, 100) clipX (120 - 0) / 200 0.6 clipY (100 - 0) / 150 0.667 |0.6| 1, |0.667| 1 → 在内部 ✓ 像素(200, 0) clipX (200 - 0) / 200 1.0 clipY (0 - 0) / 150 0 |1.0| 1 → 恰好在右边界上 ✓ 像素(250, 0) clipX (250 - 0) / 200 1.25 clipY (0 - 0) / 150 0 |1.25| 1 → 在外部 ✓ 像素(-200, -150) clipX (-200 - 0) / 200 -1.0 clipY (-150 - 0) / 150 -1.0 恰好在左下角 ✓2.2 展开成Shader友好的形式原始公式 clipX (px - cx) / halfW clipY (py - cy) / halfH 展开 clipX px / halfW - cx / halfW clipY py / halfH - cy / halfH 整理成 乘法 加法 的形式 clipX px × (1/halfW) (-cx/halfW) clipY py × (1/halfH) (-cy/halfH) 定义 scaleX 1 / halfW scaleY 1 / halfH offsetX -cx / halfW offsetY -cy / halfH 最终 clipX px × scaleX offsetX clipY py × scaleY offsetY 向量形式 clipPos pixelPos × scale offset 打包成一个Vector4传给Shader _ClipRange0 (offsetX, offsetY, scaleX, scaleY) Shader中一行代码搞定 float2 clipPos worldPos.xy * _ClipRange0.zw _ClipRange0.xy; 为什么要展开成这种形式 原始公式clipX (px - cx) / halfW 需要1次减法 1次除法 除法在GPU上很慢 展开后clipX px × scaleX offsetX 需要1次乘法 1次加法 乘法和加法在GPU上非常快 而且这正好是一个MAD指令Multiply-Add 大多数GPU可以在一个时钟周期内完成 scale和offset是常量每帧只算一次在CPU上算好 pixelPos是变量每个像素不同 常量 × 变量 常量 一条MAD指令 这就是为什么要把公式展开成这种形式 把CPU能做的计算提前做好 留给GPU的只有最简单的乘加运算2.3 为什么offset是负的offsetX -cx / halfW 为什么有个负号 让我们用一个具体例子来理解 裁剪区域中心在 cx 100 halfW 200 offsetX -100 / 200 -0.5 现在验证——像素在裁剪中心(100, ?) clipX 100 × (1/200) (-0.5) 0.5 (-0.5) 0 裁剪中心的clipX 0 ✓ 完美 如果offset是正的错误的 offsetX 100 / 200 0.5 clipX 100 × (1/200) 0.5 0.5 0.5 1.0 裁剪中心的clipX 1.0那就在边界上了 这显然不对。 负号的直觉理解 裁剪中心在右边(cx100) → 所有像素相对于裁剪中心都要往左移 → 减去中心的偏移 → offset是负的 就像你站在一个偏右的位置看世界 世界在你眼中整体偏左了 ┌─── 世界坐标 ───────────────────────┐ │ │ │ 0 100 200 │ │ │ ● │ │ │ 裁剪中心 │ │ │ └────────────────────────────────────┘ ┌─── 裁剪坐标 ───────────────────────┐ │ │ │ -0.5 0 0.5 │ │ │ ● │ │ │ 中心 │ │ │ └────────────────────────────────────┘ 世界坐标100 → 裁剪坐标0 世界坐标0 → 裁剪坐标-0.5 世界坐标200 → 裁剪坐标0.5 整个世界被左移了0.5个单位 这个左移量就是offset -0.5三、逐像素的完整计算过程3.1 设定场景场景一个聊天窗口 裁剪区域 center (50, -30) ← 中心偏右50偏下30 size (400, 300) ← 宽400高300 softness (20, 40) ← X方向softness20Y方向softness40 halfW 200 halfH 150 裁剪区域的四个边界世界坐标 左边界 50 - 200 -150 右边界 50 200 250 下边界 -30 - 150 -180 上边界 -30 150 120 ┌─── 世界坐标系 ──────────────────────────────┐ │ │ │ 120 │ │ ┌───────────┬───────────┐ │ │ │ │ │ │ │ -150 │ ●(50,-30) │ 250 │ │ │ 裁剪中心 │ │ │ │ │ │ │ │ └───────────┴───────────┘ │ │ -180 │ │ │ │ ●(0,0) Panel原点 │ │ │ └──────────────────────────────────────────────┘ Shader参数计算 offsetX -50 / 200 -0.25 offsetY -(-30) / 150 0.2 scaleX 1 / 200 0.005 scaleY 1 / 150 0.00667 _ClipRange0 (-0.25, 0.2, 0.005, 0.00667) softFactorX 1000 / 20 50 softFactorY 1000 / 40 25 _ClipArgs0 (50, 25, 0, 0)3.2 七个像素的命运现在让我们跟踪7个不同位置的像素 看看它们各自的命运 ┌─── 裁剪区域 ──────────────────────────────┐ │ │ │ A(-100, 80) B(200, 80) │ │ ★ ★ │ │ │ │ C(50, -30) │ │ ★ 中心 │ │ │ │ D(-150, -180) E(250, -30) │ │ ★ 左下角 ★ 右边界 │ └────────────────────────────────────────────┘ F(300, -30) ★ 裁剪区域外面 G(245, -30) ★ 接近右边界在过渡带内═══════════════════════════════════════════════════ 像素A世界坐标(-100, 80) —— 裁剪区域内部偏左上 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX -100 × 0.005 (-0.25) -0.5 (-0.25) -0.75 clipY 80 × 0.00667 0.2 0.5336 0.2 0.7336 clipPos (-0.75, 0.7336) 第二步判断位置 |clipX| 0.75 1 → X方向在内部 |clipY| 0.7336 1 → Y方向在内部 → 在裁剪区域内部 第三步计算距离边界的距离 distX 1.0 - |clipX| 1.0 - 0.75 0.25 distY 1.0 - |clipY| 1.0 - 0.7336 0.2664 distX 0.25 表示距离X边界还有25%的空间 distY 0.2664 表示距离Y边界还有26.64%的空间 第四步计算fade过渡透明度 fadeX clamp(0.25 × 50, 0, 1) clamp(12.5, 0, 1) 1.0 fadeY clamp(0.2664 × 25, 0, 1) clamp(6.66, 0, 1) 1.0 第五步最终alpha alpha fadeX × fadeY 1.0 × 1.0 1.0 结论完全可见 ✓ 像素A距离所有边界都很远 fade值远超1.0被clamp到1.0 完全不受裁剪影响 ★ A(-100, 80) clipPos (-0.75, 0.73) alpha 1.0 状态完全可见 █═══════════════════════════════════════════════════ 像素C世界坐标(50, -30) —— 裁剪区域正中心 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 50 × 0.005 (-0.25) 0.25 - 0.25 0 clipY -30 × 0.00667 0.2 -0.2001 0.2 -0.0001 ≈ 0 clipPos (0, 0) 第二步判断位置 |clipX| 0 1 → 正中心 |clipY| 0 1 → 正中心 → 在裁剪区域正中心 第三步距离边界 distX 1.0 - 0 1.0 → 距离边界最远 distY 1.0 - 0 1.0 → 距离边界最远 第四步fade fadeX clamp(1.0 × 50, 0, 1) 1.0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 1.0 结论完全可见 ✓ 裁剪中心的clipPos恰好是(0, 0) 这验证了我们的变换公式是正确的 ★ C(50, -30) clipPos (0, 0) alpha 1.0 状态完全可见 █中心点═══════════════════════════════════════════════════ 像素E世界坐标(250, -30) —— 恰好在右边界上 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 250 × 0.005 (-0.25) 1.25 - 0.25 1.0 clipY -30 × 0.00667 0.2 ≈ 0 clipPos (1.0, 0) 第二步判断位置 |clipX| 1.0 1 → 恰好在右边界上 |clipY| 0 1 → Y方向在内部 第三步距离边界 distX 1.0 - 1.0 0 → 距离X边界为零 distY 1.0 - 0 1.0 第四步fade fadeX clamp(0 × 50, 0, 1) clamp(0, 0, 1) 0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 0 × 1.0 0 结论完全不可见 ✓ 恰好在边界上的像素alpha0 这是NGUI的设计选择边界上的像素不显示 ★ E(250, -30) clipPos (1.0, 0) alpha 0.0 状态不可见 ░═══════════════════════════════════════════════════ 像素G世界坐标(245, -30) —— 接近右边界在过渡带内 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 245 × 0.005 (-0.25) 1.225 - 0.25 0.975 clipY -30 × 0.00667 0.2 ≈ 0 clipPos (0.975, 0) 第二步判断位置 |clipX| 0.975 1 → 在内部但非常接近右边界 |clipY| 0 1 → Y方向在内部 第三步距离边界 distX 1.0 - 0.975 0.025 distY 1.0 - 0 1.0 第四步fade fadeX clamp(0.025 × 50, 0, 1) clamp(1.25, 0, 1) 1.0 fadeY clamp(1.0 × 25, 0, 1) 1.0 第五步alpha 1.0 × 1.0 1.0 等等距离边界只有5像素但alpha还是1.0 让我们算一下过渡带的实际范围 fade从1.0变到0.0的条件 dist × softFactor 从 1.0 到 0.0 dist 从 1/softFactor 到 0 X方向 dist阈值 1/50 0.02 对应像素距离 0.02 × halfW 0.02 × 200 4像素 像素G距离边界 250 - 245 5像素 4像素 所以还没进入过渡带alpha仍然是1.0 ★ G(245, -30) clipPos (0.975, 0) alpha 1.0 状态完全可见 █刚好在过渡带外═══════════════════════════════════════════════════ 像素G2世界坐标(248, -30) —— 在过渡带内部 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 248 × 0.005 (-0.25) 1.24 - 0.25 0.99 clipY ≈ 0 clipPos (0.99, 0) 第二步距离边界 distX 1.0 - 0.99 0.01 第三步fade fadeX clamp(0.01 × 50, 0, 1) clamp(0.5, 0, 1) 0.5 第四步alpha 0.5 × 1.0 0.5 结论半透明 距离边界2像素 过渡带宽度4像素 2/4 0.5 → 恰好半透明 ★ G2(248, -30) clipPos (0.99, 0) alpha 0.5 状态半透明 ▒═══════════════════════════════════════════════════ 像素F世界坐标(300, -30) —— 裁剪区域外部 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX 300 × 0.005 (-0.25) 1.5 - 0.25 1.25 clipY ≈ 0 clipPos (1.25, 0) 第二步判断位置 |clipX| 1.25 1 → 在裁剪区域外部 第三步距离边界 distX 1.0 - 1.25 -0.25 → 负数已经超出边界 第四步fade fadeX clamp(-0.25 × 50, 0, 1) clamp(-12.5, 0, 1) 0 第五步alpha 0 × 1.0 0 结论完全不可见 ✓ 超出边界50像素 dist是负数fade被clamp到0 ★ F(300, -30) clipPos (1.25, 0) alpha 0.0 状态不可见 ░═══════════════════════════════════════════════════ 像素D世界坐标(-150, -180) —— 左下角 ═══════════════════════════════════════════════════ 第一步坐标变换 clipX -150 × 0.005 (-0.25) -0.75 (-0.25) -1.0 clipY -180 × 0.00667 0.2 -1.20 0.2 -1.0 clipPos (-1.0, -1.0) 第二步判断位置 |clipX| 1.0 1 → 恰好在左边界上 |clipY| 1.0 1 → 恰好在下边界上 → 左下角的角点 第三步距离边界 distX 1.0 - 1.0 0 distY 1.0 - 1.0 0 第四步fade fadeX clamp(0 × 50, 0, 1) 0 fadeY clamp(0 × 25, 0, 1) 0 第五步alpha 0 × 0 0 结论完全不可见 ✓ 角点上两个方向的dist都是0 alpha 0 ★ D(-150, -180) clipPos (-1.0, -1.0) alpha 0.0 状态不可见 ░角点3.3 七个像素的命运总览把7个像素的结果画在一张图上 ┌─── 裁剪区域 ──────────────────────────────────────┐ │ │ │ A(-100,80) B(200,80) │ │ █ alpha1.0 █ alpha1.0 │ │ 完全可见 完全可见 │ │ │ │ C(50,-30) │ │ █ alpha1.0 │ │ 中心完全可见 │ │ │ │ G(245) G2(248) E(250)│ │ █ ▒ ░ │ │ 1.0 0.5 0.0 │ │ │ │ D(-150,-180) │ │ ░ alpha0.0 │ │ 角点不可见 │ └────────────────────────────────────────────────────┘ F(300) ░ alpha0.0 外部不可见 █ 完全可见 (alpha 1.0) ▒ 半透明 (alpha 0.5) ░ 不可见 (alpha 0.0) 从G到E的过渡带放大看 像素位置 243 244 245 246 247 248 249 250 251 252 距离边界 7 6 5 4 3 2 1 0 -1 -2 clipPos.x: .965 .970 .975 .980 .985 .990 .995 1.0 1.005 1.01 distX: .035 .030 .025 .020 .015 .010 .005 0 -.005 -.01 fadeX: 1.0 1.0 1.0 1.0 0.75 0.5 0.25 0 0 0 显示 █ █ █ █ ▓ ▒ ░ ░ ░ ░ 过渡带宽度 4像素从246到249 在这4个像素内alpha从1.0平滑过渡到0.0 246: alpha1.0 ████████████████████ 完全不透明 247: alpha0.75 ███████████████░░░░░ 75%不透明 248: alpha0.5 ██████████░░░░░░░░░░ 50%不透明 249: alpha0.25 █████░░░░░░░░░░░░░░░ 25%不透明 250: alpha0.0 ░░░░░░░░░░░░░░░░░░░░ 完全透明四、角落的特殊处理4.1 角落处两个方向同时渐变到目前为止我们的例子都是只有一个方向接近边界。 但角落处X和Y两个方向同时接近边界。 这时候 alpha fadeX × fadeY 的乘法效果就体现出来了。 ┌─── 裁剪区域右上角放大 ──────────────────┐ │ │ │ 位置(246, 116) │ │ 距右边界4px距上边界4px │ │ │ │ clipPos (0.98, 0.973) │ │ distX 0.02, distY 0.027 │ │ fadeX clamp(0.02×50, 0, 1) 1.0 │ │ fadeY clamp(0.027×25, 0, 1) 0.675 │ │ alpha 1.0 × 0.675 0.675 │ │ │ │ 位置(248, 118) │ │ 距右边界2px距上边界2px │ │ │ │ clipPos (0.99, 0.987) │ │ distX 0.01, distY 0.013 │ │ fadeX clamp(0.01×50, 0, 1) 0.5 │ │ fadeY clamp(0.013×25, 0, 1) 0.333 │ │ alpha 0.5 × 0.333 0.167 │ │ │ └──────────────────────────────────────────────┘ 角落处的alpha fadeX × fadeY 两个方向的fade相乘 结果比任何一个方向单独的fade都小 这意味着角落处的渐变更快、更透明 视觉上形成一个平滑的圆角过渡效果 角落处的alpha分布俯视图 接近上边界 ↓ 1.0 0.8 0.5 0.2 0.0 0.8 0.64 0.4 0.16 0.0 0.5 0.4 0.25 0.1 0.0 ← 接近右边界 0.2 0.16 0.1 0.04 0.0 0.0 0.0 0.0 0.0 0.0 注意对角线上的值 (1.0, 1.0) → 1.0×1.0 1.0 (0.8, 0.8) → 0.8×0.8 0.64 ← 比0.8小 (0.5, 0.5) → 0.5×0.5 0.25 ← 比0.5小很多 (0.2, 0.2) → 0.2×0.2 0.04 ← 几乎不可见 乘法让角落处的过渡更加圆润 不是直角的过渡而是弧形的过渡 如果用min(fadeX, fadeY)代替乘法 (0.5, 0.5) → min 0.5 角落处的过渡是直角的 如果用fadeX × fadeY (0.5, 0.5) → 0.25 角落处的过渡是弧形的 NGUI选择乘法视觉效果更自然角落过渡的视觉对比 乘法fadeX × fadeY ████████████████████████ ████████████████████▓▓░░ ████████████████████▓░░░ ████████████████████░░░░ ██████████████████▓░░░░░ ████████████████▓░░░░░░░ 角落是弧形的像被砂纸打磨过 过渡柔和自然 最小值 min(fadeX, fadeY) ████████████████████████ ████████████████████████ ████████████████████████ ████████████████████░░░░ ████████████████████░░░░ ████████████████████░░░░ 角落是直角的像被刀切过 过渡生硬 实际UI中弧形过渡看起来更舒服 这就是为什么NGUI用乘法而不是min五、完整的Shader代码解读/// 完整的NGUI SoftClip片元着色器带详细注释 // CPU传来的常量 uniform float4 _ClipRange0; // (offsetX, offsetY, scaleX, scaleY) uniform float4 _ClipArgs0; // (softFactorX, softFactorY, 0, 0) // 从顶点着色器传来的数据 struct v2f { float4 pos : SV_POSITION; // 屏幕位置光栅化用 float2 uv : TEXCOORD0; // 纹理坐标采样用 float4 color : COLOR; // 顶点颜色着色用 float2 worldPos : TEXCOORD1; // 世界/本地位置裁剪用 }; float4 frag(v2f i) : SV_Target { // ═══════════════════════════════════════════ // 第一幕采样纹理 // ═══════════════════════════════════════════ // // 从图集中取出这个像素的颜色 // 乘以顶点颜色用于tint着色 // float4 color tex2D(_MainTex, i.uv) * i.color; // 此时color是如果没有裁剪这个像素应该是什么颜色 // 接下来要决定这个像素该不该显示 // ═══════════════════════════════════════════ // 第二幕坐标变换世界坐标 → 裁剪坐标 // ═══════════════════════════════════════════ // // i.worldPos 这个像素在Panel本地坐标系中的位置 // 比如 (120, 100) // // _ClipRange0.zw (scaleX, scaleY) // 比如 (0.005, 0.00667) // // _ClipRange0.xy (offsetX, offsetY) // 比如 (-0.25, 0.2) // // 变换公式clipPos worldPos × scale offset // float2 clipPos i.worldPos * _ClipRange0.zw _ClipRange0.xy; // 现在clipPos是裁剪空间坐标 // 范围(-1, 1) 裁剪区域内部 // (-1, 1)之外 裁剪区域外部 // ═══════════════════════════════════════════ // 第三幕计算到边界的距离 // ═══════════════════════════════════════════ // // abs(clipPos) 像素到裁剪中心的归一化距离 // 0 在中心 // 1 在边界上 // 1 在外部 // // 1.0 - abs(clipPos) 像素到边界的归一化距离 // 1 在中心距离边界最远 // 0 在边界上 // 0 在外部已经超出边界 // float2 dist float2(1.0, 1.0) - abs(clipPos); // ═══════════════════════════════════════════ // 第四幕计算过渡透明度 // ═══════════════════════════════════════════ // // dist × softFactor // 把距离缩放到过渡带的范围 // softFactor越大过渡带越窄 // softFactor越小过渡带越宽 // // clamp(x, 0, 1) // x 1 → 1在过渡带内部完全可见 // 0 x 1 → x在过渡带中部分可见 // x 0 → 0在裁剪区域外完全不可见 // float2 fade clamp(dist * _ClipArgs0.xy, 0.0, 1.0); // ═══════════════════════════════════════════ // 第五幕应用裁剪 // ═══════════════════════════════════════════ // // fadeX × fadeY // 两个方向都可见 → alpha不变 // 任一方向不可见 → alpha 0 // 角落处 → alpha 两个方向的乘积弧形过渡 // color.a * fade.x * fade.y; // ═══════════════════════════════════════════ // 谢幕返回最终颜色 // ═══════════════════════════════════════════ // // 这个像素的颜色已经确定 // alpha1.0 → 完全显示 // alpha0.5 → 半透明在过渡带中 // alpha0.0 → 完全透明被裁剪掉了 // return color; }六、顶点着色器中的坐标传递片元着色器需要worldPos来计算裁剪。 但worldPos从哪里来 答案从顶点着色器传过来。 顶点着色器的工作 输入顶点的本地坐标 v.vertex 输出 1. 屏幕位置 o.pos用于光栅化决定像素在屏幕上的位置 2. 纹理坐标 o.uv用于采样纹理 3. 顶点颜色 o.color用于着色 4. 世界位置 o.worldPos用于裁剪计算← 关键v2f vert(appdata v) { v2f o; // 标准的顶点变换本地坐标 → 裁剪空间屏幕 o.pos UnityObjectToClipPos(v.vertex); // 纹理坐标直接传递 o.uv v.texcoord; // 顶点颜色直接传递 o.color v.color; // ═══════════════════════════════════════ // 关键传递世界位置给片元着色器 // ═══════════════════════════════════════ // // 方式A传递本地坐标如果_ClipRange0是本地空间的 o.worldPos v.vertex.xy; // 方式B传递世界坐标如果_ClipRange0是世界空间的 // o.worldPos mul(unity_ObjectToWorld, v.vertex).xy; // // NGUI通常使用方式A // 因为Panel的裁剪参数是在本地空间定义的 return o; }顶点到像素的插值过程 一个三角形有3个顶点每个顶点都有worldPos。 三角形内部的每个像素通过重心插值获得worldPos。 三角形的三个顶点 V0 worldPos(100, 200) V1 worldPos(300, 200) V2 worldPos(200, 50) V0(100,200) ────────── V1(300,200) \ / \ ★P / \ (180,150) / \ / \ / \ / \ / \ / \/ V2(200,50) 像素P在三角形内部 它的worldPos通过重心插值计算 假设P的重心坐标是 (u0.3, v0.4, w0.3) P.worldPos V0.worldPos × 0.3 V1.worldPos × 0.4 V2.worldPos × 0.3 P.worldPos.x 100×0.3 300×0.4 200×0.3 3012060 210 P.worldPos.y 200×0.3 200×0.4 50×0.3 608015 155 P.worldPos (210, 155) 注实际的重心坐标由GPU硬件自动计算 我们不需要手动做这个插值 GPU的光栅化器会自动完成 这个插值后的worldPos就是片元着色器中 i.worldPos的值 然后片元着色器用这个值来计算裁剪 clipPos (210, 155) × _ClipRange0.zw _ClipRange0.xy七、嵌套裁剪时的坐标变换当两个Panel嵌套时像素需要同时满足两层裁剪。 外层PanelclipRange_outer (0, 0, 800, 600) 内层PanelclipRange_inner (100, 50, 400, 300) Shader需要两组参数 _ClipRange0 外层的变换参数 _ClipRange1 内层的变换参数 片元着色器中 float4 frag(v2f i) : SV_Target { float4 color tex2D(_MainTex, i.uv) * i.color; // 第一层裁剪 float2 clipPos0 i.worldPos * _ClipRange0.zw _ClipRange0.xy; float2 dist0 1.0 - abs(clipPos0); float2 fade0 clamp(dist0 * _ClipArgs0.xy, 0, 1); // 第二层裁剪 float2 clipPos1 i.worldPos * _ClipRange1.zw _ClipRange1.xy; float2 dist1 1.0 - abs(clipPos1); float2 fade1 clamp(dist1 * _ClipArgs1.xy, 0, 1); // 两层裁剪的alpha相乘 color.a * fade0.x * fade0.y * fade1.x * fade1.y; return color; } 一个像素必须同时在两个裁剪区域内才能显示。 ┌─── 外层裁剪区域 ────────────────────────────┐ │ │ │ ┌─── 内层裁剪区域 ──────────┐ │ │ │ │ │ │ │ ★ 这个像素 │ │ │ │ 同时在两个区域内 │ │ │ │ alpha fade0 × fade1 │ │ │ │ │ │ │ └──────────────────────────┘ │ │ │ │ ★ 这个像素 │ │ 在外层内但在内层外 │ │ fade1 0 → alpha 0 │ │ │ └──────────────────────────────────────────────┘ ★ 这个像素 在两个区域都外面 fade0 0, fade1 0 → alpha 0八、性能视角这个坐标变换在GPU上有多快 让我们数一下指令 // 坐标变换2个MADMultiply-Add float2 clipPos worldPos * _ClipRange0.zw _ClipRange0.xy; // → 1条SIMD指令两个分量并行计算 // 取绝对值1个ABS // 减法1个SUB float2 dist 1.0 - abs(clipPos); // → 2条指令 // 乘法1个MUL // 钳制1个CLAMP或SAT float2 fade clamp(dist * _ClipArgs0.xy, 0, 1); // → 2条指令 // 乘法1个MUL color.a * fade.x * fade.y; // → 2条指令fade.x*fade.y, 然后*color.a 总计约7条GPU指令 现代GPU每秒可以执行数十亿条指令。 7条指令的开销几乎可以忽略不计。 对比一下 采样一次纹理 tex2D() ≈ 等效几十到几百条指令 因为要等待内存访问 所以裁剪计算的开销远小于纹理采样。 这就是为什么SoftClip比TextureMask更快 SoftClip7条数学指令 TextureMask7条数学指令 1次额外纹理采样 ┌──────────────────────────────────────────┐ │ │ │ 裁剪计算的性能特点 │ │ │ │ ✓ 纯数学运算不访问内存 │ │ ✓ 所有操作都是SIMD友好的 │ │ ✓ 没有分支if/elseGPU最喜欢 │ │ ✓ 常量参数每帧只传一次 │ │ ✓ 总共约7条指令微不足道 │ │ │ │ 这就是好的Shader设计 │ │ 把能在CPU做的提前做好变换参数 │ │ 留给GPU的只有最简单的乘加运算 │ │ │ └──────────────────────────────────────────┘九、总结像素世界坐标到裁剪坐标的变换本质上就是三件事 ┌──────────────────────────────────────────────────┐ │ │ │ 1. 平移减去中心偏移 │ │ │ │ 把裁剪区域的中心对齐到原点 │ │ │ │ 像素坐标(120) - 裁剪中心(50) 偏移(70) │ │ │ │ 就像你站在裁剪区域的中心看世界 │ │ 所有东西都相对于你重新定位 │ │ │ │ 2. 缩放除以半宽半高 │ │ │ │ 把裁剪区域的大小归一化到(-1, 1) │ │ │ │ 偏移(70) ÷ 半宽(200) 归一化(0.35) │ │ │ │ 就像把一把不同长度的尺子 │ │ 都缩放到同一个标准长度 │ │ 这样比较起来就方便了 │ │ │ │ 3. 合并成一条指令 │ │ │ │ clipPos pixelPos × scale offset │ │ │ │ 把平移和缩放合并成一次乘加运算 │ │ GPU一条指令就能完成 │ │ 每秒执行数十亿次毫不费力 │ │ │ └──────────────────────────────────────────────────┘回到开头的故事。裁缝说“这个点在布的右边120厘米上面100厘米。”翻译官算了算对缝纫机说“这个点在裁剪区域内X方向偏右60%Y方向偏上67%。两个方向都没超过边界。保留它。”缝纫机咔嚓一下这个像素活了下来。每一帧数百万个像素经历同样的审判。每一个像素都被问同一个问题你在边界内吗每一个像素都用同一个公式回答。这个公式只有一行clipPos worldPos × scale offset简单到可以写在一张便签纸上。强大到可以裁剪整个世界。