Aximmetry Learning Notes

Aximmetry 低成本XR方案折腾笔记

项目背景

我需要搭建出一套非常低成本的XR方案,调研的时候考虑国内一些自研方案,以及Hecoos等主流方案,测试期暂时使用Aximmetry作为演示方案。以下是Aximmetry在本次测试中所使用到的内容。

各版本之间的区别

  1. Studio Edition(工作室版):适合非常小的或家庭工作室,如 YouTube 创作者等。该版本可通过 HDMI 或 USB 采集设备在一台 PC 上使用多个摄像头,可通过显卡的 HDMI/DP/DVI 端口生成一个或多个视频输出,还可直接将输出流式传输到 YouTube、Facebook、Twitch 或任何基于 RTMP 的视频服务。带水印的 Studio 版本可无限期用于非商业用途,也可通过订阅计划去除水印。
  2. Professional Edition(专业版):适用于小型专业工作室,支持在一台 PC 上最多使用 4 个 SDI/ndi 输入 / 输出端口,如 3 个 SDI 摄像机输入和 1 个 SDI 复合输出,且不需要任何摄像机跟踪解决方案,仅使用固定摄像机与虚拟摄像机运动。
  3. Broadcast Edition(广播版):适用于需要摄像机跟踪功能的专业工作室,可在一台 PC 上设置无限数量的 SDI/ndi 输入 / 输出端口(仅受硬件性能限制),也可在多 PC 配置中使用。此外,广播版还具备先进的混合现实解决方案,支持多摄像机跟踪、行业标准的色度键控器、高质量实时图形、摄像机偏移和镜头畸变的自动校准等功能。

安装使用

使用环境的基本内容

软件名字 版本 备注
Aximmetry Composer image-20251009155943445 这个软件是主要用来做合成和接收跟踪内容的
Aximmetry UE image-20251009160407488 Aximmetry自己改的UE,他们有修改渲染管线(目测是为了加水印)。他们该版本的引擎是基于UE5.5进行修改的。可以看到在引擎的插件目录里有两个插件。有源码也没有引入外部的库。但是Aximmetry修改了渲染管线,猜测是防止盗版。
UE工程 5.5 一个使用Aximmetry UE制作的场景。建议创建一个5.5的原始的UE工程,方便将插件重新编译及测试插件。

建立基本的工程

进入工程的设置

image-20251009155003718

在Composer的开始设置中,主要考虑输入的内容(左边的输入设置),这个是实景的拍摄相机。输出视频的大小(右边的输出设置),这个是最终输出画面的设置。以及下面的UE渲染设置。

节点设置

image-20251009161750537
  1. VirtualCam节点:工程里添加一个镜头,如图连线。
  2. 主节点:里面添加镜头和工程,并使用Live模式。
  3. Green:添加测试用到视频素材,这里可以使用一个绿幕素材,Aximmetry会自动对其进行抠像。
  4. Camera Tracking:相机追踪节点,这里面的选项待会儿要填写上AximmetryEye

完成之后点击Composer上面的Play按钮,且让工程在编辑器状态下运行起来,就会有合成的画面了。

设置UE内容

image-20251011142232445

在场景中添加相机。

设置相机运镜

手动设置

image-20250930144118468

在这里可以用来设置相机的运动属性等信息。A和B代表着起始点和最终点。点击最左边的按钮可以让相机运动起来。

点击“摄影机/渲染设置”的编辑操作,可以在Aximmetry的渲染出来的窗口画面里,直接拖动相机位置。使用↑↓←→按钮调整相机坐标。

Iphone相机跟踪

image-20251013114229527 image-20251013114601929

在AppStore(IOS)下载Aximmetry Eye这个软件。然后通过网线有线连接(重点,无线我测试有问题),最好套上一个散热器(不然,一会儿手机就烫得不行然后断连了,然后数据飘逸了)。然后在连接的时候,选择发送Tracking Data(如图)即可(因为实际上我们只需要摄影机姿态数据)。连接完成之后,就可以在Flow页面Camera Tracking的节点细节面板里面看到手机这个选项了。

image-20251013203732954

每次初始化的时候Iphone所在的位置以及轴向,就会被当作场景的中心点。

  • 请参看参考资料,部分有视频。

相机的FOV设定

image-20251013202543178

FOV也可以进行手动标定,一般相机上的镜头标尺不是很准。
所以需要一些精确的标定。
可以在合成画面中,左右/上下横移:如果虚拟旋转速度比真实的快,那么需要减小Zoom、反之亦然。

Iphone外参手动标定

image-20251027113433462

首先在相机的上方增加增加一个Actor物体,命名为CameraLocation。

Aximmetry的逻辑是 相机生成在原点 然后+-手机Aximmetry Eye上的偏移值,就是相机在运行时候的位置。

所以需要在CameraLocation,修改其位置,至实际上物理相机距离中心原点的位置。

相机的标定

基础概念

相机标定的概念包括两部分:镜头标定和跟踪标定。

镜头校准的概念

镜头校准的概念是,演播室摄像机镜头的固有特性( 例如镜头畸变、中心偏移等 )始终会导致图像及其色彩失真,而虚拟摄像机( 演播室摄像机的虚拟对应物 )默认会呈现完美图像( 图像和/或色彩均无畸变 )。镜头校准的目标是将二者(演播室摄像机镜头与虚拟摄影机)统一在一起。(畸变校准)

跟踪校准的概念

跟踪校准的概念是,跟踪系统只能测量其跟踪设备的位置和旋转,而虚拟制作需要演播室摄像机无视差点( 靠近摄像机传感器 )的位置和旋转。跟踪校准的目标是计算这两个位置和旋转之间的差异。

工具介绍

官网对于标定内容的总结里面提及了Aximmetry有两个标定工具:Basic Calibrator和Camera Calibrator,在软件路径下可以看到这两个工具。

image-20251009165746851 image-20251009165932446
Basic Calibrator

Basic Calibrator 是 Aximmetry 提供的手动相机校准工具,用于调整相机的内外参数,确保虚拟场景与真实摄像机的透视和运动保持一致。

官方教程Aximmetry Basic Calibrator Tutorial

基本概念
  • Z(Zoom):表示最小放大值,即镜头的变焦参数
  • F(Focus):代表镜头的对焦距离
校准步骤

1. 配置相机属性(Properties)

首先需要在 Basic Calibrator 中设置相机的基本参数:

  • 传感器尺寸(Sensor Size):输入摄像机传感器的物理尺寸
    • 示例:索尼 FX3 的传感器尺寸为 35.6 x 23.8 mm
  • 相机高度(Camera Height):测量并输入相机距离地面的实际高度
    • 这个参数对于跟踪校准非常重要

2. 设置虚拟地平面

  • Pan Virtual:设置虚拟场景中地平面的高度
  • 确保虚拟地平面与真实地面对齐,这是后续校准的基准

3. 添加校准点(Calibration Points)

  • 点击 Add 按钮添加校准点
  • 建议使用 5 个以上的校准点,以获得更准确的校准结果
  • 注意事项
    • 不要在画面边缘位置添加校准点,因为镜头边缘的畸变较大
    • 校准点应均匀分布在画面的中心区域
    • 选择画面中清晰可辨的参考点(如地面标记、墙角等)

