From 921e77229ac3fca33dbb8dbec8cd7dfec587c8a0 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 16 Oct 2026 23:27:06 +0200 Subject: [PATCH 1/2] physics: Let the ball pass through a trigger's polygon. A curved trigger's top polygon is triangulated, and its triangles reported contacts, so the contact solver held the ball up on the trigger's hit height. Its edges and corners were solid too. A ball in a shooter lane would roll on the switch like on a shelf and lose most of the plunger's shot when launched from its edge. The triangles of a trigger now follow the non-rigid branch of VP's Hit3DPoly: they only report crossings, never a contact, with a small tolerance so a crossing on a seam between two triangles isn't missed. Like VP's Hit3DPoly, the polygon has no edge or vertex colliders. --- .../Physics/TriggerPolygonTests.cs | 183 ++++++++++++++++++ .../Physics/TriggerPolygonTests.cs.meta | 2 + .../Physics/Collider/ColliderUtils.cs | 5 +- .../Physics/Collider/TriangleCollider.cs | 97 +++++++--- .../VPT/Trigger/TriggerColliderGenerator.cs | 4 +- 5 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs new file mode 100644 index 0000000000..bda5fbe050 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs @@ -0,0 +1,183 @@ +// Visual Pinball Engine +// Copyright (C) 2023 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + /// + /// The polygon of a trigger is not solid: the ball passes through it, and it + /// only reports when the ball crosses it. + /// + public class TriggerPolygonTests + { + private const int TriggerId = 7; + private const float TopZ = 20f; + + [Test] + public void BallOnTopOfTheTriggerMakesNoContact() + { + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + insideOfs.SetInsideOf(TriggerId, 1); + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(-50f, -50f, TopZ + 25f) }; + var collEvent = new CollisionEventData(); + + var hitTime = collider.HitTest(ref collEvent, in insideOfs, in ball, 1f); + + Assert.That(hitTime, Is.EqualTo(-1f), "a ball resting on the trigger's top is already inside, nothing to report"); + Assert.That(collEvent.IsContact, Is.False, "the trigger's top must not hold the ball up"); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallTouchingTheTopFromOutsideIsAHitNotAContact() + { + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(-50f, -50f, TopZ + 25f) }; + var collEvent = new CollisionEventData(); + + var hitTime = collider.HitTest(ref collEvent, in insideOfs, in ball, 1f); + + Assert.That(hitTime, Is.EqualTo(0f)); + Assert.That(collEvent.HitFlag, Is.False, "entering the volume is a hit"); + Assert.That(collEvent.IsContact, Is.False); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallRollingBelowTheTopIsIgnored() + { + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(-50f, -50f, 25f), Velocity = new float3(0f, 2f, 0f) }; + var collEvent = new CollisionEventData(); + + Assert.That(collider.HitTest(ref collEvent, in insideOfs, in ball, 1f), Is.EqualTo(-1f), "outside of the trigger"); + insideOfs.SetInsideOf(TriggerId, 1); + Assert.That(collider.HitTest(ref collEvent, in insideOfs, in ball, 1f), Is.EqualTo(-1f), "inside of the trigger"); + Assert.That(collEvent.IsContact, Is.False); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallDroppingThroughTheTopIsAHit() + { + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + // 5 units above the top, falling at 10 units per step + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(-50f, -50f, TopZ + 30f), Velocity = new float3(0f, 0f, -10f) }; + var collEvent = new CollisionEventData(); + + var hitTime = collider.HitTest(ref collEvent, in insideOfs, in ball, 1f); + + Assert.That(hitTime, Is.EqualTo(0.5f).Within(1e-5f)); + Assert.That(collEvent.HitFlag, Is.False, "entering the volume is a hit"); + Assert.That(collEvent.IsContact, Is.False); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallLeavingThroughTheTopIsAnUnHit() + { + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + insideOfs.SetInsideOf(TriggerId, 1); + // 5 units below the top, rising at 10 units per step + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(-50f, -50f, TopZ + 20f), Velocity = new float3(0f, 0f, 10f) }; + var collEvent = new CollisionEventData(); + + var hitTime = collider.HitTest(ref collEvent, in insideOfs, in ball, 1f); + + Assert.That(hitTime, Is.EqualTo(0.5f).Within(1e-5f)); + Assert.That(collEvent.HitFlag, Is.True, "leaving the volume is an unhit"); + Assert.That(collEvent.IsContact, Is.False); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallDroppingOntoASeamOfThePolygonIsAHit() + { + // a polygon triangulated into two triangles: rounding must not make both reject the shared edge + var a = new float3(-22.8f, 164.4f, TopZ); + var b = new float3(224.4f, 132f, TopZ); + var c = new float3(237.6f, 229.6f, TopZ); + var d = new float3(-178.4f, 224.8f, TopZ); + var info = new ColliderInfo { ItemId = TriggerId, ItemType = ItemType.Trigger }; + var triangles = new[] { new TriangleCollider(a, d, b, info), new TriangleCollider(b, d, c, info) }; + var seam = (b + d) * 0.5f; + + var insideOfs = new InsideOfs(Allocator.Temp); + try { + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(seam.x, seam.y, TopZ + 30f), Velocity = new float3(0f, 0f, -10f) }; + var hits = 0; + foreach (var triangle in triangles) { + Assert.That(triangle.Normal().z, Is.GreaterThan(0.99f)); + var collEvent = new CollisionEventData(); + if (triangle.HitTest(ref collEvent, in insideOfs, in ball, 1f) >= 0) { + hits++; + } + } + Assert.That(hits, Is.GreaterThan(0), "a crossing on the seam of two triangles is reported"); + } finally { + insideOfs.Dispose(); + } + } + + [Test] + public void BallCrossingJustOutsideTheEdgeOfATriangleIsAHit() + { + // a hair beyond the hypotenuse of the triangle, as rounding would put a point of the neighbor's edge + var collider = CreateTriggerTop(); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0.0005f, 0.0005f, TopZ + 30f), Velocity = new float3(0f, 0f, -10f) }; + var collEvent = new CollisionEventData(); + + Assert.That(collider.HitTest(ref collEvent, in insideOfs, in ball, 1f), Is.EqualTo(0.5f).Within(1e-5f)); + } finally { + insideOfs.Dispose(); + } + } + + private static TriangleCollider CreateTriggerTop() + { + var collider = new TriangleCollider(new float3(-100f, -100f, TopZ), new float3(-100f, 100f, TopZ), + new float3(100f, -100f, TopZ), new ColliderInfo { ItemId = TriggerId, ItemType = ItemType.Trigger }); + Assert.That(collider.Normal().z, Is.GreaterThan(0.99f)); + return collider; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs.meta new file mode 100644 index 0000000000..023a321861 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4764cd063b72c32438659b7cee1c4a6f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs index b7c1a83b6e..ae54003d1d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs @@ -35,7 +35,8 @@ public static class ColliderUtils /// Collider info /// Colliders to write to /// Transformation matrix to supply when adding colliders. - public static void Generate3DPolyColliders(in float3[] rgv, ColliderInfo info, ref ColliderReference colliders, float4x4 matrix) + /// If set, no edge and vertex colliders are added. + public static void Generate3DPolyColliders(in float3[] rgv, ColliderInfo info, ref ColliderReference colliders, float4x4 matrix, bool onlyTriangles = false) { // Fewer than three points is not a polygon. Both the triangulator and the rgv[0] lookup // below index unconditionally, so bail out before they do. @@ -59,7 +60,7 @@ public static void Generate3DPolyColliders(in float3[] rgv, ColliderInfo info, r } var mesh = new Mesh(triangulatedVerts, outputIndices); - GenerateCollidersFromMesh(mesh, info, ref colliders, matrix); + GenerateCollidersFromMesh(mesh, info, ref colliders, matrix, onlyTriangles); } public static void GenerateCollidersFromMesh(Mesh mesh, ColliderInfo info, ref ColliderReference colliders, float4x4 matrix, bool onlyTriangles = false) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs index c61f20a22c..8dab7f09aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs @@ -33,6 +33,12 @@ internal struct TriangleCollider : ICollider /// Minimal upward normal component for a playfield triangle to count as floor. private const float FloorNormalMinZ = 0.7f; + /// + /// Barycentric slack for the triangles of a trigger's polygon. Without it, rounding can + /// reject a point on the seam of two triangles in both, and the crossing goes unnoticed. + /// + private const float TriggerSeamTolerance = 1e-5f; + public int Id { get => Header.Id; @@ -82,6 +88,10 @@ public float HitTest(ref CollisionEventData collEvent, in InsideOfs insideOfs, i // return -1.0; // } + if (Header.ItemType == ItemType.Trigger) { + return HitTestTrigger(ref collEvent, in insideOfs, in ball, dTime); + } + var bnv = math.dot(_normal, ball.Velocity); // speed in Normal-vector direction if (bnv > PhysicsConstants.ContactVel) { // return if clearly ball is receding from object return -1.0f; @@ -128,11 +138,73 @@ public float HitTest(ref CollisionEventData collEvent, in InsideOfs insideOfs, i var adv = hitTime * ball.Velocity; hitPos += adv; - // Check if hitPos is within the triangle + if (IsInTriangle(hitPos)) { + + collEvent.HitNormal = _normal; + collEvent.HitDistance = bnd; // 3dhit actual contact distance ... + //coll.m_hitRigid = true; // collision type + + if (isContact) { + collEvent.IsContact = true; + collEvent.HitOrgNormalVelocity = bnv; + } + return hitTime; + } + + return -1.0f; + } + + /// + /// The non-rigid branch of VP's Hit3DPoly::HitTest, for the polygon of a trigger. + /// + /// + /// + /// The ball passes through a trigger's polygon. It only reports when the ball crosses it, so + /// it never makes a contact. Otherwise, the contact solver would treat it as a floor the ball + /// can rest on. + /// + private float HitTestTrigger(ref CollisionEventData collEvent, in InsideOfs insideOfs, in BallState ball, float dTime) + { + var bnv = math.dot(_normal, ball.Velocity); // speed in Normal-vector direction + var hitPos = ball.Position - ball.Radius * _normal; // nearest point on ball ... projected radius along norm + var bnd = math.dot(_normal, hitPos - Rgv0); // distance from plane to ball + + var isUnHit = bnv > PhysicsConstants.LowNormVel; + var inside = bnd <= 0; // ball inside object volume + + float hitTime; + if (bnv * bnd >= 0) { // outside-receding || inside-approaching + if (math.abs(bnd) >= ball.Radius * 0.5f // not too close ... nor too far away + || inside != insideOfs.IsOutsideOf(Header.ItemId, ball.Id)) { // ...ball outside and hit set or ball inside and no hit set + return -1.0f; + } + hitTime = 0; + isUnHit = !inside; // ball on outside is UnHit, otherwise it's a Hit + + } else { + hitTime = bnd / -bnv; + } + + if (float.IsNaN(hitTime) || float.IsInfinity(hitTime) || hitTime < 0 || hitTime > dTime) { + return -1.0f; // time is outside this frame ... no collision + } + + if (!IsInTriangle(hitPos + hitTime * ball.Velocity, TriggerSeamTolerance)) { + return -1.0f; + } + + collEvent.HitNormal = _normal; + collEvent.HitFlag = isUnHit; // UnHit signal -> is receding from outside target + collEvent.HitDistance = bnd; + return hitTime; + } + + private bool IsInTriangle(float3 pos, float tolerance = 0f) + { // 1. Compute vectors var v0 = Rgv2 - Rgv0; var v1 = Rgv1 - Rgv0; - var v2 = hitPos - Rgv0; + var v2 = pos - Rgv0; // 2. Compute dot products var dot00 = math.dot(v0, v0); @@ -147,26 +219,7 @@ public float HitTest(ref CollisionEventData collEvent, in InsideOfs insideOfs, i var v = (dot00 * dot12 - dot01 * dot02) * invDenom; // 4. Check if point is in triangle - var pointInTriangle = u >= 0 && v >= 0 && u + v <= 1; - - if (pointInTriangle) { - - if (Header.ItemType == ItemType.Trigger && bnd < 0 == insideOfs.IsOutsideOf(Header.ItemId, ball.Id)) { - collEvent.HitFlag = bnd > 0; - } - - collEvent.HitNormal = _normal; - collEvent.HitDistance = bnd; // 3dhit actual contact distance ... - //coll.m_hitRigid = true; // collision type - - if (isContact) { - collEvent.IsContact = true; - collEvent.HitOrgNormalVelocity = bnv; - } - return hitTime; - } - - return -1.0f; + return u >= -tolerance && v >= -tolerance && u + v <= 1 + tolerance; } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderGenerator.cs index 37dd663c74..f2eeb478ec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderGenerator.cs @@ -71,7 +71,9 @@ private void GenerateCurvedHitObjects(ref ColliderReference colliders) rgv[i] = vVertex[i]; rgv3D[i] = new float3(rgv[i].X, rgv[i].Y, _colliderComponent.HitHeight); } - ColliderUtils.Generate3DPolyColliders(rgv3D, _api.GetColliderInfo(), ref colliders, _matrix); + // like VP's Hit3DPoly, the polygon has no edges: the ball passes through it, so the + // edges would only add solid lines and points to bump into. + ColliderUtils.Generate3DPolyColliders(rgv3D, _api.GetColliderInfo(), ref colliders, _matrix, onlyTriangles: true); // walls for (var i = 0; i < count; i++) { From 37c27fbb578172c1256835b1e5a51af2079ce773 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 16 Oct 2026 23:39:22 +0200 Subject: [PATCH 2/2] test: Generate a trigger polygon and check it has no solid edges. --- .../Physics/TriggerPolygonTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs index bda5fbe050..0305a1cdab 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs @@ -17,6 +17,8 @@ using NUnit.Framework; using Unity.Collections; using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; namespace VisualPinball.Unity.Test @@ -172,6 +174,51 @@ public void BallCrossingJustOutsideTheEdgeOfATriangleIsAHit() } } + [Test] + public void GeneratedPolygonHasNoSolidEdgesAndReportsACrossing() + { + var go = new GameObject("trigger"); + var nonTransformable = new NativeParallelHashMap(1, Allocator.Persistent); + var references = new ColliderReference(ref nonTransformable, Allocator.Persistent); + var insideOfs = new InsideOfs(Allocator.Temp); + try { + var trigger = go.AddComponent(); + var triggerCollider = go.AddComponent(); + triggerCollider.HitHeight = TopZ; + trigger.DragPoints = new[] { + new DragPointData(0f, 0f), new DragPointData(100f, 0f), + new DragPointData(100f, 100f), new DragPointData(0f, 100f), + }; + var api = new TriggerApi(go, null, null); + new TriggerColliderGenerator(api, trigger, triggerCollider, null, float4x4.identity).GenerateColliders(ref references); + + Assert.That(references.Line3DColliders.Length, Is.EqualTo(0), "no solid edges on the polygon"); + Assert.That(references.PointColliders.Length, Is.EqualTo(0), "no solid corners on the polygon"); + Assert.That(references.LineColliders.Length, Is.EqualTo(4), "one wall per side"); + Assert.That(references.TriangleColliders.Length, Is.GreaterThanOrEqualTo(2)); + + // a ball falling onto the middle of the polygon, which is on the seam of the triangles + var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(50f, 50f, TopZ + 30f), Velocity = new float3(0f, 0f, -10f) }; + var hits = 0; + for (var i = 0; i < references.TriangleColliders.Length; i++) { + var triangle = references.TriangleColliders[i]; + Assert.That(triangle.Normal().z, Is.GreaterThan(0.99f), "the polygon faces up"); + var collEvent = new CollisionEventData(); + if (triangle.HitTest(ref collEvent, in insideOfs, in ball, 1f) >= 0) { + Assert.That(collEvent.HitFlag, Is.False, "entering the volume is a hit"); + Assert.That(collEvent.IsContact, Is.False); + hits++; + } + } + Assert.That(hits, Is.GreaterThan(0), "the crossing is reported"); + } finally { + insideOfs.Dispose(); + references.Dispose(); + nonTransformable.Dispose(); + Object.DestroyImmediate(go); + } + } + private static TriangleCollider CreateTriggerTop() { var collider = new TriangleCollider(new float3(-100f, -100f, TopZ), new float3(-100f, 100f, TopZ),