1. 项目概述为什么要在UE5里“造轮子”画饼图做游戏尤其是带有复杂UI和数值反馈的游戏数据可视化是绕不开的一环。排行榜、角色属性、资源分布、任务进度……这些信息如果只用干巴巴的数字和文字堆砌玩家的认知负担会很重。一张直观的图表比如饼图能瞬间让玩家理解“我的资源都花在哪了”、“这个Boss的弱点属性占比如何”。UE5的UMGUnreal Motion Graphics系统功能强大从按钮、进度条到复杂的列表视图都能做但偏偏官方没有提供一个现成的“饼图”控件。这就是我们今天要解决的问题在UE5中从零开始实现一个功能完整、性能可靠、且易于在蓝图中使用的UMG饼图控件。你可能会问为什么不直接用Slate画或者用第三方插件原因很简单可控性和集成度。用Slate直接绘制虽然灵活但脱离了UMG的便捷编辑和事件绑定体系用第三方插件可能面临兼容性、定制化程度不足或额外成本的问题。自己实现一个意味着你可以完全掌控它的外观、交互逻辑和数据绑定方式并且能无缝集成到你的项目UI工作流中。这不仅仅是“画个圆”而是构建一个可复用的数据可视化工具。2. 核心设计思路从数据到屏幕的渲染管线在动手写代码之前我们必须想清楚这个控件的工作流程。一个饼图控件的生命周期大致可以分为四个阶段数据准备 - 几何构建 - 材质渲染 - 交互反馈。UMG控件本质上是Slate控件的包装而Slate的渲染依赖于材质和顶点数据。因此我们的核心思路是使用UMG自定义控件的画布OnPaint事件通过动态计算顶点数据调用Slate的绘制指令来渲染一个由多个三角形扇组成的“饼”并为每一块“饼”应用独立的颜色和可能的交互状态。2.1 技术选型为什么是SLeafWidget和OnPaint在UE5的Slate框架中有多种自定义控件的方式。对于饼图这种需要完全自定义绘制的控件继承自SLeafWidget是最直接和高效的选择。SLeafWidget是一个叶子节点控件它不包含任何子组件只负责渲染自己。我们需要重写它的三个关键虚函数ComputeDesiredSize: 告诉布局系统这个控件需要多大空间。对于饼图我们通常希望它是正方形的大小可以由一个属性如Size来控制。OnPaint: 这是核心的渲染函数。在这里我们将计算饼图每一块的顶点位置、颜色并调用FSlateDrawElement::MakeCustomVerts或一系列FSlateDrawElement::MakeBox来绘制。考虑到饼图是连续的、基于三角扇的图形使用MakeCustomVerts来提交自定义顶点和索引数据是最合适的。FVector2D计算: 我们需要一个函数将角度弧度转换为圆上的点坐标。FVector2D( FMath::Cos(Angle), FMath::Sin(Angle) )是基本操作。为什么不直接用UMG的UWidget蓝图类包装一个SImage然后动态改材质参数对于简单的、分块较少的饼图这或许可行比如用径向渐变材质通过参数控制每块的比例。但当分块数量动态变化、需要精确的鼠标交互如悬停、点击某一块时材质方案会变得异常复杂且性能不佳。而顶点方案则天生适合处理离散的几何块和精确的命中检测。2.2 数据结构设计如何组织饼图数据一个饼图的数据模型至少需要两部分数值和标签/颜色。在C中我们可以设计一个简单的结构体USTRUCT(BlueprintType) struct FPieChartSegment { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart) float Value 0.0f; // 该扇区的数值 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart) FLinearColor Color FLinearColor::White; // 扇区颜色 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart) FText Label; // 扇区标签用于图例或提示 // 内部使用的计算后角度弧度不需要暴露给蓝图 float StartAngle 0.0f; float EndAngle 0.0f; };然后在自定义的UPieChartWidget类中我们会有一个TArrayFPieChartSegment类型的属性Segments用来存储所有扇区数据。当Segments被修改通过蓝图或C我们需要标记控件为“脏”并触发重新计算和重绘。注意这里Value使用float类型。在实际计算比例时需要将所有Value求和然后计算每个Value占总和的比例这个比例乘以360度或2π弧度就是该扇区的角度跨度。务必处理总和为0的情况避免除零错误。3. 核心实现C控件类的搭建让我们开始动手创建C类。假设你的项目名为MyProject你需要在UE编辑器中或通过右键.uproject文件生成新的C类。3.1 创建自定义Widget类在编辑器中选择“工具”-“新建C类”。选择父类为UserWidget。虽然我们最终渲染靠Slate但为了无缝集成UMG编辑器我们通常创建一个继承自UWidget或UUserWidget的类并在内部持有一个我们自定义的Slate控件。更直接的方式是继承自UWidget然后重写RebuildWidget、ReleaseSlateResources和SynchronizeProperties。将类命名为PieChartWidget例如并创建。3.2 关键头文件定义打开生成的PieChartWidget.h我们需要进行大幅修改。#pragma once #include CoreMinimal.h #include Components/Widget.h #include PieChartWidget.generated.h // 前向声明我们的Slate类 class SPieChart; /** * UMG控件类用于在蓝图中创建和配置饼图。 */ UCLASS() class MYPROJECT_API UPieChartWidget : public UWidget { GENERATED_BODY() public: UPieChartWidget(const FObjectInitializer ObjectInitializer); //~ Begin UWidget Interface virtual void SynchronizeProperties() override; virtual void ReleaseSlateResources(bool bReleaseChildren) override; //~ End UWidget Interface protected: //~ Begin UWidget Interface virtual TSharedRefSWidget RebuildWidget() override; //~ End UWidget Interface /** 当饼图数据发生变化时更新Slate控件 */ UFUNCTION(BlueprintCallable, Category PieChart) void UpdateSegments(); /** 计算所有扇区的总数值 */ float GetTotalValue() const; public: /** 饼图扇区数据数组 */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart, meta(DisplayNameSegments)) TArrayFPieChartSegment Segments; /** 饼图的半径大小 */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart, meta(ClampMin0)) float Radius 50.0f; /** 饼图边框的厚度 */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart, meta(ClampMin0)) float BorderThickness 2.0f; /** 饼图边框的颜色 */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart) FLinearColor BorderColor FLinearColor::Black; /** 当鼠标悬停在某个扇区上时该扇区的突出显示比例例如1.1表示放大10% */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category PieChart|Interaction, meta(ClampMin1.0)) float HoverScaleFactor 1.05f; /** 当某个扇区被点击时广播此事件传递扇区索引和标签 */ UPROPERTY(BlueprintAssignable, Category PieChart|Interaction) FOnPieChartSegmentClicked OnSegmentClicked; // 委托类型定义 DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnPieChartSegmentClicked, int32, SegmentIndex, const FText, SegmentLabel); private: /** 指向底层Slate控件的共享指针 */ TSharedPtrSPieChart MyPieChart; };同时我们需要在同一个头文件或另一个头文件中定义我们的Slate控件SPieChart。为了清晰我们可以将其定义在.cpp文件中但为了教学这里展示一个简化的头文件内定义思路实际项目可能分开。// SPieChart 的简化声明通常在.cpp中实现 class SPieChart : public SLeafWidget { public: SLATE_BEGIN_ARGS(SPieChart) {} SLATE_ARGUMENT(TArrayFPieChartSegment*, Segments) // 指向UObject中数据的指针 SLATE_ARGUMENT(float, Radius) SLATE_ARGUMENT(float, BorderThickness) SLATE_ARGUMENT(FLinearColor, BorderColor) SLATE_ARGUMENT(float, HoverScaleFactor) SLATE_EVENT(FOnSegmentClickedSlateSignature, OnSegmentClicked) // Slate层的事件 SLATE_END_ARGS() void Construct(const FArguments InArgs); virtual FVector2D ComputeDesiredSize(float) const override; virtual int32 OnPaint(...) const override; virtual FReply OnMouseMove(const FGeometry, const FPointerEvent) override; virtual FReply OnMouseButtonDown(const FGeometry, const FPointerEvent) override; virtual void OnMouseLeave(const FPointerEvent) override; // ... 其他私有成员和方法如命中检测、数据计算等。 private: TArrayFPieChartSegment* SegmentsPtr nullptr; float Radius; float BorderThickness; FLinearColor BorderColor; float HoverScaleFactor; FOnSegmentClickedSlateSignature OnSegmentClicked; mutable int32 HoveredSegmentIndex INDEX_NONE; // 当前悬停的扇区索引 };3.3 核心C实现细节现在打开PieChartWidget.cpp这是实现逻辑的核心。第一步实现UPieChartWidget的构建和同步。UPieChartWidget::UPieChartWidget(const FObjectInitializer ObjectInitializer) : Super(ObjectInitializer) { // 设置控件默认可见 SetVisibility(ESlateVisibility::Visible); } TSharedRefSWidget UPieChartWidget::RebuildWidget() { // 创建Slate控件实例并将UMG属性的指针传递给它 MyPieChart SNew(SPieChart) .Segments(Segments) .Radius(Radius) .BorderThickness(BorderThickness) .BorderColor(BorderColor) .HoverScaleFactor(HoverScaleFactor) .OnSegmentClicked_UObject(this, UPieChartWidget::HandleSegmentClickedInternal); return MyPieChart.ToSharedRef(); } void UPieChartWidget::SynchronizeProperties() { Super::SynchronizeProperties(); // 将UMG对象的最新属性同步到Slate控件 if (MyPieChart.IsValid()) { MyPieChart-SetRadius(Radius); MyPieChart-SetBorderThickness(BorderThickness); MyPieChart-SetBorderColor(BorderColor); MyPieChart-SetHoverScaleFactor(HoverScaleFactor); // Segments是通过指针传递的所以Slate控件始终访问的是最新的数组。 // 但数组内容变化时需要手动触发更新。 UpdateSegments(); } } void UPieChartWidget::ReleaseSlateResources(bool bReleaseChildren) { Super::ReleaseSlateResources(bReleaseChildren); MyPieChart.Reset(); } void UPieChartWidget::UpdateSegments() { if (MyPieChart.IsValid()) { // 通知Slate控件数据已更新需要重新计算几何和重绘 MyPieChart-Invalidate(EInvalidateWidget::LayoutAndVolatility); } } float UPieChartWidget::GetTotalValue() const { float Total 0.0f; for (const auto Segment : Segments) { Total Segment.Value; } return Total; } void UPieChartWidget::HandleSegmentClickedInternal(int32 SegmentIndex, const FText Label) { // 将Slate层的事件转发到UMG层的蓝图可分配委托 OnSegmentClicked.Broadcast(SegmentIndex, Label); }第二步实现SPieChart的绘制逻辑OnPaint。这是最复杂的部分。我们需要为每个扇区生成一个三角扇。一个扇区假设从角度A到角度B可以用一个中心顶点和圆周上的一系列顶点构成的三角形来绘制。int32 SPieChart::OnPaint(const FPaintArgs Args, const FGeometry AllottedGeometry, const FSlateRect MyCullingRect, FSlateWindowElementList OutDrawElements, int32 LayerId, const FWidgetStyle InWidgetStyle, bool bParentEnabled) const { const bool bIsEnabled ShouldBeEnabled(bParentEnabled); const ESlateDrawEffect DrawEffects bIsEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect; const FVector2D Center AllottedGeometry.GetLocalSize() * 0.5f; const float LocalRadius Radius * AllottedGeometry.Scale; // 考虑DPI缩放 if (!SegmentsPtr || SegmentsPtr-Num() 0) { // 可以绘制一个空心的圆或直接返回 return LayerId; } // 1. 计算每个扇区的起始和结束角度弧度 const float TotalValue GetTotalValueInternal(); if (TotalValue SMALL_NUMBER) { return LayerId; // 没有有效数据 } float CurrentAngle 0.0f; // 从0度右侧开始 for (int32 i 0; i SegmentsPtr-Num(); i) { FPieChartSegment Segment (*SegmentsPtr)[i]; float SegmentAngle (Segment.Value / TotalValue) * 2.0f * PI; Segment.StartAngle CurrentAngle; Segment.EndAngle CurrentAngle SegmentAngle; CurrentAngle Segment.EndAngle; } // 2. 为每个扇区生成顶点数据并绘制 for (int32 i 0; i SegmentsPtr-Num(); i) { const FPieChartSegment Segment (*SegmentsPtr)[i]; const float StartAngle Segment.StartAngle; const float EndAngle Segment.EndAngle; const FLinearColor FinalColor Segment.Color * InWidgetStyle.GetColorAndOpacityTint(); // 应用Widget的色调和透明度 // 确定当前扇区是否被悬停是否需要缩放 const bool bIsHovered (i HoveredSegmentIndex); const float Scale bIsHovered ? HoverScaleFactor : 1.0f; const float ScaledRadius LocalRadius * Scale; // 生成三角扇的顶点 // 我们使用足够多的分段来让圆弧看起来平滑例如每5度一个点。 const float AngleStep FMath::DegreesToRadians(5.0f); // 5度步长 int32 NumSteps FMath::CeilToInt((EndAngle - StartAngle) / AngleStep); NumSteps FMath::Max(NumSteps, 2); // 至少2个点起点和终点 TArrayFSlateVertex Vertices; TArraySlateIndex Indices; // 中心顶点 (索引0) FVector2D CenterPos Center; if (bIsHovered) { // 悬停时扇区轻微向圆心外移动产生“弹出”效果 float HoverOffsetAngle (StartAngle EndAngle) * 0.5f; CenterPos FVector2D(FMath::Cos(HoverOffsetAngle), FMath::Sin(HoverOffsetAngle)) * (ScaledRadius - LocalRadius) * 0.3f; } Vertices.Add(FSlateVertex::MakeESlateVertexRounding::Disabled(AllottedGeometry.ToPaintGeometry(), CenterPos, FVector2D::ZeroVector, FinalColor)); // 圆弧上的顶点 for (int32 Step 0; Step NumSteps; Step) // 注意是 为了闭合 { float Angle StartAngle (Step / static_castfloat(NumSteps)) * (EndAngle - StartAngle); FVector2D Point Center FVector2D(FMath::Cos(Angle), FMath::Sin(Angle)) * ScaledRadius; Vertices.Add(FSlateVertex::MakeESlateVertexRounding::Disabled(AllottedGeometry.ToPaintGeometry(), Point, FVector2D::ZeroVector, FinalColor)); } // 生成三角形索引 (0, i1, i2) for (SlateIndex i 1; i Vertices.Num() - 1; i) { Indices.Add(0); Indices.Add(i); Indices.Add(i 1); } // 使用自定义顶点绘制扇区填充 FSlateResourceHandle ResourceHandle FSlateApplication::Get().GetRenderer()-GetResourceHandle(*FSlateDefaultBrush::Get().GetRenderingResource()); TSharedPtrFSlateRenderTransform Transform MakeSharedFSlateRenderTransform(); OutDrawElements.PushTransform(Transform.ToSharedRef()); FSlateDrawElement::MakeCustomVerts( OutDrawElements, LayerId, ResourceHandle, Vertices, Indices, nullptr, 0, 0, DrawEffects ); OutDrawElements.PopTransform(); // 3. 绘制扇区边框可选更复杂需要画圆弧 // 这里简化可以为整个圆画一个边框。精细实现需要为每个扇区画两条半径边和一条弧边。 // 可以使用 FSlateDrawElement::MakeLines 或更细分的三角带来实现。 } // 绘制整个饼图的外边框简单实现 if (BorderThickness 0.0f) { FSlateDrawElement::MakeLines( OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), GenerateCirclePoints(Center, LocalRadius, 60), // 生成60个点的圆环 DrawEffects, BorderColor, true, // bAntialias BorderThickness * AllottedGeometry.Scale ); } return LayerId; }实操心得FSlateVertex::Make和FSlateDrawElement::MakeCustomVerts是Slate底层绘制的核心。注意顶点坐标是局部空间的需要通过AllottedGeometry.ToPaintGeometry()提供的变换矩阵转换到屏幕空间。ESlateVertexRounding::Disabled对于非像素对齐的图形如平滑的圆很重要可以避免锯齿。另外大量动态生成顶点对性能有影响如果饼图扇区固定且数据不常变应考虑缓存顶点数据。第三步实现交互鼠标悬停与点击。交互的核心是命中检测。我们需要在OnMouseMove和OnMouseButtonDown中判断鼠标位置落在了哪个扇区。FReply SPieChart::OnMouseMove(const FGeometry MyGeometry, const FPointerEvent MouseEvent) { const FVector2D LocalPos MyGeometry.AbsoluteToLocal(MouseEvent.GetScreenSpacePosition()); const FVector2D Center MyGeometry.GetLocalSize() * 0.5f; const FVector2D Dir LocalPos - Center; const float DistanceSquared Dir.SizeSquared(); const float LocalRadiusSquared Radius * Radius * MyGeometry.Scale * MyGeometry.Scale; int32 NewHoveredIndex INDEX_NONE; if (DistanceSquared LocalRadiusSquared SegmentsPtr) // 鼠标在圆内 { // 计算鼠标相对于圆心的角度 float Angle FMath::Atan2(Dir.Y, Dir.X); // 范围 [-PI, PI] if (Angle 0) Angle 2.0f * PI; // 转换到 [0, 2*PI) for (int32 i 0; i SegmentsPtr-Num(); i) { const FPieChartSegment Segment (*SegmentsPtr)[i]; // 处理角度环绕例如从350度到10度的扇区 if (Segment.StartAngle Segment.EndAngle) { if (Angle Segment.StartAngle Angle Segment.EndAngle) { NewHoveredIndex i; break; } } else { // 扇区跨越0度 if (Angle Segment.StartAngle || Angle Segment.EndAngle) { NewHoveredIndex i; break; } } } } if (NewHoveredIndex ! HoveredSegmentIndex) { HoveredSegmentIndex NewHoveredIndex; Invalidate(EInvalidateWidget::Paint); // 触发重绘更新悬停状态 } return SLeafWidget::OnMouseMove(MyGeometry, MouseEvent); } FReply SPieChart::OnMouseButtonDown(const FGeometry MyGeometry, const FPointerEvent MouseEvent) { if (MouseEvent.GetEffectingButton() EKeys::LeftMouseButton HoveredSegmentIndex ! INDEX_NONE) { if (OnSegmentClicked.IsBound() SegmentsPtr SegmentsPtr-IsValidIndex(HoveredSegmentIndex)) { OnSegmentClicked.Execute(HoveredSegmentIndex, (*SegmentsPtr)[HoveredSegmentIndex].Label); } return FReply::Handled(); } return SLeafWidget::OnMouseButtonDown(MyGeometry, MouseEvent); } void SPieChart::OnMouseLeave(const FPointerEvent MouseEvent) { if (HoveredSegmentIndex ! INDEX_NONE) { HoveredSegmentIndex INDEX_NONE; Invalidate(EInvalidateWidget::Paint); } SLeafWidget::OnMouseLeave(MouseEvent); }注意事项角度计算和比较是命中检测的难点。确保你的角度范围是连续的[0, 2π)并处理好扇区跨越0度即起始角度大于结束角度的情况。Invalidate(EInvalidateWidget::Paint)是请求重绘的关键调用但不要每帧都调用只在状态如悬停索引改变时调用。4. 蓝图集成与使用完成C部分后编译项目。在UE编辑器的控件面板中你应该能找到你的PieChartWidget。4.1 在UMG编辑器中配置从控件面板拖拽PieChartWidget到画布上。在细节面板中找到Segments数组点击“”号添加元素。为每个元素设置Value、Color和Label。调整Radius、BorderThickness等属性实时预览效果。4.2 动态数据绑定与更新静态配置在编辑时有用但运行时更常见的是动态更新数据。在蓝图中动态设置数据你可以创建一个蓝图函数库或者直接在某个Widget蓝图中获取PieChartWidget的引用然后Clear其Segments数组再根据游戏数据如玩家资源数量、任务完成度动态地Add新的FPieChartSegment结构体实例。最后必须调用该控件暴露的UpdateSegments函数以通知控件刷新显示。绑定事件在细节面板中找到On Segment Clicked事件点击“”号绑定一个自定义事件。当玩家点击饼图的某一块时这个事件会被触发并传递被点击扇区的Index和Label。你可以根据这个索引执行相应的逻辑比如跳转到对应的详情页面、消耗该资源等。4.3 性能优化与进阶功能一个基础的饼图已经完成了。但在实际项目中你可能还需要考虑以下方面顶点缓存如果Segments数据不频繁变化但控件需要每帧重绘比如有动画那么每次OnPaint都重新计算顶点和索引是浪费的。可以将计算好的顶点/索引数据缓存起来只在数据变化时重新计算。图例Legend一个完整的图表通常需要图例。你可以将PieChartWidget作为核心再创建一个更大的UserWidget例如PieChartWithLegend在这个父Widget中左侧放置饼图右侧用一个Vertical Box动态生成一系列颜色块和文本标签来作为图例。图例的点击也可以与饼图扇区联动。动画为扇区的出现、数据更新或悬停效果添加插值动画。这需要在SPieChart中维护一些动画状态如目标角度、当前角度、目标颜色等并在Tick函数中更新这些状态然后触发重绘。注意Slate控件的Tick需要谨慎使用避免不必要的性能开销。空心饼图环形图这需要修改几何生成逻辑。不再是绘制从圆心到圆周的三角形而是绘制两个同心圆之间的部分。这需要为每个扇区生成一个“环带”顶点数量会翻倍索引逻辑也更复杂需要绘制四边形由两个三角形组成。文本渲染在扇区上或旁边绘制百分比或标签文本。可以使用FSlateDrawElement::MakeText。难点在于文本位置和抗重叠处理。一个简单的方法是将文本放在扇区弧线的中点外侧。5. 常见问题与调试技巧在实现过程中你肯定会遇到各种问题。这里记录一些典型的坑和解决方法。问题一饼图不显示或显示为黑块。检查1确认Segments数组里有数据且Value总和大于0。检查2在OnPaint函数中设置断点查看Vertices和Indices数组是否被正确填充。检查顶点坐标是否在控件可视范围内。检查3检查FSlateDrawElement::MakeCustomVerts调用参数特别是ResourceHandle。使用默认的FSlateApplication::Get().GetRenderer()-GetResourceHandle(*FSlateDefaultBrush::Get().GetRenderingResource())通常是安全的。检查4确保控件的Visibility属性不是Collapsed或Hidden。问题二鼠标交互不灵敏或错位。检查1OnMouseMove中的坐标转换是否正确MyGeometry.AbsoluteToLocal是关键。检查2命中检测的角度计算逻辑尤其是处理角度跨越0点的情况务必反复测试边界条件。检查3控件是否被其他UI元素覆盖检查UMG的层级ZOrder。问题三性能问题帧率下降。优化1如前所述实现顶点缓存。只有在Segments或Radius等属性改变时才重新计算顶点。优化2减少扇区的几何细分程度。对于小的饼图不需要每5度一个点可以尝试每10度或15度。优化3如果饼图不需要交互可以重写SupportsKeyboardFocus和ComputeVolatility返回较低的值减少Slate系统的开销。工具使用UE5的Slate Widget Reflector通过控制台命令SlateReflector打开可以查看所有Slate控件的层级、属性和绘制指令是调试UI性能的利器。问题四在蓝图中调用UpdateSegments后控件没更新。检查1确保你获取到的PieChartWidget引用是有效的。检查2确认你修改的是控件实例上的Segments数组而不是一个局部变量。检查3在C的UpdateSegments函数中是否成功调用了MyPieChart-Invalidate(...)可以在那里加一个日志输出确认。实现一个自定义的UMG控件尤其是涉及自定义绘制的控件是深入理解UE5 UI系统的好机会。它融合了UObject、Slate、渲染和蓝图交互等多个模块。虽然开头几步会有些磕绊但一旦跑通你将获得一个高度定制化、性能可控的强大UI组件能够极大地丰富你游戏的表现力。