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; + } + } +}