UE5 UMG自定义环状图控件实现:从原理到高性能绘制
1. 项目概述:为什么要在UE5里折腾环状图?
做游戏UI,尤其是RPG、策略或者模拟经营类,数据可视化是个绕不开的坎。血条、经验条、技能冷却、资源占比……这些信息用传统的进度条或数字堆叠,玩家看久了容易疲劳,信息传达也不够直观。环状图,或者说饼图、扇形图,恰恰是展示比例和构成的神器。想象一下,在游戏里查看角色属性分布、队伍资源分配,或者一个技能的伤害类型构成,一个色彩分明的环状图比一堆数字和长条要友好得多。
UE5的UMG(Unreal Motion Graphics)UI系统功能强大,但官方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, Category="Donut") float Value; // 该段的起始角度(度),0度为正右方,顺时针增加 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Donut") float StartAngle; // 该段覆盖的角度范围(度) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Donut") float AngleSpan; // 该段的颜色 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Donut") FLinearColor Color; // 内部构造函数,方便初始化 FDonutSegment() : Value(0.0f), StartAngle(0.0f), AngleSpan(90.0f), Color(FLinearColor::White) {} };在控件类(例如UDonutChartWidget)中,我们会有一个TArray<FDonutSegment> 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到结束角A+Span),我们需要把它分解成一系列三角形。最简单的方法是使用“三角形扇”,以一个点为圆心,然后依次连接圆弧上的点。
但是,由于我们是空心圆环,圆心位置是“空”的。因此,对于环状图的一段,我们需要用两个同心圆弧上的点来构造四边形,再将每个四边形分解为两个三角形。
具体步骤(对于一段Segment):
- 计算该段的内外圆弧:内圆弧半径 =
InnerRadius, 外圆弧半径 =InnerRadius + Thickness。 - 根据
StartAngle和AngleSpan,以一定的角度步长(例如1度或2度,步长越小越圆滑,但顶点数越多)采样圆弧上的点。- 对于每个采样角度
θ:- 内圆弧点
P_inner = Center + (InnerRadius * cosθ, InnerRadius * sinθ) - 外圆弧点
P_outer = Center + ((InnerRadius+Thickness) * cosθ, (InnerRadius+Thickness) * sinθ)
- 内圆弧点
- 对于每个采样角度
- 将相邻两对采样点
(P_inner_i, P_outer_i)和(P_inner_{i+1}, P_outer_{i+1})组成一个四边形,这个四边形由两个三角形构成:- 三角形1:
(P_inner_i, P_outer_i, P_inner_{i+1}) - 三角形2:
(P_inner_{i+1}, P_outer_i, P_outer_{i+1})
- 三角形1:
这样,遍历所有采样点,我们就为这一段生成了所需的三角形列表。
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); TArray<FSlateVertex> Vertices; TArray<SlateIndex> 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, EditAnywhere):
Segments:分段数据数组,可直接在UMG编辑器细节面板中编辑。InnerRadius和Thickness:控制环的形状。bUseValueAsAngle:切换模式。
函数(BlueprintCallable):
AddSegment(float Value, FLinearColor Color):添加一个新段,如果bUseValueAsAngle为真,则根据Value占总和的比例自动计算角度。RemoveSegment(int32 Index):移除指定段。UpdateSegmentValue(int32 Index, float NewValue):更新某段的值,并触发重绘。ClearAllSegments():清空所有段。RecalculateAnglesFromValues():当bUseValueAsAngle为真时,手动调用此函数根据当前所有段的Value重新计算分配角度。
事件(BlueprintImplementableEvent):
OnSegmentClicked(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.3,Thickness为0.15,bUseValueAsAngle为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函数,直接传入一个TArray<float>和一个TArray<FLinearColor>,内部自动构建分段。这更符合从数据表或网络获取数据的场景。 - 添加文本标签支持:在每段环的中间或外侧,自动添加一个
UTextBlock子控件来显示该段的标签或数值百分比。这需要在控件内部动态创建和管理这些TextBlock,并根据角度计算其位置。 - 设计期预览:重写
UWidget::GetDesiredSize返回一个固定值,并在OnPaint中为编辑器模式 (IsDesignTime()) 绘制一些默认的预览分段,这样在UMG编辑器中就能看到大致样子,而不是一个空框。 - 导出为静态Sprite:如果环状图在运行时不会改变,可以考虑提供一个“烘焙”功能,将当前状态渲染到一个
UTexture2D上,之后直接显示这个纹理,可以完全省去动态绘制的开销。
实现一个UMG环状图控件,从原理到实践,涉及了UE Slate渲染管线、自定义几何绘制、数据结构设计和蓝图交互等多个层面。它不像调用一个现成API那么简单,但带来的灵活性和性能优势是巨大的。当你看到自己实现的控件在游戏中流畅地旋转、变化,并清晰地传达出游戏信息时,这种成就感正是引擎编程的乐趣所在。最关键的是,这个轮子一旦造好,就可以在未来的所有UE5项目中复用,成为你工具箱里的一件利器。