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 LampStatuses => _lampPlayer.LampStates; @@ -148,6 +177,7 @@ private PhysicsEngine PhysicsEngine { private int _currentBallId; public void SetLamp(string lampId, float value) => _lampPlayer.HandleLampEvent(lampId, value); + public void SetLamp(string lampId, float value, LampSource source) => _lampPlayer.HandleLampEvent(lampId, value, source); public void SetLamp(string lampId, LampStatus status) => _lampPlayer.HandleLampEvent(lampId, status); public void SetLamp(string lampId, Color color) => _lampPlayer.HandleLampEvent(lampId, color); @@ -157,6 +187,9 @@ private PhysicsEngine PhysicsEngine { private void Awake() { + Debug.developerConsoleEnabled = false; + Debug.developerConsoleVisible = false; + _tableComponent = GetComponent(); var engineComponent = GetComponent(); @@ -170,7 +203,7 @@ private void Awake() GamelogicEngine = engineComponent; _lampPlayer.Awake(this, _tableComponent, GamelogicEngine); _coilPlayer.Awake(this, _tableComponent, GamelogicEngine, _lampPlayer, _wirePlayer); - _switchPlayer.Awake(_tableComponent, GamelogicEngine, _inputManager); + _switchPlayer.Awake(this, _tableComponent, GamelogicEngine, _inputManager); _wirePlayer.Awake(_tableComponent, _inputManager, _switchPlayer, this, PhysicsEngine); _displayPlayer.Awake(GamelogicEngine); } @@ -321,8 +354,7 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) public void OnEvent(in EventData eventData) { - Debug.Log(eventData); - switch (eventData.EventId) { + switch (eventData.EventId) { case EventId.HitEventsHit: if (!_hittables.ContainsKey(eventData.ItemId)) { Debug.LogError($"Cannot find {eventData.ItemId} in hittables."); @@ -477,4 +509,4 @@ public BallEvent(int ballId, GameObject ball) Ball = ball; } } -} +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs index 2ace4dd33..f0abd43f0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs @@ -128,21 +128,22 @@ public bool HasWireDest(IWireableComponent device, string deviceItem) internal void OnSwitch(bool enabled) { // handle switch -> gamelogic engine - if (Engine != null && _switches != null) { - foreach (var switchConfig in _switches) { - - // set new status now - _switchStatuses[switchConfig.SwitchId].IsSwitchEnabled = enabled; - Engine.Switch(switchConfig.SwitchId, switchConfig.IsNormallyClosed ? !enabled : enabled); + if (Engine != null && _switches != null) { + foreach (var switchConfig in _switches) { + var isClosed = switchConfig.IsNormallyClosed ? !enabled : enabled; + + // set new status now + _switchStatuses[switchConfig.SwitchId].IsSwitchEnabled = enabled; + _player.DispatchSwitch(switchConfig.SwitchId, isClosed); // if it's pulse, schedule to re-open - if (enabled && switchConfig.IsPulseSwitch) { - _physicsEngine.ScheduleAction( - switchConfig.PulseDelay, - () => { - _switchStatuses[switchConfig.SwitchId].IsSwitchEnabled = false; - Engine.Switch(switchConfig.SwitchId, switchConfig.IsNormallyClosed); - IsEnabled = false; + if (enabled && switchConfig.IsPulseSwitch) { + _physicsEngine.ScheduleAction( + switchConfig.PulseDelay, + () => { + _switchStatuses[switchConfig.SwitchId].IsSwitchEnabled = false; + _player.DispatchSwitch(switchConfig.SwitchId, switchConfig.IsNormallyClosed); + IsEnabled = false; #if UNITY_EDITOR RefreshUI(); #endif @@ -170,11 +171,12 @@ internal void OnSwitch(bool enabled) internal void ScheduleSwitch(bool enabled, int delay, Action onSwitched) { // handle switch -> gamelogic engine - if (Engine != null && _switches != null) { - foreach (var switchConfig in _switches) { - _physicsEngine.ScheduleAction(delay, - () => Engine.Switch(switchConfig.SwitchId, switchConfig.IsNormallyClosed ? !enabled : enabled)); - } + if (Engine != null && _switches != null) { + foreach (var switchConfig in _switches) { + var isClosed = switchConfig.IsNormallyClosed ? !enabled : enabled; + _physicsEngine.ScheduleAction(delay, + () => _player.DispatchSwitch(switchConfig.SwitchId, isClosed)); + } } else { Logger.Warn("Cannot schedule device switch."); } @@ -193,12 +195,11 @@ internal void ScheduleSwitch(bool enabled, int delay, Action onSwitched) } } - // handle own status - _physicsEngine.ScheduleAction(delay, () => { - Debug.Log($"Setting scheduled switch {Name} to {enabled}."); - IsEnabled = enabled; - - onSwitched.Invoke(enabled); + // handle own status + _physicsEngine.ScheduleAction(delay, () => { + IsEnabled = enabled; + + onSwitched.Invoke(enabled); #if UNITY_EDITOR RefreshUI(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs index 9e01abac4..e4ebd5595 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs @@ -38,9 +38,10 @@ public class SwitchPlayer ///
private readonly Dictionary> _keySwitchAssignments = new(); - private TableComponent _tableComponent; - private IGamelogicEngine _gamelogicEngine; - private InputManager _inputManager; + private TableComponent _tableComponent; + private Player _player; + private IGamelogicEngine _gamelogicEngine; + private InputManager _inputManager; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -51,11 +52,12 @@ internal void RegisterSwitchDevice(ISwitchDeviceComponent component, IApiSwitchD public bool SwitchDeviceExists(ISwitchDeviceComponent component) => _switchDevices.ContainsKey(component); - public void Awake(TableComponent tableComponent, IGamelogicEngine gamelogicEngine, InputManager inputManager) - { - _tableComponent = tableComponent; - _gamelogicEngine = gamelogicEngine; - _inputManager = inputManager; + public void Awake(Player player, TableComponent tableComponent, IGamelogicEngine gamelogicEngine, InputManager inputManager) + { + _player = player; + _tableComponent = tableComponent; + _gamelogicEngine = gamelogicEngine; + _inputManager = inputManager; } public void OnStart() @@ -76,19 +78,23 @@ public void OnStart() break; } - // check if device exists - if (!_switchDevices.TryGetValue(switchMapping.Device, out var device)) { - Logger.Error($"Unknown switch device \"{switchMapping.Device}\"."); - break; - } + // check if device exists + if (!_switchDevices.TryGetValue(switchMapping.Device, out var device)) { + if (switchMapping.Device is IMechHandler) { + Logger.Info($"Switch \"{switchMapping.Id}\" on mech device \"{switchMapping.Device}\" is handled by mech config and does not require runtime switch registration."); + break; + } + Logger.Error($"Unknown switch device \"{switchMapping.Device}\"."); + break; + } var deviceSwitch = device.Switch(switchMapping.DeviceItem); if (deviceSwitch != null) { - var existingSwitchStatus = SwitchStatuses.ContainsKey(switchMapping.Id) ? SwitchStatuses[switchMapping.Id] : null; - var switchStatus = deviceSwitch.AddSwitchDest(new SwitchConfig(switchMapping), existingSwitchStatus); - SwitchStatuses[switchMapping.Id] = switchStatus; - - } else { + var existingSwitchStatus = SwitchStatuses.ContainsKey(switchMapping.Id) ? SwitchStatuses[switchMapping.Id] : null; + var switchStatus = deviceSwitch.AddSwitchDest(new SwitchConfig(switchMapping), existingSwitchStatus); + SwitchStatuses[switchMapping.Id] = switchStatus; + + } else { Logger.Error($"Unknown switch \"{switchMapping.DeviceItem}\" in switch device \"{switchMapping.Device}\"."); } @@ -126,13 +132,13 @@ private void HandleKeyInput(object obj, InputActionChange change) case InputActionChange.ActionStarted: case InputActionChange.ActionCanceled: var action = (InputAction)obj; - if (_keySwitchAssignments.TryGetValue(action.name, out var assignment)) { - if (_gamelogicEngine != null) { - foreach (var sw in assignment) { - sw.IsSwitchEnabled = change == InputActionChange.ActionStarted; - _gamelogicEngine.Switch(sw.SwitchId, sw.IsSwitchClosed); - } - } + if (_keySwitchAssignments.TryGetValue(action.name, out var assignment)) { + if (_player != null) { + foreach (var sw in assignment) { + sw.IsSwitchEnabled = change == InputActionChange.ActionStarted; + _player.DispatchSwitch(sw.SwitchId, sw.IsSwitchClosed); + } + } } else { Logger.Info($"Unmapped input command \"{action.name}\"."); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs index 004dc6a8a..b3bc8cc6c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs @@ -48,6 +48,7 @@ public struct ColliderReference : IDisposable public readonly bool IsKinematic; // if set, populate _itemIdToColliderIds private NativeParallelHashMap> _itemIdToColliderIds; private NativeParallelHashMap _nonTransformableColliderTransforms; + private readonly Allocator _allocator; public ColliderReference(ref NativeParallelHashMap nonTransformableColliderTransforms, Allocator allocator, bool isKinematic = false) { @@ -67,6 +68,7 @@ public ColliderReference(ref NativeParallelHashMap nonTransformab Lookups = new NativeList(allocator); IsKinematic = isKinematic; + _allocator = allocator; _itemIdToColliderIds = new NativeParallelHashMap>(0, allocator); _nonTransformableColliderTransforms = nonTransformableColliderTransforms; } @@ -263,7 +265,7 @@ private void TrackReference(int itemId, int colliderId) #endif if (!_itemIdToColliderIds.ContainsKey(itemId)) { - _itemIdToColliderIds[itemId] = new NativeList(Allocator.Temp); + _itemIdToColliderIds[itemId] = new NativeList(_allocator); } _itemIdToColliderIds[itemId].Add(colliderId); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs index d97b4e9c7..85e969682 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs @@ -18,6 +18,7 @@ using System.Diagnostics; using System; +using System.Runtime.InteropServices; using Unity.Burst; using Unity.Collections.LowLevel.Unsafe; using Unity.Collections; @@ -62,6 +63,8 @@ public unsafe struct NativeColliders : IDisposable [NativeDisableUnsafePtrRestriction] private void* m_PlaneColliderBuffer; private readonly Allocator m_AllocatorLabel; + + [MarshalAs(UnmanagedType.U1)] private readonly bool m_IsKinematic; private int m_Length; // must be here, and called like that. @@ -648,4 +651,4 @@ public NativeCollidersDebugView(NativeColliders nativeColliders) } public ICollider[] Colliders => _nativeColliders.ToArray(); } -} +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/RenderPipeline.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/RenderPipeline.cs index 19ff7ef92..15845215e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/RenderPipeline.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Rendering/RenderPipeline.cs @@ -17,6 +17,8 @@ using System; using System.Linq; using NLog; +using UnityEngine; +using Logger = NLog.Logger; namespace VisualPinball.Unity { @@ -41,12 +43,6 @@ public interface IRenderPipeline ///
IMaterialConverter MaterialConverter { get; } - /// - /// Provides a bunch of helper methods for setting common attributes - /// in materials. - /// - IMaterialAdapter MaterialAdapter { get; } - /// /// Converts a light from Visual Pinball to the active renderer. /// @@ -56,11 +52,6 @@ public interface IRenderPipeline /// Creates a new ball. /// IBallConverter BallConverter { get; } - - /// - /// Provides access to VPE's game item prefabs. - /// - IPrefabProvider PrefabProvider { get; } } public enum RenderPipelineType @@ -98,6 +89,7 @@ public static class RenderPipeline public static IRenderPipeline Current { get { if (_current == null) { + Debug.Log("Detecting render pipeline..."); var t = typeof(IRenderPipeline); var pipelines = AppDomain.CurrentDomain.GetAssemblies() .Where(x => x.FullName.StartsWith("VisualPinball.")) @@ -106,10 +98,13 @@ public static IRenderPipeline Current { .Select(x => (IRenderPipeline) 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/Simulation.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation.meta new file mode 100644 index 000000000..6dcfb9ce7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e570742e75e3ff9489ebf56965dd8ee7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs new file mode 100644 index 000000000..e07e67977 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs @@ -0,0 +1,153 @@ +// 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.Collections.Generic; +using NLog; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Simulation +{ + internal interface IGamelogicInputDispatcher : IDisposable + { + void DispatchSwitch(string switchId, bool isClosed); + void FlushMainThread(); + } + + internal static class GamelogicInputDispatcherFactory + { + public static IGamelogicInputDispatcher Create(IGamelogicEngine gamelogicEngine) + { + if (gamelogicEngine == null) { + return NoopInputDispatcher.Instance; + } + + if (gamelogicEngine is IGamelogicInputThreading { SwitchDispatchMode: GamelogicInputDispatchMode.SimulationThread }) { + return new DirectInputDispatcher(gamelogicEngine); + } + + return new MainThreadQueuedInputDispatcher(gamelogicEngine); + } + } + + internal sealed class NoopInputDispatcher : IGamelogicInputDispatcher + { + public static readonly NoopInputDispatcher Instance = new NoopInputDispatcher(); + + private NoopInputDispatcher() + { + } + + public void DispatchSwitch(string switchId, bool isClosed) + { + } + + public void FlushMainThread() + { + } + + public void Dispose() + { + } + } + + internal sealed class DirectInputDispatcher : IGamelogicInputDispatcher + { + private readonly IGamelogicEngine _gamelogicEngine; + + public DirectInputDispatcher(IGamelogicEngine gamelogicEngine) + { + _gamelogicEngine = gamelogicEngine; + } + + public void DispatchSwitch(string switchId, bool isClosed) + { + _gamelogicEngine.Switch(switchId, isClosed); + } + + public void FlushMainThread() + { + } + + public void Dispose() + { + } + } + + internal sealed class MainThreadQueuedInputDispatcher : IGamelogicInputDispatcher + { + private readonly struct QueuedSwitchEvent + { + public readonly string SwitchId; + public readonly bool IsClosed; + + public QueuedSwitchEvent(string switchId, bool isClosed) + { + SwitchId = switchId; + IsClosed = isClosed; + } + } + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const int MaxQueuedEvents = 8192; + + private readonly IGamelogicEngine _gamelogicEngine; + private readonly object _queueLock = new object(); + private readonly Queue _queue = new Queue(256); + private int _droppedEvents; + + public MainThreadQueuedInputDispatcher(IGamelogicEngine gamelogicEngine) + { + _gamelogicEngine = gamelogicEngine; + } + + public void DispatchSwitch(string switchId, bool isClosed) + { + lock (_queueLock) { + if (_queue.Count >= MaxQueuedEvents) { + _droppedEvents++; + return; + } + _queue.Enqueue(new QueuedSwitchEvent(switchId, isClosed)); + } + } + + public void FlushMainThread() + { + while (true) { + QueuedSwitchEvent item; + int dropped = 0; + lock (_queueLock) { + if (_queue.Count == 0) { + dropped = _droppedEvents; + _droppedEvents = 0; + item = default; + } else { + item = _queue.Dequeue(); + } + } + + if (dropped > 0) { + Logger.Warn($"[SimulationThread] Dropped {dropped} queued switch events for {_gamelogicEngine.Name}"); + } + + if (item.SwitchId == null) { + break; + } + + _gamelogicEngine.Switch(item.SwitchId, item.IsClosed); + } + } + + public void Dispose() + { + lock (_queueLock) { + _queue.Clear(); + _droppedEvents = 0; + } + } + } +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs.meta new file mode 100644 index 000000000..cf2a6e740 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff3a7b6505a779743be183677d254aaf \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs new file mode 100644 index 000000000..3f556e1bd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs @@ -0,0 +1,121 @@ +// 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.Threading; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// Lock-free SPSC (Single Producer, Single Consumer) ring buffer for input events. + /// Producer: Input polling thread + /// Consumer: Simulation thread + /// + /// Implementation uses a circular buffer with atomic head/tail indices. + /// Thread-safe for single producer and single consumer without locks. + /// + public class InputEventBuffer : IDisposable + { + private readonly NativeInputApi.InputEvent[] _buffer; + private readonly int _capacity; + private readonly int _mask; // For power-of-2 wraparound + + // Use separate cache lines to avoid false sharing + private volatile int _head; // Consumer reads from head + private volatile int _tail; // Producer writes to tail + + /// + /// Creates a new input event buffer. + /// + /// Maximum number of events to buffer (default 1024, must be power of 2) + public InputEventBuffer(int capacity = 1024) + { + // Ensure capacity is power of 2 for efficient modulo via bitwise AND + if (capacity <= 0 || (capacity & (capacity - 1)) != 0) + { + throw new ArgumentException("Capacity must be a power of 2", nameof(capacity)); + } + + _capacity = capacity; + _mask = capacity - 1; + _buffer = new NativeInputApi.InputEvent[capacity]; + _head = 0; + _tail = 0; + } + + /// + /// Try to enqueue an input event (non-blocking). + /// Called by input polling thread (producer). + /// + public bool TryEnqueue(NativeInputApi.InputEvent evt) + { + // Read head (consumer position) with volatile semantics + int currentTail = _tail; + int nextTail = (currentTail + 1) & _mask; + + // Check if buffer is full + if (nextTail == Volatile.Read(ref _head)) + { + // Buffer full - drop event (oldest event stays) + return false; + } + + // Write event to buffer + _buffer[currentTail] = evt; + + // Advance tail (make event visible to consumer) + Volatile.Write(ref _tail, nextTail); + + return true; + } + + /// + /// Try to dequeue an input event (non-blocking). + /// Called by simulation thread (consumer). + /// + public bool TryDequeue(out NativeInputApi.InputEvent evt) + { + // Read head (our position) + int currentHead = _head; + + // Check if buffer is empty + if (currentHead == Volatile.Read(ref _tail)) + { + evt = default; + return false; + } + + // Read event from buffer + evt = _buffer[currentHead]; + + // Advance head (free slot for producer) + int nextHead = (currentHead + 1) & _mask; + Volatile.Write(ref _head, nextHead); + + return true; + } + + /// + /// Get the approximate number of events currently in the buffer. + /// Note: This is an estimate and may not be exact due to concurrent access. + /// + public int Count + { + get + { + int head = Volatile.Read(ref _head); + int tail = Volatile.Read(ref _tail); + int count = (tail - head) & _mask; + return count; + } + } + + public void Dispose() + { + // Nothing to dispose for array-based buffer + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs.meta new file mode 100644 index 000000000..aca1ce096 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd84ba87941c49449a5f96a504b855c0 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs new file mode 100644 index 000000000..7f195cd14 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -0,0 +1,141 @@ +// 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 AOT; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// P/Invoke wrapper for native input polling library + /// + public static class NativeInputApi + { + private const string DllName = "VpeNativeInput"; + + #region Enums + + /// + /// Input action enum (must match native enum) + /// + public enum InputAction + { + LeftFlipper = 0, + RightFlipper = 1, + UpperLeftFlipper = 2, + UpperRightFlipper = 3, + LeftMagnasave = 4, + RightMagnasave = 5, + Start = 6, + Plunge = 7, + PlungerAnalog = 8, + CoinInsert1 = 9, + CoinInsert2 = 10, + CoinInsert3 = 11, + CoinInsert4 = 12, + ExitGame = 13, + SlamTilt = 14, + } + + /// + /// Input binding type + /// + public enum BindingType + { + Keyboard = 0, + Gamepad = 1, + Mouse = 2, + } + + /// + /// Key codes (Windows virtual key codes) + /// + public enum KeyCode + { + LShift = 0xA0, + RShift = 0xA1, + LControl = 0xA2, + RControl = 0xA3, + Space = 0x20, + Return = 0x0D, + D1 = 0x31, + Num1 = 0x31, // alias for top-row '1' + D5 = 0x35, + Num5 = 0x35, // alias for top-row '5' + Numpad1 = 0x61, + A = 0x41, + S = 0x53, + D = 0x44, + W = 0x57, + } + + #endregion + + #region Structures + + /// + /// Input event structure (matches native struct layout) + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputEvent + { + public long TimestampUsec; + public int Action; // InputAction + public float Value; + private int _padding; + } + + /// + /// Input binding structure + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputBinding + { + public int Action; // InputAction + public int BindingType; // BindingType + public int KeyCode; // KeyCode or button index + private int _padding; + } + + #endregion + + #region Delegates + + /// + /// Callback for input events + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void InputEventCallback(ref InputEvent evt, IntPtr userData); + + #endregion + + #region Native Functions + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputInit(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputShutdown(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputSetBindings(InputBinding[] bindings, int count); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputStopPolling(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern long VpeGetTimestampUsec(); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeSetThreadPriority(); + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs.meta new file mode 100644 index 000000000..ce69f5688 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aafc982450151df45806b88fac2b9dde \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs new file mode 100644 index 000000000..b6fecbd8b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -0,0 +1,287 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using NLog; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// Manages native input polling and forwards events to the simulation thread. + /// Runs input polling on a separate thread at high frequency (500-1000 Hz). + /// + public class NativeInputManager : IDisposable + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const string LogPrefix = "[PinMAME-debug]"; + private static int _loggedFirstEvent; + + #region Fields + + private static volatile NativeInputManager _instance; + private static readonly object _instanceLock = new object(); + + private volatile SimulationThread _simulationThread; + private bool _initialized = false; + private bool _polling = false; + private int _pollIntervalUs = 0; + private const double PerfSampleWindowSeconds = 0.25; + private long _inputPerfWindowStartTicks = Stopwatch.GetTimestamp(); + private int _inputEventsInWindow; + private float _actualEventRateHz; + + // Input configuration + private readonly List _bindings = new(); + + // Callback delegate (must be kept alive to prevent GC) + private NativeInputApi.InputEventCallback _callbackDelegate; + + #endregion + + #region Singleton + + public static NativeInputManager Instance + { + get + { + if (_instance == null) + { + lock (_instanceLock) + { + if (_instance == null) + { + _instance = new NativeInputManager(); + } + } + } + return _instance; + } + } + + public static NativeInputManager TryGetExistingInstance() + { + return Volatile.Read(ref _instance); + } + + public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; + public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + + private NativeInputManager() + { + // Private constructor for singleton + } + + #endregion + + #region Public API + + /// + /// Initialize native input system + /// + public bool Initialize() + { + if (_initialized) return true; + + int result = NativeInputApi.VpeInputInit(); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to initialize native input system"); + return false; + } + + _initialized = true; + Logger.Info($"{LogPrefix} [NativeInputManager] Initialized"); + + // Setup default bindings + SetupDefaultBindings(); + + return true; + } + + /// + /// Set the simulation thread to forward input events to + /// + public void SetSimulationThread(SimulationThread simulationThread) + { + _simulationThread = simulationThread; + } + + /// + /// Add an input binding + /// + public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) + { + _bindings.Add(new NativeInputApi.InputBinding + { + Action = (int)action, + BindingType = (int)NativeInputApi.BindingType.Keyboard, + KeyCode = (int)keyCode + }); + } + + /// + /// Clear all bindings + /// + public void ClearBindings() + { + _bindings.Clear(); + } + + /// + /// Start input polling + /// + /// Polling interval in microseconds (default 500) + public bool StartPolling(int pollIntervalUs = 500) + { + #if UNITY_EDITOR + // Avoid extremely aggressive polling in the editor; it can delay/derail PinMAME stop/start. + if (pollIntervalUs < 1000) { + pollIntervalUs = 1000; + } + #endif + + if (!_initialized) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Not initialized"); + return false; + } + + if (_polling) + { + Logger.Warn($"{LogPrefix} [NativeInputManager] Already polling"); + return true; + } + + // Send bindings to native layer + NativeInputApi.VpeInputSetBindings(_bindings.ToArray(), _bindings.Count); + + // Create callback delegate (keep reference to prevent GC) + _callbackDelegate = OnInputEvent; + + // Start polling thread + int result = NativeInputApi.VpeInputStartPolling(_callbackDelegate, IntPtr.Zero, pollIntervalUs); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to start polling"); + return false; + } + + _polling = true; + _pollIntervalUs = pollIntervalUs; + Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); + + return true; + } + + /// + /// Stop input polling + /// + public void StopPolling() + { + if (!_polling) return; + + NativeInputApi.VpeInputStopPolling(); + _polling = false; + _pollIntervalUs = 0; + Volatile.Write(ref _actualEventRateHz, 0f); + + Logger.Info($"{LogPrefix} [NativeInputManager] Stopped polling"); + } + + #endregion + + #region Private Methods + + /// + /// Setup default input bindings + /// + private void SetupDefaultBindings() + { + ClearBindings(); + + // Flippers + AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.LShift); + AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.RShift); + // Fallback keys (useful when modifier VKs are unreliable in some contexts) + AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.A); + AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.D); + + // Start + AddBinding(NativeInputApi.InputAction.Start, NativeInputApi.KeyCode.D1); + + // Coin + AddBinding(NativeInputApi.InputAction.CoinInsert1, NativeInputApi.KeyCode.D5); + + // Plunger (align with Unity InputManager defaults: Enter) + AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Return); + AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Space); + + Logger.Info($"{LogPrefix} [NativeInputManager] Configured {_bindings.Count} default bindings"); + } + + /// + /// Input event callback from native layer (called on input polling thread) + /// + [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] + private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) + { + if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { + Logger.Info($"{LogPrefix} [NativeInputManager] First event: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } + if (Logger.IsTraceEnabled) { + Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } + + // Forward to simulation thread via ring buffer + var instance = Volatile.Read(ref _instance); + instance?.MarkInputEventActivity(); + instance?._simulationThread?.EnqueueInputEvent(evt); + } + + private void MarkInputEventActivity() + { + Interlocked.Increment(ref _inputEventsInWindow); + + var nowTicks = Stopwatch.GetTimestamp(); + var startTicks = Volatile.Read(ref _inputPerfWindowStartTicks); + var elapsedSeconds = (nowTicks - startTicks) / (double)Stopwatch.Frequency; + if (elapsedSeconds < PerfSampleWindowSeconds) { + return; + } + + if (Interlocked.CompareExchange(ref _inputPerfWindowStartTicks, nowTicks, startTicks) != startTicks) { + return; + } + + var eventsInWindow = Interlocked.Exchange(ref _inputEventsInWindow, 0); + var rate = elapsedSeconds > 0.0 ? eventsInWindow / elapsedSeconds : 0.0; + Volatile.Write(ref _actualEventRateHz, (float)rate); + } + + #endregion + + #region Dispose + + public void Dispose() + { + StopPolling(); + + if (_initialized) + { + NativeInputApi.VpeInputShutdown(); + _initialized = false; + } + + _instance = null; + } + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs.meta new file mode 100644 index 000000000..f51738723 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83a5f2d7397b7d1449111e03e2a0152f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs new file mode 100644 index 000000000..f184e0c34 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -0,0 +1,359 @@ +// 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.Threading; +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// Shared simulation state between simulation thread and Unity main thread. + /// Uses triple-buffering for truly lock-free reads: the sim thread always + /// writes to its own buffer, publishes via atomic exchange, and the main + /// thread acquires the latest published buffer — neither thread ever + /// touches the other's active buffer. + /// + public class SimulationState : IDisposable + { + /// + /// Maximum number of coils/solenoids supported + /// + private const int MaxCoils = 64; + + /// + /// Maximum number of lamps supported + /// + private const int MaxLamps = 256; + + /// + /// Maximum number of GI strings supported + /// + private const int MaxGIStrings = 8; + + /// + /// Maximum number of balls tracked per snapshot + /// + internal const int MaxBalls = 32; + + /// + /// Maximum number of float-animated items (flippers, gates, spinners, + /// plungers, drop targets, hit targets, triggers, bumper rings) + /// + internal const int MaxFloatAnimations = 128; + + /// + /// Maximum number of float2-animated items (bumper skirts) + /// + internal const int MaxFloat2Animations = 16; + + #region Animation Snapshot Structures + + /// + /// Per-ball snapshot for lock-free rendering. + /// + [StructLayout(LayoutKind.Sequential)] + public struct BallSnapshot + { + public int Id; + public float3 Position; + public float Radius; + public byte IsFrozen; // 0 = no, 1 = yes + public float3x3 Orientation; // BallOrientationForUnity + } + + /// + /// Per-item float animation value (flipper angle, gate angle, etc.) + /// + [StructLayout(LayoutKind.Sequential)] + public struct FloatAnimation + { + public int ItemId; + public float Value; + } + + /// + /// Per-item float2 animation value (bumper skirt rotation) + /// + [StructLayout(LayoutKind.Sequential)] + public struct Float2Animation + { + public int ItemId; + public float2 Value; + } + + #endregion + + #region State Structures + + /// + /// Coil state (solenoid) + /// + [StructLayout(LayoutKind.Sequential)] + public struct CoilState + { + public int Id; + public byte IsActive; // 0 = off, 1 = on + public byte _padding1; + public short _padding2; + } + + /// + /// Lamp state + /// + [StructLayout(LayoutKind.Sequential)] + public struct LampState + { + public int Id; + public float Value; // 0.0 - 1.0 brightness + } + + /// + /// GI (General Illumination) state + /// + [StructLayout(LayoutKind.Sequential)] + public struct GIState + { + public int Id; + public float Value; // 0.0 - 1.0 brightness + } + + /// + /// Complete simulation state snapshot including animation data for + /// lock-free visual updates. + /// + public struct Snapshot + { + // Timing + public long SimulationTimeUsec; + public long RealTimeUsec; + public long PublishRealTimeUsec; + public long SimulationTickDurationUsec; + public long SnapshotCopyUsec; + public long KinematicScanUsec; + public long EventDrainUsec; + public long FenceUpdateIntervalUsec; + public float GamelogicCallbackRateHz; + public int PendingInputActionCount; + public int PendingScheduledActionCount; + public int ExternalSwitchQueueDepth; + public long LastSwitchDispatchUsec; + public long LastFlipperInputUsec; + public long LastCoilDispatchUsec; + public long LastSwitchObservationUsec; + public long LastCoilOutputUsec; + + // PinMAME state + public NativeArray CoilStates; + public int CoilCount; + public NativeArray LampStates; + public int LampCount; + public NativeArray GIStates; + public int GICount; + + // Physics state references (not copied, just references) + // The actual PhysicsState is too large to copy every tick + // Instead, we'll use versioning and the main thread will read directly + public int PhysicsStateVersion; + + // --- Animation snapshot data (filled by sim thread) --- + + public NativeArray BallSnapshots; + public int BallCount; + public int BallSourceCount; + public byte BallSnapshotsTruncated; + + public NativeArray FloatAnimations; + public int FloatAnimationCount; + public int FloatAnimationSourceCount; + public byte FloatAnimationsTruncated; + + public NativeArray Float2Animations; + public int Float2AnimationCount; + public int Float2AnimationSourceCount; + public byte Float2AnimationsTruncated; + + public void Allocate() + { + CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); + LampStates = new NativeArray(MaxLamps, Allocator.Persistent); + GIStates = new NativeArray(MaxGIStrings, Allocator.Persistent); + CoilCount = 0; + LampCount = 0; + GICount = 0; + PublishRealTimeUsec = 0; + SimulationTickDurationUsec = 0; + SnapshotCopyUsec = 0; + KinematicScanUsec = 0; + EventDrainUsec = 0; + FenceUpdateIntervalUsec = 0; + GamelogicCallbackRateHz = 0f; + PendingInputActionCount = 0; + PendingScheduledActionCount = 0; + ExternalSwitchQueueDepth = 0; + LastSwitchDispatchUsec = 0; + LastFlipperInputUsec = 0; + LastCoilDispatchUsec = 0; + LastSwitchObservationUsec = 0; + LastCoilOutputUsec = 0; + BallSnapshots = new NativeArray(MaxBalls, Allocator.Persistent); + FloatAnimations = new NativeArray(MaxFloatAnimations, Allocator.Persistent); + Float2Animations = new NativeArray(MaxFloat2Animations, Allocator.Persistent); + BallCount = 0; + BallSourceCount = 0; + BallSnapshotsTruncated = 0; + FloatAnimationCount = 0; + FloatAnimationSourceCount = 0; + FloatAnimationsTruncated = 0; + Float2AnimationCount = 0; + Float2AnimationSourceCount = 0; + Float2AnimationsTruncated = 0; + } + + public void Dispose() + { + if (CoilStates.IsCreated) CoilStates.Dispose(); + if (LampStates.IsCreated) LampStates.Dispose(); + if (GIStates.IsCreated) GIStates.Dispose(); + if (BallSnapshots.IsCreated) BallSnapshots.Dispose(); + if (FloatAnimations.IsCreated) FloatAnimations.Dispose(); + if (Float2Animations.IsCreated) Float2Animations.Dispose(); + } + } + + #endregion + + #region Fields + + // Triple-buffered snapshots + private Snapshot _buffer0; + private Snapshot _buffer1; + private Snapshot _buffer2; + + /// + /// Index of the buffer the sim thread is currently writing to. + /// Only the sim thread reads/writes this field. + /// + private int _writeIndex; + + /// + /// Index of the most recently published buffer. + /// Shared between threads — accessed only via Interlocked.Exchange. + /// + private int _readyIndex; + + /// + /// Index of the buffer the main thread is currently reading from. + /// Only the main thread reads/writes this field. + /// + private int _readIndex; + + private bool _disposed; + + #endregion + + #region Constructor / Dispose + + public SimulationState() + { + _buffer0.Allocate(); + _buffer1.Allocate(); + _buffer2.Allocate(); + + // Sim thread starts writing to 0, published ("ready") starts as 1, + // main thread starts reading from 2. + _writeIndex = 0; + _readyIndex = 1; + _readIndex = 2; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _buffer0.Dispose(); + _buffer1.Dispose(); + _buffer2.Dispose(); + } + + #endregion + + #region Write (Simulation Thread) + + /// + /// Get the current write buffer. + /// + /// Thread: Simulation thread only. + internal ref Snapshot GetWriteBuffer() + { + return ref GetBufferByIndex(_writeIndex); + } + + /// + /// Publish the write buffer so the main thread can pick it up, and + /// reclaim the previously-ready buffer as the new write target. + /// Allocation-free. + /// + /// Thread: Simulation thread only. + internal void PublishWriteBuffer() + { + // Atomically swap _readyIndex with our _writeIndex. + // After this, the old ready buffer becomes our new write buffer, + // and the data we just wrote is now the ready buffer. + _writeIndex = Interlocked.Exchange(ref _readyIndex, _writeIndex); + } + + #endregion + + #region Read (Main Thread) + + /// + /// Acquire the latest published snapshot for reading. + /// Returns a ref to the acquired buffer that is safe to read until the + /// next call to . + /// Allocation-free. + /// + /// Thread: Main thread only. + internal ref readonly Snapshot AcquireReadBuffer() + { + // Atomically swap _readyIndex with our _readIndex. + // After this we own what was the ready buffer (latest data), and + // our previous read buffer goes back into the ready slot (which + // the sim thread may reclaim as write). + _readIndex = Interlocked.Exchange(ref _readyIndex, _readIndex); + return ref GetBufferByIndex(_readIndex); + } + + /// + /// Peek at the current read buffer without swapping. + /// Useful when you just need to re-read the last acquired snapshot. + /// + /// Thread: Main thread only. + internal ref readonly Snapshot PeekReadBuffer() + { + return ref GetBufferByIndex(_readIndex); + } + + #endregion + + #region Helpers + + private ref Snapshot GetBufferByIndex(int index) + { + switch (index) { + case 0: return ref _buffer0; + case 1: return ref _buffer1; + case 2: return ref _buffer2; + default: throw new IndexOutOfRangeException($"Invalid buffer index {index}"); + } + } + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs.meta new file mode 100644 index 000000000..e9ad360f4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64764e547899b2f49bab741a105d439d \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs new file mode 100644 index 000000000..3c5350bcd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -0,0 +1,795 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Runtime; +using System.Threading; +using NLog; +using Unity.Mathematics; +using Logger = NLog.Logger; +using VisualPinball.Engine.Common; +using VisualPinball.Unity; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// High-performance simulation thread that runs physics and PinMAME + /// at 1000 Hz (1ms per tick) independent of rendering frame rate. + /// + /// Goals: + /// - Sub-millisecond input latency + /// - Decoupled from rendering + /// - Allocation-free hot path + /// - Lock-free communication with main thread + /// + public class SimulationThread : IDisposable + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const string LogPrefix = "[PinMAME-debug]"; + + #region Constants + + private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds + private const long BusyWaitThresholdUsec = 25; // Keep active spin short to avoid starving render/main thread + private const int MaxCoilOutputsPerTick = 128; + + #endregion + + #region Fields + + private readonly PhysicsEngine _physicsEngine; + private readonly IGamelogicEngine _gamelogicEngine; + private readonly IGamelogicTimeFence _timeFence; + private readonly IGamelogicCoilOutputFeed _coilOutputFeed; + private readonly IGamelogicSharedStateWriter _sharedStateWriter; + private readonly IGamelogicPerformanceStats _gamelogicPerformanceStats; + private readonly IGamelogicLatencyStats _gamelogicLatencyStats; + private readonly IGamelogicInputDispatcher _inputDispatcher; + private readonly Action _simulationCoilDispatcher; + private readonly InputEventBuffer _inputBuffer; + private readonly SimulationState _sharedState; + + private Thread _thread; + private volatile bool _running = false; + private volatile bool _paused = false; + + // Timing (Stopwatch ticks - avoids high-frequency P/Invoke) + private readonly long _tickIntervalTicks; + private readonly long _busyWaitThresholdTicks; + private long _lastTickTicks; + private long _simulationTimeUsec; + private long _lastTimeFenceUsec = long.MinValue; + private long _lastTimeFenceIntervalUsec; + private long _lastSimulationTickDurationUsec; + private long _lastSnapshotCopyUsec; + private long _lastSwitchDispatchUsec; + private long _lastFlipperInputUsec; + private long _lastCoilDispatchUsec; + private double _simulationClockScale = 1.0; + private long _latestMainThreadClockUsec; + private volatile bool _hasMainThreadClockSync; + + // Input state tracking (allocation-free indexed arrays) + private readonly bool[] _actionStates; + private readonly string[] _actionToSwitchId; + private volatile bool _inputMappingsBuilt; + + // Statistics + private long _tickCount = 0; + private long _inputEventsProcessed = 0; + private long _inputEventsDropped = 0; + + private volatile bool _gamelogicStarted; + private volatile bool _needsInitialSwitchSync; + private bool _externalSwitchQueueWarningIssued; + private bool _externalSwitchQueueDropWarningIssued; + + private readonly object _externalSwitchQueueLock = new object(); + private readonly Queue _externalSwitchQueue = new Queue(128); + private const int MaxExternalSwitchQueueSize = 8192; + + private readonly struct PendingSwitchEvent + { + public readonly string SwitchId; + public readonly bool IsClosed; + + public PendingSwitchEvent(string switchId, bool isClosed) + { + SwitchId = switchId; + IsClosed = isClosed; + } + } + + #endregion + + #region Constructor + + public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine, + Action simulationCoilDispatcher) + { + _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); + _gamelogicEngine = gamelogicEngine; + _timeFence = gamelogicEngine as IGamelogicTimeFence; + _coilOutputFeed = gamelogicEngine as IGamelogicCoilOutputFeed; + _sharedStateWriter = gamelogicEngine as IGamelogicSharedStateWriter; + _gamelogicPerformanceStats = gamelogicEngine as IGamelogicPerformanceStats; + _gamelogicLatencyStats = gamelogicEngine as IGamelogicLatencyStats; + _inputDispatcher = GamelogicInputDispatcherFactory.Create(gamelogicEngine); + _simulationCoilDispatcher = simulationCoilDispatcher; + + _inputBuffer = new InputEventBuffer(1024); + _sharedState = new SimulationState(); + + // Precompute timing constants + _tickIntervalTicks = (Stopwatch.Frequency * TickIntervalUsec) / 1_000_000; + _busyWaitThresholdTicks = (Stopwatch.Frequency * BusyWaitThresholdUsec) / 1_000_000; + if (_tickIntervalTicks <= 0) { + _tickIntervalTicks = 1; + } + + // Initialize input state + mapping arrays + var actionCount = Enum.GetValues(typeof(NativeInputApi.InputAction)).Length; + _actionStates = new bool[actionCount]; + _actionToSwitchId = new string[actionCount]; + _needsInitialSwitchSync = true; + + if (_gamelogicEngine != null) { + _gamelogicEngine.OnStarted += OnGamelogicStarted; + } + } + + #endregion + + #region Public API + + /// + /// Start the simulation thread. + /// + /// Thread: Main thread only. + public void Start() + { + if (_running) return; + + _running = true; + _paused = false; + _gamelogicStarted = _gamelogicEngine != null; + _lastTickTicks = Stopwatch.GetTimestamp(); + _simulationTimeUsec = _hasMainThreadClockSync ? Interlocked.Read(ref _latestMainThreadClockUsec) : 0; + _lastTimeFenceUsec = long.MinValue; + _tickCount = 0; + _inputEventsProcessed = 0; + _inputEventsDropped = 0; + _needsInitialSwitchSync = true; + + _thread = new Thread(SimulationThreadFunc) + { + Name = "VPE Simulation Thread", + IsBackground = true, + Priority = ThreadPriority.AboveNormal + }; + _thread.Start(); + + Logger.Info($"{LogPrefix} [SimulationThread] Started at 1000 Hz"); + } + + /// + /// Stop the simulation thread. + /// + /// Thread: Main thread only. + public void Stop() + { + if (!_running) return; + + _running = false; + + if (_thread != null && _thread.IsAlive) + { + _thread.Join(5000); // Wait up to 5 seconds + } + + Logger.Info($"{LogPrefix} [SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events, {_inputEventsDropped} dropped"); + } + + /// + /// Pause the simulation (for debugging). + /// + /// Thread: Main thread only (sets volatile flag). + public void Pause() + { + _paused = true; + } + + /// + /// Resume the simulation. + /// + /// Thread: Main thread only (sets volatile flag). + public void Resume() + { + _paused = false; + } + + /// + /// Enqueue an input event from the input polling thread. + /// + /// Thread: Native input polling thread (thread-safe via lock-free ring buffer). + public void EnqueueInputEvent(NativeInputApi.InputEvent evt) + { + if (!_inputBuffer.TryEnqueue(evt)) { + Interlocked.Increment(ref _inputEventsDropped); + } + } + + /// + /// Get the current shared state (for main thread to read). + /// Returns a peek at the current read buffer without acquiring a new + /// one. The actual acquire happens in + /// to avoid + /// double-swapping per frame. + /// + /// Thread: Main thread only. + public ref readonly SimulationState.Snapshot GetSharedState() + { + return ref _sharedState.PeekReadBuffer(); + } + + /// + /// The triple-buffered SimulationState, so the caller can pass it to + /// . + /// + /// Thread: Main thread only (used during initialization). + public SimulationState SharedState => _sharedState; + + /// + /// Flush any queued main-thread input dispatches. + /// + /// Thread: Main thread only. + public void FlushMainThreadInputDispatch() + { + _inputDispatcher.FlushMainThread(); + } + + /// + /// Publish the latest Unity-scaled simulation clock sample from the + /// main thread so the simulation thread can stay aligned with + /// single-threaded timing semantics, including slow-motion and + /// time-lapse modes. + /// + /// Thread: Main thread only. + public void SyncClockFromMainThread(ulong currentClockUsec, float timeScale) + { + Interlocked.Exchange(ref _latestMainThreadClockUsec, (long)currentClockUsec); + Volatile.Write(ref _simulationClockScale, math.max(0.0, timeScale)); + _hasMainThreadClockSync = true; + } + + /// + /// Enqueue a switch event that originated from the main thread (or any + /// non-polling thread). The event is picked up by the simulation + /// thread on the next tick. + /// + /// Thread: Any thread (protected by lock). + public bool EnqueueExternalSwitch(string switchId, bool isClosed) + { + if (string.IsNullOrEmpty(switchId)) { + return false; + } + + InputLatencyTracker.RecordSwitchInputDispatched(switchId, isClosed); + + lock (_externalSwitchQueueLock) { + if (_externalSwitchQueue.Count >= MaxExternalSwitchQueueSize) { + if (!_externalSwitchQueueDropWarningIssued) { + _externalSwitchQueueDropWarningIssued = true; + Logger.Warn($"{LogPrefix} [SimulationThread] External switch queue is full ({MaxExternalSwitchQueueSize}). Dropping switch events."); + } + return false; + } + _externalSwitchQueue.Enqueue(new PendingSwitchEvent(switchId, isClosed)); + if (!_externalSwitchQueueWarningIssued && _externalSwitchQueue.Count >= MaxExternalSwitchQueueSize / 2) { + _externalSwitchQueueWarningIssued = true; + Logger.Warn($"{LogPrefix} [SimulationThread] External switch queue backlog reached {_externalSwitchQueue.Count} items."); + } + return true; + } + } + + #endregion + + #region Simulation Thread + + private void SimulationThreadFunc() + { + // Editor playmode is a hostile environment for time-critical threads (domain/scene reload, + // asset imports, editor windows). Keep time-critical only for player builds. + // Wait for physics engine to be fully initialized + // This prevents accessing physics state before it's ready + Logger.Info($"{LogPrefix} [SimulationThread] Waiting for physics initialization..."); + int waitCount = 0; + while (_running && _physicsEngine != null && !_physicsEngine.IsInitialized && waitCount < 100) + { + Thread.Sleep(50); + waitCount++; + } + + if (waitCount >= 100) + { + Logger.Error($"{LogPrefix} [SimulationThread] Timeout waiting for physics initialization"); + _running = false; + return; + } + + Logger.Info($"{LogPrefix} [SimulationThread] Physics initialized, starting simulation"); + + // Try to enable no-GC region for hot path + bool noGcRegion = false; + try + { + // Allocate 10MB for no-GC region + if (GC.TryStartNoGCRegion(10 * 1024 * 1024, true)) + { + noGcRegion = true; + Logger.Info($"{LogPrefix} [SimulationThread] No-GC region enabled"); + } + } + catch (Exception ex) + { + Logger.Warn($"{LogPrefix} [SimulationThread] Failed to start no-GC region: {ex.Message}"); + } + + try + { + // Build input mappings once (not on hot path) + BuildInputMappingsIfNeeded(); + + // Main simulation loop + while (_running) + { + if (_paused) + { + Thread.Sleep(10); + continue; + } + + // Timing: Sleep until next tick, then busy-wait for precision. + long targetTicks = _lastTickTicks + _tickIntervalTicks; + long nowTicks = Stopwatch.GetTimestamp(); + long sleepTicks = targetTicks - nowTicks; + + if (sleepTicks > _busyWaitThresholdTicks) + { + var sleepMs = (int)(((sleepTicks - _busyWaitThresholdTicks) * 1000) / Stopwatch.Frequency); + if (sleepMs > 0) { + Thread.Sleep(sleepMs); + } else { + Thread.Yield(); + } + } + + #if !UNITY_EDITOR + // Busy-wait for precision (last 100us) + var spinner = new SpinWait(); + while (Stopwatch.GetTimestamp() < targetTicks) + { + spinner.SpinOnce(); + } + #endif + + // Execute simulation tick (hot path - must be allocation-free!) + SimulationTick(); + + _lastTickTicks = targetTicks; + _tickCount++; + } + } + finally + { + // Exit no-GC region + if (noGcRegion && GCSettings.LatencyMode == GCLatencyMode.NoGCRegion) + { + try + { + GC.EndNoGCRegion(); + Logger.Info($"{LogPrefix} [SimulationThread] No-GC region ended"); + } + catch { } + } + } + } + + /// + /// Single simulation tick - MUST BE ALLOCATION-FREE! + /// + /// Thread: Simulation thread only. + private void SimulationTick() + { + var tickStartTicks = Stopwatch.GetTimestamp(); + + if (_hasMainThreadClockSync) { + var syncedClockUsec = Interlocked.Read(ref _latestMainThreadClockUsec); + if (syncedClockUsec > _simulationTimeUsec) { + _simulationTimeUsec = syncedClockUsec; + } + } + + // 0. Process switch events that originated on Unity/main thread. + ProcessExternalSwitchEvents(); + + // 1. Process input events from ring buffer + ProcessInputEvents(); + + // 2. Apply low-latency coil outputs from gamelogic to simulation-side handlers. + ProcessGamelogicOutputs(); + + // 3. Update physics simulation + UpdatePhysics(); + + // 4. Move the emulation fence after inputs+outputs+physics. + // Throttle updates to reduce fence wake/sleep churn in PinMAME. + if (_timeFence != null && _simulationTimeUsec != _lastTimeFenceUsec) { + _lastTimeFenceIntervalUsec = _lastTimeFenceUsec == long.MinValue ? 0 : _simulationTimeUsec - _lastTimeFenceUsec; + _timeFence.SetTimeFence(_simulationTimeUsec / 1_000_000.0); + _lastTimeFenceUsec = _simulationTimeUsec; + } + + // 5. Write to shared state and swap buffers + WriteSharedState(); + + // Increment simulation time + _simulationTimeUsec += ScaledTickIntervalUsec(); + _lastSimulationTickDurationUsec = (Stopwatch.GetTimestamp() - tickStartTicks) * 1_000_000L / Stopwatch.Frequency; + } + + /// + /// Process all pending input events from the ring buffer. + /// + /// Thread: Simulation thread only. + private void ProcessInputEvents() + { + BuildInputMappingsIfNeeded(); + + while (_inputBuffer.TryDequeue(out var evt)) + { + var actionIndex = evt.Action; + if ((uint)actionIndex >= (uint)_actionStates.Length) { + continue; + } + + bool isPressed = evt.Value > 0.5f; + bool previousState = _actionStates[actionIndex]; + _actionStates[actionIndex] = isPressed; + + if (previousState == isPressed) { + continue; + } + + InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); + + // Only forward to GLE once it's ready (or at least has started) + if (_gamelogicEngine != null && _gamelogicStarted) { + SendMappedSwitch(actionIndex, isPressed); + } + _inputEventsProcessed++; + } + + // If the GLE just started, ensure it sees the current input state. + if (_gamelogicEngine != null && _gamelogicStarted && _needsInitialSwitchSync) { + SyncAllMappedSwitches(); + _needsInitialSwitchSync = false; + } + } + + private void ProcessGamelogicOutputs() + { + if (_coilOutputFeed == null || _simulationCoilDispatcher == null) { + return; + } + + var processed = 0; + while (processed < MaxCoilOutputsPerTick && _coilOutputFeed.TryDequeueCoilEvent(out var coilEvent)) { + _lastCoilDispatchUsec = GetTimestampUsec(); + _simulationCoilDispatcher(coilEvent.Id, coilEvent.IsEnabled); + processed++; + } + } + + private void ProcessExternalSwitchEvents() + { + while (true) { + PendingSwitchEvent evt; + lock (_externalSwitchQueueLock) { + if (_externalSwitchQueue.Count == 0) { + break; + } + evt = _externalSwitchQueue.Dequeue(); + } + + if (_gamelogicEngine != null && _gamelogicStarted) { + _inputDispatcher.DispatchSwitch(evt.SwitchId, evt.IsClosed); + } + } + } + + private void SendMappedSwitch(int actionIndex, bool isPressed) + { + if ((uint)actionIndex >= (uint)_actionToSwitchId.Length) { + return; + } + var switchId = _actionToSwitchId[actionIndex]; + if (switchId == null) { + return; + } + + _lastSwitchDispatchUsec = GetTimestampUsec(); + if (isPressed && IsFlipperAction(actionIndex)) { + _lastFlipperInputUsec = _lastSwitchDispatchUsec; + } + if (actionIndex == (int)NativeInputApi.InputAction.Start && Logger.IsInfoEnabled) { + Logger.Info($"{LogPrefix} [SimulationThread] Input Start -> Switch({switchId}, {isPressed})"); + } + if (Logger.IsInfoEnabled && isPressed) { + if (actionIndex == (int)NativeInputApi.InputAction.LeftFlipper) { + Logger.Info($"{LogPrefix} [SimulationThread] Input LeftFlipper -> Switch({switchId}, True)"); + } + else if (actionIndex == (int)NativeInputApi.InputAction.RightFlipper) { + Logger.Info($"{LogPrefix} [SimulationThread] Input RightFlipper -> Switch({switchId}, True)"); + } + } + _inputDispatcher.DispatchSwitch(switchId, isPressed); + } + + private static bool IsFlipperAction(int actionIndex) + { + return actionIndex == (int)NativeInputApi.InputAction.LeftFlipper + || actionIndex == (int)NativeInputApi.InputAction.RightFlipper + || actionIndex == (int)NativeInputApi.InputAction.UpperLeftFlipper + || actionIndex == (int)NativeInputApi.InputAction.UpperRightFlipper; + } + + private void SyncAllMappedSwitches() + { + for (var i = 0; i < _actionToSwitchId.Length; i++) + { + var switchId = _actionToSwitchId[i]; + if (switchId == null) { + continue; + } + _inputDispatcher.DispatchSwitch(switchId, _actionStates[i]); + } + } + + private void BuildInputMappingsIfNeeded() + { + if (_inputMappingsBuilt) { + return; + } + BuildInputMappings(); + _inputMappingsBuilt = true; + _needsInitialSwitchSync = true; + } + + private void BuildInputMappings() + { + Array.Clear(_actionToSwitchId, 0, _actionToSwitchId.Length); + + if (_gamelogicEngine == null) { + return; + } + + var requestedSwitches = _gamelogicEngine.RequestedSwitches; + for (var i = 0; i < requestedSwitches.Length; i++) + { + var sw = requestedSwitches[i]; + if (sw == null || string.IsNullOrEmpty(sw.InputActionHint)) { + continue; + } + + if (!TryMapInputActionHint(sw.InputActionHint, out var action)) { + continue; + } + + var actionIndex = (int)action; + if ((uint)actionIndex >= (uint)_actionToSwitchId.Length) { + continue; + } + + // Prefer the first mapping we see. + _actionToSwitchId[actionIndex] ??= sw.Id; + } + + if (Logger.IsDebugEnabled) + { + Logger.Debug($"{LogPrefix} [SimulationThread] Built input action -> switch mappings"); + } + + if (Logger.IsInfoEnabled) { + LogMapping(NativeInputApi.InputAction.Start, "Start"); + LogMapping(NativeInputApi.InputAction.CoinInsert1, "CoinInsert1"); + LogMapping(NativeInputApi.InputAction.LeftFlipper, "LeftFlipper"); + LogMapping(NativeInputApi.InputAction.RightFlipper, "RightFlipper"); + } + } + + private void LogMapping(NativeInputApi.InputAction action, string name) + { + var idx = (int)action; + var mapped = (uint)idx < (uint)_actionToSwitchId.Length ? _actionToSwitchId[idx] : null; + Logger.Info($"{LogPrefix} [SimulationThread] Mapping: {name}={mapped}"); + } + + private static bool TryMapInputActionHint(string inputActionHint, out NativeInputApi.InputAction action) + { + // Keep this allocation-free and fast: match against known InputConstants strings. + if (inputActionHint == InputConstants.ActionLeftFlipper) { + action = NativeInputApi.InputAction.LeftFlipper; + return true; + } + if (inputActionHint == InputConstants.ActionRightFlipper) { + action = NativeInputApi.InputAction.RightFlipper; + return true; + } + if (inputActionHint == InputConstants.ActionUpperLeftFlipper) { + action = NativeInputApi.InputAction.UpperLeftFlipper; + return true; + } + if (inputActionHint == InputConstants.ActionUpperRightFlipper) { + action = NativeInputApi.InputAction.UpperRightFlipper; + return true; + } + if (inputActionHint == InputConstants.ActionLeftMagnasave) { + action = NativeInputApi.InputAction.LeftMagnasave; + return true; + } + if (inputActionHint == InputConstants.ActionRightMagnasave) { + action = NativeInputApi.InputAction.RightMagnasave; + return true; + } + if (inputActionHint == InputConstants.ActionStartGame) { + action = NativeInputApi.InputAction.Start; + return true; + } + if (inputActionHint == InputConstants.ActionPlunger) { + action = NativeInputApi.InputAction.Plunge; + return true; + } + if (inputActionHint == InputConstants.ActionPlungerAnalog) { + action = NativeInputApi.InputAction.PlungerAnalog; + return true; + } + if (inputActionHint == InputConstants.ActionInsertCoin1) { + action = NativeInputApi.InputAction.CoinInsert1; + return true; + } + if (inputActionHint == InputConstants.ActionInsertCoin2) { + action = NativeInputApi.InputAction.CoinInsert2; + return true; + } + if (inputActionHint == InputConstants.ActionInsertCoin3) { + action = NativeInputApi.InputAction.CoinInsert3; + return true; + } + if (inputActionHint == InputConstants.ActionInsertCoin4) { + action = NativeInputApi.InputAction.CoinInsert4; + return true; + } + if (inputActionHint == InputConstants.ActionSlamTilt) { + action = NativeInputApi.InputAction.SlamTilt; + return true; + } + + action = default; + return false; + } + + /// + /// Update physics simulation (1ms step). + /// + /// Thread: Simulation thread only. + private void UpdatePhysics() + { + if (_physicsEngine != null) + { + // Execute physics tick directly on simulation thread + // This works now because we changed Allocator.Temp to Allocator.TempJob + // in the physics hot path, allowing custom threads to execute physics. + _physicsEngine.ExecuteTick((ulong)_simulationTimeUsec); + } + } + + /// + /// Write simulation state to shared memory and publish the buffer. + /// Called on the sim thread after physics has executed, so reading + /// physics state maps is safe (single writer, sequential). + /// + private void WriteSharedState() + { + ref var writeBuffer = ref _sharedState.GetWriteBuffer(); + + // Update timing + writeBuffer.SimulationTimeUsec = _simulationTimeUsec; + writeBuffer.RealTimeUsec = GetTimestampUsec(); + writeBuffer.SimulationTickDurationUsec = _lastSimulationTickDurationUsec; + writeBuffer.FenceUpdateIntervalUsec = _lastTimeFenceIntervalUsec; + writeBuffer.LastSwitchDispatchUsec = _lastSwitchDispatchUsec; + writeBuffer.LastFlipperInputUsec = _lastFlipperInputUsec; + writeBuffer.LastCoilDispatchUsec = _lastCoilDispatchUsec; + lock (_externalSwitchQueueLock) { + writeBuffer.ExternalSwitchQueueDepth = _externalSwitchQueue.Count; + } + _physicsEngine.FillDiagnostics(ref writeBuffer); + if (_gamelogicPerformanceStats != null && _gamelogicPerformanceStats.TryGetPerformanceStats(out var performanceStats)) { + writeBuffer.GamelogicCallbackRateHz = performanceStats.CallbackRateHz; + } + if (_gamelogicLatencyStats != null && _gamelogicLatencyStats.TryGetLatencyStats(out var latencyStats)) { + writeBuffer.LastSwitchObservationUsec = latencyStats.LastSwitchObservationUsec; + writeBuffer.LastCoilOutputUsec = latencyStats.LastCoilOutputUsec; + } + + // Copy PinMAME state (coils, lamps, GI) + writeBuffer.CoilCount = 0; + writeBuffer.LampCount = 0; + writeBuffer.GICount = 0; + _sharedStateWriter?.WriteSharedState(ref writeBuffer); + + // Increment physics state version (main thread will detect changes) + writeBuffer.PhysicsStateVersion++; + + // Snapshot animation data from physics state maps into the buffer. + // Acquire the physics lock for the snapshot copy so teardown cannot + // dispose the native maps between ExecuteTick() and publication. + var snapshotStartUsec = GetTimestampUsec(); + if (!_physicsEngine.TrySnapshotAnimations(ref writeBuffer)) { + return; + } + _lastSnapshotCopyUsec = GetTimestampUsec() - snapshotStartUsec; + writeBuffer.SnapshotCopyUsec = _lastSnapshotCopyUsec; + writeBuffer.PublishRealTimeUsec = GetTimestampUsec(); + + // Atomically publish this buffer (lock-free triple-buffer swap) + _sharedState.PublishWriteBuffer(); + } + + private static long GetTimestampUsec() + { + long ticks = Stopwatch.GetTimestamp(); + return (ticks * 1_000_000) / Stopwatch.Frequency; + } + + private long ScaledTickIntervalUsec() + { + var scaledTickUsec = (long)math.round(TickIntervalUsec * Volatile.Read(ref _simulationClockScale)); + return scaledTickUsec < 0 ? 0 : scaledTickUsec; + } + + #endregion + + private void OnGamelogicStarted(object sender, EventArgs e) + { + _gamelogicStarted = true; + _inputMappingsBuilt = false; // switches can be populated after init + _needsInitialSwitchSync = true; + } + + #region Dispose + + public void Dispose() + { + Stop(); + if (_gamelogicEngine != null) { + _gamelogicEngine.OnStarted -= OnGamelogicStarted; + } + _inputDispatcher?.Dispose(); + lock (_externalSwitchQueueLock) { + _externalSwitchQueue.Clear(); + } + _inputBuffer?.Dispose(); + _sharedState?.Dispose(); + } + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta new file mode 100644 index 000000000..db138d460 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4dafa5d92cb47604eb814669e917b4cf \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs new file mode 100644 index 000000000..b537e58b3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -0,0 +1,338 @@ +// 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.Diagnostics; +using NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// Unity component that manages the high-performance simulation thread. + /// Add this to your table GameObject to enable sub-millisecond input latency. + /// + /// Architecture: + /// - Simulation thread runs at 1000 Hz (1ms per tick) + /// - Input polling thread runs at 500-1000 Hz + /// - Unity main thread runs at display refresh rate (60-144 Hz) + /// - Lock-free communication between threads using ring buffers and triple-buffering + /// + [AddComponentMenu("Visual Pinball/Simulation Thread")] + [RequireComponent(typeof(PhysicsEngine))] + public class SimulationThreadComponent : MonoBehaviour + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const string LogPrefix = "[PinMAME-debug]"; + + #region Inspector Fields + + [Header("Simulation Settings")] + [Tooltip("Enable the high-performance simulation thread (1000 Hz)")] + public bool EnableSimulationThread = true; + + [Tooltip("Enable native input polling (Windows only, requires VpeNativeInput.dll)")] + public bool EnableNativeInput = true; + + [Tooltip("Input polling interval in microseconds (default 500μs = 2000 Hz)")] + [Range(100, 2000)] + public int InputPollingIntervalUs = 500; + + [Header("Debug")] + [Tooltip("Show simulation statistics in console")] + public bool ShowStatistics = false; + + [Tooltip("Statistics update interval in seconds")] + [Range(1f, 10f)] + public float StatisticsInterval = 5f; + + #endregion + + #region Fields + + private PhysicsEngine _physicsEngine; + private IGamelogicEngine _gamelogicEngine; + private SimulationThread _simulationThread; + private NativeInputManager _inputManager; + + private bool _started = false; + private float _lastStatisticsTime; + private float _simulationThreadSpeedX; + private float _simulationThreadHz; + private long _lastSampleSimulationUsec; + private float _lastSampleUnscaledTime; + + public float SimulationThreadSpeedX => _simulationThreadSpeedX; + public float SimulationThreadHz => _simulationThreadHz; + public float InputThreadTargetHz => _inputManager?.TargetPollingHz ?? 0f; + public float InputThreadActualHz => _inputManager?.ActualEventRateHz ?? 0f; + + #endregion + + #region Unity Lifecycle + + private void Awake() + { + _physicsEngine = GetComponent(); + // Note: Player.GamelogicEngine is assigned in Player.Awake(). + // Script execution order can cause this Awake() to run first, so we resolve + // the engine again in StartSimulation(). + } + + private void Start() + { + if (!EnableSimulationThread) + { + Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation thread disabled"); + return; + } + + StartSimulation(); + } + + private void Update() + { + if (!_started || _simulationThread == null) return; + + _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); + + // Engines that are not thread-safe for switch updates receive queued + // events here on Unity's main thread. + _simulationThread.FlushMainThreadInputDispatch(); + + // Read shared state from simulation thread + ref readonly var state = ref _simulationThread.GetSharedState(); + + // Apply state to Unity GameObjects + ApplySimulationState(in state); + + UpdateSimulationSpeed(in state); + + // Show statistics + if (ShowStatistics && Time.time - _lastStatisticsTime >= StatisticsInterval) + { + LogStatistics(in state); + _lastStatisticsTime = Time.time; + } + } + + private void OnDestroy() + { + StopSimulation(); + } + + private void OnDisable() + { + StopSimulation(); + } + + private void OnApplicationQuit() + { + StopSimulation(); + } + + #endregion + + #region Public API + + /// + /// Start the simulation thread + /// + public void StartSimulation() + { + if (_started) return; + + try + { + var player = GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren(); + + // Resolve dependencies (safe even if Awake order differs) + _physicsEngine ??= GetComponent(); + if (_gamelogicEngine == null) { + _gamelogicEngine = player != null + ? player.GamelogicEngine + : (GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren()); + } + + if (_gamelogicEngine == null) { + Logger.Warn($"{LogPrefix} [SimulationThreadComponent] No IGamelogicEngine found (input will not reach PinMAME)"); + } + + // Enable external timing on PhysicsEngine + // This disables Unity's Update() loop and gives control to the simulation thread + _physicsEngine.SetExternalTiming(true); + + // Create simulation thread + _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, + player != null + ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) + : null); + _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); + + // Provide the triple-buffered SimulationState to PhysicsEngine so + // that ApplyMovements() can read lock-free snapshots. + _physicsEngine.SetSimulationState(_simulationThread.SharedState); + + // Initialize and start native input if enabled + if (EnableNativeInput) + { + _inputManager = NativeInputManager.Instance; + if (_inputManager.Initialize()) + { + _inputManager.SetSimulationThread(_simulationThread); + _inputManager.StartPolling(InputPollingIntervalUs); + } + else + { + Logger.Warn($"{LogPrefix} [SimulationThreadComponent] Native input not available, falling back to Unity Input System"); + } + } + + // Start simulation thread + _simulationThread.Start(); + + _started = true; + _lastStatisticsTime = Time.time; + + Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation started with external physics timing"); + } + catch (Exception ex) + { + Logger.Error($"{LogPrefix} [SimulationThreadComponent] Failed to start simulation: {ex}"); + } + } + + /// + /// Stop the simulation thread + /// + public void StopSimulation() + { + if (!_started) return; + + _inputManager?.StopPolling(); + _simulationThread?.Stop(); + _simulationThread?.Dispose(); + _simulationThread = null; + + // Restore normal Unity Update() loop timing + _physicsEngine.SetExternalTiming(false); + _physicsEngine.SetSimulationState(null); + + _started = false; + _simulationThreadSpeedX = 0f; + _simulationThreadHz = 0f; + _lastSampleSimulationUsec = 0; + _lastSampleUnscaledTime = 0f; + + Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation stopped"); + } + + /// + /// Pause the simulation (for debugging) + /// + public void PauseSimulation() + { + _simulationThread?.Pause(); + } + + /// + /// Resume the simulation + /// + public void ResumeSimulation() + { + _simulationThread?.Resume(); + } + + internal bool EnqueueSwitchFromMainThread(string switchId, bool isClosed) + { + if (!_started || _simulationThread == null) { + return false; + } + + return _simulationThread.EnqueueExternalSwitch(switchId, isClosed); + } + + #endregion + + #region Private Methods + + /// + /// Apply simulation state to Unity GameObjects + /// + private void ApplySimulationState(in SimulationState.Snapshot state) + { + // Animation data (ball positions, flipper angles, etc.) is now + // applied lock-free by PhysicsEngine.ApplyMovements() via the + // triple-buffered snapshot. This method handles any remaining + // state that isn't covered by the snapshot (PinMAME coils, lamps, + // GI). + if (_gamelogicEngine is IGamelogicSharedStateApplier sharedStateApplier) { + sharedStateApplier.ApplySharedState(in state); + } + } + + /// + /// Log statistics about simulation performance + /// + private void LogStatistics(in SimulationState.Snapshot state) + { + long simTimeMs = state.SimulationTimeUsec / 1000; + long realTimeMs = state.RealTimeUsec / 1000; + double ratio = (double)simTimeMs / realTimeMs; + var snapshotAgeUsec = state.PublishRealTimeUsec > 0 ? TimestampUsec - state.PublishRealTimeUsec : 0; + var switchToObservationUsec = state.LastSwitchDispatchUsec > 0 && state.LastSwitchObservationUsec >= state.LastSwitchDispatchUsec + ? state.LastSwitchObservationUsec - state.LastSwitchDispatchUsec + : -1; + var flipperToCoilOutputUsec = state.LastFlipperInputUsec > 0 && state.LastCoilOutputUsec >= state.LastFlipperInputUsec + ? state.LastCoilOutputUsec - state.LastFlipperInputUsec + : -1; + var coilDispatchToPublishUsec = state.LastCoilDispatchUsec > 0 && state.PublishRealTimeUsec >= state.LastCoilDispatchUsec + ? state.PublishRealTimeUsec - state.LastCoilDispatchUsec + : -1; + + Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}, Tick={state.SimulationTickDurationUsec}us, Snapshot={state.SnapshotCopyUsec}us, Kinematic={state.KinematicScanUsec}us, EventDrain={state.EventDrainUsec}us, InputQ={state.PendingInputActionCount}, ScheduledQ={state.PendingScheduledActionCount}, SwitchQ={state.ExternalSwitchQueueDepth}, GLE={state.GamelogicCallbackRateHz:F1}Hz, Fence={state.FenceUpdateIntervalUsec}us, SnapshotAge={snapshotAgeUsec}us, Switch->PinMAME={switchToObservationUsec}us, Flipper->Coil={flipperToCoilOutputUsec}us, Coil->Publish={coilDispatchToPublishUsec}us, Balls={state.BallCount}/{state.BallSourceCount}, Floats={state.FloatAnimationCount}/{state.FloatAnimationSourceCount}, Float2={state.Float2AnimationCount}/{state.Float2AnimationSourceCount}"); + + if (state.BallSnapshotsTruncated != 0 || state.FloatAnimationsTruncated != 0 || state.Float2AnimationsTruncated != 0) { + Logger.Warn($"{LogPrefix} [SimulationThread] Snapshot truncation detected: Balls={state.BallSnapshotsTruncated != 0}, Floats={state.FloatAnimationsTruncated != 0}, Float2={state.Float2AnimationsTruncated != 0}"); + } + } + + private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000L) / Stopwatch.Frequency; + + private void UpdateSimulationSpeed(in SimulationState.Snapshot state) + { + var now = Time.unscaledTime; + + if (_lastSampleUnscaledTime <= 0f) { + _lastSampleUnscaledTime = now; + _lastSampleSimulationUsec = state.SimulationTimeUsec; + _simulationThreadSpeedX = 0f; + _simulationThreadHz = 0f; + return; + } + + var deltaTime = now - _lastSampleUnscaledTime; + if (deltaTime < 0.05f) { + return; + } + + var deltaSimulationUsec = state.SimulationTimeUsec - _lastSampleSimulationUsec; + if (deltaSimulationUsec < 0) { + deltaSimulationUsec = 0; + } + + var instantSpeedX = deltaTime > 0f ? (float)deltaSimulationUsec / (deltaTime * 1_000_000f) : 0f; + _simulationThreadSpeedX = Mathf.Lerp(_simulationThreadSpeedX, instantSpeedX, 0.3f); + _simulationThreadHz = _simulationThreadSpeedX * 1000f; + + _lastSampleUnscaledTime = now; + _lastSampleSimulationUsec = state.SimulationTimeUsec; + } + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta new file mode 100644 index 000000000..427138b97 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8619aa4eadc3aed469744e0a0db4bf30 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs index 9b56e7da8..e0a40ba13 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs @@ -133,9 +133,13 @@ private void OnDestroy() UnityEditor.SceneView.duringSceneGui -= DrawPhysicsDebug; } - private void DrawPhysicsDebug(UnityEditor.SceneView sceneView) - { - ref var ballState = ref _physicsEngine.BallState(Id); + private void DrawPhysicsDebug(UnityEditor.SceneView sceneView) + { + if (!_physicsEngine.BallExists(Id)) { + return; + } + + ref var ballState = ref _physicsEngine.BallState(Id); // velocity DrawArrow( diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallDisplacementPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallDisplacementPhysics.cs index bf50db4f2..ac5e56e15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallDisplacementPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallDisplacementPhysics.cs @@ -14,11 +14,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using Unity.Mathematics; -using UnityEngine; - -namespace VisualPinball.Unity -{ +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ internal static class BallDisplacementPhysics { internal static void UpdateDisplacements(ref BallState ball, float dTime) @@ -50,26 +49,25 @@ internal static void UpdateDisplacements(ref BallState ball, float dTime) VPOrthonormalize(ref ball.BallOrientationForUnity); } - private static void VPOrthonormalize(ref float3x3 orientation) - { - Vector3 vX = new Vector3(orientation.c0.x, orientation.c1.x, orientation.c2.x); - Vector3 vY = new Vector3(orientation.c0.y, orientation.c1.y, orientation.c2.y); - Vector3 vZ = Vector3.Cross(vX, vY); - vX = Vector3.Normalize(vX); - vZ = Vector3.Normalize(vZ); - vY = Vector3.Cross(vZ, vX); - - orientation.c0.x = vX.x; - orientation.c0.y = vY.x; - orientation.c0.z = vZ.x; - orientation.c1.x = vX.y; - orientation.c1.y = vY.y; - orientation.c1.z = vZ.y; - orientation.c2.x = vX.z; - orientation.c2.y = vY.z; - orientation.c2.z = vZ.z; - - } + private static void VPOrthonormalize(ref float3x3 orientation) + { + var vX = new float3(orientation.c0.x, orientation.c1.x, orientation.c2.x); + var vY = new float3(orientation.c0.y, orientation.c1.y, orientation.c2.y); + var vZ = math.cross(vX, vY); + vX = math.normalize(vX); + vZ = math.normalize(vZ); + vY = math.cross(vZ, vX); + + orientation.c0.x = vX.x; + orientation.c0.y = vY.x; + orientation.c0.z = vZ.x; + orientation.c1.x = vX.y; + orientation.c1.y = vY.y; + orientation.c1.z = vZ.y; + orientation.c2.x = vX.z; + orientation.c2.y = vY.z; + orientation.c2.z = vZ.z; + } private static float3x3 CreateSkewSymmetric(in float3 pv3D) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs index 6ab5688c0..9bda61f68 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs @@ -50,27 +50,29 @@ public int CreateBall(IBallCreationPosition ballCreator, float radius = 25f, flo if (!ballPrefab) { ballPrefab = RenderPipeline.Current.BallConverter.CreateDefaultBall(); } + var ballGo = Object.Instantiate(ballPrefab, _parent); var ballComp = ballGo.GetComponent(); + ballGo.name = $"Ball {NumBallsCreated++}"; ballGo.transform.localScale = Physics.ScaleToWorld(new Vector3(radius, radius, radius) * 2f); ballGo.transform.localPosition = localPos.TranslateToWorld(); ballComp.Radius = radius; ballComp.Mass = mass; ballComp.Velocity = ballCreator.GetBallCreationVelocity().ToUnityFloat3(); - ballComp.IsFrozen = false; - - // register ball - _physicsEngine.Register(ballComp); - _player.BallCreated(ballGo.GetInstanceID(), ballGo); + ballComp.IsFrozen = false; + + // register ball + _physicsEngine.RegisterRuntimeBall(ballComp); + _player.BallCreated(ballGo.GetInstanceID(), ballGo); return ballComp.Id; } - public void DestroyBall(int ballId) - { - var ballComponent = _physicsEngine.UnregisterBall(ballId); - _player.BallDestroyed(ballId, ballComponent.gameObject); + public void DestroyBall(int ballId) + { + var ballComponent = _physicsEngine.UnregisterRuntimeBall(ballId); + _player.BallDestroyed(ballId, ballComponent.gameObject); // destroy game object Object.DestroyImmediate(ballComponent.gameObject); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs index 287f9e134..1d3612765 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs @@ -37,7 +37,7 @@ public static void UpdateVelocities(ref BallState ball, float3 gravity) -2.0f ); } else { - ball.Velocity += (float)PhysicsConstants.PhysFactor * gravity; + ball.Velocity += PhysicsConstants.PhysFactor * gravity; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs index cb1e1d749..843272072 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs @@ -16,9 +16,10 @@ using System; using System.Linq; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT.Bumper; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -67,43 +68,45 @@ public BumperApi(GameObject go, Player player, PhysicsEngine physicsEngine) : ba void IApiSwitch.AddWireDest(WireDestConfig wireConfig) => AddWireDest(wireConfig); void IApiSwitch.RemoveWireDest(string destId) => RemoveWireDest(destId); - void IApiCoil.OnCoil(bool enabled) - { - if (enabled) { - ref var bumperState = ref PhysicsEngine.BumperState(ItemId); - bumperState.RingAnimation.IsHit = true; - - ref var insideOfs = ref PhysicsEngine.InsideOfs; - var idsOfBallsInColl = insideOfs.GetIdsOfBallsInsideItem(ItemId); - var state = PhysicsEngine.CreateState(); - foreach (var ballId in idsOfBallsInColl) { - if (!PhysicsEngine.Balls.ContainsKey(ballId)) { - continue; - } - ref var ballState = ref PhysicsEngine.BallState(ballId); - float3 bumperPos = MainComponent.Position; - float3 ballPos = ballState.Position; - var bumpDirection = ballPos - bumperPos; - bumpDirection.z = 0f; - bumpDirection = math.normalize(bumpDirection); - var collEvent = new CollisionEventData { - HitTime = 0f, - HitNormal = bumpDirection, - HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * ColliderComponent.Force, - HitDistance = 0f, - HitFlag = false, - HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), - IsContact = true, - ColliderId = _switchColliderId, - IsKinematic = false, - BallId = ballId - }; - var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); - BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); - } - } - - CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); + void IApiCoil.OnCoil(bool enabled) + { + if (enabled) { + var bumperPos = (float3)MainComponent.Position; + var force = ColliderComponent.Force; + var switchColliderId = _switchColliderId; + var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.RingAnimation.IsHit = true; + + var idsOfBallsInColl = state.InsideOfs.GetIdsOfBallsInsideItem(ItemId); + foreach (var ballId in idsOfBallsInColl) { + if (!state.Balls.ContainsKey(ballId)) { + continue; + } + + ref var ballState = ref state.Balls.GetValueByRef(ballId); + var bumpDirection = ballState.Position - bumperPos; + bumpDirection.z = 0f; + bumpDirection = math.normalize(bumpDirection); + var collEvent = new CollisionEventData { + HitTime = 0f, + HitNormal = bumpDirection, + HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * force, + HitDistance = 0f, + HitFlag = false, + HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), + IsContact = true, + ColliderId = switchColliderId, + IsKinematic = false, + BallId = ballId + }; + BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); + } + }); + } + + CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); } void IApiWireDest.OnChange(bool enabled) => (this as IApiCoil).OnCoil(enabled); @@ -120,13 +123,16 @@ internal override void RemoveWireDest(string destId) UpdateBumperWireState(); } - private void UpdateBumperWireState() - { - string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; - BumperComponent bumperComponent = MainComponent; - ref var bumperState = ref PhysicsEngine.BumperState(ItemId); - bumperState.IsSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); - } + private void UpdateBumperWireState() + { + string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; + BumperComponent bumperComponent = MainComponent; + var isSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.IsSwitchWiredToCoil = isSwitchWiredToCoil; + }); + } #endregion @@ -168,16 +174,19 @@ void IApiHittable.OnHit(int ballId, bool isUnHit) Switch?.Invoke(this, new SwitchEventArgs(false, ballId)); OnSwitch(false); } - } else { - Hit?.Invoke(this, new HitEventArgs(ballId)); - ref var bumperState = ref PhysicsEngine.BumperState(ItemId); - bumperState.SkirtAnimation.HitEvent = true; - bumperState.RingAnimation.IsHit = true; - ref var ballState = ref PhysicsEngine.BallState(ballId); - bumperState.SkirtAnimation.BallPosition = ballState.Position; - Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); - OnSwitch(true); - } + } else { + Hit?.Invoke(this, new HitEventArgs(ballId)); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.SkirtAnimation.HitEvent = true; + bumperState.RingAnimation.IsHit = true; + if (state.Balls.ContainsKey(ballId)) { + bumperState.SkirtAnimation.BallPosition = state.Balls.GetValueByRef(ballId).Position; + } + }); + Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); + OnSwitch(true); + } } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs index b6fe2d4c8..889b99711 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs @@ -340,11 +340,7 @@ private void InstantiateEditorColliders(bool showColliders, ref NativeParallelHa var playfieldBounds = GetComponentInParent().Bounds; _octree = new NativeOctree(playfieldBounds, 32, 10, Allocator.Persistent); var nativeColliders = new NativeColliders(ref colliders, Allocator.TempJob); - var populateJob = new PhysicsPopulateJob { - Colliders = nativeColliders, - Octree = _octree, - }; - populateJob.Run(); + PhysicsPopulate.Populate(ref nativeColliders, ref _octree); } } finally { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs index 3330f9f98..ff5389037 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs @@ -17,9 +17,10 @@ // ReSharper disable EventNeverSubscribedTo.Global using System; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT.Flipper; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT.Flipper; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -68,11 +69,11 @@ public class FlipperApi : CollidableApi - public void RotateToEnd() - { - ref var state = ref PhysicsEngine.FlipperState(ItemId); - state.Movement.EnableRotateEvent = 1; - state.Movement.StartRotateToEndTime = PhysicsEngine.TimeMsec; - state.Movement.AngleAtRotateToEnd = state.Movement.Angle; - state.Solenoid.Value = true; - } + public void RotateToEnd() + { + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var flipperState = ref state.FlipperStates.GetValueByRef(ItemId); + flipperState.Movement.EnableRotateEvent = 1; + flipperState.Movement.StartRotateToEndTime = state.Env.TimeMsec; + flipperState.Movement.AngleAtRotateToEnd = flipperState.Movement.Angle; + flipperState.Solenoid.Value = true; + }); + } /// /// Disables the flipper's solenoid, making the flipper rotate back to /// its resting position. /// - public void RotateToStart() - { - ref var state = ref PhysicsEngine.FlipperState(ItemId); - state.Movement.EnableRotateEvent = -1; - state.Solenoid.Value = false; - } + public void RotateToStart() + { + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var flipperState = ref state.FlipperStates.GetValueByRef(ItemId); + flipperState.Movement.EnableRotateEvent = -1; + flipperState.Solenoid.Value = false; + }); + } internal ref FlipperState State => ref PhysicsEngine.FlipperState(ItemId); - internal float StartAngle - { - set { - ref var flipperState = ref PhysicsEngine.FlipperState(ItemId); - flipperState.Static.AngleStart = value; - } - } + internal float StartAngle + { + set { + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var flipperState = ref state.FlipperStates.GetValueByRef(ItemId); + flipperState.Static.AngleStart = value; + }); + } + } #region Coil Handling @@ -134,10 +141,56 @@ private IApiCoil Coil(string deviceItem) { }; } - private void OnMainCoilEnabled() => OnCoil(true, false); - private void OnMainCoilDisabled() => OnCoil(false, false); - private void OnHoldCoilEnabled() => OnCoil(true, true); - private void OnHoldCoilDisabled() => OnCoil(false, true); + private void OnMainCoilEnabled() => OnCoil(true, false); + private void OnMainCoilDisabled() => OnCoil(false, false); + private void OnHoldCoilEnabled() => OnCoil(true, true); + private void OnHoldCoilDisabled() => OnCoil(false, true); + + // Simulation-thread coil callbacks: only set the solenoid flag and + // enable-rotate-event, matching vpinball's SetSolenoidState pattern. + // The flipper correction timestamps (StartRotateToEndTime, AngleAtRotateToEnd) + // are NOT set here because they depend on PhysicsEngine.TimeMsec which is + // only meaningful inside a physics tick. The physics loop will read + // Solenoid.Value in UpdateVelocities and handle movement from there. + private void OnMainCoilEnabledSimThread() => OnCoilSimThread(true, false); + private void OnMainCoilDisabledSimThread() => OnCoilSimThread(false, false); + private void OnHoldCoilEnabledSimThread() => OnCoilSimThread(true, true); + private void OnHoldCoilDisabledSimThread() => OnCoilSimThread(false, true); + + private void OnCoilSimThread(bool enabled, bool isHoldCoil) + { + if (MainComponent.IsDualWound) { + OnDualWoundCoilSimThread(enabled, isHoldCoil); + } else { + OnSingleWoundCoilSimThread(enabled); + } + } + + private void OnSingleWoundCoilSimThread(bool enabled) + { + ref var state = ref PhysicsEngine.FlipperState(ItemId); + state.Movement.EnableRotateEvent = enabled ? (sbyte)1 : (sbyte)-1; + state.Solenoid.Value = enabled; + } + + private void OnDualWoundCoilSimThread(bool enabled, bool isHoldCoil) + { + ref var state = ref PhysicsEngine.FlipperState(ItemId); + if (enabled) { + if (!isHoldCoil) { + state.Movement.EnableRotateEvent = 1; + state.Solenoid.Value = true; + } + } else { + if (!isHoldCoil) { + state.Movement.EnableRotateEvent = -1; + state.Solenoid.Value = false; + } + // Note: dual-wound hold coil release with EOS handling requires + // switch events which are main-thread only. The sim-thread path + // handles the common single-wound and main-coil cases. + } + } private void OnCoil(bool enabled, bool isHoldCoil) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs index 8a903d104..8b2280871 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs @@ -471,14 +471,15 @@ private void Start() _flipperApi = GetComponentInParent().TableApi.Flipper(this); } - public void UpdateAnimationValue(float value) - { - if (HasAngleChanged(_lastRotationAngle, value)) { - _lastRotationAngle = value; - transform.SetLocalYRotation(value); - OnAnimationValueChanged?.Invoke(value); - } - } + public void UpdateAnimationValue(float value) + { + if (HasAngleChanged(_lastRotationAngle, value)) { + _lastRotationAngle = value; + transform.SetLocalYRotation(value); + InputLatencyTracker.RecordFlipperVisualMovement(IsLeft); + OnAnimationValueChanged?.Invoke(value); + } + } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs index 08cb16ab7..97e94b7b3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs @@ -90,12 +90,12 @@ internal static void UpdateVelocities(ref FlipperState state) // update current torque linearly towards desired torque // (simple model for coil hysteresis) - if (desiredTorque >= vState.CurrentTorque) { - vState.CurrentTorque = math.min(vState.CurrentTorque + torqueRampUpSpeed * (float)PhysicsConstants.PhysFactor, desiredTorque); - - } else { - vState.CurrentTorque = math.max(vState.CurrentTorque - torqueRampUpSpeed * (float)PhysicsConstants.PhysFactor, desiredTorque); - } + if (desiredTorque >= vState.CurrentTorque) { + vState.CurrentTorque = math.min(vState.CurrentTorque + torqueRampUpSpeed * PhysicsConstants.PhysFactor, desiredTorque); + + } else { + vState.CurrentTorque = math.max(vState.CurrentTorque - torqueRampUpSpeed * PhysicsConstants.PhysFactor, desiredTorque); + } // resolve contacts with stoppers var torque = vState.CurrentTorque; @@ -118,7 +118,7 @@ internal static void UpdateVelocities(ref FlipperState state) } } - mState.AngularMomentum += (float)PhysicsConstants.PhysFactor * torque; + mState.AngularMomentum += PhysicsConstants.PhysFactor * torque; mState.AngleSpeed = mState.AngularMomentum / data.Inertia; vState.AngularAcceleration = torque / data.Inertia; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateApi.cs index c9a6c6546..464eccce2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateApi.cs @@ -15,9 +15,10 @@ // along with this program. If not, see . using System; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT.Gate; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT.Gate; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -81,13 +82,16 @@ public GateApi(GameObject go, Player player, PhysicsEngine physicsEngine) : base { } - public void Lift(float speed, float angleDeg) - { - ref var gateState = ref PhysicsEngine.GateState(ItemId); - gateState.Movement.IsLifting = true; - gateState.Movement.LiftSpeed = speed; - gateState.Movement.LiftAngle = math.radians(angleDeg); - } + public void Lift(float speed, float angleDeg) + { + var liftAngle = math.radians(angleDeg); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var gateState = ref state.GateStates.GetValueByRef(ItemId); + gateState.Movement.IsLifting = true; + gateState.Movement.LiftSpeed = speed; + gateState.Movement.LiftAngle = liftAngle; + }); + } #region Wiring diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs index abc1189f9..d3b2913fe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs @@ -262,13 +262,11 @@ internal GateState CreateState() AngleMin = math.radians(collComponent._angleMin), AngleMax = math.radians(collComponent._angleMax), Height = Position.z, - Damping = math.pow(math.clamp(collComponent.Damping, 0, 1), (float)PhysicsConstants.PhysFactor), + Damping = math.pow(math.clamp(collComponent.Damping, 0, 1), PhysicsConstants.PhysFactor), GravityFactor = collComponent.GravityFactor, TwoWay = collComponent.TwoWay, } : default; - Debug.Log($"Damping = {staticData.Damping}"); - - var wireComponent = GetComponentInChildren(); + var wireComponent = GetComponentInChildren(); var movementData = collComponent && wireComponent ? new GateMovementState { Angle = math.radians(collComponent._angleMin), diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateLifterApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateLifterApi.cs index 6823c0573..bc26138c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateLifterApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateLifterApi.cs @@ -29,22 +29,24 @@ public class GateLifterApi : IApi, IApiCoilDevice, IApiWireDeviceDest private readonly Player _player; private readonly PhysicsEngine _physicsEngine; - private readonly GateComponent _gateComponent; - private readonly GateLifterComponent _gateLifterComponent; - private readonly GateColliderComponent _gateColliderComponent; + private readonly GateComponent _gateComponent; + private readonly GateLifterComponent _gateLifterComponent; + private readonly GateColliderComponent _gateColliderComponent; + private readonly int _gateColliderItemId; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private GateApi _gateApi; internal GateLifterApi(GameObject go, Player player, PhysicsEngine physicsEngine) { - _gateComponent = go.GetComponent(); - _gateColliderComponent = go.GetComponent(); - _gateLifterComponent = go.GetComponent(); - _player = player; - _physicsEngine = physicsEngine; - LifterCoil = new DeviceCoil(_player, OnLifterCoilEnabled, OnLifterCoilDisabled); - } + _gateComponent = go.GetComponent(); + _gateColliderComponent = go.GetComponent(); + _gateLifterComponent = go.GetComponent(); + _player = player; + _physicsEngine = physicsEngine; + _gateColliderItemId = _gateComponent.gameObject.GetInstanceID(); + LifterCoil = new DeviceCoil(_player, OnLifterCoilEnabled, OnLifterCoilDisabled, OnLifterCoilEnabledSimulationThread, OnLifterCoilDisabledSimulationThread); + } void IApi.OnInit(BallManager ballManager) { @@ -68,26 +70,38 @@ public IApiWireDest Wire(string deviceItem) }; } - private void OnLifterCoilEnabled() - { - if (_gateColliderComponent == null) { - Logger.Warn("Lifter coil enabled, but gate collider not found."); - return; - } - _physicsEngine.DisableCollider(((ICollidableComponent)_gateColliderComponent).ItemId); - _gateApi.Lift(_gateLifterComponent.AnimationSpeed, _gateLifterComponent.LiftedAngleDeg); - } + private void OnLifterCoilEnabled() + { + if (_gateColliderComponent == null) { + Logger.Warn("Lifter coil enabled, but gate collider not found."); + return; + } + _physicsEngine.DisableCollider(_gateColliderItemId); + _gateApi.Lift(_gateLifterComponent.AnimationSpeed, _gateLifterComponent.LiftedAngleDeg); + } - private void OnLifterCoilDisabled() - { + private void OnLifterCoilDisabled() + { if (_gateColliderComponent == null) { Logger.Warn("Lifter coil enabled, but gate collider not found."); return; } - _physicsEngine.EnableCollider(((ICollidableComponent)_gateColliderComponent).ItemId); - _gateApi.Lift(_gateLifterComponent.AnimationSpeed, 0f); - } + _physicsEngine.EnableCollider(_gateColliderItemId); + _gateApi.Lift(_gateLifterComponent.AnimationSpeed, 0f); + } + + private void OnLifterCoilEnabledSimulationThread() + { + _physicsEngine.DisableCollider(_gateColliderItemId); + _gateApi.Lift(_gateLifterComponent.AnimationSpeed, _gateLifterComponent.LiftedAngleDeg); + } + + private void OnLifterCoilDisabledSimulationThread() + { + _physicsEngine.EnableCollider(_gateColliderItemId); + _gateApi.Lift(_gateLifterComponent.AnimationSpeed, 0f); + } void IApi.OnDestroy() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateVelocityPhysics.cs index 5765b77f0..4558eacb3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateVelocityPhysics.cs @@ -32,7 +32,7 @@ internal static void UpdateVelocities(ref GateMovementState movementState, in Ga movementState.AngleSpeed = 0.0f; } if (math.abs(movementState.AngleSpeed) != 0.0f && movementState.Angle != state.AngleMin) { - movementState.AngleSpeed -= math.sin(movementState.Angle) * state.GravityFactor * (float)(PhysicsConstants.PhysFactor / 100.0); // Center of gravity towards bottom of object, makes it stop vertical + movementState.AngleSpeed -= math.sin(movementState.Angle) * state.GravityFactor * (PhysicsConstants.PhysFactor / 100.0f); // Center of gravity towards bottom of object, makes it stop vertical movementState.AngleSpeed *= state.Damping; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimationComponent.cs index e03189e56..8814ae42c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimationComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimationComponent.cs @@ -16,11 +16,10 @@ // ReSharper disable InconsistentNaming -using System.Collections; -using NLog; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.InputSystem; +using NLog; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.InputSystem; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -66,18 +65,27 @@ public class DropTargetAnimationComponent : AnimationComponent//, TODO IPa [Tooltip("Animation curve of the drop animation.")] public AnimationCurve PullUpAnimationCurve = AnimationCurve.EaseInOut(0, 0, 1f, 1); - private float _startPos; - private bool _isAnimating; - - private Keyboard _keyboard; - private IGamelogicEngine _gle; + private float _startPos; + private bool _isAnimating; + private bool _isRotating; + private bool _isDropping; + private bool _isResetting; + private bool _dropScheduled; + private float _rotationElapsed; + private float _dropElapsed; + private float _resetElapsed; + private float _dropDistanceWorld; + + private Keyboard _keyboard; + private IGamelogicEngine _gle; #endregion - private void Start() - { - _startPos = transform.localPosition.y; - } + private void Start() + { + _startPos = transform.localPosition.y; + _dropDistanceWorld = Physics.ScaleToWorld(DropDistance); + } protected override void OnAnimationValueChanged(bool value) { @@ -88,83 +96,135 @@ protected override void OnAnimationValueChanged(bool value) } } - private void OnHit() - { - if (_isAnimating) { - return; - } - - _isAnimating = true; - StartCoroutine(AnimateRotation()); - if (DropDelay == 0f) { - StartCoroutine(AnimateDrop()); - } - } + private void OnHit() + { + if (_isAnimating) { + return; + } + + _isAnimating = true; + _isRotating = true; + _rotationElapsed = 0f; + _dropScheduled = false; + if (DropDelay == 0f) { + StartDropAnimation(); + } + } private void OnReset() { if (_isAnimating) { return; - } - - _isAnimating = true; - StartCoroutine(AnimateReset()); - } - - private IEnumerator AnimateRotation() - { - var t = 0f; - while (t < RotationDuration) { - var f = RotationAnimationCurve.Evaluate(t / RotationDuration); - transform.SetLocalXRotation(math.radians(f * RotationAngle)); - t += Time.deltaTime; - if (DropDelay != 0 && t >= DropDelay) { - StartCoroutine(AnimateDrop()); - } - yield return null; // wait one frame - } - - // snap back to the start - transform.SetLocalXRotation(0); - } - - private IEnumerator AnimateDrop() - { - var t = 0f; - while (t < DropDuration) { - var f = DropAnimationCurve.Evaluate(t / DropDuration); - var pos = transform.localPosition; - pos.y = _startPos - f * Physics.ScaleToWorld(DropDistance); - transform.localPosition = pos; - t += Time.deltaTime; - yield return null; // wait one frame - } - - // finally, snap to the curve's final value - var finalPos = transform.localPosition; - finalPos.y = _startPos - Physics.ScaleToWorld(DropDistance); - transform.localPosition = finalPos; - _isAnimating = false; - } - - private IEnumerator AnimateReset() - { - var t = 0f; - while (t < PullUpDuration) { - var f = PullUpAnimationCurve.Evaluate(t / PullUpDuration); - var pos = transform.localPosition; - pos.y = _startPos - Physics.ScaleToWorld(DropDistance) + f * Physics.ScaleToWorld(DropDistance); - transform.localPosition = pos; - t += Time.deltaTime; - yield return null; // wait one frame - } - - // finally, snap to the curve's final value - var finalPos = transform.localPosition; - finalPos.y = _startPos; - transform.localPosition = finalPos; - _isAnimating = false; - } + } + + _isAnimating = true; + _isResetting = true; + _resetElapsed = 0f; + _isRotating = false; + _isDropping = false; + _dropScheduled = false; + } + + private void Update() + { + if (_isRotating) { + UpdateRotation(); + } + + if (_isDropping) { + UpdateDrop(); + } + + if (_isResetting) { + UpdateReset(); + } + } + + private void UpdateRotation() + { + if (RotationDuration <= 0f) { + transform.SetLocalXRotation(0f); + _isRotating = false; + return; + } + + _rotationElapsed += Time.deltaTime; + if (!_dropScheduled && DropDelay != 0f && _rotationElapsed >= DropDelay) { + StartDropAnimation(); + } + + if (_rotationElapsed < RotationDuration) { + var f = RotationAnimationCurve.Evaluate(_rotationElapsed / RotationDuration); + transform.SetLocalXRotation(math.radians(f * RotationAngle)); + return; + } + + // snap back to the start + transform.SetLocalXRotation(0f); + _isRotating = false; + } + + private void StartDropAnimation() + { + if (_dropScheduled) { + return; + } + + _dropScheduled = true; + _isDropping = true; + _dropElapsed = 0f; + } + + private void UpdateDrop() + { + if (DropDuration <= 0f) { + SetLocalY(_startPos - _dropDistanceWorld); + _isDropping = false; + _isAnimating = false; + return; + } + + _dropElapsed += Time.deltaTime; + if (_dropElapsed < DropDuration) { + var f = DropAnimationCurve.Evaluate(_dropElapsed / DropDuration); + SetLocalY(_startPos - f * _dropDistanceWorld); + return; + } + + // finally, snap to the curve's final value + SetLocalY(_startPos - _dropDistanceWorld); + _isDropping = false; + _isAnimating = false; + } + + private void UpdateReset() + { + if (PullUpDuration <= 0f) { + SetLocalY(_startPos); + _isResetting = false; + _isAnimating = false; + return; + } + + _resetElapsed += Time.deltaTime; + if (_resetElapsed < PullUpDuration) { + var f = PullUpAnimationCurve.Evaluate(_resetElapsed / PullUpDuration); + SetLocalY(_startPos - _dropDistanceWorld + f * _dropDistanceWorld); + return; + } + + // finally, snap to the curve's final value + SetLocalY(_startPos); + _isResetting = false; + _isAnimating = false; + } + + private void SetLocalY(float y) + { + var pos = transform.localPosition; + pos.y = y; + transform.localPosition = pos; + } // #region Packaging // diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index 76c63ef60..dc3fd7ece 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -16,10 +16,11 @@ using System; using System.Collections.Generic; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT; -using VisualPinball.Engine.VPT.HitTarget; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.HitTarget; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -55,11 +56,11 @@ public class DropTargetApi : CollidableApi /// /// Thrown if target is not a drop target (but a hit target, which can't be dropped) - public bool IsDropped - { - get => PhysicsEngine.DropTargetState(ItemId).Animation.IsDropped; - set => SetIsDropped(value); - } + public bool IsDropped + { + get => IsCurrentlyDropped(); + set => SetIsDropped(value); + } internal DropTargetApi(GameObject go, Player player, PhysicsEngine physicsEngine) : base(go, player, physicsEngine) { @@ -76,26 +77,34 @@ public void OnDropStatusChanged(bool isDropped, int ballId) /// /// /// - private void SetIsDropped(bool isDropped) - { - ref var state = ref PhysicsEngine.DropTargetState(ItemId); - if (state.Animation.IsDropped != isDropped) { - if (!isDropped) { - Reset?.Invoke(this, EventArgs.Empty); - MainComponent.UpdateAnimationValue(false); - } - state.Animation.MoveAnimation = true; - if (isDropped) { - state.Animation.MoveDown = true; - } - else { - state.Animation.MoveDown = false; - state.Animation.TimeStamp = PhysicsEngine.TimeMsec; - } - } else { - state.Animation.IsDropped = isDropped; - } - } + private void SetIsDropped(bool isDropped) + { + var resetTimestamp = PhysicsEngine.TimeMsec; + if (IsCurrentlyDropped() != isDropped && !isDropped) { + Reset?.Invoke(this, EventArgs.Empty); + MainComponent.UpdateAnimationValue(false); + } + + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var dropTargetState = ref state.DropTargetStates.GetValueByRef(ItemId); + if (dropTargetState.Animation.IsDropped != isDropped) { + dropTargetState.Animation.MoveAnimation = true; + if (isDropped) { + dropTargetState.Animation.MoveDown = true; + } else { + dropTargetState.Animation.MoveDown = false; + dropTargetState.Animation.TimeStamp = resetTimestamp; + } + } else { + dropTargetState.Animation.IsDropped = isDropped; + } + }); + } + + private bool IsCurrentlyDropped() + { + return PhysicsEngine.DropTargetState(ItemId).Animation.IsDropped; + } #region Wiring diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs index 14833bd18..465759a45 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs @@ -218,9 +218,7 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float var rotQuaternion = new quaternion(rotMatrix); ballData.Velocity = math.mul(rotQuaternion, velocity); - Debug.Log($"Kick[{MainComponent.name}]: inclination {math.degrees(inclination)}, speedz = {speedZ}, velocity = {ballData.Velocity} ({velocity}) ({x}, {y}, {z}), pos = {ballData.Position}"); - - ballData.IsFrozen = false; + ballData.IsFrozen = false; ballData.AngularMomentum = float3.zero; // update collision event diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs index 77e4a5758..bb6397b50 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs @@ -25,9 +25,11 @@ namespace VisualPinball.Unity { [PackAs("KickerCollider")] [AddComponentMenu("Pinball/Collision/Kicker Collider")] - public class KickerColliderComponent : ColliderComponent, IPackable - { - #region Data + public class KickerColliderComponent : ColliderComponent, IPackable + { + private int _itemId; + + #region Data [Range(-90f, 90f)] [Tooltip("How many degrees of randomness is added to the ball trajectory when ejecting.")] @@ -96,25 +98,26 @@ public override bool PhysicsOverwrite #endregion - private void Awake() - { - PhysicsEngine = GetComponentInParent(); - } + private void Awake() + { + PhysicsEngine = GetComponentInParent(); + _itemId = MainComponent.gameObject.GetInstanceID(); + } protected override IApiColliderGenerator InstantiateColliderApi(Player player, PhysicsEngine physicsEngine) => MainComponent.KickerApi ?? new KickerApi(gameObject, player, physicsEngine); - public override void OnTransformationChanged(float4x4 currTransformationMatrix) - { - // update kicker center, so the internal collision shape is correct - ref var kickerData = ref PhysicsEngine.KickerState(ItemId); - kickerData.Static.Center = currTransformationMatrix.c3.xy; - kickerData.Static.ZLow = currTransformationMatrix.c3.z; - if (PhysicsEngine.HasBallsInsideOf(ItemId)) { - foreach (var ballId in PhysicsEngine.GetBallsInsideOf(ItemId)) { - ref var ball = ref PhysicsEngine.BallState(ballId); - ball.Position = currTransformationMatrix.c3.xyz; - } + public override void OnTransformationChanged(float4x4 currTransformationMatrix) + { + // update kicker center, so the internal collision shape is correct + ref var kickerData = ref PhysicsEngine.KickerState(_itemId); + kickerData.Static.Center = currTransformationMatrix.c3.xy; + kickerData.Static.ZLow = currTransformationMatrix.c3.z; + if (PhysicsEngine.HasBallsInsideOf(_itemId)) { + foreach (var ballId in PhysicsEngine.GetBallsInsideOf(_itemId)) { + ref var ball = ref PhysicsEngine.BallState(ballId); + ball.Position = currTransformationMatrix.c3.xyz; + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs index 6722ac319..c61d2d73b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs @@ -15,10 +15,11 @@ // along with this program. If not, see . using System; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.InputSystem; -using VisualPinball.Engine.VPT.Plunger; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.InputSystem; +using VisualPinball.Engine.VPT.Plunger; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -66,12 +67,14 @@ internal PlungerApi(GameObject go, Player player, PhysicsEngine physicsEngine) : FireCoil = new DeviceCoil(Player, Fire); } - internal void OnAnalogPlunge(InputAction.CallbackContext ctx) - { - var pos = ctx.ReadValue(); // 0 = resting pos, 1 = pulled back - ref var plungerState = ref PhysicsEngine.PlungerState(ItemId); - plungerState.Movement.AnalogPosition = pos; - } + internal void OnAnalogPlunge(InputAction.CallbackContext ctx) + { + var pos = ctx.ReadValue(); // 0 = resting pos, 1 = pulled back + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); + plungerState.Movement.AnalogPosition = pos; + }); + } void IApi.OnInit(BallManager ballManager) { @@ -83,49 +86,54 @@ void IApi.OnDestroy() { } - public void PullBack() - { - var collComponent = GameObject.GetComponent(); - if (!collComponent) { - return; - } - - ref var plungerState = ref PhysicsEngine.PlungerState(ItemId); - if (DoRetract) { - PlungerCommands.PullBackAndRetract(collComponent.SpeedPull, ref plungerState.Velocity, ref plungerState.Movement); - - } else { - PlungerCommands.PullBack(collComponent.SpeedPull, ref plungerState.Velocity, ref plungerState.Movement); - } - } - - public void Fire() - { - var collComponent = GameObject.GetComponent(); - if (!collComponent) { - return; - } - ref var plungerState = ref PhysicsEngine.PlungerState(ItemId); - - // check for an auto plunger - if (collComponent.IsAutoPlunger) { - // Auto Plunger - this models a "Launch Ball" button or a - // ROM-controlled launcher, rather than a player-operated - // spring plunger. In a physical machine, this would be - // implemented as a solenoid kicker, so the amount of force - // is constant (modulo some mechanical randomness). Simulate - // this by triggering a release from the maximum retracted - // position. - PlungerCommands.Fire(1f, ref plungerState.Velocity, ref plungerState.Movement, in plungerState.Static); - - } else { - // Regular plunger - trigger a release from the current - // position, using the keyboard firing strength. - - var pos = (plungerState.Movement.Position - plungerState.Static.FrameEnd) / (plungerState.Static.FrameStart - plungerState.Static.FrameEnd); - PlungerCommands.Fire(pos, ref plungerState.Velocity, ref plungerState.Movement, in plungerState.Static); - } - } + public void PullBack() + { + var collComponent = GameObject.GetComponent(); + if (!collComponent) { + return; + } + + var doRetract = DoRetract; + var speedPull = collComponent.SpeedPull; + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); + if (doRetract) { + PlungerCommands.PullBackAndRetract(speedPull, ref plungerState.Velocity, ref plungerState.Movement); + } else { + PlungerCommands.PullBack(speedPull, ref plungerState.Velocity, ref plungerState.Movement); + } + }); + } + + public void Fire() + { + var collComponent = GameObject.GetComponent(); + if (!collComponent) { + return; + } + var isAutoPlunger = collComponent.IsAutoPlunger; + + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); + + // check for an auto plunger + if (isAutoPlunger) { + // Auto Plunger - this models a "Launch Ball" button or a + // ROM-controlled launcher, rather than a player-operated + // spring plunger. In a physical machine, this would be + // implemented as a solenoid kicker, so the amount of force + // is constant (modulo some mechanical randomness). Simulate + // this by triggering a release from the maximum retracted + // position. + PlungerCommands.Fire(1f, ref plungerState.Velocity, ref plungerState.Movement, in plungerState.Static); + } else { + // Regular plunger - trigger a release from the current + // position, using the keyboard firing strength. + var pos = (plungerState.Movement.Position - plungerState.Static.FrameEnd) / (plungerState.Static.FrameStart - plungerState.Static.FrameEnd); + PlungerCommands.Fire(pos, ref plungerState.Velocity, ref plungerState.Movement, in plungerState.Static); + } + }); + } IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); IApiWireDest IApiWireDeviceDest.Wire(string deviceItem) => Coil(deviceItem); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerComponent.cs index 1c772fbb1..76031d7d0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerComponent.cs @@ -234,7 +234,7 @@ internal SpinnerState CreateState() ? new SpinnerStaticState { AngleMax = math.radians(AngleMax), AngleMin = math.radians(AngleMin), - Damping = math.pow(Damping, (float)PhysicsConstants.PhysFactor), + Damping = math.pow(Damping, PhysicsConstants.PhysFactor), Elasticity = collComponent.Elasticity, Height = Position.z } : default; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerVelocityPhysics.cs index 6bff32dd1..c558b7536 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerVelocityPhysics.cs @@ -24,7 +24,7 @@ internal static class SpinnerVelocityPhysics internal static void UpdateVelocities(ref SpinnerMovementState movement, in SpinnerStaticState state) { // Center of gravity towards bottom of object, makes it stop vertical - movement.AngleSpeed -= math.sin(movement.Angle) * (float)(0.0025 * PhysicsConstants.PhysFactor); + movement.AngleSpeed -= math.sin(movement.Angle) * (0.0025f * PhysicsConstants.PhysFactor); movement.AngleSpeed *= state.Damping; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SlingshotComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SlingshotComponent.cs index 9d9271703..18e77b79a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SlingshotComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SlingshotComponent.cs @@ -17,8 +17,7 @@ // ReSharper disable InconsistentNaming using System; -using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using Unity.Mathematics; using UnityEngine; using UnityEngine.Serialization; @@ -71,11 +70,21 @@ public class SlingshotComponent : MonoBehaviour, IMeshComponent, IMainRenderable new Keyframe(1, 0) ); - [NonSerialized] public float Position; - [SerializeField] private bool _isLocked; - [NonSerialized] private readonly Dictionary _meshes = new Dictionary(); - [NonSerialized] private RubberMeshGenerator _meshGenerator; - private RubberMeshGenerator MeshGenerator => _meshGenerator ??= new RubberMeshGenerator(this); + [NonSerialized] public float Position; + [SerializeField] private bool _isLocked; + [NonSerialized] private readonly Dictionary _meshes = new Dictionary(); + [NonSerialized] private RubberMeshGenerator _meshGenerator; + [NonSerialized] private bool _isAnimating; + [NonSerialized] private float _animationJourney; + [NonSerialized] private DragPointData[] _dragPointsBuffer = Array.Empty(); + [NonSerialized] private MeshFilter _meshFilter; + [NonSerialized] private MeshRenderer _meshRenderer; + [NonSerialized] private MeshRenderer _rubberOffMeshRenderer; + [NonSerialized] private PlayfieldComponent _playfield; + [NonSerialized] private bool _loggedMissingMeshComponents; + [NonSerialized] private bool _loggedMissingRubberReferences; + [NonSerialized] private bool _loggedMismatchedDragPoints; + private RubberMeshGenerator MeshGenerator => _meshGenerator ??= new RubberMeshGenerator(this); private const int MaxNumMeshCaches = 15; @@ -95,13 +104,17 @@ public class SlingshotComponent : MonoBehaviour, IMeshComponent, IMainRenderable public SlingshotApi SlingshotApi { get; private set; } - private void Awake() - { - var player = GetComponentInParent(); - var physicsEngine = GetComponentInParent(); - SlingshotApi = new SlingshotApi(gameObject, player, physicsEngine); - - player.Register(SlingshotApi, this); + private void Awake() + { + var player = GetComponentInParent(); + var physicsEngine = GetComponentInParent(); + _meshFilter = GetComponent(); + _meshRenderer = GetComponent(); + _playfield = GetComponentInParent(); + _rubberOffMeshRenderer = RubberOff ? RubberOff.GetComponent() : null; + SlingshotApi = new SlingshotApi(gameObject, player, physicsEngine); + + player.Register(SlingshotApi, this); } private void Start() @@ -110,11 +123,13 @@ private void Start() if (!player || player.TableApi == null || !SlingshotSurface) { return; } - var slingshotSurfaceApi = player.TableApi.Surface(SlingshotSurface.MainComponent); - if (slingshotSurfaceApi != null) { - slingshotSurfaceApi.Slingshot += OnSlingshot; - } - } + var slingshotSurfaceApi = player.TableApi.Surface(SlingshotSurface.MainComponent); + if (slingshotSurfaceApi != null) { + slingshotSurfaceApi.Slingshot += OnSlingshot; + } + + PrewarmMeshes(); + } private void OnDestroy() { @@ -127,37 +142,47 @@ private void OnDestroy() _meshes.Clear(); } - private void OnSlingshot(object sender, EventArgs e) => TriggerAnimation(); - - private void TriggerAnimation() - { - StopAllCoroutines(); - StartCoroutine(nameof(Animate)); - } - - private IEnumerator Animate() - { - var duration = AnimationDuration / 1000; - var journey = 0f; - while (journey <= duration) { - - journey += Time.deltaTime; - var curvePercent = AnimationCurve.Evaluate(journey / duration); - Position = math.clamp(curvePercent, 0f, 1f); - - RebuildMeshes(); - - yield return null; - } - } + private void OnSlingshot(object sender, EventArgs e) => TriggerAnimation(); + + private void TriggerAnimation() + { + _animationJourney = 0f; + _isAnimating = true; + } + + private void Update() + { + if (!_isAnimating) { + return; + } + + var duration = AnimationDuration / 1000; + if (duration <= 0f) { + Position = 0f; + RebuildMeshes(); + _isAnimating = false; + return; + } + + _animationJourney += Time.deltaTime; + var curvePercent = AnimationCurve.Evaluate(_animationJourney / duration); + Position = math.clamp(curvePercent, 0f, 1f); + RebuildMeshes(); + + if (_animationJourney > duration) { + Position = 0f; + RebuildMeshes(); + _isAnimating = false; + } + } #endregion #region IRubberData - public DragPointData[] DragPoints => DragPointsAt(Position); - public int Thickness => RubberOff.GetComponent()?.Thickness ?? 8; - public float Height => RubberOff.GetComponent()?.Height ?? 25f; + public DragPointData[] DragPoints => DragPointsAt(Position); + public int Thickness => RubberOff ? RubberOff.Thickness : 8; + public float Height => RubberOff ? RubberOff.Height : 25f; #endregion @@ -165,28 +190,23 @@ private IEnumerator Animate() public void UpdateTransforms() { } public void UpdateVisibility() { } - public void RebuildMeshes() - { - var mf = GetComponent(); - var mr = GetComponent(); - if (!mf || !mr) { - Debug.LogWarning("Mesh filter or renderer not found."); - return; - } - - // mesh - var mesh = GetMesh(); - if (mesh != null) { - mf.sharedMesh = mesh; - } - - // material - if (RubberOff && !mr.sharedMaterial) { - var rubberMr = RubberOff.GetComponent(); - if (rubberMr) { - mr.sharedMaterial = rubberMr.sharedMaterial; - } - } + public void RebuildMeshes() + { + if (!_meshFilter || !_meshRenderer) { + LogConfigurationWarningOnce(ref _loggedMissingMeshComponents, "Mesh filter or renderer not found."); + return; + } + + // mesh + var mesh = GetMesh(); + if (mesh != null) { + _meshFilter.sharedMesh = mesh; + } + + // material + if (_rubberOffMeshRenderer && !_meshRenderer.sharedMaterial) { + _meshRenderer.sharedMaterial = _rubberOffMeshRenderer.sharedMaterial; + } if (CoilArm) { var currentRot = CoilArm.transform.rotation.eulerAngles; @@ -204,29 +224,25 @@ public void RebuildMeshes() } } - private Mesh GetMesh() - { - var pos = (int)(Position * MaxNumMeshCaches); - if (Application.isPlaying && _meshes.ContainsKey(pos)) { - return _meshes[pos]; - } - - if (!RubberOff || DragPoints.Length < 3) { - return null; - } - - var pf = GetComponentInParent(); - var r0 = RubberOff.GetComponent(); - if (!r0 || !pf) { - return null; - } - - Debug.Log($"Generating new mesh at {pos}"); - - var mesh = MeshGenerator - .GetTransformedMesh(0, pf.PlayfieldDetailLevel) - .TransformToWorld() - .ToUnityMesh(); + private Mesh GetMesh() + { + var pos = (int)(Position * MaxNumMeshCaches); + if (Application.isPlaying && _meshes.TryGetValue(pos, out var cachedMesh)) { + return cachedMesh; + } + + if (!TryGetSourceDragPoints(out var dp0, out var dp1) || dp0.Length < 3) { + return null; + } + + if (!_playfield) { + return null; + } + + var mesh = MeshGenerator + .GetTransformedMesh(0, _playfield.PlayfieldDetailLevel) + .TransformToWorld() + .ToUnityMesh(); mesh.name = $"{name} (Mesh)"; _meshes[pos] = mesh; @@ -236,34 +252,67 @@ private Mesh GetMesh() public static GameObject LoadPrefab() => Resources.Load("Prefabs/Slingshot"); - private DragPointData[] DragPointsAt(float pos) - { - if (RubberOn == null || RubberOff == null) { - Debug.LogWarning("Rubber references not set."); - return Array.Empty(); - } - var r0 = RubberOff.GetComponent(); - var r1 = RubberOn.GetComponent(); - if (r0 == null || r1 == null || r0.DragPoints == null || r1.DragPoints == null) { - Debug.LogWarning("Rubber references not found or drag points not set."); - return Array.Empty(); - } - - var dp0 = r0.DragPoints; - var dp1 = r1.DragPoints; - - if (dp0.Length != dp1.Length) { - Debug.LogWarning($"Drag point number varies ({dp0.Length} vs {dp1.Length}.)."); - return Array.Empty(); - } - - var dp = new DragPointData[dp0.Length]; - for (var i = 0; i < dp.Length; i++) { - dp[i] = dp0[i].Lerp(dp1[i], pos); - } - - return dp; - } + private DragPointData[] DragPointsAt(float pos) + { + if (!TryGetSourceDragPoints(out var dp0, out var dp1)) { + return Array.Empty(); + } + + if (dp0.Length != dp1.Length) { + LogConfigurationWarningOnce(ref _loggedMismatchedDragPoints, $"Drag point number varies ({dp0.Length} vs {dp1.Length}.)."); + return Array.Empty(); + } + + if (_dragPointsBuffer.Length != dp0.Length) { + _dragPointsBuffer = new DragPointData[dp0.Length]; + } + + for (var i = 0; i < _dragPointsBuffer.Length; i++) { + _dragPointsBuffer[i] = dp0[i].Lerp(dp1[i], pos); + } + + return _dragPointsBuffer; + } + + private bool TryGetSourceDragPoints(out DragPointData[] dp0, out DragPointData[] dp1) + { + dp0 = null; + dp1 = null; + + if (RubberOn == null || RubberOff == null || RubberOn.DragPoints == null || RubberOff.DragPoints == null) { + LogConfigurationWarningOnce(ref _loggedMissingRubberReferences, "Rubber references not found or drag points not set."); + return false; + } + + dp0 = RubberOff.DragPoints; + dp1 = RubberOn.DragPoints; + return true; + } + + private void PrewarmMeshes() + { + if (!Application.isPlaying || !TryGetSourceDragPoints(out var dp0, out _) || dp0.Length < 3 || !_playfield) { + return; + } + + var previousPosition = Position; + for (var pos = 0; pos <= MaxNumMeshCaches; pos++) { + Position = (float)pos / MaxNumMeshCaches; + GetMesh(); + } + Position = previousPosition; + } + + private void LogConfigurationWarningOnce(ref bool flag, string message) + { + if (flag) { + return; + } + flag = true; +#if UNITY_EDITOR + Debug.LogWarning(message); +#endif + } public void CopyFromObject(GameObject go) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/SwitchAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/SwitchAnimationComponent.cs index 6106a2a6d..b420d1448 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/SwitchAnimationComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/SwitchAnimationComponent.cs @@ -19,10 +19,9 @@ #if UNITY_EDITOR using UnityEditor; #endif -using System.Collections; -using NLog; -using Unity.Mathematics; -using UnityEngine; +using NLog; +using Unity.Mathematics; +using UnityEngine; using VisualPinball.Engine.VPT.Trigger; using Logger = NLog.Logger; @@ -53,9 +52,11 @@ public float StartAngle { private float _currentAngle; private bool _ballInside; private int _ballId; - private float _yEnter; - private float _yExit; - private Coroutine _animateBackwardsCoroutine; + private float _yEnter; + private float _yExit; + private bool _isAnimatingBackwards; + private float _backwardsAnimationElapsed; + private float _backwardsAnimationFrom; private TriggerComponent _triggerComp; private PhysicsEngine _physicsEngine; @@ -84,27 +85,29 @@ private void Start() _triggerComp.TriggerApi.UnHit += UnHit; } - private void OnHit(object sender, HitEventArgs e) - { - if (_ballInside) { - // ignore other balls - return; - } - if (_animateBackwardsCoroutine != null) { - StopCoroutine(_animateBackwardsCoroutine); - _animateBackwardsCoroutine = null; - } - - _ballInside = true; - _ballId = e.BallId; - } + private void OnHit(object sender, HitEventArgs e) + { + if (_ballInside) { + // ignore other balls + return; + } + + _isAnimatingBackwards = false; + + _ballInside = true; + _ballId = e.BallId; + } private void Update() { - if (!_ballInside) { - // nothing to animate - return; - } + if (!_ballInside) { + if (_isAnimatingBackwards) { + AnimateBackwards(); + } + + // nothing to animate + return; + } var ballComponent = _physicsEngine.GetBall(_ballId); var ballTransform = ballComponent.transform; @@ -126,35 +129,40 @@ private void UnHit(object sender, HitEventArgs e) // ignore other balls return; } - _ballId = 0; - _ballInside = false; - - if (_animateBackwardsCoroutine != null) { - StopCoroutine(_animateBackwardsCoroutine); - } - _animateBackwardsCoroutine = StartCoroutine(AnimateBackwards()); - } - - private IEnumerator AnimateBackwards() - { - // rotate from _currentAngle to _startAngle - var from = _currentAngle; - var to = _startAngle; - var d = to - from; - - var t = 0f; - while (t < BackwardsAnimationDurationSeconds) { - var f = BackwardsAnimationCurve.Evaluate(t / BackwardsAnimationDurationSeconds); - _currentAngle = from + f * d; - transform.SetLocalXRotation(math.radians(_currentAngle)); - t += Time.deltaTime; - yield return null; // wait one frame - } - - // finally, snap to the curve's final value - transform.SetLocalXRotation(math.radians(to)); - _animateBackwardsCoroutine = null; - } + _ballId = 0; + _ballInside = false; + _backwardsAnimationElapsed = 0f; + _backwardsAnimationFrom = _currentAngle; + _isAnimatingBackwards = true; + } + + private void AnimateBackwards() + { + // rotate from _currentAngle to _startAngle + var from = _backwardsAnimationFrom; + var to = _startAngle; + var d = to - from; + + if (BackwardsAnimationDurationSeconds <= 0f) { + transform.SetLocalXRotation(math.radians(to)); + _currentAngle = to; + _isAnimatingBackwards = false; + return; + } + + _backwardsAnimationElapsed += Time.deltaTime; + if (_backwardsAnimationElapsed < BackwardsAnimationDurationSeconds) { + var f = BackwardsAnimationCurve.Evaluate(_backwardsAnimationElapsed / BackwardsAnimationDurationSeconds); + _currentAngle = from + f * d; + transform.SetLocalXRotation(math.radians(_currentAngle)); + return; + } + + // finally, snap to the curve's final value + _currentAngle = to; + transform.SetLocalXRotation(math.radians(to)); + _isAnimatingBackwards = false; + } private void OnDestroy() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs index 004bab012..453d3ba1c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs @@ -120,7 +120,8 @@ public class TroughApi : ItemApi, /// A reference to the drain switch on the playfield needed to destroy the ball and update the state of the /// . /// - private IApiSwitch _drainSwitch; + private IApiSwitch _drainSwitch; + private KickerApi _drainKicker; /// /// A reference to the exit kicker on the playfield needed to create and kick new balls into the plunger lane. @@ -211,8 +212,8 @@ internal TroughApi(GameObject go, Player player, PhysicsEngine physicsEngine) : ExitCoil = new DeviceCoil(Player, () => EjectBall()); } - void IApi.OnInit(BallManager ballManager) - { + void IApi.OnInit(BallManager ballManager) + { base.OnInit(ballManager); // reference playfield elements @@ -220,10 +221,15 @@ void IApi.OnInit(BallManager ballManager) _ejectCoil = TableApi.Coil(MainComponent.PlayfieldExitKicker, MainComponent.PlayfieldExitKickerItem); _ejectKicker = TableApi.Kicker(MainComponent.PlayfieldExitKicker); - // setup entry handler - if (_drainSwitch != null) { - _drainSwitch.Switch += OnEntry; - } + // setup entry handler + if (_drainSwitch != null) { + if (_drainSwitch is KickerApi drainKicker) { + _drainKicker = drainKicker; + _drainKicker.Hit += OnDrainKickerHit; + } else { + _drainSwitch.Switch += OnEntry; + } + } // fill up the ball stack var ballCount = MainComponent.Type == TroughType.ClassicSingleBall ? 1 : MainComponent.BallCount; @@ -280,13 +286,18 @@ private void AddBall() } } - /// - /// Destroys the ball and simulates a drain. - /// - private void OnEntry(object sender, SwitchEventArgs args) - { - if (args.IsEnabled) { - Logger.Info("Draining ball into trough."); + private void OnDrainKickerHit(object sender, HitEventArgs args) + { + OnEntry(sender, new SwitchEventArgs(true, args.BallId)); + } + + /// + /// Destroys the ball and simulates a drain. + /// + private void OnEntry(object sender, SwitchEventArgs args) + { + if (args.IsEnabled) { + Logger.Info("Draining ball into trough."); if (_drainSwitch is KickerApi kickerApi) { kickerApi.DestroyBall(); } else { @@ -658,9 +669,14 @@ void IApi.OnDestroy() { Logger.Info("Destroying trough!"); - if (_drainSwitch != null) { - _drainSwitch.Switch -= OnEntry; - } + if (_drainSwitch != null) { + if (_drainKicker != null) { + _drainKicker.Hit -= OnDrainKickerHit; + _drainKicker = null; + } else { + _drainSwitch.Switch -= OnEntry; + } + } if (MainComponent.Type == TroughType.ModernOpto || MainComponent.Type == TroughType.ModernMech) { _stackSwitches[MainComponent.SwitchCount - 1].Switch -= OnLastStackSwitch; }