feat: add UE5 LED display client — backend API, C++ source, stereo framework
Backend: - Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status) returning flat JSON optimised for UE5 C++ parsing UE5 client (ue_client/): - PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors - ComputePointActor / InteractiveObjectBase: hover/select state, material switching - GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom - StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD) - MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD) - PlanetPlayerController: unified mouse + motion-capture input routing - PlanetGameMode, Build.cs, Config, mock data Docs: - ue5_mvp_fused_plan.md updated to v3.0 for LED display context - ue_client_setup_guide.md: step-by-step editor setup guide - ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"])
|
||||
|
||||
351
backend/app/api/v1/ue_data.py
Normal file
351
backend/app/api/v1/ue_data.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""UE Client Data API
|
||||
|
||||
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
|
||||
Avoids GeoJSON nesting — every field is at the top level of each item.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _current_stmt(source: str, limit: Optional[int] = None):
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == source)
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.order_by(CollectedData.id.desc())
|
||||
)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
return stmt
|
||||
|
||||
|
||||
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
|
||||
result = await db.execute(_current_stmt(source, limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
v = float(value)
|
||||
return v if v == v else None # reject NaN
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status")
|
||||
async def ue_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Quick health-check + data counts for the UE client."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
async def count_source(source: str) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(CollectedData)
|
||||
.where(CollectedData.source == source)
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"server_time": to_iso8601_utc(datetime.now(UTC)),
|
||||
"compute_points_count": await count_source("top500"),
|
||||
"cables_count": await count_source("telegeography_cables"),
|
||||
"landing_points_count": await count_source("arcgis_landing"),
|
||||
"satellites_count": await count_source("celestrak"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compute points (TOP500 supercomputers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/compute-points")
|
||||
async def ue_compute_points(
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Returns TOP500 supercomputer data as a flat JSON array.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 500,
|
||||
"items": [
|
||||
{
|
||||
"id": "top500_1",
|
||||
"name": "Frontier",
|
||||
"latitude": 36.01,
|
||||
"longitude": -84.26,
|
||||
"country": "United States",
|
||||
"city": "Oak Ridge",
|
||||
"rank": 1,
|
||||
"rmax_tflops": 1194000.0,
|
||||
"rpeak_tflops": 1679616.0,
|
||||
"cores": 8730112,
|
||||
"power_kw": 22703.0
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "top500", limit)
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
lat = _safe_float(get_record_field(record, "latitude"))
|
||||
lon = _safe_float(get_record_field(record, "longitude"))
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
items.append({
|
||||
"id": f"top500_{record.id}",
|
||||
"name": record.name or "Unknown",
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"country": get_record_field(record, "country") or "",
|
||||
"city": get_record_field(record, "city") or "",
|
||||
"rank": meta.get("rank"),
|
||||
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
|
||||
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
|
||||
"cores": meta.get("cores"),
|
||||
"power_kw": _safe_float(get_record_field(record, "power")),
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cable landing points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/landing-points")
|
||||
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Returns cable landing points as a flat JSON array.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 1200,
|
||||
"items": [
|
||||
{
|
||||
"id": "lp_42",
|
||||
"name": "Shoreham",
|
||||
"latitude": 50.83,
|
||||
"longitude": -0.28,
|
||||
"country": "United Kingdom",
|
||||
"city": "Shoreham-by-Sea",
|
||||
"cable_names": ["FLAG", "TAT-14"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Load landing points
|
||||
lp_records = await _fetch(db, "arcgis_landing")
|
||||
|
||||
# Load relation + cable data for cable_names mapping
|
||||
rel_result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "arcgis_relation")
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
rel_records = list(rel_result.scalars().all())
|
||||
|
||||
cable_result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "telegeography_cables")
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
cable_records = list(cable_result.scalars().all())
|
||||
|
||||
# Build mapping: city_id → list of cable names
|
||||
city_to_cable_ids: Dict[int, List[int]] = {}
|
||||
for r in rel_records:
|
||||
meta = r.extra_data or {}
|
||||
city_id = meta.get("city_id")
|
||||
cable_id = meta.get("cable_id")
|
||||
if city_id is not None and cable_id is not None:
|
||||
city_to_cable_ids.setdefault(city_id, [])
|
||||
if cable_id not in city_to_cable_ids[city_id]:
|
||||
city_to_cable_ids[city_id].append(cable_id)
|
||||
|
||||
cable_id_to_name: Dict[int, str] = {}
|
||||
for r in cable_records:
|
||||
meta = r.extra_data or {}
|
||||
cable_id = meta.get("cable_id")
|
||||
if cable_id and r.name:
|
||||
cable_id_to_name[cable_id] = r.name
|
||||
|
||||
items = []
|
||||
for record in lp_records:
|
||||
lat = _safe_float(get_record_field(record, "latitude"))
|
||||
lon = _safe_float(get_record_field(record, "longitude"))
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
meta = record.extra_data or {}
|
||||
city_id = meta.get("city_id")
|
||||
cable_names = []
|
||||
if city_id in city_to_cable_ids:
|
||||
cable_names = [
|
||||
cable_id_to_name[cid]
|
||||
for cid in city_to_cable_ids[city_id]
|
||||
if cid in cable_id_to_name
|
||||
]
|
||||
items.append({
|
||||
"id": f"lp_{record.id}",
|
||||
"name": record.name or "Unknown",
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"country": get_record_field(record, "country") or "",
|
||||
"city": get_record_field(record, "city") or "",
|
||||
"cable_names": cable_names,
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cables (route geometry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/cables")
|
||||
async def ue_cables(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Returns cable route geometry.
|
||||
|
||||
Each segment is a flat array of [lon, lat] pairs.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 100,
|
||||
"items": [
|
||||
{
|
||||
"id": "cable_42",
|
||||
"cable_id": "flag",
|
||||
"name": "FLAG",
|
||||
"status": "active",
|
||||
"length_km": 28000,
|
||||
"segments": [
|
||||
[[lon, lat], [lon, lat], ...]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "telegeography_cables")
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
route_coords = meta.get("route_coordinates", [])
|
||||
segments: List[List[List[float]]] = []
|
||||
|
||||
if route_coords:
|
||||
# Support both flat [lon,lat] array and array-of-arrays
|
||||
if route_coords and isinstance(route_coords[0][0], list):
|
||||
raw_lines = route_coords
|
||||
else:
|
||||
raw_lines = [route_coords]
|
||||
|
||||
for raw_line in raw_lines:
|
||||
line = []
|
||||
for pt in raw_line:
|
||||
try:
|
||||
line.append([float(pt[0]), float(pt[1])])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if len(line) >= 2:
|
||||
segments.append(line)
|
||||
|
||||
if not segments:
|
||||
continue
|
||||
|
||||
items.append({
|
||||
"id": f"cable_{record.id}",
|
||||
"cable_id": record.source_id or record.name or "",
|
||||
"name": record.name or "Unknown",
|
||||
"status": meta.get("status", "active"),
|
||||
"length_km": _safe_float(get_record_field(record, "value")),
|
||||
"owners": meta.get("owners") or [],
|
||||
"rfs": meta.get("rfs"),
|
||||
"color": meta.get("color"),
|
||||
"segments": segments,
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Satellites (TLE data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/satellites")
|
||||
async def ue_satellites(
|
||||
limit: int = Query(default=200, ge=1, le=5000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Returns satellite TLE data for orbit propagation in UE.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 200,
|
||||
"items": [
|
||||
{
|
||||
"id": "sat_42",
|
||||
"norad_id": "25544",
|
||||
"name": "ISS (ZARYA)",
|
||||
"tle_line1": "1 25544U ...",
|
||||
"tle_line2": "2 25544 ...",
|
||||
"epoch": "2026-04-14T00:00:00Z",
|
||||
"inclination": 51.6,
|
||||
"mean_motion": 15.5
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "celestrak", limit)
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
norad_id = meta.get("norad_cat_id")
|
||||
if not norad_id:
|
||||
continue
|
||||
tle1 = meta.get("tle_line1")
|
||||
tle2 = meta.get("tle_line2")
|
||||
if not tle1 or not tle2:
|
||||
tle1, tle2 = build_tle_lines_from_elements(
|
||||
norad_cat_id=norad_id,
|
||||
epoch=meta.get("epoch"),
|
||||
inclination=meta.get("inclination"),
|
||||
raan=meta.get("raan"),
|
||||
eccentricity=meta.get("eccentricity"),
|
||||
arg_of_perigee=meta.get("arg_of_perigee"),
|
||||
mean_anomaly=meta.get("mean_anomaly"),
|
||||
mean_motion=meta.get("mean_motion"),
|
||||
)
|
||||
items.append({
|
||||
"id": f"sat_{record.id}",
|
||||
"norad_id": str(norad_id),
|
||||
"name": record.name or "Unknown",
|
||||
"tle_line1": tle1 or "",
|
||||
"tle_line2": tle2 or "",
|
||||
"epoch": meta.get("epoch") or "",
|
||||
"inclination": _safe_float(meta.get("inclination")),
|
||||
"raan": _safe_float(meta.get("raan")),
|
||||
"eccentricity": _safe_float(meta.get("eccentricity")),
|
||||
"mean_motion": _safe_float(meta.get("mean_motion")),
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
File diff suppressed because it is too large
Load Diff
319
docs/ue_client_setup_guide.md
Normal file
319
docs/ue_client_setup_guide.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# UE5 客户端手动操作指南
|
||||
|
||||
> 本指南对应 `ue_client/` 目录下已生成的所有代码和配置。
|
||||
> 代码已写好,你只需要做编辑器里的点击操作。
|
||||
> 遇到红色错误先看文末"常见问题"章节。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
| 软件 | 版本 | 下载地址 |
|
||||
|------|------|---------|
|
||||
| Unreal Engine | **5.3** | Epic Games Launcher → Library → 5.3 |
|
||||
| Cesium for Unreal | 最新 | 直接在下一步从 Marketplace 安装 |
|
||||
| Visual Studio | 2022 Community | visualstudio.microsoft.com(安装 C++ 游戏开发工作负载) |
|
||||
|
||||
> **注意**:Cesium for Unreal 必须先安装,否则代码会编译失败。
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段(Phase A):本地演示版(不需要后端)
|
||||
|
||||
### 步骤 1:安装 Cesium for Unreal
|
||||
|
||||
1. 打开 **Epic Games Launcher**
|
||||
2. 顶部切换到 **Unreal Engine** 选项卡
|
||||
3. 左侧点击 **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 里做轨道传播计算 |
|
||||
62
docs/ue_todo.md
Normal file
62
docs/ue_todo.md
Normal file
@@ -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` 里取消注释绑定代码
|
||||
10
ue_client/Config/DefaultEngine.ini
Normal file
10
ue_client/Config/DefaultEngine.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[/Script/Engine.RendererSettings]
|
||||
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True
|
||||
r.AntiAliasingMethod=4
|
||||
|
||||
[/Script/Engine.Engine]
|
||||
NearClipPlane=1.0
|
||||
|
||||
[Core.System]
|
||||
Paths=../../../Engine/Content
|
||||
Paths=%GAMEDIR%Content
|
||||
3
ue_client/Config/DefaultGame.ini
Normal file
3
ue_client/Config/DefaultGame.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[/Script/EngineSettings.GameMapsSettings]
|
||||
GlobalDefaultGameMode=/Script/PlanetClient.PlanetGameMode
|
||||
GameDefaultMap=/Game/Maps/EarthMap
|
||||
6
ue_client/Config/DefaultInput.ini
Normal file
6
ue_client/Config/DefaultInput.ini
Normal file
@@ -0,0 +1,6 @@
|
||||
[/Script/Engine.InputSettings]
|
||||
+ActionMappings=(ActionName="SelectPoint",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=LeftMouseButton)
|
||||
+ActionMappings=(ActionName="RightMouseDown",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=RightMouseButton)
|
||||
+AxisMappings=(AxisName="ZoomCamera",Scale=-1.0,Key=MouseWheelAxis)
|
||||
+AxisMappings=(AxisName="RotateYaw",Scale=1.0,Key=MouseX)
|
||||
+AxisMappings=(AxisName="RotatePitch",Scale=-1.0,Key=MouseY)
|
||||
135
ue_client/Content/Data/mock_compute_points.json
Normal file
135
ue_client/Content/Data/mock_compute_points.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"count": 10,
|
||||
"items": [
|
||||
{
|
||||
"id": "top500_1",
|
||||
"name": "Frontier",
|
||||
"latitude": 36.01,
|
||||
"longitude": -84.26,
|
||||
"country": "United States",
|
||||
"city": "Oak Ridge",
|
||||
"rank": 1,
|
||||
"rmax_tflops": 1194000.0,
|
||||
"rpeak_tflops": 1679616.0,
|
||||
"cores": 8730112,
|
||||
"power_kw": 22703.0
|
||||
},
|
||||
{
|
||||
"id": "top500_2",
|
||||
"name": "Aurora",
|
||||
"latitude": 41.71,
|
||||
"longitude": -87.98,
|
||||
"country": "United States",
|
||||
"city": "Lemont",
|
||||
"rank": 2,
|
||||
"rmax_tflops": 1012000.0,
|
||||
"rpeak_tflops": 1321000.0,
|
||||
"cores": 9264128,
|
||||
"power_kw": 38698.0
|
||||
},
|
||||
{
|
||||
"id": "top500_3",
|
||||
"name": "Eagle",
|
||||
"latitude": 52.36,
|
||||
"longitude": 4.90,
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"rank": 3,
|
||||
"rmax_tflops": 561200.0,
|
||||
"rpeak_tflops": 736000.0,
|
||||
"cores": 4620800,
|
||||
"power_kw": 12410.0
|
||||
},
|
||||
{
|
||||
"id": "top500_4",
|
||||
"name": "Fugaku",
|
||||
"latitude": 34.66,
|
||||
"longitude": 135.22,
|
||||
"country": "Japan",
|
||||
"city": "Kobe",
|
||||
"rank": 4,
|
||||
"rmax_tflops": 442010.0,
|
||||
"rpeak_tflops": 537212.0,
|
||||
"cores": 7630848,
|
||||
"power_kw": 29899.0
|
||||
},
|
||||
{
|
||||
"id": "top500_5",
|
||||
"name": "LUMI",
|
||||
"latitude": 65.01,
|
||||
"longitude": 25.46,
|
||||
"country": "Finland",
|
||||
"city": "Kajaani",
|
||||
"rank": 5,
|
||||
"rmax_tflops": 379700.0,
|
||||
"rpeak_tflops": 531000.0,
|
||||
"cores": 2748384,
|
||||
"power_kw": 6016.0
|
||||
},
|
||||
{
|
||||
"id": "top500_6",
|
||||
"name": "MareNostrum 5",
|
||||
"latitude": 41.39,
|
||||
"longitude": 2.11,
|
||||
"country": "Spain",
|
||||
"city": "Barcelona",
|
||||
"rank": 6,
|
||||
"rmax_tflops": 138200.0,
|
||||
"rpeak_tflops": 314000.0,
|
||||
"cores": 2764800,
|
||||
"power_kw": 10000.0
|
||||
},
|
||||
{
|
||||
"id": "top500_7",
|
||||
"name": "Summit",
|
||||
"latitude": 35.93,
|
||||
"longitude": -84.31,
|
||||
"country": "United States",
|
||||
"city": "Oak Ridge",
|
||||
"rank": 7,
|
||||
"rmax_tflops": 148600.0,
|
||||
"rpeak_tflops": 200795.0,
|
||||
"cores": 2414592,
|
||||
"power_kw": 10096.0
|
||||
},
|
||||
{
|
||||
"id": "top500_8",
|
||||
"name": "Tianhe-2A",
|
||||
"latitude": 23.13,
|
||||
"longitude": 113.27,
|
||||
"country": "China",
|
||||
"city": "Guangzhou",
|
||||
"rank": 8,
|
||||
"rmax_tflops": 100679.0,
|
||||
"rpeak_tflops": 100679.0,
|
||||
"cores": 4981760,
|
||||
"power_kw": 18482.0
|
||||
},
|
||||
{
|
||||
"id": "top500_9",
|
||||
"name": "Selene",
|
||||
"latitude": 40.82,
|
||||
"longitude": -74.17,
|
||||
"country": "United States",
|
||||
"city": "Santa Clara",
|
||||
"rank": 9,
|
||||
"rmax_tflops": 63460.0,
|
||||
"rpeak_tflops": 79200.0,
|
||||
"cores": 555520,
|
||||
"power_kw": 2646.0
|
||||
},
|
||||
{
|
||||
"id": "top500_10",
|
||||
"name": "Perlmutter",
|
||||
"latitude": 37.88,
|
||||
"longitude": -122.25,
|
||||
"country": "United States",
|
||||
"city": "Berkeley",
|
||||
"rank": 10,
|
||||
"rmax_tflops": 93750.0,
|
||||
"rpeak_tflops": 120000.0,
|
||||
"cores": 761856,
|
||||
"power_kw": 3060.0
|
||||
}
|
||||
]
|
||||
}
|
||||
53
ue_client/Source/PlanetClient/ComputePointActor.cpp
Normal file
53
ue_client/Source/PlanetClient/ComputePointActor.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#include "ComputePointActor.h"
|
||||
#include "Components/StaticMeshComponent.h"
|
||||
|
||||
AComputePointActor::AComputePointActor()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = false;
|
||||
|
||||
SphereMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("SphereMesh"));
|
||||
RootComponent = SphereMesh;
|
||||
|
||||
// Enable mouse-over events so the PlayerController can detect hover
|
||||
SphereMesh->bReceivesDecals = false;
|
||||
}
|
||||
|
||||
void AComputePointActor::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
SetActorScale3D(FVector(PointScale));
|
||||
ApplyMaterial();
|
||||
}
|
||||
|
||||
void AComputePointActor::SetPointData(const FComputePoint& Data)
|
||||
{
|
||||
PointData = Data;
|
||||
|
||||
// Optional: name the actor in the Outliner for easy debugging
|
||||
SetActorLabel(FString::Printf(TEXT("[%d] %s"), Data.Rank, *Data.Name));
|
||||
}
|
||||
|
||||
void AComputePointActor::SetVisualState(EPointVisualState NewState)
|
||||
{
|
||||
if (VisualState == NewState) return;
|
||||
VisualState = NewState;
|
||||
ApplyMaterial();
|
||||
}
|
||||
|
||||
void AComputePointActor::ApplyMaterial()
|
||||
{
|
||||
if (!SphereMesh) return;
|
||||
|
||||
UMaterialInterface* Mat = nullptr;
|
||||
switch (VisualState)
|
||||
{
|
||||
case EPointVisualState::Hovered: Mat = HoveredMaterial; break;
|
||||
case EPointVisualState::Selected: Mat = SelectedMaterial; break;
|
||||
default: Mat = NormalMaterial; break;
|
||||
}
|
||||
|
||||
if (Mat)
|
||||
{
|
||||
SphereMesh->SetMaterial(0, Mat);
|
||||
}
|
||||
}
|
||||
95
ue_client/Source/PlanetClient/ComputePointActor.h
Normal file
95
ue_client/Source/PlanetClient/ComputePointActor.h
Normal file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "PlanetDataTypes.h"
|
||||
#include "ComputePointActor.generated.h"
|
||||
|
||||
class UStaticMeshComponent;
|
||||
class UBillboardComponent;
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EPointVisualState : uint8
|
||||
{
|
||||
Normal UMETA(DisplayName="Normal"),
|
||||
Hovered UMETA(DisplayName="Hovered"),
|
||||
Selected UMETA(DisplayName="Selected"),
|
||||
};
|
||||
|
||||
/**
|
||||
* AComputePointActor
|
||||
*
|
||||
* Represents one supercomputer data point on the Cesium globe.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Create a Blueprint subclass: Content Browser → New Blueprint →
|
||||
* search "AComputePointActor" as parent class → name it BP_ComputePointActor
|
||||
* 2. In the Blueprint, assign a sphere mesh to SphereMesh (Engine/BasicShapes/Sphere)
|
||||
* 3. Assign a material to NormalMaterial / HoveredMaterial / SelectedMaterial
|
||||
* 4. Set this Blueprint class in APlanetDataManager → ComputePointClass
|
||||
*/
|
||||
UCLASS(BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API AComputePointActor : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
AComputePointActor();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Components
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// The visible sphere. Assign a sphere mesh in your Blueprint subclass.
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
|
||||
UStaticMeshComponent* SphereMesh;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Materials (assign in Blueprint subclass)
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||
UMaterialInterface* NormalMaterial;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||
UMaterialInterface* HoveredMaterial;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||
UMaterialInterface* SelectedMaterial;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Point scale (world units). Tweak this in the Details Panel.
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||
float PointScale = 80000.f;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Data (set by APlanetDataManager after spawn)
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FComputePoint PointData;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
EPointVisualState VisualState = EPointVisualState::Normal;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Called by APlanetDataManager immediately after spawning.
|
||||
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||
void SetPointData(const FComputePoint& Data);
|
||||
|
||||
// Called by PlanetPlayerController on hover/select.
|
||||
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||
void SetVisualState(EPointVisualState NewState);
|
||||
|
||||
// Convenience: is this point currently selected?
|
||||
UFUNCTION(BlueprintPure, Category="Planet")
|
||||
bool IsSelected() const { return VisualState == EPointVisualState::Selected; }
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
void ApplyMaterial();
|
||||
};
|
||||
122
ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp
Normal file
122
ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "GlobeInteractionComponent.h"
|
||||
#include "CesiumGeoreference.h"
|
||||
#include "Engine/World.h"
|
||||
#include "GameFramework/Pawn.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
|
||||
UGlobeInteractionComponent::UGlobeInteractionComponent()
|
||||
{
|
||||
PrimaryComponentTick.bCanEverTick = true;
|
||||
}
|
||||
|
||||
void UGlobeInteractionComponent::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
FindGeoreference();
|
||||
}
|
||||
|
||||
void UGlobeInteractionComponent::FindGeoreference()
|
||||
{
|
||||
Georeference = ACesiumGeoreference::GetDefaultGeoreference(GetWorld());
|
||||
if (!Georeference)
|
||||
{
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("GlobeInteractionComponent: 场景中没有 CesiumGeoreference,请添加一个"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 旋转
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UGlobeInteractionComponent::RotateGlobe(float DeltaYaw, float DeltaPitch)
|
||||
{
|
||||
if (!Georeference) return;
|
||||
|
||||
// 累积惯性速度(本帧的输入叠加到惯性上)
|
||||
InertiaYaw += DeltaYaw * RotateSensitivity;
|
||||
InertiaPitch += DeltaPitch * RotateSensitivity;
|
||||
|
||||
// 立即应用(Tick 里再处理惯性衰减)
|
||||
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
|
||||
double NewLat = FMath::Clamp(
|
||||
Georeference->GetOriginLatitude() + InertiaPitch,
|
||||
-85.0, 85.0
|
||||
);
|
||||
|
||||
Georeference->SetOriginLongitude(NewLon);
|
||||
Georeference->SetOriginLatitude(NewLat);
|
||||
|
||||
// 保持经度在 [-180, 180]
|
||||
if (NewLon > 180.0) Georeference->SetOriginLongitude(NewLon - 360.0);
|
||||
if (NewLon < -180.0) Georeference->SetOriginLongitude(NewLon + 360.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 缩放
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UGlobeInteractionComponent::ZoomGlobe(float Value)
|
||||
{
|
||||
if (FMath::IsNearlyZero(Value)) return;
|
||||
|
||||
// Value > 0 = 拉近(高度减小),Value < 0 = 推远(高度增大)
|
||||
CurrentAltitudeMeters -= Value * ZoomSensitivity;
|
||||
CurrentAltitudeMeters = FMath::Clamp(CurrentAltitudeMeters,
|
||||
MinAltitudeMeters,
|
||||
MaxAltitudeMeters);
|
||||
ApplyAltitudeToCamera();
|
||||
}
|
||||
|
||||
void UGlobeInteractionComponent::ApplyAltitudeToCamera()
|
||||
{
|
||||
if (!Georeference) return;
|
||||
|
||||
// 把当前 Origin 经纬度 + 新高度转换为 Unreal 世界坐标,然后移动拥有者 Pawn
|
||||
AController* Controller = Cast<AController>(GetOwner());
|
||||
APawn* Pawn = Controller ? Controller->GetPawn() : Cast<APawn>(GetOwner());
|
||||
if (!Pawn) return;
|
||||
|
||||
double Lon = Georeference->GetOriginLongitude();
|
||||
double Lat = Georeference->GetOriginLatitude();
|
||||
|
||||
FVector NewPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
|
||||
FVector(Lon, Lat, CurrentAltitudeMeters)
|
||||
);
|
||||
Pawn->SetActorLocation(NewPos);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 惯性 Tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UGlobeInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType,
|
||||
FActorComponentTickFunction* ThisTickFunction)
|
||||
{
|
||||
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
|
||||
|
||||
// 只有惯性速度足够大时才继续转动
|
||||
if (FMath::IsNearlyZero(InertiaYaw, 0.001f) &&
|
||||
FMath::IsNearlyZero(InertiaPitch, 0.001f)) return;
|
||||
|
||||
if (!Georeference) return;
|
||||
|
||||
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
|
||||
double NewLat = FMath::Clamp(
|
||||
Georeference->GetOriginLatitude() + InertiaPitch,
|
||||
-85.0, 85.0
|
||||
);
|
||||
Georeference->SetOriginLongitude(NewLon);
|
||||
Georeference->SetOriginLatitude(NewLat);
|
||||
|
||||
// 惯性衰减
|
||||
InertiaYaw *= InertiaDamping;
|
||||
InertiaPitch *= InertiaDamping;
|
||||
}
|
||||
|
||||
void UGlobeInteractionComponent::StopInertia()
|
||||
{
|
||||
InertiaYaw = 0.f;
|
||||
InertiaPitch = 0.f;
|
||||
}
|
||||
96
ue_client/Source/PlanetClient/GlobeInteractionComponent.h
Normal file
96
ue_client/Source/PlanetClient/GlobeInteractionComponent.h
Normal file
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "GlobeInteractionComponent.generated.h"
|
||||
|
||||
class ACesiumGeoreference;
|
||||
|
||||
/**
|
||||
* UGlobeInteractionComponent
|
||||
*
|
||||
* 挂在 PlayerController 或 Pawn 上,处理地球旋转和缩放。
|
||||
*
|
||||
* 旋转原理:
|
||||
* 修改 CesiumGeoreference 的 OriginLongitude / OriginLatitude,
|
||||
* 相当于"移动地球"到新中心,视觉效果等同于旋转地球。
|
||||
*
|
||||
* 缩放原理:
|
||||
* 沿相机到地球中心方向移动相机,改变观察高度。
|
||||
*
|
||||
* 使用方式:
|
||||
* 在 PlanetPlayerController::BeginPlay 里 AddComponent,
|
||||
* 收到拖拽/滚轮输入时调用 RotateGlobe / ZoomGlobe。
|
||||
*/
|
||||
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent), BlueprintType)
|
||||
class PLANETCLIENT_API UGlobeInteractionComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UGlobeInteractionComponent();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 可在 Details 面板调整的参数
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// 拖拽灵敏度:每像素对应的经纬度偏移量(度)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||
float RotateSensitivity = 0.15f;
|
||||
|
||||
// 缩放灵敏度:每格滚轮对应的高度变化(米)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||
float ZoomSensitivity = 200000.f;
|
||||
|
||||
// 最小观察高度(米),防止穿入地面
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||
double MinAltitudeMeters = 500000.0;
|
||||
|
||||
// 最大观察高度(米)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||
double MaxAltitudeMeters = 30000000.0;
|
||||
|
||||
// 旋转惯性平滑系数 (0=无惯性, 1=永不停)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe",
|
||||
meta=(ClampMin="0.0", ClampMax="0.99"))
|
||||
float InertiaDamping = 0.85f;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 公开 API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// 每帧由 PlayerController 调用,传入鼠标位移(像素)
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||
void RotateGlobe(float DeltaYaw, float DeltaPitch);
|
||||
|
||||
// 每帧由 PlayerController 调用,Value > 0 = 拉近,< 0 = 推远
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||
void ZoomGlobe(float Value);
|
||||
|
||||
// 立即停止惯性
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||
void StopInertia();
|
||||
|
||||
// 返回当前相机高度(米)
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Globe")
|
||||
double GetCurrentAltitudeMeters() const { return CurrentAltitudeMeters; }
|
||||
|
||||
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
|
||||
FActorComponentTickFunction* ThisTickFunction) override;
|
||||
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
UPROPERTY()
|
||||
ACesiumGeoreference* Georeference = nullptr;
|
||||
|
||||
// 惯性速度(经度/帧,纬度/帧)
|
||||
float InertiaYaw = 0.f;
|
||||
float InertiaPitch = 0.f;
|
||||
|
||||
// 当前相机高度(米),随 Zoom 更新
|
||||
double CurrentAltitudeMeters = 5000000.0;
|
||||
|
||||
void FindGeoreference();
|
||||
void ApplyAltitudeToCamera();
|
||||
};
|
||||
53
ue_client/Source/PlanetClient/InteractiveObjectBase.cpp
Normal file
53
ue_client/Source/PlanetClient/InteractiveObjectBase.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#include "InteractiveObjectBase.h"
|
||||
|
||||
AInteractiveObjectBase::AInteractiveObjectBase()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = false;
|
||||
}
|
||||
|
||||
void AInteractiveObjectBase::SetInteractiveState(EInteractiveObjectState NewState)
|
||||
{
|
||||
if (CurrentState == NewState) return;
|
||||
if (CurrentState == EInteractiveObjectState::Disabled &&
|
||||
NewState != EInteractiveObjectState::Normal) return;
|
||||
|
||||
EInteractiveObjectState OldState = CurrentState;
|
||||
CurrentState = NewState;
|
||||
|
||||
switch (NewState)
|
||||
{
|
||||
case EInteractiveObjectState::Hovered: OnHovered(); break;
|
||||
case EInteractiveObjectState::Selected: OnSelected(); break;
|
||||
case EInteractiveObjectState::Normal:
|
||||
if (OldState == EInteractiveObjectState::Selected) OnDeselected();
|
||||
else OnRestored();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
OnStateChanged.Broadcast(NewState);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 默认实现(子类可重写)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
FText AInteractiveObjectBase::GetInfoTitle_Implementation() const
|
||||
{
|
||||
return FText::FromString(GetActorLabel());
|
||||
}
|
||||
|
||||
FText AInteractiveObjectBase::GetInfoBody_Implementation() const
|
||||
{
|
||||
return FText::FromString(TEXT(""));
|
||||
}
|
||||
|
||||
UTexture2D* AInteractiveObjectBase::GetInfoIcon_Implementation() const
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void AInteractiveObjectBase::OnHovered_Implementation() {}
|
||||
void AInteractiveObjectBase::OnSelected_Implementation() {}
|
||||
void AInteractiveObjectBase::OnDeselected_Implementation(){}
|
||||
void AInteractiveObjectBase::OnRestored_Implementation() {}
|
||||
93
ue_client/Source/PlanetClient/InteractiveObjectBase.h
Normal file
93
ue_client/Source/PlanetClient/InteractiveObjectBase.h
Normal file
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "InteractiveObjectBase.generated.h"
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EInteractiveObjectState : uint8
|
||||
{
|
||||
Normal UMETA(DisplayName="正常"),
|
||||
Hovered UMETA(DisplayName="悬停高亮"),
|
||||
Selected UMETA(DisplayName="已选中"),
|
||||
Disabled UMETA(DisplayName="不可交互"),
|
||||
};
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectStateChanged,
|
||||
EInteractiveObjectState, NewState);
|
||||
|
||||
/**
|
||||
* AInteractiveObjectBase
|
||||
*
|
||||
* 所有可交互 3D 物件的基类。
|
||||
* 当前场景中的可交互物件包括:
|
||||
* - AComputePointActor(超算数据点)—— 已实现
|
||||
* - 其他演示物件(TODO: 根据需求添加子类)
|
||||
*
|
||||
* 子类需要:
|
||||
* 1. 重写 OnHovered / OnSelected / OnDeselected(或响应 OnStateChanged 委托)
|
||||
* 2. 将根 Component 设置为可被射线检测(SetCollisionResponseToChannel)
|
||||
* 3. 可选:重写 GetInfoTitle / GetInfoBody 供信息卡使用
|
||||
*/
|
||||
UCLASS(Abstract, BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API AInteractiveObjectBase : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
AInteractiveObjectBase();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 状态变更委托
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|Interactive")
|
||||
FOnObjectStateChanged OnStateChanged;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 状态 API(由 PlanetPlayerController 调用)
|
||||
// -----------------------------------------------------------------------
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Interactive")
|
||||
void SetInteractiveState(EInteractiveObjectState NewState);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||
EInteractiveObjectState GetInteractiveState() const { return CurrentState; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||
bool IsSelected() const { return CurrentState == EInteractiveObjectState::Selected; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||
bool IsHovered() const { return CurrentState == EInteractiveObjectState::Hovered; }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 信息卡内容(子类可重写提供具体文本)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// 信息卡标题,默认返回 Actor 名
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||
FText GetInfoTitle() const;
|
||||
|
||||
// 信息卡正文,返回富文本格式的详情
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||
FText GetInfoBody() const;
|
||||
|
||||
// 信息卡图标(可选,返回空则不显示图标)
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||
UTexture2D* GetInfoIcon() const;
|
||||
|
||||
protected:
|
||||
// 子类重写这三个函数来处理视觉状态变化
|
||||
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||
void OnHovered();
|
||||
|
||||
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||
void OnSelected();
|
||||
|
||||
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||
void OnDeselected();
|
||||
|
||||
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||
void OnRestored(); // 从任何状态回到 Normal
|
||||
|
||||
private:
|
||||
EInteractiveObjectState CurrentState = EInteractiveObjectState::Normal;
|
||||
};
|
||||
150
ue_client/Source/PlanetClient/MotionCaptureInterface.h
Normal file
150
ue_client/Source/PlanetClient/MotionCaptureInterface.h
Normal file
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/Interface.h"
|
||||
#include "MotionCaptureInterface.generated.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 动捕事件 —— 供应商中间件触发这些事件,UE5 侧响应
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 手势类型枚举
|
||||
*
|
||||
* TODO(等动捕供应商确认手势集合后补充):
|
||||
* 当前列出的是推测的基本手势,以实际交付为准。
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class EMotionGesture : uint8
|
||||
{
|
||||
None UMETA(DisplayName="无"),
|
||||
SwipeLeft UMETA(DisplayName="向左划"), // 地球向左转
|
||||
SwipeRight UMETA(DisplayName="向右划"), // 地球向右转
|
||||
SwipeUp UMETA(DisplayName="向上划"), // 地球向上转
|
||||
SwipeDown UMETA(DisplayName="向下划"), // 地球向下转
|
||||
PinchIn UMETA(DisplayName="捏合(缩小)"),// 缩放
|
||||
PinchOut UMETA(DisplayName="展开(放大)"),// 缩放
|
||||
Point UMETA(DisplayName="指向"), // 悬停选中
|
||||
Confirm UMETA(DisplayName="确认"), // 点击选中
|
||||
Dismiss UMETA(DisplayName="挥手取消"), // 关闭信息卡
|
||||
};
|
||||
|
||||
// 动捕系统触发某个离散手势时广播
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnGestureDetected, EMotionGesture, Gesture);
|
||||
|
||||
// 动捕系统持续输出指向方向时广播(用于悬停高亮)
|
||||
// Direction: 归一化方向向量,由动捕系统解算后传入
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPointingDirectionChanged, FVector, Direction);
|
||||
|
||||
// 动捕系统输出旋转增量时广播(用于连续拖拽旋转地球)
|
||||
// DeltaYaw/DeltaPitch: 单帧内的角度增量(度)
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRotateDelta, float, DeltaYaw, float, DeltaPitch);
|
||||
|
||||
// 动捕系统输出缩放增量时广播
|
||||
// DeltaScale: > 0 放大,< 0 缩小
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnZoomDelta, float, DeltaScale);
|
||||
|
||||
|
||||
/**
|
||||
* UMotionCaptureReceiver
|
||||
*
|
||||
* 动捕接收器基类。根据供应商协议,创建对应的子类实现:
|
||||
*
|
||||
* - LiveLink 协议 → UMotionCaptureReceiverLiveLink (TODO)
|
||||
* - OSC/UDP 协议 → UMotionCaptureReceiverOSC (TODO)
|
||||
*
|
||||
* 子类负责接收数据并调用 BroadcastXxx() 方法,
|
||||
* PlanetPlayerController 订阅这些委托即可,不关心底层协议。
|
||||
*
|
||||
* TODO(等动捕供应商回复后):
|
||||
* 1. 确认协议(Live Link / OSC / 私有 SDK)
|
||||
* 2. 在对应子类里实现连接和数据接收
|
||||
* 3. 把子类拖进场景,在 PlanetDataManager / PlayerController 里引用它
|
||||
*/
|
||||
UCLASS(Abstract, BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API UMotionCaptureReceiver : public UObject
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
// -----------------------------------------------------------------------
|
||||
// 外部订阅这些委托
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||
FOnGestureDetected OnGestureDetected;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||
FOnPointingDirectionChanged OnPointingDirectionChanged;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||
FOnRotateDelta OnRotateDelta;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||
FOnZoomDelta OnZoomDelta;
|
||||
|
||||
// 连接到动捕中间件(子类实现)
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
|
||||
virtual void Connect() {}
|
||||
|
||||
// 断开连接(子类实现)
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
|
||||
virtual void Disconnect() {}
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|MotionCapture")
|
||||
virtual bool IsConnected() const { return false; }
|
||||
|
||||
protected:
|
||||
// 子类解析到数据后调用这些方法广播
|
||||
void BroadcastGesture(EMotionGesture Gesture)
|
||||
{
|
||||
OnGestureDetected.Broadcast(Gesture);
|
||||
}
|
||||
|
||||
void BroadcastPointing(const FVector& Direction)
|
||||
{
|
||||
OnPointingDirectionChanged.Broadcast(Direction);
|
||||
}
|
||||
|
||||
void BroadcastRotateDelta(float DeltaYaw, float DeltaPitch)
|
||||
{
|
||||
OnRotateDelta.Broadcast(DeltaYaw, DeltaPitch);
|
||||
}
|
||||
|
||||
void BroadcastZoomDelta(float DeltaScale)
|
||||
{
|
||||
OnZoomDelta.Broadcast(DeltaScale);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 动作映射配置
|
||||
//
|
||||
// 把动捕手势映射到具体的 UE 操作,在 Details 面板里可以改。
|
||||
// 这样不用改代码就能重新映射手势。
|
||||
// ---------------------------------------------------------------------------
|
||||
USTRUCT(BlueprintType)
|
||||
struct FMotionActionMapping
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// 触发"旋转地球"的手势(持续输出增量)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
EMotionGesture RotateGlobe = EMotionGesture::None; // TODO: 填入供应商手势名
|
||||
|
||||
// 触发"放大"的手势
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
EMotionGesture ZoomIn = EMotionGesture::PinchOut;
|
||||
|
||||
// 触发"缩小"的手势
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
EMotionGesture ZoomOut = EMotionGesture::PinchIn;
|
||||
|
||||
// 触发"选中数据点"的手势
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
EMotionGesture SelectPoint = EMotionGesture::Confirm;
|
||||
|
||||
// 触发"关闭信息卡"的手势
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
EMotionGesture DismissInfoCard = EMotionGesture::Dismiss;
|
||||
};
|
||||
33
ue_client/Source/PlanetClient/PlanetClient.Build.cs
Normal file
33
ue_client/Source/PlanetClient/PlanetClient.Build.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class PlanetClient : ModuleRules
|
||||
{
|
||||
public PlanetClient(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"InputCore",
|
||||
"HTTP",
|
||||
"Json",
|
||||
"JsonUtilities",
|
||||
"CesiumRuntime",
|
||||
"UMG",
|
||||
"Slate",
|
||||
"SlateCore",
|
||||
"RenderCore", // StereoRenderingManager 用到
|
||||
"RHI", // 渲染硬件接口
|
||||
// TODO: 动捕协议确认后按需添加:
|
||||
// "LiveLink", // Live Link 协议
|
||||
// "LiveLinkInterface",
|
||||
// "Networking", // OSC/UDP 协议
|
||||
// "Sockets",
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { });
|
||||
}
|
||||
}
|
||||
4
ue_client/Source/PlanetClient/PlanetClient.cpp
Normal file
4
ue_client/Source/PlanetClient/PlanetClient.cpp
Normal file
@@ -0,0 +1,4 @@
|
||||
#include "PlanetClient.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PlanetClient, "PlanetClient");
|
||||
3
ue_client/Source/PlanetClient/PlanetClient.h
Normal file
3
ue_client/Source/PlanetClient/PlanetClient.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
220
ue_client/Source/PlanetClient/PlanetDataManager.cpp
Normal file
220
ue_client/Source/PlanetClient/PlanetDataManager.cpp
Normal file
@@ -0,0 +1,220 @@
|
||||
#include "PlanetDataManager.h"
|
||||
#include "ComputePointActor.h"
|
||||
|
||||
#include "HttpModule.h"
|
||||
#include "Interfaces/IHttpResponse.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
// Cesium coordinate conversion
|
||||
#include "CesiumGeoreference.h"
|
||||
|
||||
APlanetDataManager::APlanetDataManager()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = false;
|
||||
}
|
||||
|
||||
void APlanetDataManager::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
|
||||
// Build default mock path if not overridden
|
||||
if (MockDataPath.IsEmpty())
|
||||
{
|
||||
MockDataPath = FPaths::ProjectContentDir() / TEXT("Data/mock_compute_points.json");
|
||||
}
|
||||
|
||||
FetchAllData();
|
||||
}
|
||||
|
||||
void APlanetDataManager::FetchAllData()
|
||||
{
|
||||
// Clear previous actors
|
||||
for (AComputePointActor* Point : SpawnedPoints)
|
||||
{
|
||||
if (IsValid(Point)) Point->Destroy();
|
||||
}
|
||||
SpawnedPoints.Empty();
|
||||
|
||||
if (bUseLocalMockData)
|
||||
{
|
||||
LoadMockComputePoints();
|
||||
}
|
||||
else
|
||||
{
|
||||
FetchComputePoints();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase A: local JSON file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetDataManager::LoadMockComputePoints()
|
||||
{
|
||||
FString JsonStr;
|
||||
if (!FFileHelper::LoadFileToString(JsonStr, *MockDataPath))
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: cannot read mock file: %s"), *MockDataPath);
|
||||
return;
|
||||
}
|
||||
TArray<FComputePoint> Points = ParseComputePointsJson(JsonStr);
|
||||
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: loaded %d points from mock file"), Points.Num());
|
||||
SpawnComputePoints(Points);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase B: HTTP request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetDataManager::FetchComputePoints()
|
||||
{
|
||||
const FString Url = BackendBaseUrl + TEXT("/api/v1/ue/compute-points");
|
||||
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Req = FHttpModule::Get().CreateRequest();
|
||||
Req->SetURL(Url);
|
||||
Req->SetVerb(TEXT("GET"));
|
||||
Req->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
|
||||
Req->OnProcessRequestComplete().BindUObject(
|
||||
this, &APlanetDataManager::OnComputePointsResponse);
|
||||
Req->ProcessRequest();
|
||||
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: GET %s"), *Url);
|
||||
}
|
||||
|
||||
void APlanetDataManager::OnComputePointsResponse(FHttpRequestPtr Request,
|
||||
FHttpResponsePtr Response,
|
||||
bool bSuccess)
|
||||
{
|
||||
if (!bSuccess || !Response.IsValid())
|
||||
{
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("PlanetDataManager: HTTP request failed. Is the backend running at %s?"),
|
||||
*BackendBaseUrl);
|
||||
return;
|
||||
}
|
||||
if (Response->GetResponseCode() != 200)
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: HTTP %d from %s"),
|
||||
Response->GetResponseCode(), *Request->GetURL());
|
||||
return;
|
||||
}
|
||||
|
||||
TArray<FComputePoint> Points = ParseComputePointsJson(Response->GetContentAsString());
|
||||
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: received %d compute points"), Points.Num());
|
||||
SpawnComputePoints(Points);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TArray<FComputePoint> APlanetDataManager::ParseComputePointsJson(const FString& JsonStr)
|
||||
{
|
||||
TArray<FComputePoint> Result;
|
||||
|
||||
TSharedPtr<FJsonObject> Root;
|
||||
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonStr);
|
||||
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: JSON parse failed"));
|
||||
return Result;
|
||||
}
|
||||
|
||||
const TArray<TSharedPtr<FJsonValue>>* Items;
|
||||
if (!Root->TryGetArrayField(TEXT("items"), Items))
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: no 'items' array in JSON"));
|
||||
return Result;
|
||||
}
|
||||
|
||||
for (const TSharedPtr<FJsonValue>& Val : *Items)
|
||||
{
|
||||
const TSharedPtr<FJsonObject>* ObjPtr;
|
||||
if (!Val->TryGetObject(ObjPtr)) continue;
|
||||
const TSharedPtr<FJsonObject>& Obj = *ObjPtr;
|
||||
|
||||
FComputePoint Pt;
|
||||
Obj->TryGetStringField(TEXT("id"), Pt.Id);
|
||||
Obj->TryGetStringField(TEXT("name"), Pt.Name);
|
||||
Obj->TryGetStringField(TEXT("country"), Pt.Country);
|
||||
Obj->TryGetStringField(TEXT("city"), Pt.City);
|
||||
|
||||
double Lat, Lon;
|
||||
if (!Obj->TryGetNumberField(TEXT("latitude"), Lat)) continue;
|
||||
if (!Obj->TryGetNumberField(TEXT("longitude"), Lon)) continue;
|
||||
Pt.Latitude = (float)Lat;
|
||||
Pt.Longitude = (float)Lon;
|
||||
|
||||
int32 Rank;
|
||||
if (Obj->TryGetNumberField(TEXT("rank"), Rank)) Pt.Rank = Rank;
|
||||
|
||||
double Rmax;
|
||||
if (Obj->TryGetNumberField(TEXT("rmax_tflops"), Rmax)) Pt.RmaxTFlops = (float)Rmax;
|
||||
double Rpeak;
|
||||
if (Obj->TryGetNumberField(TEXT("rpeak_tflops"), Rpeak)) Pt.RpeakTFlops = (float)Rpeak;
|
||||
int32 Cores;
|
||||
if (Obj->TryGetNumberField(TEXT("cores"), Cores)) Pt.Cores = Cores;
|
||||
double Power;
|
||||
if (Obj->TryGetNumberField(TEXT("power_kw"), Power)) Pt.PowerKw = (float)Power;
|
||||
|
||||
Result.Add(Pt);
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn actors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetDataManager::SpawnComputePoints(const TArray<FComputePoint>& Points)
|
||||
{
|
||||
if (!ComputePointClass)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("PlanetDataManager: ComputePointClass not set — set it in the Details Panel"));
|
||||
return;
|
||||
}
|
||||
|
||||
UWorld* World = GetWorld();
|
||||
if (!World) return;
|
||||
|
||||
// Find the CesiumGeoreference in the level
|
||||
ACesiumGeoreference* Georeference = ACesiumGeoreference::GetDefaultGeoreference(World);
|
||||
if (!Georeference)
|
||||
{
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("PlanetDataManager: no CesiumGeoreference in level. Add a CesiumGeoreference actor."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const FComputePoint& Pt : Points)
|
||||
{
|
||||
// Convert geographic coordinates to Unreal world coordinates
|
||||
// Altitude is PointAltitudeMeters above sea level
|
||||
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
|
||||
FVector(Pt.Longitude, Pt.Latitude, PointAltitudeMeters)
|
||||
);
|
||||
|
||||
FActorSpawnParameters Params;
|
||||
Params.SpawnCollisionHandlingOverride =
|
||||
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||||
|
||||
AComputePointActor* Actor = World->SpawnActor<AComputePointActor>(
|
||||
ComputePointClass, WorldPos, FRotator::ZeroRotator, Params);
|
||||
|
||||
if (Actor)
|
||||
{
|
||||
Actor->SetPointData(Pt);
|
||||
SpawnedPoints.Add(Actor);
|
||||
}
|
||||
}
|
||||
|
||||
OnComputePointsLoaded.Broadcast(SpawnedPoints.Num());
|
||||
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: spawned %d compute point actors"),
|
||||
SpawnedPoints.Num());
|
||||
}
|
||||
97
ue_client/Source/PlanetClient/PlanetDataManager.h
Normal file
97
ue_client/Source/PlanetClient/PlanetDataManager.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "Interfaces/IHttpRequest.h"
|
||||
#include "PlanetDataTypes.h"
|
||||
#include "PlanetDataManager.generated.h"
|
||||
|
||||
class AComputePointActor;
|
||||
|
||||
// Fired once all compute points have been spawned into the world
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnComputePointsLoaded, int32, Count);
|
||||
|
||||
/**
|
||||
* APlanetDataManager
|
||||
*
|
||||
* Place one of these in your level. On BeginPlay it fetches data from the
|
||||
* Planet backend and spawns AComputePointActor instances at the correct
|
||||
* Cesium globe positions.
|
||||
*
|
||||
* Phase A (local): Set bUseLocalMockData = true and point MockDataPath to
|
||||
* Content/Data/mock_compute_points.json.
|
||||
* Phase B (live): Set bUseLocalMockData = false and fill BackendBaseUrl.
|
||||
*/
|
||||
UCLASS(BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API APlanetDataManager : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
APlanetDataManager();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Inspector-editable properties
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Base URL of the Planet backend, e.g. "http://localhost:8000"
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||
FString BackendBaseUrl = TEXT("http://localhost:8000");
|
||||
|
||||
// When true, loads mock_compute_points.json from disk instead of the API.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||
bool bUseLocalMockData = true;
|
||||
|
||||
// Absolute path to the mock JSON file (Phase A).
|
||||
// Default points to <ProjectDir>/Content/Data/mock_compute_points.json
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config",
|
||||
meta=(EditCondition="bUseLocalMockData"))
|
||||
FString MockDataPath;
|
||||
|
||||
// The Blueprint subclass of AComputePointActor to spawn.
|
||||
// Set this in the Details Panel to your BP_ComputePointActor.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||
TSubclassOf<AComputePointActor> ComputePointClass;
|
||||
|
||||
// Height above sea level (metres) at which to place data points.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||
double PointAltitudeMeters = 50000.0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Events
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||
FOnComputePointsLoaded OnComputePointsLoaded;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Call this to re-fetch and rebuild all points.
|
||||
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||
void FetchAllData();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||
TArray<AComputePointActor*> GetAllComputePoints() const { return SpawnedPoints; }
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
TArray<AComputePointActor*> SpawnedPoints;
|
||||
|
||||
// HTTP handlers
|
||||
void FetchComputePoints();
|
||||
void OnComputePointsResponse(FHttpRequestPtr Request,
|
||||
FHttpResponsePtr Response,
|
||||
bool bSuccess);
|
||||
|
||||
// Local mock data loader (Phase A)
|
||||
void LoadMockComputePoints();
|
||||
|
||||
// Shared spawn logic
|
||||
void SpawnComputePoints(const TArray<FComputePoint>& Points);
|
||||
|
||||
// JSON → FComputePoint array
|
||||
static TArray<FComputePoint> ParseComputePointsJson(const FString& JsonStr);
|
||||
};
|
||||
108
ue_client/Source/PlanetClient/PlanetDataTypes.h
Normal file
108
ue_client/Source/PlanetClient/PlanetDataTypes.h
Normal file
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "PlanetDataTypes.generated.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FComputePoint — corresponds to /api/v1/ue/compute-points items
|
||||
// ---------------------------------------------------------------------------
|
||||
USTRUCT(BlueprintType)
|
||||
struct FComputePoint
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// Unique string id (e.g. "top500_42")
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Id;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Name;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float Latitude = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float Longitude = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Country;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString City;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
int32 Rank = 0;
|
||||
|
||||
// Rmax in TFlops. 0 means not available.
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float RmaxTFlops = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float RpeakTFlops = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
int32 Cores = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float PowerKw = 0.f;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FLandingPoint — corresponds to /api/v1/ue/landing-points items
|
||||
// ---------------------------------------------------------------------------
|
||||
USTRUCT(BlueprintType)
|
||||
struct FLandingPoint
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Id;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Name;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float Latitude = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float Longitude = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Country;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString City;
|
||||
|
||||
// Names of cables that pass through this landing point
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
TArray<FString> CableNames;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FCableSegment / FCable — corresponds to /api/v1/ue/cables items
|
||||
// ---------------------------------------------------------------------------
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Id;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString CableId; // slug, e.g. "flag"
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Name;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
FString Status;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
float LengthKm = 0.f;
|
||||
|
||||
// Each inner TArray is one segment: ordered [lon, lat] pairs
|
||||
// Stored as FVector2D(lon, lat) per point
|
||||
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||
TArray<TArray<FVector2D>> Segments;
|
||||
};
|
||||
28
ue_client/Source/PlanetClient/PlanetGameMode.cpp
Normal file
28
ue_client/Source/PlanetClient/PlanetGameMode.cpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#include "PlanetGameMode.h"
|
||||
#include "PlanetPlayerController.h"
|
||||
#include "StereoRenderingManager.h"
|
||||
#include "GameFramework/SpectatorPawn.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
APlanetGameMode::APlanetGameMode()
|
||||
{
|
||||
PlayerControllerClass = APlanetPlayerController::StaticClass();
|
||||
// 真实运行时在 World Settings 里改为 CesiumDynamicPawn
|
||||
DefaultPawnClass = ASpectatorPawn::StaticClass();
|
||||
}
|
||||
|
||||
void APlanetGameMode::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
|
||||
// 自动在场景里生成 StereoRenderingManager(如果场景里没有的话)
|
||||
TArray<AActor*> Found;
|
||||
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AStereoRenderingManager::StaticClass(), Found);
|
||||
if (Found.Num() == 0)
|
||||
{
|
||||
GetWorld()->SpawnActor<AStereoRenderingManager>(
|
||||
AStereoRenderingManager::StaticClass(),
|
||||
FVector::ZeroVector, FRotator::ZeroRotator);
|
||||
UE_LOG(LogTemp, Log, TEXT("PlanetGameMode: 已自动生成 StereoRenderingManager"));
|
||||
}
|
||||
}
|
||||
27
ue_client/Source/PlanetClient/PlanetGameMode.h
Normal file
27
ue_client/Source/PlanetClient/PlanetGameMode.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/GameModeBase.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "PlanetGameMode.generated.h"
|
||||
|
||||
/**
|
||||
* APlanetGameMode
|
||||
*
|
||||
* 场景入口。BeginPlay 时自动生成 StereoRenderingManager。
|
||||
*
|
||||
* 编辑器操作(步骤见 ue_client_setup_guide.md):
|
||||
* 1. World Settings → Game Mode Override → PlanetGameMode
|
||||
* 2. World Settings → Default Pawn Class → CesiumDynamicPawn
|
||||
*/
|
||||
UCLASS()
|
||||
class PLANETCLIENT_API APlanetGameMode : public AGameModeBase
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
APlanetGameMode();
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
};
|
||||
246
ue_client/Source/PlanetClient/PlanetPlayerController.cpp
Normal file
246
ue_client/Source/PlanetClient/PlanetPlayerController.cpp
Normal file
@@ -0,0 +1,246 @@
|
||||
#include "PlanetPlayerController.h"
|
||||
#include "GlobeInteractionComponent.h"
|
||||
#include "InteractiveObjectBase.h"
|
||||
#include "Engine/World.h"
|
||||
#include "GameFramework/Pawn.h"
|
||||
|
||||
APlanetPlayerController::APlanetPlayerController()
|
||||
{
|
||||
bShowMouseCursor = true;
|
||||
bEnableClickEvents = true;
|
||||
bEnableMouseOverEvents = true;
|
||||
PrimaryActorTick.bCanEverTick = true;
|
||||
|
||||
GlobeInteraction = CreateDefaultSubobject<UGlobeInteractionComponent>(
|
||||
TEXT("GlobeInteraction"));
|
||||
}
|
||||
|
||||
void APlanetPlayerController::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
SetInputMode(FInputModeGameAndUI());
|
||||
|
||||
// TODO(供应商回复后取消注释):
|
||||
// BindMotionCaptureEvents();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输入绑定
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::SetupInputComponent()
|
||||
{
|
||||
Super::SetupInputComponent();
|
||||
|
||||
InputComponent->BindAction("SelectPoint", IE_Pressed, this,
|
||||
&APlanetPlayerController::OnLeftMouseDown);
|
||||
InputComponent->BindAction("SelectPoint", IE_Released, this,
|
||||
&APlanetPlayerController::OnLeftMouseUp);
|
||||
InputComponent->BindAction("Escape", IE_Pressed, this,
|
||||
&APlanetPlayerController::OnEscape);
|
||||
|
||||
InputComponent->BindAxis("RotateYaw", this, &APlanetPlayerController::OnMouseX);
|
||||
InputComponent->BindAxis("RotatePitch", this, &APlanetPlayerController::OnMouseY);
|
||||
InputComponent->BindAxis("ZoomCamera", this, &APlanetPlayerController::OnZoom);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick:悬停检测
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::Tick(float DeltaTime)
|
||||
{
|
||||
Super::Tick(DeltaTime);
|
||||
if (!bIsDragging) UpdateHover();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 鼠标输入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::OnLeftMouseDown()
|
||||
{
|
||||
bLeftMouseDown = true;
|
||||
bIsDragging = false;
|
||||
|
||||
float X, Y;
|
||||
GetMousePosition(X, Y);
|
||||
MouseDownPosition = FVector2D(X, Y);
|
||||
|
||||
// 停止惯性,准备接管控制
|
||||
if (GlobeInteraction) GlobeInteraction->StopInertia();
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnLeftMouseUp()
|
||||
{
|
||||
if (!bIsDragging)
|
||||
{
|
||||
// 没有拖拽 → 判定为点击
|
||||
AInteractiveObjectBase* Clicked = GetObjectUnderCursor();
|
||||
if (Clicked && Clicked != SelectedObject)
|
||||
{
|
||||
// 取消上一个选中
|
||||
DeselectCurrent();
|
||||
// 选中新物件
|
||||
SelectedObject = Clicked;
|
||||
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
|
||||
OnObjectSelected.Broadcast(SelectedObject);
|
||||
}
|
||||
else if (!Clicked)
|
||||
{
|
||||
// 点了空白处 → 取消选中
|
||||
DeselectCurrent();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 点了已选中的物件 → 取消选中
|
||||
DeselectCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
bLeftMouseDown = false;
|
||||
bIsDragging = false;
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnMouseX(float Value)
|
||||
{
|
||||
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
|
||||
|
||||
// 判断是否超过拖拽阈值
|
||||
if (!bIsDragging)
|
||||
{
|
||||
float X, Y;
|
||||
GetMousePosition(X, Y);
|
||||
float Dist = FVector2D::Distance(FVector2D(X, Y), MouseDownPosition);
|
||||
if (Dist < DragThresholdPixels) return;
|
||||
bIsDragging = true;
|
||||
}
|
||||
|
||||
if (GlobeInteraction) GlobeInteraction->RotateGlobe(Value * MouseDragSensitivity, 0.f);
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnMouseY(float Value)
|
||||
{
|
||||
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
|
||||
if (!bIsDragging) return;
|
||||
|
||||
if (GlobeInteraction) GlobeInteraction->RotateGlobe(0.f, Value * MouseDragSensitivity);
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnZoom(float Value)
|
||||
{
|
||||
if (FMath::IsNearlyZero(Value)) return;
|
||||
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(Value);
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnEscape()
|
||||
{
|
||||
DeselectCurrent();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 悬停
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::UpdateHover()
|
||||
{
|
||||
AInteractiveObjectBase* UnderCursor = GetObjectUnderCursor();
|
||||
|
||||
if (HoveredObject && HoveredObject != UnderCursor && HoveredObject != SelectedObject)
|
||||
{
|
||||
HoveredObject->SetInteractiveState(EInteractiveObjectState::Normal);
|
||||
}
|
||||
|
||||
HoveredObject = UnderCursor;
|
||||
|
||||
if (HoveredObject && HoveredObject != SelectedObject)
|
||||
{
|
||||
HoveredObject->SetInteractiveState(EInteractiveObjectState::Hovered);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 取消选中
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::DeselectCurrent()
|
||||
{
|
||||
if (!SelectedObject) return;
|
||||
SelectedObject->SetInteractiveState(EInteractiveObjectState::Normal);
|
||||
SelectedObject = nullptr;
|
||||
OnObjectDeselected.Broadcast();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 射线检测
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
AInteractiveObjectBase* APlanetPlayerController::GetObjectUnderCursor() const
|
||||
{
|
||||
FHitResult Hit;
|
||||
bool bHit = GetHitResultUnderCursorByChannel(
|
||||
UEngineTypes::ConvertToTraceType(ECC_Visibility), true, Hit);
|
||||
if (!bHit) return nullptr;
|
||||
return Cast<AInteractiveObjectBase>(Hit.GetActor());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 动捕事件(TODO: 供应商回复后实现)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void APlanetPlayerController::BindMotionCaptureEvents()
|
||||
{
|
||||
if (!MotionCaptureReceiver)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("PlayerController: MotionCaptureReceiver 未设置,动捕功能禁用"));
|
||||
return;
|
||||
}
|
||||
|
||||
MotionCaptureReceiver->OnGestureDetected.AddDynamic(
|
||||
this, &APlanetPlayerController::OnMotionGesture);
|
||||
MotionCaptureReceiver->OnRotateDelta.AddDynamic(
|
||||
this, &APlanetPlayerController::OnMotionRotate);
|
||||
MotionCaptureReceiver->OnZoomDelta.AddDynamic(
|
||||
this, &APlanetPlayerController::OnMotionZoom);
|
||||
|
||||
MotionCaptureReceiver->Connect();
|
||||
UE_LOG(LogTemp, Log, TEXT("PlayerController: 动捕事件已绑定"));
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnMotionGesture(EMotionGesture Gesture)
|
||||
{
|
||||
if (Gesture == MotionActionMapping.SelectPoint)
|
||||
{
|
||||
// 手势确认 → 等同于点击当前悬停的物件
|
||||
if (HoveredObject)
|
||||
{
|
||||
DeselectCurrent();
|
||||
SelectedObject = HoveredObject;
|
||||
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
|
||||
OnObjectSelected.Broadcast(SelectedObject);
|
||||
}
|
||||
}
|
||||
else if (Gesture == MotionActionMapping.DismissInfoCard)
|
||||
{
|
||||
DeselectCurrent();
|
||||
}
|
||||
else if (Gesture == MotionActionMapping.ZoomIn)
|
||||
{
|
||||
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(1.f);
|
||||
}
|
||||
else if (Gesture == MotionActionMapping.ZoomOut)
|
||||
{
|
||||
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(-1.f);
|
||||
}
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnMotionRotate(float DeltaYaw, float DeltaPitch)
|
||||
{
|
||||
if (GlobeInteraction) GlobeInteraction->RotateGlobe(DeltaYaw, DeltaPitch);
|
||||
}
|
||||
|
||||
void APlanetPlayerController::OnMotionZoom(float DeltaScale)
|
||||
{
|
||||
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(DeltaScale);
|
||||
}
|
||||
117
ue_client/Source/PlanetClient/PlanetPlayerController.h
Normal file
117
ue_client/Source/PlanetClient/PlanetPlayerController.h
Normal file
@@ -0,0 +1,117 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "MotionCaptureInterface.h"
|
||||
#include "PlanetPlayerController.generated.h"
|
||||
|
||||
class AInteractiveObjectBase;
|
||||
class UGlobeInteractionComponent;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectSelected, AInteractiveObjectBase*, Object);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnObjectDeselected);
|
||||
|
||||
/**
|
||||
* APlanetPlayerController
|
||||
*
|
||||
* 统一处理两路输入:
|
||||
* 1. 鼠标/键盘(始终可用,调试和演示备用)
|
||||
* 2. 动捕手势(TODO: 供应商协议确认后接入)
|
||||
*
|
||||
* 鼠标操作:
|
||||
* 左键拖拽 → 旋转地球
|
||||
* 滚轮 → 缩放
|
||||
* 悬停 → 高亮物件
|
||||
* 左键单击 → 选中物件 / 取消选中
|
||||
* ESC → 取消选中
|
||||
*/
|
||||
UCLASS(BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API APlanetPlayerController : public APlayerController
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
APlanetPlayerController();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 组件
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
|
||||
UGlobeInteractionComponent* GlobeInteraction;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 动捕配置(TODO: 供应商回复后配置)
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
FMotionActionMapping MotionActionMapping;
|
||||
|
||||
// 动捕接收器实例(TODO: 确认协议后指向具体子类实例)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||
UMotionCaptureReceiver* MotionCaptureReceiver = nullptr;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 鼠标灵敏度
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
|
||||
float MouseDragSensitivity = 0.3f;
|
||||
|
||||
// 判定为"拖拽"而非"点击"的最小移动距离(像素)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
|
||||
float DragThresholdPixels = 5.f;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 事件
|
||||
// -----------------------------------------------------------------------
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||
FOnObjectSelected OnObjectSelected;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||
FOnObjectDeselected OnObjectDeselected;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 公开 API
|
||||
// -----------------------------------------------------------------------
|
||||
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||
void DeselectCurrent();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet")
|
||||
AInteractiveObjectBase* GetSelectedObject() const { return SelectedObject; }
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
virtual void Tick(float DeltaTime) override;
|
||||
virtual void SetupInputComponent() override;
|
||||
|
||||
private:
|
||||
// 鼠标拖拽状态
|
||||
bool bLeftMouseDown = false;
|
||||
bool bIsDragging = false;
|
||||
FVector2D MouseDownPosition = FVector2D::ZeroVector;
|
||||
|
||||
// 交互状态
|
||||
AInteractiveObjectBase* SelectedObject = nullptr;
|
||||
AInteractiveObjectBase* HoveredObject = nullptr;
|
||||
|
||||
// 输入回调
|
||||
void OnLeftMouseDown();
|
||||
void OnLeftMouseUp();
|
||||
void OnMouseX(float Value);
|
||||
void OnMouseY(float Value);
|
||||
void OnZoom(float Value);
|
||||
void OnEscape();
|
||||
|
||||
// 悬停检测
|
||||
void UpdateHover();
|
||||
|
||||
// 射线检测
|
||||
AInteractiveObjectBase* GetObjectUnderCursor() const;
|
||||
|
||||
// 动捕事件绑定(TODO: 供应商回复后在 BeginPlay 里绑定接收器)
|
||||
UFUNCTION()
|
||||
void OnMotionGesture(EMotionGesture Gesture);
|
||||
UFUNCTION()
|
||||
void OnMotionRotate(float DeltaYaw, float DeltaPitch);
|
||||
UFUNCTION()
|
||||
void OnMotionZoom(float DeltaScale);
|
||||
void BindMotionCaptureEvents();
|
||||
};
|
||||
131
ue_client/Source/PlanetClient/StereoRenderingManager.cpp
Normal file
131
ue_client/Source/PlanetClient/StereoRenderingManager.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "StereoRenderingManager.h"
|
||||
#include "Engine/Engine.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
AStereoRenderingManager::AStereoRenderingManager()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = false;
|
||||
}
|
||||
|
||||
void AStereoRenderingManager::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
|
||||
if (bAutoEnableOnPlay)
|
||||
{
|
||||
EnableStereo(DefaultStereoMode);
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogTemp, Log,
|
||||
TEXT("StereoRenderingManager: 立体渲染未自动启动。"
|
||||
"确认视频处理器格式后,在 Details 面板勾选 bAutoEnableOnPlay。"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开 API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AStereoRenderingManager::EnableStereo(EStereoOutputMode Mode)
|
||||
{
|
||||
if (Mode == EStereoOutputMode::Off)
|
||||
{
|
||||
DisableStereo();
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(供应商回复后检查这里的命令是否与视频处理器匹配):
|
||||
//
|
||||
// UE5 内置立体模式通过以下方式启用。
|
||||
// 如果视频处理器需要特殊格式,可能需要修改 stereo mode 参数。
|
||||
//
|
||||
// 参考命令:
|
||||
// stereo on —— 开启立体渲染(需要 VR 设备或自定义立体设备)
|
||||
// r.StereoRendering 1 —— 软件层立体渲染标志
|
||||
// vr.StereoMode <n> —— 0=SbS full, 1=SbS half, 2=TbB full, 3=TbB half
|
||||
//
|
||||
// 目前使用 r.StereoRendering 的"双视口"方式,兼容性最好。
|
||||
|
||||
switch (Mode)
|
||||
{
|
||||
case EStereoOutputMode::SideBySide:
|
||||
// Side-by-Side:左半=左眼,右半=右眼,总分辨率 2x 宽
|
||||
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
|
||||
ExecConsoleCommand(TEXT("stereo on"));
|
||||
ExecConsoleCommand(TEXT("vr.StereoMode 0")); // TODO: 根据实际效果调整
|
||||
break;
|
||||
|
||||
case EStereoOutputMode::TopBottom:
|
||||
// Top-Bottom:上半=左眼,下半=右眼,总分辨率 2x 高
|
||||
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
|
||||
ExecConsoleCommand(TEXT("stereo on"));
|
||||
ExecConsoleCommand(TEXT("vr.StereoMode 2")); // TODO: 根据实际效果调整
|
||||
break;
|
||||
|
||||
case EStereoOutputMode::FrameSequential:
|
||||
// 本项目屏幕 60Hz,帧序列需要 120Hz,暂不支持
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("StereoRenderingManager: Frame Sequential 需要 120Hz 输出,"
|
||||
"当前屏幕规格不支持,已忽略。"));
|
||||
return;
|
||||
|
||||
default: break;
|
||||
}
|
||||
|
||||
// 设置 IPD
|
||||
SetIPD(IPDCm);
|
||||
|
||||
bStereoActive = true;
|
||||
CurrentMode = Mode;
|
||||
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已开启,模式=%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");
|
||||
}
|
||||
}
|
||||
97
ue_client/Source/PlanetClient/StereoRenderingManager.h
Normal file
97
ue_client/Source/PlanetClient/StereoRenderingManager.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "StereoRenderingManager.generated.h"
|
||||
|
||||
/**
|
||||
* 立体渲染输出格式
|
||||
*
|
||||
* TODO(等待视频处理器供应商回复):
|
||||
* 确认视频处理器接受哪种格式后,在 DefaultStereoMode 里选择对应值。
|
||||
* - 如果是诺瓦星云/Brompton 处理器,通常接受 SideBySide
|
||||
* - Frame Sequential 需要 120Hz+ 输出,本项目屏幕不需要,基本不选
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class EStereoOutputMode : uint8
|
||||
{
|
||||
Off UMETA(DisplayName="关闭(普通 2D 输出)"),
|
||||
SideBySide UMETA(DisplayName="左右并排 Side-by-Side"), // 最常见,优先尝试
|
||||
TopBottom UMETA(DisplayName="上下叠加 Top-Bottom"), // 备选
|
||||
FrameSequential UMETA(DisplayName="帧序列(需 120Hz)"), // 本项目暂不用
|
||||
};
|
||||
|
||||
/**
|
||||
* AStereoRenderingManager
|
||||
*
|
||||
* 放一个在场景里(或在 GameMode 里 SpawnActor),控制立体渲染的开关和参数。
|
||||
* 运行时可以通过 Blueprint 或控制台随时切换模式,方便现场调试。
|
||||
*
|
||||
* TODO(等待供应商回复后填写):
|
||||
* 1. 确认视频处理器输入格式 → 修改 DefaultStereoMode
|
||||
* 2. 确认屏幕分辨率 → 修改渲染分辨率(目前按 5120x1440 SbS 估算)
|
||||
* 3. 调整 IPDCm 到适合大屏幕观看距离的值(推荐 3-6cm,距离越远 IPD 越小)
|
||||
*/
|
||||
UCLASS(BlueprintType, Blueprintable)
|
||||
class PLANETCLIENT_API AStereoRenderingManager : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
AStereoRenderingManager();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 可在 Details 面板调整
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// TODO: 等供应商回复后改为正确格式
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
|
||||
EStereoOutputMode DefaultStereoMode = EStereoOutputMode::SideBySide;
|
||||
|
||||
// 瞳距(cm)。大屏幕 + 远距离观看建议 3.0-5.0cm
|
||||
// TODO: 根据实际屏幕尺寸和观看距离校准
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo",
|
||||
meta=(ClampMin="1.0", ClampMax="10.0"))
|
||||
float IPDCm = 6.5f;
|
||||
|
||||
// 是否在 BeginPlay 时自动启用立体渲染
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
|
||||
bool bAutoEnableOnPlay = false; // 默认 false,等确认格式后改为 true
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Blueprint 可调用的运行时 API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// 开启立体渲染
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||
void EnableStereo(EStereoOutputMode Mode);
|
||||
|
||||
// 关闭立体渲染,切回普通 2D
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||
void DisableStereo();
|
||||
|
||||
// 切换(当前开 → 关,当前关 → 用 DefaultStereoMode 开)
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||
void ToggleStereo();
|
||||
|
||||
// 调整 IPD(运行时微调景深效果)
|
||||
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||
void SetIPD(float NewIPDCm);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
|
||||
bool IsStereoActive() const { return bStereoActive; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
|
||||
EStereoOutputMode GetCurrentMode() const { return CurrentMode; }
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
bool bStereoActive = false;
|
||||
EStereoOutputMode CurrentMode = EStereoOutputMode::Off;
|
||||
|
||||
// 执行实际的控制台命令
|
||||
void ExecConsoleCommand(const FString& Cmd);
|
||||
static FString ModeToString(EStereoOutputMode Mode);
|
||||
};
|
||||
Reference in New Issue
Block a user