Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

using NUnit.Framework;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using VisualPinball.Engine.Math;
using VisualPinball.Engine.VPT;

namespace VisualPinball.Unity.Test
{
/// <summary>
/// The polygon of a trigger is not solid: the ball passes through it, and it
/// only reports when the ball crosses it.
/// </summary>
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<int, float4x4>(1, Allocator.Persistent);
var references = new ColliderReference(ref nonTransformable, Allocator.Persistent);
var insideOfs = new InsideOfs(Allocator.Temp);
try {
var trigger = go.AddComponent<TriggerComponent>();
var triggerCollider = go.AddComponent<TriggerColliderComponent>();
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));
Comment thread
freezy marked this conversation as resolved.
return collider;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public static class ColliderUtils
/// <param name="info">Collider info</param>
/// <param name="colliders">Colliders to write to</param>
/// <param name="matrix">Transformation matrix to supply when adding colliders.</param>
public static void Generate3DPolyColliders(in float3[] rgv, ColliderInfo info, ref ColliderReference colliders, float4x4 matrix)
/// <param name="onlyTriangles">If set, no edge and vertex colliders are added.</param>
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.
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ internal struct TriangleCollider : ICollider
/// <summary>Minimal upward normal component for a playfield triangle to count as floor.</summary>
private const float FloorNormalMinZ = 0.7f;

/// <summary>
/// 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.
/// </summary>
private const float TriggerSeamTolerance = 1e-5f;

public int Id
{
get => Header.Id;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/// <summary>
/// The non-rigid branch of VP's <c>Hit3DPoly::HitTest</c>, for the polygon of a trigger.
/// </summary>
///
/// <remarks>
/// 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.
/// </remarks>
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);
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
Loading