4. 调整焦距(Focal Length)

这是校准的核心步骤,需要通过观察虚拟网格与真实场景的匹配程度来调整:

  • 判断标准:横向或纵向平移相机,观察虚拟网格与地面标记的相对运动速度
    • 如果虚拟网格旋转速度比真实标记快:需要增加 Focal Length
    • 如果虚拟网格旋转速度比真实标记慢:需要减小 Focal Length
  • 调整方法
    • 逐步微调 Focal Length 参数
    • 重复测试相机移动,直到虚拟网格与真实场景完美同步

5. 验证校准结果

  • 在不同位置和角度测试相机运动
  • 确保虚拟场景与真实场景在各个方向上都能保持一致
  • 如果发现偏差,可以添加更多校准点或重新调整参数
校准技巧
  • 使用稳定的三脚架或云台,避免相机抖动影响校准精度
  • 在光线充足的环境下进行校准,确保参考点清晰可见
  • 定期重新校准,特别是在更换镜头或调整焦距后
  • 保存校准配置文件,方便后续使用
支持的跟踪系统类型

Basic Calibrator 目前支持两种类型的跟踪系统校准:

image-20251009170158715
  • 6DOF(6 自由度跟踪系统)

    • 定义:能够同时获取摄像机在三维空间中的位置(X、Y、Z)和旋转(Pitch俯仰、Yaw偏航、Roll翻滚)数据
    • 特点:实现摄像机的完整空间运动追踪
    • 应用场景:高精度虚拟制作和混合现实场景
    • 典型设备:来自外部的专业跟踪系统(如 OptiTrack、Vicon 等)
  • PTZ(Pan-Tilt-Zoom)

    • 定义:具备云台(水平旋转)、俯仰(垂直旋转)和变焦功能的摄像机
    • 特点:不具备完整的空间位置追踪能力,主要追踪旋转和变焦参数
    • 应用场景:场景监控或简单虚拟制作
    • 典型设备:PTZ 摄像机系统
设备配置示例

在设备映射器中添加 Aximmetry Eye:

image-20250930155931126
image-20250930164348165

重要提示:使用 Aximmetry Eye 时,一定要通过网线有线连接才能获得稳定的跟踪数据。无线连接可能导致数据延迟或连接不稳定。

其他思考

关于Iphone同步问题

因为Iphone在之前的版本中并不支持时间吗和Genlock,所以在同步上就会有很多问题。

论坛里提及了这个问题。是否可以使用iphone17支持了GenLock是否可以直接拿来使用作为外部跟踪器。

Youtube里面提到的Blackmgiac为Iphone制作的外部同步设备

使用时候的问题

引擎版本不兼容问题

官网也提到了这个问题,这会涉及到一些别的插件的重新编译、兼容性的问题。

Aximmetry 的 UE 版本不是官方完整源码版,因此想要重新编译复杂 C++ 插件(特别是依赖私有模块或渲染管线扩展的)往往会失败。

可以:

  1. 使用蓝图或纯资源插件;
  2. 寻找已为该版本预编译的二进制包;
  3. 如果必须用 C++ 插件,尝试在官方 UE5.5 建工程验证,确认可以跑通,然后再到Aximmetry的引擎中去跑。

采集卡格式不兼容问题

在项目设置上会出现采集卡识别不到型号/分辨率等问题,或是采集卡断掉之后,画面呈现黑的。
这时候需要拔掉插件卡重新插入,然后重启软件。

自己写一个XR软件

我们有自研的需求,考虑到Aximmetry的以“片的形式”放一个采集卡画面到场景中的实现逻辑较为简单,所以在这里手搓一个XR软件,用于给后面的大软件进行操作逻辑整理,我设想的是,这是一个编辑器下的工具,可以依托UE完成整个合成和渲染操作,而不是Aximmetry。

实现的功能比较简单:
主要实现:

  1. 接入绿幕图像
  2. 绿幕一直朝向摄影机
  3. 相机的多机位切换及多机位运动
  4. 控制对应的屏幕全屏显示

接入绿幕图像

image-20251104155838931

新建一个场景,并新建MediaPlate到场景中。

导入绿幕视频(可以直接把UE支持的格式的视频拖拽到UE的编辑器面板里)并连接MediaPlate。

image-20251104160039501

修改绿幕材质(直接把MediaPlate的材质复制出来一份到本地),并新增一个MF_ChromKeyer,并按照如图所示进行修改材质的节点,然后将复制出来的材质赋予MediaPlate。

image-20251104160012519

修改子材质,对抠像的内容选择绿色的部分。保存,即可完成抠像,这里暂时使用一个临时的视频素材,正式使用的时候,这里可以使用一个采集卡接入的视频流。

image-20251104160147691

截止到这里,我们已经可以在场景中显示一个抠像的Plate了。

但是这样的Level无法保存下来。离开场景之后MediaPlate里面的素材会丢。

所以我们还要完善一下材质:

image-20251104165145468

新建一个MediaPlayer,将里面播放的内容添加为刚刚导入的绿幕视频文件。

image-20251104165246306

新建一个MediaTexture,里面的媒体指定为刚刚新建的MediaPlayer。

image-20251104165327820

然后把MediaTexture拖动到MediaPlate的材质里面,并修改输入源。保存之后,MediaPlate就正常了。
至此,我们已经完成了绿幕接入这一项。

绿幕一直朝向摄影机

但是我们会发现一个问题,当我们切换不同相机的视角的时候,所以需要设置相机的摄影机朝向。

image-20251104171602887
image-20251104172150884

否则就会出现这种明显的片的形式。

相机的多机位切换及多机位运动

因为我们是直接控制UE里的摄影机器,所以不管是在Play状态下还是在Editor状态下,都可以直接依靠UE的Sequence功能实现镜头的运动。但是切换并不能很好的实现。所以可以用我的软件实现快速的镜头切换。

image-20251104174529386

在运行时,可以点击这个控制台,来操作相机进行切换。

控制对应的屏幕全屏显示

image-20251104174853388

选择需要把画面渲染到的屏幕,然后选择渲染分辨率,对画面进行全屏渲染。然后直播的时候,可以用OBS采集对应的窗口画面,进行直播。

于是我们就绕开了Aximmetry及其引擎,在UE中进行合成和渲染,并且也方便使用开源的UE及其周边子插件,而不是被Aximmetry的特殊版本引擎所限制。

当然这个插件目前还有很多问题。Just for Fun.如果要使用还要解决很多问题,比如标定等。

所有源码

软件的整体结构

image-20251104180744538

源码

STcgXRViewport.cpp

文件路径: Private\STcgXRViewport.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
#include "STcgXRViewport.h"
#include "TcgXRViewportClient.h"
#include "PreviewScene.h"
#include "Editor.h"
#include "LevelEditor.h"
#include "Engine/World.h"
#include "Engine/Level.h"
#include "GameFramework/Actor.h"
#include "CineCameraActor.h"
#include "CineCameraComponent.h"
#include "LevelEditorViewport.h" // FLevelEditorViewportClient
#include "TcgXR.h"

