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++) {