diff --git a/VisualPinball.Engine/Common/Constants.cs b/VisualPinball.Engine/Common/Constants.cs
index 4bb1623e1..c003e00d0 100644
--- a/VisualPinball.Engine/Common/Constants.cs
+++ b/VisualPinball.Engine/Common/Constants.cs
@@ -66,7 +66,7 @@ public static class PhysicsConstants
public const float DefaultStepTimeS = 0.01f; // DEFAULT_STEPTIME_S
- public const double PhysFactor = PhysicsStepTimeS / DefaultStepTimeS; // PHYS_FACTOR
+ public const float PhysFactor = (float)(PhysicsStepTimeS / DefaultStepTimeS); // PHYS_FACTOR
public const float LowNormVel = 0.0001f; // C_LOWNORMVEL
@@ -113,10 +113,19 @@ public static class PhysicsConstants
public const float ToleranceEndPoints = 0.0f; // C_TOL_ENDPNTS
public const float ToleranceRadius = 0.005f; // C_TOL_RADIUS
- ///
- /// Precision level and cycles for interative calculations // acceptable contact time ... near zero time
- ///
- public const int Internations = 20; // C_INTERATIONS
+ ///
+ /// Precision level and cycles for interative calculations // acceptable contact time ... near zero time
+ ///
+ public const int Internations = 20; // C_INTERATIONS
+
+ ///
+ /// Maximum number of physics sub-steps per frame.
+ /// If the physics loop falls behind (e.g. due to a frame hitch), it
+ /// will catch up for at most this many iterations before skipping
+ /// physics time forward. Prevents hitch cascades.
+ /// 200 iterations = 200ms of physics at 1kHz step rate.
+ ///
+ public const int MaxSubSteps = 200; // PHYSICS_MAX_LOOPS
}
public static class InputConstants
diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab
index b0f27b09b..a5cdcea82 100644
--- a/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab
+++ b/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab
@@ -11,6 +11,7 @@ GameObject:
- component: {fileID: 3881672604355561253}
- component: {fileID: 6075728238804368159}
- component: {fileID: 5180081487853661404}
+ - component: {fileID: 8313200498354517976}
m_Layer: 0
m_Name: DefaultBall
m_TagString: Untagged
@@ -25,12 +26,13 @@ Transform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8289283333368007096}
+ serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &6075728238804368159
MeshFilter:
@@ -51,11 +53,17 @@ MeshRenderer:
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
+ m_RayTracingAccelStructBuildFlagsOverride: 0
+ m_RayTracingAccelStructBuildFlags: 1
+ m_SmallMeshCulling: 1
+ m_ForceMeshLod: -1
+ m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
@@ -77,7 +85,27 @@ MeshRenderer:
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
+ m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
+--- !u!114 &8313200498354517976
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8289283333368007096}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: a04fca20ce2246b9abf456441b587efd, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: VisualPinball.Unity::VisualPinball.Unity.BallComponent
+ Radius: 25
+ Mass: 1
+ Velocity:
+ x: 0
+ y: 0
+ z: 0
+ IsFrozen: 0
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering.meta
new file mode 100644
index 000000000..fa492a369
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: d517d975ada2452f92fbecd868c5458a
+timeCreated: 1758552018
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/IMaterialAdapter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IMaterialAdapter.cs
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/IMaterialAdapter.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IMaterialAdapter.cs
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/IMaterialAdapter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IMaterialAdapter.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/IMaterialAdapter.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IMaterialAdapter.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/IPrefabProvider.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IPrefabProvider.cs
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/IPrefabProvider.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IPrefabProvider.cs
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/IPrefabProvider.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IPrefabProvider.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/IPrefabProvider.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/IPrefabProvider.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs
new file mode 100644
index 000000000..c842a18ff
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs
@@ -0,0 +1,92 @@
+// Visual Pinball Engine
+// Copyright (C) 2023 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Linq;
+using NLog;
+using UnityEngine;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity.Editor
+{
+ ///
+ /// A common interface for all render pipelines that covers material
+ /// creation, lighting setup and ball creation.
+ ///
+ public interface IRenderPipelineConverter
+ {
+ ///
+ /// Name of the render pipeline
+ ///
+ string Name { get; }
+
+ ///
+ /// Type of the render pipeline.
+ ///
+ RenderPipelineType Type { get; }
+
+ ///
+ /// Provides a bunch of helper methods for setting common attributes
+ /// in materials.
+ ///
+ IMaterialAdapter MaterialAdapter { get; }
+
+ ///
+ /// Provides access to VPE's game item prefabs.
+ ///
+ IPrefabProvider PrefabProvider { get; }
+ }
+
+ ///
+ /// A global static class that checks which render pipeline implementations
+ /// are available and instantiates an SRP if available or the included
+ /// built-in instance otherwise.
+ ///
+ public static class RenderPipelineConverter
+ {
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ private static IRenderPipelineConverter _current;
+
+ ///
+ /// Returns the currently instantiated render pipeline.
+ ///
+ public static IRenderPipelineConverter Current {
+ get {
+ if (_current == null) {
+ Debug.Log("Detecting render pipeline converter...");
+ var t = typeof(IRenderPipelineConverter);
+ var pipelines = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(x => x.FullName.StartsWith("VisualPinball."))
+ .SelectMany(x => x.GetTypes())
+ .Where(x => x.IsClass && t.IsAssignableFrom(x))
+ .Select(x => (IRenderPipelineConverter) Activator.CreateInstance(x))
+ .ToArray();
+
+ Debug.Log("Found pipelines: " + string.Join(", ", pipelines.Select(p => p.Name)));
+
+ _current = pipelines.Length == 1
+ ? pipelines.First()
+ : pipelines.First(p => p.Type != RenderPipelineType.Standard);
+
+ Debug.Log($"Instantiated {_current.Name}.");
+ Logger.Info($"Instantiated {_current.Name}.");
+ }
+ return _current;
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs.meta
new file mode 100644
index 000000000..34e5eb272
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 9736826defbc4a88a18ebb9bf47f6bf4
+timeCreated: 1758552092
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard.meta
new file mode 100644
index 000000000..a1418bc54
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 387e1e79a16b4ab1aaa59b8cccafe59b
+timeCreated: 1758552033
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardMaterialAdapter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardMaterialAdapter.cs
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardMaterialAdapter.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardMaterialAdapter.cs
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardMaterialAdapter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardMaterialAdapter.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardMaterialAdapter.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardMaterialAdapter.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardPrefabProvider.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardPrefabProvider.cs
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardPrefabProvider.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardPrefabProvider.cs
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardPrefabProvider.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardPrefabProvider.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardPrefabProvider.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardPrefabProvider.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardRenderPipeline.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardRenderPipeline.cs
similarity index 88%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardRenderPipeline.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardRenderPipeline.cs
index 6d472e8dd..669a097cf 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardRenderPipeline.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardRenderPipeline.cs
@@ -16,9 +16,11 @@
// ReSharper disable UnusedType.Global
+using VisualPinball.Unity.Editor;
+
namespace VisualPinball.Unity
{
- public class StandardRenderPipeline : IRenderPipeline
+ public class StandardRenderPipeline : IRenderPipelineConverter
{
public string Name { get; } = "Built-in Render Pipeline";
@@ -26,7 +28,6 @@ public class StandardRenderPipeline : IRenderPipeline
public IMaterialConverter MaterialConverter { get; } = new StandardMaterialConverter();
public IMaterialAdapter MaterialAdapter { get; } = new StandardMaterialAdapter();
public ILightConverter LightConverter { get; } = new StandardLightConverter();
- public IBallConverter BallConverter { get; } = new StandardBallConverter();
public IPrefabProvider PrefabProvider { get; } = new StandardPrefabProvider();
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardRenderPipeline.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardRenderPipeline.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Rendering/Standard/StandardRenderPipeline.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard/StandardRenderPipeline.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs
new file mode 100644
index 000000000..27454ffaf
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs
@@ -0,0 +1,221 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Assets/Editor/GuidPrefabReferenceReplacer.cs
+// Two-field YAML replacer for prefab references.
+// Replaces occurrences of "guid: " with the new prefab's GUID and normalizes fileID/type
+// so references point to the prefab root (fileID: 100100000, type: 3).
+// Unity 2020+
+//
+// SAFETY: Use version control. Test with Dry Run first.
+//
+// Namespace per your request.
+
+// Assets/Editor/MissingPrefabInstanceReplacer.cs
+// Rebinds scene Prefab instances with MissingAsset status to a new prefab asset.
+// Unity 2022.3+/Unity 6+
+//
+// Uses PrefabUtility.ReplacePrefabAssetOfPrefabInstance / ReplacePrefabAssetOfPrefabInstances
+// to rebase instances, preserving overrides by matching hierarchy paths.
+//
+// NOTE: Works on *instances* (the red, missing ones). It does not edit raw YAML.
+//
+// Namespace: VisualPinball.Unity.Editor
+
+#if UNITY_EDITOR
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace VisualPinball.Unity.Editor
+{
+ public class MissingPrefabInstanceReplacer : EditorWindow
+ {
+ [MenuItem("Tools/Missing Prefab Instance Replacer")]
+ public static void Open() => GetWindow("Missing Prefab Replacer");
+
+ [SerializeField] private GameObject newPrefabAsset; // target prefab asset (root)
+
+ private List _missingRoots = new List();
+ private Vector2 _scroll;
+
+ void OnGUI()
+ {
+ EditorGUILayout.HelpBox(
+ "Finds Prefab instances in open scenes whose source asset is missing, "
+ + "then replaces their Prefab Asset with the new Prefab (preserving overrides).",
+ MessageType.Info);
+
+ newPrefabAsset = (GameObject)EditorGUILayout.ObjectField(
+ new GUIContent("New Prefab (asset)"),
+ newPrefabAsset, typeof(GameObject), false);
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Scan Open Scenes")) ScanOpenScenes();
+ using (new EditorGUI.DisabledScope(_missingRoots.Count == 0))
+ {
+ if (GUILayout.Button($"Replace ALL ({_missingRoots.Count})"))
+ ReplaceMany(_missingRoots);
+ }
+ }
+
+ EditorGUILayout.Space(6);
+ EditorGUILayout.LabelField($"Missing instances found: {_missingRoots.Count}", EditorStyles.boldLabel);
+
+ using (var sv = new EditorGUILayout.ScrollViewScope(_scroll, GUILayout.MinHeight(250)))
+ {
+ _scroll = sv.scrollPosition;
+ for (int i = 0; i < _missingRoots.Count; i++)
+ {
+ var go = _missingRoots[i];
+ if (!go) continue;
+
+ EditorGUILayout.BeginVertical("box");
+ EditorGUILayout.LabelField(go.name, EditorStyles.boldLabel);
+ EditorGUILayout.LabelField(ScenePath(go), EditorStyles.miniLabel);
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ EditorGUILayout.ObjectField("Instance Root", go, typeof(GameObject), true);
+ using (new EditorGUI.DisabledScope(!newPrefabAsset))
+ {
+ if (GUILayout.Button("Replace This"))
+ ReplaceMany(new List { go });
+ }
+ }
+ EditorGUILayout.EndVertical();
+ }
+ }
+ }
+
+ private void ScanOpenScenes()
+ {
+ _missingRoots.Clear();
+
+ for (int s = 0; s < SceneManager.sceneCount; s++)
+ {
+ var scene = SceneManager.GetSceneAt(s);
+ if (!scene.isLoaded) continue;
+
+ foreach (var root in scene.GetRootGameObjects())
+ {
+ // include inactive, nested
+ var all = root.GetComponentsInChildren(true)
+ .Select(t => t.gameObject);
+
+ foreach (var go in all)
+ {
+ // Only consider *outermost* prefab instance roots
+ if (!PrefabUtility.IsOutermostPrefabInstanceRoot(go)) continue;
+
+ // Is this instance missing its source asset?
+ // (Either API works; both included for robustness across versions)
+ var status = PrefabUtility.GetPrefabInstanceStatus(go);
+ bool missing = PrefabUtility.IsPrefabAssetMissing(go) ||
+ status == PrefabInstanceStatus.MissingAsset;
+
+ if (missing) _missingRoots.Add(go);
+ }
+ }
+ }
+
+ // De-dup and keep stable order
+ _missingRoots = _missingRoots.Distinct().ToList();
+ Repaint();
+ EditorUtility.DisplayDialog("Scan complete",
+ $"Found {_missingRoots.Count} missing prefab instance(s) in open scenes.", "OK");
+ }
+
+ private void ReplaceMany(List instanceRoots)
+ {
+ if (!newPrefabAsset)
+ {
+ EditorUtility.DisplayDialog("Assign Prefab",
+ "Please assign the 'New Prefab (asset)' first.", "OK");
+ return;
+ }
+
+ // Prefer hierarchy-based matching to preserve overrides across names/paths
+ var settings = new PrefabReplacingSettings
+ {
+ logInfo = true,
+ objectMatchMode = ObjectMatchMode.ByHierarchy,
+ // Keep overrides unless you want to clear some types explicitly:
+ // prefabOverridesOptions = PrefabOverridesOptions.ClearAllNonDefaultOverrides
+ };
+
+ // Batch replacement API (Unity 6+). If unavailable, fall back to per-item.
+ try
+ {
+ PrefabUtility.ReplacePrefabAssetOfPrefabInstances(
+ instanceRoots.Where(x => x).ToArray(),
+ newPrefabAsset,
+ settings,
+ InteractionMode.AutomatedAction
+ );
+ }
+ catch
+ {
+ // Fallback: do one by one
+ foreach (var go in instanceRoots.Where(x => x))
+ {
+ PrefabUtility.ReplacePrefabAssetOfPrefabInstance(
+ go, newPrefabAsset, settings, InteractionMode.AutomatedAction);
+ }
+ }
+
+ // Save modified scenes
+ var touched = new HashSet();
+ foreach (var go in instanceRoots.Where(x => x))
+ touched.Add(go.scene);
+
+ foreach (var sc in touched)
+ {
+ if (sc.IsValid() && sc.isLoaded)
+ {
+ EditorSceneManager.MarkSceneDirty(sc);
+ EditorSceneManager.SaveScene(sc);
+ }
+ }
+
+ // Remove the ones we just fixed from the list and refresh UI
+ _missingRoots.RemoveAll(x => x == null || !PrefabUtility.IsPrefabAssetMissing(x));
+ Repaint();
+
+ EditorUtility.DisplayDialog("Done",
+ "Replacement complete. The red 'Missing Prefab' instances should now be normal prefab instances linked to the new asset.",
+ "OK");
+ }
+
+ private static string ScenePath(GameObject go)
+ {
+ // Pretty transform path for display
+ var stack = new System.Collections.Generic.Stack();
+ var t = go.transform;
+ while (t != null)
+ {
+ stack.Push(t.name);
+ t = t.parent;
+ }
+ return string.Join("/", stack);
+ }
+ }
+}
+#endif
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs.meta
new file mode 100644
index 000000000..6d6d67f57
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3a497f7ddae24e8a9b802fd6bfe87f7f
+timeCreated: 1755781679
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Bumper/BumperExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Bumper/BumperExtensions.cs
index c8040f5b6..c11ad2e41 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Bumper/BumperExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Bumper/BumperExtensions.cs
@@ -22,7 +22,7 @@ public static class BumperExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Bumper bumper)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateBumper();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateBumper();
return new VpxPrefab(prefab, bumper);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperExtensions.cs
index 76326086f..4a5866e84 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperExtensions.cs
@@ -22,7 +22,7 @@ public static class FlipperExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Flipper flipper)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateFlipper();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateFlipper();
return new VpxPrefab(prefab, flipper);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Gate/GateExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Gate/GateExtensions.cs
index c1a25f143..c7b6dbc32 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Gate/GateExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Gate/GateExtensions.cs
@@ -22,7 +22,7 @@ public static class GateExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Gate gate)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateGate(gate.Data.GateType);
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateGate(gate.Data.GateType);
return new VpxPrefab(prefab, gate);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetExtensions.cs
index 5036322ae..e69d6c6a4 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetExtensions.cs
@@ -24,8 +24,8 @@ public static class TargetExtensions
internal static IVpxPrefab InstantiatePrefab(this HitTarget hitTarget)
{
var prefab = hitTarget.Data.IsDropTarget
- ? RenderPipeline.Current.PrefabProvider.CreateDropTarget(hitTarget.Data.TargetType)
- : RenderPipeline.Current.PrefabProvider.CreateHitTarget(hitTarget.Data.TargetType);
+ ? RenderPipelineConverter.Current.PrefabProvider.CreateDropTarget(hitTarget.Data.TargetType)
+ : RenderPipelineConverter.Current.PrefabProvider.CreateHitTarget(hitTarget.Data.TargetType);
if (!prefab) {
throw new Exception($"Cannot instantiate prefab for target type {hitTarget.Data.TargetType}");
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Kicker/KickerExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Kicker/KickerExtensions.cs
index 9865ece2b..ae2f4081f 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Kicker/KickerExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Kicker/KickerExtensions.cs
@@ -22,7 +22,7 @@ public static class KickerExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Kicker kicker)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateKicker(kicker.Data.KickerType);
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateKicker(kicker.Data.KickerType);
return new VpxPrefab(prefab, kicker);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightExtensions.cs
index 54895101b..f8e9eb74f 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightExtensions.cs
@@ -24,8 +24,8 @@ public static class LightExtensions
internal static IVpxPrefab InstantiatePrefab(this Light light, Table table)
{
var prefab = light.IsInsertLight(table)
- ? RenderPipeline.Current.PrefabProvider.CreateInsertLight()
- : RenderPipeline.Current.PrefabProvider.CreateLight();
+ ? RenderPipelineConverter.Current.PrefabProvider.CreateInsertLight()
+ : RenderPipelineConverter.Current.PrefabProvider.CreateLight();
return new VpxPrefab(prefab, light);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Plunger/PlungerExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Plunger/PlungerExtensions.cs
index 1e9daa3b9..03f13fb17 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Plunger/PlungerExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Plunger/PlungerExtensions.cs
@@ -22,7 +22,7 @@ public static class PlungerExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Plunger plunger)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreatePlunger();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreatePlunger();
return new VpxPrefab(prefab, plunger);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerExtensions.cs
index bd68d3c42..f8281760d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerExtensions.cs
@@ -22,7 +22,7 @@ public static class SpinnerExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Spinner spinner)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateSpinner();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateSpinner();
return new VpxPrefab(prefab, spinner);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trough/TroughExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trough/TroughExtensions.cs
index 13e6ec44b..5dbde6e7d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trough/TroughExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trough/TroughExtensions.cs
@@ -22,7 +22,7 @@ public static class TroughExtensions
{
internal static IVpxPrefab InstantiatePrefab(this Trough trough)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateTrough();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateTrough();
return new VpxPrefab(prefab, trough);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef
index dbc1e90eb..411abede6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef
@@ -11,8 +11,7 @@
"Unity.Mathematics",
"Unity.InputSystem",
"VisualPinball.Engine",
- "VisualPinball.Unity",
- "VisualPinball.Unity.Patcher"
+ "VisualPinball.Unity"
],
"includePlatforms": [
"Editor"
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Matcher/TablePatcher.cs b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Matcher/TablePatcher.cs
index c3f00d545..4bb8e8376 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Matcher/TablePatcher.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Matcher/TablePatcher.cs
@@ -376,7 +376,7 @@ protected static LightComponent CreateLight(string name, float x, float y, GameO
var light = VpeLight.GetDefault(name, x, y);
light.Data.ShowBulbMesh = false;
- var prefab = RenderPipeline.Current.PrefabProvider.CreateLight();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateLight();
var lightGo = PrefabUtility.InstantiatePrefab(prefab, parentGo.transform) as GameObject;
if (!lightGo) {
return null;
@@ -411,7 +411,7 @@ protected GameObject ConvertToInsertLight(LightComponent lo)
///
private GameObject CreateInsertLight(LightData data, GameObject parentGo)
{
- var prefab = RenderPipeline.Current.PrefabProvider.CreateInsertLight();
+ var prefab = RenderPipelineConverter.Current.PrefabProvider.CreateInsertLight();
var go = PrefabUtility.InstantiatePrefab(prefab, parentGo.transform) as GameObject;
go!.name = data.Name;
data.OffImage = TableContainer.Table.Data.Image;
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/JurassicPark.cs b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/JurassicPark.cs
index 7d90b7197..6c8256e10 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/JurassicPark.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/JurassicPark.cs
@@ -20,6 +20,7 @@
using UnityEngine;
using UnityEngine.Rendering;
+using VisualPinball.Unity.Editor;
namespace VisualPinball.Unity.Patcher
{
@@ -56,7 +57,7 @@ public class JurassicPark : TablePatcher
[NameMatch("TrexMain")]
public void FixBrokenNormalMap(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetNormalMapDisabled(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetNormalMapDisabled(gameObject);
}
[NameMatch("LFLogo", Ref="Playfield/Flippers/LeftFlipper")]
@@ -74,13 +75,13 @@ public void ReparentFlippers(PrimitiveComponent primitive, GameObject gameObject
[NameMatch("PRightFlipper1")]
public void SetAlphaCutOffEnabled(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetAlphaCutOffEnabled(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetAlphaCutOffEnabled(gameObject);
}
[NameMatch("Primitive_Plastics")]
public void SetOpaque(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetOpaque(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetOpaque(gameObject);
}
[NameMatch("leftrail")]
@@ -89,7 +90,7 @@ public void SetOpaque(GameObject gameObject)
[NameMatch("sidewalls")]
public void SetDoubleSided(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetDoubleSided(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetDoubleSided(gameObject);
}
[NameMatch("Primitive_SideWallReflect")]
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/Mississippi.cs b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/Mississippi.cs
index c4652401b..1bd911a93 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/Mississippi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/Mississippi.cs
@@ -19,6 +19,7 @@
// ReSharper disable UnusedMember.Global
using UnityEngine;
+using VisualPinball.Unity.Editor;
namespace VisualPinball.Unity.Patcher
{
@@ -30,7 +31,7 @@ public class Mississippi : TablePatcher
public void SetDoubleSided(GameObject gameObject, ref GameObject child)
{
if (gameObject == child)
- RenderPipeline.Current.MaterialAdapter.SetDoubleSided(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetDoubleSided(gameObject);
}
///
@@ -51,7 +52,7 @@ public void SetTransparentDepthPrepassEnabled(GameObject gameObject, ref GameObj
{
if (gameObject == child)
{
- RenderPipeline.Current.MaterialAdapter.SetTransparentDepthPrepassEnabled(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetTransparentDepthPrepassEnabled(gameObject);
}
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/TomAndJerry.cs b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/TomAndJerry.cs
index 391b6fe25..3719e678d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/TomAndJerry.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/Patcher/Tables/TomAndJerry.cs
@@ -19,6 +19,7 @@
// ReSharper disable UnusedMember.Global
using UnityEngine;
+using VisualPinball.Unity.Editor;
using VisualPinball.Unity.Patcher.Matcher.Table;
namespace VisualPinball.Unity.Patcher
@@ -52,7 +53,7 @@ public void HideGameObject(GameObject gameObject)
[NameMatch("BumperCap3")] // Tom Bumper
public void SetOpaque(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetOpaque(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetOpaque(gameObject);
}
///
@@ -64,7 +65,7 @@ public void SetOpaque(GameObject gameObject)
[NameMatch("MusclesKnife")]
public void SetAlphaClip(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetAlphaCutOff(gameObject, 0.05f);
+ RenderPipelineConverter.Current.MaterialAdapter.SetAlphaCutOff(gameObject, 0.05f);
}
///
@@ -81,7 +82,7 @@ public void SetAlphaClip(GameObject gameObject)
[NameMatch("Primitive66")] // jerry at plunger
public void SetDoubleSided(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetDoubleSided(gameObject);
+ RenderPipelineConverter.Current.MaterialAdapter.SetDoubleSided(gameObject);
}
[NameMatch("Ramp5")]
@@ -91,7 +92,7 @@ public void SetDoubleSided(GameObject gameObject)
[NameMatch("Ramp20")]
public void SetMetallic(GameObject gameObject)
{
- RenderPipeline.Current.MaterialAdapter.SetMetallic(gameObject, 1.0f);
+ RenderPipelineConverter.Current.MaterialAdapter.SetMetallic(gameObject, 1.0f);
}
[NameMatch("Lflip", Ref = "Playfield/Flippers/LeftFlipper")]
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.asmdef
index 74b35105d..73c6710d8 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.asmdef
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.asmdef
@@ -4,8 +4,8 @@
"references": [
"VisualPinball.Engine",
"VisualPinball.Unity",
- "VisualPinball.Engine.PinMAME.Unity",
- "Unity.Entities.Hybrid"
+ "VisualPinball.Unity.Editor",
+ "VisualPinball.Engine.PinMAME.Unity"
],
"includePlatforms": [
"Editor"
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.csproj b/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.csproj
index 4aba3dd37..690432495 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.csproj
+++ b/VisualPinball.Unity/VisualPinball.Unity.Patcher/VisualPinball.Unity.Patcher.csproj
@@ -33,6 +33,7 @@
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs
index 98e77d59d..bb2a34e4c 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs
@@ -14,10 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-using System.Runtime.CompilerServices;
-using Unity.Collections;
-using Unity.Collections.LowLevel.Unsafe;
-using Unity.Mathematics;
+using System.Runtime.CompilerServices;
+using Unity.Mathematics;
namespace VisualPinball.Unity
{
@@ -75,14 +73,17 @@ public static float Random()
return (float) _random.NextDouble();
}
- public static bool Sign(float f)
- {
- var floats = new NativeArray(1, Allocator.Temp) { [0] = f };
- var b = floats.Reinterpret(UnsafeUtility.SizeOf());
- var sign = (b[3] & 0x80) == 0x80 && (b[2] & 0x00) == 0x00 && (b[1] & 0x00) == 0x00 && (b[0] & 0x00) == 0x00;
- floats.Dispose();
- return sign;
- }
+ ///
+ /// Returns true if is negative zero (-0.0f).
+ ///
+ ///
+ /// IEEE 754: -0.0f has bit pattern 0x80000000 (sign bit set, all other bits zero).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool Sign(float f)
+ {
+ return math.asint(f) == unchecked((int)0x80000000);
+ }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ulong NextMultipleOf16(ulong input) => ((input + 15) >> 4) << 4;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
index fd7ebc1e9..61b87de37 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
@@ -16,8 +16,7 @@
// ReSharper disable InconsistentNaming
-using System.Collections;
-using UnityEngine;
+using UnityEngine;
namespace VisualPinball.Unity
{
@@ -36,8 +35,11 @@ public class EventTransformComponent : MonoBehaviour
private IWireableComponent _wireComponent;
- private Vector3 _initialPosition;
- private Coroutine _enterCoroutine;
+ private Vector3 _initialPosition;
+ private bool _isAnimating;
+ private float _animationElapsed;
+ private float _animationDuration;
+ private AnimationCurve _animationCurve;
#region Runtime
@@ -85,38 +87,58 @@ private void OnCoilChanged(object sender, NoIdCoilEventArgs e)
}
}
- private void OnEnter()
- {
- if (_enterCoroutine != null) {
- StopCoroutine(_enterCoroutine);
- }
- _enterCoroutine = StartCoroutine(Animate(EnterAnimationDurationSeconds, EnterAnimationCurve));
- }
-
- private IEnumerator Animate(float duration, AnimationCurve curve)
- {
- var t = 0f;
- while (t < duration) {
- var f = curve.Evaluate(t / duration);
-
- if (TranslateGlobally) {
- transform.position = _initialPosition + Direction * f;
+ private void OnEnter()
+ {
+ _animationElapsed = 0f;
+ _animationDuration = EnterAnimationDurationSeconds;
+ _animationCurve = EnterAnimationCurve;
+ _isAnimating = true;
+ }
+
+ private void Update()
+ {
+ if (!_isAnimating) {
+ return;
+ }
+
+ if (_animationDuration <= 0f) {
+ ApplyTranslation(1f);
+ FinishAnimation();
+ return;
+ }
+
+ _animationElapsed += Time.deltaTime;
+ if (_animationElapsed < _animationDuration) {
+ var f = _animationCurve.Evaluate(_animationElapsed / _animationDuration);
+ ApplyTranslation(f);
+ return;
+ }
+
+ ApplyTranslation(1f);
+ FinishAnimation();
+ }
+
+ private void ApplyTranslation(float factor)
+ {
+ if (TranslateGlobally) {
+ transform.position = _initialPosition + Direction * factor;
+ } else {
+ transform.localPosition = _initialPosition + Direction * factor;
+ }
+ }
+
+ private void FinishAnimation()
+ {
+ if (!TwoWay) {
+ if (TranslateGlobally) {
+ transform.position = _initialPosition;
} else {
- transform.localPosition = _initialPosition + Direction * f;
- }
- t += Time.deltaTime;
- yield return null; // wait one frame
- }
-
- if (!TwoWay) {
- if (TranslateGlobally) {
- transform.position = _initialPosition;
- } else {
- transform.localPosition = _initialPosition;
- }
- }
- _enterCoroutine = null;
- }
+ transform.localPosition = _initialPosition;
+ }
+ }
+
+ _isAnimating = false;
+ }
private void OnExit()
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs
index feabd86ac..472a2ce6a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs
@@ -16,10 +16,11 @@
#nullable enable
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using NLog;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using NLog;
using UnityEngine;
using Logger = NLog.Logger;
@@ -100,9 +101,13 @@ public void OnStart()
}
}
- if (_coilAssignments.Count > 0) {
- _gamelogicEngine.OnCoilChanged += HandleCoilEvent;
- }
+ // Ensure all dictionary writes above are visible to the simulation
+ // thread before it can call HandleCoilEventSimulationThread().
+ Thread.MemoryBarrier();
+
+ if (_coilAssignments.Count > 0) {
+ _gamelogicEngine.OnCoilChanged += HandleCoilEvent;
+ }
}
}
@@ -120,8 +125,8 @@ private void AssignCoilMapping(CoilMapping coilMapping, bool isLampCoil)
}
}
- private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil)
- {
+ private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil)
+ {
if (!_coilAssignments.ContainsKey(id)) {
_coilAssignments[id] = new List();
}
@@ -130,20 +135,19 @@ private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCo
w.DestinationDeviceItem == coilMapping.DeviceItem &&
w.IsDynamic) != null;
- _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire));
- CoilStatuses[id] = false;
- }
-
- private void HandleCoilEvent(object sender, CoilEventArgs coilEvent)
- {
- // always save state
- CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled;
+ _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire));
+ CoilStatuses[id] = false;
+ }
+
+ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent)
+ {
+ // always save state
+ CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled;
// trigger coil if mapped
- if (_coilAssignments.ContainsKey(coilEvent.Id)) {
- foreach (var destConfig in _coilAssignments[coilEvent.Id]) {
-
- if (destConfig.HasDynamicWire) {
+ if (_coilAssignments.ContainsKey(coilEvent.Id)) {
+ foreach (var destConfig in _coilAssignments[coilEvent.Id]) {
+ if (destConfig.HasDynamicWire) {
// goes back through the wire mapping, which will decide whether it has already sent the event or not
_wirePlayer!.HandleCoilEvent(coilEvent.Id, coilEvent.IsEnabled);
continue;
@@ -184,14 +188,78 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent)
} else {
Logger.Info($"Ignoring unassigned coil \"{coilEvent.Id}\".");
}
- }
-
- public void OnDestroy()
- {
- if (_coilAssignments.Count > 0 && _gamelogicEngine != null) {
- _gamelogicEngine.OnCoilChanged -= HandleCoilEvent;
- }
- }
+ }
+
+ internal bool HandleCoilEventSimulationThread(string id, bool isEnabled)
+ {
+ if (!_coilAssignments.ContainsKey(id)) {
+ return false;
+ }
+
+ var dispatched = false;
+ foreach (var destConfig in _coilAssignments[id]) {
+ if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) {
+ continue;
+ }
+
+ if (!_coilDevices.ContainsKey(destConfig.Device)) {
+ continue;
+ }
+
+ var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem);
+ if (coil is ISimulationThreadCoil simulationThreadCoil) {
+ simulationThreadCoil.OnCoilSimulationThread(isEnabled);
+ dispatched = true;
+ }
+ }
+
+ return dispatched;
+ }
+
+ internal bool SupportsSimulationThreadDispatch(string id)
+ {
+ if (!_coilAssignments.ContainsKey(id)) {
+ return false;
+ }
+
+ foreach (var destConfig in _coilAssignments[id]) {
+ if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) {
+ continue;
+ }
+
+ if (!_coilDevices.ContainsKey(destConfig.Device)) {
+ continue;
+ }
+
+ var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem);
+ if (coil is DeviceCoil deviceCoil && deviceCoil.SupportsSimulationThreadDispatch) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public void OnDestroy()
+ {
+ if (_coilAssignments.Count > 0 && _gamelogicEngine != null) {
+ _gamelogicEngine.OnCoilChanged -= HandleCoilEvent;
+ }
+
+ // Reset simulation-thread state on all device coils so a
+ // subsequent play session starts clean.
+ foreach (var destList in _coilAssignments.Values) {
+ foreach (var destConfig in destList) {
+ if (destConfig.Device == null || !_coilDevices.ContainsKey(destConfig.Device)) {
+ continue;
+ }
+ var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem);
+ if (coil is DeviceCoil deviceCoil) {
+ deviceCoil.ResetSimulationState();
+ }
+ }
+ }
+ }
#if UNITY_EDITOR
private void RefreshUI()
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs
index 879b7f3e4..3c94685d4 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs
@@ -1,68 +1,141 @@
-// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-using System;
-using UnityEngine;
-
-namespace VisualPinball.Unity
-{
- public class DeviceCoil : IApiCoil
- {
- public bool IsEnabled;
- public event EventHandler CoilStatusChanged;
-
- protected Action OnEnable;
- protected Action OnDisable;
-
- private readonly Player _player;
-
- public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null)
- {
- _player = player;
- OnEnable = onEnable;
- OnDisable = onDisable;
- }
-
- public void OnCoil(bool enabled)
- {
- IsEnabled = enabled;
- if (enabled) {
- OnEnable?.Invoke();
- } else {
- OnDisable?.Invoke();
- }
- CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled));
-#if UNITY_EDITOR
- RefreshUI();
-#endif
- }
-
- public void OnChange(bool enabled) => OnCoil(enabled);
-
-#if UNITY_EDITOR
- private void RefreshUI()
- {
- if (!_player.UpdateDuringGameplay) {
- return;
- }
-
- foreach (var editor in (UnityEditor.Editor[])Resources.FindObjectsOfTypeAll(Type.GetType("VisualPinball.Unity.Editor.TroughInspector, VisualPinball.Unity.Editor"))) {
- editor.Repaint();
- }
- }
-#endif
- }
-}
+// Visual Pinball Engine
+// Copyright (C) 2023 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Threading;
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ public interface ISimulationThreadCoil
+ {
+ void OnCoilSimulationThread(bool enabled);
+ }
+
+ public class DeviceCoil : IApiCoil, ISimulationThreadCoil
+ {
+ private int _isEnabled;
+ private int _simulationEnabled;
+
+ ///
+ /// When true, the simulation thread is actively dispatching coil
+ /// changes for this coil. The main-thread
+ /// path will skip /
+ /// callbacks to avoid double-firing (the sim thread already set
+ /// the solenoid flag directly). UI refresh and status events
+ /// still fire on the main thread.
+ ///
+ private volatile bool _simThreadActive;
+
+ ///
+ /// Whether this coil exposes explicit simulation-thread callbacks.
+ /// Only coils whose effect is physics-critical and thread-safe should
+ /// opt into this path. Examples are flippers or gate lifters, where
+ /// coil latency directly affects collision timing. Visual-only or
+ /// orchestration-heavy coils, such as lamp/ flasher mappings or coils
+ /// that still touch Unity objects on enable/disable, should stay on the
+ /// normal main-thread path.
+ ///
+ public bool SupportsSimulationThreadDispatch => OnEnableSimulationThread != null || OnDisableSimulationThread != null;
+
+ public bool IsEnabled => Volatile.Read(ref _isEnabled) != 0;
+ public event EventHandler CoilStatusChanged;
+
+ protected Action OnEnable;
+ protected Action OnDisable;
+ protected Action OnEnableSimulationThread;
+ protected Action OnDisableSimulationThread;
+
+ private readonly Player _player;
+
+ public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null,
+ Action onEnableSimulationThread = null, Action onDisableSimulationThread = null)
+ {
+ _player = player;
+ OnEnable = onEnable;
+ OnDisable = onDisable;
+ OnEnableSimulationThread = onEnableSimulationThread;
+ OnDisableSimulationThread = onDisableSimulationThread;
+ }
+
+ public void OnCoil(bool enabled)
+ {
+ Interlocked.Exchange(ref _isEnabled, enabled ? 1 : 0);
+
+ // When the simulation thread is actively handling this coil,
+ // skip the main-thread enable/disable callbacks. The sim thread
+ // already set the solenoid flag directly via OnCoilSimulationThread.
+ // We still update _isEnabled above and fire CoilStatusChanged/UI
+ // below, since those are main-thread-only concerns.
+ if (!_simThreadActive) {
+ if (enabled) {
+ OnEnable?.Invoke();
+ } else {
+ OnDisable?.Invoke();
+ }
+ }
+ CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled));
+#if UNITY_EDITOR
+ RefreshUI();
+#endif
+ }
+
+ public void OnCoilSimulationThread(bool enabled)
+ {
+ // Mark this coil as sim-thread-active on first dispatch.
+ // This suppresses the main-thread OnEnable/OnDisable callbacks.
+ if (!_simThreadActive) {
+ _simThreadActive = true;
+ }
+
+ if (Interlocked.Exchange(ref _simulationEnabled, enabled ? 1 : 0) == (enabled ? 1 : 0)) {
+ return;
+ }
+
+ if (enabled) {
+ OnEnableSimulationThread?.Invoke();
+ } else {
+ OnDisableSimulationThread?.Invoke();
+ }
+ }
+
+ public void OnChange(bool enabled) => OnCoil(enabled);
+
+ ///
+ /// Reset simulation-thread state. Call when the simulation thread
+ /// is torn down or the gamelogic engine stops, so that a subsequent
+ /// session starts clean.
+ ///
+ internal void ResetSimulationState()
+ {
+ _simThreadActive = false;
+ Interlocked.Exchange(ref _simulationEnabled, 0);
+ }
+
+#if UNITY_EDITOR
+ private void RefreshUI()
+ {
+ if (!_player.UpdateDuringGameplay) {
+ return;
+ }
+
+ foreach (var editor in (UnityEditor.Editor[])Resources.FindObjectsOfTypeAll(Type.GetType("VisualPinball.Unity.Editor.TroughInspector, VisualPinball.Unity.Editor"))) {
+ editor.Repaint();
+ }
+ }
+#endif
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs
index a79978c92..ac1b684ab 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DisplayPlayer.cs
@@ -47,18 +47,25 @@ public void Awake(IGamelogicEngine gamelogicEngine)
}
}
- private void HandleDisplaysRequested(object sender, RequestedDisplays requestedDisplays)
- {
- foreach (var display in requestedDisplays.Displays) {
- if (_displayGameObjects.ContainsKey(display.Id)) {
- Logger.Info($"Updating display \"{display.Id}\" to {display.Width}x{display.Height}");
- _displayGameObjects[display.Id].UpdateDimensions(display.Width, display.Height, display.FlipX);
- _displayGameObjects[display.Id].Clear();
- } else {
- Logger.Warn($"Cannot find game object for display \"{display.Id}\"");
- }
- }
- }
+ private void HandleDisplaysRequested(object sender, RequestedDisplays requestedDisplays)
+ {
+ foreach (var display in requestedDisplays.Displays) {
+ if (_displayGameObjects.ContainsKey(display.Id)) {
+ Logger.Info($"Updating display \"{display.Id}\" to {display.Width}x{display.Height}");
+ var displayGameObject = _displayGameObjects[display.Id];
+ displayGameObject.UpdateDimensions(display.Width, display.Height, display.FlipX);
+ if (display.LitColor.HasValue) {
+ displayGameObject.UpdateColor(display.LitColor.Value);
+ }
+ if (display.UnlitColor.HasValue) {
+ displayGameObject.UnlitColor = display.UnlitColor.Value;
+ }
+ displayGameObject.Clear();
+ } else {
+ Logger.Warn($"Cannot find game object for display \"{display.Id}\"");
+ }
+ }
+ }
private void HandleDisplayClear(object sender, string id)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs
index ce32d4b23..eedc7bca0 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs
@@ -140,8 +140,8 @@ CancellationToken ct
/// Sometimes we want to extend an existing GLE with other components. This
/// API allows other components to be able to drive the GLE.
///
- public interface IGamelogicBridge
- {
+ public interface IGamelogicBridge
+ {
void SetCoil(string id, bool isEnabled);
void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp);
@@ -152,10 +152,106 @@ public interface IGamelogicBridge
public event EventHandler OnSwitchChanged;
- // todo displays
- }
-
- public class RequestedDisplays
+ // todo displays
+ }
+
+ public enum GamelogicInputDispatchMode
+ {
+ MainThread = 0,
+ SimulationThread = 1,
+ }
+
+ ///
+ /// Optional capability interface for game logic engines that can safely
+ /// receive switch updates from the simulation thread.
+ ///
+ public interface IGamelogicInputThreading
+ {
+ GamelogicInputDispatchMode SwitchDispatchMode { get; }
+ }
+
+ ///
+ /// Optional capability interface for game logic engines that support
+ /// external emulation time fencing.
+ ///
+ public interface IGamelogicTimeFence
+ {
+ ///
+ /// Set the emulation time fence in seconds.
+ ///
+ /// Absolute simulation time in seconds.
+ void SetTimeFence(double timeInSeconds);
+ }
+
+ ///
+ /// Optional capability interface for game logic engines that expose
+ /// low-latency coil output changes for simulation-thread consumption.
+ ///
+ public interface IGamelogicCoilOutputFeed
+ {
+ ///
+ /// Try to dequeue a coil output change.
+ ///
+ /// The dequeued coil event.
+ /// True when a coil event was dequeued.
+ bool TryDequeueCoilEvent(out CoilEventArgs coilEvent);
+ }
+
+ ///
+ /// Optional capability interface for game logic engines that can copy
+ /// their current output state into the threaded simulation snapshot.
+ ///
+ public interface IGamelogicSharedStateWriter
+ {
+ void WriteSharedState(ref Simulation.SimulationState.Snapshot snapshot);
+ }
+
+ ///
+ /// Optional capability interface for game logic engines that can consume
+ /// the shared simulation snapshot on Unity's main thread.
+ ///
+ public interface IGamelogicSharedStateApplier
+ {
+ void ApplySharedState(in Simulation.SimulationState.Snapshot snapshot);
+ }
+
+ public readonly struct GamelogicPerformanceStats
+ {
+ public readonly bool IsRunning;
+ public readonly float CallbackRateHz;
+ public readonly int RunState;
+
+ public GamelogicPerformanceStats(bool isRunning, float callbackRateHz, int runState)
+ {
+ IsRunning = isRunning;
+ CallbackRateHz = callbackRateHz;
+ RunState = runState;
+ }
+ }
+
+ public interface IGamelogicPerformanceStats
+ {
+ bool TryGetPerformanceStats(out GamelogicPerformanceStats stats);
+ }
+
+ public readonly struct GamelogicLatencyStats
+ {
+ public readonly long LastSwitchObservationUsec;
+ public readonly long LastCoilOutputUsec;
+
+ public GamelogicLatencyStats(long lastSwitchObservationUsec, long lastCoilOutputUsec)
+ {
+ LastSwitchObservationUsec = lastSwitchObservationUsec;
+ LastCoilOutputUsec = lastCoilOutputUsec;
+ }
+ }
+
+ public interface IGamelogicLatencyStats
+ {
+ bool TryGetLatencyStats(out GamelogicLatencyStats stats);
+ }
+
+ public class RequestedDisplays
{
public readonly DisplayConfig[] Displays;
@@ -171,35 +267,40 @@ public RequestedDisplays(DisplayConfig config)
}
[Serializable]
- public class DisplayConfig
- {
- public readonly string Id;
- public readonly int Width;
- public readonly int Height;
- public readonly bool FlipX;
-
- public DisplayConfig(string id, int width, int height)
- {
- Id = id;
- Width = width;
- Height = height;
- }
-
- public DisplayConfig(string id, uint width, uint height)
- {
- Id = id;
- Width = (int)width;
- Height = (int)height;
- }
-
- public DisplayConfig(string id, uint width, uint height, bool flipX)
- {
- Id = id;
- Width = (int)width;
- Height = (int)height;
- FlipX = flipX;
- }
- }
+ public class DisplayConfig
+ {
+ public readonly string Id;
+ public readonly int Width;
+ public readonly int Height;
+ public readonly bool FlipX;
+ public readonly Color? LitColor;
+ public readonly Color? UnlitColor;
+
+ public DisplayConfig(string id, int width, int height)
+ : this(id, width, height, false)
+ {
+ }
+
+ public DisplayConfig(string id, int width, int height, bool flipX, Color? litColor = null, Color? unlitColor = null)
+ {
+ Id = id;
+ Width = width;
+ Height = height;
+ FlipX = flipX;
+ LitColor = litColor;
+ UnlitColor = unlitColor;
+ }
+
+ public DisplayConfig(string id, uint width, uint height)
+ : this(id, (int)width, (int)height)
+ {
+ }
+
+ public DisplayConfig(string id, uint width, uint height, bool flipX)
+ : this(id, (int)width, (int)height, flipX)
+ {
+ }
+ }
public enum DisplayFrameFormat
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs
new file mode 100644
index 000000000..8622ac733
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs
@@ -0,0 +1,970 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.InputSystem;
+using VisualPinball.Unity;
+using VisualPinball.Unity.Simulation;
+using Math = System.Math;
+
+[DefaultExecutionOrder(10000)]
+public class FramePacingGraph : MonoBehaviour
+{
+ [Header("Visibility & Input")] public bool startVisible = true;
+ public InputActionReference toggleAction;
+
+ [Header("Graph Layout (pixels)")] public Vector2 anchorOffset = new Vector2(24f, 24f);
+ public Vector2 size = new Vector2(kStatsPanelWidth, 150);
+ public GraphAnchor anchor = GraphAnchor.TopLeft;
+
+ [Header("Stats Panel (independent)")] public bool statsPanelEnabled = true;
+
+ [Tooltip("Corner of the screen to anchor the stats panel to.")]
+ public GraphAnchor statsAnchor = GraphAnchor.TopLeft;
+
+ [Tooltip("Offset from the chosen stats anchor (pixels).")]
+ public Vector2 statsOffset = new Vector2(24f, 200f);
+
+ [Tooltip("Padding inside the stats panel (pixels).")]
+ public float statsPanelPadding = 6f;
+
+ [Header("Time Window & Scale")] [Range(1f, 60f)]
+ public float windowSeconds = 10f;
+
+ [Header("Stats")] [Tooltip("Seconds used for Avg/P95/P99. If <= 0, uses the full graph window.")]
+ public float statsSeconds = 3f;
+
+ [Range(60, 480)] public int maxExpectedFps = 240;
+ public float yMaxMs = 40f;
+ public bool autoY = true;
+ public float autoYMargin = 1.2f;
+
+ [Header("CPU Busy (heuristic when wait=0)")] [Range(0.05f, 0.4f)]
+ public float nearFullFrameThresholdPercent = 0.20f; // 20% margin
+
+ public float nonZeroWaitEpsilonMs = 0.5f; // consider wait valid if > 0.5 ms
+ public bool useDx12HeuristicWhenWaitZero = true;
+
+ [Header("Appearance")] public float lineWidth = 2f;
+ public float gridLineWidth = 1f;
+ public int gridLinesY = 4;
+ public Color backgroundColor = new Color(0f, 0f, 0f, 0.6f);
+ public Color borderColor = new Color(1f, 1f, 1f, 0.2f);
+ public Color axisTextColor = new Color(1f, 1f, 1f, 0.85f);
+ public int fontSize = 12;
+
+ [Header("Built-in Metric Colors")] public Color totalColor = new Color(1f, 1f, 1f, 0.9f);
+ public Color cpuColor = new Color(0.25f, 0.8f, 1f, 0.9f);
+ public Color gpuColor = new Color(1f, 0.5f, 0.25f, 0.9f);
+
+ [Header("Totals")]
+ [Tooltip("When true, uses frame timing (max(CPU, GPU)) for 'Total' when valid; otherwise uses unscaledDeltaTime.")]
+ public bool totalFromFrameTimingWhenAvailable = false;
+
+ [Header("Performance")] [Tooltip("Recompute stats (avg/p95/p99) every N frames.")] [Range(1, 60)]
+ public int statsEveryNFrames = 12;
+
+ [Tooltip("Cap per-frame draw work by aggregating into pixel columns.")]
+ public bool useColumnEnvelope = true;
+
+ [Tooltip("Optionally disable FrameTimingManager reads if heavy on your platform.")]
+ public bool enableCpuGpuCollection = true;
+
+ // Fixed stats panel width (dependent on fixed contents)
+ const float kStatsPanelWidth = 635f;
+
+ bool initialized;
+
+ // Fixed-width font for stats
+ GUIStyle monoStyle;
+ static Font sMonoFont; // cached across instances
+
+ // FPS badge styles (cached)
+ GUIStyle fpsBigStyle;
+ int fpsBigSizeCached = -1;
+
+ // Public API for custom metrics
+ public int RegisterCustomMetric(string name, Color color, Func sampler, float scale = 1f,
+ bool enabled = true)
+ {
+ if (string.IsNullOrEmpty(name) || sampler == null) return -1;
+ var m = new Metric(name, color, sampler, scale, enabled, capacity);
+ customMetrics.Add(m);
+ return customMetrics.Count - 1;
+ }
+
+ // ----- Internals -----
+ public enum GraphAnchor
+ {
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight
+ }
+
+ struct Stats
+ {
+ public float avg, p95, p99, min, max;
+ public bool valid;
+ }
+
+ class Metric
+ {
+ public string name;
+ public Color color;
+ public Func sampler;
+ public float scale;
+ public bool enabled;
+ public float[] values; // ring-buffer aligned
+ public Stats stats;
+ public string cachedLegend; // updated when stats recompute
+
+ public Metric(string name, Color color, Func sampler, float scale, bool enabled, int capacity)
+ {
+ this.name = name;
+ this.color = color;
+ this.sampler = sampler;
+ this.scale = scale;
+ this.enabled = enabled;
+ values = new float[capacity];
+ stats = default;
+ cachedLegend = name;
+ }
+ }
+
+ Metric totalMetric, cpuMetric, gpuMetric;
+ readonly List customMetrics = new();
+
+ float[] timestamps;
+ int head = 0, count = 0, capacity;
+
+ FrameTiming[] ftBuf = new FrameTiming[1];
+ bool haveFT = false;
+ float lastCpuMs = 0f, lastGpuMs = 0f; // raw totals
+ float lastCpuBusyMs = 0f, lastGpuBusyMs = 0f; // busy we plot
+ float lastCpuWaitMs = 0f; // optional: expose as a line if you want
+
+ static Material lineMat;
+
+ bool visible;
+ int frameSinceStats = 0;
+
+ // scratch arrays (no GC)
+ float[] scratchValues;
+
+ // FPS smoothing & text
+ const float fpsSmoothTau = 0.5f;
+ float smoothedFps = 0f;
+ float fpsTextTimer = 0f;
+ string fpsText = "0.0 FPS";
+
+ // Column envelope caches
+ float[] colMin, colMax; // reused per metric
+ Vector2[] polyPts; // reused polyline points
+
+ // Cached GUIStyle
+ GUIStyle labelStyle;
+
+ SimulationThreadComponent simulationThreadComponent;
+ NativeInputManager nativeInputManager;
+ IGamelogicPerformanceStats gamelogicPerformanceStats;
+ bool gamelogicPerformanceStatsResolved;
+ float simulationThreadSpeedX;
+ float simulationThreadHz;
+ float inputThreadHz;
+ float inputThreadActualHz;
+ float pinMameCallbackHz;
+ int pinMameRunState;
+ bool pinMameStatsValid;
+
+ // 2) Awake(): build a GUIStyle without GUI.skin and set initialized = true at the end
+ void Awake()
+ {
+ visible = startVisible;
+ useGUILayout = false;
+
+ capacity = Mathf.Max(8, Mathf.CeilToInt(windowSeconds * maxExpectedFps));
+ timestamps = new float[capacity];
+
+ totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity);
+ cpuMetric = new Metric("CPU Busy", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity);
+ gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity);
+
+ scratchValues = new float[capacity];
+ EnsureLineMaterial();
+
+ if (toggleAction != null && toggleAction.action != null)
+ {
+ toggleAction.action.performed += OnToggle;
+ toggleAction.action.Enable();
+ }
+
+ // ✅ Do NOT touch GUI.skin here
+ labelStyle = new GUIStyle();
+ labelStyle.fontSize = fontSize;
+ labelStyle.normal.textColor = axisTextColor;
+
+ // Monospace style for the right panel
+ monoStyle = new GUIStyle();
+ monoStyle.fontSize = fontSize;
+ monoStyle.normal.textColor = axisTextColor;
+ monoStyle.richText = false;
+
+ // Try common OS monospace fonts (fallback to default if none found)
+ if (sMonoFont == null)
+ {
+ try
+ {
+ sMonoFont = Font.CreateDynamicFontFromOSFont(
+ new[] { "Consolas", "Courier New", "Lucida Console", "DejaVu Sans Mono", "Menlo", "SF Mono" },
+ fontSize);
+ }
+ catch
+ {
+ /* platform may not allow OS fonts; safe to ignore */
+ }
+ }
+
+ if (sMonoFont != null) monoStyle.font = sMonoFont;
+
+ initialized = true; // <-- mark fully constructed
+ }
+
+ void OnDestroy()
+ {
+ if (toggleAction != null && toggleAction.action != null)
+ toggleAction.action.performed -= OnToggle;
+ }
+
+ // 3) OnValidate(): only resize when we're initialized
+ void OnValidate()
+ {
+ if (labelStyle != null)
+ {
+ labelStyle.fontSize = fontSize;
+ labelStyle.normal.textColor = axisTextColor;
+ }
+
+ if (monoStyle != null)
+ {
+ monoStyle.fontSize = fontSize;
+ monoStyle.normal.textColor = axisTextColor;
+ }
+
+ var needed = Mathf.Max(8, Mathf.CeilToInt(windowSeconds * maxExpectedFps));
+ if (Application.isPlaying && initialized && needed != capacity)
+ {
+ ResizeCapacity(needed);
+ }
+ }
+
+
+ // 4) ResizeCapacity(): allocate fresh arrays & reset counters
+ void ResizeCapacity(int newCap)
+ {
+ capacity = newCap;
+
+ timestamps = new float[capacity];
+ ResizeMetric(totalMetric, capacity);
+ ResizeMetric(cpuMetric, capacity);
+ ResizeMetric(gpuMetric, capacity);
+ for (var i = 0; i < customMetrics.Count; i++)
+ ResizeMetric(customMetrics[i], capacity);
+
+ scratchValues = new float[capacity];
+ head = 0;
+ count = 0;
+ }
+
+ // 5) ResizeMetric(): null-safe and use the new capacity
+ static void ResizeMetric(Metric m, int newCap)
+ {
+ if (m == null) return;
+ m.values = new float[newCap];
+ m.stats = default;
+ m.cachedLegend = m.name;
+ }
+
+ void OnToggle(InputAction.CallbackContext _) => visible = !visible;
+
+ void Update()
+ {
+ UpdateThreadSpeedStats();
+
+ // Get last frame timing
+ if (enableCpuGpuCollection)
+ {
+ var got = FrameTimingManager.GetLatestTimings(1, ftBuf);
+ haveFT = got > 0;
+ if (haveFT)
+ {
+ // --- inside Update(), right after GetLatestTimings() succeeds ---
+ var ft = ftBuf[0];
+
+ lastCpuMs = Mathf.Max(0f, (float)ft.cpuFrameTime);
+ lastGpuMs = Mathf.Max(0f, (float)ft.gpuFrameTime);
+
+ lastCpuBusyMs = EstimateCpuBusyMs(ft);
+ lastGpuBusyMs = lastGpuMs;
+ }
+ }
+ else
+ {
+ haveFT = false;
+ lastCpuMs = 0f;
+ lastGpuMs = 0f;
+ }
+
+ // Sample
+ var now = Time.unscaledTime;
+ var totalMs = totalMetric.sampler();
+ var cpuMs = cpuMetric.enabled ? cpuMetric.sampler() : 0f;
+ var gpuMs = gpuMetric.enabled ? gpuMetric.sampler() : 0f;
+ WriteSample(now, totalMs, cpuMs, gpuMs);
+
+ // right after WriteSample(now, totalMs, cpuMs, gpuMs);
+ var last = (head - 1 + capacity) % capacity;
+
+ // Custom metrics
+ var idxPrev = (head - 1 + capacity) % capacity;
+ for (var i = 0; i < customMetrics.Count; i++)
+ {
+ var m = customMetrics[i];
+ if (!m.enabled || m.sampler == null)
+ {
+ m.values[idxPrev] = 0f;
+ continue;
+ }
+
+ var v = 0f;
+ try
+ {
+ v = m.sampler() * m.scale;
+ }
+ catch
+ {
+ }
+
+ m.values[idxPrev] = v;
+ }
+
+ // FPS smoothing & text throttling
+ var instFps = totalMs > 0.0001f ? 1000f / totalMs : 0f;
+ var a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau);
+ smoothedFps = Mathf.Lerp(smoothedFps, instFps, a);
+ fpsTextTimer += Time.unscaledDeltaTime;
+ if (fpsTextTimer >= 0.15f)
+ {
+ fpsTextTimer = 0f;
+ fpsText = $"{smoothedFps:0.0} FPS";
+ }
+
+ // Stats occasionally
+ if (++frameSinceStats >= statsEveryNFrames)
+ {
+ frameSinceStats = 0;
+ RecomputeStats(totalMetric);
+ if (cpuMetric.enabled) RecomputeStats(cpuMetric);
+ if (gpuMetric.enabled) RecomputeStats(gpuMetric);
+ for (var i = 0; i < customMetrics.Count; i++)
+ if (customMetrics[i].enabled)
+ RecomputeStats(customMetrics[i]);
+ }
+
+ // Capture for next frame
+ if (enableCpuGpuCollection) FrameTimingManager.CaptureFrameTimings();
+ }
+
+ float EstimateCpuBusyMs(FrameTiming ft)
+ {
+ var cpuFrame = Mathf.Max(0f, (float)ft.cpuFrameTime);
+ var gpuFrame = Mathf.Max(0f, (float)ft.gpuFrameTime);
+ var mainFrame = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime);
+ var rendFrame = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime);
+ var waitMain = Mathf.Max(0f, (float)ft.cpuMainThreadPresentWaitTime);
+
+ // If wait is actually reported, trust it.
+ if (waitMain > nonZeroWaitEpsilonMs)
+ return Mathf.Max(0f, mainFrame - waitMain);
+
+ if (!useDx12HeuristicWhenWaitZero)
+ return mainFrame; // fall back to main (may include idle)
+
+ // --- Heuristic path (DX12 often reports wait=0 even when main is waiting) ---
+ var fullWithMargin = (1f - nearFullFrameThresholdPercent) * cpuFrame;
+ var gpuNear = gpuFrame > fullWithMargin;
+ var mainNear = mainFrame > fullWithMargin;
+ var renderNear = rendFrame > fullWithMargin;
+
+ // GPU-bound & main is near frame time => main likely waiting; use render thread work.
+ if (gpuNear && mainNear && !renderNear)
+ return rendFrame;
+
+ // CPU-bound => take the heavier CPU thread.
+ if (!gpuNear && (mainNear || renderNear))
+ return Mathf.Max(mainFrame, rendFrame);
+
+ // Balanced/indeterminate: choose the larger "work" but never exceed the frame period.
+ return Mathf.Min(cpuFrame, Mathf.Max(rendFrame, mainFrame));
+ }
+
+ void WriteSample(float now, float totalMs, float cpuMs, float gpuMs)
+ {
+ timestamps[head] = now;
+ totalMetric.values[head] = totalMs;
+ cpuMetric.values[head] = cpuMs;
+ gpuMetric.values[head] = gpuMs;
+ head = (head + 1) % capacity;
+ count = Mathf.Min(count + 1, capacity);
+ }
+
+ float SampleTotalMs()
+ {
+ if (totalFromFrameTimingWhenAvailable && haveFT)
+ {
+ var candidate = Mathf.Max(lastCpuMs, lastGpuMs);
+ if (candidate > 0f) return candidate;
+ }
+
+ return Time.unscaledDeltaTime * 1000f;
+ }
+
+ void UpdateThreadSpeedStats()
+ {
+ if (simulationThreadComponent == null) {
+ simulationThreadComponent = FindFirstObjectByType();
+ }
+
+ if (simulationThreadComponent != null) {
+ simulationThreadSpeedX = simulationThreadComponent.SimulationThreadSpeedX;
+ simulationThreadHz = simulationThreadComponent.SimulationThreadHz;
+ inputThreadActualHz = simulationThreadComponent.InputThreadActualHz;
+ }
+
+ if (nativeInputManager == null) {
+ nativeInputManager = NativeInputManager.TryGetExistingInstance();
+ }
+ inputThreadHz = nativeInputManager?.TargetPollingHz ?? 0f;
+
+ if (!gamelogicPerformanceStatsResolved) {
+ var player = FindFirstObjectByType();
+ if (player != null) {
+ gamelogicPerformanceStats = player.GamelogicEngine as IGamelogicPerformanceStats;
+ gamelogicPerformanceStatsResolved = true;
+ }
+ }
+
+ if (gamelogicPerformanceStats != null && gamelogicPerformanceStats.TryGetPerformanceStats(out var stats)) {
+ pinMameStatsValid = true;
+ pinMameCallbackHz = stats.CallbackRateHz;
+ pinMameRunState = stats.RunState;
+ } else {
+ pinMameStatsValid = false;
+ pinMameCallbackHz = 0f;
+ pinMameRunState = 0;
+ }
+ }
+
+ void RecomputeStats(Metric m)
+ {
+ var visible = CollectVisibleValues(m.values, scratchValues, statsSeconds, out var sum);
+
+ if (visible <= 0)
+ {
+ m.stats = default;
+ m.cachedLegend = $"{m.name}: -";
+ return;
+ }
+
+ Array.Sort(scratchValues, 0, visible);
+
+ var avg = sum / visible;
+ var p95 = PercentileSorted(scratchValues, visible, 0.95f);
+ var p99 = PercentileSorted(scratchValues, visible, 0.99f);
+ var min = scratchValues[0];
+ var max = scratchValues[visible - 1];
+
+ m.stats.avg = avg;
+ m.stats.p95 = p95;
+ m.stats.p99 = p99;
+ m.stats.min = min;
+ m.stats.max = max;
+ m.stats.valid = true;
+
+ // (cachedLegend not used for the right panel anymore, but keep it tidy)
+ m.cachedLegend =
+ $"{m.name}: avg {avg:0.00} ms • p95 {p95:0.00} • p99 {p99:0.00} • min {min:0.00} • max {max:0.00}";
+ }
+
+ int CollectVisibleValues(float[] src, float[] dst, float seconds, out float sum)
+ {
+ sum = 0f;
+ if (count == 0) return 0;
+ var now = Time.unscaledTime;
+ var minTime = now - (seconds > 0f ? seconds : windowSeconds);
+ int visible = 0, start = (head - count + capacity) % capacity;
+
+ for (var i = 0; i < count; i++)
+ {
+ var idx = (start + i) % capacity;
+ var t = timestamps[idx];
+ if (t < minTime) continue;
+ var v = src[idx];
+ dst[visible++] = v;
+ sum += v;
+ }
+
+ return visible;
+ }
+
+ static float PercentileSorted(float[] sorted, int n, float p)
+ {
+ if (n == 0) return 0f;
+ if (p <= 0f) return sorted[0];
+ if (p >= 1f) return sorted[n - 1];
+ var f = (n - 1) * p;
+ int i0 = Mathf.FloorToInt(f), i1 = Math.Min(n - 1, i0 + 1);
+ var t = f - i0;
+ return Mathf.Lerp(sorted[i0], sorted[i1], t);
+ }
+
+ // ---------- RENDER ----------
+ void OnGUI()
+ {
+ if (!visible) return;
+
+ // 🧨 Critical fix: only draw on Repaint to avoid extra work during key/mouse events
+ var evt = Event.current;
+ if (evt == null || evt.type != EventType.Repaint) return;
+
+ // Graph
+ var graphRect = ResolveRect();
+ if (graphRect.width <= 4f || graphRect.height <= 4f) return;
+
+ DrawGraphBackgroundAndGrid(graphRect);
+ DrawFpsBadge(graphRect);
+
+ var now = Time.unscaledTime;
+ var invY = ResolveYScale(out var yMax);
+
+ // Draw metrics (batched, column-envelope)
+ DrawMetric(graphRect, now, yMax, invY, totalMetric);
+ if (cpuMetric.enabled) DrawMetric(graphRect, now, yMax, invY, cpuMetric);
+ if (gpuMetric.enabled) DrawMetric(graphRect, now, yMax, invY, gpuMetric);
+ for (var i = 0; i < customMetrics.Count; i++)
+ if (customMetrics[i].enabled)
+ DrawMetric(graphRect, now, yMax, invY, customMetrics[i]);
+
+ // Y-axis labels on the graph
+ GUI.Label(new Rect(graphRect.x + 6, graphRect.y - (fontSize + 2), 120, fontSize + 4), $"{yMax:0.#} ms",
+ labelStyle);
+ GUI.Label(new Rect(graphRect.x + 6, graphRect.y + graphRect.height * 0.5f - (fontSize + 2), 120, fontSize + 4),
+ $"{(yMax * 0.5f):0.#} ms", labelStyle);
+ GUI.Label(new Rect(graphRect.x + 6, graphRect.yMax - (fontSize + 2), 120, fontSize + 4), $"0 ms", labelStyle);
+
+ // Time axis labels
+ GUI.Label(new Rect(graphRect.xMax - 100, graphRect.yMax + 2, 100, fontSize + 4), "now", labelStyle);
+
+ // Freely positionable stats panel (fixed size)
+ if (statsPanelEnabled)
+ {
+ var statsRect = ResolveStatsRect();
+ DrawPanelBackground(statsRect);
+ DrawStatsPanel(statsRect);
+ }
+ }
+
+ Rect ResolveRect()
+ {
+ float x = anchorOffset.x, y = anchorOffset.y;
+ switch (anchor)
+ {
+ case GraphAnchor.TopRight:
+ x = Screen.width - size.x - anchorOffset.x;
+ break;
+ case GraphAnchor.BottomLeft:
+ y = Screen.height - size.y - anchorOffset.y;
+ break;
+ case GraphAnchor.BottomRight:
+ x = Screen.width - size.x - anchorOffset.x;
+ y = Screen.height - size.y - anchorOffset.y;
+ break;
+ }
+
+ return new Rect(x, y, size.x, size.y);
+ }
+
+ Rect ResolveStatsRect()
+ {
+ float w = kStatsPanelWidth; // fixed width (your class constant)
+ float h = ComputeStatsPanelHeight(); // dynamic height based on lines + padding
+
+ // X respects LEFT/RIGHT anchor
+ bool rightAnchor = (statsAnchor == GraphAnchor.TopRight || statsAnchor == GraphAnchor.BottomRight);
+ float x = rightAnchor
+ ? (Screen.width - w - statsOffset.x) // offset from right edge
+ : statsOffset.x; // offset from left edge
+
+ // Y respects TOP/BOTTOM anchor (unchanged)
+ bool bottomAnchor = (statsAnchor == GraphAnchor.BottomLeft || statsAnchor == GraphAnchor.BottomRight);
+ float y = bottomAnchor
+ ? (Screen.height - h - statsOffset.y) // offset from bottom
+ : statsOffset.y; // offset from top
+
+ return new Rect(x, y, w, h);
+ }
+
+
+ float ComputeStatsPanelHeight()
+ {
+ // Match the same vertical spacing used when drawing.
+ float pad = statsPanelPadding;
+ float headerH = fontSize + 6f;
+ float lineH = fontSize + 4f;
+
+ int lines = 0;
+
+ // Header is always drawn
+ lines += 1;
+
+ // Built-ins (only if they will draw)
+ if (totalMetric != null && totalMetric.stats.valid) lines += 1;
+ if (cpuMetric != null && cpuMetric.enabled && cpuMetric.stats.valid) lines += 1;
+ if (gpuMetric != null && gpuMetric.enabled && gpuMetric.stats.valid) lines += 1;
+
+ // Custom metrics that are enabled AND have valid stats
+ for (int i = 0; i < customMetrics.Count; i++)
+ {
+ var m = customMetrics[i];
+ if (m != null && m.enabled && m.stats.valid) lines += 1;
+ }
+
+ // Thread speed lines
+ lines += 1; // section header
+ lines += 3; // sim/input/pinmame
+
+ // Height = top pad + header + N*(lineH) + bottom pad
+ float h = pad + headerH + (lines - 1) * lineH + pad;
+
+ // Optional: enforce a minimum height so the panel doesn’t collapse
+ float minH = pad + headerH + pad; // header only
+ if (h < minH) h = minH;
+
+ return h;
+ }
+
+
+ float ResolveYScale(out float yMaxOut)
+ {
+ var yMaxLocal = yMaxMs;
+ if (autoY)
+ {
+ var maxSeen = 0f;
+ if (totalMetric.stats.valid) maxSeen = Mathf.Max(maxSeen, totalMetric.stats.p99);
+ if (cpuMetric.enabled && cpuMetric.stats.valid) maxSeen = Mathf.Max(maxSeen, cpuMetric.stats.p99);
+ if (gpuMetric.enabled && gpuMetric.stats.valid) maxSeen = Mathf.Max(maxSeen, gpuMetric.stats.p99);
+ for (var i = 0; i < customMetrics.Count; i++)
+ if (customMetrics[i].enabled && customMetrics[i].stats.valid)
+ maxSeen = Mathf.Max(maxSeen, customMetrics[i].stats.p99);
+ if (maxSeen <= 0f) maxSeen = 16f;
+ yMaxLocal = Mathf.Min(Mathf.Max(8f, maxSeen * autoYMargin), Mathf.Max(yMaxMs, 8f));
+ }
+
+ yMaxOut = yMaxLocal;
+ return yMaxLocal > 0f ? 1f / yMaxLocal : 0.1f;
+ }
+
+ void DrawMetric(Rect r, float now, float yMax, float invY, Metric m)
+ {
+ if (count <= 1) return;
+
+ if (useColumnEnvelope)
+ {
+ DrawMetricColumnEnvelope(r, now, yMax, invY, m);
+ }
+ else
+ {
+ // Fallback simple poly (batched)
+ BuildPolylinePoints(r, now, invY, m.values, out var pts, out var n);
+ DrawPolylineBatched(pts, n, m.color, lineWidth);
+ }
+ }
+
+ void DrawMetricColumnEnvelope(Rect r, float now, float yMax, float invY, Metric m)
+ {
+ var w = Mathf.Max(2, Mathf.RoundToInt(r.width));
+ EnsureColumnBuffers(w);
+
+ // reset
+ for (var i = 0; i < w; i++)
+ {
+ colMin[i] = float.PositiveInfinity;
+ colMax[i] = float.NegativeInfinity;
+ }
+
+ // fill per-column min/max
+ var minTime = now - windowSeconds;
+ var start = (head - count + capacity) % capacity;
+ for (var i = 0; i < count; i++)
+ {
+ var idx = (start + i) % capacity;
+ var t = timestamps[idx];
+ if (t < minTime) continue;
+
+ var x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds);
+ var cx = Mathf.Clamp(Mathf.RoundToInt((w - 1) * x01), 0, w - 1);
+
+ var y01 = Mathf.Clamp01(m.values[idx] * invY);
+ var y = Mathf.Lerp(r.yMax, r.y, y01);
+
+ if (y < colMin[cx]) colMin[cx] = y;
+ if (y > colMax[cx]) colMax[cx] = y;
+ }
+
+ // Build poly on the fly (midpoint of envelope)
+ var nPts = 0;
+ for (var x = 0; x < w; x++)
+ {
+ if (!float.IsFinite(colMin[x])) continue;
+ var mx = r.x + (x / (float)(w - 1)) * r.width;
+ var my = 0.5f * (colMin[x] + colMax[x]);
+ if (nPts >= polyPts.Length) Array.Resize(ref polyPts, nPts + 64);
+ polyPts[nPts++] = new Vector2(mx, my);
+ }
+
+ if (nPts >= 2) DrawPolylineBatched(polyPts, nPts, m.color, lineWidth);
+ }
+
+ void BuildPolylinePoints(Rect r, float now, float invY, float[] src, out Vector2[] pts, out int n)
+ {
+ var start = (head - count + capacity) % capacity;
+ var cap = Mathf.Min(count, Mathf.RoundToInt(r.width)); // rough cap
+ if (polyPts == null || polyPts.Length < cap) polyPts = new Vector2[Mathf.Max(cap, 128)];
+ n = 0;
+ var minTime = now - windowSeconds;
+ for (var i = 0; i < count; i++)
+ {
+ var idx = (start + i) % capacity;
+ var t = timestamps[idx];
+ if (t < minTime) continue;
+ var x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds);
+ var x = Mathf.Lerp(r.x, r.xMax, x01);
+ var y01 = Mathf.Clamp01(src[idx] * invY);
+ var y = Mathf.Lerp(r.yMax, r.y, y01);
+ polyPts[n++] = new Vector2(x, y);
+ }
+
+ pts = polyPts;
+ }
+
+ // ------- batched GL drawing -------
+ static void EnsureLineMaterial()
+ {
+ if (lineMat != null) return;
+ var s = Shader.Find("Hidden/Internal-Colored");
+ lineMat = new Material(s) { hideFlags = HideFlags.HideAndDontSave };
+ lineMat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
+ lineMat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
+ lineMat.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off);
+ lineMat.SetInt("_ZWrite", 0);
+ }
+
+ static void DrawPolylineBatched(Vector2[] pts, int n, Color c, float width)
+ {
+ if (n < 2) return;
+ EnsureLineMaterial();
+ lineMat.SetPass(0);
+ GL.PushMatrix();
+ GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0);
+ GL.Begin(GL.QUADS);
+ GL.Color(c);
+
+ for (var i = 0; i < n - 1; i++)
+ {
+ Vector2 a = pts[i], b = pts[i + 1];
+ var d = b - a;
+ var len = d.magnitude;
+ if (len <= 0.001f) continue;
+ var nrm = new Vector2(-d.y, d.x) / len * (width * 0.5f);
+ GL.Vertex(a - nrm);
+ GL.Vertex(a + nrm);
+ GL.Vertex(b + nrm);
+ GL.Vertex(b - nrm);
+ }
+
+ GL.End();
+ GL.PopMatrix();
+ }
+
+ // --- Backgrounds & grids (separate for graph vs. stats) ---
+ void DrawGraphBackgroundAndGrid(Rect graphRect)
+ {
+ // Graph background fill
+ FillRect(graphRect, backgroundColor);
+
+ // Border around graph
+ DrawPolylineBatched(
+ new[]
+ {
+ new Vector2(graphRect.x, graphRect.y), new Vector2(graphRect.xMax, graphRect.y),
+ new Vector2(graphRect.xMax, graphRect.yMax), new Vector2(graphRect.x, graphRect.yMax),
+ new Vector2(graphRect.x, graphRect.y)
+ }, 5, borderColor, gridLineWidth);
+
+ // Horizontal grid inside the graph
+ if (gridLinesY > 0)
+ {
+ for (var i = 1; i < gridLinesY; i++)
+ {
+ var y = Mathf.Lerp(graphRect.y, graphRect.yMax, i / (float)gridLinesY);
+ DrawPolylineBatched(new[] { new Vector2(graphRect.x, y), new Vector2(graphRect.xMax, y) }, 2,
+ borderColor, 1f);
+ }
+ }
+ }
+
+ void DrawFpsBadge(Rect graphRect)
+ {
+ // ~30% opacity so graph lines remain readable
+ var col = axisTextColor; col.a = 0.3f;
+ GUI.color = col;
+
+ // Big number (≈10x your base font size)
+ int bigSize = Mathf.Max(12, fontSize * 4);
+
+ // Build/update style only when size changes
+ if (fpsBigStyle == null || fpsBigSizeCached != bigSize)
+ {
+ fpsBigStyle = new GUIStyle
+ {
+ fontSize = bigSize,
+ fontStyle = FontStyle.Bold,
+ alignment = TextAnchor.UpperRight,
+ normal = { textColor = Color.white }
+ };
+ fpsBigSizeCached = bigSize;
+ if (sMonoFont != null) fpsBigStyle.font = sMonoFont;
+ }
+
+ // Number text
+ int fpsInt = Mathf.RoundToInt(smoothedFps);
+ string fpsNum = fpsInt.ToString();
+
+ // Measure and place at top-right of graph
+ var numSize = fpsBigStyle.CalcSize(new GUIContent(fpsNum));
+ const float pad = 8f;
+ float xRight = graphRect.xMax - pad;
+ float yTop = graphRect.y + pad;
+
+ var numRect = new Rect(xRight - numSize.x, yTop, numSize.x, numSize.y);
+ GUI.Label(numRect, fpsNum, fpsBigStyle);
+
+ // Restore GUI color
+ GUI.color = Color.white;
+ }
+
+ void DrawPanelBackground(Rect statsRect)
+ {
+ // Stats panel background fill (fixed-size rectangle)
+ FillRect(statsRect, backgroundColor);
+
+ // Border around stats panel
+ DrawPolylineBatched(
+ new[]
+ {
+ new Vector2(statsRect.x, statsRect.y), new Vector2(statsRect.xMax, statsRect.y),
+ new Vector2(statsRect.xMax, statsRect.yMax), new Vector2(statsRect.x, statsRect.yMax),
+ new Vector2(statsRect.x, statsRect.y)
+ }, 5, borderColor, gridLineWidth);
+ }
+
+ static void FillRect(Rect r, Color c)
+ {
+ EnsureLineMaterial();
+ lineMat.SetPass(0);
+ GL.PushMatrix();
+ GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0);
+ GL.Begin(GL.QUADS);
+ GL.Color(c);
+ GL.Vertex3(r.x, r.y, 0);
+ GL.Vertex3(r.xMax, r.y, 0);
+ GL.Vertex3(r.xMax, r.yMax, 0);
+ GL.Vertex3(r.x, r.yMax, 0);
+ GL.End();
+ GL.PopMatrix();
+ }
+
+ void DrawStatsPanel(Rect statsRect)
+ {
+ var pad = statsPanelPadding;
+ var x = statsRect.x + pad;
+ var y = statsRect.y + pad;
+ var w = statsRect.width - pad * 2f;
+
+ // Header
+ GUI.color = axisTextColor;
+ var header = "Metric avg min max p95 p99";
+ GUI.Label(new Rect(x, y, w, fontSize + 6), header, monoStyle);
+ y += fontSize + 6;
+
+ // Built-ins
+ DrawStatsLine(totalMetric, ref y, x, w);
+ if (cpuMetric.enabled) DrawStatsLine(cpuMetric, ref y, x, w);
+ if (gpuMetric.enabled) DrawStatsLine(gpuMetric, ref y, x, w);
+
+ // Custom metrics
+ for (var i = 0; i < customMetrics.Count; i++)
+ {
+ var m = customMetrics[i];
+ if (m.enabled) DrawStatsLine(m, ref y, x, w);
+ }
+
+ GUI.color = axisTextColor;
+ GUI.Label(new Rect(x, y, w, fontSize + 6), "Thread speed", monoStyle);
+ y += fontSize + 4;
+
+ DrawThreadLine(ref y, x, w, "Simulation", $"{simulationThreadSpeedX:0.000}x ({simulationThreadHz:0.0} Hz)");
+ DrawThreadLine(ref y, x, w, "Input poll", inputThreadHz > 0f ? $"{inputThreadActualHz:0.0} Hz (target {inputThreadHz:0.0})" : "n/a");
+ DrawThreadLine(ref y, x, w, "PinMAME", pinMameStatsValid ? $"{pinMameCallbackHz:0.0} cb/s (run={pinMameRunState})" : "n/a");
+
+ GUI.color = Color.white;
+ }
+
+ static string TruncPad(string s, int max)
+ {
+ if (string.IsNullOrEmpty(s)) return new string(' ', max);
+ if (s.Length > max) return s.Substring(0, max);
+ if (s.Length < max) return s + new string(' ', max - s.Length);
+ return s;
+ }
+
+ void DrawStatsLine(Metric m, ref float y, float x, float w)
+ {
+ if (m == null || !m.stats.valid) return;
+ GUI.color = m.color;
+ var name = TruncPad(m.name, 12);
+ var line = string.Format("{0} {1,6:0.00} {2,6:0.00} {3,6:0.00} {4,6:0.00} {5,6:0.00}",
+ name, m.stats.avg, m.stats.min, m.stats.max, m.stats.p95, m.stats.p99);
+ GUI.Label(new Rect(x, y, w, fontSize + 6), line, monoStyle);
+ y += fontSize + 4;
+ }
+
+ void DrawThreadLine(ref float y, float x, float w, string name, string value)
+ {
+ var label = string.Format("{0,-12} {1}", TruncPad(name, 12), value);
+ GUI.Label(new Rect(x, y, w, fontSize + 6), label, monoStyle);
+ y += fontSize + 4;
+ }
+
+ Rect lastRect;
+
+ void EnsureColumnBuffers(int w)
+ {
+ if (colMin == null || colMin.Length < w)
+ {
+ colMin = new float[w];
+ colMax = new float[w];
+ }
+
+ if (polyPts == null || polyPts.Length < w) polyPts = new Vector2[Mathf.Max(w, 128)];
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs.meta
new file mode 100644
index 000000000..213301ec4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 7da4e3930ec8423392e1f24b1186965d
+timeCreated: 1758543936
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs
new file mode 100644
index 000000000..f3520a3e9
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs
@@ -0,0 +1,233 @@
+// Visual Pinball Engine
+// Copyright (C) 2023 freezy and VPE Team
+// https://github.com/freezy/VisualPinball.Engine
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+using System;
+using System.Runtime.InteropServices;
+using System.Diagnostics;
+using NLog;
+using VisualPinball.Unity.Simulation;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity
+{
+ internal static class InputLatencyTracker
+ {
+ private enum TimestampClock
+ {
+ None,
+ Native,
+ Stopwatch,
+ }
+
+ private readonly struct PendingInput
+ {
+ public readonly long TimestampUsec;
+ public readonly TimestampClock Clock;
+
+ public PendingInput(long timestampUsec, TimestampClock clock)
+ {
+ TimestampUsec = timestampUsec;
+ Clock = clock;
+ }
+ }
+
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ private const string LogPrefix = "[PinMAME-debug]";
+ private static readonly object LockObj = new object();
+
+ private static PendingInput _leftPending;
+ private static PendingInput _rightPending;
+
+ private static double _flipperLatencySumMs;
+ private static int _flipperLatencyCount;
+ private static float _lastFlipperLatencyMs;
+
+ private static bool _nativeTimestampAvailable = true;
+ private static int _inputPressLogCount;
+ private static int _visualDetectLogCount;
+ private static bool _nativeTimestampFailureLogged;
+
+ public static void Reset()
+ {
+ lock (LockObj) {
+ _leftPending = default;
+ _rightPending = default;
+ _flipperLatencySumMs = 0;
+ _flipperLatencyCount = 0;
+ _lastFlipperLatencyMs = 0;
+ _inputPressLogCount = 0;
+ _visualDetectLogCount = 0;
+ _nativeTimestampFailureLogged = false;
+ }
+ Logger.Info($"{LogPrefix} [InputLatency] Reset tracker");
+ }
+
+ public static void RecordInputPolled(NativeInputApi.InputAction action, bool isPressed, long timestampUsec)
+ {
+ if (!isPressed) {
+ return;
+ }
+
+ if (timestampUsec <= 0) {
+ timestampUsec = GetStopwatchTimestampUsec();
+ }
+
+ lock (LockObj) {
+ switch (action) {
+ case NativeInputApi.InputAction.LeftFlipper:
+ case NativeInputApi.InputAction.UpperLeftFlipper:
+ _leftPending = new PendingInput(timestampUsec, TimestampClock.Native);
+ break;
+
+ case NativeInputApi.InputAction.RightFlipper:
+ case NativeInputApi.InputAction.UpperRightFlipper:
+ _rightPending = new PendingInput(timestampUsec, TimestampClock.Native);
+ break;
+ }
+
+ if (_inputPressLogCount < 10) {
+ _inputPressLogCount++;
+ Logger.Info($"{LogPrefix} [InputLatency] Polled press action={action}, ts={timestampUsec}us");
+ }
+ }
+ }
+
+ public static void RecordSwitchInputDispatched(string switchId, bool isClosed)
+ {
+ if (!isClosed || string.IsNullOrEmpty(switchId)) {
+ return;
+ }
+
+ var timestampUsec = GetStopwatchTimestampUsec();
+ var handled = false;
+
+ lock (LockObj) {
+ switch (switchId) {
+ case "s_flipper_lower_left":
+ case "s_flipper_upper_left":
+ _leftPending = new PendingInput(timestampUsec, TimestampClock.Stopwatch);
+ handled = true;
+ break;
+
+ case "s_flipper_lower_right":
+ case "s_flipper_upper_right":
+ _rightPending = new PendingInput(timestampUsec, TimestampClock.Stopwatch);
+ handled = true;
+ break;
+ }
+
+ if (handled && _inputPressLogCount < 10) {
+ _inputPressLogCount++;
+ Logger.Info($"{LogPrefix} [InputLatency] Switch press dispatch switchId={switchId}, ts={timestampUsec}us");
+ }
+ }
+ }
+
+ public static void RecordFlipperVisualMovement(bool isLeftFlipper)
+ {
+ PendingInput pending;
+ lock (LockObj) {
+ pending = isLeftFlipper ? _leftPending : _rightPending;
+ }
+
+ if (pending.TimestampUsec <= 0 || pending.Clock == TimestampClock.None) {
+ return;
+ }
+
+ if (!TryGetVisualTimestampUsec(pending.Clock, out var visualTimestampUsec)) {
+ if (!_nativeTimestampFailureLogged) {
+ _nativeTimestampFailureLogged = true;
+ Logger.Warn($"{LogPrefix} [InputLatency] Visual timestamp unavailable while movement detected (clock={pending.Clock})");
+ }
+ return;
+ }
+
+ var latencyUsec = visualTimestampUsec - pending.TimestampUsec;
+ if (latencyUsec < 0) {
+ Logger.Warn($"{LogPrefix} [InputLatency] Negative latency detected (clock={pending.Clock}, visual={visualTimestampUsec}us, input={pending.TimestampUsec}us)");
+ latencyUsec = 0;
+ }
+
+ var latencyMs = latencyUsec / 1000.0;
+ lock (LockObj) {
+ if (isLeftFlipper) {
+ _leftPending = default;
+ } else {
+ _rightPending = default;
+ }
+
+ _flipperLatencySumMs += latencyMs;
+ _flipperLatencyCount++;
+ _lastFlipperLatencyMs = (float)latencyMs;
+
+ if (_visualDetectLogCount < 20) {
+ _visualDetectLogCount++;
+ Logger.Info($"{LogPrefix} [InputLatency] Visual flipper movement ({(isLeftFlipper ? "L" : "R")}) latency={latencyMs:0.000}ms (clock={pending.Clock}, input={pending.TimestampUsec}us, visual={visualTimestampUsec}us)");
+ }
+ }
+ }
+
+ public static float SampleFlipperLatencyMs()
+ {
+ lock (LockObj) {
+ if (_flipperLatencyCount > 0) {
+ _lastFlipperLatencyMs = (float)(_flipperLatencySumMs / _flipperLatencyCount);
+ Logger.Info($"{LogPrefix} [InputLatency] Sample window avg={_lastFlipperLatencyMs:0.000}ms from {_flipperLatencyCount} sample(s)");
+ _flipperLatencySumMs = 0;
+ _flipperLatencyCount = 0;
+ }
+
+ return _lastFlipperLatencyMs;
+ }
+ }
+
+ private static bool TryGetVisualTimestampUsec(TimestampClock clock, out long timestampUsec)
+ {
+ timestampUsec = 0;
+ switch (clock) {
+ case TimestampClock.Native:
+ return TryGetNativeTimestampUsec(out timestampUsec);
+
+ case TimestampClock.Stopwatch:
+ timestampUsec = GetStopwatchTimestampUsec();
+ return timestampUsec > 0;
+
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryGetNativeTimestampUsec(out long timestampUsec)
+ {
+ timestampUsec = 0;
+ if (!_nativeTimestampAvailable) {
+ return false;
+ }
+
+ try {
+ timestampUsec = NativeInputApi.VpeGetTimestampUsec();
+ return timestampUsec > 0;
+ }
+ catch (DllNotFoundException) {
+ _nativeTimestampAvailable = false;
+ }
+ catch (EntryPointNotFoundException) {
+ _nativeTimestampAvailable = false;
+ }
+ catch (TypeLoadException) {
+ _nativeTimestampAvailable = false;
+ }
+
+ return false;
+ }
+
+ private static long GetStopwatchTimestampUsec()
+ {
+ var ticks = Stopwatch.GetTimestamp();
+ return (ticks * 1_000_000L) / Stopwatch.Frequency;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs.meta
new file mode 100644
index 000000000..6ec290893
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 88c7e8b6974696c4caa629d43d3ab6bc
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs
index 67b1a75b6..a86ff0cb3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs
@@ -1,8 +1,7 @@
using System;
using Unity.Collections;
+using Unity.Mathematics;
using VisualPinball.Unity.Collections;
-using System.Collections.Generic;
-using UnityEngine;
namespace VisualPinball.Unity
{
@@ -42,10 +41,9 @@ internal void SetInsideOf(int itemId, int ballId)
internal void SetOutsideOfAll(int ballId) // aka ball destroyed
{
- if (!_bitLookup.ContainsKey(ballId)) {
+ if (!_bitLookup.TryGetValue(ballId, out var bitIndex)) {
return;
}
- var bitIndex = _bitLookup[ballId];
using (var enumerator = _insideOfs.GetEnumerator()) {
while (enumerator.MoveNext()) {
ref var ballIndices = ref enumerator.Current.Value;
@@ -70,37 +68,36 @@ internal void SetOutsideOf(int itemId, int ballId)
internal bool IsInsideOf(int itemId, int ballId)
{
- return _insideOfs.ContainsKey(itemId) && _insideOfs[itemId].IsSet(GetBitIndex(ballId));
+ return _insideOfs.TryGetValue(itemId, out var bits) && bits.IsSet(GetBitIndex(ballId));
}
internal bool IsOutsideOf(int itemId, int ballId) => !IsInsideOf(itemId, ballId);
internal int GetInsideCount(int itemId)
{
- if (!_insideOfs.ContainsKey(itemId)) {
+ if (!_insideOfs.TryGetValue(itemId, out var bits)) {
return 0;
}
- return _insideOfs[itemId].CountBits();
+ return bits.CountBits();
}
internal bool IsEmpty(int itemId)
{
- if (!_insideOfs.ContainsKey(itemId)) {
+ if (!_insideOfs.TryGetValue(itemId, out var bits)) {
return true;
}
- return !_insideOfs[itemId].TestAny(0, 64);
+ return !bits.TestAny(0, 64);
}
- internal List GetIdsOfBallsInsideItem(int itemId)
+ internal FixedList64Bytes GetIdsOfBallsInsideItem(int itemId)
{
- var ballIds = new List();
- if (!_insideOfs.ContainsKey(itemId)) {
+ var ballIds = new FixedList64Bytes();
+ if (!_insideOfs.TryGetValue(itemId, out var bits)) {
return ballIds;
}
- ref var bits = ref _insideOfs.GetValueByRef(itemId);
for (int i = 0; i < 64; i++) {
if (bits.IsSet(i)) {
if (TryGetBallId(i, out var ballId)) {
@@ -137,21 +134,26 @@ private void ClearBitIndex(int ballId)
private int GetBitIndex(int ballId)
{
- if (_bitLookup.ContainsKey(ballId)) {
- return _bitLookup[ballId];
+ if (_bitLookup.TryGetValue(ballId, out var existingIndex)) {
+ return existingIndex;
}
- var bitArrayIndices = _bitLookup.GetValueArray(Allocator.Temp); // todo don't copy but ref
- for (var i = 0; i < 64; i++) {
- if (bitArrayIndices.Contains(i)) {
- continue;
+ // Build a bitmask of occupied indices by iterating the map (no allocation).
+ ulong occupied = 0;
+ using (var enumerator = _bitLookup.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ occupied |= 1UL << enumerator.Current.Value;
}
- _bitLookup[ballId] = i;
- bitArrayIndices.Dispose();
- return i;
}
- bitArrayIndices.Dispose();
- throw new IndexOutOfRangeException("Bit index in InsideOfs is full.");
+
+ // Find the first zero bit (first free index).
+ var free = ~occupied;
+ if (free == 0) {
+ throw new IndexOutOfRangeException("Bit index in InsideOfs is full.");
+ }
+ var newIndex = math.tzcnt(free);
+ _bitLookup[ballId] = newIndex;
+ return newIndex;
}
private bool TryGetBallId(int bitIndex, out int ballId)
@@ -172,4 +174,4 @@ public void Dispose()
_insideOfs.Dispose();
}
}
-}
+}
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/LampPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/LampPlayer.cs
index dc7d9003a..8dfd81c59 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/LampPlayer.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/LampPlayer.cs
@@ -114,13 +114,18 @@ private void HandleLampEvent(object sender, LampEventArgs lampEvent)
}
}
- public void HandleLampEvent(string id, float value)
- {
- LampAction action = default;
- if (Apply(id, LampSource.Lamp, false, ref action)) {
- ApplyValue(id, value, action.State, action.Lamp, action.Mapping);
- }
- }
+ public void HandleLampEvent(string id, float value)
+ {
+ HandleLampEvent(id, value, LampSource.Lamp);
+ }
+
+ public void HandleLampEvent(string id, float value, LampSource source)
+ {
+ LampAction action = default;
+ if (Apply(id, source, false, ref action)) {
+ ApplyValue(id, value, action.State, action.Lamp, action.Mapping);
+ }
+ }
public void HandleLampEvent(string id, LampStatus status)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
index 4a4b69488..a738cfbd9 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
@@ -37,14 +37,14 @@ public PhysicsCycle(Allocator a)
_contacts = new NativeList(a);
}
- internal void Simulate(ref PhysicsState state, in AABB playfieldBounds, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kineticOctree, float dTime)
+ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kineticOctree, ref NativeOctree ballOctree, float dTime)
{
PerfMarker.Begin();
var staticCounts = PhysicsConstants.StaticCnts;
- // create octree of ball-to-ball collision
+ // rebuild octree of ball-to-ball collision (clear + re-insert, no alloc)
// it's okay to have this code outside of the inner loop, as the ball hitrects already include the maximum distance they can travel in that timespan
- using var ballOctree = PhysicsDynamicBroadPhase.CreateOctree(ref state.Balls, in playfieldBounds);
+ PhysicsDynamicBroadPhase.RebuildOctree(ref ballOctree, ref state.Balls);
while (dTime > 0) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
index ffa298ccb..814aea43a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
@@ -26,17 +26,16 @@ public static class PhysicsDynamicBroadPhase
private static readonly ProfilerMarker PerfMarkerBallOctree = new("CreateBallOctree");
private static readonly ProfilerMarker PerfMarkerDynamicBroadPhase = new("DynamicBroadPhase");
- internal static NativeOctree CreateOctree(ref NativeParallelHashMap balls, in AABB playfieldBounds)
+ internal static void RebuildOctree(ref NativeOctree octree, ref NativeParallelHashMap balls)
{
PerfMarkerBallOctree.Begin();
- var octree = new NativeOctree(playfieldBounds, 16, 10, Allocator.TempJob);
+ octree.Clear();
using var enumerator = balls.GetEnumerator();
while (enumerator.MoveNext()) {
ref var ball = ref enumerator.Current.Value;
octree.Insert(ball.Id, ball.Aabb);
}
PerfMarkerBallOctree.End();
- return octree;
}
internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingBalls, ref NativeParallelHashMap balls)
@@ -44,14 +43,20 @@ internal static void FindOverlaps(in NativeOctree octree, in BallState ball
PerfMarkerDynamicBroadPhase.Begin();
overlappingBalls.Clear();
octree.RangeAABBUnique(ball.Aabb, overlappingBalls);
- using var ob = overlappingBalls.ToNativeArray(Allocator.Temp);
- for (var i = 0; i < ob.Length; i ++) {
- var overlappingBallId = ob[i];
+
+ // Collect IDs to remove into a stack-allocated list to avoid copying the hash set to a NativeArray.
+ var toRemove = new FixedList64Bytes();
+ using var enumerator = overlappingBalls.GetEnumerator();
+ while (enumerator.MoveNext()) {
+ var overlappingBallId = enumerator.Current;
ref var overlappingBall = ref balls.GetValueByRef(overlappingBallId);
if (overlappingBallId == ball.Id || overlappingBall.IsFrozen) {
- overlappingBalls.Remove(overlappingBallId);
+ toRemove.Add(overlappingBallId);
}
}
+ for (var i = 0; i < toRemove.Length; i++) {
+ overlappingBalls.Remove(toRemove[i]);
+ }
PerfMarkerDynamicBroadPhase.End();
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
index 810047fcc..dcb994e82 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2023 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -19,19 +19,95 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Threading;
using NativeTrees;
using Unity.Collections;
-using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using VisualPinball.Engine.Common;
using VisualPinball.Unity.Collections;
+using VisualPinball.Unity.Simulation;
using AABB = NativeTrees.AABB;
using Debug = UnityEngine.Debug;
using Random = Unity.Mathematics.Random;
namespace VisualPinball.Unity
{
+ ///
+ /// Central physics engine for the Visual Pinball simulation.
+ ///
+ /// Operating Modes
+ ///
+ /// - Single-threaded (default): runs
+ /// physics and applies movements on the Unity main thread via
+ /// ExecutePhysicsUpdate.
+ /// - Simulation-thread: A dedicated 1 kHz thread calls
+ /// . The main thread reads the latest
+ /// snapshot lock-free via
+ /// and the triple-buffered .
+ ///
+ ///
+ /// Architecture
+ /// This MonoBehaviour is a thin lifecycle/API shell. All shared
+ /// mutable state lives in (created
+ /// eagerly as a field initializer, populated in Awake/Start,
+ /// disposed in OnDestroy).
+ /// All threading/tick methods live in
+ /// (created at end of
+ /// Start).
+ ///
+ /// Threading Contract
+ /// Four threads participate at runtime:
+ ///
+ /// - Unity main thread (~60-144 Hz) — rendering, UI, event
+ /// drain, visual state application.
+ /// - Simulation thread (1000 Hz) — physics ticks, input
+ /// dispatch, coil output processing, GLE time fence.
+ /// - Native input polling thread (500-2000 Hz) — raw
+ /// keyboard/gamepad polling via VpeNativeInput.dll.
+ /// - PinMAME emulation thread (variable, time-fenced) —
+ /// ROM emulation.
+ ///
+ ///
+ /// Communication Channels
+ ///
+ ///
+ /// Channel
+ /// Direction / Protection
+ ///
+ /// -
+ /// (triple buffer)
+ /// Sim -> Main. Lock-free (atomic index swap).
+ /// THE canonical channel for animation data (ball positions,
+ /// flipper angles, etc.).
+ ///
+ /// -
+ /// PendingKinematicTransforms
+ /// Main -> Sim. Protected by
+ /// PendingKinematicLock.
+ ///
+ /// -
+ /// InputActions queue
+ /// Any -> Sim. Protected by
+ /// InputActionsLock. Used by component APIs
+ /// (Schedule) to enqueue state mutations.
+ ///
+ /// -
+ /// EventQueue
+ /// Sim -> Main. NativeQueue; drained under
+ /// PhysicsLock via Monitor.TryEnter.
+ ///
+ /// -
+ /// PhysicsLock
+ /// Coarse lock held by sim thread during tick. Main
+ /// thread acquires non-blockingly for callback drain.
+ ///
+ ///
+ ///
+ /// Lock Ordering
+ /// PhysicsLock -> PendingKinematicLock
+ /// -> InputActionsLock
+ ///
[PackAs("PhysicsEngine")]
public class PhysicsEngine : MonoBehaviour, IPackable
{
@@ -54,116 +130,269 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac
#endregion
- #region States
-
- [NonSerialized] private AABB _playfieldBounds;
- [NonSerialized] private InsideOfs _insideOfs;
- [NonSerialized] private NativeOctree _octree;
- [NonSerialized] private NativeColliders _colliders;
- [NonSerialized] private NativeColliders _kinematicColliders;
- [NonSerialized] private NativeColliders _kinematicCollidersAtIdentity;
- [NonSerialized] private NativeParallelHashMap _kinematicColliderLookups;
- [NonSerialized] private NativeParallelHashMap _colliderLookups; // only used for editor debug
- [NonSerialized] private readonly LazyInit> _physicsEnv = new(() => new NativeArray(1, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _eventQueue = new(() => new NativeQueue(Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _ballStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _bumperStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _flipperStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _gateStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit>_dropTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _hitTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _kickerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _plungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _spinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _surfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _triggerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
- [NonSerialized] private readonly LazyInit> _disabledCollisionItems = new(() => new NativeParallelHashSet(0, Allocator.Persistent));
- [NonSerialized] private bool _swapBallCollisionHandling;
-
- [NonSerialized] public NativeParallelHashMap> ElasticityOverVelocityLUTs;
- [NonSerialized] public NativeParallelHashMap> FrictionOverVelocityLUTs;
+ #region Fields
- #endregion
+ ///
+ /// All shared mutable state. Initialized eagerly as a field
+ /// initializer so that other components' Awake / OnEnable
+ /// can safely call Register / EnableCollider before
+ /// this MonoBehaviour's own Awake runs (Unity does not
+ /// guarantee Awake order between sibling components).
+ /// Populated through and ,
+ /// disposed in .
+ ///
+ [NonSerialized] private readonly PhysicsEngineContext _ctx = new();
- #region Transforms
+ ///
+ /// Threading/tick methods. Created at end of
+ /// once all context fields are populated.
+ ///
+ [NonSerialized] private PhysicsEngineThreading _threading;
+
+ // Lifecycle-local references (used during Awake/Start, then passed
+ // to _threading constructor).
+ [NonSerialized] private Player _player;
+ [NonSerialized] private ICollidableComponent[] _colliderComponents;
+ [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents;
+ [NonSerialized] private float4x4 _worldToPlayfield;
+ [NonSerialized] private int _mainThreadManagedThreadId;
+ [NonSerialized] private int _simulationThreadManagedThreadId = -1;
+ [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet();
+ [NonSerialized] private bool _inputActionsQueueWarningIssued;
+ [NonSerialized] private bool _scheduledActionsQueueWarningIssued;
+
+ private const int InputActionsQueueWarningThreshold = 256;
+ private const int ScheduledActionsWarningThreshold = 256;
+
+ #endregion
- [NonSerialized] private readonly Dictionary _ballComponents = new();
+ private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000);
+ internal ulong CurrentSimulationClockUsec => NowUsec;
+ internal float CurrentSimulationClockScale => Time.timeScale;
+ internal bool UsesExternalTiming => _ctx.UseExternalTiming;
///
- /// Last transforms of kinematic items, so we can detect changes.
+ /// Check if the physics engine has completed initialization.
+ /// Used by the simulation thread to wait for physics to be ready.
///
- [NonSerialized] private readonly LazyInit> _kinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public bool IsInitialized => _ctx != null && _ctx.IsInitialized;
+
+ #region Ref-Return Properties
///
- /// The transforms of the kinematic items that have changes since the last frame.
+ /// Elasticity-over-velocity lookup tables, keyed by item ID.
///
- [NonSerialized] private readonly LazyInit> _updatedKinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ ///
+ /// Accessed by via ref return
+ /// (see CollidableApi.cs:68). Must remain a ref-return
+ /// property so callers can take a reference to the native map.
+ ///
+ public ref NativeParallelHashMap> ElasticityOverVelocityLUTs => ref _ctx.ElasticityOverVelocityLUTs;
///
- /// The current matrix to which the ball will be transformed to, if it collides with a non-transformable collider.
- /// This changes as the non-transformable collider collider transforms (it's called non-transformable as in
- /// not transformable by the physics engine, but it can be transformed by the game).
- ///
- /// todo save inverse matrix, too
+ /// Friction-over-velocity lookup tables, keyed by item ID.
///
///
- /// This has nothing to do with kinematic transformations, it's purely to add full support for transformations
- /// for items where the original physics engine doesn't.
+ /// Same ref-return requirement as
+ /// .
///
- [NonSerialized] private readonly LazyInit> _nonTransformableColliderTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
-
- [NonSerialized] private readonly Dictionary> _boolAnimatedComponents = new();
- [NonSerialized] private readonly Dictionary> _floatAnimatedComponents = new();
- [NonSerialized] private readonly Dictionary> _float2AnimatedComponents = new();
+ public ref NativeParallelHashMap> FrictionOverVelocityLUTs => ref _ctx.FrictionOverVelocityLUTs;
#endregion
- [NonSerialized] private readonly Queue _inputActions = new();
- [NonSerialized] private readonly List _scheduledActions = new();
-
- [NonSerialized] private Player _player;
- [NonSerialized] private PhysicsMovements _physicsMovements;
- [NonSerialized] private ICollidableComponent[] _colliderComponents;
- [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents;
- [NonSerialized] private float4x4 _worldToPlayfield;
-
- private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000);
-
#region API
public void ScheduleAction(int timeoutMs, Action action) => ScheduleAction((uint)timeoutMs, action);
public void ScheduleAction(uint timeoutMs, Action action)
{
- lock (_scheduledActions) {
- _scheduledActions.Add(new ScheduledAction(_physicsEnv.Ref[0].CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action));
+ var scheduleAt = CurrentScheduledActionTimeUsec + (ulong)timeoutMs * 1000;
+ lock (_ctx.ScheduledActionsLock) {
+ PushScheduledAction(new PhysicsEngineContext.ScheduledAction(scheduleAt, action));
+ if (!_scheduledActionsQueueWarningIssued && _ctx.ScheduledActions.Count >= ScheduledActionsWarningThreshold) {
+ _scheduledActionsQueueWarningIssued = true;
+ LogQueueWarning($"[PhysicsEngine] ScheduledActions backlog reached {_ctx.ScheduledActions.Count} items. Callback production may be outpacing drain.");
+ }
+ }
+ }
+
+ private void PushScheduledAction(PhysicsEngineContext.ScheduledAction action)
+ {
+ var scheduledActions = _ctx.ScheduledActions;
+ scheduledActions.Add(action);
+ var index = scheduledActions.Count - 1;
+ while (index > 0) {
+ var parentIndex = (index - 1) / 2;
+ if (scheduledActions[parentIndex].ScheduleAt <= scheduledActions[index].ScheduleAt) {
+ break;
+ }
+
+ (scheduledActions[parentIndex], scheduledActions[index]) = (scheduledActions[index], scheduledActions[parentIndex]);
+ index = parentIndex;
}
}
internal delegate void InputAction(ref PhysicsState state);
- internal ref NativeParallelHashMap Balls => ref _ballStates.Ref;
- internal ref InsideOfs InsideOfs => ref _insideOfs;
- internal NativeQueue.ParallelWriter EventQueue => _eventQueue.Ref.AsParallelWriter();
-
- internal void Schedule(InputAction action) => _inputActions.Enqueue(action);
- internal bool BallExists(int ballId) => _ballStates.Ref.ContainsKey(ballId);
- internal ref BallState BallState(int ballId) => ref _ballStates.Ref.GetValueByRef(ballId);
- internal ref BumperState BumperState(int itemId) => ref _bumperStates.Ref.GetValueByRef(itemId);
- internal ref FlipperState FlipperState(int itemId) => ref _flipperStates.Ref.GetValueByRef(itemId);
- internal ref GateState GateState(int itemId) => ref _gateStates.Ref.GetValueByRef(itemId);
- internal ref DropTargetState DropTargetState(int itemId) => ref _dropTargetStates.Ref.GetValueByRef(itemId);
- internal ref HitTargetState HitTargetState(int itemId) => ref _hitTargetStates.Ref.GetValueByRef(itemId);
- internal ref KickerState KickerState(int itemId) => ref _kickerStates.Ref.GetValueByRef(itemId);
- internal ref PlungerState PlungerState(int itemId) => ref _plungerStates.Ref.GetValueByRef(itemId);
- internal ref SpinnerState SpinnerState(int itemId) => ref _spinnerStates.Ref.GetValueByRef(itemId);
- internal ref SurfaceState SurfaceState(int itemId) => ref _surfaceStates.Ref.GetValueByRef(itemId);
- internal ref TriggerState TriggerState(int itemId) => ref _triggerStates.Ref.GetValueByRef(itemId);
- internal void SetBallInsideOf(int ballId, int itemId) => _insideOfs.SetInsideOf(itemId, ballId);
- internal bool HasBallsInsideOf(int itemId) => _insideOfs.GetInsideCount(itemId) > 0;
- internal List GetBallsInsideOf(int itemId) => _insideOfs.GetIdsOfBallsInsideItem(itemId);
-
- internal uint TimeMsec => _physicsEnv.Ref[0].TimeMsec;
- internal Random Random => _physicsEnv.Ref[0].Random;
+ internal ref NativeParallelHashMap Balls => ref _ctx.BallStates.Ref;
+ internal ref InsideOfs InsideOfs => ref _ctx.InsideOfs;
+ internal NativeQueue.ParallelWriter EventQueue => _ctx.EventQueue.Ref.AsParallelWriter();
+
+ ///
+ /// Enqueue a state mutation to be processed on the sim thread
+ /// (or main thread in single-threaded mode) inside PhysicsLock.
+ ///
+ ///
+ /// Thread: Any thread. Protected by InputActionsLock.
+ ///
+ internal void Schedule(InputAction action)
+ {
+ lock (_ctx.InputActionsLock) {
+ _ctx.InputActions.Enqueue(action);
+ if (!_inputActionsQueueWarningIssued && _ctx.InputActions.Count >= InputActionsQueueWarningThreshold) {
+ _inputActionsQueueWarningIssued = true;
+ LogQueueWarning($"[PhysicsEngine] InputActions backlog reached {_ctx.InputActions.Count} items. Producers may be outpacing simulation-thread drain.");
+ }
+ }
+ }
+
+ internal void MutateState(InputAction action)
+ {
+ if (_ctx.UseExternalTiming) {
+ Schedule(action);
+ return;
+ }
+
+ var state = _ctx.CreateState();
+ action(ref state);
+ }
+
+ internal void MarkCurrentThreadAsSimulationThread()
+ {
+ Interlocked.Exchange(ref _simulationThreadManagedThreadId, Thread.CurrentThread.ManagedThreadId);
+ }
+
+ private bool IsMainThread => _mainThreadManagedThreadId == Thread.CurrentThread.ManagedThreadId;
+ private bool IsSimulationThread => Volatile.Read(ref _simulationThreadManagedThreadId) == Thread.CurrentThread.ManagedThreadId;
+ private ulong CurrentScheduledActionTimeUsec
+ {
+ get {
+ if (!_ctx.UseExternalTiming || IsSimulationThread) {
+ return _ctx.PhysicsEnv.CurPhysicsFrameTime;
+ }
+
+ var publishedUsec = Interlocked.Read(ref _ctx.PublishedPhysicsFrameTimeUsec);
+ return publishedUsec > 0 ? (ulong)publishedUsec : CurrentSimulationClockUsec;
+ }
+ }
+
+ private void GuardLiveStateAccess(string accessorName)
+ {
+ if (!_ctx.UseExternalTiming || IsSimulationThread) {
+ return;
+ }
+
+ if (!IsMainThread) {
+ throw new InvalidOperationException($"Live physics state '{accessorName}' accessed from unsupported thread {Thread.CurrentThread.ManagedThreadId}. In external-timing mode, live state is owned by the simulation thread.");
+ }
+
+ if (_unsafeLiveStateAccessWarnings.Add(accessorName)) {
+ LogQueueWarning($"[PhysicsEngine] Live physics state '{accessorName}' was accessed from the Unity main thread while external timing is enabled. Prefer snapshot data or schedule work onto the simulation thread.");
+ }
+ }
+
+ [System.Diagnostics.Conditional("UNITY_EDITOR")]
+ [System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
+ private static void LogQueueWarning(string message)
+ {
+ Debug.LogWarning(message);
+ }
+
+ // ── State accessors ──────────────────────────────────────────
+ // These return refs into native hash maps. In single-threaded
+ // mode they are safe. In threaded mode, callers on the sim
+ // thread (e.g. FlipperApi coil callbacks) access them inside
+ // PhysicsLock. Main-thread callers should only read through
+ // the triple-buffered snapshot; direct access is a pre-existing
+ // thread-safety concern (see AGENTS.md audit notes).
+
+ internal bool BallExists(int ballId)
+ {
+ GuardLiveStateAccess(nameof(BallExists));
+ return _ctx.BallStates.Ref.ContainsKey(ballId);
+ }
+ internal ref BallState BallState(int ballId)
+ {
+ GuardLiveStateAccess(nameof(BallState));
+ return ref _ctx.BallStates.Ref.GetValueByRef(ballId);
+ }
+ internal ref BumperState BumperState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(BumperState));
+ return ref _ctx.BumperStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref FlipperState FlipperState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(FlipperState));
+ return ref _ctx.FlipperStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref GateState GateState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(GateState));
+ return ref _ctx.GateStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref DropTargetState DropTargetState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(DropTargetState));
+ return ref _ctx.DropTargetStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref HitTargetState HitTargetState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(HitTargetState));
+ return ref _ctx.HitTargetStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref KickerState KickerState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(KickerState));
+ return ref _ctx.KickerStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref PlungerState PlungerState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(PlungerState));
+ return ref _ctx.PlungerStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref SpinnerState SpinnerState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(SpinnerState));
+ return ref _ctx.SpinnerStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref SurfaceState SurfaceState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(SurfaceState));
+ return ref _ctx.SurfaceStates.Ref.GetValueByRef(itemId);
+ }
+ internal ref TriggerState TriggerState(int itemId)
+ {
+ GuardLiveStateAccess(nameof(TriggerState));
+ return ref _ctx.TriggerStates.Ref.GetValueByRef(itemId);
+ }
+ internal void SetBallInsideOf(int ballId, int itemId)
+ {
+ GuardLiveStateAccess(nameof(SetBallInsideOf));
+ _ctx.InsideOfs.SetInsideOf(itemId, ballId);
+ }
+ internal bool HasBallsInsideOf(int itemId)
+ {
+ GuardLiveStateAccess(nameof(HasBallsInsideOf));
+ return _ctx.InsideOfs.GetInsideCount(itemId) > 0;
+ }
+ internal FixedList64Bytes GetBallsInsideOf(int itemId)
+ {
+ GuardLiveStateAccess(nameof(GetBallsInsideOf));
+ return _ctx.InsideOfs.GetIdsOfBallsInsideItem(itemId);
+ }
+
+ internal uint TimeMsec => _ctx.PhysicsEnv.TimeMsec;
+ internal Random Random => _ctx.PhysicsEnv.Random;
internal void Register(T item) where T : MonoBehaviour
{
var go = item.gameObject;
@@ -172,64 +401,155 @@ internal void Register(T item) where T : MonoBehaviour
// states
switch (item) {
case BallComponent c:
- if (!_ballStates.Ref.ContainsKey(itemId)) {
- _ballStates.Ref[itemId] = c.CreateState();
+ if (!_ctx.BallStates.Ref.ContainsKey(itemId)) {
+ _ctx.BallStates.Ref[itemId] = c.CreateState();
}
- _ballComponents.TryAdd(itemId, c);
+ _ctx.BallComponents.TryAdd(itemId, c);
break;
- case BumperComponent c: _bumperStates.Ref[itemId] = c.CreateState(); break;
+ case BumperComponent c: _ctx.BumperStates.Ref[itemId] = c.CreateState(); break;
case FlipperComponent c:
- _flipperStates.Ref[itemId] = c.CreateState();
+ _ctx.FlipperStates.Ref[itemId] = c.CreateState();
break;
- case GateComponent c: _gateStates.Ref[itemId] = c.CreateState(); break;
- case DropTargetComponent c: _dropTargetStates.Ref[itemId] = c.CreateState(); break;
- case HitTargetComponent c: _hitTargetStates.Ref[itemId] = c.CreateState(); break;
- case KickerComponent c: _kickerStates.Ref[itemId] = c.CreateState(); break;
+ case GateComponent c: _ctx.GateStates.Ref[itemId] = c.CreateState(); break;
+ case DropTargetComponent c: _ctx.DropTargetStates.Ref[itemId] = c.CreateState(); break;
+ case HitTargetComponent c: _ctx.HitTargetStates.Ref[itemId] = c.CreateState(); break;
+ case KickerComponent c: _ctx.KickerStates.Ref[itemId] = c.CreateState(); break;
case PlungerComponent c:
- _plungerStates.Ref[itemId] = c.CreateState();
+ _ctx.PlungerStates.Ref[itemId] = c.CreateState();
break;
- case SpinnerComponent c: _spinnerStates.Ref[itemId] = c.CreateState(); break;
- case SurfaceComponent c: _surfaceStates.Ref[itemId] = c.CreateState(); break;
- case TriggerComponent c: _triggerStates.Ref[itemId] = c.CreateState(); break;
+ case SpinnerComponent c: _ctx.SpinnerStates.Ref[itemId] = c.CreateState(); break;
+ case SurfaceComponent c: _ctx.SurfaceStates.Ref[itemId] = c.CreateState(); break;
+ case TriggerComponent c: _ctx.TriggerStates.Ref[itemId] = c.CreateState(); break;
}
// animations
- if (item is IAnimationValueEmitter boolAnimatedComponent) {
- _boolAnimatedComponents.TryAdd(itemId, boolAnimatedComponent);
- }
if (item is IAnimationValueEmitter floatAnimatedComponent) {
- _floatAnimatedComponents.TryAdd(itemId, floatAnimatedComponent);
+ _ctx.FloatAnimatedComponents.TryAdd(itemId, floatAnimatedComponent);
}
if (item is IAnimationValueEmitter float2AnimatedComponent) {
- _float2AnimatedComponents.TryAdd(itemId, float2AnimatedComponent);
+ _ctx.Float2AnimatedComponents.TryAdd(itemId, float2AnimatedComponent);
}
}
internal BallComponent UnregisterBall(int ballId)
{
- var b = _ballComponents[ballId];
- _ballComponents.Remove(ballId);
- _ballStates.Ref.Remove(ballId);
- _insideOfs.SetOutsideOfAll(ballId);
+ if (_ctx.UseExternalTiming) {
+ throw new InvalidOperationException("Use UnregisterRuntimeBall() when external timing is enabled.");
+ }
+
+ var b = _ctx.BallComponents[ballId];
+ _ctx.BallComponents.Remove(ballId);
+ _ctx.BallStates.Ref.Remove(ballId);
+ _ctx.InsideOfs.SetOutsideOfAll(ballId);
return b;
}
- internal bool IsColliderEnabled(int itemId) => !_disabledCollisionItems.Ref.Contains(itemId);
+ internal void RegisterRuntimeBall(BallComponent ball)
+ {
+ var ballId = ball.gameObject.GetInstanceID();
+ var ballState = ball.CreateState();
+ _ctx.BallComponents[ballId] = ball;
+
+ if (_ctx.UseExternalTiming) {
+ lock (_ctx.PhysicsLock) {
+ if (_ctx.BallStates.Ref.IsCreated && !_ctx.BallStates.Ref.ContainsKey(ballId)) {
+ _ctx.BallStates.Ref[ballId] = ballState;
+ }
+ }
+ return;
+ }
- internal void EnableCollider(int itemId)
+ if (!_ctx.BallStates.Ref.ContainsKey(ballId)) {
+ _ctx.BallStates.Ref[ballId] = ballState;
+ }
+ }
+
+ internal BallComponent UnregisterRuntimeBall(int ballId)
{
- if (_disabledCollisionItems.Ref.Contains(itemId)) {
- _disabledCollisionItems.Ref.Remove(itemId);
+ var ball = _ctx.BallComponents[ballId];
+ _ctx.BallComponents.Remove(ballId);
+
+ if (_ctx.UseExternalTiming) {
+ lock (_ctx.PhysicsLock) {
+ if (_ctx.BallStates.Ref.IsCreated) {
+ _ctx.BallStates.Ref.Remove(ballId);
+ }
+ _ctx.InsideOfs.SetOutsideOfAll(ballId);
+ }
+ return ball;
}
+
+ _ctx.BallStates.Ref.Remove(ballId);
+ _ctx.InsideOfs.SetOutsideOfAll(ballId);
+
+ return ball;
+ }
+
+ internal bool IsColliderEnabled(int itemId) => !_ctx.DisabledCollisionItems.Ref.Contains(itemId);
+
+ internal void EnableCollider(int itemId)
+ {
+ MutateState((ref PhysicsState state) => state.EnableColliders(itemId));
}
internal void DisableCollider(int itemId)
{
- if (!_disabledCollisionItems.Ref.Contains(itemId)) {
- _disabledCollisionItems.Ref.Add(itemId);
+ MutateState((ref PhysicsState state) => state.DisableColliders(itemId));
+ }
+
+ public BallComponent GetBall(int itemId) => _ctx.BallComponents[itemId];
+
+ #endregion
+
+ #region Forwarding — Simulation Thread
+
+ ///
+ /// Execute a single physics tick with external timing.
+ /// Forwarded to .
+ ///
+ ///
+ /// Thread: Simulation thread (called by
+ /// ).
+ ///
+ public void ExecuteTick(ulong timeUsec) => _threading.ExecuteTick(timeUsec);
+
+ ///
+ /// Copy current animation values into a snapshot buffer.
+ /// Forwarded to .
+ ///
+ ///
+ /// Thread: Simulation thread.
+ ///
+ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) => _threading.SnapshotAnimations(ref snapshot);
+
+ internal bool TrySnapshotAnimations(ref SimulationState.Snapshot snapshot)
+ {
+ if (!_ctx.UseExternalTiming || !_ctx.IsInitialized) {
+ return false;
+ }
+
+ lock (_ctx.PhysicsLock) {
+ if (!_ctx.IsInitialized) {
+ return false;
+ }
+
+ _threading.SnapshotAnimations(ref snapshot);
+ return true;
}
}
- public BallComponent GetBall(int itemId) => _ballComponents[itemId];
+ internal void FillDiagnostics(ref SimulationState.Snapshot snapshot)
+ {
+ snapshot.KinematicScanUsec = Interlocked.Read(ref _ctx.LastKinematicScanUsec);
+ snapshot.EventDrainUsec = Interlocked.Read(ref _ctx.LastEventDrainUsec);
+
+ lock (_ctx.InputActionsLock) {
+ snapshot.PendingInputActionCount = _ctx.InputActions.Count;
+ }
+
+ lock (_ctx.ScheduledActionsLock) {
+ snapshot.PendingScheduledActionCount = _ctx.ScheduledActions.Count;
+ }
+ }
#endregion
@@ -237,33 +557,52 @@ internal void DisableCollider(int itemId)
private void Awake()
{
+ _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId;
_player = GetComponentInParent();
- _physicsMovements = new PhysicsMovements();
- _insideOfs = new InsideOfs(Allocator.Persistent);
- _physicsEnv.Ref[0] = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength);
+ _ctx.InsideOfs = new InsideOfs(Allocator.Persistent);
+ _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength);
+ Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime);
+ _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent);
+ _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent);
+
_colliderComponents = GetComponentsInChildren();
_kinematicColliderComponents = _colliderComponents.Where(c => c.IsKinematic).ToArray();
- ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent);
- FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent);
}
private void Start()
{
- // create static octree
var sw = Stopwatch.StartNew();
var playfield = GetComponentInChildren();
+ // register frame pacing stats
+ var stats = FindFirstObjectByType();
+ if (stats) {
+ long lastBusyTotalUsec = Interlocked.Read(ref _ctx.PhysicsBusyTotalUsec);
+ InputLatencyTracker.Reset();
+ stats.RegisterCustomMetric("Physics", Color.magenta, () => {
+ var totalBusyUsec = Interlocked.Read(ref _ctx.PhysicsBusyTotalUsec);
+ var deltaBusyUsec = totalBusyUsec - lastBusyTotalUsec;
+ if (deltaBusyUsec < 0) {
+ deltaBusyUsec = 0;
+ }
+ lastBusyTotalUsec = totalBusyUsec;
+ return deltaBusyUsec / 1000f;
+ });
+ stats.RegisterCustomMetric("In Lat (ms)", new Color(0.65f, 1f, 0.3f, 0.9f), InputLatencyTracker.SampleFlipperLatencyMs);
+ }
+
+ // create static octree
Debug.Log($"Found {_colliderComponents.Length} collidable items ({_kinematicColliderComponents.Length} kinematic).");
- var colliders = new ColliderReference(ref _nonTransformableColliderTransforms.Ref, Allocator.Temp);
- var kinematicColliders = new ColliderReference(ref _nonTransformableColliderTransforms.Ref, Allocator.Temp, true);
+ var colliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp);
+ var kinematicColliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp, true);
foreach (var colliderItem in _colliderComponents) {
if (!colliderItem.IsCollidable) {
- _disabledCollisionItems.Ref.Add(colliderItem.ItemId);
+ _ctx.DisabledCollisionItems.Ref.Add(colliderItem.ItemId);
}
var translateWithinPlayfieldMatrix = colliderItem.GetLocalToPlayfieldMatrixInVpx(playfield.transform.worldToLocalMatrix);
// todo check if we cannot only add those that are actually non-transformable
- _nonTransformableColliderTransforms.Ref[colliderItem.ItemId] = translateWithinPlayfieldMatrix;
+ _ctx.NonTransformableColliderTransforms.Ref[colliderItem.ItemId] = translateWithinPlayfieldMatrix;
if (colliderItem.IsKinematic) {
colliderItem.GetColliders(_player, this, ref kinematicColliders, translateWithinPlayfieldMatrix, 0);
@@ -273,213 +612,154 @@ private void Start()
}
// allocate colliders
- _colliders = new NativeColliders(ref colliders, Allocator.Persistent);
- _kinematicColliders = new NativeColliders(ref kinematicColliders, Allocator.Persistent);
+ _ctx.Colliders = new NativeColliders(ref colliders, Allocator.Persistent);
+ _ctx.KinematicColliders = new NativeColliders(ref kinematicColliders, Allocator.Persistent);
// get kinetic collider matrices
_worldToPlayfield = playfield.transform.worldToLocalMatrix;
foreach (var coll in _kinematicColliderComponents) {
- _kinematicTransforms.Ref[coll.ItemId] = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield);
+ var matrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield);
+ _ctx.KinematicTransforms.Ref[coll.ItemId] = matrix;
+ _ctx.MainThreadKinematicCache[coll.ItemId] = matrix;
}
#if UNITY_EDITOR
- _colliderLookups = colliders.CreateLookup(Allocator.Persistent);
+ _ctx.ColliderLookups = colliders.CreateLookup(Allocator.Persistent);
#endif
- _kinematicColliderLookups = kinematicColliders.CreateLookup(Allocator.Persistent);
+ _ctx.KinematicColliderLookups = kinematicColliders.CreateLookup(Allocator.Persistent);
// create identity kinematic colliders
- kinematicColliders.TransformToIdentity(ref _kinematicTransforms.Ref);
- _kinematicCollidersAtIdentity = new NativeColliders(ref kinematicColliders, Allocator.Persistent);
+ kinematicColliders.TransformToIdentity(ref _ctx.KinematicTransforms.Ref);
+ _ctx.KinematicCollidersAtIdentity = new NativeColliders(ref kinematicColliders, Allocator.Persistent);
// create octree
var elapsedMs = sw.Elapsed.TotalMilliseconds;
- _playfieldBounds = playfield.Bounds;
- _octree = new NativeOctree(_playfieldBounds, 1024, 10, Allocator.Persistent);
+ _ctx.PlayfieldBounds = playfield.Bounds;
+ _ctx.Octree = new NativeOctree(_ctx.PlayfieldBounds, 1024, 10, Allocator.Persistent);
sw.Restart();
- var populateJob = new PhysicsPopulateJob {
- Colliders = _colliders,
- Octree = _octree,
- };
- populateJob.Run();
- _octree = populateJob.Octree;
- Debug.Log($"Octree of {_colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms).");
+ unsafe {
+ fixed (NativeColliders* c = &_ctx.Colliders)
+ fixed (NativeOctree* o = &_ctx.Octree) {
+ PhysicsPopulate.PopulateUnsafe((IntPtr)c, (IntPtr)o);
+ }
+ }
+ Debug.Log($"Octree of {_ctx.Colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms).");
+
+ // create persistent kinematic and ball octrees (cleared + rebuilt each use)
+ _ctx.KinematicOctree = new NativeOctree(_ctx.PlayfieldBounds, 1024, 10, Allocator.Persistent);
+ _ctx.BallOctree = new NativeOctree(_ctx.PlayfieldBounds, 1024, 10, Allocator.Persistent);
+
+ // create persistent physics cycle (holds contacts buffer)
+ _ctx.PhysicsCycle = new PhysicsCycle(Allocator.Persistent);
// get balls
var balls = GetComponentsInChildren();
foreach (var ball in balls) {
Register(ball);
}
+
+ // Create threading helper (all context fields are now populated)
+ _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicColliderComponents, _worldToPlayfield);
+
+ // Mark as initialized for simulation thread
+ _ctx.IsInitialized = true;
}
- internal PhysicsState CreateState()
+ internal PhysicsState CreateState() => _ctx.CreateState();
+
+ ///
+ /// Enable external timing control (for simulation thread).
+ /// When enabled, delegates to
+ /// ,
+ /// ,
+ /// and .
+ ///
+ ///
+ /// Thread: Main thread only (called during setup/teardown).
+ ///
+ public void SetExternalTiming(bool enable)
{
- var events = _eventQueue.Ref.AsParallelWriter();
- var env = _physicsEnv.Ref[0];
- return new PhysicsState(ref env, ref _octree, ref _colliders, ref _kinematicColliders,
- ref _kinematicCollidersAtIdentity, ref _kinematicTransforms.Ref, ref _updatedKinematicTransforms.Ref,
- ref _nonTransformableColliderTransforms.Ref, ref _kinematicColliderLookups, ref events,
- ref _insideOfs, ref _ballStates.Ref, ref _bumperStates.Ref, ref _dropTargetStates.Ref, ref _flipperStates.Ref, ref _gateStates.Ref,
- ref _hitTargetStates.Ref, ref _kickerStates.Ref, ref _plungerStates.Ref, ref _spinnerStates.Ref,
- ref _surfaceStates.Ref, ref _triggerStates.Ref, ref _disabledCollisionItems.Ref, ref _swapBallCollisionHandling,
- ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs);
+ _ctx.UseExternalTiming = enable;
}
- private void Update()
+ ///
+ /// Provide the triple-buffered so that
+ /// can write
+ /// animation data and
+ /// can read it
+ /// lock-free.
+ ///
+ ///
+ /// Thread: Main thread only (called during setup/teardown).
+ ///
+ public void SetSimulationState(SimulationState state)
{
- // check for updated kinematic transforms
- _updatedKinematicTransforms.Ref.Clear();
- foreach (var coll in _kinematicColliderComponents) {
- var lastTransformationMatrix = _kinematicTransforms.Ref[coll.ItemId];
- var currTransformationMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield);
- if (lastTransformationMatrix.Equals(currTransformationMatrix)) {
- continue;
- }
- _updatedKinematicTransforms.Ref.Add(coll.ItemId, currTransformationMatrix);
- _kinematicTransforms.Ref[coll.ItemId] = currTransformationMatrix;
- coll.OnTransformationChanged(currTransformationMatrix);
- }
+ _ctx.SimulationState = state;
+ }
- // prepare job
- var events = _eventQueue.Ref.AsParallelWriter();
- using var overlappingColliders = new NativeParallelHashSet(0, Allocator.TempJob);
-
- var updatePhysics = new PhysicsUpdateJob {
- InitialTimeUsec = NowUsec,
- DeltaTimeMs = Time.deltaTime * 1000,
- PhysicsEnv = _physicsEnv.Ref,
- Octree = _octree,
- Colliders = _colliders,
- KinematicColliders = _kinematicColliders,
- KinematicCollidersAtIdentity = _kinematicCollidersAtIdentity,
- KinematicColliderLookups = _kinematicColliderLookups,
- KinematicTransforms = _kinematicTransforms.Ref,
- UpdatedKinematicTransforms = _updatedKinematicTransforms.Ref,
- NonTransformableColliderTransforms = _nonTransformableColliderTransforms.Ref,
- InsideOfs = _insideOfs,
- Events = events,
- Balls = _ballStates.Ref,
- BumperStates = _bumperStates.Ref,
- DropTargetStates = _dropTargetStates.Ref,
- FlipperStates = _flipperStates.Ref,
- GateStates = _gateStates.Ref,
- HitTargetStates = _hitTargetStates.Ref,
- KickerStates = _kickerStates.Ref,
- PlungerStates = _plungerStates.Ref,
- SpinnerStates = _spinnerStates.Ref,
- SurfaceStates = _surfaceStates.Ref,
- TriggerStates = _triggerStates.Ref,
- DisabledCollisionItems = _disabledCollisionItems.Ref,
- PlayfieldBounds = _playfieldBounds,
- OverlappingColliders = overlappingColliders,
- ElasticityOverVelocityLUTs = ElasticityOverVelocityLUTs,
- FrictionOverVelocityLUTs = FrictionOverVelocityLUTs,
- };
-
- var state = CreateState();
-
- // process input
- while (_inputActions.Count > 0) {
- var action = _inputActions.Dequeue();
- action(ref state);
+ ///
+ /// Unity update loop. Dispatches to either the threaded or
+ /// single-threaded code path via .
+ ///
+ private void Update()
+ {
+ if (_threading == null) { // Start() hasn't completed yet
+ return;
}
-
- // run physics loop
- updatePhysics.Run();
-
- // dequeue events
- while (_eventQueue.Ref.TryDequeue(out var eventData)) {
- _player.OnEvent(in eventData);
+ if (_ctx.UseExternalTiming) {
+ // Simulation thread mode: physics runs on simulation thread,
+ // but managed callbacks must run on Unity main thread.
+ _threading.DrainExternalThreadCallbacks();
+
+ // Collect kinematic transform changes on main thread and
+ // stage them for the sim thread to apply.
+ _threading.UpdateKinematicTransformsFromMainThread();
+
+ _threading.ApplyMovements();
+ } else {
+ // Normal mode: Execute full physics update
+ _threading.ExecutePhysicsUpdate(NowUsec);
}
+ }
- // process scheduled events from managed land
- lock (_scheduledActions) {
- for (var i = _scheduledActions.Count - 1; i >= 0; i--) {
- if (_physicsEnv.Ref[0].CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) {
- _scheduledActions[i].Action();
- _scheduledActions.RemoveAt(i);
- }
- }
+ private void OnDestroy()
+ {
+ if (_ctx == null) {
+ return;
}
- #region Movements
+ StopSimulationThreadIfRunning();
- _physicsMovements.ApplyBallMovement(ref state, _ballComponents);
- _physicsMovements.ApplyFlipperMovement(ref _flipperStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplyBumperMovement(ref _bumperStates.Ref, _floatAnimatedComponents, _float2AnimatedComponents);
- _physicsMovements.ApplyDropTargetMovement(ref _dropTargetStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplyHitTargetMovement(ref _hitTargetStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplyGateMovement(ref _gateStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplyPlungerMovement(ref _plungerStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplySpinnerMovement(ref _spinnerStates.Ref, _floatAnimatedComponents);
- _physicsMovements.ApplyTriggerMovement(ref _triggerStates.Ref, _floatAnimatedComponents);
+ _ctx.IsInitialized = false;
+ _ctx.UseExternalTiming = false;
- #endregion
+ lock (_ctx.PhysicsLock) {
+ _ctx.Dispose();
+ }
}
-
- private void OnDestroy()
+
+ private void OnDisable()
{
- _physicsEnv.Ref.Dispose();
- _eventQueue.Ref.Dispose();
- _ballStates.Ref.Dispose();
- ElasticityOverVelocityLUTs.Dispose();
- FrictionOverVelocityLUTs.Dispose();
- _colliders.Dispose();
- _kinematicColliders.Dispose();
- _insideOfs.Dispose();
- _octree.Dispose();
- _bumperStates.Ref.Dispose();
- _dropTargetStates.Ref.Dispose();
- _flipperStates.Ref.Dispose();
- _gateStates.Ref.Dispose();
- _hitTargetStates.Ref.Dispose();
- using (var enumerator = _kickerStates.Ref.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- enumerator.Current.Value.Dispose();
- }
+ if (_ctx == null || !_ctx.UseExternalTiming) {
+ return;
}
- _kickerStates.Ref.Dispose();
- _plungerStates.Ref.Dispose();
- _spinnerStates.Ref.Dispose();
- _surfaceStates.Ref.Dispose();
- using (var enumerator = _triggerStates.Ref.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- enumerator.Current.Value.Dispose();
- }
- }
- _triggerStates.Ref.Dispose();
- _disabledCollisionItems.Ref.Dispose();
- _kinematicTransforms.Ref.Dispose();
- _updatedKinematicTransforms.Ref.Dispose();
- using (var enumerator = _kinematicColliderLookups.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- enumerator.Current.Value.Dispose();
- }
- }
- _kinematicColliderLookups.Dispose();
- using (var enumerator = _colliderLookups.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- enumerator.Current.Value.Dispose();
- }
- }
- _colliderLookups.Dispose();
- }
- #endregion
+ StopSimulationThreadIfRunning();
+ }
- private class ScheduledAction
+ private void StopSimulationThreadIfRunning()
{
- public readonly ulong ScheduleAt;
- public readonly Action Action;
+ var simulationThreadComponent = GetComponent()
+ ?? GetComponentInParent()
+ ?? GetComponentInChildren();
- public ScheduledAction(ulong scheduleAt, Action action)
- {
- ScheduleAt = scheduleAt;
- Action = action;
- }
+ simulationThreadComponent?.StopSimulation();
}
- public ICollider[] GetColliders(int itemId) => GetColliders(itemId, ref _colliderLookups, ref _colliders);
- public ICollider[] GetKinematicColliders(int itemId) => GetColliders(itemId, ref _kinematicColliderLookups, ref _kinematicColliders);
+ #endregion
+
+ public ICollider[] GetColliders(int itemId) => GetColliders(itemId, ref _ctx.ColliderLookups, ref _ctx.Colliders);
+ public ICollider[] GetKinematicColliders(int itemId) => GetColliders(itemId, ref _ctx.KinematicColliderLookups, ref _ctx.KinematicColliders);
private static ICollider[] GetColliders(int itemId, ref NativeParallelHashMap lookups, ref NativeColliders nativeColliders)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs
new file mode 100644
index 000000000..13aa7dbd1
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs
@@ -0,0 +1,356 @@
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Collections.Generic;
+using NativeTrees;
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Unity.Simulation;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Shared mutable state for the physics engine.
+ ///
+ /// This object is the single source of truth for all physics data
+ /// that (lifecycle / public API) and
+ /// (tick execution / movement
+ /// application) both need. Extracting it into a dedicated class
+ /// makes the data contract explicit and prevents hidden coupling
+ /// through partial-class field sharing.
+ ///
+ /// Ownership: Created eagerly as a field initializer on
+ /// so it is available before any
+ /// Awake calls (Unity does not guarantee Awake order).
+ /// Populated in Awake/Start. Passed by reference to
+ /// at the end of Start.
+ /// Disposed in OnDestroy.
+ ///
+ internal class PhysicsEngineContext : IDisposable
+ {
+ #region Physics World
+
+ public AABB PlayfieldBounds;
+ public InsideOfs InsideOfs;
+ public NativeOctree Octree;
+
+ ///
+ /// Persistent octree for kinematic-to-ball collision detection.
+ ///
+ ///
+ /// Created once in with
+ /// Allocator.Persistent. Cleared and rebuilt only when
+ /// is set, rather than every
+ /// physics tick. This reduces overhead from ~1 kHz to ~60 Hz.
+ ///
+ public NativeOctree KinematicOctree;
+
+ ///
+ /// Whether the kinematic octree needs to be rebuilt before the
+ /// next physics tick. Set to true when kinematic transforms
+ /// change (either via pending staging in threaded mode or via
+ /// direct detection in single-threaded mode). Initialized to
+ /// true so the first tick builds the octree.
+ ///
+ public bool KinematicOctreeDirty = true;
+
+ ///
+ /// Persistent octree for ball-to-ball collision detection.
+ ///
+ ///
+ /// Created once in with
+ /// Allocator.Persistent. Cleared and rebuilt every
+ /// physics cycle (ball positions change every tick), but avoids
+ /// per-tick allocation/deallocation overhead.
+ ///
+ public NativeOctree BallOctree;
+
+ ///
+ /// Persistent physics cycle struct, holding the contacts buffer.
+ ///
+ ///
+ /// Created once in with
+ /// Allocator.Persistent. Avoids per-tick allocation of
+ /// the internal NativeList<ContactBufferElement>.
+ ///
+ public PhysicsCycle PhysicsCycle;
+
+ public NativeColliders Colliders;
+ public NativeColliders KinematicColliders;
+ public NativeColliders KinematicCollidersAtIdentity;
+ public NativeParallelHashMap KinematicColliderLookups;
+ public NativeParallelHashMap ColliderLookups; // only used for editor debug
+ public NativeParallelHashSet OverlappingColliders = new(0, Allocator.Persistent);
+ public PhysicsEnv PhysicsEnv;
+ public bool SwapBallCollisionHandling;
+
+ #endregion
+
+ #region Per-Item State Maps
+
+ public readonly LazyInit> EventQueue = new(() => new NativeQueue(Allocator.Persistent));
+ public readonly LazyInit> BallStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> BumperStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> FlipperStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> GateStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> DropTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> HitTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> KickerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> PlungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> SpinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> SurfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> TriggerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ public readonly LazyInit> DisabledCollisionItems = new(() => new NativeParallelHashSet(0, Allocator.Persistent));
+
+ public NativeParallelHashMap> ElasticityOverVelocityLUTs;
+ public NativeParallelHashMap> FrictionOverVelocityLUTs;
+
+ #endregion
+
+ #region Transforms & Animation Components
+
+ ///
+ /// Main-thread-only lookup: ballId -> BallComponent for applying
+ /// visual movement from snapshots.
+ ///
+ public readonly Dictionary BallComponents = new();
+
+ ///
+ /// Last transforms of kinematic items, so we can detect changes.
+ ///
+ ///
+ /// Written by: sim thread (via ApplyPendingKinematicTransforms)
+ /// or main thread (single-threaded mode).
+ ///
+ public readonly LazyInit> KinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+
+ ///
+ /// The transforms of kinematic items that have changed since the last frame.
+ ///
+ ///
+ /// Written by: sim thread (inside PhysicsLock) or main thread
+ /// (single-threaded mode). Read by: physics loop.
+ ///
+ public readonly LazyInit> UpdatedKinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+
+ ///
+ /// The current matrix to which the ball will be transformed to, if
+ /// it collides with a non-transformable collider. This changes as
+ /// the non-transformable collider transforms (it's called
+ /// non-transformable as in not transformable by the physics engine,
+ /// but it can be transformed by the game).
+ ///
+ public readonly LazyInit> NonTransformableColliderTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+
+ ///
+ /// Main-thread-only lookup: itemId -> animation emitter for float
+ /// values (flipper angle, gate angle, plunger position, etc.).
+ ///
+ public readonly Dictionary> FloatAnimatedComponents = new();
+
+ ///
+ /// Main-thread-only lookup: itemId -> animation emitter for float2
+ /// values (bumper skirt rotation).
+ ///
+ public readonly Dictionary> Float2AnimatedComponents = new();
+
+ #endregion
+
+ #region Cross-Thread Communication
+
+ ///
+ /// Input actions enqueued by component APIs (any thread) and drained
+ /// by the sim thread inside PhysicsLock.
+ /// Protected by .
+ ///
+ public readonly Queue InputActions = new();
+ public readonly object InputActionsLock = new();
+
+ ///
+ /// Scheduled managed callbacks stored in a min-heap by due time.
+ /// Protected by locking on .
+ ///
+ public readonly List ScheduledActions = new();
+ public readonly object ScheduledActionsLock = new();
+
+ ///
+ /// Reference to the triple-buffered simulation state owned by the
+ /// . Set via
+ /// after the thread
+ /// is created. Null when running in single-threaded mode.
+ ///
+ public SimulationState SimulationState;
+
+ ///
+ /// Staging area for kinematic transform updates computed on the
+ /// main thread. Protected by .
+ ///
+ public readonly LazyInit> PendingKinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+
+ ///
+ /// Lock protecting .
+ /// Lock ordering: sim thread may hold PhysicsLock then
+ /// acquire PendingKinematicLock.
+ ///
+ public readonly object PendingKinematicLock = new();
+
+ ///
+ /// Main-thread-only cache of last-reported kinematic transforms,
+ /// used to detect changes without reading
+ /// (which the sim thread writes).
+ ///
+ public readonly Dictionary MainThreadKinematicCache = new();
+
+ ///
+ /// Whether to use external timing (simulation thread) or Unity's
+ /// Time. When true, delegates
+ /// physics to the simulation thread.
+ ///
+ public bool UseExternalTiming;
+
+ ///
+ /// Coarse lock held by the sim thread for the duration of each
+ /// tick. Main thread acquires non-blockingly via
+ /// Monitor.TryEnter to drain callbacks.
+ ///
+ public readonly object PhysicsLock = new();
+
+ ///
+ /// Whether physics engine is fully initialized and ready for the
+ /// simulation thread.
+ ///
+ public volatile bool IsInitialized;
+
+ ///
+ /// Accumulated physics busy time in microseconds. Updated
+ /// atomically via
+ /// from any thread.
+ ///
+ public long PhysicsBusyTotalUsec;
+ public long PublishedPhysicsFrameTimeUsec;
+ public long LastKinematicScanUsec;
+ public long LastEventDrainUsec;
+
+ #endregion
+
+ #region Methods
+
+ ///
+ /// Create a snapshot that references the
+ /// live native containers. Used by both the simulation thread and
+ /// the single-threaded main-thread path.
+ ///
+ internal PhysicsState CreateState()
+ {
+ var events = EventQueue.Ref.AsParallelWriter();
+ return new PhysicsState(ref PhysicsEnv, ref Octree, ref Colliders, ref KinematicColliders,
+ ref KinematicCollidersAtIdentity, ref KinematicTransforms.Ref, ref UpdatedKinematicTransforms.Ref,
+ ref NonTransformableColliderTransforms.Ref, ref KinematicColliderLookups, ref events,
+ ref InsideOfs, ref BallStates.Ref, ref BumperStates.Ref, ref DropTargetStates.Ref, ref FlipperStates.Ref, ref GateStates.Ref,
+ ref HitTargetStates.Ref, ref KickerStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref,
+ ref SurfaceStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling,
+ ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs);
+ }
+
+ ///
+ /// Dispose all native collections. Called from
+ /// .
+ ///
+ public void Dispose()
+ {
+ OverlappingColliders.Dispose();
+ EventQueue.Ref.Dispose();
+ BallStates.Ref.Dispose();
+ ElasticityOverVelocityLUTs.Dispose();
+ FrictionOverVelocityLUTs.Dispose();
+ Colliders.Dispose();
+ KinematicColliders.Dispose();
+ KinematicCollidersAtIdentity.Dispose();
+ InsideOfs.Dispose();
+ Octree.Dispose();
+ KinematicOctree.Dispose();
+ BallOctree.Dispose();
+ PhysicsCycle.Dispose();
+ BumperStates.Ref.Dispose();
+ DropTargetStates.Ref.Dispose();
+ FlipperStates.Ref.Dispose();
+ GateStates.Ref.Dispose();
+ HitTargetStates.Ref.Dispose();
+
+ using (var enumerator = KickerStates.Ref.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ enumerator.Current.Value.Dispose();
+ }
+ }
+ KickerStates.Ref.Dispose();
+
+ PlungerStates.Ref.Dispose();
+ SpinnerStates.Ref.Dispose();
+ SurfaceStates.Ref.Dispose();
+
+ using (var enumerator = TriggerStates.Ref.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ enumerator.Current.Value.Dispose();
+ }
+ }
+ TriggerStates.Ref.Dispose();
+
+ DisabledCollisionItems.Ref.Dispose();
+ KinematicTransforms.Ref.Dispose();
+ UpdatedKinematicTransforms.Ref.Dispose();
+ PendingKinematicTransforms.Ref.Dispose();
+ NonTransformableColliderTransforms.Ref.Dispose();
+
+ using (var enumerator = KinematicColliderLookups.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ enumerator.Current.Value.Dispose();
+ }
+ }
+ KinematicColliderLookups.Dispose();
+
+ if (ColliderLookups.IsCreated) {
+ using (var enumerator = ColliderLookups.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ enumerator.Current.Value.Dispose();
+ }
+ }
+ ColliderLookups.Dispose();
+ }
+ }
+
+ #endregion
+
+ #region Nested Types
+
+ ///
+ /// A managed callback scheduled for future execution at a specific
+ /// physics time.
+ ///
+ internal class ScheduledAction
+ {
+ public readonly ulong ScheduleAt;
+ public readonly Action Action;
+
+ public ScheduledAction(ulong scheduleAt, Action action)
+ {
+ ScheduleAt = scheduleAt;
+ Action = action;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs.meta
new file mode 100644
index 000000000..da3f42b32
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cc3ab9108d186c740abc14ea04dab458
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs
new file mode 100644
index 000000000..c23b382bd
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs
@@ -0,0 +1,726 @@
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using NLog;
+using Unity.Mathematics;
+using VisualPinball.Unity.Collections;
+using VisualPinball.Unity.Simulation;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Threading-related methods for the physics engine, organized by thread
+ /// affinity.
+ ///
+ /// This is a standalone class (not a partial of
+ /// ) that accesses all shared state through
+ /// its reference. This makes the
+ /// data dependencies explicit — every field access goes through
+ /// _ctx.FieldName.
+ ///
+ /// Ownership: Created by
+ /// after the context is fully populated. Receives the context,
+ /// player, kinematic collider components, and the world-to-playfield
+ /// matrix as constructor arguments.
+ ///
+ internal class PhysicsEngineThreading
+ {
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ private readonly PhysicsEngine _physicsEngine;
+ private readonly PhysicsEngineContext _ctx;
+ private readonly Player _player;
+ private readonly ICollidableComponent[] _kinematicColliderComponents;
+ private readonly Dictionary _kinematicColliderComponentsByItemId;
+ private readonly float4x4 _worldToPlayfield;
+ private readonly PhysicsMovements _physicsMovements = new();
+ private readonly List _deferredMainThreadEvents = new();
+ private readonly List _deferredMainThreadScheduledActions = new();
+ private readonly List _dueSingleThreadScheduledActions = new();
+ private readonly List _pendingInputActions = new();
+ private readonly List> _pendingKinematicUpdates = new();
+ private readonly int[] _snapshotFlipperIds;
+ private readonly int[] _snapshotBumperRingIds;
+ private readonly int[] _snapshotBumperSkirtIds;
+ private readonly int[] _snapshotDropTargetIds;
+ private readonly int[] _snapshotHitTargetIds;
+ private readonly int[] _snapshotGateIds;
+ private readonly int[] _snapshotPlungerIds;
+ private readonly int[] _snapshotSpinnerIds;
+ private readonly int[] _snapshotTriggerIds;
+ private bool _ballSnapshotOverflowWarningIssued;
+ private bool _floatSnapshotOverflowWarningIssued;
+ private bool _float2SnapshotOverflowWarningIssued;
+
+ internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player,
+ ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield)
+ {
+ _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine));
+ _ctx = ctx ?? throw new ArgumentNullException(nameof(ctx));
+ _player = player;
+ _kinematicColliderComponents = kinematicColliderComponents;
+ _kinematicColliderComponentsByItemId = new Dictionary(kinematicColliderComponents?.Length ?? 0);
+ if (kinematicColliderComponents != null) {
+ foreach (var coll in kinematicColliderComponents) {
+ _kinematicColliderComponentsByItemId[coll.ItemId] = coll;
+ }
+ }
+ _snapshotFlipperIds = SnapshotIds(_ctx.FlipperStates.Ref);
+ _snapshotBumperRingIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.RingItemId != 0);
+ _snapshotBumperSkirtIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.SkirtItemId != 0);
+ _snapshotDropTargetIds = SnapshotIds(_ctx.DropTargetStates.Ref, static state => state.AnimatedItemId != 0);
+ _snapshotHitTargetIds = SnapshotIds(_ctx.HitTargetStates.Ref, static state => state.AnimatedItemId != 0);
+ _snapshotGateIds = SnapshotIds(_ctx.GateStates.Ref);
+ _snapshotPlungerIds = SnapshotIds(_ctx.PlungerStates.Ref);
+ _snapshotSpinnerIds = SnapshotIds(_ctx.SpinnerStates.Ref);
+ _snapshotTriggerIds = SnapshotIds(_ctx.TriggerStates.Ref, static state => state.AnimatedItemId != 0);
+ _worldToPlayfield = worldToPlayfield;
+ }
+
+ private static int[] SnapshotIds(global::Unity.Collections.NativeParallelHashMap map, Func predicate = null)
+ where TState : unmanaged
+ {
+ var ids = new List();
+ using var enumerator = map.GetEnumerator();
+ while (enumerator.MoveNext()) {
+ if (predicate == null || predicate(enumerator.Current.Value)) {
+ ids.Add(enumerator.Current.Key);
+ }
+ }
+ return ids.ToArray();
+ }
+
+ #region Simulation Thread
+
+ // ──────────────────────────────────────────────────────────────
+ // Methods in this region execute on the SIMULATION THREAD
+ // (or on the main thread in single-threaded mode).
+ // They run inside PhysicsLock unless noted otherwise.
+ // ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Execute a single physics tick with external timing.
+ ///
+ ///
+ /// Thread: Simulation thread (called by
+ /// ).
+ /// Acquires PhysicsLock for the duration of the tick.
+ /// Does NOT apply movements to GameObjects — the main thread reads
+ /// the triple-buffered instead.
+ ///
+ /// Current simulation time in microseconds
+ internal void ExecuteTick(ulong timeUsec)
+ {
+ if (!_ctx.IsInitialized) return;
+ _physicsEngine.MarkCurrentThreadAsSimulationThread();
+
+ lock (_ctx.PhysicsLock) {
+ if (!_ctx.IsInitialized) {
+ return;
+ }
+
+ ExecutePhysicsSimulation(timeUsec);
+ }
+ }
+
+ ///
+ /// Core physics simulation loop for the simulation thread.
+ ///
+ ///
+ /// Thread: Simulation thread (inside PhysicsLock).
+ /// The single-threaded equivalent is
+ /// , which additionally drains
+ /// events and applies movements.
+ ///
+ private void ExecutePhysicsSimulation(ulong currentTimeUsec)
+ {
+ var sw = Stopwatch.StartNew();
+
+ // Apply kinematic transform updates staged by main thread.
+ ApplyPendingKinematicTransforms();
+
+ var state = _ctx.CreateState();
+
+ // Rebuild kinematic octree only when transforms have changed.
+ if (_ctx.KinematicOctreeDirty) {
+ PhysicsKinematics.RebuildOctree(ref _ctx.KinematicOctree, ref state);
+ _ctx.KinematicOctreeDirty = false;
+ }
+
+ // process input
+ ProcessInputActions(ref state);
+
+ // run physics loop (Burst-compiled, thread-safe)
+ PhysicsUpdate.Execute(
+ ref state,
+ ref _ctx.PhysicsEnv,
+ ref _ctx.OverlappingColliders,
+ ref _ctx.KinematicOctree,
+ ref _ctx.BallOctree,
+ ref _ctx.PhysicsCycle,
+ currentTimeUsec
+ );
+ Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime);
+
+ RecordPhysicsBusyTime(sw.ElapsedTicks);
+ }
+
+ ///
+ /// Drain the input actions queue.
+ ///
+ ///
+ /// Thread: Simulation thread (inside PhysicsLock).
+ /// Also called from main thread in single-threaded mode.
+ ///
+ private void ProcessInputActions(ref PhysicsState state)
+ {
+ _pendingInputActions.Clear();
+
+ lock (_ctx.InputActionsLock) {
+ while (_ctx.InputActions.Count > 0) {
+ _pendingInputActions.Add(_ctx.InputActions.Dequeue());
+ }
+ }
+
+ foreach (var action in _pendingInputActions) {
+ action(ref state);
+ }
+ }
+
+ ///
+ /// Apply kinematic transforms staged by the main thread into the
+ /// physics state maps.
+ ///
+ ///
+ /// Thread: Simulation thread (inside PhysicsLock).
+ /// Lock ordering: PhysicsLock (held) then
+ /// PendingKinematicLock (inner).
+ ///
+ private void ApplyPendingKinematicTransforms()
+ {
+ if (!_ctx.PendingKinematicTransforms.Ref.IsCreated) return;
+
+ _ctx.UpdatedKinematicTransforms.Ref.Clear();
+
+ lock (_ctx.PendingKinematicLock) {
+ if (_ctx.PendingKinematicTransforms.Ref.Count() == 0) return;
+
+ using var enumerator = _ctx.PendingKinematicTransforms.Ref.GetEnumerator();
+ while (enumerator.MoveNext()) {
+ var itemId = enumerator.Current.Key;
+ var matrix = enumerator.Current.Value;
+ _ctx.UpdatedKinematicTransforms.Ref[itemId] = matrix;
+ _ctx.KinematicTransforms.Ref[itemId] = matrix;
+
+ var coll = GetKinematicColliderComponent(itemId);
+ coll?.OnTransformationChanged(matrix);
+ }
+ _ctx.PendingKinematicTransforms.Ref.Clear();
+ _ctx.KinematicOctreeDirty = true;
+ }
+ }
+
+ private ICollidableComponent GetKinematicColliderComponent(int itemId)
+ {
+ return _kinematicColliderComponentsByItemId.TryGetValue(itemId, out var coll) ? coll : null;
+ }
+
+ ///
+ /// Copy current animation values from physics state maps into the
+ /// given snapshot buffer. Must be allocation-free.
+ ///
+ ///
+ /// Thread: Simulation thread. Called by
+ /// AFTER
+ /// returns (sequential within the thread,
+ /// so reading physics state maps is safe without an extra lock).
+ ///
+ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot)
+ {
+ // --- Balls ---
+ var ballCount = 0;
+ var ballSourceCount = 0;
+ using (var enumerator = _ctx.BallStates.Ref.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ballSourceCount++;
+ if (ballCount >= SimulationState.MaxBalls) {
+ continue;
+ }
+
+ ref var ball = ref enumerator.Current.Value;
+ snapshot.BallSnapshots[ballCount] = new SimulationState.BallSnapshot {
+ Id = ball.Id,
+ Position = ball.Position,
+ Radius = ball.Radius,
+ IsFrozen = ball.IsFrozen ? (byte)1 : (byte)0,
+ Orientation = ball.BallOrientationForUnity
+ };
+ ballCount++;
+ }
+ }
+ snapshot.BallCount = ballCount;
+ snapshot.BallSourceCount = ballSourceCount;
+ snapshot.BallSnapshotsTruncated = ballSourceCount > SimulationState.MaxBalls ? (byte)1 : (byte)0;
+ if (!_ballSnapshotOverflowWarningIssued && snapshot.BallSnapshotsTruncated != 0) {
+ _ballSnapshotOverflowWarningIssued = true;
+ Logger.Warn($"[PhysicsEngine] Ball snapshot capacity exceeded: {ballSourceCount} balls for max {SimulationState.MaxBalls}. Snapshot output is truncated.");
+ }
+
+ // --- Float animations ---
+ var floatCount = 0;
+ snapshot.FloatAnimationSourceCount = _snapshotFlipperIds.Length + _snapshotBumperRingIds.Length + _snapshotDropTargetIds.Length + _snapshotHitTargetIds.Length + _snapshotGateIds.Length + _snapshotPlungerIds.Length + _snapshotSpinnerIds.Length + _snapshotTriggerIds.Length;
+
+ // Flippers
+ for (var i = 0; i < _snapshotFlipperIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotFlipperIds[i];
+ ref var s = ref _ctx.FlipperStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Movement.Angle
+ };
+ }
+
+ // Bumper rings (float) — ring animation
+ for (var i = 0; i < _snapshotBumperRingIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotBumperRingIds[i];
+ ref var s = ref _ctx.BumperStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.RingAnimation.Offset
+ };
+ }
+
+ // Drop targets
+ for (var i = 0; i < _snapshotDropTargetIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotDropTargetIds[i];
+ ref var s = ref _ctx.DropTargetStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Animation.ZOffset
+ };
+ }
+
+ // Hit targets
+ for (var i = 0; i < _snapshotHitTargetIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotHitTargetIds[i];
+ ref var s = ref _ctx.HitTargetStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Animation.XRotation
+ };
+ }
+
+ // Gates
+ for (var i = 0; i < _snapshotGateIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotGateIds[i];
+ ref var s = ref _ctx.GateStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Movement.Angle
+ };
+ }
+
+ // Plungers
+ for (var i = 0; i < _snapshotPlungerIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotPlungerIds[i];
+ ref var s = ref _ctx.PlungerStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Animation.Position
+ };
+ }
+
+ // Spinners
+ for (var i = 0; i < _snapshotSpinnerIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotSpinnerIds[i];
+ ref var s = ref _ctx.SpinnerStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Movement.Angle
+ };
+ }
+
+ // Triggers
+ for (var i = 0; i < _snapshotTriggerIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) {
+ var itemId = _snapshotTriggerIds[i];
+ ref var s = ref _ctx.TriggerStates.Ref.GetValueByRef(itemId);
+ snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation {
+ ItemId = itemId, Value = s.Movement.HeightOffset
+ };
+ }
+
+ snapshot.FloatAnimationCount = floatCount;
+ snapshot.FloatAnimationsTruncated = snapshot.FloatAnimationSourceCount > SimulationState.MaxFloatAnimations ? (byte)1 : (byte)0;
+ if (!_floatSnapshotOverflowWarningIssued && snapshot.FloatAnimationsTruncated != 0) {
+ _floatSnapshotOverflowWarningIssued = true;
+ Logger.Warn($"[PhysicsEngine] Float animation snapshot capacity exceeded: {snapshot.FloatAnimationSourceCount} channels for max {SimulationState.MaxFloatAnimations}. Snapshot output is truncated.");
+ }
+
+ // --- Float2 animations (bumper skirts) ---
+ var float2Count = 0;
+ snapshot.Float2AnimationSourceCount = _snapshotBumperSkirtIds.Length;
+ for (var i = 0; i < _snapshotBumperSkirtIds.Length && float2Count < SimulationState.MaxFloat2Animations; i++) {
+ var itemId = _snapshotBumperSkirtIds[i];
+ ref var s = ref _ctx.BumperStates.Ref.GetValueByRef(itemId);
+ snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation {
+ ItemId = itemId, Value = s.SkirtAnimation.Rotation
+ };
+ }
+ snapshot.Float2AnimationCount = float2Count;
+ snapshot.Float2AnimationsTruncated = snapshot.Float2AnimationSourceCount > SimulationState.MaxFloat2Animations ? (byte)1 : (byte)0;
+ if (!_float2SnapshotOverflowWarningIssued && snapshot.Float2AnimationsTruncated != 0) {
+ _float2SnapshotOverflowWarningIssued = true;
+ Logger.Warn($"[PhysicsEngine] Float2 animation snapshot capacity exceeded: {snapshot.Float2AnimationSourceCount} channels for max {SimulationState.MaxFloat2Animations}. Snapshot output is truncated.");
+ }
+ }
+
+ #endregion
+
+ #region Main Thread — Threaded Mode
+
+ // ──────────────────────────────────────────────────────────────
+ // Methods in this region execute on the UNITY MAIN THREAD but
+ // only when running in simulation-thread mode
+ // (UseExternalTiming == true). They consume data produced by
+ // the simulation thread.
+ // ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Apply physics state to GameObjects. Reads the latest published
+ /// snapshot from the triple-buffered
+ /// — completely lock-free.
+ ///
+ ///
+ /// Thread: Main thread only.
+ ///
+ internal void ApplyMovements()
+ {
+ if (!_ctx.UseExternalTiming || !_ctx.IsInitialized) return;
+
+ if (_ctx.SimulationState == null) {
+ throw new InvalidOperationException(
+ "ApplyMovements() requires a SimulationState. " +
+ "Call SetSimulationState() before enabling external timing.");
+ }
+
+ ref readonly var snapshot = ref _ctx.SimulationState.AcquireReadBuffer();
+ ApplyMovementsFromSnapshot(in snapshot);
+ }
+
+ ///
+ /// Apply visual updates from a triple-buffer snapshot.
+ ///
+ ///
+ /// Thread: Main thread only. Lock-free.
+ /// The single-threaded path uses instead.
+ ///
+ private void ApplyMovementsFromSnapshot(in SimulationState.Snapshot snapshot)
+ {
+ // Balls
+ for (var i = 0; i < snapshot.BallCount; i++) {
+ var bs = snapshot.BallSnapshots[i];
+ if (bs.IsFrozen != 0) continue;
+ if (_ctx.BallComponents.TryGetValue(bs.Id, out var ballComponent)) {
+ var ballState = new BallState {
+ Id = bs.Id,
+ Position = bs.Position,
+ Radius = bs.Radius,
+ IsFrozen = false,
+ BallOrientationForUnity = bs.Orientation,
+ };
+ ballComponent.Move(ballState);
+ }
+ }
+
+ // Float animations
+ for (var i = 0; i < snapshot.FloatAnimationCount; i++) {
+ var anim = snapshot.FloatAnimations[i];
+ if (_ctx.FloatAnimatedComponents.TryGetValue(anim.ItemId, out var emitter)) {
+ emitter.UpdateAnimationValue(anim.Value);
+ }
+ }
+
+ // Float2 animations
+ for (var i = 0; i < snapshot.Float2AnimationCount; i++) {
+ var anim = snapshot.Float2Animations[i];
+ if (_ctx.Float2AnimatedComponents.TryGetValue(anim.ItemId, out var emitter)) {
+ emitter.UpdateAnimationValue(anim.Value);
+ }
+ }
+ }
+
+ ///
+ /// Drain physics-originated managed callbacks on the Unity main thread.
+ /// Non-blocking: if the simulation thread currently holds the physics
+ /// lock, callbacks are deferred to the next frame.
+ ///
+ ///
+ /// Thread: Main thread only.
+ ///
+ internal void DrainExternalThreadCallbacks()
+ {
+ if (!_ctx.UseExternalTiming || !_ctx.IsInitialized) {
+ return;
+ }
+
+ _deferredMainThreadEvents.Clear();
+ _deferredMainThreadScheduledActions.Clear();
+ var drainStartTicks = Stopwatch.GetTimestamp();
+
+ if (!Monitor.TryEnter(_ctx.PhysicsLock)) {
+ return; // sim thread is mid-tick; drain next frame
+ }
+ try {
+ while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) {
+ _deferredMainThreadEvents.Add(eventData);
+ }
+
+ lock (_ctx.ScheduledActionsLock) {
+ DrainDueScheduledActions(_ctx.PhysicsEnv.CurPhysicsFrameTime, _deferredMainThreadScheduledActions);
+ }
+ } finally {
+ Monitor.Exit(_ctx.PhysicsLock);
+ }
+
+ foreach (var eventData in _deferredMainThreadEvents) {
+ _player.OnEvent(in eventData);
+ }
+
+ foreach (var action in _deferredMainThreadScheduledActions) {
+ action();
+ }
+
+ Interlocked.Exchange(ref _ctx.LastEventDrainUsec, ElapsedUsec(drainStartTicks, Stopwatch.GetTimestamp()));
+ }
+
+ ///
+ /// Collect kinematic transform changes on the Unity main thread and
+ /// stage them for the sim thread to apply.
+ ///
+ ///
+ /// Thread: Main thread only.
+ /// Uses for
+ /// change detection (never reads
+ /// which the
+ /// sim thread writes). Writes to
+ ///
+ /// under .
+ ///
+ internal void UpdateKinematicTransformsFromMainThread()
+ {
+ if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicColliderComponents == null) return;
+
+ var scanStartTicks = Stopwatch.GetTimestamp();
+
+ _pendingKinematicUpdates.Clear();
+
+ foreach (var coll in _kinematicColliderComponents) {
+ var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield);
+
+ // Check against main-thread cache
+ if (_ctx.MainThreadKinematicCache.TryGetValue(coll.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) {
+ continue;
+ }
+
+ // Transform changed — update cache
+ _ctx.MainThreadKinematicCache[coll.ItemId] = currMatrix;
+ _pendingKinematicUpdates.Add(new KeyValuePair(coll.ItemId, currMatrix));
+ }
+
+ if (_pendingKinematicUpdates.Count == 0) {
+ return;
+ }
+
+ lock (_ctx.PendingKinematicLock) {
+ foreach (var update in _pendingKinematicUpdates) {
+ _ctx.PendingKinematicTransforms.Ref[update.Key] = update.Value;
+ }
+ }
+
+ Interlocked.Exchange(ref _ctx.LastKinematicScanUsec, ElapsedUsec(scanStartTicks, Stopwatch.GetTimestamp()));
+ }
+
+ #endregion
+
+ #region Main Thread — Single-Threaded Mode
+
+ // ──────────────────────────────────────────────────────────────
+ // Methods in this region execute on the UNITY MAIN THREAD in
+ // single-threaded mode (UseExternalTiming == false).
+ // Physics runs and movements apply in the same frame.
+ // ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Full physics update including movement application.
+ ///
+ ///
+ /// Thread: Main thread only. Single-threaded mode only.
+ ///
+ internal void ExecutePhysicsUpdate(ulong currentTimeUsec)
+ {
+ var sw = Stopwatch.StartNew();
+
+ // check for updated kinematic transforms
+ _ctx.UpdatedKinematicTransforms.Ref.Clear();
+ foreach (var coll in _kinematicColliderComponents) {
+ var lastTransformationMatrix = _ctx.KinematicTransforms.Ref[coll.ItemId];
+ var currTransformationMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield);
+ if (lastTransformationMatrix.Equals(currTransformationMatrix)) {
+ continue;
+ }
+ _ctx.UpdatedKinematicTransforms.Ref.Add(coll.ItemId, currTransformationMatrix);
+ _ctx.KinematicTransforms.Ref[coll.ItemId] = currTransformationMatrix;
+ coll.OnTransformationChanged(currTransformationMatrix);
+ _ctx.KinematicOctreeDirty = true;
+ }
+
+ var state = _ctx.CreateState();
+
+ // Rebuild kinematic octree only when transforms have changed.
+ if (_ctx.KinematicOctreeDirty) {
+ PhysicsKinematics.RebuildOctree(ref _ctx.KinematicOctree, ref state);
+ _ctx.KinematicOctreeDirty = false;
+ }
+
+ // process input
+ ProcessInputActions(ref state);
+
+ // run physics loop
+ PhysicsUpdate.Execute(
+ ref state,
+ ref _ctx.PhysicsEnv,
+ ref _ctx.OverlappingColliders,
+ ref _ctx.KinematicOctree,
+ ref _ctx.BallOctree,
+ ref _ctx.PhysicsCycle,
+ currentTimeUsec
+ );
+ Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime);
+
+ // dequeue events
+ while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) {
+ _player.OnEvent(in eventData);
+ }
+
+ _dueSingleThreadScheduledActions.Clear();
+ lock (_ctx.ScheduledActionsLock) {
+ DrainDueScheduledActions(_ctx.PhysicsEnv.CurPhysicsFrameTime, _dueSingleThreadScheduledActions);
+ }
+ foreach (var action in _dueSingleThreadScheduledActions) {
+ action();
+ }
+
+ // Apply movements to GameObjects
+ ApplyAllMovements(ref state);
+
+ RecordPhysicsBusyTime(sw.ElapsedTicks);
+ }
+
+ ///
+ /// Apply all physics movements to GameObjects directly from state maps.
+ ///
+ ///
+ /// Thread: Main thread only. Single-threaded mode only.
+ /// In threaded mode, reads
+ /// from the triple-buffered snapshot instead.
+ ///
+ private void ApplyAllMovements(ref PhysicsState state)
+ {
+ _physicsMovements.ApplyBallMovement(ref state, _ctx.BallComponents);
+ _physicsMovements.ApplyFlipperMovement(ref _ctx.FlipperStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplyBumperMovement(ref _ctx.BumperStates.Ref, _ctx.FloatAnimatedComponents, _ctx.Float2AnimatedComponents);
+ _physicsMovements.ApplyDropTargetMovement(ref _ctx.DropTargetStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplyHitTargetMovement(ref _ctx.HitTargetStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplyGateMovement(ref _ctx.GateStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents);
+ _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents);
+ }
+
+ #endregion
+
+ #region Shared
+
+ ///
+ /// Record physics busy time for performance monitoring.
+ ///
+ ///
+ /// Thread: Any (thread-safe via ).
+ ///
+ private void RecordPhysicsBusyTime(long elapsedTicks)
+ {
+ var elapsedUsec = (elapsedTicks * 1_000_000L) / Stopwatch.Frequency;
+ if (elapsedUsec < 0) {
+ elapsedUsec = 0;
+ }
+
+ Interlocked.Add(ref _ctx.PhysicsBusyTotalUsec, elapsedUsec);
+ }
+
+ private static long ElapsedUsec(long startTicks, long endTicks)
+ {
+ var elapsedTicks = endTicks - startTicks;
+ if (elapsedTicks < 0) {
+ elapsedTicks = 0;
+ }
+ return (elapsedTicks * 1_000_000L) / Stopwatch.Frequency;
+ }
+
+ private void DrainDueScheduledActions(ulong currentTimeUsec, List destination)
+ {
+ while (_ctx.ScheduledActions.Count > 0 && _ctx.ScheduledActions[0].ScheduleAt < currentTimeUsec) {
+ destination.Add(PopScheduledAction().Action);
+ }
+ }
+
+ private PhysicsEngineContext.ScheduledAction PopScheduledAction()
+ {
+ var scheduledActions = _ctx.ScheduledActions;
+ var root = scheduledActions[0];
+ var lastIndex = scheduledActions.Count - 1;
+ var last = scheduledActions[lastIndex];
+ scheduledActions.RemoveAt(lastIndex);
+
+ if (lastIndex == 0) {
+ return root;
+ }
+
+ scheduledActions[0] = last;
+ var index = 0;
+ while (true) {
+ var left = index * 2 + 1;
+ if (left >= scheduledActions.Count) {
+ break;
+ }
+
+ var right = left + 1;
+ var smallest = right < scheduledActions.Count && scheduledActions[right].ScheduleAt < scheduledActions[left].ScheduleAt
+ ? right
+ : left;
+
+ if (scheduledActions[index].ScheduleAt <= scheduledActions[smallest].ScheduleAt) {
+ break;
+ }
+
+ (scheduledActions[index], scheduledActions[smallest]) = (scheduledActions[smallest], scheduledActions[index]);
+ index = smallest;
+ }
+
+ return root;
+ }
+
+ #endregion
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs.meta
new file mode 100644
index 000000000..c1ace07ce
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6d9620be6f4a43d3b398f9734732c3e1
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
index e320395ce..ebaacae53 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
@@ -14,10 +14,9 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-using NativeTrees;
-using Unity.Collections;
-using Unity.Profiling;
-using VisualPinball.Unity.Collections;
+using NativeTrees;
+using Unity.Profiling;
+using VisualPinball.Unity.Collections;
namespace VisualPinball.Unity
{
@@ -42,17 +41,26 @@ internal static void TransformFullyTransformableColliders(ref PhysicsState state
PerfMarkerTransform.End();
}
- internal static NativeOctree CreateOctree(ref PhysicsState state, in AABB playfieldBounds)
- {
- PerfMarkerBallOctree.Begin();
- var octree = new NativeOctree(playfieldBounds, 1024, 10, Allocator.TempJob);
-
- for (var i = 0; i < state.KinematicCollidersAtIdentity.Length; i++) {
- octree.Insert(i, state.KinematicCollidersAtIdentity.GetTransformedAabb(i, ref state.KinematicTransforms));
- }
-
- PerfMarkerBallOctree.End();
- return octree;
- }
+ ///
+ /// Clear and repopulate an existing persistent kinematic octree.
+ ///
+ ///
+ /// The kinematic octree is allocated once with
+ /// Allocator.Persistent and reused across frames. This
+ /// method clears and re-inserts all entries, avoiding per-tick
+ /// allocation overhead. It is only called when kinematic
+ /// transforms have actually changed.
+ ///
+ internal static void RebuildOctree(ref NativeOctree octree, ref PhysicsState state)
+ {
+ PerfMarkerBallOctree.Begin();
+ octree.Clear();
+
+ for (var i = 0; i < state.KinematicCollidersAtIdentity.Length; i++) {
+ octree.Insert(i, state.KinematicCollidersAtIdentity.GetTransformedAabb(i, ref state.KinematicTransforms));
+ }
+
+ PerfMarkerBallOctree.End();
+ }
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs
new file mode 100644
index 000000000..df37d4d4d
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Runtime.InteropServices;
+using NativeTrees;
+using Unity.Burst;
+using Unity.Collections.LowLevel.Unsafe;
+
+namespace VisualPinball.Unity
+{
+ [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)]
+ internal static class PhysicsPopulate
+ {
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ public delegate void PopulateFn(IntPtr colliders, IntPtr octree);
+
+ [BurstCompile]
+ public static unsafe void PopulateUnsafe(IntPtr collidersPtr, IntPtr octreePtr)
+ {
+ ref var colliders = ref UnsafeUtility.AsRef(collidersPtr.ToPointer());
+ ref var octree = ref UnsafeUtility.AsRef>(octreePtr.ToPointer());
+
+ for (var i = 0; i < colliders.Length; i++) {
+ octree.Insert(i, colliders.GetAabb(i));
+ }
+ }
+
+
+ [BurstCompile]
+ public static void Populate(ref NativeColliders colliders, ref NativeOctree octree)
+ {
+ for (var i = 0; i < colliders.Length; i++) {
+ octree.Insert(i, colliders.GetAabb(i));
+ }
+ }
+
+ public static FunctionPointer Ptr;
+
+ public static void Init()
+ {
+ Ptr = BurstCompiler.CompileFunctionPointer(PopulateUnsafe);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs
deleted file mode 100644
index eaa43a57e..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using NativeTrees;
-using Unity.Burst;
-using Unity.Collections;
-using Unity.Jobs;
-
-namespace VisualPinball.Unity
-{
- [BurstCompile(CompileSynchronously = true)]
- internal struct PhysicsPopulateJob : IJob
- {
- [ReadOnly]
- public NativeColliders Colliders;
- public NativeOctree Octree;
-
- public void Execute()
- {
- for (var i = 0; i < Colliders.Length; i++) {
- Octree.Insert(i, Colliders.GetAabb(i));
- }
- }
- }
-}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
index f98b53c5f..5ed9db979 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
@@ -16,6 +16,7 @@
// ReSharper disable InconsistentNaming
+using System.Runtime.InteropServices;
using NativeTrees;
using Unity.Collections;
using Unity.Mathematics;
@@ -128,6 +129,8 @@ internal struct PhysicsState
internal NativeParallelHashMap SurfaceStates;
internal NativeParallelHashMap TriggerStates;
internal NativeParallelHashSet DisabledCollisionItems;
+
+ [MarshalAs(UnmanagedType.U1)]
internal bool SwapBallCollisionHandling;
public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref NativeColliders colliders,
@@ -360,4 +363,4 @@ private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId)
#endregion
}
-}
+}
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
new file mode 100644
index 000000000..512452eb0
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
@@ -0,0 +1,201 @@
+// Copyright (C) 2023 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using NativeTrees;
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Collections.LowLevel.Unsafe;
+using VisualPinball.Engine.Common;
+
+// ReSharper disable InconsistentNaming
+
+namespace VisualPinball.Unity
+{
+ [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)]
+ internal static class PhysicsUpdate
+ {
+ // [ReadOnly]
+ // public ulong InitialTimeUsec;
+ //
+ // public float DeltaTimeMs;
+ //
+ // [NativeDisableParallelForRestriction]
+ // public NativeParallelHashSet OverlappingColliders;
+ // public NativeArray PhysicsEnv;
+ // public NativeOctree Octree;
+ // public NativeColliders Colliders;
+ // public NativeColliders KinematicColliders;
+ // public NativeColliders KinematicCollidersAtIdentity;
+ // public NativeParallelHashMap KinematicTransforms;
+ // public NativeParallelHashMap UpdatedKinematicTransforms;
+ // public NativeParallelHashMap NonTransformableColliderTransforms;
+ //
+ // public NativeParallelHashMap KinematicColliderLookups;
+ // public InsideOfs InsideOfs;
+ // public NativeQueue.ParallelWriter Events;
+ // public AABB PlayfieldBounds;
+ //
+ // public NativeParallelHashMap Balls;
+ // public NativeParallelHashMap BumperStates;
+ // public NativeParallelHashMap DropTargetStates;
+ // public NativeParallelHashMap FlipperStates;
+ // public NativeParallelHashMap GateStates;
+ // public NativeParallelHashMap HitTargetStates;
+ // public NativeParallelHashMap KickerStates;
+ // public NativeParallelHashMap PlungerStates;
+ // public NativeParallelHashMap SpinnerStates;
+ // public NativeParallelHashMap SurfaceStates;
+ // public NativeParallelHashMap TriggerStates;
+ // public NativeParallelHashSet DisabledCollisionItems;
+ //
+ // public NativeParallelHashMap> ElasticityOverVelocityLUTs;
+ // public NativeParallelHashMap> FrictionOverVelocityLUTs;
+ //
+ // public bool SwapBallCollisionHandling;
+
+ [BurstCompile]
+ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kineticOctree, ref NativeOctree ballOctree, ref PhysicsCycle cycle, ulong initialTimeUsec)
+ {
+ // ref var state = ref UnsafeUtility.AsRef(statePtr.ToPointer());
+ // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer());
+ // ref var overlappingColliders = ref UnsafeUtility.AsRef>(overlappingCollidersPtr.ToPointer());
+
+ // Transform kinematic colliders that have changed since the last frame.
+ // This is a no-op on ticks where no transforms were staged.
+ PhysicsKinematics.TransformFullyTransformableColliders(ref state);
+
+ var subSteps = 0;
+ while (env.CurPhysicsFrameTime < initialTimeUsec) // loop here until current (real) time matches the physics (simulated) time
+ {
+ // Safety cap: if we've been catching up for too many iterations (e.g. after
+ // a frame hitch), skip physics time forward to prevent cascading hitches.
+ if (subSteps >= PhysicsConstants.MaxSubSteps) {
+ env.CurPhysicsFrameTime = initialTimeUsec;
+ env.NextPhysicsFrameTime = initialTimeUsec + PhysicsConstants.PhysicsStepTime;
+ break;
+ }
+ subSteps++;
+
+ env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000);
+ var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime));
+
+ // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball)
+ #region Update Velocities
+
+ // balls
+ using (var enumerator = state.Balls.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity);
+ }
+ }
+ // flippers
+ using (var enumerator = state.FlipperStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ FlipperVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value);
+ }
+ }
+ // gates
+ using (var enumerator = state.GateStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var gateState = ref enumerator.Current.Value;
+ GateVelocityPhysics.UpdateVelocities(ref gateState.Movement, in gateState.Static);
+ }
+ }
+ // plungers
+ using (var enumerator = state.PlungerStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var plungerState = ref enumerator.Current.Value;
+ PlungerVelocityPhysics.UpdateVelocities(ref plungerState.Movement, ref plungerState.Velocity, in plungerState.Static);
+ }
+ }
+ // spinners
+ using (var enumerator = state.SpinnerStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var spinnerState = ref enumerator.Current.Value;
+ SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static);
+ }
+ }
+
+ #endregion
+
+ // primary physics loop
+ cycle.Simulate(ref state, ref overlappingColliders, ref kineticOctree, ref ballOctree, physicsDiffTime);
+
+ // ball trail, keep old pos of balls
+ using (var enumerator = state.Balls.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ BallRingCounterPhysics.Update(ref enumerator.Current.Value);
+ }
+ }
+
+ env.CurPhysicsFrameTime = env.NextPhysicsFrameTime;
+ env.NextPhysicsFrameTime += PhysicsConstants.PhysicsStepTime;
+ }
+
+ if (subSteps > 0) {
+ UpdateAnimations(ref state, env.TimeMsec, subSteps * (PhysicsConstants.PhysicsStepTime / 1000f));
+ }
+ }
+
+ private static void UpdateAnimations(ref PhysicsState state, uint animationTimeMsec, float animationDeltaTimeMs)
+ {
+ // bumper
+ using (var enumerator = state.BumperStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var bumperState = ref enumerator.Current.Value;
+ if (bumperState.RingItemId != 0) {
+ BumperRingAnimation.Update(ref bumperState.RingAnimation, animationDeltaTimeMs);
+ }
+ if (bumperState.SkirtItemId != 0) {
+ BumperSkirtAnimation.Update(ref bumperState.SkirtAnimation, animationDeltaTimeMs);
+ }
+ }
+ }
+
+ // drop target
+ using (var enumerator = state.DropTargetStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var dropTargetState = ref enumerator.Current.Value;
+ DropTargetAnimation.Update(enumerator.Current.Key, ref dropTargetState.Animation, in dropTargetState.Static, ref state);
+ }
+ }
+
+ // hit target
+ using (var enumerator = state.HitTargetStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var hitTargetState = ref enumerator.Current.Value;
+ HitTargetAnimation.Update(ref hitTargetState.Animation, in hitTargetState.Static, animationTimeMsec);
+ }
+ }
+
+ // plunger
+ using (var enumerator = state.PlungerStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var plungerState = ref enumerator.Current.Value;
+ PlungerAnimation.Update(ref plungerState.Animation, in plungerState.Movement, in plungerState.Static);
+ }
+ }
+
+ // trigger
+ using (var enumerator = state.TriggerStates.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ ref var triggerState = ref enumerator.Current.Value;
+ TriggerAnimation.Update(ref triggerState.Animation, ref triggerState.Movement, in triggerState.Static, animationDeltaTimeMs);
+ }
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs
deleted file mode 100644
index 7e17cd31e..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs
+++ /dev/null
@@ -1,197 +0,0 @@
-// Copyright (C) 2023 freezy and VPE Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-using NativeTrees;
-using Unity.Burst;
-using Unity.Collections;
-using Unity.Jobs;
-using Unity.Mathematics;
-using VisualPinball.Engine.Common;
-// ReSharper disable InconsistentNaming
-
-namespace VisualPinball.Unity
-{
- [BurstCompile(CompileSynchronously = true)]
- internal struct PhysicsUpdateJob : IJob
- {
- [ReadOnly]
- public ulong InitialTimeUsec;
-
- public float DeltaTimeMs;
-
- [NativeDisableParallelForRestriction]
- public NativeParallelHashSet OverlappingColliders;
- public NativeArray PhysicsEnv;
- public NativeOctree Octree;
- public NativeColliders Colliders;
- public NativeColliders KinematicColliders;
- public NativeColliders KinematicCollidersAtIdentity;
- public NativeParallelHashMap KinematicTransforms;
- public NativeParallelHashMap UpdatedKinematicTransforms;
- public NativeParallelHashMap NonTransformableColliderTransforms;
-
- public NativeParallelHashMap KinematicColliderLookups;
- public InsideOfs InsideOfs;
- public NativeQueue.ParallelWriter Events;
- public AABB PlayfieldBounds;
-
- public NativeParallelHashMap Balls;
- public NativeParallelHashMap BumperStates;
- public NativeParallelHashMap DropTargetStates;
- public NativeParallelHashMap FlipperStates;
- public NativeParallelHashMap GateStates;
- public NativeParallelHashMap HitTargetStates;
- public NativeParallelHashMap KickerStates;
- public NativeParallelHashMap PlungerStates;
- public NativeParallelHashMap SpinnerStates;
- public NativeParallelHashMap SurfaceStates;
- public NativeParallelHashMap TriggerStates;
- public NativeParallelHashSet DisabledCollisionItems;
-
- public NativeParallelHashMap> ElasticityOverVelocityLUTs;
- public NativeParallelHashMap> FrictionOverVelocityLUTs;
-
- public bool SwapBallCollisionHandling;
-
- public void Execute()
- {
- var env = PhysicsEnv[0];
- var state = new PhysicsState(ref env, ref Octree, ref Colliders, ref KinematicColliders,
- ref KinematicCollidersAtIdentity, ref KinematicTransforms, ref UpdatedKinematicTransforms,
- ref NonTransformableColliderTransforms, ref KinematicColliderLookups, ref Events,
- ref InsideOfs, ref Balls, ref BumperStates, ref DropTargetStates, ref FlipperStates, ref GateStates,
- ref HitTargetStates, ref KickerStates, ref PlungerStates, ref SpinnerStates,
- ref SurfaceStates, ref TriggerStates, ref DisabledCollisionItems, ref SwapBallCollisionHandling,
- ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs);
- using var cycle = new PhysicsCycle(Allocator.Temp);
-
- // create octree of kinematic-to-ball collision. should be okay here, since kinetic colliders don't transform more than once per frame.
- PhysicsKinematics.TransformFullyTransformableColliders(ref state);
- var kineticOctree = PhysicsKinematics.CreateOctree(ref state, in PlayfieldBounds);
-
- while (env.CurPhysicsFrameTime < InitialTimeUsec) // loop here until current (real) time matches the physics (simulated) time
- {
- env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000);
- var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime));
-
- // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball)
- #region Update Velocities
-
- // balls
- using (var enumerator = state.Balls.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity);
- }
- }
- // flippers
- using (var enumerator = FlipperStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- FlipperVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value);
- }
- }
- // gates
- using (var enumerator = GateStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var gateState = ref enumerator.Current.Value;
- GateVelocityPhysics.UpdateVelocities(ref gateState.Movement, in gateState.Static);
- }
- }
- // plungers
- using (var enumerator = PlungerStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var plungerState = ref enumerator.Current.Value;
- PlungerVelocityPhysics.UpdateVelocities(ref plungerState.Movement, ref plungerState.Velocity, in plungerState.Static);
- }
- }
- // spinners
- using (var enumerator = SpinnerStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var spinnerState = ref enumerator.Current.Value;
- SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static);
- }
- }
-
- #endregion
-
- // primary physics loop
- cycle.Simulate(ref state, in PlayfieldBounds, ref OverlappingColliders, ref kineticOctree, physicsDiffTime);
-
- // ball trail, keep old pos of balls
- using (var enumerator = state.Balls.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- BallRingCounterPhysics.Update(ref enumerator.Current.Value);
- }
- }
-
- #region Animation
-
- // todo it should be enough to calculate animations only once per frame
-
- // bumper
- using (var enumerator = BumperStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var bumperState = ref enumerator.Current.Value;
- if (bumperState.RingItemId != 0) {
- BumperRingAnimation.Update(ref bumperState.RingAnimation, PhysicsConstants.PhysicsStepTime / 1000f);
- }
- if (bumperState.SkirtItemId != 0) {
- BumperSkirtAnimation.Update(ref bumperState.SkirtAnimation, PhysicsConstants.PhysicsStepTime / 1000f);
- }
- }
- }
-
- // drop target
- using (var enumerator = DropTargetStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var dropTargetState = ref enumerator.Current.Value;
- DropTargetAnimation.Update(enumerator.Current.Key, ref dropTargetState.Animation, in dropTargetState.Static, ref state);
- }
- }
-
- // hit target
- using (var enumerator = HitTargetStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var hitTargetState = ref enumerator.Current.Value;
- HitTargetAnimation.Update(ref hitTargetState.Animation, in hitTargetState.Static, env.TimeMsec);
- }
- }
-
- // plunger
- using (var enumerator = PlungerStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var plungerState = ref enumerator.Current.Value;
- PlungerAnimation.Update(ref plungerState.Animation, in plungerState.Movement, in plungerState.Static);
- }
- }
-
- // trigger
- using (var enumerator = TriggerStates.GetEnumerator()) {
- while (enumerator.MoveNext()) {
- ref var triggerState = ref enumerator.Current.Value;
- TriggerAnimation.Update(ref triggerState.Animation, ref triggerState.Movement, in triggerState.Static, PhysicsConstants.PhysicsStepTime / 1000f);
- }
- }
-
- #endregion
-
- env.CurPhysicsFrameTime = env.NextPhysicsFrameTime;
- env.NextPhysicsFrameTime += PhysicsConstants.PhysicsStepTime;
- }
-
- PhysicsEnv[0] = env;
- kineticOctree.Dispose();
- }
- }
-}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
index 5c4bed113..b8a48543b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
@@ -26,6 +26,7 @@
using VisualPinball.Engine.Common;
using VisualPinball.Engine.Game;
using VisualPinball.Engine.Game.Engines;
+using VisualPinball.Unity.Simulation;
using Color = VisualPinball.Engine.Math.Color;
using Logger = NLog.Logger;
@@ -111,6 +112,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac
private TableComponent _tableComponent;
private PlayfieldComponent _playfieldComponent;
private PhysicsEngine _physicsEngine;
+ private SimulationThreadComponent _simulationThreadComponent;
private CancellationTokenSource _gamelogicEngineInitCts;
private PlayfieldComponent PlayfieldComponent {
@@ -131,6 +133,15 @@ private PhysicsEngine PhysicsEngine {
}
}
+ private SimulationThreadComponent SimulationThreadComponent {
+ get {
+ if (_simulationThreadComponent == null) {
+ _simulationThreadComponent = GetComponent();
+ }
+ return _simulationThreadComponent;
+ }
+ }
+
#region Access
internal IApiSwitch Switch(ISwitchDeviceComponent component, string switchItem) => component != null ? _switchPlayer.Switch(component, switchItem) : null;
@@ -139,6 +150,24 @@ private PhysicsEngine PhysicsEngine {
public IApiWireDeviceDest WireDevice(IWireableComponent c) => _wirePlayer.WireDevice(c);
internal void HandleWireSwitchChange(WireDestConfig wireConfig, bool isEnabled) => _wirePlayer.HandleSwitchChange(wireConfig, isEnabled);
+ internal void DispatchSwitch(string switchId, bool isClosed)
+ {
+ if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) {
+ return;
+ }
+ GamelogicEngine?.Switch(switchId, isClosed);
+ }
+
+ internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled)
+ {
+ return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled);
+ }
+
+ public bool SupportsSimulationThreadCoilDispatch(string coilId)
+ {
+ return _coilPlayer.SupportsSimulationThreadDispatch(coilId);
+ }
+
public Dictionary SwitchStatuses => _switchPlayer.SwitchStatuses;
public Dictionary CoilStatuses => _coilPlayer.CoilStatuses;
public Dictionary