From e82c6ad2505d6487569912d89c212bdac2efae33 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 14:33:18 +0200 Subject: [PATCH 1/8] physics: add drop target validation baselines --- .../physics/drop-target-physics-validation.md | 27 ++++++ .../Physics/DropTargetPhysicsBaselineTests.cs | 89 +++++++++++++++++++ .../DropTargetPhysicsBaselineTests.cs.meta | 11 +++ .../Physics/RothDropTargetGoldenData.cs | 55 ++++++++++++ .../Physics/RothDropTargetGoldenData.cs.meta | 11 +++ 5 files changed, 193 insertions(+) create mode 100644 VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs.meta diff --git a/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md b/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md new file mode 100644 index 000000000..ff7ebef7a --- /dev/null +++ b/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md @@ -0,0 +1,27 @@ +# Drop target physics validation + +The advanced drop-target implementation is developed against three distinct evidence sets. They must not be conflated. + +## Compatibility fixtures + +`RothDropTargetGoldenData` contains reviewable inputs extracted from these local Roth/nFozzy references: + +- `Dark Chaos (apophis 2025) 2.0.vbs`, SHA-256 `D5DC776B80D919E4418732F03CE55BF4586C93C24AFEBABE9E4D28C98E74DD39`; +- `Catacomb (Stern 1981) v2.0.1.vbs`, SHA-256 `5A814437D836211DA377BE4EBFD91BE242043B8F97D4897D86E32C5942DE5E6B`. + +Phase 0 deliberately does not assert fixture literals against themselves. Phase 2 must run production `RothCompatible` code against every golden case. The fixture records that: + +- target mass is `0.2` in VPE mass units and the actual ball mass participates in the correction; +- normal velocity uses the elastic two-mass ratio while tangent velocity is retained; +- both inspected tables ship with scripted bricking disabled; +- the synthetic brick fixture may enable the existing `30` velocity and `8` center-distance thresholds, but must remain labeled synthetic; +- Dark Chaos backside release uses a velocity threshold of `15`; +- Catacomb's vertical `TargetBouncer` is a table-level post-effect, not part of the Roth drop-target class. + +## Moving-collider feasibility gate + +Mechanical mode depends on moving mesh contact. Before enabling it for authored content, tests must cover triangle faces, generated edges and points, relative-velocity time of impact, transformed bounds, sustained reset contact, and the supported maximum reset speed. A failure blocks Mechanical mode; it must never fall back to assigning a fixed ball Z velocity. + +## Physical calibration + +No bundled profile may be called realistic until it has been fitted and validated against a measured target mechanism. Until those measurements are checked in, all Mechanical profiles are provisional. The minimum dataset records ball and target trajectories, contact location, drop/brick outcome, target parts and spring configuration, and reset motion. Fit and validation shots must be separate. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs new file mode 100644 index 000000000..3f756e1a5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs @@ -0,0 +1,89 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Test +{ + public class DropTargetPhysicsBaselineTests + { + private const float Tolerance = 1e-5f; + + [Test] + public void StandardBallAndPhysicsTimeBaseStayCompatibleWithRoth() + { + var createBall = typeof(BallManager).GetMethod(nameof(BallManager.CreateBall)); + var massParameter = createBall?.GetParameters()[2]; + + Assert.That(massParameter, Is.Not.Null); + Assert.That(massParameter.DefaultValue, Is.EqualTo(1f)); + Assert.That(PhysicsConstants.DefaultStepTimeS, Is.EqualTo(0.01f)); + Assert.That(PhysicsConstants.PhysicsStepTimeS, Is.EqualTo(0.001d).Within(Tolerance)); + Assert.That(PhysicsConstants.PhysFactor, Is.EqualTo(0.1f)); + } + + [Test] + public void MovingTriangleDetectsStationaryBallFromRelativeVelocity() + { + var triangle = new TriangleCollider( + new float3(-10f, -10f, 0f), + new float3(-10f, 10f, 0f), + new float3(10f, -10f, 0f), + new ColliderInfo { ItemId = 1 } + ); + var ball = new BallState { + Id = 1, + Position = new float3(0f, 0f, 2f), + Velocity = float3.zero, + Radius = 1f, + Mass = 1f, + }; + var staticEvent = new CollisionEventData(); + var relativeEvent = new CollisionEventData(); + + var staticHit = triangle.HitTest(ref staticEvent, default, in ball, 1f); + // The generic kinematic narrow phase tests in the collider's rest frame. + // A face moving +Z at 2 therefore sees a stationary ball moving -Z at 2. + ball.Velocity -= new float3(0f, 0f, 2f); + var relativeHit = triangle.HitTest(ref relativeEvent, default, in ball, 1f); + + Assert.That(staticHit, Is.LessThan(0f)); + Assert.That(relativeHit, Is.EqualTo(0.5f).Within(Tolerance)); + Assert.That(relativeEvent.HitNormal, Is.EqualTo(new float3(0f, 0f, 1f))); + } + + [Test] + public void KinematicVelocityUsesTheVpxTenMillisecondTimeBase() + { + var previousMatrix = float4x4.identity; + var currentMatrix = float4x4.Translate(new float3(10f, 0f, 0f)); + var previous = new KinematicVelocityState { LastUpdateUsec = 1000 }; + + var velocity = PhysicsKinematics.DeriveVelocity( + in previous, + in previousMatrix, + in currentMatrix, + 11000, + out var isIsolated + ); + + Assert.That(isIsolated, Is.False); + Assert.That(velocity.LinearVelocity, Is.EqualTo(new float3(10f, 0f, 0f))); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs.meta new file mode 100644 index 000000000..42ab63d20 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsBaselineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27cf92343f134196adbf0c444a7a99c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs new file mode 100644 index 000000000..e02e3ccd6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs @@ -0,0 +1,55 @@ +// Visual Pinball Engine +// 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 . + +namespace VisualPinball.Unity.Test +{ + /// + /// Reviewable compatibility inputs extracted from the local Roth/nFozzy + /// scripts. Phase 2 tests run production compatibility code against these + /// cases; Phase 0 deliberately does not assert the data against itself. + /// + internal static class RothDropTargetGoldenData + { + internal const string DarkChaosSha256 = "D5DC776B80D919E4418732F03CE55BF4586C93C24AFEBABE9E4D28C98E74DD39"; + internal const string CatacombSha256 = "5A814437D836211DA377BE4EBFD91BE242043B8F97D4897D86E32C5942DE5E6B"; + + internal const bool EnableBrick = false; + internal const float TargetMass = 0.2f; + internal const float BrickVelocity = 30f; + internal const float BrickCenterDistance = 8f; + internal const float DarkChaosBackHitVelocity = 15f; + + internal static readonly RothMassCase[] MassCases = { + new RothMassCase(1f, 30f, 20f), + new RothMassCase(1f, 15f, 10f), + new RothMassCase(2f, 30f, 24.545454f), + }; + } + + internal readonly struct RothMassCase + { + internal readonly float BallMass; + internal readonly float IncomingNormalVelocity; + internal readonly float ExpectedNormalVelocity; + + internal RothMassCase(float ballMass, float incomingNormalVelocity, float expectedNormalVelocity) + { + BallMass = ballMass; + IncomingNormalVelocity = incomingNormalVelocity; + ExpectedNormalVelocity = expectedNormalVelocity; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs.meta new file mode 100644 index 000000000..885589054 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetGoldenData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e3e34e06d4ab4e2597fbbdb8b443d161 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From ad2a5b26109dd00c57e75111e6ae93b5a7e37657 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 14:44:41 +0200 Subject: [PATCH 2/8] physics: scaffold drop target modes --- .../HitTarget/DropTargetColliderInspector.cs | 44 +++++- .../Physics/DropTargetPhysicsSchemaTests.cs | 87 ++++++++++++ .../DropTargetPhysicsSchemaTests.cs.meta | 11 ++ .../Physics/Collider/ColliderInfo.cs | 7 +- .../Physics/Collider/ColliderRole.cs | 28 ++++ .../Physics/Collider/ColliderRole.cs.meta | 11 ++ .../Physics/Collision/ColliderHeader.cs | 20 +-- .../VPT/HitTarget/DropTargetApi.cs | 6 +- .../HitTarget/DropTargetColliderComponent.cs | 54 ++++++-- .../VPT/HitTarget/DropTargetPackable.cs | 82 +++++++---- .../VPT/HitTarget/DropTargetPhysicsConfig.cs | 128 ++++++++++++++++++ .../HitTarget/DropTargetPhysicsConfig.cs.meta | 11 ++ .../VPT/HitTarget/TargetColliderGenerator.cs | 7 +- 13 files changed, 442 insertions(+), 54 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs index 8f5380937..b99017a96 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs @@ -18,8 +18,42 @@ namespace VisualPinball.Unity.Editor { - [CustomEditor(typeof(DropTargetColliderComponent)), CanEditMultipleObjects] - public class DropTargetColliderInspector : TargetColliderInspector - { - } -} + [CustomEditor(typeof(DropTargetColliderComponent)), CanEditMultipleObjects] + public class DropTargetColliderInspector : TargetColliderInspector + { + private SerializedProperty _collisionColliderMeshProperty; + private SerializedProperty _physicsModeProperty; + private SerializedProperty _mechanicalProfileProperty; + private SerializedProperty _overrideMechanicalProfileProperty; + private SerializedProperty _mechanicalOverridesProperty; + private SerializedProperty _rothConfigProperty; + + protected override void OnEnable() + { + base.OnEnable(); + _collisionColliderMeshProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.CollisionColliderMesh)); + _physicsModeProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.PhysicsMode)); + _mechanicalProfileProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.MechanicalProfile)); + _overrideMechanicalProfileProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.OverrideMechanicalProfile)); + _mechanicalOverridesProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.MechanicalOverrides)); + _rothConfigProperty = serializedObject.FindProperty(nameof(DropTargetColliderComponent.RothConfig)); + } + + protected override void OnTargetInspectorGUI() + { + PropertyField(_physicsModeProperty, updateColliders: true); + PropertyField(_collisionColliderMeshProperty, "Collision Collider", updateColliders: true); + + var mode = (DropTargetPhysicsMode)_physicsModeProperty.intValue; + if (mode == DropTargetPhysicsMode.RothCompatible) { + EditorGUILayout.PropertyField(_rothConfigProperty, true); + } else if (mode == DropTargetPhysicsMode.Mechanical) { + PropertyField(_mechanicalProfileProperty); + PropertyField(_overrideMechanicalProfileProperty); + if (_mechanicalProfileProperty.objectReferenceValue == null || _overrideMechanicalProfileProperty.boolValue) { + EditorGUILayout.PropertyField(_mechanicalOverridesProperty, true); + } + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs new file mode 100644 index 000000000..65c10fe61 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs @@ -0,0 +1,87 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Text; +using NUnit.Framework; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + public class DropTargetPhysicsSchemaTests + { + [Test] + public void ColliderHeaderPreservesSemanticRole() + { + var header = new ColliderHeader(); + header.Init(new ColliderInfo { + ItemId = 42, + ItemType = ItemType.HitTarget, + Role = ColliderRole.DropTargetPhysicalFace, + }, ColliderType.Triangle); + + Assert.That(header.Role, Is.EqualTo(ColliderRole.DropTargetPhysicalFace)); + Assert.That(header.ColliderInfo.Role, Is.EqualTo(ColliderRole.DropTargetPhysicalFace)); + } + + [Test] + public void PreSchemaPackageDefaultsToLegacyWithoutZeroingAdvancedDefaults() + { + var legacyBytes = Encoding.UTF8.GetBytes("{\"IsMovable\":true,\"Threshold\":2.0,\"UseHitEvent\":true}"); + var data = PackageApi.Packer.Unpack(legacyBytes); + + DropTargetColliderPackable.ApplySchemaDefaults(ref data); + + Assert.That(data.IsMovable, Is.True); + Assert.That(data.Threshold, Is.EqualTo(2f)); + Assert.That(data.UseHitEvent, Is.True); + Assert.That(data.PhysicsMode, Is.EqualTo(DropTargetPhysicsMode.Legacy)); + Assert.That(data.MechanicalOverrides.EffectiveFaceMass, Is.EqualTo(0.2f)); + Assert.That(data.MechanicalOverrides.DropTravel, Is.EqualTo(52f)); + Assert.That(data.RothConfig.TargetMass, Is.EqualTo(0.2f)); + Assert.That(data.RothConfig.EnableBrick, Is.False); + } + + [Test] + public void CurrentSchemaRoundTripsModeAndConfigurations() + { + var expected = new DropTargetColliderPackable { + PhysicsSchemaVersion = DropTargetColliderPackable.CurrentPhysicsSchemaVersion, + PhysicsMode = DropTargetPhysicsMode.Mechanical, + OverrideMechanicalProfile = true, + MechanicalOverrides = DropTargetMechanicalConfig.Default, + RothConfig = DropTargetRothConfig.Default, + }; + expected.MechanicalOverrides.RearStopTravel = 7f; + expected.RothConfig.EnableBacksideDrop = true; + + var bytes = PackageApi.Packer.Pack(expected); + var actual = PackageApi.Packer.Unpack(bytes); + DropTargetColliderPackable.ApplySchemaDefaults(ref actual); + + Assert.That(actual.PhysicsMode, Is.EqualTo(DropTargetPhysicsMode.Mechanical)); + Assert.That(actual.OverrideMechanicalProfile, Is.True); + Assert.That(actual.MechanicalOverrides.RearStopTravel, Is.EqualTo(7f)); + Assert.That(actual.RothConfig.EnableBacksideDrop, Is.True); + } + + [Test] + public void DefaultModeRemainsLegacy() + { + Assert.That(default(DropTargetPhysicsMode), Is.EqualTo(DropTargetPhysicsMode.Legacy)); + Assert.That(default(ColliderRole), Is.EqualTo(ColliderRole.None)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs.meta new file mode 100644 index 000000000..60a953f33 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetPhysicsSchemaTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f09acb08d440434aac7ea1ce9b282ee3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderInfo.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderInfo.cs index 336d86ffc..9497a4625 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderInfo.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderInfo.cs @@ -28,9 +28,10 @@ namespace VisualPinball.Unity public struct ColliderInfo { public int Id; - public int ItemId; - public ItemType ItemType; - public PhysicsMaterialData Material; + public int ItemId; + public ItemType ItemType; + public ColliderRole Role; + public PhysicsMaterialData Material; public float HitThreshold; public bool FireEvents; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs new file mode 100644 index 000000000..f05004b28 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs @@ -0,0 +1,28 @@ +// Visual Pinball Engine +// 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 . + +namespace VisualPinball.Unity +{ + public enum ColliderRole : byte + { + None, + DropTargetFrontSensor, + DropTargetPhysicalFace, + DropTargetSide, + DropTargetBackFace, + DropTargetBody, + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs.meta new file mode 100644 index 000000000..836d05fb0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderRole.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fca40ec53b3b45d3ad86998f1947a1fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs index 7db433353..ee5f6dd6d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs @@ -26,8 +26,9 @@ namespace VisualPinball.Unity /// public struct ColliderHeader : IEquatable { - public ColliderType Type; - public ItemType ItemType; + public ColliderType Type; + public ItemType ItemType; + public ColliderRole Role; public int Id; public int ItemId; /** @@ -65,8 +66,9 @@ public void Init(ColliderInfo info, ColliderType colliderType) if (info.ItemId == 0) { throw new InvalidOperationException("Entity of " + info.ItemType + " " + colliderType + " not set!"); } - Type = colliderType; - ItemType = info.ItemType; + Type = colliderType; + ItemType = info.ItemType; + Role = info.Role; IsTransformed = true; // per default, we assume that we don't have to transform the ball during runtime. Id = info.Id; ItemId = info.ItemId; @@ -78,7 +80,8 @@ public void Init(ColliderInfo info, ColliderType colliderType) public ColliderInfo ColliderInfo => new ColliderInfo { Id = Id, ItemId = ItemId, - ItemType = ItemType, + ItemType = ItemType, + Role = Role, Material = Material, HitThreshold = Threshold, FireEvents = FireEvents @@ -88,8 +91,9 @@ public void Init(ColliderInfo info, ColliderType colliderType) public static bool operator !=(ColliderHeader a, ColliderHeader b) => !a.Equals(b); public readonly bool Equals(ColliderHeader other) - => Type == other.Type - && ItemType == other.ItemType + => Type == other.Type + && ItemType == other.ItemType + && Role == other.Role && Id == other.Id && ItemId == other.ItemId && Material == other.Material @@ -105,6 +109,6 @@ public override readonly bool Equals(object obj) } public override readonly int GetHashCode() => HashCode.Combine( - Type, ItemType, Id, ItemId, Material, Threshold, FireEvents); + Type, ItemType, Role, Id, ItemId, Material, Threshold, FireEvents); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index dc3fd7ece..121f25142 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -125,8 +125,10 @@ private bool IsCurrentlyDropped() protected override void CreateColliders(ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) { var colliderGenerator = new TargetColliderGenerator(this, translateWithinPlayfieldMatrix); - colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, MainComponent); - colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.Primitive, MainComponent); + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetPhysicalFace); + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.Primitive, + MainComponent, ColliderRole.DropTargetBackFace); } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs index 685641bd7..833efc65b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs @@ -32,8 +32,23 @@ public class DropTargetColliderComponent : ColliderComponent (MainComponent as DropTargetComponent)?.DropTargetApi ?? new DropTargetApi(gameObject, player, physicsEngine); - public int NumColliderMeshes => 2; - public Mesh GetColliderMesh(int index) - { - return index switch { - 0 => FrontColliderMesh, - 1 => BackColliderMesh, - _ => throw new ArgumentException($"Must be smaller than {NumColliderMeshes}") - }; + public DropTargetMechanicalConfig ResolvedMechanicalConfig + => MechanicalProfile != null && !OverrideMechanicalProfile + ? MechanicalProfile.Config + : MechanicalOverrides; + + private void OnValidate() + { + // Newly added serialized struct fields can be all-zero on existing scene + // components. Zero mass/travel is not a valid authored profile, so use it + // as the migration sentinel before an author opts into an advanced mode. + if (MechanicalOverrides.EffectiveFaceMass <= 0f || MechanicalOverrides.DropTravel <= 0f) { + MechanicalOverrides = DropTargetMechanicalConfig.Default; + } + if (RothConfig.TargetMass <= 0f) { + RothConfig = DropTargetRothConfig.Default; + } + } + + public int NumColliderMeshes => 3; + public Mesh GetColliderMesh(int index) + { + return index switch { + 0 => FrontColliderMesh, + 1 => BackColliderMesh, + 2 => CollisionColliderMesh, + _ => throw new ArgumentException($"Must be smaller than {NumColliderMeshes}") + }; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs index a42aafe9a..f5a6e324f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs @@ -28,35 +28,65 @@ public static void Unpack(byte[] bytes, DropTargetComponent comp) } } - public struct DropTargetColliderPackable - { - public bool IsMovable; - public float Threshold; - public bool UseHitEvent; + public struct DropTargetColliderPackable + { + public const int CurrentPhysicsSchemaVersion = 1; + + public bool IsMovable; + public float Threshold; + public bool UseHitEvent; + public int PhysicsSchemaVersion; + public DropTargetPhysicsMode PhysicsMode; + public bool OverrideMechanicalProfile; + public DropTargetMechanicalConfig MechanicalOverrides; + public DropTargetRothConfig RothConfig; public static byte[] Pack(DropTargetColliderComponent comp) { return PackageApi.Packer.Pack(new DropTargetColliderPackable { IsMovable = comp._isKinematic, - Threshold = comp.Threshold, - UseHitEvent = comp.UseHitEvent, - }); - } + Threshold = comp.Threshold, + UseHitEvent = comp.UseHitEvent, + PhysicsSchemaVersion = CurrentPhysicsSchemaVersion, + PhysicsMode = comp.PhysicsMode, + OverrideMechanicalProfile = comp.OverrideMechanicalProfile, + MechanicalOverrides = comp.MechanicalOverrides, + RothConfig = comp.RothConfig, + }); + } public static void Unpack(byte[] bytes, DropTargetColliderComponent comp) { - var data = PackageApi.Packer.Unpack(bytes); - comp._isKinematic = data.IsMovable; - comp.Threshold = data.Threshold; - comp.UseHitEvent = data.UseHitEvent; - } - } + var data = PackageApi.Packer.Unpack(bytes); + ApplySchemaDefaults(ref data); + comp._isKinematic = data.IsMovable; + comp.Threshold = data.Threshold; + comp.UseHitEvent = data.UseHitEvent; + comp.PhysicsMode = data.PhysicsMode; + comp.OverrideMechanicalProfile = data.OverrideMechanicalProfile; + comp.MechanicalOverrides = data.MechanicalOverrides; + comp.RothConfig = data.RothConfig; + } + + internal static void ApplySchemaDefaults(ref DropTargetColliderPackable data) + { + if (data.PhysicsSchemaVersion > 0) { + return; + } + data.PhysicsMode = DropTargetPhysicsMode.Legacy; + data.OverrideMechanicalProfile = false; + data.MechanicalOverrides = DropTargetMechanicalConfig.Default; + data.RothConfig = DropTargetRothConfig.Default; + } + } public struct DropTargetColliderReferencesPackable { public PhysicalMaterialPackable PhysicalMaterial; - public string FrontColliderMeshGuid; - public string BackColliderMeshGuid; + public string FrontColliderMeshGuid; + public string BackColliderMeshGuid; + public string CollisionColliderMeshGuid; + public int MechanicalProfileAssetRef; public static byte[] PackReferences(DropTargetColliderComponent comp, PackagedFiles files) { @@ -68,10 +98,12 @@ public static byte[] PackReferences(DropTargetColliderComponent comp, PackagedFi Scatter = comp.Scatter, Overwrite = comp.OverwritePhysics, AssetRef = files.AddAsset(comp.PhysicsMaterial), - }, - FrontColliderMeshGuid = files.GetColliderMeshGuid(comp, 0), - BackColliderMeshGuid = files.GetColliderMeshGuid(comp, 1) - }); + }, + FrontColliderMeshGuid = files.GetColliderMeshGuid(comp, 0), + BackColliderMeshGuid = files.GetColliderMeshGuid(comp, 1), + CollisionColliderMeshGuid = files.GetColliderMeshGuid(comp, 2), + MechanicalProfileAssetRef = files.AddAsset(comp.MechanicalProfile), + }); } public static void Unpack(byte[] bytes, DropTargetColliderComponent comp, PackagedFiles files) @@ -83,9 +115,11 @@ public static void Unpack(byte[] bytes, DropTargetColliderComponent comp, Packag comp.Scatter = data.PhysicalMaterial.Scatter; comp.OverwritePhysics = data.PhysicalMaterial.Overwrite; comp.PhysicsMaterial = files.GetAsset(data.PhysicalMaterial.AssetRef); - comp.FrontColliderMesh = files.GetColliderMesh(data.FrontColliderMeshGuid, 0); - comp.BackColliderMesh = files.GetColliderMesh(data.BackColliderMeshGuid, 1); - } + comp.FrontColliderMesh = files.GetColliderMesh(data.FrontColliderMeshGuid, 0); + comp.BackColliderMesh = files.GetColliderMesh(data.BackColliderMeshGuid, 1); + comp.CollisionColliderMesh = files.GetColliderMesh(data.CollisionColliderMeshGuid, 2); + comp.MechanicalProfile = files.GetAsset(data.MechanicalProfileAssetRef); + } } public struct DropTargetAnimationPackable diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs new file mode 100644 index 000000000..8e11b9abf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs @@ -0,0 +1,128 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public enum DropTargetPhysicsMode : byte + { + Legacy, + RothCompatible, + Mechanical, + } + + public enum DropTargetDeflectionKind : byte + { + SlidingBlade, + HingedBlade, + } + + [Serializable] + public struct DropTargetMechanicalConfig + { + public float EffectiveFaceMass; + public float MinimumFaceImpulse; + + public DropTargetDeflectionKind DeflectionKind; + public Vector3 DeflectionAxis; + public Vector3 DeflectionPivot; + public Vector3 ReferenceContactPoint; + public float LatchReleaseTravel; + public float LatchRelatchTravel; + public float LatchEscapeDrop; + public float RearStopTravel; + public float RearSpringFrequencyHz; + public float RearDampingRatio; + public float RearStopRestitution; + + public float DropMass; + public float DropTravel; + public float DropSpringForce; + public float GuideDamping; + public float GuideFriction; + public float GuideVelocityDeadband; + public float DownStopRestitution; + public float DroppedSwitchTravel; + + public float ResetDurationMs; + public float ResetEffectiveMass; + public float ResetOvershootTravel; + public float ResetSettleDelayMs; + public float RaisedSwitchTravel; + + public bool EnableBacksideRelease; + public float BacksideReleaseImpulse; + public float MechanicalVariation; + + public static DropTargetMechanicalConfig Default => new DropTargetMechanicalConfig { + EffectiveFaceMass = 0.2f, + DeflectionKind = DropTargetDeflectionKind.SlidingBlade, + DeflectionAxis = Vector3.back, + LatchReleaseTravel = 2f, + LatchRelatchTravel = 1f, + LatchEscapeDrop = 2f, + RearStopTravel = 4f, + RearSpringFrequencyHz = 70f, + RearDampingRatio = 0.25f, + RearStopRestitution = 0.65f, + DropMass = 0.2f, + DropTravel = 52f, + DropSpringForce = 40f, + GuideDamping = 0.2f, + GuideFriction = 0.1f, + GuideVelocityDeadband = 0.001f, + DownStopRestitution = 0.1f, + DroppedSwitchTravel = 20f, + ResetDurationMs = 40f, + ResetEffectiveMass = 1f, + ResetOvershootTravel = 10f, + ResetSettleDelayMs = 20f, + RaisedSwitchTravel = 2f, + }; + } + + [Serializable] + public struct DropTargetRothConfig + { + public float TargetMass; + public bool EnableBrick; + public float BrickVelocity; + public float BrickCenterDistance; + public bool EnableBacksideDrop; + public float BacksideVelocity; + public bool EnableVerticalBouncer; + public float VerticalBouncerFactor; + public float VerticalBouncerDeflection; + + public static DropTargetRothConfig Default => new DropTargetRothConfig { + TargetMass = 0.2f, + BrickVelocity = 30f, + BrickCenterDistance = 8f, + BacksideVelocity = 15f, + VerticalBouncerFactor = 0.9f, + VerticalBouncerDeflection = 1f, + }; + } + + [PackAs("DropTargetPhysicsProfile")] + [CreateAssetMenu(fileName = "DropTargetPhysicsProfile", menuName = "Pinball/Drop Target Physics Profile", order = 101)] + public class DropTargetPhysicsProfile : ScriptableObject + { + public DropTargetMechanicalConfig Config = DropTargetMechanicalConfig.Default; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs.meta new file mode 100644 index 000000000..cdfcf9896 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5005f3dd24c94acda48b3f533d7215c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs index dcf8d1392..898f8eb26 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs @@ -33,7 +33,8 @@ public TargetColliderGenerator(IApiColliderGenerator api, float4x4 matrix) _matrix = matrix; } - internal void GenerateColliders(ref ColliderReference colliders, Mesh colliderMesh, ItemType itemType, MonoBehaviour mainComp) + internal void GenerateColliders(ref ColliderReference colliders, Mesh colliderMesh, ItemType itemType, + MonoBehaviour mainComp, ColliderRole role = ColliderRole.None) { if (colliderMesh == null) { Debug.LogWarning($"Collider mesh of target \"{mainComp.name}\" is not set. Target will not fully work."); @@ -47,7 +48,9 @@ internal void GenerateColliders(ref ColliderReference colliders, Mesh colliderMe meshData.GetVertices(unityVertices); meshData.GetIndices(unityIndices, 0); - ColliderUtils.GenerateCollidersFromMesh(in unityVertices, in unityIndices, math.mul(_matrix, Physics.WorldToVpx), _api.GetColliderInfo(itemType), ref colliders); + var info = _api.GetColliderInfo(itemType); + info.Role = role; + ColliderUtils.GenerateCollidersFromMesh(in unityVertices, in unityIndices, math.mul(_matrix, Physics.WorldToVpx), info, ref colliders); unityVertices.Dispose(); unityIndices.Dispose(); From 33fc6b88c3f62594edc9b79a7488b9d319d5f19c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 15:09:08 +0200 Subject: [PATCH 3/8] physics: add Roth drop target mode --- .../Physics/RothDropTargetPhysicsTests.cs | 113 ++++++++++++++ .../RothDropTargetPhysicsTests.cs.meta | 11 ++ .../VisualPinball.Unity/Game/PhysicsState.cs | 9 +- .../Game/PhysicsStaticCollision.cs | 18 ++- .../Physics/Collision/ContactPhysics.cs | 6 +- .../VPT/HitTarget/DropTargetAnimation.cs | 19 ++- .../VPT/HitTarget/DropTargetApi.cs | 23 ++- .../HitTarget/DropTargetColliderComponent.cs | 2 +- .../VPT/HitTarget/DropTargetComponent.cs | 52 +++++-- .../VPT/HitTarget/DropTargetPhysicsConfig.cs | 14 ++ .../VPT/HitTarget/DropTargetState.cs | 1 + .../VPT/HitTarget/DropTargetStaticState.cs | 20 ++- .../VPT/HitTarget/RothDropTargetPhysics.cs | 106 +++++++++++++ .../HitTarget/RothDropTargetPhysics.cs.meta | 11 ++ .../VPT/HitTarget/TargetCollider.cs | 146 ++++++++++++++++-- .../VPT/HitTarget/TargetColliderGenerator.cs | 5 +- 16 files changed, 498 insertions(+), 58 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs new file mode 100644 index 000000000..475a2a034 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs @@ -0,0 +1,113 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class RothDropTargetPhysicsTests + { + private const float Tolerance = 1e-5f; + + [Test] + public void ProductionMassCorrectionMatchesEveryGoldenCase() + { + foreach (var golden in RothDropTargetGoldenData.MassCases) { + var ball = new BallState { + Mass = golden.BallMass, + Velocity = new float3(-golden.IncomingNormalVelocity, 7f, 3f), + }; + var preImpactVelocity = ball.Velocity; + RothDropTargetPhysics.ApplyMassCorrection(ref ball, in preImpactVelocity, + new float3(1f, 0f, 0f), RothDropTargetGoldenData.TargetMass); + + Assert.That(ball.Velocity.x, Is.EqualTo(-golden.ExpectedNormalVelocity).Within(Tolerance)); + Assert.That(ball.Velocity.y, Is.EqualTo(7f).Within(Tolerance)); + Assert.That(ball.Velocity.z, Is.EqualTo(3f).Within(Tolerance)); + } + } + + [Test] + public void ShippedConfigurationNeverClassifiesABrick() + { + var config = DropTargetRothConfig.Default; + config.EnableBrick = RothDropTargetGoldenData.EnableBrick; + + var outcome = RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetFrontSensor, + 100f, 1f, 0f); + + Assert.That(outcome, Is.EqualTo(RothDropTargetOutcome.FaceDrop)); + } + + [Test] + public void SyntheticBrickRequiresSpeedAndCentralContact() + { + var config = DropTargetRothConfig.Default; + config.EnableBrick = true; + + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetFrontSensor, + 31f, 1f, 7.9f), Is.EqualTo(RothDropTargetOutcome.Brick)); + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetFrontSensor, + 30f, 1f, 7.9f), Is.EqualTo(RothDropTargetOutcome.FaceDrop)); + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetFrontSensor, + 31f, 1f, 8f), Is.EqualTo(RothDropTargetOutcome.FaceDrop)); + } + + [Test] + public void BacksideAndSideClassificationAreExplicit() + { + var config = DropTargetRothConfig.Default; + config.EnableBacksideDrop = true; + + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetBackFace, + 16f, 1f, 0f), Is.EqualTo(RothDropTargetOutcome.BacksideDrop)); + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetBackFace, + 15f, 1f, 0f), Is.EqualTo(RothDropTargetOutcome.BacksideBounce)); + Assert.That(RothDropTargetPhysics.Classify(in config, ColliderRole.DropTargetFrontSensor, + 20f, 0.49f, 0f), Is.EqualTo(RothDropTargetOutcome.SideHit)); + } + + [Test] + public void VerticalBouncerIsDeterministicAndPreservesSpeed() + { + var config = DropTargetRothConfig.Default; + config.EnableVerticalBouncer = true; + var first = new BallState { Id = 7, Velocity = new float3(3f, 4f, 0f) }; + var second = first; + + RothDropTargetPhysics.ApplyVerticalBouncer(ref first, in config, 42, 3); + RothDropTargetPhysics.ApplyVerticalBouncer(ref second, in config, 42, 3); + + Assert.That(first.Velocity, Is.EqualTo(second.Velocity)); + Assert.That(math.length(first.Velocity), Is.EqualTo(5f).Within(Tolerance)); + Assert.That(first.Velocity.z, Is.GreaterThan(0f)); + } + + [Test] + public void VerticalBouncerMatchesReferenceByReplacingExistingZ() + { + var config = DropTargetRothConfig.Default; + config.EnableVerticalBouncer = true; + var ball = new BallState { Id = 7, Velocity = new float3(3f, 4f, 12f) }; + + RothDropTargetPhysics.ApplyVerticalBouncer(ref ball, in config, 42, 3); + + Assert.That(math.length(ball.Velocity), Is.EqualTo(5f).Within(Tolerance)); + Assert.That(ball.Velocity.z, Is.GreaterThan(0f).And.LessThan(5f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs.meta new file mode 100644 index 000000000..5510d9c0d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 402185afbbff41e4bf035e976d16e54e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 43776bb74..c72d14500 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -457,9 +457,12 @@ internal float HitTest(ref NativeColliders colliders, int colliderId, ref BallSt return -1f; } - private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId) - { - if (colliders.GetItemType(colliderId) == ItemType.HitTarget && HasDropTargetState(colliderId, ref colliders)) { + private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId) + { + ref var header = ref colliders.GetHeader(colliderId); + var isDropTargetCollider = header.ItemType == ItemType.HitTarget + || header.Role == ColliderRole.DropTargetFrontSensor; + if (isDropTargetCollider && HasDropTargetState(colliderId, ref colliders)) { ref var dropTargetState = ref GetDropTargetState(colliderId, ref colliders); if (dropTargetState.Animation.IsDropped || dropTargetState.Animation.MoveAnimation) { // QUICKFIX so that DT is not triggered twice return true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 953bef7d4..d1e240c04 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -164,11 +164,17 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r ball.CollisionEvent.ClearCollider(); } - private static bool CollidesWithItem(ref NativeColliders colliders, ref ColliderHeader collHeader, ref BallState ball, ref PhysicsState state) - { - // hit target - var colliderId = ball.CollisionEvent.ColliderId; - if (collHeader.ItemType == ItemType.HitTarget) { + private static bool CollidesWithItem(ref NativeColliders colliders, ref ColliderHeader collHeader, ref BallState ball, ref PhysicsState state) + { + // hit target + var colliderId = ball.CollisionEvent.ColliderId; + if (collHeader.Role == ColliderRole.DropTargetFrontSensor && state.HasDropTargetState(colliderId, ref colliders)) { + ref var sensorTargetState = ref state.GetDropTargetState(colliderId, ref colliders); + TargetCollider.DropTargetCollide(ref ball, ref state.EventQueue, ref sensorTargetState, + in ball.CollisionEvent.HitNormal, in ball.CollisionEvent, in collHeader, ref state); + return true; + } + if (collHeader.ItemType == ItemType.HitTarget) { var normal = collHeader.Type == ColliderType.Triangle ? colliders.Triangle(colliderId).Normal() @@ -176,7 +182,7 @@ private static bool CollidesWithItem(ref NativeColliders colliders, ref Collider if (state.HasDropTargetState(colliderId, ref colliders)) { ref var dropTargetState = ref state.GetDropTargetState(colliderId, ref colliders); - TargetCollider.DropTargetCollide(ref ball, ref state.EventQueue, ref dropTargetState.Animation, in normal, in ball.CollisionEvent, in collHeader, ref state); + TargetCollider.DropTargetCollide(ref ball, ref state.EventQueue, ref dropTargetState, in normal, in ball.CollisionEvent, in collHeader, ref state); return true; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index 973fcd05e..69d3c5437 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -34,8 +34,10 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball gravity = matrixInv.MultiplyVector(gravity); } - ref var collHeader = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId); - if (collHeader.Type == ColliderType.Flipper) { + ref var collHeader = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId); + if (collHeader.Role == ColliderRole.DropTargetFrontSensor) { + // Semantic sensors report crossings but never generate a support impulse. + } else if (collHeader.Type == ColliderType.Flipper) { ref var flipperCollider = ref colliders.Flipper(collEvent.ColliderId); ref var flipperState = ref state.GetFlipperState(collEvent.ColliderId, ref colliders); flipperCollider.Contact(ref ball, ref flipperState.Movement, in collEvent, in flipperState.Static, in flipperState.Velocity, hitTime, in gravity); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimation.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimation.cs index d704a159e..510505621 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimation.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetAnimation.cs @@ -26,20 +26,23 @@ internal static void Update(int itemId, ref DropTargetAnimationState animation, animation.TimeMsec = state.Env.TimeMsec; var diffTimeMsec = (float)(state.Env.TimeMsec - oldTimeMsec); - if (animation.HitEvent) { - if (!animation.IsDropped) { - animation.MoveDown = true; - } + if (animation.HitEvent) { + if (!animation.IsDropped) { + animation.MoveDown = true; + animation.TimeStamp = animation.TimeMsec; + } animation.MoveAnimation = true; animation.HitEvent = false; } if (animation.MoveAnimation) { - var step = staticState.Speed; - - if (animation.MoveDown) { - step = -step; + var step = staticState.RaiseSpeed; + + if (animation.MoveDown) { + step = animation.TimeMsec - animation.TimeStamp < (uint)staticState.DropDelay + ? 0f + : -staticState.DropSpeed; } else if (animation.TimeMsec - animation.TimeStamp < (uint) staticState.RaiseDelay) { step = 0.0f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index 121f25142..06c62bfc3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -122,14 +122,29 @@ private bool IsCurrentlyDropped() protected override bool FireHitEvents => true; protected override float HitThreshold => ColliderComponent.Threshold; - protected override void CreateColliders(ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) - { - var colliderGenerator = new TargetColliderGenerator(this, translateWithinPlayfieldMatrix); + protected override void CreateColliders(ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + { + var colliderGenerator = new TargetColliderGenerator(this, translateWithinPlayfieldMatrix); + if (ColliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible) { + if (ColliderComponent.CollisionColliderMesh) { + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.Trigger, + MainComponent, ColliderRole.DropTargetFrontSensor, true); + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.CollisionColliderMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetPhysicalFace); + } else { + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetPhysicalFace); + } + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetBackFace); + return; + } + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, MainComponent, ColliderRole.DropTargetPhysicalFace); colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.Primitive, MainComponent, ColliderRole.DropTargetBackFace); - } + } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs index 833efc65b..cc7cba751 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs @@ -137,7 +137,7 @@ private void OnValidate() if (MechanicalOverrides.EffectiveFaceMass <= 0f || MechanicalOverrides.DropTravel <= 0f) { MechanicalOverrides = DropTargetMechanicalConfig.Default; } - if (RothConfig.TargetMass <= 0f) { + if (RothConfig.TargetMass <= 0f || RothConfig.DropTravel <= 0f) { RothConfig = DropTargetRothConfig.Default; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 2ef956ef1..94b44b155 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -159,20 +159,44 @@ internal DropTargetState CreateState() var colliderComponent = GetComponent(); var animationComponent = GetComponentInChildren(); - var staticData = colliderComponent && animationComponent - ? new DropTargetStaticState { - Speed = animationComponent.Speed, - RaiseDelay = animationComponent.RaiseDelay, - UseHitEvent = colliderComponent.UseHitEvent, - } : default; - - var animationData = colliderComponent && animationComponent - ? new DropTargetAnimationState { - IsDropped = animationComponent.IsDropped, - MoveDown = !animationComponent.IsDropped, - DropDistance = animationComponent.DropDistance, - ZOffset = animationComponent.IsDropped ? -animationComponent.DropDistance : 0f - } : default; + var staticData = default(DropTargetStaticState); + if (colliderComponent) { + var angle = math.radians(Rotation - 90f); + var roth = colliderComponent.RothConfig; + var rothDropTravel = roth.DropTravel > 0f ? roth.DropTravel : 52f; + staticData = new DropTargetStaticState { + PhysicsMode = colliderComponent.PhysicsMode, + Roth = roth, + Center = Position, + FaceNormal = new float3(math.cos(angle), math.sin(angle), 0f), + HasRothSensor = colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible + && colliderComponent.CollisionColliderMesh, + DropSpeed = animationComponent ? animationComponent.Speed : 0f, + RaiseSpeed = animationComponent ? animationComponent.Speed : 0f, + RaiseDelay = animationComponent ? animationComponent.RaiseDelay : 0f, + UseHitEvent = colliderComponent.UseHitEvent, + }; + if (colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible) { + staticData.DropSpeed = rothDropTravel / math.max(roth.DropDurationMs, 1f); + staticData.RaiseSpeed = rothDropTravel / math.max(roth.RaiseDurationMs, 1f); + staticData.DropDelay = math.max(roth.DropDelayMs, 0f); + staticData.RaiseDelay = math.max(roth.RaiseDelayMs, 0f); + } + } + + var animationData = colliderComponent && animationComponent + ? new DropTargetAnimationState { + IsDropped = animationComponent.IsDropped, + MoveDown = !animationComponent.IsDropped, + DropDistance = colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible + ? math.max(colliderComponent.RothConfig.DropTravel, 1f) + : animationComponent.DropDistance, + ZOffset = animationComponent.IsDropped + ? -(colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible + ? math.max(colliderComponent.RothConfig.DropTravel, 1f) + : animationComponent.DropDistance) + : 0f + } : default; return new DropTargetState( animationComponent ? UnityObjectId.Get(animationComponent.gameObject) : 0, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs index 8e11b9abf..77d63d56c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs @@ -108,6 +108,13 @@ public struct DropTargetRothConfig public bool EnableVerticalBouncer; public float VerticalBouncerFactor; public float VerticalBouncerDeflection; + public int DeterministicSeed; + public float DropDelayMs; + public float DropDurationMs; + public float RaiseDelayMs; + public float RaiseDurationMs; + public float DropTravel; + public float ResetOvershootTravel; public static DropTargetRothConfig Default => new DropTargetRothConfig { TargetMass = 0.2f, @@ -116,6 +123,13 @@ public struct DropTargetRothConfig BacksideVelocity = 15f, VerticalBouncerFactor = 0.9f, VerticalBouncerDeflection = 1f, + DeterministicSeed = 1, + DropDelayMs = 20f, + DropDurationMs = 90f, + RaiseDelayMs = 40f, + RaiseDurationMs = 40f, + DropTravel = 52f, + ResetOvershootTravel = 10f, }; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs index 96cd8a424..ec3da0f9a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs @@ -21,6 +21,7 @@ internal struct DropTargetState internal readonly int AnimatedItemId; internal DropTargetStaticState Static; internal DropTargetAnimationState Animation; + internal uint RothHitCounter; public DropTargetState(int animatedItemId, DropTargetStaticState @static, DropTargetAnimationState animation) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs index 5bdf7317d..4bc467223 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs @@ -18,10 +18,18 @@ namespace VisualPinball.Unity { - internal struct DropTargetStaticState - { - public float Speed; - public float RaiseDelay; - public bool UseHitEvent; - } + internal struct DropTargetStaticState + { + public DropTargetPhysicsMode PhysicsMode; + public DropTargetRothConfig Roth; + public float3 Center; + public float3 FaceNormal; + public bool HasRothSensor; + + public float DropSpeed; + public float RaiseSpeed; + public float DropDelay; + public float RaiseDelay; + public bool UseHitEvent; + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs new file mode 100644 index 000000000..330998521 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs @@ -0,0 +1,106 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal enum RothDropTargetOutcome : byte + { + None, + FaceDrop, + Brick, + SideHit, + BacksideDrop, + BacksideBounce, + } + + internal static class RothDropTargetPhysics + { + internal static RothDropTargetOutcome Classify(in DropTargetRothConfig config, ColliderRole role, + float approachSpeed, float faceAlignment, float centerDistance) + { + if (role == ColliderRole.DropTargetBackFace) { + return config.EnableBacksideDrop && approachSpeed > config.BacksideVelocity + ? RothDropTargetOutcome.BacksideDrop + : RothDropTargetOutcome.BacksideBounce; + } + if (faceAlignment < 0.5f || approachSpeed <= 0f) { + return RothDropTargetOutcome.SideHit; + } + if (config.EnableBrick && config.BrickVelocity > 0f + && approachSpeed > config.BrickVelocity && centerDistance < config.BrickCenterDistance) { + return RothDropTargetOutcome.Brick; + } + return RothDropTargetOutcome.FaceDrop; + } + + internal static void ApplyMassCorrection(ref BallState ball, in float3 preImpactVelocity, + in float3 faceNormal, float targetMass) + { + var normal = math.normalizesafe(faceNormal); + var normalVelocity = math.dot(preImpactVelocity, normal); + var tangentVelocity = preImpactVelocity - normalVelocity * normal; + var denominator = ball.Mass + targetMass; + if (ball.Mass <= 0f || targetMass < 0f || denominator <= 0f) { + return; + } + var correctedNormalVelocity = normalVelocity * (ball.Mass - targetMass) / denominator; + ball.Velocity = tangentVelocity + correctedNormalVelocity * normal; + } + + internal static void ApplyVerticalBouncer(ref BallState ball, in DropTargetRothConfig config, + int itemId, uint hitCounter) + { + if (!config.EnableVerticalBouncer) { + return; + } + // Match VPW TargetBouncer: BallSpeed is XY speed and velz is replaced, + // not combined with an existing vertical component. + var xySpeed = math.length(ball.Velocity.xy); + if (xySpeed <= 0f) { + return; + } + var multiplier = BouncerMultiplier(StableIndex(config.DeterministicSeed, itemId, ball.Id, hitCounter)); + var z = math.min(math.abs(xySpeed * multiplier * config.VerticalBouncerDeflection + * config.VerticalBouncerFactor), xySpeed * 0.999f); + var newXySpeed = math.sqrt(math.max(xySpeed * xySpeed - z * z, 0f)); + ball.Velocity.xy = math.normalizesafe(ball.Velocity.xy) * newXySpeed; + ball.Velocity.z = z; + } + + private static int StableIndex(int seed, int itemId, int ballId, uint hitCounter) + { + var hash = (uint)seed; + hash = (hash ^ (uint)itemId) * 16777619u; + hash = (hash ^ (uint)ballId) * 16777619u; + hash = (hash ^ hitCounter) * 16777619u; + return (int)(hash % 6u); + } + + private static float BouncerMultiplier(int index) + { + return index switch { + 0 => 0.2f, + 1 => 0.25f, + 2 => 0.3f, + 3 => 0.4f, + 4 => 0.45f, + _ => 0.5f, + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs.meta new file mode 100644 index 000000000..93d1ba461 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/RothDropTargetPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d03e5a7dbf1d46429e7c6b7402ad966d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs index f533dd31b..861222489 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs @@ -14,19 +14,32 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using Unity.Collections; -using Unity.Mathematics; +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.VPT; namespace VisualPinball.Unity { - internal static class TargetCollider - { - public static void DropTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, - ref DropTargetAnimationState animation, in float3 normal, in CollisionEventData collEvent, - in ColliderHeader collHeader, ref PhysicsState state) - { - if (animation.IsDropped) { - return; + internal static class TargetCollider + { + public static void DropTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + ref DropTargetState target, in float3 normal, in CollisionEventData collEvent, + in ColliderHeader collHeader, ref PhysicsState state) + { + if (target.Static.PhysicsMode == DropTargetPhysicsMode.RothCompatible) { + RothCompatibleCollide(ref ball, ref hitEvents, ref target, in normal, in collEvent, in collHeader, ref state); + return; + } + LegacyDropTargetCollide(ref ball, ref hitEvents, ref target.Animation, in normal, in collEvent, in collHeader, ref state); + } + + private static void LegacyDropTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + ref DropTargetAnimationState animation, in float3 normal, in CollisionEventData collEvent, + in ColliderHeader collHeader, ref PhysicsState state) + { + if (animation.IsDropped) { + return; } var dot = -math.dot(collEvent.HitNormal, ball.Velocity); @@ -36,8 +49,117 @@ public static void DropTargetCollide(ref BallState ball, ref NativeQueuem_currentHitThreshold = dot; Collider.FireHitEvent(ref ball, ref hitEvents, in collHeader); - } - } + } + } + + private static void RothCompatibleCollide(ref BallState ball, + ref NativeQueue.ParallelWriter hitEvents, ref DropTargetState target, in float3 normal, + in CollisionEventData collEvent, in ColliderHeader collHeader, ref PhysicsState state) + { + if (target.Animation.IsDropped) { + return; + } + + var preImpactVelocity = ball.Velocity; + if (collHeader.Role == ColliderRole.DropTargetFrontSensor) { + var inside = state.InsideOfs.IsInsideOf(collHeader.ItemId, ball.Id); + if (collEvent.HitFlag != inside) { + return; + } + ball.Position += PhysicsConstants.StaticTime * ball.Velocity; + if (inside) { + state.InsideOfs.SetOutsideOf(collHeader.ItemId, ball.Id); + return; + } + state.InsideOfs.SetInsideOf(collHeader.ItemId, ball.Id); + ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, in collHeader, false); + return; + } + + BallCollider.Collide3DWall(ref ball, in collHeader.Material, in collEvent, in normal, ref state); + if (collHeader.Role == ColliderRole.DropTargetPhysicalFace && !target.Static.HasRothSensor) { + ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, in collHeader, true); + return; + } + if (collHeader.Role == ColliderRole.DropTargetBackFace) { + var approachSpeed = -math.dot(preImpactVelocity, math.normalizesafe(collEvent.HitNormal)); + var outcome = RothDropTargetPhysics.Classify(in target.Static.Roth, collHeader.Role, + approachSpeed, 1f, 0f); + if (outcome == RothDropTargetOutcome.BacksideDrop && approachSpeed >= collHeader.Threshold) { + ActivateDrop(ref ball, ref hitEvents, ref target, in collHeader); + } + } + } + + private static void ProcessRothHit(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + ref DropTargetState target, in float3 preImpactVelocity, in CollisionEventData collEvent, + in ColliderHeader collHeader, bool solidFallback) + { + var faceNormal = math.normalizesafe(target.Static.FaceNormal); + if (math.dot(faceNormal, collEvent.HitNormal) < 0f) { + faceNormal = -faceNormal; + } + var hitNormal = math.normalizesafe(collEvent.HitNormal); + var approachSpeed = -math.dot(preImpactVelocity, faceNormal); + var faceAlignment = math.abs(math.dot(hitNormal, faceNormal)); + var tangent = math.normalizesafe(math.cross(new float3(0f, 0f, 1f), faceNormal)); + var contactPoint = ball.Position - ball.Radius * hitNormal; + var centerDistance = math.abs(math.dot(contactPoint - target.Static.Center, tangent)); + var outcome = RothDropTargetPhysics.Classify(in target.Static.Roth, collHeader.Role, + approachSpeed, faceAlignment, centerDistance); + var qualifies = approachSpeed >= collHeader.Threshold; + + if (solidFallback) { + // Without a separate sensor and offset wall, retain the wall rebound + // unless this hit actually drops the target. Otherwise an elastic mass + // correction would point the ball back into a face that remains solid. + if (outcome == RothDropTargetOutcome.FaceDrop && qualifies) { + RothDropTargetPhysics.ApplyMassCorrection(ref ball, in preImpactVelocity, in faceNormal, + target.Static.Roth.TargetMass); + RothDropTargetPhysics.ApplyVerticalBouncer(ref ball, in target.Static.Roth, + collHeader.ItemId, target.RothHitCounter++); + ActivateDrop(ref ball, ref hitEvents, ref target, in collHeader); + } else if (outcome == RothDropTargetOutcome.Brick && qualifies) { + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } + return; + } + + // Roth applies DTBallPhysics to state 4 (side) as well as normal and + // brick hits; preserve that script behavior in the true sensor path. + if (outcome == RothDropTargetOutcome.FaceDrop || outcome == RothDropTargetOutcome.Brick + || outcome == RothDropTargetOutcome.SideHit) { + RothDropTargetPhysics.ApplyMassCorrection(ref ball, in preImpactVelocity, in faceNormal, + target.Static.Roth.TargetMass); + RothDropTargetPhysics.ApplyVerticalBouncer(ref ball, in target.Static.Roth, + collHeader.ItemId, target.RothHitCounter++); + } + + if (!qualifies) { + return; + } + if (outcome == RothDropTargetOutcome.FaceDrop) { + ActivateDrop(ref ball, ref hitEvents, ref target, in collHeader); + } else if (outcome == RothDropTargetOutcome.Brick) { + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } + } + + private static void ActivateDrop(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + ref DropTargetState target, in ColliderHeader collHeader) + { + target.Animation.HitEvent = true; + target.Animation.MoveAnimation = true; + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } + + private static void FireDropTargetHit(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + in ColliderHeader collHeader) + { + var eventHeader = collHeader; + eventHeader.ItemType = ItemType.HitTarget; + Collider.FireHitEvent(ref ball, ref hitEvents, in eventHeader); + } public static void HitTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, ref HitTargetAnimationData animationData, in float3 normal, in CollisionEventData collEvent, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs index 898f8eb26..4dbe25b16 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetColliderGenerator.cs @@ -34,7 +34,7 @@ public TargetColliderGenerator(IApiColliderGenerator api, float4x4 matrix) } internal void GenerateColliders(ref ColliderReference colliders, Mesh colliderMesh, ItemType itemType, - MonoBehaviour mainComp, ColliderRole role = ColliderRole.None) + MonoBehaviour mainComp, ColliderRole role = ColliderRole.None, bool onlyTriangles = false) { if (colliderMesh == null) { Debug.LogWarning($"Collider mesh of target \"{mainComp.name}\" is not set. Target will not fully work."); @@ -50,7 +50,8 @@ internal void GenerateColliders(ref ColliderReference colliders, Mesh colliderMe var info = _api.GetColliderInfo(itemType); info.Role = role; - ColliderUtils.GenerateCollidersFromMesh(in unityVertices, in unityIndices, math.mul(_matrix, Physics.WorldToVpx), info, ref colliders); + ColliderUtils.GenerateCollidersFromMesh(in unityVertices, in unityIndices, + math.mul(_matrix, Physics.WorldToVpx), info, ref colliders, onlyTriangles); unityVertices.Dispose(); unityIndices.Dispose(); From e8658dd289acb642653db29a97e8321339cc38ab Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 15:32:00 +0200 Subject: [PATCH 4/8] physics: simulate drop target mechanics --- .../MechanicalDropTargetPhysicsTests.cs | 174 ++++++++++ .../MechanicalDropTargetPhysicsTests.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsState.cs | 16 +- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 20 +- .../VPT/HitTarget/DropTargetApi.cs | 10 + .../HitTarget/DropTargetColliderComponent.cs | 3 + .../VPT/HitTarget/DropTargetComponent.cs | 17 +- .../HitTarget/DropTargetMechanicalState.cs | 71 ++++ .../DropTargetMechanicalState.cs.meta | 11 + .../VPT/HitTarget/DropTargetPackable.cs | 3 + .../VPT/HitTarget/DropTargetPhysicsConfig.cs | 2 +- .../VPT/HitTarget/DropTargetState.cs | 1 + .../VPT/HitTarget/DropTargetStaticState.cs | 1 + .../HitTarget/MechanicalDropTargetPhysics.cs | 314 ++++++++++++++++++ .../MechanicalDropTargetPhysics.cs.meta | 11 + .../VPT/HitTarget/TargetCollider.cs | 78 +++++ 16 files changed, 727 insertions(+), 16 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs new file mode 100644 index 000000000..60dc87d8c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs @@ -0,0 +1,174 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Test +{ + public class MechanicalDropTargetPhysicsTests + { + private const float Tolerance = 1e-4f; + + [Test] + public void FiniteMassImpactMatchesClosedFormSanityCase() + { + var target = CreateTargetState(); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Velocity = new float3(-30f, 0f, 0f), + }; + + var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, new float3(1f, 0f, 0f), 0.35f, 0f); + + Assert.That(result.Applied, Is.True); + Assert.That(ball.Velocity.x, Is.EqualTo(-23.25f).Within(Tolerance)); + Assert.That(target.Mechanical.QDot, Is.EqualTo(33.75f).Within(Tolerance)); + } + + [Test] + public void UnpoweredImpactDoesNotCreateEnergy() + { + var target = CreateTargetState(); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Velocity = new float3(-30f, 0f, 0f), + }; + var before = 0.5f * ball.Mass * math.lengthsq(ball.Velocity); + + MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, new float3(1f, 0f, 0f), 0.35f, 0f); + var after = 0.5f * ball.Mass * math.lengthsq(ball.Velocity) + + 0.5f * target.Static.Mechanical.EffectiveFaceMass + * target.Mechanical.QDot * target.Mechanical.QDot; + + Assert.That(after, Is.LessThanOrEqualTo(before + Tolerance)); + Assert.That(after, Is.EqualTo(384.1875f).Within(Tolerance)); + } + + [Test] + public void DampedOscillatorMatchesUndampedQuarterPeriod() + { + var frequency = 25f; + var q = 1f; + var qDot = 0f; + var quarterPeriodInternal = 1f / (4f * frequency * PhysicsConstants.DefaultStepTimeS); + + MechanicalDropTargetPhysics.IntegrateDampedOscillator(ref q, ref qDot, + frequency, 0f, quarterPeriodInternal); + + Assert.That(q, Is.EqualTo(0f).Within(Tolerance)); + Assert.That(qDot, Is.LessThan(0f)); + } + + [Test] + public void TangentialFrictionReducesSlipAndTransfersSpin() + { + var target = CreateTargetState(); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Velocity = new float3(-30f, 10f, 0f), + }; + + var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, new float3(1f, 0f, 0f), 0.35f, 0.2f); + + Assert.That(result.Applied, Is.True); + Assert.That(math.abs(result.TangentImpulse), Is.GreaterThan(0f)); + Assert.That(math.abs(ball.Velocity.y), Is.LessThan(10f)); + Assert.That(math.length(ball.AngularMomentum), Is.GreaterThan(0f)); + } + + [Test] + public void SweptRearStopReflectsOnlyTheClosingVelocity() + { + var config = DropTargetMechanicalConfig.Default; + config.RearSpringFrequencyHz = 0f; + config.RearStopTravel = 4f; + config.RearStopRestitution = 0.5f; + var mechanical = new DropTargetMechanicalState { Q = 3.9f, QDot = 10f }; + + MechanicalDropTargetPhysics.IntegrateRearWithStop(ref mechanical, in config, 0.1f); + + Assert.That(mechanical.Q, Is.LessThanOrEqualTo(config.RearStopTravel)); + Assert.That(mechanical.Q, Is.EqualTo(3.55f).Within(0.01f)); + Assert.That(mechanical.QDot, Is.EqualTo(-5f).Within(Tolerance)); + } + + [Test] + public void LatchedRestStateStaysExactlyQuiescent() + { + var target = CreateTargetState(); + var state = new PhysicsState(); + + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, + PhysicsConstants.PhysFactor, ref state); + + Assert.That(target.Mechanical.Q, Is.EqualTo(0f)); + Assert.That(target.Mechanical.QDot, Is.EqualTo(0f)); + Assert.That(target.Mechanical.D, Is.EqualTo(0f)); + Assert.That(target.Mechanical.DDot, Is.EqualTo(0f)); + } + + [Test] + public void EscapeWinsRelatchAtSameStep() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Released; + target.Mechanical.Q = target.Static.Mechanical.LatchRelatchTravel; + target.Mechanical.QDot = -0.01f; + target.Mechanical.D = target.Static.Mechanical.LatchEscapeDrop; + var state = new PhysicsState(); + + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, 0f, ref state); + + Assert.That(target.Mechanical.State, Is.EqualTo(DropTargetMechanismState.Dropping)); + } + + [Test] + public void ReturnBeforeEscapeRelatchesAsBrick() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Released; + target.Mechanical.Q = target.Static.Mechanical.LatchRelatchTravel; + target.Mechanical.QDot = -0.01f; + target.Mechanical.D = target.Static.Mechanical.LatchEscapeDrop - 0.1f; + var state = new PhysicsState(); + + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, 0f, ref state); + + Assert.That(target.Mechanical.State, Is.EqualTo(DropTargetMechanismState.Latched)); + Assert.That(target.Mechanical.LastImpactOutcome, Is.EqualTo(DropTargetImpactOutcome.BrickRelatch)); + Assert.That(target.Mechanical.D, Is.EqualTo(0f)); + } + + private static DropTargetState CreateTargetState() + { + return new DropTargetState(0, new DropTargetStaticState { + PhysicsMode = DropTargetPhysicsMode.Mechanical, + FaceNormal = new float3(1f, 0f, 0f), + Mechanical = DropTargetMechanicalConfig.Default, + }, default) { + Mechanical = new DropTargetMechanicalState { State = DropTargetMechanismState.Latched } + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs.meta new file mode 100644 index 000000000..80c723c97 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34cef13f5ae74e71957645fba68f007f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index c72d14500..b24a1d702 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -307,9 +307,19 @@ internal float3 GetKinematicSurfaceVelocity(ref NativeColliders colliders, int c { if (!colliders.IsKinematic) { return float3.zero; - } - var itemId = colliders.GetItemId(colliderId); - if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { + } + var itemId = colliders.GetItemId(colliderId); + if (DropTargetStates.TryGetValue(itemId, out var dropTarget) + && dropTarget.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + var mechanicalVelocity = MechanicalDropTargetPhysics.SurfaceVelocity( + in dropTarget.Static, in dropTarget.Mechanical); + if (colliders.IsTransformed(colliderId)) { + return mechanicalVelocity; + } + ref var mechanicalMatrix = ref KinematicTransforms.GetValueByRef(itemId); + return math.inverse(mechanicalMatrix).MultiplyVector(mechanicalVelocity); + } + if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { return float3.zero; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index cd4e96723..c5fe026a4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -97,9 +97,12 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } subSteps++; - // Step kinematic collider poses toward their target transforms, capped - // per tick so fast movers can't skip past a ball. No-op when idle. - PhysicsKinematics.StepKinematics(ref state); + // Step kinematic collider poses toward their target transforms, capped + // per tick so fast movers can't skip past a ball. No-op when idle. + PhysicsKinematics.StepKinematics(ref state); + if (MechanicalDropTargetPhysics.UpdateAll(ref state, PhysicsConstants.PhysFactor)) { + PhysicsKinematics.RebuildOctree(ref kinematicOctree, ref state); + } env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000); var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime)); @@ -202,10 +205,13 @@ private static void UpdateAnimations(ref PhysicsState state, uint animationTimeM } // 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); + using (var enumerator = state.DropTargetStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var dropTargetState = ref enumerator.Current.Value; + if (dropTargetState.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + continue; + } + DropTargetAnimation.Update(enumerator.Current.Key, ref dropTargetState.Animation, in dropTargetState.Static, ref state); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index 06c62bfc3..6486fae7e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -139,6 +139,16 @@ protected override void CreateColliders(ref ColliderReference colliders, float4x MainComponent, ColliderRole.DropTargetBackFace); return; } + if (ColliderComponent.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + var physicalMesh = ColliderComponent.CollisionColliderMesh + ? ColliderComponent.CollisionColliderMesh + : ColliderComponent.FrontColliderMesh; + colliderGenerator.GenerateColliders(ref colliders, physicalMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetPhysicalFace); + colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.HitTarget, + MainComponent, ColliderRole.DropTargetBackFace); + return; + } colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, MainComponent, ColliderRole.DropTargetPhysicalFace); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs index cc7cba751..f2e24f4a2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs @@ -131,6 +131,9 @@ public DropTargetMechanicalConfig ResolvedMechanicalConfig private void OnValidate() { + if (PhysicsMode == DropTargetPhysicsMode.Mechanical) { + _isKinematic = true; + } // Newly added serialized struct fields can be all-zero on existing scene // components. Zero mass/travel is not a valid authored profile, so use it // as the migration sentinel before an author opts into an advanced mode. diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 94b44b155..9b5f77bd4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -167,6 +167,7 @@ internal DropTargetState CreateState() staticData = new DropTargetStaticState { PhysicsMode = colliderComponent.PhysicsMode, Roth = roth, + Mechanical = colliderComponent.ResolvedMechanicalConfig, Center = Position, FaceNormal = new float3(math.cos(angle), math.sin(angle), 0f), HasRothSensor = colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible @@ -198,11 +199,17 @@ internal DropTargetState CreateState() : 0f } : default; - return new DropTargetState( - animationComponent ? UnityObjectId.Get(animationComponent.gameObject) : 0, - staticData, - animationData - ); + return new DropTargetState( + animationComponent ? UnityObjectId.Get(animationComponent.gameObject) : 0, + staticData, + animationData + ) { + Mechanical = new DropTargetMechanicalState { + State = animationData.IsDropped ? DropTargetMechanismState.Down : DropTargetMechanismState.Latched, + D = animationData.IsDropped ? staticData.Mechanical.DropTravel : 0f, + DroppedSwitchClosed = animationData.IsDropped, + } + }; } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs new file mode 100644 index 000000000..e52246f27 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs @@ -0,0 +1,71 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal enum DropTargetMechanismState : byte + { + Latched, + Released, + Dropping, + Down, + Resetting, + Settling, + ForcedDrop, + } + + internal enum DropTargetImpactOutcome : byte + { + None, + FaceDrop, + BrickRelatch, + SideDeflection, + BacksideDrop, + BacksideBounce, + ForcedDrop, + } + + internal struct DropTargetMechanicalState + { + internal DropTargetMechanismState State; + internal DropTargetImpactOutcome LastImpactOutcome; + internal float Q; + internal float QDot; + internal float D; + internal float DDot; + internal bool DroppedSwitchClosed; // reset path must re-arm this after the raised crossing + internal bool HitEventFired; // reset path must re-arm this before returning to Latched + internal bool PoseInitialized; + internal float4x4 BaseTransform; + internal int EventLimitTrips; + } + + internal readonly struct DropTargetImpactResult + { + internal readonly bool Applied; + internal readonly float NormalImpulse; + internal readonly float TangentImpulse; + + internal DropTargetImpactResult(bool applied, float normalImpulse, float tangentImpulse) + { + Applied = applied; + NormalImpulse = normalImpulse; + TangentImpulse = tangentImpulse; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs.meta new file mode 100644 index 000000000..5a8989a5c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c13165eb9bc7476090c08479f159f34b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs index f5a6e324f..82ac46f03 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs @@ -66,6 +66,9 @@ public static void Unpack(byte[] bytes, DropTargetColliderComponent comp) comp.OverrideMechanicalProfile = data.OverrideMechanicalProfile; comp.MechanicalOverrides = data.MechanicalOverrides; comp.RothConfig = data.RothConfig; + if (comp.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + comp._isKinematic = true; + } } internal static void ApplySchemaDefaults(ref DropTargetColliderPackable data) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs index 77d63d56c..01189ad4d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs @@ -82,7 +82,7 @@ public struct DropTargetMechanicalConfig RearStopRestitution = 0.65f, DropMass = 0.2f, DropTravel = 52f, - DropSpringForce = 40f, + DropSpringForce = 0.25f, GuideDamping = 0.2f, GuideFriction = 0.1f, GuideVelocityDeadband = 0.001f, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs index ec3da0f9a..1075733a6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs @@ -21,6 +21,7 @@ internal struct DropTargetState internal readonly int AnimatedItemId; internal DropTargetStaticState Static; internal DropTargetAnimationState Animation; + internal DropTargetMechanicalState Mechanical; internal uint RothHitCounter; public DropTargetState(int animatedItemId, DropTargetStaticState @static, DropTargetAnimationState animation) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs index 4bc467223..eac88c1d3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs @@ -22,6 +22,7 @@ internal struct DropTargetStaticState { public DropTargetPhysicsMode PhysicsMode; public DropTargetRothConfig Roth; + public DropTargetMechanicalConfig Mechanical; public float3 Center; public float3 FaceNormal; public bool HasRothSensor; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs new file mode 100644 index 000000000..710952fb9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs @@ -0,0 +1,314 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game; + +namespace VisualPinball.Unity +{ + internal static class MechanicalDropTargetPhysics + { + private const int StopRootIterations = 8; + + internal static float3 SurfaceVelocity(in DropTargetStaticState staticState, + in DropTargetMechanicalState mechanical) + { + return -staticState.FaceNormal * mechanical.QDot + new float3(0f, 0f, -1f) * mechanical.DDot; + } + + internal static DropTargetImpactResult ResolveImpact(ref BallState ball, + ref DropTargetMechanicalState mechanical, in DropTargetStaticState staticState, + in float3 contactNormal, float restitution, float friction) + { + var config = staticState.Mechanical; + var normal = math.normalizesafe(contactNormal); + var contactOffset = -ball.Radius * normal; + var deflectionAxis = -math.normalizesafe(staticState.FaceNormal); + var downAxis = new float3(0f, 0f, -1f); + var targetVelocity = SurfaceVelocity(in staticState, in mechanical); + var relativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) - targetVelocity; + var normalVelocity = math.dot(relativeVelocity, normal); + if (normalVelocity >= -PhysicsConstants.LowNormVel || ball.Mass <= 0f + || config.EffectiveFaceMass <= 0f) { + return default; + } + + var jq = math.dot(deflectionAxis, normal); + var dIsFree = mechanical.State == DropTargetMechanismState.Released + || mechanical.State == DropTargetMechanismState.Dropping + || mechanical.State == DropTargetMechanismState.ForcedDrop; + var jd = dIsFree ? math.dot(downAxis, normal) : 0f; + var invQMass = 1f / config.EffectiveFaceMass; + var invDMass = dIsFree && config.DropMass > 0f ? 1f / config.DropMass : 0f; + var ballAngularTerm = math.dot(normal, + math.cross(math.cross(contactOffset, normal) / ball.Inertia, contactOffset)); + var normalMass = ball.InvMass + ballAngularTerm + jq * jq * invQMass + jd * jd * invDMass; + if (normalMass <= 0f) { + return default; + } + + var normalImpulse = -(1f + math.clamp(restitution, 0f, 1f)) * normalVelocity / normalMass; + var impulse = normal * normalImpulse; + ball.ApplySurfaceImpulse(math.cross(contactOffset, impulse), impulse); + mechanical.QDot -= jq * normalImpulse * invQMass; + mechanical.DDot -= jd * normalImpulse * invDMass; + + var postRelativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var tangentVelocity = postRelativeVelocity - normal * math.dot(postRelativeVelocity, normal); + var tangentSpeed = math.length(tangentVelocity); + var tangentImpulse = 0f; + if (tangentSpeed > 1e-5f && friction > 0f) { + var tangent = tangentVelocity / tangentSpeed; + var jtq = math.dot(deflectionAxis, tangent); + var jtd = dIsFree ? math.dot(downAxis, tangent) : 0f; + var ballTangentTerm = math.dot(tangent, + math.cross(math.cross(contactOffset, tangent) / ball.Inertia, contactOffset)); + var tangentMass = ball.InvMass + ballTangentTerm + jtq * jtq * invQMass + + jtd * jtd * invDMass; + if (tangentMass > 0f) { + tangentImpulse = math.clamp(-tangentSpeed / tangentMass, + -friction * normalImpulse, friction * normalImpulse); + var frictionImpulse = tangent * tangentImpulse; + ball.ApplySurfaceImpulse(math.cross(contactOffset, frictionImpulse), frictionImpulse); + mechanical.QDot -= jtq * tangentImpulse * invQMass; + mechanical.DDot -= jtd * tangentImpulse * invDMass; + } + } + + return new DropTargetImpactResult(true, normalImpulse, tangentImpulse); + } + + internal static bool UpdateAll(ref PhysicsState state, float dt) + { + var changed = false; + using var enumerator = state.DropTargetStates.GetEnumerator(); + while (enumerator.MoveNext()) { + var itemId = enumerator.Current.Key; + ref var target = ref enumerator.Current.Value; + if (target.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { + continue; + } + ref var mechanical = ref target.Mechanical; + if (!mechanical.PoseInitialized) { + if (!state.KinematicTransforms.TryGetValue(itemId, out mechanical.BaseTransform)) { + continue; + } + mechanical.PoseInitialized = true; + } + if (mechanical.State == DropTargetMechanismState.Down) { + continue; + } + if (mechanical.State == DropTargetMechanismState.Latched + && mechanical.Q == 0f && mechanical.QDot == 0f + && mechanical.D == 0f && mechanical.DDot == 0f) { + continue; + } + + var previousQ = mechanical.Q; + var previousD = mechanical.D; + Step(itemId, ref target, in state.Env.Gravity, dt, ref state); + if (math.abs(mechanical.Q - previousQ) <= 1e-6f + && math.abs(mechanical.D - previousD) <= 1e-6f) { + continue; + } + PublishPose(itemId, ref target, ref state); + changed = true; + } + return changed; + } + + internal static void Step(int itemId, ref DropTargetState target, in float3 gravity, + float dt, ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + var config = target.Static.Mechanical; + if (mechanical.State == DropTargetMechanismState.Down + || mechanical.State == DropTargetMechanismState.Resetting) { + target.Animation.ZOffset = -mechanical.D; + return; + } + + IntegrateRearWithStop(ref mechanical, in config, dt); + if (mechanical.State == DropTargetMechanismState.Latched + && mechanical.Q >= config.LatchReleaseTravel) { + mechanical.State = DropTargetMechanismState.Released; + mechanical.LastImpactOutcome = DropTargetImpactOutcome.FaceDrop; + } + + if (mechanical.State == DropTargetMechanismState.Released + || mechanical.State == DropTargetMechanismState.Dropping + || mechanical.State == DropTargetMechanismState.ForcedDrop) { + IntegrateDrop(ref mechanical, in config, in gravity, dt); + // Escape wins a same-tick relatch boundary. + if (mechanical.D >= config.LatchEscapeDrop) { + mechanical.State = DropTargetMechanismState.Dropping; + } else if (mechanical.Q <= config.LatchRelatchTravel && mechanical.QDot < 0f) { + mechanical.State = DropTargetMechanismState.Latched; + mechanical.LastImpactOutcome = DropTargetImpactOutcome.BrickRelatch; + mechanical.D = 0f; + mechanical.DDot = 0f; + mechanical.HitEventFired = false; + } + } + + if (!mechanical.DroppedSwitchClosed && mechanical.D >= config.DroppedSwitchTravel) { + mechanical.DroppedSwitchClosed = true; + if (target.Static.UseHitEvent) { + state.EventQueue.Enqueue(new EventData(EventId.TargetEventsDropped, itemId)); + } + } + if (mechanical.D >= config.DropTravel) { + mechanical.D = config.DropTravel; + mechanical.DDot = 0f; + mechanical.State = DropTargetMechanismState.Down; + target.Animation.IsDropped = true; + state.DisableColliders(itemId); + } + if (mechanical.State == DropTargetMechanismState.Latched + && math.abs(mechanical.Q) < 1e-4f && math.abs(mechanical.QDot) < 1e-4f) { + mechanical.Q = 0f; + mechanical.QDot = 0f; + mechanical.HitEventFired = false; + } + target.Animation.ZOffset = -mechanical.D; + } + + internal static void IntegrateRearWithStop(ref DropTargetMechanicalState state, + in DropTargetMechanicalConfig config, float dt) + { + var startQ = state.Q; + var startQDot = state.QDot; + IntegrateDampedOscillator(ref state.Q, ref state.QDot, config.RearSpringFrequencyHz, + config.RearDampingRatio, dt); + if (state.Q <= config.RearStopTravel || config.RearStopTravel <= 0f) { + return; + } + + var low = 0f; + var high = dt; + for (var i = 0; i < StopRootIterations; i++) { + var mid = (low + high) * 0.5f; + var q = startQ; + var qDot = startQDot; + IntegrateDampedOscillator(ref q, ref qDot, config.RearSpringFrequencyHz, + config.RearDampingRatio, mid); + if (q < config.RearStopTravel) { + low = mid; + } else { + high = mid; + } + } + state.Q = startQ; + state.QDot = startQDot; + IntegrateDampedOscillator(ref state.Q, ref state.QDot, config.RearSpringFrequencyHz, + config.RearDampingRatio, high); + state.Q = config.RearStopTravel; + if (state.QDot > 0f) { + state.QDot = -state.QDot * math.clamp(config.RearStopRestitution, 0f, 1f); + } + IntegrateDampedOscillator(ref state.Q, ref state.QDot, config.RearSpringFrequencyHz, + config.RearDampingRatio, dt - high); + } + + internal static void IntegrateDampedOscillator(ref float q, ref float qDot, + float frequencyHz, float dampingRatio, float dt) + { + var omega = 2f * math.PI * math.max(frequencyHz, 0f) * PhysicsConstants.DefaultStepTimeS; + if (omega <= 0f || dt <= 0f) { + q += qDot * dt; + return; + } + var zeta = math.max(dampingRatio, 0f); + if (zeta < 1f - 1e-4f) { + var wd = omega * math.sqrt(1f - zeta * zeta); + var a = q; + var b = (qDot + zeta * omega * q) / wd; + var sin = math.sin(wd * dt); + var cos = math.cos(wd * dt); + var decay = math.exp(-zeta * omega * dt); + var wave = a * cos + b * sin; + q = decay * wave; + qDot = decay * (-zeta * omega * wave - a * wd * sin + b * wd * cos); + } else if (zeta <= 1f + 1e-4f) { + var a = q; + var b = qDot + omega * q; + var decay = math.exp(-omega * dt); + q = decay * (a + b * dt); + qDot = decay * (b - omega * (a + b * dt)); + } else { + var root = math.sqrt(zeta * zeta - 1f); + var r1 = -omega * (zeta - root); + var r2 = -omega * (zeta + root); + var c1 = (qDot - r2 * q) / (r1 - r2); + var c2 = q - c1; + var e1 = math.exp(r1 * dt); + var e2 = math.exp(r2 * dt); + q = c1 * e1 + c2 * e2; + qDot = c1 * r1 * e1 + c2 * r2 * e2; + } + } + + private static void IntegrateDrop(ref DropTargetMechanicalState state, + in DropTargetMechanicalConfig config, in float3 gravity, float dt) + { + if (config.DropMass <= 0f) { + return; + } + var downAxis = new float3(0f, 0f, -1f); + var drive = config.DropSpringForce - config.GuideDamping * state.DDot; + if (math.abs(state.DDot) <= config.GuideVelocityDeadband) { + if (math.abs(drive) <= config.GuideFriction) { + drive = 0f; + state.DDot = 0f; + } else { + drive -= math.sign(drive) * config.GuideFriction; + } + } else { + drive -= math.sign(state.DDot) * config.GuideFriction; + } + var acceleration = math.dot(gravity, downAxis) + drive / config.DropMass; + state.DDot += acceleration * dt; + state.D = math.max(0f, state.D + state.DDot * dt); + } + + private static void PublishPose(int itemId, ref DropTargetState target, ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + var linearVelocity = SurfaceVelocity(in target.Static, in mechanical); + var pose = mechanical.BaseTransform; + pose.c3.xyz += -target.Static.FaceNormal * mechanical.Q + + new float3(0f, 0f, -1f) * mechanical.D; + state.KinematicTransforms[itemId] = pose; + var velocityState = new KinematicVelocityState { + LinearVelocity = linearVelocity, + StepVelocity = linearVelocity, + Pivot = pose.c3.xyz, + LastUpdateUsec = state.Env.CurPhysicsFrameTime, + }; + if (!state.KinematicVelocities.TryAdd(itemId, velocityState)) { + state.KinematicVelocities[itemId] = velocityState; + } + if (!state.KinematicColliderLookups.TryGetValue(itemId, out var colliderLookups)) { + return; + } + for (var i = 0; i < colliderLookups.Length; i++) { + state.TransformKinematicColliders(colliderLookups[i], pose); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs.meta new file mode 100644 index 000000000..71f6b64d9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 17aee166face4be491a33a6d7ce029e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs index 861222489..bfa2c595f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs @@ -27,6 +27,10 @@ public static void DropTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, ref DropTargetState target, in float3 normal, + in CollisionEventData collEvent, in ColliderHeader collHeader, ref PhysicsState state) + { + if (target.Mechanical.State == DropTargetMechanismState.Down) { + return; + } + var preImpactVelocity = ball.Velocity; + var hitNormal = math.normalizesafe(collEvent.HitNormal); + var faceAlignment = math.abs(math.dot(hitNormal, math.normalizesafe(target.Static.FaceNormal))); + if (collHeader.Role != ColliderRole.DropTargetPhysicalFace || faceAlignment < 0.5f) { + BallCollider.Collide3DWall(ref ball, in collHeader.Material, in collEvent, in normal, ref state); + if (collHeader.Role == ColliderRole.DropTargetBackFace) { + var impulse = ball.Mass * math.max(-math.dot(preImpactVelocity, hitNormal), 0f); + if (target.Static.Mechanical.EnableBacksideRelease + && impulse >= target.Static.Mechanical.BacksideReleaseImpulse) { + target.Mechanical.State = DropTargetMechanismState.Released; + target.Mechanical.LastImpactOutcome = DropTargetImpactOutcome.BacksideDrop; + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } else { + target.Mechanical.LastImpactOutcome = DropTargetImpactOutcome.BacksideBounce; + } + } else { + target.Mechanical.LastImpactOutcome = DropTargetImpactOutcome.SideDeflection; + } + return; + } + + var approachSpeed = -math.dot(preImpactVelocity + - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), hitNormal); + var restitution = ResolveElasticity(in collHeader.Material, in collEvent, approachSpeed, ref state); + var friction = ResolveFriction(in collHeader.Material, in collEvent, approachSpeed, ref state); + var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, in hitNormal, restitution, friction); + if (!result.Applied) { + return; + } + + var hDist = math.clamp(-PhysicsConstants.DispGain * collEvent.HitDistance, + 0f, PhysicsConstants.DispLimit); + ball.Position += hitNormal * hDist; + if (!target.Mechanical.HitEventFired + && approachSpeed >= collHeader.Threshold + && result.NormalImpulse >= target.Static.Mechanical.MinimumFaceImpulse) { + target.Mechanical.HitEventFired = true; + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } + } + + private static float ResolveElasticity(in PhysicsMaterialData material, + in CollisionEventData collEvent, float speed, ref PhysicsState state) + { + if (!material.UseElasticityOverVelocity) { + return Math.ElasticityWithFalloff(material.Elasticity, material.ElasticityFalloff, speed); + } + // Mechanical contacts deliberately sample loss by approach-speed + // magnitude; the legacy wall path's signed lookup clamps to its first bin. + var colliders = collEvent.IsKinematic ? state.KinematicColliders : state.Colliders; + var itemId = colliders.GetItemId(collEvent.ColliderId); + return state.ElasticityOverVelocityLUTs[itemId].InterpolateLUT(0f, 63f, math.abs(speed)); + } + + private static float ResolveFriction(in PhysicsMaterialData material, + in CollisionEventData collEvent, float speed, ref PhysicsState state) + { + if (!material.UseFrictionOverVelocity) { + return material.Friction; + } + // As above, velocity-dependent Mechanical friction is indexed by speed. + var colliders = collEvent.IsKinematic ? state.KinematicColliders : state.Colliders; + var itemId = colliders.GetItemId(collEvent.ColliderId); + return state.FrictionOverVelocityLUTs[itemId].InterpolateLUT(0f, 127f, math.abs(speed)); + } + private static void LegacyDropTargetCollide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, ref DropTargetAnimationState animation, in float3 normal, in CollisionEventData collEvent, in ColliderHeader collHeader, ref PhysicsState state) From e1bfe5985e5bdc0d1283552ae1319a66587ce821 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 19:29:34 +0200 Subject: [PATCH 5/8] physics: simulate drop target resets --- .../MechanicalDropTargetPhysicsTests.cs | 124 +++++++ .../VisualPinball.Unity/Game/PhysicsCycle.cs | 198 +++++++++++ .../Physics/Collision/ContactBufferElement.cs | 4 +- .../Physics/Collision/ContactPhysics.cs | 16 +- .../VPT/HitTarget/DropTargetApi.cs | 16 +- .../VPT/HitTarget/DropTargetComponent.cs | 7 +- .../HitTarget/DropTargetMechanicalState.cs | 22 +- .../VPT/HitTarget/DropTargetState.cs | 2 + .../HitTarget/MechanicalDropTargetPhysics.cs | 319 +++++++++++++++++- .../VPT/HitTarget/TargetCollider.cs | 49 ++- 10 files changed, 742 insertions(+), 15 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs index 60dc87d8c..7c6ef22e2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs @@ -15,6 +15,7 @@ // along with this program. If not, see . using NUnit.Framework; +using Unity.Collections; using Unity.Mathematics; using VisualPinball.Engine.Common; @@ -160,6 +161,129 @@ public void ReturnBeforeEscapeRelatchesAsBrick() Assert.That(target.Mechanical.D, Is.EqualTo(0f)); } + [Test] + public void ResetStrokePublishesUpwardSurfaceVelocity() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Resetting; + target.Mechanical.D = target.Static.Mechanical.DropTravel; + target.Mechanical.ResetStartD = target.Mechanical.D; + target.Mechanical.DroppedSwitchClosed = true; + var state = new PhysicsState(); + + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, + PhysicsConstants.PhysFactor, ref state); + + Assert.That(target.Mechanical.D, Is.LessThan(target.Static.Mechanical.DropTravel)); + Assert.That(target.Mechanical.DDot, Is.LessThan(0f)); + Assert.That(MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, + in target.Mechanical).z, Is.GreaterThan(0f)); + } + + [Test] + public void ResetContactUsesFiniteResetMass() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Resetting; + target.Mechanical.DDot = -10f; + var ball = new BallState { + Mass = 1f, + Radius = 25f, + }; + + var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, new float3(0f, 0f, 1f), 0f, 0f); + + Assert.That(result.Applied, Is.True); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(Tolerance)); + Assert.That(target.Mechanical.DDot, Is.EqualTo(-5f).Within(Tolerance)); + } + + [Test] + public void SustainedResetContactSharesGravitySupportWithResetMass() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Resetting; + var ball = new BallState { + Mass = 1f, + Radius = 25f, + }; + + var result = MechanicalDropTargetPhysics.ResolveContact(ref ball, + ref target.Mechanical, in target.Static, new float3(0f, 0f, 1f), + new float3(0f, 0f, -1f), PhysicsConstants.PhysFactor, 0f); + + Assert.That(result.Applied, Is.True); + Assert.That(ball.Velocity.z, Is.EqualTo(0.05f).Within(Tolerance)); + Assert.That(target.Mechanical.DDot, Is.EqualTo(0.05f).Within(Tolerance)); + } + + [Test] + public void CompletedResetRelatchesAndRearmsEvents() + { + var target = CreateTargetState(); + target.Mechanical.State = DropTargetMechanismState.Resetting; + target.Mechanical.D = target.Static.Mechanical.DropTravel; + target.Mechanical.ResetStartD = target.Mechanical.D; + target.Mechanical.DroppedSwitchClosed = true; + target.Mechanical.HitEventFired = true; + var state = new PhysicsState(); + + for (var i = 0; i < 60; i++) { + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, + PhysicsConstants.PhysFactor, ref state); + } + + Assert.That(target.Mechanical.State, Is.EqualTo(DropTargetMechanismState.Latched)); + Assert.That(target.Mechanical.Q, Is.EqualTo(0f)); + Assert.That(target.Mechanical.QDot, Is.EqualTo(0f)); + Assert.That(target.Mechanical.D, Is.EqualTo(0f)); + Assert.That(target.Mechanical.DDot, Is.EqualTo(0f)); + Assert.That(target.Mechanical.DroppedSwitchClosed, Is.False); + Assert.That(target.Mechanical.HitEventFired, Is.False); + } + + [Test] + public void SimultaneousTwoBallGroupSharesTargetMomentumSymmetrically() + { + var target = CreateTargetState(); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Velocity = new float3(-30f, 0f, 0f), + }; + var contacts = new NativeList(Allocator.Temp); + try { + contacts.Add(new MechanicalDropTargetContact { + BallId = 1, + Ball = ball, + Normal = new float3(1f, 0f, 0f), + Restitution = 0.35f, + }); + contacts.Add(new MechanicalDropTargetContact { + BallId = 2, + Ball = ball, + Normal = new float3(1f, 0f, 0f), + Restitution = 0.35f, + }); + + MechanicalDropTargetPhysics.ResolveImpactGroup(ref contacts, + ref target.Mechanical, in target.Static); + + Assert.That(contacts[0].Applied, Is.EqualTo(1)); + Assert.That(contacts[1].Applied, Is.EqualTo(1)); + Assert.That(contacts[0].NormalImpulse, Is.GreaterThan(0f)); + Assert.That(contacts[1].NormalImpulse, Is.GreaterThan(0f)); + Assert.That(contacts[0].NormalImpulse, + Is.EqualTo(contacts[1].NormalImpulse).Within(0.01f)); + Assert.That(contacts[0].Ball.Velocity.x, + Is.EqualTo(contacts[1].Ball.Velocity.x).Within(0.01f)); + Assert.That(math.isfinite(target.Mechanical.QDot), Is.True); + } finally { + contacts.Dispose(); + } + } + private static DropTargetState CreateTargetState() { return new DropTargetState(0, new DropTargetStaticState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index f5f3c71f7..4c4d14e34 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -17,6 +17,7 @@ using System; using NativeTrees; using Unity.Collections; +using Unity.Mathematics; using Unity.Profiling; using VisualPinball.Engine.Common; using VisualPinball.Unity.Collections; @@ -26,6 +27,8 @@ namespace VisualPinball.Unity public struct PhysicsCycle : IDisposable { private NativeList _contacts; + private NativeList _mechanicalDropTargetContacts; + private NativeList _mechanicalImpactContacts; private static readonly ProfilerMarker PerfMarker = new("PhysicsCycle"); private static readonly ProfilerMarker PerfMarkerDisplacement = new("Displacement"); @@ -35,6 +38,8 @@ public struct PhysicsCycle : IDisposable public PhysicsCycle(Allocator a) { _contacts = new NativeList(a); + _mechanicalDropTargetContacts = new NativeList(a); + _mechanicalImpactContacts = new NativeList(a); } internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, float dTime) @@ -140,6 +145,13 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov // dynamic collision (ball/ball) PhysicsDynamicCollision.Collide(hitTime, ref ball, ref state); + } + } + + ResolveMechanicalDropTargetContacts(hitTime, ref state); + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; // static & kinematic collision PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); @@ -149,8 +161,12 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov // handle contacts PerfMarkerContacts.Begin(); + ResolveMechanicalDropTargetSupportContacts(hitTime, ref state); for (var i = 0; i < _contacts.Length; i++) { ref var contact = ref _contacts.GetElementAsRef(i); + if (contact.Handled != 0) { + continue; + } ref var ball = ref state.Balls.GetValueByRef(contact.BallId); if (contact.CollEvent.IsKinematic) { ContactPhysics.Update(ref contact, ref ball, ref state, ref state.KinematicColliders, hitTime); @@ -212,6 +228,188 @@ private void ApplyFlipperTime(ref float hitTime, ref PhysicsState state) public void Dispose() { _contacts.Dispose(); + _mechanicalDropTargetContacts.Dispose(); + _mechanicalImpactContacts.Dispose(); + } + + private void ResolveMechanicalDropTargetContacts(float hitTime, ref PhysicsState state) + { + _mechanicalDropTargetContacts.Clear(); + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + var collEvent = ball.CollisionEvent; + if (collEvent.ColliderId < 0 || collEvent.HitTime > hitTime) { + continue; + } + + var header = collEvent.IsKinematic + ? state.KinematicColliders.GetHeader(collEvent.ColliderId) + : state.Colliders.GetHeader(collEvent.ColliderId); + if (!state.DropTargetStates.TryGetValue(header.ItemId, out var target) + || target.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { + continue; + } + + _mechanicalDropTargetContacts.Add(new MechanicalDropTargetContactCandidate( + collEvent.HitTime, header.ItemId, enumerator.Current.Key, collEvent.ColliderId, + header.Role, collEvent.IsKinematic)); + } + } + + _mechanicalDropTargetContacts.AsArray().Sort(); + for (var groupStart = 0; groupStart < _mechanicalDropTargetContacts.Length;) { + var hitTimeKey = _mechanicalDropTargetContacts[groupStart].HitTimeKey; + var targetItemId = _mechanicalDropTargetContacts[groupStart].TargetItemId; + var groupEnd = groupStart + 1; + while (groupEnd < _mechanicalDropTargetContacts.Length + && _mechanicalDropTargetContacts[groupEnd].HitTimeKey == hitTimeKey + && _mechanicalDropTargetContacts[groupEnd].TargetItemId == targetItemId) { + groupEnd++; + } + + ref var target = ref state.DropTargetStates.GetValueByRef(targetItemId); + var isResetStroke = target.Mechanical.State == DropTargetMechanismState.Resetting + || target.Mechanical.State == DropTargetMechanismState.Settling; + _mechanicalImpactContacts.Clear(); + for (var i = groupStart; i < groupEnd; i++) { + var candidate = _mechanicalDropTargetContacts[i]; + ref var ball = ref state.Balls.GetValueByRef(candidate.BallId); + var collEvent = ball.CollisionEvent; + var header = collEvent.IsKinematic + ? state.KinematicColliders.GetHeader(collEvent.ColliderId) + : state.Colliders.GetHeader(collEvent.ColliderId); + var hitNormal = math.normalizesafe(collEvent.HitNormal); + var faceAlignment = math.abs(math.dot(hitNormal, + math.normalizesafe(target.Static.FaceNormal))); + if (header.Role != ColliderRole.DropTargetPhysicalFace + || target.Mechanical.State == DropTargetMechanismState.Down + || (!isResetStroke && faceAlignment < 0.5f)) { + continue; + } + + var approachSpeed = -math.dot(ball.Velocity + - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), + hitNormal); + _mechanicalImpactContacts.Add(new MechanicalDropTargetContact { + BallId = candidate.BallId, + Ball = ball, + Normal = hitNormal, + Restitution = TargetCollider.ResolveElasticity(in header.Material, in collEvent, + approachSpeed, ref state), + Friction = TargetCollider.ResolveFriction(in header.Material, in collEvent, + approachSpeed, ref state), + }); + } + + MechanicalDropTargetPhysics.ResolveImpactGroup(ref _mechanicalImpactContacts, + ref target.Mechanical, in target.Static); + for (var i = 0; i < _mechanicalImpactContacts.Length; i++) { + ref var contact = ref _mechanicalImpactContacts.GetElementAsRef(i); + ref var ball = ref state.Balls.GetValueByRef(contact.BallId); + var collEvent = ball.CollisionEvent; + var header = collEvent.IsKinematic + ? state.KinematicColliders.GetHeader(collEvent.ColliderId) + : state.Colliders.GetHeader(collEvent.ColliderId); + ball = contact.Ball; + TargetCollider.CompleteMechanicalImpact(ref ball, ref target, in collEvent, in header, + contact.ApproachSpeed, contact.NormalImpulse, ref state.EventQueue); + ball.CollisionEvent.ClearCollider(); + } + + for (var i = groupStart; i < groupEnd; i++) { + var candidate = _mechanicalDropTargetContacts[i]; + ref var ball = ref state.Balls.GetValueByRef(candidate.BallId); + if (ball.CollisionEvent.ColliderId != candidate.ColliderId + || ball.CollisionEvent.IsKinematic != (candidate.IsKinematic != 0)) { + continue; + } + PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); + ball.CollisionEvent.ClearCollider(); + } + + groupStart = groupEnd; + } + } + + private void ResolveMechanicalDropTargetSupportContacts(float hitTime, ref PhysicsState state) + { + _mechanicalDropTargetContacts.Clear(); + for (var i = 0; i < _contacts.Length; i++) { + ref var contact = ref _contacts.GetElementAsRef(i); + var collEvent = contact.CollEvent; + if (collEvent.ColliderId < 0) { + continue; + } + var header = collEvent.IsKinematic + ? state.KinematicColliders.GetHeader(collEvent.ColliderId) + : state.Colliders.GetHeader(collEvent.ColliderId); + if (!state.DropTargetStates.TryGetValue(header.ItemId, out var target) + || target.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical + || (target.Mechanical.State != DropTargetMechanismState.Resetting + && target.Mechanical.State != DropTargetMechanismState.Settling)) { + continue; + } + _mechanicalDropTargetContacts.Add(new MechanicalDropTargetContactCandidate( + collEvent.HitTime, header.ItemId, contact.BallId, collEvent.ColliderId, + header.Role, collEvent.IsKinematic, i)); + } + + _mechanicalDropTargetContacts.AsArray().Sort(); + for (var i = 0; i < _mechanicalDropTargetContacts.Length; i++) { + var candidate = _mechanicalDropTargetContacts[i]; + ref var contact = ref _contacts.GetElementAsRef(candidate.ContactIndex); + ref var ball = ref state.Balls.GetValueByRef(contact.BallId); + if (contact.CollEvent.IsKinematic) { + ContactPhysics.Update(ref contact, ref ball, ref state, ref state.KinematicColliders, hitTime); + } else { + ContactPhysics.Update(ref contact, ref ball, ref state, ref state.Colliders, hitTime); + } + contact.Handled = 1; + } + } + + private readonly struct MechanicalDropTargetContactCandidate : IComparable + { + private const float HitTimeQuantization = 1000000f; + + internal readonly int HitTimeKey; + internal readonly int TargetItemId; + internal readonly int BallId; + internal readonly int ColliderId; + internal readonly ColliderRole Role; + internal readonly byte IsKinematic; + internal readonly int ContactIndex; + + internal MechanicalDropTargetContactCandidate(float hitTime, int targetItemId, int ballId, + int colliderId, ColliderRole role, bool isKinematic, int contactIndex = -1) + { + HitTimeKey = (int)math.round(hitTime * HitTimeQuantization); + TargetItemId = targetItemId; + BallId = ballId; + ColliderId = colliderId; + Role = role; + IsKinematic = isKinematic ? (byte)1 : (byte)0; + ContactIndex = contactIndex; + } + + public int CompareTo(MechanicalDropTargetContactCandidate other) + { + var result = HitTimeKey.CompareTo(other.HitTimeKey); + if (result != 0) { + return result; + } + result = TargetItemId.CompareTo(other.TargetItemId); + if (result != 0) { + return result; + } + result = BallId.CompareTo(other.BallId); + if (result != 0) { + return result; + } + result = ((byte)Role).CompareTo((byte)other.Role); + return result != 0 ? result : ColliderId.CompareTo(other.ColliderId); + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs index dafe9c893..a7f1573dc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs @@ -1,4 +1,4 @@ -// Visual Pinball Engine +// Visual Pinball Engine // Copyright (C) 2023 freezy and VPE Team // // This program is free software: you can redistribute it and/or modify @@ -20,11 +20,13 @@ internal struct ContactBufferElement { public CollisionEventData CollEvent; public int BallId; + public byte Handled; public ContactBufferElement(int ballId, CollisionEventData collEvent) { BallId = ballId; CollEvent = collEvent; + Handled = 0; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index 69d3c5437..e233ea929 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -41,7 +41,21 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball ref var flipperCollider = ref colliders.Flipper(collEvent.ColliderId); ref var flipperState = ref state.GetFlipperState(collEvent.ColliderId, ref colliders); flipperCollider.Contact(ref ball, ref flipperState.Movement, in collEvent, in flipperState.Static, in flipperState.Velocity, hitTime, in gravity); - } else { + } else if (state.HasDropTargetState(collEvent.ColliderId, ref colliders)) { + ref var target = ref state.GetDropTargetState(collEvent.ColliderId, ref colliders); + if (target.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical + && (target.Mechanical.State == DropTargetMechanismState.Resetting + || target.Mechanical.State == DropTargetMechanismState.Settling)) { + MechanicalDropTargetPhysics.ResolveContact(ref ball, ref target.Mechanical, + in target.Static, in collEvent.HitNormal, in gravity, hitTime, + collHeader.Material.Friction); + } else { + var colliderVelocity = state.GetKinematicSurfaceVelocity(in collEvent, + ball.Position - ball.Radius * collEvent.HitNormal); + Collider.Contact(in collHeader, ref ball, in collEvent, hitTime, in gravity, + in colliderVelocity); + } + } else { // surface velocity of the collider at the contact point (zero unless kinematic and moving) var colliderVelocity = state.GetKinematicSurfaceVelocity(in collEvent, ball.Position - ball.Radius * collEvent.HitNormal); Collider.Contact(in collHeader, ref ball, in collEvent, hitTime, in gravity, in colliderVelocity); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index 6486fae7e..a40577e4a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -87,6 +87,14 @@ private void SetIsDropped(bool isDropped) PhysicsEngine.MutateState((ref PhysicsState state) => { ref var dropTargetState = ref state.DropTargetStates.GetValueByRef(ItemId); + if (dropTargetState.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + if (isDropped) { + MechanicalDropTargetPhysics.ForceDrop(ItemId, ref dropTargetState, ref state); + } else { + MechanicalDropTargetPhysics.BeginReset(ItemId, ref dropTargetState, ref state); + } + return; + } if (dropTargetState.Animation.IsDropped != isDropped) { dropTargetState.Animation.MoveAnimation = true; if (isDropped) { @@ -103,7 +111,13 @@ private void SetIsDropped(bool isDropped) private bool IsCurrentlyDropped() { - return PhysicsEngine.DropTargetState(ItemId).Animation.IsDropped; + ref var state = ref PhysicsEngine.DropTargetState(ItemId); + if (state.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { + return state.Animation.IsDropped; + } + return state.Mechanical.State == DropTargetMechanismState.Down + || state.Mechanical.State == DropTargetMechanismState.Resetting + || state.Mechanical.State == DropTargetMechanismState.Settling; } #region Wiring diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 9b5f77bd4..6da681eb6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -17,9 +17,10 @@ // ReSharper disable InconsistentNaming using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; +using UnityEngine; using VisualPinball.Engine.VPT.HitTarget; using VisualPinball.Engine.VPT.Table; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs index e52246f27..36ce50c07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs @@ -48,8 +48,11 @@ internal struct DropTargetMechanicalState internal float QDot; internal float D; internal float DDot; - internal bool DroppedSwitchClosed; // reset path must re-arm this after the raised crossing - internal bool HitEventFired; // reset path must re-arm this before returning to Latched + internal float ResetStartD; + internal float ResetElapsedMs; + internal float SettleElapsedMs; + internal bool DroppedSwitchClosed; + internal bool HitEventFired; internal bool PoseInitialized; internal float4x4 BaseTransform; internal int EventLimitTrips; @@ -68,4 +71,19 @@ internal DropTargetImpactResult(bool applied, float normalImpulse, float tangent TangentImpulse = tangentImpulse; } } + + internal struct MechanicalDropTargetContact + { + internal int BallId; + internal BallState Ball; + internal float3 Normal; + internal float3 Tangent; + internal float Restitution; + internal float Friction; + internal float ApproachSpeed; + internal float NormalImpulse; + internal float TangentImpulse; + internal byte Applied; + internal byte HasTangent; + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs index 1075733a6..90c9b1324 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs @@ -29,6 +29,8 @@ public DropTargetState(int animatedItemId, DropTargetStaticState @static, DropTa AnimatedItemId = animatedItemId; Static = @static; Animation = animation; + Mechanical = default; + RothHitCounter = 0; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs index 710952fb9..2ded273df 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs @@ -14,15 +14,19 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using Unity.Collections; using Unity.Mathematics; using VisualPinball.Engine.Common; using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { internal static class MechanicalDropTargetPhysics { private const int StopRootIterations = 8; + private const int ContactSolverIterations = 24; + private const float MillisecondsPerInternalTime = PhysicsConstants.DefaultStepTimeS * 1000f; internal static float3 SurfaceVelocity(in DropTargetStaticState staticState, in DropTargetMechanicalState mechanical) @@ -48,12 +52,16 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, } var jq = math.dot(deflectionAxis, normal); + var isResetStroke = mechanical.State == DropTargetMechanismState.Resetting + || mechanical.State == DropTargetMechanismState.Settling; var dIsFree = mechanical.State == DropTargetMechanismState.Released || mechanical.State == DropTargetMechanismState.Dropping - || mechanical.State == DropTargetMechanismState.ForcedDrop; + || mechanical.State == DropTargetMechanismState.ForcedDrop + || isResetStroke; var jd = dIsFree ? math.dot(downAxis, normal) : 0f; var invQMass = 1f / config.EffectiveFaceMass; - var invDMass = dIsFree && config.DropMass > 0f ? 1f / config.DropMass : 0f; + var dMass = isResetStroke ? config.ResetEffectiveMass : config.DropMass; + var invDMass = dIsFree && dMass > 0f ? 1f / dMass : 0f; var ballAngularTerm = math.dot(normal, math.cross(math.cross(contactOffset, normal) / ball.Inertia, contactOffset)); var normalMass = ball.InvMass + ballAngularTerm + jq * jq * invQMass + jd * jd * invDMass; @@ -93,6 +101,188 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, return new DropTargetImpactResult(true, normalImpulse, tangentImpulse); } + internal static void ResolveImpactGroup(ref NativeList contacts, + ref DropTargetMechanicalState mechanical, in DropTargetStaticState staticState) + { + if (contacts.Length == 0) { + return; + } + + var config = staticState.Mechanical; + if (config.EffectiveFaceMass <= 0f) { + return; + } + var deflectionAxis = -math.normalizesafe(staticState.FaceNormal); + var downAxis = new float3(0f, 0f, -1f); + var isResetStroke = mechanical.State == DropTargetMechanismState.Resetting + || mechanical.State == DropTargetMechanismState.Settling; + var dIsFree = mechanical.State == DropTargetMechanismState.Released + || mechanical.State == DropTargetMechanismState.Dropping + || mechanical.State == DropTargetMechanismState.ForcedDrop + || isResetStroke; + var dMass = isResetStroke ? config.ResetEffectiveMass : config.DropMass; + var invQMass = 1f / config.EffectiveFaceMass; + var invDMass = dIsFree && dMass > 0f ? 1f / dMass : 0f; + + for (var i = 0; i < contacts.Length; i++) { + ref var contact = ref contacts.GetElementAsRef(i); + contact.Normal = math.normalizesafe(contact.Normal); + var contactOffset = -contact.Ball.Radius * contact.Normal; + var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var normalVelocity = math.dot(relativeVelocity, contact.Normal); + var tangentVelocity = relativeVelocity - contact.Normal * normalVelocity; + var tangentSpeed = math.length(tangentVelocity); + contact.ApproachSpeed = -normalVelocity; + contact.Applied = normalVelocity < -PhysicsConstants.LowNormVel + && contact.Ball.Mass > 0f ? (byte)1 : (byte)0; + contact.HasTangent = tangentSpeed > 1e-5f ? (byte)1 : (byte)0; + contact.Tangent = contact.HasTangent != 0 ? tangentVelocity / tangentSpeed : float3.zero; + contact.NormalImpulse = 0f; + contact.TangentImpulse = 0f; + } + + for (var iteration = 0; iteration < ContactSolverIterations; iteration++) { + for (var i = 0; i < contacts.Length; i++) { + ref var contact = ref contacts.GetElementAsRef(i); + if (contact.Applied == 0) { + continue; + } + var normal = contact.Normal; + var contactOffset = -contact.Ball.Radius * normal; + var jq = math.dot(deflectionAxis, normal); + var jd = dIsFree ? math.dot(downAxis, normal) : 0f; + var ballAngularTerm = math.dot(normal, + math.cross(math.cross(contactOffset, normal) / contact.Ball.Inertia, contactOffset)); + var effectiveInvMass = contact.Ball.InvMass + ballAngularTerm + + jq * jq * invQMass + jd * jd * invDMass; + if (effectiveInvMass <= 0f) { + continue; + } + + var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var desiredVelocity = math.clamp(contact.Restitution, 0f, 1f) * contact.ApproachSpeed; + var impulseDelta = (desiredVelocity - math.dot(relativeVelocity, normal)) + / effectiveInvMass; + var accumulatedImpulse = math.max(contact.NormalImpulse + impulseDelta, 0f); + impulseDelta = accumulatedImpulse - contact.NormalImpulse; + contact.NormalImpulse = accumulatedImpulse; + ApplyGroupImpulse(ref contact.Ball, ref mechanical, in deflectionAxis, in downAxis, + in normal, in contactOffset, impulseDelta, dIsFree, invQMass, invDMass); + } + } + + for (var iteration = 0; iteration < ContactSolverIterations; iteration++) { + for (var i = 0; i < contacts.Length; i++) { + ref var contact = ref contacts.GetElementAsRef(i); + if (contact.Applied == 0 || contact.HasTangent == 0 + || contact.Friction <= 0f || contact.NormalImpulse <= 0f) { + continue; + } + var normal = contact.Normal; + var contactOffset = -contact.Ball.Radius * normal; + var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var tangent = contact.Tangent; + var jq = math.dot(deflectionAxis, tangent); + var jd = dIsFree ? math.dot(downAxis, tangent) : 0f; + var ballAngularTerm = math.dot(tangent, + math.cross(math.cross(contactOffset, tangent) / contact.Ball.Inertia, contactOffset)); + var effectiveInvMass = contact.Ball.InvMass + ballAngularTerm + + jq * jq * invQMass + jd * jd * invDMass; + if (effectiveInvMass <= 0f) { + continue; + } + + var impulseDelta = -math.dot(relativeVelocity, tangent) / effectiveInvMass; + var limit = contact.Friction * contact.NormalImpulse; + var accumulatedImpulse = math.clamp(contact.TangentImpulse + impulseDelta, -limit, limit); + impulseDelta = accumulatedImpulse - contact.TangentImpulse; + contact.TangentImpulse = accumulatedImpulse; + ApplyGroupImpulse(ref contact.Ball, ref mechanical, in deflectionAxis, in downAxis, + in tangent, in contactOffset, impulseDelta, dIsFree, invQMass, invDMass); + } + } + } + + internal static DropTargetImpactResult ResolveContact(ref BallState ball, + ref DropTargetMechanicalState mechanical, in DropTargetStaticState staticState, + in float3 contactNormal, in float3 gravity, float dt, float friction) + { + var config = staticState.Mechanical; + var normal = math.normalizesafe(contactNormal); + var contactOffset = -ball.Radius * normal; + var deflectionAxis = -math.normalizesafe(staticState.FaceNormal); + var downAxis = new float3(0f, 0f, -1f); + var dMass = config.ResetEffectiveMass; + if (ball.Mass <= 0f || config.EffectiveFaceMass <= 0f || dMass <= 0f) { + return default; + } + + var jq = math.dot(deflectionAxis, normal); + var jd = math.dot(downAxis, normal); + var invQMass = 1f / config.EffectiveFaceMass; + var invDMass = 1f / dMass; + var ballAngularTerm = math.dot(normal, + math.cross(math.cross(contactOffset, normal) / ball.Inertia, contactOffset)); + var effectiveInvMass = ball.InvMass + ballAngularTerm + + jq * jq * invQMass + jd * jd * invDMass; + if (effectiveInvMass <= 0f) { + return default; + } + + var relativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var closingVelocity = math.dot(relativeVelocity, normal) + + math.dot(gravity, normal) * math.max(dt, 0f); + var normalImpulse = math.max(-closingVelocity / effectiveInvMass, 0f); + if (normalImpulse <= 0f) { + return default; + } + ApplyGroupImpulse(ref ball, ref mechanical, in deflectionAxis, in downAxis, + in normal, in contactOffset, normalImpulse, true, invQMass, invDMass); + + var postRelativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) + - SurfaceVelocity(in staticState, in mechanical); + var tangentVelocity = postRelativeVelocity - normal * math.dot(postRelativeVelocity, normal); + var tangentSpeed = math.length(tangentVelocity); + var tangentImpulse = 0f; + if (tangentSpeed > 1e-5f && friction > 0f) { + var tangent = tangentVelocity / tangentSpeed; + var jtq = math.dot(deflectionAxis, tangent); + var jtd = math.dot(downAxis, tangent); + var tangentAngularTerm = math.dot(tangent, + math.cross(math.cross(contactOffset, tangent) / ball.Inertia, contactOffset)); + var tangentInvMass = ball.InvMass + tangentAngularTerm + + jtq * jtq * invQMass + jtd * jtd * invDMass; + if (tangentInvMass > 0f) { + tangentImpulse = math.clamp(-tangentSpeed / tangentInvMass, + -friction * normalImpulse, friction * normalImpulse); + ApplyGroupImpulse(ref ball, ref mechanical, in deflectionAxis, in downAxis, + in tangent, in contactOffset, tangentImpulse, true, invQMass, invDMass); + } + } + + return new DropTargetImpactResult(true, normalImpulse, tangentImpulse); + } + + private static void ApplyGroupImpulse(ref BallState ball, + ref DropTargetMechanicalState mechanical, in float3 deflectionAxis, in float3 downAxis, + in float3 direction, in float3 contactOffset, float impulseMagnitude, bool dIsFree, + float invQMass, float invDMass) + { + if (impulseMagnitude == 0f) { + return; + } + var impulse = direction * impulseMagnitude; + ball.ApplySurfaceImpulse(math.cross(contactOffset, impulse), impulse); + mechanical.QDot -= math.dot(deflectionAxis, direction) * impulseMagnitude * invQMass; + if (dIsFree) { + mechanical.DDot -= math.dot(downAxis, direction) * impulseMagnitude * invDMass; + } + } + internal static bool UpdateAll(ref PhysicsState state, float dt) { var changed = false; @@ -137,11 +327,18 @@ internal static void Step(int itemId, ref DropTargetState target, in float3 grav { ref var mechanical = ref target.Mechanical; var config = target.Static.Mechanical; - if (mechanical.State == DropTargetMechanismState.Down - || mechanical.State == DropTargetMechanismState.Resetting) { + if (mechanical.State == DropTargetMechanismState.Down) { target.Animation.ZOffset = -mechanical.D; return; } + if (mechanical.State == DropTargetMechanismState.Resetting) { + IntegrateReset(itemId, ref target, dt, ref state); + return; + } + if (mechanical.State == DropTargetMechanismState.Settling) { + IntegrateSettle(itemId, ref target, dt, ref state); + return; + } IntegrateRearWithStop(ref mechanical, in config, dt); if (mechanical.State == DropTargetMechanismState.Latched @@ -188,6 +385,45 @@ internal static void Step(int itemId, ref DropTargetState target, in float3 grav target.Animation.ZOffset = -mechanical.D; } + internal static void BeginReset(int itemId, ref DropTargetState target, ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + if (mechanical.State == DropTargetMechanismState.Latched) { + return; + } + + mechanical.State = DropTargetMechanismState.Resetting; + mechanical.ResetStartD = mechanical.D; + mechanical.ResetElapsedMs = 0f; + mechanical.SettleElapsedMs = 0f; + mechanical.Q = 0f; + mechanical.QDot = 0f; + mechanical.DDot = 0f; + target.Animation.IsDropped = false; + target.Animation.MoveAnimation = false; + target.Animation.HitEvent = false; + state.EnableColliders(itemId); + } + + internal static void ForceDrop(int itemId, ref DropTargetState target, ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + if (mechanical.State == DropTargetMechanismState.Down) { + return; + } + + mechanical.State = DropTargetMechanismState.ForcedDrop; + mechanical.LastImpactOutcome = DropTargetImpactOutcome.ForcedDrop; + mechanical.ResetElapsedMs = 0f; + mechanical.SettleElapsedMs = 0f; + mechanical.D = math.max(mechanical.D, 0f); + mechanical.DDot = math.max(mechanical.DDot, 0f); + target.Animation.IsDropped = false; + target.Animation.MoveAnimation = false; + target.Animation.HitEvent = false; + state.EnableColliders(itemId); + } + internal static void IntegrateRearWithStop(ref DropTargetMechanicalState state, in DropTargetMechanicalConfig config, float dt) { @@ -286,6 +522,81 @@ private static void IntegrateDrop(ref DropTargetMechanicalState state, state.D = math.max(0f, state.D + state.DDot * dt); } + private static void IntegrateReset(int itemId, ref DropTargetState target, float dt, + ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + var config = target.Static.Mechanical; + var previousD = mechanical.D; + mechanical.ResetElapsedMs += math.max(dt, 0f) * MillisecondsPerInternalTime; + var progress = config.ResetDurationMs <= 0f + ? 1f + : math.saturate(mechanical.ResetElapsedMs / config.ResetDurationMs); + var smoothProgress = MinimumJerk(progress); + mechanical.D = math.lerp(mechanical.ResetStartD, -math.max(config.ResetOvershootTravel, 0f), + smoothProgress); + mechanical.DDot = dt > 0f ? (mechanical.D - previousD) / dt : 0f; + FireRaisedCrossing(itemId, ref target, ref state); + + if (progress >= 1f) { + mechanical.State = DropTargetMechanismState.Settling; + mechanical.SettleElapsedMs = 0f; + } + target.Animation.ZOffset = -mechanical.D; + } + + private static void IntegrateSettle(int itemId, ref DropTargetState target, float dt, + ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + var config = target.Static.Mechanical; + var previousD = mechanical.D; + mechanical.SettleElapsedMs += math.max(dt, 0f) * MillisecondsPerInternalTime; + var progress = config.ResetSettleDelayMs <= 0f + ? 1f + : math.saturate(mechanical.SettleElapsedMs / config.ResetSettleDelayMs); + mechanical.D = math.lerp(-math.max(config.ResetOvershootTravel, 0f), 0f, + MinimumJerk(progress)); + mechanical.DDot = dt > 0f ? (mechanical.D - previousD) / dt : 0f; + FireRaisedCrossing(itemId, ref target, ref state); + + if (progress >= 1f) { + mechanical.State = DropTargetMechanismState.Latched; + mechanical.Q = 0f; + mechanical.QDot = 0f; + mechanical.D = 0f; + mechanical.DDot = 0f; + mechanical.ResetStartD = 0f; + mechanical.ResetElapsedMs = 0f; + mechanical.SettleElapsedMs = 0f; + mechanical.DroppedSwitchClosed = false; + mechanical.HitEventFired = false; + target.Animation.IsDropped = false; + target.Animation.MoveAnimation = false; + } + target.Animation.ZOffset = -mechanical.D; + } + + private static void FireRaisedCrossing(int itemId, ref DropTargetState target, + ref PhysicsState state) + { + ref var mechanical = ref target.Mechanical; + if (!mechanical.DroppedSwitchClosed + || mechanical.D > target.Static.Mechanical.RaisedSwitchTravel) { + return; + } + + mechanical.DroppedSwitchClosed = false; + if (target.Static.UseHitEvent) { + state.EventQueue.Enqueue(new EventData(EventId.TargetEventsRaised, itemId)); + } + } + + private static float MinimumJerk(float t) + { + return t * t * t * (10f + t * (-15f + 6f * t)); + } + private static void PublishPose(int itemId, ref DropTargetState target, ref PhysicsState state) { ref var mechanical = ref target.Mechanical; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs index bfa2c595f..687219ad1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs @@ -48,6 +48,22 @@ private static void MechanicalCollide(ref BallState ball, var preImpactVelocity = ball.Velocity; var hitNormal = math.normalizesafe(collEvent.HitNormal); var faceAlignment = math.abs(math.dot(hitNormal, math.normalizesafe(target.Static.FaceNormal))); + var isResetContact = target.Mechanical.State == DropTargetMechanismState.Resetting + || target.Mechanical.State == DropTargetMechanismState.Settling; + if (isResetContact) { + var resetApproachSpeed = -math.dot(preImpactVelocity + - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), hitNormal); + var resetRestitution = ResolveElasticity(in collHeader.Material, in collEvent, + resetApproachSpeed, ref state); + var resetFriction = ResolveFriction(in collHeader.Material, in collEvent, + resetApproachSpeed, ref state); + var resetResult = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, in hitNormal, resetRestitution, resetFriction); + if (resetResult.Applied) { + DisplaceBallFromTarget(ref ball, in collEvent, in hitNormal); + } + return; + } if (collHeader.Role != ColliderRole.DropTargetPhysicalFace || faceAlignment < 0.5f) { BallCollider.Collide3DWall(ref ball, in collHeader.Material, in collEvent, in normal, ref state); if (collHeader.Role == ColliderRole.DropTargetBackFace) { @@ -76,18 +92,45 @@ private static void MechanicalCollide(ref BallState ball, return; } + DisplaceBallFromTarget(ref ball, in collEvent, in hitNormal); + if (!target.Mechanical.HitEventFired + && approachSpeed >= collHeader.Threshold + && result.NormalImpulse >= target.Static.Mechanical.MinimumFaceImpulse) { + target.Mechanical.HitEventFired = true; + FireDropTargetHit(ref ball, ref hitEvents, in collHeader); + } + } + + private static void DisplaceBallFromTarget(ref BallState ball, + in CollisionEventData collEvent, in float3 hitNormal) + { var hDist = math.clamp(-PhysicsConstants.DispGain * collEvent.HitDistance, 0f, PhysicsConstants.DispLimit); ball.Position += hitNormal * hDist; + } + + internal static void CompleteMechanicalImpact(ref BallState ball, ref DropTargetState target, + in CollisionEventData collEvent, in ColliderHeader collHeader, float approachSpeed, + float normalImpulse, ref NativeQueue.ParallelWriter hitEvents) + { + if (normalImpulse <= 0f) { + return; + } + var hitNormal = math.normalizesafe(collEvent.HitNormal); + DisplaceBallFromTarget(ref ball, in collEvent, in hitNormal); + if (target.Mechanical.State == DropTargetMechanismState.Resetting + || target.Mechanical.State == DropTargetMechanismState.Settling) { + return; + } if (!target.Mechanical.HitEventFired && approachSpeed >= collHeader.Threshold - && result.NormalImpulse >= target.Static.Mechanical.MinimumFaceImpulse) { + && normalImpulse >= target.Static.Mechanical.MinimumFaceImpulse) { target.Mechanical.HitEventFired = true; FireDropTargetHit(ref ball, ref hitEvents, in collHeader); } } - private static float ResolveElasticity(in PhysicsMaterialData material, + internal static float ResolveElasticity(in PhysicsMaterialData material, in CollisionEventData collEvent, float speed, ref PhysicsState state) { if (!material.UseElasticityOverVelocity) { @@ -100,7 +143,7 @@ private static float ResolveElasticity(in PhysicsMaterialData material, return state.ElasticityOverVelocityLUTs[itemId].InterpolateLUT(0f, 63f, math.abs(speed)); } - private static float ResolveFriction(in PhysicsMaterialData material, + internal static float ResolveFriction(in PhysicsMaterialData material, in CollisionEventData collEvent, float speed, ref PhysicsState state) { if (!material.UseFrictionOverVelocity) { From 538e372ebb8f8d689110ccd3fcf8d51b6266ebfc Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 19:48:05 +0200 Subject: [PATCH 6/8] physics: add drop target calibration tools --- CHANGELOG.md | 1 + VisualPinball.Unity/Assets/Physics.meta | 8 ++ .../Assets/Physics/Drop Target Profiles.meta | 8 ++ .../Generic Sliding Blade (Provisional).asset | 47 +++++++ ...ric Sliding Blade (Provisional).asset.meta | 8 ++ .../manual/mechanisms/drop-target-physics.md | 76 ++++++++++ .../Documentation~/creators-guide/toc.yml | 6 +- .../physics/drop-target-physics-validation.md | 21 +++ .../HitTarget/DropTargetColliderInspector.cs | 133 +++++++++++++++++- .../DropTargetDeflectionPhysicsTests.cs | 113 +++++++++++++++ .../DropTargetDeflectionPhysicsTests.cs.meta | 11 ++ .../MechanicalDropTargetPhysicsTests.cs | 45 ++++++ .../VisualPinball.Unity/Game/PhysicsCycle.cs | 43 +++++- .../VisualPinball.Unity/Game/PhysicsState.cs | 27 ++-- .../Game/PhysicsStaticCollision.cs | 11 +- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 3 +- .../Physics/Collision/ContactPhysics.cs | 1 + .../VPT/HitTarget/DropTargetApi.cs | 42 +++++- .../VPT/HitTarget/DropTargetComponent.cs | 9 +- .../HitTarget/DropTargetDeflectionPhysics.cs | 83 +++++++++++ .../DropTargetDeflectionPhysics.cs.meta | 11 ++ .../VPT/HitTarget/DropTargetPhysicsConfig.cs | 12 +- .../VPT/HitTarget/DropTargetPhysicsProfile.cs | 37 +++++ .../DropTargetPhysicsProfile.cs.meta | 11 ++ .../HitTarget/MechanicalDropTargetPhysics.cs | 121 +++++++++++----- .../VPT/HitTarget/TargetCollider.cs | 7 +- 26 files changed, 828 insertions(+), 67 deletions(-) create mode 100644 VisualPinball.Unity/Assets/Physics.meta create mode 100644 VisualPinball.Unity/Assets/Physics/Drop Target Profiles.meta create mode 100644 VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset create mode 100644 VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset.meta create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/drop-target-physics.md create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd2a6060..011edca7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Built with Unity 6.5 ### Added +- Drop targets can opt into Roth-compatible or finite-mass mechanical physics, including bricks, multi-target bank shots, moving reset contact, backside behavior, and reusable calibration profiles ([#536](https://github.com/freezy/VisualPinball.Engine/issues/536)). - Make packaging functional ([#557](https://github.com/freezy/VisualPinball.Engine/pull/557)) - New threading model ([#552](https://github.com/freezy/VisualPinball.Engine/pull/552)) - Free transformation ([#500](https://github.com/freezy/VisualPinball.Engine/pull/500)) diff --git a/VisualPinball.Unity/Assets/Physics.meta b/VisualPinball.Unity/Assets/Physics.meta new file mode 100644 index 000000000..7d44454c8 --- /dev/null +++ b/VisualPinball.Unity/Assets/Physics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7d49d53f8a0a43a4a5b2e14839f5e1d2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Physics/Drop Target Profiles.meta b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles.meta new file mode 100644 index 000000000..7bb3b049b --- /dev/null +++ b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5ce7217eb2ee4bda924d537ca49e96aa +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset new file mode 100644 index 000000000..fe87b42cb --- /dev/null +++ b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset @@ -0,0 +1,47 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c2b95ae3618f42e198695c93e407bc69, type: 3} + m_Name: Generic Sliding Blade (Provisional) + m_EditorClassIdentifier: + MechanismName: Generic sliding-blade drop target + Calibration: 0 + CalibrationSource: Uncalibrated starting values for evaluation only; do not label as realistic. + Config: + EffectiveFaceMass: 0.2 + MinimumFaceImpulse: 0 + DeflectionKind: 0 + DeflectionAxis: {x: 0, y: 0, z: -1} + DeflectionPivot: {x: 0, y: 0, z: 0} + ReferenceContactPoint: {x: 0, y: 0, z: 0} + LatchReleaseTravel: 2 + LatchRelatchTravel: 1 + LatchEscapeDrop: 2 + RearStopTravel: 4 + RearSpringFrequencyHz: 70 + RearDampingRatio: 0.25 + RearStopRestitution: 0.65 + DropMass: 0.2 + DropTravel: 52 + DropSpringForce: 0.25 + GuideDamping: 0.2 + GuideFriction: 0.1 + GuideVelocityDeadband: 0.001 + DownStopRestitution: 0.1 + DroppedSwitchTravel: 20 + ResetDurationMs: 40 + ResetEffectiveMass: 1 + ResetOvershootTravel: 10 + ResetSettleDelayMs: 20 + RaisedSwitchTravel: 2 + EnableBacksideRelease: 0 + BacksideReleaseImpulse: 0 + MechanicalVariation: 0 diff --git a/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset.meta b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset.meta new file mode 100644 index 000000000..50abf97ea --- /dev/null +++ b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a44f53af7cd34bf38f1f33d958fed4ab +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/drop-target-physics.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/drop-target-physics.md new file mode 100644 index 000000000..bdcd02596 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/drop-target-physics.md @@ -0,0 +1,76 @@ +--- +uid: drop-target-physics +title: Drop Target Physics +description: Choose and calibrate legacy, Roth-compatible, or mechanical drop-target behavior. +--- + +# Drop Target Physics + +Drop targets provide three physics modes. Existing tables and prefabs remain on **Legacy** unless an author explicitly changes the mode. + +## Choosing a mode + +| Mode | Use it for | Behavior | +|---|---|---| +| **Legacy** | Existing tables and maximum compatibility | Preserves the original solid-wall hit and animation behavior. | +| **Roth Compatible** | Tables that previously used the common Roth `DTHit`/`DTBallPhysics` scripts | Uses a non-solid front sensor and offset collision wall, with the Roth mass correction, optional brick heuristic, backside threshold, and deterministic optional vertical bouncer. | +| **Mechanical** | New tables and physical calibration work | Simulates a finite-mass moving blade, rear spring and stop, latch release/relatch, vertical guide, switch crossings, and a moving reset stroke. Bricks and ball lift emerge from contact and mechanism parameters. | + +Mechanical mode is the most physical model, but its generic defaults are deliberately marked **provisional**. A profile should only be marked **Measured** after it has been fitted to real-machine footage and validated against shots that were not used for fitting. + +## Collider meshes + +The collider component exposes three mesh slots: + +- **Front Collider** remains the legacy solid face. In Roth mode it becomes the non-solid hit sensor. +- **Back Collider** represents the passive rear/body surface. +- **Collision Collider** is the dedicated offset wall in Roth mode and the moving physical face in Mechanical mode. + +For Roth parity, author a separate front sensor and offset collision mesh. If Collision Collider is empty, VPE retains a solid-front fallback, but it cannot exactly reproduce the sensor-plus-offset-wall arrangement. Mechanical mode can fall back to Front Collider, though a simplified dedicated collision mesh is preferable for predictable contact and lower broad-phase cost. + +## Mechanical profiles + +Create a reusable profile with **Assets > Create > Pinball > Drop Target Physics Profile**. Assign it to each target built from the same mechanism. Enable the local override only for a measured per-target difference. + +VPE includes **Generic Sliding Blade (Provisional)** as an explicit evaluation starting point. It is not physical calibration data and must remain labeled provisional. + +The most influential parameters are: + +- **Effective Face Mass**, material elasticity, and friction control the ball/target impulse and rebound. +- **Deflection Kind** selects a sliding blade or a hinged blade. Hinged axes, pivots, and reference contact points are authored in target-local VPX coordinates; hinge travel thresholds are radians, and effective face mass is defined at the reference point. +- **Latch Release Travel**, **Latch Relatch Travel**, and **Latch Escape Drop** define release versus brick timing. +- **Rear Spring Frequency**, damping, rear clearance, and stop restitution control blade recoil. +- **Drop Mass**, spring force, guide damping, and guide friction control downward escape. +- **Reset Duration**, **Reset Effective Mass**, overshoot, and settle time control contact-derived ball lifting during reset. + +Scene gizmos show the latch release, rear stop, down stop, and reset overshoot for a selected Mechanical target. Inspector errors identify impossible masses, travel ordering, and switch thresholds. + +## Events and game logic + +The public drop-target API is unchanged: + +- `Hit` fires for the first qualified face contact, including a brick. +- the dropped switch and `TargetEventsDropped` close once at the downward switch crossing; +- the raised switch and `TargetEventsRaised` open once during reset; +- setting `IsDropped = true` starts a physical forced drop; +- setting `IsDropped = false` starts the moving reset stroke. + +Mechanical targets remain collidable while dropping and while re-entering the playfield. A ball resting above a resetting blade is lifted by the moving finite-mass contact solver; advanced modes do not assign a fixed vertical ball velocity. + +The reset bar is modeled as a powered, prescribed trajectory. Its configured effective mass controls the ball impulse during contact, while the actuator resumes the authored trajectory on the next physics step. This intentionally represents energy supplied by the reset coil rather than an unpowered momentum-conserving collision. + +## Calibration + +Record front and side views with a scale marker at 240 fps or faster. Track ball center, rear blade deflection, vertical target travel, switch timing, and reset motion across controlled speeds, angles, and contact positions. Fit parameters on one set of shots and record validation error on held-out shots in the profile's **Calibration Source** field. + +At minimum, document the mechanism/parts family, camera frame rate and scale, ball mass, sample count, fitted dataset, held-out dataset, and errors in post-impact velocity, peak deflection, drop timing, brick classification, and reset lift. + +## Compatibility and export + +The mode, third collider mesh, and profiles round-trip in VPE packages. Old packages without these fields load as Legacy, and mesh indices 0 and 1 retain their previous meaning. + +VPX BIFF has no fields for VPE's mechanical target masses, latch geometry, springs, or reset actuator. VPX export therefore retains the ordinary target data but cannot preserve advanced physics settings. Keep the VPE package or Unity source as the authoritative editable copy, and use table scripts when a VPX-only distribution needs Roth-style behavior. + +## Performance + +Tables without Mechanical targets bypass the advanced update and contact-reduction paths. Idle latched and fully down Mechanical targets take a rest-state fast path. Moving targets update their collider transforms and the kinematic broad phase only while their pose changes. Prefer simple dedicated collision meshes, and profile a full active bank on the target platform before shipping. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 2384a7fd8..7aa83ce92 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -113,8 +113,10 @@ href: manual/mechanisms/light-groups.md - name: Teleporters href: manual/mechanisms/teleporters.md - - name: Drop Target Banks - href: manual/mechanisms/drop-target-banks.md + - name: Drop Target Banks + href: manual/mechanisms/drop-target-banks.md + - name: Drop Target Physics + href: manual/mechanisms/drop-target-physics.md - name: Rotators href: manual/mechanisms/rotators.md - name: Score Reels diff --git a/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md b/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md index ff7ebef7a..b224add78 100644 --- a/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md +++ b/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md @@ -25,3 +25,24 @@ Mechanical mode depends on moving mesh contact. Before enabling it for authored ## Physical calibration No bundled profile may be called realistic until it has been fitted and validated against a measured target mechanism. Until those measurements are checked in, all Mechanical profiles are provisional. The minimum dataset records ball and target trajectories, contact location, drop/brick outcome, target parts and spring configuration, and reset motion. Fit and validation shots must be separate. + +## Performance gate + +Profile a deterministic scene with the same ball trajectories in Legacy and Mechanical modes. Capture at least 10,000 physics ticks after warm-up on the reference machine and report median, p95, and maximum tick time. The profiler markers `DropTarget.MechanicalUpdate`, `DropTarget.ContactReduction`, and `DropTarget.ImpactGroup` isolate mechanism integration, stable contact collection/reduction, and the fixed-iteration contact solve. + +Record separate runs for 20 idle latched targets, 20 simultaneously moving targets, and the five-target/two-ball contact fixture. Mechanical mode passes the provisional rollout gate only when the complete physics tick adds less than 5% to the Legacy reference. Do not infer that result from the isolated markers or from editor-frame timing. If the gate fails, retain full collision coverage and optimize collider complexity or localized broad-phase updates rather than reducing contact correctness. + +## Collision-dispatch integration check + +Before promoting a Mechanical profile beyond provisional, run the deterministic five-target scene in Unity with the physics trajectory logger enabled. Use two balls arranged to reach the same target at the same global collision time, then repeat with their creation order reversed. Record the complete 1 ms ball/target state stream and event stream for both runs and require byte-identical results. + +The fixture must exercise all of these dispatch handshakes: + +- two physical-face candidates are collected, sorted, and solved as one target/time group; +- each grouped ball state is written back exactly once and its collision event is cleared; +- a side or back collider at the same tick follows the generic collision path exactly once; +- a sustained reset contact is marked handled and is not processed again by the generic contact loop; +- triangle-face, generated-edge, and generated-point contacts remain distinct and deterministic; +- reversing ball creation order does not change state transitions or `Hit`, dropped, or raised events. + +The analytical tests cover the finite-mass group solve and total candidate ordering. They do not replace this scene-level dispatch check; attach the two trajectory logs and profiler capture to calibration evidence. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs index b99017a96..633954b8c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs @@ -14,7 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using UnityEditor; +using UnityEditor; +using UnityEngine; namespace VisualPinball.Unity.Editor { @@ -27,6 +28,7 @@ public class DropTargetColliderInspector : TargetColliderInspector(); + var mainComponent = component.GetComponent(); + var api = player && mainComponent ? player.TableApi.DropTarget(mainComponent) : null; + if (api == null || !api.TryGetMechanicalDiagnostics(out var diagnostics)) { + return; + } + + _runtimeDiagnosticsFoldout = EditorGUILayout.BeginFoldoutHeaderGroup( + _runtimeDiagnosticsFoldout, "Mechanical Runtime Diagnostics"); + if (_runtimeDiagnosticsFoldout) { + using (new EditorGUI.DisabledScope(true)) { + EditorGUILayout.TextField("State", diagnostics.State); + EditorGUILayout.TextField("Last Outcome", diagnostics.LastImpactOutcome); + EditorGUILayout.FloatField("Rear Travel", diagnostics.RearTravel); + EditorGUILayout.FloatField("Rear Velocity", diagnostics.RearVelocity); + EditorGUILayout.FloatField("Drop Travel", diagnostics.DropTravel); + EditorGUILayout.FloatField("Drop Velocity", diagnostics.DropVelocity); + EditorGUILayout.Toggle("Dropped Switch", diagnostics.DroppedSwitchClosed); + EditorGUILayout.IntField("Event Guard Trips", diagnostics.EventLimitTrips); + } + } + EditorGUILayout.EndFoldoutHeaderGroup(); + Repaint(); + } + + private static void DrawValidation(DropTargetColliderComponent component, DropTargetPhysicsMode mode) + { + if (mode == DropTargetPhysicsMode.Legacy) { + return; + } + if (!component.FrontColliderMesh) { + EditorGUILayout.HelpBox("Advanced drop-target physics requires a front collider mesh.", MessageType.Error); + } + if (mode == DropTargetPhysicsMode.RothCompatible && !component.CollisionColliderMesh) { + EditorGUILayout.HelpBox( + "Without a dedicated Collision Collider, Roth mode uses the front mesh as a solid fallback and cannot reproduce the sensor-plus-offset-wall arrangement exactly.", + MessageType.Warning); + return; + } + if (mode != DropTargetPhysicsMode.Mechanical) { + return; + } + + var config = component.ResolvedMechanicalConfig; + if (component.MechanicalProfile && !component.OverrideMechanicalProfile) { + var profile = component.MechanicalProfile; + var calibration = profile.Calibration == DropTargetProfileCalibration.Measured + ? $"Measured profile: {profile.MechanismName}" + : $"Provisional profile: {profile.MechanismName}"; + EditorGUILayout.HelpBox(calibration, profile.Calibration == DropTargetProfileCalibration.Measured + ? MessageType.Info : MessageType.Warning); + if (profile.Calibration == DropTargetProfileCalibration.Measured + && string.IsNullOrWhiteSpace(profile.CalibrationSource)) { + EditorGUILayout.HelpBox("Measured profiles must identify their source data and validation.", MessageType.Error); + } + } else { + EditorGUILayout.HelpBox( + "Local mechanical values are provisional until they are fitted and validated against a real mechanism.", + MessageType.Warning); + } + + if (config.EffectiveFaceMass <= 0f || config.DropMass <= 0f || config.ResetEffectiveMass <= 0f) { + EditorGUILayout.HelpBox("Face, drop, and reset effective masses must be greater than zero.", MessageType.Error); + } + if (config.DropTravel <= 0f || config.RearStopTravel < config.LatchReleaseTravel) { + EditorGUILayout.HelpBox( + "Drop travel must be positive and rear-stop travel must not precede latch release.", + MessageType.Error); + } + if (config.LatchRelatchTravel > config.LatchReleaseTravel + || config.LatchEscapeDrop > config.DropTravel) { + EditorGUILayout.HelpBox( + "Relatch travel must not exceed release travel, and latch escape must occur before the down stop.", + MessageType.Error); + } + if (config.DroppedSwitchTravel > config.DropTravel + || config.RaisedSwitchTravel > config.DropTravel) { + EditorGUILayout.HelpBox("Switch travel thresholds must lie within the drop stroke.", MessageType.Error); + } + if (config.DeflectionKind == DropTargetDeflectionKind.HingedBlade) { + var axis = config.DeflectionAxis; + var lever = Vector3.Cross(axis.normalized, + config.ReferenceContactPoint - config.DeflectionPivot); + if (axis.sqrMagnitude < 1e-6f || lever.sqrMagnitude < 1e-6f) { + EditorGUILayout.HelpBox( + "A hinged blade requires a non-zero local hinge axis and a reference contact point away from that axis.", + MessageType.Error); + } + } + } + + [DrawGizmo(GizmoType.Selected | GizmoType.Active)] + private static void DrawMechanicalTravelGizmo(DropTargetColliderComponent component, GizmoType _) + { + if (component.PhysicsMode != DropTargetPhysicsMode.Mechanical) { + return; + } + var config = component.ResolvedMechanicalConfig; + var origin = component.transform.position; + var up = component.transform.up; + var rear = -component.transform.forward; + var dropEnd = origin - up * VisualPinball.Unity.Physics.ScaleToWorld(config.DropTravel); + var resetEnd = origin + up * VisualPinball.Unity.Physics.ScaleToWorld(config.ResetOvershootTravel); + var release = origin + rear * VisualPinball.Unity.Physics.ScaleToWorld(config.LatchReleaseTravel); + var rearStop = origin + rear * VisualPinball.Unity.Physics.ScaleToWorld(config.RearStopTravel); + + Handles.color = new Color(0.2f, 0.8f, 1f, 1f); + Handles.DrawLine(origin, dropEnd, 2f); + Handles.DrawLine(origin, resetEnd, 2f); + Handles.Label(dropEnd, "Down stop"); + Handles.Label(resetEnd, "Reset overshoot"); + Handles.color = new Color(1f, 0.65f, 0.15f, 1f); + Handles.DrawLine(origin, rearStop, 2f); + Handles.DrawWireDisc(release, up, VisualPinball.Unity.Physics.ScaleToWorld(1f)); + Handles.Label(release, "Latch release"); + Handles.Label(rearStop, "Rear stop"); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs new file mode 100644 index 000000000..bffaf5fa8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs @@ -0,0 +1,113 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class DropTargetDeflectionPhysicsTests + { + [Test] + public void SlidingBladeUsesFaceMassAtEveryPoint() + { + var target = CreateTarget(DropTargetDeflectionKind.SlidingBlade); + + var data = DropTargetDeflectionPhysics.AtPoint(in target.Static, + in target.Mechanical, new float3(0f, 20f, 0f)); + + Assert.That(data.VelocityJacobian.x, Is.EqualTo(-1f).Within(1e-5f)); + Assert.That(data.InverseGeneralizedMass, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void HingedReferencePointReproducesConfiguredEffectiveMass() + { + var target = CreateTarget(DropTargetDeflectionKind.HingedBlade); + var referencePoint = new float3(0f, 10f, 0f); + + var data = DropTargetDeflectionPhysics.AtPoint(in target.Static, + in target.Mechanical, in referencePoint); + var normalJacobian = math.dot(data.VelocityJacobian, new float3(1f, 0f, 0f)); + + Assert.That(normalJacobian * normalJacobian * data.InverseGeneralizedMass, + Is.EqualTo(1f / target.Static.Mechanical.EffectiveFaceMass).Within(1e-5f)); + Assert.That(math.dot(data.VelocityJacobian, -target.Static.FaceNormal), + Is.GreaterThan(0f)); + } + + [Test] + public void HingedOffCenterContactHasPositionDependentEffectiveMass() + { + var target = CreateTarget(DropTargetDeflectionKind.HingedBlade); + var reference = DropTargetDeflectionPhysics.AtPoint(in target.Static, + in target.Mechanical, new float3(0f, 10f, 0f)); + var offCenter = DropTargetDeflectionPhysics.AtPoint(in target.Static, + in target.Mechanical, new float3(0f, 20f, 0f)); + + Assert.That(math.lengthsq(offCenter.VelocityJacobian), + Is.EqualTo(4f * math.lengthsq(reference.VelocityJacobian)).Within(1e-5f)); + } + + [Test] + public void HingedImpactUsesContactPointEffectiveMass() + { + var referenceTarget = CreateTarget(DropTargetDeflectionKind.HingedBlade); + var offCenterTarget = referenceTarget; + var referenceBall = new BallState { + Mass = 1f, + Radius = 25f, + Position = new float3(25f, 10f, 0f), + Velocity = new float3(-30f, 0f, 0f), + }; + var offCenterBall = referenceBall; + offCenterBall.Position.y = 20f; + + var referenceResult = MechanicalDropTargetPhysics.ResolveImpact(ref referenceBall, + ref referenceTarget.Mechanical, in referenceTarget.Static, + new float3(1f, 0f, 0f), 0.35f, 0f); + var offCenterResult = MechanicalDropTargetPhysics.ResolveImpact(ref offCenterBall, + ref offCenterTarget.Mechanical, in offCenterTarget.Static, + new float3(1f, 0f, 0f), 0.35f, 0f); + + Assert.That(referenceResult.Applied, Is.True); + Assert.That(offCenterResult.Applied, Is.True); + Assert.That(offCenterResult.NormalImpulse, Is.LessThan(referenceResult.NormalImpulse)); + Assert.That(offCenterBall.Velocity.x, Is.LessThan(referenceBall.Velocity.x)); + } + + private static DropTargetState CreateTarget(DropTargetDeflectionKind kind) + { + var config = DropTargetMechanicalConfig.Default; + config.DeflectionKind = kind; + config.DeflectionAxis = Vector3.forward; + config.DeflectionPivot = Vector3.zero; + config.ReferenceContactPoint = new Vector3(0f, 10f, 0f); + return new DropTargetState(0, new DropTargetStaticState { + PhysicsMode = DropTargetPhysicsMode.Mechanical, + FaceNormal = new float3(1f, 0f, 0f), + Mechanical = config, + }, default) { + Mechanical = new DropTargetMechanicalState { + State = DropTargetMechanismState.Latched, + PoseInitialized = true, + BaseTransform = float4x4.identity, + } + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs.meta new file mode 100644 index 000000000..4dbd8cab5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DropTargetDeflectionPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58ea384bb99f469289a8b801e7eca239 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs index 7c6ef22e2..9aa7bf7af 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs @@ -284,6 +284,51 @@ public void SimultaneousTwoBallGroupSharesTargetMomentumSymmetrically() } } + [Test] + public void MechanicalTargetPresenceGateIgnoresLegacyTargets() + { + var targets = new NativeParallelHashMap(2, Allocator.Temp); + try { + Assert.That(MechanicalDropTargetPhysics.ContainsMechanicalTargets(ref targets), Is.False); + + var legacy = CreateTargetState(); + legacy.Static.PhysicsMode = DropTargetPhysicsMode.Legacy; + targets.Add(1, legacy); + Assert.That(MechanicalDropTargetPhysics.ContainsMechanicalTargets(ref targets), Is.False); + + targets.Add(2, CreateTargetState()); + Assert.That(MechanicalDropTargetPhysics.ContainsMechanicalTargets(ref targets), Is.True); + } finally { + targets.Dispose(); + } + } + + [Test] + public void MechanicalContactCandidatesHaveStableTotalOrder() + { + var candidates = new NativeList( + Allocator.Temp); + try { + candidates.Add(new PhysicsCycle.MechanicalDropTargetContactCandidate( + 0.002f, 8, 3, 5, ColliderRole.DropTargetPhysicalFace, true, 2)); + candidates.Add(new PhysicsCycle.MechanicalDropTargetContactCandidate( + 0.001f, 8, 3, 5, ColliderRole.DropTargetPhysicalFace, true, 1)); + candidates.Add(new PhysicsCycle.MechanicalDropTargetContactCandidate( + 0.001f, 8, 3, 5, ColliderRole.DropTargetPhysicalFace, true, 0)); + candidates.Add(new PhysicsCycle.MechanicalDropTargetContactCandidate( + 0.001f, 7, 4, 9, ColliderRole.DropTargetPhysicalFace, false)); + + candidates.AsArray().Sort(); + + Assert.That(candidates[0].TargetItemId, Is.EqualTo(7)); + Assert.That(candidates[1].ContactIndex, Is.EqualTo(0)); + Assert.That(candidates[2].ContactIndex, Is.EqualTo(1)); + Assert.That(candidates[3].HitTimeKey, Is.GreaterThan(candidates[2].HitTimeKey)); + } finally { + candidates.Dispose(); + } + } + private static DropTargetState CreateTargetState() { return new DropTargetState(0, new DropTargetStaticState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index 4c4d14e34..40d6bac62 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -34,6 +34,7 @@ public struct PhysicsCycle : IDisposable private static readonly ProfilerMarker PerfMarkerDisplacement = new("Displacement"); private static readonly ProfilerMarker PerfMarkerCollision = new("Collision"); private static readonly ProfilerMarker PerfMarkerContacts = new("Contacts"); + private static readonly ProfilerMarker PerfMarkerMechanicalTargetContacts = new("DropTarget.ContactReduction"); public PhysicsCycle(Allocator a) { @@ -148,7 +149,9 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov } } - ResolveMechanicalDropTargetContacts(hitTime, ref state); + if (state.HasMechanicalDropTargets) { + ResolveMechanicalDropTargetContacts(hitTime, ref state); + } using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; @@ -161,7 +164,9 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov // handle contacts PerfMarkerContacts.Begin(); - ResolveMechanicalDropTargetSupportContacts(hitTime, ref state); + if (state.HasMechanicalDropTargets) { + ResolveMechanicalDropTargetSupportContacts(hitTime, ref state); + } for (var i = 0; i < _contacts.Length; i++) { ref var contact = ref _contacts.GetElementAsRef(i); if (contact.Handled != 0) { @@ -234,6 +239,7 @@ public void Dispose() private void ResolveMechanicalDropTargetContacts(float hitTime, ref PhysicsState state) { + PerfMarkerMechanicalTargetContacts.Begin(); _mechanicalDropTargetContacts.Clear(); using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { @@ -250,6 +256,15 @@ private void ResolveMechanicalDropTargetContacts(float hitTime, ref PhysicsState || target.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { continue; } + var isTransformed = collEvent.IsKinematic + ? state.KinematicColliders.IsTransformed(collEvent.ColliderId) + : state.Colliders.IsTransformed(collEvent.ColliderId); + if (!isTransformed) { + // Mechanical contact math is evaluated in playfield space. Advanced target + // mesh colliders are transformable; retain the generic local-space path for + // any future non-transformable representation. + continue; + } _mechanicalDropTargetContacts.Add(new MechanicalDropTargetContactCandidate( collEvent.HitTime, header.ItemId, enumerator.Current.Key, collEvent.ColliderId, @@ -289,7 +304,8 @@ private void ResolveMechanicalDropTargetContacts(float hitTime, ref PhysicsState } var approachSpeed = -math.dot(ball.Velocity - - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), + - MechanicalDropTargetPhysics.SurfaceVelocityAtPoint(in target.Static, + in target.Mechanical, ball.Position - ball.Radius * hitNormal), hitNormal); _mechanicalImpactContacts.Add(new MechanicalDropTargetContact { BallId = candidate.BallId, @@ -330,10 +346,12 @@ private void ResolveMechanicalDropTargetContacts(float hitTime, ref PhysicsState groupStart = groupEnd; } + PerfMarkerMechanicalTargetContacts.End(); } private void ResolveMechanicalDropTargetSupportContacts(float hitTime, ref PhysicsState state) { + PerfMarkerMechanicalTargetContacts.Begin(); _mechanicalDropTargetContacts.Clear(); for (var i = 0; i < _contacts.Length; i++) { ref var contact = ref _contacts.GetElementAsRef(i); @@ -350,6 +368,12 @@ private void ResolveMechanicalDropTargetSupportContacts(float hitTime, ref Physi && target.Mechanical.State != DropTargetMechanismState.Settling)) { continue; } + var isTransformed = collEvent.IsKinematic + ? state.KinematicColliders.IsTransformed(collEvent.ColliderId) + : state.Colliders.IsTransformed(collEvent.ColliderId); + if (!isTransformed) { + continue; + } _mechanicalDropTargetContacts.Add(new MechanicalDropTargetContactCandidate( collEvent.HitTime, header.ItemId, contact.BallId, collEvent.ColliderId, header.Role, collEvent.IsKinematic, i)); @@ -367,9 +391,10 @@ private void ResolveMechanicalDropTargetSupportContacts(float hitTime, ref Physi } contact.Handled = 1; } + PerfMarkerMechanicalTargetContacts.End(); } - private readonly struct MechanicalDropTargetContactCandidate : IComparable + internal readonly struct MechanicalDropTargetContactCandidate : IComparable { private const float HitTimeQuantization = 1000000f; @@ -408,7 +433,15 @@ public int CompareTo(MechanicalDropTargetContactCandidate other) return result; } result = ((byte)Role).CompareTo((byte)other.Role); - return result != 0 ? result : ColliderId.CompareTo(other.ColliderId); + if (result != 0) { + return result; + } + result = ColliderId.CompareTo(other.ColliderId); + if (result != 0) { + return result; + } + result = IsKinematic.CompareTo(other.IsKinematic); + return result != 0 ? result : ContactIndex.CompareTo(other.ContactIndex); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index b24a1d702..64cd14adb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -144,10 +144,13 @@ internal struct PhysicsState internal NativeParallelHashMap SurfaceStates; internal NativeParallelHashMap TurntableStates; internal NativeParallelHashMap TriggerStates; - internal NativeParallelHashSet DisabledCollisionItems; - - [MarshalAs(UnmanagedType.U1)] - internal bool SwapBallCollisionHandling; + internal NativeParallelHashSet DisabledCollisionItems; + + [MarshalAs(UnmanagedType.U1)] + internal bool HasMechanicalDropTargets; + + [MarshalAs(UnmanagedType.U1)] + internal bool SwapBallCollisionHandling; public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref NativeColliders colliders, ref NativeColliders kinematicColliders, ref NativeColliders kinematicCollidersAtIdentity, @@ -180,9 +183,11 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native EventQueue = eventQueue; InsideOfs = insideOfs; Balls = balls; - BumperStates = bumperStates; - DropTargetStates = dropTargetStates; - FlipperStates = flipperStates; + BumperStates = bumperStates; + DropTargetStates = dropTargetStates; + HasMechanicalDropTargets = MechanicalDropTargetPhysics.ContainsMechanicalTargets( + ref dropTargetStates); + FlipperStates = flipperStates; GateStates = gateStates; HitTargetStates = hitTargetStates; KickerStates = kickerStates; @@ -311,12 +316,14 @@ internal float3 GetKinematicSurfaceVelocity(ref NativeColliders colliders, int c var itemId = colliders.GetItemId(colliderId); if (DropTargetStates.TryGetValue(itemId, out var dropTarget) && dropTarget.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical) { - var mechanicalVelocity = MechanicalDropTargetPhysics.SurfaceVelocity( - in dropTarget.Static, in dropTarget.Mechanical); if (colliders.IsTransformed(colliderId)) { - return mechanicalVelocity; + return MechanicalDropTargetPhysics.SurfaceVelocityAtPoint( + in dropTarget.Static, in dropTarget.Mechanical, in position); } ref var mechanicalMatrix = ref KinematicTransforms.GetValueByRef(itemId); + var worldPosition = mechanicalMatrix.MultiplyPoint(position); + var mechanicalVelocity = MechanicalDropTargetPhysics.SurfaceVelocityAtPoint( + in dropTarget.Static, in dropTarget.Mechanical, in worldPosition); return math.inverse(mechanicalMatrix).MultiplyVector(mechanicalVelocity); } if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index d1e240c04..88ac6f508 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -180,8 +180,15 @@ private static bool CollidesWithItem(ref NativeColliders colliders, ref Collider ? colliders.Triangle(colliderId).Normal() : ball.CollisionEvent.HitNormal; - if (state.HasDropTargetState(colliderId, ref colliders)) { - ref var dropTargetState = ref state.GetDropTargetState(colliderId, ref colliders); + if (state.HasDropTargetState(colliderId, ref colliders)) { + ref var dropTargetState = ref state.GetDropTargetState(colliderId, ref colliders); + if (dropTargetState.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical + && !colliders.IsTransformed(colliderId)) { + // Mechanical target state and motion screws live in playfield space. A future + // non-transformable representation must use the generic local-space wall + // response until it provides an explicit coordinate conversion. + return false; + } TargetCollider.DropTargetCollide(ref ball, ref state.EventQueue, ref dropTargetState, in normal, in ball.CollisionEvent, in collHeader, ref state); return true; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index c5fe026a4..b0f49649b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -100,7 +100,8 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ // Step kinematic collider poses toward their target transforms, capped // per tick so fast movers can't skip past a ball. No-op when idle. PhysicsKinematics.StepKinematics(ref state); - if (MechanicalDropTargetPhysics.UpdateAll(ref state, PhysicsConstants.PhysFactor)) { + if (state.HasMechanicalDropTargets + && MechanicalDropTargetPhysics.UpdateAll(ref state, PhysicsConstants.PhysFactor)) { PhysicsKinematics.RebuildOctree(ref kinematicOctree, ref state); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index e233ea929..9d2f37494 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -44,6 +44,7 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball } else if (state.HasDropTargetState(collEvent.ColliderId, ref colliders)) { ref var target = ref state.GetDropTargetState(collEvent.ColliderId, ref colliders); if (target.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical + && colliders.IsTransformed(collEvent.ColliderId) && (target.Mechanical.State == DropTargetMechanismState.Resetting || target.Mechanical.State == DropTargetMechanismState.Settling)) { MechanicalDropTargetPhysics.ResolveContact(ref ball, ref target.Mechanical, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index a40577e4a..d23183952 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -22,9 +22,33 @@ using VisualPinball.Engine.VPT.HitTarget; using VisualPinball.Unity.Collections; -namespace VisualPinball.Unity -{ - public class DropTargetApi : CollidableApi, +namespace VisualPinball.Unity +{ + public readonly struct DropTargetMechanicalDiagnostics + { + public readonly string State; + public readonly string LastImpactOutcome; + public readonly float RearTravel; + public readonly float RearVelocity; + public readonly float DropTravel; + public readonly float DropVelocity; + public readonly bool DroppedSwitchClosed; + public readonly int EventLimitTrips; + + internal DropTargetMechanicalDiagnostics(in DropTargetMechanicalState state) + { + State = state.State.ToString(); + LastImpactOutcome = state.LastImpactOutcome.ToString(); + RearTravel = state.Q; + RearVelocity = state.QDot; + DropTravel = state.D; + DropVelocity = state.DDot; + DroppedSwitchClosed = state.DroppedSwitchClosed; + EventLimitTrips = state.EventLimitTrips; + } + } + + public class DropTargetApi : CollidableApi, IApi, IApiHittable, IApiSwitch, IApiSwitchDevice, IApiDroppable { /// @@ -116,9 +140,21 @@ private bool IsCurrentlyDropped() return state.Animation.IsDropped; } return state.Mechanical.State == DropTargetMechanismState.Down + || state.Mechanical.State == DropTargetMechanismState.ForcedDrop || state.Mechanical.State == DropTargetMechanismState.Resetting || state.Mechanical.State == DropTargetMechanismState.Settling; } + + public bool TryGetMechanicalDiagnostics(out DropTargetMechanicalDiagnostics diagnostics) + { + ref var state = ref PhysicsEngine.DropTargetState(ItemId); + if (state.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { + diagnostics = default; + return false; + } + diagnostics = new DropTargetMechanicalDiagnostics(in state.Mechanical); + return true; + } #region Wiring diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 6da681eb6..856afb0e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -105,8 +105,13 @@ public override HitTargetData CopyDataTo(HitTargetData data, string[] materialNa // collision data var colliderComponent = GetComponentInChildren(); - if (colliderComponent) { - data.IsCollidable = colliderComponent.enabled; + if (colliderComponent) { + if (forExport && colliderComponent.PhysicsMode != DropTargetPhysicsMode.Legacy) { + Debug.LogWarning($"Drop target '{name}' uses {colliderComponent.PhysicsMode} physics. " + + "VPX has no fields for VPE advanced drop-target mechanics; only vanilla target data will be exported.", + this); + } + data.IsCollidable = colliderComponent.enabled; data.Threshold = colliderComponent.Threshold; data.UseHitEvent = colliderComponent.UseHitEvent; data.PhysicsMaterial = colliderComponent.PhysicsMaterial == null ? string.Empty : colliderComponent.PhysicsMaterial.name; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs new file mode 100644 index 000000000..d111dc824 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs @@ -0,0 +1,83 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal readonly struct DropTargetDeflectionData + { + internal readonly float3 VelocityJacobian; + internal readonly float InverseGeneralizedMass; + internal readonly float3 Axis; + internal readonly float3 Pivot; + + internal DropTargetDeflectionData(in float3 velocityJacobian, + float inverseGeneralizedMass, in float3 axis, in float3 pivot) + { + VelocityJacobian = velocityJacobian; + InverseGeneralizedMass = inverseGeneralizedMass; + Axis = axis; + Pivot = pivot; + } + } + + internal static class DropTargetDeflectionPhysics + { + internal static DropTargetDeflectionData AtPoint(in DropTargetStaticState staticState, + in DropTargetMechanicalState mechanical, in float3 point) + { + var config = staticState.Mechanical; + var rearDirection = -math.normalizesafe(staticState.FaceNormal); + if (config.DeflectionKind != DropTargetDeflectionKind.HingedBlade) { + var invMass = config.EffectiveFaceMass > 0f ? 1f / config.EffectiveFaceMass : 0f; + return new DropTargetDeflectionData(rearDirection, invMass, float3.zero, + float3.zero); + } + + var baseTransform = mechanical.PoseInitialized + ? mechanical.BaseTransform + : float4x4.identity; + var localAxis = new float3(config.DeflectionAxis.x, config.DeflectionAxis.y, + config.DeflectionAxis.z); + var localPivot = new float3(config.DeflectionPivot.x, config.DeflectionPivot.y, + config.DeflectionPivot.z); + var localReference = new float3(config.ReferenceContactPoint.x, + config.ReferenceContactPoint.y, config.ReferenceContactPoint.z); + var axis = math.normalizesafe(baseTransform.MultiplyVector(localAxis)); + var pivot = math.transform(baseTransform, localPivot); + var referencePoint = math.transform(baseTransform, localReference); + var referenceJacobian = math.cross(axis, referencePoint - pivot); + if (math.dot(referenceJacobian, rearDirection) < 0f) { + axis = -axis; + referenceJacobian = -referenceJacobian; + } + var referenceLeverSq = math.lengthsq(referenceJacobian); + var inertia = config.EffectiveFaceMass * referenceLeverSq; + var invInertia = inertia > 1e-6f ? 1f / inertia : 0f; + var velocityJacobian = math.cross(axis, point - pivot); + return new DropTargetDeflectionData(in velocityJacobian, invInertia, in axis, in pivot); + } + + internal static float3 SurfaceVelocityAtPoint(in DropTargetStaticState staticState, + in DropTargetMechanicalState mechanical, in float3 point) + { + var deflection = AtPoint(in staticState, in mechanical, in point); + return deflection.VelocityJacobian * mechanical.QDot + + new float3(0f, 0f, -1f) * mechanical.DDot; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs.meta new file mode 100644 index 000000000..f88799f08 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetDeflectionPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e427d56f5ec149ef9acc0bd5061f5c66 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs index 01189ad4d..6be1eabfd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs @@ -32,6 +32,12 @@ public enum DropTargetDeflectionKind : byte HingedBlade, } + public enum DropTargetProfileCalibration : byte + { + Provisional, + Measured, + } + [Serializable] public struct DropTargetMechanicalConfig { @@ -133,10 +139,4 @@ public struct DropTargetRothConfig }; } - [PackAs("DropTargetPhysicsProfile")] - [CreateAssetMenu(fileName = "DropTargetPhysicsProfile", menuName = "Pinball/Drop Target Physics Profile", order = 101)] - public class DropTargetPhysicsProfile : ScriptableObject - { - public DropTargetMechanicalConfig Config = DropTargetMechanicalConfig.Default; - } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs new file mode 100644 index 000000000..7343542b1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs @@ -0,0 +1,37 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + [PackAs("DropTargetPhysicsProfile")] + [CreateAssetMenu(fileName = "DropTargetPhysicsProfile", menuName = "Pinball/Drop Target Physics Profile", order = 101)] + public class DropTargetPhysicsProfile : ScriptableObject + { + [Tooltip("Human-readable mechanism or parts family represented by this profile.")] + public string MechanismName = "Provisional generic drop target"; + + [Tooltip("Measured means that the profile was fitted to recorded real-machine motion and held-out shots.")] + public DropTargetProfileCalibration Calibration = DropTargetProfileCalibration.Provisional; + + [TextArea] + [Tooltip("Measurement rig, source data, fit version, and validation notes. Required for measured profiles.")] + public string CalibrationSource; + + public DropTargetMechanicalConfig Config = DropTargetMechanicalConfig.Default; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs.meta new file mode 100644 index 000000000..411f8c471 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2b95ae3618f42e198695c93e407bc69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs index 2ded273df..ca9d20318 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs @@ -16,6 +16,7 @@ using Unity.Collections; using Unity.Mathematics; +using Unity.Profiling; using VisualPinball.Engine.Common; using VisualPinball.Engine.Game; using VisualPinball.Unity.Collections; @@ -27,11 +28,32 @@ internal static class MechanicalDropTargetPhysics private const int StopRootIterations = 8; private const int ContactSolverIterations = 24; private const float MillisecondsPerInternalTime = PhysicsConstants.DefaultStepTimeS * 1000f; + private static readonly ProfilerMarker PerfMarkerUpdate = new("DropTarget.MechanicalUpdate"); + private static readonly ProfilerMarker PerfMarkerImpactGroup = new("DropTarget.ImpactGroup"); + + internal static bool ContainsMechanicalTargets( + ref NativeParallelHashMap targets) + { + using var enumerator = targets.GetEnumerator(); + while (enumerator.MoveNext()) { + if (enumerator.Current.Value.Static.PhysicsMode == DropTargetPhysicsMode.Mechanical) { + return true; + } + } + return false; + } internal static float3 SurfaceVelocity(in DropTargetStaticState staticState, in DropTargetMechanicalState mechanical) { - return -staticState.FaceNormal * mechanical.QDot + new float3(0f, 0f, -1f) * mechanical.DDot; + var point = mechanical.PoseInitialized ? mechanical.BaseTransform.c3.xyz : staticState.Center; + return DropTargetDeflectionPhysics.SurfaceVelocityAtPoint(in staticState, in mechanical, in point); + } + + internal static float3 SurfaceVelocityAtPoint(in DropTargetStaticState staticState, + in DropTargetMechanicalState mechanical, in float3 point) + { + return DropTargetDeflectionPhysics.SurfaceVelocityAtPoint(in staticState, in mechanical, in point); } internal static DropTargetImpactResult ResolveImpact(ref BallState ball, @@ -41,9 +63,11 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, var config = staticState.Mechanical; var normal = math.normalizesafe(contactNormal); var contactOffset = -ball.Radius * normal; - var deflectionAxis = -math.normalizesafe(staticState.FaceNormal); + var contactPoint = ball.Position + contactOffset; + var deflection = DropTargetDeflectionPhysics.AtPoint(in staticState, in mechanical, + in contactPoint); var downAxis = new float3(0f, 0f, -1f); - var targetVelocity = SurfaceVelocity(in staticState, in mechanical); + var targetVelocity = SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint); var relativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) - targetVelocity; var normalVelocity = math.dot(relativeVelocity, normal); if (normalVelocity >= -PhysicsConstants.LowNormVel || ball.Mass <= 0f @@ -51,7 +75,7 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, return default; } - var jq = math.dot(deflectionAxis, normal); + var jq = math.dot(deflection.VelocityJacobian, normal); var isResetStroke = mechanical.State == DropTargetMechanismState.Resetting || mechanical.State == DropTargetMechanismState.Settling; var dIsFree = mechanical.State == DropTargetMechanismState.Released @@ -59,7 +83,7 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, || mechanical.State == DropTargetMechanismState.ForcedDrop || isResetStroke; var jd = dIsFree ? math.dot(downAxis, normal) : 0f; - var invQMass = 1f / config.EffectiveFaceMass; + var invQMass = deflection.InverseGeneralizedMass; var dMass = isResetStroke ? config.ResetEffectiveMass : config.DropMass; var invDMass = dIsFree && dMass > 0f ? 1f / dMass : 0f; var ballAngularTerm = math.dot(normal, @@ -76,13 +100,13 @@ internal static DropTargetImpactResult ResolveImpact(ref BallState ball, mechanical.DDot -= jd * normalImpulse * invDMass; var postRelativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset) - - SurfaceVelocity(in staticState, in mechanical); + - SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint); var tangentVelocity = postRelativeVelocity - normal * math.dot(postRelativeVelocity, normal); var tangentSpeed = math.length(tangentVelocity); var tangentImpulse = 0f; if (tangentSpeed > 1e-5f && friction > 0f) { var tangent = tangentVelocity / tangentSpeed; - var jtq = math.dot(deflectionAxis, tangent); + var jtq = math.dot(deflection.VelocityJacobian, tangent); var jtd = dIsFree ? math.dot(downAxis, tangent) : 0f; var ballTangentTerm = math.dot(tangent, math.cross(math.cross(contactOffset, tangent) / ball.Inertia, contactOffset)); @@ -112,7 +136,7 @@ internal static void ResolveImpactGroup(ref NativeList 0f ? 1f / dMass : 0f; for (var i = 0; i < contacts.Length; i++) { ref var contact = ref contacts.GetElementAsRef(i); contact.Normal = math.normalizesafe(contact.Normal); var contactOffset = -contact.Ball.Radius * contact.Normal; + var contactPoint = contact.Ball.Position + contactOffset; var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset) - - SurfaceVelocity(in staticState, in mechanical); + - SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint); var normalVelocity = math.dot(relativeVelocity, contact.Normal); var tangentVelocity = relativeVelocity - contact.Normal * normalVelocity; var tangentSpeed = math.length(tangentVelocity); @@ -150,26 +174,30 @@ internal static void ResolveImpactGroup(ref NativeList 1e-5f && friction > 0f) { var tangent = tangentVelocity / tangentSpeed; - var jtq = math.dot(deflectionAxis, tangent); + var jtq = math.dot(deflection.VelocityJacobian, tangent); var jtd = math.dot(downAxis, tangent); var tangentAngularTerm = math.dot(tangent, math.cross(math.cross(contactOffset, tangent) / ball.Inertia, contactOffset)); @@ -259,7 +294,7 @@ internal static DropTargetImpactResult ResolveContact(ref BallState ball, if (tangentInvMass > 0f) { tangentImpulse = math.clamp(-tangentSpeed / tangentInvMass, -friction * normalImpulse, friction * normalImpulse); - ApplyGroupImpulse(ref ball, ref mechanical, in deflectionAxis, in downAxis, + ApplyGroupImpulse(ref ball, ref mechanical, in deflection.VelocityJacobian, in downAxis, in tangent, in contactOffset, tangentImpulse, true, invQMass, invDMass); } } @@ -268,7 +303,7 @@ internal static DropTargetImpactResult ResolveContact(ref BallState ball, } private static void ApplyGroupImpulse(ref BallState ball, - ref DropTargetMechanicalState mechanical, in float3 deflectionAxis, in float3 downAxis, + ref DropTargetMechanicalState mechanical, in float3 deflectionVelocityJacobian, in float3 downAxis, in float3 direction, in float3 contactOffset, float impulseMagnitude, bool dIsFree, float invQMass, float invDMass) { @@ -277,7 +312,7 @@ private static void ApplyGroupImpulse(ref BallState ball, } var impulse = direction * impulseMagnitude; ball.ApplySurfaceImpulse(math.cross(contactOffset, impulse), impulse); - mechanical.QDot -= math.dot(deflectionAxis, direction) * impulseMagnitude * invQMass; + mechanical.QDot -= math.dot(deflectionVelocityJacobian, direction) * impulseMagnitude * invQMass; if (dIsFree) { mechanical.DDot -= math.dot(downAxis, direction) * impulseMagnitude * invDMass; } @@ -285,6 +320,7 @@ private static void ApplyGroupImpulse(ref BallState ball, internal static bool UpdateAll(ref PhysicsState state, float dt) { + PerfMarkerUpdate.Begin(); var changed = false; using var enumerator = state.DropTargetStates.GetEnumerator(); while (enumerator.MoveNext()) { @@ -319,6 +355,7 @@ internal static bool UpdateAll(ref PhysicsState state, float dt) PublishPose(itemId, ref target, ref state); changed = true; } + PerfMarkerUpdate.End(); return changed; } @@ -525,6 +562,9 @@ private static void IntegrateDrop(ref DropTargetMechanicalState state, private static void IntegrateReset(int itemId, ref DropTargetState target, float dt, ref PhysicsState state) { + // The reset bar is a prescribed, powered actuator. Contact sees its finite effective + // mass during the current solve, but the next step resumes trajectory tracking; the + // actuator supplies the momentum needed to stay on that measured motion. ref var mechanical = ref target.Mechanical; var config = target.Static.Mechanical; var previousD = mechanical.D; @@ -548,6 +588,7 @@ private static void IntegrateReset(int itemId, ref DropTargetState target, float private static void IntegrateSettle(int itemId, ref DropTargetState target, float dt, ref PhysicsState state) { + // Settling remains position-driven for the same powered-actuator reason as reset. ref var mechanical = ref target.Mechanical; var config = target.Static.Mechanical; var previousD = mechanical.D; @@ -600,10 +641,24 @@ private static float MinimumJerk(float t) private static void PublishPose(int itemId, ref DropTargetState target, ref PhysicsState state) { ref var mechanical = ref target.Mechanical; - var linearVelocity = SurfaceVelocity(in target.Static, in mechanical); var pose = mechanical.BaseTransform; - pose.c3.xyz += -target.Static.FaceNormal * mechanical.Q - + new float3(0f, 0f, -1f) * mechanical.D; + if (target.Static.Mechanical.DeflectionKind == DropTargetDeflectionKind.HingedBlade) { + var basePosition = pose.c3.xyz; + var deflection = DropTargetDeflectionPhysics.AtPoint(in target.Static, + in mechanical, in basePosition); + if (deflection.InverseGeneralizedMass > 0f) { + var rotateAroundPivot = math.mul( + float4x4.TRS(deflection.Pivot, quaternion.AxisAngle(deflection.Axis, mechanical.Q), + new float3(1f)), + float4x4.Translate(-deflection.Pivot)); + pose = math.mul(rotateAroundPivot, pose); + } + } else { + pose.c3.xyz += -target.Static.FaceNormal * mechanical.Q; + } + pose.c3.xyz += new float3(0f, 0f, -1f) * mechanical.D; + var posePosition = pose.c3.xyz; + var linearVelocity = SurfaceVelocityAtPoint(in target.Static, in mechanical, in posePosition); state.KinematicTransforms[itemId] = pose; var velocityState = new KinematicVelocityState { LinearVelocity = linearVelocity, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs index 687219ad1..cf2bb1647 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs @@ -47,12 +47,14 @@ private static void MechanicalCollide(ref BallState ball, } var preImpactVelocity = ball.Velocity; var hitNormal = math.normalizesafe(collEvent.HitNormal); + var contactPoint = ball.Position - ball.Radius * hitNormal; var faceAlignment = math.abs(math.dot(hitNormal, math.normalizesafe(target.Static.FaceNormal))); var isResetContact = target.Mechanical.State == DropTargetMechanismState.Resetting || target.Mechanical.State == DropTargetMechanismState.Settling; if (isResetContact) { var resetApproachSpeed = -math.dot(preImpactVelocity - - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), hitNormal); + - MechanicalDropTargetPhysics.SurfaceVelocityAtPoint(in target.Static, + in target.Mechanical, in contactPoint), hitNormal); var resetRestitution = ResolveElasticity(in collHeader.Material, in collEvent, resetApproachSpeed, ref state); var resetFriction = ResolveFriction(in collHeader.Material, in collEvent, @@ -83,7 +85,8 @@ private static void MechanicalCollide(ref BallState ball, } var approachSpeed = -math.dot(preImpactVelocity - - MechanicalDropTargetPhysics.SurfaceVelocity(in target.Static, in target.Mechanical), hitNormal); + - MechanicalDropTargetPhysics.SurfaceVelocityAtPoint(in target.Static, + in target.Mechanical, in contactPoint), hitNormal); var restitution = ResolveElasticity(in collHeader.Material, in collEvent, approachSpeed, ref state); var friction = ResolveFriction(in collHeader.Material, in collEvent, approachSpeed, ref state); var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, From 030c2675a939fbb91ebfcec3f65e0879de696a81 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 20:18:04 +0200 Subject: [PATCH 7/8] physics: correct drop target mechanics --- .../Generic Sliding Blade (Provisional).asset | 1 - .../HitTarget/DropTargetColliderInspector.cs | 28 +++++++- .../MechanicalDropTargetPhysicsTests.cs | 72 +++++++++++++++++++ .../Physics/RothDropTargetPhysicsTests.cs | 20 ++++++ .../VisualPinball.Unity/Game/InsideOfs.cs | 25 +++++-- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 37 +++++----- .../VPT/HitTarget/DropTargetApi.cs | 10 ++- .../VPT/HitTarget/DropTargetComponent.cs | 21 +++--- .../HitTarget/DropTargetMechanicalState.cs | 1 - .../VPT/HitTarget/DropTargetPhysicsConfig.cs | 1 - .../HitTarget/MechanicalDropTargetPhysics.cs | 14 ++-- .../VPT/HitTarget/TargetCollider.cs | 17 +++-- 12 files changed, 197 insertions(+), 50 deletions(-) diff --git a/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset index fe87b42cb..a9b5909bc 100644 --- a/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset +++ b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset @@ -44,4 +44,3 @@ MonoBehaviour: RaisedSwitchTravel: 2 EnableBacksideRelease: 0 BacksideReleaseImpulse: 0 - MechanicalVariation: 0 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs index 633954b8c..560e2f333 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs @@ -87,7 +87,6 @@ private void DrawRuntimeDiagnostics(DropTargetColliderComponent component, EditorGUILayout.FloatField("Drop Travel", diagnostics.DropTravel); EditorGUILayout.FloatField("Drop Velocity", diagnostics.DropVelocity); EditorGUILayout.Toggle("Dropped Switch", diagnostics.DroppedSwitchClosed); - EditorGUILayout.IntField("Event Guard Trips", diagnostics.EventLimitTrips); } } EditorGUILayout.EndFoldoutHeaderGroup(); @@ -157,6 +156,11 @@ private static void DrawValidation(DropTargetColliderComponent component, DropTa "A hinged blade requires a non-zero local hinge axis and a reference contact point away from that axis.", MessageType.Error); } + if (Mathf.Max(config.LatchReleaseTravel, config.RearStopTravel) > Mathf.PI * 0.5f) { + EditorGUILayout.HelpBox( + "Hinged travel thresholds are radians; release or rear-stop travel exceeds 90 degrees.", + MessageType.Warning); + } } } @@ -169,7 +173,7 @@ private static void DrawMechanicalTravelGizmo(DropTargetColliderComponent compon var config = component.ResolvedMechanicalConfig; var origin = component.transform.position; var up = component.transform.up; - var rear = -component.transform.forward; + var rear = component.transform.forward; var dropEnd = origin - up * VisualPinball.Unity.Physics.ScaleToWorld(config.DropTravel); var resetEnd = origin + up * VisualPinball.Unity.Physics.ScaleToWorld(config.ResetOvershootTravel); var release = origin + rear * VisualPinball.Unity.Physics.ScaleToWorld(config.LatchReleaseTravel); @@ -181,6 +185,26 @@ private static void DrawMechanicalTravelGizmo(DropTargetColliderComponent compon Handles.Label(dropEnd, "Down stop"); Handles.Label(resetEnd, "Reset overshoot"); Handles.color = new Color(1f, 0.65f, 0.15f, 1f); + if (config.DeflectionKind == DropTargetDeflectionKind.HingedBlade) { + var localPivot = VisualPinball.Unity.Physics.VpxToWorld.MultiplyPoint(config.DeflectionPivot); + var localAxis = VisualPinball.Unity.Physics.VpxToWorld.MultiplyVector(config.DeflectionAxis); + var localReference = VisualPinball.Unity.Physics.VpxToWorld.MultiplyPoint( + config.ReferenceContactPoint); + var pivot = component.transform.TransformPoint(localPivot); + var axis = component.transform.TransformDirection(localAxis).normalized; + var reference = component.transform.TransformPoint(localReference); + var referenceArm = reference - pivot; + if (axis.sqrMagnitude > 1e-6f && referenceArm.sqrMagnitude > 1e-8f) { + Handles.DrawWireArc(pivot, axis, referenceArm, + config.RearStopTravel * Mathf.Rad2Deg, referenceArm.magnitude, 2f); + var releaseArm = Quaternion.AngleAxis(config.LatchReleaseTravel * Mathf.Rad2Deg, axis) + * referenceArm; + Handles.DrawLine(pivot, pivot + releaseArm, 2f); + Handles.Label(pivot + releaseArm, "Latch release"); + Handles.Label(pivot, "Hinge pivot"); + } + return; + } Handles.DrawLine(origin, rearStop, 2f); Handles.DrawWireDisc(release, up, VisualPinball.Unity.Physics.ScaleToWorld(1f)); Handles.Label(release, "Latch release"); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs index 9aa7bf7af..18143238e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs @@ -329,6 +329,78 @@ public void MechanicalContactCandidatesHaveStableTotalOrder() } } + [Test] + public void ComponentRotationZeroFacesPlayerSide() + { + var normal = DropTargetComponent.FaceNormalFromRotation(0f); + + Assert.That(normal.x, Is.EqualTo(0f).Within(Tolerance)); + Assert.That(normal.y, Is.EqualTo(1f).Within(Tolerance)); + Assert.That(normal.z, Is.EqualTo(0f).Within(Tolerance)); + } + + [Test] + public void FrontImpactUsingComponentNormalDrivesReleaseRearward() + { + var target = CreateTargetState(); + target.Static.FaceNormal = DropTargetComponent.FaceNormalFromRotation(0f); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Position = new float3(0f, 25f, 0f), + Velocity = new float3(0f, -30f, 0f), + }; + + var result = MechanicalDropTargetPhysics.ResolveImpact(ref ball, ref target.Mechanical, + in target.Static, in target.Static.FaceNormal, 0.35f, 0f); + + Assert.That(result.Applied, Is.True); + Assert.That(target.Mechanical.QDot, Is.GreaterThan(0f)); + var state = new PhysicsState(); + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, + PhysicsConstants.PhysFactor, ref state); + Assert.That(target.Mechanical.State, Is.Not.EqualTo(DropTargetMechanismState.Latched)); + Assert.That(target.Mechanical.D, Is.GreaterThan(0f)); + } + + [Test] + public void DownStopRestitutionReflectsClosingDropVelocity() + { + var target = CreateTargetState(); + var config = target.Static.Mechanical; + config.DropTravel = 1f; + config.DownStopRestitution = 0.5f; + target.Static.Mechanical = config; + target.Mechanical.State = DropTargetMechanismState.Dropping; + target.Mechanical.D = 0.9f; + target.Mechanical.DDot = 2f; + target.Mechanical.DroppedSwitchClosed = true; + var state = new PhysicsState(); + + MechanicalDropTargetPhysics.Step(1, ref target, float3.zero, 0.1f, ref state); + + Assert.That(target.Mechanical.D, Is.EqualTo(config.DropTravel)); + Assert.That(target.Mechanical.DDot, Is.LessThan(0f)); + Assert.That(target.Mechanical.State, Is.EqualTo(DropTargetMechanismState.Dropping)); + } + + [Test] + public void MechanicalIsDroppedTracksThePhysicalSwitch() + { + var mechanical = new DropTargetMechanicalState { + State = DropTargetMechanismState.ForcedDrop, + DroppedSwitchClosed = false, + }; + Assert.That(DropTargetApi.IsMechanicalDropped(in mechanical), Is.False); + + mechanical.State = DropTargetMechanismState.Resetting; + mechanical.DroppedSwitchClosed = true; + Assert.That(DropTargetApi.IsMechanicalDropped(in mechanical), Is.True); + + mechanical.DroppedSwitchClosed = false; + Assert.That(DropTargetApi.IsMechanicalDropped(in mechanical), Is.False); + } + private static DropTargetState CreateTargetState() { return new DropTargetState(0, new DropTargetStaticState { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs index 475a2a034..01de5007f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs @@ -15,6 +15,7 @@ // along with this program. If not, see . using NUnit.Framework; +using Unity.Collections; using Unity.Mathematics; namespace VisualPinball.Unity.Test @@ -109,5 +110,24 @@ public void VerticalBouncerMatchesReferenceByReplacingExistingZ() Assert.That(math.length(ball.Velocity), Is.EqualTo(5f).Within(Tolerance)); Assert.That(ball.Velocity.z, Is.GreaterThan(0f).And.LessThan(5f)); } + + [Test] + public void ClearingDroppedSensorRemovesEveryTrackedBall() + { + var insideOfs = new InsideOfs(Allocator.Temp); + try { + insideOfs.SetInsideOf(42, 1); + insideOfs.SetInsideOf(42, 2); + insideOfs.SetInsideOf(7, 2); + + insideOfs.SetOutsideOfItem(42); + + Assert.That(insideOfs.IsOutsideOf(42, 1), Is.True); + Assert.That(insideOfs.IsOutsideOf(42, 2), Is.True); + Assert.That(insideOfs.IsInsideOf(7, 2), Is.True); + } finally { + insideOfs.Dispose(); + } + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs index d21136ca3..1ed0cb293 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs @@ -54,8 +54,8 @@ internal void SetOutsideOfAll(int ballId) // aka ball destroyed _bitLookup.Remove(ballId); } - internal void SetOutsideOf(int itemId, int ballId) - { + internal void SetOutsideOf(int itemId, int ballId) + { if (!_insideOfs.ContainsKey(itemId)) { return; } @@ -63,8 +63,25 @@ internal void SetOutsideOf(int itemId, int ballId) ref var bits = ref _insideOfs.GetValueByRef(itemId); bits.SetBits(GetBitIndex(ballId), false); ClearBitIndex(ballId); - ClearItems(itemId); - } + ClearItems(itemId); + } + + internal void SetOutsideOfItem(int itemId) + { + if (!_insideOfs.TryGetValue(itemId, out var bits)) { + return; + } + var ballIds = new FixedList512Bytes(); + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (bits.IsSet(bitIndex) && TryGetBallId(bitIndex, out var ballId)) { + ballIds.Add(ballId); + } + } + _insideOfs.Remove(itemId); + for (var i = 0; i < ballIds.Length; i++) { + ClearBitIndex(ballIds[i]); + } + } internal bool IsInsideOf(int itemId, int ballId) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index 40d6bac62..23da8ac4e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -140,24 +140,29 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov // collision PerfMarkerCollision.Begin(); - using (var enumerator = state.Balls.GetEnumerator()) { - while (enumerator.MoveNext()) { - ref var ball = ref enumerator.Current.Value; - - // dynamic collision (ball/ball) - PhysicsDynamicCollision.Collide(hitTime, ref ball, ref state); - } - } - if (state.HasMechanicalDropTargets) { + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + PhysicsDynamicCollision.Collide(hitTime, ref ball, ref state); + } + } ResolveMechanicalDropTargetContacts(hitTime, ref state); - } - using (var enumerator = state.Balls.GetEnumerator()) { - while (enumerator.MoveNext()) { - ref var ball = ref enumerator.Current.Value; - - // static & kinematic collision - PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); + } + } + } else { + // Preserve the original per-ball dynamic/static ordering for every table + // that has not opted into Mechanical drop-target physics. + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + PhysicsDynamicCollision.Collide(hitTime, ref ball, ref state); + PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); + } } } PerfMarkerCollision.End(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs index d23183952..d167ed63d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs @@ -33,7 +33,6 @@ public readonly struct DropTargetMechanicalDiagnostics public readonly float DropTravel; public readonly float DropVelocity; public readonly bool DroppedSwitchClosed; - public readonly int EventLimitTrips; internal DropTargetMechanicalDiagnostics(in DropTargetMechanicalState state) { @@ -44,7 +43,6 @@ internal DropTargetMechanicalDiagnostics(in DropTargetMechanicalState state) DropTravel = state.D; DropVelocity = state.DDot; DroppedSwitchClosed = state.DroppedSwitchClosed; - EventLimitTrips = state.EventLimitTrips; } } @@ -139,12 +137,12 @@ private bool IsCurrentlyDropped() if (state.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) { return state.Animation.IsDropped; } - return state.Mechanical.State == DropTargetMechanismState.Down - || state.Mechanical.State == DropTargetMechanismState.ForcedDrop - || state.Mechanical.State == DropTargetMechanismState.Resetting - || state.Mechanical.State == DropTargetMechanismState.Settling; + return IsMechanicalDropped(in state.Mechanical); } + internal static bool IsMechanicalDropped(in DropTargetMechanicalState state) + => state.DroppedSwitchClosed; + public bool TryGetMechanicalDiagnostics(out DropTargetMechanicalDiagnostics diagnostics) { ref var state = ref PhysicsEngine.DropTargetState(ItemId); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 856afb0e4..074d7f040 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -160,14 +160,13 @@ private void Awake() #region State - internal DropTargetState CreateState() - { - var colliderComponent = GetComponent(); - var animationComponent = GetComponentInChildren(); - + internal DropTargetState CreateState() + { + var colliderComponent = GetComponent(); + var animationComponent = GetComponentInChildren(); + var staticData = default(DropTargetStaticState); if (colliderComponent) { - var angle = math.radians(Rotation - 90f); var roth = colliderComponent.RothConfig; var rothDropTravel = roth.DropTravel > 0f ? roth.DropTravel : 52f; staticData = new DropTargetStaticState { @@ -175,7 +174,7 @@ internal DropTargetState CreateState() Roth = roth, Mechanical = colliderComponent.ResolvedMechanicalConfig, Center = Position, - FaceNormal = new float3(math.cos(angle), math.sin(angle), 0f), + FaceNormal = FaceNormalFromRotation(Rotation), HasRothSensor = colliderComponent.PhysicsMode == DropTargetPhysicsMode.RothCompatible && colliderComponent.CollisionColliderMesh, DropSpeed = animationComponent ? animationComponent.Speed : 0f, @@ -216,7 +215,13 @@ internal DropTargetState CreateState() DroppedSwitchClosed = animationData.IsDropped, } }; - } + } + + internal static float3 FaceNormalFromRotation(float rotation) + { + var angle = math.radians(rotation + 90f); + return new float3(math.cos(angle), math.sin(angle), 0f); + } #endregion } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs index 36ce50c07..bfc615ffd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs @@ -55,7 +55,6 @@ internal struct DropTargetMechanicalState internal bool HitEventFired; internal bool PoseInitialized; internal float4x4 BaseTransform; - internal int EventLimitTrips; } internal readonly struct DropTargetImpactResult diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs index 6be1eabfd..62eb9f346 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs @@ -73,7 +73,6 @@ public struct DropTargetMechanicalConfig public bool EnableBacksideRelease; public float BacksideReleaseImpulse; - public float MechanicalVariation; public static DropTargetMechanicalConfig Default => new DropTargetMechanicalConfig { EffectiveFaceMass = 0.2f, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs index ca9d20318..e953e05d6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs @@ -408,10 +408,16 @@ internal static void Step(int itemId, ref DropTargetState target, in float3 grav } if (mechanical.D >= config.DropTravel) { mechanical.D = config.DropTravel; - mechanical.DDot = 0f; - mechanical.State = DropTargetMechanismState.Down; - target.Animation.IsDropped = true; - state.DisableColliders(itemId); + var reboundSpeed = math.max(mechanical.DDot, 0f) + * math.clamp(config.DownStopRestitution, 0f, 1f); + if (reboundSpeed > math.max(config.GuideVelocityDeadband, 0f)) { + mechanical.DDot = -reboundSpeed; + } else { + mechanical.DDot = 0f; + mechanical.State = DropTargetMechanismState.Down; + target.Animation.IsDropped = true; + state.DisableColliders(itemId); + } } if (mechanical.State == DropTargetMechanismState.Latched && math.abs(mechanical.Q) < 1e-4f && math.abs(mechanical.QDot) < 1e-4f) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs index cf2bb1647..4f2b4e07d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs @@ -196,13 +196,15 @@ private static void RothCompatibleCollide(ref BallState ball, return; } state.InsideOfs.SetInsideOf(collHeader.ItemId, ball.Id); - ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, in collHeader, false); + ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, + in collHeader, false, ref state); return; } BallCollider.Collide3DWall(ref ball, in collHeader.Material, in collEvent, in normal, ref state); if (collHeader.Role == ColliderRole.DropTargetPhysicalFace && !target.Static.HasRothSensor) { - ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, in collHeader, true); + ProcessRothHit(ref ball, ref hitEvents, ref target, in preImpactVelocity, in collEvent, + in collHeader, true, ref state); return; } if (collHeader.Role == ColliderRole.DropTargetBackFace) { @@ -210,14 +212,14 @@ private static void RothCompatibleCollide(ref BallState ball, var outcome = RothDropTargetPhysics.Classify(in target.Static.Roth, collHeader.Role, approachSpeed, 1f, 0f); if (outcome == RothDropTargetOutcome.BacksideDrop && approachSpeed >= collHeader.Threshold) { - ActivateDrop(ref ball, ref hitEvents, ref target, in collHeader); + ActivateDrop(ref ball, ref hitEvents, ref target, in collHeader, ref state); } } } private static void ProcessRothHit(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, ref DropTargetState target, in float3 preImpactVelocity, in CollisionEventData collEvent, - in ColliderHeader collHeader, bool solidFallback) + in ColliderHeader collHeader, bool solidFallback, ref PhysicsState state) { var faceNormal = math.normalizesafe(target.Static.FaceNormal); if (math.dot(faceNormal, collEvent.HitNormal) < 0f) { @@ -242,7 +244,7 @@ private static void ProcessRothHit(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, - ref DropTargetState target, in ColliderHeader collHeader) + ref DropTargetState target, in ColliderHeader collHeader, ref PhysicsState state) { + state.InsideOfs.SetOutsideOfItem(collHeader.ItemId); target.Animation.HitEvent = true; target.Animation.MoveAnimation = true; FireDropTargetHit(ref ball, ref hitEvents, in collHeader); From 7be560229c28a0c10bc091ed162f6636ecb4e412 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 20:28:54 +0200 Subject: [PATCH 8/8] physics: clean up drop target whitespace --- .../VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs index 074d7f040..e0effc9ba 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs @@ -203,7 +203,6 @@ internal DropTargetState CreateState() : animationComponent.DropDistance) : 0f } : default; - return new DropTargetState( animationComponent ? UnityObjectId.Get(animationComponent.gameObject) : 0, staticData,