Compare commits

...

5 Commits

Author SHA1 Message Date
linkong
c97dd83f3e Merge pull request 'release: bump version to 0.27.6' (#7) from dev into feature/ue5-led-client
Reviewed-on: #7
2026-04-15 01:20:07 +00:00
linkong
a4e6ce7489 docs: update mocap delivery timeline and migration workflow 2026-04-14 19:20:40 +08:00
linkong
7ffc8537e4 docs: update mocap TODO — confirmed as UE5 plugin, not Live Link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:00:50 +08:00
linkong
4dd396ea65 docs: update Marketplace → Fab in UE setup guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:56:37 +08:00
linkong
1e6f4b338b feat: add UE5 LED display client — backend API, C++ source, stereo framework
Backend:
- Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status)
  returning flat JSON optimised for UE5 C++ parsing

UE5 client (ue_client/):
- PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors
- ComputePointActor / InteractiveObjectBase: hover/select state, material switching
- GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom
- StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD)
- MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD)
- PlanetPlayerController: unified mouse + motion-capture input routing
- PlanetGameMode, Build.cs, Config, mock data

Docs:
- ue5_mvp_fused_plan.md updated to v3.0 for LED display context
- ue_client_setup_guide.md: step-by-step editor setup guide
- ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:39:05 +08:00
29 changed files with 2953 additions and 949 deletions

View File

