Skip to content

World-space UI

WorldSpaceService and SusWorldSpacePanel live in sus-core. Prefer SusApp (auto __SusWorldSpacePanel__ + WorldSpaceService.Default). Kit v1 SusWorldTrackerService is legacy — do not use for new code (see services docs in the kit package).


1. Purpose

World-space components display UI above objects in the 3D world: health bars, name/level signs, damage pop-up. Elements “hang” over Transform unit and follow it when the camera and object move.

Positioning is done by WorldSpaceService (sus-core): every frame Tick() recalculates Camera.WorldToScreenPoint → panel coordinates and updates the left/top of the element.


2. Connection

Preferred: let SusApp create the world panel and wire WorldSpaceService.Default (__SusWorldSpacePanel__, PanelSettings.renderMode = WorldSpace). Opt out with UseWorldSpace(false) for pure 2D apps.

csharp
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));

Manual / tests (no SusApp): create a service, attach a driver, then bind:

csharp
// 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. Components

3.1 SusHealthBar

Health bar 0..100%. Reacts to Prop<​float> Value.

File: sus-kit/Components/world/SusHealthBar.sharq

Props:

PropTypeDefaultDestination
Value Prop<​float>1fFilling 0..1 (Health / MaxHealth)
ShowLabel Prop<​bool> trueShow label "67%"

Style: 200×20px, dark track, green hb-fill bar (warning / critical thresholds), centered label.

CSS classes: sus-healthbar, hb-fill, hb-label

csharp
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));

3.2 SusNameplate

A plate with the name and level of the unit.

File: sus-kit/Components/world/SusNameplate.sharq

Props:

PropTypeDefaultDestination
UnitName Prop<​string>""Unit name
Level Prop<​int>0Level (0 = do not show)
HpFraction Prop<​float>-1fInline HP bar fill 0..1; < 0 hides the bar
Align Prop<​string>"start"Layout of name + HP: start | center | end

Style: name + optional level + optional HP strip (--sk-nameplate-hp-width, default 80px).

CSS classes: sus-nameplate, np-root, np-name, np-level, np-hp-bar, np-hp-fill, np-root--align-*

csharp
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));

3.3 SusFloatingDamage

Pop-up damage: the number flies up and goes out in 1.2s, then self-removes from the hierarchy (RemoveFromHierarchy → WorldSpaceService auto-Unbind).

File: sus-kit/Components/world/SusFloatingDamage.sharq

Props:

PropTypeDefaultDestination
Damage Prop<​int>0Damage/Heal Amount
IsCritical Prop<​bool> falseCritical Strike (Orange)
IsHeal Prop<​bool> falseTreatment (green, prefix “+”)

Animation: 1.2s - upward movement 40px + fade-out (opacity: 1 → 0). On completion: RemoveFromHierarchy().

CSS classes: sus-floating-damage, fd-label

csharp
// 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

Pool (recommended):

csharp
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
);

3.4 SusWorldMarker (core) + SusUnitNameplate (kit)

SusWorldMarker is a sus-core container ( sus-core/Runtime/World/SusWorldMarker.cs) — tracking / edge projection only. It holds no unit chrome. Put kit content (typically SusUnitNameplate) as a child.

Container props (core):

PropTypeDefaultDestination
Target Prop<​Transform> nullTransform to follow
WorldOffset Prop<​Vector3>(0,2,0)Offset over object
Camera Prop<​Camera> nullFalls back to WorldSpaceService.Camera
UpdateRate Prop<​int>16Reposition interval (ms)

SusUnitNameplatesus-kit/Components/world/SusUnitNameplate.sharq (unit chrome: name, level, faction, icon, optional HP).

PropTypeDefaultDestination
UnitName Prop<​string>""Display name
Level Prop<​int>0Level
Faction Prop<​string>"" ally | enemy | neutral
Icon Prop<​string>""Phosphor / icon alias
HpFraction Prop<​float>1fHP fill 0..1
ShowHealthBar Prop<​bool> falseShow HP strip
Target Prop<​Transform> nullOptional auto-bind source
AutoBind Prop<​bool> trueRead IWorldUnit* from Target
csharp
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);

Edge projection helpers: WorldSpaceService.GetEdgePosition() ( EdgeSide.Left/Right/Top/Bottom).


