From 1e6f4b338b2b3e46905c010a9ed2b7028fc45a33 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 14 Apr 2026 18:39:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20UE5=20LED=20display=20client?= =?UTF-8?q?=20=E2=80=94=20backend=20API,=20C++=20source,=20stereo=20framew?= =?UTF-8?q?ork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/main.py | 2 + backend/app/api/v1/ue_data.py | 351 ++++++ docs/ue5_mvp_fused_plan.md | 1069 ++--------------- docs/ue_client_setup_guide.md | 319 +++++ docs/ue_todo.md | 62 + ue_client/Config/DefaultEngine.ini | 10 + ue_client/Config/DefaultGame.ini | 3 + ue_client/Config/DefaultInput.ini | 6 + .../Content/Data/mock_compute_points.json | 135 +++ .../Source/PlanetClient/ComputePointActor.cpp | 53 + .../Source/PlanetClient/ComputePointActor.h | 95 ++ .../GlobeInteractionComponent.cpp | 122 ++ .../PlanetClient/GlobeInteractionComponent.h | 96 ++ .../PlanetClient/InteractiveObjectBase.cpp | 53 + .../PlanetClient/InteractiveObjectBase.h | 93 ++ .../PlanetClient/MotionCaptureInterface.h | 150 +++ .../Source/PlanetClient/PlanetClient.Build.cs | 33 + .../Source/PlanetClient/PlanetClient.cpp | 4 + ue_client/Source/PlanetClient/PlanetClient.h | 3 + .../Source/PlanetClient/PlanetDataManager.cpp | 220 ++++ .../Source/PlanetClient/PlanetDataManager.h | 97 ++ .../Source/PlanetClient/PlanetDataTypes.h | 108 ++ .../Source/PlanetClient/PlanetGameMode.cpp | 28 + .../Source/PlanetClient/PlanetGameMode.h | 27 + .../PlanetClient/PlanetPlayerController.cpp | 246 ++++ .../PlanetClient/PlanetPlayerController.h | 117 ++ .../PlanetClient/StereoRenderingManager.cpp | 131 ++ .../PlanetClient/StereoRenderingManager.h | 97 ++ 28 files changed, 2781 insertions(+), 949 deletions(-) create mode 100644 backend/app/api/v1/ue_data.py create mode 100644 docs/ue_client_setup_guide.md create mode 100644 docs/ue_todo.md create mode 100644 ue_client/Config/DefaultEngine.ini create mode 100644 ue_client/Config/DefaultGame.ini create mode 100644 ue_client/Config/DefaultInput.ini create mode 100644 ue_client/Content/Data/mock_compute_points.json create mode 100644 ue_client/Source/PlanetClient/ComputePointActor.cpp create mode 100644 ue_client/Source/PlanetClient/ComputePointActor.h create mode 100644 ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp create mode 100644 ue_client/Source/PlanetClient/GlobeInteractionComponent.h create mode 100644 ue_client/Source/PlanetClient/InteractiveObjectBase.cpp create mode 100644 ue_client/Source/PlanetClient/InteractiveObjectBase.h create mode 100644 ue_client/Source/PlanetClient/MotionCaptureInterface.h create mode 100644 ue_client/Source/PlanetClient/PlanetClient.Build.cs create mode 100644 ue_client/Source/PlanetClient/PlanetClient.cpp create mode 100644 ue_client/Source/PlanetClient/PlanetClient.h create mode 100644 ue_client/Source/PlanetClient/PlanetDataManager.cpp create mode 100644 ue_client/Source/PlanetClient/PlanetDataManager.h create mode 100644 ue_client/Source/PlanetClient/PlanetDataTypes.h create mode 100644 ue_client/Source/PlanetClient/PlanetGameMode.cpp create mode 100644 ue_client/Source/PlanetClient/PlanetGameMode.h create mode 100644 ue_client/Source/PlanetClient/PlanetPlayerController.cpp create mode 100644 ue_client/Source/PlanetClient/PlanetPlayerController.h create mode 100644 ue_client/Source/PlanetClient/StereoRenderingManager.cpp create mode 100644 ue_client/Source/PlanetClient/StereoRenderingManager.h diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 6860a914..a8c9d11c 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -15,6 +15,7 @@ from app.api.v1 import ( bgp, system_control, tv, + ue_data, ) 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(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) +api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"]) diff --git a/backend/app/api/v1/ue_data.py b/backend/app/api/v1/ue_data.py new file mode 100644 index 00000000..76b14c3f --- /dev/null +++ b/backend/app/api/v1/ue_data.py @@ -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} diff --git a/docs/ue5_mvp_fused_plan.md b/docs/ue5_mvp_fused_plan.md index ac98d434..7190cc5f 100644 --- a/docs/ue5_mvp_fused_plan.md +++ b/docs/ue5_mvp_fused_plan.md @@ -1,981 +1,152 @@ -# 智能星球 UE5 客户端一期实施方案(融合版) +# 智能星球 UE5 客户端实施方案(LED 大屏版) -> 版本:v2.0 +> 版本:v3.0 > 日期:2026-04-14 -> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端** -> 适用对象:**UE 零基础新手** -> 输出结果:一份 **能直接照着做** 的实施手册 -> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率** +> 背景更新:目标从"普通桌面地球"升级为"5m 被动偏振 3D LED 大屏 + 动捕交互演示系统" --- -## 一、这份融合版方案解决什么问题 - -你原来的 MVP 方案是靠谱的,优点很明显: - -- 范围克制 -- 适合新手入门 -- 目标明确 -- 能较快做出“看得见、点得到”的成果 - -但它也有几个风险: - -- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立 -- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡 -- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑 -- 时间估计略乐观 - -所以这份融合版方案的核心思路是: - -## 核心原则 - -**先做“本地数据可交互地球”,再做“真实后端对接”。** - -也就是把一期再拆成两个更稳的里程碑: - -### 里程碑 A:本地演示版 -先不接后端,只做: - -- UE5 项目能打开 -- Cesium 地球能显示 -- 本地 JSON 里的点能正确落到地球 -- 点击点能弹信息卡 -- HUD 能正常显示假状态 - -### 里程碑 B:后端接入版 -在 A 的基础上再做: - -- HTTP 拉取真实后端数据 -- 显示真实 TOP500 数据 -- 显示后端在线状态 -- 为后续扩展海缆/BGP/卫星打基础 - -这样做的好处是: - -- 把问题拆开 -- 更容易调试 -- 更适合 UE 新手 -- 不会因为后端联调没通就把整个 UE 开发节奏打断 - ---- - -# 二、一期目标:做什么,不做什么 - -## 这次一期一定要做的 - -做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项: - -1. 能打开 UE 项目并看到 3D 地球 -2. 能在地球上显示超算数据点 -3. 能点击数据点弹出信息卡 -4. 能显示一个基础 HUD -5. 能通过 HTTP 接入后端数据 -6. 能打包成 Windows 可执行程序 - ---- - -## 这次一期先不做的 - -这些全部放到后续阶段: - -- 海缆路径渲染 -- 卫星轨迹与卫星图层 -- BGP 图层 -- WebSocket 实时更新 -- 粒子特效大升级 -- 自动巡航 -- 多屏/3D 偏振/大屏联动 - -一句话: - -**一期不是“把 Web Earth 全搬到 UE”,而是“证明 UE 客户端链路能跑通”。** - ---- - -# 三、UE 专有名词字典(零基础版) - -这部分你最好先读一遍。后面所有步骤都围绕这些词。 - -## 1. Actor -**Actor = 场景里的一个对象** - -你可以把它理解成: - -- 一个地球控制器 -- 一个超算点 -- 一台相机 -- 一条海缆 - -这些在 UE 里都可以是 Actor。 - ---- - -## 2. Component -**Component = 挂在 Actor 身上的功能零件** - -比如一个超算点 Actor,可能有: - -- 一个球形外观 -- 一个碰撞盒 -- 一个标签 -- 一个发光效果 - -这些零件就是 Component。 - -一句话: - -**Actor 是整台机器,Component 是机器上的零件。** - ---- - -## 3. Blueprint(蓝图) -**Blueprint = UE 的可视化编程系统** - -你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。 - -你可以把它理解成: - -- 前端里的函数 + 事件监听 -- 只不过不是写文本代码,而是连线 - ---- - -## 4. Level / Map(关卡) -**Level = 一个场景文件** - -你可以把它理解成 Three.js 的一个 Scene。 - -本期只需要一个主场景: - -- `Main` - ---- - -## 5. Widget / UMG -**Widget = UI 组件** -**UMG = UE 的 UI 编辑系统** - -比如: - -- 信息卡 -- 状态栏 -- 右上角连接状态 -- 图例 -- HUD 面板 - -这些都用 Widget 做。 - ---- - -## 6. Material(材质) -**Material = 决定物体外观的系统** - -比如: - -- 球体是什么颜色 -- 是否发光 -- 是否透明 -- 是否随性能大小变亮 - -这些都由材质控制。 - ---- - -## 7. Static Mesh -**Static Mesh = 不会变形的 3D 模型** - -比如: - -- 球 -- 立方体 -- 平面 -- 某个固定模型 - -超算点一期里可以先直接用球体 Static Mesh。 - ---- - -## 8. Pawn -**Pawn = 玩家控制的对象** - -一期里你可以把它理解成: - -- 带相机的飞行控制器 - ---- - -## 9. PlayerController -**PlayerController = 处理输入的对象** - -比如: - -- 鼠标点击 -- 拖拽 -- 滚轮缩放 - -这些都由 PlayerController 或其相关逻辑来处理。 - ---- - -## 10. GameMode -**GameMode = 游戏/场景的主规则配置入口** - -它决定: - -- 默认用哪个 Pawn -- 默认用哪个 PlayerController - -你可以把它理解成“主入口配置”。 - ---- - -## 11. Viewport -**Viewport = 你看 3D 场景的窗口** - -就是 UE 编辑器中间那块 3D 视图。 - ---- - -## 12. Outliner -**Outliner = 当前场景对象列表** - -你可以把它理解成: - -- Scene 树 -- DOM 树 -- 资源树 - ---- - -## 13. Details Panel -**Details Panel = 选中对象后的属性面板** - -相当于“右侧属性编辑器”。 - ---- - -## 14. Cesium for Unreal -**Cesium for Unreal = UE 里的地球插件** - -它负责: - -- 真实地球 -- 卫星影像 -- 地形 -- 经纬度坐标和 UE 世界坐标的转换 - -如果没有它,你得自己处理地球和坐标系统,会非常难。 - ---- - -## 15. Struct(结构体) -**Struct = 数据结构定义** - -你可以把它理解成 TypeScript 里的 `interface`。 - -比如: - -```ts -interface ComputePoint { - id: string - name: string - latitude: number - longitude: number - performance: number -} +## 一、真实场景描述 + +``` +领导进入展示间 + ↓ +5.12m × 2.88m 被动偏振 3D LED 大屏开机 + ↓ +UE5 实时渲染的 3D 地球从屏幕"飞出" +(配合被动 3D 眼镜,数据点有真实景深) + ↓ +演示者做手势(无穿戴动捕摄像头捕捉) +→ 地球旋转、缩放 +→ 指向数据点 → 高亮 +→ 确认手势 → 信息卡弹出(漂浮在屏幕前方) + ↓ +鼠标/触控作为备用输入 ``` -在 UE 里这类东西叫 Struct。 - --- -## 16. Event Dispatcher -**Event Dispatcher = 事件分发器** +## 二、系统架构 -你可以把它理解成: +``` +┌─────────────────────────────────────────────────┐ +│ RTX 5090 渲染工作站 │ +│ │ +│ Planet 后端(FastAPI) │ +│ ↕ /api/v1/ue/* │ +│ UE5 主程序(本项目) │ +│ ├── Cesium 地球 │ +│ ├── 超算数据点 │ +│ ├── 其他可交互物件 │ +│ └── 立体渲染输出 (Side-by-Side / Top-Bottom) │ +│ │ +└─────────────┬───────────────────────────────────┘ + │ HDMI/DP 视频信号 + ▼ + 视频处理器(随屏配套) + │ LED 驱动信号(行偏振) + ▼ + 5.12m × 2.88m 被动偏振 3D LED 大屏 + ↑ + 观众戴无源 3D 眼镜(≤18g,无需充电) -- EventEmitter -- 发布订阅 - -比如: - -“数据加载完毕”这个事件,就可以分发给其他蓝图。 - ---- - -## 17. Spline -**Spline = 一条平滑曲线** - -后面做海缆、轨迹时非常有用。 -一期可以先知道这个词,不一定马上用。 - ---- - -## 18. Niagara -**Niagara = UE 粒子特效系统** - -比如: - -- 流光 -- 光晕 -- 拖尾 -- 火花 - -一期先不重点碰它。 - ---- - -# 四、你的真实开发策略:两阶段起步 - -这是这份融合版和原方案最大的区别。 - ---- - -## 阶段 A:本地演示版(先脱离后端) - -### 目标 -先把下面这些完全打通: - -- UE 项目启动正常 -- Cesium 地球正常 -- 相机可操作 -- 本地 JSON 文件能生成地球标记点 -- 点击点能弹信息卡 -- HUD 能显示假数据 - -### 为什么一定要先做这个 -因为如果你一上来就接真实后端,你会同时碰到: - -- WSL2 到 Windows 网络 -- Docker 端口映射 -- UE HTTP 请求 -- 蓝图 JSON 解析 -- Cesium 坐标转换 -- 标记点生成 - -新手很容易直接乱掉。 - ---- - -## 阶段 B:后端接入版(再联调) - -### 目标 -在 A 的基础上,加上: - -- HTTP 拉真实后端数据 -- 显示真实 TOP500 点 -- 右上角显示后端在线状态 -- 为后续做更多图层留下数据接入层 - ---- - -# 五、环境准备 - -## 1. 你要安装的软件 - -### Epic Games Launcher -用来下载和启动 UE。 - -### Unreal Engine 5.4 -建议直接用 5.4 稳定版。 - -### Visual Studio 2022 -虽然一期主要用 Blueprint,但 UE 的很多项目依赖 VS 环境。 - -安装组件: -- Desktop development with C++ -- Game development with C++ - -### Git -用来管理文档和后续工程。 - -### Cesium for Unreal -用来做地球。 - ---- - -## 2. 你的环境约束 - -你现在是: - -- 后端可能跑在 WSL2 / Docker -- UE 必须跑在 Windows - -所以你的真实运行方式通常会是: - -- **Windows** 运行 UE5 -- **WSL2** 运行后端 -- 两者通过 HTTP 通信 - -这里最关键的一条是: - -**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。** - ---- - -# 六、推荐的项目结构 - -## UE 项目目录内的 Content 结构 - -```text -Content/ - Blueprints/ - Data/ - Widgets/ - Materials/ - Levels/ - FX/ - Textures/ +动捕摄像头(×2) + ↓ 手势识别(无穿戴) +动捕中间件 ─→ UE5(Live Link / OSC,待确认) ``` -建议说明: +--- -- `Blueprints/` 放逻辑蓝图 -- `Data/` 放本地 JSON、DataTable、Struct -- `Widgets/` 放 UI -- `Materials/` 放材质 -- `Levels/` 放场景 -- `FX/` 放特效 -- `Textures/` 放贴图 +## 三、一期交付目标(6 项) + +1. ✅ 地球在 3D 大屏上正确显示,有景深效果 +2. ✅ 超算数据点散布在地球上,可悬停高亮 +3. ✅ 点击/手势确认 → 弹出数据点信息卡 +4. ✅ 鼠标拖拽/手势 → 地球旋转(带惯性) +5. ✅ 滚轮/手势 → 缩放 +6. ⏳ 立体渲染格式配置(等视频处理器格式答复) +7. ⏳ 动捕手势接入(等中间件协议答复) --- -# 七、一期最小蓝图清单 +## 四、已完成的代码 -一期只需要这几个核心蓝图。 +### 后端(`backend/app/api/v1/ue_data.py`) -## 1. `BP_GlobeCamera` -作用:相机控制器 +| 接口 | 说明 | +|------|------| +| `GET /api/v1/ue/status` | 健康检查 | +| `GET /api/v1/ue/compute-points` | 超算数据(平铺 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.h/.cpp` | HTTP 拉取 + 本地 mock 数据 + 生成 Actor | +| `ComputePointActor.h/.cpp` | 单个超算点 Actor,三态材质 | +| `InteractiveObjectBase.h/.cpp` | 所有可交互物件的基类 | +| `GlobeInteractionComponent.h/.cpp` | 地球旋转/缩放,带惯性 | +| `StereoRenderingManager.h/.cpp` | 立体渲染框架,运行时切换模式 | +| `MotionCaptureInterface.h` | 动捕接口定义,协议无关 | +| `PlanetPlayerController.h/.cpp` | 统一处理鼠标 + 动捕输入 | +| `PlanetGameMode.h/.cpp` | 场景入口,自动初始化管理器 | +| `PlanetClient.Build.cs` | 模块依赖(含 TODO 注释)| + +### 数据和配置 + +| 文件 | 说明 | +|------|------| +| `Content/Data/mock_compute_points.json` | 10 个真实超算的 mock 数据 | +| `Config/DefaultGame.ini` | GameMode 配置 | +| `Config/DefaultEngine.ini` | 渲染设置 | +| `Config/DefaultInput.ini` | 键鼠输入绑定 | --- -## 2. `BP_PlanetGameMode` -作用:指定默认的 Pawn 等 +## 五、你需要在编辑器里做的操作 + +> 完整步骤见 `docs/ue_client_setup_guide.md` + +**最小操作清单(Phase A 本地演示):** + +1. 安装 UE5.3 + Cesium for Unreal 插件 +2. 打开 `ue_client/PlanetClient.uproject`,等待编译 +3. 创建空关卡 `EarthMap`,通过 Cesium 菜单添加地球 +4. 拖入 CesiumDynamicPawn,设置 Auto Possess Player 0 +5. World Settings → GameMode → PlanetGameMode +6. 创建 `BP_ComputePointActor`(父类 `AComputePointActor`),配置球体网格 + 三色材质 +7. 创建 `BP_PlanetDataManager`,拖入场景,绑定 `ComputePointClass` +8. Play → 看到 10 个橙色球体,可悬停 + 点击 --- -## 3. `BP_DataLoader` -作用:负责读数据 +## 六、TODO 项(等供应商答复) -一期建议支持两种来源: +> 详细对照表见 `docs/ue_todo.md` -- 本地 JSON -- HTTP 接口 +### TODO-1:立体渲染格式 +- **等待**:视频处理器接受什么 3D 输入格式(SbS / TbB / 行交错) +- **代码位置**:`StereoRenderingManager.cpp` +- **工作量**:0.5 天 -这样调试更稳。 +### TODO-2:动捕协议 +- **等待**:中间件使用 Live Link / OSC / 私有 SDK +- **代码位置**:`MotionCaptureInterface.h`,`PlanetPlayerController.cpp` +- **工作量**:Live Link=2h,OSC=1天,私有SDK=1-3天 --- -## 4. `BP_ComputePoint` -作用:一个超算点的显示对象 - -负责: -- 接收一条数据 -- 放到正确经纬度位置 -- 显示外观 -- 处理点击 - ---- - -## 5. `WBP_InfoCard` -作用:点开后显示详情 - -显示: -- 名称 -- 国家 -- 算力 -- 可选显示更多字段 - ---- - -## 6. `WBP_StatusBar` -作用:右上角状态栏 - -显示: -- 后端在线/离线 -- 当前加载条数 -- 当前模式(本地数据 / 真实后端) - ---- - -# 八、数据层设计 - -一期不要一开始就完全照搬后端返回结构。 -你要先定义一个 UE 友好的结构。 - -## `S_ComputePoint` - -字段建议: - -- `PointId`:字符串,唯一 ID -- `Name`:字符串 -- `Latitude`:浮点 -- `Longitude`:浮点 -- `Performance`:浮点 -- `CoreCount`:整数 -- `Country`:字符串 -- `Source`:字符串 - -这个结构同时适用于: - -- 本地 JSON -- 后端 API 返回结果转换后的对象 - ---- - -# 九、最稳的执行路线 - -下面是整个实施计划最重要的部分。 - ---- - -# Phase 0:安装和验证环境 - -## 目标 -确保你能: - -- 安装 UE5.4 -- 启用 Cesium -- 能打开一个空项目 -- 能在 Windows 浏览器访问你的后端 - -## 验收 -满足以下 4 条: - -- UE 能打开 -- Cesium 能启用 -- 项目能创建 -- Windows 浏览器能访问后端 summary 接口 - -如果第 4 条做不到,不要继续推进真实接口联调。 - ---- - -# Phase 1:创建项目并把地球显示出来 - -## 目标 -打开项目后,能看到一个真实地球。 - -## 操作顺序 - -1. 新建 UE5 Blank Blueprint 项目 -2. 创建 `Main` 场景 -3. 启用 Cesium -4. 添加: - - `Cesium World Terrain` - - `Cesium Sun Sky` - - `CesiumGeoreference` -5. 调整视角,让你能看到整个地球 - -## 验收 -能录一段短视频,里面能看到地球和镜头移动。 - ---- - -# Phase 2:做相机控制 - -## 目标 -让地球可以: - -- 鼠标拖拽旋转 -- 滚轮缩放 - -## 说明 -这里可以沿用原 MVP 方案的思路: - -- `BP_GlobeCamera` 作为 Pawn -- Spring Arm + Camera 组成相机结构 -- 用输入控制旋转和缩放 - -## 注意 -这一版相机只是“一期可用版”,不是最终镜头系统。 - -## 验收 -按 Play 后: - -- 地球可旋转 -- 可缩放 -- 不会直接飞走或抖动失控 - ---- - -# Phase 3:先喂本地 JSON 数据 - -这是融合版方案里最关键的改动。 - -## 目标 -不接后端,先验证: - -- 数据结构正常 -- JSON 能读 -- 点能生成 -- 点击交互正常 - -## 为什么先这么做 -因为这样可以把问题收缩成 3 件事: - -- Cesium 坐标转换 -- 点渲染 -- UI 弹窗 - -不牵涉后端联调。 - -## 本地 JSON 示例格式 - -建议放在 `Content/Data/compute_points.json` - -```json -[ - { - "PointId": "top500_1", - "Name": "Frontier", - "Latitude": 35.93, - "Longitude": -84.31, - "Performance": 1194.0, - "CoreCount": 8730624, - "Country": "US", - "Source": "top500" - }, - { - "PointId": "top500_2", - "Name": "Fugaku", - "Latitude": 34.69, - "Longitude": 135.19, - "Performance": 442.0, - "CoreCount": 7630848, - "Country": "JP", - "Source": "top500" - } -] -``` - -## 推荐做法 -先做一个“本地模式”开关。 - -在 `BP_DataLoader` 里支持: - -- Mode = LocalJson -- Mode = HttpApi - -先永远跑 `LocalJson`。 - -## 验收 -你应该能看到: - -- 多个点出现在地球上 -- 大致位置正确 -- 点击能弹信息卡 - ---- - -# Phase 4:做超算点蓝图 - -## 目标 -完成 `BP_ComputePoint` - -每个点要实现: - -- 接收一条 `S_ComputePoint` -- 经度纬度转成 UE 世界坐标 -- 在地球上显示为一个可见的发光球 -- 支持被点击 - -## 显示建议 - -### 外观 -先用最简单的球体 Static Mesh。 - -### 材质 -做一个发光材质: - -- 红橙色 -- 自发光 -- 不追求复杂效果 - -### 大小 -球体要足够大,确保在地球尺度下看得见。 - -### 高度 -不要贴地表太近,建议悬浮在地表上方一个固定高度。 - -## 验收 -同一批数据点在地球上的位置大体合理。 - ---- - -# Phase 5:做信息卡 - -## 目标 -点击一个点后,弹出一个简单的信息卡。 - -## `WBP_InfoCard` 要显示的内容 -建议只显示最关键的 3 个字段: - -- 名称 -- 国家 -- 算力 - -一期先不要堆太多字段。 - -## 验收 -点击点 → 卡片出现 -点击关闭 → 卡片消失 - ---- - -# Phase 6:做基础 HUD - -## 目标 -屏幕上始终有一个简单状态栏。 - -## `WBP_StatusBar` 显示内容建议 -- 当前模式:Local / HTTP -- 已加载数据点数量 -- 后端状态:Unknown / Online / Offline - -在本地模式阶段,状态可以先写死或显示 `Local Demo`。 - -## 验收 -不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。 - ---- - -# Phase 7:再接真实后端 - -这是第二阶段开始。 - -## 目标 -把数据源从本地 JSON 切到 HTTP。 - -## 正确做法 -不要把 `BP_DataLoader` 重写。 -而是让它支持: - -- LocalJsonLoader -- HttpLoader - -也就是: - -**显示层不变,只替换数据来源。** - -## 最重要的接口原则 -如果后端已有接口字段非常杂,不一定要 UE 直接吃。 -可以加一个“更适合 UE 的轻量接口”。 - -例如: - -`/api/v1/ue/bootstrap/top500` - -返回尽量扁平的数据: - -```json -[ - { - "PointId": "top500_1", - "Name": "Frontier", - "Latitude": 35.93, - "Longitude": -84.31, - "Performance": 1194.0, - "CoreCount": 8730624, - "Country": "US", - "Source": "top500" - } -] -``` - -## 为什么推荐 UE 轻量接口 -因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。 - ---- - -# Phase 8:做连接状态检测 - -## 目标 -让 HUD 能显示: - -- 在线 -- 离线 -- 本地模式 - -## 正确实现思路 -建议用一个很小的状态请求,比如: - -- summary 接口 -- health 接口 -- 或 UE 专用 ping 接口 - -不要让状态检测去依赖一个超大的数据接口。 - -## 验收 -后端关掉时,状态栏能明显变成 Offline。 - ---- - -# Phase 9:打包发布 - -## 目标 -把项目打包成 Windows 可执行程序。 - -## 注意 -打包是一期必须尝试的,但不要让它阻塞前面所有开发。 - -也就是说: - -- 编辑器里没稳定跑通前,不要反复纠结打包 -- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包 - -## 验收 -双击 exe 可以运行,进入地球场景并正常展示数据。 - ---- - -# 十、建议的 14 天执行计划 - -这版比原 MVP 的时间估计更保守,也更适合新手。 - -## 第 1 天 -- 安装 UE5.4 -- 安装 Cesium -- 创建空项目 -- 创建 Main 场景 - -## 第 2 天 -- 启用 Cesium -- 把地球跑起来 -- 保存项目结构 - -## 第 3 天 -- 做 `BP_GlobeCamera` -- 跑通旋转和缩放 - -## 第 4 天 -- 建 `S_ComputePoint` -- 准备本地 JSON 文件 -- 做 `BP_DataLoader` 的本地模式 - -## 第 5 天 -- 做 `BP_ComputePoint` -- 本地 JSON 批量生成点 - -## 第 6 天 -- 调整点大小、颜色、高度 -- 检查经纬度位置是否大致正确 - -## 第 7 天 -- 做 `WBP_InfoCard` -- 跑通点击点弹卡片 - -## 第 8 天 -- 做 `WBP_StatusBar` -- 显示本地模式状态和点数量 - -## 第 9 天 -- Windows 浏览器验证后端接口 -- 准备 HTTP 版加载逻辑 - -## 第 10 天 -- 实现 HTTP 拉真实数据 -- 先在日志里确认数据到了 - -## 第 11 天 -- 把 HTTP 数据接到点渲染 -- 切换 Local / HTTP 两种模式 - -## 第 12 天 -- 做连接状态 Online / Offline -- 补错误提示 - -## 第 13 天 -- 测试完整链路 -- 修点选、缩放、HUD 细节 - -## 第 14 天 -- 进行第一次打包 -- 在 Windows 下运行 exe 验证 - ---- - -# 十一、这份方案和原 MVP 方案怎么融合 - -下面是合并关系。 - -## 保留原 MVP 方案的部分 -这些内容很好,建议继续用: - -- 术语表 -- Phase 结构化写法 -- `BP_GlobeCamera` -- `BP_ComputePoint` -- `WBP_InfoCard` -- `WBP_StatusBar` -- 相机、点、信息卡、状态栏这 4 个核心对象 -- “先别做海缆、卫星、BGP”的范围控制 - -## 用融合版修正的部分 -这些是这份新文档加进去的: - -- 两阶段起步:先本地 JSON,再真实后端 -- 不默认 `localhost` 一定通 -- 推荐做 UE 轻量接口,而不是死扛原始接口 -- 把打包放到后段,而不是过早纠结 -- 时间预估更保守 -- 明确“一期只是证明链路跑通” - ---- - -# 十二、验收清单 - -## 环境 -- [ ] UE5.4 安装成功 -- [ ] Cesium 插件启用成功 -- [ ] Windows 能访问后端接口 - -## 本地演示版 -- [ ] 地球渲染正常 -- [ ] 鼠标可旋转和缩放 -- [ ] 本地 JSON 数据能生成点 -- [ ] 点的位置大体正确 -- [ ] 点击点能弹信息卡 -- [ ] HUD 可显示本地模式和点数量 - -## 后端接入版 -- [ ] HTTP 能拉取真实数据 -- [ ] HTTP 数据能生成点 -- [ ] HUD 能显示 Online/Offline -- [ ] 切换 Local / HTTP 模式不崩 -- [ ] exe 能打包并运行 - ---- - -# 十三、后续路线(MVP 之后) - -当这一期做完后,下一步顺序建议是: - -1. 海缆路径 -2. 卫星点或轨迹 -3. 更稳的相机与巡航 -4. WebSocket 增量更新 -5. BGP 区域态势 -6. BGP 事件点 -7. 更强的粒子和视觉风格 - -也就是说: - -**先补“静态层和镜头层”,再补“高频实时层”。** - ---- - -# 十四、一句话总结 - -这份融合版方案的核心就是: - -**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线,让你第一次做 UE 时更稳、更容易成功。** - -如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是: - -**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。** +## 七、后续阶段规划 + +| 阶段 | 内容 | 状态 | +|------|------|------| +| Phase A | 本地 mock 数据,鼠标交互,单目 2D | ✅ 代码就绪 | +| Phase B | 接入真实后端 `/api/v1/ue/*` | ✅ 代码就绪 | +| Phase C | 立体 3D 输出 | ⏳ 等格式确认 | +| Phase D | 动捕手势交互 | ⏳ 等协议确认 | +| Phase E | 海缆 Spline 渲染 | 待开发 | +| Phase F | 其他可交互物件(基类已就绪) | 待定义 | diff --git a/docs/ue_client_setup_guide.md b/docs/ue_client_setup_guide.md new file mode 100644 index 00000000..e786fda2 --- /dev/null +++ b/docs/ue_client_setup_guide.md @@ -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. 左侧点击 **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 Log(Window → 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 实时更新 | 后端已有 WebSocket,UE 端需要使用 WebSockets 插件 | +| 卫星轨迹 | TLE 数据已就绪,需要在 UE 里做轨道传播计算 | diff --git a/docs/ue_todo.md b/docs/ue_todo.md new file mode 100644 index 00000000..508f6ded --- /dev/null +++ b/docs/ue_todo.md @@ -0,0 +1,62 @@ +# 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:动捕中间件协议 + +**等待信息**:光学姿态识别系统用什么协议输出数据给 UE5? + +| 可能答案 | 对应操作 | +|---------|---------| +| **Live Link**(首选) | 在 UE5 编辑器里添加 Live Link Source,零代码接入,约 2 小时完成 | +| **OSC over UDP** | 新建 `MotionCaptureReceiverOSC.h/.cpp`,实现 UDP 监听和 OSC 解析,约 1 天 | +| 私有 SDK(提供 .dll) | 需要 SDK 文档,封装成 UE5 插件,约 2-3 天 | +| 私有 SDK(提供 UE5 插件) | 直接安装插件,对接事件接口,约 0.5 天 | + +**代码位置(已预留接口,填协议实现即可)**: +``` +ue_client/Source/PlanetClient/MotionCaptureInterface.h — UMotionCaptureReceiver 基类 +ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents() 函数(TODO 注释处取消注释) +ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释的 LiveLink/Sockets 依赖 +``` + +**需要同时确认**: +- 手势集合(供应商能识别哪些具体手势,用于填写 `EMotionGesture` 枚举) +- 数据刷新频率(帧率) +- 是否支持持续输出旋转增量,还是只能输出离散手势事件 + +--- + +## 答复到位后的操作清单 + +拿到答案后告诉我,我来: + +1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide +2. **动捕协议** → 实现对应的 `UMotionCaptureReceiver` 子类,更新 `Build.cs` 依赖,在 `PlayerController::BeginPlay` 里取消注释绑定代码 diff --git a/ue_client/Config/DefaultEngine.ini b/ue_client/Config/DefaultEngine.ini new file mode 100644 index 00000000..bd2183d3 --- /dev/null +++ b/ue_client/Config/DefaultEngine.ini @@ -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 diff --git a/ue_client/Config/DefaultGame.ini b/ue_client/Config/DefaultGame.ini new file mode 100644 index 00000000..921936f9 --- /dev/null +++ b/ue_client/Config/DefaultGame.ini @@ -0,0 +1,3 @@ +[/Script/EngineSettings.GameMapsSettings] +GlobalDefaultGameMode=/Script/PlanetClient.PlanetGameMode +GameDefaultMap=/Game/Maps/EarthMap diff --git a/ue_client/Config/DefaultInput.ini b/ue_client/Config/DefaultInput.ini new file mode 100644 index 00000000..bab3d97a --- /dev/null +++ b/ue_client/Config/DefaultInput.ini @@ -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) diff --git a/ue_client/Content/Data/mock_compute_points.json b/ue_client/Content/Data/mock_compute_points.json new file mode 100644 index 00000000..34de6273 --- /dev/null +++ b/ue_client/Content/Data/mock_compute_points.json @@ -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 + } + ] +} diff --git a/ue_client/Source/PlanetClient/ComputePointActor.cpp b/ue_client/Source/PlanetClient/ComputePointActor.cpp new file mode 100644 index 00000000..cfe90e64 --- /dev/null +++ b/ue_client/Source/PlanetClient/ComputePointActor.cpp @@ -0,0 +1,53 @@ +#include "ComputePointActor.h" +#include "Components/StaticMeshComponent.h" + +AComputePointActor::AComputePointActor() +{ + PrimaryActorTick.bCanEverTick = false; + + SphereMesh = CreateDefaultSubobject(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); + } +} diff --git a/ue_client/Source/PlanetClient/ComputePointActor.h b/ue_client/Source/PlanetClient/ComputePointActor.h new file mode 100644 index 00000000..2dfd682a --- /dev/null +++ b/ue_client/Source/PlanetClient/ComputePointActor.h @@ -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(); +}; diff --git a/ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp b/ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp new file mode 100644 index 00000000..734a2ca0 --- /dev/null +++ b/ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp @@ -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(GetOwner()); + APawn* Pawn = Controller ? Controller->GetPawn() : Cast(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; +} diff --git a/ue_client/Source/PlanetClient/GlobeInteractionComponent.h b/ue_client/Source/PlanetClient/GlobeInteractionComponent.h new file mode 100644 index 00000000..5ae20e9f --- /dev/null +++ b/ue_client/Source/PlanetClient/GlobeInteractionComponent.h @@ -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(); +}; diff --git a/ue_client/Source/PlanetClient/InteractiveObjectBase.cpp b/ue_client/Source/PlanetClient/InteractiveObjectBase.cpp new file mode 100644 index 00000000..183eb2c5 --- /dev/null +++ b/ue_client/Source/PlanetClient/InteractiveObjectBase.cpp @@ -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() {} diff --git a/ue_client/Source/PlanetClient/InteractiveObjectBase.h b/ue_client/Source/PlanetClient/InteractiveObjectBase.h new file mode 100644 index 00000000..f4c3e9a9 --- /dev/null +++ b/ue_client/Source/PlanetClient/InteractiveObjectBase.h @@ -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; +}; diff --git a/ue_client/Source/PlanetClient/MotionCaptureInterface.h b/ue_client/Source/PlanetClient/MotionCaptureInterface.h new file mode 100644 index 00000000..88fda65c --- /dev/null +++ b/ue_client/Source/PlanetClient/MotionCaptureInterface.h @@ -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; +}; diff --git a/ue_client/Source/PlanetClient/PlanetClient.Build.cs b/ue_client/Source/PlanetClient/PlanetClient.Build.cs new file mode 100644 index 00000000..ad0f8a8a --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetClient.Build.cs @@ -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[] { }); + } +} diff --git a/ue_client/Source/PlanetClient/PlanetClient.cpp b/ue_client/Source/PlanetClient/PlanetClient.cpp new file mode 100644 index 00000000..c2232c9c --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetClient.cpp @@ -0,0 +1,4 @@ +#include "PlanetClient.h" +#include "Modules/ModuleManager.h" + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PlanetClient, "PlanetClient"); diff --git a/ue_client/Source/PlanetClient/PlanetClient.h b/ue_client/Source/PlanetClient/PlanetClient.h new file mode 100644 index 00000000..56c5ef1e --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetClient.h @@ -0,0 +1,3 @@ +#pragma once + +#include "CoreMinimal.h" diff --git a/ue_client/Source/PlanetClient/PlanetDataManager.cpp b/ue_client/Source/PlanetClient/PlanetDataManager.cpp new file mode 100644 index 00000000..c54afd43 --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetDataManager.cpp @@ -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 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 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 Points = ParseComputePointsJson(Response->GetContentAsString()); + UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: received %d compute points"), Points.Num()); + SpawnComputePoints(Points); +} + +// --------------------------------------------------------------------------- +// JSON parser +// --------------------------------------------------------------------------- + +TArray APlanetDataManager::ParseComputePointsJson(const FString& JsonStr) +{ + TArray Result; + + TSharedPtr Root; + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonStr); + if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) + { + UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: JSON parse failed")); + return Result; + } + + const TArray>* Items; + if (!Root->TryGetArrayField(TEXT("items"), Items)) + { + UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: no 'items' array in JSON")); + return Result; + } + + for (const TSharedPtr& Val : *Items) + { + const TSharedPtr* ObjPtr; + if (!Val->TryGetObject(ObjPtr)) continue; + const TSharedPtr& 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& 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( + 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()); +} diff --git a/ue_client/Source/PlanetClient/PlanetDataManager.h b/ue_client/Source/PlanetClient/PlanetDataManager.h new file mode 100644 index 00000000..372ef3af --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetDataManager.h @@ -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 /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 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 GetAllComputePoints() const { return SpawnedPoints; } + +protected: + virtual void BeginPlay() override; + +private: + TArray 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& Points); + + // JSON → FComputePoint array + static TArray ParseComputePointsJson(const FString& JsonStr); +}; diff --git a/ue_client/Source/PlanetClient/PlanetDataTypes.h b/ue_client/Source/PlanetClient/PlanetDataTypes.h new file mode 100644 index 00000000..e16a193b --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetDataTypes.h @@ -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 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> Segments; +}; diff --git a/ue_client/Source/PlanetClient/PlanetGameMode.cpp b/ue_client/Source/PlanetClient/PlanetGameMode.cpp new file mode 100644 index 00000000..053abe64 --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetGameMode.cpp @@ -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 Found; + UGameplayStatics::GetAllActorsOfClass(GetWorld(), AStereoRenderingManager::StaticClass(), Found); + if (Found.Num() == 0) + { + GetWorld()->SpawnActor( + AStereoRenderingManager::StaticClass(), + FVector::ZeroVector, FRotator::ZeroRotator); + UE_LOG(LogTemp, Log, TEXT("PlanetGameMode: 已自动生成 StereoRenderingManager")); + } +} diff --git a/ue_client/Source/PlanetClient/PlanetGameMode.h b/ue_client/Source/PlanetClient/PlanetGameMode.h new file mode 100644 index 00000000..95bfcf58 --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetGameMode.h @@ -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; +}; diff --git a/ue_client/Source/PlanetClient/PlanetPlayerController.cpp b/ue_client/Source/PlanetClient/PlanetPlayerController.cpp new file mode 100644 index 00000000..c9dd8520 --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetPlayerController.cpp @@ -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( + 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(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); +} diff --git a/ue_client/Source/PlanetClient/PlanetPlayerController.h b/ue_client/Source/PlanetClient/PlanetPlayerController.h new file mode 100644 index 00000000..4d446e66 --- /dev/null +++ b/ue_client/Source/PlanetClient/PlanetPlayerController.h @@ -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(); +}; diff --git a/ue_client/Source/PlanetClient/StereoRenderingManager.cpp b/ue_client/Source/PlanetClient/StereoRenderingManager.cpp new file mode 100644 index 00000000..a057b831 --- /dev/null +++ b/ue_client/Source/PlanetClient/StereoRenderingManager.cpp @@ -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 —— 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: 立体渲染已开启,模式=%s,IPD=%.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"); + } +} diff --git a/ue_client/Source/PlanetClient/StereoRenderingManager.h b/ue_client/Source/PlanetClient/StereoRenderingManager.h new file mode 100644 index 00000000..2a21a73b --- /dev/null +++ b/ue_client/Source/PlanetClient/StereoRenderingManager.h @@ -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); +}; -- 2.49.1 From 4dd396ea654045efa372bcd8090ad3dd3620d143 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 14 Apr 2026 18:56:37 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20update=20Marketplace=20=E2=86=92=20?= =?UTF-8?q?Fab=20in=20UE=20setup=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/ue_client_setup_guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ue_client_setup_guide.md b/docs/ue_client_setup_guide.md index e786fda2..f1845253 100644 --- a/docs/ue_client_setup_guide.md +++ b/docs/ue_client_setup_guide.md @@ -24,7 +24,7 @@ 1. 打开 **Epic Games Launcher** 2. 顶部切换到 **Unreal Engine** 选项卡 -3. 左侧点击 **Marketplace** → 搜索 `Cesium for Unreal` +3. 左侧点击 **Fab**(原 Marketplace,已改名)→ 搜索 `Cesium for Unreal` 4. 点击 **免费获取**(Free),然后点击 **安装到引擎** → 选择 5.3 5. 等待安装完成 -- 2.49.1 From 7ffc8537e4d33e5a87c3cebc9a4a8e44a8b57f7e Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 14 Apr 2026 19:00:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20update=20mocap=20TODO=20=E2=80=94?= =?UTF-8?q?=20confirmed=20as=20UE5=20plugin,=20not=20Live=20Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/ue_todo.md | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/ue_todo.md b/docs/ue_todo.md index 508f6ded..0293bfa0 100644 --- a/docs/ue_todo.md +++ b/docs/ue_todo.md @@ -29,29 +29,35 @@ ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函 --- -## TODO-2:动捕中间件协议 +## TODO-2:动捕插件 API 文档 -**等待信息**:光学姿态识别系统用什么协议输出数据给 UE5? +**已确认**:动捕识别以 UE5 插件形式交付,摄像头直连电脑,不走 Live Link 转接。 -| 可能答案 | 对应操作 | -|---------|---------| -| **Live Link**(首选) | 在 UE5 编辑器里添加 Live Link Source,零代码接入,约 2 小时完成 | -| **OSC over UDP** | 新建 `MotionCaptureReceiverOSC.h/.cpp`,实现 UDP 监听和 OSC 解析,约 1 天 | -| 私有 SDK(提供 .dll) | 需要 SDK 文档,封装成 UE5 插件,约 2-3 天 | -| 私有 SDK(提供 UE5 插件) | 直接安装插件,对接事件接口,约 0.5 天 | +**等待信息**:供应商提供插件文件 + API 文档后,需要知道: -**代码位置(已预留接口,填协议实现即可)**: +| 需要确认的内容 | 用途 | +|-------------|------| +| 插件模块名称(`ModuleName`) | 加入 `PlanetClient.Build.cs` 依赖 | +| 手势事件的 C++ 类名 / 委托名 | 替换 `MotionCaptureInterface.h` 里的抽象接口 | +| 手势集合(能识别哪些手势) | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` | +| 是否支持持续输出旋转增量 | 决定旋转地球用"持续增量"还是"离散手势触发" | +| 数据回调是 C++ 委托还是 Blueprint 事件 | 决定绑定方式 | + +**拿到插件后的接入步骤(我来做)**: +1. 把插件放到 `ue_client/Plugins/` 目录 +2. 在 `PlanetClient.uproject` 里启用插件 +3. 在 `PlanetClient.Build.cs` 里加插件模块依赖 +4. 用插件实际 API 实现 `UMotionCaptureReceiver` 子类 +5. 在 `PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()` + +**代码位置(接口已预留)**: ``` -ue_client/Source/PlanetClient/MotionCaptureInterface.h — UMotionCaptureReceiver 基类 -ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents() 函数(TODO 注释处取消注释) -ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释的 LiveLink/Sockets 依赖 +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 数组加插件条目 ``` -**需要同时确认**: -- 手势集合(供应商能识别哪些具体手势,用于填写 `EMotionGesture` 枚举) -- 数据刷新频率(帧率) -- 是否支持持续输出旋转增量,还是只能输出离散手势事件 - --- ## 答复到位后的操作清单 @@ -59,4 +65,4 @@ ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释的 LiveLink/ 拿到答案后告诉我,我来: 1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide -2. **动捕协议** → 实现对应的 `UMotionCaptureReceiver` 子类,更新 `Build.cs` 依赖,在 `PlayerController::BeginPlay` 里取消注释绑定代码 +2. **动捕插件** → 插件放入 `ue_client/Plugins/`,实现 `UMotionCaptureReceiver` 子类对接插件 API,更新 `Build.cs` 和 `.uproject` -- 2.49.1 From a4e6ce7489cf17cc93b7ae34af0ad207210707d6 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 14 Apr 2026 19:20:40 +0800 Subject: [PATCH 4/4] docs: update mocap delivery timeline and migration workflow --- docs/ue5_led_context.md | 144 ++++++++++++++++++++++++++++++++++++++++ docs/ue_todo.md | 52 ++++++++++----- 2 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 docs/ue5_led_context.md diff --git a/docs/ue5_led_context.md b/docs/ue5_led_context.md new file mode 100644 index 00000000..8a3d6fd0 --- /dev/null +++ b/docs/ue5_led_context.md @@ -0,0 +1,144 @@ +# 项目背景:UE5 + 3D LED 大屏展示系统 + +> 供其他 AI 快速了解项目背景和当前状态。 +> 最后更新:2026-04-14 + +--- + +## 一、项目定位 + +这不是一个普通的桌面地球应用,而是一套**领导演示用的 3D 沉浸式展示系统**。 + +核心逻辑: +- 态势感知数据(超算、海缆、卫星)是内容 +- 3D LED 大屏 + 实时渲染 + 动捕交互是"醋"——没有它,内容再好也只是普通屏幕 +- 主要受众:领导/决策层,注重视觉冲击力 + +--- + +## 二、硬件配置 + +| 硬件 | 规格 | 说明 | +|------|------|------| +| 3D LED 大屏 | 5.12m × 2.88m,P1.538mm,被动偏振式 | 观众戴无源 3D 眼镜(≤18g),60Hz 即可出稳定 3D | +| 渲染工作站 | 双路国产 X86 CPU + RTX 5090 32GB | 驱动 UE5 实时渲染 | +| 视频处理器 | 随屏配套(品牌待确认) | 接收 GPU 信号,驱动 LED 墙 | +| 动捕摄像头 | RGB 摄像头 ×2,4K,直连电脑 | 无穿戴姿态识别 | +| 音响 | 解码功放 + 吸顶喇叭 ×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/TbB,IPD 可调 | +| `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` diff --git a/docs/ue_todo.md b/docs/ue_todo.md index 0293bfa0..835eddf3 100644 --- a/docs/ue_todo.md +++ b/docs/ue_todo.md @@ -29,30 +29,45 @@ ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函 --- -## TODO-2:动捕插件 API 文档 +## TODO-2:动捕角色接入 -**已确认**:动捕识别以 UE5 插件形式交付,摄像头直连电脑,不走 Live Link 转接。 +**已确认**: +- **本周末前**:供应商交付含动捕插件 + 3D 角色(已配好绑定和重定向)的基础 UE5 工程 + - 接入两台摄像头,4080 以上显卡即可直接运行动捕调试 +- **之后尽快**:动画资产单独交付,复制到工程 Content/ 目录即可直接调用 +- **我们的任务**:把他们工程内容迁移进 `ue_client/`,把角色动作映射到地球操作 -**等待信息**:供应商提供插件文件 + API 文档后,需要知道: +--- + +### 阶段 A:拿到基础工程后(本周末) + +**迁移步骤**: + +1. **迁移动捕插件** + - 从供应商工程 `Plugins/` 拷到 `ue_client/Plugins/` + - `PlanetClient.uproject` 的 `Plugins` 数组添加插件条目(`Enabled: true`) + - `PlanetClient.Build.cs` 的 `PublicDependencyModuleNames` 加插件模块名 + +2. **迁移角色** + - 把角色 Blueprint、动画、骨骼网格从供应商 `Content/` 拷到 `ue_client/Content/` + - 在关卡里放置角色 Actor,确认摄像头接入后能正常驱动 + +3. **接入动作触发(关键,看到工程后确认方式)**: | 需要确认的内容 | 用途 | |-------------|------| -| 插件模块名称(`ModuleName`) | 加入 `PlanetClient.Build.cs` 依赖 | -| 手势事件的 C++ 类名 / 委托名 | 替换 `MotionCaptureInterface.h` 里的抽象接口 | -| 手势集合(能识别哪些手势) | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` | -| 是否支持持续输出旋转增量 | 决定旋转地球用"持续增量"还是"离散手势触发" | -| 数据回调是 C++ 委托还是 Blueprint 事件 | 决定绑定方式 | +| 角色动作怎么暴露给外部? | Blueprint Custom Event / AnimNotify / 骨骼姿态变量? | +| 手势集合有哪些? | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` | +| 是否持续输出旋转增量? | 旋转地球用"持续增量"还是"离散手势触发" | +| 插件模块名称(`ModuleName`)| 加入 `Build.cs` 依赖 | -**拿到插件后的接入步骤(我来做)**: -1. 把插件放到 `ue_client/Plugins/` 目录 -2. 在 `PlanetClient.uproject` 里启用插件 -3. 在 `PlanetClient.Build.cs` 里加插件模块依赖 -4. 用插件实际 API 实现 `UMotionCaptureReceiver` 子类 -5. 在 `PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()` +**拿到工程后(我来做)**: +- 用插件实际 API 实现 `UMotionCaptureReceiver` 子类 +- 在 `PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()` **代码位置(接口已预留)**: ``` -ue_client/Source/PlanetClient/MotionCaptureInterface.h — 基类和手势枚举 +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 数组加插件条目 @@ -60,6 +75,13 @@ ue_client/PlanetClient.uproject — Plugins 数组加 --- +### 阶段 B:动画资产到位后 + +- 把供应商提供的动画资产直接复制到 `ue_client/Content/` 对应目录 +- 在 `PlanetDataManager` / 场景 Actor 里引用这些资产即可调用 + +--- + ## 答复到位后的操作清单 拿到答案后告诉我,我来: -- 2.49.1