Unity UI Image 源码解析二Filled 径向填充与 RadialCut本文是《Unity UI Image 源码解析》的第二篇继续分析Image.Type.Filled重点说明Radial90、Radial180、Radial360以及底层的RadialCut算法。第一篇负责梳理Simple、Sliced、Tiled和Filled的整体网格生成流程本文只讨论 Filled 的径向裁剪部分。本文对照的源码版本Unity2022.3.62f3uGUIcom.unity.ugui1.0.0。源码位置Library/PackageCache/com.unity.ugui1.0.0/Runtime/UI/Core/Image.cs系列导航上一篇Unity UI Image 源码解析一Simple、Sliced、Tiled 与 Filled当前Unity UI Image 源码解析二Filled 径向填充与 RadialCut。一、径向填充的整体流程GenerateFilledSprite使用两个长度为 4 的静态数组保存矩形位置和 UV。源码中的实际类型是Vector3[]不是Vector2[]staticreadonlyVector3[]s_XynewVector3[4];staticreadonlyVector3[]s_UvnewVector3[4];初始化后的顶点顺序为索引位置坐标0左下(v.x, v.y)1左上(v.x, v.w)2右上(v.z, v.w)3右下(v.z, v.y)UV 使用相同顺序s_Xy[0]newVector2(v.x,v.y);s_Xy[1]newVector2(v.x,v.w);s_Xy[2]newVector2(v.z,v.w);s_Xy[3]newVector2(v.z,v.y);s_Uv[0]newVector2(tx0,ty0);s_Uv[1]newVector2(tx0,ty1);s_Uv[2]newVector2(tx1,ty1);s_Uv[3]newVector2(tx1,ty0);虽然右侧赋值使用了Vector2但它会隐式转换并保存到Vector3[]中Z 分量为 0。1. fillAmount 的边界处理fillAmount的公开属性会把输入限制在 0 到 1publicfloatfillAmount{get{returnm_FillAmount;}set{if(SetPropertyUtility.SetStruct(refm_FillAmount,Mathf.Clamp01(value))){SetVerticesDirty();}}}生成网格时还有两个重要边界m_FillAmount 0.001f直接返回不生成顶点m_FillAmount 1f径向模式不再执行RadialCut而是直接添加完整 Quad。因此 Radial180 和 Radial360 在fillAmount1时不会继续拆成 2 个或 4 个区域而是只生成一个完整 Quad。2. Fill Origin 对照同一个m_FillOrigin在不同填充模式下含义不同值Origin90Origin180Origin3600BottomLeftBottomBottom1TopLeftLeftRight2TopRightTopTop3BottomRightRightLeftfillMethod改变时Unity 会把m_FillOrigin重置为 0。反序列化时也会修正超出当前模式范围的 Origin。二、RadialCut 的核心逻辑径向模式最终都会调用下面这个入口staticboolRadialCut(Vector3[]xy,Vector3[]uv,floatfill,boolinvert,intcorner){// 没有需要绘制的部分if(fill0.001f)returnfalse;// 奇数 corner 会反转裁剪方向if((corner1)1)invert!invert;// 已经是完整区域无需调整顶点if(!invertfill0.999f)returntrue;// 把 0~1 映射到 0~90°floatangleMathf.Clamp01(fill);if(invert)angle1f-angle;angle*90f*Mathf.Deg2Rad;floatcosMathf.Cos(angle);floatsinMathf.Sin(angle);// 位置和 UV 必须使用相同参数一起裁剪RadialCut(xy,cos,sin,invert,corner);RadialCut(uv,cos,sin,invert,corner);returntrue;}这里有三个容易遗漏的细节m_FillClockwise作为invert传入但它不一定等于辅助函数最终使用的invert当corner为奇数时源码会执行invert !invert位置数组xy和 UV 数组uv必须做完全相同的裁剪否则几何边界和纹理边界会错位。源码注释写的是 “Even corners invert the fill direction”但实际条件是(corner 1) 1执行逻辑对应奇数corner。分析行为时应以代码条件为准。1. corner 如何决定顶点索引第二个重载先根据corner旋转四个索引inti0corner;inti1(corner1)%4;inti2(corner2)%4;inti3(corner3)%4;因此i0、i1、i2、i3不是固定对应某个屏幕方向。不同 Origin 或不同分区传入不同corner后同一条赋值语句会作用到不同的实际顶点。后面的示意图可以帮助理解某个具体 Origin但不能把图中的 B、C、D、E 点名直接推广到所有corner。2. 为什么比较 sin 和 cossin和cos决定裁剪射线先碰到矩形的水平边还是垂直边sin cos把sin归一化为 1再用cos / sin计算横向插值cos sin把cos归一化为 1再用sin / cos计算纵向插值sin cos对应 45°两个比例都设为 1。以偶数corner分支为例源码为if(cossin){sin/cos;cos1f;if(!invert){xy[i1].yMathf.Lerp(xy[i0].y,xy[i2].y,sin);xy[i2].yxy[i1].y;}}elseif(sincos){cos/sin;sin1f;if(invert){xy[i2].xMathf.Lerp(xy[i0].x,xy[i2].x,cos);xy[i3].xxy[i2].x;}}else{cos1f;sin1f;}if(invert)xy[i3].yMathf.Lerp(xy[i0].y,xy[i2].y,sin);elsexy[i1].xMathf.Lerp(xy[i0].x,xy[i2].x,cos);奇数corner还会进入另一套 X/Y 对调后的分支。这样同一套算法才能覆盖四个起始角。三、FillMethod.Radial90Radial90以矩形的一个角为径向中心把fillAmount映射到 0° 到 90°if(fillMethodFillMethod.Radial90){if(RadialCut(s_Xy,s_Uv,m_FillAmount,m_FillClockwise,m_FillOrigin)){AddQuad(toFill,s_Xy,color,s_Uv);}}它最多添加一个 Quad因此最多生成 4 个顶点和 2 个三角形。下面沿用原图以Origin90.BottomLeft也就是corner0为例。因为 0 是偶数invert不会被额外翻转所以此时辅助函数中的invert与m_FillClockwise相同。1. Clockwise true(逆时针)此时inverttrue角度使用补值计算floatangleMathf.Clamp01(fill);if(invert)angle1f-angle;angle*90f*Mathf.Deg2Rad;1cos sin下图左下角为i0A左上角为i1E右上为i2D右下为i3B这一分支先执行sin/cos;cos1f;对于偶数corner且inverttrue随后通过sin更新i3的 Y 坐标B移到C处xy[i3].yMathf.Lerp(xy[i0].y,xy[i2].y,sin);2 cos sin45° 时不需要先缩放某一个比例最后按inverttrue的路径更新i1的 y 坐标。B移到C处上图画错3cos sin这一分支先把sin归一化为 1再同时修正i2、i3的 X 坐标最后还会更新i3的 Y 坐标B和C移到D处cos/sin;sin1f;xy[i2].xMathf.Lerp(xy[i0].x,xy[i2].x,cos);xy[i3].xxy[i2].x;xy[i3].yMathf.Lerp(xy[i0].y,xy[i2].y,sin);2. Clockwise false(顺时针)对于当前的偶数corner此时invertfalse不计算补角。1sin cos下图左下角为i0A左上角为i1E右上为i2c右下为i3B该分支只归一化比例最终通过cos更新i1的 X 坐标E移到D处cos/sin;sin1f;xy[i1].xMathf.Lerp(xy[i0].x,xy[i2].x,cos);2sin cos45° 时不需要先缩放某一个比例最后按invertfalse的路径更新i1的 X 坐标。D移到C处3sin cos该分支会先让i1和i2使用相同的 Y 坐标再更新i1的 X 坐标E和D移到C处sin/cos;cos1f;xy[i1].yMathf.Lerp(xy[i0].y,xy[i2].y,sin);xy[i2].yxy[i1].y;xy[i1].xMathf.Lerp(xy[i0].x,xy[i2].x,cos);四、FillMethod.Radial180Radial180不直接对完整矩形做一次 180° 裁剪而是先拆成两个半区再把每个半区作为一个 Radial90 区域处理。1. Bottom 或 Top拆成左右两半当m_FillOrigin为 Bottom0或 Top2时if(m_FillOrigin0||m_FillOrigin2){fy00f;fy11f;if(sideeven){fx00f;fx10.5f;}else{fx00.5f;fx11f;}}B为半圆圆心C移到D点左右半区的归一化范围分别为左半区fx0 0.0fx1 0.5 右半区fx0 0.5fx1 1.0 纵向 fy0 0.0fy1 1.0even m_FillOrigin 1 ? 1 : 0用于调整 Top 与 Bottom 情况下两个半区的处理顺序。2. Left 或 Right拆成上下两半当m_FillOrigin为 Left1或 Right3时源码改为拆分 Y 轴fx00f;fx11f;if(sideeven){fy00.5f;fy11f;}else{fy00f;fy10.5f;}G为半圆圆心A移到B点上下半区的归一化范围分别为上半区fy0 0.5fy1 1.0 下半区fy0 0.0fy1 0.5 横向 fx0 0.0fx1 1.03. 每个半区的局部填充值两个半区分别计算自己的局部valfloatvalm_FillClockwise?fillAmount*2f-side:m_FillAmount*2f-(1-side);这里源码第一项使用公开属性fillAmount第二项使用字段m_FillAmount由于属性 getter 直接返回该字段两者数值相同。val随后会经过Mathf.Clamp01if(RadialCut(s_Xy,s_Uv,Mathf.Clamp01(val),m_FillClockwise,(sidem_FillOrigin3)%4)){AddQuad(toFill,s_Xy,color,s_Uv);}因此一个半区可以处于三种状态val 0RadialCut返回 false不添加该半区0 val 1裁剪当前半区val 1当前半区完整显示。fillAmount 1时 Radial180 最多添加两个独立 Quad也就是最多 8 个顶点、4 个三角形。五、FillMethod.Radial360Radial360把完整矩形拆成四个象限corner区域X 范围Y 范围0左下00.500.51左上00.50.512右上0.510.513右下0.5100.5源码中的区域计算为for(intcorner0;corner4;corner){floatfx0,fx1,fy0,fy1;if(corner2){fx00f;fx10.5f;}else{fx00.5f;fx11f;}if(corner0||corner3){fy00f;fy10.5f;}else{fy00.5f;fy11f;}// 根据 fx0、fx1、fy0、fy1 生成当前象限的位置和 UV。}每个象限的局部填充值为floatvalm_FillClockwise?m_FillAmount*4f-((cornerm_FillOrigin)%4):m_FillAmount*4f-(3-((cornerm_FillOrigin)%4));然后把当前象限交给RadialCutif(RadialCut(s_Xy,s_Uv,Mathf.Clamp01(val),m_FillClockwise,(corner2)%4)){AddQuad(toFill,s_Xy,color,s_Uv);}J为圆心B和C移到D点图中的四个区域都复用 Radial90 的裁剪逻辑。m_FillOrigin决定从哪个象限开始m_FillClockwise决定象限推进顺序val决定当前象限是不绘制、部分绘制还是完整绘制。fillAmount 1时 Radial360 最多添加 4 个独立 Quad也就是最多 16 个顶点、8 个三角形。fillAmount 1时则直接添加一个完整 Quad。六、算法总结1. 执行流程阶段操作关键变量或方法1清空旧顶点数据toFill.Clear()2过滤接近 0 的填充值m_FillAmount 0.001f3获取绘制矩形和 Outer UVGetDrawingDimensions、GetOuterUV4初始化 4 个位置和 UVs_Xy、s_Uv5根据模式拆成 1、2 或 4 个区域Radial90、Radial180、Radial3606计算当前区域的局部填充值val7同时裁剪位置与 UVRadialCut(xy, ...)、RadialCut(uv, ...)8提交有效区域AddQuad2. 局部填充值公式模式方向公式Radial90两种方向直接使用m_FillAmountRadial180ClockwisefillAmount * 2 - sideRadial180Counter-clockwisem_FillAmount * 2 - (1 - side)Radial360Clockwisem_FillAmount * 4 - ((corner origin) % 4)Radial360Counter-clockwisem_FillAmount * 4 - (3 - ((corner origin) % 4))所有 Radial180 和 Radial360 的局部val都会先经过Mathf.Clamp01再传给RadialCut。3. 常用变量变量源码类型含义vVector4绘制矩形x左、y下、z右、w上outerVector4Sprite Outer UVx左、y下、z右、w上tx0, ty0, tx1, ty1floatUV 的左右下上边界s_XyVector3[]当前 Quad 的 4 个位置s_UvVector3[]当前 Quad 的 4 个 UVm_FillAmountfloat总填充比例范围为 01m_FillClockwisebool径向填充方向m_FillOriginint当前 FillMethod 对应的起点枚举值cornerint当前 Radial90 局部区域的角索引invertboolRadialCut内部实际使用的裁剪方向4. 顶点数量模式fillAmount 1时的最大顶点数最大三角形数Radial9042Radial18084Radial360168这些 Quad 之间不共享顶点。fillAmount 1时三种径向模式都会直接生成一个完整 Quad即 4 个顶点和 2 个三角形。七、总结Radial90 直接裁剪一个矩形Radial180 先拆成两个半区再分别执行 Radial90 裁剪Radial360 先拆成四个象限再分别执行 Radial90 裁剪RadialCut会同步修改位置与 UVcorner为奇数时会翻转invert所以m_FillClockwise不能在所有情况下直接等同于辅助函数中的最终裁剪方向s_Xy和s_Uv的真实类型是Vector3[]fillAmount接近 0 时不生成网格等于 1 时直接生成完整 Quad。理解这套算法的关键不是死记某个图中的顶点字母而是先确认当前corner对应的i0i3再结合invert、sin和cos判断实际移动的顶点。