diff --git a/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs b/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs
index 8888ad1dc9..8e0d896d1d 100644
--- a/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs
+++ b/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs
@@ -119,6 +119,7 @@ public void OnDestroy()
BasisObjectSyncDriver.OnDestroy();
Application.onBeforeRender -= OnBeforeRender;
RemoteBoneJobSystem.Dispose();
+ BasisAuthoredMotionSystem.Dispose();
BasisAvatarBufferPool.Deinitialize();
}
@@ -310,6 +311,9 @@ public void LateUpdate()
}
ProfileEnd(PROF_BLENDSHAPE_APPLY);
+ // ── Authored motion: write non-humanoid authored bones before jiggle samples them ──
+ BasisAuthoredMotionSystem.Complete(BasisAuthoredMotionSystem.Schedule());
+
// ── JigglePhysics schedule ──
ProfileBegin(PROF_JIGGLE_SCHEDULE);
diff --git a/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion.meta b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion.meta
new file mode 100644
index 0000000000..12abc5e23f
--- /dev/null
+++ b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9cd51cc56e1f6104fac61b892a4f4666
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs
new file mode 100644
index 0000000000..f696371571
--- /dev/null
+++ b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs
@@ -0,0 +1,632 @@
+using System.Collections.Generic;
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+using UnityEngine.Jobs;
+using Movement = BasisAuthoredMotion.Movement;
+using Kind = BasisAuthoredMotion.Movement.Kind;
+using Channel = BasisAuthoredMotion.Movement.Channel;
+using Waveform = BasisAuthoredMotion.Movement.Waveform;
+
+///
+/// Blittable, flattened plus the captured rest pose.
+/// One slot per driven transform — a chain element is its own slot, with its element phase /
+/// falloff baked in at registration. Parallel to the system's TransformAccessArray.
+///
+public struct AuthoredMovementData
+{
+ public int kind; // (int)Movement.Kind
+ public int channel; // (int)Movement.Channel
+ public int waveform; // (int)Movement.Waveform
+
+ public float3 axis;
+ public float amplitude;
+ public float frequencyHz;
+ public float phase;
+ public float pulseWidth;
+
+ public float speedDeg; // Rotate
+ public float radius; // Orbit
+ public float orbitSpeedDeg; // Orbit
+ public float3 pivotLocal; // Orbit pivot, in the target's parent space
+ public float noiseSpeed; // Noise
+ public uint seed; // Noise / RandomSelect
+
+ // RandomSelect — selection is deterministic per cycle, so each slot evaluates independently.
+ public float period; // seconds per pick cycle
+ public float totalWeight; // sum of option weights + idle weight
+ public float attack; // ease-in seconds
+ public float release; // ease-out seconds
+ public int optionStart; // first option index in the shared options buffer
+ public int optionCount; // options targeting this slot
+
+ // Sequence — a playhead over a shared baked-sample buffer; rotation written absolutely.
+ public int sampleStart; // first frame index in the shared rotation-sample buffer
+ public int frameCount; // frames for this slot's transform
+ public float frameRate; // samples per second
+ public int loop; // 0 = one-shot, 1 = loop
+
+ // Captured rest pose (local space); motion composes as a delta from this.
+ public quaternion restRotation;
+ public float3 restPosition;
+ public float3 restScale;
+}
+
+///
+/// One weighted RandomSelect option. The slot's target eases to this rotation when a
+/// cycle's deterministic roll lands in [bandLo, bandHi) of the movement's total weight;
+/// the gap up to totalWeight is the idle band (pose nothing). Held in a buffer shared by
+/// every slot so the per-transform job stays allocation-free.
+///
+public struct AuthoredOptionData
+{
+ public float bandLo;
+ public float bandHi;
+ public float3 axis;
+ public float angleDeg;
+}
+
+///
+/// Single compute-and-write pass for all avatars' authored movements. One movement touches only
+/// a few transforms, so a single parallelises cleanly
+/// across avatars (no compute/apply split like the 51-bone skeleton needs). Every kind is a delta
+/// from the captured rest pose. All math is Unity.Mathematics, so the routine is Burst-legal.
+///
+[BurstCompile(FloatMode = FloatMode.Fast, FloatPrecision = FloatPrecision.Low)]
+public struct AuthoredMotionJob : IJobParallelForTransform
+{
+ [ReadOnly] public NativeArray Movements;
+ [ReadOnly] public NativeArray Options;
+ [ReadOnly] public NativeArray RotationSamples;
+ [ReadOnly] public NativeArray ValidMask;
+ public float Time;
+
+ public void Execute(int index, TransformAccess transform)
+ {
+ if (ValidMask[index] == 0) return;
+
+ AuthoredMovementData m = Movements[index];
+ float t = Time;
+
+ switch ((Kind)m.kind)
+ {
+ case Kind.Oscillate:
+ {
+ float w = Wave(m.waveform, t * m.frequencyHz * 2f * math.PI + m.phase, m.pulseWidth);
+ ApplyChannel(transform, m, m.amplitude * w);
+ break;
+ }
+ case Kind.Noise:
+ {
+ float n = noise.snoise(new float2(t * m.noiseSpeed, m.seed * 0.7283f));
+ ApplyChannel(transform, m, m.amplitude * n);
+ break;
+ }
+ case Kind.Rotate:
+ {
+ float angle = math.radians((t * m.speedDeg) % 360f);
+ transform.localRotation = math.mul(m.restRotation, quaternion.AxisAngle(math.normalizesafe(m.axis), angle));
+ break;
+ }
+ case Kind.Orbit:
+ {
+ float theta = math.radians(t * m.orbitSpeedDeg);
+ float3 n = math.normalizesafe(m.axis, new float3(0f, 1f, 0f));
+ float3 u = math.cross(n, new float3(0f, 1f, 0f));
+ if (math.lengthsq(u) < 1e-6f) u = math.cross(n, new float3(1f, 0f, 0f));
+ u = math.normalizesafe(u);
+ float3 v = math.cross(n, u);
+ transform.localPosition = m.pivotLocal + m.radius * (math.cos(theta) * u + math.sin(theta) * v);
+ break;
+ }
+
+ case Kind.RandomSelect:
+ {
+ if (m.optionCount == 0) break;
+ float period = m.period > 1e-4f ? m.period : 1f;
+ float cyclesF = t / period;
+ int cycle = (int)math.floor(cyclesF);
+ float intoCycle = (cyclesF - cycle) * period; // seconds since this cycle began
+
+ quaternion now = PickDelta(m, cycle, out bool posedNow);
+ quaternion prev = PickDelta(m, cycle - 1, out bool posedPrev);
+
+ // Ease out (release) when this cycle picks rest after being posed; ease in (attack) otherwise.
+ float ease = (!posedNow && posedPrev) ? m.release : m.attack;
+ float blend = ease > 1e-4f ? math.saturate(intoCycle / ease) : 1f;
+ transform.localRotation = math.mul(m.restRotation, math.slerp(prev, now, blend));
+ break;
+ }
+
+ case Kind.Sequence:
+ {
+ if (m.frameCount <= 0) break;
+ float fr = m.frameRate > 1e-4f ? m.frameRate : 30f;
+ float length = m.frameCount / fr;
+ float pt = m.loop != 0 ? math.fmod(t, length) : math.min(math.max(t, 0f), length);
+ if (pt < 0f) pt += length; // fmod can return negative for negative t
+
+ float frameF = pt * fr;
+ int f0 = (int)math.floor(frameF);
+ float frac = frameF - f0;
+ int f1;
+ if (m.loop != 0)
+ {
+ f0 = ((f0 % m.frameCount) + m.frameCount) % m.frameCount;
+ f1 = (f0 + 1) % m.frameCount;
+ }
+ else
+ {
+ f0 = math.clamp(f0, 0, m.frameCount - 1);
+ f1 = math.min(f0 + 1, m.frameCount - 1);
+ }
+
+ quaternion q0 = new quaternion(RotationSamples[m.sampleStart + f0]);
+ quaternion q1 = new quaternion(RotationSamples[m.sampleStart + f1]);
+ transform.localRotation = math.slerp(q0, q1, frac);
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+
+ // Deterministic weighted pick for one cycle: this slot's winning rotation delta, or identity if idle / another target won.
+ quaternion PickDelta(in AuthoredMovementData m, int cycle, out bool posed)
+ {
+ uint h = math.hash(new int2((int)m.seed, cycle)) | 1u;
+ float r = new Unity.Mathematics.Random(h).NextFloat() * m.totalWeight;
+
+ for (int o = 0; o < m.optionCount; o++)
+ {
+ AuthoredOptionData opt = Options[m.optionStart + o];
+ if (r >= opt.bandLo && r < opt.bandHi)
+ {
+ posed = true;
+ return quaternion.AxisAngle(math.normalizesafe(opt.axis, new float3(0f, 1f, 0f)), math.radians(opt.angleDeg));
+ }
+ }
+ posed = false;
+ return quaternion.identity;
+ }
+
+ static void ApplyChannel(TransformAccess transform, in AuthoredMovementData m, float value)
+ {
+ float3 axis = math.normalizesafe(m.axis, new float3(0f, 1f, 0f));
+ switch ((Channel)m.channel)
+ {
+ case Channel.Rotation:
+ transform.localRotation = math.mul(m.restRotation, quaternion.AxisAngle(axis, math.radians(value)));
+ break;
+ case Channel.Position:
+ transform.localPosition = m.restPosition + axis * value;
+ break;
+ case Channel.Scale:
+ transform.localScale = m.restScale + axis * value;
+ break;
+ }
+ }
+
+ // phase is in radians; pulseWidth is the square/pulse duty cycle (0–1).
+ static float Wave(int waveform, float phase, float pulseWidth)
+ {
+ switch ((Waveform)waveform)
+ {
+ case Waveform.Sine: return math.sin(phase);
+ case Waveform.Triangle: { float x = math.frac(phase / (2f * math.PI)); return 4f * math.abs(x - 0.5f) - 1f; }
+ case Waveform.Square: { float x = math.frac(phase / (2f * math.PI)); return x < pulseWidth ? 1f : -1f; }
+ case Waveform.Pulse: { float x = math.frac(phase / (2f * math.PI)); return x < pulseWidth ? 1f : 0f; }
+ default: return math.sin(phase);
+ }
+ }
+}
+
+///
+/// Static orchestrator for authored-motion evaluation — a sibling to RemoteBoneJobSystem.
+/// Holds the persistent SoA + for every registered avatar's
+/// driven transforms and schedules one batched Burst pass per frame. Drives transforms outside the
+/// networked humanoid skeleton, so there's no write contention with the bone pipeline.
+///
+/// Registration is calibration-driven (no scene discovery): the local/remote avatar drivers
+/// call for each on the avatar and
+/// on teardown/recalibration. Authoritative state lives in managed
+/// Registration records (rest poses captured once at registration); a structural change
+/// rebuilds the native containers from them.
+///
+public static class BasisAuthoredMotionSystem
+{
+ sealed class Registration
+ {
+ public BasisAuthoredMotion Component;
+ public Transform[] Targets; // one per slot
+ public AuthoredMovementData[] Data; // parallel to Targets
+ public AuthoredOptionData[] Options; // RandomSelect options, rebased into sOptions on rebuild
+ public float4[] RotationSamples; // Sequence rotation frames, rebased into sRotationSamples on rebuild
+ public bool[] MovementEnabled; // per-slot author default (Movement.enabled)
+ public bool ComponentEnabled; // mirrors the component's runtime enabled state
+ public int Offset; // current start index in the native containers
+ }
+
+ // Persistent SoA, parallel to sTargets.
+ static NativeList sMovements;
+ static NativeList sOptions; // shared RandomSelect option bands
+ static NativeList sRotationSamples; // shared Sequence rotation frames (absolute, xyzw)
+ static NativeList sValidMask;
+ static TransformAccessArray sTargets;
+
+ static readonly List sRegistrations = new List();
+ static readonly Dictionary sLookup = new Dictionary();
+
+ static JobHandle sPending;
+ static bool sInitialized;
+
+ public static int SlotCount => sInitialized ? sMovements.Length : 0;
+
+ public static void Initialize(int initialCapacity = 0)
+ {
+ if (sInitialized) return;
+ sMovements = new NativeList(initialCapacity, Allocator.Persistent);
+ sOptions = new NativeList(0, Allocator.Persistent);
+ sRotationSamples = new NativeList(0, Allocator.Persistent);
+ sValidMask = new NativeList(initialCapacity, Allocator.Persistent);
+ sTargets = new TransformAccessArray(math.max(1, initialCapacity));
+ sRegistrations.Clear();
+ sLookup.Clear();
+ sInitialized = true;
+ }
+
+ public static void Dispose()
+ {
+ if (!sInitialized) return;
+ CompletePending();
+ if (sMovements.IsCreated) sMovements.Dispose();
+ if (sOptions.IsCreated) sOptions.Dispose();
+ if (sRotationSamples.IsCreated) sRotationSamples.Dispose();
+ if (sValidMask.IsCreated) sValidMask.Dispose();
+ if (sTargets.isCreated) sTargets.Dispose();
+ sRegistrations.Clear();
+ sLookup.Clear();
+ sInitialized = false;
+ }
+
+ ///
+ /// Registers every movement on , capturing rest poses from the
+ /// avatar's current (calibration TPose) state. Re-registering an already-known component
+ /// refreshes it. Safe to call with a null/empty component.
+ ///
+ public static void Register(BasisAuthoredMotion component)
+ {
+ if (component == null) return;
+ if (!sInitialized) Initialize();
+ if (sLookup.ContainsKey(component)) Unregister(component);
+
+ Registration reg = Build(component);
+ if (reg == null || reg.Data.Length == 0) return;
+
+ sRegistrations.Add(reg);
+ sLookup[component] = reg;
+ component.EnabledStateChanged += OnEnabledStateChanged;
+ Rebuild();
+ }
+
+ public static void Unregister(BasisAuthoredMotion component)
+ {
+ if (!sInitialized || component == null) return;
+ if (!sLookup.TryGetValue(component, out Registration reg)) return;
+ component.EnabledStateChanged -= OnEnabledStateChanged;
+ sRegistrations.Remove(reg);
+ sLookup.Remove(component);
+ Rebuild();
+ }
+
+ ///
+ /// Schedules the single compute-and-write pass for all registered movements. Call once per
+ /// frame, before the jiggle updater's LateUpdate samples the bones (so authored motion is the
+ /// animated base and jiggle layers on top). Returns the handle for dependency chaining;
+ /// the caller (or the next ) completes it via .
+ ///
+ public static JobHandle Schedule()
+ {
+ if (!sInitialized || sMovements.Length == 0) return default;
+
+ // Complete the previous frame's writes before rescheduling over the same containers.
+ CompletePending();
+
+ // A destroyed transform auto-drops from the TransformAccessArray, desyncing the parallel SoA — rebuild to resync.
+ if (sTargets.length != sMovements.Length)
+ {
+ Rebuild();
+ if (sMovements.Length == 0) return default;
+ }
+
+ // TODO: a shared/networked clock for bit-identical remote copies; Time.timeAsDouble is fine for local validation.
+ float time = (float)Time.timeAsDouble;
+
+ sPending = new AuthoredMotionJob
+ {
+ Movements = sMovements.AsDeferredJobArray(),
+ Options = sOptions.AsDeferredJobArray(),
+ RotationSamples = sRotationSamples.AsDeferredJobArray(),
+ ValidMask = sValidMask.AsDeferredJobArray(),
+ Time = time,
+ }.Schedule(sTargets);
+
+ return sPending;
+ }
+
+ public static void Complete(JobHandle handle)
+ {
+ handle.Complete();
+ CompletePending();
+ }
+
+ static void CompletePending()
+ {
+ sPending.Complete();
+ sPending = default;
+ }
+
+ // ── Internals ──────────────────────────────────────────────────────────────
+
+ static Registration Build(BasisAuthoredMotion component)
+ {
+ var data = new List();
+ var targets = new List();
+ var movementEnabled = new List();
+ var options = new List();
+ var rotSamples = new List();
+
+ Movement[] movements = component.movements;
+ for (int i = 0; i < movements.Length; i++)
+ {
+ Movement mv = movements[i];
+ uint seed = mv.seed != 0 ? mv.seed : (uint)(i + 1);
+
+ switch (mv.kind)
+ {
+ case Kind.Oscillate:
+ case Kind.Noise:
+ // Chain kinds: one slot per element, element phase/falloff baked in.
+ if (mv.chain != null)
+ {
+ for (int n = 0; n < mv.chain.Length; n++)
+ {
+ Transform tf = mv.chain[n];
+ if (tf == null) continue;
+ AuthoredMovementData d = Base(mv, seed, tf);
+ d.phase = mv.phase + n * mv.chainPhaseStep;
+ d.amplitude = mv.amplitude * Mathf.Pow(mv.chainFalloff, n);
+ AddSlot(data, targets, movementEnabled, d, tf, mv.enabled);
+ }
+ }
+ break;
+
+ case Kind.Rotate:
+ if (mv.target != null)
+ AddSlot(data, targets, movementEnabled, Base(mv, seed, mv.target), mv.target, mv.enabled);
+ break;
+
+ case Kind.Orbit:
+ if (mv.target != null)
+ {
+ AuthoredMovementData d = Base(mv, seed, mv.target);
+ Vector3 pivotWorld = mv.pivot != null ? mv.pivot.position : mv.target.position;
+ d.pivotLocal = mv.target.parent != null
+ ? (float3)mv.target.parent.InverseTransformPoint(pivotWorld)
+ : (float3)pivotWorld;
+ AddSlot(data, targets, movementEnabled, d, mv.target, mv.enabled);
+ }
+ break;
+
+ case Kind.RandomSelect:
+ {
+ if (mv.options == null || mv.options.Length == 0) break;
+
+ // Cumulative weight bands in declaration order; the idle weight takes the remainder.
+ float running = 0f;
+ var resolved = new List<(Transform tf, float3 axis, float angle, float lo, float hi)>();
+ for (int o = 0; o < mv.options.Length; o++)
+ {
+ var opt = mv.options[o];
+ float w = Mathf.Max(0f, opt.weight);
+ Transform tf = opt.target != null ? opt.target : mv.selectTarget;
+ if (tf == null || w <= 0f) continue;
+ float lo = running; running += w;
+ resolved.Add((tf, opt.axis, opt.angleDeg, lo, running));
+ }
+ float total = running + Mathf.Max(0f, mv.idleWeight);
+ if (resolved.Count == 0 || total <= 0f) break;
+
+ // One slot per distinct target; lay its options out contiguously in the buffer.
+ var done = new HashSet();
+ for (int o = 0; o < resolved.Count; o++)
+ {
+ Transform tf = resolved[o].tf;
+ if (!done.Add(tf)) continue;
+
+ int start = options.Count;
+ int count = 0;
+ for (int p = 0; p < resolved.Count; p++)
+ {
+ if (resolved[p].tf != tf) continue;
+ options.Add(new AuthoredOptionData
+ {
+ bandLo = resolved[p].lo,
+ bandHi = resolved[p].hi,
+ axis = resolved[p].axis,
+ angleDeg = resolved[p].angle,
+ });
+ count++;
+ }
+
+ AuthoredMovementData d = Base(mv, seed, tf);
+ d.period = Mathf.Max(1e-4f, mv.intervalRange.x);
+ d.totalWeight = total;
+ d.attack = mv.attack;
+ d.release = mv.release;
+ d.optionStart = start;
+ d.optionCount = count;
+ AddSlot(data, targets, movementEnabled, d, tf, mv.enabled);
+ }
+ break;
+ }
+
+ case Kind.Sequence:
+ {
+ BasisMotionClip clip = mv.bakedClip;
+ if (clip == null || clip.transformCount <= 0 || clip.frameCount <= 0 || clip.rotationSamples == null)
+ break;
+
+ Transform root = mv.sequenceRoot != null ? mv.sequenceRoot : component.transform;
+ int fc = clip.frameCount;
+ for (int ti = 0; ti < clip.transformCount; ti++)
+ {
+ string path = (clip.paths != null && ti < clip.paths.Length) ? clip.paths[ti] : null;
+ Transform tf = ResolvePath(root, path);
+ if (tf == null) continue;
+
+ int start = rotSamples.Count;
+ for (int f = 0; f < fc; f++)
+ {
+ Vector4 q = clip.rotationSamples[ti * fc + f];
+ rotSamples.Add(new float4(q.x, q.y, q.z, q.w));
+ }
+
+ AuthoredMovementData d = Base(mv, seed, tf);
+ d.sampleStart = start;
+ d.frameCount = fc;
+ d.frameRate = clip.frameRate;
+ d.loop = mv.loop ? 1 : 0;
+ AddSlot(data, targets, movementEnabled, d, tf, mv.enabled);
+ }
+ break;
+ }
+ }
+ }
+
+ if (data.Count == 0) return null;
+
+ return new Registration
+ {
+ Component = component,
+ Targets = targets.ToArray(),
+ Data = data.ToArray(),
+ Options = options.ToArray(),
+ RotationSamples = rotSamples.ToArray(),
+ MovementEnabled = movementEnabled.ToArray(),
+ ComponentEnabled = component.isActiveAndEnabled,
+ Offset = 0,
+ };
+ }
+
+ // Builds the common fields and captures the rest pose from the driven transform.
+ static AuthoredMovementData Base(Movement mv, uint seed, Transform tf)
+ {
+ tf.GetLocalPositionAndRotation(out Vector3 restPos, out Quaternion restRot);
+ return new AuthoredMovementData
+ {
+ kind = (int)mv.kind,
+ channel = (int)mv.channel,
+ waveform = (int)mv.waveform,
+ axis = mv.axis,
+ amplitude = mv.amplitude,
+ frequencyHz = mv.frequencyHz,
+ phase = mv.phase,
+ pulseWidth = mv.pulseWidth,
+ speedDeg = mv.speedDeg,
+ radius = mv.radius,
+ orbitSpeedDeg = mv.orbitSpeedDeg,
+ noiseSpeed = mv.noiseSpeed,
+ seed = seed,
+ restRotation = restRot,
+ restPosition = restPos,
+ restScale = tf.localScale,
+ };
+ }
+
+ static void AddSlot(List data, List targets, List enabledList,
+ AuthoredMovementData d, Transform tf, bool movementEnabled)
+ {
+ data.Add(d);
+ targets.Add(tf);
+ enabledList.Add(movementEnabled);
+ }
+
+ // Resolves a baked-clip path under root (empty = root itself), matching how an AnimationClip binds curves.
+ static Transform ResolvePath(Transform root, string path)
+ {
+ if (root == null) return null;
+ return string.IsNullOrEmpty(path) ? root : root.Find(path);
+ }
+
+ // Rebuilds the native containers from the managed registrations on a structural change — never per-frame.
+ static void Rebuild()
+ {
+ CompletePending();
+
+ // Prune registrations whose component was destroyed without an explicit Unregister (Unity fake-null).
+ for (int i = sRegistrations.Count - 1; i >= 0; i--)
+ {
+ if (sRegistrations[i].Component == null)
+ {
+ sLookup.Remove(sRegistrations[i].Component);
+ sRegistrations.RemoveAt(i);
+ }
+ }
+
+ int total = 0;
+ for (int i = 0; i < sRegistrations.Count; i++) total += sRegistrations[i].Data.Length;
+
+ sMovements.Clear();
+ sOptions.Clear();
+ sRotationSamples.Clear();
+ sValidMask.Clear();
+ if (sTargets.isCreated) sTargets.Dispose();
+ sTargets = new TransformAccessArray(math.max(1, total));
+
+ for (int r = 0; r < sRegistrations.Count; r++)
+ {
+ Registration reg = sRegistrations[r];
+ reg.Offset = sMovements.Length;
+
+ // Concatenate this registration's side buffers; slots reference them via a rebased offset.
+ int optionBase = sOptions.Length;
+ if (reg.Options != null)
+ for (int o = 0; o < reg.Options.Length; o++) sOptions.Add(reg.Options[o]);
+
+ int sampleBase = sRotationSamples.Length;
+ if (reg.RotationSamples != null)
+ for (int s = 0; s < reg.RotationSamples.Length; s++) sRotationSamples.Add(reg.RotationSamples[s]);
+
+ for (int i = 0; i < reg.Data.Length; i++)
+ {
+ Transform tf = reg.Targets[i];
+ if (tf == null) continue; // UGC: a target may have been destroyed
+ AuthoredMovementData d = reg.Data[i];
+ if ((Kind)d.kind == Kind.RandomSelect) d.optionStart += optionBase;
+ else if ((Kind)d.kind == Kind.Sequence) d.sampleStart += sampleBase;
+ sMovements.Add(d);
+ sTargets.Add(tf);
+ sValidMask.Add((byte)(reg.ComponentEnabled && reg.MovementEnabled[i] ? 1 : 0));
+ }
+ }
+ }
+
+ // Toggle-system-agnostic: any actuator flipping the component's enabled lands here; patch just its mask slice.
+ static void OnEnabledStateChanged(BasisAuthoredMotion component, bool enabled)
+ {
+ if (!sLookup.TryGetValue(component, out Registration reg)) return;
+ reg.ComponentEnabled = enabled;
+
+ CompletePending(); // the job reads ValidMask
+ for (int i = 0; i < reg.Data.Length; i++)
+ {
+ int slot = reg.Offset + i;
+ if (slot < sValidMask.Length)
+ sValidMask[slot] = (byte)(enabled && reg.MovementEnabled[i] ? 1 : 0);
+ }
+ }
+}
diff --git a/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs.meta b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs.meta
new file mode 100644
index 0000000000..bb0a081a22
--- /dev/null
+++ b/Basis/Packages/com.basis.framework/Drivers/AuthoredMotion/BasisAuthoredMotionSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5def080570cda5245b9a9f6b41aab67d
\ No newline at end of file
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
index 9d043aaab5..a8a621d463 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
@@ -149,6 +149,13 @@ public void InitialLocalCalibration(BasisLocalPlayer player, List(true);
+ for (int i = 0; i < authoredMotions.Length; i++)
+ {
+ BasisAuthoredMotionSystem.Register(authoredMotions[i]);
+ }
+
player.LocalRigDriver.Builder = BasisHelpers.GetOrAddComponent(AvatarAnimatorParent);
player.LocalRigDriver.Builder.enabled = false;
diff --git a/Basis/Packages/com.basis.framework/Drivers/Remote/BasisRemoteAvatarDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Remote/BasisRemoteAvatarDriver.cs
index 4ccb9efec5..30eca3656e 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Remote/BasisRemoteAvatarDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Remote/BasisRemoteAvatarDriver.cs
@@ -138,6 +138,13 @@ public void RemoteCalibration(BasisRemotePlayer RemotePlayer)
Rig.OnInitialize();
}
+ // Register authored motion (drives non-humanoid transforms the bone job / IK don't touch); rest captured at the current TPose.
+ var authoredMotions = RemotePlayer.BasisAvatar.GetComponentsInChildren(true);
+ for (int i = 0; i < authoredMotions.Length; i++)
+ {
+ BasisAuthoredMotionSystem.Register(authoredMotions[i]);
+ }
+
// Face visibility setup
Player.FaceIsVisible = false;
if (RemotePlayer.BasisAvatar == null)
diff --git a/Basis/Packages/com.basis.framework/Players/Remote/BasisRemotePlayer.cs b/Basis/Packages/com.basis.framework/Players/Remote/BasisRemotePlayer.cs
index 0252b12852..b142585113 100644
--- a/Basis/Packages/com.basis.framework/Players/Remote/BasisRemotePlayer.cs
+++ b/Basis/Packages/com.basis.framework/Players/Remote/BasisRemotePlayer.cs
@@ -529,6 +529,17 @@ public void OnDestroy()
OnRemotePlayerDestroying?.Invoke();
+ // Unregister authored motion while the avatar transforms are still alive, so the
+ // TransformAccessArray entries drop cleanly before Unity destroys them.
+ if (BasisAvatar != null)
+ {
+ var authoredMotions = BasisAvatar.GetComponentsInChildren(true);
+ for (int i = 0; i < authoredMotions.Length; i++)
+ {
+ BasisAuthoredMotionSystem.Unregister(authoredMotions[i]);
+ }
+ }
+
RemoveFromBoneDriver();
}
diff --git a/Basis/Packages/com.basis.sdk/Localization/Languages/en.json b/Basis/Packages/com.basis.sdk/Localization/Languages/en.json
index 4ff04a009d..95aec86c73 100644
--- a/Basis/Packages/com.basis.sdk/Localization/Languages/en.json
+++ b/Basis/Packages/com.basis.sdk/Localization/Languages/en.json
@@ -193,6 +193,75 @@
{ "key": "sdk.parameterDriver.field.destMin", "value": "Dest Min" },
{ "key": "sdk.parameterDriver.field.destMax", "value": "Dest Max" },
+ { "key": "sdk.authoredMotion.movements.header", "value": "Movements ({0})" },
+ { "key": "sdk.authoredMotion.movements.empty", "value": "No movements — click '+ Add Movement' below." },
+ { "key": "sdk.authoredMotion.movements.add", "value": "+ Add Movement" },
+ { "key": "sdk.authoredMotion.label.placeholder", "value": "" },
+ { "key": "sdk.authoredMotion.field.kind.label", "value": "Kind" },
+ { "key": "sdk.authoredMotion.field.kind.tooltip", "value": "Which authored-motion primitive this entry drives. The fields below change to match the kind." },
+ { "key": "sdk.authoredMotion.field.label.label", "value": "Label" },
+ { "key": "sdk.authoredMotion.field.label.tooltip", "value": "Author-facing identifier only; has no runtime effect." },
+ { "key": "sdk.authoredMotion.field.enabled.label", "value": "Enabled" },
+ { "key": "sdk.authoredMotion.field.enabled.tooltip", "value": "Author default for this movement. The runtime on/off toggle rides the component's own enabled state." },
+ { "key": "sdk.authoredMotion.field.axis.label", "value": "Axis" },
+ { "key": "sdk.authoredMotion.field.axis.tooltip", "value": "Local axis the movement acts about." },
+ { "key": "sdk.authoredMotion.field.channel.label", "value": "Channel" },
+ { "key": "sdk.authoredMotion.field.channel.tooltip", "value": "What the value drives — Rotation (degrees), Position (metres) or Scale (factor)." },
+ { "key": "sdk.authoredMotion.field.waveform.label", "value": "Waveform" },
+ { "key": "sdk.authoredMotion.field.waveform.tooltip", "value": "Oscillation shape: Sine, Triangle, Square or Pulse." },
+ { "key": "sdk.authoredMotion.field.pulseWidth.label", "value": "Pulse Width" },
+ { "key": "sdk.authoredMotion.field.pulseWidth.tooltip", "value": "Duty cycle (0–1) for the Square and Pulse waveforms." },
+ { "key": "sdk.authoredMotion.field.amplitude.label", "value": "Amplitude" },
+ { "key": "sdk.authoredMotion.field.amplitude.tooltip", "value": "Peak deviation from rest. Units follow Channel: degrees, metres or scale-factor." },
+ { "key": "sdk.authoredMotion.field.frequencyHz.label", "value": "Frequency (Hz)" },
+ { "key": "sdk.authoredMotion.field.frequencyHz.tooltip", "value": "Cycles per second." },
+ { "key": "sdk.authoredMotion.field.phase.label", "value": "Phase" },
+ { "key": "sdk.authoredMotion.field.phase.tooltip", "value": "Starting phase offset, in radians." },
+ { "key": "sdk.authoredMotion.field.chain.label", "value": "Chain" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.chainPhaseStep.label", "value": "Chain Phase Step" },
+ { "key": "sdk.authoredMotion.field.chainPhaseStep.tooltip", "value": "Phase delay (radians) added per element down the chain — produces the travelling-wave look." },
+ { "key": "sdk.authoredMotion.field.chainFalloff.label", "value": "Chain Falloff" },
+ { "key": "sdk.authoredMotion.field.chainFalloff.tooltip", "value": "Amplitude multiplier applied per element down the chain (1 = no falloff)." },
+ { "key": "sdk.authoredMotion.field.target.label", "value": "Target" },
+ { "key": "sdk.authoredMotion.field.target.tooltip", "value": "Transform to drive. Used by Rotate and Orbit; Oscillate and Noise use Chain instead." },
+ { "key": "sdk.authoredMotion.field.speedDeg.label", "value": "Speed (deg/sec)" },
+ { "key": "sdk.authoredMotion.field.speedDeg.tooltip", "value": "Constant angular velocity about Axis, in degrees per second." },
+ { "key": "sdk.authoredMotion.field.pivot.label", "value": "Pivot" },
+ { "key": "sdk.authoredMotion.field.pivot.tooltip", "value": "Point the Target revolves around. Defaults to the Target's own position when unset." },
+ { "key": "sdk.authoredMotion.field.radius.label", "value": "Radius" },
+ { "key": "sdk.authoredMotion.field.radius.tooltip", "value": "Distance from the pivot, in metres." },
+ { "key": "sdk.authoredMotion.field.orbitSpeedDeg.label", "value": "Orbit Speed (deg/sec)" },
+ { "key": "sdk.authoredMotion.field.orbitSpeedDeg.tooltip", "value": "Revolution speed around the pivot, in degrees per second." },
+ { "key": "sdk.authoredMotion.field.selectTarget.label", "value": "Select Target" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.options.label", "value": "Options" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.idleWeight.label", "value": "Idle Weight" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.intervalRange.label", "value": "Interval Range" },
+ { "key": "sdk.authoredMotion.field.intervalRange.tooltip", "value": "Time between picks. The X value sets the fixed period in seconds; Y is reserved." },
+ { "key": "sdk.authoredMotion.field.attack.label", "value": "Attack" },
+ { "key": "sdk.authoredMotion.field.attack.tooltip", "value": "Ease-in time toward a newly chosen pose, in seconds." },
+ { "key": "sdk.authoredMotion.field.release.label", "value": "Release" },
+ { "key": "sdk.authoredMotion.field.release.tooltip", "value": "Ease-out time when a target returns to rest, in seconds." },
+ { "key": "sdk.authoredMotion.field.preventRepeats.label", "value": "Prevent Repeats" },
+ { "key": "sdk.authoredMotion.field.preventRepeats.tooltip", "value": "Avoid picking the same option twice in a row. Not yet honored by the deterministic picker." },
+ { "key": "sdk.authoredMotion.field.seed.label", "value": "Seed" },
+ { "key": "sdk.authoredMotion.field.seed.tooltip", "value": "Random seed for Noise and RandomSelect. 0 derives one from the registration index." },
+ { "key": "sdk.authoredMotion.field.noiseSpeed.label", "value": "Noise Speed" },
+ { "key": "sdk.authoredMotion.field.noiseSpeed.tooltip", "value": "How fast the simplex-noise field is sampled." },
+ { "key": "sdk.authoredMotion.field.sequenceTarget.label", "value": "Sequence Target" },
+ { "key": "sdk.authoredMotion.field.sequenceTarget.tooltip", "value": "Transform the timeline drives." },
+ { "key": "sdk.authoredMotion.field.sequenceRoot.label", "value": "Sequence Root" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.bakedClip.label", "value": "Baked Clip" },
+ { "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." },
+ { "key": "sdk.authoredMotion.field.keyframes.label", "value": "Keyframes" },
+ { "key": "sdk.authoredMotion.field.keyframes.tooltip", "value": "Inline pose-delta timeline for short motion. Ignored when a Baked Clip is assigned." },
+ { "key": "sdk.authoredMotion.field.loop.label", "value": "Loop" },
+ { "key": "sdk.authoredMotion.field.loop.tooltip", "value": "Loop the sequence, or play it once." },
+
{ "key": "sdk.buildReport.window.title", "value": "Basis Build Report Viewer" },
{ "key": "sdk.buildReport.window.tabTitle", "value": "Basis Bundle Report" },
{ "key": "sdk.buildReport.noDirectory", "value": "No build reports directory found." },
diff --git a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
index 6159cc8802..22a91584b7 100644
--- a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
+++ b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
@@ -92,6 +92,7 @@ public static string GetTagColor(LogTag logTag)
LogTag.Shims => "#FF00FF", // Magenta
LogTag.Props => "#FFB6C1", // Light Pink
LogTag.LocalNetwork => "#ff0055",
+ LogTag.AuthoredMotion => "#BA55D3", // Medium Orchid
_ => "#FFFFFF" // Default White
};
}
@@ -141,6 +142,7 @@ public enum LogTag
Shims,
Props,
LocalNetwork,
+ AuthoredMotion,
}
public enum MessageType
diff --git a/Basis/Packages/com.basis.sdk/Scripts/BasisAuthoredMotion.cs b/Basis/Packages/com.basis.sdk/Scripts/BasisAuthoredMotion.cs
new file mode 100644
index 0000000000..06027f59c8
--- /dev/null
+++ b/Basis/Packages/com.basis.sdk/Scripts/BasisAuthoredMotion.cs
@@ -0,0 +1,111 @@
+using System;
+using UnityEngine;
+
+///
+/// Data-only avatar component declaring authored, deterministic dynamic motion on transforms
+/// the humanoid rig and IK don't drive (tail/ear chains, accessories, etc.). It holds pure
+/// serialized configuration and runs no per-instance per-frame Update — all runtime
+/// evaluation happens in the batched BasisAuthoredMotionSystem job, which reads this
+/// component at calibration. The config model mirrors 's
+/// Operation[] shape (an enum kind + per-kind fields).
+///
+/// Allow it onto an avatar by adding its type to the Content Police
+/// (ContentPoliceSelector.selectedTypes in AvatarContentPoliceSelector.asset).
+/// Group movements that toggle together into one component; an avatar may carry several.
+///
+public class BasisAuthoredMotion : MonoBehaviour
+{
+ ///
+ /// Raised on enable/disable so a registered motion system can flip this component's slice
+ /// of its valid-mask without a per-frame poll. The system subscribes at registration; the
+ /// component holds no reference to any toggle package, so any actuator that flips
+ /// (e.g. an HVR.Vixxy activation) drives it unchanged.
+ ///
+ public event Action EnabledStateChanged;
+
+ public Movement[] movements = Array.Empty();
+
+ private void OnEnable() => EnabledStateChanged?.Invoke(this, true);
+ private void OnDisable() => EnabledStateChanged?.Invoke(this, false);
+
+ [Serializable]
+ public class Movement
+ {
+ // Open, extensible set — new kinds slot in without disturbing registration / scheduling / toggles.
+ public enum Kind { Oscillate, Rotate, Orbit, RandomSelect, Sequence, Noise }
+ public enum Channel { Rotation, Position, Scale } // what Oscillate / Noise drive
+ public enum Waveform { Sine, Triangle, Square, Pulse }
+
+ public Kind kind = Kind.Oscillate;
+ public string label; // author-facing identifier only
+ public bool enabled = true; // author default; runtime toggle rides the component's own enabled
+ public Vector3 axis = Vector3.up; // local axis the movement acts about
+
+ // Oscillate — periodic motion on `channel`; a chain makes a travelling wave (1 entry = simple sway).
+ public Channel channel = Channel.Rotation; // amplitude unit: deg | metres | scale-factor
+ public Waveform waveform = Waveform.Sine;
+ public float pulseWidth = 0.5f; // square/pulse duty cycle (0–1)
+ public Transform[] chain;
+ public float amplitude = 15f;
+ public float frequencyHz = 0.5f;
+ public float phase = 0f;
+ public float chainPhaseStep = 0f; // phase delay per element down the chain
+ public float chainFalloff = 1f; // amplitude scale per element down the chain
+
+ // Rotate — constant angular velocity about `axis`, in place.
+ public Transform target;
+ public float speedDeg = 36f; // deg/sec
+
+ // Orbit — revolve `target` around `pivot` at `radius` (not a spin-in-place).
+ public Transform pivot;
+ public float radius = 0.1f;
+ public float orbitSpeedDeg = 90f; // deg/sec around the pivot
+
+ // RandomSelect — every `intervalRange.x` seconds, deterministically pick one weighted option (or idle)
+ // and ease the target in/out. Each Option may set its own `target`, else falls back to `selectTarget`.
+ public Transform selectTarget; // default target for options that leave their own target null
+ public Option[] options = Array.Empty