// 构造函数,初始化自定义Viewport
void STcgXRViewport::Construct(const FArguments& InArgs)
{
SEditorViewport::Construct(SEditorViewport::FArguments());
}

// 创建自定义ViewportClient
TSharedRef<FEditorViewportClient> STcgXRViewport::MakeEditorViewportClient()
{
if (!PreviewScene.IsValid())
{
PreviewScene = MakeUnique<FPreviewScene>(FPreviewScene::ConstructionValues());
}

ViewportClient = MakeShared<FTcgXRViewportClient>(PreviewScene.Get(), StaticCastSharedRef<SEditorViewport>(SharedThis(this)));
ViewportClient->SetViewMode(VMI_Lit);

// 从第一个主编辑器视口复制相机参数
if (GEditor)
{
for (FEditorViewportClient* VC : GEditor->GetAllViewportClients())
{
if (VC && VC->IsPerspective())
{
const FVector Loc = VC->GetViewLocation();
const FRotator Rot = VC->GetViewRotation();
const bool bIsOrtho = VC->IsOrtho();
const float FOV = VC->ViewFOV; // 使用源视口FOV
const float OrthoWidth = 2048.f; // 默认正交宽度
ViewportClient->SetCameraFrom(Loc, Rot, FOV, OrthoWidth, bIsOrtho);
break;
}
}
}

// 应用缓存参数(如果UI在Client创建前触发了设置,不会崩溃)
ApplyCachedParams();

return ViewportClient.ToSharedRef();
}

void STcgXRViewport::ApplyCachedParams()
{
if (!ViewportClient.IsValid()) return;
if (CachedFOV.IsSet()) { ViewportClient->SetFOV(CachedFOV.GetValue()); CachedFOV.Reset(); }
if (CachedExposure.IsSet()) { ViewportClient->SetExposure(CachedExposure.GetValue()); CachedExposure.Reset(); }
if (CachedFocalLength.IsSet()) { ViewportClient->SetFocalLength(CachedFocalLength.GetValue()); CachedFocalLength.Reset(); }
if (CachedAperture.IsSet()) { ViewportClient->SetAperture(CachedAperture.GetValue()); CachedAperture.Reset(); }
if (CachedFocusDistance.IsSet()) { ViewportClient->SetFocusDistance(CachedFocusDistance.GetValue()); CachedFocusDistance.Reset(); }
}

// XR状态控制接口
void STcgXRViewport::SetShowKeyed(bool b) { if (ViewportClient) ViewportClient->SetShowKeyed(b); }
void STcgXRViewport::SetVirtualCamMove(bool b) { if (ViewportClient) ViewportClient->SetVirtualCamMove(b); }
void STcgXRViewport::SetMixedRealityBlend(bool b) { if (ViewportClient) ViewportClient->SetMixedRealityBlend(b); }

bool STcgXRViewport::IsShowKeyed() const { return ViewportClient ? ViewportClient->IsShowKeyed() : false; }
bool STcgXRViewport::IsVirtualCamMove() const { return ViewportClient ? ViewportClient->IsVirtualCamMove() : false; }
bool STcgXRViewport::IsMixedRealityBlend() const { return ViewportClient ? ViewportClient->IsMixedRealityBlend() : true; }

// 工具:获取当前锁定的电影相机(如果任何一个关卡视口处于Pilot/锁定状态)
static UCineCameraComponent* GetLockedCineCamera()
{
if (!GEditor) return nullptr;
const TArray<FLevelEditorViewportClient*>& Clients = GEditor->GetLevelViewportClients();
for (FLevelEditorViewportClient* LVC : Clients)
{
if (!LVC || !LVC->IsPerspective()) continue;
AActor* LockedActor = nullptr;
if (AActor* CineLock = LVC->GetCinematicActorLock().GetLockedActor())
{
LockedActor = CineLock;
}
else if (LVC->GetActiveActorLock().IsValid())
{
LockedActor = LVC->GetActiveActorLock().Get();
}
if (LockedActor)
{
if (ACineCameraActor* Cine = Cast<ACineCameraActor>(LockedActor))
{
return Cine->GetCineCameraComponent();
}
}
}
return nullptr;
}

// 捕获键盘输入并分发给模块做相机切换
FReply STcgXRViewport::OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent)
{
if (FTcgXRModule* Mod = FModuleManager::GetModulePtr<FTcgXRModule>("TcgXR"))
{
if (Mod->HandleKeyDown(InKeyEvent.GetKey()))
{
return FReply::Handled();
}
}
return SEditorViewport::OnKeyDown(MyGeometry, InKeyEvent);
}

// 安全设置接口(优先修改锁定到关卡视口的相机,否则本地缓存/ViewportClient)
void STcgXRViewport::SetFOVParam(float InFOV)
{
if (UCineCameraComponent* CineComp = GetLockedCineCamera())
{
CineComp->SetFieldOfView(InFOV);
return;
}
if (ViewportClient.IsValid()) { ViewportClient->SetFOV(InFOV); }
else { CachedFOV = InFOV; }
}

void STcgXRViewport::SetExposureParam(float InExposure)
{
if (UCineCameraComponent* CineComp = GetLockedCineCamera())
{
FPostProcessSettings& PPS = CineComp->PostProcessSettings;
PPS.bOverride_AutoExposureBias = true;
PPS.AutoExposureBias = InExposure;
CineComp->PostProcessBlendWeight = 1.0f;
CineComp->MarkRenderStateDirty();
return;
}
if (ViewportClient.IsValid()) { ViewportClient->SetExposure(InExposure); }
else { CachedExposure = InExposure; }
}

void STcgXRViewport::SetFocalLengthParam(float InFocal)
{
if (UCineCameraComponent* CineComp = GetLockedCineCamera())
{
CineComp->SetCurrentFocalLength(InFocal);
return;
}
if (ViewportClient.IsValid()) { ViewportClient->SetFocalLength(InFocal); }
else { CachedFocalLength = InFocal; }
}

void STcgXRViewport::SetApertureParam(float InAperture)
{
if (UCineCameraComponent* CineComp = GetLockedCineCamera())
{
CineComp->SetCurrentAperture(InAperture);
return;
}
if (ViewportClient.IsValid()) { ViewportClient->SetAperture(InAperture); }
else { CachedAperture = InAperture; }
}

void STcgXRViewport::SetFocusDistanceParam(float InFocus)
{
if (UCineCameraComponent* CineComp = GetLockedCineCamera())
{
CineComp->FocusSettings.ManualFocusDistance = InFocus;
CineComp->SetFocusSettings(CineComp->FocusSettings);
return;
}
if (ViewportClient.IsValid()) { ViewportClient->SetFocusDistance(InFocus); }
else { CachedFocusDistance = InFocus; }
}

TcgXR.cpp

文件路径: Private\TcgXR.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
// Copyright Epic Games, Inc. All Rights Reserved.
// 本文件为TcgXR插件主模块,负责插件的初始化、UI布局和命令注册。

