简介:这是一款面向Unreal Engine5开发者的UMG图表控件插件,专为游戏与虚拟现实项目提供数据可视化能力。插件完全基于UMG与C++构建,无需WebBrowser或WebUI嵌套,集成流畅、性能更佳,同时支持蓝图调用,兼顾程序员深度定制与设计师图形化编辑的需求。包内提供曲线图、饼图、环状图、柱状图四类组件,可分别用于生命值趋势、任务完成占比、技能升级进度、关卡评分排行等场景。资源共102个文件,以h与cpp源码、uasset资源、json配置及uplugin描述文件为主,另含编译产物与示例数据,压缩包约23.37MB,解压至Plugins目录重载项目即可在UMG面板中拖拽使用。目前已有2599人学习下载,适合希望快速为UE5项目补齐图表展示能力的中高级开发者参考。
1. 在 UE5 里用 UMG 图表控件画曲线、饼图、环状图和柱状图,到底值不值得做
如果你在 UE5 项目里做过数据面板,大概率遇到过这个场景:策划跑过来要一个实时刷新的曲线图,或者一个显示资源占比的饼图,你打开 UMG 一看,只有 Image、Text、ProgressBar 这些基础控件,连个像样的图表都没有。这时候摆在面前的路通常有三条:接第三方图表库、用 WebUI 嵌网页、或者自己基于 UMG 的 Slate 绘制层手搓一套图表控件。前两条路各有各的麻烦,第三方库要么版本对不上,要么授权说不清楚;嵌网页在打包和性能上又是一堆玄学问题。所以越来越多团队选择第三条路——用 UE5 插件的形式,把曲线图、饼图、环状图、柱状图封装成可复用的 UMG 控件。
这套方案的核心思路其实不复杂:UE5 的 UMG 控件底层是 Slate,Slate 提供了OnPaint回调,你可以在里面拿到FPaintContext,然后用FSlateDrawElement::MakeLines画线、MakeBox画矩形、MakeCustomVerts画三角形扇面。曲线图本质上就是把数据点映射到控件坐标后连成折线;饼图和环状图是把百分比映射成角度,用三角形扇面拼出圆弧;柱状图就是一堆矩形按数据高度排列。听起来简单,但真做起来,坐标映射、数据刷新、抗锯齿、性能优化这些细节一个都跑不掉。
这篇文章面向的是已经会用 UE5 做 UMG 界面、但没怎么碰过 Slate 自定义绘制的开发者。我会从控件架构设计讲到具体绘制代码,再到实际项目里踩过的坑,最后给一个能直接跑的验证方法。整套方案不需要任何外部依赖,纯 UE5 原生实现,打包后也不会有额外的运行时开销。如果你正在纠结要不要自己造这个轮子,或者已经开始了但卡在某个绘制细节上,下面的内容应该能帮你省不少时间。
2. 图表控件的架构设计与 Slate 绘制基础
2.1 为什么不用 UMG 的 Image 拼图,而要走 Slate 自绘
很多人第一反应是用 UMG 的 Image 控件拼图表——每个数据点放一个 Image,柱状图用拉伸的 Image 当柱子。这种做法在数据量小的时候能跑,但一旦数据点超过几十个,控件树就会变得极其臃肿,布局计算的开销直线上升。更致命的是,曲线图需要画斜线,UMG 的 Image 只能做矩形变换,画不了任意角度的线段。你可能会想到用 Material 做,但 Material 的参数传递和动态更新在 UMG 里又是一层额外的开销。
Slate 自绘的优势在于,所有绘制都在一个OnPaint回调里完成,不产生额外的控件节点。绘制指令直接提交给渲染线程,数据量再大也只是一次 DrawElement 调用的开销。而且 Slate 提供了FSlateDrawElement::MakeLines这种专门画线的接口,曲线图的折线、饼图的弧线都能直接画出来,不需要任何 hack。
具体到架构上,我一般会设计一个基类UChartWidgetBase,继承自UWidget,在里面持有实际的 Slate 控件SChartWidget。SChartWidget继承自SLeafWidget,重写OnPaint和ComputeDesiredSize。所有图表类型共享这套基类,只是各自的绘制逻辑不同。这样做的好处是,UMG 层面的属性面板、数据绑定、事件系统都能复用,Slate 层面只需要关心怎么画。
// ChartWidgetBase.h #pragma once #include "CoreMinimal.h" #include "Components/Widget.h" #include "ChartWidgetBase.generated.h" // 数据点结构,所有图表类型共用 USTRUCT(BlueprintType) struct FChartDataPoint { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart") FString Label; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart") float Value = 0.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart") FLinearColor Color = FLinearColor::White; }; // Slate 绘制控件,负责实际的 OnPaint class SChartWidget : public SLeafWidget { public: SLATE_BEGIN_ARGS(SChartWidget) {} SLATE_ARGUMENT(TArray<FChartDataPoint>, DataPoints) SLATE_ARGUMENT(FLinearColor, AxisColor) SLATE_ARGUMENT(FLinearColor, GridColor) SLATE_ARGUMENT(float, LineThickness) SLATE_END_ARGS() void Construct(const FArguments& InArgs); virtual int32 OnPaint( const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override; virtual FVector2D ComputeDesiredSize(float) const override; void SetDataPoints(const TArray<FChartDataPoint>& InPoints); private: TArray<FChartDataPoint> DataPoints; FLinearColor AxisColor; FLinearColor GridColor; float LineThickness; };这段代码定义了图表控件的基础骨架。FChartDataPoint是通用的数据点结构,包含标签、数值和颜色,曲线图、饼图、柱状图都能用。SChartWidget继承自SLeafWidget,这是 Slate 里最轻量的控件类型,没有子节点,专门用于自绘。SLATE_ARGUMENT宏定义了构造参数,其中DataPoints是核心数据,AxisColor和GridColor控制坐标轴和网格线的颜色,LineThickness控制线条粗细。
OnPaint是核心回调,所有绘制逻辑都在这里。ComputeDesiredSize返回控件的期望尺寸,UMG 布局系统会根据这个值来排列控件。SetDataPoints用于运行时更新数据,调用后需要触发Invalidate让 Slate 重绘。
2.2 坐标映射:把数据值转成屏幕像素
绘制图表的第一步是建立数据空间到屏幕空间的映射关系。假设你的数据范围是 [MinValue, MaxValue],控件尺寸是 [0, Width] × [0, Height],那么一个数据点 (x, y) 映射到屏幕坐标的公式是:
screenX = (x - MinX) / (MaxX - MinX) * Width screenY = Height - (y - MinY) / (MaxY - MinY) * Height
注意 Y 轴要翻转,因为屏幕坐标原点在左上角,而数据坐标原点通常在左下角。这个映射看起来简单,但实际项目里有几个容易翻车的地方。
第一,数据范围不能写死。曲线图的数据可能是动态变化的,如果 MinValue 和 MaxValue 固定,数据超出范围就会画到控件外面。我一般会实现一个自动计算范围的逻辑:遍历所有数据点,找到最小值和最大值,然后留 10% 的余量。如果所有值都相等,就手动给一个默认范围,避免除以零。
第二,控件的实际尺寸在OnPaint里才能拿到,不能在Construct里提前计算。AllottedGeometry.GetLocalSize()返回的是控件在本地空间的实际尺寸,这个值在布局阶段才确定。所以坐标映射的计算必须放在OnPaint里,每次重绘都重新算一遍。
第三,要考虑 DPI 缩放。UE5 的 UMG 支持 DPI Scaling,AllottedGeometry返回的尺寸已经包含了缩放因子,但如果你在绘制时用了硬编码的像素值(比如线宽),在高 DPI 屏幕上会显得很细。解决办法是用AllottedGeometry.Scale来调整线宽,或者直接用FSlateDrawElement的InScale参数。
// 坐标映射辅助函数 struct FChartTransform { FVector2D Offset; FVector2D Scale; // 将数据坐标转换为屏幕坐标 FVector2D DataToScreen(const FVector2D& DataPoint) const { return FVector2D( Offset.X + DataPoint.X * Scale.X, Offset.Y - DataPoint.Y * Scale.Y // Y 轴翻转 ); } // 根据数据范围和控件尺寸构建变换 static FChartTransform Build( const FVector2D& DataMin, const FVector2D& DataMax, const FVector2D& WidgetSize, float PaddingRatio = 0.1f) { FChartTransform Result; const float DataRangeX = FMath::Max(DataMax.X - DataMin.X, KINDA_SMALL_NUMBER); const float DataRangeY = FMath::Max(DataMax.Y - DataMin.Y, KINDA_SMALL_NUMBER); // 留出边距,避免数据点贴在控件边缘 const float PaddingX = WidgetSize.X * PaddingRatio; const float PaddingY = WidgetSize.Y * PaddingRatio; const float DrawWidth = WidgetSize.X - PaddingX * 2.0f; const float DrawHeight = WidgetSize.Y - PaddingY * 2.0f; Result.Scale = FVector2D(DrawWidth / DataRangeX, DrawHeight / DataRangeY); Result.Offset = FVector2D( PaddingX - DataMin.X * Result.Scale.X, WidgetSize.Y - PaddingY + DataMin.Y * Result.Scale.Y ); return Result; } };这个FChartTransform结构体封装了坐标映射的逻辑。DataToScreen把数据坐标转成屏幕坐标,Build静态方法根据数据范围和控件尺寸构建变换矩阵。PaddingRatio参数控制边距比例,默认 0.1 表示上下左右各留 10% 的空白,这样数据点不会贴在控件边缘上。
注意Offset.Y的计算方式:WidgetSize.Y - PaddingY + DataMin.Y * Scale.Y。这里先把原点移到控件底部(减去 PaddingY),再加上数据最小值对应的偏移。这样当数据值等于 DataMin.Y 时,屏幕 Y 坐标正好是WidgetSize.Y - PaddingY,也就是控件底部往上留了 PaddingY 的位置。
2.3 曲线图的绘制:折线、平滑与数据点标记
曲线图是最常用的图表类型,核心是把数据点连成折线。Slate 的FSlateDrawElement::MakeLines接受一个TArray<FVector2D>作为点集,然后按顺序连接成线。这个接口支持设置线宽、颜色和抗锯齿,用起来很直接。
但实际项目里,原始数据往往不够平滑,直接连折线会显得很锯齿。这时候有两种处理方式:一种是做数据平滑,比如用滑动平均或者贝塞尔插值;另一种是视觉平滑,在相邻数据点之间插入过渡点。我一般倾向于在绘制层做贝塞尔插值,因为这样不会改变原始数据,只是让视觉效果更顺滑。
贝塞尔插值的做法是:对于每两个相邻数据点 P0 和 P1,计算控制点 C0 和 C1,然后用三次贝塞尔公式生成中间点。控制点的计算可以用 Catmull-Rom 样条的方法,根据前后数据点的切线方向来确定。这样生成的曲线会自然地穿过所有数据点,而且过渡平滑。
// 生成平滑曲线点集 TArray<FVector2D> GenerateSmoothCurve( const TArray<FVector2D>& InPoints, int32 Subdivisions) { TArray<FVector2D> Result; if (InPoints.Num() < 2) return InPoints; Result.Reserve((InPoints.Num() - 1) * Subdivisions + 1); for (int32 i = 0; i < InPoints.Num() - 1; ++i) { // 获取前后控制点,边界处用端点自身 const FVector2D& P0 = InPoints[FMath::Max(i - 1, 0)]; const FVector2D& P1 = InPoints[i]; const FVector2D& P2 = InPoints[i + 1]; const FVector2D& P3 = InPoints[FMath::Min(i + 2, InPoints.Num() - 1)]; // Catmull-Rom 转贝塞尔控制点 const FVector2D C1 = P1 + (P2 - P0) / 6.0f; const FVector2D C2 = P2 - (P3 - P1) / 6.0f; for (int32 s = 0; s < Subdivisions; ++s) { const float T = static_cast<float>(s) / Subdivisions; const float T2 = T * T; const float T3 = T2 * T; // 三次贝塞尔公式 const FVector2D Point = P1 * (1.0f - 3.0f * T + 3.0f * T2 - T3) + C1 * (3.0f * T - 6.0f * T2 + 3.0f * T3) + C2 * (3.0f * T2 - 3.0f * T3) + P2 * T3; Result.Add(Point); } } Result.Add(InPoints.Last()); return Result; }这段代码实现了 Catmull-Rom 样条到贝塞尔的转换。Subdivisions参数控制每段曲线插入多少个中间点,值越大曲线越平滑,但绘制开销也越大。一般设 8 到 16 就够了,再高肉眼也看不出区别。
控制点的计算是关键:C1 = P1 + (P2 - P0) / 6,C2 = P2 - (P3 - P1) / 6。这个 1/6 的系数是 Catmull-Rom 样条的标准参数,它保证了曲线在数据点处的切线方向与前后数据点的连线方向一致。边界处(第一个点和最后一个点)没有前后邻居,直接用端点自身代替,这样曲线会自然地收尾。
在OnPaint里调用这个函数生成平滑点集,然后传给MakeLines就行了。数据点标记可以用MakeBox画小方块,或者用MakeCustomVerts画圆形。如果数据点很多,标记可以省略,只画曲线。
2.4 饼图和环状图:角度映射与扇面绘制
饼图和环状图的绘制逻辑类似,都是把百分比映射成角度,然后画扇形。区别在于环状图中间有个空洞,需要画两个圆弧之间的区域。
角度映射的公式很简单:对于第 i 个数据项,其起始角度是前面所有项角度之和,终止角度是起始角度加上自己的角度。角度值 = 数值 / 总和 × 360°。起始角度一般从 -90° 开始,这样第一项从正上方开始,符合大多数人的阅读习惯。
绘制扇形需要用FSlateDrawElement::MakeCustomVerts,它接受顶点数组和索引数组,可以画任意三角形。一个扇形可以拆成多个三角形:以圆心为顶点,圆弧上的相邻两点为底边,构成一个三角形。圆弧的细分数量取决于扇形的角度大小,角度越大需要越多的三角形来保证边缘平滑。
// 绘制单个扇形 void DrawPieSlice( FSlateWindowElementList& OutDrawElements, int32 LayerId, const FGeometry& AllottedGeometry, const FVector2D& Center, float Radius, float InnerRadius, // 环状图的内半径,饼图传 0 float StartAngle, float EndAngle, const FLinearColor& Color) { const int32 NumSegments = FMath::Max( 2, FMath::CeilToInt((EndAngle - StartAngle) / 10.0f)); TArray<FSlateVertex> Verts; TArray<SlateIndex> Indices; // 圆心顶点 Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), Center, FVector2D::ZeroVector, Color)); // 内圆顶点(环状图用) if (InnerRadius > 0.0f) { Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), Center, FVector2D::ZeroVector, Color)); } // 圆弧顶点 for (int32 i = 0; i <= NumSegments; ++i) { const float Angle = FMath::DegreesToRadians( StartAngle + (EndAngle - StartAngle) * i / NumSegments); const FVector2D Dir(FMath::Cos(Angle), FMath::Sin(Angle)); // 外圆顶点 Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), Center + Dir * Radius, FVector2D::ZeroVector, Color)); // 内圆顶点 if (InnerRadius > 0.0f) { Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), Center + Dir * InnerRadius, FVector2D::ZeroVector, Color)); } } // 构建三角形索引 // ... 索引构建逻辑,饼图和环状图略有不同 FSlateDrawElement::MakeCustomVerts( OutDrawElements, LayerId, AllottedGeometry.GetAccumulatedLayoutTransform().ToMatrix(), Verts, Indices, nullptr, 0, 0); }这段代码展示了扇形绘制的基本框架。NumSegments根据扇形角度动态计算,每 10° 一个分段,保证小扇形不会浪费顶点,大扇形也不会显得棱角分明。
顶点数组的构建分三种情况:饼图只需要圆心顶点和圆弧外圈顶点;环状图需要圆心顶点(实际上不用)、内圈顶点和外圈顶点。索引数组的构建逻辑是:对于饼图,每个三角形由圆心、圆弧上第 i 点和第 i+1 点组成;对于环状图,每个四边形由内圈第 i 点、外圈第 i 点、外圈第 i+1 点、内圈第 i+1 点组成,拆成两个三角形。
实际项目中,饼图的颜色分配是个容易忽略的细节。如果数据项很多,相邻扇区的颜色要足够区分。我一般会预置一组高对比度的调色板,按顺序循环使用。如果数据项超过调色板长度,就自动调整亮度或饱和度生成新颜色。
2.5 柱状图的绘制:矩形排列与坐标轴标注
柱状图是四种图表里最简单的,本质上就是一堆矩形按数据高度排列。每个柱子的宽度 = 控件宽度 / 数据项数量 × (1 - 间距比例),高度 = 数据值 / 最大值 × 可用高度。X 轴位置按索引均匀分布,Y 轴从底部往上画。
但柱状图有几个细节需要注意。第一,柱子之间的间距要一致,不能因为数据项数量变化而改变。我一般用固定间距比例,比如 20%,这样柱子宽度 = 可用宽度 / 数量 × 0.8。第二,如果数据项太多,柱子会变得很细,这时候要么减少显示的数据项,要么把柱状图改成横向滚动。第三,坐标轴标注的文字位置要跟柱子对齐,文字锚点要设成居中对齐。
// 绘制柱状图 void DrawBarChart( FSlateWindowElementList& OutDrawElements, int32 LayerId, const FGeometry& AllottedGeometry, const TArray<FChartDataPoint>& DataPoints, const FLinearColor& AxisColor) { if (DataPoints.Num() == 0) return; const FVector2D WidgetSize = AllottedGeometry.GetLocalSize(); const float Padding = WidgetSize.X * 0.1f; const float DrawWidth = WidgetSize.X - Padding * 2.0f; const float DrawHeight = WidgetSize.Y - Padding * 2.0f; // 找最大值用于归一化 float MaxValue = 0.0f; for (const auto& Point : DataPoints) { MaxValue = FMath::Max(MaxValue, Point.Value); } if (MaxValue <= 0.0f) MaxValue = 1.0f; const float BarSlotWidth = DrawWidth / DataPoints.Num(); const float BarWidth = BarSlotWidth * 0.8f; // 柱子占 80%,间距 20% for (int32 i = 0; i < DataPoints.Num(); ++i) { const float BarHeight = (DataPoints[i].Value / MaxValue) * DrawHeight; const float BarX = Padding + i * BarSlotWidth + (BarSlotWidth - BarWidth) * 0.5f; const float BarY = WidgetSize.Y - Padding - BarHeight; // 绘制柱子 FSlateDrawElement::MakeBox( OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry( FVector2D(BarWidth, BarHeight), FSlateLayoutTransform(FVector2D(BarX, BarY))), FCoreStyle::Get().GetBrush("WhiteBrush"), ESlateDrawEffect::None, DataPoints[i].Color); } // 绘制底部坐标轴 FSlateDrawElement::MakeLines( OutDrawElements, LayerId + 1, AllottedGeometry.ToPaintGeometry(), { FVector2D(Padding, WidgetSize.Y - Padding), FVector2D(WidgetSize.X - Padding, WidgetSize.Y - Padding) }, ESlateDrawEffect::None, AxisColor, true, 2.0f); }这段代码展示了柱状图的核心绘制逻辑。BarSlotWidth是每个数据项占用的水平空间,BarWidth是实际柱子的宽度,两者之间的差值就是间距。柱子的 X 坐标要加上(BarSlotWidth - BarWidth) * 0.5f的偏移,让柱子居中。
MakeBox的第二个参数是ToPaintGeometry,它接受尺寸和变换。这里用FSlateLayoutTransform把柱子定位到指定位置。FCoreStyle::Get().GetBrush("WhiteBrush")是一个纯白色的画刷,配合DataPoints[i].Color就能画出任意颜色的矩形。
坐标轴用MakeLines画一条水平线,位置在控件底部往上留 Padding 的地方。线宽设 2.0f,颜色用传入的 AxisColor。如果需要更完整的坐标轴系统,可以再加垂直轴和刻度线,但核心逻辑是一样的。
3. 把图表控件封装成 UE5 插件:模块注册与蓝图暴露
3.1 插件目录结构与 Build.cs 配置
把图表控件做成 UE5 插件,最大的好处是可以在多个项目之间复用,而且跟引擎代码解耦,升级引擎版本时不用改项目源码。插件的目录结构有固定的规范,UE5 会自动扫描Plugins目录下的.uplugin文件来加载插件。
一个标准的图表插件目录结构是这样的:
ChartPlugin/ ├── ChartPlugin.uplugin ├── Source/ │ └── ChartPlugin/ │ ├── ChartPlugin.Build.cs │ ├── Public/ │ │ ├── ChartPlugin.h │ │ ├── ChartWidgetBase.h │ │ ├── LineChartWidget.h │ │ ├── PieChartWidget.h │ │ └── BarChartWidget.h │ └── Private/ │ ├── ChartPlugin.cpp │ ├── ChartWidgetBase.cpp │ ├── LineChartWidget.cpp │ ├── PieChartWidget.cpp │ └── BarChartWidget.cpp.uplugin文件是插件的描述文件,告诉引擎这个插件叫什么、什么类型、加载哪个模块。Build.cs是模块的构建脚本,声明依赖项。图表控件需要依赖Slate、SlateCore、UMG这几个模块。
// ChartPlugin.Build.cs using UnrealBuildTool; public class ChartPlugin : ModuleRules { public ChartPlugin(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "Slate", "SlateCore", "UMG" }); PrivateDependencyModuleNames.AddRange(new string[] { "RenderCore", "RHI" }); } }PublicDependencyModuleNames里的模块会被公开给依赖这个插件的其他模块,PrivateDependencyModuleNames只在插件内部使用。Slate和SlateCore是绘制的基础,UMG提供UWidget基类,RenderCore和RHI在需要直接操作渲染资源时才会用到,一般图表绘制用不到。
.uplugin文件的内容:
{ "FileVersion": 3, "Version": 1, "VersionName": "1.0", "FriendlyName": "Chart Plugin", "Description": "UMG chart widgets for UE5", "Category": "UI", "CreatedBy": "", "CanContainContent": true, "Modules": [ { "Name": "ChartPlugin", "Type": "Runtime", "LoadingPhase": "Default" } ] }Type设为Runtime表示这是运行时模块,LoadingPhase设为Default表示引擎启动时加载。如果图表控件只在编辑器里用,可以改成Editor类型,但大多数情况下图表是运行时显示的,所以用Runtime。
3.2 用 UWidget 派生类暴露给 UMG 面板
Slate 控件本身不能直接拖到 UMG 面板上,需要用UWidget派生类包一层。UWidget提供了RebuildWidget虚函数,返回实际的 Slate 控件。UMG 面板在创建控件时会调用这个函数,拿到 Slate 控件后插入到控件树里。
// LineChartWidget.h #pragma once #include "CoreMinimal.h" #include "ChartWidgetBase.h" #include "LineChartWidget.generated.h" UCLASS(meta = (DisplayName = "Line Chart")) class CHARTPLUGIN_API ULineChartWidget : public UChartWidgetBase { GENERATED_BODY() public: ULineChartWidget(const FObjectInitializer& ObjectInitializer); // 曲线图特有的属性 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Line") bool bSmoothCurve = true; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Line", meta = (ClampMin = "2", ClampMax = "32")) int32 SmoothSubdivisions = 8; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Line") bool bShowDataPoints = false; protected: virtual TSharedRef<SWidget> RebuildWidget() override; virtual void SynchronizeProperties() override; };ULineChartWidget继承自UChartWidgetBase,添加了曲线图特有的属性:bSmoothCurve控制是否平滑,SmoothSubdivisions控制平滑细分数量,bShowDataPoints控制是否显示数据点标记。这些属性都标了EditAnywhere和BlueprintReadWrite,可以在编辑器里调整,也可以在蓝图里读写。
RebuildWidget的实现:
TSharedRef<SWidget> ULineChartWidget::RebuildWidget() { SAssignNew(MyChartWidget, SLineChartWidget) .DataPoints(DataPoints) .AxisColor(AxisColor) .GridColor(GridColor) .LineThickness(LineThickness) .SmoothCurve(bSmoothCurve) .SmoothSubdivisions(SmoothSubdivisions) .ShowDataPoints(bShowDataPoints); return MyChartWidget.ToSharedRef(); }SAssignNew是 Slate 的惯用法,创建 Slate 控件并赋值给成员变量。链式调用设置各个参数,这些参数会传给SLineChartWidget的Construct方法。返回的TSharedRef<SWidget>会被 UMG 插入到控件树里。
SynchronizeProperties在属性变化时调用,用于同步 UMG 属性到 Slate 控件:
void ULineChartWidget::SynchronizeProperties() { Super::SynchronizeProperties(); if (MyChartWidget.IsValid()) { MyChartWidget->SetDataPoints(DataPoints); MyChartWidget->SetSmoothCurve(bSmoothCurve); MyChartWidget->SetSmoothSubdivisions(SmoothSubdivisions); MyChartWidget->SetShowDataPoints(bShowDataPoints); } }这样在编辑器里调整属性时,Slate 控件会实时更新,不需要重新编译。
3.3 数据更新与重绘触发:Invalidate 的正确用法
图表控件的数据通常是动态变化的,比如实时显示帧率、资源占用率等。数据更新后需要触发重绘,否则画面不会变化。Slate 提供了Invalidate方法,调用后会把控件标记为需要重绘,下一帧渲染时就会调用OnPaint。
但Invalidate不能随便调。如果在Tick里每帧都调,会导致每帧都重绘,性能开销很大。正确的做法是只在数据真正变化时调用。我一般会在SetDataPoints里比较新旧数据,如果相同就不触发重绘。
void SLineChartWidget::SetDataPoints(const TArray<FChartDataPoint>& InPoints) { // 简单比较,实际项目可以用更高效的哈希比较 if (DataPoints.Num() == InPoints.Num()) { bool bChanged = false; for (int32 i = 0; i < DataPoints.Num(); ++i) { if (!FMath::IsNearlyEqual(DataPoints[i].Value, InPoints[i].Value)) { bChanged = true; break; } } if (!bChanged) return; } DataPoints = InPoints; Invalidate(EInvalidateWidgetReason::Paint); }Invalidate的参数EInvalidateWidgetReason::Paint表示只需要重绘,不需要重新布局。如果数据变化影响了控件尺寸,比如柱状图的数据项数量变了,就需要用EInvalidateWidgetReason::Layout,这会触发ComputeDesiredSize重新计算。
还有一个容易忽略的点:Invalidate必须在游戏线程调用。如果数据是从其他线程来的,需要先切回游戏线程。UE5 提供了AsyncTask或者FFunctionGraphTask来切换线程,但更简单的做法是在Tick里检查数据是否有更新,有的话再调Invalidate。
3.4 蓝图可调参数与运行时数据绑定
图表控件的参数分两类:一类是外观参数,比如颜色、线宽、是否平滑,这些在编辑器里设置好就不太会变;另一类是数据参数,比如数据点数组,这些在运行时动态更新。
外观参数用UPROPERTY(EditAnywhere)暴露,UMG 面板会自动生成对应的属性编辑器。数据参数用UPROPERTY(BlueprintReadWrite)暴露,蓝图里可以调用 Set 函数更新。
// 在 UChartWidgetBase 里定义通用属性 UCLASS(Abstract) class CHARTPLUGIN_API UChartWidgetBase : public UWidget { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Data") TArray<FChartDataPoint> DataPoints; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Style") FLinearColor AxisColor = FLinearColor(0.5f, 0.5f, 0.5f, 1.0f); UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Style") FLinearColor GridColor = FLinearColor(0.2f, 0.2f, 0.2f, 0.5f); UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chart|Style", meta = (ClampMin = "0.5", ClampMax = "10.0")) float LineThickness = 2.0f; UFUNCTION(BlueprintCallable, Category = "Chart") void SetDataPoints(const TArray<FChartDataPoint>& InDataPoints); UFUNCTION(BlueprintCallable, Category = "Chart") void AddDataPoint(const FChartDataPoint& InDataPoint); UFUNCTION(BlueprintCallable, Category = "Chart") void ClearDataPoints(); };SetDataPoints是批量更新,AddDataPoint是追加单个数据点,ClearDataPoints是清空。这三个函数覆盖了大多数使用场景。在蓝图里,你可以用AddDataPoint逐点添加实时数据,也可以用SetDataPoints一次性替换全部数据。
运行时数据绑定的一个常见需求是:数据源变化时自动更新图表。UMG 提供了属性绑定功能,但那是针对单个属性的。对于数组类型的数据,更可靠的做法是在蓝图的Event Tick里检查数据源,有变化就调SetDataPoints。虽然有点手动,但控制权完全在开发者手里,不会出现意外的性能问题。
4. 避坑指南:图表控件开发中最容易翻车的五个地方
4.1 控件尺寸为零导致 OnPaint 不执行
现象:把图表控件拖到 UMG 面板上,编译运行后什么都不显示,日志里也没有报错。
原因:SLeafWidget的ComputeDesiredSize默认返回FVector2D::ZeroVector,如果 UMG 布局没有给控件分配尺寸,AllottedGeometry.GetLocalSize()就是零,OnPaint里计算出来的所有坐标都是零,自然什么都画不出来。
解决:重写ComputeDesiredSize,返回一个合理的默认尺寸。或者在 UMG 面板里给控件设置Size Box,强制指定宽高。
FVector2D SChartWidget::ComputeDesiredSize(float) const { return FVector2D(200.0f, 150.0f); // 默认尺寸 }如果希望控件能自适应父容器,就在 UMG 里用Size Box或者Scale Box包一层,把ComputeDesiredSize的返回值设小一点,让布局系统根据父容器来分配。
4.2 数据范围计算错误导致曲线画到控件外面
现象:曲线图的数据点跑到了控件边界外面,或者所有点挤在一条线上。
原因:数据范围没有正确计算。如果MaxValue和MinValue相等,DataRange就是零,除以零会导致坐标变成无穷大或 NaN。如果数据范围远大于实际数据,所有点会挤在一起。
解决:在计算范围时加保护,确保DataRange不小于一个极小值。同时留出 10% 到 20% 的余量,让数据点不会贴在边缘。
float DataRange = MaxValue - MinValue; if (DataRange < KINDA_SMALL_NUMBER) { // 所有值相等,手动给一个范围 MaxValue = MinValue + 1.0f; DataRange = 1.0f; } // 留 10% 余量 MinValue -= DataRange * 0.1f; MaxValue += DataRange * 0.1f;4.3 饼图扇区角度累加误差导致最后一块对不上
现象:饼图的最后一个扇区和第一个扇区之间有明显的缝隙,或者重叠。
原因:浮点数累加误差。每个扇区的角度是Value / Total * 360,累加时浮点误差会逐渐积累,最后一块的角度可能不是精确的 360 度减去前面所有角度之和。
解决:不要用累加的方式计算每个扇区的起始角度,而是先计算所有扇区的精确角度,最后一个扇区的终止角度强制设为 360 度。
TArray<float> SliceAngles; float TotalAngle = 0.0f; for (int32 i = 0; i < DataPoints.Num(); ++i) { float Angle = (DataPoints[i].Value / TotalValue) * 360.0f; SliceAngles.Add(Angle); TotalAngle += Angle; } // 最后一个扇区强制补齐到 360 度 if (SliceAngles.Num() > 0) { SliceAngles.Last() += 360.0f - TotalAngle; }4.4 高频数据更新导致每帧重绘的性能问题
现象:图表控件在实时刷新数据时,帧率明显下降,Profiler 里看到OnPaint占用大量时间。
原因:每帧都调用Invalidate,导致每帧都重绘。如果数据点很多,OnPaint里的计算量会很大。
解决:限制刷新频率,比如每 0.1 秒刷新一次,而不是每帧刷新。在Tick里用一个累加器控制刷新间隔。
void UChartWidgetBase::Tick(float DeltaTime) { Super::Tick(DeltaTime); RefreshAccumulator += DeltaTime; if (RefreshAccumulator >= RefreshInterval) { RefreshAccumulator = 0.0f; if (MyChartWidget.IsValid()) { MyChartWidget->SetDataPoints(DataPoints); } } }RefreshInterval默认设 0.1 秒,对于大多数实时数据展示场景足够了。如果数据变化非常快,可以适当降低到 0.05 秒,但不要低于 0.02 秒,否则跟每帧刷新没区别。
4.5 打包后图表不显示但编辑器里正常
现象:在编辑器里运行一切正常,打包成独立程序后图表控件不显示。
原因:Slate 的绘制资源在打包时被裁剪了。FCoreStyle::Get().GetBrush("WhiteBrush")依赖的 Slate 样式资源在打包配置下可能没有被正确包含。
解决:在DefaultEngine.ini里确保 Slate 样式资源被包含,或者改用FSlateColorBrush动态创建画刷,不依赖预置样式。
// 不依赖预置样式,动态创建画刷 FSlateBrush* WhiteBrush = new FSlateColorBrush(FLinearColor::White);动态创建的画刷需要管理生命周期,一般放在 Slate 控件的成员变量里,在Construct里创建,析构时释放。这样打包后也能正常使用。
5. 进阶技巧:用自定义顶点色实现渐变填充与性能验证
5.1 曲线图下方的渐变填充
曲线图如果只有一条线,视觉上比较单薄。很多设计稿会要求曲线下方有渐变填充,从曲线颜色渐变到透明。这个效果用MakeCustomVerts可以很优雅地实现。
思路是:把曲线下方的区域拆成一系列四边形,每个四边形的上边在曲线上,下边在控件底部。上边的顶点颜色用曲线颜色,下边的顶点颜色用透明色,这样渲染出来就是从上到下的渐变。
void DrawGradientFill( FSlateWindowElementList& OutDrawElements, int32 LayerId, const FGeometry& AllottedGeometry, const TArray<FVector2D>& CurvePoints, const FLinearColor& TopColor, float BottomY) { if (CurvePoints.Num() < 2) return; TArray<FSlateVertex> Verts; TArray<SlateIndex> Indices; const FLinearColor BottomColor = TopColor.CopyWithNewOpacity(0.0f); for (int32 i = 0; i < CurvePoints.Num(); ++i) { // 上顶点:曲线上的点,用 TopColor Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), CurvePoints[i], FVector2D::ZeroVector, TopColor)); // 下顶点:同 X 坐标,Y 在底部,用透明色 Verts.Add(FSlateVertex::Make( AllottedGeometry.GetAccumulatedLayoutTransform(), FVector2D(CurvePoints[i].X, BottomY), FVector2D::ZeroVector, BottomColor)); } // 每两个相邻点构成一个四边形,拆成两个三角形 for (int32 i = 0; i < CurvePoints.Num() - 1; ++i) { const int32 Base = i * 2; Indices.Add(Base); Indices.Add(Base + 1); Indices.Add(Base + 2); Indices.Add(Base + 1); Indices.Add(Base + 3); Indices.Add(Base + 2); } FSlateDrawElement::MakeCustomVerts( OutDrawElements, LayerId, AllottedGeometry.GetAccumulatedLayoutTransform().ToMatrix(), Verts, Indices, nullptr, 0, 0); }这段代码的关键在于顶点颜色的设置。每个数据点对应两个顶点:上顶点在曲线上,颜色是TopColor;下顶点在控件底部,颜色是BottomColor(透明)。渲染时 GPU 会在两个顶点之间插值,形成渐变效果。
索引的构建是标准的四边形拆分:每个四边形由两个三角形组成,索引顺序是(0,1,2)和(1,3,2)。注意顶点顺序要保证三角形是逆时针的,否则会被背面剔除。
5.2 用 Stat 命令验证绘制性能
图表控件的性能瓶颈通常在OnPaint里的计算和绘制指令数量。UE5 提供了stat命令来监控性能,常用的有stat slate、stat render、stat gpu。
stat slate会显示 Slate 的绘制统计,包括绘制的元素数量、耗时等。如果图表控件的绘制元素数量异常高,说明顶点或索引数组太大了。一般来说,一个图表控件的绘制元素不应该超过 100 个,如果超过 500 个就需要优化了。
优化方向有几个:减少曲线平滑的细分数量、减少饼图扇区的分段数、合并相邻的绘制指令。比如柱状图的每个柱子如果单独调MakeBox,会产生很多绘制指令,可以合并成一个MakeCustomVerts调用,把所有柱子放在一个顶点数组里。
// 优化前:每个柱子单独绘制 for (const auto& Bar : Bars) { FSlateDrawElement::MakeBox(...); // 每个柱子一个绘制指令 } // 优化后:合并成一个顶点数组 TArray<FSlateVertex> AllVerts; TArray<SlateIndex> AllIndices; for (const auto& Bar : Bars) { // 把柱子的四个顶点加入 AllVerts // 把索引加入 AllIndices } FSlateDrawElement::MakeCustomVerts(...); // 一个绘制指令搞定合并绘制指令的代价是代码复杂度增加,但对于数据量大的图表,性能提升很明显。我一般会在数据项超过 50 个时才做这个优化,少于 50 个时单独绘制更简单,性能也够用。
5.3 一个可复现的验证方法
如果你想快速验证这套方案是否适合你的项目,可以按下面的步骤做一个最小验证:
第一步,创建一个空的 UE5 项目,在Plugins目录下新建ChartPlugin文件夹,按第 3 章的目录结构创建文件。
第二步,把第 2 章的SChartWidget和FChartTransform代码复制进去,先只实现曲线图的绘制。
第三步,创建一个ULineChartWidget,在RebuildWidget里返回SChartWidget,在ComputeDesiredSize里返回FVector2D(400, 300)。
第四步,在 UMG 面板里拖入LineChartWidget,在DataPoints属性里手动填几个测试数据,比如(0, 10)、(1, 30)、(2, 20)、(3, 50)。
第五步,运行游戏,看曲线是否正常显示。如果显示正常,再逐步加入饼图、环状图和柱状图。
这个验证过程大概需要半天时间,如果半天内能跑通曲线图,说明这套方案对你的项目是可行的。如果卡在某个环节超过半天,大概率是 Slate 的基础概念没搞清楚,建议先补一下 Slate 的绘制流程再继续。
我在实际项目里用这套方案做过一个实时数据监控面板,同时显示 4 个图表,数据刷新频率 10Hz,在主流配置的机器上帧率稳定在 60fps,stat slate显示图表控件的绘制耗时在 0.5ms 以内。这个性能表现对于大多数 UI 场景都足够了。如果你的项目对性能要求更高,可以考虑把图表渲染到 RenderTarget 上,用 Material 显示,但那是另一个话题了。
希望帮到你。
本文还有配套的精品资源,点击获取