Skip to content

Commit 73859da

Browse files
authored
feat: BasisAuthoredMotion — batched authored-motion driver for cosmetic/secondary motion (#832)
## Summary Replaces per-avatar cosmetic/secondary Animators with a batched, Burst-compiled authored-motion system. A single cosmetic Unity Animator on the reference avatar measured at roughly **1/5 of scene frame time at 1000 CCU** — almost entirely fixed per-instance Animator overhead, paid on every replicated copy. This moves that motion into one job over all avatars. - **`BasisAuthoredMotion`** (SDK, data-only): a Content-Police-allowed component holding a list of reusable `Movement`s — no per-instance `Update`. - **`BasisAuthoredMotionSystem`** (framework): a static orchestrator + one Burst `IJobParallelForTransform` over every registered avatar's driven transforms, a sibling to `RemoteBoneJobSystem`. Registered at local/remote calibration; pumped from `BasisEventDriver.LateUpdate` immediately before the jiggle pass, so authored motion is the animated base and jiggle layers on top. - **Six movement kinds:** Oscillate (sine/triangle/square/pulse, incl. chain travelling waves), Rotate, Orbit, Noise, RandomSelect (weighted random poses, multi-target + idle), Sequence (baked-clip playback). - **Authoring:** a per-Kind custom inspector (shows only the relevant fields, localized tooltips) and `BasisMotionClipBaker`, an editor window that bakes an `AnimationClip` into a shared `BasisMotionClip` for Sequence. It drives only non-humanoid transforms (tails, ears, accessories) that the networked skeleton and IK don't touch, so there's no write contention with the bone pipeline. ## Required checks All boxes below must be ticked before this PR can merge. If a check is genuinely N/A, tick it anyway and explain under **Notes**. <!-- required-checks-start --> <!-- Tick the boxes in place — do not edit the line text. The pr-checklist workflow parses this block; per-PR context goes under Notes. --> - [x] **Tested** — I built and ran this locally. The change works in the editor and (where relevant) in a built player. - [x] **Transform access is combined and limited** — In hot paths, transform reads/writes go through `TransformAccessArray` or are otherwise batched. I have not added per-frame `transform.position` / `transform.rotation` / `transform.localPosition` calls inside loops. Whenever I need both position and rotation, I use the combined APIs — `SetPositionAndRotation` / `SetLocalPositionAndRotation` for writes, `GetPositionAndRotation` / `GetLocalPositionAndRotation` for reads — instead of two separate property accesses; the combined call does one local-to-world matrix traversal instead of two. - [x] **Addressables used for asset/memory loading** — Any new asset loads go through Addressables. No new `Resources.Load`, no direct asset references that pull large content into memory on scene load. - [x] **No new `GetComponent` / `AddComponent` where avoidable** — Where unavoidable, the result is cached on a field, and any `GetComponent<T>` is replaced with `TryGetComponent<T>(out var x)` — bare `GetComponent` will be denied. `TryGetComponent` is the modern API (Unity 2019.2+) and skips the Editor-only GC allocation `GetComponent` causes when a component is missing: Unity wraps the `null` return in a managed "fake null" object so its overloaded `==` operator can still detect destroyed C++ objects, and constructing that wrapper allocates; `TryGetComponent` returns a `bool` plus `out` parameter and never builds the wrapper. None of these calls run inside `Update`, `LateUpdate`, `FixedUpdate`, jobs, or other per-frame code paths. - [x] **Per-frame work is scheduled through `BasisEventDriver`** — Any new per-frame work hooks into `BasisEventDriver` rather than adding standalone `Update` / `LateUpdate` / `FixedUpdate` callbacks on a MonoBehaviour. - [x] **Considered jobification** — I asked whether this work can be moved to a Unity Job (Burst-compiled where possible). If it can, it is. If it cannot, the reason is in **Notes**. - [x] **No needless `{ get; set; }` properties or access lockdowns** — Public fields are fine; Basis is meant to be read and modified freely, so don't wall things off `private`/`internal` without a real reason. Don't wrap a field in `{ get; set; }` when the accessors do nothing — property accessors have a real performance cost vs direct field access, and the lead maintainer prefers plain fields (or a method / setter-only property when only the setter needs logic) over a noop-getter pair. For `.Instance` singletons, callers reassigning `Type.Instance` is allowed; if that would break your code, log a warning or throw — don't block the assignment. Locking down access is not your call. - [x] **Camera access goes through `BasisLocalCameraDriver`** — Code that needs the local camera (transform, projection, rig data, etc.) pulls it from `BasisLocalCameraDriver` rather than looking one up itself. Don't roll a separate camera discovery path. - [x] **Logging uses `BasisDebug`** — All new logging calls go through `BasisDebug.Log` / `BasisDebug.LogWarning` / `BasisDebug.LogError` (with an appropriate `LogTag`) instead of `UnityEngine.Debug.Log` / `Debug.LogWarning` / `Debug.LogError`. `BasisDebug` routes through Basis's tagged, color-coded logger and respects the project-wide `LoggingDisabled` toggle so logging can be killed at runtime; bare `Debug.Log` calls bypass that and will be denied. - [x] **No scene-wide discovery for dependencies** — New code is architected so it does not need `FindObjectOfType` / `FindObjectsOfType` / `GameObject.Find` / `FindGameObjectsWithTag` to locate what it depends on. References are wired in — registered through an existing manager/driver, injected at init, or passed in by the caller — rather than discovered by scanning the scene at runtime. If a scene scan is genuinely unavoidable, justify it under **Notes**. - [x] **No allocations in hot paths** — Per-frame code (Update / LateUpdate / FixedUpdate, simulation loops, jobs, anything called once per frame or more) does not allocate. No `new` on reference types, no LINQ, no `string` concatenation/interpolation, no boxing, no `foreach` over interface-typed collections. Allocate once at init and reuse the buffer. - [x] **No debugging in hot paths** — No log calls of any kind on per-frame paths, including `BasisDebug`. Hot-path logging floods the console and incurs cost on every frame regardless of whether the message is filtered out downstream. If a hot-path log is needed while iterating, gate it behind `#if UNITY_EDITOR` and remove (or leave gated) before merge. - [x] **Hot-path collection access is optimized** — Cache `.Count` (lists) / `.Length` (arrays) into a local `int` before the loop instead of re-reading the property each iteration. Prefer `T[]` (with a separate length int when the array is over-sized) over `List<T>` where the data is hot — Unity's mono BCL doesn't expose `CollectionsMarshal.AsSpan(List<T>)`, so a list can't be fed into `Span<T>` / unsafe paths cleanly. Where the perf justifies it, drop into `Span<T>` / `ref` locals / `Unsafe.As` / `unsafe` pointer code to skip bounds checks and copies, and call out the invariants you're relying on under **Notes** so reviewers can sanity-check them. <!-- required-checks-end --> ## Testing details Tick the platforms you actually tested on. Leave the rest unticked — these are informational and do not block merge. - [x] Windows - [ ] Linux - [ ] Android - [ ] iOS - [ ] macOS Input / control mode coverage: - [ ] Tested in VR (note headset under **Notes**) - [x] Tested in desktop / non-VR mode - [ ] Tested with phone controls (mobile touch input) - [ ] N/A — change does not touch player/XR/input code Where applicable, confirm these flows still work after your changes: - [ ] Hot-switching (desktop ↔ VR mode swap at runtime) - [ ] Avatar swapping - [ ] Server swapping (joining / leaving / changing servers) - [ ] N/A — change does not touch any of the above ## Notes - **Draft because the perf acceptance bar isn't met yet.** Validated functionally in-editor (SDK *Test In Editor*) on the reference avatar — tail wag (baked Sequence) with jiggle layering on top, ear twitch (multi-target RandomSelect), Oscillate and Rotate. The **1000-CCU load test** (the whole point — beating the ~1/5-frame-time Animator baseline) and **built-player** runs are still outstanding. - **Addressables / Camera boxes are N/A** — the feature loads no assets at runtime and uses no camera. - **Component lookup** is a single `GetComponentsInChildren<BasisAuthoredMotion>` at calibration (gathering all components on the avatar), never per-frame; no bare `GetComponent`. - **Allocations** — `Build`/`Rebuild` allocate (managed lists, flattening config into the SoA) but run only on a structural change at calibration; the per-frame `Schedule` and the Burst job are allocation-free. - **`transform.Find`** — `Sequence` binds a baked clip's transform paths to bones via `root.Find(path)` **once at registration**, cached into the `TransformAccessArray` (never per-frame), mirroring how an `AnimationClip` binds its curves by path. It isn't in checkbox 10's denial list, but flagging it since STYLE.md mentions `transform.Find`: this is a scoped, one-shot bind, not scene-wide discovery. - **Determinism / follow-up** — `RandomSelect` picks and `Sequence` playheads currently derive from `Time.timeAsDouble`. A shared/networked clock is needed for bit-identical playback across clients before multiplayer; tracked as a follow-up. `RandomSelect.preventRepeats` is serialized but not yet honored by the deterministic picker. - **Critical-flow boxes left unticked**: avatar-swap re-registration is wired through the calibration hooks but not yet stress-tested. ## Usage **On an avatar:** add a `BasisAuthoredMotion` component (Content-Police-allowed) and one or more **Movements**, each with a **Kind**: - **Oscillate** — periodic sway on a bone chain (`axis`, `amplitude`, `frequencyHz`, `waveform`; one chain entry = a simple sway, multiple = a travelling wave down the chain). - **Rotate** — constant spin in place (`target`, `axis`, `speedDeg` in deg/sec). - **Orbit** — revolve a `target` around a `pivot` at a `radius`. - **Noise** — organic simplex drift on a channel. - **RandomSelect** — on a fixed interval (`intervalRange.x`), pick one weighted pose `Option`; each option can drive its own bone (or fall back to `selectTarget`), with an `idleWeight` for the "do nothing" outcome. Good for ear flicks / blinks. - **Sequence** — play a baked clip (below). **Baking a Sequence clip:** `Basis ▸ Authored Motion ▸ Bake Clip` → choose the source `AnimationClip` and the avatar root → it writes a shared `BasisMotionClip`. Assign that to the Sequence movement's **Baked Clip** and set **Sequence Root** to the same root the paths were baked against. **Toggling:** a movement group rides the component's own `enabled` — any toggle system that flips `Behaviour.enabled` (e.g. an HVR.Vixxy activation) turns it on/off, with no per-frame polling. **Jiggle:** authored motion writes before the jiggle pass each frame, so `JiggleRig` physics layers on top of it automatically.
2 parents 0b531d3 + cbb5e69 commit 73859da

19 files changed

Lines changed: 1168 additions & 0 deletions

File tree

Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ public void OnDestroy()
119119
BasisObjectSyncDriver.OnDestroy();
120120
Application.onBeforeRender -= OnBeforeRender;
121121
RemoteBoneJobSystem.Dispose();
122+
BasisAuthoredMotionSystem.Dispose();
122123
BasisAvatarBufferPool.Deinitialize();
123124
}
124125