#include "TcgXR.h"
#include "TcgXRStyle.h"
#include "TcgXRCommands.h"
#include "ToolMenus.h"
#include "Framework/Docking/TabManager.h"
#include "Widgets/Docking/SDockTab.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SComboBox.h"
#include "Widgets/Input/SSlider.h"
#include "Widgets/Input/SSpinBox.h"
#include "Widgets/Input/SComboButton.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/SWindow.h"
#include "Framework/Application/SlateApplication.h"
#include "STcgXRViewport.h"
#include "TcgXRViewportClient.h"
#include "LevelEditor.h"
#include "LevelEditorViewport.h"
#include "SLevelViewport.h"
#include "EngineUtils.h"
#include "Camera/CameraActor.h"
#include "Editor.h" // FEditorDelegates

static const FName TcgXRTabName("TcgXRWindow");

#define LOCTEXT_NAMESPACE "FTcgXRModule"

void FTcgXRModule::StartupModule()
{
FTcgXRStyle::Initialize();
FTcgXRStyle::ReloadTextures();

FTcgXRCommands::Register();

PluginCommands = MakeShareable(new FUICommandList);
PluginCommands->MapAction(
FTcgXRCommands::Get().PluginAction,
FExecuteAction::CreateRaw(this, &FTcgXRModule::PluginButtonClicked),
FCanExecuteAction());

FGlobalTabmanager::Get()->RegisterNomadTabSpawner(TcgXRTabName,
FOnSpawnTab::CreateRaw(this, &FTcgXRModule::OnSpawnPluginTab))
.SetDisplayName(LOCTEXT("TcgXRTabTitle", "TcgXR"))
.SetMenuType(ETabSpawnerMenuType::Hidden);

UToolMenus::RegisterStartupCallback(FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FTcgXRModule::RegisterMenus));

if (GEngine)
{
ActorAddedHandle = GEngine->OnLevelActorAdded().AddLambda([this](AActor* /*Actor*/){ RefreshCameraList(); });
ActorDeletedHandle = GEngine->OnLevelActorDeleted().AddLambda([this](AActor* /*Actor*/){ RefreshCameraList(); });
}
// 当编辑器相机移动或切换锁定时,刷新“当前控制”文本
FEditorDelegates::OnEditorCameraMoved.AddLambda([this](const FVector&, const FRotator&, ELevelViewportType, int32){ RebuildCameraHotkeyUI(); });
}

void FTcgXRModule::ShutdownModule()
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);

FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(TcgXRTabName);

if (GEngine)
{
GEngine->OnLevelActorAdded().Remove(ActorAddedHandle);
GEngine->OnLevelActorDeleted().Remove(ActorDeletedHandle);
}

FTcgXRStyle::Shutdown();
FTcgXRCommands::Unregister();
}

void FTcgXRModule::PluginButtonClicked()
{
FGlobalTabmanager::Get()->TryInvokeTab(TcgXRTabName);
}

void FTcgXRModule::RegisterMenus()
{
FToolMenuOwnerScoped OwnerScoped(this);

if (UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Window"))
{
FToolMenuSection& Section = Menu->FindOrAddSection("WindowLayout");
Section.AddMenuEntryWithCommandList(FTcgXRCommands::Get().PluginAction, PluginCommands);
}

if (UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.PlayToolBar"))
{
FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("PluginTools");
FToolMenuEntry& Entry = Section.AddEntry(FToolMenuEntry::InitToolBarButton(FTcgXRCommands::Get().PluginAction));
Entry.SetCommandList(PluginCommands);
Entry.Icon = FSlateIcon(FTcgXRStyle::GetStyleSetName(), TEXT("TcgXR.PluginAction.Small"), TEXT("TcgXR.PluginAction"));
}
}

bool FTcgXRModule::HandleKeyDown(const FKey& Key)
{
if (TWeakObjectPtr<ACameraActor>* Found = CameraHotkeyMap.Find(Key))
{
SwitchToCamera(*Found);
RebuildCameraHotkeyUI();
return true;
}
return false;
}

void FTcgXRModule::BindCameraHotkey(TWeakObjectPtr<ACameraActor> Camera, const FKey& Key)
{
// 确保一个相机只绑定一个键:先移除旧绑定
TArray<FKey> KeysToRemove;
for (const auto& Pair : CameraHotkeyMap)
{
if (Pair.Value == Camera)
{
KeysToRemove.Add(Pair.Key);
}
}
for (const FKey& OldKey : KeysToRemove)
{
CameraHotkeyMap.Remove(OldKey);
}
// 同一按键重新绑定到新相机
CameraHotkeyMap.Add(Key, Camera);
RebuildCameraHotkeyUI();
}

void FTcgXRModule::SwitchToCamera(TWeakObjectPtr<ACameraActor> Camera)
{
if (!Camera.IsValid()) return;
FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor");
TSharedPtr<SLevelViewport> ActiveViewport = LevelEditorModule.GetFirstActiveLevelViewport();
if (!ActiveViewport.IsValid()) return;

FLevelEditorViewportClient& LVC = ActiveViewport->GetLevelViewportClient();
LVC.SetActorLock(Camera.Get());
LVC.UpdateViewForLockedActor();
if (!ActiveViewport->IsLockedCameraViewEnabled())
{
ActiveViewport->ToggleActorPilotCameraView();
}
LVC.Invalidate();
}

FText FTcgXRModule::GetCurrentControlledCameraText() const
{
if (!GEditor) return LOCTEXT("NoEditor", "当前控制: 无(未找到编辑器)");
const TArray<FLevelEditorViewportClient*>& Clients = GEditor->GetLevelViewportClients();
for (FLevelEditorViewportClient* LVC : Clients)
{
if (!LVC || !LVC->IsPerspective()) continue;
if (AActor* CineLock = LVC->GetCinematicActorLock().GetLockedActor())
{
return FText::Format(LOCTEXT("ControllingCam","当前控制: {0}"), FText::FromString(CineLock->GetActorLabel()));
}
if (LVC->GetActiveActorLock().IsValid())
{
AActor* Locked = LVC->GetActiveActorLock().Get();
return FText::Format(LOCTEXT("ControllingActor","当前控制: {0}"), FText::FromString(Locked->GetActorLabel()));
}
}
return LOCTEXT("NoCam","当前控制: 无(未锁定相机)");
}

FText FTcgXRModule::GetBoundKeyTextForCamera(TWeakObjectPtr<ACameraActor> Camera) const
{
for (const auto& Pair : CameraHotkeyMap)
{
if (Pair.Value == Camera)
{
return FText::Format(LOCTEXT("BoundKeyFmt","已绑定: {0}"), Pair.Key.GetDisplayName());
}
}
return LOCTEXT("NoKeyBound","未绑定");
}

void FTcgXRModule::RefreshCameraList()
{
CachedCameras.Reset();
if (GEditor)
{
UWorld* World = GEditor->GetEditorWorldContext().World();
if (World)
{
for (TActorIterator<ACameraActor> It(World); It; ++It)
{
CachedCameras.Add(*It);
}
}
}
RebuildCameraHotkeyUI();
}

void FTcgXRModule::RebuildCameraHotkeyUI()
{
TSharedPtr<SVerticalBox> Panel = CameraListPanel.Pin();
if (!Panel.IsValid()) return;
Panel->ClearChildren();

// 顶部显示当前控制的相机
Panel->AddSlot().AutoHeight().Padding(0,2)
[
SNew(STextBlock)
.Text_Lambda([this](){ return GetCurrentControlledCameraText(); })
];

int32 Index = 1;
for (TWeakObjectPtr<ACameraActor> Cam : CachedCameras)
{
TWeakObjectPtr<ACameraActor> LocalCam = Cam;
FText Label = FText::FromString(FString::Printf(TEXT("相机%d: %s"), Index, LocalCam.IsValid() ? *LocalCam->GetActorLabel() : TEXT("(无)")));
Panel->AddSlot().AutoHeight().Padding(0,2)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(STextBlock).Text(Label)
]
+ SHorizontalBox::Slot().AutoWidth().Padding(8,2)
[
SNew(STextBlock)
.Text_Lambda([this, LocalCam](){ return GetBoundKeyTextForCamera(LocalCam); })
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SComboButton)
.ButtonContent()[ SNew(STextBlock).Text(LOCTEXT("BindKey", "绑定按键")) ]
.OnGetMenuContent_Lambda([this, LocalCam]()
{
FMenuBuilder MenuBuilder(true, nullptr);
TArray<FKey> Keys; Keys.Reserve(9);
Keys.Add(EKeys::One); Keys.Add(EKeys::Two); Keys.Add(EKeys::Three); Keys.Add(EKeys::Four);
Keys.Add(EKeys::Five); Keys.Add(EKeys::Six); Keys.Add(EKeys::Seven); Keys.Add(EKeys::Eight); Keys.Add(EKeys::Nine);
for (const FKey& K : Keys)
{
FText KeyName = K.GetDisplayName();
MenuBuilder.AddMenuEntry(KeyName, LOCTEXT("BindKeyTip","绑定到该键"), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([this, LocalCam, K](){ BindCameraHotkey(LocalCam, K); })));
}
return MenuBuilder.MakeWidget();
})
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SButton)
.Text(LOCTEXT("SwitchNow","切换到该相机"))
.OnClicked_Lambda([this, LocalCam]()
{
SwitchToCamera(LocalCam);
RebuildCameraHotkeyUI();
return FReply::Handled();
})
]
];
++Index;
}
}

