UE5 UMG自定义环状图控件实现:从原理到高性能绘制

📅 2026/7/29 6:22:56
UE5 UMG自定义环状图控件实现:从原理到高性能绘制
1. 项目概述为什么要在UE5里折腾环状图做游戏UI尤其是RPG、策略或者模拟经营类数据可视化是个绕不开的坎。血条、经验条、技能冷却、资源占比……这些信息用传统的进度条或数字堆叠玩家看久了容易疲劳信息传达也不够直观。环状图或者说饼图、扇形图恰恰是展示比例和构成的神器。想象一下在游戏里查看角色属性分布、队伍资源分配或者一个技能的伤害类型构成一个色彩分明的环状图比一堆数字和长条要友好得多。UE5的UMGUnreal Motion GraphicsUI系统功能强大但官方Widget控件库里并没有现成的环状图控件。这意味着每当我们需要展示一个百分比或者几个部分的占比时要么找第三方插件可能不兼容、要付费或者功能过剩要么就得自己动手造轮子。自己实现的好处是可控性极高从外观风格到交互逻辑都能完美匹配项目需求性能开销也心里有数。这个项目就是要在UMG的框架下从零开始实现一个高性能、可定制、蓝图和C都能方便调用的环状图控件。这个控件最终的目标是设计师在UMG编辑器中能像拖放一个按钮那样拖放这个环状图然后通过简单的属性设置比如分段数量、每段角度、颜色、厚度来快速配置出想要的样式程序员则可以通过暴露出的函数接口动态地更新每一段的值或角度实现数据驱动下的实时变化。无论是用于静态的资源图标还是动态的技能冷却指示器它都应该能胜任。2. 核心设计思路用“扇形切片”拼成一个“圆环”实现环状图最直观的思路不是去画一个“空心的圆”而是用多个“扇形的片”首尾相接围成一个圈。UMG里最接近“绘制任意形状”的底层控件是UImage但它默认显示的是纹理。我们需要一个能自定义几何图形的控件。这里UE为我们提供了USlateVectorArtData和SVectorImage的底层Slate架构但对于UMG层面更通用的方法是继承UWidget并重写其OnPaint函数在渲染时直接向画布FPaintContext提交顶点和索引数据。2.1 技术选型自定义Widget vs. 材质球主要有两条技术路径纯C自定义Widget本方案选择继承UWidget在OnPaint中利用FSlateDrawElement绘制三角形扇Triangle Fan来构成每个扇形切片。这种方式性能最优控制粒度最细所有顶点计算都在CPU完成直接由Slate渲染管线处理没有额外的渲染状态切换或材质开销。适合需要频繁更新、数量众多的动态图表。UMG材质球Material驱动创建一个动态材质实例将百分比等参数传入材质在材质编辑器中用数学节点计算每个像素是否在扇形区域内并着色。这种方法更“美术友好”可以做出非常炫酷的渐变、发光等效果。但缺点是每更新一次数据都需要设置材质参数对于复杂分段比如超过10段的逻辑控制会变得很麻烦性能也可能不如直接绘制几何体。考虑到我们这个控件需要支持多分段、每段独立颜色和值、以及高频更新如实时变化的血量我们选择第一条路基于UWidget的自定义绘制。它的核心是构建一系列围绕中心点的三角形。2.2 数据结构设计我们需要一个结构体来定义“环”的每一段USTRUCT(BlueprintType) struct FDonutSegment { GENERATED_BODY() // 该段的值用于计算占整体比例可选 UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryDonut) float Value; // 该段的起始角度度0度为正右方顺时针增加 UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryDonut) float StartAngle; // 该段覆盖的角度范围度 UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryDonut) float AngleSpan; // 该段的颜色 UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryDonut) FLinearColor Color; // 内部构造函数方便初始化 FDonutSegment() : Value(0.0f), StartAngle(0.0f), AngleSpan(90.0f), Color(FLinearColor::White) {} };在控件类例如UDonutChartWidget中我们会有一个TArrayFDonutSegment Segments数组来管理所有分段。同时控件还需要一些整体属性InnerRadius内环半径控制环的“空心”大小。Thickness环的厚度即外环半径减内环半径。bUseValueAsAngle布尔值如果为true则根据所有Segment的Value总和自动计算每个Segment的AngleSpan如果为false则直接使用每个Segment自定义的AngleSpan。注意角度与弧度在计算和存储时我们使用角度Degree更为直观因为UMG编辑器和蓝图通常使用角度。但在三角函数sin, cos计算时需要转换为弧度Radian。牢记转换公式弧度 角度 * PI / 180.0f。3. 核心实现在OnPaint中绘制扇形切片这是整个控件的灵魂所在。我们会在自定义Widget的OnPaint函数里遍历Segments数组为每一段生成顶点并绘制。3.1 顶点生成算法对于一个扇形切片从起始角A到结束角ASpan我们需要把它分解成一系列三角形。最简单的方法是使用“三角形扇”以一个点为圆心然后依次连接圆弧上的点。但是由于我们是空心圆环圆心位置是“空”的。因此对于环状图的一段我们需要用两个同心圆弧上的点来构造四边形再将每个四边形分解为两个三角形。具体步骤对于一段Segment计算该段的内外圆弧内圆弧半径 InnerRadius 外圆弧半径 InnerRadius Thickness。根据StartAngle和AngleSpan以一定的角度步长例如1度或2度步长越小越圆滑但顶点数越多采样圆弧上的点。对于每个采样角度θ内圆弧点P_inner Center (InnerRadius * cosθ, InnerRadius * sinθ)外圆弧点P_outer Center ((InnerRadiusThickness) * cosθ, (InnerRadiusThickness) * sinθ)将相邻两对采样点(P_inner_i, P_outer_i)和(P_inner_{i1}, P_outer_{i1})组成一个四边形这个四边形由两个三角形构成三角形1:(P_inner_i, P_outer_i, P_inner_{i1})三角形2:(P_inner_{i1}, P_outer_i, P_outer_{i1})这样遍历所有采样点我们就为这一段生成了所需的三角形列表。3.2 OnPaint函数实现要点在UDonutChartWidget::OnPaint中我们需要获取绘制区域和中心点从FPaintContext的AllottedGeometry获取控件实际分配的大小和位置计算出中心点坐标。通常中心点就是分配区域的中心。单位转换我们的InnerRadius和Thickness在设计时可能使用归一化的比例如0-1相对于控件最小边的一半也可能使用绝对像素值。在OnPaint中需要根据设计将其转换为实际的屏幕像素坐标。遍历与绘制对Segments数组进行循环。对于每一段设置该段的颜色 (FSlateDrawElement::MakeDebugQuad或其他绘制方法可以指定颜色但更底层的是使用FSlateDrawElement::MakeCustomVerts)。按照上述算法生成该段所有三角形的顶点数据位置、颜色、UV。调用FSlateDrawElement::AddDrawElement提交这一批顶点进行绘制。为了减少Draw Call理想情况下应该将所有段的顶点数据合并到一个顶点/索引缓冲区中一次性提交。但对于动态更新频繁且段数不多的UI控件分段提交在实现上更简单清晰性能影响也可接受。void UDonutChartWidget::OnPaint(FPaintContext Context) const { Super::OnPaint(Context); const FGeometry AllottedGeometry Context.AllottedGeometry; const FVector2D LocalSize AllottedGeometry.GetLocalSize(); const FVector2D Center LocalSize * 0.5f; const float Scale AllottedGeometry.Scale; // 计算实际像素半径 const float PixelInnerRadius InnerRadius * FMath::Min(LocalSize.X, LocalSize.Y) * 0.5f; const float PixelThickness Thickness * FMath::Min(LocalSize.X, LocalSize.Y) * 0.5f; const float OuterRadius PixelInnerRadius PixelThickness; // 角度步长平衡效果与性能 const float AngleStep 1.0f; // 每1度采样一次 for (const FDonutSegment Segment : Segments) { if (Segment.AngleSpan 0.0f || PixelThickness 0.0f) { continue; } FColor DrawColor Segment.Color.ToFColor(true); TArrayFSlateVertex Vertices; TArraySlateIndex Indices; // SlateIndex 通常是 uint16 或 uint32 float CurrentAngle Segment.StartAngle; const float EndAngle Segment.StartAngle Segment.AngleSpan; // 生成起始边的两个顶点 float CurrentRad FMath::DegreesToRadians(CurrentAngle); FVector2D Dir FVector2D(FMath::Cos(CurrentRad), FMath::Sin(CurrentRad)); FVector2D InnerStart Center Dir * PixelInnerRadius; FVector2D OuterStart Center Dir * OuterRadius; int32 VertexIndex 0; // 这里简化示意实际需要循环生成四边形并分解为三角形填充Vertices和Indices // ... // 创建绘制请求 FSlateResourceHandle ResourceHandle FSlateApplication::Get().GetRenderer()-GetResourceHandle(*WhiteTexture); FSlateDrawElement::MakeCustomVerts( Context.OutDrawElements, Context.LayerId, ResourceHandle, Vertices, Indices, nullptr, 0, 0 ); } }实操心得性能与效果的权衡AngleStep角度步长是关键参数。设为1度一个360度的圆需要360个采样点一段90度的扇形需要90个四边形180个三角形非常精细。但对于小尺寸控件或大量实例顶点数会爆炸。我的经验是根据环的最终屏幕像素大小动态调整步长。例如可以设置一个规则当环的外半径像素小于50时步长用5度大于50时用2度大于100时用1度。这能在大多数情况下取得很好的平衡。3.3 蓝图与C的接口暴露为了让设计师和策划能方便使用我们需要将核心功能暴露给蓝图。属性BlueprintReadWrite, EditAnywhereSegments分段数据数组可直接在UMG编辑器细节面板中编辑。InnerRadius和Thickness控制环的形状。bUseValueAsAngle切换模式。函数BlueprintCallableAddSegment(float Value, FLinearColor Color)添加一个新段如果bUseValueAsAngle为真则根据Value占总和的比例自动计算角度。RemoveSegment(int32 Index)移除指定段。UpdateSegmentValue(int32 Index, float NewValue)更新某段的值并触发重绘。ClearAllSegments()清空所有段。RecalculateAnglesFromValues()当bUseValueAsAngle为真时手动调用此函数根据当前所有段的Value重新计算分配角度。事件BlueprintImplementableEventOnSegmentClicked(int32 SegmentIndex)可以结合UMG的OnMouseButtonDown事件通过射线检测判断点击位置落在哪个Segment上然后触发此事件实现环状图的交互功能如点击某段高亮或弹出详情。4. 高级功能与优化实践一个基础的绘制控件完成后接下来要考虑的是让它更实用、更强大。4.1 交互与点击检测实现点击检测的核心是几何计算。当控件收到鼠标点击事件时我们需要将点击的屏幕坐标转换到控件的本地坐标系然后计算该点相对于圆心的极坐标距离和角度最后判断这个点是否落在某个扇形段的环状区域内。判断逻辑如下计算点击点P到圆心Center的距离Dist。如果Dist InnerRadius或Dist (InnerRadius Thickness)则点击落在空心区域或环外无效。计算点击点相对于圆心的角度ClickAngle使用FMath::Atan2(P.Y - Center.Y, P.X - Center.X)得到弧度再转成度并规范到0-360度范围。遍历Segments对于每一段规范其StartAngle和EndAngle到0-360度并处理跨越0度的情况例如一段从350度到10度。判断ClickAngle是否落在该段的角度范围内并且Dist在环的厚度范围内。如果是则触发该段的点击事件并传递段索引。避坑技巧角度周期处理这是最容易出错的地方。因为角度是周期性的360度等于0度所以一段的角度范围可能是[350, 10]实际跨越了360度线。在判断一个角度θ是否属于[start, end]时如果start end需要将区间拆分为[start, 360)和[0, end]两个子区间分别判断。一个稳健的判断函数是必不可少的。4.2 动画与过渡效果静态的环状图已经有用但动态变化的环状图更能吸引玩家注意力。常见的动画需求有数据更新动画当某段的值发生变化时其对应的角度平滑过渡到新的目标值。初始绘制动画环状图在显示时各段从0度逐渐展开到目标角度。高亮动画鼠标悬停或点击某段时该段可以轻微放大、亮度提高或分离出去。实现这些动画需要在控件内维护两套角度数据一套是当前显示的角度 (CurrentAngleSpan)另一套是目标角度 (TargetAngleSpan)。在Tick函数或利用UMG的动画系统中每一帧根据插值速度如使用FMath::FInterpTo将CurrentAngleSpan向TargetAngleSpan逼近并触发重绘InvalidateLayoutAndVolatility。对于高亮动画可以额外维护一个“高亮段索引”和“高亮偏移量”。在绘制该段时将其内外圆弧的半径临时加上一个偏移量并绘制一个更亮或颜色不同的层在上面。4.3 性能优化要点顶点缓存与脏标记每次OnPaint都重新计算所有顶点是非常低效的。只有当Segments数据、控件大小或InnerRadius/Thickness等属性真正改变时才需要重新生成顶点数据。我们可以将计算好的顶点和索引数据缓存起来并设置一个bVertexCacheDirty标记。只有在标记为脏时才重新计算缓存。合并Draw Call如前所述将所有制作为同一材质或颜色的段的顶点数据合并到一个大的顶点/索引缓冲区中然后一次性提交绘制可以显著减少Slate渲染指令的数量。这对于包含大量环状图实例的复杂UI界面尤为重要。避免在Tick中频繁更新如果数据不是每帧都变就不要在Tick里更新。使用事件驱动仅在数据源发生变化时调用控件的更新接口。LOD细节层次对于远离摄像机或者尺寸特别小的环状图可以大幅降低AngleStep甚至用更少的段数比如只用4个三角形构成一个八边形环来近似。这需要在OnPaint中根据控件的最终渲染尺寸动态决定。5. 在项目中的实际应用与问题排查5.1 典型应用场景配置假设我们做一个游戏内的“角色能力六维图”。控件创建在UMG编辑器中将我们自定义的DonutChartWidget拖入画布。属性设置在细节面板设置InnerRadius为0.3Thickness为0.15bUseValueAsAngle为true。数据初始化在蓝图或C中准备一个包含6个FDonutSegment的数组分别代表力量、敏捷、智力等。每个Segment的Value设为对应能力值如0-100并分配不同的Color。绑定与更新将控件Segments属性绑定到这个数组。当角色升级或装备变更导致能力值变化时更新数组中对应Segment的Value然后调用控件的RecalculateAnglesFromValues()和InvalidateLayoutAndVolatility()或控件自动监听数组变化图表就会自动更新。5.2 常见问题与解决方案速查表问题现象可能原因排查步骤与解决方案环状图不显示或全黑1. 顶点数据生成错误三角形面积为零或反向。2. 颜色值异常如Alpha为0。3. 控件尺寸为零。1. 检查OnPaint中的顶点计算逻辑特别是三角函数参数单位弧度/度。用DrawDebug在屏幕上打印出关键顶点位置验证。2. 确保Segment.Color的A通道不为0。3. 检查UMG中控件是否被正确布局有无被其他控件挤压。环状图边缘有锯齿采样步长 (AngleStep) 太大导致圆弧由明显的多边形构成。减小AngleStep如从5度改为1度。考虑启用抗锯齿Slate默认可能已开启检查项目渲染设置。点击检测不准确1. 点击坐标转换错误。2. 角度范围判断逻辑有bug特别是跨越0度时。3. 环的厚度 (Thickness) 太小点击容差低。1. 在点击事件中打印出转换后的本地坐标和计算出的角度、距离与鼠标实际位置对比。2. 单独编写并测试角度判断函数用多种边界情况如[350,10], [10, 350]验证。3. 在判断时加入一个微小的容差范围或者视觉上加厚点击区域。数据更新时画面闪烁在每帧中频繁设置bVertexCacheDirty并重算顶点导致性能波动。确保只在数据实际变化时标记缓存为脏。使用TArray的OnChanged委托或自定义变更通知。对于动画考虑在Tick中更新一个插值后的“渲染用数据”而非直接修改原始数据。多实例时性能下降明显每个控件独立生成顶点Draw Call过多且顶点计算未优化。1. 实现顶点缓存。2. 尝试合并Draw Call对于相同样式的环挑战较大。3. 引入LOD小尺寸控件降低几何精度。4. 考虑是否真的需要这么多环状图或用更简单的Widget如UProgressBar模拟替代部分。在UMG编辑器中预览正常运行时错位控件中心点计算未考虑控件的渲染变换Render Transform或锚点Anchors。在OnPaint中使用AllottedGeometry.GetAbsolutePosition()和GetLocalSize()计算绝对位置和尺寸。确保所有计算基于AllottedGeometry提供的信息而不是假设控件在(0,0)点。5.3 从“能用”到“好用”一些实用技巧提供多种数据绑定方式除了直接操作Segments数组可以暴露一个SetValues函数直接传入一个TArrayfloat和一个TArrayFLinearColor内部自动构建分段。这更符合从数据表或网络获取数据的场景。添加文本标签支持在每段环的中间或外侧自动添加一个UTextBlock子控件来显示该段的标签或数值百分比。这需要在控件内部动态创建和管理这些TextBlock并根据角度计算其位置。设计期预览重写UWidget::GetDesiredSize返回一个固定值并在OnPaint中为编辑器模式 (IsDesignTime()) 绘制一些默认的预览分段这样在UMG编辑器中就能看到大致样子而不是一个空框。导出为静态Sprite如果环状图在运行时不会改变可以考虑提供一个“烘焙”功能将当前状态渲染到一个UTexture2D上之后直接显示这个纹理可以完全省去动态绘制的开销。实现一个UMG环状图控件从原理到实践涉及了UE Slate渲染管线、自定义几何绘制、数据结构设计和蓝图交互等多个层面。它不像调用一个现成API那么简单但带来的灵活性和性能优势是巨大的。当你看到自己实现的控件在游戏中流畅地旋转、变化并清晰地传达出游戏信息时这种成就感正是引擎编程的乐趣所在。最关键的是这个轮子一旦造好就可以在未来的所有UE5项目中复用成为你工具箱里的一件利器。