@@ -310,6 +311,9 @@ public void LateUpdate()
310311
}
311312
ProfileEnd(PROF_BLENDSHAPE_APPLY);
312313

314+
// ── Authored motion: write non-humanoid authored bones before jiggle samples them ──
315+
BasisAuthoredMotionSystem.Complete(BasisAuthoredMotionSystem.Schedule());
316+
313317
// ── JigglePhysics schedule ──
314318
ProfileBegin(PROF_JIGGLE_SCHEDULE);
315319

Basis/Packages/com.basis.framework/Drivers/AuthoredMotion.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs

Lines changed: 632 additions & 0 deletions
Large diffs are not rendered by default.

Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ public void InitialLocalCalibration(BasisLocalPlayer player, List<BasisHeadChop.
149149
Rig.OnInitialize();
150150
}
151151

152+
// Register authored motion (drives non-humanoid transforms IK doesn't touch); rest captured at the current TPose.
153+
var authoredMotions = player.BasisAvatar.GetComponentsInChildren<BasisAuthoredMotion>(true);
154+
for (int i = 0; i < authoredMotions.Length; i++)
155+
{
156+
BasisAuthoredMotionSystem.Register(authoredMotions[i]);
157+
}
158+
152159
player.LocalRigDriver.Builder = BasisHelpers.GetOrAddComponent<RigBuilder>(AvatarAnimatorParent);
153160
player.LocalRigDriver.Builder.enabled = false;
154161

