ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

逐行拆解 NGUI 的裁剪 Shader

逐行拆解 NGUI 的裁剪 Shader 零、先给一句话这两段代码干的事 把每个像素换算成它距离裁剪框边界还有多远 再把这个距离转成一个 0~1 的系数去乘 alpha。 框内深处 → 系数 1 → alpha 不变 → 正常显示 框外 → 系数 0 → alpha 归零 → 完全消失 边界附近 → 系数 0~1 → 渐变 → 这就是 SoftClip 的软本质上它是一个矩形的有符号距离场SDF。下面从 frag 开始讲——因为frag 定义了契约vert 只是为了满足这个契约。倒着看更容易懂。一、先读 frag它假定了什么half4 frag(v2f i) : COLOR { half4 col tex2D(_MainTex, i.texcoord) * i.color; float2 f (float2(1.0, 1.0) - abs(i.worldPos)) * _ClipArgs0; col.a * clamp(min(f.x, f.y), 0.0, 1.0); return col; }1.1 关键前提i.worldPos是归一化裁剪坐标这个变量名起得很糟后面专门讲它不是世界坐标。它的约定是i.worldPos 的含义 y 1 ┌───────────────────┐ │ │ │ │ x-1 │ (0, 0) │ x 1 ← 裁剪框的四条边 │ 中心 │ │ │ └───────────────────┘ y -1顶点位置i.worldPos裁剪框正中心(0, 0)裁剪框右边界x 1裁剪框左边界x -1裁剪框上边界y 1裁剪框下边界y -1框外右边远处x 1.5、2.0…记住裁剪框被规范化成了一个边长为 2、中心在原点的正方形。不管实际裁剪区是 640×800 还是 100×2000在这个空间里都是[-1,1] × [-1,1]。1.2 第一步abs(i.worldPos)abs(i.worldPos)取绝对值把左右两边和上下两边折叠成同一件事 ↓ 现在只需要关心离中心多远不用管在哪一侧位置worldPos.xabs中心0.00.0右边界1.01.0左边界−1.01.0右侧框外1.31.3左侧框外−1.31.3结论abs 后 1 就是边界 1 是内部 1 是外部1.3 第二步1.0 - abs(...)float2(1.0, 1.0) - abs(i.worldPos)这一步把离中心的距离翻转成离边界的距离位置abs1 - abs含义中心0.01.0离边界最远整个半宽半路0.50.5离边界还有半个半宽边界上1.00.0正好在边界框外一点1.1−0.1已经出去了框外很远2.0−1.0出去很远┌────────────────────────────────────────────────┐ │ 这就是【有符号距离场】 │ │ 正数 在框内数值 离边界有多远归一化 │ │ 零 正好在边界上 │ │ 负数 在框外 │ └────────────────────────────────────────────────┘画出来1 - abs(x) ↑ 1.0 ┤ ╱╲ │ ╱ ╲ 0.5 ┤ ╱ ╲ │ ╱ ╲ 0.0 ┼──╱────────────────╲──→ x │ -1 0 1 -0.5 ┤╱ ╲ │ └── 框外 框外 ──┘1.4 第三步* _ClipArgs0羽化锐度float2 f (1.0 - abs(i.worldPos)) * _ClipArgs0;_ClipArgs0 是一个放大系数。 它决定离边界多远才算完全不透明。先不管它具体数值看效果_ClipArgs0效果很大如 1000只要往内一点点f 就 ≫ 1 →硬切边中等如 80边界附近有一条渐变带 →软边很小如 2渐变带非常宽 →整个画面都在渐变后面第五节会精确推导它的值。1.5 第四步min(f.x, f.y)这是很多人卡住的地方。为什么是 min不是别的因为矩形内部的判定是【与】关系 x 方向要在内 AND y 方向要在内 ↓ 取两者中更危险的那个离边界更近的 值更小的 ↓ min举例一个靠近右边界、但上下居中的点┌───────────────────┐ │ │ │ │ │ ● │ ← 这个点 │ │ └───────────────────┘ f.x 0.05 ← 离左右边界很近危险 f.y 0.90 ← 离上下边界很远安全 min 0.05 → 取最危险的那个 → 它应该接近透明 ✓如果用 maxmax 0.90 → 完全不透明 ✗ → 一个已经快出右边界的点被当成完全安全的裁剪失效如果用相乘0.05 × 0.90 0.045 看起来也差不多但在四个角落会出问题 角落处 f.x 0.9, f.y 0.9 → 0.81 → 变半透明 而实际上角落深处应该是完全不透明的 ↓ 会看到四个角发暗┌───────────────────────────────────────────────┐ │ min 是矩形 SDF 的标准做法 │ │ 它给出的是到最近那条边的距离 │ └───────────────────────────────────────────────┘可视化 min(f.x, f.y) 的结果灰度表示值大小┌───────────────────┐ │░░░░░░░░░░░░░░░░░░░│ ← 边缘值接近 0 │░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░│ │░▒██████████████▒░│ │░▒██████████████▒░│ ← 中心值大 │░▒██████████████▒░│ │░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░│ │░░░░░░░░░░░░░░░░░░░│ └───────────────────┘1.6 第五步clamp(..., 0.0, 1.0)col.a * clamp(min(f.x, f.y), 0.0, 1.0);min 的值clamp 后结果负数框外0alpha × 0 完全透明0 ~ 1羽化带原值alpha 被按比例削弱 →渐变 1框内深处1alpha × 1 原样不变clamp 的上界 1.0 非常重要 没有它框内深处的 f 可能是 50、80 ↓ col.a * 80 → alpha 溢出到远大于 1 ↓ 半透明的东西变成完全不透明颜色混合全错二、再读 vert怎么把坐标变成[-1,1]o.worldPos v.vertex.xy * _ClipRange0.zw _ClipRange0.xy;这是一个标准的仿射变换缩放 平移输出 输入 × 缩放 平移2.1 反推两个参数该是多少已知条件frag 的契约裁剪框中心 (cx, cy) → 映射到 (0, 0) 裁剪框右边界 cx halfW → 映射到 1 裁剪框上边界 cy halfH → 映射到 1解方程只看 x 方向条件 ①中心映射到 0 cx × scale offset 0 → offset −cx × scale 条件 ②右边界映射到 1 (cx halfW) × scale offset 1 把 ① 代入 ② (cx halfW) × scale − cx × scale 1 halfW × scale 1 → scale 1 / halfW 再代回 ① → offset −cx / halfW2.2 所以两个 uniform 的真实含义_ClipRange0.zw ( 1 / halfWidth , 1 / halfHeight ) ← 缩放 _ClipRange0.xy ( -cx / halfWidth , -cy / halfHeight ) ← 平移分量含义典型值框 640×800中心在原点.z1 / 半宽1/320 0.003125.w1 / 半高1/400 0.0025.x−中心x / 半宽0 / 320 0.y−中心y / 半高0 / 400 0注意.xy和.zw的顺序平移在前、缩放在后但公式里是xy * zw xy——先用zw缩放再加xy平移。这个打包顺序纯粹是为了省一个float4没有别的含义。2.3 C# 侧对应的代码// UIDrawCall.SetClipping —— 语义等价写法voidSetClipping(intindex,Vector4cr,Vector2soft){// cr (centerX, centerY, halfWidth, halfHeight)floatinvHalfW(cr.z!0f)?1f/cr.z:1f;floatinvHalfH(cr.w!0f)?1f/cr.w:1f;mDynamicMat.SetVector(_ClipRangeindex,newVector4(-cr.x*invHalfW,// .x 平移-cr.y*invHalfH,// .y 平移invHalfW,// .z 缩放invHalfH// .w 缩放));// 羽化锐度下一节推导Vector2sharpnewVector2(1000f,1000f);if(soft.x0f)sharp.xcr.z/soft.x;if(soft.y0f)sharp.ycr.w/soft.y;mDynamicMat.SetVector(_ClipArgsindex,sharp);}不同 NGUI 版本在传进来的cr.zw是全宽还是半宽上有细微差别但最终语义是固定的——你可以从 frag 的1 - abs(pos)反推出来只有当边界处|pos| 1时这个公式才成立。三、完整数值演算场景设定裁剪框中心 (0, 0)宽 640高 800 → halfW 320, halfH 400 clipSoftness (4, 4) ← NGUI 默认值 推出 _ClipRange0 (0, 0, 1/320, 1/400) (0, 0, 0.003125, 0.0025) _ClipArgs0 (320/4, 400/4) (80, 100)3.1 沿 x 轴取几个点逐步算Panel 空间 x①× 0.003125②abs③1 − abs④× 80⑤clamp结果0中心0.00000.00001.000080.01.0完全不透明2000.62500.62500.375030.01.0完全不透明3000.93750.93750.06255.01.0完全不透明316离边 4px0.98750.98750.01251.01.0羽化带起点318离边 2px0.99380.99380.00630.50.5半透明319离边 1px0.99690.99690.00310.250.25更淡320边界上1.00001.00000.00000.00.0完全透明325框外 5px1.01561.0156−0.0156−1.250.0完全透明500框外很远1.56251.5625−0.5625−45.00.0完全透明3.2 画成曲线alpha 系数 ↑ 1.0 ┼──────────────────────┐ │ │╲ │ │ ╲ ← 4 像素宽的羽化带 0.5 ┤ │ ╲ │ │ ╲ 0.0 ┼──────────────────────┼────╲──────→ x 0 200 300 316 320 500 ↑ ↑ 羽化起点 边界注意到一件事clipSoftness 4羽化带正好就是 4 个像素宽。这不是巧合——下一节讲为什么。四、_ClipArgs0的推导4.1 我们想要什么设计目标 离边界 ≥ softness 像素 → 完全不透明 离边界 softness/2 像素 → 半透明 离边界 0 像素 → 完全透明4.2 推导设 d_px 到边界的【像素】距离 d_norm 到边界的【归一化】距离 frag 里的 (1 - abs(pos)) 两者关系 d_norm d_px / halfW ← 因为整个半宽被归一化成了 1 frag 里的计算 f d_norm × args (d_px / halfW) × args 代入设计目标d_px softness 时 f 1 (softness / halfW) × args 1 ↓ args halfW / softness4.3 验证halfW 320, softness 4 args 320 / 4 80到边界像素距离d_normf d_norm × 80alpha 系数8 px8/320 0.0252.01.0clamp ✓4 px4/320 0.01251.01.0✓ 羽化起点3 px0.0093750.750.75 ✓2 px0.006250.500.5✓ 正好半透明1 px0.0031250.250.25 ✓0 px000✓ 边界归零完全对上设计目标。4.4 x 和 y 的 args 为什么不同_ClipArgs0 (320/4, 400/4) (80, 100) ↑x方向 ↑y方向因为 halfW ≠ halfH ↓ 同样是 4 像素的羽化宽度 归一化后 x 方向占 4/320y 方向占 4/400 ↓ 所以需要不同的放大系数才能让两个方向的 【像素羽化宽度】一致这就是为什么_ClipArgs0是float2而不是一个标量。4.5 SoftClip 与 AlphaClip 的唯一区别Vector2sharpnewVector2(1000f,1000f);// ← 默认很大if(soft.x0f)sharp.xcr.z/soft.x;if(soft.y0f)sharp.ycr.w/soft.y;模式softnessargs羽化带宽度SoftClip4804 像素AlphaClip01000halfW/1000 0.32 像素0.32 像素的羽化带 → 小于一个像素 → 肉眼看就是硬切 ↓ 所谓 AlphaClip就是羽化宽度小到看不见的 SoftClip ↓ 两者走的是【完全相同】的 Shader 代码路径这也解释了前面案例里的一个现象把 SoftClip 改成 AlphaClip 对粒子毫无影响——因为改的只是_ClipArgs0这一个 uniform 的数值而粒子根本不读这个 uniform。五、几个容易困惑的点5.1 为什么叫worldPos它明明不是世界坐标o.worldPos v.vertex.xy * _ClipRange0.zw _ClipRange0.xy;这是 NGUI 的历史遗留命名非常误导人。 它的真实含义是 归一化的 Panel 裁剪空间坐标 ↓ 如果让我重命名应该叫 clipCoord 或 normalizedClipPos在你自己写 Shader 时请用清晰的名字struct v2f { float4 vertex : SV_POSITION; float2 clipCoord : TEXCOORD1; // ← 这样就不会误会了 ... };5.2 为什么v.vertex.xy可以直接用这是整段代码能成立的前提必须讲清楚。// UIPanel.FillAllDrawCallsw.WriteToBuffers(dc.verts,dc.uvs,dc.cols,dc.norms,dc.tans);// UIWidget.WriteToBuffers等价publicvoidWriteToBuffers(BetterListVector3v,...){mGeom.Clear();OnFill(mGeom.verts,mGeom.uvs,mGeom.cols);// ① 生成 widget【本地空间】顶点if(mGeom.hasVertices){// ② 变换到【Panel 空间】Matrix4x4matpanel.worldToLocal*cachedTransform.localToWorldMatrix;mGeom.ApplyTransform(mat);mGeom.WriteToBuffers(v,u,c,n,t);}}// UIGeometry.ApplyTransformpublicvoidApplyTransform(Matrix4x4widgetToPanel){for(inti0;iverts.size;i)mRtpVerts.Add(widgetToPanel.MultiplyPoint3x4(verts.buffer[i]));}再加上 UIDrawCall 的 Transform 是恒等的// UIDrawCall.CreateGameObjectgonewGameObject(name);go.transform.parentpanel.cachedTransform;go.transform.localPositionVector3.zero;// ★go.transform.localRotationQuaternion.identity;go.transform.localScaleVector3.one;串起来widget 本地顶点 │ mat panel.worldToLocal × widget.localToWorld ↓ Panel 本地空间顶点 ← 写进 Mesh │ │ DrawCall 的 Transform 恒等 │ 所以 GPU 拿到的 v.vertex Mesh 里的值 ↓ v.vertex.xy 就是 Panel 空间坐标 ✓┌───────────────────────────────────────────────────┐ │ 这就是为什么 NGUI 能直接 v.vertex.xy * scale │ │ 而【粒子不行】——粒子的顶点在自己的本地空间或世界空间 │ │ 不在 Panel 空间 │ │ │ │ 所以给粒子加裁剪时必须额外传一个矩阵 │ │ float3 wpos mul(unity_ObjectToWorld, v.vertex);│ │ float2 ppos mul(_UIClipMatrix, float4(wpos,1));│ │ 先转到 Panel 空间再走同样的归一化 │ └───────────────────────────────────────────────────┘5.3 为什么在 vert 算不在 frag 算理由 1顶点数远少于像素数一个 100×100 的 Sprite 顶点4 个 像素10000 个 ↓ 在 vert 算 → 4 次乘加 在 frag 算 → 10000 次乘加理由 2这个变换是仿射的插值结果完全等价o.worldPos v.vertex.xy * scale offset └─────── 线性函数 ────────┘ 光栅化的插值也是线性的 ↓ 先变换再插值 ≡ 先插值再变换 ↓ 没有任何精度损失结果 100% 相同⚠️ 如果变换是非线性的比如带透视除法、带 pow 就不能这样提到 vert 里 —— 插值会产生偏差。 NGUI 这里能提正是因为它是纯仿射变换。5.4 为什么不用clip()/discard// 另一种写法 if (min(f.x, f.y) 0) discard;col.a * ...discard能做羽化✓✗ 只能硬切移动 GPU 友好✓✗ 破坏 Early-Z / Tile 优化省 fillrate✗部分能省NGUI 选 alpha 乘法的原因 ① 要支持 clipSoftness羽化 ② discard 在 PowerVR / Adreno 上会让整个 tile 的优化失效 代价 被裁掉的像素仍然走完了整个 frag仍然消耗 fillrate ↓ 这也是前面反复强调的Shader 裁剪不省性能的底层原因5.5 widget 有旋转时会怎样widget 旋转 45° ↓ ApplyTransform 时旋转已经烘进顶点坐标 ↓ Shader 拿到的是旋转后的 Panel 空间坐标 ↓ 裁剪框仍然是 Panel 空间的【轴对齐矩形】 ↓ 效果旋转的 widget 被一个不旋转的矩形框裁掉 ✓ 这是正确行为┌───────────────┐ ← 裁剪框始终轴对齐 │ ╱╲ │ │ ╱ ╲ │ │ ╱ ╲ │ ← 旋转 45° 的 widget │ ╱______╲ │ └───────────────┘ ↓ 超出框的部分被正确裁掉但如果 Panel 自身旋转了_ClipRange 是在 Panel 本地空间定义的 DrawCall 是 Panel 的子物体继承 Panel 的旋转 ↓ 裁剪框跟着 Panel 一起转 ✓ 仍然正确六、多层裁剪 2 和 3 变体6.1 什么时候用到嵌套 Panel MainPanel (SoftClip) └ ScrollPanel (SoftClip) └ widget ← 需要同时被两个框裁剪 → 用 Unlit/Transparent Colored 26.2 2 变体的代码float4 _ClipRange0; float2 _ClipArgs0; float4 _ClipRange1; float2 _ClipArgs1; struct v2f { float4 vertex : SV_POSITION; float2 texcoord : TEXCOORD0; float2 worldPos0 : TEXCOORD1; float2 worldPos1 : TEXCOORD2; // ★ 第二套坐标 fixed4 color : COLOR; }; v2f vert(appdata_t v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // ★ 同一个顶点用两套参数各算一遍 o.worldPos0 v.vertex.xy * _ClipRange0.zw _ClipRange0.xy; o.worldPos1 v.vertex.xy * _ClipRange1.zw _ClipRange1.xy; return o; } half4 frag(v2f i) : COLOR { half4 col tex2D(_MainTex, i.texcoord) * i.color; float2 f0 (float2(1,1) - abs(i.worldPos0)) * _ClipArgs0; float2 f1 (float2(1,1) - abs(i.worldPos1)) * _ClipArgs1; // ★ 四个方向全部取 min —— 等价于两个矩形求交集 float a min(min(f0.x, f0.y), min(f1.x, f1.y)); col.a * clamp(a, 0.0, 1.0); return col; }6.3 为什么 min 就等于求交集只要有【任何一个】方向判定为在外面值为负 min 就会取到那个负数 → clamp 成 0 → 像素消失 ↓ 必须【所有】方向都在内部像素才能存活 ↓ 这正是集合交集的定义框 A 框 B A ∩ B ┌──────────┐ ┌──────────┐ ┌──────────┐ │██████████│ │ │ │ │ │██████████│ ∩ │ ████████│ │ ████████│ │██████████│ │ ████████│ │ ████████│ └──────────┘ └──────────┘ └──────────┘6.4 为什么最多只有 3 层// UIDrawCallif(index4mDynamicMat!null){...}NGUI 只预置了 1 / 2 / 3 三个变体 ↓ 超过 3 层嵌套裁剪就没有对应的 Shader ↓ Shader.Find(Unlit/Transparent Colored 4) 返回 null → 裁剪失效实务提示如果你自己写带裁剪的 UI Shader必须提供 4 个文件无后缀 / 1 / 2 / 3否则在嵌套 Panel 里会突然不裁剪。七、对比如果让你自己写会怎么写7.1 最直觉的朴素写法float4 _ClipRect; // (centerX, centerY, width, height) —— 原始像素值 float2 _ClipSoftness; // (4, 4) —— 原始像素值 half4 frag(v2f i) : COLOR { half4 col tex2D(_MainTex, i.texcoord) * i.color; // 到边界的【像素】距离 float2 half _ClipRect.zw * 0.5; float2 d half - abs(i.panelPos - _ClipRect.xy); // 除以羽化宽度 col.a * saturate(min(d.x, d.y) / min(_ClipSoftness.x, _ClipSoftness.y)); return col; }可读性好很多但 frag 里有2 次乘法half 2 次减法 2 次 abs 1 次 min 1 次【除法】 ← 最贵7.2 NGUI 的写法// vert每顶点次数极少 o.worldPos v.vertex.xy * _ClipRange0.zw _ClipRange0.xy; // 2 mad // frag每像素次数极多 float2 f (float2(1,1) - abs(i.worldPos)) * _ClipArgs0; // 2 sub 2 abs 2 mul col.a * clamp(min(f.x, f.y), 0.0, 1.0); // 1 min 1 clamp 1 mulfrag 里0 次除法全是乘加 ↓ 所有除法1/halfW、halfW/softness都预先在 C# 里算好 打包进 _ClipRange0 和 _ClipArgs0 两个 uniform7.3 优化思路总结┌──────────────────────────────────────────────────┐ │ 能在 CPU 算的别在 GPU 算 │ │ → 1/halfW、halfW/softness 预计算成 uniform │ │ │ │ 能在 vert 算的别在 frag 算 │ │ → 仿射变换提到 vert靠插值白嫖 │ │ │ │ 能用乘法的别用除法 │ │ → 除法预先转成倒数 │ │ │ │ 能打包的别占多个 uniform │ │ → scale 和 offset 塞进同一个 float4 │ └──────────────────────────────────────────────────┘代价是可读性——这也是为什么这段代码第一眼看不懂。八、动手验证把中间结果画出来理解一段 Shader 最快的方式是把中间量输出成颜色。half4 frag(v2f i) : COLOR { half4 col tex2D(_MainTex, i.texcoord) * i.color; // ══ 调试开关取消注释其中一个 ══ // ① 看归一化坐标红x绿y // 中心应该是黑色右上角接近黄色 // return half4(i.worldPos.x * 0.5 0.5, i.worldPos.y * 0.5 0.5, 0, 1); // ② 看 1 - abs内部亮外部黑 // float2 d float2(1,1) - abs(i.worldPos); // return half4(saturate(d.x), saturate(d.y), 0, 1); // ③ 看最终的裁剪系数应该是中间白、边缘渐黑 // float2 f (float2(1,1) - abs(i.worldPos)) * _ClipArgs0; // float a clamp(min(f.x, f.y), 0.0, 1.0); // return half4(a, a, a, 1); // ④ 只看羽化带在哪把 args 调小才看得清 // float2 f (float2(1,1) - abs(i.worldPos)) * 3.0; // float a clamp(min(f.x, f.y), 0.0, 1.0); // return half4(a, a, a, 1); float2 f (float2(1.0, 1.0) - abs(i.worldPos)) * _ClipArgs0; col.a * clamp(min(f.x, f.y), 0.0, 1.0); return col; }预期看到的画面开关现象①裁剪框中心是深灰0.5,0.5右上角偏黄左下角偏黑②框内是彩色渐变框外纯黑③框内几乎全白只有边缘 4px 有极细的渐变④把 args 强行改成 3 → 能看到很宽的灰色羽化带一眼看懂羽化原理开关 ④ 特别推荐。把_ClipArgs0从 80 改成 3羽化带会从 4 像素变成 107 像素——羽化机制瞬间可视化。九、整体数据流┌─────────────────────────────────────────────────────────────┐ │ C# 侧UIPanel │ │ │ │ baseClipRegion (0, 0, 640, 800) │ │ clipOffset (0, -240) ← UIScrollView 滚动时改这个 │ │ ↓ │ │ finalClipRegion (0, -240, 640, 800) │ │ ↓ │ │ → UIDrawCall.SetClipping(0, cr, softness) │ └──────────────────────────┬──────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ C# 侧UIDrawCall │ │ │ │ halfW 320, halfH 400 │ │ softness (4, 4) │ │ ↓ 预计算所有除法 │ │ _ClipRange0 (-cx/320, -cy/400, 1/320, 1/400) │ │ ( 0, 0.6, 0.003125, 0.0025) │ │ _ClipArgs0 (320/4, 400/4) (80, 100) │ │ ↓ │ │ mDynamicMat.SetVector(...) │ │ mDynamicMat.shader Shader.Find(...Colored 1) │ └──────────────────────────┬──────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ GPUvert每个顶点执行一次 │ │ │ │ v.vertex.xy Panel 空间坐标如 (300, -100) │ │ ↓ × zw xy │ │ o.worldPos (0.9375, 0.35) 归一化到 [-1,1] │ └──────────────────────────┬──────────────────────────────────┘ ↓ 光栅化线性插值仿射变换可安全插值 ┌─────────────────────────────────────────────────────────────┐ │ GPUfrag每个像素执行一次 │ │ │ │ i.worldPos (0.9375, 0.35) │ │ ↓ abs │ │ (0.9375, 0.35) │ │ ↓ 1 - abs 到边界的归一化距离 │ │ (0.0625, 0.65) │ │ ↓ × ClipArgs 转成 0~1 的羽化系数 │ │ (5.0, 65.0) │ │ ↓ min 取最靠近边界的那个方向 │ │ 5.0 │ │ ↓ clamp(0,1) │ │ 1.0 │ │ ↓ │ │ col.a * 1.0 → 完全不透明 │ └─────────────────────────────────────────────────────────────┘十、速查两行代码的语义// vert把 Panel 空间坐标归一化到 [-1,1]裁剪框边界 ±1 o.worldPos v.vertex.xy * _ClipRange0.zw _ClipRange0.xy; // └ Panel坐标 ┘ └ 1/半尺寸 ┘ └ -中心/半尺寸 ┘ // frag算到边界的距离转成 0~1 系数乘 alpha float2 f (float2(1,1) - abs(i.worldPos)) * _ClipArgs0; // └─ 到边界的归一化距离 ─┘ └ 半尺寸/羽化像素 ┘ col.a * clamp(min(f.x, f.y), 0.0, 1.0); // └ 矩形SDF ┘ └ 外0→0内1→1 ┘两个 uniformUniform分量值作用_ClipRange0.xy(-cx/halfW, -cy/halfH)平移把框中心移到原点.zw(1/halfW, 1/halfH)缩放把框半尺寸变成 1_ClipArgs0.xy(halfW/soft, halfH/soft)羽化锐度越大边缘越硬关键数值框 640×800softness 4_ClipRange0.zw (0.003125, 0.0025) ← 1/320, 1/400 _ClipArgs0 (80, 100) ← 320/4, 400/4 羽化带宽度 4 像素四个为什么问题答案为什么用min矩形内部判定是与关系min 给出到最近边的距离。用 max 会失效用乘法会让四角发暗为什么在 vert 算顶点数 ≪ 像素数且仿射变换插值完全等价零精度损失为什么不用discard① 无法做羽化 ② 破坏移动 GPU 的 Tile/Early-Z 优化为什么叫worldPos命名历史遗留它不是世界坐标是归一化的 Panel 裁剪坐标三个衍生结论① SoftClip 与 AlphaClip 走完全相同的代码 区别只是 _ClipArgs0 的数值80 vs 1000 → 改 clipping 模式对不读这个 uniform 的东西粒子毫无影响 ② v.vertex.xy 能直接用是因为 UIGeometry.ApplyTransform 已经把顶点变换到了 Panel 空间且 DrawCall 的 Transform 是恒等的 → 粒子顶点不在 Panel 空间所以必须额外传 worldToLocalMatrix ③ 自己写带裁剪的 UI Shader必须提供 4 个变体 名字 / 名字 1 / 名字 2 / 名字 3 → 少一个嵌套 Panel 时就会突然失效收尾把整段代码翻译成人话vert 把这个顶点换算一下——裁剪框中心算 0框边缘算 1。 frag 看看这个像素离框边界还有多远1 - abs。 远就完全显示系数 1 到边界就完全消失系数 0 中间那 4 个像素做个渐变乘 args 再 clamp。 x 和 y 两个方向谁更危险就听谁的min。三句话一、这段代码是一个矩形的有符号距离场SDF。1 - abs(归一化坐标)给出到边界还有多远内正外负min(x, y)取最近的那条边clamp把它压成 0~1 的 alpha 系数。羽化、硬切、完全消失全都是同一个公式的不同参数。二、vert 那行是纯粹的仿射变换目的只有一个把任意尺寸的裁剪框统一成[-1,1]。之所以能提到 vert 里是因为仿射变换的插值结果和逐像素计算完全等价所有除法都预先在 C# 算好塞进 uniformfrag 里一次除法都没有。三、v.vertex.xy能直接当 Panel 空间坐标用是整段代码成立的隐藏前提。这来自UIGeometry.ApplyTransform把顶点烘进了 Panel 空间 DrawCall 的 Transform 是恒等的。粒子没有这个前提所以给粒子加裁剪时必须额外传一个worldToLocalMatrix先手动转到 Panel 空间——这正是前面案例里那三行核心代码的由来。最后建议把_ClipArgs0临时改成3.0跑一次。羽化带会从 4 像素撑到 100 多像素整个机制会在一瞬间变得直观。
返回列表