4. WorldSpaceService API

Service in sus-core/Runtime/Services/WorldSpaceService.cs.

MethodDestination
BindToWorld(el, target, offset)Bind element to Transform. Preferred: world panel; fallback: WorldMarkerLayer (below screens). Returns WorldBinding.
Unbind(el)Unbind, remove from world panel / marker layer.
UnbindTarget(transform)Remove all bindings from the given Transform.
Tick()Frame-by-frame recalculation of positions. Call from LateUpdate.
AttachDriver()Create a GameObject with WorldSpaceDriver (LateUpdate → Tick).

Properties:

PropertyTypeDestination
WorldSpacePanel SusWorldSpacePanelPreferred host (variant A, under screens)
MarkerLayer VisualElement (WorldMarkerLayer)Variant-B screen-space host, inserted below screens
OverlayHost OverlayHostDeprecated legacy fallback — avoid (host paints over screens)
MainCamera / Camera CameraCamera for WorldToScreenPoint (fallback path)
Count intInstance: number of active bindings
BindingCount int (static) WorldSpaceService.BindingCount — same count via Default / standalone
UpdateIntervalMs floatUpdate interval in ms (0 = every frame)

WorldBinding:

FieldTypeDestination
Element VisualElementAnchored element
Target TransformTarget in a 3D world
Offset Vector3Offset in world units
ClampToScreenEdges boolPress to the edges if the target is off-screen

5. Tick behavior

Each Tick() call for each binding:

  1. If Target == nullauto-Unbind and removal from the marker layer.
  2. Camera.WorldToScreenPoint(Target.position + Offset).
  3. If z < 0 (target behind the camera) → display: None.
  4. Otherwise display: Flex, translation ScreenToPanel, centering.
  5. If ClampToScreenEdges = true Mathf.Clamp on the edges of the panel.

Early departure:

  • Count == 0 / BindingCount == 0 - nothing to update.
  • UpdateIntervalMs > 0 && (now - lastTick) < interval — throttle.

6. Z-order

Preferred: world UI lives in a separate UIDocument / SusWorldSpacePanel (PanelSettings.renderMode = WorldSpace) that the camera draws under all screen UI. Screen overlays stay on the 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

Fallback (variant B): if there is no world panel, WorldSpaceService places flat markers into a dedicated WorldMarkerLayer — the first child of the screen root, so it renders below screens and the OverlayHost. World markers must never sit in the OverlayHost (the top-most layer): a menu or HUD has to be able to cover them. Prefer the separate world panel when possible.


7. Option A - World-space panel (SusWorldSpacePanel)

Advanced / manual. Normally you don't create this yourself — SusApp already builds __SusWorldSpacePanel__ and wires WorldSpaceService.Default (see §2). This section is for manual setups (custom panel settings, tests, non-SusApp bootstraps).

For a true 3D render with perspective and overlapping geometry - SusWorldSpacePanel. This is a MonoBehaviour requiring UIDocument with PanelSettings.renderMode = WorldSpace.

Differences from option B:

  • Elements live in a separate world-space panel, which the camera renders as part of the scene.
  • The position is given by the container's transform.position (not recalculated from WorldToScreenPoint).
  • Billboard: elements always look at the camera.
  • Remote scale: elements do not become smaller/inflated at any distance.

Connection

Create an object in the scene or through code:

csharp
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;

Integration with WorldSpaceService

csharp
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

MethodDestination
AttachElement(el, target, offset)Snap element to Transform in 3D
DetachElement(el)Unbind element
DetachTarget(transform)Untie everything with Transform
DetachAll()Clear all

Properties to configure:

PropertyTypeDefaultDestination
EnableBillboard bool trueAlways look at the camera
EnableDistanceScaling bool trueScale by distance
BaseDistance float10Distance at which scale = 1x
MinScale float0.5Minimum scale
MaxScale float2Maximum scale

8. Limitations

  • Option A: requires PanelSettings asset with renderMode = WorldSpace - create in Editor (Assets → Create → UI Toolkit → Panel Settings).
  • Option B: elements are “flat” and do not overlap with the scene geometry.
  • RuntimePanelUtils.ScreenToPanel requires the element to be in a panel.

Open-core: Core & Router MIT · Kit & Game commercial