void FTcgXRModule::CloseAllFullscreenWindows()
{
for (TWeakPtr<SWindow>& W : FullscreenWindows)
{
if (TSharedPtr<SWindow> SW = W.Pin())
{
SW->RequestDestroyWindow();
}
}
FullscreenWindows.Reset();
}

// 生成主窗口Tab,包括Viewport、右侧摄影机控制面板、底部全屏按钮
TSharedRef<SDockTab> FTcgXRModule::OnSpawnPluginTab(const FSpawnTabArgs& Args)
{
TSharedPtr<STcgXRViewport> ViewportWidget;

static float ManualExposure = 0.0f;
static float ManualFOV = 90.0f;
static float ManualFocal = 35.0f;
static float ManualAperture = 2.8f;
static float ManualFocus = 1000.0f;
static bool bUseLiveLink = false;
static TArray<TSharedPtr<FString>> ResolutionOptions;
ResolutionOptions.Reset();
ResolutionOptions.Add(MakeShared<FString>(TEXT("1920x1080")));
ResolutionOptions.Add(MakeShared<FString>(TEXT("2560x1440")));
ResolutionOptions.Add(MakeShared<FString>(TEXT("3840x2160")));
static TArray<TSharedPtr<FString>> MonitorOptions;
static int32 SelectedMonitorIndex = 0;
static TSharedPtr<FString> SelectedResolution = ResolutionOptions.Num() > 0 ? ResolutionOptions[0] : nullptr;

MonitorOptions.Reset();
{
FDisplayMetrics Metrics;
FSlateApplication::Get().GetDisplayMetrics(Metrics);
MonitorOptions.Add(MakeShared<FString>(FString::Printf(TEXT("主显示器 %dx%d"), Metrics.PrimaryDisplayWidth, Metrics.PrimaryDisplayHeight)));
for (int32 i = 0; i < Metrics.MonitorInfo.Num(); ++i)
{
const auto& M = Metrics.MonitorInfo[i];
FPlatformRect Rect = M.WorkArea;
MonitorOptions.Add(MakeShared<FString>(FString::Printf(TEXT("显示器%d %dx%d @(%d,%d)"), i, Rect.Right-Rect.Left, Rect.Bottom-Rect.Top, Rect.Left, Rect.Top)));
}
if (MonitorOptions.Num() == 0)
{
MonitorOptions.Add(MakeShared<FString>(TEXT("显示器0")));
}
}

TSharedRef<SDockTab> Tab = SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.f)
[
SAssignNew(ViewportWidget, STcgXRViewport)
]
+ SHorizontalBox::Slot().AutoWidth().Padding(8.f,0.f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight().Padding(0,2)
[
SAssignNew(CameraListPanel, SVerticalBox)
]
// 顶部快速参数行:移除FOV和曝光
+ SVerticalBox::Slot().AutoHeight().Padding(0, 2)
[
SNew(SHorizontalBox)
// 焦距
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(STextBlock).Text(LOCTEXT("Focal", "焦距"))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SSpinBox<float>).MinValue(1.f).MaxValue(300.f)
.Value_Lambda([&]{ return ManualFocal; })
.OnValueChanged_Lambda([&](float V){ ManualFocal = V; if(ViewportWidget.IsValid()) ViewportWidget->SetFocalLengthParam(V); })
]
// 光圈
+ SHorizontalBox::Slot().AutoWidth().Padding(8,2)
[
SNew(STextBlock).Text(LOCTEXT("Aperture", "光圈"))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SSpinBox<float>).MinValue(0.7f).MaxValue(32.f)
.Value_Lambda([&]{ return ManualAperture; })
.OnValueChanged_Lambda([&](float V){ ManualAperture = V; if(ViewportWidget.IsValid()) ViewportWidget->SetApertureParam(V); })
]
// 对焦
+ SHorizontalBox::Slot().AutoWidth().Padding(8,2)
[
SNew(STextBlock).Text(LOCTEXT("Focus", "对焦"))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SSpinBox<float>).MinValue(1.f).MaxValue(100000.f)
.Value_Lambda([&]{ return ManualFocus; })
.OnValueChanged_Lambda([&](float V){ ManualFocus = V; if(ViewportWidget.IsValid()) ViewportWidget->SetFocusDistanceParam(V); })
]
]

+ SVerticalBox::Slot().AutoHeight().Padding(0,6,0,0)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(SCheckBox)
.IsChecked_Lambda([] { return bUseLiveLink ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; })
.OnCheckStateChanged_Lambda([](ECheckBoxState State) { bUseLiveLink = (State == ECheckBoxState::Checked); })
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(STextBlock).Text(LOCTEXT("LiveLinkSwitch", "使用LiveLink外部相机"))
]
]

