World-space UI
WorldSpaceService和SusWorldSpacePanel位于 sus-core。优先使用 SusApp(自动创建__SusWorldSpacePanel__+WorldSpaceService.Default)。Kit v1 的SusWorldTrackerService已经过时(legacy)——新代码不要使用它(参见 kit 包中的 services 文档)。
1. 用途
World-space 组件把 UI 显示在 3D 世界中的物体上方:血条、 姓名/等级牌、伤害飘字。这些元素“悬挂”在 Transform 单位之上,并在相机或物体移动时跟随。
定位由 WorldSpaceService(sus-core)完成:每帧 Tick() 重新计算 Camera.WorldToScreenPoint → panel 坐标, 并更新元素的 left/top。
2. 接入
推荐做法: 让 SusApp 创建 world panel 并接好 WorldSpaceService.Default (__SusWorldSpacePanel__,PanelSettings.renderMode = WorldSpace)。纯 2D 应用可以通过 UseWorldSpace(false) 关闭该功能。
SusApp.Create(uiDocument)
.UseTheme(SusTheme.Dark)
.Mount<HomeScreen>();
// Bind after Mount — Default is already set
var hp = new SusHealthBar();
hp.Value.Value = 0.67f;
WorldSpaceService.BindToWorld(hp, unit.transform, offset: new Vector3(0, 2f, 0));手动接入 / 测试(不使用 SusApp):创建一个 service,挂上 driver,然后绑定:
// Variant-B markers live in a dedicated layer UNDER the screens — never the OverlayHost.
var markerLayer = SusBootstrap.GetOrCreateWorldMarkerLayer(uiDocument.rootVisualElement);
var world = new WorldSpaceService
{
MarkerLayer = markerLayer,
MainCamera = Camera.main
};
world.AttachDriver();
WorldSpaceService.Default = world;
var hp = new SusHealthBar();
world.Bind(hp, unit.transform, offset: new Vector3(0, 2f, 0));3. 组件
3.1 SusHealthBar
血条 0..100%。响应 Prop<float> Value。
文件: sus-kit/Components/world/SusHealthBar.sharq
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
Value | Prop<float> | 1f | 填充比例 0..1(Health / MaxHealth) |
ShowLabel | Prop<bool> | true | 显示标签“67%” |
样式: 200×20px,深色底槽,绿色 hb-fill 填充条(有 warning / critical 阈值),标签居中。
CSS classes: sus-healthbar, hb-fill, hb-label
var hp = new SusHealthBar();
hp.Value.Value = 0.67f; // 67%
hp.ShowLabel.Value = false; // hide percentage
world.BindToWorld(hp, unit.transform, offset: new Vector3(0, 2f, 0));样式钩子
USS 类契约 — 无需改 C# 即可覆盖外观。状态通过这些类切换:
hb-fillhb-labelsus-healthbarsus-healthbar--criticalsus-healthbar--healthysus-healthbar--warning
3.2 SusNameplate
显示单位名称和等级的牌子。
文件: sus-kit/Components/world/SusNameplate.sharq
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
UnitName | Prop<string> | "" | 单位名称 |
Level | Prop<int> | 0 | 等级(0 = 不显示) |
HpFraction | Prop<float> | -1f | 内联血条填充比例 0..1;< 0 隐藏血条 |
Align | Prop<string> | "start" | 名称 + HP 的布局:start | center | end |
样式: 名称 + 可选等级 + 可选 HP 条(--sk-nameplate-hp-width,默认 80px)。
CSS classes: sus-nameplate, np-root, np-name, np-level, np-hp-bar, np-hp-fill, np-root--align-*
var np = new SusNameplate();
np.UnitName.Value = "Warrior";
np.Level.Value = 5; // will show "Lv.5"
np.HpFraction.Value = 0.75f; // show HP bar at 75%
np.Align.Value = "center";
world.BindToWorld(np, unit.transform, offset: new Vector3(0, 2.5f, 0));样式钩子
USS 类契约 — 无需改 C# 即可覆盖外观。状态通过这些类切换:
np-hp-barnp-hp-fillnp-levelnp-namenp-rootnp-root--align-centernp-root--align-endnp-root--align-startnp-rowsus-slot
3.3 SusFloatingDamage
伤害飘字:数字向上飞出并在 1.2 秒内淡出,然后 自动从层级中移除(RemoveFromHierarchy → WorldSpaceService 自动 Unbind)。
文件: sus-kit/Components/world/SusFloatingDamage.sharq
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
Damage | Prop<int> | 0 | 伤害/治疗数值 |
IsCritical | Prop<bool> | false | 暴击(橙色) |
IsHeal | Prop<bool> | false | 治疗(绿色,前缀“+”) |
动画: 1.2 秒 - 向上位移 40px + 淡出(opacity: 1 → 0)。 完成后:RemoveFromHierarchy()。
CSS classes: sus-floating-damage, fd-label
// From the pool
var dmg = pool.Get();
dmg.Damage.Value = 50;
dmg.IsCritical.Value = true;
world.BindToWorld(dmg, unit.transform, offset: new Vector3(0, 2.5f, 0));
// dmg will delete itself in 1.2s对象池(推荐):
var pool = new SusObjectPool<SusFloatingDamage>(
createFunc: () => new SusFloatingDamage(),
onGet: d => { d.style.opacity = 1f; d.style.display = DisplayStyle.Flex; },
onRelease: d => d.RemoveFromHierarchy(),
maxSize: 20
);样式钩子
USS 类契约 — 无需改 C# 即可覆盖外观。状态通过这些类切换:
fd-labelsus-floating-damagesus-floating-damage--critsus-floating-damage--defaultsus-floating-damage--heal
3.4 SusWorldMarker(core)+ SusUnitNameplate(kit)
SusWorldMarker 是一个 sus-core 容器(sus-core/Runtime/World/SusWorldMarker.cs)—— 只负责跟踪 / 边缘投影,不含任何单位外观。把 kit 内容 (通常是 SusUnitNameplate)作为子元素放进去。
容器 Props(core):
| Prop | Type | Default | Description |
|---|---|---|---|
Target | Prop<Transform> | null | 要跟随的 Transform |
WorldOffset | Prop<Vector3> | (0,2,0) | 相对物体的偏移 |
Camera | Prop<Camera> | null | 回退到 WorldSpaceService.Camera |
UpdateRate | Prop<int> | 16 | 重新定位间隔(ms) |
SusUnitNameplate — sus-kit/Components/world/SusUnitNameplate.sharq(单位外观:名称、等级、阵营、图标、可选 HP)。
| Prop | Type | Default | Description |
|---|---|---|---|
UnitName | Prop<string> | "" | 显示名称 |
Level | Prop<int> | 0 | 等级 |
Faction | Prop<string> | "" | ally | enemy | neutral |
Icon | Prop<string> | "" | Phosphor / 图标别名 |
HpFraction | Prop<float> | 1f | HP 填充比例 0..1 |
ShowHealthBar | Prop<bool> | false | 显示 HP 条 |
Target | Prop<Transform> | null | 可选的自动绑定来源 |
AutoBind | Prop<bool> | true | 从 Target 读取 IWorldUnit* |
var marker = new SusWorldMarker();
marker.Target.Value = unit.transform;
marker.WorldOffset.Value = new Vector3(0, 2f, 0);
var plate = new SusUnitNameplate();
plate.UnitName.Value = "Warrior";
plate.ShowHealthBar.Value = true;
plate.HpFraction.Value = 0.8f;
marker.Add(plate);
WorldSpaceService.BindToWorld(marker, unit.transform, marker.WorldOffset.Value);边缘投影辅助方法:WorldSpaceService.GetEdgePosition()(EdgeSide.Left/Right/Top/Bottom)。
样式钩子
USS 类契约 — 无需改 C# 即可覆盖外观。状态通过这些类切换:
sus-unit-nameplatesus-unit-nameplate__contentsus-unit-nameplate__factionsus-unit-nameplate__faction--allysus-unit-nameplate__faction--enemysus-unit-nameplate__faction--neutralsus-unit-nameplate__hpsus-unit-nameplate__hp-fillsus-unit-nameplate__hp-fill--criticalsus-unit-nameplate__hp-fill--warningsus-unit-nameplate__iconsus-unit-nameplate__levelsus-unit-nameplate__namesus-unit-nameplate__platesus-unit-nameplate__row
4. WorldSpaceService API
服务位于 sus-core/Runtime/Services/WorldSpaceService.cs。
| Method | Description |
|---|---|
BindToWorld(el, target, offset) | 把元素绑定到 Transform。优先使用 world panel;回退到 WorldMarkerLayer(在屏幕之下)。返回 WorldBinding。 |
Unbind(el) | 解除绑定,从 world panel / marker layer 中移除。 |
UnbindTarget(transform) | 移除给定 Transform 上的所有绑定。 |
Tick() | 逐帧重新计算位置。从 LateUpdate 调用。 |
AttachDriver() | 创建一个带 WorldSpaceDriver 的 GameObject(LateUpdate → Tick)。 |
属性:
| Property | Type | Description |
|---|---|---|
WorldSpacePanel | SusWorldSpacePanel | 首选宿主(方案 A,位于屏幕之下) |
MarkerLayer | VisualElement(WorldMarkerLayer) | 方案 B 的 screen-space 宿主,插入在屏幕之下 |
OverlayHost | OverlayHost | 已废弃的旧回退方案——避免使用(该宿主会盖在屏幕上面) |
MainCamera / Camera | Camera | 用于 WorldToScreenPoint 的相机(回退路径) |
Count | int | 实例:当前活跃绑定数量 |
BindingCount | int(static) | WorldSpaceService.BindingCount —— 通过 Default / 独立实例得到的同一个计数 |
UpdateIntervalMs | float | 更新间隔(ms,0 = 每帧) |
WorldBinding:
| Field | Type | Description |
|---|---|---|
Element | VisualElement | 被锚定的元素 |
Target | Transform | 3D 世界中的目标 |
Offset | Vector3 | 世界单位下的偏移 |
ClampToScreenEdges | bool | 目标超出屏幕时贴到边缘 |
5. Tick 行为
每次 Tick() 调用,对每个绑定执行:
- 若
Target == null→ 自动 Unbind 并从 marker layer 中移除。 Camera.WorldToScreenPoint(Target.position + Offset)。- 若
z < 0(目标在相机后方)→display: None。 - 否则
display: Flex,通过ScreenToPanel转换坐标并居中。 - 若
ClampToScreenEdges = true→ 在 panel 边缘做Mathf.Clamp。
提前退出条件:
Count == 0/BindingCount == 0- 没有可更新的内容。UpdateIntervalMs > 0 && (now - lastTick) < interval— 节流。
6. Z 序
推荐做法: world UI 放在独立的 UIDocument / SusWorldSpacePanel (PanelSettings.renderMode = WorldSpace)中,由相机绘制在所有屏幕 UI 之下。 屏幕层的 overlay 保留在 screen-space document 中。
World-space panel (separate UIDocument) ← variant A, UNDER screens
└── healthbar / nameplate / floating damage
Screen-space UIDocument (root)
├── WorldMarkerLayer ← first child = lowest (variant-B markers)
├── ScreenHost ← app content: Mount<T> / router SusRouteView
└── OverlayHost ← last child = always on top of screens
├── [Transition = 10] curtain
├── [Modal = 20] dialogues
├── [Tooltip = 30] tips
├── [Dropdown = 40] drops
├── [Toast = 45] snackbars
└── [Console = 50] dev-console回退方案(方案 B): 如果没有 world panel,WorldSpaceService 会把扁平的 标记放进一个专用的 WorldMarkerLayer——屏幕根的第一个子元素,因此它 渲染在屏幕以及 OverlayHost 之下。World 标记绝不能出现在 OverlayHost (最顶层)中:菜单或 HUD 必须能够遮住它们。尽可能优先使用独立的 world panel。
7. 方案 A - World-space panel(SusWorldSpacePanel)
进阶 / 手动接入。 通常你不需要自己创建它——
SusApp已经构建了__SusWorldSpacePanel__并接好了WorldSpaceService.Default(见 §2)。本节适用于 手动搭建的场景(自定义 panel settings、测试、非 SusApp 的引导流程)。
要实现带透视和几何遮挡的真正 3D 渲染 - 用 SusWorldSpacePanel。这是一个 MonoBehaviour,需要 UIDocument 配合 PanelSettings.renderMode = WorldSpace。
与方案 B 的区别:
- 元素位于独立的 world-space panel 中,相机会把它作为 场景的一部分来渲染。
- 位置由容器的
transform.position给出(不是根据 WorldToScreenPoint 重新计算)。 - Billboard:元素始终面向相机。
- 远距离缩放:元素在任何距离下都不会变小/变大。
接入
在场景中创建一个物体,或者通过代码创建:
var go = new GameObject("WorldSpacePanel", typeof(UIDocument), typeof(SusWorldSpacePanel));
var doc = go.GetComponent<UIDocument>();
doc.panelSettings = Resources.Load<PanelSettings>("WorldSpacePanelSettings");
var panel = go.GetComponent<SusWorldSpacePanel>();
panel.TargetCamera = Camera.main;与 WorldSpaceService 集成
var world = new WorldSpaceService { Camera = Camera.main };
world.AttachDriver();
// Switch to variant A
world.UseWorldSpacePanel(panel);
// Elements automatically go to the world-space panel
world.BindToWorld(hp, unit.transform, offset: new Vector3(0, 2f, 0));
// Return to variant B
world.UseScreenSpacePanel();API SusWorldSpacePanel
| Method | Description |
|---|---|
AttachElement(el, target, offset) | 将元素锚定到 3D 中的 Transform |
DetachElement(el) | 解除元素绑定 |
DetachTarget(transform) | 解除某个 Transform 上的所有绑定 |
DetachAll() | 清空全部 |
可配置属性:
| Property | Type | Default | Description |
|---|---|---|---|
EnableBillboard | bool | true | 始终面向相机 |
EnableDistanceScaling | bool | true | 按距离缩放 |
BaseDistance | float | 10 | 缩放 = 1x 时的距离 |
MinScale | float | 0.5 | 最小缩放 |
MaxScale | float | 2 | 最大缩放 |
8. 限制
- 方案 A:需要
renderMode = WorldSpace的PanelSettings资源 - 在 Editor 中创建(Assets → Create → UI Toolkit → Panel Settings)。 - 方案 B:元素是“扁平”的,不会与场景几何体产生遮挡。
RuntimePanelUtils.ScreenToPanel要求元素必须位于某个 panel 中。