Basis/Packages/com.basis.framework/Drivers/Remote/BasisRemoteAvatarDriver.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ public void RemoteCalibration(BasisRemotePlayer RemotePlayer)
138138
Rig.OnInitialize();
139139
}
140140

141+
// Register authored motion (drives non-humanoid transforms the bone job / IK don't touch); rest captured at the current TPose.
142+
var authoredMotions = RemotePlayer.BasisAvatar.GetComponentsInChildren<BasisAuthoredMotion>(true);
143+
for (int i = 0; i < authoredMotions.Length; i++)
144+
{
145+
BasisAuthoredMotionSystem.Register(authoredMotions[i]);
146+
}
147+
141148
// Face visibility setup
142149
Player.FaceIsVisible = false;
143150
if (RemotePlayer.BasisAvatar == null)

Basis/Packages/com.basis.framework/Players/Remote/BasisRemotePlayer.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,17 @@ public void OnDestroy()
529529

530530
OnRemotePlayerDestroying?.Invoke();
531531

532+
// Unregister authored motion while the avatar transforms are still alive, so the
533+
// TransformAccessArray entries drop cleanly before Unity destroys them.
534+
if (BasisAvatar != null)
535+
{
536+
var authoredMotions = BasisAvatar.GetComponentsInChildren<BasisAuthoredMotion>(true);
537+
for (int i = 0; i < authoredMotions.Length; i++)
538+
{
539+
BasisAuthoredMotionSystem.Unregister(authoredMotions[i]);
540+
}
541+
}
542+
532543
RemoveFromBoneDriver();
533544
}
534545