+ SVerticalBox::Slot().AutoHeight().Padding(0, 8, 0, 0)
[
SNew(SVerticalBox)
.Visibility_Lambda([] { return bUseLiveLink ? EVisibility::Visible : EVisibility::Collapsed; })
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock).Text(LOCTEXT("LiveLinkTitle", "LiveLink外部相机参数"))
]
+ SVerticalBox::Slot().AutoHeight().Padding(0, 2)
[
SNew(STextBlock).Text(LOCTEXT("LiveLinkStatus", "状态: 未连接/已连接"))
]
]

+ SVerticalBox::Slot().AutoHeight().VAlign(VAlign_Bottom).Padding(0, 16, 0, 0)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight().Padding(0, 8, 0, 0)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(STextBlock).Text(LOCTEXT("MonitorLabel", "屏幕"))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(SComboBox<TSharedPtr<FString>>)
.OptionsSource(&MonitorOptions)
.OnSelectionChanged_Lambda([&](TSharedPtr<FString> NewItem, ESelectInfo::Type){ SelectedMonitorIndex = FMath::Clamp(MonitorOptions.IndexOfByKey(NewItem), 0, MonitorOptions.Num()-1); })
.OnGenerateWidget_Lambda([](TSharedPtr<FString> InItem){ return SNew(STextBlock).Text(FText::FromString(*InItem)); })
[
SNew(STextBlock).Text_Lambda([&]{ return MonitorOptions.IsValidIndex(SelectedMonitorIndex) ? FText::FromString(*MonitorOptions[SelectedMonitorIndex]) : LOCTEXT("MonitorUnknown","未知屏幕"); })
]
]
]
+ SVerticalBox::Slot().AutoHeight().Padding(0, 4, 0, 0)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(STextBlock).Text(LOCTEXT("ResolutionLabel", "输出分辨率"))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.f)
[
SNew(SComboBox<TSharedPtr<FString>>)
.OptionsSource(&ResolutionOptions)
.OnSelectionChanged_Lambda([&](TSharedPtr<FString> NewItem, ESelectInfo::Type) { SelectedResolution = NewItem; })
.OnGenerateWidget_Lambda([](TSharedPtr<FString> InItem) { return SNew(STextBlock).Text(FText::FromString(*InItem)); })
[
SNew(STextBlock).Text_Lambda([&] { return SelectedResolution.IsValid() ? FText::FromString(*SelectedResolution) : LOCTEXT("ResUnknown","未知"); })
]
]
]
+ SVerticalBox::Slot().AutoHeight().Padding(0, 6, 0, 0)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SButton)
.Text(LOCTEXT("FullscreenBtn", "全屏显示"))
.OnClicked_Lambda([this, ViewportWidget]() {
if(!ViewportWidget.IsValid()) return FReply::Handled();
TSharedPtr<FString> LocalSelectedResolution = SelectedResolution;
int32 LocalSelectedMonitorIndex = SelectedMonitorIndex;
int32 W=1920, H=1080;
{
FString Res= LocalSelectedResolution.IsValid() ? *LocalSelectedResolution : FString(TEXT("1920x1080"));
FString LW, LH;
if(Res.Split(TEXT("x"), &LW, &LH)) { W = FCString::Atoi(*LW); H = FCString::Atoi(*LH); }
}
TSharedPtr<SWindow> FullscreenWindow = SNew(SWindow)
.Title(LOCTEXT("XRFullscreen", "XR全屏输出"))
.ClientSize(FVector2D(W, H))
.SizingRule(ESizingRule::FixedSize)
.SupportsMaximize(false)
.SupportsMinimize(false)
.HasCloseButton(true)
.UseOSWindowBorder(false)
.CreateTitleBar(false);

TSharedPtr<STcgXRViewport> FSViewport;
FullscreenWindow->SetContent(SAssignNew(FSViewport, STcgXRViewport));

if (FSlateApplication::IsInitialized())
{
FSlateApplication::Get().AddWindow(FullscreenWindow.ToSharedRef());
FDisplayMetrics Metrics; FSlateApplication::Get().GetDisplayMetrics(Metrics);
FVector2D TargetPos(0,0);
if (Metrics.MonitorInfo.IsValidIndex(LocalSelectedMonitorIndex))
{
const auto& M = Metrics.MonitorInfo[LocalSelectedMonitorIndex];
TargetPos = FVector2D(M.WorkArea.Left, M.WorkArea.Top);
}
FullscreenWindow->MoveWindowTo(TargetPos);
}

FullscreenWindows.Add(FullscreenWindow);

if (ViewportWidget->GetClient().IsValid() && FSViewport.IsValid())
{
auto Src = ViewportWidget->GetClient();
auto Dst = FSViewport->GetClient();
Dst->SetCameraFrom(Src->GetViewLocation(), Src->GetViewRotation(), Src->GetFOV(), 2048.f, false);
Dst->SetFOV(Src->GetFOV());
Dst->SetExposure(Src->GetExposure());
Dst->SetFocalLength(Src->GetFocalLength());
Dst->SetAperture(Src->GetAperture());
Dst->SetFocusDistance(Src->GetFocusDistance());
}

return FReply::Handled();
})
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2)
[
SNew(SButton)
.Text(LOCTEXT("CloseFullscreenBtn", "关闭所有全屏"))
.OnClicked_Lambda([this]()
{
CloseAllFullscreenWindows();
return FReply::Handled();
})
]
]
]
]
];

RefreshCameraList();
return Tab;
}

#undef LOCTEXT_NAMESPACE

IMPLEMENT_MODULE(FTcgXRModule, TcgXR)

TcgXRCommands.cpp

文件路径: Private\TcgXRCommands.cpp

1
2
3
4
5
6
7
8
9
10
11
12
// Copyright Epic Games, Inc. All Rights Reserved.

#include "TcgXRCommands.h"

#define LOCTEXT_NAMESPACE "FTcgXRModule"

void FTcgXRCommands::RegisterCommands()
{
UI_COMMAND(PluginAction, "TcgXR", "Execute TcgXR action", EUserInterfaceActionType::Button, FInputChord());
}

#undef LOCTEXT_NAMESPACE

TcgXRStyle.cpp

文件路径: Private\TcgXRStyle.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Copyright Epic Games, Inc. All Rights Reserved.

#include "TcgXRStyle.h"
#include "TcgXR.h"
#include "Framework/Application/SlateApplication.h"
#include "Styling/SlateStyleRegistry.h"
#include "Slate/SlateGameResources.h"
#include "Interfaces/IPluginManager.h"
#include "Styling/SlateStyleMacros.h"

#define RootToContentDir Style->RootToContentDir

TSharedPtr<FSlateStyleSet> FTcgXRStyle::StyleInstance = nullptr;

void FTcgXRStyle::Initialize()
{
if (!StyleInstance.IsValid())
{
StyleInstance = Create();
FSlateStyleRegistry::RegisterSlateStyle(*StyleInstance);
}
}

void FTcgXRStyle::Shutdown()
{
FSlateStyleRegistry::UnRegisterSlateStyle(*StyleInstance);
ensure(StyleInstance.IsUnique());
StyleInstance.Reset();
}

