Character-mounted assets and live-tuning their placement
The method for hanging custom models off the player rig -- gadgets, backpacks, held items -- with three mount styles off one skeleton, and a live slider panel that dumps C#-ready constants for paste-back.
The method for hanging custom models off the player rig -- gadgets, backpacks, held items -- and dialing each one's placement to final values live in the running game instead of edit-recompile-relaunch. Three mount styles off one skeleton, a live override layer consumed at the exact runtime seam the shipped constants use, and a draggable-slider panel that dumps C#-ready constant lines for paste-back. What you tune on screen is byte-for-byte what the baked constant reproduces.
Provenance: shipped and owner-verified live in a voxel-terrain project with six Tripo-generated traversal gadgets mounted and position-tuned to final values in one session.
The single-source-of-truth split
The load-bearing decision is that mount data lives in a static that BOTH the runtime renderer AND any edit-mode preview harness read -- never inline in the controller. Pull every mount constant out of the controller into a dedicated mounts static so a preview harness can never diverge from what a player sees in play. The same split is what makes the live-tune layer a drop-in: the tuner is a second reader seeded from the first.
MountsData (static) <-- the shipped constants, the BAKE TARGET
| seeds
v
MountTweak (static) <-- live override layer (TEMP), defaults == MountsData
| consumed at the SAME seam
v
ItemController <-- FollowHandMount / FollowBackMount read the layer,
apply x UnitsPerMetre at the boundaryThe units-per-metre conversion happens once, at the mount SETTER in the controller -- never in the data static. Author every offset in metres; the boundary is the single multiply. Two conversions (or zero) is the classic scale bug.
The three mount styles
Each item declares a style; the controller anchors the mount differently per
style. The frame that the per-item offset/rotation live in is DIFFERENT for
each -- this is the thing people get wrong.
public enum MountStyle { Hand, Back, Feet }Hand mount -- skeleton attachment + hand-local correction
The gadget pins to the citizen's right-hand weapon-hold attachment every frame. Resolve the hand transform by trying attachment names first, then bone names, caching whichever resolves:
static readonly string[] HandAttachNames = { "hold_R", "hold_r" };
static readonly string[] HandBoneNames = { "hand_R", "hand_r" };
// try GetAttachment(name) for each attach name;
// fall back to TryGetBoneTransform(name)
// posM = t.Position / UnitsPerMetre; rot = t.Rotation;Per-item dials are a correction applied in the hand's LOCAL frame on top of the attachment transform:
var worldPosM = handPosM + handRot * offsetM; // offset is hand-local
mount.WorldPosition = worldPosM * M; // the ONE x M boundary
mount.WorldRotation = handRot * rotFix.ToRotation();offsetM reads as "forward-along-the-grip / into-the-palm" in the hand's own
axes, and rotFix (an Angles(pitch,yaw,roll)) twists the model against the
palm. These are LOOK calls -- the exact twist is eyeballed against captures.
Back / bone mount -- follows the animation bob
A back gadget parented rigidly to the character ROOT tracks smooth root motion,
not the animated spine -- the result is "my shoulders bob but the gadget just
slides." Pin the mount to an upper-spine BONE (e.g. spine_2) each frame so it
bobs and leans with the run cycle.
Follow only the bone's position (which bobs/leans) and keep a clean
body-aligned rotation (character facing times the tuned Angles) -- this
sidesteps bone-local-axis guesswork entirely:
var bodyRot = chr.WorldRotation; // yaw-only facing
mount.WorldPosition = (bonePosM + bodyRot * offsetM) * M; // offset in BODY frame
mount.WorldRotation = bodyRot * rot.ToRotation();offsetM here is the vector from the spine bone to where the item sits, in the
body frame (x forward, y left, z up) -- NOT the bone-local frame.
Feet / root mount -- rigid child of the character root
The simplest style: parent the mount to the character and set a local offset/rotation in the ROOT frame:
mount.SetParent(chr.GameObject, false);
mount.LocalPosition = offsetM * M;
mount.LocalRotation = rot.ToRotation();Model-origin discipline (the silent vertical-offset bug)
Assets authored grounded at z=0 (origin at the model's bottom) extend upward from the mount point. If a gadget is ~0.5 m tall and the mount z is 0.85, the tops end up flanking the head. Know where your model's origin sits before you reason about the offset -- half of "it's floating / it's buried" is the model origin, not the dial.
The frame-0 bind-pose trap
GetAttachment / TryGetBoneTransform can return the bind pose on the
first frame after a model is created -- the skeleton hasn't been animated yet.
A mount placed once at attach snaps to a T-pose location and then jumps when
the animation first evaluates.
Fix: place immediately at attach AND re-pin every frame in OnPreRender (which
runs after the citizen pose is evaluated). The attach-frame placement is
provisional; the per-frame OnPreRender re-pin is authoritative. Do the follow
in OnPreRender, not OnUpdate, so you read the bone/attachment transform for
the pose that will actually render this frame.
The live-tune override layer + panel
Iterating mount numbers by editing a constant, recompiling, relaunching, and re-walking to the test spot costs minutes per nudge. The override layer collapses that to a drag.
The WYSIWYG / bake-fidelity contract
The override layer is a static, per-item Tune { PosM, Rot, Scale }, seeded
from the shipped constants on first access:
static Tune SeedFor(ItemKind k)
{
// read CURRENT shipped constants for this item's style into a Tune
// deep-COPY any array fields so panel edits never mutate the readonly source
return new Tune { PosM = pos, Rot = rot, Scale = characterScale };
}Because every entry starts equal to the constant, opening the panel changes
nothing until a value is touched. And because the controller now reads
MountTweak.For(kind) at the EXACT seam it used to read the mounts static --
same follow methods -- what the owner tunes is byte-for-byte what a constant edit
will reproduce. No "the panel looked right but the bake drifted" gap.
Tabbed panel, hotbar-drives-the-tab
One tab per mountable item; only the active tab's rows render (keeps the live text-run count low -- see the glyph budget below). The critical UX lesson: the hotbar drives the tab, NOT the reverse. An early version force-equipped the active tab's item every frame, which fought the player -- every gadget switch snapped back.
The fix:
- While the panel is open, if the player is HOLDING a gadget, follow the tab to the held item
- If the player is holding NOTHING (stowed), equip the tab's item so its mount stays visible (WYSIWYG -- you must see the thing you're tuning)
- Clicking a tab also equips that item
Never force-equip against a live player selection.
Force-showing the tuned item
Some placement is only visible under a runtime state -- e.g. flame nozzles only
show while thrusting. Gate a force-show on panelOpen && activeTab == kind and
OR it into the runtime's visibility check, so the effect offset is tunable
without holding the trigger key.
Dump -- C#-ready constant lines for paste-back
A Dump button prints each item's tuned values as a paste-ready snippet to the
console; a Copy puts one tab's line on the clipboard. Format the dump to mirror
the constant's SIGNATURE exactly (the Vector3(...)/Angles(...) literals, the
same field names) so paste-back into the mounts static is mechanical. After
baking, the tuned value and the constant are identical by the WYSIWYG contract.
Editor-only gating + temp-tool discipline
A live-tune panel must not ship:
- Gate on
Application.IsEditor. A published build is neverIsEditor, so the toggle key and convar are both dead on the live game. - Mark every piece TEMPORARY with a delete list. The tweak static and its panel both carry a header naming exactly what to strip after bake. The mount struct and the constant defaults are permanent; only the panel rows that edit them are temporary.
The static-persistence / convar trap
The panel's open-state is a [ConVar]. s&box persists convars across sessions,
so a session can boot with the panel logically open -- which invisibly
force-equips the last active tab's item and makes the hotbar look "stuck." Any
static/convar that gates behavior and survives a play session must be
force-reset to inert at session start:
protected override void OnStart()
{
if (MountTweak.PanelOpen)
Log.Info("[panel] was OPEN at session start (persisted convar) -- forcing closed");
MountTweak.PanelOpen = false;
}General rule: any persisted static that changes runtime behavior must default-close on boot, and log when it was found non-default.
The draggable slider row
Each row is a click-to-jump + drag-to-scrub track with a small +/- beside it
for cm/degree precision. There is no native drag helper; the mechanic is the
engine's own SliderControl pattern, hand-rolled (see
Draggable slider click-drag).
The load-bearing SCSS idiom: .track with position: relative + .fill with
position: absolute so dragging fills the bar without growing the row.
Razor (one row):
@foreach (var r in Rows)
{
var row = r;
float cur = MountTweak.Get(Tab, row.field);
string val = cur.ToString(row.fmt);
int fillPct = (int)(Frac(cur, row) * 100f);
<div class="mt-row">
<div class="mt-rlab"><span>@row.label</span><span>@val</span></div>
<div class="mt-slider">
<span class="mt-stp"
onclick=@(() => Nudge(row.field, -row.step * StepMult))>-</span>
<div class="mt-track"
onmousedown=@(e => TrackPointer(e, row, true))
onmousemove=@(e => TrackPointer(e, row, false))>
<div class="mt-fill" style="width: @(fillPct)%;"></div>
</div>
<span class="mt-stp"
onclick=@(() => Nudge(row.field, row.step * StepMult))>+</span>
</div>
</div>
}The shared pointer handler routes the absolute drag target back through a
delta-taking Nudge mutator, so all clamps live in one place:
void TrackPointer(PanelEvent ev, Row row, bool jump)
{
if (ev is not MousePanelEvent e) return;
var track = e.This;
if (track is null) return;
if (!jump && !track.PseudoClass.HasFlag(PseudoClass.Active)) return;
float w = track.Box.Rect.Width;
if (w <= 0f) return;
float frac = Math.Clamp(e.LocalPosition.x / w, 0f, 1f);
float target = row.min + frac * (row.max - row.min);
if (row.step > 0f)
target = MathF.Round(target / row.step) * row.step;
target = Math.Clamp(target, row.min, row.max);
MountTweak.Nudge(Tab, row.field, target - MountTweak.Get(Tab, row.field));
}Re-render + Razor traps
BuildHashmust fold every displayed value (rounded to an int so it changes on a nudge), or the on-screen number freezes while the mount moves (see UI frozen BuildHash):snippetprotected override int BuildHash() { int h = HashCode.Combine(PanelOpen, (int)Tab, _coarse); foreach (var r in Rows) h = HashCode.Combine(h, (int)MathF.Round( MountTweak.Get(Tab, r.field) * 1000f)); return h; }- Render rows in the component's main
<root>markup, not aRenderFragmentexpression -- thin flex rows with a gap under-measure their height inside a fragment and pile on each other (see Razor fragment flex gap). - Resolve computed values to a plain local before interpolating
(
string val = cur.ToString(...)then@val) -- inline@Foo()inside a fragment can render blank (see Razor text renders blank).
The glyph-budget + F-key constraints
Two s&box UI traps bite dev panels hard:
- Never bind the toggle to an F key. The editor owns F1-F12 in play mode,
so a raw
Input.Keyboard.Pressed("F7")never fires -- silently. Use a letter key with a convar fallback so the tool stays reachable if the key collides (see Editor captures play hotkeys). - Font-size glyph corruption is a cumulative text-count budget, not just a
per-declaration one. Too many
font-sizedeclarations and/or high live text-run counts corrupts text to solid filled blocks. Keep to a handful of grouped sizes (3 is safe), noletter-spacing, and render only the active tab's rows (see Font glyph corruption).
Build order
- Split mount DATA into a static read by both the runtime and any preview harness. Author offsets in metres; put the single x-units-per-metre at the controller's mount setter.
- Implement the three follow styles: hand (attachment + hand-local correction),
bone-follow (spine position + body-aligned rotation), feet (root child).
Re-pin in
OnPreRenderafter pose evaluation. - Add the override layer seeded from the constants; move the controller's reads to it at the same seam (defaults == constants means no-op until touched).
- Build the tabbed slider panel; hotbar-drives-tab, force-show the tuned item,
BuildHashfolds every value, rows in main markup. - Add Dump/Copy that mirror the constant signature. Gate everything on
Application.IsEditor, force-close on boot, and write the delete-list header. - Tune live; dump; paste back into the constants; delete the temp surface per the delete list.