1. 智慧工地UI系统:从零开始的布局与规划
如果你刚接触虚幻5和数字孪生,可能会觉得在庞大的工地上构建一个信息清晰、操作流畅的UI系统是个大工程。别担心,我刚开始做这类项目时也这么想,但实际走下来,你会发现只要思路清晰,用UEC++一步步搭建,整个过程就像搭积木一样有趣。智慧工地的UI,说白了就是工地的“指挥中心”和“信息看板”。它需要实时显示日期时间、车辆进出、能源消耗、监控画面、人员考勤等一大堆信息,还得让用户能方便地切换查看。我们的目标,就是把这些零散的功能模块,通过一个主UI界面有机地整合起来,让管理者一目了然。
在动手写代码之前,我习惯先在纸上或者设计软件里画个草图。对于智慧工地主UI,我通常会把它分成几个核心区域:顶部是全局信息区(日期时间、项目名称),左侧是导航或分类信息面板(比如车辆、巡查、能源的汇总卡片),中间是主视觉区(可以显示3D场景、监控视频或数据图表),右侧是详细数据面板(点击左侧卡片后展开的详细信息),底部则是一排功能按钮(工程介绍、公司介绍、数据中心等)。用虚幻的UMG(虚幻动态图形)来做,最上层就是一个Canvas Panel(画布面板),因为它能让我们用绝对坐标或锚点自由摆放控件,特别适合这种复杂布局。
我建议你先在虚幻编辑器中创建一个新的Widget Blueprint,命名为WBP_MainUI。别急着放控件,先拖入一个Canvas Panel作为根容器。然后,在这个画布里,我们首先添加一个Scale Box(缩放框)。这个组件非常有用,它能把里面的所有子控件作为一个整体进行缩放。想象一下,你的UI可能需要在不同分辨率的显示器上运行,有了Scale Box,你只需要调整它的缩放比例,里面的所有按钮、文字框都会按比例变化,省去了逐个调整锚点的麻烦。我们把最顶部的日期时间、项目标题这些组件都放到这个Scale Box里,这样基础的布局框架就搭好了。
2. 核心功能模块的UI设计与实现
2.1 动态日期时间与信息面板
一个实时更新的日期时间显示是数字孪生系统的“门面”,它告诉用户系统正在鲜活地运行。在UEC++里实现这个,我们不在UMG设计器里放一个静态文本,而是通过代码动态绑定。首先,在主UI的C++类(比如UMainUserWidget)里,我们声明一个UTextBlock指针类型的变量,并在NativeConstruct函数中(或者通过WidgetTree)找到我们在蓝图中创建的那个文本控件。
// 在.h文件中
UPROPERTY(meta = (BindWidget))
class UTextBlock* Text_DateTime;
// 在.cpp文件的NativeConstruct或初始化函数中
void UMainUserWidget::NativeConstruct()
{
Super::NativeConstruct();
// 确保指针有效
if (Text_DateTime)
{
// 启动一个定时器,每秒更新一次时间
GetWorld()->GetTimerManager().SetTimer(TimeUpdateTimerHandle, this, &UMainUserWidget::UpdateDateTime, 1.0f, true);
// 立即更新一次,避免初始1秒的空白
UpdateDateTime();
}
}
void UMainUserWidget::UpdateDateTime()
{
if (Text_DateTime)
{
FDateTime Now = FDateTime::Now();
// 格式化成更易读的字符串,例如“2023-10-27 14:30:22”
FString FormattedTime = Now.ToString(TEXT("%Y-%m-%d %H:%M:%S"));
Text_DateTime->SetText(FText::FromString(FormattedTime));
}
}
对于车辆情况、能源数据这些信息面板,我建议做成可复用的卡片式组件。创建一个单独的WBP_InfoCard控件,里面包含图标、标题、数值和单位。然后在主UI里,你可以实例化多个这个卡片控件,并通过C++代码为它们设置不同的数据。比如,车辆卡片的数据可能来自一个模拟的数据源,或者未来对接的物联网传感器。我们在Tick函数或一个数据更新事件中,去刷新这些卡片的显示。这样做的好处是逻辑清晰,维护方便,要修改卡片样式只需改一个地方。
2.2 可折叠的数据中心与交互逻辑
数据中心页面通常信息量很大,不适合一直平铺在主界面上。所以做成可折叠/展开的侧边栏是常见做法。在UMG里设计好一个包含各种图表、列表的Canvas Panel,我们称之为DataCenterPanel。在C++类中,我们用一个布尔变量bIsDataCenterVisible来控制它的显示状态。
实现点击“数据更新”按钮切换显示的逻辑是关键。首先,将按钮的OnClicked事件在C++中绑定到一个自定义函数上。
// .h文件
UPROPERTY(meta = (BindWidget))
class UButton* Button_DataUpdate;
UPROPERTY(meta = (BindWidget))
class UCanvasPanel* DataCenterPanel;
UFUNCTION()
void OnDataUpdateButtonClicked();
private:
bool bIsDataCenterVisible;
// .cpp文件
void UMainUserWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Button_DataUpdate)
{
Button_DataUpdate->OnClicked.AddDynamic(this, &UMainUserWidget::OnDataUpdateButtonClicked);
}
// 初始化为隐藏
bIsDataCenterVisible = false;
if (DataCenterPanel)
{
DataCenterPanel->SetVisibility(ESlateVisibility::Collapsed);
}
}
void UMainUserWidget::OnDataUpdateButtonClicked()
{
bIsDataCenterVisible = !bIsDataCenterVisible;
if (DataCenterPanel)
{
DataCenterPanel->SetVisibility(bIsDataCenterVisible ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}
}
对于“智慧中心”按钮要隐藏左右两侧UI的需求,一个高效的技巧是:在布局时,将左侧和右侧的所有UI控件分别包裹在两个独立的Canvas Panel(比如LeftPanel和RightPanel)中。然后,将这两个Canvas Panel再放入一个共同的父级Canvas Panel,命名为SidePanelsContainer。这样,当点击“智慧中心”按钮时,你只需要设置SidePanelsContainer的可见性为Collapsed,就能一次性隐藏所有侧边UI,逻辑简洁,性能也好。
2.3 滚动文字、图片与新闻的实现
滚动效果能为静态界面增添活力,常用于公告、工程介绍或新闻播报。实现原理其实很简单,就是在Tick函数里不断调整一个文本或图片的渲染偏移量。以横向滚动文字为例,我们在UI中放置一个Text Block,并将其放在一个Canvas Panel里(这样我们才能修改其位置)。在C++中,我们记录一个浮点数ScrollOffset作为当前偏移量,和一个布尔值bShouldScroll控制是否滚动。
// .h文件
UPROPERTY(meta = (BindWidget))
class UCanvasPanel* Canvas_ScrollingTextContainer; // 假设文本是这个画布的子控件
FVector2D TextInitialPosition;
float CurrentScrollOffset;
bool bIsScrolling;
float ScrollSpeed;
float ScrollEndThreshold; // 滚动到多长距离后开始回滚或重置
// .cpp文件 Tick函数内
void UMainUserWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
if (bIsScrolling && Canvas_ScrollingTextContainer)
{
// 假设我们控制的是画布内第一个子控件的位置
UWidget* ChildWidget = Canvas_ScrollingTextContainer->GetChildAt(0);
if (ChildWidget)
{
// 计算新位置
CurrentScrollOffset -= ScrollSpeed * InDeltaTime; // 向左滚动
FVector2D NewPosition = TextInitialPosition + FVector2D(CurrentScrollOffset, 0);
// 应用新位置(需要通过Slot来设置)
if (UPanelSlot* Slot = Canvas_ScrollingTextContainer->GetContentSlot())
{
if (UCanvasPanelSlot* CanvasSlot = Cast<UCanvasPanelSlot>(Slot))
{
CanvasSlot->SetPosition(NewPosition);
}
}
// 判断是否滚动到尽头,进行回滚或循环逻辑
if (CurrentScrollOffset <= -ScrollEndThreshold)
{
// 这里可以实现直接重置到开头(循环滚动),或者反向滚动
// 例如反向滚动:
ScrollSpeed = -ScrollSpeed;
}
if (CurrentScrollOffset >= 0) // 回到起点后再次反向
{
ScrollSpeed = FMath::Abs(ScrollSpeed); // 恢复正向速度
}
}
}
}
对于滚动图片和新闻,可以采用完全相同的逻辑,只是控制的控件变成了Image。为了更好的复用性,我强烈建议将滚动逻辑封装成一个独立的C++函数或组件,传入需要滚动的控件引用和滚动参数,这样你就能用同一套代码驱动不同的滚动元素了。
3. 高级功能集成:视频、数据与场景控制
3.1 集成实时监控视频流
在数字孪生中嵌入真实的监控视频,能让虚拟场景和现实世界无缝连接。虚幻引擎通过Web Browser插件可以内嵌浏览器组件来播放网页视频。但这里有个坑我得提醒你:引擎自带的Web Browser组件内核版本可能较旧,无法播放某些现代视频流(比如H.5的直播流)。你需要更新这个组件的CEF(Chromium Embedded Framework)库。
操作步骤是这样的:首先在虚幻编辑器的“插件”设置里启用Web Browser和Web Browser Widget插件并重启。然后,去CEF官网下载对应你操作系统和引擎版本的预编译库,替换引擎目录下Engine\Binaries\ThirdParty\CEF3中的文件。这个过程需要一点耐心,但一次搞定后就很方便了。在UI中,你添加一个Web Browser控件,然后在C++中通过LoadURL函数加载你的视频流地址(例如一个RTSP流转成的HTTP-FLV或HLS地址)。记得处理好控件的尺寸和鼠标交互,让它看起来就像UI的一部分。
3.2 数据驱动的进度条与滑动条
进度条和滑动条是数据可视化的基础。比如,用进度条显示当前工地用电量占预算的百分比,用滑动条展示设备在线率。它们的核心逻辑都是“数值映射到视觉长度”。在UEC++中,UProgressBar和USlider控件都有设置百分比的属性。
假设我们有一个表示“能源使用率”的进度条。我们需要在数据更新时(比如每5秒从模拟数据源获取一次),计算当前值占总量的百分比,然后设置给进度条。
UPROPERTY(meta = (BindWidget))
class UProgressBar* ProgressBar_EnergyUsage;
void UMainUserWidget::UpdateEnergyDisplay(float CurrentUsage, float TotalBudget)
{
if (ProgressBar_EnergyUsage && TotalBudget > 0)
{
float Percent = CurrentUsage / TotalBudget;
ProgressBar_EnergyUsage->SetPercent(Percent);
// 你还可以根据百分比改变颜色(比如超过80%变红色)
FLinearColor Color = (Percent > 0.8f) ? FLinearColor::Red : FLinearColor::Green;
ProgressBar_EnergyUsage->SetFillColorAndOpacity(Color);
}
}
滑动条的逻辑类似,但滑动条通常还允许用户交互来反向设置数值。比如,我们可以做一个控制模拟时间流逝速度的滑动条。你需要绑定滑动条的OnValueChanged事件,在事件处理函数中,将滑动条的值(0到1之间)映射到你想要控制的时间倍率上(比如0.5倍到3倍速)。
3.3 本地数据持久化:读写配置文件
一个完整的系统需要能保存和读取配置或临时数据。虽然原始文章提到了读写txt,但对于更结构化的数据(比如用户设置的UI布局、常用的监控点位),我更喜欢使用JSON或INI格式。虚幻引擎提供了非常方便的FJsonObject和GConfig来处理这些。
不过,为了理解文件操作的基础,我们先用蓝图函数库的方式实现一个文本文件读写工具,这和原始文章思路一致,但我会补充更多细节。创建一个继承自UBlueprintFunctionLibrary的C++类,比如叫UFileIOLibrary。头文件需要包含必要的文件操作头文件。
// FileIOLibrary.h
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Misc/FileHelper.h"
#include "HAL/PlatformFilemanager.h"
#include "FileIOLibrary.generated.h"
UCLASS()
class DIGITALTWIN_API UFileIOLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
// 读取文本文件全部内容
UFUNCTION(BlueprintCallable, Category = "File IO")
static bool ReadTextFile(const FString& FilePath, FString& OutContent);
// 写入文本到文件(覆盖)
UFUNCTION(BlueprintCallable, Category = "File IO")
static bool WriteTextFile(const FString& FilePath, const FString& Content);
// 追加文本到文件末尾
UFUNCTION(BlueprintCallable, Category = "File IO")
static bool AppendTextToFile(const FString& FilePath, const FString& Content);
};
在cpp文件中实现这些函数时,要特别注意路径问题。我建议使用FPaths::ProjectDir()来获取项目根目录,然后拼接你的数据文件夹路径,这样能保证在不同电脑上运行的一致性。FFileHelper类的LoadFileToString和SaveStringToFile函数是读写文件的主力,它们封装了跨平台的文件操作,非常可靠。通过这个函数库,你可以在UI逻辑中轻松地将滑动条的位置、用户选择的主题等设置保存到本地,下次启动时再读回来。
4. 场景联动与动态元素控制
4.1 用UI控制场景时间与天气
让UI上的一个滑动条就能控制虚拟世界的日出日落,这种交互体验非常棒。这需要UI和场景中的天空系统进行通信。假设你使用了像Ultra Dynamic Sky这样的第三方天空插件,它通常会提供一个可控制的蓝图实例或C++对象,其中有一个Time Of Day(时间)变量。
首先,在主UI的C++类中,我们需要获取到场景中这个天空Actor的引用。这可以通过标签查找或者游戏模式初始化时传递引用来实现。然后,在UI滑动条的OnValueChanged事件处理函数中,将滑动条的值(0到1)映射到一天中的时间(0到24小时),并设置给天空Actor。
// 假设我们有一个指向天空Actor的弱引用
TWeakObjectPtr<AActor> DynamicSkyActor;
UPROPERTY(meta = (BindWidget))
class USlider* Slider_TimeOfDay;
void UMainUserWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Slider_TimeOfDay)
{
Slider_TimeOfDay->OnValueChanged.AddDynamic(this, &UMainUserWidget::OnTimeSliderChanged);
// 初始化滑块值,比如对应中午12点
Slider_TimeOfDay->SetValue(0.5f);
}
// 查找天空Actor(示例,最好在游戏开始时由GameMode设置)
FindDynamicSkyActor();
}
void UMainUserWidget::OnTimeSliderChanged(float Value)
{
if (DynamicSkyActor.IsValid())
{
// 将0-1的值映射到0-24小时
float HourOfDay = Value * 24.0f;
// 这里需要调用天空Actor上暴露出来的设置时间的函数。
// 假设天空Actor有一个名为“SetTime”的BlueprintCallable函数。
// 我们可以通过接口(Interface)或直接调用公开的UFunction来安全地操作。
// 以下为通过UFunction调用的示例:
FName FunctionName = TEXT("SetTimeOfDay");
UFunction* SetTimeFunction = DynamicSkyActor->FindFunction(FunctionName);
if (SetTimeFunction)
{
struct FSetTimeParams { float InTime; };
FSetTimeParams Params = { HourOfDay };
DynamicSkyActor->ProcessEvent(SetTimeFunction, &Params);
}
}
}
控制天气的逻辑也类似,你可以做几个按钮,比如“晴天”、“雨天”、“多云”,点击按钮时,调用天空系统提供的切换天气的函数。这样,你的UI就从单纯的信息展示,升级成了整个数字孪生世界的控制台。
4.2 驱动场景中的动态模型:车辆与吊机
让场景里的小车自动往返跑,吊机来回旋转,能极大增强场景的真实感。这需要我们在C++中编写自定义的Actor类。对于小车,思路是定义起点和终点,然后在Tick函数中使用插值(FMath::VInterpConstantTo或FMath::VInterpTo)让小车平滑移动。到达终点后,等待片刻,再交换起点和终点,向反方向移动。
原始文章中的代码给出了一个很好的基础框架。我想补充几个实践中的细节:第一,MakeEditWidget属性生成的编辑控件位置是相对于Actor自身的局部坐标,这在场景中编辑时非常直观,但在计算世界坐标时千万别忘了加上Actor自身的世界位置,就像代码中EndPoint += StartPoint;做的那样。第二,使用定时器FTimerHandle来处理到达终点后的停留延迟,比在Tick里用计数器更清晰。第三,移动逻辑最好放在Tick里,但插值计算本身要乘以DeltaTime,以保证在不同帧率下移动速度一致。
对于吊机的左右旋转,关键在于控制Yaw(偏航角)在一个范围内(比如-90度到90度)往复变化。在Tick函数中,获取当前旋转,判断Yaw是否到达边界,然后根据一个标识位a来决定是加角度还是减角度。这里使用的RotationRate * DeltaTime确保了旋转速度与帧率无关。把写好的这两个Actor类拖入场景,指定好模型和初始参数,它们就能自己动起来了。这种将行为封装在Actor内部的方式,使得场景搭建变得非常模块化。
4.3 场景漫游与信息点交互
最后,为了让用户能自由探索这个数字孪生工地,我们需要实现场景漫游功能。这通常通过一个可控制的Pawn( pawn 类角色)来实现,为其添加移动输入和镜头控制。同时,我们可以在场景中预设几个关键的信息点(Info Point),比如“大门监控点”、“塔吊视角”、“项目部”等。
在UI底部制作一排按钮,分别对应这些信息点。当用户点击“大门监控点”按钮时,我们通过C++代码将漫游Pawn的位置和旋转瞬间设置到预设的坐标上(可以用一个Actor或Scene Component作为目标点)。同时,可以触发显示该点对应的监控视频UI面板。这需要你在游戏模式或玩家控制器中持有对漫游Pawn和各个信息点Actor的引用,并在UI按钮点击事件中调用相应的传送函数。
实现这个功能后,你的智慧工地系统就从静态的“看板”升级成了可交互、可探索的“虚拟空间”。用户既可以宏观地浏览整个UI数据概览,又可以微观地瞬移到某个具体位置查看细节,这才是数字孪生体验的精髓。整个开发流程虽然步骤不少,但每一步都有明确的目标和可实现的方法,拆解开来逐个攻克,你会发现用UEC++构建一个专业的智慧工地UI系统,并没有想象中那么遥不可及。
206



被折叠的 条评论
为什么被折叠?