FName FTcgXRStyle::GetStyleSetName()
{
static FName StyleSetName(TEXT("TcgXRStyle"));
return StyleSetName;
}


const FVector2D Icon16x16(16.0f, 16.0f);
const FVector2D Icon20x20(20.0f, 20.0f);
const FVector2D Icon40x40(40.0f, 40.0f);

TSharedRef< FSlateStyleSet > FTcgXRStyle::Create()
{
TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("TcgXRStyle"));
Style->SetContentRoot(IPluginManager::Get().FindPlugin("TcgXR")->GetBaseDir() / TEXT("Resources"));

// Use vector image brushes from Resources/TcgXR.svg for toolbar/menu icons
Style->Set("TcgXR.PluginAction", new IMAGE_BRUSH_SVG(TEXT("TcgXR"), Icon40x40));
Style->Set("TcgXR.PluginAction.Small", new IMAGE_BRUSH_SVG(TEXT("TcgXR"), Icon20x20));
return Style;
}

void FTcgXRStyle::ReloadTextures()
{
if (FSlateApplication::IsInitialized())
{
FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
}
}

const ISlateStyle& FTcgXRStyle::Get()
{
return *StyleInstance;
}

TcgXRViewportClient.cpp

文件路径: Private\TcgXRViewportClient.cpp

1
2
3
// FTcgXRViewportClient implementation has been moved inline to TcgXRViewportClient.h
// This translation unit is intentionally left empty to avoid duplicate definitions.

STcgXRViewport.h

文件路径: Public\STcgXRViewport.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#pragma once

#include "CoreMinimal.h"
#include "SEditorViewport.h"

class FTcgXRViewportClient;
class FPreviewScene;

// TcgXR自定义Viewport控件,支持XR场景显示和相机控制
class STcgXRViewport : public SEditorViewport
{
public:
SLATE_BEGIN_ARGS(STcgXRViewport) {}
SLATE_END_ARGS()

// 构造函数
void Construct(const FArguments& InArgs);

// XR状态控制接口
void SetShowKeyed(bool b); // 设置是否显示抠像
void SetVirtualCamMove(bool b); // 设置是否虚拟相机运动
void SetMixedRealityBlend(bool b); // 设置是否虚实融合

bool IsShowKeyed() const; // 是否显示抠像
bool IsVirtualCamMove() const; // 是否虚拟相机运动
bool IsMixedRealityBlend() const; // 是否虚实融合

// 访问底层ViewportClient(用于复制相机/参数到其它视口)
TSharedPtr<FTcgXRViewportClient> GetClient() const { return ViewportClient; }

// 安全设置相机参数:若Client尚未就绪,则缓存稍后应用
void SetFOVParam(float InFOV);
void SetExposureParam(float InExposure);
void SetFocalLengthParam(float InFocal);
void SetApertureParam(float InAperture);
void SetFocusDistanceParam(float InFocus);

protected:
// 创建自定义ViewportClient
virtual TSharedRef<FEditorViewportClient> MakeEditorViewportClient() override;
// 不显示默认工具栏
virtual TSharedPtr<SWidget> MakeViewportToolbar() override { return SNullWidget::NullWidget; }

// 捕获键盘输入分发到模块(用于热键切换相机)
virtual FReply OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) override;

private:
// 应用所有缓存的相机参数
void ApplyCachedParams();

private:
TSharedPtr<FTcgXRViewportClient> ViewportClient; // 视口客户端
TUniquePtr<FPreviewScene> PreviewScene; // 预览场景

// 缓存相机参数(在Client未就绪时临时保存)
TOptional<float> CachedFOV;
TOptional<float> CachedExposure;
TOptional<float> CachedFocalLength;
TOptional<float> CachedAperture;
TOptional<float> CachedFocusDistance;
};

TcgXR.h

文件路径: Public\TcgXR.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"

class FToolBarBuilder;
class FMenuBuilder;
class ACinemaCameraActor;
class ACameraActor;
class SWindow;
class SVerticalBox;

class FTcgXRModule : public IModuleInterface
{
public:
/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;

/** This function will be bound to Command. */
void PluginButtonClicked();

// 热键:处理按键(由STcgXRViewport调用),返回是否已处理
bool HandleKeyDown(const FKey& Key);
// 热键:绑定某个相机到某个按键
void BindCameraHotkey(TWeakObjectPtr<ACameraActor> Camera, const FKey& Key);
// 切换关卡视口到指定相机
void SwitchToCamera(TWeakObjectPtr<ACameraActor> Camera);
// 刷新相机列表并重建UI
void RefreshCameraList();

// 关闭所有全屏窗口
void CloseAllFullscreenWindows();

// 显示当前控制的相机名称
FText GetCurrentControlledCameraText() const;
// 获取相机已绑定的按键显示文本
FText GetBoundKeyTextForCamera(TWeakObjectPtr<ACameraActor> Camera) const;

private:
void RegisterMenus();

// Spawn the XR viewport tab
TSharedRef<class SDockTab> OnSpawnPluginTab(const class FSpawnTabArgs& Args);

// 重建相机热键绑定UI
void RebuildCameraHotkeyUI();

private:
TSharedPtr<class FUICommandList> PluginCommands;

// 相机与热键映射
TMap<FKey, TWeakObjectPtr<ACameraActor>> CameraHotkeyMap;
TArray<TWeakObjectPtr<ACameraActor>> CachedCameras;
TWeakPtr<SVerticalBox> CameraListPanel;

// 监听句柄
FDelegateHandle ActorAddedHandle;
FDelegateHandle ActorDeletedHandle;

// 全屏窗口列表
TArray<TWeakPtr<SWindow>> FullscreenWindows;
};

TcgXRCommands.h

文件路径: Public\TcgXRCommands.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "Framework/Commands/Commands.h"
#include "TcgXRStyle.h"

class FTcgXRCommands : public TCommands<FTcgXRCommands>
{
public:

FTcgXRCommands()
: TCommands<FTcgXRCommands>(TEXT("TcgXR"), NSLOCTEXT("Contexts", "TcgXR", "TcgXR Plugin"), NAME_None, FTcgXRStyle::GetStyleSetName())
{
}

// TCommands<> interface
virtual void RegisterCommands() override;

public:
TSharedPtr< FUICommandInfo > PluginAction;
};

TcgXRStyle.h

文件路径: Public\TcgXRStyle.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "Styling/SlateStyle.h"

class FTcgXRStyle
{
public:

static void Initialize();

static void Shutdown();

/** reloads textures used by slate renderer */
static void ReloadTextures();

/** @return The Slate style set for the Shooter game */
static const ISlateStyle& Get();

static FName GetStyleSetName();

private:

static TSharedRef< class FSlateStyleSet > Create();

private:

static TSharedPtr< class FSlateStyleSet > StyleInstance;
};

TcgXRViewportClient.h

文件路径: Public\TcgXRViewportClient.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#pragma once

#include "CoreMinimal.h"
#include "EditorViewportClient.h"
#include "PreviewScene.h"
#include "SEditorViewport.h"
#include "Editor.h"
#include "SceneView.h"

