// ╔════════════════════════════════════════════════════════════════╗ // ║ Copyright © 2025 NWH Coding d.o.o. All rights reserved. ║ // ║ Licensed under Unity Asset Store Terms of Service: ║ // ║ https://unity.com/legal/as-terms ║ // ║ Use permitted only in compliance with the License. ║ // ║ Distributed "AS IS", without warranty of any kind. ║ // ╚════════════════════════════════════════════════════════════════╝ #region using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Events; using UnityEngine.SceneManagement; #if UNITY_EDITOR using UnityEditor; using NWH.NUI; #endif #endregion namespace NWH.Common.ShiftingOrigin { /// /// Prevents floating point precision errors by shifting all scene objects back toward world origin /// when the main camera exceeds the distance threshold. /// /// /// /// As objects move far from world origin [0,0,0], floating point precision degrades causing /// physics jitter and rendering artifacts. ShiftingOrigin solves this by periodically moving /// all scene content back toward origin, keeping the player near [0,0,0] at all times. /// /// /// The shift is transparent to gameplay - relative positions remain identical. Useful for /// open world games, flight simulators, or any scenario with large travel distances. /// /// /// Only affects the current scene. For multi-scene setups, ensure one ShiftingOrigin instance /// per loaded scene set. /// /// public class ShiftingOrigin : MonoBehaviour { public static ShiftingOrigin Instance; /// /// Distance from world origin in meters that triggers an origin shift. /// Default 500m works well for most scenarios. /// public float distanceThreshold = 500f; /// /// Event invoked after the origin shift completes and physics is re-synced. /// public UnityEvent onAfterJump = new(); /// /// Event invoked before the origin shift begins. Rigidbody sleep thresholds are temporarily disabled. /// public UnityEvent onBeforeJump = new(); private Camera _cameraMain; private Vector3 _cameraPosition; private Transform _cameraTransform; private ParticleSystem.Particle[] _particles; // Cached to avoid expensive FindObjectsByType calls private List _cachedRigidbodies = new List(); private List _originalSleepThresholds = new List(); private List _cachedParticleSystems = new List(); private int _cameraCheckFrameCounter = 0; /// /// Cumulative offset applied to all objects since scene start. /// Useful for tracking absolute world position despite origin shifts. /// public Vector3 TotalOffset { get; private set; } private void Awake() { Debug.Assert(Instance == null, "Only one ShiftingOrigin script can be present in a scene."); Instance = this; onBeforeJump.AddListener(BeforeJump); onAfterJump.AddListener(AfterJump); } private void Start() { _cameraMain = Camera.main; if (_cameraMain != null) { _cameraTransform = _cameraMain.transform; } RefreshCaches(); } /// /// Refreshes cached references to Rigidbodies and ParticleSystems. /// Call this if new physics objects or particle systems are added to the scene at runtime. /// public void RefreshCaches() { _cachedRigidbodies.Clear(); _originalSleepThresholds.Clear(); _cachedParticleSystems.Clear(); // Cache Rigidbodies and preserve their original sleepThreshold values var rbs = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); foreach (var rb in rbs) { if (rb != null) { _cachedRigidbodies.Add(rb); _originalSleepThresholds.Add(rb.sleepThreshold); } } // Cache ParticleSystems var pss = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); _cachedParticleSystems.AddRange(pss); } private void LateUpdate() { // Check Camera.main every 60 frames to support runtime camera switching _cameraCheckFrameCounter++; if (_cameraCheckFrameCounter >= 60) { Camera newCamera = Camera.main; if (newCamera != _cameraMain) { _cameraMain = newCamera; _cameraTransform = _cameraMain != null ? _cameraMain.transform : null; } _cameraCheckFrameCounter = 0; } if (_cameraMain == null) { return; } if (_cameraTransform == null) { return; } _cameraPosition = _cameraTransform.position; if (_cameraPosition.magnitude > distanceThreshold) { Jump(); } } private static List FindObjects() where T : Object { return FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).ToList(); // Not the fastest solution } private void BeforeJump() { for (int i = 0; i < _cachedRigidbodies.Count; i++) { if (_cachedRigidbodies[i] != null) { _cachedRigidbodies[i].sleepThreshold = float.MaxValue; } } } private void AfterJump() { // Restore original values to preserve custom physics settings for (int i = 0; i < _cachedRigidbodies.Count; i++) { if (_cachedRigidbodies[i] != null && i < _originalSleepThresholds.Count) { _cachedRigidbodies[i].sleepThreshold = _originalSleepThresholds[i]; } } Physics.SyncTransforms(); } private void Jump() { onBeforeJump.Invoke(); TotalOffset += _cameraPosition; // Move root transforms for (int i = 0; i < SceneManager.sceneCount; i++) { foreach (GameObject g in SceneManager.GetSceneAt(i).GetRootGameObjects()) { if (g != null && g.transform != null) { g.transform.position -= _cameraPosition; } } } // Move particles for (int pi = 0; pi < _cachedParticleSystems.Count; pi++) { ParticleSystem ps = _cachedParticleSystems[pi]; if (ps == null) { continue; } ParticleSystem.MainModule main = ps.main; if (main.simulationSpace != ParticleSystemSimulationSpace.World) { continue; } int maxParticles = main.maxParticles; if (maxParticles == 0) { continue; } bool wasPaused = ps.isPaused; bool wasPlaying = ps.isPlaying; if (!wasPaused) { ps.Pause(); } if (_particles == null || _particles.Length < maxParticles) { _particles = new ParticleSystem.Particle[maxParticles]; } int num = ps.GetParticles(_particles); for (int i = 0; i < num; i++) { _particles[i].position -= _cameraPosition; } ps.SetParticles(_particles, num); if (wasPlaying) { ps.Play(); } } onAfterJump.Invoke(); } } } #if UNITY_EDITOR namespace NWH.Common.ShiftingOrigin { [CustomEditor(typeof(ShiftingOrigin))] public class ShiftingOriginEditor : NUIEditor { public override bool OnInspectorNUI() { if (!base.OnInspectorNUI()) { return false; } drawer.Field("distanceThreshold"); drawer.Field("onBeforeJump"); drawer.Field("onAfterJump"); drawer.EndEditor(this); return true; } } } #endif