Pose Tuner (dev tool)
The Pose Tuner is an in-game panel for tuning procedural animation: the number tables your pose code reads every frame, the live inputs that drive them, and — through a 3D gizmo — bone rotations, bone positions and the placement of a held weapon. You tune in the real renderer, on the real rig, and export exactly what changed as a paste-ready snippet. Your committed source stays the source of truth; the panel is never a data format.
It is a dev tool, not part of the game runtime:
- Draft previews get it stamped at
_portals/dev/pose-tuner.js, on the game's own origin, so the strict game CSP is satisfied. - Published bundles never include it. A game that lazy-imports it behind a dev flag works in preview and quietly no-ops in production.
- Local development: download the same bytes from
portals.to/portals-sdk/pose-tuner.js
into your project's
_portals/dev/directory. Files under_portals/are managed by Portals and never upload, so the local copy cannot drift into your bundle.
// Behind a flag only you use — absent in production, so the import rejects
// and the game plays on untouched.
if (new URLSearchParams(location.search).has('poseTuner')) {
try {
const { createPoseTuner } = await import('./_portals/dev/pose-tuner.js');
tuner = createPoseTuner({ title: 'My game', storageKey: 'my-game-tuner' });
} catch {
/* published build — no dev tools */
}
}
Why a panel instead of an animation editor
A procedural pose is a pure function of numbers (carry tables, grip offsets, recoil shares) plus inputs (pitch, kick, flags). No authored clip can be "the" gun-holding animation, because the hold has to survive things clips cannot know: camera pitch, strafe direction, recoil, scope state. So the tool binds the live objects your frame loop already reads — a slider mutates the table in place and the very next frame shows the result — and forces the inputs so the failure states are one click instead of a play session.
Wiring
Three touch points, each a no-op when the tuner is off:
// 1. Bind the live tables your pose code reads (nested objects and numeric
// arrays are walked; per-key ranges optional).
tuner.table('HEAVY_CARRY', HEAVY_CARRY, { tuck: { min: -1, max: 1 } });
// 2. Name forced inputs after your pose function's argument keys, then merge
// them over the real inputs each frame.
tuner.control('pitch', { min: -1.35, max: 1.35, step: 0.01 });
tuner.control('scoped', { type: 'toggle' });
aimWeapon(avatar, tuner.apply({ pose, lookYaw, pitch, kick, scoped }));
// 3. Route the frame delta through pause / single-step / slow-mo.
const dt = tuner.dt(rawDt);
// 4. Optional: the freecam — WASD flies, Q/E sink/rise, right-drag looks,
// wheel scales speed, Shift ×3. Flies on its own clock, so it keeps moving
// while the game is paused. `onToggle` is where the game stands its own
// movement down (the controller still hears WASD otherwise); update() runs
// LAST in the frame, after every game-side camera write, and wins by going
// last. No-op while off.
const freecam = tuner.attachFreecam({
THREE, camera, domElement: renderer.domElement,
onToggle: (on) => controller.setEnabled(!on),
});
freecam.update(); // right before renderer.render(...)
Editing bones and weapons in 3D
attachBoneEditor adds direct manipulation: pick a stance (activation goes
through your game's real code paths), then click a joint and drag a
local-space gizmo. Edit mode grows a clickable sphere on every bone
(green on registered targets, orange on the selection) so the pick targets
are visible, and the skeleton overlay shows by default. Pass defaultOn: true
to start with edit mode already enabled — note the canvas then belongs to the
editor from the first frame, so give the game another way into pointer lock
(an action button calling requestPointerLock()). A button switches the
gizmo between rotation and position; holding Shift hides it so
you can click straight through to the next joint; right-drag orbits and the
wheel zooms via callbacks you supply.
tuner.attachBoneEditor({
THREE, scene, camera, domElement: renderer.domElement,
getBones: () => avatar.bones,
poses: [
{ id: 'hipPose', label: 'hip', activate: () => holdHipStance() },
{ id: 'adsPose', label: 'ads', activate: () => setAds(true) },
],
loadTransformControls: () =>
import('three/addons/controls/TransformControls.js').then((m) => m.TransformControls),
});
three/addons/controls/TransformControls.js is part of the managed Three.js
runtime, so the import resolves in hosted previews and in local projects that
use the managed import map. Without it the editor falls back to sliders.
Bone rotations are stored per pose as Euler XYZ radians and exported as a
boneAdjustmap — exactly the shapeavatar.animations.loadtakes at clip registration, and applied with the same math, so what you see while dragging is what the baked clip will look like.Bone positions are local offsets over the clip, exported separately (
boneAdjustcannot bake positions — apply them at runtime).Non-bone targets — a held weapon's mount, a prop — register as
targets: the gizmo attaches in translate or rotate mode and every drag calls yourfold(object, poseId, mode)to convert the dragged transform back into whatever live table your game poses it from. If the target's object is replaced mid-session (a weapon swap builds a new mount), the editor re-resolvesgetObject()and re-attaches on its own.One trap:
foldreceives the panel's selected pose, but a table your game keys by its own live state must resolve that key itself and ignore the argument. A two-handed weapon is in its heavy stance whatever the pose row says — folding into the selected pose writes a row the game is not reading, so the drag snaps back next frame and looks dead while the wrong row quietly accumulates edits. On Guardian SDK 0.44.0+,gunMount(avatar)is the ready-made gun target (holder, rest position, rest rotation), andaimWeapon'snudge/tilttables are where the folds land.
Run the editor's per-frame pass in the post-mixer slot. On Guardian SDK 0.42.0 and later, register it once and the ordering is the SDK's promise:
avatars.onAfterUpdate(() => tuner.boneEditor?.update());
On older pinned versions, call it in your frame loop directly after
avatars.update(dt) and before your own bone writers (procedural aim, IK,
ragdolls) — anything that writes bones before the mixer pass is silently
overwritten.
The panel
| Section | What it does |
|---|---|
| Force state | Sliders/toggles that override pose inputs; one-click corner-state snapshots (pitch extremes, strafe diagonals, scoped raises) and release all. |
| Time | Pause, single-step, slow-motion — via tuner.dt(). |
| Actions | Buttons wired to your game's real paths (fire once, ADS on/off, cycle weapon). |
| Tables | One collapsible section per bound table, per-row reset dots, persisted across reloads under storageKey. |
| Bones | Pose buttons, bone/target picker, gizmo mode and space buttons (rotate/translate; drag in the bone's local axes or the world's — the stored edit is local either way, so boneAdjust exports do not care), rot/pos rows, boneAdjust export. |
| Inspect | Skeleton overlay drawn through the mesh. |
| Camera | The freecam toggle — a detached fly camera on its own clock, for judging a pose from angles gameplay never gives you (and for walking around a frame the Time section holds frozen). |
| Undo / redo | ↶/↷ in the header, plus Ctrl/⌘ Z and Ctrl/⌘ ⇧Z. Covers everything that edits data — table sliders, bone edits, gizmo drags (one entry per drag), resets. Forced inputs are session state and stay out. |
| Copy changes / Reset all | Export a TABLE.key = value; diff of everything that differs from the code's values; restore the code's values and clear persistence — bone edits included (they persist under their own storageKey:bones entry, and a reset that left them behind kept re-posing bones out of stale storage). |
Export diffs are always measured against what the code held at bind time — persisted slider edits from a previous session still show as changed, so nothing tuned can silently fail to make it back into your source.
Caveats
- Keystrokes inside the panel are kept from the game where possible, but a capture-phase listener registered before the tuner mounts (the Guardian controller's input is one) still sees them first. Pause time while typing in a number field, or use the sliders.
tuner.dt()pausing stops what you step with that delta; eases timed off wall-clock keep moving. Force their inputs instead (that is what akickcontrol is for).- Bones a later procedural pass rewrites (an IK'd arm, an aim-bent spine) show that pass, not your edit — tune those through their tables, or edit them under a stance the pass leaves alone.