// TcgXR自定义ViewportClient,负责相机控制、XR显示等
class FTcgXRViewportClient : public FEditorViewportClient
{
public:
// 构造函数,初始化视口参数
FTcgXRViewportClient(FPreviewScene* InPreviewScene, const TSharedRef<SEditorViewport>& InEditorViewport)
: FEditorViewportClient(nullptr, InPreviewScene, InEditorViewport)
{
SetViewLocation(FVector(0, -300, 200));
SetViewRotation(FRotator(-10, 0, 0));
SetRealtime(true);
EngineShowFlags.SetGrid(true);
EngineShowFlags.EnableAdvancedFeatures();
}

// 渲染主编辑器世界,保证视口内容与主场景一致
virtual UWorld* GetWorld() const override
{
return (GEditor) ? GEditor->GetEditorWorldContext().World() : nullptr;
}

// XR相关切换
void SetShowKeyed(bool bValue)
{
bShowKeyed = bValue;
EngineShowFlags.PostProcessing = bShowKeyed; // 示例:切换后处理
Invalidate();
}

void SetVirtualCamMove(bool bValue)
{
bVirtualCamMove = bValue;
}

void SetMixedRealityBlend(bool bValue)
{
bMixedRealityBlend = bValue;
Invalidate();
}

// 相机参数控制
void SetFOV(float InFOV)
{
ViewFOV = FMath::Clamp(InFOV, 15.f, 170.f);
Invalidate();
}

float GetFOV() const { return ViewFOV; }

void SetExposure(float InExposure)
{
ManualExposureBias = FMath::Clamp(InExposure, -10.f, 10.f);
bApplyExposureBias = true; // 启用曝光补偿
Invalidate();
}

float GetExposure() const { return ManualExposureBias; }

void SetFocalLength(float InFocalLength)
{
FocalLength = FMath::Max(1.f, InFocalLength);
// 使用默认胶片宽度36mm将焦距映射到FOV
const float SensorWidth = 36.0f; // mm
const float HFOVRadians = 2.f * FMath::Atan((SensorWidth * 0.5f) / FocalLength);
SetFOV(FMath::RadiansToDegrees(HFOVRadians));
}

float GetFocalLength() const { return FocalLength; }

void SetAperture(float InAperture)
{
Aperture = FMath::Clamp(InAperture, 0.7f, 32.f);
bApplyDOF = true; // 启用景深覆盖
Invalidate();
}

float GetAperture() const { return Aperture; }

void SetFocusDistance(float InDistance)
{
FocusDistance = FMath::Max(1.f, InDistance);
bApplyDOF = true;
Invalidate();
}

float GetFocusDistance() const { return FocusDistance; }

// 从外部视口复制相机参数
void SetCameraFrom(const FVector& InLoc, const FRotator& InRot, float InFOV, float InOrthoWidth, bool bInIsOrtho)
{
SetViewLocation(InLoc);
SetViewRotation(InRot);
if (bInIsOrtho)
{
SetViewportType(LVT_OrthoFreelook);
SetOrthoZoom(InOrthoWidth);
}
else
{
SetViewportType(LVT_Perspective);
ViewFOV = InFOV;
}
Invalidate();
}

virtual void Tick(float DeltaSeconds) override
{
FEditorViewportClient::Tick(DeltaSeconds);

if (!bVirtualCamMove && GEditor)
{
for (FEditorViewportClient* VC : GEditor->GetAllViewportClients())
{
if (VC && VC->IsPerspective())
{
const FVector Loc = VC->GetViewLocation();
const FRotator Rot = VC->GetViewRotation();
const bool bIsOrtho = VC->IsOrtho();
const float FOV = ViewFOV; // 使用当前FOV
const float OrthoWidth = 2048.f;
SetCameraFrom(Loc, Rot, FOV, OrthoWidth, bIsOrtho);
break;
}
}
}

if (bVirtualCamMove)
{
const float Speed = 15.f;
FRotator R = GetViewRotation();
R.Yaw += Speed * DeltaSeconds;
SetViewRotation(R);
}
}

// 覆盖后处理参数:仅在需要时设置曝光补偿/景深,尽量保持与编辑器视口一致
virtual void OverridePostProcessSettings(FSceneView& View) override
{
FEditorViewportClient::OverridePostProcessSettings(View);

if (bApplyExposureBias)
{
View.FinalPostProcessSettings.bOverride_AutoExposureBias = true;
View.FinalPostProcessSettings.AutoExposureBias = ManualExposureBias;
}

if (bApplyDOF)
{
View.FinalPostProcessSettings.bOverride_DepthOfFieldFstop = true;
View.FinalPostProcessSettings.DepthOfFieldFstop = Aperture;
View.FinalPostProcessSettings.bOverride_DepthOfFieldFocalDistance = true;
View.FinalPostProcessSettings.DepthOfFieldFocalDistance = FocusDistance;
}
}

// XR状态查询
bool IsShowKeyed() const { return bShowKeyed; }
bool IsVirtualCamMove() const { return bVirtualCamMove; }
bool IsMixedRealityBlend() const { return bMixedRealityBlend; }

private:
bool bShowKeyed = false; // 是否显示抠像
bool bVirtualCamMove = false; // 是否虚拟相机运动
bool bMixedRealityBlend = true; // 是否虚实融合

// 相机可调参数
float ManualExposureBias = 0.f; // 曝光补偿(log2EV)
bool bApplyExposureBias = false;
float FocalLength = 35.f; // 焦距(mm)
float Aperture = 2.8f; // 光圈(F)
float FocusDistance = 1000.f; // 对焦距离
bool bApplyDOF = false;
};

TcgXR.Build.cs

文件路径: TcgXR.Build.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// Copyright Epic Games, Inc. All Rights Reserved.

using UnrealBuildTool;

public class TcgXR : ModuleRules
{
public TcgXR(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;

PublicIncludePaths.AddRange(
new string[] {
// ... add public include paths required here ...
}
);


PrivateIncludePaths.AddRange(
new string[] {
// ... add other private include paths required here ...
}
);


PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
// ... add other public dependencies that you statically link with here ...
}
);


PrivateDependencyModuleNames.AddRange(
new string[]
{
"Projects",
"InputCore",
"EditorFramework",
"UnrealEd",
"ToolMenus",
"CoreUObject",
"Engine",
"Slate",
"SlateCore",
"AppFramework",
"LevelEditor",
"CinematicCamera", // 使用电影相机
// ... add private dependencies that you statically link with here ...
}
);


DynamicallyLoadedModuleNames.AddRange(
new string[]
{
// ... add any modules that your module loads dynamically here ...
}
);
}
}

参考资料

  1. 最基础概览视频:我看着这个视频作为最初的入门
  2. Aximmetry Eye Camera Tracking Device:里面还提及了如何解决TimeCode的问题
  3. Virtual Production demo with Aximmetry Eye mobile app:这里面提及了Iphone数据流送回来的方案
  4. Aximmetry Eye VIrtual Camera Tracker:里面有提及标定的事情,也有提及(可能)使用WIFI6能够实现无线传输。