Basis/Packages/com.basis.sdk/Localization/Languages/en.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,75 @@
193193
{ "key": "sdk.parameterDriver.field.destMin", "value": "Dest Min" },
194194
{ "key": "sdk.parameterDriver.field.destMax", "value": "Dest Max" },
195195

196+
{ "key": "sdk.authoredMotion.movements.header", "value": "Movements ({0})" },
197+
{ "key": "sdk.authoredMotion.movements.empty", "value": "No movements — click '+ Add Movement' below." },
198+
{ "key": "sdk.authoredMotion.movements.add", "value": "+ Add Movement" },
199+
{ "key": "sdk.authoredMotion.label.placeholder", "value": "<movement>" },
200+
{ "key": "sdk.authoredMotion.field.kind.label", "value": "Kind" },
201+
{ "key": "sdk.authoredMotion.field.kind.tooltip", "value": "Which authored-motion primitive this entry drives. The fields below change to match the kind." },
202+
{ "key": "sdk.authoredMotion.field.label.label", "value": "Label" },
203+
{ "key": "sdk.authoredMotion.field.label.tooltip", "value": "Author-facing identifier only; has no runtime effect." },
204+
{ "key": "sdk.authoredMotion.field.enabled.label", "value": "Enabled" },
205+
{ "key": "sdk.authoredMotion.field.enabled.tooltip", "value": "Author default for this movement. The runtime on/off toggle rides the component's own enabled state." },
206+
{ "key": "sdk.authoredMotion.field.axis.label", "value": "Axis" },
207+
{ "key": "sdk.authoredMotion.field.axis.tooltip", "value": "Local axis the movement acts about." },
208+
{ "key": "sdk.authoredMotion.field.channel.label", "value": "Channel" },
209+
{ "key": "sdk.authoredMotion.field.channel.tooltip", "value": "What the value drives — Rotation (degrees), Position (metres) or Scale (factor)." },
210+
{ "key": "sdk.authoredMotion.field.waveform.label", "value": "Waveform" },
211+
{ "key": "sdk.authoredMotion.field.waveform.tooltip", "value": "Oscillation shape: Sine, Triangle, Square or Pulse." },
212+
{ "key": "sdk.authoredMotion.field.pulseWidth.label", "value": "Pulse Width" },
213+
{ "key": "sdk.authoredMotion.field.pulseWidth.tooltip", "value": "Duty cycle (0–1) for the Square and Pulse waveforms." },
214+
{ "key": "sdk.authoredMotion.field.amplitude.label", "value": "Amplitude" },
215+
{ "key": "sdk.authoredMotion.field.amplitude.tooltip", "value": "Peak deviation from rest. Units follow Channel: degrees, metres or scale-factor." },
216+
{ "key": "sdk.authoredMotion.field.frequencyHz.label", "value": "Frequency (Hz)" },
217+
{ "key": "sdk.authoredMotion.field.frequencyHz.tooltip", "value": "Cycles per second." },
218+
{ "key": "sdk.authoredMotion.field.phase.label", "value": "Phase" },
219+
{ "key": "sdk.authoredMotion.field.phase.tooltip", "value": "Starting phase offset, in radians." },
220+
{ "key": "sdk.authoredMotion.field.chain.label", "value": "Chain" },
221+
{ "key": "sdk.authoredMotion.field.chain.tooltip", "value": "Transforms this movement drives. One entry is a simple sway on a single bone; multiple entries form a travelling wave down the chain. Oscillate and Noise are driven by this list — not by Target." },
222+
{ "key": "sdk.authoredMotion.field.chainPhaseStep.label", "value": "Chain Phase Step" },
223+
{ "key": "sdk.authoredMotion.field.chainPhaseStep.tooltip", "value": "Phase delay (radians) added per element down the chain — produces the travelling-wave look." },
224+
{ "key": "sdk.authoredMotion.field.chainFalloff.label", "value": "Chain Falloff" },
225+
{ "key": "sdk.authoredMotion.field.chainFalloff.tooltip", "value": "Amplitude multiplier applied per element down the chain (1 = no falloff)." },
226+
{ "key": "sdk.authoredMotion.field.target.label", "value": "Target" },
227+
{ "key": "sdk.authoredMotion.field.target.tooltip", "value": "Transform to drive. Used by Rotate and Orbit; Oscillate and Noise use Chain instead." },
228+
{ "key": "sdk.authoredMotion.field.speedDeg.label", "value": "Speed (deg/sec)" },
229+
{ "key": "sdk.authoredMotion.field.speedDeg.tooltip", "value": "Constant angular velocity about Axis, in degrees per second." },
230+
{ "key": "sdk.authoredMotion.field.pivot.label", "value": "Pivot" },
231+
{ "key": "sdk.authoredMotion.field.pivot.tooltip", "value": "Point the Target revolves around. Defaults to the Target's own position when unset." },
232+
{ "key": "sdk.authoredMotion.field.radius.label", "value": "Radius" },
233+
{ "key": "sdk.authoredMotion.field.radius.tooltip", "value": "Distance from the pivot, in metres." },
234+
{ "key": "sdk.authoredMotion.field.orbitSpeedDeg.label", "value": "Orbit Speed (deg/sec)" },
235+
{ "key": "sdk.authoredMotion.field.orbitSpeedDeg.tooltip", "value": "Revolution speed around the pivot, in degrees per second." },
236+
{ "key": "sdk.authoredMotion.field.selectTarget.label", "value": "Select Target" },
237+
{ "key": "sdk.authoredMotion.field.selectTarget.tooltip", "value": "Default target for options that don't set their own. Lets one component pose a single bone several ways, while options that name their own target can each drive a different bone." },
238+
{ "key": "sdk.authoredMotion.field.options.label", "value": "Options" },
239+
{ "key": "sdk.authoredMotion.field.options.tooltip", "value": "Weighted poses to pick between. Each option may target its own bone (falling back to Select Target) and is chosen in proportion to its weight." },
240+
{ "key": "sdk.authoredMotion.field.idleWeight.label", "value": "Idle Weight" },
241+
{ "key": "sdk.authoredMotion.field.idleWeight.tooltip", "value": "Relative weight of the 'pose nothing' outcome. Larger values make a rest cycle (all targets returning to rest) more likely than any single option." },
242+
{ "key": "sdk.authoredMotion.field.intervalRange.label", "value": "Interval Range" },
243+
{ "key": "sdk.authoredMotion.field.intervalRange.tooltip", "value": "Time between picks. The X value sets the fixed period in seconds; Y is reserved." },
244+
{ "key": "sdk.authoredMotion.field.attack.label", "value": "Attack" },
245+
{ "key": "sdk.authoredMotion.field.attack.tooltip", "value": "Ease-in time toward a newly chosen pose, in seconds." },
246+
{ "key": "sdk.authoredMotion.field.release.label", "value": "Release" },
247+
{ "key": "sdk.authoredMotion.field.release.tooltip", "value": "Ease-out time when a target returns to rest, in seconds." },
248+
{ "key": "sdk.authoredMotion.field.preventRepeats.label", "value": "Prevent Repeats" },
249+
{ "key": "sdk.authoredMotion.field.preventRepeats.tooltip", "value": "Avoid picking the same option twice in a row. Not yet honored by the deterministic picker." },
250+
{ "key": "sdk.authoredMotion.field.seed.label", "value": "Seed" },
251+
{ "key": "sdk.authoredMotion.field.seed.tooltip", "value": "Random seed for Noise and RandomSelect. 0 derives one from the registration index." },
252+
{ "key": "sdk.authoredMotion.field.noiseSpeed.label", "value": "Noise Speed" },
253+
{ "key": "sdk.authoredMotion.field.noiseSpeed.tooltip", "value": "How fast the simplex-noise field is sampled." },
254+
{ "key": "sdk.authoredMotion.field.sequenceTarget.label", "value": "Sequence Target" },
255+
{ "key": "sdk.authoredMotion.field.sequenceTarget.tooltip", "value": "Transform the timeline drives." },
256+
{ "key": "sdk.authoredMotion.field.sequenceRoot.label", "value": "Sequence Root" },
257+
{ "key": "sdk.authoredMotion.field.sequenceRoot.tooltip", "value": "The baked clip's transform paths resolve under this root (e.g. the avatar root the clip was authored against). Defaults to this component's transform when unset." },
258+
{ "key": "sdk.authoredMotion.field.bakedClip.label", "value": "Baked Clip" },
259+
{ "key": "sdk.authoredMotion.field.bakedClip.tooltip", "value": "Shared, read-only baked-curve asset produced by the clip baker. Drives every bone the source AnimationClip animated." },
260+
{ "key": "sdk.authoredMotion.field.keyframes.label", "value": "Keyframes" },
261+
{ "key": "sdk.authoredMotion.field.keyframes.tooltip", "value": "Inline pose-delta timeline for short motion. Ignored when a Baked Clip is assigned." },
262+
{ "key": "sdk.authoredMotion.field.loop.label", "value": "Loop" },
263+
{ "key": "sdk.authoredMotion.field.loop.tooltip", "value": "Loop the sequence, or play it once." },
264+
196265
{ "key": "sdk.buildReport.window.title", "value": "Basis Build Report Viewer" },
197266
{ "key": "sdk.buildReport.window.tabTitle", "value": "Basis Bundle Report" },
198267
{ "key": "sdk.buildReport.noDirectory", "value": "No build reports directory found." },

Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public static string GetTagColor(LogTag logTag)
9292
LogTag.Shims => "#FF00FF", // Magenta
9393
LogTag.Props => "#FFB6C1", // Light Pink
9494
LogTag.LocalNetwork => "#ff0055",
95+
LogTag.AuthoredMotion => "#BA55D3", // Medium Orchid
9596
_ => "#FFFFFF" // Default White
9697
};
9798
}
@@ -141,6 +142,7 @@ public enum LogTag
141142
Shims,
142143
Props,
143144
LocalNetwork,
145+
AuthoredMotion,
144146
}
145147

