diff --git a/Basis/Packages/com.basis.shim/BasisShims.asmdef b/Basis/Packages/com.basis.shim/BasisShims.asmdef
index b2237e132..fca7915c1 100644
--- a/Basis/Packages/com.basis.shim/BasisShims.asmdef
+++ b/Basis/Packages/com.basis.shim/BasisShims.asmdef
@@ -7,6 +7,7 @@
"BasisNetworkCore",
"BasisDebug",
"Cilbox",
+ "BasisEventDriver",
"BasisSDK",
"Basis Framework",
"BasisMediaPlayer",
diff --git a/Basis/Packages/com.basis.shim/Shims/BasisBlendShapeSyncShim.cs b/Basis/Packages/com.basis.shim/Shims/BasisBlendShapeSyncShim.cs
new file mode 100644
index 000000000..8f18c3df2
--- /dev/null
+++ b/Basis/Packages/com.basis.shim/Shims/BasisBlendShapeSyncShim.cs
@@ -0,0 +1,452 @@
+using System;
+using Basis.EventDriver;
+using UnityEngine;
+
+namespace Basis.Shims
+{
+ ///
+ /// Copies skinned-mesh blendshape weights between paired renderers natively, once per
+ /// frame, on behalf of a Cilbox-sandboxed script. This is what carries face tracking,
+ /// visemes and expressions onto a copied or mirrored avatar.
+ ///
+ /// WHY THIS EXISTS: inside the interpreter every call out to Unity is a
+ /// MethodBase.Invoke plus two heap allocations — Cilbox builds a fresh object[] and
+ /// StackElement[] per call, and Mono then re-validates the same immutable MethodInfo
+ /// on every invoke. Blendshapes have no batch weight API, so a sandboxed script must
+ /// call GetBlendShapeWeight once PER SHAPE PER FRAME: on an ARKit-style face that is
+ /// hundreds of reflection invokes a frame for one mesh. Handing the paired renderers
+ /// across the boundary once and running the loop natively removes all of it.
+ ///
+ /// GRANTS NO NEW AUTHORITY. A sandboxed script can already read and write blendshape
+ /// weights on any SkinnedMeshRenderer it holds a reference to — that is exactly what
+ /// the slow version did, one reflection call at a time. Only the speed changes.
+ ///
+ /// SEPARATE FROM ON PURPOSE. The two have
+ /// different threading futures: paired transform copying is jobifiable and Basis
+ /// already drives transforms off the main thread through TransformAccessArray, whereas
+ /// SetBlendShapeWeight is main-thread only and always will be. Splitting them is what
+ /// allows scheduled transform work to overlap this main-thread work rather than
+ /// serialising behind it. Nothing in this class can move off the main thread.
+ ///
+ /// Ticks on , defaulting to OnLateUpdate, which fires
+ /// after face tracking, lipsync and expression drivers have set weights for the frame,
+ /// so the copy is never a frame stale.
+ ///
+ /// Typical use from a sandboxed script:
+ ///
+ /// faceSync = new Basis.Shims.BasisBlendShapeSyncShim();
+ /// faceSync.BindMeshes(sourceMeshes, targetMeshes, this);
+ ///
+ /// Pass the FULL paired mesh arrays: the shim keeps only the meshes that actually carry
+ /// blendshapes and allocates its own weight caches, so the caller precomputes nothing.
+ ///
+ /// Phase selectors are const int rather than an enum on purpose: a const inlines to a
+ /// plain ldc.i4 in the caller's IL, so sandboxed callers need no enum type resolution,
+ /// no boxing through Cilbox's enum machinery, and no extra link.xml preserve entry for
+ /// a nested type.
+ ///
+ public sealed class BasisBlendShapeSyncShim
+ {
+ // ---- Tick phase --------------------------------------------------------------
+
+ /// Run on BasisEventDriver.OnLateUpdate, after face and viseme drivers. The default.
+ public const int PhaseLateUpdate = 0;
+ /// Run on BasisEventDriver.OnUpdate, before them.
+ public const int PhaseUpdate = 1;
+
+ // ---- Limits ------------------------------------------------------------------
+ //
+ // WORTH KNOWING, PLATFORM SIDE: native work is NOT covered by Cilbox's time
+ // budget — its accounting measures interpreted instructions only. So moving a loop
+ // behind a shim also moves it outside the ceiling that kept a runaway sandboxed
+ // loop merely slow. This per-instance cap bounds one binding; it does NOT bound a
+ // script that constructs many shims. A real ceiling for that belongs to the
+ // platform, where it can be attributed per prop.
+
+ /// Largest number of blendshape-bearing mesh pairs a single shim will copy.
+ public const int MaxMeshes = 256;
+
+ /// Default minimum weight change worth writing.
+ public const float DefaultEpsilon = 0.01f;
+
+ // ---- State -------------------------------------------------------------------
+
+ // Only meshes that actually carry shapes are kept, and cache holds the last weight
+ // written per shape so a still face costs reads and nothing else.
+ private SkinnedMeshRenderer[] source = Array.Empty();
+ private SkinnedMeshRenderer[] target = Array.Empty();
+ private float[][] cache = Array.Empty();
+ private int count;
+
+ // Optional renderers to gate on: no copy on frames where none is on screen.
+ private Renderer[] gate = Array.Empty();
+ private int gateCount;
+
+ // Destroyed alongside this object, the shim unhooks itself. Without it a caller
+ // that forgets to Dispose would leave a subscription on a STATIC event forever,
+ // pinning every bound renderer in memory.
+ private UnityEngine.Object owner;
+
+ private int phase = PhaseLateUpdate;
+ private bool hookedLate;
+ private bool hookedUpdate;
+ private bool disposed;
+
+ // ---- Configuration -----------------------------------------------------------
+
+ ///
+ /// Minimum weight change worth writing. 0 writes on any change; the default ~0.01
+ /// skips imperceptible jitter. Negative values are treated as 0. Safe to change
+ /// while bound.
+ ///
+ public float Epsilon { get; set; } = DefaultEpsilon;
+
+ ///
+ /// PhaseLateUpdate (default) or PhaseUpdate. Changing this re-subscribes
+ /// immediately.
+ ///
+ public int Phase
+ {
+ get { return phase; }
+ set
+ {
+ if (phase == value)
+ {
+ return;
+ }
+ phase = value;
+ Unhook();
+ UpdateHook();
+ }
+ }
+
+ /// Set false to pause copying without unbinding.
+ public bool Enabled { get; set; } = true;
+
+ /// Number of blendshape-bearing mesh pairs currently bound.
+ public int MeshCount { get { return count; } }
+
+ // ---- Binding -----------------------------------------------------------------
+
+ ///
+ /// Bind (or re-bind) the mesh pairs to mirror. Pass the FULL paired arrays — meshes
+ /// with no blendshapes are dropped here, once, rather than being walked every
+ /// frame. Pairing is by index and the shorter length wins; shapes are matched by
+ /// index too, which is exact when the target is a clone of the source (shared
+ /// Mesh), and if the two meshes disagree on shape count the lower count wins.
+ ///
+ /// Weights are synced in full once here, so the target starts correct rather than
+ /// waiting for the first shape to move.
+ ///
+ ///
+ /// Object whose destruction ends the subscription — normally the calling behaviour
+ /// itself (this). Pass null to opt out, in which case calling Dispose is
+ /// mandatory.
+ ///
+ public void BindMeshes(SkinnedMeshRenderer[] sourceMeshes, SkinnedMeshRenderer[] targetMeshes, UnityEngine.Object lifetimeOwner)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ owner = lifetimeOwner;
+ Clear();
+
+ if (sourceMeshes == null || targetMeshes == null)
+ {
+ UpdateHook();
+ return;
+ }
+
+ int pairs = sourceMeshes.Length < targetMeshes.Length
+ ? sourceMeshes.Length
+ : targetMeshes.Length;
+
+ SkinnedMeshRenderer[] src = new SkinnedMeshRenderer[pairs];
+ SkinnedMeshRenderer[] dst = new SkinnedMeshRenderer[pairs];
+ float[][] caches = new float[pairs][];
+ int kept = 0;
+ bool capped = false;
+
+ for (int i = 0; i < pairs; i++)
+ {
+ if (kept >= MaxMeshes)
+ {
+ capped = true;
+ break;
+ }
+
+ SkinnedMeshRenderer s = sourceMeshes[i];
+ SkinnedMeshRenderer d = targetMeshes[i];
+ if (s == null || d == null)
+ {
+ continue;
+ }
+
+ Mesh sourceMesh = s.sharedMesh;
+ Mesh targetMesh = d.sharedMesh;
+ if (sourceMesh == null || targetMesh == null)
+ {
+ continue;
+ }
+
+ int sourceShapes = sourceMesh.blendShapeCount;
+ int targetShapes = targetMesh.blendShapeCount;
+ int shapes = sourceShapes < targetShapes ? sourceShapes : targetShapes;
+ if (shapes == 0)
+ {
+ continue;
+ }
+
+ // Initial full sync doubles as cache seeding: no sentinel value needed, and
+ // the target is correct from the frame it is bound.
+ float[] weights = new float[shapes];
+ for (int b = 0; b < shapes; b++)
+ {
+ float weight = s.GetBlendShapeWeight(b);
+ d.SetBlendShapeWeight(b, weight);
+ weights[b] = weight;
+ }
+
+ src[kept] = s;
+ dst[kept] = d;
+ caches[kept] = weights;
+ kept++;
+ }
+
+ if (capped)
+ {
+ Debug.LogWarning("[BasisBlendShapeSyncShim] More than " + MaxMeshes
+ + " blendshape meshes bound; copying the first " + MaxMeshes + " only.");
+ }
+
+ if (kept > 0)
+ {
+ if (kept != pairs)
+ {
+ Array.Resize(ref src, kept);
+ Array.Resize(ref dst, kept);
+ Array.Resize(ref caches, kept);
+ }
+ source = src;
+ target = dst;
+ cache = caches;
+ count = kept;
+ }
+
+ UpdateHook();
+ }
+
+ ///
+ /// Renderers to test before doing any work — the copy is skipped entirely on frames
+ /// where none of them is being rendered by any camera. Pass null or an empty array
+ /// to always copy. Worth setting for anything that can be off screen, since a face
+ /// nobody is looking at still costs a read per shape.
+ ///
+ public void SetVisibilityGate(Renderer[] renderers)
+ {
+ if (disposed)
+ {
+ return;
+ }
+ if (renderers == null || renderers.Length == 0)
+ {
+ gate = Array.Empty();
+ gateCount = 0;
+ return;
+ }
+ Renderer[] g = new Renderer[renderers.Length];
+ Array.Copy(renderers, g, renderers.Length);
+ gate = g;
+ gateCount = g.Length;
+ }
+
+ /// Stop copying and drop everything bound, keeping the shim reusable.
+ public void Unbind()
+ {
+ Clear();
+ gate = Array.Empty();
+ gateCount = 0;
+ Unhook();
+ }
+
+ /// Unbind and refuse further binds. Idempotent.
+ public void Dispose()
+ {
+ disposed = true;
+ Unbind();
+ owner = null;
+ }
+
+ private void Clear()
+ {
+ source = Array.Empty();
+ target = Array.Empty();
+ cache = Array.Empty();
+ count = 0;
+ }
+
+ // ---- Ticking -----------------------------------------------------------------
+
+ ///
+ /// Subscribe only while there is something to copy, so an idle or unbound shim
+ /// costs the event driver nothing.
+ ///
+ private void UpdateHook()
+ {
+ if (disposed || count == 0)
+ {
+ Unhook();
+ return;
+ }
+
+ // Drop the wrong-phase subscription as well as adding the right one, so this is
+ // correct regardless of which path got us here.
+ if (phase == PhaseUpdate)
+ {
+ if (hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate -= HandleTick;
+ hookedLate = false;
+ }
+ if (!hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate += HandleTick;
+ hookedUpdate = true;
+ }
+ }
+ else
+ {
+ if (hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate -= HandleTick;
+ hookedUpdate = false;
+ }
+ if (!hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate += HandleTick;
+ hookedLate = true;
+ }
+ }
+ }
+
+ private void Unhook()
+ {
+ if (hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate -= HandleTick;
+ hookedLate = false;
+ }
+ if (hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate -= HandleTick;
+ hookedUpdate = false;
+ }
+ }
+
+ private void HandleTick()
+ {
+ // Owner destroyed and nobody called Dispose: clean up after them. The
+ // ReferenceEquals guard distinguishes "no owner was given" (opted out) from
+ // "owner was given and has since been destroyed", which Unity's == null reports
+ // identically.
+ if (!ReferenceEquals(owner, null) && owner == null)
+ {
+ Dispose();
+ return;
+ }
+
+ if (disposed || !Enabled || count == 0)
+ {
+ return;
+ }
+
+ if (!AnyGateVisible())
+ {
+ return;
+ }
+
+ CopyWeights();
+ }
+
+ ///
+ /// Run one copy right now, ignoring and the tick phase but
+ /// still honouring the visibility gate. This is the seam for driving ordering by
+ /// hand: set false so the automatic tick does nothing, then
+ /// call this exactly where you want the work to land — for instance after
+ /// scheduling transform jobs, so the two overlap. Main thread only.
+ ///
+ public void Sync()
+ {
+ if (disposed || count == 0 || !AnyGateVisible())
+ {
+ return;
+ }
+ CopyWeights();
+ }
+
+ ///
+ /// Only shapes that actually moved are written: the read is unavoidable (there is
+ /// no batch weight API) but a still face then costs nothing but reads.
+ ///
+ private void CopyWeights()
+ {
+ int meshes = count;
+ SkinnedMeshRenderer[] src = source;
+ SkinnedMeshRenderer[] dst = target;
+ float[][] caches = cache;
+ float epsilon = Epsilon;
+ if (epsilon < 0f)
+ {
+ epsilon = 0f;
+ }
+
+ for (int m = 0; m < meshes; m++)
+ {
+ SkinnedMeshRenderer s = src[m];
+ SkinnedMeshRenderer d = dst[m];
+ if (s == null || d == null)
+ {
+ continue;
+ }
+
+ float[] weights = caches[m];
+ int shapes = weights.Length;
+ for (int b = 0; b < shapes; b++)
+ {
+ float weight = s.GetBlendShapeWeight(b);
+ float delta = weight - weights[b];
+ if (delta > epsilon || delta < -epsilon)
+ {
+ d.SetBlendShapeWeight(b, weight);
+ weights[b] = weight;
+ }
+ }
+ }
+ }
+
+ ///
+ /// True when there is no gate, or when at least one gated renderer is being rendered
+ /// by some camera this frame.
+ ///
+ private bool AnyGateVisible()
+ {
+ int gates = gateCount;
+ if (gates == 0)
+ {
+ return true;
+ }
+ Renderer[] renderers = gate;
+ for (int i = 0; i < gates; i++)
+ {
+ Renderer renderer = renderers[i];
+ if (renderer != null && renderer.isVisible)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/Basis/Packages/com.basis.shim/Shims/BasisTransformSyncShim.cs b/Basis/Packages/com.basis.shim/Shims/BasisTransformSyncShim.cs
new file mode 100644
index 000000000..f1f829ad0
--- /dev/null
+++ b/Basis/Packages/com.basis.shim/Shims/BasisTransformSyncShim.cs
@@ -0,0 +1,467 @@
+using System;
+using Basis.EventDriver;
+using UnityEngine;
+
+namespace Basis.Shims
+{
+ ///
+ /// Copies transform state between paired transforms natively, once per frame, on
+ /// behalf of a Cilbox-sandboxed script.
+ ///
+ /// WHY THIS EXISTS: inside the interpreter every call out to Unity is a
+ /// MethodBase.Invoke plus two heap allocations — Cilbox builds a fresh object[] and
+ /// StackElement[] per call, and Mono then re-validates the same immutable MethodInfo
+ /// on every invoke. That makes per-frame transform work brutally expensive from a
+ /// sandboxed script: reading and writing local position + rotation costs five
+ /// reflection invokes and roughly a dozen allocations PER TRANSFORM PER FRAME.
+ /// Measured on a 223-transform avatar skeleton: ~1116 invokes and ~146 KB of garbage
+ /// every frame, ~9 ms, of which only ~1 ms was the interpreter's own opcode loop and
+ /// ~1 ms the actual Unity work. Everything else was reflection overhead.
+ ///
+ /// Hand the paired arrays across the boundary ONCE and run the loop natively and that
+ /// cost goes to zero; the copy itself is tens of microseconds.
+ ///
+ /// GRANTS NO NEW AUTHORITY. A sandboxed script can already write position, rotation
+ /// and scale on any Transform it holds a reference to — that is exactly what the slow
+ /// version did, one reflection call at a time. This shim only changes the speed, and
+ /// touches nothing but the transform channels you select.
+ ///
+ /// Ticks on , defaulting to OnLateUpdate, which fires
+ /// after Basis's IK and bone drivers have posed avatars for the frame — so a follower
+ /// lands on THIS frame's pose. A plain MonoBehaviour LateUpdate races those drivers
+ /// and can be a frame stale depending on script order.
+ ///
+ /// Typical use from a sandboxed script:
+ ///
+ /// sync = new Basis.Shims.BasisTransformSyncShim();
+ /// sync.Channels = Basis.Shims.BasisTransformSyncShim.ChannelPose;
+ /// sync.BindTransforms(sourceTransforms, targetTransforms, this);
+ ///
+ /// Blendshape weights are deliberately NOT handled here — see
+ /// . They are split because the two have
+ /// different threading futures: transform copying is jobifiable (Basis already drives
+ /// paired transforms off the main thread via TransformAccessArray), while
+ /// SetBlendShapeWeight is main-thread only. Keeping them separate is what lets
+ /// scheduled transform work overlap main-thread blendshape work instead of serialising
+ /// behind it. Set false and drive yourself if
+ /// you need to control that interleaving by hand today.
+ ///
+ /// Channel/space/phase selectors are const int masks rather than enums on purpose:
+ /// a const inlines to a plain ldc.i4 in the caller's IL, so sandboxed callers need
+ /// no enum type resolution, no boxing through Cilbox's enum machinery, and no extra
+ /// link.xml preserve entry for a nested type.
+ ///
+ public sealed class BasisTransformSyncShim
+ {
+ // ---- Channels: which transform state to copy (bit mask) ----------------------
+
+ /// Copy position.
+ public const int ChannelPosition = 1;
+ /// Copy rotation.
+ public const int ChannelRotation = 2;
+ /// Copy scale. Always LOCAL scale — Unity has no world-scale setter.
+ public const int ChannelScale = 4;
+ /// Position + rotation. The usual choice for pose following.
+ public const int ChannelPose = ChannelPosition | ChannelRotation;
+ /// Position + rotation + scale.
+ public const int ChannelAll = ChannelPosition | ChannelRotation | ChannelScale;
+
+ // ---- Space -------------------------------------------------------------------
+
+ /// Copy parent-relative state. Correct when the target mirrors the source hierarchy.
+ public const int SpaceLocal = 0;
+ /// Copy world state. Use when source and target live in unrelated hierarchies.
+ public const int SpaceWorld = 1;
+
+ // ---- Tick phase --------------------------------------------------------------
+
+ /// Run on BasisEventDriver.OnLateUpdate, after animation and IK. The default.
+ public const int PhaseLateUpdate = 0;
+ /// Run on BasisEventDriver.OnUpdate, before animation and IK.
+ public const int PhaseUpdate = 1;
+
+ // ---- Limits ------------------------------------------------------------------
+ //
+ // WORTH KNOWING, PLATFORM SIDE: native work is NOT covered by Cilbox's time
+ // budget — its accounting measures interpreted instructions only. So moving a
+ // loop behind a shim also moves it outside the ceiling that kept a runaway
+ // sandboxed loop merely slow. These per-instance caps bound one binding; they do
+ // NOT bound a script that constructs many shims. A real ceiling for that belongs
+ // to the platform (a budget Basis owns and can attribute per prop), not to this
+ // class, and is deliberately left as a policy decision rather than half-solved
+ // here. An earlier draft kept a static cross-instance total, which was worse:
+ // BasisEventDriver.ResetEventCallbacks is private, so there is no reliable hook to
+ // reset it on scene teardown, and a leaked count would eventually refuse bindings
+ // for every other prop in the session.
+
+ /// Largest number of transform pairs a single shim will copy.
+ public const int MaxPairs = 8192;
+
+ // ---- State -------------------------------------------------------------------
+
+ private Transform[] source = Array.Empty();
+ private Transform[] target = Array.Empty();
+ private int count;
+
+ // Optional renderers to gate on: no copy on frames where none is on screen.
+ private Renderer[] gate = Array.Empty();
+ private int gateCount;
+
+ // Destroyed alongside this object, the shim unhooks itself. Without it a caller
+ // that forgets to Dispose would leave a subscription on a STATIC event forever,
+ // pinning every bound transform in memory.
+ private UnityEngine.Object owner;
+
+ private int phase = PhaseLateUpdate;
+ private bool hookedLate;
+ private bool hookedUpdate;
+ private bool disposed;
+
+ // ---- Configuration -----------------------------------------------------------
+
+ ///
+ /// Which transform channels to copy — any combination of ChannelPosition,
+ /// ChannelRotation and ChannelScale. Defaults to ChannelPose. Safe to change
+ /// while bound; takes effect on the next frame.
+ ///
+ public int Channels { get; set; } = ChannelPose;
+
+ ///
+ /// SpaceLocal (default) or SpaceWorld. Applies to position and rotation; scale is
+ /// always local because Unity exposes no world-scale setter.
+ ///
+ public int Space { get; set; } = SpaceLocal;
+
+ ///
+ /// PhaseLateUpdate (default) or PhaseUpdate. Changing this re-subscribes
+ /// immediately.
+ ///
+ public int Phase
+ {
+ get { return phase; }
+ set
+ {
+ if (phase == value)
+ {
+ return;
+ }
+ phase = value;
+ Unhook();
+ UpdateHook();
+ }
+ }
+
+ /// Set false to pause copying without unbinding.
+ public bool Enabled { get; set; } = true;
+
+ /// Number of transform pairs currently bound.
+ public int PairCount { get { return count; } }
+
+ // ---- Binding -----------------------------------------------------------------
+
+ ///
+ /// Bind (or re-bind) the transform pairs to copy: target[i] takes source[i]'s
+ /// state each frame, per and . Arrays
+ /// are COPIED, so the caller may rebuild or drop its own freely. Pairing is by
+ /// index and the shorter length wins. Passing empty or null arrays unbinds the
+ /// binding.
+ ///
+ ///
+ /// Object whose destruction ends the subscription — normally the calling
+ /// behaviour itself (this). Pass null to opt out, in which case calling
+ /// Dispose is mandatory.
+ ///
+ public void BindTransforms(Transform[] sourceTransforms, Transform[] targetTransforms, UnityEngine.Object lifetimeOwner)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ owner = lifetimeOwner;
+
+ int pairs = 0;
+ if (sourceTransforms != null && targetTransforms != null)
+ {
+ pairs = sourceTransforms.Length < targetTransforms.Length
+ ? sourceTransforms.Length
+ : targetTransforms.Length;
+ }
+
+ if (pairs > MaxPairs)
+ {
+ Debug.LogWarning("[BasisTransformSyncShim] " + pairs + " transform pairs requested; copying the first "
+ + MaxPairs + " only.");
+ pairs = MaxPairs;
+ }
+
+ ClearPairs();
+
+ if (pairs > 0)
+ {
+ // Private copies: the caller's arrays may be sandbox-owned and replaced
+ // on its next rebuild while we are mid-iteration on a later frame.
+ Transform[] src = new Transform[pairs];
+ Transform[] dst = new Transform[pairs];
+ Array.Copy(sourceTransforms, src, pairs);
+ Array.Copy(targetTransforms, dst, pairs);
+ source = src;
+ target = dst;
+ count = pairs;
+ }
+
+ UpdateHook();
+ }
+
+
+ ///
+ /// Renderers to test before doing any work — the copy is skipped entirely on
+ /// frames where none of them is being rendered by any camera. Pass null or an
+ /// empty array to always copy. Cheap and worth setting for anything that can be
+ /// off screen.
+ ///
+ public void SetVisibilityGate(Renderer[] renderers)
+ {
+ if (disposed)
+ {
+ return;
+ }
+ if (renderers == null || renderers.Length == 0)
+ {
+ gate = Array.Empty();
+ gateCount = 0;
+ return;
+ }
+ Renderer[] g = new Renderer[renderers.Length];
+ Array.Copy(renderers, g, renderers.Length);
+ gate = g;
+ gateCount = g.Length;
+ }
+
+ /// Stop copying and drop everything bound, keeping the shim reusable.
+ public void Unbind()
+ {
+ ClearPairs();
+ gate = Array.Empty();
+ gateCount = 0;
+ Unhook();
+ }
+
+ /// Unbind and refuse further binds. Idempotent.
+ public void Dispose()
+ {
+ disposed = true;
+ Unbind();
+ owner = null;
+ }
+
+ private void ClearPairs()
+ {
+ source = Array.Empty();
+ target = Array.Empty();
+ count = 0;
+ }
+
+
+ // ---- Ticking -----------------------------------------------------------------
+
+ ///
+ /// Subscribe only while there is something to copy, so an idle or unbound shim
+ /// costs the event driver nothing.
+ ///
+ private void UpdateHook()
+ {
+ if (disposed || count == 0)
+ {
+ Unhook();
+ return;
+ }
+
+ // Drop the wrong-phase subscription as well as adding the right one, so this
+ // is correct regardless of which path got us here.
+ if (phase == PhaseUpdate)
+ {
+ if (hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate -= HandleTick;
+ hookedLate = false;
+ }
+ if (!hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate += HandleTick;
+ hookedUpdate = true;
+ }
+ }
+ else
+ {
+ if (hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate -= HandleTick;
+ hookedUpdate = false;
+ }
+ if (!hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate += HandleTick;
+ hookedLate = true;
+ }
+ }
+ }
+
+ private void Unhook()
+ {
+ if (hookedLate)
+ {
+ BasisEventDriver.OnLateUpdate -= HandleTick;
+ hookedLate = false;
+ }
+ if (hookedUpdate)
+ {
+ BasisEventDriver.OnUpdate -= HandleTick;
+ hookedUpdate = false;
+ }
+ }
+
+ private void HandleTick()
+ {
+ // Owner destroyed and nobody called Dispose: clean up after them. The
+ // ReferenceEquals guard distinguishes "no owner was given" (opted out) from
+ // "owner was given and has since been destroyed", which Unity's == null
+ // reports identically.
+ if (!ReferenceEquals(owner, null) && owner == null)
+ {
+ Dispose();
+ return;
+ }
+
+ if (disposed || !Enabled || count == 0)
+ {
+ return;
+ }
+
+ if (!AnyGateVisible())
+ {
+ return;
+ }
+
+ CopyTransforms();
+ }
+
+ ///
+ /// Run one copy right now, ignoring and the tick phase but
+ /// still honouring the visibility gate. This is the seam for driving ordering by
+ /// hand: set false so the automatic tick does nothing, then
+ /// call this exactly where you want the work to land relative to other systems.
+ /// Only valid on the main thread.
+ ///
+ public void Sync()
+ {
+ if (disposed || count == 0 || !AnyGateVisible())
+ {
+ return;
+ }
+ CopyTransforms();
+ }
+
+ private void CopyTransforms()
+ {
+ int pairs = count;
+ if (pairs == 0)
+ {
+ return;
+ }
+
+ // Decide the shape of the work ONCE, outside the loop.
+ int channels = Channels;
+ bool doPosition = (channels & ChannelPosition) != 0;
+ bool doRotation = (channels & ChannelRotation) != 0;
+ bool doScale = (channels & ChannelScale) != 0;
+ if (!doPosition && !doRotation && !doScale)
+ {
+ return;
+ }
+ bool world = Space == SpaceWorld;
+ bool combined = doPosition && doRotation;
+
+ Transform[] src = source;
+ Transform[] dst = target;
+
+ for (int i = 0; i < pairs; i++)
+ {
+ Transform s = src[i];
+ Transform d = dst[i];
+ if (s == null || d == null)
+ {
+ continue;
+ }
+
+ if (combined)
+ {
+ // One paired get/set beats two property round trips.
+ if (world)
+ {
+ s.GetPositionAndRotation(out Vector3 worldPosition, out Quaternion worldRotation);
+ d.SetPositionAndRotation(worldPosition, worldRotation);
+ }
+ else
+ {
+ s.GetLocalPositionAndRotation(out Vector3 localPosition, out Quaternion localRotation);
+ d.SetLocalPositionAndRotation(localPosition, localRotation);
+ }
+ }
+ else if (doPosition)
+ {
+ if (world)
+ {
+ d.position = s.position;
+ }
+ else
+ {
+ d.localPosition = s.localPosition;
+ }
+ }
+ else if (doRotation)
+ {
+ if (world)
+ {
+ d.rotation = s.rotation;
+ }
+ else
+ {
+ d.localRotation = s.localRotation;
+ }
+ }
+
+ if (doScale)
+ {
+ // Local only: Transform exposes no world-scale setter (lossyScale is
+ // read-only), so there is nothing sane to do for SpaceWorld here.
+ d.localScale = s.localScale;
+ }
+ }
+ }
+
+
+ ///
+ /// True when there is no gate, or when at least one gated renderer is being
+ /// rendered by some camera this frame.
+ ///
+ private bool AnyGateVisible()
+ {
+ int gates = gateCount;
+ if (gates == 0)
+ {
+ return true;
+ }
+ Renderer[] renderers = gate;
+ for (int i = 0; i < gates; i++)
+ {
+ Renderer renderer = renderers[i];
+ if (renderer != null && renderer.isVisible)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}