From f9830faf3114cf5932e9cdcabdb54f12d241cc6e Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 22 Sep 2025 13:49:13 +0200 Subject: [PATCH 01/51] jobs: Remove job for populating octree. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 13 +++--- .../Game/PhysicsPopulate.cs | 40 +++++++++++++++++++ ...ateJob.cs.meta => PhysicsPopulate.cs.meta} | 0 .../Game/PhysicsPopulateJob.cs | 22 ---------- .../VPT/ColliderComponent.cs | 6 +-- 5 files changed, 48 insertions(+), 33 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs rename VisualPinball.Unity/VisualPinball.Unity/Game/{PhysicsPopulateJob.cs.meta => PhysicsPopulate.cs.meta} (100%) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulateJob.cs diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 810047fcc..e13c6fa47 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -21,6 +21,7 @@ using System.Linq; using NativeTrees; using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; @@ -296,12 +297,12 @@ private void Start() _octree = new NativeOctree(_playfieldBounds, 1024, 10, Allocator.Persistent); sw.Restart(); - var populateJob = new PhysicsPopulateJob { - Colliders = _colliders, - Octree = _octree, - }; - populateJob.Run(); - _octree = populateJob.Octree; + unsafe { + fixed (NativeColliders* c = &_colliders) + fixed (NativeOctree* o = &_octree) { + PhysicsPopulate.PopulateUnsafe((IntPtr)c, (IntPtr)o); + } + } Debug.Log($"Octree of {_colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms)."); // get balls diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs new file mode 100644 index 000000000..aa93705ea --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs @@ -0,0 +1,40 @@ +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)); + } + } + + 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/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 { From 075328c154602a453f8d33c31cb7c58cdb4a19a2 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 22 Sep 2025 22:38:04 +0200 Subject: [PATCH 02/51] refactor: Split render pipeline interface into runtime and editor. --- .../Resources/Prefabs/DefaultBall.prefab | 30 ++- .../VisualPinball.Unity.Editor/Rendering.meta | 3 + .../Rendering/IMaterialAdapter.cs | 0 .../Rendering/IMaterialAdapter.cs.meta | 0 .../Rendering/IPrefabProvider.cs | 0 .../Rendering/IPrefabProvider.cs.meta | 0 .../Rendering/RenderPipelineConverter.cs | 92 ++++++++ .../Rendering/RenderPipelineConverter.cs.meta | 3 + .../Rendering/Standard.meta | 3 + .../Standard/StandardMaterialAdapter.cs | 0 .../Standard/StandardMaterialAdapter.cs.meta | 0 .../Standard/StandardPrefabProvider.cs | 0 .../Standard/StandardPrefabProvider.cs.meta | 0 .../Standard/StandardRenderPipeline.cs | 5 +- .../Standard/StandardRenderPipeline.cs.meta | 0 .../Utils/PrefabReferenceRebinder.cs | 221 ++++++++++++++++++ .../Utils/PrefabReferenceRebinder.cs.meta | 3 + .../VPT/Bumper/BumperExtensions.cs | 2 +- .../VPT/Flipper/FlipperExtensions.cs | 2 +- .../VPT/Gate/GateExtensions.cs | 2 +- .../VPT/HitTarget/TargetExtensions.cs | 4 +- .../VPT/Kicker/KickerExtensions.cs | 2 +- .../VPT/Light/LightExtensions.cs | 4 +- .../VPT/Plunger/PlungerExtensions.cs | 2 +- .../VPT/Spinner/SpinnerExtensions.cs | 2 +- .../VPT/Trough/TroughExtensions.cs | 2 +- .../VisualPinball.Unity.Editor.asmdef | 3 +- .../Matcher/TablePatcher.cs | 4 +- .../Patcher/Tables/JurassicPark.cs | 9 +- .../Patcher/Tables/Mississippi.cs | 5 +- .../Patcher/Tables/TomAndJerry.cs | 9 +- .../VisualPinball.Unity.Patcher.asmdef | 4 +- .../VisualPinball.Unity.Patcher.csproj | 1 + .../Rendering/RenderPipeline.cs | 17 +- .../VPT/Ball/BallManager.cs | 2 + 35 files changed, 395 insertions(+), 41 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering.meta rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/IMaterialAdapter.cs (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/IMaterialAdapter.cs.meta (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/IPrefabProvider.cs (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/IPrefabProvider.cs.meta (100%) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/RenderPipelineConverter.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Rendering/Standard.meta rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardMaterialAdapter.cs (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardMaterialAdapter.cs.meta (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardPrefabProvider.cs (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardPrefabProvider.cs.meta (100%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardRenderPipeline.cs (88%) rename VisualPinball.Unity/{VisualPinball.Unity => VisualPinball.Unity.Editor}/Rendering/Standard/StandardRenderPipeline.cs.meta (100%) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/PrefabReferenceRebinder.cs.meta 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/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/VPT/Ball/BallManager.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs index 6ab5688c0..39580e34c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs @@ -50,8 +50,10 @@ 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(); From 46c3bdfece8d3dc26da5cf45ca0c56533a5c5b7e Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 23 Sep 2025 00:17:06 +0200 Subject: [PATCH 03/51] perf: Add frame pacing stats overlay. --- .../Game/FramePacingGraph.cs | 658 ++++++++++++++++++ .../Game/FramePacingGraph.cs.meta | 3 + 2 files changed, 661 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs new file mode 100644 index 000000000..29403c9ff --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -0,0 +1,658 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +[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(560f, 220f); + public GraphAnchor anchor = GraphAnchor.TopLeft; + + [Header("Time Window & Scale")] [Range(1f, 60f)] + public float windowSeconds = 10f; + + [Range(60, 480)] public int maxExpectedFps = 240; + public float yMaxMs = 40f; + public bool autoY = true; + public float autoYMargin = 1.2f; + + [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 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; + + bool initialized = false; + int lastFTCount; + + float lastCpuBusyMs = 0f; + + // 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; + } + + public void SetCustomMetricEnabled(int index, bool enabled) + { + if (index >= 0 && index < customMetrics.Count) customMetrics[index].enabled = enabled; + } + + public void ClearCustomMetrics() => customMetrics.Clear(); + + // ----- Internals ----- + public enum GraphAnchor + { + TopLeft, + TopRight, + BottomLeft, + BottomRight + } + + struct Stats + { + public float avg, p95, p99; + 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; + + 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; + +// 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 (ms)", cpuColor, () => lastCpuMs, 1f, enableCpuGpuCollection, capacity); + gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuMs, 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; + + 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; + } + + int 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 (int 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() + { + // Get last frame timing + if (enableCpuGpuCollection) + { + uint got = enableCpuGpuCollection ? FrameTimingManager.GetLatestTimings(1, ftBuf) : 0; + lastFTCount = (int)got; + haveFT = got > 0; + if (haveFT) + { + var ft = ftBuf[0]; + lastCpuMs = Mathf.Max(0f, (float)ft.cpuFrameTime); + lastGpuMs = Mathf.Max(0f, (float)ft.gpuFrameTime); + } + } + else + { + haveFT = false; + lastCpuMs = 0f; + lastGpuMs = 0f; + } + + // Sample + float now = Time.unscaledTime; + float totalMs = totalMetric.sampler(); + float cpuMs = cpuMetric.enabled ? cpuMetric.sampler() : 0f; + float gpuMs = gpuMetric.enabled ? gpuMetric.sampler() : 0f; + WriteSample(now, totalMs, cpuMs, gpuMs); + + // Custom metrics + int idxPrev = (head - 1 + capacity) % capacity; + for (int i = 0; i < customMetrics.Count; i++) + { + var m = customMetrics[i]; + if (!m.enabled || m.sampler == null) + { + m.values[idxPrev] = 0f; + continue; + } + + float v = 0f; + try + { + v = m.sampler() * m.scale; + } + catch + { + } + + m.values[idxPrev] = v; + } + + // FPS smoothing & text throttling + float instFps = Time.unscaledDeltaTime > 0f ? 1f / Time.unscaledDeltaTime : 0f; + float 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 (int i = 0; i < customMetrics.Count; i++) + if (customMetrics[i].enabled) + RecomputeStats(customMetrics[i]); + } + + // Capture for next frame + if (enableCpuGpuCollection) FrameTimingManager.CaptureFrameTimings(); + } + + 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) + { + float candidate = Mathf.Max(lastCpuMs, lastGpuMs); + if (candidate > 0f) return candidate; + } + + return Time.unscaledDeltaTime * 1000f; + } + + void RecomputeStats(Metric m) + { + int visible = CollectVisibleValues(m.values, scratchValues, out float sum); + if (visible <= 0) + { + m.stats = default; + m.cachedLegend = $"{m.name}: -"; + return; + } + + Array.Sort(scratchValues, 0, visible); + float avg = sum / visible; + float p95 = PercentileSorted(scratchValues, visible, 0.95f); + float p99 = PercentileSorted(scratchValues, visible, 0.99f); + + m.stats.avg = avg; + m.stats.p95 = p95; + m.stats.p99 = p99; + m.stats.valid = true; + m.cachedLegend = $"{m.name}: avg {avg:0.0} ms • p95 {p95:0.0} • p99 {p99:0.0}"; + } + + int CollectVisibleValues(float[] src, float[] dst, out float sum) + { + sum = 0f; + if (count == 0) return 0; + float now = Time.unscaledTime, minTime = now - windowSeconds; + int visible = 0, start = (head - count + capacity) % capacity; + for (int i = 0; i < count; i++) + { + int idx = (start + i) % capacity; + float t = timestamps[idx]; + if (t >= minTime) + { + float 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]; + float f = (n - 1) * p; + int i0 = Mathf.FloorToInt(f), i1 = Math.Min(n - 1, i0 + 1); + float 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; + + Rect r = ResolveRect(); + if (r.width <= 4f || r.height <= 4f) return; + + DrawBackgroundAndGrid(r); + + float now = Time.unscaledTime; + float invY = ResolveYScale(out float yMax); + + // Draw metrics (batched, column-envelope) + DrawMetric(r, now, yMax, invY, totalMetric); + if (cpuMetric.enabled) DrawMetric(r, now, yMax, invY, cpuMetric); + if (gpuMetric.enabled) DrawMetric(r, now, yMax, invY, gpuMetric); + for (int i = 0; i < customMetrics.Count; i++) + if (customMetrics[i].enabled) + DrawMetric(r, now, yMax, invY, customMetrics[i]); + + // Labels (legend + axis + fps) + GUI.Label(new Rect(r.x + 6, r.y - (fontSize + 2), 120, fontSize + 4), $"{yMax:0.#} ms", labelStyle); + GUI.Label(new Rect(r.x + 6, r.y + r.height * 0.5f - (fontSize + 2), 120, fontSize + 4), + $"{(yMax * 0.5f):0.#} ms", labelStyle); + GUI.Label(new Rect(r.x + 6, r.yMax - (fontSize + 2), 120, fontSize + 4), $"0 ms", labelStyle); + + // FPS + GUI.Label(new Rect(r.x + 8, r.y + 6, 120, fontSize + 6), fpsText, labelStyle); + GUI.Label(new Rect(r.x + 125, r.y + 6, 180, fontSize + 6),$"FT:{lastFTCount} {(haveFT ? "OK" : "OFF")}", labelStyle); + + // Legends (cached strings) + float lx = r.x + 8, ly = Mathf.Max(r.y + 24, r.yMax - 18 - (fontSize + 4) * (3 + customMetrics.Count)); + GUI.color = totalMetric.color; + GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), totalMetric.cachedLegend, labelStyle); + ly += fontSize + 4; + if (cpuMetric.enabled) + { + GUI.color = cpuMetric.color; + GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), cpuMetric.cachedLegend, labelStyle); + ly += fontSize + 4; + } + + if (gpuMetric.enabled) + { + GUI.color = gpuMetric.color; + GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), gpuMetric.cachedLegend, labelStyle); + ly += fontSize + 4; + } + + for (int i = 0; i < customMetrics.Count; i++) + { + var m = customMetrics[i]; + if (!m.enabled || !m.stats.valid) continue; + GUI.color = m.color; + GUI.Label(new Rect(lx, ly, 1400, fontSize + 6), m.cachedLegend, labelStyle); + ly += fontSize + 4; + } + + GUI.color = Color.white; + + // Time axis labels + GUI.Label(new Rect(r.xMax - 100, r.yMax + 2, 100, fontSize + 4), "now", labelStyle); + GUI.Label(new Rect(r.x, r.yMax + 2, 180, fontSize + 4), $"-{windowSeconds:0.#} s", labelStyle); + } + + 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); + } + + float ResolveYScale(out float yMaxOut) + { + float yMaxLocal = yMaxMs; + if (autoY) + { + float 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 (int 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 int n); + DrawPolylineBatched(pts, n, m.color, lineWidth); + } + } + + void DrawMetricColumnEnvelope(Rect r, float now, float yMax, float invY, Metric m) + { + int w = Mathf.Max(2, Mathf.RoundToInt(r.width)); + EnsureColumnBuffers(w); + + // reset + for (int i = 0; i < w; i++) + { + colMin[i] = float.PositiveInfinity; + colMax[i] = float.NegativeInfinity; + } + + // fill per-column min/max + float minTime = now - windowSeconds; + int start = (head - count + capacity) % capacity; + for (int i = 0; i < count; i++) + { + int idx = (start + i) % capacity; + float t = timestamps[idx]; + if (t < minTime) continue; + + float x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds); + int cx = Mathf.Clamp(Mathf.RoundToInt((w - 1) * x01), 0, w - 1); + + float y01 = Mathf.Clamp01(m.values[idx] * invY); + float 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) + int nPts = 0; + for (int x = 0; x < w; x++) + { + if (!float.IsFinite(colMin[x])) continue; + float mx = r.x + (x / (float)(w - 1)) * r.width; + float 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) + { + int start = (head - count + capacity) % capacity; + int 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; + float minTime = now - windowSeconds; + for (int i = 0; i < count; i++) + { + int idx = (start + i) % capacity; + float t = timestamps[idx]; + if (t < minTime) continue; + float x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds); + float x = Mathf.Lerp(r.x, r.xMax, x01); + float y01 = Mathf.Clamp01(src[idx] * invY); + float 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; + Shader 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 (int i = 0; i < n - 1; i++) + { + Vector2 a = pts[i], b = pts[i + 1]; + Vector2 d = b - a; + float len = d.magnitude; + if (len <= 0.001f) continue; + Vector2 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(); + } + + void DrawBackgroundAndGrid(Rect r) + { + // background + EnsureLineMaterial(); + lineMat.SetPass(0); + GL.PushMatrix(); + GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0); + GL.Begin(GL.QUADS); + GL.Color(backgroundColor); + 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(); + + // border + DrawPolylineBatched( + new[] + { + new Vector2(r.x, r.y), new Vector2(r.xMax, r.y), new Vector2(r.xMax, r.yMax), new Vector2(r.x, r.yMax), + new Vector2(r.x, r.y) + }, 5, borderColor, gridLineWidth); + + // horizontal grid + if (gridLinesY > 0) + { + for (int i = 1; i < gridLinesY; i++) + { + float y = Mathf.Lerp(r.y, r.yMax, i / (float)gridLinesY); + DrawPolylineBatched(new[] { new Vector2(r.x, y), new Vector2(r.xMax, y) }, 2, borderColor, 1f); + } + } + } + + 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 From a2edfde20b8209a5d2439d1bff4d9a57131a487e Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 23 Sep 2025 00:36:07 +0200 Subject: [PATCH 04/51] perf: Use busy time. --- .../Game/FramePacingGraph.cs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 29403c9ff..2e2939b82 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -47,9 +47,6 @@ public class FramePacingGraph : MonoBehaviour public bool enableCpuGpuCollection = true; bool initialized = false; - int lastFTCount; - - float lastCpuBusyMs = 0f; // Public API for custom metrics public int RegisterCustomMetric(string name, Color color, Func sampler, float scale = 1f, @@ -116,6 +113,8 @@ public Metric(string name, Color color, Func sampler, float scale, bool e FrameTiming[] ftBuf = new FrameTiming[1]; bool haveFT = false; float lastCpuMs = 0f, lastGpuMs = 0f; + float lastCpuBusyMs = 0f, lastGpuBusyMs = 0f; // busy values we plot + static Material lineMat; @@ -148,8 +147,8 @@ void Awake() timestamps = new float[capacity]; totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity); - cpuMetric = new Metric("CPU (ms)", cpuColor, () => lastCpuMs, 1f, enableCpuGpuCollection, capacity); - gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuMs, 1f, enableCpuGpuCollection, capacity); + cpuMetric = new Metric("CPU Busy (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity); + gpuMetric = new Metric("GPU Busy (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity); scratchValues = new float[capacity]; EnsureLineMaterial(); @@ -227,14 +226,26 @@ void Update() // Get last frame timing if (enableCpuGpuCollection) { - uint got = enableCpuGpuCollection ? FrameTimingManager.GetLatestTimings(1, ftBuf) : 0; - lastFTCount = (int)got; + uint got = FrameTimingManager.GetLatestTimings(1, ftBuf); haveFT = got > 0; if (haveFT) { var ft = ftBuf[0]; + + // raw (for optional Total=max(CPU,GPU)) lastCpuMs = Mathf.Max(0f, (float)ft.cpuFrameTime); lastGpuMs = Mathf.Max(0f, (float)ft.gpuFrameTime); + + // busy + float mainWork = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); + float renderWork = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); + + // CPU Busy = max of main vs render thread work (excludes present wait) + lastCpuBusyMs = Mathf.Max(mainWork, renderWork); + + // GPU Busy = gpuFrameTime + lastGpuBusyMs = lastGpuMs; + } } else @@ -409,7 +420,6 @@ void OnGUI() // FPS GUI.Label(new Rect(r.x + 8, r.y + 6, 120, fontSize + 6), fpsText, labelStyle); - GUI.Label(new Rect(r.x + 125, r.y + 6, 180, fontSize + 6),$"FT:{lastFTCount} {(haveFT ? "OK" : "OFF")}", labelStyle); // Legends (cached strings) float lx = r.x + 8, ly = Mathf.Max(r.y + 24, r.yMax - 18 - (fontSize + 4) * (3 + customMetrics.Count)); From 3a1693bba1f3ecce886d6b3a0fea5e05ea338009 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 23 Sep 2025 19:38:47 +0200 Subject: [PATCH 05/51] perf: Plot physics loop. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index e13c6fa47..a89f3bb12 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -21,7 +21,6 @@ using System.Linq; using NativeTrees; using Unity.Collections; -using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; @@ -130,6 +129,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); + private float _lastFrameTimeMs; + #region API public void ScheduleAction(int timeoutMs, Action action) => ScheduleAction((uint)timeoutMs, action); @@ -254,6 +255,11 @@ private void Start() var sw = Stopwatch.StartNew(); var playfield = GetComponentInChildren(); + var stats = FindFirstObjectByType(); + if (stats) { + stats.RegisterCustomMetric("Physics", Color.magenta, () => _lastFrameTimeMs); + } + 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); @@ -327,6 +333,7 @@ internal PhysicsState CreateState() private void Update() { + var sw = Stopwatch.StartNew(); // check for updated kinematic transforms _updatedKinematicTransforms.Ref.Clear(); foreach (var coll in _kinematicColliderComponents) { @@ -415,6 +422,8 @@ private void Update() _physicsMovements.ApplyTriggerMovement(ref _triggerStates.Ref, _floatAnimatedComponents); #endregion + + _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; } private void OnDestroy() From 6776d1c4d78c9b5904e37efd2ab0b9392d56e871 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 24 Sep 2025 00:33:55 +0200 Subject: [PATCH 06/51] perf: Fix CPU usage. --- .../Game/FramePacingGraph.cs | 124 +++++++++++++----- 1 file changed, 94 insertions(+), 30 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 2e2939b82..34706199d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -16,11 +16,20 @@ public class FramePacingGraph : MonoBehaviour [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; @@ -46,7 +55,9 @@ public class FramePacingGraph : MonoBehaviour [Tooltip("Optionally disable FrameTimingManager reads if heavy on your platform.")] public bool enableCpuGpuCollection = true; - bool initialized = false; + bool initialized; + bool debugTimings = true; + string ftDebugLine = ""; // Public API for custom metrics public int RegisterCustomMetric(string name, Color color, Func sampler, float scale = 1f, @@ -112,9 +123,9 @@ public Metric(string name, Color color, Func sampler, float scale, bool e FrameTiming[] ftBuf = new FrameTiming[1]; bool haveFT = false; - float lastCpuMs = 0f, lastGpuMs = 0f; - float lastCpuBusyMs = 0f, lastGpuBusyMs = 0f; // busy values we plot - + 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; @@ -129,6 +140,7 @@ public Metric(string name, Color color, Func sampler, float scale, bool e float smoothedFps = 0f; float fpsTextTimer = 0f; string fpsText = "0.0 FPS"; + string lastSampleLine = ""; // Column envelope caches float[] colMin, colMax; // reused per metric @@ -147,8 +159,8 @@ void Awake() timestamps = new float[capacity]; totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity); - cpuMetric = new Metric("CPU Busy (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity); - gpuMetric = new Metric("GPU Busy (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity); + cpuMetric = new Metric("CPU (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity); + gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity); scratchValues = new float[capacity]; EnsureLineMaterial(); @@ -228,23 +240,24 @@ void Update() { uint got = FrameTimingManager.GetLatestTimings(1, ftBuf); haveFT = got > 0; - if (haveFT) - { + if (haveFT) { + + // --- inside Update(), right after GetLatestTimings() succeeds --- var ft = ftBuf[0]; - // raw (for optional Total=max(CPU,GPU)) lastCpuMs = Mathf.Max(0f, (float)ft.cpuFrameTime); lastGpuMs = Mathf.Max(0f, (float)ft.gpuFrameTime); - // busy - float mainWork = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); - float renderWork = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); - - // CPU Busy = max of main vs render thread work (excludes present wait) - lastCpuBusyMs = Mathf.Max(mainWork, renderWork); - - // GPU Busy = gpuFrameTime + lastCpuBusyMs = EstimateCpuBusyMs(ft); lastGpuBusyMs = lastGpuMs; + if (debugTimings) + { + float renderFrame = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); + float mainFrame = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); + float presentWait = Mathf.Max(0f, (float)ft.cpuMainThreadPresentWaitTime); + ftDebugLine = + $"cpuFrame {lastCpuMs:0.00} | gpuFrame {lastGpuMs:0.00} | main {mainFrame:0.00} | render {renderFrame:0.00} | wait {presentWait:0.00}"; + } } } @@ -262,6 +275,10 @@ void Update() float gpuMs = gpuMetric.enabled ? gpuMetric.sampler() : 0f; WriteSample(now, totalMs, cpuMs, gpuMs); + // right after WriteSample(now, totalMs, cpuMs, gpuMs); + int last = (head - 1 + capacity) % capacity; + lastSampleLine = $"sampCPU {cpuMetric.values[last]:0.00} | sampGPU {gpuMetric.values[last]:0.00} | sampTotal {totalMetric.values[last]:0.00}"; + // Custom metrics int idxPrev = (head - 1 + capacity) % capacity; for (int i = 0; i < customMetrics.Count; i++) @@ -312,6 +329,40 @@ void Update() if (enableCpuGpuCollection) FrameTimingManager.CaptureFrameTimings(); } + float EstimateCpuBusyMs(FrameTiming ft) + { + float cpuFrame = Mathf.Max(0f, (float)ft.cpuFrameTime); + float gpuFrame = Mathf.Max(0f, (float)ft.gpuFrameTime); + float mainFrame = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); + float rendFrame = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); + float 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) --- + float fullWithMargin = (1f - nearFullFrameThresholdPercent) * cpuFrame; + bool gpuNear = gpuFrame > fullWithMargin; + bool mainNear = mainFrame > fullWithMargin; + bool 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; @@ -335,7 +386,8 @@ float SampleTotalMs() void RecomputeStats(Metric m) { - int visible = CollectVisibleValues(m.values, scratchValues, out float sum); + int visible = CollectVisibleValues(m.values, scratchValues, statsSeconds, out float sum); + if (visible <= 0) { m.stats = default; @@ -352,30 +404,29 @@ void RecomputeStats(Metric m) m.stats.p95 = p95; m.stats.p99 = p99; m.stats.valid = true; - m.cachedLegend = $"{m.name}: avg {avg:0.0} ms • p95 {p95:0.0} • p99 {p99:0.0}"; + m.cachedLegend = $"{m.name}: avg {avg:0.00} ms • p95 {p95:0.00} • p99 {p99:0.00}"; } - int CollectVisibleValues(float[] src, float[] dst, out float sum) + int CollectVisibleValues(float[] src, float[] dst, float seconds, out float sum) { sum = 0f; if (count == 0) return 0; - float now = Time.unscaledTime, minTime = now - windowSeconds; + float now = Time.unscaledTime; + float minTime = now - (seconds > 0f ? seconds : windowSeconds); int visible = 0, start = (head - count + capacity) % capacity; - for (int i = 0; i < count; i++) - { + + for (int i = 0; i < count; i++) { int idx = (start + i) % capacity; float t = timestamps[idx]; - if (t >= minTime) - { - float v = src[idx]; - dst[visible++] = v; - sum += v; - } + if (t < minTime) continue; + float v = src[idx]; + dst[visible++] = v; + sum += v; } - return visible; } + static float PercentileSorted(float[] sorted, int n, float p) { if (n == 0) return 0f; @@ -420,6 +471,10 @@ void OnGUI() // FPS GUI.Label(new Rect(r.x + 8, r.y + 6, 120, fontSize + 6), fpsText, labelStyle); + if (debugTimings) { + GUI.Label(new Rect(r.x + 120, r.y + 6, 520, fontSize + 6), ftDebugLine, labelStyle); + } + GUI.Label(new Rect(r.x + 120, r.y + 24, 520, fontSize + 6), lastSampleLine, labelStyle); // Legends (cached strings) float lx = r.x + 8, ly = Mathf.Max(r.y + 24, r.yMax - 18 - (fontSize + 4) * (3 + customMetrics.Count)); @@ -449,6 +504,15 @@ void OnGUI() ly += fontSize + 4; } + if (cpuMetric.stats.valid && gpuMetric.stats.valid) + { + float delta = cpuMetric.stats.avg - gpuMetric.stats.avg; // >0 => CPU heavier + string bound = delta > 0.05f ? "CPU-bound" : (delta < -0.05f ? "GPU-bound" : "Balanced"); + string deltaTxt = $"Δ (CPU-GPU): {delta:+0.00;-0.00;0.00} ms • {bound}"; + GUI.color = Color.white; + GUI.Label(new Rect(lx, ly + 4, 420, fontSize + 6), deltaTxt, labelStyle); + } + GUI.color = Color.white; // Time axis labels From 505f4f15e6aaa409872d51d9b860ce6555d7f84b Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 25 Sep 2025 22:12:52 +0200 Subject: [PATCH 07/51] perf: Print stats to the right. --- .../Game/FramePacingGraph.cs | 390 ++++++++++-------- 1 file changed, 220 insertions(+), 170 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 34706199d..3dbd661d7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -13,11 +13,16 @@ public class FramePacingGraph : MonoBehaviour public Vector2 size = new Vector2(560f, 220f); public GraphAnchor anchor = GraphAnchor.TopLeft; + [Header("Stats Panel")] [Tooltip("Width of the right-side stats panel (pixels).")] + public float statsPanelWidth = 260f; + + [Tooltip("Padding inside the right-side 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.")] + [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; @@ -25,8 +30,9 @@ public class FramePacingGraph : MonoBehaviour 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 + [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; @@ -56,8 +62,11 @@ public class FramePacingGraph : MonoBehaviour public bool enableCpuGpuCollection = true; bool initialized; - bool debugTimings = true; - string ftDebugLine = ""; + + // Fixed-width font for stats + GUIStyle monoStyle; + static Font sMonoFont; // cached across instances + // Public API for custom metrics public int RegisterCustomMetric(string name, Color color, Func sampler, float scale = 1f, @@ -69,13 +78,6 @@ public int RegisterCustomMetric(string name, Color color, Func sampler, f return customMetrics.Count - 1; } - public void SetCustomMetricEnabled(int index, bool enabled) - { - if (index >= 0 && index < customMetrics.Count) customMetrics[index].enabled = enabled; - } - - public void ClearCustomMetrics() => customMetrics.Clear(); - // ----- Internals ----- public enum GraphAnchor { @@ -87,7 +89,7 @@ public enum GraphAnchor struct Stats { - public float avg, p95, p99; + public float avg, p95, p99, min, max; public bool valid; } @@ -123,9 +125,9 @@ public Metric(string name, Color color, Func sampler, float scale, bool e FrameTiming[] ftBuf = new FrameTiming[1]; bool haveFT = false; - float lastCpuMs = 0f, lastGpuMs = 0f; // raw totals + 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 + float lastCpuWaitMs = 0f; // optional: expose as a line if you want static Material lineMat; @@ -140,7 +142,6 @@ public Metric(string name, Color color, Func sampler, float scale, bool e float smoothedFps = 0f; float fpsTextTimer = 0f; string fpsText = "0.0 FPS"; - string lastSampleLine = ""; // Column envelope caches float[] colMin, colMax; // reused per metric @@ -159,8 +160,8 @@ void Awake() timestamps = new float[capacity]; totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity); - cpuMetric = new Metric("CPU (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity); - gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity); + cpuMetric = new Metric("CPU (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, capacity); + gpuMetric = new Metric("GPU (ms)", gpuColor, () => lastGpuBusyMs, 1f, enableCpuGpuCollection, capacity); scratchValues = new float[capacity]; EnsureLineMaterial(); @@ -176,6 +177,30 @@ void Awake() 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 } @@ -195,7 +220,14 @@ void OnValidate() labelStyle.normal.textColor = axisTextColor; } - int needed = Mathf.Max(8, Mathf.CeilToInt(windowSeconds * maxExpectedFps)); + 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); @@ -210,9 +242,9 @@ void ResizeCapacity(int newCap) timestamps = new float[capacity]; ResizeMetric(totalMetric, capacity); - ResizeMetric(cpuMetric, capacity); - ResizeMetric(gpuMetric, capacity); - for (int i = 0; i < customMetrics.Count; i++) + ResizeMetric(cpuMetric, capacity); + ResizeMetric(gpuMetric, capacity); + for (var i = 0; i < customMetrics.Count; i++) ResizeMetric(customMetrics[i], capacity); scratchValues = new float[capacity]; @@ -238,10 +270,10 @@ void Update() // Get last frame timing if (enableCpuGpuCollection) { - uint got = FrameTimingManager.GetLatestTimings(1, ftBuf); + var got = FrameTimingManager.GetLatestTimings(1, ftBuf); haveFT = got > 0; - if (haveFT) { - + if (haveFT) + { // --- inside Update(), right after GetLatestTimings() succeeds --- var ft = ftBuf[0]; @@ -250,15 +282,6 @@ void Update() lastCpuBusyMs = EstimateCpuBusyMs(ft); lastGpuBusyMs = lastGpuMs; - if (debugTimings) - { - float renderFrame = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); - float mainFrame = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); - float presentWait = Mathf.Max(0f, (float)ft.cpuMainThreadPresentWaitTime); - ftDebugLine = - $"cpuFrame {lastCpuMs:0.00} | gpuFrame {lastGpuMs:0.00} | main {mainFrame:0.00} | render {renderFrame:0.00} | wait {presentWait:0.00}"; - } - } } else @@ -269,19 +292,18 @@ void Update() } // Sample - float now = Time.unscaledTime; - float totalMs = totalMetric.sampler(); - float cpuMs = cpuMetric.enabled ? cpuMetric.sampler() : 0f; - float gpuMs = gpuMetric.enabled ? gpuMetric.sampler() : 0f; + 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); - int last = (head - 1 + capacity) % capacity; - lastSampleLine = $"sampCPU {cpuMetric.values[last]:0.00} | sampGPU {gpuMetric.values[last]:0.00} | sampTotal {totalMetric.values[last]:0.00}"; + var last = (head - 1 + capacity) % capacity; // Custom metrics - int idxPrev = (head - 1 + capacity) % capacity; - for (int i = 0; i < customMetrics.Count; i++) + var idxPrev = (head - 1 + capacity) % capacity; + for (var i = 0; i < customMetrics.Count; i++) { var m = customMetrics[i]; if (!m.enabled || m.sampler == null) @@ -290,21 +312,17 @@ void Update() continue; } - float v = 0f; - try - { + var v = 0f; + try { v = m.sampler() * m.scale; - } - catch - { - } + } catch { } m.values[idxPrev] = v; } // FPS smoothing & text throttling - float instFps = Time.unscaledDeltaTime > 0f ? 1f / Time.unscaledDeltaTime : 0f; - float a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau); + var instFps = Time.unscaledDeltaTime > 0f ? 1f / Time.unscaledDeltaTime : 0f; + var a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau); smoothedFps = Mathf.Lerp(smoothedFps, instFps, a); fpsTextTimer += Time.unscaledDeltaTime; if (fpsTextTimer >= 0.15f) @@ -320,7 +338,7 @@ void Update() RecomputeStats(totalMetric); if (cpuMetric.enabled) RecomputeStats(cpuMetric); if (gpuMetric.enabled) RecomputeStats(gpuMetric); - for (int i = 0; i < customMetrics.Count; i++) + for (var i = 0; i < customMetrics.Count; i++) if (customMetrics[i].enabled) RecomputeStats(customMetrics[i]); } @@ -331,11 +349,11 @@ void Update() float EstimateCpuBusyMs(FrameTiming ft) { - float cpuFrame = Mathf.Max(0f, (float)ft.cpuFrameTime); - float gpuFrame = Mathf.Max(0f, (float)ft.gpuFrameTime); - float mainFrame = Mathf.Max(0f, (float)ft.cpuMainThreadFrameTime); - float rendFrame = Mathf.Max(0f, (float)ft.cpuRenderThreadFrameTime); - float waitMain = Mathf.Max(0f, (float)ft.cpuMainThreadPresentWaitTime); + 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) @@ -345,10 +363,10 @@ float EstimateCpuBusyMs(FrameTiming ft) return mainFrame; // fall back to main (may include idle) // --- Heuristic path (DX12 often reports wait=0 even when main is waiting) --- - float fullWithMargin = (1f - nearFullFrameThresholdPercent) * cpuFrame; - bool gpuNear = gpuFrame > fullWithMargin; - bool mainNear = mainFrame > fullWithMargin; - bool renderNear = rendFrame > fullWithMargin; + 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) @@ -377,7 +395,7 @@ float SampleTotalMs() { if (totalFromFrameTimingWhenAvailable && haveFT) { - float candidate = Mathf.Max(lastCpuMs, lastGpuMs); + var candidate = Mathf.Max(lastCpuMs, lastGpuMs); if (candidate > 0f) return candidate; } @@ -386,7 +404,7 @@ float SampleTotalMs() void RecomputeStats(Metric m) { - int visible = CollectVisibleValues(m.values, scratchValues, statsSeconds, out float sum); + var visible = CollectVisibleValues(m.values, scratchValues, statsSeconds, out var sum); if (visible <= 0) { @@ -396,33 +414,43 @@ void RecomputeStats(Metric m) } Array.Sort(scratchValues, 0, visible); - float avg = sum / visible; - float p95 = PercentileSorted(scratchValues, visible, 0.95f); - float p99 = PercentileSorted(scratchValues, visible, 0.99f); + + 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; - m.cachedLegend = $"{m.name}: avg {avg:0.00} ms • p95 {p95:0.00} • p99 {p99:0.00}"; + + // (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; - float now = Time.unscaledTime; - float minTime = now - (seconds > 0f ? seconds : windowSeconds); + var now = Time.unscaledTime; + var minTime = now - (seconds > 0f ? seconds : windowSeconds); int visible = 0, start = (head - count + capacity) % capacity; - for (int i = 0; i < count; i++) { - int idx = (start + i) % capacity; - float t = timestamps[idx]; + for (var i = 0; i < count; i++) + { + var idx = (start + i) % capacity; + var t = timestamps[idx]; if (t < minTime) continue; - float v = src[idx]; + var v = src[idx]; dst[visible++] = v; sum += v; } + return visible; } @@ -432,9 +460,9 @@ 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]; - float f = (n - 1) * p; + var f = (n - 1) * p; int i0 = Mathf.FloorToInt(f), i1 = Math.Min(n - 1, i0 + 1); - float t = f - i0; + var t = f - i0; return Mathf.Lerp(sorted[i0], sorted[i1], t); } @@ -447,19 +475,20 @@ void OnGUI() var evt = Event.current; if (evt == null || evt.type != EventType.Repaint) return; - Rect r = ResolveRect(); + var r = ResolveRect(); if (r.width <= 4f || r.height <= 4f) return; - DrawBackgroundAndGrid(r); + // Draw full background (graph + right panel) and grid + DrawBackgroundGridAndPanel(r); - float now = Time.unscaledTime; - float invY = ResolveYScale(out float yMax); + var now = Time.unscaledTime; + var invY = ResolveYScale(out var yMax); // Draw metrics (batched, column-envelope) DrawMetric(r, now, yMax, invY, totalMetric); if (cpuMetric.enabled) DrawMetric(r, now, yMax, invY, cpuMetric); if (gpuMetric.enabled) DrawMetric(r, now, yMax, invY, gpuMetric); - for (int i = 0; i < customMetrics.Count; i++) + for (var i = 0; i < customMetrics.Count; i++) if (customMetrics[i].enabled) DrawMetric(r, now, yMax, invY, customMetrics[i]); @@ -471,49 +500,10 @@ void OnGUI() // FPS GUI.Label(new Rect(r.x + 8, r.y + 6, 120, fontSize + 6), fpsText, labelStyle); - if (debugTimings) { - GUI.Label(new Rect(r.x + 120, r.y + 6, 520, fontSize + 6), ftDebugLine, labelStyle); - } - GUI.Label(new Rect(r.x + 120, r.y + 24, 520, fontSize + 6), lastSampleLine, labelStyle); - - // Legends (cached strings) - float lx = r.x + 8, ly = Mathf.Max(r.y + 24, r.yMax - 18 - (fontSize + 4) * (3 + customMetrics.Count)); - GUI.color = totalMetric.color; - GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), totalMetric.cachedLegend, labelStyle); - ly += fontSize + 4; - if (cpuMetric.enabled) - { - GUI.color = cpuMetric.color; - GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), cpuMetric.cachedLegend, labelStyle); - ly += fontSize + 4; - } - - if (gpuMetric.enabled) - { - GUI.color = gpuMetric.color; - GUI.Label(new Rect(lx, ly, 1000, fontSize + 6), gpuMetric.cachedLegend, labelStyle); - ly += fontSize + 4; - } - - for (int i = 0; i < customMetrics.Count; i++) - { - var m = customMetrics[i]; - if (!m.enabled || !m.stats.valid) continue; - GUI.color = m.color; - GUI.Label(new Rect(lx, ly, 1400, fontSize + 6), m.cachedLegend, labelStyle); - ly += fontSize + 4; - } - if (cpuMetric.stats.valid && gpuMetric.stats.valid) - { - float delta = cpuMetric.stats.avg - gpuMetric.stats.avg; // >0 => CPU heavier - string bound = delta > 0.05f ? "CPU-bound" : (delta < -0.05f ? "GPU-bound" : "Balanced"); - string deltaTxt = $"Δ (CPU-GPU): {delta:+0.00;-0.00;0.00} ms • {bound}"; - GUI.color = Color.white; - GUI.Label(new Rect(lx, ly + 4, 420, fontSize + 6), deltaTxt, labelStyle); - } - - GUI.color = Color.white; + // Right-side stats panel + var statsRect = new Rect(r.xMax, r.y, statsPanelWidth, r.height); + DrawStatsPanel(statsRect); // Time axis labels GUI.Label(new Rect(r.xMax - 100, r.yMax + 2, 100, fontSize + 4), "now", labelStyle); @@ -542,14 +532,14 @@ Rect ResolveRect() float ResolveYScale(out float yMaxOut) { - float yMaxLocal = yMaxMs; + var yMaxLocal = yMaxMs; if (autoY) { - float maxSeen = 0f; + 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 (int i = 0; i < customMetrics.Count; i++) + 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; @@ -571,49 +561,49 @@ void DrawMetric(Rect r, float now, float yMax, float invY, Metric m) else { // Fallback simple poly (batched) - BuildPolylinePoints(r, now, invY, m.values, out var pts, out int n); + 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) { - int w = Mathf.Max(2, Mathf.RoundToInt(r.width)); + var w = Mathf.Max(2, Mathf.RoundToInt(r.width)); EnsureColumnBuffers(w); // reset - for (int i = 0; i < w; i++) + for (var i = 0; i < w; i++) { colMin[i] = float.PositiveInfinity; colMax[i] = float.NegativeInfinity; } // fill per-column min/max - float minTime = now - windowSeconds; - int start = (head - count + capacity) % capacity; - for (int i = 0; i < count; i++) + var minTime = now - windowSeconds; + var start = (head - count + capacity) % capacity; + for (var i = 0; i < count; i++) { - int idx = (start + i) % capacity; - float t = timestamps[idx]; + var idx = (start + i) % capacity; + var t = timestamps[idx]; if (t < minTime) continue; - float x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds); - int cx = Mathf.Clamp(Mathf.RoundToInt((w - 1) * x01), 0, w - 1); + var x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds); + var cx = Mathf.Clamp(Mathf.RoundToInt((w - 1) * x01), 0, w - 1); - float y01 = Mathf.Clamp01(m.values[idx] * invY); - float y = Mathf.Lerp(r.yMax, r.y, y01); + 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) - int nPts = 0; - for (int x = 0; x < w; x++) + var nPts = 0; + for (var x = 0; x < w; x++) { if (!float.IsFinite(colMin[x])) continue; - float mx = r.x + (x / (float)(w - 1)) * r.width; - float my = 0.5f * (colMin[x] + colMax[x]); + 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); } @@ -623,20 +613,20 @@ void DrawMetricColumnEnvelope(Rect r, float now, float yMax, float invY, Metric void BuildPolylinePoints(Rect r, float now, float invY, float[] src, out Vector2[] pts, out int n) { - int start = (head - count + capacity) % capacity; - int cap = Mathf.Min(count, Mathf.RoundToInt(r.width)); // rough cap + 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; - float minTime = now - windowSeconds; - for (int i = 0; i < count; i++) + var minTime = now - windowSeconds; + for (var i = 0; i < count; i++) { - int idx = (start + i) % capacity; - float t = timestamps[idx]; + var idx = (start + i) % capacity; + var t = timestamps[idx]; if (t < minTime) continue; - float x01 = 1f - Mathf.Clamp01((now - t) / windowSeconds); - float x = Mathf.Lerp(r.x, r.xMax, x01); - float y01 = Mathf.Clamp01(src[idx] * invY); - float y = Mathf.Lerp(r.yMax, r.y, y01); + 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); } @@ -647,7 +637,7 @@ void BuildPolylinePoints(Rect r, float now, float invY, float[] src, out Vector2 static void EnsureLineMaterial() { if (lineMat != null) return; - Shader s = Shader.Find("Hidden/Internal-Colored"); + 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); @@ -665,13 +655,13 @@ static void DrawPolylineBatched(Vector2[] pts, int n, Color c, float width) GL.Begin(GL.QUADS); GL.Color(c); - for (int i = 0; i < n - 1; i++) + for (var i = 0; i < n - 1; i++) { Vector2 a = pts[i], b = pts[i + 1]; - Vector2 d = b - a; - float len = d.magnitude; + var d = b - a; + var len = d.magnitude; if (len <= 0.001f) continue; - Vector2 nrm = new Vector2(-d.y, d.x) / len * (width * 0.5f); + var nrm = new Vector2(-d.y, d.x) / len * (width * 0.5f); GL.Vertex(a - nrm); GL.Vertex(a + nrm); GL.Vertex(b + nrm); @@ -682,41 +672,101 @@ static void DrawPolylineBatched(Vector2[] pts, int n, Color c, float width) GL.PopMatrix(); } - void DrawBackgroundAndGrid(Rect r) + void DrawBackgroundGridAndPanel(Rect graphRect) { - // background + var full = new Rect(graphRect.x, graphRect.y, graphRect.width + statsPanelWidth, graphRect.height); EnsureLineMaterial(); + + // Full background (graph + panel) lineMat.SetPass(0); GL.PushMatrix(); GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0); GL.Begin(GL.QUADS); GL.Color(backgroundColor); - 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.Vertex3(full.x, full.y, 0); + GL.Vertex3(full.xMax, full.y, 0); + GL.Vertex3(full.xMax, full.yMax, 0); + GL.Vertex3(full.x, full.yMax, 0); GL.End(); GL.PopMatrix(); - // border + // Border around full DrawPolylineBatched( new[] { - new Vector2(r.x, r.y), new Vector2(r.xMax, r.y), new Vector2(r.xMax, r.yMax), new Vector2(r.x, r.yMax), - new Vector2(r.x, r.y) + new Vector2(full.x, full.y), new Vector2(full.xMax, full.y), + new Vector2(full.xMax, full.yMax), new Vector2(full.x, full.yMax), + new Vector2(full.x, full.y) }, 5, borderColor, gridLineWidth); - // horizontal grid + // Vertical separator between graph and panel + DrawPolylineBatched(new[] + { + new Vector2(graphRect.xMax, graphRect.y), + new Vector2(graphRect.xMax, graphRect.yMax) + }, 2, borderColor, gridLineWidth); + + // Horizontal grid inside the graph only if (gridLinesY > 0) { - for (int i = 1; i < gridLinesY; i++) + for (var i = 1; i < gridLinesY; i++) { - float y = Mathf.Lerp(r.y, r.yMax, i / (float)gridLinesY); - DrawPolylineBatched(new[] { new Vector2(r.x, y), new Vector2(r.xMax, y) }, 2, borderColor, 1f); + 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 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 = 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; + } + + Rect lastRect; void EnsureColumnBuffers(int w) From 87766efcbd1e074110357eecedf88e6613238593 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 25 Sep 2025 22:54:03 +0200 Subject: [PATCH 08/51] perf: Fix stats window placing. --- .../Game/FramePacingGraph.cs | 207 ++++++++++++------ 1 file changed, 137 insertions(+), 70 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 3dbd661d7..13c88fe83 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -10,13 +10,19 @@ public class FramePacingGraph : MonoBehaviour public InputActionReference toggleAction; [Header("Graph Layout (pixels)")] public Vector2 anchorOffset = new Vector2(24f, 24f); - public Vector2 size = new Vector2(560f, 220f); + public Vector2 size = new Vector2(kStatsPanelWidth, 220f); public GraphAnchor anchor = GraphAnchor.TopLeft; - [Header("Stats Panel")] [Tooltip("Width of the right-side stats panel (pixels).")] - public float statsPanelWidth = 260f; + [Header("Stats Panel (independent)")] + public bool statsPanelEnabled = true; - [Tooltip("Padding inside the right-side stats panel (pixels).")] + [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)] @@ -61,13 +67,15 @@ public class FramePacingGraph : MonoBehaviour [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 - // Public API for custom metrics public int RegisterCustomMetric(string name, Color color, Func sampler, float scale = 1f, bool enabled = true) @@ -150,7 +158,7 @@ public Metric(string name, Color color, Func sampler, float scale, bool e // Cached GUIStyle GUIStyle labelStyle; -// 2) Awake(): build a GUIStyle without GUI.skin and set initialized = true at the end + // 2) Awake(): build a GUIStyle without GUI.skin and set initialized = true at the end void Awake() { visible = startVisible; @@ -200,18 +208,16 @@ void Awake() 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 + // 3) OnValidate(): only resize when we're initialized void OnValidate() { if (labelStyle != null) @@ -226,7 +232,6 @@ void OnValidate() monoStyle.normal.textColor = axisTextColor; } - var needed = Mathf.Max(8, Mathf.CeilToInt(windowSeconds * maxExpectedFps)); if (Application.isPlaying && initialized && needed != capacity) { @@ -235,7 +240,7 @@ void OnValidate() } -// 4) ResizeCapacity(): allocate fresh arrays & reset counters + // 4) ResizeCapacity(): allocate fresh arrays & reset counters void ResizeCapacity(int newCap) { capacity = newCap; @@ -252,8 +257,7 @@ void ResizeCapacity(int newCap) count = 0; } - -// 5) ResizeMetric(): null-safe and use the new capacity + // 5) ResizeMetric(): null-safe and use the new capacity static void ResizeMetric(Metric m, int newCap) { if (m == null) return; @@ -262,7 +266,6 @@ static void ResizeMetric(Metric m, int newCap) m.cachedLegend = m.name; } - void OnToggle(InputAction.CallbackContext _) => visible = !visible; void Update() @@ -325,8 +328,7 @@ void Update() var a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau); smoothedFps = Mathf.Lerp(smoothedFps, instFps, a); fpsTextTimer += Time.unscaledDeltaTime; - if (fpsTextTimer >= 0.15f) - { + if (fpsTextTimer >= 0.15f) { fpsTextTimer = 0f; fpsText = $"{smoothedFps:0.0} FPS"; } @@ -380,7 +382,6 @@ float EstimateCpuBusyMs(FrameTiming ft) return Mathf.Min(cpuFrame, Mathf.Max(rendFrame, mainFrame)); } - void WriteSample(float now, float totalMs, float cpuMs, float gpuMs) { timestamps[head] = now; @@ -454,7 +455,6 @@ int CollectVisibleValues(float[] src, float[] dst, float seconds, out float sum) return visible; } - static float PercentileSorted(float[] sorted, int n, float p) { if (n == 0) return 0f; @@ -475,39 +475,41 @@ void OnGUI() var evt = Event.current; if (evt == null || evt.type != EventType.Repaint) return; - var r = ResolveRect(); - if (r.width <= 4f || r.height <= 4f) return; + // Graph + var graphRect = ResolveRect(); + if (graphRect.width <= 4f || graphRect.height <= 4f) return; - // Draw full background (graph + right panel) and grid - DrawBackgroundGridAndPanel(r); + DrawGraphBackgroundAndGrid(graphRect); var now = Time.unscaledTime; var invY = ResolveYScale(out var yMax); // Draw metrics (batched, column-envelope) - DrawMetric(r, now, yMax, invY, totalMetric); - if (cpuMetric.enabled) DrawMetric(r, now, yMax, invY, cpuMetric); - if (gpuMetric.enabled) DrawMetric(r, now, yMax, invY, gpuMetric); + 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(r, now, yMax, invY, customMetrics[i]); + DrawMetric(graphRect, now, yMax, invY, customMetrics[i]); - // Labels (legend + axis + fps) - GUI.Label(new Rect(r.x + 6, r.y - (fontSize + 2), 120, fontSize + 4), $"{yMax:0.#} ms", labelStyle); - GUI.Label(new Rect(r.x + 6, r.y + r.height * 0.5f - (fontSize + 2), 120, fontSize + 4), - $"{(yMax * 0.5f):0.#} ms", labelStyle); - GUI.Label(new Rect(r.x + 6, r.yMax - (fontSize + 2), 120, fontSize + 4), $"0 ms", labelStyle); + // 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); - // FPS - GUI.Label(new Rect(r.x + 8, r.y + 6, 120, fontSize + 6), fpsText, labelStyle); - - // Right-side stats panel - var statsRect = new Rect(r.xMax, r.y, statsPanelWidth, r.height); - DrawStatsPanel(statsRect); + // FPS on the graph + GUI.Label(new Rect(graphRect.x + 8, graphRect.y + 6, 120, fontSize + 6), fpsText, labelStyle); // Time axis labels - GUI.Label(new Rect(r.xMax - 100, r.yMax + 2, 100, fontSize + 4), "now", labelStyle); - GUI.Label(new Rect(r.x, r.yMax + 2, 180, fontSize + 4), $"-{windowSeconds:0.#} s", labelStyle); + 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() @@ -526,10 +528,65 @@ Rect ResolveRect() 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; + } + + // 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; @@ -672,41 +729,22 @@ static void DrawPolylineBatched(Vector2[] pts, int n, Color c, float width) GL.PopMatrix(); } - void DrawBackgroundGridAndPanel(Rect graphRect) + // --- Backgrounds & grids (separate for graph vs. stats) --- + void DrawGraphBackgroundAndGrid(Rect graphRect) { - var full = new Rect(graphRect.x, graphRect.y, graphRect.width + statsPanelWidth, graphRect.height); - EnsureLineMaterial(); - - // Full background (graph + panel) - lineMat.SetPass(0); - GL.PushMatrix(); - GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0); - GL.Begin(GL.QUADS); - GL.Color(backgroundColor); - GL.Vertex3(full.x, full.y, 0); - GL.Vertex3(full.xMax, full.y, 0); - GL.Vertex3(full.xMax, full.yMax, 0); - GL.Vertex3(full.x, full.yMax, 0); - GL.End(); - GL.PopMatrix(); + // Graph background fill + FillRect(graphRect, backgroundColor); - // Border around full + // Border around graph DrawPolylineBatched( new[] { - new Vector2(full.x, full.y), new Vector2(full.xMax, full.y), - new Vector2(full.xMax, full.yMax), new Vector2(full.x, full.yMax), - new Vector2(full.x, full.y) + 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); - // Vertical separator between graph and panel - DrawPolylineBatched(new[] - { - new Vector2(graphRect.xMax, graphRect.y), - new Vector2(graphRect.xMax, graphRect.yMax) - }, 2, borderColor, gridLineWidth); - - // Horizontal grid inside the graph only + // Horizontal grid inside the graph if (gridLinesY > 0) { for (var i = 1; i < gridLinesY; i++) @@ -718,6 +756,37 @@ void DrawBackgroundGridAndPanel(Rect graphRect) } } + 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; @@ -746,7 +815,6 @@ void DrawStatsPanel(Rect statsRect) GUI.color = Color.white; } - static string TruncPad(string s, int max) { if (string.IsNullOrEmpty(s)) return new string(' ', max); @@ -766,7 +834,6 @@ void DrawStatsLine(Metric m, ref float y, float x, float w) y += fontSize + 4; } - Rect lastRect; void EnsureColumnBuffers(int w) From 5721c00d0b5f23884f96df4ad2b4e9f816d80712 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 25 Sep 2025 23:53:11 +0200 Subject: [PATCH 09/51] perf: Make FPS big. --- .../Game/FramePacingGraph.cs | 93 ++++++++++++++----- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 13c88fe83..d37e4797f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -10,11 +10,10 @@ public class FramePacingGraph : MonoBehaviour public InputActionReference toggleAction; [Header("Graph Layout (pixels)")] public Vector2 anchorOffset = new Vector2(24f, 24f); - public Vector2 size = new Vector2(kStatsPanelWidth, 220f); + public Vector2 size = new Vector2(kStatsPanelWidth, 150); public GraphAnchor anchor = GraphAnchor.TopLeft; - [Header("Stats Panel (independent)")] - public bool statsPanelEnabled = true; + [Header("Stats Panel (independent)")] public bool statsPanelEnabled = true; [Tooltip("Corner of the screen to anchor the stats panel to.")] public GraphAnchor statsAnchor = GraphAnchor.TopLeft; @@ -76,6 +75,10 @@ public class FramePacingGraph : MonoBehaviour 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) @@ -316,9 +319,13 @@ void Update() } var v = 0f; - try { + try + { v = m.sampler() * m.scale; - } catch { } + } + catch + { + } m.values[idxPrev] = v; } @@ -328,7 +335,8 @@ void Update() var a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau); smoothedFps = Mathf.Lerp(smoothedFps, instFps, a); fpsTextTimer += Time.unscaledDeltaTime; - if (fpsTextTimer >= 0.15f) { + if (fpsTextTimer >= 0.15f) + { fpsTextTimer = 0f; fpsText = $"{smoothedFps:0.0} FPS"; } @@ -480,6 +488,7 @@ void OnGUI() if (graphRect.width <= 4f || graphRect.height <= 4f) return; DrawGraphBackgroundAndGrid(graphRect); + DrawFpsBadge(graphRect); var now = Time.unscaledTime; var invY = ResolveYScale(out var yMax); @@ -493,13 +502,12 @@ void OnGUI() 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.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); - // FPS on the graph - GUI.Label(new Rect(graphRect.x + 8, graphRect.y + 6, 120, fontSize + 6), fpsText, labelStyle); - // Time axis labels GUI.Label(new Rect(graphRect.xMax - 100, graphRect.yMax + 2, 100, fontSize + 4), "now", labelStyle); @@ -528,23 +536,26 @@ Rect ResolveRect() 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 + 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 + 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 + float y = bottomAnchor + ? (Screen.height - h - statsOffset.y) // offset from bottom + : statsOffset.y; // offset from top return new Rect(x, y, w, h); } @@ -555,7 +566,7 @@ float ComputeStatsPanelHeight() // Match the same vertical spacing used when drawing. float pad = statsPanelPadding; float headerH = fontSize + 6f; - float lineH = fontSize + 4f; + float lineH = fontSize + 4f; int lines = 0; @@ -564,8 +575,8 @@ float ComputeStatsPanelHeight() // 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; + 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++) @@ -585,8 +596,6 @@ float ComputeStatsPanelHeight() } - - float ResolveYScale(out float yMaxOut) { var yMaxLocal = yMaxMs; @@ -756,6 +765,46 @@ void DrawGraphBackgroundAndGrid(Rect graphRect) } } + 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) From 16ce6d25a7de34bf4581bad84944037f241c4f07 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Feb 2026 23:48:35 +0100 Subject: [PATCH 10/51] physics: Commit old code --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 93 +++++++------ .../Game/PhysicsPopulate.cs | 2 + .../VisualPinball.Unity/Game/PhysicsState.cs | 5 +- .../Game/PhysicsUpdateJob.cs | 129 +++++++++--------- .../Physics/NativeColliders.cs | 5 +- 5 files changed, 122 insertions(+), 112 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index a89f3bb12..d56af04ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -21,7 +21,6 @@ using System.Linq; using NativeTrees; using Unity.Collections; -using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using VisualPinball.Engine.Common; @@ -64,7 +63,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [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 NativeParallelHashSet _overlappingColliders = new(0, Allocator.Persistent); + [NonSerialized] private PhysicsEnv _physicsEnv; [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)); @@ -137,7 +137,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public void ScheduleAction(uint timeoutMs, Action action) { lock (_scheduledActions) { - _scheduledActions.Add(new ScheduledAction(_physicsEnv.Ref[0].CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); + _scheduledActions.Add(new ScheduledAction(_physicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); } } @@ -164,8 +164,8 @@ public void ScheduleAction(uint timeoutMs, Action action) 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 uint TimeMsec => _physicsEnv.TimeMsec; + internal Random Random => _physicsEnv.Random; internal void Register(T item) where T : MonoBehaviour { var go = item.gameObject; @@ -242,7 +242,7 @@ private void Awake() _player = GetComponentInParent(); _physicsMovements = new PhysicsMovements(); _insideOfs = new InsideOfs(Allocator.Persistent); - _physicsEnv.Ref[0] = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); + _physicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); _colliderComponents = GetComponentsInChildren(); _kinematicColliderComponents = _colliderComponents.Where(c => c.IsKinematic).ToArray(); ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); @@ -321,8 +321,7 @@ private void Start() internal PhysicsState CreateState() { var events = _eventQueue.Ref.AsParallelWriter(); - var env = _physicsEnv.Ref[0]; - return new PhysicsState(ref env, ref _octree, ref _colliders, ref _kinematicColliders, + 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, @@ -349,39 +348,39 @@ private void Update() // 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, - }; + // 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(); @@ -392,7 +391,13 @@ private void Update() } // run physics loop - updatePhysics.Run(); + PhysicsUpdate.Execute( + ref state, + ref _physicsEnv, + ref _overlappingColliders, + _playfieldBounds, + NowUsec + ); // dequeue events while (_eventQueue.Ref.TryDequeue(out var eventData)) { @@ -402,7 +407,7 @@ private void Update() // 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) { + if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { _scheduledActions[i].Action(); _scheduledActions.RemoveAt(i); } @@ -428,7 +433,7 @@ private void Update() private void OnDestroy() { - _physicsEnv.Ref.Dispose(); + _overlappingColliders.Dispose(); _eventQueue.Ref.Dispose(); _ballStates.Ref.Dispose(); ElasticityOverVelocityLUTs.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs index aa93705ea..df37d4d4d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsPopulate.cs @@ -23,6 +23,8 @@ public static unsafe void PopulateUnsafe(IntPtr collidersPtr, IntPtr octreePtr) } } + + [BurstCompile] public static void Populate(ref NativeColliders colliders, ref NativeOctree octree) { for (var i = 0; i < colliders.Length; 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/PhysicsUpdateJob.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs index 7e17cd31e..9d117cae8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdateJob.cs @@ -13,75 +13,73 @@ // 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.Jobs; -using Unity.Mathematics; +using Unity.Collections.LowLevel.Unsafe; using VisualPinball.Engine.Common; + // ReSharper disable InconsistentNaming namespace VisualPinball.Unity { - [BurstCompile(CompileSynchronously = true)] - internal struct PhysicsUpdateJob : IJob + [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; - - public void Execute() + // [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, in AABB playfieldBounds, ulong initialTimeUsec) { - 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); + // ref var state = ref UnsafeUtility.AsRef(statePtr.ToPointer()); + // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer()); + // ref var overlappingColliders = ref UnsafeUtility.AsRef>(overlappingCollidersPtr.ToPointer()); + 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); + var kineticOctree = PhysicsKinematics.CreateOctree(ref state, in playfieldBounds); - while (env.CurPhysicsFrameTime < InitialTimeUsec) // loop here until current (real) time matches the physics (simulated) time + 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)); @@ -96,27 +94,27 @@ public void Execute() } } // flippers - using (var enumerator = FlipperStates.GetEnumerator()) { + using (var enumerator = state.FlipperStates.GetEnumerator()) { while (enumerator.MoveNext()) { FlipperVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value); } } // gates - using (var enumerator = GateStates.GetEnumerator()) { + 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 = PlungerStates.GetEnumerator()) { + 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 = SpinnerStates.GetEnumerator()) { + using (var enumerator = state.SpinnerStates.GetEnumerator()) { while (enumerator.MoveNext()) { ref var spinnerState = ref enumerator.Current.Value; SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); @@ -126,7 +124,7 @@ public void Execute() #endregion // primary physics loop - cycle.Simulate(ref state, in PlayfieldBounds, ref OverlappingColliders, ref kineticOctree, physicsDiffTime); + cycle.Simulate(ref state, in playfieldBounds, ref overlappingColliders, ref kineticOctree, physicsDiffTime); // ball trail, keep old pos of balls using (var enumerator = state.Balls.GetEnumerator()) { @@ -140,7 +138,7 @@ public void Execute() // todo it should be enough to calculate animations only once per frame // bumper - using (var enumerator = BumperStates.GetEnumerator()) { + using (var enumerator = state.BumperStates.GetEnumerator()) { while (enumerator.MoveNext()) { ref var bumperState = ref enumerator.Current.Value; if (bumperState.RingItemId != 0) { @@ -153,7 +151,7 @@ public void Execute() } // drop target - using (var enumerator = DropTargetStates.GetEnumerator()) { + 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); @@ -161,7 +159,7 @@ public void Execute() } // hit target - using (var enumerator = HitTargetStates.GetEnumerator()) { + using (var enumerator = state.HitTargetStates.GetEnumerator()) { while (enumerator.MoveNext()) { ref var hitTargetState = ref enumerator.Current.Value; HitTargetAnimation.Update(ref hitTargetState.Animation, in hitTargetState.Static, env.TimeMsec); @@ -169,7 +167,7 @@ public void Execute() } // plunger - using (var enumerator = PlungerStates.GetEnumerator()) { + 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); @@ -177,7 +175,7 @@ public void Execute() } // trigger - using (var enumerator = TriggerStates.GetEnumerator()) { + 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, PhysicsConstants.PhysicsStepTime / 1000f); @@ -190,8 +188,7 @@ public void Execute() env.NextPhysicsFrameTime += PhysicsConstants.PhysicsStepTime; } - PhysicsEnv[0] = env; kineticOctree.Dispose(); } } -} +} \ No newline at end of file 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 From 6a97e674db1f220d89a37e81e5eeb4735b485382 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Feb 2026 00:51:01 +0100 Subject: [PATCH 11/51] physics: Add POC of simulation threads. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 7 +- .../{PhysicsUpdateJob.cs => PhysicsUpdate.cs} | 0 ...pdateJob.cs.meta => PhysicsUpdate.cs.meta} | 0 .../VisualPinball.Unity/Simulation.meta | 8 + .../Simulation/InputEventBuffer.cs | 121 ++++++ .../Simulation/InputEventBuffer.cs.meta | 2 + .../Simulation/NativeInputApi.cs | 136 ++++++ .../Simulation/NativeInputApi.cs.meta | 2 + .../Simulation/NativeInputManager.cs | 224 ++++++++++ .../Simulation/NativeInputManager.cs.meta | 2 + .../Simulation/SimulationState.cs | 174 ++++++++ .../Simulation/SimulationState.cs.meta | 2 + .../Simulation/SimulationThread.cs | 400 ++++++++++++++++++ .../Simulation/SimulationThread.cs.meta | 2 + .../Simulation/SimulationThreadComponent.cs | 226 ++++++++++ .../SimulationThreadComponent.cs.meta | 2 + 16 files changed, 1305 insertions(+), 3 deletions(-) rename VisualPinball.Unity/VisualPinball.Unity/Game/{PhysicsUpdateJob.cs => PhysicsUpdate.cs} (100%) rename VisualPinball.Unity/VisualPinball.Unity/Game/{PhysicsUpdateJob.cs.meta => PhysicsUpdate.cs.meta} (100%) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/InputEventBuffer.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index d56af04ac..4479f34f9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -251,15 +251,16 @@ private void Awake() private void Start() { - // create static octree var sw = Stopwatch.StartNew(); var playfield = GetComponentInChildren(); + // register frame pacing stats var stats = FindFirstObjectByType(); if (stats) { stats.RegisterCustomMetric("Physics", Color.magenta, () => _lastFrameTimeMs); } + // 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); @@ -347,7 +348,7 @@ private void Update() } // prepare job - var events = _eventQueue.Ref.AsParallelWriter(); + // var events = _eventQueue.Ref.AsParallelWriter(); // using var overlappingColliders = new NativeParallelHashSet(0, Allocator.TempJob); // var updatePhysics = new PhysicsUpdateJob { @@ -507,4 +508,4 @@ private static ICollider[] GetColliders(int itemId, ref NativeParallelHashMap + /// 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..db04d1328 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -0,0 +1,136 @@ +// 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, + 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..dbd4567c1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -0,0 +1,224 @@ +// 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 +{ + /// + /// 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(); + + #region Fields + + private static NativeInputManager _instance; + private static readonly object _instanceLock = new object(); + + private SimulationThread _simulationThread; + private bool _initialized = false; + private bool _polling = false; + + // 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; + } + } + + 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("[NativeInputManager] Failed to initialize native input system"); + return false; + } + + _initialized = true; + Logger.Info("[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 (!_initialized) + { + Logger.Error("[NativeInputManager] Not initialized"); + return false; + } + + if (_polling) + { + Logger.Warn("[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("[NativeInputManager] Failed to start polling"); + return false; + } + + _polling = true; + Logger.Info($"[NativeInputManager] Started polling at {pollIntervalUs}μs interval ({1000000 / pollIntervalUs} Hz)"); + + return true; + } + + /// + /// Stop input polling + /// + public void StopPolling() + { + if (!_polling) return; + + NativeInputApi.VpeInputStopPolling(); + _polling = false; + + Logger.Info("[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); + + // Start + AddBinding(NativeInputApi.InputAction.Start, NativeInputApi.KeyCode.Return); + + // Plunger + AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Space); + + Logger.Info($"[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) + { + // Forward to simulation thread via ring buffer + _instance?._simulationThread?.EnqueueInputEvent(evt); + } + + #endregion + + #region Dispose + + public void Dispose() + { + StopPolling(); + + if (_initialized) + { + NativeInputApi.VpeInputShutdown(); + _initialized = false; + } + + _instance = null; + } + + #endregion + } +} \ No newline at end of file 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..70a2876c0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -0,0 +1,174 @@ +// 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 Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; + +namespace VisualPinball.Unity.Simulation +{ + /// + /// Shared simulation state between simulation thread and Unity main thread. + /// Uses double-buffering for lock-free reads. + /// + 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; + + #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 + /// + public struct Snapshot + { + // Timing + public long SimulationTimeUsec; + public long RealTimeUsec; + + // PinMAME state + public NativeArray CoilStates; + public NativeArray LampStates; + public NativeArray GIStates; + + // 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; + + public void Allocate() + { + CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); + LampStates = new NativeArray(MaxLamps, Allocator.Persistent); + GIStates = new NativeArray(MaxGIStrings, Allocator.Persistent); + } + + public void Dispose() + { + if (CoilStates.IsCreated) CoilStates.Dispose(); + if (LampStates.IsCreated) LampStates.Dispose(); + if (GIStates.IsCreated) GIStates.Dispose(); + } + } + + #endregion + + #region Fields + + // Double-buffered snapshots + private Snapshot _backBuffer; + private Snapshot _frontBuffer; + + // Atomic pointer swap for lock-free reading + private volatile int _currentFrontBuffer = 0; // 0 = _frontBuffer, 1 = _backBuffer + + private bool _disposed = false; + + #endregion + + #region Constructor / Dispose + + public SimulationState() + { + _frontBuffer.Allocate(); + _backBuffer.Allocate(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _frontBuffer.Dispose(); + _backBuffer.Dispose(); + } + + #endregion + + #region Write (Simulation Thread) + + /// + /// Get the back buffer for writing. + /// Called by simulation thread only. + /// + public ref Snapshot GetBackBuffer() + { + return ref (_currentFrontBuffer == 0 ? ref _backBuffer : ref _frontBuffer); + } + + /// + /// Swap buffers atomically. + /// Called by simulation thread after writing to back buffer. + /// + public void SwapBuffers() + { + // Atomic swap + _currentFrontBuffer = 1 - _currentFrontBuffer; + } + + #endregion + + #region Read (Main Thread) + + /// + /// Get the front buffer for reading. + /// Called by Unity main thread only. + /// Lock-free read. + /// + public ref readonly Snapshot GetFrontBuffer() + { + return ref (_currentFrontBuffer == 0 ? ref _frontBuffer : ref _backBuffer); + } + + #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..adc7ffea6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -0,0 +1,400 @@ +// 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.Runtime; +using System.Threading; +using NLog; +using Logger = NLog.Logger; + +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(); + + #region Constants + + private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds + private const long BusyWaitThresholdUsec = 100; // Last 100μs busy-wait for precision + private const double TickIntervalSeconds = 0.001; // 1ms in seconds + + #endregion + + #region Fields + + private readonly PhysicsEngine _physicsEngine; + private readonly IGamelogicEngine _gamelogicEngine; + private readonly InputEventBuffer _inputBuffer; + private readonly SimulationState _sharedState; + + private Thread _thread; + private volatile bool _running = false; + private volatile bool _paused = false; + + // Timing + private long _lastTickUsec; + private long _simulationTimeUsec; + private double _pinmameSimulationTimeSeconds; + + // Input state tracking + private readonly Dictionary _inputStates = new(); + + // Statistics + private long _tickCount = 0; + private long _inputEventsProcessed = 0; + + #endregion + + #region Constructor + + public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine) + { + _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); + _gamelogicEngine = gamelogicEngine; + + _inputBuffer = new InputEventBuffer(1024); + _sharedState = new SimulationState(); + + // Initialize input states + foreach (NativeInputApi.InputAction action in Enum.GetValues(typeof(NativeInputApi.InputAction))) + { + _inputStates[action] = false; + } + } + + #endregion + + #region Public API + + /// + /// Start the simulation thread + /// + public void Start() + { + if (_running) return; + + _running = true; + _paused = false; + _lastTickUsec = NativeInputApi.VpeGetTimestampUsec(); + _simulationTimeUsec = 0; + _pinmameSimulationTimeSeconds = 0.0; + _tickCount = 0; + + _thread = new Thread(SimulationThreadFunc) + { + Name = "VPE Simulation Thread", + IsBackground = false, + Priority = ThreadPriority.Highest + }; + _thread.Start(); + + Logger.Info("[SimulationThread] Started at 1000 Hz"); + } + + /// + /// Stop the simulation thread + /// + public void Stop() + { + if (!_running) return; + + _running = false; + + if (_thread != null && _thread.IsAlive) + { + _thread.Join(5000); // Wait up to 5 seconds + } + + Logger.Info($"[SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events"); + } + + /// + /// Pause the simulation (for debugging) + /// + public void Pause() + { + _paused = true; + } + + /// + /// Resume the simulation + /// + public void Resume() + { + _paused = false; + } + + /// + /// Enqueue an input event from the input polling thread + /// + public void EnqueueInputEvent(NativeInputApi.InputEvent evt) + { + _inputBuffer.TryEnqueue(evt); + } + + /// + /// Get the current shared state (for main thread to read) + /// + public ref readonly SimulationState.Snapshot GetSharedState() + { + return ref _sharedState.GetFrontBuffer(); + } + + #endregion + + #region Simulation Thread + + private void SimulationThreadFunc() + { + // Set thread priority to time-critical + NativeInputApi.VpeSetThreadPriority(); + + // 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("[SimulationThread] No-GC region enabled"); + } + } + catch (Exception ex) + { + Logger.Warn($"[SimulationThread] Failed to start no-GC region: {ex.Message}"); + } + + try + { + // Main simulation loop + while (_running) + { + if (_paused) + { + Thread.Sleep(10); + continue; + } + + // Timing: Sleep until next tick, then busy-wait for precision + long targetTimeUsec = _lastTickUsec + TickIntervalUsec; + long nowUsec = NativeInputApi.VpeGetTimestampUsec(); + long sleepUsec = targetTimeUsec - nowUsec; + + if (sleepUsec > BusyWaitThresholdUsec) + { + // Sleep most of the time to avoid CPU waste + Thread.Sleep((int)((sleepUsec - BusyWaitThresholdUsec) / 1000)); + } + + // Busy-wait for precision (last 100μs) + while (NativeInputApi.VpeGetTimestampUsec() < targetTimeUsec) + { + Thread.SpinWait(10); // ~40ns per iteration + } + + // Execute simulation tick (hot path - must be allocation-free!) + SimulationTick(); + + _lastTickUsec = targetTimeUsec; + _tickCount++; + } + } + finally + { + // Exit no-GC region + if (noGcRegion && GCSettings.LatencyMode == GCLatencyMode.NoGCRegion) + { + try + { + GC.EndNoGCRegion(); + Logger.Info("[SimulationThread] No-GC region ended"); + } + catch { } + } + } + } + + /// + /// Single simulation tick - MUST BE ALLOCATION-FREE! + /// + private void SimulationTick() + { + // 1. Process input events from ring buffer + ProcessInputEvents(); + + // 2. Advance PinMAME simulation using SetTimeFence + if (_gamelogicEngine != null) + { + AdvancePinMAME(); + } + + // 3. Poll PinMAME outputs (coils, lamps, GI) + if (_gamelogicEngine != null) + { + PollPinMAMEOutputs(); + } + + // 4. Update physics simulation + UpdatePhysics(); + + // 5. Write to shared state and swap buffers + WriteSharedState(); + + // Increment simulation time + _simulationTimeUsec += TickIntervalUsec; + } + + /// + /// Process all pending input events from the ring buffer + /// + private void ProcessInputEvents() + { + while (_inputBuffer.TryDequeue(out var evt)) + { + var action = (NativeInputApi.InputAction)evt.Action; + bool isPressed = evt.Value > 0.5f; + + // Track state change + bool previousState = _inputStates[action]; + _inputStates[action] = isPressed; + + // Only process if state changed + if (previousState != isPressed) + { + HandleInputAction(action, isPressed); + _inputEventsProcessed++; + } + } + } + + /// + /// Handle a single input action (map to switch/coil) + /// + private void HandleInputAction(NativeInputApi.InputAction action, bool isPressed) + { + // Map input actions to switch IDs + // This is a simplified example - actual mapping would come from configuration + switch (action) + { + case NativeInputApi.InputAction.LeftFlipper: + _gamelogicEngine?.Switch("s_flipper_left", isPressed); + break; + + case NativeInputApi.InputAction.RightFlipper: + _gamelogicEngine?.Switch("s_flipper_right", isPressed); + break; + + case NativeInputApi.InputAction.Start: + _gamelogicEngine?.Switch("s_start", isPressed); + break; + + case NativeInputApi.InputAction.Plunge: + _gamelogicEngine?.Switch("s_plunger", isPressed); + break; + + // Add more mappings as needed + } + } + + /// + /// Advance PinMAME simulation to current time using SetTimeFence + /// + private void AdvancePinMAME() + { + // Increment PinMAME time + _pinmameSimulationTimeSeconds += TickIntervalSeconds; + + // Tell PinMAME to run until this time + // Note: This requires the PinMAME API to expose SetTimeFence in C# + // For now, we'll assume it's running asynchronously + // TODO: Add PinmameSetTimeFence to pinmame-dotnet API + } + + /// + /// Poll PinMAME for output changes (coils, lamps, GI) + /// + private void PollPinMAMEOutputs() + { + // This would use the PinMAME API to get changed outputs + // For now, this is a placeholder + // TODO: Implement PinMAME output polling + } + + /// + /// Update physics simulation (1ms step) + /// + private void UpdatePhysics() + { + // TODO: Refactor PhysicsEngine to expose ExecuteTick(long deltaUsec) method + // + // Required changes to PhysicsEngine.cs: + // 1. Add public method: ExecuteTick(long deltaUsec) + // 2. Expose PhysicsState for external access (or use internal state) + // 3. Update timing from external source instead of Time.deltaTime + // 4. Ensure thread-safety for concurrent access + // + // Proposed implementation: + // public void ExecuteTick(long deltaUsec) + // { + // _physicsEnv.CurPhysicsFrameTime = deltaUsec; + // _physicsEnv.NextPhysicsFrameTime = deltaUsec + PhysicsConstants.PhysicsStepTime; + // PhysicsUpdate.Execute(ref _physicsState, ref _physicsEnv, ...); + // } + // + // For now, physics continues to run in Unity's Update() loop. + // This integration is tracked in SIMULATION_THREAD_TODO.md Phase 1. + } + + /// + /// Write simulation state to shared memory and swap buffers + /// + private void WriteSharedState() + { + ref var backBuffer = ref _sharedState.GetBackBuffer(); + + // Update timing + backBuffer.SimulationTimeUsec = _simulationTimeUsec; + backBuffer.RealTimeUsec = NativeInputApi.VpeGetTimestampUsec(); + + // Copy PinMAME state (coils, lamps, GI) + // This is where we'd copy the changed outputs from PinMAME + // For now, this is a placeholder + // TODO: Implement state copying + + // Increment physics state version (main thread will detect changes) + backBuffer.PhysicsStateVersion++; + + // Atomically swap buffers (lock-free) + _sharedState.SwapBuffers(); + } + + #endregion + + #region Dispose + + public void Dispose() + { + Stop(); + _inputBuffer?.Dispose(); + _sharedState?.Dispose(); + } + + #endregion + } +} \ No newline at end of file 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..6f18661e9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -0,0 +1,226 @@ +// 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 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 double-buffering + /// + [AddComponentMenu("Visual Pinball/Simulation Thread")] + [RequireComponent(typeof(PhysicsEngine))] + public class SimulationThreadComponent : MonoBehaviour + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + #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; + + #endregion + + #region Unity Lifecycle + + private void Awake() + { + _physicsEngine = GetComponent(); + + // Get gamelogic engine from Player if available + var player = GetComponent(); + if (player != null) + { + _gamelogicEngine = player.GamelogicEngine; + } + } + + private void Start() + { + if (!EnableSimulationThread) + { + Logger.Info("[SimulationThreadComponent] Simulation thread disabled"); + return; + } + + StartSimulation(); + } + + private void Update() + { + if (!_started || _simulationThread == null) return; + + // Read shared state from simulation thread + ref readonly var state = ref _simulationThread.GetSharedState(); + + // Apply state to Unity GameObjects + ApplySimulationState(in state); + + // Show statistics + if (ShowStatistics && Time.time - _lastStatisticsTime >= StatisticsInterval) + { + LogStatistics(in state); + _lastStatisticsTime = Time.time; + } + } + + private void OnDestroy() + { + StopSimulation(); + } + + private void OnApplicationQuit() + { + StopSimulation(); + } + + #endregion + + #region Public API + + /// + /// Start the simulation thread + /// + public void StartSimulation() + { + if (_started) return; + + try + { + // Create simulation thread + _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine); + + // Initialize and start native input if enabled + if (EnableNativeInput) + { + _inputManager = NativeInputManager.Instance; + if (_inputManager.Initialize()) + { + _inputManager.SetSimulationThread(_simulationThread); + _inputManager.StartPolling(InputPollingIntervalUs); + } + else + { + Logger.Warn("[SimulationThreadComponent] Native input not available, falling back to Unity Input System"); + } + } + + // Start simulation thread + _simulationThread.Start(); + + _started = true; + _lastStatisticsTime = Time.time; + + Logger.Info("[SimulationThreadComponent] Simulation started"); + } + catch (Exception ex) + { + Logger.Error($"[SimulationThreadComponent] Failed to start simulation: {ex}"); + } + } + + /// + /// Stop the simulation thread + /// + public void StopSimulation() + { + if (!_started) return; + + _inputManager?.StopPolling(); + _simulationThread?.Stop(); + _simulationThread?.Dispose(); + _simulationThread = null; + + _started = false; + + Logger.Info("[SimulationThreadComponent] Simulation stopped"); + } + + /// + /// Pause the simulation (for debugging) + /// + public void PauseSimulation() + { + _simulationThread?.Pause(); + } + + /// + /// Resume the simulation + /// + public void ResumeSimulation() + { + _simulationThread?.Resume(); + } + + #endregion + + #region Private Methods + + /// + /// Apply simulation state to Unity GameObjects + /// + private void ApplySimulationState(in SimulationState.Snapshot state) + { + // This is where we'd update GameObjects based on the simulation state + // For now, the physics engine will continue to update GameObjects directly + // In a full implementation, we'd read the physics state here and apply it + + // TODO: Apply ball positions, flipper rotations, etc. from shared 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; + + Logger.Info($"[SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}"); + } + + #endregion + } +} \ No newline at end of file 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 From 9250900e9414fdcc50beb16e216ad5c282d5b106 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Feb 2026 01:08:15 +0100 Subject: [PATCH 12/51] physics: Use external timing for polling input data. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 87 +++++++++++-------- .../Simulation/SimulationThread.cs | 70 +++++++++------ .../Simulation/SimulationThreadComponent.cs | 9 +- 3 files changed, 103 insertions(+), 63 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 4479f34f9..cbe9f9221 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -129,6 +129,16 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); + /// + /// Current physics time in microseconds (for external tick support) + /// + private ulong _externalTimeUsec = 0; + + /// + /// Whether to use external timing (simulation thread) or Unity's Time + /// + private bool _useExternalTiming = false; + private float _lastFrameTimeMs; #region API @@ -331,9 +341,48 @@ internal PhysicsState CreateState() ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs); } + /// + /// Enable external timing control (for simulation thread). + /// When enabled, Update() does nothing and ExecuteTick() must be called instead. + /// + /// Whether to enable external timing + public void SetExternalTiming(bool enable) + { + _useExternalTiming = enable; + if (enable) { + _externalTimeUsec = (ulong)(Time.timeAsDouble * 1000000); + } + } + + /// + /// Execute a single physics tick with external timing (for simulation thread). + /// This allows precise control of physics simulation independent of Unity's Update loop. + /// + /// Current time in microseconds + public void ExecuteTick(ulong timeUsec) + { + _externalTimeUsec = timeUsec; + ExecutePhysicsUpdate(timeUsec); + } + private void Update() + { + // Skip update if external timing is enabled (simulation thread controls physics) + if (_useExternalTiming) { + return; + } + + ExecutePhysicsUpdate(NowUsec); + } + + /// + /// Core physics update logic (can be called from Unity Update or simulation thread) + /// + /// Current time in microseconds + private void ExecutePhysicsUpdate(ulong currentTimeUsec) { var sw = Stopwatch.StartNew(); + // check for updated kinematic transforms _updatedKinematicTransforms.Ref.Clear(); foreach (var coll in _kinematicColliderComponents) { @@ -347,42 +396,6 @@ private void Update() coll.OnTransformationChanged(currTransformationMatrix); } - // 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 @@ -397,7 +410,7 @@ private void Update() ref _physicsEnv, ref _overlappingColliders, _playfieldBounds, - NowUsec + currentTimeUsec ); // dequeue events diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index adc7ffea6..95ef31801 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Runtime; using System.Threading; using NLog; @@ -317,13 +318,31 @@ private void HandleInputAction(NativeInputApi.InputAction action, bool isPressed /// private void AdvancePinMAME() { - // Increment PinMAME time + if (_gamelogicEngine == null) return; + + // Increment PinMAME time by 1ms _pinmameSimulationTimeSeconds += TickIntervalSeconds; // Tell PinMAME to run until this time - // Note: This requires the PinMAME API to expose SetTimeFence in C# - // For now, we'll assume it's running asynchronously - // TODO: Add PinmameSetTimeFence to pinmame-dotnet API + // PinMAME will execute in its own thread until it reaches the target time, + // then return control. This provides precise synchronization. + try + { + // Check if this is PinMAME (has SetTimeFence method) + var pinmameType = _gamelogicEngine.GetType(); + var setTimeFenceMethod = pinmameType.GetMethod("SetTimeFence"); + + if (setTimeFenceMethod != null) + { + setTimeFenceMethod.Invoke(_gamelogicEngine, new object[] { _pinmameSimulationTimeSeconds }); + } + } + catch (Exception ex) + { + // Silently ignore if SetTimeFence is not available + // This allows the simulation thread to work with non-PinMAME engines + Logger.Debug($"[SimulationThread] SetTimeFence not available: {ex.Message}"); + } } /// @@ -331,9 +350,23 @@ private void AdvancePinMAME() /// private void PollPinMAMEOutputs() { - // This would use the PinMAME API to get changed outputs - // For now, this is a placeholder - // TODO: Implement PinMAME output polling + if (_gamelogicEngine == null) return; + + // Poll for changed outputs from the gamelogic engine + // These are typically processed via events, but in the simulation thread + // we can poll them directly for lower latency + // + // The gamelogic engine fires events for: + // - OnCoilChanged (solenoids) + // - OnLampChanged (lamps) + // - OnGIChanged (general illumination) + // + // These events are already being fired by the engine's internal threads, + // so we don't need to poll explicitly here. The events will be picked up + // by the main thread's event handlers. + // + // Future optimization: Copy changed states directly to shared state here + // instead of relying on event dispatch queue. } /// @@ -341,24 +374,11 @@ private void PollPinMAMEOutputs() /// private void UpdatePhysics() { - // TODO: Refactor PhysicsEngine to expose ExecuteTick(long deltaUsec) method - // - // Required changes to PhysicsEngine.cs: - // 1. Add public method: ExecuteTick(long deltaUsec) - // 2. Expose PhysicsState for external access (or use internal state) - // 3. Update timing from external source instead of Time.deltaTime - // 4. Ensure thread-safety for concurrent access - // - // Proposed implementation: - // public void ExecuteTick(long deltaUsec) - // { - // _physicsEnv.CurPhysicsFrameTime = deltaUsec; - // _physicsEnv.NextPhysicsFrameTime = deltaUsec + PhysicsConstants.PhysicsStepTime; - // PhysicsUpdate.Execute(ref _physicsState, ref _physicsEnv, ...); - // } - // - // For now, physics continues to run in Unity's Update() loop. - // This integration is tracked in SIMULATION_THREAD_TODO.md Phase 1. + if (_physicsEngine != null) + { + // Execute physics tick with current simulation time + _physicsEngine.ExecuteTick((ulong)_simulationTimeUsec); + } } /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 6f18661e9..72cde1076 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -128,6 +128,10 @@ public void StartSimulation() try { + // 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); @@ -152,7 +156,7 @@ public void StartSimulation() _started = true; _lastStatisticsTime = Time.time; - Logger.Info("[SimulationThreadComponent] Simulation started"); + Logger.Info("[SimulationThreadComponent] Simulation started with external physics timing"); } catch (Exception ex) { @@ -172,6 +176,9 @@ public void StopSimulation() _simulationThread?.Dispose(); _simulationThread = null; + // Restore normal Unity Update() loop timing + _physicsEngine.SetExternalTiming(false); + _started = false; Logger.Info("[SimulationThreadComponent] Simulation stopped"); From 43b9f9797bfc8c379c9c2805582ee51725f5bcff Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Feb 2026 01:33:38 +0100 Subject: [PATCH 13/51] physics: Fix allocation and threading issues. --- .../VisualPinball.Unity/Common/Math.cs | 2 +- .../VisualPinball.Unity/Game/InsideOfs.cs | 2 +- .../Game/PhysicsDynamicBroadPhase.cs | 2 +- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 112 ++++++++++++++++-- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 2 +- .../Simulation/SimulationThread.cs | 24 +++- 6 files changed, 126 insertions(+), 18 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs index 98e77d59d..b6215ae65 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs @@ -77,7 +77,7 @@ public static float Random() public static bool Sign(float f) { - var floats = new NativeArray(1, Allocator.Temp) { [0] = f }; + var floats = new NativeArray(1, Allocator.TempJob) { [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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs index 67b1a75b6..88f6a8c2d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs @@ -141,7 +141,7 @@ private int GetBitIndex(int ballId) return _bitLookup[ballId]; } - var bitArrayIndices = _bitLookup.GetValueArray(Allocator.Temp); // todo don't copy but ref + var bitArrayIndices = _bitLookup.GetValueArray(Allocator.TempJob); // todo don't copy but ref for (var i = 0; i < 64; i++) { if (bitArrayIndices.Contains(i)) { continue; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs index ffa298ccb..a5794bf8e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs @@ -44,7 +44,7 @@ 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); + using var ob = overlappingBalls.ToNativeArray(Allocator.TempJob); for (var i = 0; i < ob.Length; i ++) { var overlappingBallId = ob[i]; ref var overlappingBall = ref balls.GetValueByRef(overlappingBallId); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index cbe9f9221..76761286c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -139,6 +139,16 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac /// private bool _useExternalTiming = false; + /// + /// Lock for synchronizing physics state access between threads + /// + private readonly object _physicsLock = new object(); + + /// + /// Whether physics engine is fully initialized and ready for simulation thread + /// + private bool _isInitialized = false; + private float _lastFrameTimeMs; #region API @@ -339,6 +349,9 @@ internal PhysicsState CreateState() 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); + + // Mark as initialized for simulation thread + _isInitialized = true; } /// @@ -356,29 +369,97 @@ public void SetExternalTiming(bool enable) /// /// Execute a single physics tick with external timing (for simulation thread). - /// This allows precise control of physics simulation independent of Unity's Update loop. + /// This runs the physics simulation but does NOT apply movements to GameObjects. + /// Call ApplyMovements() from the main thread to update transforms. /// /// Current time in microseconds public void ExecuteTick(ulong timeUsec) { - _externalTimeUsec = timeUsec; - ExecutePhysicsUpdate(timeUsec); + // Wait until physics engine is fully initialized + if (!_isInitialized) { + return; + } + + lock (_physicsLock) { + _externalTimeUsec = timeUsec; + ExecutePhysicsSimulation(timeUsec); + } + } + + /// + /// Apply physics state to GameObjects (must be called from main thread). + /// This updates transforms based on physics simulation results. + /// + public void ApplyMovements() + { + if (!_useExternalTiming || !_isInitialized) return; + + // Don't acquire lock here - just read the current state + // Physics simulation writes to native collections which are thread-safe for concurrent readers + var state = CreateState(); + ApplyAllMovements(ref state); } private void Update() { - // Skip update if external timing is enabled (simulation thread controls physics) if (_useExternalTiming) { - return; + // Simulation thread mode: Only apply movements (physics runs on simulation thread) + ApplyMovements(); + } else { + // Normal mode: Execute full physics update + ExecutePhysicsUpdate(NowUsec); + } + } + + /// + /// Core physics simulation (can be called from simulation thread). + /// Does NOT apply movements to GameObjects - only updates physics state. + /// + private void ExecutePhysicsSimulation(ulong currentTimeUsec) + { + var sw = Stopwatch.StartNew(); + + // check for updated kinematic transforms (main thread only, skip for now) + // TODO: Find a way to update these from simulation thread + + var state = CreateState(); + + // process input + while (_inputActions.Count > 0) { + var action = _inputActions.Dequeue(); + action(ref state); } - ExecutePhysicsUpdate(NowUsec); + // run physics loop (Burst-compiled, thread-safe) + PhysicsUpdate.Execute( + ref state, + ref _physicsEnv, + ref _overlappingColliders, + _playfieldBounds, + currentTimeUsec + ); + + // dequeue events + while (_eventQueue.Ref.TryDequeue(out var eventData)) { + _player.OnEvent(in eventData); + } + + // process scheduled events from managed land + lock (_scheduledActions) { + for (var i = _scheduledActions.Count - 1; i >= 0; i--) { + if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { + _scheduledActions[i].Action(); + _scheduledActions.RemoveAt(i); + } + } + } + + _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; } /// - /// Core physics update logic (can be called from Unity Update or simulation thread) + /// Full physics update (main thread only - includes movement application) /// - /// Current time in microseconds private void ExecutePhysicsUpdate(ulong currentTimeUsec) { var sw = Stopwatch.StartNew(); @@ -428,8 +509,17 @@ private void ExecutePhysicsUpdate(ulong currentTimeUsec) } } - #region Movements + // Apply movements to GameObjects + ApplyAllMovements(ref state); + + _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; + } + /// + /// Apply all physics movements to GameObjects (main thread only) + /// + private void ApplyAllMovements(ref PhysicsState state) + { _physicsMovements.ApplyBallMovement(ref state, _ballComponents); _physicsMovements.ApplyFlipperMovement(ref _flipperStates.Ref, _floatAnimatedComponents); _physicsMovements.ApplyBumperMovement(ref _bumperStates.Ref, _floatAnimatedComponents, _float2AnimatedComponents); @@ -439,10 +529,6 @@ private void ExecutePhysicsUpdate(ulong currentTimeUsec) _physicsMovements.ApplyPlungerMovement(ref _plungerStates.Ref, _floatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _spinnerStates.Ref, _floatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _triggerStates.Ref, _floatAnimatedComponents); - - #endregion - - _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; } private void OnDestroy() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 9d117cae8..953194b25 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -73,7 +73,7 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer()); // ref var overlappingColliders = ref UnsafeUtility.AsRef>(overlappingCollidersPtr.ToPointer()); - using var cycle = new PhysicsCycle(Allocator.Temp); + using var cycle = new PhysicsCycle(Allocator.TempJob); // 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 95ef31801..2a0a04cd0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -165,6 +165,26 @@ private void SimulationThreadFunc() // Set thread priority to time-critical NativeInputApi.VpeSetThreadPriority(); + // Wait for physics engine to be fully initialized + // This prevents accessing physics state before it's ready + Logger.Info("[SimulationThread] Waiting for physics initialization..."); + int waitCount = 0; + while (_running && _physicsEngine != null && waitCount < 100) + { + // Check if physics has started (simple heuristic - wait a bit) + Thread.Sleep(50); + waitCount++; + } + + if (waitCount >= 100) + { + Logger.Error("[SimulationThread] Timeout waiting for physics initialization"); + _running = false; + return; + } + + Logger.Info("[SimulationThread] Physics initialized, starting simulation"); + // Try to enable no-GC region for hot path bool noGcRegion = false; try @@ -376,7 +396,9 @@ private void UpdatePhysics() { if (_physicsEngine != null) { - // Execute physics tick with current simulation time + // 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); } } From fedb7616647a7e014883505ca08396ee80a09f10 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Feb 2026 17:17:50 +0100 Subject: [PATCH 14/51] physics: Allocator fix. --- .../VisualPinball.Unity/Physics/Collider/ColliderReference.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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); } From a11e5dae70c270ac094f9e8aa9645bf45e403bcf Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 7 Feb 2026 15:54:51 +0100 Subject: [PATCH 15/51] gle: Add better lifecycle handling. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 11 +- .../Simulation/NativeInputApi.cs | 31 +- .../Simulation/NativeInputManager.cs | 158 ++-- .../Simulation/SimulationState.cs | 27 +- .../Simulation/SimulationThread.cs | 691 +++++++++++------- .../Simulation/SimulationThreadComponent.cs | 113 +-- 6 files changed, 619 insertions(+), 412 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 76761286c..691d3329f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -149,6 +149,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac /// private bool _isInitialized = false; + /// + /// Check if the physics engine has completed initialization. + /// Used by the simulation thread to wait for physics to be ready. + /// + public bool IsInitialized => _isInitialized; + private float _lastFrameTimeMs; #region API @@ -337,6 +343,9 @@ private void Start() foreach (var ball in balls) { Register(ball); } + + // Mark as initialized for simulation thread + _isInitialized = true; } internal PhysicsState CreateState() @@ -350,8 +359,6 @@ internal PhysicsState CreateState() ref _surfaceStates.Ref, ref _triggerStates.Ref, ref _disabledCollisionItems.Ref, ref _swapBallCollisionHandling, ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs); - // Mark as initialized for simulation thread - _isInitialized = true; } /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index db04d1328..7f195cd14 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -54,19 +54,24 @@ public enum BindingType /// /// Key codes (Windows virtual key codes) /// - public enum KeyCode - { - LShift = 0xA0, - RShift = 0xA1, - LControl = 0xA2, - RControl = 0xA3, - Space = 0x20, - Return = 0x0D, - A = 0x41, - S = 0x53, - D = 0x44, - W = 0x57, - } + 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 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index dbd4567c1..efd522701 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -4,10 +4,11 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -using System; -using System.Collections.Generic; -using NLog; -using Logger = NLog.Logger; +using System; +using System.Collections.Generic; +using System.Threading; +using NLog; +using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation { @@ -15,16 +16,18 @@ 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(); + 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 NativeInputManager _instance; + private static volatile NativeInputManager _instance; private static readonly object _instanceLock = new object(); - private SimulationThread _simulationThread; + private volatile SimulationThread _simulationThread; private bool _initialized = false; private bool _polling = false; @@ -68,19 +71,19 @@ private NativeInputManager() /// /// Initialize native input system /// - public bool Initialize() - { - if (_initialized) return true; - - int result = NativeInputApi.VpeInputInit(); - if (result == 0) - { - Logger.Error("[NativeInputManager] Failed to initialize native input system"); - return false; - } - - _initialized = true; - Logger.Info("[NativeInputManager] Initialized"); + 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(); @@ -121,19 +124,27 @@ public void ClearBindings() /// Start input polling /// /// Polling interval in microseconds (default 500) - public bool StartPolling(int pollIntervalUs = 500) - { - if (!_initialized) - { - Logger.Error("[NativeInputManager] Not initialized"); - return false; - } - - if (_polling) - { - Logger.Warn("[NativeInputManager] Already polling"); - return true; - } + public bool StartPolling(int pollIntervalUs = 500) + { + #if UNITY_EDITOR + // Avoid extremely aggressive polling in the editor; it can starve other threads + // (notably PinMAME's emulation thread) and lead to flaky 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); @@ -142,15 +153,15 @@ public bool StartPolling(int pollIntervalUs = 500) _callbackDelegate = OnInputEvent; // Start polling thread - int result = NativeInputApi.VpeInputStartPolling(_callbackDelegate, IntPtr.Zero, pollIntervalUs); - if (result == 0) - { - Logger.Error("[NativeInputManager] Failed to start polling"); - return false; - } + int result = NativeInputApi.VpeInputStartPolling(_callbackDelegate, IntPtr.Zero, pollIntervalUs); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to start polling"); + return false; + } - _polling = true; - Logger.Info($"[NativeInputManager] Started polling at {pollIntervalUs}μs interval ({1000000 / pollIntervalUs} Hz)"); + _polling = true; + Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); return true; } @@ -165,7 +176,7 @@ public void StopPolling() NativeInputApi.VpeInputStopPolling(); _polling = false; - Logger.Info("[NativeInputManager] Stopped polling"); + Logger.Info($"{LogPrefix} [NativeInputManager] Stopped polling"); } #endregion @@ -175,32 +186,47 @@ public void StopPolling() /// /// Setup default input bindings /// - private void SetupDefaultBindings() - { - ClearBindings(); - - // Flippers - AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.LShift); - AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.RShift); - - // Start - AddBinding(NativeInputApi.InputAction.Start, NativeInputApi.KeyCode.Return); - - // Plunger - AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Space); - - Logger.Info($"[NativeInputManager] Configured {_bindings.Count} default 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) - { - // Forward to simulation thread via ring buffer - _instance?._simulationThread?.EnqueueInputEvent(evt); - } + 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?._simulationThread?.EnqueueInputEvent(evt); + } #endregion @@ -221,4 +247,4 @@ public void Dispose() #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 70a2876c0..098d18228 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -69,16 +69,23 @@ public struct GIState /// /// Complete simulation state snapshot /// - public struct Snapshot - { - // Timing - public long SimulationTimeUsec; - public long RealTimeUsec; - - // PinMAME state - public NativeArray CoilStates; - public NativeArray LampStates; - public NativeArray GIStates; + public struct Snapshot + { + // Timing + public long SimulationTimeUsec; + public long RealTimeUsec; + + // Input stats (for debugging / telemetry) + public long InputEventsProcessed; + public long InputEventsDropped; + public int LastInputAction; + public float LastInputValue; + public long LastInputTimestampUsec; + + // PinMAME state + public NativeArray CoilStates; + public NativeArray LampStates; + public NativeArray GIStates; // Physics state references (not copied, just references) // The actual PhysicsState is too large to copy every tick diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 2a0a04cd0..37349b250 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -4,16 +4,16 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Runtime; -using System.Threading; -using NLog; -using Logger = NLog.Logger; - -namespace VisualPinball.Unity.Simulation -{ +using System; +using System.Diagnostics; +using System.Runtime; +using System.Threading; +using NLog; +using Logger = NLog.Logger; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Simulation +{ /// /// High-performance simulation thread that runs physics and PinMAME /// at 1000 Hz (1ms per tick) independent of rendering frame rate. @@ -24,17 +24,17 @@ namespace VisualPinball.Unity.Simulation /// - Allocation-free hot path /// - Lock-free communication with main thread /// - public class SimulationThread : IDisposable - { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + 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 = 100; // Last 100μs busy-wait for precision - private const double TickIntervalSeconds = 0.001; // 1ms in seconds - - #endregion + private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds + private const long BusyWaitThresholdUsec = 100; // Last 100us busy-wait for precision + + #endregion #region Fields @@ -43,40 +43,61 @@ public class SimulationThread : IDisposable private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; - private Thread _thread; - private volatile bool _running = false; - private volatile bool _paused = false; - - // Timing - private long _lastTickUsec; - private long _simulationTimeUsec; - private double _pinmameSimulationTimeSeconds; - - // Input state tracking - private readonly Dictionary _inputStates = new(); - - // Statistics - private long _tickCount = 0; - private long _inputEventsProcessed = 0; - - #endregion + 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; + + // 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 int _lastInputAction = -1; + private float _lastInputValue; + private long _lastInputTimestampUsec; + + private volatile bool _gamelogicStarted; + private volatile bool _needsInitialSwitchSync; + + #endregion #region Constructor - public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine) - { - _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); - _gamelogicEngine = gamelogicEngine; - - _inputBuffer = new InputEventBuffer(1024); - _sharedState = new SimulationState(); - - // Initialize input states - foreach (NativeInputApi.InputAction action in Enum.GetValues(typeof(NativeInputApi.InputAction))) - { - _inputStates[action] = false; - } - } + public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine) + { + _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); + _gamelogicEngine = gamelogicEngine; + + _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 @@ -85,33 +106,40 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE /// /// Start the simulation thread /// - public void Start() - { - if (_running) return; - - _running = true; - _paused = false; - _lastTickUsec = NativeInputApi.VpeGetTimestampUsec(); - _simulationTimeUsec = 0; - _pinmameSimulationTimeSeconds = 0.0; - _tickCount = 0; - - _thread = new Thread(SimulationThreadFunc) - { - Name = "VPE Simulation Thread", - IsBackground = false, - Priority = ThreadPriority.Highest - }; - _thread.Start(); - - Logger.Info("[SimulationThread] Started at 1000 Hz"); + public void Start() + { + if (_running) return; + + _running = true; + _paused = false; + _gamelogicStarted = _gamelogicEngine != null; + _lastTickTicks = Stopwatch.GetTimestamp(); + _simulationTimeUsec = 0; + _tickCount = 0; + _inputEventsProcessed = 0; + _inputEventsDropped = 0; + _needsInitialSwitchSync = true; + + _thread = new Thread(SimulationThreadFunc) + { + Name = "VPE Simulation Thread", + IsBackground = true, + #if UNITY_EDITOR + Priority = ThreadPriority.AboveNormal + #else + Priority = ThreadPriority.Highest + #endif + }; + _thread.Start(); + + Logger.Info($"{LogPrefix} [SimulationThread] Started at 1000 Hz"); } /// /// Stop the simulation thread /// - public void Stop() - { + public void Stop() + { if (!_running) return; _running = false; @@ -121,8 +149,8 @@ public void Stop() _thread.Join(5000); // Wait up to 5 seconds } - Logger.Info($"[SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events"); - } + Logger.Info($"{LogPrefix} [SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events, {_inputEventsDropped} dropped"); + } /// /// Pause the simulation (for debugging) @@ -143,10 +171,12 @@ public void Resume() /// /// Enqueue an input event from the input polling thread /// - public void EnqueueInputEvent(NativeInputApi.InputEvent evt) - { - _inputBuffer.TryEnqueue(evt); - } + public void EnqueueInputEvent(NativeInputApi.InputEvent evt) + { + if (!_inputBuffer.TryEnqueue(evt)) { + Interlocked.Increment(ref _inputEventsDropped); + } + } /// /// Get the current shared state (for main thread to read) @@ -160,30 +190,33 @@ public ref readonly SimulationState.Snapshot GetSharedState() #region Simulation Thread - private void SimulationThreadFunc() - { - // Set thread priority to time-critical - NativeInputApi.VpeSetThreadPriority(); + private void SimulationThreadFunc() + { + // Avoid time-critical priority in the editor. + // It can starve other threads (notably PinMAME's emulation thread) and lead to + // flaky startup/shutdown across play sessions. + #if !UNITY_EDITOR + NativeInputApi.VpeSetThreadPriority(); + #endif // Wait for physics engine to be fully initialized // This prevents accessing physics state before it's ready - Logger.Info("[SimulationThread] Waiting for physics initialization..."); + Logger.Info($"{LogPrefix} [SimulationThread] Waiting for physics initialization..."); int waitCount = 0; - while (_running && _physicsEngine != null && waitCount < 100) + while (_running && _physicsEngine != null && !_physicsEngine.IsInitialized && waitCount < 100) { - // Check if physics has started (simple heuristic - wait a bit) Thread.Sleep(50); waitCount++; } if (waitCount >= 100) { - Logger.Error("[SimulationThread] Timeout waiting for physics initialization"); + Logger.Error($"{LogPrefix} [SimulationThread] Timeout waiting for physics initialization"); _running = false; return; } - Logger.Info("[SimulationThread] Physics initialized, starting simulation"); + Logger.Info($"{LogPrefix} [SimulationThread] Physics initialized, starting simulation"); // Try to enable no-GC region for hot path bool noGcRegion = false; @@ -193,49 +226,64 @@ private void SimulationThreadFunc() if (GC.TryStartNoGCRegion(10 * 1024 * 1024, true)) { noGcRegion = true; - Logger.Info("[SimulationThread] No-GC region enabled"); + Logger.Info($"{LogPrefix} [SimulationThread] No-GC region enabled"); } } catch (Exception ex) { - Logger.Warn($"[SimulationThread] Failed to start no-GC region: {ex.Message}"); + Logger.Warn($"{LogPrefix} [SimulationThread] Failed to start no-GC region: {ex.Message}"); } - try - { - // Main simulation loop - while (_running) - { - if (_paused) - { - Thread.Sleep(10); - continue; - } - - // Timing: Sleep until next tick, then busy-wait for precision - long targetTimeUsec = _lastTickUsec + TickIntervalUsec; - long nowUsec = NativeInputApi.VpeGetTimestampUsec(); - long sleepUsec = targetTimeUsec - nowUsec; - - if (sleepUsec > BusyWaitThresholdUsec) - { - // Sleep most of the time to avoid CPU waste - Thread.Sleep((int)((sleepUsec - BusyWaitThresholdUsec) / 1000)); - } - - // Busy-wait for precision (last 100μs) - while (NativeInputApi.VpeGetTimestampUsec() < targetTimeUsec) - { - Thread.SpinWait(10); // ~40ns per iteration - } + 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. + // In the editor we avoid a busy-wait loop to prevent starving other threads + // (notably PinMAME's emulation thread) which can lead to flaky 2nd-run behavior. + long targetTicks = _lastTickTicks + _tickIntervalTicks; + long nowTicks = Stopwatch.GetTimestamp(); + long sleepTicks = targetTicks - nowTicks; + + if (sleepTicks > 0) + { + var sleepMs = (int)((sleepTicks * 1000) / Stopwatch.Frequency); + if (sleepMs > 0) { + Thread.Sleep(sleepMs); + } else { + Thread.Yield(); + } + } + + #if !UNITY_EDITOR + // Busy-wait for precision (last 100us) + if (sleepTicks <= _busyWaitThresholdTicks) + { + var spinner = new SpinWait(); + while (Stopwatch.GetTimestamp() < targetTicks) + { + spinner.SpinOnce(); + } + } + #endif // Execute simulation tick (hot path - must be allocation-free!) SimulationTick(); - _lastTickUsec = targetTimeUsec; - _tickCount++; - } - } + _lastTickTicks = targetTicks; + _tickCount++; + } + } finally { // Exit no-GC region @@ -244,7 +292,7 @@ private void SimulationThreadFunc() try { GC.EndNoGCRegion(); - Logger.Info("[SimulationThread] No-GC region ended"); + Logger.Info($"{LogPrefix} [SimulationThread] No-GC region ended"); } catch { } } @@ -254,28 +302,16 @@ private void SimulationThreadFunc() /// /// Single simulation tick - MUST BE ALLOCATION-FREE! /// - private void SimulationTick() - { - // 1. Process input events from ring buffer - ProcessInputEvents(); - - // 2. Advance PinMAME simulation using SetTimeFence - if (_gamelogicEngine != null) - { - AdvancePinMAME(); - } - - // 3. Poll PinMAME outputs (coils, lamps, GI) - if (_gamelogicEngine != null) - { - PollPinMAMEOutputs(); - } - - // 4. Update physics simulation - UpdatePhysics(); - - // 5. Write to shared state and swap buffers - WriteSharedState(); + private void SimulationTick() + { + // 1. Process input events from ring buffer + ProcessInputEvents(); + + // 2. Update physics simulation + UpdatePhysics(); + + // 3. Write to shared state and swap buffers + WriteSharedState(); // Increment simulation time _simulationTimeUsec += TickIntervalUsec; @@ -284,115 +320,204 @@ private void SimulationTick() /// /// Process all pending input events from the ring buffer /// - private void ProcessInputEvents() - { - while (_inputBuffer.TryDequeue(out var evt)) - { - var action = (NativeInputApi.InputAction)evt.Action; - bool isPressed = evt.Value > 0.5f; - - // Track state change - bool previousState = _inputStates[action]; - _inputStates[action] = isPressed; - - // Only process if state changed - if (previousState != isPressed) - { - HandleInputAction(action, isPressed); - _inputEventsProcessed++; - } - } - } - - /// - /// Handle a single input action (map to switch/coil) - /// - private void HandleInputAction(NativeInputApi.InputAction action, bool isPressed) - { - // Map input actions to switch IDs - // This is a simplified example - actual mapping would come from configuration - switch (action) - { - case NativeInputApi.InputAction.LeftFlipper: - _gamelogicEngine?.Switch("s_flipper_left", isPressed); - break; - - case NativeInputApi.InputAction.RightFlipper: - _gamelogicEngine?.Switch("s_flipper_right", isPressed); - break; - - case NativeInputApi.InputAction.Start: - _gamelogicEngine?.Switch("s_start", isPressed); - break; - - case NativeInputApi.InputAction.Plunge: - _gamelogicEngine?.Switch("s_plunger", isPressed); - break; - - // Add more mappings as needed - } - } - - /// - /// Advance PinMAME simulation to current time using SetTimeFence - /// - private void AdvancePinMAME() - { - if (_gamelogicEngine == null) return; - - // Increment PinMAME time by 1ms - _pinmameSimulationTimeSeconds += TickIntervalSeconds; - - // Tell PinMAME to run until this time - // PinMAME will execute in its own thread until it reaches the target time, - // then return control. This provides precise synchronization. - try - { - // Check if this is PinMAME (has SetTimeFence method) - var pinmameType = _gamelogicEngine.GetType(); - var setTimeFenceMethod = pinmameType.GetMethod("SetTimeFence"); - - if (setTimeFenceMethod != null) - { - setTimeFenceMethod.Invoke(_gamelogicEngine, new object[] { _pinmameSimulationTimeSeconds }); - } - } - catch (Exception ex) - { - // Silently ignore if SetTimeFence is not available - // This allows the simulation thread to work with non-PinMAME engines - Logger.Debug($"[SimulationThread] SetTimeFence not available: {ex.Message}"); - } - } - - /// - /// Poll PinMAME for output changes (coils, lamps, GI) - /// - private void PollPinMAMEOutputs() - { - if (_gamelogicEngine == null) return; - - // Poll for changed outputs from the gamelogic engine - // These are typically processed via events, but in the simulation thread - // we can poll them directly for lower latency - // - // The gamelogic engine fires events for: - // - OnCoilChanged (solenoids) - // - OnLampChanged (lamps) - // - OnGIChanged (general illumination) - // - // These events are already being fired by the engine's internal threads, - // so we don't need to poll explicitly here. The events will be picked up - // by the main thread's event handlers. - // - // Future optimization: Copy changed states directly to shared state here - // instead of relying on event dispatch queue. - } - - /// - /// Update physics simulation (1ms step) - /// - private void UpdatePhysics() + 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; + _lastInputAction = actionIndex; + _lastInputValue = evt.Value; + _lastInputTimestampUsec = evt.TimestampUsec; + bool previousState = _actionStates[actionIndex]; + _actionStates[actionIndex] = isPressed; + + if (previousState == isPressed) { + continue; + } + + // 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 SendMappedSwitch(int actionIndex, bool isPressed) + { + if ((uint)actionIndex >= (uint)_actionToSwitchId.Length) { + return; + } + var switchId = _actionToSwitchId[actionIndex]; + if (switchId == null) { + return; + } + 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)"); + } + } + _gamelogicEngine.Switch(switchId, isPressed); + } + + private void SyncAllMappedSwitches() + { + for (var i = 0; i < _actionToSwitchId.Length; i++) + { + var switchId = _actionToSwitchId[i]; + if (switchId == null) { + continue; + } + _gamelogicEngine.Switch(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) + /// + private void UpdatePhysics() { if (_physicsEngine != null) { @@ -406,13 +531,19 @@ private void UpdatePhysics() /// /// Write simulation state to shared memory and swap buffers /// - private void WriteSharedState() - { - ref var backBuffer = ref _sharedState.GetBackBuffer(); - - // Update timing - backBuffer.SimulationTimeUsec = _simulationTimeUsec; - backBuffer.RealTimeUsec = NativeInputApi.VpeGetTimestampUsec(); + private void WriteSharedState() + { + ref var backBuffer = ref _sharedState.GetBackBuffer(); + + // Update timing + backBuffer.SimulationTimeUsec = _simulationTimeUsec; + backBuffer.RealTimeUsec = GetTimestampUsec(); + + backBuffer.InputEventsProcessed = Interlocked.Read(ref _inputEventsProcessed); + backBuffer.InputEventsDropped = Interlocked.Read(ref _inputEventsDropped); + backBuffer.LastInputAction = _lastInputAction; + backBuffer.LastInputValue = _lastInputValue; + backBuffer.LastInputTimestampUsec = _lastInputTimestampUsec; // Copy PinMAME state (coils, lamps, GI) // This is where we'd copy the changed outputs from PinMAME @@ -423,20 +554,36 @@ private void WriteSharedState() backBuffer.PhysicsStateVersion++; // Atomically swap buffers (lock-free) - _sharedState.SwapBuffers(); - } - - #endregion - - #region Dispose - - public void Dispose() - { - Stop(); - _inputBuffer?.Dispose(); - _sharedState?.Dispose(); - } + _sharedState.SwapBuffers(); + } + + private static long GetTimestampUsec() + { + long ticks = Stopwatch.GetTimestamp(); + return (ticks * 1_000_000) / Stopwatch.Frequency; + } + + #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; + } + _inputBuffer?.Dispose(); + _sharedState?.Dispose(); + } #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 72cde1076..65a708a23 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -23,9 +23,10 @@ namespace VisualPinball.Unity.Simulation /// [AddComponentMenu("Visual Pinball/Simulation Thread")] [RequireComponent(typeof(PhysicsEngine))] - public class SimulationThreadComponent : MonoBehaviour - { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + public class SimulationThreadComponent : MonoBehaviour + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const string LogPrefix = "[PinMAME-debug]"; #region Inspector Fields @@ -64,23 +65,19 @@ public class SimulationThreadComponent : MonoBehaviour #region Unity Lifecycle - private void Awake() - { - _physicsEngine = GetComponent(); - - // Get gamelogic engine from Player if available - var player = GetComponent(); - if (player != null) - { - _gamelogicEngine = player.GamelogicEngine; - } - } + 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("[SimulationThreadComponent] Simulation thread disabled"); + Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation thread disabled"); return; } @@ -105,10 +102,15 @@ private void Update() } } - private void OnDestroy() - { - StopSimulation(); - } + private void OnDestroy() + { + StopSimulation(); + } + + private void OnDisable() + { + StopSimulation(); + } private void OnApplicationQuit() { @@ -122,15 +124,28 @@ private void OnApplicationQuit() /// /// Start the simulation thread /// - public void StartSimulation() - { - if (_started) return; - - try - { - // Enable external timing on PhysicsEngine - // This disables Unity's Update() loop and gives control to the simulation thread - _physicsEngine.SetExternalTiming(true); + public void StartSimulation() + { + if (_started) return; + + try + { + // Resolve dependencies (safe even if Awake order differs) + _physicsEngine ??= GetComponent(); + if (_gamelogicEngine == null) { + var player = GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren(); + _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); @@ -146,9 +161,9 @@ public void StartSimulation() } else { - Logger.Warn("[SimulationThreadComponent] Native input not available, falling back to Unity Input System"); - } - } + Logger.Warn($"{LogPrefix} [SimulationThreadComponent] Native input not available, falling back to Unity Input System"); + } + } // Start simulation thread _simulationThread.Start(); @@ -156,13 +171,13 @@ public void StartSimulation() _started = true; _lastStatisticsTime = Time.time; - Logger.Info("[SimulationThreadComponent] Simulation started with external physics timing"); - } - catch (Exception ex) - { - Logger.Error($"[SimulationThreadComponent] Failed to start simulation: {ex}"); - } - } + 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 @@ -181,8 +196,8 @@ public void StopSimulation() _started = false; - Logger.Info("[SimulationThreadComponent] Simulation stopped"); - } + Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation stopped"); + } /// /// Pause the simulation (for debugging) @@ -219,15 +234,15 @@ private void ApplySimulationState(in SimulationState.Snapshot 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; - - Logger.Info($"[SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}"); - } + private void LogStatistics(in SimulationState.Snapshot state) + { + long simTimeMs = state.SimulationTimeUsec / 1000; + long realTimeMs = state.RealTimeUsec / 1000; + double ratio = (double)simTimeMs / realTimeMs; + + Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}, InputProcessed={state.InputEventsProcessed}, InputDropped={state.InputEventsDropped}, LastAction={state.LastInputAction}, LastValue={state.LastInputValue:F2}"); + } #endregion } -} \ No newline at end of file +} From 19a0f47a1bc72e4852ad6ce3eaa15db0fd1a6b7e Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 7 Feb 2026 16:17:15 +0100 Subject: [PATCH 16/51] gle: Clean up debug code. --- .../Simulation/NativeInputManager.cs | 183 +++++++++--------- .../Simulation/SimulationState.cs | 7 - .../Simulation/SimulationThread.cs | 33 +--- .../Simulation/SimulationThreadComponent.cs | 2 +- 4 files changed, 100 insertions(+), 125 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index efd522701..0cb0bbe87 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -4,11 +4,11 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -using System; -using System.Collections.Generic; -using System.Threading; -using NLog; -using Logger = NLog.Logger; +using System; +using System.Collections.Generic; +using System.Threading; +using NLog; +using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation { @@ -16,18 +16,18 @@ 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; + 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 volatile NativeInputManager _instance; private static readonly object _instanceLock = new object(); - private volatile SimulationThread _simulationThread; + private volatile SimulationThread _simulationThread; private bool _initialized = false; private bool _polling = false; @@ -71,19 +71,19 @@ private NativeInputManager() /// /// 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"); + 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(); @@ -124,27 +124,26 @@ public void ClearBindings() /// 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 starve other threads - // (notably PinMAME's emulation thread) and lead to flaky 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; - } + 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); @@ -153,15 +152,15 @@ public bool StartPolling(int pollIntervalUs = 500) _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; - } + int result = NativeInputApi.VpeInputStartPolling(_callbackDelegate, IntPtr.Zero, pollIntervalUs); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to start polling"); + return false; + } - _polling = true; - Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); + _polling = true; + Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); return true; } @@ -176,7 +175,7 @@ public void StopPolling() NativeInputApi.VpeInputStopPolling(); _polling = false; - Logger.Info($"{LogPrefix} [NativeInputManager] Stopped polling"); + Logger.Info($"{LogPrefix} [NativeInputManager] Stopped polling"); } #endregion @@ -186,47 +185,47 @@ public void StopPolling() /// /// 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"); - } + 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?._simulationThread?.EnqueueInputEvent(evt); - } + 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?._simulationThread?.EnqueueInputEvent(evt); + } #endregion @@ -247,4 +246,4 @@ public void Dispose() #endregion } -} +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 098d18228..540cdfbb4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -75,13 +75,6 @@ public struct Snapshot public long SimulationTimeUsec; public long RealTimeUsec; - // Input stats (for debugging / telemetry) - public long InputEventsProcessed; - public long InputEventsDropped; - public int LastInputAction; - public float LastInputValue; - public long LastInputTimestampUsec; - // PinMAME state public NativeArray CoilStates; public NativeArray LampStates; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 37349b250..4d58497aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -62,9 +62,6 @@ public class SimulationThread : IDisposable private long _tickCount = 0; private long _inputEventsProcessed = 0; private long _inputEventsDropped = 0; - private int _lastInputAction = -1; - private float _lastInputValue; - private long _lastInputTimestampUsec; private volatile bool _gamelogicStarted; private volatile bool _needsInitialSwitchSync; @@ -192,9 +189,8 @@ public ref readonly SimulationState.Snapshot GetSharedState() private void SimulationThreadFunc() { - // Avoid time-critical priority in the editor. - // It can starve other threads (notably PinMAME's emulation thread) and lead to - // flaky startup/shutdown across play sessions. + // Editor playmode is a hostile environment for time-critical threads (domain/scene reload, + // asset imports, editor windows). Keep time-critical only for player builds. #if !UNITY_EDITOR NativeInputApi.VpeSetThreadPriority(); #endif @@ -248,16 +244,14 @@ private void SimulationThreadFunc() continue; } - // Timing: Sleep until next tick. - // In the editor we avoid a busy-wait loop to prevent starving other threads - // (notably PinMAME's emulation thread) which can lead to flaky 2nd-run behavior. + // Timing: Sleep until next tick, then busy-wait for precision. long targetTicks = _lastTickTicks + _tickIntervalTicks; long nowTicks = Stopwatch.GetTimestamp(); long sleepTicks = targetTicks - nowTicks; - if (sleepTicks > 0) + if (sleepTicks > _busyWaitThresholdTicks) { - var sleepMs = (int)((sleepTicks * 1000) / Stopwatch.Frequency); + var sleepMs = (int)(((sleepTicks - _busyWaitThresholdTicks) * 1000) / Stopwatch.Frequency); if (sleepMs > 0) { Thread.Sleep(sleepMs); } else { @@ -267,13 +261,10 @@ private void SimulationThreadFunc() #if !UNITY_EDITOR // Busy-wait for precision (last 100us) - if (sleepTicks <= _busyWaitThresholdTicks) + var spinner = new SpinWait(); + while (Stopwatch.GetTimestamp() < targetTicks) { - var spinner = new SpinWait(); - while (Stopwatch.GetTimestamp() < targetTicks) - { - spinner.SpinOnce(); - } + spinner.SpinOnce(); } #endif @@ -332,9 +323,6 @@ private void ProcessInputEvents() } bool isPressed = evt.Value > 0.5f; - _lastInputAction = actionIndex; - _lastInputValue = evt.Value; - _lastInputTimestampUsec = evt.TimestampUsec; bool previousState = _actionStates[actionIndex]; _actionStates[actionIndex] = isPressed; @@ -539,11 +527,6 @@ private void WriteSharedState() backBuffer.SimulationTimeUsec = _simulationTimeUsec; backBuffer.RealTimeUsec = GetTimestampUsec(); - backBuffer.InputEventsProcessed = Interlocked.Read(ref _inputEventsProcessed); - backBuffer.InputEventsDropped = Interlocked.Read(ref _inputEventsDropped); - backBuffer.LastInputAction = _lastInputAction; - backBuffer.LastInputValue = _lastInputValue; - backBuffer.LastInputTimestampUsec = _lastInputTimestampUsec; // Copy PinMAME state (coils, lamps, GI) // This is where we'd copy the changed outputs from PinMAME diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 65a708a23..be54928af 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -240,7 +240,7 @@ private void LogStatistics(in SimulationState.Snapshot state) long realTimeMs = state.RealTimeUsec / 1000; double ratio = (double)simTimeMs / realTimeMs; - Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}, InputProcessed={state.InputEventsProcessed}, InputDropped={state.InputEventsDropped}, LastAction={state.LastInputAction}, LastValue={state.LastInputValue:F2}"); + Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}"); } #endregion From d7734d8d1546c53d8aa6e881ffbd6c79f2a9f477 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 15 Feb 2026 15:49:53 +0100 Subject: [PATCH 17/51] gle: Make event reception thread-safe. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 691d3329f..0051905ab 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -119,6 +119,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac #endregion [NonSerialized] private readonly Queue _inputActions = new(); + private readonly object _inputActionsLock = new object(); [NonSerialized] private readonly List _scheduledActions = new(); [NonSerialized] private Player _player; @@ -147,7 +148,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac /// /// Whether physics engine is fully initialized and ready for simulation thread /// - private bool _isInitialized = false; + private volatile bool _isInitialized = false; /// /// Check if the physics engine has completed initialization. @@ -173,7 +174,12 @@ public void ScheduleAction(uint timeoutMs, Action action) internal ref InsideOfs InsideOfs => ref _insideOfs; internal NativeQueue.ParallelWriter EventQueue => _eventQueue.Ref.AsParallelWriter(); - internal void Schedule(InputAction action) => _inputActions.Enqueue(action); + internal void Schedule(InputAction action) + { + lock (_inputActionsLock) { + _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); @@ -401,16 +407,18 @@ public void ApplyMovements() { if (!_useExternalTiming || !_isInitialized) return; - // Don't acquire lock here - just read the current state - // Physics simulation writes to native collections which are thread-safe for concurrent readers - var state = CreateState(); - ApplyAllMovements(ref state); + lock (_physicsLock) { + var state = CreateState(); + ApplyAllMovements(ref state); + } } private void Update() { if (_useExternalTiming) { - // Simulation thread mode: Only apply movements (physics runs on simulation thread) + // Simulation thread mode: physics runs on simulation thread, + // but managed callbacks must run on Unity main thread. + DrainExternalThreadCallbacks(); ApplyMovements(); } else { // Normal mode: Execute full physics update @@ -418,6 +426,31 @@ private void Update() } } + /// + /// Drain physics-originated managed callbacks on the Unity main thread. + /// + private void DrainExternalThreadCallbacks() + { + if (!_useExternalTiming || !_isInitialized) { + return; + } + + lock (_physicsLock) { + while (_eventQueue.Ref.TryDequeue(out var eventData)) { + _player.OnEvent(in eventData); + } + + lock (_scheduledActions) { + for (var i = _scheduledActions.Count - 1; i >= 0; i--) { + if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { + _scheduledActions[i].Action(); + _scheduledActions.RemoveAt(i); + } + } + } + } + } + /// /// Core physics simulation (can be called from simulation thread). /// Does NOT apply movements to GameObjects - only updates physics state. @@ -432,10 +465,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) var state = CreateState(); // process input - while (_inputActions.Count > 0) { - var action = _inputActions.Dequeue(); - action(ref state); - } + ProcessInputActions(ref state); // run physics loop (Burst-compiled, thread-safe) PhysicsUpdate.Execute( @@ -446,21 +476,6 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) currentTimeUsec ); - // dequeue events - while (_eventQueue.Ref.TryDequeue(out var eventData)) { - _player.OnEvent(in eventData); - } - - // process scheduled events from managed land - lock (_scheduledActions) { - for (var i = _scheduledActions.Count - 1; i >= 0; i--) { - if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { - _scheduledActions[i].Action(); - _scheduledActions.RemoveAt(i); - } - } - } - _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; } @@ -487,10 +502,7 @@ private void ExecutePhysicsUpdate(ulong currentTimeUsec) var state = CreateState(); // process input - while (_inputActions.Count > 0) { - var action = _inputActions.Dequeue(); - action(ref state); - } + ProcessInputActions(ref state); // run physics loop PhysicsUpdate.Execute( @@ -537,6 +549,16 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplySpinnerMovement(ref _spinnerStates.Ref, _floatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _triggerStates.Ref, _floatAnimatedComponents); } + + private void ProcessInputActions(ref PhysicsState state) + { + lock (_inputActionsLock) { + while (_inputActions.Count > 0) { + var action = _inputActions.Dequeue(); + action(ref state); + } + } + } private void OnDestroy() { @@ -614,4 +636,4 @@ private static ICollider[] GetColliders(int itemId, ref NativeParallelHashMap Date: Tue, 17 Feb 2026 00:38:11 +0100 Subject: [PATCH 18/51] fix: Switch dispatch issues. --- .../VisualPinball.Unity/Game/CoilPlayer.cs | 27 ++-- .../Game/Engine/IGamelogicEngine.cs | 23 ++- .../VisualPinball.Unity/Game/Player.cs | 86 ++++++---- .../VisualPinball.Unity/Game/SwitchHandler.cs | 38 ++--- .../VisualPinball.Unity/Game/SwitchPlayer.cs | 56 ++++--- .../Simulation/GamelogicInputDispatchers.cs | 153 ++++++++++++++++++ .../GamelogicInputDispatchers.cs.meta | 2 + .../Simulation/SimulationThread.cs | 83 ++++++++-- .../Simulation/SimulationThreadComponent.cs | 37 +++-- .../VPT/Trough/TroughApi.cs | 50 ++++-- 10 files changed, 421 insertions(+), 134 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index feabd86ac..c02c908c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -120,8 +120,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 +130,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; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index ce32d4b23..72f0b755b 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,8 +152,23 @@ public interface IGamelogicBridge public event EventHandler OnSwitchChanged; - // todo displays - } + // 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; } + } public class RequestedDisplays { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 5c4bed113..028df31ca 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -22,10 +22,11 @@ using Unity.Mathematics; using UnityEngine; using UnityEngine.InputSystem; -using UnityEngine.Serialization; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game; -using VisualPinball.Engine.Game.Engines; +using UnityEngine.Serialization; +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; @@ -107,11 +108,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private const float SlowMotionMax = 0.1f; private const float TimeLapseMax = 2.5f; - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private TableComponent _tableComponent; private PlayfieldComponent _playfieldComponent; - private PhysicsEngine _physicsEngine; - private CancellationTokenSource _gamelogicEngineInitCts; + private PhysicsEngine _physicsEngine; + private SimulationThreadComponent _simulationThreadComponent; + private CancellationTokenSource _gamelogicEngineInitCts; private PlayfieldComponent PlayfieldComponent { get { @@ -122,14 +124,23 @@ private PlayfieldComponent PlayfieldComponent { } } - private PhysicsEngine PhysicsEngine { + private PhysicsEngine PhysicsEngine { get { if (_physicsEngine == null) { _physicsEngine = GetComponentInChildren(); } return _physicsEngine; } - } + } + + private SimulationThreadComponent SimulationThreadComponent { + get { + if (_simulationThreadComponent == null) { + _simulationThreadComponent = GetComponent(); + } + return _simulationThreadComponent; + } + } #region Access @@ -137,7 +148,15 @@ private PhysicsEngine PhysicsEngine { public IApiCoil Coil(ICoilDeviceComponent component, string coilItem) => component != null ? _coilPlayer.Coil(component, coilItem) : null; public IApiLamp Lamp(ILampDeviceComponent component) => component != null ? _lampPlayer.Lamp(component) : null; public IApiWireDeviceDest WireDevice(IWireableComponent c) => _wirePlayer.WireDevice(c); - internal void HandleWireSwitchChange(WireDestConfig wireConfig, bool isEnabled) => _wirePlayer.HandleSwitchChange(wireConfig, isEnabled); + 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); + } public Dictionary SwitchStatuses => _switchPlayer.SwitchStatuses; public Dictionary CoilStatuses => _coilPlayer.CoilStatuses; @@ -170,7 +189,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); } @@ -297,15 +316,15 @@ public void Register(TApi api, MonoBehaviour component) where TApi : IApi } } - private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) - { - if (!apiColl.IsColliderAvailable) { - return; - } - _colliderGenerators.Add(apiColl); - if (apiColl is IApiHittable apiHittable) { - _hittables[itemId] = apiHittable; - } + private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) + { + if (!apiColl.IsColliderAvailable) { + return; + } + _colliderGenerators.Add(apiColl); + if (apiColl is IApiHittable apiHittable) { + _hittables[itemId] = apiHittable; + } if (apiColl is IApiCollidable apiCollidable) { _collidables[itemId] = apiCollidable; @@ -319,20 +338,19 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); - public void OnEvent(in EventData eventData) - { - Debug.Log(eventData); - switch (eventData.EventId) { - case EventId.HitEventsHit: - if (!_hittables.ContainsKey(eventData.ItemId)) { - Debug.LogError($"Cannot find {eventData.ItemId} in hittables."); - } - _hittables[eventData.ItemId].OnHit(eventData.BallId); - break; - - case EventId.HitEventsUnhit: - _hittables[eventData.ItemId].OnHit(eventData.BallId, true); - break; + public void OnEvent(in EventData eventData) + { + switch (eventData.EventId) { + case EventId.HitEventsHit: + if (!_hittables.ContainsKey(eventData.ItemId)) { + Debug.LogError($"Cannot find {eventData.ItemId} in hittables."); + } + _hittables[eventData.ItemId].OnHit(eventData.BallId); + break; + + case EventId.HitEventsUnhit: + _hittables[eventData.ItemId].OnHit(eventData.BallId, true); + break; case EventId.LimitEventsBos: _rotatables[eventData.ItemId].OnRotate(eventData.FloatParam, false); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs index 2ace4dd33..9c888cc5a 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."); } 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/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/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 4d58497aa..8e60f02d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later using System; +using System.Collections.Generic; using System.Diagnostics; using System.Runtime; using System.Threading; @@ -38,10 +39,11 @@ public class SimulationThread : IDisposable #region Fields - private readonly PhysicsEngine _physicsEngine; - private readonly IGamelogicEngine _gamelogicEngine; - private readonly InputEventBuffer _inputBuffer; - private readonly SimulationState _sharedState; + private readonly PhysicsEngine _physicsEngine; + private readonly IGamelogicEngine _gamelogicEngine; + private readonly IGamelogicInputDispatcher _inputDispatcher; + private readonly InputEventBuffer _inputBuffer; + private readonly SimulationState _sharedState; private Thread _thread; private volatile bool _running = false; @@ -66,6 +68,22 @@ public class SimulationThread : IDisposable private volatile bool _gamelogicStarted; private volatile bool _needsInitialSwitchSync; + 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 @@ -74,6 +92,7 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _gamelogicEngine = gamelogicEngine; + _inputDispatcher = GamelogicInputDispatcherFactory.Create(gamelogicEngine); _inputBuffer = new InputEventBuffer(1024); _sharedState = new SimulationState(); @@ -178,10 +197,30 @@ public void EnqueueInputEvent(NativeInputApi.InputEvent evt) /// /// Get the current shared state (for main thread to read) /// - public ref readonly SimulationState.Snapshot GetSharedState() - { - return ref _sharedState.GetFrontBuffer(); - } + public ref readonly SimulationState.Snapshot GetSharedState() + { + return ref _sharedState.GetFrontBuffer(); + } + + public void FlushMainThreadInputDispatch() + { + _inputDispatcher.FlushMainThread(); + } + + public bool EnqueueExternalSwitch(string switchId, bool isClosed) + { + if (string.IsNullOrEmpty(switchId)) { + return false; + } + + lock (_externalSwitchQueueLock) { + if (_externalSwitchQueue.Count >= MaxExternalSwitchQueueSize) { + return false; + } + _externalSwitchQueue.Enqueue(new PendingSwitchEvent(switchId, isClosed)); + return true; + } + } #endregion @@ -295,6 +334,9 @@ private void SimulationThreadFunc() /// private void SimulationTick() { + // 0. Process switch events that originated on Unity/main thread. + ProcessExternalSwitchEvents(); + // 1. Process input events from ring buffer ProcessInputEvents(); @@ -344,6 +386,23 @@ private void ProcessInputEvents() } } + 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) { @@ -364,7 +423,7 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) Logger.Info($"{LogPrefix} [SimulationThread] Input RightFlipper -> Switch({switchId}, True)"); } } - _gamelogicEngine.Switch(switchId, isPressed); + _inputDispatcher.DispatchSwitch(switchId, isPressed); } private void SyncAllMappedSwitches() @@ -375,7 +434,7 @@ private void SyncAllMappedSwitches() if (switchId == null) { continue; } - _gamelogicEngine.Switch(switchId, _actionStates[i]); + _inputDispatcher.DispatchSwitch(switchId, _actionStates[i]); } } @@ -563,6 +622,10 @@ public void Dispose() if (_gamelogicEngine != null) { _gamelogicEngine.OnStarted -= OnGamelogicStarted; } + _inputDispatcher?.Dispose(); + lock (_externalSwitchQueueLock) { + _externalSwitchQueue.Clear(); + } _inputBuffer?.Dispose(); _sharedState?.Dispose(); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index be54928af..4eac9a6f3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -84,12 +84,16 @@ private void Start() StartSimulation(); } - private void Update() - { - if (!_started || _simulationThread == null) return; - - // Read shared state from simulation thread - ref readonly var state = ref _simulationThread.GetSharedState(); + private void Update() + { + if (!_started || _simulationThread == null) return; + + // 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); @@ -210,12 +214,21 @@ public void PauseSimulation() /// /// Resume the simulation /// - public void ResumeSimulation() - { - _simulationThread?.Resume(); - } - - #endregion + 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 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; } From d45b9d20b468159b0262c0c8ed46a7968e6bc185 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 17 Feb 2026 22:38:22 +0100 Subject: [PATCH 19/51] perf: Show CPU busy. --- .../Game/FramePacingGraph.cs | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index d37e4797f..eccd824f9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -49,13 +49,13 @@ public class FramePacingGraph : MonoBehaviour 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("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 max(CPU, GPU) for 'Total' when valid; otherwise uses unscaledDeltaTime.")] - public bool totalFromFrameTimingWhenAvailable = false; + [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; @@ -171,7 +171,7 @@ void Awake() timestamps = new float[capacity]; totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity); - cpuMetric = new Metric("CPU (ms)", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, 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]; @@ -298,11 +298,11 @@ void Update() } // 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); + 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; @@ -331,9 +331,9 @@ void Update() } // FPS smoothing & text throttling - var instFps = Time.unscaledDeltaTime > 0f ? 1f / Time.unscaledDeltaTime : 0f; - var a = 1f - Mathf.Exp(-Time.unscaledDeltaTime / fpsSmoothTau); - smoothedFps = Mathf.Lerp(smoothedFps, instFps, a); + 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) { @@ -400,16 +400,16 @@ void WriteSample(float now, float totalMs, float cpuMs, float gpuMs) 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; - } + float SampleTotalMs() + { + if (totalFromFrameTimingWhenAvailable && haveFT) + { + var candidate = Mathf.Max(lastCpuMs, lastGpuMs); + if (candidate > 0f) return candidate; + } + + return Time.unscaledDeltaTime * 1000f; + } void RecomputeStats(Metric m) { @@ -561,22 +561,22 @@ Rect ResolveStatsRect() } - 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; + 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++) @@ -585,8 +585,8 @@ float ComputeStatsPanelHeight() if (m != null && m.enabled && m.stats.valid) lines += 1; } - // Height = top pad + header + N*(lineH) + bottom pad - float h = pad + headerH + (lines - 1) * lineH + pad; + // 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 @@ -836,22 +836,22 @@ static void FillRect(Rect r, Color c) 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; + 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); + 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 From 5c463a9df6d392cec00f348d0614867b0333c208 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 17 Feb 2026 22:38:37 +0100 Subject: [PATCH 20/51] perf: Properly pull physics duration. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 0051905ab..c840f425b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using NativeTrees; using Unity.Collections; using Unity.Mathematics; @@ -157,6 +158,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public bool IsInitialized => _isInitialized; private float _lastFrameTimeMs; + private long _physicsBusyTotalUsec; #region API @@ -289,7 +291,16 @@ private void Start() // register frame pacing stats var stats = FindFirstObjectByType(); if (stats) { - stats.RegisterCustomMetric("Physics", Color.magenta, () => _lastFrameTimeMs); + long lastBusyTotalUsec = Interlocked.Read(ref _physicsBusyTotalUsec); + stats.RegisterCustomMetric("Physics", Color.magenta, () => { + var totalBusyUsec = Interlocked.Read(ref _physicsBusyTotalUsec); + var deltaBusyUsec = totalBusyUsec - lastBusyTotalUsec; + if (deltaBusyUsec < 0) { + deltaBusyUsec = 0; + } + lastBusyTotalUsec = totalBusyUsec; + return deltaBusyUsec / 1000f; + }); } // create static octree @@ -476,7 +487,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) currentTimeUsec ); - _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; + RecordPhysicsBusyTime(sw.ElapsedTicks); } /// @@ -531,7 +542,18 @@ private void ExecutePhysicsUpdate(ulong currentTimeUsec) // Apply movements to GameObjects ApplyAllMovements(ref state); - _lastFrameTimeMs = (float)sw.Elapsed.TotalMilliseconds; + RecordPhysicsBusyTime(sw.ElapsedTicks); + } + + private void RecordPhysicsBusyTime(long elapsedTicks) + { + var elapsedUsec = (elapsedTicks * 1_000_000L) / Stopwatch.Frequency; + if (elapsedUsec < 0) { + elapsedUsec = 0; + } + + Interlocked.Add(ref _physicsBusyTotalUsec, elapsedUsec); + _lastFrameTimeMs = elapsedUsec / 1000f; } /// From 1e93a23d0fe3bbd0b46a9ee3bfa995c567ffe782 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 17 Feb 2026 23:19:14 +0100 Subject: [PATCH 21/51] perf: Plot input latency --- .../Game/InputLatencyTracker.cs | 233 ++++++++++++++++++ .../Game/InputLatencyTracker.cs.meta | 2 + .../VisualPinball.Unity/Game/PhysicsEngine.cs | 2 + .../Simulation/NativeInputManager.cs | 8 +- .../Simulation/SimulationThread.cs | 5 + .../VPT/Flipper/FlipperComponent.cs | 17 +- 6 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/InputLatencyTracker.cs.meta 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/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index c840f425b..e5d7139ca 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -292,6 +292,7 @@ private void Start() var stats = FindFirstObjectByType(); if (stats) { long lastBusyTotalUsec = Interlocked.Read(ref _physicsBusyTotalUsec); + InputLatencyTracker.Reset(); stats.RegisterCustomMetric("Physics", Color.magenta, () => { var totalBusyUsec = Interlocked.Read(ref _physicsBusyTotalUsec); var deltaBusyUsec = totalBusyUsec - lastBusyTotalUsec; @@ -301,6 +302,7 @@ private void Start() lastBusyTotalUsec = totalBusyUsec; return deltaBusyUsec / 1000f; }); + stats.RegisterCustomMetric("In Lat (ms)", new Color(0.65f, 1f, 0.3f, 0.9f), InputLatencyTracker.SampleFlipperLatencyMs); } // create static octree diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 0cb0bbe87..984b1927e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -127,10 +127,10 @@ public void ClearBindings() 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; - // } + // Avoid extremely aggressive polling in the editor; it can delay/derail PinMAME stop/start. + if (pollIntervalUs < 1000) { + pollIntervalUs = 1000; + } #endif if (!_initialized) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 8e60f02d5..76b95204e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -12,6 +12,7 @@ using NLog; using Logger = NLog.Logger; using VisualPinball.Engine.Common; +using VisualPinball.Unity; namespace VisualPinball.Unity.Simulation { @@ -213,6 +214,8 @@ public bool EnqueueExternalSwitch(string switchId, bool isClosed) return false; } + InputLatencyTracker.RecordSwitchInputDispatched(switchId, isClosed); + lock (_externalSwitchQueueLock) { if (_externalSwitchQueue.Count >= MaxExternalSwitchQueueSize) { return false; @@ -372,6 +375,8 @@ private void ProcessInputEvents() 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); 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 From bbbb64df1ee4ab26f5a6fca8c0be2914549b9b44 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 17 Feb 2026 23:58:59 +0100 Subject: [PATCH 22/51] gle: Add support for SetTimeFence() --- .../Game/Engine/IGamelogicEngine.cs | 13 +++++++++++++ .../Simulation/SimulationThread.cs | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index 72f0b755b..9f694c3f5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -169,6 +169,19 @@ 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); + } public class RequestedDisplays { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 76b95204e..a78c2a68c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -42,6 +42,7 @@ public class SimulationThread : IDisposable private readonly PhysicsEngine _physicsEngine; private readonly IGamelogicEngine _gamelogicEngine; + private readonly IGamelogicTimeFence _timeFence; private readonly IGamelogicInputDispatcher _inputDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; @@ -93,6 +94,7 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _gamelogicEngine = gamelogicEngine; + _timeFence = gamelogicEngine as IGamelogicTimeFence; _inputDispatcher = GamelogicInputDispatcherFactory.Create(gamelogicEngine); _inputBuffer = new InputEventBuffer(1024); @@ -337,6 +339,8 @@ private void SimulationThreadFunc() /// private void SimulationTick() { + _timeFence?.SetTimeFence(_simulationTimeUsec / 1_000_000.0); + // 0. Process switch events that originated on Unity/main thread. ProcessExternalSwitchEvents(); From fa83599af4b53f6dc15219c20701ed9df552a4ec Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Mar 2026 01:36:35 +0100 Subject: [PATCH 23/51] physics: Align threading logic with vpinball's. --- .../VisualPinball.Unity/Game/CoilPlayer.cs | 28 ++- .../VisualPinball.Unity/Game/DeviceCoil.cs | 75 ++++--- .../Game/Engine/IGamelogicEngine.cs | 33 ++++ .../Game/FramePacingGraph.cs | 184 ++++++++++++------ .../VisualPinball.Unity/Game/Player.cs | 5 + .../Simulation/NativeInputManager.cs | 75 +++++-- .../Simulation/SimulationThread.cs | 40 +++- .../Simulation/SimulationThreadComponent.cs | 63 +++++- .../VPT/Flipper/FlipperApi.cs | 10 +- 9 files changed, 396 insertions(+), 117 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index c02c908c9..72709c7e1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -183,7 +183,33 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) } else { Logger.Info($"Ignoring unassigned coil \"{coilEvent.Id}\"."); } - } + } + + 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; + } public void OnDestroy() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index 879b7f3e4..5058c1963 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -14,33 +14,49 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using System; -using UnityEngine; +using System; +using System.Threading; +using UnityEngine; -namespace VisualPinball.Unity -{ - public class DeviceCoil : IApiCoil - { - public bool IsEnabled; - public event EventHandler CoilStatusChanged; +namespace VisualPinball.Unity +{ + public interface ISimulationThreadCoil + { + void OnCoilSimulationThread(bool enabled); + } + + public class DeviceCoil : IApiCoil + , ISimulationThreadCoil + { + private int _isEnabled; + private int _simulationEnabled; + + public bool IsEnabled => Volatile.Read(ref _isEnabled) != 0; + public event EventHandler CoilStatusChanged; - protected Action OnEnable; - protected Action OnDisable; + 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) - { - _player = player; - OnEnable = onEnable; - OnDisable = onDisable; - } - - public void OnCoil(bool enabled) - { - IsEnabled = enabled; - if (enabled) { - OnEnable?.Invoke(); + 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); + + if (enabled) { + OnEnable?.Invoke(); } else { OnDisable?.Invoke(); } @@ -48,7 +64,20 @@ public void OnCoil(bool enabled) #if UNITY_EDITOR RefreshUI(); #endif - } + } + + public void OnCoilSimulationThread(bool enabled) + { + 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index 9f694c3f5..dffa43f39 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -182,6 +182,39 @@ public interface IGamelogicTimeFence /// 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); + } + + 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 class RequestedDisplays { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index eccd824f9..83ade7427 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -1,7 +1,10 @@ -using System; +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 @@ -49,13 +52,13 @@ public class FramePacingGraph : MonoBehaviour 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("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("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; @@ -161,6 +164,17 @@ public Metric(string name, Color color, Func sampler, float scale, bool e // Cached GUIStyle GUIStyle labelStyle; + SimulationThreadComponent simulationThreadComponent; + NativeInputManager nativeInputManager; + IGamelogicPerformanceStats gamelogicPerformanceStats; + 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() { @@ -171,7 +185,7 @@ void Awake() timestamps = new float[capacity]; totalMetric = new Metric("Total (ms)", totalColor, SampleTotalMs, 1f, true, capacity); - cpuMetric = new Metric("CPU Busy", cpuColor, () => lastCpuBusyMs, 1f, enableCpuGpuCollection, 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]; @@ -273,6 +287,8 @@ static void ResizeMetric(Metric m, int newCap) void Update() { + UpdateThreadSpeedStats(); + // Get last frame timing if (enableCpuGpuCollection) { @@ -298,11 +314,11 @@ void Update() } // 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); + 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; @@ -331,9 +347,9 @@ void Update() } // 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); + 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) { @@ -400,16 +416,51 @@ void WriteSample(float now, float totalMs, float cpuMs, float gpuMs) count = Mathf.Min(count + 1, capacity); } - float SampleTotalMs() - { - if (totalFromFrameTimingWhenAvailable && haveFT) - { - var candidate = Mathf.Max(lastCpuMs, lastGpuMs); - if (candidate > 0f) return candidate; + 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; } - - return Time.unscaledDeltaTime * 1000f; - } + + if (nativeInputManager == null) { + nativeInputManager = NativeInputManager.TryGetExistingInstance(); + } + inputThreadHz = nativeInputManager?.TargetPollingHz ?? 0f; + + if (gamelogicPerformanceStats == null) { + var player = FindFirstObjectByType(); + if (player != null) { + gamelogicPerformanceStats = player.GamelogicEngine as IGamelogicPerformanceStats; + } + } + + 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) { @@ -561,22 +612,22 @@ Rect ResolveStatsRect() } - 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; + 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++) @@ -585,8 +636,12 @@ float ComputeStatsPanelHeight() if (m != null && m.enabled && m.stats.valid) lines += 1; } - // Height = top pad + header + N*(lineH) + bottom pad - float h = pad + headerH + (lines - 1) * lineH + pad; + // 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 @@ -836,22 +891,22 @@ static void FillRect(Rect r, Color c) 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; + 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); + 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 @@ -861,6 +916,14 @@ void DrawStatsPanel(Rect statsRect) 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; } @@ -883,6 +946,13 @@ void DrawStatsLine(Metric m, ref float y, float x, float w) 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) @@ -895,4 +965,4 @@ void EnsureColumnBuffers(int w) if (polyPts == null || polyPts.Length < w) polyPts = new Vector2[Mathf.Max(w, 128)]; } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 028df31ca..9ec32f013 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -157,6 +157,11 @@ internal void DispatchSwitch(string switchId, bool isClosed) } GamelogicEngine?.Switch(switchId, isClosed); } + + internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) + { + return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled); + } public Dictionary SwitchStatuses => _switchPlayer.SwitchStatuses; public Dictionary CoilStatuses => _coilPlayer.CoilStatuses; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 984b1927e..a097c2ec1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -28,8 +28,13 @@ public class NativeInputManager : IDisposable private static readonly object _instanceLock = new object(); private volatile SimulationThread _simulationThread; - private bool _initialized = false; - private bool _polling = false; + private bool _initialized = false; + private bool _polling = false; + private int _pollIntervalUs = 0; + private const double PerfSampleWindowSeconds = 0.25; + private long _inputPerfWindowStartTicks = DateTime.UtcNow.Ticks; + private int _inputEventsInWindow; + private float _actualEventRateHz; // Input configuration private readonly List _bindings = new(); @@ -41,7 +46,7 @@ public class NativeInputManager : IDisposable #region Singleton - public static NativeInputManager Instance + public static NativeInputManager Instance { get { @@ -57,7 +62,15 @@ public static NativeInputManager Instance } 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() { @@ -124,8 +137,8 @@ public void ClearBindings() /// Start input polling /// /// Polling interval in microseconds (default 500) - public bool StartPolling(int pollIntervalUs = 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) { @@ -159,8 +172,9 @@ public bool StartPolling(int pollIntervalUs = 500) return false; } - _polling = true; - Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); + _polling = true; + _pollIntervalUs = pollIntervalUs; + Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); return true; } @@ -168,12 +182,14 @@ public bool StartPolling(int pollIntervalUs = 500) /// /// Stop input polling /// - public void StopPolling() - { - if (!_polling) return; + public void StopPolling() + { + if (!_polling) return; - NativeInputApi.VpeInputStopPolling(); - _polling = false; + NativeInputApi.VpeInputStopPolling(); + _polling = false; + _pollIntervalUs = 0; + Volatile.Write(ref _actualEventRateHz, 0f); Logger.Info($"{LogPrefix} [NativeInputManager] Stopped polling"); } @@ -213,8 +229,8 @@ private void SetupDefaultBindings() /// 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) - { + 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}"); } @@ -223,9 +239,30 @@ private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userD } // Forward to simulation thread via ring buffer - var instance = Volatile.Read(ref _instance); - instance?._simulationThread?.EnqueueInputEvent(evt); - } + var instance = Volatile.Read(ref _instance); + instance?.MarkInputEventActivity(); + instance?._simulationThread?.EnqueueInputEvent(evt); + } + + private void MarkInputEventActivity() + { + Interlocked.Increment(ref _inputEventsInWindow); + + var nowTicks = DateTime.UtcNow.Ticks; + var startTicks = Volatile.Read(ref _inputPerfWindowStartTicks); + var elapsedSeconds = (nowTicks - startTicks) / (double)TimeSpan.TicksPerSecond; + 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 @@ -246,4 +283,4 @@ public void Dispose() #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index a78c2a68c..2841e1744 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -35,6 +35,8 @@ public class SimulationThread : IDisposable private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds private const long BusyWaitThresholdUsec = 100; // Last 100us busy-wait for precision + private const int MaxCoilOutputsPerTick = 128; + private const long TimeFenceUpdateIntervalUsec = 5_000; #endregion @@ -43,7 +45,9 @@ public class SimulationThread : IDisposable private readonly PhysicsEngine _physicsEngine; private readonly IGamelogicEngine _gamelogicEngine; private readonly IGamelogicTimeFence _timeFence; + private readonly IGamelogicCoilOutputFeed _coilOutputFeed; private readonly IGamelogicInputDispatcher _inputDispatcher; + private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; @@ -56,6 +60,7 @@ public class SimulationThread : IDisposable private readonly long _busyWaitThresholdTicks; private long _lastTickTicks; private long _simulationTimeUsec; + private long _lastTimeFenceUsec = long.MinValue; // Input state tracking (allocation-free indexed arrays) private readonly bool[] _actionStates; @@ -90,12 +95,15 @@ public PendingSwitchEvent(string switchId, bool isClosed) #region Constructor - public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine) + 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; _inputDispatcher = GamelogicInputDispatcherFactory.Create(gamelogicEngine); + _simulationCoilDispatcher = simulationCoilDispatcher; _inputBuffer = new InputEventBuffer(1024); _sharedState = new SimulationState(); @@ -134,6 +142,7 @@ public void Start() _gamelogicStarted = _gamelogicEngine != null; _lastTickTicks = Stopwatch.GetTimestamp(); _simulationTimeUsec = 0; + _lastTimeFenceUsec = long.MinValue; _tickCount = 0; _inputEventsProcessed = 0; _inputEventsDropped = 0; @@ -339,18 +348,26 @@ private void SimulationThreadFunc() /// private void SimulationTick() { - _timeFence?.SetTimeFence(_simulationTimeUsec / 1_000_000.0); - // 0. Process switch events that originated on Unity/main thread. ProcessExternalSwitchEvents(); // 1. Process input events from ring buffer ProcessInputEvents(); - // 2. Update physics simulation + // 2. Apply low-latency coil outputs from gamelogic to simulation-side handlers. + ProcessGamelogicOutputs(); + + // 3. Update physics simulation UpdatePhysics(); - // 3. Write to shared state and swap buffers + // 4. Move the emulation fence after inputs+outputs+physics. + // Throttle updates to reduce fence wake/sleep churn in PinMAME. + if (_timeFence != null && (_lastTimeFenceUsec == long.MinValue || _simulationTimeUsec - _lastTimeFenceUsec >= TimeFenceUpdateIntervalUsec)) { + _timeFence.SetTimeFence(_simulationTimeUsec / 1_000_000.0); + _lastTimeFenceUsec = _simulationTimeUsec; + } + + // 5. Write to shared state and swap buffers WriteSharedState(); // Increment simulation time @@ -395,6 +412,19 @@ private void ProcessInputEvents() } } + private void ProcessGamelogicOutputs() + { + if (_coilOutputFeed == null || _simulationCoilDispatcher == null) { + return; + } + + var processed = 0; + while (processed < MaxCoilOutputsPerTick && _coilOutputFeed.TryDequeueCoilEvent(out var coilEvent)) { + _simulationCoilDispatcher(coilEvent.Id, coilEvent.IsEnabled); + processed++; + } + } + private void ProcessExternalSwitchEvents() { while (true) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 4eac9a6f3..3ff81a366 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -58,8 +58,17 @@ public class SimulationThreadComponent : MonoBehaviour private SimulationThread _simulationThread; private NativeInputManager _inputManager; - private bool _started = false; - private float _lastStatisticsTime; + 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 @@ -95,8 +104,10 @@ private void Update() // Read shared state from simulation thread ref readonly var state = ref _simulationThread.GetSharedState(); - // Apply state to Unity GameObjects - ApplySimulationState(in state); + // Apply state to Unity GameObjects + ApplySimulationState(in state); + + UpdateSimulationSpeed(in state); // Show statistics if (ShowStatistics && Time.time - _lastStatisticsTime >= StatisticsInterval) @@ -134,10 +145,11 @@ public void StartSimulation() try { + var player = GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren(); + // Resolve dependencies (safe even if Awake order differs) _physicsEngine ??= GetComponent(); if (_gamelogicEngine == null) { - var player = GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren(); _gamelogicEngine = player != null ? player.GamelogicEngine : (GetComponent() ?? GetComponentInParent() ?? GetComponentInChildren()); @@ -152,7 +164,10 @@ public void StartSimulation() _physicsEngine.SetExternalTiming(true); // Create simulation thread - _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine); + _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, + player != null + ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) + : null); // Initialize and start native input if enabled if (EnableNativeInput) @@ -198,7 +213,11 @@ public void StopSimulation() // Restore normal Unity Update() loop timing _physicsEngine.SetExternalTiming(false); - _started = false; + _started = false; + _simulationThreadSpeedX = 0f; + _simulationThreadHz = 0f; + _lastSampleSimulationUsec = 0; + _lastSampleUnscaledTime = 0f; Logger.Info($"{LogPrefix} [SimulationThreadComponent] Simulation stopped"); } @@ -255,6 +274,36 @@ private void LogStatistics(in SimulationState.Snapshot state) Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}"); } + + 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/VPT/Flipper/FlipperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs index 3330f9f98..ab5d75379 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs @@ -68,11 +68,11 @@ public class FlipperApi : CollidableApi Date: Sun, 1 Mar 2026 01:37:10 +0100 Subject: [PATCH 24/51] physics: Address threading issues. --- .../VisualPinball.Unity/Game/CoilPlayer.cs | 45 +++++++++---- .../VisualPinball.Unity/Game/DeviceCoil.cs | 66 ++++++++++++++----- .../Game/FramePacingGraph.cs | 20 +++--- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 18 ++++- .../Simulation/NativeInputManager.cs | 17 ++--- .../VPT/Flipper/FlipperApi.cs | 58 ++++++++++++++-- 6 files changed, 170 insertions(+), 54 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 72709c7e1..914fdcecc 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; + } } } @@ -211,12 +216,26 @@ internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) return dispatched; } - public void OnDestroy() - { - if (_coilAssignments.Count > 0 && _gamelogicEngine != null) { - _gamelogicEngine.OnCoilChanged -= HandleCoilEvent; - } - } + 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 5058c1963..b4878595d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -31,16 +31,26 @@ public class DeviceCoil : IApiCoil 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; + 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; - + + private readonly Player _player; + public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null, Action onEnableSimulationThread = null, Action onDisableSimulationThread = null) { @@ -55,19 +65,32 @@ public void OnCoil(bool enabled) { Interlocked.Exchange(ref _isEnabled, enabled ? 1 : 0); - if (enabled) { - OnEnable?.Invoke(); - } else { - OnDisable?.Invoke(); - } - CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); -#if UNITY_EDITOR - RefreshUI(); -#endif + // 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; } @@ -79,9 +102,20 @@ public void OnCoilSimulationThread(bool enabled) } } - public void OnChange(bool enabled) => OnCoil(enabled); - -#if UNITY_EDITOR + 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs index 83ade7427..8622ac733 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/FramePacingGraph.cs @@ -164,9 +164,10 @@ public Metric(string name, Color color, Func sampler, float scale, bool e // Cached GUIStyle GUIStyle labelStyle; - SimulationThreadComponent simulationThreadComponent; - NativeInputManager nativeInputManager; - IGamelogicPerformanceStats gamelogicPerformanceStats; + SimulationThreadComponent simulationThreadComponent; + NativeInputManager nativeInputManager; + IGamelogicPerformanceStats gamelogicPerformanceStats; + bool gamelogicPerformanceStatsResolved; float simulationThreadSpeedX; float simulationThreadHz; float inputThreadHz; @@ -444,12 +445,13 @@ void UpdateThreadSpeedStats() } inputThreadHz = nativeInputManager?.TargetPollingHz ?? 0f; - if (gamelogicPerformanceStats == null) { - var player = FindFirstObjectByType(); - if (player != null) { - gamelogicPerformanceStats = player.GamelogicEngine as IGamelogicPerformanceStats; - } - } + 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; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index e5d7139ca..f7451de47 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -415,14 +415,21 @@ public void ExecuteTick(ulong timeUsec) /// /// Apply physics state to GameObjects (must be called from main thread). /// This updates transforms based on physics simulation results. + /// Non-blocking: if the simulation thread currently holds the physics + /// lock, this frame's visual update is skipped (next frame will catch up). /// public void ApplyMovements() { if (!_useExternalTiming || !_isInitialized) return; - lock (_physicsLock) { + if (!Monitor.TryEnter(_physicsLock)) { + return; // sim thread is mid-tick; skip this frame + } + try { var state = CreateState(); ApplyAllMovements(ref state); + } finally { + Monitor.Exit(_physicsLock); } } @@ -441,6 +448,8 @@ private void Update() /// /// 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. /// private void DrainExternalThreadCallbacks() { @@ -448,7 +457,10 @@ private void DrainExternalThreadCallbacks() return; } - lock (_physicsLock) { + if (!Monitor.TryEnter(_physicsLock)) { + return; // sim thread is mid-tick; drain next frame + } + try { while (_eventQueue.Ref.TryDequeue(out var eventData)) { _player.OnEvent(in eventData); } @@ -461,6 +473,8 @@ private void DrainExternalThreadCallbacks() } } } + } finally { + Monitor.Exit(_physicsLock); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index a097c2ec1..b6fecbd8b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -4,11 +4,12 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -using System; -using System.Collections.Generic; -using System.Threading; -using NLog; -using Logger = NLog.Logger; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using NLog; +using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation { @@ -32,7 +33,7 @@ public class NativeInputManager : IDisposable private bool _polling = false; private int _pollIntervalUs = 0; private const double PerfSampleWindowSeconds = 0.25; - private long _inputPerfWindowStartTicks = DateTime.UtcNow.Ticks; + private long _inputPerfWindowStartTicks = Stopwatch.GetTimestamp(); private int _inputEventsInWindow; private float _actualEventRateHz; @@ -248,9 +249,9 @@ private void MarkInputEventActivity() { Interlocked.Increment(ref _inputEventsInWindow); - var nowTicks = DateTime.UtcNow.Ticks; + var nowTicks = Stopwatch.GetTimestamp(); var startTicks = Volatile.Read(ref _inputPerfWindowStartTicks); - var elapsedSeconds = (nowTicks - startTicks) / (double)TimeSpan.TicksPerSecond; + var elapsedSeconds = (nowTicks - startTicks) / (double)Stopwatch.Frequency; if (elapsedSeconds < PerfSampleWindowSeconds) { return; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs index ab5d75379..e5de3ff1a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs @@ -70,8 +70,8 @@ public class FlipperApi : CollidableApi 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) { From a9dad29066e9d5aa0b1ab1af0c7661fb603f689c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Mar 2026 18:02:14 +0100 Subject: [PATCH 25/51] physics: Remove physics lock for ApplyMovements, and fix kinematic colliders. Expand SimulationState double-buffer to carry ball/flipper/gate transform data, and have ApplyMovements() read from the front buffer without acquiring _physicsLock. This eliminates the TryEnter frame drops (B1) and resolves the double-buffer race (Bug 1) by ensuring the main thread only reads from the stable front buffer. Fix the kinematic collider update gap. Approach: main thread writes kinematic transforms to a shared buffer (protected by a lightweight lock or atomic flag), and the sim thread reads them at the start of each tick. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 332 ++++++++++++- .../Simulation/SimulationState.cs | 463 +++++++++++------- .../Simulation/SimulationThread.cs | 60 ++- .../Simulation/SimulationThreadComponent.cs | 55 ++- 4 files changed, 685 insertions(+), 225 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f7451de47..df6348f27 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -26,6 +26,7 @@ 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; @@ -129,6 +130,37 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; [NonSerialized] private float4x4 _worldToPlayfield; + /// + /// Reference to the triple-buffered simulation state owned by the + /// SimulationThread. Set via after + /// the thread is created. Null when running in single-threaded mode. + /// + [NonSerialized] private SimulationState _simulationState; + + #region Kinematic Pending Buffer (Fix 2) + + /// + /// Staging area for kinematic transform updates computed on the main + /// thread. Protected by . + /// + [NonSerialized] private readonly LazyInit> _pendingKinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + + /// + /// Lock protecting . + /// Lock ordering: sim thread may hold _physicsLock then acquire + /// _pendingKinematicLock. Main thread only holds _pendingKinematicLock. + /// + private readonly object _pendingKinematicLock = new object(); + + /// + /// Main-thread-only cache of last-reported kinematic transforms, + /// used to detect changes without reading _kinematicTransforms (which + /// the sim thread writes). + /// + [NonSerialized] private readonly Dictionary _mainThreadKinematicCache = new(); + + #endregion + private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); /// @@ -332,7 +364,9 @@ private void Start() // 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); + _kinematicTransforms.Ref[coll.ItemId] = matrix; + _mainThreadKinematicCache[coll.ItemId] = matrix; } #if UNITY_EDITOR _colliderLookups = colliders.CreateLookup(Allocator.Persistent); @@ -393,6 +427,16 @@ public void SetExternalTiming(bool enable) } } + /// + /// Provide the triple-buffered SimulationState so that + /// can write animation data and + /// can read it lock-free. + /// + public void SetSimulationState(SimulationState state) + { + _simulationState = state; + } + /// /// Execute a single physics tick with external timing (for simulation thread). /// This runs the physics simulation but does NOT apply movements to GameObjects. @@ -414,22 +458,31 @@ public void ExecuteTick(ulong timeUsec) /// /// Apply physics state to GameObjects (must be called from main thread). - /// This updates transforms based on physics simulation results. - /// Non-blocking: if the simulation thread currently holds the physics - /// lock, this frame's visual update is skipped (next frame will catch up). + /// When a has been set via + /// , this reads the latest published + /// snapshot — completely lock-free. Otherwise falls back to the legacy + /// lock-based path. /// public void ApplyMovements() { if (!_useExternalTiming || !_isInitialized) return; - if (!Monitor.TryEnter(_physicsLock)) { - return; // sim thread is mid-tick; skip this frame - } - try { - var state = CreateState(); - ApplyAllMovements(ref state); - } finally { - Monitor.Exit(_physicsLock); + if (_simulationState != null) { + // Lock-free path: read from triple-buffered snapshot + ref readonly var snapshot = ref _simulationState.AcquireReadBuffer(); + ApplyMovementsFromSnapshot(in snapshot); + } else { + // Legacy path (no SimulationState set — shouldn't happen in + // normal operation but kept as safety net). + if (!Monitor.TryEnter(_physicsLock)) { + return; // sim thread is mid-tick; skip this frame + } + try { + var state = CreateState(); + ApplyAllMovements(ref state); + } finally { + Monitor.Exit(_physicsLock); + } } } @@ -439,6 +492,11 @@ private void Update() // Simulation thread mode: physics runs on simulation thread, // but managed callbacks must run on Unity main thread. DrainExternalThreadCallbacks(); + + // Collect kinematic transform changes on main thread and + // stage them for the sim thread to apply (Fix 2). + UpdateKinematicTransformsFromMainThread(); + ApplyMovements(); } else { // Normal mode: Execute full physics update @@ -486,8 +544,8 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) { var sw = Stopwatch.StartNew(); - // check for updated kinematic transforms (main thread only, skip for now) - // TODO: Find a way to update these from simulation thread + // Apply kinematic transform updates staged by main thread (Fix 2). + ApplyPendingKinematicTransforms(); var state = CreateState(); @@ -588,6 +646,251 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyTriggerMovement(ref _triggerStates.Ref, _floatAnimatedComponents); } + #region Snapshot-Based Movement (Fix 1) + + /// + /// Copy current animation values from physics state maps into the + /// given snapshot buffer. Called on the sim thread AFTER + /// returns (sequential within the thread, + /// so reading physics state maps is safe without an extra lock). + /// MUST BE ALLOCATION-FREE. + /// + internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) + { + // --- Balls --- + var ballCount = 0; + using (var enumerator = _ballStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && ballCount < SimulationState.MaxBalls) { + 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; + + // --- Float animations --- + var floatCount = 0; + + // Flippers + using (var enumerator = _flipperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Bumper rings (float) — ring animation + using (var enumerator = _bumperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.RingItemId != 0) { + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.RingAnimation.Offset + }; + } + } + } + + // Drop targets + using (var enumerator = _dropTargetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.ZOffset + }; + } + } + + // Hit targets + using (var enumerator = _hitTargetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.XRotation + }; + } + } + + // Gates + using (var enumerator = _gateStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Plungers + using (var enumerator = _plungerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.Position + }; + } + } + + // Spinners + using (var enumerator = _spinnerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Triggers + using (var enumerator = _triggerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.HeightOffset + }; + } + } + + snapshot.FloatAnimationCount = floatCount; + + // --- Float2 animations (bumper skirts) --- + var float2Count = 0; + using (var enumerator = _bumperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && float2Count < SimulationState.MaxFloat2Animations) { + ref var s = ref enumerator.Current.Value; + if (s.SkirtItemId != 0) { + snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { + ItemId = enumerator.Current.Key, Value = s.SkirtAnimation.Rotation + }; + } + } + } + snapshot.Float2AnimationCount = float2Count; + } + + /// + /// Apply visual updates from a snapshot — called on the main thread, + /// completely lock-free. Replaces the legacy + /// path when in external timing mode. + /// + 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 (_ballComponents.TryGetValue(bs.Id, out var ballComponent)) { + // Reconstruct a lightweight BallState with the fields Move() needs. + 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 (_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 (_float2AnimatedComponents.TryGetValue(anim.ItemId, out var emitter)) { + emitter.UpdateAnimationValue(anim.Value); + } + } + } + + #endregion + + #region Kinematic Transform Staging (Fix 2) + + /// + /// Collect kinematic transform changes on the Unity main thread and + /// stage them in for the sim + /// thread to apply. Only called when + /// is true. Uses for change + /// detection (never reads which the + /// sim thread writes). + /// + internal void UpdateKinematicTransformsFromMainThread() + { + if (!_useExternalTiming || !_isInitialized || _kinematicColliderComponents == null) return; + + foreach (var coll in _kinematicColliderComponents) { + var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + + // Check against main-thread cache + if (_mainThreadKinematicCache.TryGetValue(coll.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { + continue; + } + + // Transform changed — update cache + _mainThreadKinematicCache[coll.ItemId] = currMatrix; + + // Notify the component (e.g. KickerColliderComponent updates its + // center). NOTE: this writes physics state from the main thread, + // which is a pre-existing thread-safety issue inherited from the + // original code. A future improvement would schedule these as + // input actions. + coll.OnTransformationChanged(currMatrix); + + // Stage for the sim thread + lock (_pendingKinematicLock) { + _pendingKinematicTransforms.Ref[coll.ItemId] = currMatrix; + } + } + } + + /// + /// Apply kinematic transforms staged by the main thread into the + /// physics state maps. Called on the sim thread inside + /// (inside _physicsLock), so + /// writing to and + /// is safe. + /// Lock ordering: _physicsLock (held) → _pendingKinematicLock (inner). + /// + internal void ApplyPendingKinematicTransforms() + { + if (!_pendingKinematicTransforms.Ref.IsCreated) return; + + _updatedKinematicTransforms.Ref.Clear(); + + lock (_pendingKinematicLock) { + if (_pendingKinematicTransforms.Ref.Count() == 0) return; + + using var enumerator = _pendingKinematicTransforms.Ref.GetEnumerator(); + while (enumerator.MoveNext()) { + var itemId = enumerator.Current.Key; + var matrix = enumerator.Current.Value; + _updatedKinematicTransforms.Ref[itemId] = matrix; + _kinematicTransforms.Ref[itemId] = matrix; + } + _pendingKinematicTransforms.Ref.Clear(); + } + } + + #endregion + private void ProcessInputActions(ref PhysicsState state) { lock (_inputActionsLock) { @@ -632,6 +935,7 @@ private void OnDestroy() _disabledCollisionItems.Ref.Dispose(); _kinematicTransforms.Ref.Dispose(); _updatedKinematicTransforms.Ref.Dispose(); + _pendingKinematicTransforms.Ref.Dispose(); using (var enumerator = _kinematicColliderLookups.GetEnumerator()) { while (enumerator.MoveNext()) { enumerator.Current.Value.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 540cdfbb4..a1b5259a0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -1,74 +1,133 @@ -// 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 Unity.Collections; -using Unity.Collections.LowLevel.Unsafe; - -namespace VisualPinball.Unity.Simulation -{ - /// - /// Shared simulation state between simulation thread and Unity main thread. - /// Uses double-buffering for lock-free reads. - /// - 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; - - #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 - /// +// 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.Collections.LowLevel.Unsafe; +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 @@ -79,96 +138,172 @@ public struct Snapshot public NativeArray CoilStates; public NativeArray LampStates; public NativeArray GIStates; - - // 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; - - public void Allocate() - { - CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); - LampStates = new NativeArray(MaxLamps, Allocator.Persistent); - GIStates = new NativeArray(MaxGIStrings, Allocator.Persistent); - } - - public void Dispose() - { - if (CoilStates.IsCreated) CoilStates.Dispose(); - if (LampStates.IsCreated) LampStates.Dispose(); - if (GIStates.IsCreated) GIStates.Dispose(); - } - } - - #endregion - - #region Fields - - // Double-buffered snapshots - private Snapshot _backBuffer; - private Snapshot _frontBuffer; - - // Atomic pointer swap for lock-free reading - private volatile int _currentFrontBuffer = 0; // 0 = _frontBuffer, 1 = _backBuffer - - private bool _disposed = false; - - #endregion - - #region Constructor / Dispose - - public SimulationState() - { - _frontBuffer.Allocate(); - _backBuffer.Allocate(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _frontBuffer.Dispose(); - _backBuffer.Dispose(); - } - - #endregion - - #region Write (Simulation Thread) - - /// - /// Get the back buffer for writing. - /// Called by simulation thread only. - /// - public ref Snapshot GetBackBuffer() - { - return ref (_currentFrontBuffer == 0 ? ref _backBuffer : ref _frontBuffer); - } - - /// - /// Swap buffers atomically. - /// Called by simulation thread after writing to back buffer. - /// - public void SwapBuffers() - { - // Atomic swap - _currentFrontBuffer = 1 - _currentFrontBuffer; - } - - #endregion - - #region Read (Main Thread) - - /// - /// Get the front buffer for reading. - /// Called by Unity main thread only. - /// Lock-free read. - /// - public ref readonly Snapshot GetFrontBuffer() - { - return ref (_currentFrontBuffer == 0 ? ref _frontBuffer : ref _backBuffer); - } - - #endregion - } -} + + // 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 NativeArray FloatAnimations; + public int FloatAnimationCount; + + public NativeArray Float2Animations; + public int Float2AnimationCount; + + public void Allocate() + { + CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); + LampStates = new NativeArray(MaxLamps, Allocator.Persistent); + GIStates = new NativeArray(MaxGIStrings, Allocator.Persistent); + BallSnapshots = new NativeArray(MaxBalls, Allocator.Persistent); + FloatAnimations = new NativeArray(MaxFloatAnimations, Allocator.Persistent); + Float2Animations = new NativeArray(MaxFloat2Animations, Allocator.Persistent); + BallCount = 0; + FloatAnimationCount = 0; + Float2AnimationCount = 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. + /// Called by simulation thread only. + /// + public 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. + /// Called by simulation thread only — allocation-free. + /// + public 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. + /// Called by Unity main thread only — allocation-free. + /// Returns a ref to the acquired buffer that is safe to read until the + /// next call to . + /// + public 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. + /// + public 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/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 2841e1744..433d193c3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -206,14 +206,24 @@ public void EnqueueInputEvent(NativeInputApi.InputEvent evt) } } - /// - /// Get the current shared state (for main thread to read) - /// + /// + /// 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. + /// public ref readonly SimulationState.Snapshot GetSharedState() { - return ref _sharedState.GetFrontBuffer(); + return ref _sharedState.PeekReadBuffer(); } + /// + /// The triple-buffered SimulationState, so the caller can pass it to + /// . + /// + public SimulationState SharedState => _sharedState; + public void FlushMainThreadInputDispatch() { _inputDispatcher.FlushMainThread(); @@ -614,28 +624,34 @@ private void UpdatePhysics() } } - /// - /// Write simulation state to shared memory and swap buffers - /// + /// + /// 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 backBuffer = ref _sharedState.GetBackBuffer(); - + ref var writeBuffer = ref _sharedState.GetWriteBuffer(); + // Update timing - backBuffer.SimulationTimeUsec = _simulationTimeUsec; - backBuffer.RealTimeUsec = GetTimestampUsec(); + writeBuffer.SimulationTimeUsec = _simulationTimeUsec; + writeBuffer.RealTimeUsec = GetTimestampUsec(); - - // Copy PinMAME state (coils, lamps, GI) - // This is where we'd copy the changed outputs from PinMAME - // For now, this is a placeholder - // TODO: Implement state copying - - // Increment physics state version (main thread will detect changes) - backBuffer.PhysicsStateVersion++; - - // Atomically swap buffers (lock-free) - _sharedState.SwapBuffers(); + // Copy PinMAME state (coils, lamps, GI) + // This is where we'd copy the changed outputs from PinMAME + // For now, this is a placeholder + // TODO: Implement state copying + + // Increment physics state version (main thread will detect changes) + writeBuffer.PhysicsStateVersion++; + + // Snapshot animation data from physics state maps into the buffer. + // Safe: we are the only writer and this runs sequentially after + // ExecuteTick(). + _physicsEngine.SnapshotAnimations(ref writeBuffer); + + // Atomically publish this buffer (lock-free triple-buffer swap) + _sharedState.PublishWriteBuffer(); } private static long GetTimestampUsec() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 3ff81a366..15e1f0e63 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -163,11 +163,15 @@ public void StartSimulation() // This disables Unity's Update() loop and gives control to the simulation thread _physicsEngine.SetExternalTiming(true); - // Create simulation thread + // Create simulation thread _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, player != null ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) : null); + + // 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) @@ -201,24 +205,25 @@ public void StartSimulation() /// /// 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); - + 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"); } @@ -251,17 +256,17 @@ internal bool EnqueueSwitchFromMainThread(string switchId, bool isClosed) #region Private Methods - /// - /// Apply simulation state to Unity GameObjects - /// - private void ApplySimulationState(in SimulationState.Snapshot state) - { - // This is where we'd update GameObjects based on the simulation state - // For now, the physics engine will continue to update GameObjects directly - // In a full implementation, we'd read the physics state here and apply it - - // TODO: Apply ball positions, flipper rotations, etc. from shared state - } + /// + /// 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) — currently a placeholder. + } /// /// Log statistics about simulation performance From b0c5e439afe61a3611258a32de96cde741d39c3e Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Mar 2026 18:15:18 +0100 Subject: [PATCH 26/51] cleanup: Remove legacy lock-based fallback path in PhysicsEngine.ApplyMovements() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Removed the legacy fallback in ApplyMovements() — the old else branch that used Monitor.TryEnter(_physicsLock) to acquire the physics lock and call ApplyAllMovements() directly. That path was a leftover from before the triple buffer and would skip entire frames when the sim thread held the lock. 2. Added InvalidOperationException when _simulationState is null but ApplyMovements() is called in external timing mode. This is incoherent state — the startup sequence in SimulationThreadComponent.StartSimulation() always sets it before the first Update(), so hitting this means something is wired wrong. 3. Cleaned up xmldoc on both ApplyMovements() and ApplyMovementsFromSnapshot() to remove references to "legacy" and "fallback" paths. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 33 +++++++------------ .../Simulation/SimulationState.cs | 3 +- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index df6348f27..dd09af373 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -458,32 +458,21 @@ public void ExecuteTick(ulong timeUsec) /// /// Apply physics state to GameObjects (must be called from main thread). - /// When a has been set via - /// , this reads the latest published - /// snapshot — completely lock-free. Otherwise falls back to the legacy - /// lock-based path. + /// Reads the latest published snapshot from the triple-buffered + /// — completely lock-free. /// public void ApplyMovements() { if (!_useExternalTiming || !_isInitialized) return; - if (_simulationState != null) { - // Lock-free path: read from triple-buffered snapshot - ref readonly var snapshot = ref _simulationState.AcquireReadBuffer(); - ApplyMovementsFromSnapshot(in snapshot); - } else { - // Legacy path (no SimulationState set — shouldn't happen in - // normal operation but kept as safety net). - if (!Monitor.TryEnter(_physicsLock)) { - return; // sim thread is mid-tick; skip this frame - } - try { - var state = CreateState(); - ApplyAllMovements(ref state); - } finally { - Monitor.Exit(_physicsLock); - } + if (_simulationState == null) { + throw new InvalidOperationException( + "ApplyMovements() requires a SimulationState. " + + "Call SetSimulationState() before enabling external timing."); } + + ref readonly var snapshot = ref _simulationState.AcquireReadBuffer(); + ApplyMovementsFromSnapshot(in snapshot); } private void Update() @@ -781,8 +770,8 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) /// /// Apply visual updates from a snapshot — called on the main thread, - /// completely lock-free. Replaces the legacy - /// path when in external timing mode. + /// completely lock-free. Used in external timing mode (sim thread). + /// The single-threaded path uses instead. /// private void ApplyMovementsFromSnapshot(in SimulationState.Snapshot snapshot) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index a1b5259a0..33125f4df 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -8,7 +8,6 @@ using System.Runtime.InteropServices; using System.Threading; using Unity.Collections; -using Unity.Collections.LowLevel.Unsafe; using Unity.Mathematics; namespace VisualPinball.Unity.Simulation @@ -306,4 +305,4 @@ private ref Snapshot GetBufferByIndex(int index) #endregion } -} +} \ No newline at end of file From 73b8f57096218469e0120a6212a7e2b32a3ad953 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Mar 2026 23:56:45 +0100 Subject: [PATCH 27/51] physics: Move physics context and threading code into a separate class. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 966 +++++------------- .../Game/PhysicsEngineContext.cs | 307 ++++++ .../Game/PhysicsEngineContext.cs.meta | 2 + .../Game/PhysicsEngineThreading.cs | 561 ++++++++++ .../Game/PhysicsEngineThreading.cs.meta | 2 + .../Simulation/SimulationState.cs | 17 +- .../Simulation/SimulationThread.cs | 64 +- .../Simulation/SimulationThreadComponent.cs | 2 +- 8 files changed, 1182 insertions(+), 739 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index dd09af373..a3fdeb1cb 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 @@ -33,6 +33,81 @@ 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 { @@ -55,183 +130,121 @@ 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 NativeParallelHashSet _overlappingColliders = new(0, Allocator.Persistent); - [NonSerialized] private PhysicsEnv _physicsEnv; - [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; - - #endregion - - #region Transforms - - [NonSerialized] private readonly Dictionary _ballComponents = new(); + #region Fields /// - /// Last transforms of kinematic items, so we can detect changes. + /// 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 LazyInit> _kinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + [NonSerialized] private readonly PhysicsEngineContext _ctx = new(); /// - /// The transforms of the kinematic items that have changes since the last frame. + /// Threading/tick methods. Created at end of + /// once all context fields are populated. /// - [NonSerialized] private 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 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 - /// - /// - /// 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. - /// - [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(); - - #endregion - - [NonSerialized] private readonly Queue _inputActions = new(); - private readonly object _inputActionsLock = new object(); - [NonSerialized] private readonly List _scheduledActions = new(); + [NonSerialized] private PhysicsEngineThreading _threading; + // Lifecycle-local references (used during Awake/Start, then passed + // to _threading constructor). [NonSerialized] private Player _player; - [NonSerialized] private PhysicsMovements _physicsMovements; [NonSerialized] private ICollidableComponent[] _colliderComponents; [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; [NonSerialized] private float4x4 _worldToPlayfield; - /// - /// Reference to the triple-buffered simulation state owned by the - /// SimulationThread. Set via after - /// the thread is created. Null when running in single-threaded mode. - /// - [NonSerialized] private SimulationState _simulationState; - - #region Kinematic Pending Buffer (Fix 2) - - /// - /// Staging area for kinematic transform updates computed on the main - /// thread. Protected by . - /// - [NonSerialized] private readonly LazyInit> _pendingKinematicTransforms = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); - - /// - /// Lock protecting . - /// Lock ordering: sim thread may hold _physicsLock then acquire - /// _pendingKinematicLock. Main thread only holds _pendingKinematicLock. - /// - private readonly object _pendingKinematicLock = new object(); - - /// - /// Main-thread-only cache of last-reported kinematic transforms, - /// used to detect changes without reading _kinematicTransforms (which - /// the sim thread writes). - /// - [NonSerialized] private readonly Dictionary _mainThreadKinematicCache = new(); - #endregion private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); /// - /// Current physics time in microseconds (for external tick support) - /// - private ulong _externalTimeUsec = 0; - - /// - /// Whether to use external timing (simulation thread) or Unity's Time + /// Check if the physics engine has completed initialization. + /// Used by the simulation thread to wait for physics to be ready. /// - private bool _useExternalTiming = false; + public bool IsInitialized => _ctx != null && _ctx.IsInitialized; - /// - /// Lock for synchronizing physics state access between threads - /// - private readonly object _physicsLock = new object(); + #region Ref-Return Properties /// - /// Whether physics engine is fully initialized and ready for simulation thread + /// Elasticity-over-velocity lookup tables, keyed by item ID. /// - private volatile bool _isInitialized = false; + /// + /// 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; /// - /// Check if the physics engine has completed initialization. - /// Used by the simulation thread to wait for physics to be ready. + /// Friction-over-velocity lookup tables, keyed by item ID. /// - public bool IsInitialized => _isInitialized; + /// + /// Same ref-return requirement as + /// . + /// + public ref NativeParallelHashMap> FrictionOverVelocityLUTs => ref _ctx.FrictionOverVelocityLUTs; - private float _lastFrameTimeMs; - private long _physicsBusyTotalUsec; + #endregion #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.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); + lock (_ctx.ScheduledActions) { + _ctx.ScheduledActions.Add(new PhysicsEngineContext.ScheduledAction(_ctx.PhysicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); } } 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 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 (_inputActionsLock) { - _inputActions.Enqueue(action); + lock (_ctx.InputActionsLock) { + _ctx.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.TimeMsec; - internal Random Random => _physicsEnv.Random; + + // ── 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) => _ctx.BallStates.Ref.ContainsKey(ballId); + internal ref BallState BallState(int ballId) => ref _ctx.BallStates.Ref.GetValueByRef(ballId); + internal ref BumperState BumperState(int itemId) => ref _ctx.BumperStates.Ref.GetValueByRef(itemId); + internal ref FlipperState FlipperState(int itemId) => ref _ctx.FlipperStates.Ref.GetValueByRef(itemId); + internal ref GateState GateState(int itemId) => ref _ctx.GateStates.Ref.GetValueByRef(itemId); + internal ref DropTargetState DropTargetState(int itemId) => ref _ctx.DropTargetStates.Ref.GetValueByRef(itemId); + internal ref HitTargetState HitTargetState(int itemId) => ref _ctx.HitTargetStates.Ref.GetValueByRef(itemId); + internal ref KickerState KickerState(int itemId) => ref _ctx.KickerStates.Ref.GetValueByRef(itemId); + internal ref PlungerState PlungerState(int itemId) => ref _ctx.PlungerStates.Ref.GetValueByRef(itemId); + internal ref SpinnerState SpinnerState(int itemId) => ref _ctx.SpinnerStates.Ref.GetValueByRef(itemId); + internal ref SurfaceState SurfaceState(int itemId) => ref _ctx.SurfaceStates.Ref.GetValueByRef(itemId); + internal ref TriggerState TriggerState(int itemId) => ref _ctx.TriggerStates.Ref.GetValueByRef(itemId); + internal void SetBallInsideOf(int ballId, int itemId) => _ctx.InsideOfs.SetInsideOf(itemId, ballId); + internal bool HasBallsInsideOf(int itemId) => _ctx.InsideOfs.GetInsideCount(itemId) > 0; + internal List GetBallsInsideOf(int itemId) => _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; @@ -240,64 +253,84 @@ 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); + 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 bool IsColliderEnabled(int itemId) => !_ctx.DisabledCollisionItems.Ref.Contains(itemId); internal void EnableCollider(int itemId) { - if (_disabledCollisionItems.Ref.Contains(itemId)) { - _disabledCollisionItems.Ref.Remove(itemId); + if (_ctx.DisabledCollisionItems.Ref.Contains(itemId)) { + _ctx.DisabledCollisionItems.Ref.Remove(itemId); } } internal void DisableCollider(int itemId) { - if (!_disabledCollisionItems.Ref.Contains(itemId)) { - _disabledCollisionItems.Ref.Add(itemId); + if (!_ctx.DisabledCollisionItems.Ref.Contains(itemId)) { + _ctx.DisabledCollisionItems.Ref.Add(itemId); } } - public BallComponent GetBall(int itemId) => _ballComponents[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); #endregion @@ -306,13 +339,13 @@ internal void DisableCollider(int itemId) private void Awake() { _player = GetComponentInParent(); - _physicsMovements = new PhysicsMovements(); - _insideOfs = new InsideOfs(Allocator.Persistent); - _physicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); + _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); + _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); + _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() @@ -323,10 +356,10 @@ private void Start() // register frame pacing stats var stats = FindFirstObjectByType(); if (stats) { - long lastBusyTotalUsec = Interlocked.Read(ref _physicsBusyTotalUsec); + long lastBusyTotalUsec = Interlocked.Read(ref _ctx.PhysicsBusyTotalUsec); InputLatencyTracker.Reset(); stats.RegisterCustomMetric("Physics", Color.magenta, () => { - var totalBusyUsec = Interlocked.Read(ref _physicsBusyTotalUsec); + var totalBusyUsec = Interlocked.Read(ref _ctx.PhysicsBusyTotalUsec); var deltaBusyUsec = totalBusyUsec - lastBusyTotalUsec; if (deltaBusyUsec < 0) { deltaBusyUsec = 0; @@ -339,16 +372,16 @@ private void Start() // 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); @@ -358,38 +391,38 @@ 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) { var matrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); - _kinematicTransforms.Ref[coll.ItemId] = matrix; - _mainThreadKinematicCache[coll.ItemId] = matrix; + _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(); unsafe { - fixed (NativeColliders* c = &_colliders) - fixed (NativeOctree* o = &_octree) { + fixed (NativeColliders* c = &_ctx.Colliders) + fixed (NativeOctree* o = &_ctx.Octree) { PhysicsPopulate.PopulateUnsafe((IntPtr)c, (IntPtr)o); } } - Debug.Log($"Octree of {_colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms)."); + Debug.Log($"Octree of {_ctx.Colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms)."); // get balls var balls = GetComponentsInChildren(); @@ -397,564 +430,79 @@ private void Start() Register(ball); } + // Create threading helper (all context fields are now populated) + _threading = new PhysicsEngineThreading(_ctx, _player, _kinematicColliderComponents, _worldToPlayfield); + // Mark as initialized for simulation thread - _isInitialized = true; + _ctx.IsInitialized = true; } - 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); - - } + internal PhysicsState CreateState() => _ctx.CreateState(); /// /// Enable external timing control (for simulation thread). - /// When enabled, Update() does nothing and ExecuteTick() must be called instead. + /// When enabled, delegates to + /// , + /// , + /// and . /// - /// Whether to enable external timing + /// + /// Thread: Main thread only (called during setup/teardown). + /// public void SetExternalTiming(bool enable) { - _useExternalTiming = enable; - if (enable) { - _externalTimeUsec = (ulong)(Time.timeAsDouble * 1000000); - } + _ctx.UseExternalTiming = enable; } /// - /// Provide the triple-buffered SimulationState so that - /// can write animation data and - /// can read it lock-free. + /// 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) { - _simulationState = state; + _ctx.SimulationState = state; } /// - /// Execute a single physics tick with external timing (for simulation thread). - /// This runs the physics simulation but does NOT apply movements to GameObjects. - /// Call ApplyMovements() from the main thread to update transforms. + /// Unity update loop. Dispatches to either the threaded or + /// single-threaded code path via . /// - /// Current time in microseconds - public void ExecuteTick(ulong timeUsec) + private void Update() { - // Wait until physics engine is fully initialized - if (!_isInitialized) { + if (_threading == null) { // Start() hasn't completed yet return; } - - lock (_physicsLock) { - _externalTimeUsec = timeUsec; - ExecutePhysicsSimulation(timeUsec); - } - } - - /// - /// Apply physics state to GameObjects (must be called from main thread). - /// Reads the latest published snapshot from the triple-buffered - /// — completely lock-free. - /// - public void ApplyMovements() - { - if (!_useExternalTiming || !_isInitialized) return; - - if (_simulationState == null) { - throw new InvalidOperationException( - "ApplyMovements() requires a SimulationState. " + - "Call SetSimulationState() before enabling external timing."); - } - - ref readonly var snapshot = ref _simulationState.AcquireReadBuffer(); - ApplyMovementsFromSnapshot(in snapshot); - } - - private void Update() - { - if (_useExternalTiming) { + if (_ctx.UseExternalTiming) { // Simulation thread mode: physics runs on simulation thread, // but managed callbacks must run on Unity main thread. - DrainExternalThreadCallbacks(); + _threading.DrainExternalThreadCallbacks(); // Collect kinematic transform changes on main thread and - // stage them for the sim thread to apply (Fix 2). - UpdateKinematicTransformsFromMainThread(); + // stage them for the sim thread to apply. + _threading.UpdateKinematicTransformsFromMainThread(); - ApplyMovements(); + _threading.ApplyMovements(); } else { // Normal mode: Execute full physics update - ExecutePhysicsUpdate(NowUsec); + _threading.ExecutePhysicsUpdate(NowUsec); } } - /// - /// 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. - /// - private void DrainExternalThreadCallbacks() - { - if (!_useExternalTiming || !_isInitialized) { - return; - } - - if (!Monitor.TryEnter(_physicsLock)) { - return; // sim thread is mid-tick; drain next frame - } - try { - while (_eventQueue.Ref.TryDequeue(out var eventData)) { - _player.OnEvent(in eventData); - } - - lock (_scheduledActions) { - for (var i = _scheduledActions.Count - 1; i >= 0; i--) { - if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { - _scheduledActions[i].Action(); - _scheduledActions.RemoveAt(i); - } - } - } - } finally { - Monitor.Exit(_physicsLock); - } - } - - /// - /// Core physics simulation (can be called from simulation thread). - /// Does NOT apply movements to GameObjects - only updates physics state. - /// - private void ExecutePhysicsSimulation(ulong currentTimeUsec) - { - var sw = Stopwatch.StartNew(); - - // Apply kinematic transform updates staged by main thread (Fix 2). - ApplyPendingKinematicTransforms(); - - var state = CreateState(); - - // process input - ProcessInputActions(ref state); - - // run physics loop (Burst-compiled, thread-safe) - PhysicsUpdate.Execute( - ref state, - ref _physicsEnv, - ref _overlappingColliders, - _playfieldBounds, - currentTimeUsec - ); - - RecordPhysicsBusyTime(sw.ElapsedTicks); - } - - /// - /// Full physics update (main thread only - includes movement application) - /// - private void ExecutePhysicsUpdate(ulong currentTimeUsec) - { - var sw = Stopwatch.StartNew(); - - // 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); - } - - var state = CreateState(); - - // process input - ProcessInputActions(ref state); - - // run physics loop - PhysicsUpdate.Execute( - ref state, - ref _physicsEnv, - ref _overlappingColliders, - _playfieldBounds, - currentTimeUsec - ); - - // dequeue events - while (_eventQueue.Ref.TryDequeue(out var eventData)) { - _player.OnEvent(in eventData); - } - - // process scheduled events from managed land - lock (_scheduledActions) { - for (var i = _scheduledActions.Count - 1; i >= 0; i--) { - if (_physicsEnv.CurPhysicsFrameTime > _scheduledActions[i].ScheduleAt) { - _scheduledActions[i].Action(); - _scheduledActions.RemoveAt(i); - } - } - } - - // Apply movements to GameObjects - ApplyAllMovements(ref state); - - RecordPhysicsBusyTime(sw.ElapsedTicks); - } - - private void RecordPhysicsBusyTime(long elapsedTicks) - { - var elapsedUsec = (elapsedTicks * 1_000_000L) / Stopwatch.Frequency; - if (elapsedUsec < 0) { - elapsedUsec = 0; - } - - Interlocked.Add(ref _physicsBusyTotalUsec, elapsedUsec); - _lastFrameTimeMs = elapsedUsec / 1000f; - } - - /// - /// Apply all physics movements to GameObjects (main thread only) - /// - private void ApplyAllMovements(ref PhysicsState state) - { - _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); - } - - #region Snapshot-Based Movement (Fix 1) - - /// - /// Copy current animation values from physics state maps into the - /// given snapshot buffer. Called on the sim thread AFTER - /// returns (sequential within the thread, - /// so reading physics state maps is safe without an extra lock). - /// MUST BE ALLOCATION-FREE. - /// - internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) - { - // --- Balls --- - var ballCount = 0; - using (var enumerator = _ballStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && ballCount < SimulationState.MaxBalls) { - 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; - - // --- Float animations --- - var floatCount = 0; - - // Flippers - using (var enumerator = _flipperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } - } - - // Bumper rings (float) — ring animation - using (var enumerator = _bumperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.RingItemId != 0) { - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.RingAnimation.Offset - }; - } - } - } - - // Drop targets - using (var enumerator = _dropTargetStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.ZOffset - }; - } - } - - // Hit targets - using (var enumerator = _hitTargetStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.XRotation - }; - } - } - - // Gates - using (var enumerator = _gateStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } - } - - // Plungers - using (var enumerator = _plungerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.Position - }; - } - } - - // Spinners - using (var enumerator = _spinnerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } - } - - // Triggers - using (var enumerator = _triggerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.HeightOffset - }; - } - } - - snapshot.FloatAnimationCount = floatCount; - - // --- Float2 animations (bumper skirts) --- - var float2Count = 0; - using (var enumerator = _bumperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && float2Count < SimulationState.MaxFloat2Animations) { - ref var s = ref enumerator.Current.Value; - if (s.SkirtItemId != 0) { - snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { - ItemId = enumerator.Current.Key, Value = s.SkirtAnimation.Rotation - }; - } - } - } - snapshot.Float2AnimationCount = float2Count; - } - - /// - /// Apply visual updates from a snapshot — called on the main thread, - /// completely lock-free. Used in external timing mode (sim thread). - /// 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 (_ballComponents.TryGetValue(bs.Id, out var ballComponent)) { - // Reconstruct a lightweight BallState with the fields Move() needs. - 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 (_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 (_float2AnimatedComponents.TryGetValue(anim.ItemId, out var emitter)) { - emitter.UpdateAnimationValue(anim.Value); - } - } - } - - #endregion - - #region Kinematic Transform Staging (Fix 2) - - /// - /// Collect kinematic transform changes on the Unity main thread and - /// stage them in for the sim - /// thread to apply. Only called when - /// is true. Uses for change - /// detection (never reads which the - /// sim thread writes). - /// - internal void UpdateKinematicTransformsFromMainThread() - { - if (!_useExternalTiming || !_isInitialized || _kinematicColliderComponents == null) return; - - foreach (var coll in _kinematicColliderComponents) { - var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); - - // Check against main-thread cache - if (_mainThreadKinematicCache.TryGetValue(coll.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { - continue; - } - - // Transform changed — update cache - _mainThreadKinematicCache[coll.ItemId] = currMatrix; - - // Notify the component (e.g. KickerColliderComponent updates its - // center). NOTE: this writes physics state from the main thread, - // which is a pre-existing thread-safety issue inherited from the - // original code. A future improvement would schedule these as - // input actions. - coll.OnTransformationChanged(currMatrix); - - // Stage for the sim thread - lock (_pendingKinematicLock) { - _pendingKinematicTransforms.Ref[coll.ItemId] = currMatrix; - } - } - } - - /// - /// Apply kinematic transforms staged by the main thread into the - /// physics state maps. Called on the sim thread inside - /// (inside _physicsLock), so - /// writing to and - /// is safe. - /// Lock ordering: _physicsLock (held) → _pendingKinematicLock (inner). - /// - internal void ApplyPendingKinematicTransforms() - { - if (!_pendingKinematicTransforms.Ref.IsCreated) return; - - _updatedKinematicTransforms.Ref.Clear(); - - lock (_pendingKinematicLock) { - if (_pendingKinematicTransforms.Ref.Count() == 0) return; - - using var enumerator = _pendingKinematicTransforms.Ref.GetEnumerator(); - while (enumerator.MoveNext()) { - var itemId = enumerator.Current.Key; - var matrix = enumerator.Current.Value; - _updatedKinematicTransforms.Ref[itemId] = matrix; - _kinematicTransforms.Ref[itemId] = matrix; - } - _pendingKinematicTransforms.Ref.Clear(); - } - } - - #endregion - - private void ProcessInputActions(ref PhysicsState state) - { - lock (_inputActionsLock) { - while (_inputActions.Count > 0) { - var action = _inputActions.Dequeue(); - action(ref state); - } - } - } - private void OnDestroy() { - _overlappingColliders.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(); - } - } - _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(); - 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(); + _ctx?.Dispose(); } #endregion - private class ScheduledAction - { - public readonly ulong ScheduleAt; - public readonly Action Action; - - public ScheduledAction(ulong scheduleAt, Action action) - { - ScheduleAt = scheduleAt; - Action = action; - } - } - - public ICollider[] GetColliders(int itemId) => GetColliders(itemId, ref _colliderLookups, ref _colliders); - public ICollider[] GetKinematicColliders(int itemId) => GetColliders(itemId, ref _kinematicColliderLookups, ref _kinematicColliders); + 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) { @@ -967,4 +515,4 @@ private static ICollider[] GetColliders(int itemId, ref NativeParallelHashMap. + +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; + 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. Protected by locking on the list + /// instance itself. + /// + public readonly List ScheduledActions = 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; + + #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(); + 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 + } +} \ No newline at end of file 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..11b401b20 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -0,0 +1,561 @@ +// 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.Diagnostics; +using System.Threading; +using Unity.Mathematics; +using VisualPinball.Unity.Simulation; + +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 readonly PhysicsEngineContext _ctx; + private readonly Player _player; + private readonly ICollidableComponent[] _kinematicColliderComponents; + private readonly float4x4 _worldToPlayfield; + private readonly PhysicsMovements _physicsMovements = new(); + + internal PhysicsEngineThreading(PhysicsEngineContext ctx, Player player, + ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) + { + _ctx = ctx ?? throw new ArgumentNullException(nameof(ctx)); + _player = player; + _kinematicColliderComponents = kinematicColliderComponents; + _worldToPlayfield = worldToPlayfield; + } + + #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; + + lock (_ctx.PhysicsLock) { + ExecutePhysicsSimulation(timeUsec); + } + } + + /// + /// Core physics simulation loop. + /// + /// + /// Thread: Simulation thread (inside PhysicsLock).
+ /// Also called from main thread in single-threaded mode via + /// . + ///
+ private void ExecutePhysicsSimulation(ulong currentTimeUsec) + { + var sw = Stopwatch.StartNew(); + + // Apply kinematic transform updates staged by main thread. + ApplyPendingKinematicTransforms(); + + var state = _ctx.CreateState(); + + // process input + ProcessInputActions(ref state); + + // run physics loop (Burst-compiled, thread-safe) + PhysicsUpdate.Execute( + ref state, + ref _ctx.PhysicsEnv, + ref _ctx.OverlappingColliders, + _ctx.PlayfieldBounds, + currentTimeUsec + ); + + 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) + { + lock (_ctx.InputActionsLock) { + while (_ctx.InputActions.Count > 0) { + var action = _ctx.InputActions.Dequeue(); + 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; + } + _ctx.PendingKinematicTransforms.Ref.Clear(); + } + } + + /// + /// 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; + using (var enumerator = _ctx.BallStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && ballCount < SimulationState.MaxBalls) { + 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; + + // --- Float animations --- + var floatCount = 0; + + // Flippers + using (var enumerator = _ctx.FlipperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Bumper rings (float) — ring animation + using (var enumerator = _ctx.BumperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.RingItemId != 0) { + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.RingAnimation.Offset + }; + } + } + } + + // Drop targets + using (var enumerator = _ctx.DropTargetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.ZOffset + }; + } + } + + // Hit targets + using (var enumerator = _ctx.HitTargetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.XRotation + }; + } + } + + // Gates + using (var enumerator = _ctx.GateStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Plungers + using (var enumerator = _ctx.PlungerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Animation.Position + }; + } + } + + // Spinners + using (var enumerator = _ctx.SpinnerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.Angle + }; + } + } + + // Triggers + using (var enumerator = _ctx.TriggerStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { + ref var s = ref enumerator.Current.Value; + if (s.AnimatedItemId == 0) continue; + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = enumerator.Current.Key, Value = s.Movement.HeightOffset + }; + } + } + + snapshot.FloatAnimationCount = floatCount; + + // --- Float2 animations (bumper skirts) --- + var float2Count = 0; + using (var enumerator = _ctx.BumperStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext() && float2Count < SimulationState.MaxFloat2Animations) { + ref var s = ref enumerator.Current.Value; + if (s.SkirtItemId != 0) { + snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { + ItemId = enumerator.Current.Key, Value = s.SkirtAnimation.Rotation + }; + } + } + } + snapshot.Float2AnimationCount = float2Count; + } + + #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; + } + + if (!Monitor.TryEnter(_ctx.PhysicsLock)) { + return; // sim thread is mid-tick; drain next frame + } + try { + while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) { + _player.OnEvent(in eventData); + } + + lock (_ctx.ScheduledActions) { + for (var i = _ctx.ScheduledActions.Count - 1; i >= 0; i--) { + if (_ctx.PhysicsEnv.CurPhysicsFrameTime > _ctx.ScheduledActions[i].ScheduleAt) { + _ctx.ScheduledActions[i].Action(); + _ctx.ScheduledActions.RemoveAt(i); + } + } + } + } finally { + Monitor.Exit(_ctx.PhysicsLock); + } + } + + /// + /// 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; + + 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; + + // Notify the component (e.g. KickerColliderComponent updates its + // center). NOTE: this writes physics state from the main thread, + // which is a pre-existing thread-safety issue inherited from the + // original code. A future improvement would schedule these as + // input actions. + coll.OnTransformationChanged(currMatrix); + + // Stage for the sim thread + lock (_ctx.PendingKinematicLock) { + _ctx.PendingKinematicTransforms.Ref[coll.ItemId] = currMatrix; + } + } + } + + #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); + } + + var state = _ctx.CreateState(); + + // process input + ProcessInputActions(ref state); + + // run physics loop + PhysicsUpdate.Execute( + ref state, + ref _ctx.PhysicsEnv, + ref _ctx.OverlappingColliders, + _ctx.PlayfieldBounds, + currentTimeUsec + ); + + // dequeue events + while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) { + _player.OnEvent(in eventData); + } + + // process scheduled events from managed land + lock (_ctx.ScheduledActions) { + for (var i = _ctx.ScheduledActions.Count - 1; i >= 0; i--) { + if (_ctx.PhysicsEnv.CurPhysicsFrameTime > _ctx.ScheduledActions[i].ScheduleAt) { + _ctx.ScheduledActions[i].Action(); + _ctx.ScheduledActions.RemoveAt(i); + } + } + } + + // 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); + } + + #endregion + } +} \ No newline at end of file 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/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 33125f4df..5a0a394f9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -240,9 +240,9 @@ public void Dispose() /// /// Get the current write buffer. - /// Called by simulation thread only. /// - public ref Snapshot GetWriteBuffer() + /// Thread: Simulation thread only. + internal ref Snapshot GetWriteBuffer() { return ref GetBufferByIndex(_writeIndex); } @@ -250,9 +250,10 @@ public ref Snapshot GetWriteBuffer() /// /// Publish the write buffer so the main thread can pick it up, and /// reclaim the previously-ready buffer as the new write target. - /// Called by simulation thread only — allocation-free. + /// Allocation-free. /// - public void PublishWriteBuffer() + /// Thread: Simulation thread only. + internal void PublishWriteBuffer() { // Atomically swap _readyIndex with our _writeIndex. // After this, the old ready buffer becomes our new write buffer, @@ -266,11 +267,12 @@ public void PublishWriteBuffer() /// /// Acquire the latest published snapshot for reading. - /// Called by Unity main thread only — allocation-free. /// Returns a ref to the acquired buffer that is safe to read until the /// next call to . + /// Allocation-free. /// - public ref readonly Snapshot AcquireReadBuffer() + /// 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 @@ -284,7 +286,8 @@ public ref readonly Snapshot AcquireReadBuffer() /// Peek at the current read buffer without swapping. /// Useful when you just need to re-read the last acquired snapshot. ///
- public ref readonly Snapshot PeekReadBuffer() + /// Thread: Main thread only. + internal ref readonly Snapshot PeekReadBuffer() { return ref GetBufferByIndex(_readIndex); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 433d193c3..b8a26bb77 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -130,9 +130,10 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE #region Public API - /// - /// Start the simulation thread - /// + /// + /// Start the simulation thread. + /// + /// Thread: Main thread only. public void Start() { if (_running) return; @@ -163,9 +164,10 @@ public void Start() Logger.Info($"{LogPrefix} [SimulationThread] Started at 1000 Hz"); } - /// - /// Stop the simulation thread - /// + /// + /// Stop the simulation thread. + /// + /// Thread: Main thread only. public void Stop() { if (!_running) return; @@ -180,25 +182,28 @@ public void Stop() Logger.Info($"{LogPrefix} [SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events, {_inputEventsDropped} dropped"); } - /// - /// Pause the simulation (for debugging) - /// + /// + /// Pause the simulation (for debugging). + /// + /// Thread: Main thread only (sets volatile flag). public void Pause() { _paused = true; } - /// - /// Resume the simulation - /// + /// + /// Resume the simulation. + /// + /// Thread: Main thread only (sets volatile flag). public void Resume() { _paused = false; } - /// - /// Enqueue an input event from the input polling thread - /// + /// + /// 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)) { @@ -213,6 +218,7 @@ public void EnqueueInputEvent(NativeInputApi.InputEvent evt) /// to avoid /// double-swapping per frame. ///
+ /// Thread: Main thread only. public ref readonly SimulationState.Snapshot GetSharedState() { return ref _sharedState.PeekReadBuffer(); @@ -222,13 +228,24 @@ public ref readonly SimulationState.Snapshot GetSharedState() /// 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(); } + /// + /// 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)) { @@ -353,9 +370,10 @@ private void SimulationThreadFunc() } } - /// - /// Single simulation tick - MUST BE ALLOCATION-FREE! - /// + /// + /// Single simulation tick - MUST BE ALLOCATION-FREE! + /// + /// Thread: Simulation thread only. private void SimulationTick() { // 0. Process switch events that originated on Unity/main thread. @@ -384,9 +402,10 @@ private void SimulationTick() _simulationTimeUsec += TickIntervalUsec; } - /// - /// Process all pending input events from the ring buffer - /// + /// + /// Process all pending input events from the ring buffer. + /// + /// Thread: Simulation thread only. private void ProcessInputEvents() { BuildInputMappingsIfNeeded(); @@ -611,8 +630,9 @@ private static bool TryMapInputActionHint(string inputActionHint, out NativeInpu } /// - /// Update physics simulation (1ms step) + /// Update physics simulation (1ms step). /// + /// Thread: Simulation thread only. private void UpdatePhysics() { if (_physicsEngine != null) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 15e1f0e63..1df1675bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -19,7 +19,7 @@ namespace VisualPinball.Unity.Simulation /// - 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 double-buffering + /// - Lock-free communication between threads using ring buffers and triple-buffering /// [AddComponentMenu("Visual Pinball/Simulation Thread")] [RequireComponent(typeof(PhysicsEngine))] From 02d3b9fea4acd4095971f9a3f6c980a8ffc513ce Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Mar 2026 00:30:49 +0100 Subject: [PATCH 28/51] physics. Rebuild kinematic octree only once a frame, and only if dirty. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 3 ++ .../Game/PhysicsEngineContext.cs | 22 ++++++++++ .../Game/PhysicsEngineThreading.cs | 23 +++++++++-- .../Game/PhysicsKinematics.cs | 40 +++++++++++-------- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 34 ++++++++-------- 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index a3fdeb1cb..d1d0d059b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -424,6 +424,9 @@ private void Start() } Debug.Log($"Octree of {_ctx.Colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms)."); + // create persistent kinematic octree (rebuilt only when kinematic transforms change) + _ctx.KinematicOctree = new NativeOctree(_ctx.PlayfieldBounds, 1024, 10, Allocator.Persistent); + // get balls var balls = GetComponentsInChildren(); foreach (var ball in balls) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index 91dacb821..47c0f990f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -46,6 +46,27 @@ internal class PhysicsEngineContext : IDisposable 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; + public NativeColliders Colliders; public NativeColliders KinematicColliders; public NativeColliders KinematicCollidersAtIdentity; @@ -235,6 +256,7 @@ public void Dispose() KinematicCollidersAtIdentity.Dispose(); InsideOfs.Dispose(); Octree.Dispose(); + KinematicOctree.Dispose(); BumperStates.Ref.Dispose(); DropTargetStates.Ref.Dispose(); FlipperStates.Ref.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 11b401b20..a91432398 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -84,12 +84,13 @@ internal void ExecuteTick(ulong timeUsec) } /// - /// Core physics simulation loop. + /// Core physics simulation loop for the simulation thread. /// /// /// Thread: Simulation thread (inside PhysicsLock).
- /// Also called from main thread in single-threaded mode via - /// . + /// The single-threaded equivalent is + /// , which additionally drains + /// events and applies movements. ///
private void ExecutePhysicsSimulation(ulong currentTimeUsec) { @@ -100,6 +101,12 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) 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); @@ -108,6 +115,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ref state, ref _ctx.PhysicsEnv, ref _ctx.OverlappingColliders, + ref _ctx.KinematicOctree, _ctx.PlayfieldBounds, currentTimeUsec ); @@ -158,6 +166,7 @@ private void ApplyPendingKinematicTransforms() _ctx.KinematicTransforms.Ref[itemId] = matrix; } _ctx.PendingKinematicTransforms.Ref.Clear(); + _ctx.KinematicOctreeDirty = true; } } @@ -478,10 +487,17 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) _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); @@ -490,6 +506,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) ref state, ref _ctx.PhysicsEnv, ref _ctx.OverlappingColliders, + ref _ctx.KinematicOctree, _ctx.PlayfieldBounds, currentTimeUsec ); 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/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 953194b25..5f0f6d13d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -66,18 +66,18 @@ internal static class PhysicsUpdate // // public bool SwapBallCollisionHandling; - [BurstCompile] - public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, in AABB playfieldBounds, 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()); - - using var cycle = new PhysicsCycle(Allocator.TempJob); - - // 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); + [BurstCompile] + public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kineticOctree, in AABB playfieldBounds, 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()); + + using var cycle = new PhysicsCycle(Allocator.TempJob); + + // 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); while (env.CurPhysicsFrameTime < initialTimeUsec) // loop here until current (real) time matches the physics (simulated) time { @@ -184,11 +184,9 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ #endregion - env.CurPhysicsFrameTime = env.NextPhysicsFrameTime; - env.NextPhysicsFrameTime += PhysicsConstants.PhysicsStepTime; - } - - kineticOctree.Dispose(); - } + env.CurPhysicsFrameTime = env.NextPhysicsFrameTime; + env.NextPhysicsFrameTime += PhysicsConstants.PhysicsStepTime; + } + } } } \ No newline at end of file From af20baa53358ef48ce9418e0331c04e0760d11f3 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 2 Mar 2026 00:44:52 +0100 Subject: [PATCH 29/51] physics: Various performance improvements. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allocation Elimination (Hot Path) 1. Math.Sign() — zero-alloc bit test (Common/Math.cs) Replaced a NativeArray allocation per call with math.asint() bit manipulation. This was called ~2.5M times/sec from narrow-phase collision detection. 2. PhysicsDynamicBroadPhase.FindOverlaps — stack-allocated removal buffer (Game/PhysicsDynamicBroadPhase.cs) Replaced ToNativeArray(TempJob) with FixedList64Bytes for collecting hash set removals during octree overlap queries. Eliminates a temp allocation per ball pair check. 3. InsideOfs.GetBitIndex() — bitmask free-bit find (Game/InsideOfs.cs) Replaced GetValueArray(Allocator.TempJob) + O(64*n) Contains() scan with ulong bitmask + math.tzcnt() for O(1) free-bit lookup. Zero allocations. 4. InsideOfs.GetIdsOfBallsInsideItem() — stack return type (Game/InsideOfs.cs) Changed return from List (heap) to FixedList64Bytes (stack, up to 15 ints). Updated all callers (PhysicsEngine, BumperApi, KickerColliderComponent). Persistent Native Container Reuse 5. Persistent ball octree (PhysicsEngineContext.BallOctree) Moved NativeOctree for ball-to-ball collisions from per-tick TempJob alloc/dispose to a persistent field. Clear() + re-insert each cycle instead of create/destroy. 6. Persistent kinematic octree with dirty flag (PhysicsEngineContext.KinematicOctree) Same pattern, plus only rebuilt when KinematicOctreeDirty is set (transforms actually changed). Reduces rebuild frequency from ~1kHz to ~60Hz. 7. Persistent PhysicsCycle (PhysicsEngineContext.PhysicsCycle) Moved from per-tick creation to persistent field. Avoids per-tick allocation of the internal NativeList. Redundant Work Reduction 8. InsideOfs double-lookup elimination (Game/InsideOfs.cs) Replaced ContainsKey + [] patterns with TryGetValue in SetOutsideOfAll, IsInsideOf, GetInsideCount, IsEmpty, GetIdsOfBallsInsideItem. 9. PhysFactor float conversion (Engine/Common/Constants.cs + 6 files) Changed PhysFactor from double to float, removing 8 redundant (float) casts. Avoids implicit double-precision promotion in Burst-compiled velocity code. 10. VPOrthonormalize float3 conversion (VPT/Ball/BallDisplacementPhysics.cs) Replaced UnityEngine.Vector3 with Unity.Mathematics.float3 + math.cross()/math.normalize(). Eliminates Vector3↔float3 conversions on every ball displacement. Safety 11. Max substep cap (PhysicsUpdate.cs) Added PhysicsConstants.MaxSubSteps = 200. If physics falls behind (e.g. frame hitch), the loop caps iterations and skips time forward instead of cascading into a death spiral. --- Net effect: The physics tick (running at 1kHz) is now allocation-free on its hot path. The three largest per-tick native allocations (two octrees + contacts list) are gone, replaced with persistent containers that clear and refill. The remaining improvements reduce redundant hash lookups and eliminate unnecessary type conversions in Burst-compiled code. --- VisualPinball.Engine/Common/Constants.cs | 19 +++++-- .../VisualPinball.Unity/Common/Math.cs | 25 ++++----- .../VisualPinball.Unity/Game/InsideOfs.cs | 52 ++++++++++--------- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 6 +-- .../Game/PhysicsDynamicBroadPhase.cs | 19 ++++--- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 8 ++- .../Game/PhysicsEngineContext.cs | 23 ++++++++ .../Game/PhysicsEngineThreading.cs | 6 ++- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 23 +++++--- .../VPT/Ball/BallDisplacementPhysics.cs | 48 ++++++++--------- .../VPT/Ball/BallVelocityPhysics.cs | 2 +- .../VPT/Flipper/FlipperVelocityPhysics.cs | 14 ++--- .../VPT/Gate/GateComponent.cs | 2 +- .../VPT/Gate/GateVelocityPhysics.cs | 2 +- .../VPT/Spinner/SpinnerComponent.cs | 2 +- .../VPT/Spinner/SpinnerVelocityPhysics.cs | 2 +- 16 files changed, 152 insertions(+), 101 deletions(-) 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/VisualPinball.Unity/Common/Math.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/Math.cs index b6215ae65..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.TempJob) { [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/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs index 88f6a8c2d..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.TempJob); // 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/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 a5794bf8e..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.TempJob); - 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 d1d0d059b..37ae98aa4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -241,7 +241,7 @@ internal void Schedule(InputAction action) internal ref TriggerState TriggerState(int itemId) => ref _ctx.TriggerStates.Ref.GetValueByRef(itemId); internal void SetBallInsideOf(int ballId, int itemId) => _ctx.InsideOfs.SetInsideOf(itemId, ballId); internal bool HasBallsInsideOf(int itemId) => _ctx.InsideOfs.GetInsideCount(itemId) > 0; - internal List GetBallsInsideOf(int itemId) => _ctx.InsideOfs.GetIdsOfBallsInsideItem(itemId); + internal FixedList64Bytes GetBallsInsideOf(int itemId) => _ctx.InsideOfs.GetIdsOfBallsInsideItem(itemId); internal uint TimeMsec => _ctx.PhysicsEnv.TimeMsec; internal Random Random => _ctx.PhysicsEnv.Random; @@ -424,8 +424,12 @@ private void Start() } Debug.Log($"Octree of {_ctx.Colliders.Length} constructed (colliders: {elapsedMs}ms, tree: {sw.Elapsed.TotalMilliseconds}ms)."); - // create persistent kinematic octree (rebuilt only when kinematic transforms change) + // 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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index 47c0f990f..b7a99244d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -67,6 +67,27 @@ internal class PhysicsEngineContext : IDisposable /// 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; @@ -257,6 +278,8 @@ public void Dispose() InsideOfs.Dispose(); Octree.Dispose(); KinematicOctree.Dispose(); + BallOctree.Dispose(); + PhysicsCycle.Dispose(); BumperStates.Ref.Dispose(); DropTargetStates.Ref.Dispose(); FlipperStates.Ref.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index a91432398..34498c91d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -116,7 +116,8 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ref _ctx.PhysicsEnv, ref _ctx.OverlappingColliders, ref _ctx.KinematicOctree, - _ctx.PlayfieldBounds, + ref _ctx.BallOctree, + ref _ctx.PhysicsCycle, currentTimeUsec ); @@ -507,7 +508,8 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) ref _ctx.PhysicsEnv, ref _ctx.OverlappingColliders, ref _ctx.KinematicOctree, - _ctx.PlayfieldBounds, + ref _ctx.BallOctree, + ref _ctx.PhysicsCycle, currentTimeUsec ); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 5f0f6d13d..0227d9c4a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -67,21 +67,28 @@ internal static class PhysicsUpdate // public bool SwapBallCollisionHandling; [BurstCompile] - public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kineticOctree, in AABB playfieldBounds, ulong initialTimeUsec) + 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()); - using var cycle = new PhysicsCycle(Allocator.TempJob); - // 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); - while (env.CurPhysicsFrameTime < initialTimeUsec) // loop here until current (real) time matches the physics (simulated) time - { - env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000); + 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; + } + + 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) @@ -123,8 +130,8 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ #endregion - // primary physics loop - cycle.Simulate(ref state, in playfieldBounds, ref overlappingColliders, ref kineticOctree, physicsDiffTime); + // 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()) { 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/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/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/GateComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs index abc1189f9..ea4b54ef8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs @@ -262,7 +262,7 @@ 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; 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/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; } } From 7518450e97ded945f56e092d2fbe32be93fb7db8 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 09:32:49 +0100 Subject: [PATCH 30/51] physics: Use use Unity-scaled clock model as the main thread phyisics path. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 4 +- .../Simulation/SimulationThread.cs | 289 ++++++++++-------- .../Simulation/SimulationThreadComponent.cs | 5 +- 3 files changed, 167 insertions(+), 131 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 37ae98aa4..b944c8593 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -159,6 +159,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac #endregion private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); + internal ulong CurrentSimulationClockUsec => NowUsec; + internal float CurrentSimulationClockScale => Time.timeScale; /// /// Check if the physics engine has completed initialization. @@ -522,4 +524,4 @@ private static ICollider[] GetColliders(int itemId, ref NativeParallelHashMap - /// 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 - /// + /// + /// 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 - + + #region Constants + private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds private const long BusyWaitThresholdUsec = 100; // Last 100us busy-wait for precision private const int MaxCoilOutputsPerTick = 128; private const long TimeFenceUpdateIntervalUsec = 5_000; #endregion - - #region Fields - + + #region Fields + private readonly PhysicsEngine _physicsEngine; private readonly IGamelogicEngine _gamelogicEngine; private readonly IGamelogicTimeFence _timeFence; @@ -50,7 +51,7 @@ public class SimulationThread : IDisposable 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; @@ -61,6 +62,9 @@ public class SimulationThread : IDisposable private long _lastTickTicks; private long _simulationTimeUsec; private long _lastTimeFenceUsec = long.MinValue; + private double _simulationClockScale = 1.0; + private long _latestMainThreadClockUsec; + private volatile bool _hasMainThreadClockSync; // Input state tracking (allocation-free indexed arrays) private readonly bool[] _actionStates; @@ -92,9 +96,9 @@ public PendingSwitchEvent(string switchId, bool isClosed) } #endregion - - #region Constructor - + + #region Constructor + public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine, Action simulationCoilDispatcher) { @@ -125,11 +129,11 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE _gamelogicEngine.OnStarted += OnGamelogicStarted; } } - - #endregion - - #region Public API - + + #endregion + + #region Public API + /// /// Start the simulation thread. /// @@ -142,7 +146,7 @@ public void Start() _paused = false; _gamelogicStarted = _gamelogicEngine != null; _lastTickTicks = Stopwatch.GetTimestamp(); - _simulationTimeUsec = 0; + _simulationTimeUsec = _hasMainThreadClockSync ? Interlocked.Read(ref _latestMainThreadClockUsec) : 0; _lastTimeFenceUsec = long.MinValue; _tickCount = 0; _inputEventsProcessed = 0; @@ -160,46 +164,46 @@ public void Start() #endif }; _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 - } - + 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; - } - + public void Pause() + { + _paused = true; + } + /// /// Resume the simulation. /// /// Thread: Main thread only (sets volatile flag). - public void Resume() - { - _paused = false; - } - + public void Resume() + { + _paused = false; + } + /// /// Enqueue an input event from the input polling thread. /// @@ -210,7 +214,7 @@ public void EnqueueInputEvent(NativeInputApi.InputEvent 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 @@ -240,6 +244,20 @@ 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 @@ -262,11 +280,11 @@ public bool EnqueueExternalSwitch(string switchId, bool isClosed) return true; } } - - #endregion - - #region Simulation Thread - + + #endregion + + #region Simulation Thread + private void SimulationThreadFunc() { // Editor playmode is a hostile environment for time-critical threads (domain/scene reload, @@ -274,42 +292,42 @@ private void SimulationThreadFunc() #if !UNITY_EDITOR NativeInputApi.VpeSetThreadPriority(); #endif - - // Wait for physics engine to be fully initialized - // This prevents accessing physics state before it's ready + + // 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) - { + 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; - } - + _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; + + // 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) - { + } + } + catch (Exception ex) + { Logger.Warn($"{LogPrefix} [SimulationThread] Failed to start no-GC region: {ex.Message}"); - } - + } + try { // Build input mappings once (not on hot path) @@ -347,35 +365,42 @@ private void SimulationThreadFunc() spinner.SpinOnce(); } #endif - - // Execute simulation tick (hot path - must be allocation-free!) - SimulationTick(); - + + // 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(); + finally + { + // Exit no-GC region + if (noGcRegion && GCSettings.LatencyMode == GCLatencyMode.NoGCRegion) + { + try + { + GC.EndNoGCRegion(); Logger.Info($"{LogPrefix} [SimulationThread] No-GC region ended"); - } - catch { } - } - } - } - + } + catch { } + } + } + } + /// /// Single simulation tick - MUST BE ALLOCATION-FREE! /// /// Thread: Simulation thread only. private void SimulationTick() { + if (_hasMainThreadClockSync) { + var syncedClockUsec = Interlocked.Read(ref _latestMainThreadClockUsec); + if (syncedClockUsec > _simulationTimeUsec) { + _simulationTimeUsec = syncedClockUsec; + } + } + // 0. Process switch events that originated on Unity/main thread. ProcessExternalSwitchEvents(); @@ -397,11 +422,11 @@ private void SimulationTick() // 5. Write to shared state and swap buffers WriteSharedState(); - - // Increment simulation time - _simulationTimeUsec += TickIntervalUsec; - } - + + // Increment simulation time + _simulationTimeUsec += ScaledTickIntervalUsec(); + } + /// /// Process all pending input events from the ring buffer. /// @@ -634,16 +659,16 @@ private static bool TryMapInputActionHint(string inputActionHint, out NativeInpu /// /// 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); - } - } - + { + 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 @@ -680,6 +705,12 @@ private static long GetTimestampUsec() 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) @@ -704,7 +735,7 @@ public void Dispose() _inputBuffer?.Dispose(); _sharedState?.Dispose(); } - - #endregion - } -} + + #endregion + } +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 1df1675bb..d40428e73 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -97,6 +97,8 @@ 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(); @@ -163,11 +165,12 @@ public void StartSimulation() // This disables Unity's Update() loop and gives control to the simulation thread _physicsEngine.SetExternalTiming(true); - // Create simulation thread + // 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. From c4c6a6a3552991efa631b0d7e246c1b7b08b3626 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 09:50:50 +0100 Subject: [PATCH 31/51] physics: Never mutate state directly from main thread. `PhysicsEngine` now exposes `MutateState(...)`, which executes immediately in single-threaded mode but routes state mutations through the existing sim-thread input-action queue in external-timing mode. This was applied to the main direct-mutation gameplay APIs for flippers, plungers, gates, bumpers, and drop targets. Remaining gaps still exist for kicker-related state writes, collider enable/disable, and kinematic transformation callbacks. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 12 ++ .../VPT/Bumper/BumperApi.cs | 123 ++++++++++-------- .../VPT/Flipper/FlipperApi.cs | 55 ++++---- .../VisualPinball.Unity/VPT/Gate/GateApi.cs | 24 ++-- .../VPT/HitTarget/DropTargetApi.cs | 67 +++++----- .../VPT/Plunger/PlungerApi.cs | 114 ++++++++-------- 6 files changed, 222 insertions(+), 173 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index b944c8593..fbb8339f7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -161,6 +161,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private static ulong NowUsec => (ulong)(Time.timeAsDouble * 1000000); internal ulong CurrentSimulationClockUsec => NowUsec; internal float CurrentSimulationClockScale => Time.timeScale; + internal bool UsesExternalTiming => _ctx.UseExternalTiming; /// /// Check if the physics engine has completed initialization. @@ -221,6 +222,17 @@ internal void Schedule(InputAction action) } } + internal void MutateState(InputAction action) + { + if (_ctx.UseExternalTiming) { + Schedule(action); + return; + } + + var state = _ctx.CreateState(); + action(ref state); + } + // ── State accessors ────────────────────────────────────────── // These return refs into native hash maps. In single-threaded // mode they are safe. In threaded mode, callers on the sim 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/Flipper/FlipperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperApi.cs index e5de3ff1a..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 { @@ -88,35 +89,41 @@ void IApi.OnDestroy() /// Enables the flipper's solenoid, making the flipper to start moving /// to its end position. /// - 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 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/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/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); From 018fdf2b69747fc331d05ced0bbcaf89f1f4721b Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 09:55:54 +0100 Subject: [PATCH 32/51] physics: Make collider state changes sim-thread-owned. Implemented for the central collider enable/disable path. `PhysicsEngine.EnableCollider(...)` and `DisableCollider(...)` now go through `MutateState(...)`, so Unity lifecycle callers like `ColliderComponent.OnEnable()` / `OnDisable()` and gate lifter coil actions no longer write `DisabledCollisionItems` directly in external-timing mode. This still needs runtime validation on tables that toggle colliders frequently, but the ownership boundary is now correct for this path. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index fbb8339f7..43e3c3638 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -310,15 +310,11 @@ internal BallComponent UnregisterBall(int ballId) internal void EnableCollider(int itemId) { - if (_ctx.DisabledCollisionItems.Ref.Contains(itemId)) { - _ctx.DisabledCollisionItems.Ref.Remove(itemId); - } + MutateState((ref PhysicsState state) => state.EnableColliders(itemId)); } internal void DisableCollider(int itemId) { - if (!_ctx.DisabledCollisionItems.Ref.Contains(itemId)) { - _ctx.DisabledCollisionItems.Ref.Add(itemId); - } + MutateState((ref PhysicsState state) => state.DisableColliders(itemId)); } public BallComponent GetBall(int itemId) => _ctx.BallComponents[itemId]; From 2f8e2e4ebce2b2949f418038db34aaa88a96c37e Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:00:39 +0100 Subject: [PATCH 33/51] physics: Remove direct main-thread `OnTransformationChanged(...)` physics mutation. `UpdateKinematicTransformsFromMainThread()` now only detects and stages changed matrices, while `ApplyPendingKinematicTransforms()` applies the matrix and invokes `OnTransformationChanged(...)` on the simulation thread under physics ownership. Single-threaded mode still performs the callback inline on the main thread, which is correct for that mode. --- .../Game/PhysicsEngineThreading.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 34498c91d..7027469df 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -165,12 +165,30 @@ private void ApplyPendingKinematicTransforms() 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) + { + if (_kinematicColliderComponents == null) { + return null; + } + + foreach (var coll in _kinematicColliderComponents) { + if (coll.ItemId == itemId) { + return coll; + } + } + + return null; + } + /// /// Copy current animation values from physics state maps into the /// given snapshot buffer. Must be allocation-free. @@ -443,13 +461,6 @@ internal void UpdateKinematicTransformsFromMainThread() // Transform changed — update cache _ctx.MainThreadKinematicCache[coll.ItemId] = currMatrix; - // Notify the component (e.g. KickerColliderComponent updates its - // center). NOTE: this writes physics state from the main thread, - // which is a pre-existing thread-safety issue inherited from the - // original code. A future improvement would schedule these as - // input actions. - coll.OnTransformationChanged(currMatrix); - // Stage for the sim thread lock (_ctx.PendingKinematicLock) { _ctx.PendingKinematicTransforms.Ref[coll.ItemId] = currMatrix; @@ -577,4 +588,4 @@ private void RecordPhysicsBusyTime(long elapsedTicks) #endregion } -} \ No newline at end of file +} From 7a9760506ecc0519f59f3a4e65e008452be732c7 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:21:17 +0100 Subject: [PATCH 34/51] physics: Define and enforce a hard thread-ownership contract. `PhysicsEngine` now records the Unity main thread and active simulation thread IDs, marks the simulation thread on every `ExecuteTick(...)`, and guards live state accessors (`BallState`, `FlipperState`, `GetBallsInsideOf`, etc.). In external-timing mode, access from unsupported threads now throws immediately, while Unity main-thread access emits a one-time warning per accessor to surface remaining migration work without breaking validated gameplay paths all at once. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 119 +++++++++++++++--- .../Game/PhysicsEngineThreading.cs | 25 ++-- .../VPT/Kicker/KickerColliderComponent.cs | 39 +++--- 3 files changed, 137 insertions(+), 46 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 43e3c3638..f814b7af0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -155,6 +155,9 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [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(); #endregion @@ -233,6 +236,29 @@ internal void MutateState(InputAction action) 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 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)) { + Debug.LogWarning($"[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."); + } + } + // ── State accessors ────────────────────────────────────────── // These return refs into native hash maps. In single-threaded // mode they are safe. In threaded mode, callers on the sim @@ -241,21 +267,81 @@ internal void MutateState(InputAction action) // the triple-buffered snapshot; direct access is a pre-existing // thread-safety concern (see AGENTS.md audit notes). - internal bool BallExists(int ballId) => _ctx.BallStates.Ref.ContainsKey(ballId); - internal ref BallState BallState(int ballId) => ref _ctx.BallStates.Ref.GetValueByRef(ballId); - internal ref BumperState BumperState(int itemId) => ref _ctx.BumperStates.Ref.GetValueByRef(itemId); - internal ref FlipperState FlipperState(int itemId) => ref _ctx.FlipperStates.Ref.GetValueByRef(itemId); - internal ref GateState GateState(int itemId) => ref _ctx.GateStates.Ref.GetValueByRef(itemId); - internal ref DropTargetState DropTargetState(int itemId) => ref _ctx.DropTargetStates.Ref.GetValueByRef(itemId); - internal ref HitTargetState HitTargetState(int itemId) => ref _ctx.HitTargetStates.Ref.GetValueByRef(itemId); - internal ref KickerState KickerState(int itemId) => ref _ctx.KickerStates.Ref.GetValueByRef(itemId); - internal ref PlungerState PlungerState(int itemId) => ref _ctx.PlungerStates.Ref.GetValueByRef(itemId); - internal ref SpinnerState SpinnerState(int itemId) => ref _ctx.SpinnerStates.Ref.GetValueByRef(itemId); - internal ref SurfaceState SurfaceState(int itemId) => ref _ctx.SurfaceStates.Ref.GetValueByRef(itemId); - internal ref TriggerState TriggerState(int itemId) => ref _ctx.TriggerStates.Ref.GetValueByRef(itemId); - internal void SetBallInsideOf(int ballId, int itemId) => _ctx.InsideOfs.SetInsideOf(itemId, ballId); - internal bool HasBallsInsideOf(int itemId) => _ctx.InsideOfs.GetInsideCount(itemId) > 0; - internal FixedList64Bytes GetBallsInsideOf(int itemId) => _ctx.InsideOfs.GetIdsOfBallsInsideItem(itemId); + 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; @@ -348,6 +434,7 @@ internal void DisableCollider(int itemId) private void Awake() { + _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId; _player = GetComponentInParent(); _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); @@ -448,7 +535,7 @@ private void Start() } // Create threading helper (all context fields are now populated) - _threading = new PhysicsEngineThreading(_ctx, _player, _kinematicColliderComponents, _worldToPlayfield); + _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicColliderComponents, _worldToPlayfield); // Mark as initialized for simulation thread _ctx.IsInitialized = true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 7027469df..5318a785c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -16,6 +16,7 @@ // ReSharper disable InconsistentNaming using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using Unity.Mathematics; @@ -40,18 +41,27 @@ namespace VisualPinball.Unity /// internal class PhysicsEngineThreading { + 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(); - internal PhysicsEngineThreading(PhysicsEngineContext ctx, Player player, + 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; + } + } _worldToPlayfield = worldToPlayfield; } @@ -77,6 +87,7 @@ internal PhysicsEngineThreading(PhysicsEngineContext ctx, Player player, internal void ExecuteTick(ulong timeUsec) { if (!_ctx.IsInitialized) return; + _physicsEngine.MarkCurrentThreadAsSimulationThread(); lock (_ctx.PhysicsLock) { ExecutePhysicsSimulation(timeUsec); @@ -176,17 +187,7 @@ private void ApplyPendingKinematicTransforms() private ICollidableComponent GetKinematicColliderComponent(int itemId) { - if (_kinematicColliderComponents == null) { - return null; - } - - foreach (var coll in _kinematicColliderComponents) { - if (coll.ItemId == itemId) { - return coll; - } - } - - return null; + return _kinematicColliderComponentsByItemId.TryGetValue(itemId, out var coll) ? coll : null; } /// 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; + } } } } From b7abdc715efcb079bf99bbfa89b9750dd487f69d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:26:59 +0100 Subject: [PATCH 35/51] physics: Stop running managed callbacks under `PhysicsLock`. `DrainExternalThreadCallbacks()` now copies queued physics events and due scheduled actions into reusable local buffers while `PhysicsLock` is held, then releases the lock before invoking `_player.OnEvent(...)` and managed scheduled callbacks. This removes arbitrary managed callback work from the sim-thread critical section without changing callback ordering. --- .../Game/PhysicsEngineThreading.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 5318a785c..25a199e17 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -48,6 +48,8 @@ internal class PhysicsEngineThreading private readonly Dictionary _kinematicColliderComponentsByItemId; private readonly float4x4 _worldToPlayfield; private readonly PhysicsMovements _physicsMovements = new(); + private readonly List _deferredMainThreadEvents = new(); + private readonly List _deferredMainThreadScheduledActions = new(); internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) @@ -413,18 +415,21 @@ internal void DrainExternalThreadCallbacks() return; } + _deferredMainThreadEvents.Clear(); + _deferredMainThreadScheduledActions.Clear(); + if (!Monitor.TryEnter(_ctx.PhysicsLock)) { return; // sim thread is mid-tick; drain next frame } try { while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) { - _player.OnEvent(in eventData); + _deferredMainThreadEvents.Add(eventData); } lock (_ctx.ScheduledActions) { for (var i = _ctx.ScheduledActions.Count - 1; i >= 0; i--) { if (_ctx.PhysicsEnv.CurPhysicsFrameTime > _ctx.ScheduledActions[i].ScheduleAt) { - _ctx.ScheduledActions[i].Action(); + _deferredMainThreadScheduledActions.Add(_ctx.ScheduledActions[i].Action); _ctx.ScheduledActions.RemoveAt(i); } } @@ -432,6 +437,14 @@ internal void DrainExternalThreadCallbacks() } finally { Monitor.Exit(_ctx.PhysicsLock); } + + foreach (var eventData in _deferredMainThreadEvents) { + _player.OnEvent(in eventData); + } + + foreach (var action in _deferredMainThreadScheduledActions) { + action(); + } } /// From bb4e30f3dfe5f2a129abc0797d09cf67e2412455 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:33:14 +0100 Subject: [PATCH 36/51] physics: Shorten `InputActionsLock` hold time. `ProcessInputActions(...)` now copies queued `InputAction` delegates into a reusable local buffer while holding `InputActionsLock`, then releases the lock before executing the actions. This keeps producer contention low while preserving ordering and existing behavior in both threaded and single-threaded modes. --- .../VisualPinball.Unity/Game/PhysicsEngineThreading.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 25a199e17..52362df52 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -50,6 +50,7 @@ internal class PhysicsEngineThreading private readonly PhysicsMovements _physicsMovements = new(); private readonly List _deferredMainThreadEvents = new(); private readonly List _deferredMainThreadScheduledActions = new(); + private readonly List _pendingInputActions = new(); internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) @@ -146,12 +147,17 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) /// private void ProcessInputActions(ref PhysicsState state) { + _pendingInputActions.Clear(); + lock (_ctx.InputActionsLock) { while (_ctx.InputActions.Count > 0) { - var action = _ctx.InputActions.Dequeue(); - action(ref state); + _pendingInputActions.Add(_ctx.InputActions.Dequeue()); } } + + foreach (var action in _pendingInputActions) { + action(ref state); + } } /// From dc18da1e3c7eeab99cc91e85869a1f5ee8e83cad Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:39:06 +0100 Subject: [PATCH 37/51] physics: Batch kinematic staging. `UpdateKinematicTransformsFromMainThread()` now gathers all changed kinematic matrices into a reusable local batch first, then acquires `PendingKinematicLock` once to publish the whole batch into `PendingKinematicTransforms`. This removes per-collider lock churn while preserving existing change detection and staging semantics. --- .../Game/PhysicsEngineThreading.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 52362df52..51c49780d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -51,6 +51,7 @@ internal class PhysicsEngineThreading private readonly List _deferredMainThreadEvents = new(); private readonly List _deferredMainThreadScheduledActions = new(); private readonly List _pendingInputActions = new(); + private readonly List> _pendingKinematicUpdates = new(); internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) @@ -470,6 +471,8 @@ internal void UpdateKinematicTransformsFromMainThread() { if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicColliderComponents == null) return; + _pendingKinematicUpdates.Clear(); + foreach (var coll in _kinematicColliderComponents) { var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); @@ -480,10 +483,16 @@ internal void UpdateKinematicTransformsFromMainThread() // Transform changed — update cache _ctx.MainThreadKinematicCache[coll.ItemId] = currMatrix; + _pendingKinematicUpdates.Add(new KeyValuePair(coll.ItemId, currMatrix)); + } + + if (_pendingKinematicUpdates.Count == 0) { + return; + } - // Stage for the sim thread - lock (_ctx.PendingKinematicLock) { - _ctx.PendingKinematicTransforms.Ref[coll.ItemId] = currMatrix; + lock (_ctx.PendingKinematicLock) { + foreach (var update in _pendingKinematicUpdates) { + _ctx.PendingKinematicTransforms.Ref[update.Key] = update.Value; } } } From ae186d9667c50bc5add5b0b4437197943e639601 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:47:48 +0100 Subject: [PATCH 38/51] physics: Fix disposal bug. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 11 ++++++++++- .../Game/PhysicsEngineThreading.cs | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f814b7af0..497b7cc89 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -600,7 +600,16 @@ private void Update() private void OnDestroy() { - _ctx?.Dispose(); + if (_ctx == null) { + return; + } + + _ctx.IsInitialized = false; + _ctx.UseExternalTiming = false; + + lock (_ctx.PhysicsLock) { + _ctx.Dispose(); + } } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 51c49780d..b27fb742b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -94,6 +94,10 @@ internal void ExecuteTick(ulong timeUsec) _physicsEngine.MarkCurrentThreadAsSimulationThread(); lock (_ctx.PhysicsLock) { + if (!_ctx.IsInitialized) { + return; + } + ExecutePhysicsSimulation(timeUsec); } } From 3611cde7b7fad4b4f24b63901b54ede9a3cfe62e Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 10:57:42 +0100 Subject: [PATCH 39/51] physics: Bound or instrument all cross-thread queues. `PhysicsEngine` now warns on large `InputActions` / `ScheduledActions` backlogs, `SimulationThread` warns when the external switch queue backs up or starts dropping events. Existing hard bounds on the external switch queue and simulation-coil queue remain in place. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 13 +++++++++++++ .../Simulation/SimulationThread.cs | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 497b7cc89..b596d3818 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -158,6 +158,11 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [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 @@ -202,6 +207,10 @@ public void ScheduleAction(uint timeoutMs, Action action) { lock (_ctx.ScheduledActions) { _ctx.ScheduledActions.Add(new PhysicsEngineContext.ScheduledAction(_ctx.PhysicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); + if (!_scheduledActionsQueueWarningIssued && _ctx.ScheduledActions.Count >= ScheduledActionsWarningThreshold) { + _scheduledActionsQueueWarningIssued = true; + Debug.LogWarning($"[PhysicsEngine] ScheduledActions backlog reached {_ctx.ScheduledActions.Count} items. Callback production may be outpacing drain."); + } } } @@ -222,6 +231,10 @@ internal void Schedule(InputAction action) { lock (_ctx.InputActionsLock) { _ctx.InputActions.Enqueue(action); + if (!_inputActionsQueueWarningIssued && _ctx.InputActions.Count >= InputActionsQueueWarningThreshold) { + _inputActionsQueueWarningIssued = true; + Debug.LogWarning($"[PhysicsEngine] InputActions backlog reached {_ctx.InputActions.Count} items. Producers may be outpacing simulation-thread drain."); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 3b0dfc22b..b882f317f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -78,6 +78,8 @@ public class SimulationThread : IDisposable 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); @@ -274,9 +276,17 @@ public bool EnqueueExternalSwitch(string switchId, bool 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; } } @@ -738,4 +748,4 @@ public void Dispose() #endregion } -} \ No newline at end of file +} From 5fbec756b74a2cf6b89d7fd593b2af99d4f5ded1 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 11:22:35 +0100 Subject: [PATCH 40/51] fix: Too optimistic ball creation logic. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 65 +++++++++++++++++++ .../VPT/Ball/BallComponent.cs | 10 ++- .../VPT/Ball/BallManager.cs | 18 ++--- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index b596d3818..70a9cdaca 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -398,6 +398,10 @@ internal void Register(T item) where T : MonoBehaviour internal BallComponent UnregisterBall(int 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); @@ -405,6 +409,47 @@ internal BallComponent UnregisterBall(int ballId) return b; } + 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; + } + + if (!_ctx.BallStates.Ref.ContainsKey(ballId)) { + _ctx.BallStates.Ref[ballId] = ballState; + } + } + + internal BallComponent UnregisterRuntimeBall(int ballId) + { + 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) @@ -617,6 +662,8 @@ private void OnDestroy() return; } + StopSimulationThreadIfRunning(); + _ctx.IsInitialized = false; _ctx.UseExternalTiming = false; @@ -625,6 +672,24 @@ private void OnDestroy() } } + private void OnDisable() + { + if (_ctx == null || !_ctx.UseExternalTiming) { + return; + } + + StopSimulationThreadIfRunning(); + } + + private void StopSimulationThreadIfRunning() + { + var simulationThreadComponent = GetComponent() + ?? GetComponentInParent() + ?? GetComponentInChildren(); + + simulationThreadComponent?.StopSimulation(); + } + #endregion public ICollider[] GetColliders(int itemId) => GetColliders(itemId, ref _ctx.ColliderLookups, ref _ctx.Colliders); 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/BallManager.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs index 39580e34c..9bda61f68 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs @@ -60,19 +60,19 @@ public int CreateBall(IBallCreationPosition ballCreator, float radius = 25f, flo 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); From 82a6711cdf41c40c20f9a107445ab87faf68d696 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 11:25:19 +0100 Subject: [PATCH 41/51] physics: Tighten `SetTimeFence(...)` cadence. `SimulationThread` now updates `SetTimeFence(...)` whenever the simulation clock advances, instead of throttling updates to every 5 ms. This effectively moves the fence to simulation-tick cadence while still avoiding duplicate calls if the simulation clock does not advance. --- .../VisualPinball.Unity/Simulation/SimulationThread.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index b882f317f..f64a9d8c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -37,7 +37,6 @@ public class SimulationThread : IDisposable private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds private const long BusyWaitThresholdUsec = 100; // Last 100us busy-wait for precision private const int MaxCoilOutputsPerTick = 128; - private const long TimeFenceUpdateIntervalUsec = 5_000; #endregion @@ -425,7 +424,7 @@ private void SimulationTick() // 4. Move the emulation fence after inputs+outputs+physics. // Throttle updates to reduce fence wake/sleep churn in PinMAME. - if (_timeFence != null && (_lastTimeFenceUsec == long.MinValue || _simulationTimeUsec - _lastTimeFenceUsec >= TimeFenceUpdateIntervalUsec)) { + if (_timeFence != null && _simulationTimeUsec != _lastTimeFenceUsec) { _timeFence.SetTimeFence(_simulationTimeUsec / 1_000_000.0); _lastTimeFenceUsec = _simulationTimeUsec; } From dc16087018625ad9f2873b776e6226189f7270e4 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 11:50:04 +0100 Subject: [PATCH 42/51] physics: Generalize low-latency coil handling. PinMAME no longer hard-codes the sim-thread fast path to `c_flipper*`; it now queries `Player` / `CoilPlayer` to see whether the mapped destination coil actually supports simulation-thread dispatch. As part of that, gate lifter coils were upgraded to provide simulation-thread callbacks in addition to flippers, so physics-relevant gate-lift collision changes can now take the low-latency path as well. --- .../VisualPinball.Unity/Game/CoilPlayer.cs | 24 ++++++ .../VisualPinball.Unity/Game/DeviceCoil.cs | 76 +++++++++++-------- .../VisualPinball.Unity/Game/Player.cs | 5 ++ .../VPT/Gate/GateLifterApi.cs | 62 +++++++++------ 4 files changed, 110 insertions(+), 57 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 914fdcecc..472a2ce6a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -215,6 +215,30 @@ internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) 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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index b4878595d..3c94685d4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -1,23 +1,23 @@ -// 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 . - +// 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 @@ -25,8 +25,7 @@ public interface ISimulationThreadCoil void OnCoilSimulationThread(bool enabled); } - public class DeviceCoil : IApiCoil - , ISimulationThreadCoil + public class DeviceCoil : IApiCoil, ISimulationThreadCoil { private int _isEnabled; private int _simulationEnabled; @@ -41,6 +40,17 @@ public class DeviceCoil : IApiCoil /// 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; @@ -101,7 +111,7 @@ public void OnCoilSimulationThread(bool enabled) OnDisableSimulationThread?.Invoke(); } } - + public void OnChange(bool enabled) => OnCoil(enabled); /// @@ -116,16 +126,16 @@ internal void ResetSimulationState() } #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 - } -} + 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/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 9ec32f013..8cbd71b26 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -162,6 +162,11 @@ 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; 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() { From 3a96cca71dbc5890e44b2aad4d0a918fd4f2cb7b Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 12:40:41 +0100 Subject: [PATCH 43/51] physics: Finish shared-state publication for PinMAME outputs. `SimulationThread.WriteSharedState()` now calls into a new `IGamelogicSharedStateWriter` capability, and `PinMameGamelogicEngine` snapshots its current coil, lamp, and GI state into the triple-buffered `SimulationState` each sim tick. The existing event-driven main-thread playback remains in place for now, so this adds coherent shared-state publication without yet replacing the legacy event consumers. --- .../VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs | 9 +++++++++ .../VisualPinball.Unity/Simulation/SimulationState.cs | 8 +++++++- .../VisualPinball.Unity/Simulation/SimulationThread.cs | 9 ++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index dffa43f39..f076b7b1a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -197,6 +197,15 @@ public interface IGamelogicCoilOutputFeed 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); + } + public readonly struct GamelogicPerformanceStats { public readonly bool IsRunning; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 5a0a394f9..403d02d40 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -135,8 +135,11 @@ public struct Snapshot // 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 @@ -159,6 +162,9 @@ 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; BallSnapshots = new NativeArray(MaxBalls, Allocator.Persistent); FloatAnimations = new NativeArray(MaxFloatAnimations, Allocator.Persistent); Float2Animations = new NativeArray(MaxFloat2Animations, Allocator.Persistent); @@ -308,4 +314,4 @@ private ref Snapshot GetBufferByIndex(int index) #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index f64a9d8c9..33c0f96ae 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -46,6 +46,7 @@ public class SimulationThread : IDisposable private readonly IGamelogicEngine _gamelogicEngine; private readonly IGamelogicTimeFence _timeFence; private readonly IGamelogicCoilOutputFeed _coilOutputFeed; + private readonly IGamelogicSharedStateWriter _sharedStateWriter; private readonly IGamelogicInputDispatcher _inputDispatcher; private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; @@ -107,6 +108,7 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE _gamelogicEngine = gamelogicEngine; _timeFence = gamelogicEngine as IGamelogicTimeFence; _coilOutputFeed = gamelogicEngine as IGamelogicCoilOutputFeed; + _sharedStateWriter = gamelogicEngine as IGamelogicSharedStateWriter; _inputDispatcher = GamelogicInputDispatcherFactory.Create(gamelogicEngine); _simulationCoilDispatcher = simulationCoilDispatcher; @@ -692,9 +694,10 @@ private void WriteSharedState() writeBuffer.RealTimeUsec = GetTimestampUsec(); // Copy PinMAME state (coils, lamps, GI) - // This is where we'd copy the changed outputs from PinMAME - // For now, this is a placeholder - // TODO: Implement state copying + writeBuffer.CoilCount = 0; + writeBuffer.LampCount = 0; + writeBuffer.GICount = 0; + _sharedStateWriter?.WriteSharedState(ref writeBuffer); // Increment physics state version (main thread will detect changes) writeBuffer.PhysicsStateVersion++; From 72d0039d272dce17d751322211b2cbbc3070546d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 12:50:24 +0100 Subject: [PATCH 44/51] physics: Reevaluate lamp/GI polling strategy. Implemented a hybrid leaning toward the snapshot model for threaded PinMAME. `SimulationThreadComponent.ApplySimulationState(...)` now calls an `IGamelogicSharedStateApplier` capability on the main thread, and `PinMameGamelogicEngine` uses the shared snapshot to drive lamp/GI playback once threaded shared-state application is active. Non-threaded mode keeps the legacy `OnLampChanged` polling path, and coil playback remains event-driven because coils need edge-preserving behavior rather than frame-latched latest-state playback. --- .../Game/Engine/IGamelogicEngine.cs | 9 +++++++++ .../VisualPinball.Unity/Game/LampPlayer.cs | 19 ++++++++++++------- .../VisualPinball.Unity/Game/Player.cs | 13 +++++++------ .../Simulation/SimulationThreadComponent.cs | 5 ++++- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index f076b7b1a..9cde0c91d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -206,6 +206,15 @@ 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; 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/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 8cbd71b26..23b2d0054 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -173,12 +173,13 @@ public bool SupportsSimulationThreadCoilDispatch(string coilId) public Dictionary LampStatuses => _lampPlayer.LampStates; public Dictionary WireStatuses => _wirePlayer.WireStatuses; - public int NextBallId => ++_currentBallId; - private int _currentBallId; - - public void SetLamp(string lampId, float value) => _lampPlayer.HandleLampEvent(lampId, value); - public void SetLamp(string lampId, LampStatus status) => _lampPlayer.HandleLampEvent(lampId, status); - public void SetLamp(string lampId, Color color) => _lampPlayer.HandleLampEvent(lampId, color); + public int NextBallId => ++_currentBallId; + 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); #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index d40428e73..c34cd9064 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -268,7 +268,10 @@ private void ApplySimulationState(in SimulationState.Snapshot state) // 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) — currently a placeholder. + // GI). + if (_gamelogicEngine is IGamelogicSharedStateApplier sharedStateApplier) { + sharedStateApplier.ApplySharedState(in state); + } } /// From 1177376589532220415e9a6bfdd27f129c556bbb Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 13:04:37 +0100 Subject: [PATCH 45/51] perf: Optimize animation snapshot production. Implemented a lower-risk first pass using stable per-type snapshot ID lists. `PhysicsEngineThreading` now precomputes the animatable item IDs for flippers, gates, plungers, spinners, animated targets, and bumper ring/skirt channels once at startup, then snapshots those lists directly each tick instead of re-enumerating every native hash map. Ball snapshotting still enumerates the live ball map because balls are created and destroyed at runtime, but the fixed animation channels now avoid repeated whole-map sweeps. --- .../Game/PhysicsEngineThreading.cs | 156 ++++++++++-------- 1 file changed, 86 insertions(+), 70 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index b27fb742b..6afb1085e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -20,6 +20,7 @@ using System.Diagnostics; using System.Threading; using Unity.Mathematics; +using VisualPinball.Unity.Collections; using VisualPinball.Unity.Simulation; namespace VisualPinball.Unity @@ -52,6 +53,15 @@ internal class PhysicsEngineThreading private readonly List _deferredMainThreadScheduledActions = 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; internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) @@ -66,9 +76,31 @@ internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContex _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 // ────────────────────────────────────────────────────────────── @@ -236,103 +268,87 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) var floatCount = 0; // Flippers - using (var enumerator = _ctx.FlipperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } + 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 - using (var enumerator = _ctx.BumperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.RingItemId != 0) { - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.RingAnimation.Offset - }; - } - } + 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 - using (var enumerator = _ctx.DropTargetStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.ZOffset - }; - } + 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 - using (var enumerator = _ctx.HitTargetStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.XRotation - }; - } + 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 - using (var enumerator = _ctx.GateStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } + 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 - using (var enumerator = _ctx.PlungerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Animation.Position - }; - } + 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 - using (var enumerator = _ctx.SpinnerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.Angle - }; - } + 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 - using (var enumerator = _ctx.TriggerStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && floatCount < SimulationState.MaxFloatAnimations) { - ref var s = ref enumerator.Current.Value; - if (s.AnimatedItemId == 0) continue; - snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { - ItemId = enumerator.Current.Key, Value = s.Movement.HeightOffset - }; - } + 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; // --- Float2 animations (bumper skirts) --- var float2Count = 0; - using (var enumerator = _ctx.BumperStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && float2Count < SimulationState.MaxFloat2Animations) { - ref var s = ref enumerator.Current.Value; - if (s.SkirtItemId != 0) { - snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { - ItemId = enumerator.Current.Key, Value = s.SkirtAnimation.Rotation - }; - } - } + 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; } From 0745f510f983fb6d6148e7312a3f8c10e4bb50cf Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 13:15:57 +0100 Subject: [PATCH 46/51] perf: Rework `ScheduledActions` into a low-overhead scheduler. Implemented a min-heap scheduler keyed by simulation microseconds. `PhysicsEngine.ScheduleAction(...)` now pushes actions into a heap, and both threaded and single-threaded drains pop only the due actions instead of reverse-scanning and removing from a flat list each frame. This reduces scheduling overhead from O(n) whole-list scans toward O(log n) insertion/removal for the actions that actually matter. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 20 +++++- .../Game/PhysicsEngineContext.cs | 7 +- .../Game/PhysicsEngineThreading.cs | 67 ++++++++++++++----- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 70a9cdaca..d2dabd0d2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -205,8 +205,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public void ScheduleAction(int timeoutMs, Action action) => ScheduleAction((uint)timeoutMs, action); public void ScheduleAction(uint timeoutMs, Action action) { - lock (_ctx.ScheduledActions) { - _ctx.ScheduledActions.Add(new PhysicsEngineContext.ScheduledAction(_ctx.PhysicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); + lock (_ctx.ScheduledActionsLock) { + PushScheduledAction(new PhysicsEngineContext.ScheduledAction(_ctx.PhysicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); if (!_scheduledActionsQueueWarningIssued && _ctx.ScheduledActions.Count >= ScheduledActionsWarningThreshold) { _scheduledActionsQueueWarningIssued = true; Debug.LogWarning($"[PhysicsEngine] ScheduledActions backlog reached {_ctx.ScheduledActions.Count} items. Callback production may be outpacing drain."); @@ -214,6 +214,22 @@ public void ScheduleAction(uint timeoutMs, Action action) } } + 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 _ctx.BallStates.Ref; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index b7a99244d..76c362275 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -180,10 +180,11 @@ internal class PhysicsEngineContext : IDisposable public readonly object InputActionsLock = new(); /// - /// Scheduled managed callbacks. Protected by locking on the list - /// instance itself. + /// 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 @@ -349,4 +350,4 @@ public ScheduledAction(ulong scheduleAt, Action action) #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 6afb1085e..64b3c9594 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -51,6 +51,7 @@ internal class PhysicsEngineThreading 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; @@ -453,13 +454,8 @@ internal void DrainExternalThreadCallbacks() _deferredMainThreadEvents.Add(eventData); } - lock (_ctx.ScheduledActions) { - for (var i = _ctx.ScheduledActions.Count - 1; i >= 0; i--) { - if (_ctx.PhysicsEnv.CurPhysicsFrameTime > _ctx.ScheduledActions[i].ScheduleAt) { - _deferredMainThreadScheduledActions.Add(_ctx.ScheduledActions[i].Action); - _ctx.ScheduledActions.RemoveAt(i); - } - } + lock (_ctx.ScheduledActionsLock) { + DrainDueScheduledActions(_ctx.PhysicsEnv.CurPhysicsFrameTime, _deferredMainThreadScheduledActions); } } finally { Monitor.Exit(_ctx.PhysicsLock); @@ -578,14 +574,12 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) _player.OnEvent(in eventData); } - // process scheduled events from managed land - lock (_ctx.ScheduledActions) { - for (var i = _ctx.ScheduledActions.Count - 1; i >= 0; i--) { - if (_ctx.PhysicsEnv.CurPhysicsFrameTime > _ctx.ScheduledActions[i].ScheduleAt) { - _ctx.ScheduledActions[i].Action(); - _ctx.ScheduledActions.RemoveAt(i); - } - } + _dueSingleThreadScheduledActions.Clear(); + lock (_ctx.ScheduledActionsLock) { + DrainDueScheduledActions(_ctx.PhysicsEnv.CurPhysicsFrameTime, _dueSingleThreadScheduledActions); + } + foreach (var action in _dueSingleThreadScheduledActions) { + action(); } // Apply movements to GameObjects @@ -635,6 +629,49 @@ private void RecordPhysicsBusyTime(long elapsedTicks) Interlocked.Add(ref _ctx.PhysicsBusyTotalUsec, elapsedUsec); } + 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 } } From 50beb22b0e8ab80bd17ac35f0fd05c004ce8eca2 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 13:17:04 +0100 Subject: [PATCH 47/51] fix: Another race window... `ExecuteTick()` ran under `PhysicsLock`, but `WriteSharedState()` copied ball animations after that lock had already been released. If teardown/dispose slipped into that gap, `SnapshotAnimations()` could enumerate a disposed `BallStates` map. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 16 ++++++++++++++++ .../Simulation/SimulationThread.cs | 8 +++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index d2dabd0d2..f0ab7ba63 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -502,6 +502,22 @@ internal void DisableCollider(int itemId) /// 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; + } + } + #endregion #region Event Functions diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 33c0f96ae..894501440 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -703,9 +703,11 @@ private void WriteSharedState() writeBuffer.PhysicsStateVersion++; // Snapshot animation data from physics state maps into the buffer. - // Safe: we are the only writer and this runs sequentially after - // ExecuteTick(). - _physicsEngine.SnapshotAnimations(ref writeBuffer); + // Acquire the physics lock for the snapshot copy so teardown cannot + // dispose the native maps between ExecuteTick() and publication. + if (!_physicsEngine.TrySnapshotAnimations(ref writeBuffer)) { + return; + } // Atomically publish this buffer (lock-free triple-buffer swap) _sharedState.PublishWriteBuffer(); From 7b694fe7cf1450fcfc0d3d4b492dae2d2efea7f5 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 14:39:22 +0100 Subject: [PATCH 48/51] debug: Add CPU perf stats. --- .../Game/Engine/IGamelogicEngine.cs | 21 +++++++- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 14 ++++++ .../Game/PhysicsEngineContext.cs | 2 + .../Game/PhysicsEngineThreading.cs | 49 ++++++++++++++++++- .../Simulation/SimulationState.cs | 42 ++++++++++++++++ .../Simulation/SimulationThread.cs | 48 ++++++++++++++++++ .../Simulation/SimulationThreadComponent.cs | 25 ++++++++-- 7 files changed, 194 insertions(+), 7 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index 9cde0c91d..87207ce59 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -233,8 +233,25 @@ public interface IGamelogicPerformanceStats { bool TryGetPerformanceStats(out GamelogicPerformanceStats stats); } - - public class RequestedDisplays + + 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; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f0ab7ba63..f030c397f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -518,6 +518,20 @@ internal bool TrySnapshotAnimations(ref SimulationState.Snapshot snapshot) } } + 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 #region Event Functions diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index 76c362275..db6c10055 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -240,6 +240,8 @@ internal class PhysicsEngineContext : IDisposable /// from any thread. /// public long PhysicsBusyTotalUsec; + public long LastKinematicScanUsec; + public long LastEventDrainUsec; #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 64b3c9594..a5e270cc3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -19,9 +19,11 @@ 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 { @@ -42,6 +44,8 @@ namespace VisualPinball.Unity /// internal class PhysicsEngineThreading { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private readonly PhysicsEngine _physicsEngine; private readonly PhysicsEngineContext _ctx; private readonly Player _player; @@ -63,6 +67,9 @@ internal class PhysicsEngineThreading 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) @@ -250,8 +257,14 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) { // --- Balls --- var ballCount = 0; + var ballSourceCount = 0; using (var enumerator = _ctx.BallStates.Ref.GetEnumerator()) { - while (enumerator.MoveNext() && ballCount < SimulationState.MaxBalls) { + 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, @@ -264,9 +277,16 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) } } 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++) { @@ -341,9 +361,15 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) } 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); @@ -352,6 +378,11 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) }; } 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 @@ -445,6 +476,7 @@ internal void DrainExternalThreadCallbacks() _deferredMainThreadEvents.Clear(); _deferredMainThreadScheduledActions.Clear(); + var drainStartTicks = Stopwatch.GetTimestamp(); if (!Monitor.TryEnter(_ctx.PhysicsLock)) { return; // sim thread is mid-tick; drain next frame @@ -468,6 +500,8 @@ internal void DrainExternalThreadCallbacks() foreach (var action in _deferredMainThreadScheduledActions) { action(); } + + Interlocked.Exchange(ref _ctx.LastEventDrainUsec, ElapsedUsec(drainStartTicks, Stopwatch.GetTimestamp())); } /// @@ -487,6 +521,8 @@ internal void UpdateKinematicTransformsFromMainThread() { if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicColliderComponents == null) return; + var scanStartTicks = Stopwatch.GetTimestamp(); + _pendingKinematicUpdates.Clear(); foreach (var coll in _kinematicColliderComponents) { @@ -511,6 +547,8 @@ internal void UpdateKinematicTransformsFromMainThread() _ctx.PendingKinematicTransforms.Ref[update.Key] = update.Value; } } + + Interlocked.Exchange(ref _ctx.LastKinematicScanUsec, ElapsedUsec(scanStartTicks, Stopwatch.GetTimestamp())); } #endregion @@ -629,6 +667,15 @@ private void RecordPhysicsBusyTime(long elapsedTicks) 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 403d02d40..f184e0c34 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -132,6 +132,21 @@ 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; @@ -150,12 +165,18 @@ public struct Snapshot 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() { @@ -165,12 +186,33 @@ public void Allocate() 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() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 894501440..2fc388fba 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -47,6 +47,8 @@ public class SimulationThread : IDisposable 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; @@ -62,6 +64,12 @@ public class SimulationThread : IDisposable 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; @@ -109,6 +117,8 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE _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; @@ -405,6 +415,8 @@ private void SimulationThreadFunc() /// Thread: Simulation thread only. private void SimulationTick() { + var tickStartTicks = Stopwatch.GetTimestamp(); + if (_hasMainThreadClockSync) { var syncedClockUsec = Interlocked.Read(ref _latestMainThreadClockUsec); if (syncedClockUsec > _simulationTimeUsec) { @@ -427,6 +439,7 @@ private void SimulationTick() // 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; } @@ -436,6 +449,7 @@ private void SimulationTick() // Increment simulation time _simulationTimeUsec += ScaledTickIntervalUsec(); + _lastSimulationTickDurationUsec = (Stopwatch.GetTimestamp() - tickStartTicks) * 1_000_000L / Stopwatch.Frequency; } /// @@ -485,6 +499,7 @@ private void ProcessGamelogicOutputs() var processed = 0; while (processed < MaxCoilOutputsPerTick && _coilOutputFeed.TryDequeueCoilEvent(out var coilEvent)) { + _lastCoilDispatchUsec = GetTimestampUsec(); _simulationCoilDispatcher(coilEvent.Id, coilEvent.IsEnabled); processed++; } @@ -516,6 +531,11 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) 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})"); } @@ -530,6 +550,14 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) _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++) @@ -692,6 +720,22 @@ private void WriteSharedState() // 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; @@ -705,9 +749,13 @@ private void WriteSharedState() // 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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index c34cd9064..b537e58b3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -4,9 +4,10 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -using System; -using NLog; -using UnityEngine; +using System; +using System.Diagnostics; +using NLog; +using UnityEngine; using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation @@ -282,10 +283,26 @@ 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}"); + 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; From 2a94841f2f8cf8d0449aa433b39ac5183042f208 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 18:06:33 +0100 Subject: [PATCH 49/51] perf: Fix some GC issues. --- .../VisualPinball.Unity/Game/Player.cs | 143 ++++----- .../VPT/Surface/SlingshotComponent.cs | 285 ++++++++++-------- 2 files changed, 240 insertions(+), 188 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 23b2d0054..b8a48543b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -22,11 +22,11 @@ using Unity.Mathematics; using UnityEngine; using UnityEngine.InputSystem; -using UnityEngine.Serialization; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Unity.Simulation; +using UnityEngine.Serialization; +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; @@ -108,12 +108,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private const float SlowMotionMax = 0.1f; private const float TimeLapseMax = 2.5f; - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private TableComponent _tableComponent; private PlayfieldComponent _playfieldComponent; - private PhysicsEngine _physicsEngine; - private SimulationThreadComponent _simulationThreadComponent; - private CancellationTokenSource _gamelogicEngineInitCts; + private PhysicsEngine _physicsEngine; + private SimulationThreadComponent _simulationThreadComponent; + private CancellationTokenSource _gamelogicEngineInitCts; private PlayfieldComponent PlayfieldComponent { get { @@ -124,23 +124,23 @@ private PlayfieldComponent PlayfieldComponent { } } - private PhysicsEngine PhysicsEngine { + private PhysicsEngine PhysicsEngine { get { if (_physicsEngine == null) { _physicsEngine = GetComponentInChildren(); } return _physicsEngine; } - } - - private SimulationThreadComponent SimulationThreadComponent { - get { - if (_simulationThreadComponent == null) { - _simulationThreadComponent = GetComponent(); - } - return _simulationThreadComponent; - } - } + } + + private SimulationThreadComponent SimulationThreadComponent { + get { + if (_simulationThreadComponent == null) { + _simulationThreadComponent = GetComponent(); + } + return _simulationThreadComponent; + } + } #region Access @@ -148,38 +148,38 @@ private SimulationThreadComponent SimulationThreadComponent { public IApiCoil Coil(ICoilDeviceComponent component, string coilItem) => component != null ? _coilPlayer.Coil(component, coilItem) : null; public IApiLamp Lamp(ILampDeviceComponent component) => component != null ? _lampPlayer.Lamp(component) : null; 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); - } + 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; public Dictionary WireStatuses => _wirePlayer.WireStatuses; - public int NextBallId => ++_currentBallId; - 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); + public int NextBallId => ++_currentBallId; + 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); #endregion @@ -187,6 +187,9 @@ public bool SupportsSimulationThreadCoilDispatch(string coilId) private void Awake() { + Debug.developerConsoleEnabled = false; + Debug.developerConsoleVisible = false; + _tableComponent = GetComponent(); var engineComponent = GetComponent(); @@ -200,7 +203,7 @@ private void Awake() GamelogicEngine = engineComponent; _lampPlayer.Awake(this, _tableComponent, GamelogicEngine); _coilPlayer.Awake(this, _tableComponent, GamelogicEngine, _lampPlayer, _wirePlayer); - _switchPlayer.Awake(this, _tableComponent, GamelogicEngine, _inputManager); + _switchPlayer.Awake(this, _tableComponent, GamelogicEngine, _inputManager); _wirePlayer.Awake(_tableComponent, _inputManager, _switchPlayer, this, PhysicsEngine); _displayPlayer.Awake(GamelogicEngine); } @@ -327,15 +330,15 @@ public void Register(TApi api, MonoBehaviour component) where TApi : IApi } } - private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) - { - if (!apiColl.IsColliderAvailable) { - return; - } - _colliderGenerators.Add(apiColl); - if (apiColl is IApiHittable apiHittable) { - _hittables[itemId] = apiHittable; - } + private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) + { + if (!apiColl.IsColliderAvailable) { + return; + } + _colliderGenerators.Add(apiColl); + if (apiColl is IApiHittable apiHittable) { + _hittables[itemId] = apiHittable; + } if (apiColl is IApiCollidable apiCollidable) { _collidables[itemId] = apiCollidable; @@ -349,19 +352,19 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); - public void OnEvent(in EventData eventData) - { - switch (eventData.EventId) { - case EventId.HitEventsHit: - if (!_hittables.ContainsKey(eventData.ItemId)) { - Debug.LogError($"Cannot find {eventData.ItemId} in hittables."); - } - _hittables[eventData.ItemId].OnHit(eventData.BallId); - break; - - case EventId.HitEventsUnhit: - _hittables[eventData.ItemId].OnHit(eventData.BallId, true); - break; + public void OnEvent(in EventData eventData) + { + switch (eventData.EventId) { + case EventId.HitEventsHit: + if (!_hittables.ContainsKey(eventData.ItemId)) { + Debug.LogError($"Cannot find {eventData.ItemId} in hittables."); + } + _hittables[eventData.ItemId].OnHit(eventData.BallId); + break; + + case EventId.HitEventsUnhit: + _hittables[eventData.ItemId].OnHit(eventData.BallId, true); + break; case EventId.LimitEventsBos: _rotatables[eventData.ItemId].OnRotate(eventData.FloatParam, false); @@ -506,4 +509,4 @@ public BallEvent(int ballId, GameObject ball) Ball = ball; } } -} +} \ No newline at end of file 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) { From 0da2c64e8343f94a24cbf1c973d2961720a22fa5 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 23:06:19 +0100 Subject: [PATCH 50/51] physics: Make scheduling more solid. --- .../Event/EventTranslateComponent.cs | 92 ++++--- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 28 ++- .../Game/PhysicsEngineContext.cs | 1 + .../Game/PhysicsEngineThreading.cs | 2 + .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 112 ++++----- .../VisualPinball.Unity/Game/SwitchHandler.cs | 11 +- .../Simulation/SimulationThread.cs | 10 +- .../VPT/Gate/GateComponent.cs | 4 +- .../HitTarget/DropTargetAnimationComponent.cs | 232 +++++++++++------- .../VPT/Kicker/KickerApi.cs | 4 +- .../VPT/Trigger/SwitchAnimationComponent.cs | 116 +++++---- 11 files changed, 357 insertions(+), 255 deletions(-) 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/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f030c397f..dcb994e82 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -205,11 +205,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public void ScheduleAction(int timeoutMs, Action action) => ScheduleAction((uint)timeoutMs, action); public void ScheduleAction(uint timeoutMs, Action action) { + var scheduleAt = CurrentScheduledActionTimeUsec + (ulong)timeoutMs * 1000; lock (_ctx.ScheduledActionsLock) { - PushScheduledAction(new PhysicsEngineContext.ScheduledAction(_ctx.PhysicsEnv.CurPhysicsFrameTime + (ulong)timeoutMs * 1000, action)); + PushScheduledAction(new PhysicsEngineContext.ScheduledAction(scheduleAt, action)); if (!_scheduledActionsQueueWarningIssued && _ctx.ScheduledActions.Count >= ScheduledActionsWarningThreshold) { _scheduledActionsQueueWarningIssued = true; - Debug.LogWarning($"[PhysicsEngine] ScheduledActions backlog reached {_ctx.ScheduledActions.Count} items. Callback production may be outpacing drain."); + LogQueueWarning($"[PhysicsEngine] ScheduledActions backlog reached {_ctx.ScheduledActions.Count} items. Callback production may be outpacing drain."); } } } @@ -249,7 +250,7 @@ internal void Schedule(InputAction action) _ctx.InputActions.Enqueue(action); if (!_inputActionsQueueWarningIssued && _ctx.InputActions.Count >= InputActionsQueueWarningThreshold) { _inputActionsQueueWarningIssued = true; - Debug.LogWarning($"[PhysicsEngine] InputActions backlog reached {_ctx.InputActions.Count} items. Producers may be outpacing simulation-thread drain."); + LogQueueWarning($"[PhysicsEngine] InputActions backlog reached {_ctx.InputActions.Count} items. Producers may be outpacing simulation-thread drain."); } } } @@ -272,6 +273,17 @@ internal void MarkCurrentThreadAsSimulationThread() 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) { @@ -284,10 +296,17 @@ private void GuardLiveStateAccess(string accessorName) } if (_unsafeLiveStateAccessWarnings.Add(accessorName)) { - Debug.LogWarning($"[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."); + 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 @@ -542,6 +561,7 @@ private void Awake() _player = GetComponentInParent(); _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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index db6c10055..13aa7dbd1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -240,6 +240,7 @@ internal class PhysicsEngineContext : IDisposable /// from any thread. /// public long PhysicsBusyTotalUsec; + public long PublishedPhysicsFrameTimeUsec; public long LastKinematicScanUsec; public long LastEventDrainUsec; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index a5e270cc3..c23b382bd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -179,6 +179,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ref _ctx.PhysicsCycle, currentTimeUsec ); + Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); RecordPhysicsBusyTime(sw.ElapsedTicks); } @@ -606,6 +607,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) ref _ctx.PhysicsCycle, currentTimeUsec ); + Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); // dequeue events while (_ctx.EventQueue.Ref.TryDequeue(out var eventData)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 0227d9c4a..512452eb0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -82,14 +82,15 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ { // 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) { + 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)); + 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 @@ -140,60 +141,61 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } } - #region Animation - - // todo it should be enough to calculate animations only once per frame - - // 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, PhysicsConstants.PhysicsStepTime / 1000f); - } - if (bumperState.SkirtItemId != 0) { - BumperSkirtAnimation.Update(ref bumperState.SkirtAnimation, PhysicsConstants.PhysicsStepTime / 1000f); - } - } - } - - // 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, env.TimeMsec); - } - } - - // 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, PhysicsConstants.PhysicsStepTime / 1000f); - } - } - - #endregion - 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); + } + } } - } -} \ 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 9c888cc5a..f0abd43f0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchHandler.cs @@ -195,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/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 2fc388fba..3c5350bcd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -35,7 +35,7 @@ public class SimulationThread : IDisposable #region Constants private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds - private const long BusyWaitThresholdUsec = 100; // Last 100us busy-wait for precision + private const long BusyWaitThresholdUsec = 25; // Keep active spin short to avoid starving render/main thread private const int MaxCoilOutputsPerTick = 128; #endregion @@ -170,11 +170,7 @@ public void Start() { Name = "VPE Simulation Thread", IsBackground = true, - #if UNITY_EDITOR Priority = ThreadPriority.AboveNormal - #else - Priority = ThreadPriority.Highest - #endif }; _thread.Start(); @@ -310,10 +306,6 @@ 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. - #if !UNITY_EDITOR - NativeInputApi.VpeSetThreadPriority(); - #endif - // 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..."); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs index ea4b54ef8..d3b2913fe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateComponent.cs @@ -266,9 +266,7 @@ internal GateState CreateState() 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/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/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/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() { From e4d805a9d1de158deaa77cdb98ebcee8c815dbe8 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 6 Mar 2026 23:55:04 +0100 Subject: [PATCH 51/51] fix: DMD colors. --- .../VisualPinball.Unity/Game/DisplayPlayer.cs | 31 +++++---- .../Game/Engine/IGamelogicEngine.cs | 63 ++++++++++--------- 2 files changed, 53 insertions(+), 41 deletions(-) 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 87207ce59..eedc7bca0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -267,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 {