146148
public enum MessageType
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using System;
2+
using UnityEngine;
3+
4+
/// <summary>
5+
/// Data-only avatar component declaring authored, deterministic dynamic motion on transforms
6+
/// the humanoid rig and IK don't drive (tail/ear chains, accessories, etc.). It holds pure
7+
/// serialized configuration and runs no per-instance per-frame <c>Update</c> — all runtime
8+
/// evaluation happens in the batched <c>BasisAuthoredMotionSystem</c> job, which reads this
9+
/// component at calibration. The config model mirrors <see cref="BasisParameterDriver"/>'s
10+
/// <c>Operation[]</c> shape (an enum kind + per-kind fields).
11+
///
12+
/// Allow it onto an avatar by adding its type to the Content Police
13+
/// (<c>ContentPoliceSelector.selectedTypes</c> in <c>AvatarContentPoliceSelector.asset</c>).
14+
/// Group movements that toggle together into one component; an avatar may carry several.
15+
/// </summary>
16+
public class BasisAuthoredMotion : MonoBehaviour
17+
{
18+
/// <summary>
19+
/// Raised on enable/disable so a registered motion system can flip this component's slice
20+
/// of its valid-mask without a per-frame poll. The system subscribes at registration; the
21+
/// component holds no reference to any toggle package, so any actuator that flips
22+
/// <see cref="Behaviour.enabled"/> (e.g. an HVR.Vixxy activation) drives it unchanged.
23+
/// </summary>
24+
public event Action<BasisAuthoredMotion, bool> EnabledStateChanged;
25+
26+
public Movement[] movements = Array.Empty<Movement>();
27+
28+
private void OnEnable() => EnabledStateChanged?.Invoke(this, true);
29+
private void OnDisable() => EnabledStateChanged?.Invoke(this, false);
30+
31+
[Serializable]
32+
public class Movement
33+
{
34+
// Open, extensible set — new kinds slot in without disturbing registration / scheduling / toggles.
35+
public enum Kind { Oscillate, Rotate, Orbit, RandomSelect, Sequence, Noise }
36+
public enum Channel { Rotation, Position, Scale } // what Oscillate / Noise drive
37+
public enum Waveform { Sine, Triangle, Square, Pulse }
38+
39+
public Kind kind = Kind.Oscillate;
40+
public string label; // author-facing identifier only
41+
public bool enabled = true; // author default; runtime toggle rides the component's own enabled
42+
public Vector3 axis = Vector3.up; // local axis the movement acts about
43+
44+
// Oscillate — periodic motion on `channel`; a chain makes a travelling wave (1 entry = simple sway).
45+
public Channel channel = Channel.Rotation; // amplitude unit: deg | metres | scale-factor
46+
public Waveform waveform = Waveform.Sine;
47+
public float pulseWidth = 0.5f; // square/pulse duty cycle (0–1)
48+
public Transform[] chain;
49+
public float amplitude = 15f;
50+
public float frequencyHz = 0.5f;
51+
public float phase = 0f;
52+
public float chainPhaseStep = 0f; // phase delay per element down the chain
53+
public float chainFalloff = 1f; // amplitude scale per element down the chain
54+
55+
// Rotate — constant angular velocity about `axis`, in place.
56+
public Transform target;
57+
public float speedDeg = 36f; // deg/sec
58+
59+
// Orbit — revolve `target` around `pivot` at `radius` (not a spin-in-place).
60+
public Transform pivot;
61+
public float radius = 0.1f;
62+
public float orbitSpeedDeg = 90f; // deg/sec around the pivot
63+
64+
// RandomSelect — every `intervalRange.x` seconds, deterministically pick one weighted option (or idle)
65+
// and ease the target in/out. Each Option may set its own `target`, else falls back to `selectTarget`.
66+
public Transform selectTarget; // default target for options that leave their own target null
67+
public Option[] options = Array.Empty<Option>();
68+
public float idleWeight = 0f; // relative weight of the "pose nothing" outcome
69+
public Vector2 intervalRange = new Vector2(2f, 6f); // x = fixed period (seconds between picks)
70+
public float attack = 0.06f, release = 0.25f; // ease in / out seconds
71+
public bool preventRepeats = true;
72+
public uint seed = 0; // 0 = derive from registration index
73+
74+
// Sequence — authored timeline, loop or one-shot. A baked clip drives many bones via paths under
75+
// `sequenceRoot`; inline keyframes (deferred) use `sequenceTarget`.
76+
public Transform sequenceTarget; // single-bone inline-keyframe target (inline path; deferred)
77+
public Transform sequenceRoot; // baked-clip paths resolve under this (defaults to the avatar root)
78+
public Keyframe[] keyframes = Array.Empty<Keyframe>();
79+
public BasisMotionClip bakedClip; // shared baked curves; null when using inline keyframes
80+
public bool loop = true;
81+
82+
// Noise — simplex drift on `channel` about `axis`; reuses amplitude / chain / chainFalloff / seed; `noiseSpeed` = sample rate.
83+
public float noiseSpeed = 0.5f;
84+
}
85+
86+
[Serializable]
87+
public class Option
88+
{
89+
[Tooltip("Transform this option poses. Falls back to the movement's Select Target when null.")]
90+
public Transform target;
91+
[Tooltip("Local axis to rotate about.")]
92+
public Vector3 axis = Vector3.up;
93+
[Tooltip("Rotation applied about Axis when this option is selected, in degrees.")]
94+
public float angleDeg;
95+
[Tooltip("Relative likelihood this option is picked.")]
96+
public float weight = 1f;
97+
}
98+
99+
[Serializable]
100+
public class Keyframe
101+
{
102+
[Tooltip("Time of this key, in seconds from the start of the sequence.")]
103+
public float time;
104+
[Tooltip("Rotation delta from rest at this key, in euler degrees.")]
105+
public Vector3 eulerDelta;
106+
[Tooltip("Local position delta from rest at this key.")]
107+
public Vector3 positionDelta;
108+
[Tooltip("Local scale delta from rest at this key.")]
109+
public Vector3 scaleDelta;
110+
}
111+
}

0 commit comments

Comments
 (0)