@@ -15,6 +15,7 @@ from app.api.v1 import (
bgp, bgp,
system_control, system_control,
tv, tv,
ue_data,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"]) api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])

View File

@@ -0,0 +1,351 @@
"""UE Client Data API
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
Avoids GeoJSON nesting — every field is at the top level of each item.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.collected_data_fields import get_record_field
from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.collected_data import CollectedData
router = APIRouter()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _current_stmt(source: str, limit: Optional[int] = None):
stmt = (
select(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.id.desc())
)
if limit:
stmt = stmt.limit(limit)
return stmt
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
result = await db.execute(_current_stmt(source, limit))
return list(result.scalars().all())
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v == v else None # reject NaN
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Status endpoint
# ---------------------------------------------------------------------------
@router.get("/status")
async def ue_status(db: AsyncSession = Depends(get_db)):
"""Quick health-check + data counts for the UE client."""
from datetime import UTC, datetime
async def count_source(source: str) -> int:
result = await db.execute(
select(func.count())
.select_from(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
)
return result.scalar() or 0
return {
"ok": True,
"server_time": to_iso8601_utc(datetime.now(UTC)),
"compute_points_count": await count_source("top500"),
"cables_count": await count_source("telegeography_cables"),
"landing_points_count": await count_source("arcgis_landing"),
"satellites_count": await count_source("celestrak"),
}
# ---------------------------------------------------------------------------
# Compute points (TOP500 supercomputers)
# ---------------------------------------------------------------------------
@router.get("/compute-points")
async def ue_compute_points(
limit: int = Query(default=500, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
):
"""
Returns TOP500 supercomputer data as a flat JSON array.
Response shape:
{
"count": 500,
"items": [
{
"id": "top500_1",
"name": "Frontier",
"latitude": 36.01,
"longitude": -84.26,
"country": "United States",
"city": "Oak Ridge",
"rank": 1,
"rmax_tflops": 1194000.0,
"rpeak_tflops": 1679616.0,
"cores": 8730112,
"power_kw": 22703.0
}
]
}
"""
records = await _fetch(db, "top500", limit)
items = []
for record in records:
meta = record.extra_data or {}
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
items.append({
"id": f"top500_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"rank": meta.get("rank"),
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
"cores": meta.get("cores"),
"power_kw": _safe_float(get_record_field(record, "power")),
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cable landing points
# ---------------------------------------------------------------------------
@router.get("/landing-points")
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
"""
Returns cable landing points as a flat JSON array.
Response shape:
{
"count": 1200,
"items": [
{
"id": "lp_42",
"name": "Shoreham",
"latitude": 50.83,
"longitude": -0.28,
"country": "United Kingdom",
"city": "Shoreham-by-Sea",
"cable_names": ["FLAG", "TAT-14"]
}
]
}
"""
# Load landing points
lp_records = await _fetch(db, "arcgis_landing")
# Load relation + cable data for cable_names mapping
rel_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "arcgis_relation")
.where(CollectedData.is_current.is_(True))
)
rel_records = list(rel_result.scalars().all())
cable_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "telegeography_cables")
.where(CollectedData.is_current.is_(True))
)
cable_records = list(cable_result.scalars().all())
# Build mapping: city_id → list of cable names
city_to_cable_ids: Dict[int, List[int]] = {}
for r in rel_records:
meta = r.extra_data or {}
city_id = meta.get("city_id")
cable_id = meta.get("cable_id")
if city_id is not None and cable_id is not None:
city_to_cable_ids.setdefault(city_id, [])
if cable_id not in city_to_cable_ids[city_id]:
city_to_cable_ids[city_id].append(cable_id)
cable_id_to_name: Dict[int, str] = {}
for r in cable_records:
meta = r.extra_data or {}
cable_id = meta.get("cable_id")
if cable_id and r.name:
cable_id_to_name[cable_id] = r.name
items = []
for record in lp_records:
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
meta = record.extra_data or {}
city_id = meta.get("city_id")
cable_names = []
if city_id in city_to_cable_ids:
cable_names = [
cable_id_to_name[cid]
for cid in city_to_cable_ids[city_id]
if cid in cable_id_to_name
]
items.append({
"id": f"lp_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"cable_names": cable_names,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cables (route geometry)
# ---------------------------------------------------------------------------
@router.get("/cables")
async def ue_cables(db: AsyncSession = Depends(get_db)):
"""
Returns cable route geometry.
Each segment is a flat array of [lon, lat] pairs.
Response shape:
{
"count": 100,
"items": [
{
"id": "cable_42",
"cable_id": "flag",
"name": "FLAG",
"status": "active",
"length_km": 28000,
"segments": [
[[lon, lat], [lon, lat], ...]
]
}
]
}
"""
records = await _fetch(db, "telegeography_cables")
items = []
for record in records:
meta = record.extra_data or {}
route_coords = meta.get("route_coordinates", [])
segments: List[List[List[float]]] = []
if route_coords:
# Support both flat [lon,lat] array and array-of-arrays
if route_coords and isinstance(route_coords[0][0], list):
raw_lines = route_coords
else:
raw_lines = [route_coords]
for raw_line in raw_lines:
line = []
for pt in raw_line:
try:
line.append([float(pt[0]), float(pt[1])])
except (TypeError, ValueError, IndexError):
continue
if len(line) >= 2:
segments.append(line)
if not segments:
continue
items.append({
"id": f"cable_{record.id}",
"cable_id": record.source_id or record.name or "",
"name": record.name or "Unknown",
"status": meta.get("status", "active"),
"length_km": _safe_float(get_record_field(record, "value")),
"owners": meta.get("owners") or [],
"rfs": meta.get("rfs"),
"color": meta.get("color"),
"segments": segments,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Satellites (TLE data)
# ---------------------------------------------------------------------------
@router.get("/satellites")
async def ue_satellites(
limit: int = Query(default=200, ge=1, le=5000),
db: AsyncSession = Depends(get_db),
):
"""
Returns satellite TLE data for orbit propagation in UE.
Response shape:
{
"count": 200,
"items": [
{
"id": "sat_42",
"norad_id": "25544",
"name": "ISS (ZARYA)",
"tle_line1": "1 25544U ...",
"tle_line2": "2 25544 ...",
"epoch": "2026-04-14T00:00:00Z",
"inclination": 51.6,
"mean_motion": 15.5
}
]
}
"""
records = await _fetch(db, "celestrak", limit)
items = []
for record in records:
meta = record.extra_data or {}
norad_id = meta.get("norad_cat_id")
if not norad_id:
continue
tle1 = meta.get("tle_line1")
tle2 = meta.get("tle_line2")
if not tle1 or not tle2:
tle1, tle2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=meta.get("epoch"),
inclination=meta.get("inclination"),
raan=meta.get("raan"),
eccentricity=meta.get("eccentricity"),
arg_of_perigee=meta.get("arg_of_perigee"),
mean_anomaly=meta.get("mean_anomaly"),
mean_motion=meta.get("mean_motion"),
)
items.append({
"id": f"sat_{record.id}",
"norad_id": str(norad_id),
"name": record.name or "Unknown",
"tle_line1": tle1 or "",
"tle_line2": tle2 or "",
"epoch": meta.get("epoch") or "",
"inclination": _safe_float(meta.get("inclination")),
"raan": _safe_float(meta.get("raan")),
"eccentricity": _safe_float(meta.get("eccentricity")),
"mean_motion": _safe_float(meta.get("mean_motion")),
})
return {"count": len(items), "items": items}

144
docs/ue5_led_context.md Normal file
View File

@@ -0,0 +1,144 @@
# 项目背景UE5 + 3D LED 大屏展示系统
> 供其他 AI 快速了解项目背景和当前状态。
> 最后更新2026-04-14
---
## 一、项目定位
这不是一个普通的桌面地球应用,而是一套**领导演示用的 3D 沉浸式展示系统**。
核心逻辑:
- 态势感知数据(超算、海缆、卫星)是内容
- 3D LED 大屏 + 实时渲染 + 动捕交互是"醋"——没有它,内容再好也只是普通屏幕
- 主要受众:领导/决策层,注重视觉冲击力
---
## 二、硬件配置
| 硬件 | 规格 | 说明 |
|------|------|------|
| 3D LED 大屏 | 5.12m × 2.88mP1.538mm,被动偏振式 | 观众戴无源 3D 眼镜≤18g60Hz 即可出稳定 3D |
| 渲染工作站 | 双路国产 X86 CPU + RTX 5090 32GB | 驱动 UE5 实时渲染 |
| 视频处理器 | 随屏配套(品牌待确认) | 接收 GPU 信号,驱动 LED 墙 |
| 动捕摄像头 | RGB 摄像头 ×24K直连电脑 | 无穿戴姿态识别 |
| 音响 | 解码功放 + 吸顶喇叭 ×5 + 低音炮 | 配套 |
---
## 三、软件架构
```
摄像头×2直连
↓ 动捕插件(供应商提供 UE5 插件)
UE5 主程序(我们开发)
├── Cesium 地球(实时渲染)
├── 超算数据点(可交互)
├── 其他可交互物件
└── 立体渲染输出
↓ 视频信号(格式待确认)
视频处理器(随屏配套)
↓ LED 驱动信号(行偏振)
5m 被动偏振 3D LED 大屏
```
---
## 四、分工
| 部分 | 谁做 | 状态 |
|------|------|------|
| LED 屏体 + 视频处理器 | 屏幕供应商 | 采购中 |
| 动捕插件UE5 插件形式) | 动捕供应商 | 待交付 |
| UE5 基础工程(关卡+角色+动捕绑定) | 动捕供应商 | 待交付 |
| 地球场景 + 数据可视化 + 交互逻辑 | 我们(本项目) | 开发中 |
| 后端数据接口 | 我们(本项目) | 已完成 |
| 其他 9 个定制 3D 资产和动画 | 3D 内容供应商 | 采购中 |
---
## 五、动捕交互方式
**交付形式(已确认)**
- 供应商给我们**完整 UE5 工程**,包含:
- 视频动捕插件
- 已配好绑定和重定向的 3D 角色
- 3D 模型和动画资产
- 接两台摄像头即可直接运行
- **我们的任务**:把他们工程的内容(插件 + 角色 + 资产)**迁移进我们的 `ue_client/` 工程**,然后把角色动作映射到地球操作
**待确认**:角色动作的触发点是什么形式?
- 蓝图 Custom Event`OnGestureRotate`
- AnimNotify
- 需要我们自己判断骨骼姿态?
**交互目标(一期)**
1. 手势旋转地球
2. 手势缩放地球
3. 手势指向/确认 → 选中数据点,弹出信息卡
4. 鼠标作为备用输入(始终可用)
---
## 六、立体渲染
**待确认**
1. 供应商基础工程里是否已配好立体渲染输出?
2. 如果没有视频处理器接受什么格式Side-by-Side / Top-Bottom / 其他)
3. 给供应商的文件形式UE5 工程 / .exe / 视频文件 / 直连实时输出?
**已准备**`StereoRenderingManager.h/.cpp` 支持运行时切换 SbS/TbB格式确认后直接启用。
---
## 七、已完成的代码
### 后端 (`backend/app/api/v1/ue_data.py`)
- `GET /api/v1/ue/status` — 健康检查
- `GET /api/v1/ue/compute-points` — TOP500 超算(平铺 JSON
- `GET /api/v1/ue/landing-points` — 海缆登陆点
- `GET /api/v1/ue/cables` — 海缆路由几何
- `GET /api/v1/ue/satellites` — 卫星 TLE 数据
### UE5 C++ (`ue_client/Source/PlanetClient/`)
| 文件 | 功能 |
|------|------|
| `PlanetDataTypes.h` | FComputePoint 等数据结构 |
| `PlanetDataManager` | HTTP 拉取 + mock 数据 + 生成 Actor |
| `ComputePointActor` | 超算点 Actor三态材质正常/悬停/选中)|
| `InteractiveObjectBase` | 所有可交互物件的基类 |
| `GlobeInteractionComponent` | 拖拽旋转地球(改 Cesium 经纬度原点)+ 缩放,带惯性 |
| `StereoRenderingManager` | 立体渲染开关SbS/TbBIPD 可调 |
| `MotionCaptureInterface.h` | 动捕接口抽象(待用插件 API 替换实现)|
| `PlanetPlayerController` | 统一处理鼠标 + 动捕输入 |
| `PlanetGameMode` | 场景入口 |
### 文档
- `docs/ue_client_setup_guide.md` — 编辑器操作 step-by-step 指南
- `docs/ue_todo.md` — 待供应商回复的 TODO
- `docs/ue5_mvp_fused_plan.md` — 完整实施方案v3.0
---
## 八、交付时间线
| 时间 | 内容 | 状态 |
|------|------|------|
| 本周末前 | 动捕供应商:含动捕插件 + 3D 角色(绑定/重定向已配好)的基础 UE5 工程 | 等待中 |
| 之后尽快 | 动捕供应商:动画资产(复制进 Content/ 即可直接调用) | 等待中 |
| TBD | LED 屏供应商:视频处理器接受的 3D 信号格式(或直接技术支持对接) | 等待中 |
**拿到基础工程后可立即做**:迁移插件和角色,接摄像头做动捕调试,然后对接动作→地球操作映射。
**双目 3D 显示**:供应商可提供技术支持,等视频处理器到位后直接对接。
---
## 九、当前阻塞项
1. **动捕基础工程**(本周末前到)→ 迁移内容,确认动作触发方式,完成交互对接
2. **动画资产**(尽快)→ 复制入 Content/,在场景中引用
3. **3D 显示格式**(有供应商技术支持)→ 配置 `StereoRenderingManager`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,319 @@
# UE5 客户端手动操作指南
> 本指南对应 `ue_client/` 目录下已生成的所有代码和配置。
> 代码已写好,你只需要做编辑器里的点击操作。
> 遇到红色错误先看文末"常见问题"章节。
---
## 前置条件
| 软件 | 版本 | 下载地址 |
|------|------|---------|
| Unreal Engine | **5.3** | Epic Games Launcher → Library → 5.3 |
| Cesium for Unreal | 最新 | 直接在下一步从 Marketplace 安装 |
| Visual Studio | 2022 Community | visualstudio.microsoft.com安装 C++ 游戏开发工作负载) |
> **注意**Cesium for Unreal 必须先安装,否则代码会编译失败。
---
## 第一阶段Phase A本地演示版不需要后端
### 步骤 1安装 Cesium for Unreal
1. 打开 **Epic Games Launcher**
2. 顶部切换到 **Unreal Engine** 选项卡
3. 左侧点击 **Fab**(原 Marketplace已改名→ 搜索 `Cesium for Unreal`
4. 点击 **免费获取**Free然后点击 **安装到引擎** → 选择 5.3
5. 等待安装完成
---
### 步骤 2打开项目
1. 打开 **Epic Games Launcher****Unreal Engine****Library**
2. 找到 5.3,点击右侧 **Launch** 旁边的下拉箭头 → **Browse**
3. 导航到 `planet/ue_client/`,选择 `PlanetClient.uproject`,点击打开
4. UE 会提示"缺少模块,需要重新编译" → 点击 **Yes**
5. 等待编译完成(首次约 5-10 分钟)
> 如果编译报错:见文末"常见问题 → 编译错误"
---
### 步骤 3创建新关卡
1. 菜单栏 → **File****New Level**
2. 选择 **Empty Level**(空关卡)
3. 保存:**File** → **Save Current Level As**
路径:`Content/Maps/`,名称:`EarthMap`
4. 点击 **Save**
---
### 步骤 4添加 Cesium 地球
1. 顶部菜单栏 → **Cesium**如果没有此菜单说明插件未激活Edit → Plugins → 搜索 Cesium → 勾选 Enable → 重启)
2. 在 Cesium 面板里点击 **Add Blank 3D Tiles Tileset** — 这会自动在场景里添加:
- `CesiumGeoreference` Actor
- `Cesium3DTileset` Actor地球瓦片
3. 再点击 **Add Cesium ion Bing Maps Aerial** 添加卫星影像底图(需要免费 Cesium ion 账号)
> 如果没有 Cesium ion 账号Cesium 菜单 → Connect to Cesium ion → 注册免费账号
---
### 步骤 5添加相机 Pawn
1. 菜单栏 → **Cesium** → 找到 **Dynamic Pawn**(名称可能是 `CesiumFlyToComponent` 相关的 Blueprint
**或者**Content Browser → 顶部搜索框输入 `DynamicPawn` → 找到插件内容里的 `DynamicPawn` → 拖入场景
2.**Outliner** 面板里点击刚拖入的 `DynamicPawn`
3.**Details** 面板里,找到 **Auto Possess Player** → 改为 **Player 0**
> 这样游戏启动时摄像机会自动使用这个可飞行的地球相机。
---
### 步骤 6配置 GameMode
1. 菜单栏 → **Window****World Settings**(如果没有,也可以在 Details Panel 里找)
2.**World Settings** 面板里找到 **Game Mode Override**
3. 点击下拉框 → 搜索 `PlanetGameMode` → 选择它
4. 找到 **Default Pawn Class** → 改为上一步拖入的 `DynamicPawn`
---
### 步骤 7创建 ComputePoint Blueprint
这步把我写的 C++ 类包装成可以在编辑器里配置材质的 Blueprint。
1. **Content Browser** → 空白处右键 → **Blueprint Class**
2. 搜索父类:输入 `ComputePointActor` → 找到 `AComputePointActor` → 点击 **Select**
3. 命名为 `BP_ComputePointActor`,保存到 `Content/Blueprints/`
**配置 BP_ComputePointActor 的网格和材质:**
4. 双击打开 `BP_ComputePointActor`
5. 在左侧 **Components** 面板里点击 `SphereMesh`
6. 在右侧 **Details** 面板里找到 **Static Mesh** → 点击下拉 → 搜索 `Sphere` → 选择 **Engine/BasicShapes/Sphere**
7. 找到 **Material** → 点击下拉 → 搜索 `M_Basic_Wall` 或者创建新材质(见下方)
**创建三种状态的材质(颜色点即可):**
8. Content Browser → 右键 → **Material** → 命名 `M_PointNormal`
- 双击打开 → 右键空白区域 → 搜索 `Constant3Vector` → 连接到 `Base Color`
- 颜色设为橙色:`(1.0, 0.4, 0.0)`
- 保存
9. 同样方式创建 `M_PointHovered`(颜色白色 `1,1,1`)和 `M_PointSelected`(颜色青色 `0,1,1`
**回到 BP_ComputePointActor**
10.**Details** 面板里:
- **Normal Material** → 选 `M_PointNormal`
- **Hovered Material** → 选 `M_PointHovered`
- **Selected Material** → 选 `M_PointSelected`
- **Point Scale** → `80000`(根据实际效果调整)
11. 点击左上角 **Compile****Save**
---
### 步骤 8放置 DataManager 并配置
1. **Content Browser** → 右键 → **Blueprint Class** → 父类搜索 `PlanetDataManager` → 选择 `APlanetDataManager`
2. 命名为 `BP_PlanetDataManager`,保存到 `Content/Blueprints/`
3.`BP_PlanetDataManager` **拖入场景**Outliner 里会出现它)
4. 在 Outliner 里点击它 → 在 **Details** 面板里配置:
- **Use Local Mock Data** → ✅ **勾选**Phase A 不需要后端)
- **Mock Data Path** → 留空(代码会自动找 `Content/Data/mock_compute_points.json`
- **Compute Point Class** → 选择 `BP_ComputePointActor`
- **Point Altitude Meters** → `50000`(海拔 50km可调
---
### 步骤 9测试 Phase A
1. 点击顶部工具栏绿色 **Play** 按钮(或 Alt+P
2. 地球应该加载卫星影像
3. 应该看到 10 个橙色球体分布在地球上(对应 mock JSON 里的 TOP500 超算)
4. 鼠标移到球体上 → 变白色Hover
5. 点击球体 → 变青色Selected
**验证通过标准:**
- [x] 地球可见
- [x] 橙色球体出现在正确位置(美国、日本、荷兰、芬兰等)
- [x] 悬停变色
- [x] 点击变色
---
## 第二阶段Phase B连接真实后端
### 步骤 10确认后端新接口可用
后端代码已添加新路由,先验证它已经运行:
```bash
# 在 WSL 或终端里
curl http://localhost:8000/api/v1/ue/status
```
应该返回类似:
```json
{"ok": true, "server_time": "...", "compute_points_count": 500, ...}
```
如果 curl 失败:
- 检查 `planet.sh` 是否在运行(`./planet.sh start`
- 检查 WSL2 → Windows 的网络:在 UE 里使用 `172.x.x.x`WSL 网关地址)而不是 `localhost`
---
### 步骤 11获取 WSL2 → Windows 的正确 IP
在 WSL 终端里运行:
```bash
cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
```
记下这个 IP例如 `172.22.32.1`)。
---
### 步骤 12切换 DataManager 到实时模式
1. 在 Outliner 里点击 `BP_PlanetDataManager`
2. Details 面板里:
- **Use Local Mock Data** → **取消勾选**
- **Backend Base URL** → 填入 `http://172.22.32.1:8000`(你的实际 WSL IP
3. 重新 Play → DataManager 会通过 HTTP 拉取真实数据
---
### 步骤 13绑定点击事件显示信息卡可选需要 UMG
这步是可选的,需要创建一个 Widget Blueprint 来显示选中点的信息。
1. Content Browser → 右键 → **User Interface****Widget Blueprint**
2. 命名为 `WBP_PointInfo`
**设计 Widget 布局:**
3. 双击打开 `WBP_PointInfo`
4. 从左侧 **Palette** 拖入以下控件到画布:
- `Canvas Panel`(容器,设置为全屏)
- `Border`(右下角定位,用作信息卡背景,宽 300高 200
- `Text Block` × 4名称、排名、算力、国家
**绑定事件Blueprint 里操作):**
5. 打开 `BP_PlanetDataManager` 的 Event Graph
6. 找到 **BeginPlay** 节点
7. 拖出线 → 搜索 **Bind Event to On Point Selected**(这是我在 PlayerController 里定义的委托)
> 具体蓝图连线:从 PlayerController 获取 OnPointSelected → Bind → 在回调里 Create Widget WBP_PointInfo → Add to Viewport → Set 各个文本
---
## 文件结构总览
```
ue_client/
PlanetClient.uproject ← UE 项目入口
Config/
DefaultGame.ini ← GameMode 配置
DefaultEngine.ini ← 渲染/引擎设置
DefaultInput.ini ← 键鼠输入绑定
Content/
Data/
mock_compute_points.json ← Phase A 本地测试数据10个超算
Maps/
EarthMap.umap ← 你在步骤3创建的关卡
Blueprints/
BP_ComputePointActor ← 步骤7创建
BP_PlanetDataManager ← 步骤8创建
Source/
PlanetClient/
PlanetClient.Build.cs ← 模块依赖HTTP、JSON、Cesium
PlanetClient.h/.cpp ← 模块入口
PlanetDataTypes.h ← 数据结构定义FComputePoint 等)
PlanetDataManager.h/.cpp ← HTTP 拉取 + 生成 Actor
ComputePointActor.h/.cpp ← 单个超算点的可视化 Actor
PlanetPlayerController.h/.cpp ← 鼠标点击、悬停、相机控制
PlanetGameMode.h/.cpp ← GameMode 入口
```
---
## 后端新接口一览
后端已新增以下接口(无需认证,直接访问):
| 接口 | 说明 |
|------|------|
| `GET /api/v1/ue/status` | 健康检查 + 各数据源数量 |
| `GET /api/v1/ue/compute-points` | TOP500 超算数据(平铺 JSON |
| `GET /api/v1/ue/landing-points` | 海缆登陆点(平铺 JSON |
| `GET /api/v1/ue/cables` | 海缆路由几何segments 数组) |
| `GET /api/v1/ue/satellites` | 卫星 TLE 数据 |
---
## 常见问题
### Q: 编译报错 "Cannot open include file: CesiumGeoreference.h"
**A:** Cesium for Unreal 没有正确安装,或者没有在 `.uproject` 里启用。检查:
1. Epic Launcher → 插件是否安装到 5.3
2. `PlanetClient.uproject``Plugins` 数组是否有 `CesiumForUnreal: true`
3. UE 编辑器 → Edit → Plugins → 搜索 Cesium → 确认已勾选 Enabled
### Q: Play 之后没有看到橙色球体
**A:** 按以下顺序排查:
1. Output LogWindow → Output Log里搜索 `PlanetDataManager` — 查看是否有报错
2. 检查 `BP_PlanetDataManager` 的 Details → **Compute Point Class** 是否已设置为 `BP_ComputePointActor`
3. 检查 **Mock Data Path** 是否正确(留空则自动用 `Content/Data/mock_compute_points.json`
4. 检查 **Use Local Mock Data** 是否已勾选
### Q: 地球是灰色的没有卫星影像
**A:** 需要 Cesium ion 账号:
1. Cesium 菜单 → Connect to Cesium ion
2. 注册免费账号并授权
3. 重新添加 **Cesium ion Bing Maps Aerial** tileset
### Q: WSL2 里的后端 UE 无法访问Phase B
**A:** WSL2 和 Windows 是不同网络命名空间。方法:
1. 在 WSL 里运行 `ip route show default | awk '{print $3}'` — 这是 Windows 主机的 IP
2. 后端绑定到 `0.0.0.0:8000`(检查 `uvicorn` 启动参数,应已如此配置)
3. 在 UE 的 DataManager 里填写这个 IP 而不是 `localhost`
### Q: TransformLongitudeLatitudeHeightPositionToUnreal 不存在
**A:** Cesium for Unreal API 在不同版本有变化。如果编译报此错,将 `PlanetDataManager.cpp` 里的调用改为:
```cpp
// Cesium for Unreal v1.x 的旧 API
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightToUnreal(
Pt.Longitude, Pt.Latitude, PointAltitudeMeters);
// 或者通过 GeoTransforms
#include "CesiumGlobeAnchorComponent.h"
// ... 见 Cesium 文档
```
### Q: 点击球体没有反应
**A:**
1. 确认 `SphereMesh`**Collision Presets** 不是 `NoCollision`
- 打开 `BP_ComputePointActor` → 点击 `SphereMesh` → Details → Collision → 改为 `BlockAllDynamic`
2. 确认 PlayerController 的 `bEnableClickEvents = true`(代码里已设置)
3. Output Log 里搜索是否有输入相关报错
---
## 下一步(一期完成后)
| 功能 | 说明 |
|------|------|
| 海缆路径渲染 | `/api/v1/ue/cables` 已就绪,需要在 UE 里用 Spline 绘制 |
| 信息卡 UMG | 创建 Widget Blueprint 并在 PlayerController OnPointSelected 里显示 |
| WebSocket 实时更新 | 后端已有 WebSocketUE 端需要使用 WebSockets 插件 |
| 卫星轨迹 | TLE 数据已就绪,需要在 UE 里做轨道传播计算 |

90
docs/ue_todo.md Normal file
View File

@@ -0,0 +1,90 @@
# UE5 客户端 — 待供应商回复的 TODO
> 以下两个问题答复后,对应代码可在一天内完成。
> 其余所有代码均已写好,不依赖这两个答案。
---
## TODO-1视频处理器输入格式
**等待信息**LED 屏配套视频处理器(如诺瓦星云)接受什么 3D 信号格式?
| 可能答案 | 对应操作 |
|---------|---------|
| Side-by-Side左右并排 | `StereoRenderingManager.cpp``EnableStereo``SideBySide` 分支已写好,直接启用 |
| Top-Bottom上下叠加 | 同上,切换到 `TopBottom` 分支 |
| 行交错(行偏振直驱) | 需要新写一个 PostProcess Material把左右眼奇偶行合并输出 |
| 私有协议 | 需要供应商提供 UE5 插件或信号格式文档 |
**代码位置**
```
ue_client/Source/PlanetClient/StereoRenderingManager.h — 第 8-18 行 TODO 注释
ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函数
```
**需要同时确认**
- 视频处理器品牌和型号
- 屏幕物理分辨率(用于配置 UE5 输出分辨率)
- 3D 启用时是否需要特殊信号时序(如 3D Frame Packing
---
## TODO-2动捕角色接入
**已确认**
- **本周末前**:供应商交付含动捕插件 + 3D 角色(已配好绑定和重定向)的基础 UE5 工程
- 接入两台摄像头4080 以上显卡即可直接运行动捕调试
- **之后尽快**:动画资产单独交付,复制到工程 Content/ 目录即可直接调用
- **我们的任务**:把他们工程内容迁移进 `ue_client/`,把角色动作映射到地球操作
---
### 阶段 A拿到基础工程后本周末
**迁移步骤**
1. **迁移动捕插件**
- 从供应商工程 `Plugins/` 拷到 `ue_client/Plugins/`
- `PlanetClient.uproject``Plugins` 数组添加插件条目(`Enabled: true`
- `PlanetClient.Build.cs``PublicDependencyModuleNames` 加插件模块名
2. **迁移角色**
- 把角色 Blueprint、动画、骨骼网格从供应商 `Content/` 拷到 `ue_client/Content/`
- 在关卡里放置角色 Actor确认摄像头接入后能正常驱动
3. **接入动作触发(关键,看到工程后确认方式)**
| 需要确认的内容 | 用途 |
|-------------|------|
| 角色动作怎么暴露给外部? | Blueprint Custom Event / AnimNotify / 骨骼姿态变量? |
| 手势集合有哪些? | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` |
| 是否持续输出旋转增量? | 旋转地球用"持续增量"还是"离散手势触发" |
| 插件模块名称(`ModuleName`| 加入 `Build.cs` 依赖 |
**拿到工程后(我来做)**
- 用插件实际 API 实现 `UMotionCaptureReceiver` 子类
-`PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()`
**代码位置(接口已预留)**
```
ue_client/Source/PlanetClient/MotionCaptureInterface.h — 基类和手势枚举
ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents()
ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释处加插件模块名
ue_client/PlanetClient.uproject — Plugins 数组加插件条目
```
---
### 阶段 B动画资产到位后
- 把供应商提供的动画资产直接复制到 `ue_client/Content/` 对应目录
-`PlanetDataManager` / 场景 Actor 里引用这些资产即可调用
---
## 答复到位后的操作清单
拿到答案后告诉我,我来:
1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide
2. **动捕插件** → 插件放入 `ue_client/Plugins/`,实现 `UMotionCaptureReceiver` 子类对接插件 API更新 `Build.cs``.uproject`

View File

@@ -0,0 +1,10 @@
[/Script/Engine.RendererSettings]
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True
r.AntiAliasingMethod=4
[/Script/Engine.Engine]
NearClipPlane=1.0
[Core.System]
Paths=../../../Engine/Content
Paths=%GAMEDIR%Content

View File

@@ -0,0 +1,3 @@
[/Script/EngineSettings.GameMapsSettings]
GlobalDefaultGameMode=/Script/PlanetClient.PlanetGameMode
GameDefaultMap=/Game/Maps/EarthMap

View File

@@ -0,0 +1,6 @@
[/Script/Engine.InputSettings]
+ActionMappings=(ActionName="SelectPoint",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=LeftMouseButton)
+ActionMappings=(ActionName="RightMouseDown",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=RightMouseButton)
+AxisMappings=(AxisName="ZoomCamera",Scale=-1.0,Key=MouseWheelAxis)
+AxisMappings=(AxisName="RotateYaw",Scale=1.0,Key=MouseX)
+AxisMappings=(AxisName="RotatePitch",Scale=-1.0,Key=MouseY)

View File

@@ -0,0 +1,135 @@
{
"count": 10,
"items": [
{
"id": "top500_1",
"name": "Frontier",
"latitude": 36.01,
"longitude": -84.26,
"country": "United States",
"city": "Oak Ridge",
"rank": 1,
"rmax_tflops": 1194000.0,
"rpeak_tflops": 1679616.0,
"cores": 8730112,
"power_kw": 22703.0
},
{
"id": "top500_2",
"name": "Aurora",
"latitude": 41.71,
"longitude": -87.98,
"country": "United States",
"city": "Lemont",
"rank": 2,
"rmax_tflops": 1012000.0,
"rpeak_tflops": 1321000.0,
"cores": 9264128,
"power_kw": 38698.0
},
{
"id": "top500_3",
"name": "Eagle",
"latitude": 52.36,
"longitude": 4.90,
"country": "Netherlands",
"city": "Amsterdam",
"rank": 3,
"rmax_tflops": 561200.0,
"rpeak_tflops": 736000.0,
"cores": 4620800,
"power_kw": 12410.0
},
{
"id": "top500_4",
"name": "Fugaku",
"latitude": 34.66,
"longitude": 135.22,
"country": "Japan",
"city": "Kobe",
"rank": 4,
"rmax_tflops": 442010.0,
"rpeak_tflops": 537212.0,
"cores": 7630848,
"power_kw": 29899.0
},
{
"id": "top500_5",
"name": "LUMI",
"latitude": 65.01,
"longitude": 25.46,
"country": "Finland",
"city": "Kajaani",
"rank": 5,
"rmax_tflops": 379700.0,
"rpeak_tflops": 531000.0,
"cores": 2748384,
"power_kw": 6016.0
},
{
"id": "top500_6",
"name": "MareNostrum 5",
"latitude": 41.39,
"longitude": 2.11,
"country": "Spain",
"city": "Barcelona",
"rank": 6,
"rmax_tflops": 138200.0,
"rpeak_tflops": 314000.0,
"cores": 2764800,
"power_kw": 10000.0
},
{
"id": "top500_7",
"name": "Summit",
"latitude": 35.93,
"longitude": -84.31,
"country": "United States",
"city": "Oak Ridge",
"rank": 7,
"rmax_tflops": 148600.0,
"rpeak_tflops": 200795.0,
"cores": 2414592,
"power_kw": 10096.0
},
{
"id": "top500_8",
"name": "Tianhe-2A",
"latitude": 23.13,
"longitude": 113.27,
"country": "China",
"city": "Guangzhou",
"rank": 8,
"rmax_tflops": 100679.0,
"rpeak_tflops": 100679.0,
"cores": 4981760,
"power_kw": 18482.0
},
{
"id": "top500_9",
"name": "Selene",
"latitude": 40.82,
"longitude": -74.17,
"country": "United States",
"city": "Santa Clara",
"rank": 9,
"rmax_tflops": 63460.0,
"rpeak_tflops": 79200.0,
"cores": 555520,
"power_kw": 2646.0
},
{
"id": "top500_10",
"name": "Perlmutter",
"latitude": 37.88,
"longitude": -122.25,
"country": "United States",
"city": "Berkeley",
"rank": 10,
"rmax_tflops": 93750.0,
"rpeak_tflops": 120000.0,
"cores": 761856,
"power_kw": 3060.0
}
]
}

View File

@@ -0,0 +1,53 @@
#include "ComputePointActor.h"
#include "Components/StaticMeshComponent.h"
AComputePointActor::AComputePointActor()
{
PrimaryActorTick.bCanEverTick = false;
SphereMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("SphereMesh"));
RootComponent = SphereMesh;
// Enable mouse-over events so the PlayerController can detect hover
SphereMesh->bReceivesDecals = false;
}
void AComputePointActor::BeginPlay()
{
Super::BeginPlay();
SetActorScale3D(FVector(PointScale));
ApplyMaterial();
}
void AComputePointActor::SetPointData(const FComputePoint& Data)
{
PointData = Data;
// Optional: name the actor in the Outliner for easy debugging
SetActorLabel(FString::Printf(TEXT("[%d] %s"), Data.Rank, *Data.Name));
}
void AComputePointActor::SetVisualState(EPointVisualState NewState)
{
if (VisualState == NewState) return;
VisualState = NewState;
ApplyMaterial();
}
void AComputePointActor::ApplyMaterial()
{
if (!SphereMesh) return;
UMaterialInterface* Mat = nullptr;
switch (VisualState)
{
case EPointVisualState::Hovered: Mat = HoveredMaterial; break;
case EPointVisualState::Selected: Mat = SelectedMaterial; break;
default: Mat = NormalMaterial; break;
}
if (Mat)
{
SphereMesh->SetMaterial(0, Mat);
}
}

View File

@@ -0,0 +1,95 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PlanetDataTypes.h"
#include "ComputePointActor.generated.h"
class UStaticMeshComponent;
class UBillboardComponent;
UENUM(BlueprintType)
enum class EPointVisualState : uint8
{
Normal UMETA(DisplayName="Normal"),
Hovered UMETA(DisplayName="Hovered"),
Selected UMETA(DisplayName="Selected"),
};
/**
* AComputePointActor
*
* Represents one supercomputer data point on the Cesium globe.
*
* Usage:
* 1. Create a Blueprint subclass: Content Browser → New Blueprint →
* search "AComputePointActor" as parent class → name it BP_ComputePointActor
* 2. In the Blueprint, assign a sphere mesh to SphereMesh (Engine/BasicShapes/Sphere)
* 3. Assign a material to NormalMaterial / HoveredMaterial / SelectedMaterial
* 4. Set this Blueprint class in APlanetDataManager → ComputePointClass
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API AComputePointActor : public AActor
{
GENERATED_BODY()
public:
AComputePointActor();
// -----------------------------------------------------------------------
// Components
// -----------------------------------------------------------------------
// The visible sphere. Assign a sphere mesh in your Blueprint subclass.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
UStaticMeshComponent* SphereMesh;
// -----------------------------------------------------------------------
// Materials (assign in Blueprint subclass)
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* NormalMaterial;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* HoveredMaterial;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* SelectedMaterial;
// -----------------------------------------------------------------------
// Point scale (world units). Tweak this in the Details Panel.
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
float PointScale = 80000.f;
// -----------------------------------------------------------------------
// Data (set by APlanetDataManager after spawn)
// -----------------------------------------------------------------------
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FComputePoint PointData;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
EPointVisualState VisualState = EPointVisualState::Normal;
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
// Called by APlanetDataManager immediately after spawning.
UFUNCTION(BlueprintCallable, Category="Planet")
void SetPointData(const FComputePoint& Data);
// Called by PlanetPlayerController on hover/select.
UFUNCTION(BlueprintCallable, Category="Planet")
void SetVisualState(EPointVisualState NewState);
// Convenience: is this point currently selected?
UFUNCTION(BlueprintPure, Category="Planet")
bool IsSelected() const { return VisualState == EPointVisualState::Selected; }
protected:
virtual void BeginPlay() override;
private:
void ApplyMaterial();
};

View File

@@ -0,0 +1,122 @@
#include "GlobeInteractionComponent.h"
#include "CesiumGeoreference.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"
UGlobeInteractionComponent::UGlobeInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = true;
}
void UGlobeInteractionComponent::BeginPlay()
{
Super::BeginPlay();
FindGeoreference();
}
void UGlobeInteractionComponent::FindGeoreference()
{
Georeference = ACesiumGeoreference::GetDefaultGeoreference(GetWorld());
if (!Georeference)
{
UE_LOG(LogTemp, Error,
TEXT("GlobeInteractionComponent: 场景中没有 CesiumGeoreference请添加一个"));
}
}
// ---------------------------------------------------------------------------
// 旋转
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::RotateGlobe(float DeltaYaw, float DeltaPitch)
{
if (!Georeference) return;
// 累积惯性速度(本帧的输入叠加到惯性上)
InertiaYaw += DeltaYaw * RotateSensitivity;
InertiaPitch += DeltaPitch * RotateSensitivity;
// 立即应用Tick 里再处理惯性衰减)
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
double NewLat = FMath::Clamp(
Georeference->GetOriginLatitude() + InertiaPitch,
-85.0, 85.0
);
Georeference->SetOriginLongitude(NewLon);
Georeference->SetOriginLatitude(NewLat);
// 保持经度在 [-180, 180]
if (NewLon > 180.0) Georeference->SetOriginLongitude(NewLon - 360.0);
if (NewLon < -180.0) Georeference->SetOriginLongitude(NewLon + 360.0);
}
// ---------------------------------------------------------------------------
// 缩放
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::ZoomGlobe(float Value)
{
if (FMath::IsNearlyZero(Value)) return;
// Value > 0 = 拉近高度减小Value < 0 = 推远(高度增大)
CurrentAltitudeMeters -= Value * ZoomSensitivity;
CurrentAltitudeMeters = FMath::Clamp(CurrentAltitudeMeters,
MinAltitudeMeters,
MaxAltitudeMeters);
ApplyAltitudeToCamera();
}
void UGlobeInteractionComponent::ApplyAltitudeToCamera()
{
if (!Georeference) return;
// 把当前 Origin 经纬度 + 新高度转换为 Unreal 世界坐标,然后移动拥有者 Pawn
AController* Controller = Cast<AController>(GetOwner());
APawn* Pawn = Controller ? Controller->GetPawn() : Cast<APawn>(GetOwner());
if (!Pawn) return;
double Lon = Georeference->GetOriginLongitude();
double Lat = Georeference->GetOriginLatitude();
FVector NewPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
FVector(Lon, Lat, CurrentAltitudeMeters)
);
Pawn->SetActorLocation(NewPos);
}
// ---------------------------------------------------------------------------
// 惯性 Tick
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 只有惯性速度足够大时才继续转动
if (FMath::IsNearlyZero(InertiaYaw, 0.001f) &&
FMath::IsNearlyZero(InertiaPitch, 0.001f)) return;
if (!Georeference) return;
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
double NewLat = FMath::Clamp(
Georeference->GetOriginLatitude() + InertiaPitch,
-85.0, 85.0
);
Georeference->SetOriginLongitude(NewLon);
Georeference->SetOriginLatitude(NewLat);
// 惯性衰减
InertiaYaw *= InertiaDamping;
InertiaPitch *= InertiaDamping;
}
void UGlobeInteractionComponent::StopInertia()
{
InertiaYaw = 0.f;
InertiaPitch = 0.f;
}

View File

@@ -0,0 +1,96 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GlobeInteractionComponent.generated.h"
class ACesiumGeoreference;
/**
* UGlobeInteractionComponent
*
* 挂在 PlayerController 或 Pawn 上,处理地球旋转和缩放。
*
* 旋转原理:
* 修改 CesiumGeoreference 的 OriginLongitude / OriginLatitude
* 相当于"移动地球"到新中心,视觉效果等同于旋转地球。
*
* 缩放原理:
* 沿相机到地球中心方向移动相机,改变观察高度。
*
* 使用方式:
* 在 PlanetPlayerController::BeginPlay 里 AddComponent
* 收到拖拽/滚轮输入时调用 RotateGlobe / ZoomGlobe。
*/
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent), BlueprintType)
class PLANETCLIENT_API UGlobeInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
UGlobeInteractionComponent();
// -----------------------------------------------------------------------
// 可在 Details 面板调整的参数
// -----------------------------------------------------------------------
// 拖拽灵敏度:每像素对应的经纬度偏移量(度)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
float RotateSensitivity = 0.15f;
// 缩放灵敏度:每格滚轮对应的高度变化(米)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
float ZoomSensitivity = 200000.f;
// 最小观察高度(米),防止穿入地面
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
double MinAltitudeMeters = 500000.0;
// 最大观察高度(米)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
double MaxAltitudeMeters = 30000000.0;
// 旋转惯性平滑系数 (0=无惯性, 1=永不停)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe",
meta=(ClampMin="0.0", ClampMax="0.99"))
float InertiaDamping = 0.85f;
// -----------------------------------------------------------------------
// 公开 API
// -----------------------------------------------------------------------
// 每帧由 PlayerController 调用,传入鼠标位移(像素)
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void RotateGlobe(float DeltaYaw, float DeltaPitch);
// 每帧由 PlayerController 调用Value > 0 = 拉近,< 0 = 推远
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void ZoomGlobe(float Value);
// 立即停止惯性
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void StopInertia();
// 返回当前相机高度(米)
UFUNCTION(BlueprintPure, Category="Planet|Globe")
double GetCurrentAltitudeMeters() const { return CurrentAltitudeMeters; }
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
virtual void BeginPlay() override;
private:
UPROPERTY()
ACesiumGeoreference* Georeference = nullptr;
// 惯性速度(经度/帧,纬度/帧)
float InertiaYaw = 0.f;
float InertiaPitch = 0.f;
// 当前相机高度(米),随 Zoom 更新
double CurrentAltitudeMeters = 5000000.0;
void FindGeoreference();
void ApplyAltitudeToCamera();
};

View File

@@ -0,0 +1,53 @@
#include "InteractiveObjectBase.h"
AInteractiveObjectBase::AInteractiveObjectBase()
{
PrimaryActorTick.bCanEverTick = false;
}
void AInteractiveObjectBase::SetInteractiveState(EInteractiveObjectState NewState)
{
if (CurrentState == NewState) return;
if (CurrentState == EInteractiveObjectState::Disabled &&
NewState != EInteractiveObjectState::Normal) return;
EInteractiveObjectState OldState = CurrentState;
CurrentState = NewState;
switch (NewState)
{
case EInteractiveObjectState::Hovered: OnHovered(); break;
case EInteractiveObjectState::Selected: OnSelected(); break;
case EInteractiveObjectState::Normal:
if (OldState == EInteractiveObjectState::Selected) OnDeselected();
else OnRestored();
break;
default: break;
}
OnStateChanged.Broadcast(NewState);
}
// ---------------------------------------------------------------------------
// 默认实现(子类可重写)
// ---------------------------------------------------------------------------
FText AInteractiveObjectBase::GetInfoTitle_Implementation() const
{
return FText::FromString(GetActorLabel());
}
FText AInteractiveObjectBase::GetInfoBody_Implementation() const
{
return FText::FromString(TEXT(""));
}
UTexture2D* AInteractiveObjectBase::GetInfoIcon_Implementation() const
{
return nullptr;
}
void AInteractiveObjectBase::OnHovered_Implementation() {}
void AInteractiveObjectBase::OnSelected_Implementation() {}
void AInteractiveObjectBase::OnDeselected_Implementation(){}
void AInteractiveObjectBase::OnRestored_Implementation() {}

View File

@@ -0,0 +1,93 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InteractiveObjectBase.generated.h"
UENUM(BlueprintType)
enum class EInteractiveObjectState : uint8
{
Normal UMETA(DisplayName="正常"),
Hovered UMETA(DisplayName="悬停高亮"),
Selected UMETA(DisplayName="已选中"),
Disabled UMETA(DisplayName="不可交互"),
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectStateChanged,
EInteractiveObjectState, NewState);
/**
* AInteractiveObjectBase
*
* 所有可交互 3D 物件的基类。
* 当前场景中的可交互物件包括:
* - AComputePointActor超算数据点—— 已实现
* - 其他演示物件TODO: 根据需求添加子类)
*
* 子类需要:
* 1. 重写 OnHovered / OnSelected / OnDeselected或响应 OnStateChanged 委托)
* 2. 将根 Component 设置为可被射线检测SetCollisionResponseToChannel
* 3. 可选:重写 GetInfoTitle / GetInfoBody 供信息卡使用
*/
UCLASS(Abstract, BlueprintType, Blueprintable)
class PLANETCLIENT_API AInteractiveObjectBase : public AActor
{
GENERATED_BODY()
public:
AInteractiveObjectBase();
// -----------------------------------------------------------------------
// 状态变更委托
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Interactive")
FOnObjectStateChanged OnStateChanged;
// -----------------------------------------------------------------------
// 状态 API由 PlanetPlayerController 调用)
// -----------------------------------------------------------------------
UFUNCTION(BlueprintCallable, Category="Planet|Interactive")
void SetInteractiveState(EInteractiveObjectState NewState);
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
EInteractiveObjectState GetInteractiveState() const { return CurrentState; }
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
bool IsSelected() const { return CurrentState == EInteractiveObjectState::Selected; }
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
bool IsHovered() const { return CurrentState == EInteractiveObjectState::Hovered; }
// -----------------------------------------------------------------------
// 信息卡内容(子类可重写提供具体文本)
// -----------------------------------------------------------------------
// 信息卡标题,默认返回 Actor 名
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
FText GetInfoTitle() const;
// 信息卡正文,返回富文本格式的详情
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
FText GetInfoBody() const;
// 信息卡图标(可选,返回空则不显示图标)
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
UTexture2D* GetInfoIcon() const;
protected:
// 子类重写这三个函数来处理视觉状态变化
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnHovered();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnSelected();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnDeselected();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnRestored(); // 从任何状态回到 Normal
private:
EInteractiveObjectState CurrentState = EInteractiveObjectState::Normal;
};

View File

@@ -0,0 +1,150 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "MotionCaptureInterface.generated.h"
// ---------------------------------------------------------------------------
// 动捕事件 —— 供应商中间件触发这些事件UE5 侧响应
// ---------------------------------------------------------------------------
/**
* 手势类型枚举
*
* TODO等动捕供应商确认手势集合后补充:
* 当前列出的是推测的基本手势,以实际交付为准。
*/
UENUM(BlueprintType)
enum class EMotionGesture : uint8
{
None UMETA(DisplayName=""),
SwipeLeft UMETA(DisplayName="向左划"), // 地球向左转
SwipeRight UMETA(DisplayName="向右划"), // 地球向右转
SwipeUp UMETA(DisplayName="向上划"), // 地球向上转
SwipeDown UMETA(DisplayName="向下划"), // 地球向下转
PinchIn UMETA(DisplayName="捏合(缩小)"),// 缩放
PinchOut UMETA(DisplayName="展开(放大)"),// 缩放
Point UMETA(DisplayName="指向"), // 悬停选中
Confirm UMETA(DisplayName="确认"), // 点击选中
Dismiss UMETA(DisplayName="挥手取消"), // 关闭信息卡
};
// 动捕系统触发某个离散手势时广播
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnGestureDetected, EMotionGesture, Gesture);
// 动捕系统持续输出指向方向时广播(用于悬停高亮)
// Direction: 归一化方向向量,由动捕系统解算后传入
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPointingDirectionChanged, FVector, Direction);
// 动捕系统输出旋转增量时广播(用于连续拖拽旋转地球)
// DeltaYaw/DeltaPitch: 单帧内的角度增量(度)
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRotateDelta, float, DeltaYaw, float, DeltaPitch);
// 动捕系统输出缩放增量时广播
// DeltaScale: > 0 放大,< 0 缩小
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnZoomDelta, float, DeltaScale);
/**
* UMotionCaptureReceiver
*
* 动捕接收器基类。根据供应商协议,创建对应的子类实现:
*
* - LiveLink 协议 → UMotionCaptureReceiverLiveLink TODO
* - OSC/UDP 协议 → UMotionCaptureReceiverOSC TODO
*
* 子类负责接收数据并调用 BroadcastXxx() 方法,
* PlanetPlayerController 订阅这些委托即可,不关心底层协议。
*
* TODO等动捕供应商回复后:
* 1. 确认协议Live Link / OSC / 私有 SDK
* 2. 在对应子类里实现连接和数据接收
* 3. 把子类拖进场景,在 PlanetDataManager / PlayerController 里引用它
*/
UCLASS(Abstract, BlueprintType, Blueprintable)
class PLANETCLIENT_API UMotionCaptureReceiver : public UObject
{
GENERATED_BODY()
public:
// -----------------------------------------------------------------------
// 外部订阅这些委托
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnGestureDetected OnGestureDetected;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnPointingDirectionChanged OnPointingDirectionChanged;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnRotateDelta OnRotateDelta;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnZoomDelta OnZoomDelta;
// 连接到动捕中间件(子类实现)
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
virtual void Connect() {}
// 断开连接(子类实现)
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
virtual void Disconnect() {}
UFUNCTION(BlueprintPure, Category="Planet|MotionCapture")
virtual bool IsConnected() const { return false; }
protected:
// 子类解析到数据后调用这些方法广播
void BroadcastGesture(EMotionGesture Gesture)
{
OnGestureDetected.Broadcast(Gesture);
}
void BroadcastPointing(const FVector& Direction)
{
OnPointingDirectionChanged.Broadcast(Direction);
}
void BroadcastRotateDelta(float DeltaYaw, float DeltaPitch)
{
OnRotateDelta.Broadcast(DeltaYaw, DeltaPitch);
}
void BroadcastZoomDelta(float DeltaScale)
{
OnZoomDelta.Broadcast(DeltaScale);
}
};
// ---------------------------------------------------------------------------
// 动作映射配置
//
// 把动捕手势映射到具体的 UE 操作,在 Details 面板里可以改。
// 这样不用改代码就能重新映射手势。
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FMotionActionMapping
{
GENERATED_BODY()
// 触发"旋转地球"的手势(持续输出增量)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture RotateGlobe = EMotionGesture::None; // TODO: 填入供应商手势名
// 触发"放大"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture ZoomIn = EMotionGesture::PinchOut;
// 触发"缩小"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture ZoomOut = EMotionGesture::PinchIn;
// 触发"选中数据点"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture SelectPoint = EMotionGesture::Confirm;
// 触发"关闭信息卡"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture DismissInfoCard = EMotionGesture::Dismiss;
};

View File

@@ -0,0 +1,33 @@
using UnrealBuildTool;
public class PlanetClient : ModuleRules
{
public PlanetClient(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"HTTP",
"Json",
"JsonUtilities",
"CesiumRuntime",
"UMG",
"Slate",
"SlateCore",
"RenderCore", // StereoRenderingManager 用到
"RHI", // 渲染硬件接口
// TODO: 动捕协议确认后按需添加:
// "LiveLink", // Live Link 协议
// "LiveLinkInterface",
// "Networking", // OSC/UDP 协议
// "Sockets",
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}

View File

@@ -0,0 +1,4 @@
#include "PlanetClient.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PlanetClient, "PlanetClient");

View File

@@ -0,0 +1,3 @@
#pragma once
#include "CoreMinimal.h"

View File

@@ -0,0 +1,220 @@
#include "PlanetDataManager.h"
#include "ComputePointActor.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Engine/World.h"
// Cesium coordinate conversion
#include "CesiumGeoreference.h"
APlanetDataManager::APlanetDataManager()
{
PrimaryActorTick.bCanEverTick = false;
}
void APlanetDataManager::BeginPlay()
{
Super::BeginPlay();
// Build default mock path if not overridden
if (MockDataPath.IsEmpty())
{
MockDataPath = FPaths::ProjectContentDir() / TEXT("Data/mock_compute_points.json");
}
FetchAllData();
}
void APlanetDataManager::FetchAllData()
{
// Clear previous actors
for (AComputePointActor* Point : SpawnedPoints)
{
if (IsValid(Point)) Point->Destroy();
}
SpawnedPoints.Empty();
if (bUseLocalMockData)
{
LoadMockComputePoints();
}
else
{
FetchComputePoints();
}
}
// ---------------------------------------------------------------------------
// Phase A: local JSON file
// ---------------------------------------------------------------------------
void APlanetDataManager::LoadMockComputePoints()
{
FString JsonStr;
if (!FFileHelper::LoadFileToString(JsonStr, *MockDataPath))
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: cannot read mock file: %s"), *MockDataPath);
return;
}
TArray<FComputePoint> Points = ParseComputePointsJson(JsonStr);
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: loaded %d points from mock file"), Points.Num());
SpawnComputePoints(Points);
}
// ---------------------------------------------------------------------------
// Phase B: HTTP request
// ---------------------------------------------------------------------------
void APlanetDataManager::FetchComputePoints()
{
const FString Url = BackendBaseUrl + TEXT("/api/v1/ue/compute-points");
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Req = FHttpModule::Get().CreateRequest();
Req->SetURL(Url);
Req->SetVerb(TEXT("GET"));
Req->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Req->OnProcessRequestComplete().BindUObject(
this, &APlanetDataManager::OnComputePointsResponse);
Req->ProcessRequest();
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: GET %s"), *Url);
}
void APlanetDataManager::OnComputePointsResponse(FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSuccess)
{
if (!bSuccess || !Response.IsValid())
{
UE_LOG(LogTemp, Error,
TEXT("PlanetDataManager: HTTP request failed. Is the backend running at %s?"),
*BackendBaseUrl);
return;
}
if (Response->GetResponseCode() != 200)
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: HTTP %d from %s"),
Response->GetResponseCode(), *Request->GetURL());
return;
}
TArray<FComputePoint> Points = ParseComputePointsJson(Response->GetContentAsString());
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: received %d compute points"), Points.Num());
SpawnComputePoints(Points);
}
// ---------------------------------------------------------------------------
// JSON parser
// ---------------------------------------------------------------------------
TArray<FComputePoint> APlanetDataManager::ParseComputePointsJson(const FString& JsonStr)
{
TArray<FComputePoint> Result;
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonStr);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: JSON parse failed"));
return Result;
}
const TArray<TSharedPtr<FJsonValue>>* Items;
if (!Root->TryGetArrayField(TEXT("items"), Items))
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: no 'items' array in JSON"));
return Result;
}
for (const TSharedPtr<FJsonValue>& Val : *Items)
{
const TSharedPtr<FJsonObject>* ObjPtr;
if (!Val->TryGetObject(ObjPtr)) continue;
const TSharedPtr<FJsonObject>& Obj = *ObjPtr;
FComputePoint Pt;
Obj->TryGetStringField(TEXT("id"), Pt.Id);
Obj->TryGetStringField(TEXT("name"), Pt.Name);
Obj->TryGetStringField(TEXT("country"), Pt.Country);
Obj->TryGetStringField(TEXT("city"), Pt.City);
double Lat, Lon;
if (!Obj->TryGetNumberField(TEXT("latitude"), Lat)) continue;
if (!Obj->TryGetNumberField(TEXT("longitude"), Lon)) continue;
Pt.Latitude = (float)Lat;
Pt.Longitude = (float)Lon;
int32 Rank;
if (Obj->TryGetNumberField(TEXT("rank"), Rank)) Pt.Rank = Rank;
double Rmax;
if (Obj->TryGetNumberField(TEXT("rmax_tflops"), Rmax)) Pt.RmaxTFlops = (float)Rmax;
double Rpeak;
if (Obj->TryGetNumberField(TEXT("rpeak_tflops"), Rpeak)) Pt.RpeakTFlops = (float)Rpeak;
int32 Cores;
if (Obj->TryGetNumberField(TEXT("cores"), Cores)) Pt.Cores = Cores;
double Power;
if (Obj->TryGetNumberField(TEXT("power_kw"), Power)) Pt.PowerKw = (float)Power;
Result.Add(Pt);
}
return Result;
}
// ---------------------------------------------------------------------------
// Spawn actors
// ---------------------------------------------------------------------------
void APlanetDataManager::SpawnComputePoints(const TArray<FComputePoint>& Points)
{
if (!ComputePointClass)
{
UE_LOG(LogTemp, Warning,
TEXT("PlanetDataManager: ComputePointClass not set — set it in the Details Panel"));
return;
}
UWorld* World = GetWorld();
if (!World) return;
// Find the CesiumGeoreference in the level
ACesiumGeoreference* Georeference = ACesiumGeoreference::GetDefaultGeoreference(World);
if (!Georeference)
{
UE_LOG(LogTemp, Error,
TEXT("PlanetDataManager: no CesiumGeoreference in level. Add a CesiumGeoreference actor."));
return;
}
for (const FComputePoint& Pt : Points)
{
// Convert geographic coordinates to Unreal world coordinates
// Altitude is PointAltitudeMeters above sea level
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
FVector(Pt.Longitude, Pt.Latitude, PointAltitudeMeters)
);
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AComputePointActor* Actor = World->SpawnActor<AComputePointActor>(
ComputePointClass, WorldPos, FRotator::ZeroRotator, Params);
if (Actor)
{
Actor->SetPointData(Pt);
SpawnedPoints.Add(Actor);
}
}
OnComputePointsLoaded.Broadcast(SpawnedPoints.Num());
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: spawned %d compute point actors"),
SpawnedPoints.Num());
}

View File

@@ -0,0 +1,97 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interfaces/IHttpRequest.h"
#include "PlanetDataTypes.h"
#include "PlanetDataManager.generated.h"
class AComputePointActor;
// Fired once all compute points have been spawned into the world
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnComputePointsLoaded, int32, Count);
/**
* APlanetDataManager
*
* Place one of these in your level. On BeginPlay it fetches data from the
* Planet backend and spawns AComputePointActor instances at the correct
* Cesium globe positions.
*
* Phase A (local): Set bUseLocalMockData = true and point MockDataPath to
* Content/Data/mock_compute_points.json.
* Phase B (live): Set bUseLocalMockData = false and fill BackendBaseUrl.
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API APlanetDataManager : public AActor
{
GENERATED_BODY()
public:
APlanetDataManager();
// -----------------------------------------------------------------------
// Inspector-editable properties
// -----------------------------------------------------------------------
// Base URL of the Planet backend, e.g. "http://localhost:8000"
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
FString BackendBaseUrl = TEXT("http://localhost:8000");
// When true, loads mock_compute_points.json from disk instead of the API.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
bool bUseLocalMockData = true;
// Absolute path to the mock JSON file (Phase A).
// Default points to <ProjectDir>/Content/Data/mock_compute_points.json
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config",
meta=(EditCondition="bUseLocalMockData"))
FString MockDataPath;
// The Blueprint subclass of AComputePointActor to spawn.
// Set this in the Details Panel to your BP_ComputePointActor.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
TSubclassOf<AComputePointActor> ComputePointClass;
// Height above sea level (metres) at which to place data points.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
double PointAltitudeMeters = 50000.0;
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnComputePointsLoaded OnComputePointsLoaded;
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
// Call this to re-fetch and rebuild all points.
UFUNCTION(BlueprintCallable, Category="Planet")
void FetchAllData();
UFUNCTION(BlueprintCallable, Category="Planet")
TArray<AComputePointActor*> GetAllComputePoints() const { return SpawnedPoints; }
protected:
virtual void BeginPlay() override;
private:
TArray<AComputePointActor*> SpawnedPoints;
// HTTP handlers
void FetchComputePoints();
void OnComputePointsResponse(FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSuccess);
// Local mock data loader (Phase A)
void LoadMockComputePoints();
// Shared spawn logic
void SpawnComputePoints(const TArray<FComputePoint>& Points);
// JSON → FComputePoint array
static TArray<FComputePoint> ParseComputePointsJson(const FString& JsonStr);
};

View File

@@ -0,0 +1,108 @@
#pragma once
#include "CoreMinimal.h"
#include "PlanetDataTypes.generated.h"
// ---------------------------------------------------------------------------
// FComputePoint — corresponds to /api/v1/ue/compute-points items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FComputePoint
{
GENERATED_BODY()
// Unique string id (e.g. "top500_42")
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Latitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Longitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Country;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString City;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
int32 Rank = 0;
// Rmax in TFlops. 0 means not available.
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float RmaxTFlops = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float RpeakTFlops = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
int32 Cores = 0;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float PowerKw = 0.f;
};
// ---------------------------------------------------------------------------
// FLandingPoint — corresponds to /api/v1/ue/landing-points items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FLandingPoint
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Latitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Longitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Country;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString City;
// Names of cables that pass through this landing point
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
TArray<FString> CableNames;
};
// ---------------------------------------------------------------------------
// FCableSegment / FCable — corresponds to /api/v1/ue/cables items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FCable
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString CableId; // slug, e.g. "flag"
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Status;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float LengthKm = 0.f;
// Each inner TArray is one segment: ordered [lon, lat] pairs
// Stored as FVector2D(lon, lat) per point
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
TArray<TArray<FVector2D>> Segments;
};

View File

@@ -0,0 +1,28 @@
#include "PlanetGameMode.h"
#include "PlanetPlayerController.h"
#include "StereoRenderingManager.h"
#include "GameFramework/SpectatorPawn.h"
#include "Engine/World.h"
APlanetGameMode::APlanetGameMode()
{
PlayerControllerClass = APlanetPlayerController::StaticClass();
// 真实运行时在 World Settings 里改为 CesiumDynamicPawn
DefaultPawnClass = ASpectatorPawn::StaticClass();
}
void APlanetGameMode::BeginPlay()
{
Super::BeginPlay();
// 自动在场景里生成 StereoRenderingManager如果场景里没有的话
TArray<AActor*> Found;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AStereoRenderingManager::StaticClass(), Found);
if (Found.Num() == 0)
{
GetWorld()->SpawnActor<AStereoRenderingManager>(
AStereoRenderingManager::StaticClass(),
FVector::ZeroVector, FRotator::ZeroRotator);
UE_LOG(LogTemp, Log, TEXT("PlanetGameMode: 已自动生成 StereoRenderingManager"));
}
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "Kismet/GameplayStatics.h"
#include "PlanetGameMode.generated.h"
/**
* APlanetGameMode
*
* 场景入口。BeginPlay 时自动生成 StereoRenderingManager。
*
* 编辑器操作(步骤见 ue_client_setup_guide.md:
* 1. World Settings → Game Mode Override → PlanetGameMode
* 2. World Settings → Default Pawn Class → CesiumDynamicPawn
*/
UCLASS()
class PLANETCLIENT_API APlanetGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
APlanetGameMode();
protected:
virtual void BeginPlay() override;
};

View File

@@ -0,0 +1,246 @@
#include "PlanetPlayerController.h"
#include "GlobeInteractionComponent.h"
#include "InteractiveObjectBase.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
APlanetPlayerController::APlanetPlayerController()
{
bShowMouseCursor = true;
bEnableClickEvents = true;
bEnableMouseOverEvents = true;
PrimaryActorTick.bCanEverTick = true;
GlobeInteraction = CreateDefaultSubobject<UGlobeInteractionComponent>(
TEXT("GlobeInteraction"));
}
void APlanetPlayerController::BeginPlay()
{
Super::BeginPlay();
SetInputMode(FInputModeGameAndUI());
// TODO供应商回复后取消注释:
// BindMotionCaptureEvents();
}
// ---------------------------------------------------------------------------
// 输入绑定
// ---------------------------------------------------------------------------
void APlanetPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
InputComponent->BindAction("SelectPoint", IE_Pressed, this,
&APlanetPlayerController::OnLeftMouseDown);
InputComponent->BindAction("SelectPoint", IE_Released, this,
&APlanetPlayerController::OnLeftMouseUp);
InputComponent->BindAction("Escape", IE_Pressed, this,
&APlanetPlayerController::OnEscape);
InputComponent->BindAxis("RotateYaw", this, &APlanetPlayerController::OnMouseX);
InputComponent->BindAxis("RotatePitch", this, &APlanetPlayerController::OnMouseY);
InputComponent->BindAxis("ZoomCamera", this, &APlanetPlayerController::OnZoom);
}
// ---------------------------------------------------------------------------
// Tick悬停检测
// ---------------------------------------------------------------------------
void APlanetPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!bIsDragging) UpdateHover();
}
// ---------------------------------------------------------------------------
// 鼠标输入
// ---------------------------------------------------------------------------
void APlanetPlayerController::OnLeftMouseDown()
{
bLeftMouseDown = true;
bIsDragging = false;
float X, Y;
GetMousePosition(X, Y);
MouseDownPosition = FVector2D(X, Y);
// 停止惯性,准备接管控制
if (GlobeInteraction) GlobeInteraction->StopInertia();
}
void APlanetPlayerController::OnLeftMouseUp()
{
if (!bIsDragging)
{
// 没有拖拽 → 判定为点击
AInteractiveObjectBase* Clicked = GetObjectUnderCursor();
if (Clicked && Clicked != SelectedObject)
{
// 取消上一个选中
DeselectCurrent();
// 选中新物件
SelectedObject = Clicked;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
OnObjectSelected.Broadcast(SelectedObject);
}
else if (!Clicked)
{
// 点了空白处 → 取消选中
DeselectCurrent();
}
else
{
// 点了已选中的物件 → 取消选中
DeselectCurrent();
}
}
bLeftMouseDown = false;
bIsDragging = false;
}
void APlanetPlayerController::OnMouseX(float Value)
{
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
// 判断是否超过拖拽阈值
if (!bIsDragging)
{
float X, Y;
GetMousePosition(X, Y);
float Dist = FVector2D::Distance(FVector2D(X, Y), MouseDownPosition);
if (Dist < DragThresholdPixels) return;
bIsDragging = true;
}
if (GlobeInteraction) GlobeInteraction->RotateGlobe(Value * MouseDragSensitivity, 0.f);
}
void APlanetPlayerController::OnMouseY(float Value)
{
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
if (!bIsDragging) return;
if (GlobeInteraction) GlobeInteraction->RotateGlobe(0.f, Value * MouseDragSensitivity);
}
void APlanetPlayerController::OnZoom(float Value)
{
if (FMath::IsNearlyZero(Value)) return;
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(Value);
}
void APlanetPlayerController::OnEscape()
{
DeselectCurrent();
}
// ---------------------------------------------------------------------------
// 悬停
// ---------------------------------------------------------------------------
void APlanetPlayerController::UpdateHover()
{
AInteractiveObjectBase* UnderCursor = GetObjectUnderCursor();
if (HoveredObject && HoveredObject != UnderCursor && HoveredObject != SelectedObject)
{
HoveredObject->SetInteractiveState(EInteractiveObjectState::Normal);
}
HoveredObject = UnderCursor;
if (HoveredObject && HoveredObject != SelectedObject)
{
HoveredObject->SetInteractiveState(EInteractiveObjectState::Hovered);
}
}
// ---------------------------------------------------------------------------
// 取消选中
// ---------------------------------------------------------------------------
void APlanetPlayerController::DeselectCurrent()
{
if (!SelectedObject) return;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Normal);
SelectedObject = nullptr;
OnObjectDeselected.Broadcast();
}
// ---------------------------------------------------------------------------
// 射线检测
// ---------------------------------------------------------------------------
AInteractiveObjectBase* APlanetPlayerController::GetObjectUnderCursor() const
{
FHitResult Hit;
bool bHit = GetHitResultUnderCursorByChannel(
UEngineTypes::ConvertToTraceType(ECC_Visibility), true, Hit);
if (!bHit) return nullptr;
return Cast<AInteractiveObjectBase>(Hit.GetActor());
}
// ---------------------------------------------------------------------------
// 动捕事件TODO: 供应商回复后实现)
// ---------------------------------------------------------------------------
void APlanetPlayerController::BindMotionCaptureEvents()
{
if (!MotionCaptureReceiver)
{
UE_LOG(LogTemp, Warning,
TEXT("PlayerController: MotionCaptureReceiver 未设置,动捕功能禁用"));
return;
}
MotionCaptureReceiver->OnGestureDetected.AddDynamic(
this, &APlanetPlayerController::OnMotionGesture);
MotionCaptureReceiver->OnRotateDelta.AddDynamic(
this, &APlanetPlayerController::OnMotionRotate);
MotionCaptureReceiver->OnZoomDelta.AddDynamic(
this, &APlanetPlayerController::OnMotionZoom);
MotionCaptureReceiver->Connect();
UE_LOG(LogTemp, Log, TEXT("PlayerController: 动捕事件已绑定"));
}
void APlanetPlayerController::OnMotionGesture(EMotionGesture Gesture)
{
if (Gesture == MotionActionMapping.SelectPoint)
{
// 手势确认 → 等同于点击当前悬停的物件
if (HoveredObject)
{
DeselectCurrent();
SelectedObject = HoveredObject;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
OnObjectSelected.Broadcast(SelectedObject);
}
}
else if (Gesture == MotionActionMapping.DismissInfoCard)
{
DeselectCurrent();
}
else if (Gesture == MotionActionMapping.ZoomIn)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(1.f);
}
else if (Gesture == MotionActionMapping.ZoomOut)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(-1.f);
}
}
void APlanetPlayerController::OnMotionRotate(float DeltaYaw, float DeltaPitch)
{
if (GlobeInteraction) GlobeInteraction->RotateGlobe(DeltaYaw, DeltaPitch);
}
void APlanetPlayerController::OnMotionZoom(float DeltaScale)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(DeltaScale);
}

View File

@@ -0,0 +1,117 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MotionCaptureInterface.h"
#include "PlanetPlayerController.generated.h"
class AInteractiveObjectBase;
class UGlobeInteractionComponent;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectSelected, AInteractiveObjectBase*, Object);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnObjectDeselected);
/**
* APlanetPlayerController
*
* 统一处理两路输入:
* 1. 鼠标/键盘(始终可用,调试和演示备用)
* 2. 动捕手势TODO: 供应商协议确认后接入)
*
* 鼠标操作:
* 左键拖拽 → 旋转地球
* 滚轮 → 缩放
* 悬停 → 高亮物件
* 左键单击 → 选中物件 / 取消选中
* ESC → 取消选中
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API APlanetPlayerController : public APlayerController
{
GENERATED_BODY()
public:
APlanetPlayerController();
// -----------------------------------------------------------------------
// 组件
// -----------------------------------------------------------------------
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
UGlobeInteractionComponent* GlobeInteraction;
// -----------------------------------------------------------------------
// 动捕配置TODO: 供应商回复后配置)
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
FMotionActionMapping MotionActionMapping;
// 动捕接收器实例TODO: 确认协议后指向具体子类实例)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
UMotionCaptureReceiver* MotionCaptureReceiver = nullptr;
// -----------------------------------------------------------------------
// 鼠标灵敏度
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
float MouseDragSensitivity = 0.3f;
// 判定为"拖拽"而非"点击"的最小移动距离(像素)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
float DragThresholdPixels = 5.f;
// -----------------------------------------------------------------------
// 事件
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnObjectSelected OnObjectSelected;
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnObjectDeselected OnObjectDeselected;
// -----------------------------------------------------------------------
// 公开 API
// -----------------------------------------------------------------------
UFUNCTION(BlueprintCallable, Category="Planet")
void DeselectCurrent();
UFUNCTION(BlueprintPure, Category="Planet")
AInteractiveObjectBase* GetSelectedObject() const { return SelectedObject; }
protected:
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
virtual void SetupInputComponent() override;
private:
// 鼠标拖拽状态
bool bLeftMouseDown = false;
bool bIsDragging = false;
FVector2D MouseDownPosition = FVector2D::ZeroVector;
// 交互状态
AInteractiveObjectBase* SelectedObject = nullptr;
AInteractiveObjectBase* HoveredObject = nullptr;
// 输入回调
void OnLeftMouseDown();
void OnLeftMouseUp();
void OnMouseX(float Value);
void OnMouseY(float Value);
void OnZoom(float Value);
void OnEscape();
// 悬停检测
void UpdateHover();
// 射线检测
AInteractiveObjectBase* GetObjectUnderCursor() const;
// 动捕事件绑定TODO: 供应商回复后在 BeginPlay 里绑定接收器)
UFUNCTION()
void OnMotionGesture(EMotionGesture Gesture);
UFUNCTION()
void OnMotionRotate(float DeltaYaw, float DeltaPitch);
UFUNCTION()
void OnMotionZoom(float DeltaScale);
void BindMotionCaptureEvents();
};

View File

@@ -0,0 +1,131 @@
#include "StereoRenderingManager.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
AStereoRenderingManager::AStereoRenderingManager()
{
PrimaryActorTick.bCanEverTick = false;
}
void AStereoRenderingManager::BeginPlay()
{
Super::BeginPlay();
if (bAutoEnableOnPlay)
{
EnableStereo(DefaultStereoMode);
}
else
{
UE_LOG(LogTemp, Log,
TEXT("StereoRenderingManager: 立体渲染未自动启动。"
"确认视频处理器格式后,在 Details 面板勾选 bAutoEnableOnPlay。"));
}
}
// ---------------------------------------------------------------------------
// 公开 API
// ---------------------------------------------------------------------------
void AStereoRenderingManager::EnableStereo(EStereoOutputMode Mode)
{
if (Mode == EStereoOutputMode::Off)
{
DisableStereo();
return;
}
// TODO供应商回复后检查这里的命令是否与视频处理器匹配:
//
// UE5 内置立体模式通过以下方式启用。
// 如果视频处理器需要特殊格式,可能需要修改 stereo mode 参数。
//
// 参考命令:
// stereo on —— 开启立体渲染(需要 VR 设备或自定义立体设备)
// r.StereoRendering 1 —— 软件层立体渲染标志
// vr.StereoMode <n> —— 0=SbS full, 1=SbS half, 2=TbB full, 3=TbB half
//
// 目前使用 r.StereoRendering 的"双视口"方式,兼容性最好。
switch (Mode)
{
case EStereoOutputMode::SideBySide:
// Side-by-Side左半=左眼,右半=右眼,总分辨率 2x 宽
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
ExecConsoleCommand(TEXT("stereo on"));
ExecConsoleCommand(TEXT("vr.StereoMode 0")); // TODO: 根据实际效果调整
break;
case EStereoOutputMode::TopBottom:
// Top-Bottom上半=左眼,下半=右眼,总分辨率 2x 高
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
ExecConsoleCommand(TEXT("stereo on"));
ExecConsoleCommand(TEXT("vr.StereoMode 2")); // TODO: 根据实际效果调整
break;
case EStereoOutputMode::FrameSequential:
// 本项目屏幕 60Hz帧序列需要 120Hz暂不支持
UE_LOG(LogTemp, Warning,
TEXT("StereoRenderingManager: Frame Sequential 需要 120Hz 输出,"
"当前屏幕规格不支持,已忽略。"));
return;
default: break;
}
// 设置 IPD
SetIPD(IPDCm);
bStereoActive = true;
CurrentMode = Mode;
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已开启,模式=%sIPD=%.1fcm"),
*ModeToString(Mode), IPDCm);
}
void AStereoRenderingManager::DisableStereo()
{
ExecConsoleCommand(TEXT("stereo off"));
ExecConsoleCommand(TEXT("r.StereoRendering 0"));
bStereoActive = false;
CurrentMode = EStereoOutputMode::Off;
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已关闭,切回 2D"));
}
void AStereoRenderingManager::ToggleStereo()
{
if (bStereoActive)
DisableStereo();
else
EnableStereo(DefaultStereoMode);
}
void AStereoRenderingManager::SetIPD(float NewIPDCm)
{
IPDCm = FMath::Clamp(NewIPDCm, 1.f, 10.f);
// UE5 以米为单位设置 IPD
FString Cmd = FString::Printf(TEXT("vr.HMDIPDInMeters %.4f"), IPDCm / 100.f);
ExecConsoleCommand(Cmd);
}
// ---------------------------------------------------------------------------
// 内部工具
// ---------------------------------------------------------------------------
void AStereoRenderingManager::ExecConsoleCommand(const FString& Cmd)
{
if (GEngine)
{
GEngine->Exec(GetWorld(), *Cmd);
}
}
FString AStereoRenderingManager::ModeToString(EStereoOutputMode Mode)
{
switch (Mode)
{
case EStereoOutputMode::SideBySide: return TEXT("SideBySide");
case EStereoOutputMode::TopBottom: return TEXT("TopBottom");
case EStereoOutputMode::FrameSequential: return TEXT("FrameSequential");
default: return TEXT("Off");
}
}

View File

@@ -0,0 +1,97 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "StereoRenderingManager.generated.h"
/**
* 立体渲染输出格式
*
* TODO等待视频处理器供应商回复:
* 确认视频处理器接受哪种格式后,在 DefaultStereoMode 里选择对应值。
* - 如果是诺瓦星云/Brompton 处理器,通常接受 SideBySide
* - Frame Sequential 需要 120Hz+ 输出,本项目屏幕不需要,基本不选
*/
UENUM(BlueprintType)
enum class EStereoOutputMode : uint8
{
Off UMETA(DisplayName="关闭(普通 2D 输出)"),
SideBySide UMETA(DisplayName="左右并排 Side-by-Side"), // 最常见,优先尝试
TopBottom UMETA(DisplayName="上下叠加 Top-Bottom"), // 备选
FrameSequential UMETA(DisplayName="帧序列(需 120Hz"), // 本项目暂不用
};
/**
* AStereoRenderingManager
*
* 放一个在场景里(或在 GameMode 里 SpawnActor控制立体渲染的开关和参数。
* 运行时可以通过 Blueprint 或控制台随时切换模式,方便现场调试。
*
* TODO等待供应商回复后填写:
* 1. 确认视频处理器输入格式 → 修改 DefaultStereoMode
* 2. 确认屏幕分辨率 → 修改渲染分辨率(目前按 5120x1440 SbS 估算)
* 3. 调整 IPDCm 到适合大屏幕观看距离的值(推荐 3-6cm距离越远 IPD 越小)
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API AStereoRenderingManager : public AActor
{
GENERATED_BODY()
public:
AStereoRenderingManager();
// -----------------------------------------------------------------------
// 可在 Details 面板调整
// -----------------------------------------------------------------------
// TODO: 等供应商回复后改为正确格式
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
EStereoOutputMode DefaultStereoMode = EStereoOutputMode::SideBySide;
// 瞳距cm。大屏幕 + 远距离观看建议 3.0-5.0cm
// TODO: 根据实际屏幕尺寸和观看距离校准
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo",
meta=(ClampMin="1.0", ClampMax="10.0"))
float IPDCm = 6.5f;
// 是否在 BeginPlay 时自动启用立体渲染
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
bool bAutoEnableOnPlay = false; // 默认 false等确认格式后改为 true
// -----------------------------------------------------------------------
// Blueprint 可调用的运行时 API
// -----------------------------------------------------------------------
// 开启立体渲染
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void EnableStereo(EStereoOutputMode Mode);
// 关闭立体渲染,切回普通 2D
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void DisableStereo();
// 切换(当前开 → 关,当前关 → 用 DefaultStereoMode 开)
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void ToggleStereo();
// 调整 IPD运行时微调景深效果
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void SetIPD(float NewIPDCm);
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
bool IsStereoActive() const { return bStereoActive; }
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
EStereoOutputMode GetCurrentMode() const { return CurrentMode; }
protected:
virtual void BeginPlay() override;
private:
bool bStereoActive = false;
EStereoOutputMode CurrentMode = EStereoOutputMode::Off;
// 执行实际的控制台命令
void ExecConsoleCommand(const FString& Cmd);
static FString ModeToString(EStereoOutputMode Mode);
};