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