diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs
new file mode 100644
index 000000000..6bd329ab8
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs
@@ -0,0 +1,47 @@
+// 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;
+
+namespace VisualPinball.Unity.Test
+{
+ public class Line3DColliderTests
+ {
+ [Test]
+ public void HitNormalIsTransformedBackToWorldSpace()
+ {
+ var collider = new Line3DCollider(
+ new float3(0f, 0f, 0f),
+ new float3(10f, 0f, 0f),
+ new ColliderInfo { ItemId = 1 }
+ );
+ var ball = new BallState {
+ Position = new float3(5f, 2f, 2f),
+ Velocity = new float3(0f, -1f, -1f),
+ Radius = 1f
+ };
+ var collEvent = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collEvent, in ball, 2f);
+
+ Assert.That(hitTime, Is.GreaterThanOrEqualTo(0f));
+ Assert.That(collEvent.HitNormal.x, Is.EqualTo(0f).Within(1e-5f));
+ Assert.That(collEvent.HitNormal.y, Is.EqualTo(math.sqrt(0.5f)).Within(1e-5f));
+ Assert.That(collEvent.HitNormal.z, Is.EqualTo(math.sqrt(0.5f)).Within(1e-5f));
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta
new file mode 100644
index 000000000..690490f87
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7c64a52806928274b8b974b311efc219
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs
new file mode 100644
index 000000000..ce2a00dc4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs
@@ -0,0 +1,327 @@
+// 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 System.Reflection;
+using System.Reflection.Emit;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Engine.VPT;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// Regression coverage for physics transform and collision bugs.
+ ///
+ public class PhysicsRegressionTests
+ {
+ private const float Tolerance = 1e-5f;
+
+ private static readonly OpCode[] OneByteOpCodes = new OpCode[0x100];
+ private static readonly OpCode[] TwoByteOpCodes = new OpCode[0x100];
+
+ static PhysicsRegressionTests()
+ {
+ foreach (var field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) {
+ var opCode = (OpCode)field.GetValue(null);
+ var value = (ushort)opCode.Value;
+ if (value < 0x100) {
+ OneByteOpCodes[value] = opCode;
+ } else if ((value & 0xff00) == 0xfe00) {
+ TwoByteOpCodes[value & 0xff] = opCode;
+ }
+ }
+ }
+
+ [Test]
+ public void PointColliderTransformKeepsBoundsAtTransformedPoint()
+ {
+ var collider = new PointCollider(new float3(1f, 2f, 3f), new ColliderInfo { ItemId = 1 });
+ var matrix = float4x4.Translate(new float3(10f, 20f, 30f));
+
+ collider = collider.Transform(matrix);
+
+ AssertFloat3(collider.P, new float3(11f, 22f, 33f));
+ AssertFloat3(collider.Bounds.Aabb.Min, collider.P);
+ AssertFloat3(collider.Bounds.Aabb.Max, collider.P);
+ }
+
+ [Test]
+ public void LineZColliderTransformAppliesFullMatrixToBothEndpoints()
+ {
+ var source = new LineZCollider(new float2(1f, 2f), 10f, 20f, new ColliderInfo { ItemId = 1 });
+ var matrix = float4x4.TRS(
+ new float3(10f, 20f, 30f),
+ quaternion.RotateZ(math.radians(90f)),
+ new float3(2f, 3f, 4f)
+ );
+ var expectedLow = matrix.MultiplyPoint(new float3(source.XY, source.ZLow));
+ var expectedHigh = matrix.MultiplyPoint(new float3(source.XY, source.ZHigh));
+
+ var transformed = source.Transform(matrix);
+
+ AssertFloat2(transformed.XY, expectedLow.xy);
+ Assert.That(transformed.ZLow, Is.EqualTo(expectedLow.z).Within(Tolerance));
+ Assert.That(transformed.ZHigh, Is.EqualTo(expectedHigh.z).Within(Tolerance));
+ }
+
+ [Test]
+ public void GetScaleReturnsTheTrsAxisScaleAfterRotation()
+ {
+ var matrix = RotatedNonUniformScale();
+
+ AssertFloat3(matrix.GetScale(), new float3(2f, 1f, 3f));
+ }
+
+ [Test]
+ public void CircleColliderRejectsRotatedNonUniformXyScale()
+ {
+ Assert.That(CircleCollider.IsTransformable(RotatedNonUniformScale()), Is.False);
+ }
+
+ [Test]
+ public void BallTransformScalesRadiusWithUniformScale()
+ {
+ var ball = new BallState {
+ Position = new float3(150f, 0f, 0f),
+ Radius = 25f
+ };
+
+ ball.Transform(float4x4.Scale(new float3(0.5f)));
+
+ AssertFloat3(ball.Position, new float3(75f, 0f, 0f));
+ Assert.That(ball.Radius, Is.EqualTo(12.5f).Within(Tolerance));
+ }
+
+ [Test]
+ public void BallTransformRoundTripPreservesRadiusForLargeRotatedUniformScale()
+ {
+ var ball = new BallState { Radius = 25f };
+ var matrix = float4x4.TRS(
+ float3.zero,
+ quaternion.EulerXYZ(math.radians(new float3(10f, 62f, 17f))),
+ new float3(10f)
+ );
+
+ ball.Transform(math.inverse(matrix));
+ Assert.That(ball.Radius, Is.EqualTo(2.5f).Within(Tolerance));
+
+ ball.Transform(matrix);
+ Assert.That(ball.Radius, Is.EqualTo(25f).Within(Tolerance));
+ }
+
+ [Test]
+ public void CollisionEventTransformRoundTripPreservesHitVelocity()
+ {
+ var collEvent = new CollisionEventData { HitVelocity = new float2(0f, 1f) };
+ var matrix = float4x4.TRS(
+ float3.zero,
+ quaternion.RotateX(math.radians(45f)),
+ new float3(1f)
+ );
+
+ collEvent.Transform(matrix);
+ collEvent.Transform(math.inverse(matrix));
+
+ AssertFloat2(collEvent.HitVelocity, new float2(0f, 1f));
+ }
+
+ [Test]
+ public void CollisionEventClearResetsTransformedHitVelocity()
+ {
+ var collEvent = new CollisionEventData { HitVelocity = new float2(0f, 1f) };
+ var matrix = float4x4.TRS(
+ float3.zero,
+ quaternion.RotateX(math.radians(45f)),
+ new float3(1f)
+ );
+
+ collEvent.Transform(matrix);
+ collEvent.ClearCollider();
+
+ AssertFloat2(collEvent.HitVelocity, float2.zero);
+
+ collEvent.HitVelocity = new float2(0f, 1f);
+ collEvent.Transform(matrix);
+
+ AssertFloat2(collEvent.HitVelocity, new float2(0f, math.sqrt(0.5f)));
+ }
+
+ [Test]
+ public void Line3DColliderFiresHitEventForApproachingBallAboveThreshold()
+ {
+ var collider = new Line3DCollider(
+ new float3(0f, 0f, -1f),
+ new float3(0f, 0f, 1f),
+ new ColliderInfo {
+ ItemId = 1,
+ ItemType = ItemType.Primitive,
+ HitThreshold = 1f,
+ FireEvents = true
+ }
+ );
+ var ball = new BallState {
+ Id = 1,
+ Position = new float3(1f, 0f, 0f),
+ EventPosition = new float3(100f, 0f, 0f),
+ Velocity = new float3(-10f, 0f, 0f),
+ Radius = 1f,
+ Mass = 1f
+ };
+ var collEvent = new CollisionEventData {
+ HitNormal = new float3(1f, 0f, 0f),
+ HitDistance = 0f
+ };
+ var state = new PhysicsState();
+ var events = new NativeQueue(Allocator.Temp);
+
+ try {
+ var writer = events.AsParallelWriter();
+ collider.Collide(ref ball, ref writer, in collEvent, ref state);
+
+ Assert.That(events.Count, Is.EqualTo(1));
+ } finally {
+ events.Dispose();
+ }
+ }
+
+ [TestCase(typeof(Aabb), TestName = "AabbBoxedEqualsDelegatesToTypedOverload")]
+ [TestCase(typeof(ColliderHeader), TestName = "ColliderHeaderBoxedEqualsDelegatesToTypedOverload")]
+ public void BoxedEqualsDelegatesToTypedOverload(Type type)
+ {
+ var boxedEquals = type.GetMethod(
+ nameof(object.Equals),
+ BindingFlags.Instance | BindingFlags.Public,
+ null,
+ new[] { typeof(object) },
+ null
+ );
+ var typedEquals = type.GetMethod(
+ nameof(object.Equals),
+ BindingFlags.Instance | BindingFlags.Public,
+ null,
+ new[] { type },
+ null
+ );
+
+ Assert.That(boxedEquals, Is.Not.Null);
+ Assert.That(typedEquals, Is.Not.Null);
+ Assert.That(CallsMethod(boxedEquals, typedEquals), Is.True,
+ $"{type.Name}.Equals(object) does not delegate to Equals({type.Name}).");
+ }
+
+ private static float4x4 RotatedNonUniformScale()
+ {
+ return float4x4.TRS(
+ float3.zero,
+ quaternion.RotateZ(math.radians(45f)),
+ new float3(2f, 1f, 3f)
+ );
+ }
+
+ private static bool CallsMethod(MethodInfo caller, MethodInfo expectedCallee)
+ {
+ var body = caller.GetMethodBody();
+ var il = body?.GetILAsByteArray();
+ if (il == null) {
+ return false;
+ }
+
+ var offset = 0;
+ while (offset < il.Length) {
+ var opCode = ReadOpCode(il, ref offset);
+ if (opCode.OperandType == OperandType.InlineMethod) {
+ var token = BitConverter.ToInt32(il, offset);
+ var calledMethod = caller.Module.ResolveMethod(token);
+ if (HasSameSignature(calledMethod, expectedCallee)) {
+ return true;
+ }
+ }
+ offset += GetOperandSize(opCode.OperandType, il, offset);
+ }
+ return false;
+ }
+
+ private static bool HasSameSignature(MethodBase actual, MethodInfo expected)
+ {
+ if (actual.DeclaringType != expected.DeclaringType || actual.Name != expected.Name) {
+ return false;
+ }
+ var actualParameters = actual.GetParameters();
+ var expectedParameters = expected.GetParameters();
+ if (actualParameters.Length != expectedParameters.Length) {
+ return false;
+ }
+ for (var i = 0; i < actualParameters.Length; i++) {
+ if (actualParameters[i].ParameterType != expectedParameters[i].ParameterType) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static OpCode ReadOpCode(byte[] il, ref int offset)
+ {
+ var value = il[offset++];
+ return value == 0xfe ? TwoByteOpCodes[il[offset++]] : OneByteOpCodes[value];
+ }
+
+ private static int GetOperandSize(OperandType operandType, byte[] il, int offset)
+ {
+ switch (operandType) {
+ case OperandType.InlineNone:
+ return 0;
+ case OperandType.ShortInlineBrTarget:
+ case OperandType.ShortInlineI:
+ case OperandType.ShortInlineVar:
+ return 1;
+ case OperandType.InlineVar:
+ return 2;
+ case OperandType.InlineBrTarget:
+ case OperandType.InlineField:
+ case OperandType.InlineI:
+ case OperandType.InlineMethod:
+ case OperandType.InlineSig:
+ case OperandType.InlineString:
+ case OperandType.InlineTok:
+ case OperandType.InlineType:
+ case OperandType.ShortInlineR:
+ return 4;
+ case OperandType.InlineI8:
+ case OperandType.InlineR:
+ return 8;
+ case OperandType.InlineSwitch:
+ return 4 + BitConverter.ToInt32(il, offset) * 4;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(operandType), operandType, null);
+ }
+ }
+
+ private static void AssertFloat2(float2 actual, float2 expected)
+ {
+ Assert.That(actual.x, Is.EqualTo(expected.x).Within(Tolerance));
+ Assert.That(actual.y, Is.EqualTo(expected.y).Within(Tolerance));
+ }
+
+ private static void AssertFloat3(float3 actual, float3 expected)
+ {
+ Assert.That(actual.x, Is.EqualTo(expected.x).Within(Tolerance));
+ Assert.That(actual.y, Is.EqualTo(expected.y).Within(Tolerance));
+ Assert.That(actual.z, Is.EqualTo(expected.z).Within(Tolerance));
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta
new file mode 100644
index 000000000..b46db8820
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5a240399f2c24726a78fbe54cf78b518
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs
new file mode 100644
index 000000000..52d673487
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("VisualPinball.Unity.Test")]
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta
new file mode 100644
index 000000000..2744f375a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 964d153f72dc44b9996b6c113577e1cc
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs
index f61445305..363cbb28b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs
@@ -62,9 +62,9 @@ public static void RotationAroundAxis(ref this float3x3 m, float3 axis, float rS
public static float3 GetScale(this float4x4 m)
{
return new float3(
- math.length(new float3(m.c0.x, m.c1.x, m.c2.x)),
- math.length(new float3(m.c0.y, m.c1.y, m.c2.y)),
- math.length(new float3(m.c0.z, m.c1.z, m.c2.z))
+ math.length(m.c0.xyz),
+ math.length(m.c1.xyz),
+ math.length(m.c2.xyz)
);
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
index 7c39fcb6d..75e5a4f2e 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
@@ -248,7 +248,7 @@ public static bool IsTransformable(float4x4 matrix)
var rotation = matrix.GetRotationVector();
// if xy-scale is not uniform or x/y rotation is not zero, we can't transform the collider
- var uniformScale = math.abs(scale.x - scale.y) < Collider.Tolerance;
+ var uniformScale = Collider.HasEqualScale(scale.x, scale.y);
var xyRotated = math.abs(rotation.x) > Collider.Tolerance || math.abs(rotation.y) > Collider.Tolerance;
return uniformScale && !xyRotated;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
index f0fbfd205..e10246139 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
@@ -30,6 +30,13 @@ public struct Collider
{
public const float Tolerance = 1e-6f; // 1e-9f;
+ internal static bool HasEqualScale(float first, float second)
+ {
+ // GetScale error grows with the matrix scale, so compare relatively.
+ var largestMagnitude = math.max(math.abs(first), math.abs(second));
+ return math.abs(first - second) <= Tolerance * largestMagnitude;
+ }
+
public ColliderHeader Header;
public int Id => Header.Id;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs
index 6654a4d83..bf2fea71c 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs
@@ -126,7 +126,7 @@ private static float HitTest(ref CollisionEventData collEvent, ref Line3DCollide
// transform hit normal back to world coordinate system
if (hitTime >= 0) {
- collEvent.HitNormal = math.mul(coll._matrix, collEvent.HitNormal);
+ collEvent.HitNormal = math.mul(math.transpose(coll._matrix), collEvent.HitNormal);
}
return hitTime;
@@ -137,11 +137,11 @@ private static float HitTest(ref CollisionEventData collEvent, ref Line3DCollide
public void Collide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents,
in CollisionEventData collEvent, ref PhysicsState state)
{
- var dot = math.dot(collEvent.HitNormal, ball.Velocity);
+ var impactSpeed = -math.dot(collEvent.HitNormal, ball.Velocity);
BallCollider.Collide3DWall(ref ball, in Header.Material, in collEvent, in collEvent.HitNormal, ref state);
- if (Header.FireEvents && dot >= Header.Threshold && Header.IsPrimitive) {
- // todo m_obj->m_currentHitThreshold = dot;
+ if (Header.FireEvents && impactSpeed >= Header.Threshold && Header.IsPrimitive) {
+ // todo m_obj->m_currentHitThreshold = impactSpeed;
Collider.FireHitEvent(ref ball, ref hitEvents, in Header);
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs
index d47e97400..dded4bf18 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs
@@ -196,19 +196,12 @@ public void Transform(LineZCollider lineCollider, float4x4 matrix)
}
#endif
- var t = matrix.GetTranslation();
- var s = matrix.GetScale();
-
- XY = lineCollider.XY + t.xy;
- ZLow = lineCollider.ZLow + t.z;
- ZHigh = lineCollider.ZHigh + t.z;
- if (s.z > Collider.Tolerance) {
- var height = ZHigh - ZLow;
- var zMid = ZLow + height * 0.5f;
- var halfHeightScaled = height * s.z * 0.5f;
- ZLow = zMid - halfHeightScaled;
- ZHigh = zMid + halfHeightScaled;
- }
+ var p1 = matrix.MultiplyPoint(new float3(lineCollider.XY, lineCollider.ZLow));
+ var p2 = matrix.MultiplyPoint(new float3(lineCollider.XY, lineCollider.ZHigh));
+
+ XY = p1.xy;
+ ZLow = math.min(p1.z, p2.z);
+ ZHigh = math.max(p1.z, p2.z);
CalculateBounds();
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs
index 3a7994740..8e4449706 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs
@@ -152,7 +152,7 @@ public PointCollider Transform(float4x4 matrix)
public void Transform(PointCollider point, float4x4 matrix)
{
P = matrix.MultiplyPoint(point.P);
- TransformAabb(matrix);
+ Bounds = new ColliderBounds(Header.ItemId, Header.Id, new Aabb(P, P));
}
public Aabb GetTransformedAabb(float4x4 matrix)
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs
index e0dfb9fb6..260b8d9f9 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs
@@ -169,8 +169,9 @@ private static float max(params float[] values)
public readonly override bool Equals(object obj)
{
- if (obj is Aabb)
- return Equals(obj);
+ if (obj is Aabb other) {
+ return Equals(other);
+ }
return false;
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs
index c34af4011..7db433353 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs
@@ -98,8 +98,9 @@ public readonly bool Equals(ColliderHeader other)
public override readonly bool Equals(object obj)
{
- if (obj is ColliderHeader)
- return Equals(obj);
+ if (obj is ColliderHeader other) {
+ return Equals(other);
+ }
return false;
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs
index 41b7ef67e..015af599b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs
@@ -23,6 +23,7 @@ public struct CollisionEventData
public float HitTime;
public float3 HitNormal;
public float2 HitVelocity;
+ private float _hitVelocityZ;
public float HitDistance;
public bool HitFlag;
public float HitOrgNormalVelocity;
@@ -58,6 +59,13 @@ public void ClearCollider()
{
ColliderId = -1;
BallId = 0;
+ ClearHitVelocity();
+ }
+
+ public void ClearHitVelocity()
+ {
+ HitVelocity = float2.zero;
+ _hitVelocityZ = 0f;
}
public bool HasCollider()
@@ -68,7 +76,9 @@ public bool HasCollider()
public void Transform(float4x4 matrix)
{
HitNormal = matrix.MultiplyVector(HitNormal);
- HitVelocity = matrix.MultiplyVector(new float3(HitVelocity, 0)).xy;
+ var transformedHitVelocity = matrix.MultiplyVector(new float3(HitVelocity, _hitVelocityZ));
+ HitVelocity = transformedHitVelocity.xy;
+ _hitVelocityZ = transformedHitVelocity.z;
}
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
index ae9d9d2fd..4f7aeabc6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
@@ -145,7 +145,7 @@ the angular velocity used here is not the angular velocity that the ball has (wh
*/
}
- public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero);
+ public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero);
public override string ToString()
{
@@ -154,6 +154,13 @@ public override string ToString()
public void Transform(float4x4 matrix)
{
+ var scale = matrix.GetScale();
+ var hasUniformScale = Collider.HasEqualScale(scale.x, scale.y)
+ && Collider.HasEqualScale(scale.x, scale.z);
+ if (hasUniformScale) {
+ Radius *= scale.x;
+ }
+
Position = matrix.MultiplyPoint(Position);
EventPosition = matrix.MultiplyPoint(EventPosition);
Velocity = matrix.MultiplyVector(Velocity);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
index 465759a45..74b04cac7 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
@@ -218,7 +218,7 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float
var rotQuaternion = new quaternion(rotMatrix);
ballData.Velocity = math.mul(rotQuaternion, velocity);
- ballData.IsFrozen = false;
+ ballData.IsFrozen = false;
ballData.AngularMomentum = float3.zero;
// update collision event
@@ -226,7 +226,7 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float
collEvent.HitDistance = 0.0f;
collEvent.HitTime = -1.0f;
collEvent.HitNormal = float3.zero;
- collEvent.HitVelocity = float2.zero;
+ collEvent.ClearHitVelocity();
collEvent.HitFlag = false;
collEvent.IsContact = false;