Skip to content

Commit 0866767

Browse files
committed
feat(trackerobjects): tracker-to-prop binding package
Adds com.basis.trackerobjects, a runtime package for binding a VR tracker to a user-spawned prop. The binding drives the prop's transform from the tracker's pose each frame. Public surface (BasisTrackerObjectManager): - TryCreateBinding(BasisInput, Transform, string netID, out BasisTrackerBinding) - TryRemoveBinding(string id) - TryGetBindingByLoadedNetID(string netID, out BasisTrackerBinding) Each BasisTrackerBinding holds the device + target and re-asserts isKinematic = true each frame on the bound rigidbody so BasisObjectSyncNetworking can't reset it back to dynamic mid-session. v1 spec at Packages/com.basis.trackerobjects/REQUIREMENTS.md.
1 parent 5e1cecf commit 0866767

13 files changed

Lines changed: 559 additions & 0 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 BasisVR
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta

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

Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md

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

Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta

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

Basis/Packages/com.basis.trackerobjects/Runtime.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Basis.Scripts.BasisSdk.Interactions;
2+
using Basis.Scripts.Device_Management.Devices;
3+
using UnityEngine;
4+
5+
namespace Basis.TrackerObjects
6+
{
7+
public class BasisTrackerBinding
8+
{
9+
public int Id;
10+
public BasisInput Tracker;
11+
public Transform Target;
12+
public string UniqueDeviceIdentifier;
13+
public string LoadedNetID;
14+
public Vector3 LocalPositionOffset;
15+
public Quaternion LocalRotationOffset;
16+
17+
public BasisPickupInteractable PickupRef;
18+
public Rigidbody RigidRef;
19+
public bool PreBindKinematic;
20+
public bool HasKinematicCaptured;
21+
}
22+
}

Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Basis.Scripts.BasisSdk.Interactions;
4+
using Basis.Scripts.BasisSdk.Players;
5+
using Basis.Scripts.Device_Management.Devices;
6+
using UnityEngine;
7+
8+
namespace Basis.TrackerObjects
9+
{
10+
public static class BasisTrackerObjectManager
11+
{
12+
public const int RenderPriority = 99;
13+
14+
public static readonly List<BasisTrackerBinding> Bindings = new List<BasisTrackerBinding>();
15+
16+
public static event Action<BasisTrackerBinding> OnBindingCreated;
17+
public static event Action<BasisTrackerBinding> OnBindingRemoved;
18+
19+
private static int _nextID = 1;
20+
private static bool _subscribed;
21+
22+
// Single shared deny predicates — each binding lives on a distinct
23+
// BasisPickupInteractable (enforced by the LoadedNetID dedup), so the same
24+
// delegate instance is added once per pickup list and removed once on unbind.
25+
private static readonly Func<BasisInput, bool> _denyHover = static _ => false;
26+
private static readonly Func<BasisInput, bool> _denyInteract = static _ => false;
27+
28+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
29+
private static void Initialize()
30+
{
31+
if (_subscribed)
32+
{
33+
return;
34+
}
35+
BasisLocalPlayer.AfterSimulateOnRender.AddAction(RenderPriority, OnAfterSimulateOnRender);
36+
BasisRuntimeSpawnRegistry.OnRegistryChanged += OnRegistryChanged;
37+
_subscribed = true;
38+
BasisDebug.Log("BasisTrackerObjectManager subscribed", BasisDebug.LogTag.TrackerObjects);
39+
}
40+
41+
public static bool TryCreateBinding(BasisInput tracker, Transform target, string loadedNetID, out int id)
42+
{
43+
id = 0;
44+
if (tracker == null || target == null)
45+
{
46+
BasisDebug.LogError("TryCreateBinding: tracker or target was null", BasisDebug.LogTag.TrackerObjects);
47+
return false;
48+
}
49+
if (string.IsNullOrEmpty(loadedNetID))
50+
{
51+
BasisDebug.LogError("TryCreateBinding: loadedNetID was null/empty", BasisDebug.LogTag.TrackerObjects);
52+
return false;
53+
}
54+
if (TryGetBindingByLoadedNetID(loadedNetID, out _))
55+
{
56+
BasisDebug.LogWarning($"TryCreateBinding: a binding for LoadedNetID {loadedNetID} already exists", BasisDebug.LogTag.TrackerObjects);
57+
return false;
58+
}
59+
60+
tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot);
61+
target.GetPositionAndRotation(out Vector3 targetPos, out Quaternion targetRot);
62+
Quaternion invRot = Quaternion.Inverse(trackerRot);
63+
64+
id = _nextID++;
65+
BasisTrackerBinding binding = new BasisTrackerBinding
66+
{
67+
Id = id,
68+
Tracker = tracker,
69+
Target = target,
70+
UniqueDeviceIdentifier = tracker.UniqueDeviceIdentifier,
71+
LoadedNetID = loadedNetID,
72+
LocalPositionOffset = invRot * (targetPos - trackerPos),
73+
LocalRotationOffset = invRot * targetRot,
74+
};
75+
76+
if (target.TryGetComponent(out BasisPickupInteractable pickup))
77+
{
78+
binding.PickupRef = pickup;
79+
pickup.CanHoverInjected.Add(_denyHover);
80+
pickup.CanInteractInjected.Add(_denyInteract);
81+
82+
if (pickup.RigidRef != null)
83+
{
84+
binding.RigidRef = pickup.RigidRef;
85+
binding.PreBindKinematic = pickup.RigidRef.isKinematic;
86+
binding.HasKinematicCaptured = true;
87+
pickup.RigidRef.isKinematic = true;
88+
}
89+
}
90+
91+
if (!target.TryGetComponent<BasisNetworkContentBase>(out _))
92+
{
93+
BasisDebug.LogWarning($"TryCreateBinding: target {target.name} has no BasisNetworkContentBase — local-only motion, remote players will not see the binding move", BasisDebug.LogTag.TrackerObjects);
94+
}
95+
96+
Bindings.Add(binding);
97+
BasisDebug.Log($"Created tracker binding {id} for {tracker.UniqueDeviceIdentifier} -> {target.name} (netID {loadedNetID})", BasisDebug.LogTag.TrackerObjects);
98+
OnBindingCreated?.Invoke(binding);
99+
return true;
100+
}
101+
102+
public static bool TryRemoveBinding(int id)
103+
{
104+
int count = Bindings.Count;
105+
for (int index = 0; index < count; index++)
106+
{
107+
if (Bindings[index].Id == id)
108+
{
109+
RemoveAt(index);
110+
return true;
111+
}
112+
}
113+
return false;
114+
}
115+
116+
public static bool TryGetBindingByLoadedNetID(string loadedNetID, out BasisTrackerBinding binding)
117+
{
118+
binding = null;
119+
if (string.IsNullOrEmpty(loadedNetID))
120+
{
121+
return false;
122+
}
123+
int count = Bindings.Count;
124+
for (int index = 0; index < count; index++)
125+
{
126+
BasisTrackerBinding b = Bindings[index];
127+
if (b.LoadedNetID == loadedNetID)
128+
{
129+
binding = b;
130+
return true;
131+
}
132+
}
133+
return false;
134+
}
135+
136+
private static void RemoveAt(int index)
137+
{
138+
BasisTrackerBinding binding = Bindings[index];
139+
if (binding.PickupRef != null)
140+
{
141+
binding.PickupRef.CanHoverInjected.Remove(_denyHover);
142+
binding.PickupRef.CanInteractInjected.Remove(_denyInteract);
143+
}
144+
if (binding.HasKinematicCaptured && binding.RigidRef != null)
145+
{
146+
binding.RigidRef.isKinematic = binding.PreBindKinematic;
147+
}
148+
Bindings.RemoveAt(index);
149+
BasisDebug.Log($"Removed tracker binding {binding.Id}", BasisDebug.LogTag.TrackerObjects);
150+
OnBindingRemoved?.Invoke(binding);
151+
}
152+
153+
private static void OnAfterSimulateOnRender()
154+
{
155+
int count = Bindings.Count;
156+
for (int index = 0; index < count; index++)
157+
{
158+
BasisTrackerBinding binding = Bindings[index];
159+
if (binding.Tracker == null || binding.Target == null)
160+
{
161+
continue;
162+
}
163+
// BasisObjectSyncNetworking.Awake and ControlState both flip
164+
// isKinematic = false on locally-owned props, and ControlState can
165+
// re-fire on ownership-transfer events long after bind. If physics
166+
// touches the rigidbody between our writes, Scene view samples those
167+
// intermediate frames (out of step with onBeforeRender) and flickers
168+
// even when Game view stays clean. Re-asserting kinematic each frame
169+
// is cheap and avoids playing whack-a-mole with every external setter.
170+
if (binding.HasKinematicCaptured && binding.RigidRef != null)
171+
{
172+
binding.RigidRef.isKinematic = true;
173+
}
174+
binding.Tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot);
175+
binding.Target.SetPositionAndRotation(
176+
trackerPos + trackerRot * binding.LocalPositionOffset,
177+
trackerRot * binding.LocalRotationOffset);
178+
}
179+
}
180+
181+
private static void OnRegistryChanged(BasisRuntimeSpawnRegistry.RegistryChangeType type, BasisRuntimeSpawnRegistry.SpawnInstance instance)
182+
{
183+
switch (type)
184+
{
185+
case BasisRuntimeSpawnRegistry.RegistryChangeType.Removed:
186+
case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedUrl:
187+
if (instance != null && TryGetBindingByLoadedNetID(instance.LoadedNetID, out BasisTrackerBinding binding))
188+
{
189+
TryRemoveBinding(binding.Id);
190+
}
191+
break;
192+
case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedAll:
193+
for (int index = Bindings.Count - 1; index >= 0; index--)
194+
{
195+
RemoveAt(index);
196+
}
197+
break;
198+
}
199+
}
200+
}
201+
}

Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "BasisTrackerObjects",
3+
"rootNamespace": "Basis.TrackerObjects",
4+
"references": [
5+
"Basis Framework",
6+
"BasisSDK",
7+
"BasisCommon",
8+
"BasisDebug"
9+
],
10+
"includePlatforms": [],
11+
"excludePlatforms": [],
12+
"allowUnsafeCode": false,
13+
"overrideReferences": false,
14+
"precompiledReferences": [],
15+
"autoReferenced": true,
16+
"defineConstraints": [],
17+
"versionDefines": [],
18+
"noEngineReferences": false
19+
}

0 commit comments

Comments
 (0)