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..a9b5909bc
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Physics/Drop Target Profiles/Generic Sliding Blade (Provisional).asset
@@ -0,0 +1,46 @@
+%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
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
new file mode 100644
index 000000000..b224add78
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/physics/drop-target-physics-validation.md
@@ -0,0 +1,48 @@
+# 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.
+
+## 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 8f5380937..560e2f333 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/DropTargetColliderInspector.cs
@@ -14,12 +14,201 @@
// 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
{
- [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;
+ private bool _runtimeDiagnosticsFoldout = true;
+
+ 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);
+ }
+ }
+
+ if (targets.Length == 1) {
+ DrawValidation((DropTargetColliderComponent)target, mode);
+ DrawRuntimeDiagnostics((DropTargetColliderComponent)target, mode);
+ }
+ }
+
+ private void DrawRuntimeDiagnostics(DropTargetColliderComponent component,
+ DropTargetPhysicsMode mode)
+ {
+ if (!Application.isPlaying || mode != DropTargetPhysicsMode.Mechanical || !TableComponent) {
+ return;
+ }
+ var player = TableComponent.GetComponent();
+ 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.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);
+ }
+ 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);
+ }
+ }
+ }
+
+ [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);
+ 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");
+ 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/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/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.Test/Physics/MechanicalDropTargetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs
new file mode 100644
index 000000000..18143238e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MechanicalDropTargetPhysicsTests.cs
@@ -0,0 +1,415 @@
+// 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.Collections;
+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));
+ }
+
+ [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();
+ }
+ }
+
+ [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();
+ }
+ }
+
+ [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 {
+ 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.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:
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..01de5007f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/RothDropTargetPhysicsTests.cs
@@ -0,0 +1,133 @@
+// 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.Collections;
+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));
+ }
+
+ [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.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/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 f5f3c71f7..23da8ac4e 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,15 +27,20 @@ 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");
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)
{
_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)
@@ -134,23 +140,43 @@ 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);
-
- // static & kinematic collision
- PhysicsStaticCollision.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;
+ 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();
// handle contacts
PerfMarkerContacts.Begin();
+ 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) {
+ 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 +238,216 @@ 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)
+ {
+ PerfMarkerMechanicalTargetContacts.Begin();
+ _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;
+ }
+ 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,
+ 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.SurfaceVelocityAtPoint(in target.Static,
+ in target.Mechanical, ball.Position - ball.Radius * hitNormal),
+ 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;
+ }
+ 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);
+ 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;
+ }
+ 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));
+ }
+
+ _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;
+ }
+ PerfMarkerMechanicalTargetContacts.End();
+ }
+
+ internal 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);
+ 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 43776bb74..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;
@@ -307,9 +312,21 @@ 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) {
+ if (colliders.IsTransformed(colliderId)) {
+ 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)) {
return float3.zero;
}
@@ -457,9 +474,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..88ac6f508 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
@@ -164,19 +164,32 @@ 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()
: ball.CollisionEvent.HitNormal;
- 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);
+ 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 cd4e96723..b0f49649b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
@@ -97,9 +97,13 @@ 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 (state.HasMechanicalDropTargets
+ && 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 +206,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/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/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 973fcd05e..9d2f37494 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs
@@ -34,12 +34,29 @@ 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);
- } 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
+ && colliders.IsTransformed(collEvent.ColliderId)
+ && (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/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 dc3fd7ece..d167ed63d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetApi.cs
@@ -22,9 +22,31 @@
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;
+
+ 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;
+ }
+ }
+
+ public class DropTargetApi : CollidableApi,
IApi, IApiHittable, IApiSwitch, IApiSwitchDevice, IApiDroppable
{
///
@@ -87,6 +109,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 +133,25 @@ 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 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);
+ if (state.Static.PhysicsMode != DropTargetPhysicsMode.Mechanical) {
+ diagnostics = default;
+ return false;
+ }
+ diagnostics = new DropTargetMechanicalDiagnostics(in state.Mechanical);
+ return true;
}
#region Wiring
@@ -122,12 +170,39 @@ 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);
- colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.FrontColliderMesh, ItemType.HitTarget, MainComponent);
- colliderGenerator.GenerateColliders(ref colliders, ColliderComponent.BackColliderMesh, ItemType.Primitive, MainComponent);
- }
+ 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;
+ }
+ 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);
+ 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..f2e24f4a2 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()
+ {
+ 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.
+ if (MechanicalOverrides.EffectiveFaceMass <= 0f || MechanicalOverrides.DropTravel <= 0f) {
+ MechanicalOverrides = DropTargetMechanicalConfig.Default;
+ }
+ if (RothConfig.TargetMass <= 0f || RothConfig.DropTravel <= 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/DropTargetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetComponent.cs
index 2ef956ef1..e0effc9ba 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;
@@ -104,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;
@@ -154,32 +160,67 @@ private void Awake()
#region State
- 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;
-
- return new DropTargetState(
- animationComponent ? UnityObjectId.Get(animationComponent.gameObject) : 0,
- staticData,
- animationData
- );
- }
+ internal DropTargetState CreateState()
+ {
+ var colliderComponent = GetComponent();
+ var animationComponent = GetComponentInChildren();
+
+ var staticData = default(DropTargetStaticState);
+ if (colliderComponent) {
+ var roth = colliderComponent.RothConfig;
+ var rothDropTravel = roth.DropTravel > 0f ? roth.DropTravel : 52f;
+ staticData = new DropTargetStaticState {
+ PhysicsMode = colliderComponent.PhysicsMode,
+ Roth = roth,
+ Mechanical = colliderComponent.ResolvedMechanicalConfig,
+ Center = Position,
+ FaceNormal = FaceNormalFromRotation(Rotation),
+ 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,
+ staticData,
+ animationData
+ ) {
+ Mechanical = new DropTargetMechanicalState {
+ State = animationData.IsDropped ? DropTargetMechanismState.Down : DropTargetMechanismState.Latched,
+ D = animationData.IsDropped ? staticData.Mechanical.DropTravel : 0f,
+ 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/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/DropTargetMechanicalState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs
new file mode 100644
index 000000000..bfc615ffd
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetMechanicalState.cs
@@ -0,0 +1,88 @@
+// 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 float ResetStartD;
+ internal float ResetElapsedMs;
+ internal float SettleElapsedMs;
+ internal bool DroppedSwitchClosed;
+ internal bool HitEventFired;
+ internal bool PoseInitialized;
+ internal float4x4 BaseTransform;
+ }
+
+ 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;
+ }
+ }
+
+ 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/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 a42aafe9a..82ac46f03 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs
@@ -28,35 +28,68 @@ 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;
+ if (comp.PhysicsMode == DropTargetPhysicsMode.Mechanical) {
+ comp._isKinematic = true;
+ }
+ }
+
+ 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 +101,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 +118,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..62eb9f346
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPhysicsConfig.cs
@@ -0,0 +1,141 @@
+// 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,
+ }
+
+ public enum DropTargetProfileCalibration : byte
+ {
+ Provisional,
+ Measured,
+ }
+
+ [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 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 = 0.25f,
+ 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 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,
+ BrickVelocity = 30f,
+ BrickCenterDistance = 8f,
+ 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/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/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/DropTargetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs
index 96cd8a424..90c9b1324 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetState.cs
@@ -21,12 +21,16 @@ 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)
{
AnimatedItemId = animatedItemId;
Static = @static;
Animation = animation;
+ Mechanical = default;
+ RothHitCounter = 0;
}
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs
index 5bdf7317d..eac88c1d3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetStaticState.cs
@@ -18,10 +18,19 @@
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 DropTargetMechanicalConfig Mechanical;
+ 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/MechanicalDropTargetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs
new file mode 100644
index 000000000..e953e05d6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/MechanicalDropTargetPhysics.cs
@@ -0,0 +1,686 @@
+// 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.Collections;
+using Unity.Mathematics;
+using Unity.Profiling;
+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;
+ 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)
+ {
+ 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,
+ 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 contactPoint = ball.Position + contactOffset;
+ var deflection = DropTargetDeflectionPhysics.AtPoint(in staticState, in mechanical,
+ in contactPoint);
+ var downAxis = new float3(0f, 0f, -1f);
+ 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
+ || config.EffectiveFaceMass <= 0f) {
+ return default;
+ }
+
+ var jq = math.dot(deflection.VelocityJacobian, 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
+ || isResetStroke;
+ var jd = dIsFree ? math.dot(downAxis, normal) : 0f;
+ var invQMass = deflection.InverseGeneralizedMass;
+ 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;
+ 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)
+ - 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(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));
+ 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 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;
+ }
+ PerfMarkerImpactGroup.Begin();
+ 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 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 contactPoint = contact.Ball.Position + contactOffset;
+ var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset)
+ - 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);
+ 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 contactPoint = contact.Ball.Position + contactOffset;
+ var deflection = DropTargetDeflectionPhysics.AtPoint(in staticState,
+ in mechanical, in contactPoint);
+ var jq = math.dot(deflection.VelocityJacobian, 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 * deflection.InverseGeneralizedMass + jd * jd * invDMass;
+ if (effectiveInvMass <= 0f) {
+ continue;
+ }
+
+ var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset)
+ - SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint);
+ 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 deflection.VelocityJacobian, in downAxis, in normal, in contactOffset,
+ impulseDelta, dIsFree, deflection.InverseGeneralizedMass, 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 contactPoint = contact.Ball.Position + contactOffset;
+ var deflection = DropTargetDeflectionPhysics.AtPoint(in staticState,
+ in mechanical, in contactPoint);
+ var relativeVelocity = BallState.SurfaceVelocity(in contact.Ball, in contactOffset)
+ - SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint);
+ var tangent = contact.Tangent;
+ var jq = math.dot(deflection.VelocityJacobian, 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 * deflection.InverseGeneralizedMass + 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 deflection.VelocityJacobian, in downAxis, in tangent, in contactOffset,
+ impulseDelta, dIsFree, deflection.InverseGeneralizedMass, invDMass);
+ }
+ }
+ PerfMarkerImpactGroup.End();
+ }
+
+ 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 contactPoint = ball.Position + contactOffset;
+ var deflection = DropTargetDeflectionPhysics.AtPoint(in staticState, in mechanical,
+ in contactPoint);
+ 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(deflection.VelocityJacobian, normal);
+ var jd = math.dot(downAxis, normal);
+ var invQMass = deflection.InverseGeneralizedMass;
+ 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)
+ - SurfaceVelocityAtPoint(in staticState, in mechanical, in contactPoint);
+ 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 deflection.VelocityJacobian, in downAxis,
+ in normal, in contactOffset, normalImpulse, true, invQMass, invDMass);
+
+ var postRelativeVelocity = BallState.SurfaceVelocity(in ball, in contactOffset)
+ - 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(deflection.VelocityJacobian, 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 deflection.VelocityJacobian, 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 deflectionVelocityJacobian, 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(deflectionVelocityJacobian, direction) * impulseMagnitude * invQMass;
+ if (dIsFree) {
+ mechanical.DDot -= math.dot(downAxis, direction) * impulseMagnitude * invDMass;
+ }
+ }
+
+ internal static bool UpdateAll(ref PhysicsState state, float dt)
+ {
+ PerfMarkerUpdate.Begin();
+ 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;
+ }
+ PerfMarkerUpdate.End();
+ 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) {
+ 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
+ && 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;
+ 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) {
+ mechanical.Q = 0f;
+ mechanical.QDot = 0f;
+ mechanical.HitEventFired = false;
+ }
+ 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)
+ {
+ 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 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;
+ 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)
+ {
+ // 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;
+ 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;
+ var pose = mechanical.BaseTransform;
+ 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,
+ 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/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..4f2b4e07d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/TargetCollider.cs
@@ -14,19 +14,156 @@
// 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.Mechanical) {
+ MechanicalCollide(ref ball, ref hitEvents, ref target, in normal, in collEvent, in collHeader, ref state);
+ return;
+ }
+ 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 MechanicalCollide(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 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.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,
+ 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) {
+ 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.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,
+ in target.Static, in hitNormal, restitution, friction);
+ if (!result.Applied) {
+ 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
+ && normalImpulse >= target.Static.Mechanical.MinimumFaceImpulse) {
+ target.Mechanical.HitEventFired = true;
+ FireDropTargetHit(ref ball, ref hitEvents, in collHeader);
+ }
+ }
+
+ internal 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));
+ }
+
+ internal 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)
+ {
+ if (animation.IsDropped) {
+ return;
}
var dot = -math.dot(collEvent.HitNormal, ball.Velocity);
@@ -36,8 +173,120 @@ 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, 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, ref state);
+ 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, 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, ref PhysicsState state)
+ {
+ 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, ref state);
+ } 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, ref state);
+ } 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, ref PhysicsState state)
+ {
+ state.InsideOfs.SetOutsideOfItem(collHeader.ItemId);
+ 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 dcf8d1392..4dbe25b16 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, bool onlyTriangles = false)
{
if (colliderMesh == null) {
Debug.LogWarning($"Collider mesh of target \"{mainComp.name}\" is not set. Target will not fully work.");
@@ -47,7 +48,10 @@ 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, onlyTriangles);
unityVertices.Dispose();
unityIndices.Dispose();