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..0305a1cdab
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TriggerPolygonTests.cs
@@ -0,0 +1,230 @@
+// 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 UnityEngine;
+using VisualPinball.Engine.Math;
+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();
+ }
+ }
+
+ [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),
+ 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++) {