From df1b9474a5b20cdeabe43de74b23a1e5c2e6d083 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 02:54:23 +0200 Subject: [PATCH 1/6] physics: add analytic ball contact friction tests Exercise sustained contact through the public handler using production gravity and contact-step ordering. Cover analytic rolling transitions, incline acceleration, invariance, slip classification, and energy behavior. The legacy solver fails seven of nine cases, establishing the Phase 1 regression baseline. --- .../Physics/BallFrictionTests.cs | 295 ++++++++++++++++++ .../Physics/BallFrictionTests.cs.meta | 2 + 2 files changed, 297 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs new file mode 100644 index 000000000..098113a6e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs @@ -0,0 +1,295 @@ +// 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; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Test +{ + public class BallFrictionTests + { + private const float Friction = 0.2f; + private const float GravityMagnitude = 1f; + private const float Radius = 25f; + private const float Mass = 1f; + private const float RollingTolerance = 1e-4f; + + private static readonly float3 Gravity = new(0f, 0f, -GravityMagnitude); + private static readonly float3 LevelNormal = new(0f, 0f, 1f); + private static readonly float3 LevelTangent = new(1f, 0f, 0f); + private static readonly PhysicsMaterialData Material = new() { Friction = Friction }; + + [Test] + public void SlidingSphereReachesAnalyticRollingState() + { + const float initialSpeed = 4f; + var ball = CreateBall(Mass, Radius, initialSpeed * LevelTangent); + var expectedTransitionTime = 2f * initialSpeed / (7f * Friction * GravityMagnitude); + var transitionTime = float.NaN; + + for (var frame = 0; frame < 100; frame++) { + StepFrame(ref ball, in LevelNormal, in Gravity, PhysicsConstants.PhysFactor); + if (TangentialSlipSpeed(in ball, in LevelNormal) <= RollingTolerance) { + transitionTime = (frame + 1) * PhysicsConstants.PhysFactor; + break; + } + } + + Assert.That(transitionTime, Is.Not.NaN, "the sliding ball never reached rolling contact"); + Assert.That(transitionTime, Is.EqualTo(expectedTransitionTime).Within(PhysicsConstants.PhysFactor)); + Assert.That(math.dot(ball.Velocity, LevelTangent), Is.EqualTo(5f / 7f * initialSpeed).Within(1e-4f)); + Assert.That(math.dot(ball.Velocity, LevelNormal), Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void PureRollingSphereDoesNotDecayWhenCrrIsZero() + { + const float initialSpeed = 3f; + var ball = CreateRollingBall(Mass, Radius, in LevelNormal, in LevelTangent, initialSpeed); + var initialVelocity = ball.Velocity; + var initialAngularMomentum = ball.AngularMomentum; + + for (var frame = 0; frame < 100; frame++) { + StepFrame(ref ball, in LevelNormal, in Gravity, PhysicsConstants.PhysFactor); + } + + Assert.That(math.dot(ball.Velocity, LevelTangent), + Is.EqualTo(math.dot(initialVelocity, LevelTangent)).Within(1e-5f)); + Assert.That(math.dot(ball.Velocity, LevelNormal), Is.EqualTo(0f).Within(1e-5f), + "sustained contact must not create normal velocity"); + AssertFloat3(ball.AngularMomentum, initialAngularMomentum, 1e-5f); + Assert.That(TangentialSlipSpeed(in ball, in LevelNormal), Is.LessThanOrEqualTo(RollingTolerance)); + } + + [Test] + public void PureRollingSphereHasAnalyticInclineAcceleration() + { + const float angleDeg = 10f; + const float initialSpeed = 2f; + const int frames = 100; + Incline(angleDeg, out var normal, out var tangent); + var ball = CreateRollingBall(Mass, Radius, in normal, in tangent, initialSpeed); + + SimulateFrames(ref ball, in normal, in Gravity, frames); + + var elapsed = frames * PhysicsConstants.PhysFactor; + var expectedSpeed = initialSpeed + 5f / 7f * GravityMagnitude + * math.sin(math.radians(angleDeg)) * elapsed; + Assert.That(math.dot(ball.Velocity, tangent), Is.EqualTo(expectedSpeed).Within(expectedSpeed * 0.005f)); + Assert.That(math.dot(ball.Velocity, normal), Is.EqualTo(0f).Within(1e-5f)); + Assert.That(TangentialSlipSpeed(in ball, in normal), Is.LessThanOrEqualTo(RollingTolerance)); + } + + [Test] + public void ContactResultIsMassInvariant() + { + const float gravityMagnitude = 0.2f; + const float angleDeg = 20f; + const int frames = 500; + var gravity = new float3(0f, 0f, -gravityMagnitude); + Incline(angleDeg, out var normal, out var tangent); + var reference = SimulateIncline(Mass, Radius, in normal, in tangent, in gravity, frames); + var slidingReference = SimulateSliding(Mass, Radius, in gravity, frames); + + foreach (var mass in new[] { 0.5f, 2f, 5f }) { + var result = SimulateIncline(mass, Radius, in normal, in tangent, in gravity, frames); + var slidingResult = SimulateSliding(mass, Radius, in gravity, frames); + Assert.That(math.dot(result.Velocity, tangent), + Is.EqualTo(math.dot(reference.Velocity, tangent)).Within(1e-4f), $"mass {mass}"); + Assert.That(math.dot(result.AngularMomentum / result.Inertia, math.cross(normal, tangent)), + Is.EqualTo(math.dot(reference.AngularMomentum / reference.Inertia, math.cross(normal, tangent))) + .Within(1e-4f), $"mass {mass}"); + Assert.That(math.dot(slidingResult.Velocity, LevelTangent), + Is.EqualTo(math.dot(slidingReference.Velocity, LevelTangent)).Within(1e-4f), + $"sliding mass {mass}"); + Assert.That(math.dot(slidingResult.AngularMomentum / slidingResult.Inertia, new float3(0f, 1f, 0f)), + Is.EqualTo(math.dot(slidingReference.AngularMomentum / slidingReference.Inertia, + new float3(0f, 1f, 0f))).Within(1e-4f), $"sliding mass {mass}"); + } + } + + [Test] + public void ContactResultHasExpectedRadiusScaling() + { + const float angleDeg = 10f; + const float initialSpeed = 2f; + const int frames = 100; + Incline(angleDeg, out var normal, out var tangent); + var reference = SimulateIncline(Mass, Radius, in normal, in tangent, in Gravity, frames, initialSpeed); + var referenceSpeed = math.dot(reference.Velocity, tangent); + + foreach (var radius in new[] { 10f, 50f }) { + var result = SimulateIncline(Mass, radius, in normal, in tangent, in Gravity, frames, initialSpeed); + var rollingAxis = math.cross(normal, tangent); + var angularSpeed = math.dot(result.AngularMomentum / result.Inertia, rollingAxis); + Assert.That(math.dot(result.Velocity, tangent), Is.EqualTo(referenceSpeed).Within(1e-4f), $"radius {radius}"); + Assert.That(angularSpeed * radius, Is.EqualTo(referenceSpeed).Within(1e-4f), $"radius {radius}"); + } + } + + [Test] + public void ContactResultIsSubstepInvariant() + { + var fullStep = CreateBall(Mass, Radius, 4f * LevelTangent); + var splitStep = fullStep; + + BallVelocityPhysics.UpdateVelocities(ref fullStep, Gravity, float2.zero); + SolveContact(ref fullStep, in LevelNormal, in Gravity, PhysicsConstants.PhysFactor); + + BallVelocityPhysics.UpdateVelocities(ref splitStep, Gravity, float2.zero); + SolveContact(ref splitStep, in LevelNormal, in Gravity, PhysicsConstants.PhysFactor * 0.5f); + SolveContact(ref splitStep, in LevelNormal, in Gravity, PhysicsConstants.PhysFactor * 0.5f); + + // Include the normal channel: a second contact slice must not apply the + // original approach correction again. + AssertFloat3(splitStep.Velocity, fullStep.Velocity, 1e-5f); + AssertFloat3(splitStep.AngularMomentum, fullStep.AngularMomentum, 1e-5f); + + Incline(10f, out var normal, out var tangent); + fullStep = CreateRollingBall(Mass, Radius, in normal, in tangent, 2f); + splitStep = fullStep; + + BallVelocityPhysics.UpdateVelocities(ref fullStep, Gravity, float2.zero); + SolveContact(ref fullStep, in normal, in Gravity, PhysicsConstants.PhysFactor); + + BallVelocityPhysics.UpdateVelocities(ref splitStep, Gravity, float2.zero); + SolveContact(ref splitStep, in normal, in Gravity, PhysicsConstants.PhysFactor * 0.5f); + SolveContact(ref splitStep, in normal, in Gravity, PhysicsConstants.PhysFactor * 0.5f); + + AssertFloat3(splitStep.Velocity, fullStep.Velocity, 1e-5f); + AssertFloat3(splitStep.AngularMomentum, fullStep.AngularMomentum, 1e-5f); + } + + [Test] + public void SlipClassificationDoesNotDependOnNormalPreload() + { + var gravity = new float3(0f, 0f, -0.2f); + var lowPreload = CreateBall(Mass, Radius, 4f * LevelTangent + 0.02f * LevelNormal); + var highPreload = CreateBall(Mass, Radius, 4f * LevelTangent + 0.03f * LevelNormal); + + SolveContact(ref lowPreload, in LevelNormal, in gravity, PhysicsConstants.PhysFactor); + SolveContact(ref highPreload, in LevelNormal, in gravity, PhysicsConstants.PhysFactor); + + var lowTangentialSpeed = math.dot(lowPreload.Velocity, LevelTangent); + var highTangentialSpeed = math.dot(highPreload.Velocity, LevelTangent); + Assert.That(lowTangentialSpeed, Is.LessThan(4f)); + Assert.That(highTangentialSpeed, Is.LessThan(4f)); + Assert.That(lowTangentialSpeed, Is.EqualTo(highTangentialSpeed).Within(1e-5f)); + } + + [TestCase(0f)] + [TestCase(10f)] + public void RestingContactDoesNotGainEnergy(float angleDeg) + { + Incline(angleDeg, out var normal, out _); + var ball = CreateBall(Mass, Radius, float3.zero); + + BallVelocityPhysics.UpdateVelocities(ref ball, Gravity, float2.zero); + var energyAfterExternalAcceleration = KineticEnergy(in ball); + SolveContact(ref ball, in normal, in Gravity, PhysicsConstants.PhysFactor); + + Assert.That(KineticEnergy(in ball), Is.LessThanOrEqualTo(energyAfterExternalAcceleration + 1e-6f)); + } + + private static BallState SimulateIncline(float mass, float radius, in float3 normal, in float3 tangent, + in float3 gravity, int frames, float initialSpeed = 0f) + { + var ball = CreateRollingBall(mass, radius, in normal, in tangent, initialSpeed); + SimulateFrames(ref ball, in normal, in gravity, frames); + return ball; + } + + private static BallState SimulateSliding(float mass, float radius, in float3 gravity, int frames) + { + var ball = CreateBall(mass, radius, 4f * LevelTangent); + SimulateFrames(ref ball, in LevelNormal, in gravity, frames); + return ball; + } + + private static void SimulateFrames(ref BallState ball, in float3 normal, in float3 gravity, int frames) + { + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref ball, in normal, in gravity, PhysicsConstants.PhysFactor); + } + } + + private static void StepFrame(ref BallState ball, in float3 normal, in float3 gravity, float contactTime) + { + BallVelocityPhysics.UpdateVelocities(ref ball, gravity, float2.zero); + SolveContact(ref ball, in normal, in gravity, contactTime); + } + + private static void SolveContact(ref BallState ball, in float3 normal, in float3 gravity, float contactTime) + { + var collEvent = new CollisionEventData { + IsContact = true, + HitNormal = normal, + HitOrgNormalVelocity = math.dot(ball.Velocity, normal) + }; + BallCollider.HandleStaticContact(ref ball, in collEvent, Material.Friction, contactTime, in gravity, float3.zero); + } + + private static BallState CreateBall(float mass, float radius, in float3 velocity) + { + return new BallState { + Id = 1, + Position = radius * LevelNormal, + Velocity = velocity, + Radius = radius, + Mass = mass + }; + } + + private static BallState CreateRollingBall(float mass, float radius, in float3 normal, in float3 tangent, + float speed) + { + var ball = CreateBall(mass, radius, speed * tangent); + var rollingAxis = math.normalize(math.cross(normal, tangent)); + ball.AngularMomentum = ball.Inertia * speed / radius * rollingAxis; + return ball; + } + + private static float TangentialSlipSpeed(in BallState ball, in float3 normal) + { + var surfacePoint = -ball.Radius * normal; + var surfaceVelocity = BallState.SurfaceVelocity(in ball, in surfacePoint); + var tangentialSlip = surfaceVelocity - normal * math.dot(surfaceVelocity, normal); + return math.length(tangentialSlip); + } + + private static float KineticEnergy(in BallState ball) + { + var angularSpeedSq = math.lengthsq(ball.AngularMomentum / ball.Inertia); + return 0.5f * ball.Mass * math.lengthsq(ball.Velocity) + + 0.5f * ball.Inertia * angularSpeedSq; + } + + private static void Incline(float angleDeg, out float3 normal, out float3 downhillTangent) + { + var angle = math.radians(angleDeg); + normal = new float3(math.sin(angle), 0f, math.cos(angle)); + downhillTangent = new float3(math.cos(angle), 0f, -math.sin(angle)); + } + + private static void AssertFloat3(in float3 actual, in float3 expected, float tolerance) + { + 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/BallFrictionTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs.meta new file mode 100644 index 000000000..2ce1fd1f4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc0561ae1dccc3742bd60ee6822ef679 \ No newline at end of file From 69152ed6edd923bc66b20de9ef07b927f3ab0257 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 03:06:32 +0200 Subject: [PATCH 2/6] physics: normalize sustained ball contact impulses Separate normal correction from steady support and express Coulomb changes as impulses with gravity consistently treated as acceleration. Select static or kinetic friction from tangential slip, validate ball mass and radius at creation boundaries, and add flipper, rubber, separation, and invalid-state regressions. --- .../Physics/BallContactRegressionTests.cs | 170 ++++++++++++++++++ .../BallContactRegressionTests.cs.meta | 2 + .../VPT/Ball/BallCollider.cs | 82 ++++++--- .../VPT/Ball/BallComponent.cs | 14 ++ .../VPT/Ball/BallManager.cs | 1 + .../VisualPinball.Unity/VPT/Ball/BallState.cs | 4 +- 6 files changed, 243 insertions(+), 30 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs new file mode 100644 index 000000000..94550d7d5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs @@ -0,0 +1,170 @@ +// 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 NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + public class BallContactRegressionTests + { + private static readonly float3 ContactNormal = new(1f, 0f, 0f); + + [Test] + public void NonUnitMassFlipperContactIsMassInvariant() + { + var lightBall = SolveFlipperContact(0.5f); + var heavyBall = SolveFlipperContact(2f); + + AssertFloat3(lightBall.Velocity, heavyBall.Velocity); + AssertFloat3(lightBall.AngularMomentum / lightBall.Inertia, + heavyBall.AngularMomentum / heavyBall.Inertia); + } + + [Test] + public void ClearlySeparatingContactIsIgnoredAfterAnotherCollision() + { + var initialVelocity = new float3(4f, 0f, PhysicsConstants.ContactVel + 0.001f); + var ball = CreateBall(1f, initialVelocity); + var collEvent = new CollisionEventData { + IsContact = true, + HitNormal = new float3(0f, 0f, 1f), + HitOrgNormalVelocity = -0.05f + }; + var gravity = new float3(0f, 0f, -1f); + + BallCollider.HandleStaticContact(ref ball, in collEvent, 0.2f, + PhysicsConstants.PhysFactor, in gravity, float3.zero); + + AssertFloat3(ball.Velocity, initialVelocity); + AssertFloat3(ball.AngularMomentum, float3.zero); + } + + [Test] + public void LowNormalVelocityRubberContactUsesKineticFriction() + { + const float initialTangentialSpeed = 4f; + var normal = new float3(0f, 0f, 1f); + var tangent = new float3(1f, 0f, 0f); + var ball = CreateBall(1f, initialTangentialSpeed * tangent + 0.02f * normal); + var header = new ColliderHeader { + Type = ColliderType.Triangle, + ItemType = ItemType.Rubber, + Material = new PhysicsMaterialData { Friction = 0.8f } + }; + var collEvent = new CollisionEventData { + IsContact = true, + HitNormal = normal, + HitOrgNormalVelocity = math.dot(ball.Velocity, normal) + }; + var gravity = new float3(0f, 0f, -0.2f); + + Collider.Contact(in header, ref ball, in collEvent, PhysicsConstants.PhysFactor, + in gravity, float3.zero); + + var surfacePoint = -ball.Radius * normal; + var surfaceVelocity = BallState.SurfaceVelocity(in ball, in surfacePoint); + var tangentialSlip = surfaceVelocity - normal * math.dot(surfaceVelocity, normal); + Assert.That(math.length(tangentialSlip), Is.LessThan(initialTangentialSpeed)); + } + + [TestCase(0f)] + [TestCase(-1f)] + [TestCase(float.NaN)] + [TestCase(float.PositiveInfinity)] + public void BallStateCreationRejectsNonPositiveRadius(float radius) + { + var gameObject = new GameObject("Invalid ball radius"); + try { + var component = gameObject.AddComponent(); + component.Radius = radius; + Assert.Throws(() => component.CreateState()); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [TestCase(0f)] + [TestCase(-1f)] + [TestCase(float.NaN)] + [TestCase(float.PositiveInfinity)] + public void BallStateCreationRejectsNonPositiveMass(float mass) + { + var gameObject = new GameObject("Invalid ball mass"); + try { + var component = gameObject.AddComponent(); + component.Mass = mass; + Assert.Throws(() => component.CreateState()); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + private static BallState SolveFlipperContact(float mass) + { + // Align the contact arm with the normal and disable friction so this + // regression isolates the ball surface-acceleration units. + var info = new ColliderInfo { + Id = 1, + ItemId = 1, + Material = new PhysicsMaterialData { Friction = 0f } + }; + var flipper = new FlipperCollider(50f, 100f, 20f, 10f, 0f, 60f, info); + var ball = CreateBall(mass, float3.zero); + ball.Position = new float3(45f, 0f, 25f); + var gravity = -ContactNormal; + BallVelocityPhysics.UpdateVelocities(ref ball, gravity, float2.zero); + var collEvent = new CollisionEventData { + IsContact = true, + HitNormal = ContactNormal, + HitOrgNormalVelocity = math.dot(ball.Velocity, ContactNormal) + }; + var movement = new FlipperMovementState(); + var staticData = new FlipperStaticData { + Inertia = 1f, + EndRadius = 10f, + FlipperRadius = 100f + }; + var velocityData = new FlipperVelocityData(); + + flipper.Contact(ref ball, ref movement, in collEvent, in staticData, + in velocityData, PhysicsConstants.PhysFactor, in gravity); + return ball; + } + + private static BallState CreateBall(float mass, in float3 velocity) + { + return new BallState { + Id = 1, + Position = new float3(0f, 0f, 25f), + Velocity = velocity, + Radius = 25f, + Mass = mass + }; + } + + private static void AssertFloat3(in float3 actual, in float3 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(1e-5f)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(1e-5f)); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(1e-5f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs.meta new file mode 100644 index 000000000..22124aae1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c45bc581add9cde4293896a420edad50 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs index 4385ce03e..817c2b419 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs @@ -144,29 +144,42 @@ public static void HandleStaticContact(ref BallState ball, in CollisionEventData { // this should be zero, but only up to +/- PhysicsConstants.ContactVel // (relative to the surface, which may be moving if the collider is kinematic) - var normVel = math.dot(ball.Velocity - colliderVelocity, collEvent.HitNormal); + var relativeNormalVelocity = math.dot(ball.Velocity - colliderVelocity, collEvent.HitNormal); - // If some collision has changed the ball's velocity, we may not have to do anything. - if (normVel <= PhysicsConstants.ContactVel) { - - // external forces (only gravity for now) - var fe = gravity * ball.Mass; - var dot = math.dot(fe, collEvent.HitNormal); - - // note: for kinematic colliders, HitOrgNormalVelocity is already relative to the - // surface — the narrow phase hit-tests in the collider's reference frame - - // normal force is always nonnegative - var normalForce = math.max(0.0f, -(dot * dTime + collEvent.HitOrgNormalVelocity)); + // Another collision can make a previously detected contact separate before it is + // solved. In that case neither penetration correction nor friction is needed. + var isClearlySeparating = relativeNormalVelocity > PhysicsConstants.ContactVel; + if (isClearlySeparating) { + return; + } - // Add just enough to kill original normal velocity and counteract the external forces. - ball.Velocity += collEvent.HitNormal * normalForce; + var supportImpulse = SolveNormalContact(ref ball, in collEvent, dTime, in gravity, + in colliderVelocity, relativeNormalVelocity); + ApplyCoulombContactImpulse(ref ball, collEvent.HitNormal, dTime, friction, supportImpulse, + gravity, colliderVelocity); + } - ApplyFriction(ref ball, collEvent.HitNormal, dTime, friction, gravity, colliderVelocity); - } + private static float SolveNormalContact(ref BallState ball, in CollisionEventData collEvent, + float dTime, in float3 gravity, in float3 colliderVelocity, float relativeNormalVelocity) + { + // Gravity is integrated once at the start of the physics frame, before contact + // detection. The stored velocity therefore already contains that acceleration; + // adding gravity again to the correction would apply the support impulse twice. + // Use the most approaching of the stored and current relative velocities so a + // later collision cannot leave the ball moving into the surface. + var approachVelocity = math.min(collEvent.HitOrgNormalVelocity, relativeNormalVelocity); + var correctionImpulse = ball.Mass * math.max(0f, -approachVelocity); + ball.Velocity += correctionImpulse * ball.InvMass * collEvent.HitNormal; + + // The steady support load is independent of the transient penetration + // correction. Constant-velocity kinematic surfaces have the same support load + // as static surfaces because no surface acceleration is currently available. + var relativeNormalAcceleration = math.dot(gravity, collEvent.HitNormal); + return ball.Mass * math.max(0f, -relativeNormalAcceleration) * math.max(0f, dTime); } - private static void ApplyFriction(ref BallState ball, in float3 hitNormal, float dTime, float frictionCoeff, in float3 gravity, in float3 colliderVelocity) + private static void ApplyCoulombContactImpulse(ref BallState ball, in float3 hitNormal, + float dTime, float frictionCoeff, float supportImpulse, in float3 gravity, in float3 colliderVelocity) { // surface contact point relative to center of mass var surfP = -ball.Radius * hitNormal; @@ -179,16 +192,24 @@ private static void ApplyFriction(ref BallState ball, in float3 hitNormal, float // calc the tangential slip velocity var slip = surfVel - hitNormal * math.dot(surfVel, hitNormal); - var maxFriction = frictionCoeff * ball.Mass * -math.dot(gravity, hitNormal); + var maxFrictionImpulse = math.max(0f, frictionCoeff) * supportImpulse; + if (maxFrictionImpulse <= 0f) { + return; + } var slipSpeed = math.length(slip); float3 slipDir; float numer; - var normVel = math.dot(ball.Velocity - colliderVelocity, hitNormal); - if (normVel <= 0.025 || slipSpeed < PhysicsConstants.Precision) { - // check for <=0.025 originated from ball<->rubber collisions pushing the ball upwards, but this is still not enough, some could even use <=0.2 - // slip speed zero - static friction case + if (slipSpeed < PhysicsConstants.Precision) { + // External acceleration is integrated before contact detection. Exact no-slip + // therefore means an earlier contact substep already supplied the needed + // impulse; applying the acceleration correction again would make the result + // depend on how the frame was partitioned. + if (slipSpeed < 1e-6f) { + return; + } + // near-zero slip - static friction case var surfAcc = BallState.SurfaceAcceleration(in ball, in surfP, in gravity); // calc the tangential slip acceleration @@ -200,7 +221,9 @@ private static void ApplyFriction(ref BallState ball, in float3 hitNormal, float } slipDir = math.normalize(slipAcc); - numer = -math.dot(slipDir, surfAcc); + // Convert the force needed to cancel tangential acceleration into an + // impulse over this contact substep. + numer = -math.dot(slipDir, surfAcc) * dTime; } else { // nonzero slip speed - dynamic friction case @@ -209,11 +232,14 @@ private static void ApplyFriction(ref BallState ball, in float3 hitNormal, float } var cp = math.cross(surfP, slipDir); - var denom = 1.0f / ball.Mass + math.dot(slipDir, math.cross(cp / ball.Inertia, surfP)); - var friction = math.clamp(numer / denom, -maxFriction, maxFriction); + var denom = ball.InvMass + math.dot(slipDir, math.cross(cp / ball.Inertia, surfP)); + if (denom <= 0f || float.IsNaN(denom) || float.IsInfinity(denom)) { + return; + } + var frictionImpulse = math.clamp(numer / denom, -maxFrictionImpulse, maxFrictionImpulse); - if (!float.IsNaN(friction) && !float.IsInfinity(friction)) { - ball.ApplySurfaceImpulse(dTime * friction * cp, dTime * friction * slipDir); + if (!float.IsNaN(frictionImpulse) && !float.IsInfinity(frictionImpulse)) { + ball.ApplySurfaceImpulse(frictionImpulse * cp, frictionImpulse * slipDir); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs index ca315f2c3..4c059236b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs @@ -16,6 +16,7 @@ // ReSharper disable InconsistentNaming +using System; using Unity.Mathematics; using UnityEngine; @@ -31,6 +32,7 @@ public class BallComponent : MonoBehaviour internal BallState CreateState() { + ValidatePhysicsProperties(Radius, Mass); var pos = transform.localPosition.TranslateToVpx(); return new BallState { Id = Id, @@ -46,6 +48,18 @@ internal BallState CreateState() }; } + internal static void ValidatePhysicsProperties(float radius, float mass) + { + if (radius <= 0f || float.IsNaN(radius) || float.IsInfinity(radius)) { + throw new ArgumentOutOfRangeException(nameof(radius), radius, + "Ball radius must be finite and greater than zero."); + } + if (mass <= 0f || float.IsNaN(mass) || float.IsInfinity(mass)) { + throw new ArgumentOutOfRangeException(nameof(mass), mass, + "Ball mass must be finite and greater than zero."); + } + } + public void Move(BallState ball) { var ballTransform = transform; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs index 3492b5da9..7c2c9694c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs @@ -44,6 +44,7 @@ public BallManager(PhysicsEngine physicsEngine, Player player, Transform parent) public int CreateBall(IBallCreationPosition ballCreator, float radius = 25f, float mass = 1f, GameObject ballPrefab = null) { + BallComponent.ValidatePhysicsProperties(radius, mass); var localPos = ballCreator.GetBallCreationPosition().ToUnityFloat3(); localPos.z += radius; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 4f7aeabc6..b8393f00b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -129,11 +129,11 @@ public static float3 SurfaceAcceleration(in BallState ball, in float3 surfP, in var currentAngularVelocity = ball.AngularMomentum / ball.Inertia; // if we had any external torque, we would have to add "(deriv. of ang.Vel.) x surfP" here - return gravity / ball.Mass // linear acceleration + return gravity // linear acceleration + math.cross(currentAngularVelocity, math.cross(currentAngularVelocity, surfP)); // centripetal acceleration /* This was (from freezy's first implementation): - return gravity / ball.Mass // linear acceleration + return gravity // linear acceleration + math.cross(ball.angularVelocity, math.cross(ball.angularVelocity, surfP)); // centripetal acceleration * Original Code: const Vertex3Ds angularvelocity = m_angularmomentum / Inertia(); From d8babcddcf520839daa83b105ba0277bd50ba610 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 03:28:25 +0200 Subject: [PATCH 3/6] physics: add rolling resistance material data Add dimensionless Crr values to runtime materials, assets, and supported collider overrides while keeping unsupported specialized contacts inert. Expose authoring controls, preserve values through .vpe package serializers, and verify old JSON defaults plus nonzero round trips. Existing and imported tables retain a zero default. --- .../VPT/HitTarget/TargetColliderInspector.cs | 4 + .../MetalWireGuideColliderInspector.cs | 4 + .../VPT/PhysicsMaterialInspector.cs | 6 + .../Playfield/PlayfieldColliderInspector.cs | 4 + .../Primitive/PrimitiveColliderInspector.cs | 4 + .../VPT/Ramp/RampColliderInspector.cs | 4 + .../VPT/Rubber/RubberColliderInspector.cs | 4 + .../VPT/Surface/SurfaceColliderInspector.cs | 4 + .../Physics/PhysicsMaterialTests.cs | 110 ++++++++++++++++++ .../Physics/PhysicsMaterialTests.cs.meta | 2 + .../Packaging/CommonPackables.cs | 3 + .../Physics/Collision/PhysicsMaterialData.cs | 9 +- .../VPT/Bumper/BumperColliderComponent.cs | 5 + .../VPT/ColliderComponent.cs | 4 + .../VPT/Flipper/FlipperColliderComponent.cs | 7 ++ .../VPT/Gate/GateColliderComponent.cs | 5 + .../HitTarget/DropTargetColliderComponent.cs | 9 ++ .../VPT/HitTarget/DropTargetPackable.cs | 2 + .../HitTarget/HitTargetColliderComponent.cs | 9 ++ .../VPT/HitTarget/HitTargetPackable.cs | 2 + .../VPT/ICollidableComponent.cs | 1 + .../VPT/Kicker/KickerColliderComponent.cs | 5 + .../MetalWireGuideColliderComponent.cs | 9 ++ .../VPT/PhysicsMaterialAsset.cs | 9 ++ .../Playfield/PlayfieldColliderComponent.cs | 9 ++ .../VPT/Plunger/PlungerColliderComponent.cs | 5 + .../Primitive/PrimitiveColliderComponent.cs | 9 ++ .../VPT/Primitive/PrimitivePackable.cs | 2 + .../VPT/Ramp/RampColliderComponent.cs | 9 ++ .../VPT/Rubber/RubberColliderComponent.cs | 9 ++ .../VPT/Spinner/SpinnerColliderComponent.cs | 5 + .../VPT/Surface/SurfaceColliderComponent.cs | 9 ++ .../VPT/Trigger/TriggerColliderComponent.cs | 5 + 33 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsMaterialTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsMaterialTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetColliderInspector.cs index 9f8d939b3..af34099b9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/HitTarget/TargetColliderInspector.cs @@ -34,6 +34,7 @@ public abstract class TargetColliderInspector : ColliderInsp private SerializedProperty _elasticityProperty; private SerializedProperty _elasticityFalloffProperty; private SerializedProperty _frictionProperty; + private SerializedProperty _rollingResistanceProperty; private SerializedProperty _scatterProperty; protected override void OnEnable() @@ -49,6 +50,8 @@ protected override void OnEnable() _elasticityProperty = serializedObject.FindProperty(nameof(HitTargetColliderComponent.Elasticity)); _elasticityFalloffProperty = serializedObject.FindProperty(nameof(HitTargetColliderComponent.ElasticityFalloff)); _frictionProperty = serializedObject.FindProperty(nameof(HitTargetColliderComponent.Friction)); + _rollingResistanceProperty = serializedObject.FindProperty( + nameof(HitTargetColliderComponent.RollingResistance)); _scatterProperty = serializedObject.FindProperty(nameof(HitTargetColliderComponent.Scatter)); } @@ -88,6 +91,7 @@ public override void OnInspectorGUI() PropertyField(_elasticityProperty); PropertyField(_elasticityFalloffProperty); PropertyField(_frictionProperty); + PropertyField(_rollingResistanceProperty); PropertyField(_scatterProperty, "Scatter Angle"); EditorGUI.EndDisabledGroup(); } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideColliderInspector.cs index ea584b099..49fe8a912 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideColliderInspector.cs @@ -32,6 +32,7 @@ public class MetalWireGuideColliderInspector : ColliderInspector. + +using System.Text; +using NUnit.Framework; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class PhysicsMaterialTests + { + private const string PreRollingResistancePhysicalMaterialJson = + "{\"Elasticity\":0.3,\"ElasticityFalloff\":0.1,\"Friction\":0.2," + + "\"Scatter\":1.5,\"Overwrite\":true,\"AssetRef\":0}"; + + [Test] + public void PhysicsMaterialDataEqualityIncludesRollingResistance() + { + var first = new PhysicsMaterialData { RollingResistance = 0.01f }; + var second = new PhysicsMaterialData { RollingResistance = 0.02f }; + + Assert.That(first, Is.Not.EqualTo(second)); + Assert.That(first.Equals((object)second), Is.False); + + second.RollingResistance = first.RollingResistance; + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.Equals((object)second), Is.True); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + [TestCase(-0.01f)] + [TestCase(float.NaN)] + [TestCase(float.PositiveInfinity)] + [TestCase(float.NegativeInfinity)] + public void InvalidRollingResistanceSanitizesToZero(float value) + { + Assert.That(PhysicsMaterialData.SanitizeRollingResistance(value), Is.Zero); + } + + [Test] + public void ValidRollingResistanceIsPreserved() + { + Assert.That(PhysicsMaterialData.SanitizeRollingResistance(0.02f), Is.EqualTo(0.02f)); + } + + [Test] + public void ColliderMaterialSelectionUsesAssetOrOverride() + { + var gameObject = new GameObject("Rolling resistance material selection"); + var asset = ScriptableObject.CreateInstance(); + try { + var collider = gameObject.AddComponent(); + asset.RollingResistance = 0.01f; + collider.PhysicsMaterial = asset; + collider.RollingResistance = 0.02f; + + collider.OverwritePhysics = false; + Assert.That(collider.GetPhysicsMaterialData().RollingResistance, Is.EqualTo(0.01f)); + + collider.OverwritePhysics = true; + Assert.That(collider.GetPhysicsMaterialData().RollingResistance, Is.EqualTo(0.02f)); + } finally { + Object.DestroyImmediate(asset); + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void OldPhysicalMaterialPackageDefaultsToZero() + { + var bytes = Encoding.UTF8.GetBytes(PreRollingResistancePhysicalMaterialJson); + + var material = PackageApi.Packer.Unpack(bytes); + + Assert.That(material.RollingResistance, Is.Zero); + } + + [Test] + public void PhysicalMaterialPackageRoundTripsRollingResistance() + { + var expected = new PhysicalMaterialPackable { + Elasticity = 0.3f, + ElasticityFalloff = 0.1f, + Friction = 0.2f, + RollingResistance = 0.015f, + Scatter = 1.5f, + Overwrite = true, + AssetRef = 2 + }; + + var bytes = PackageApi.Packer.Pack(expected); + var actual = PackageApi.Packer.Unpack(bytes); + + Assert.That(actual.RollingResistance, Is.EqualTo(expected.RollingResistance)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsMaterialTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsMaterialTests.cs.meta new file mode 100644 index 000000000..85eda4fb0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsMaterialTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6cf7c326382d8944eb43740235c91e39 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs index 28946de95..e4383a385 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs @@ -106,6 +106,7 @@ public struct PhysicalMaterialPackable public float Elasticity; public float ElasticityFalloff; public float Friction; + public float RollingResistance; public float Scatter; public bool Overwrite; public int AssetRef; @@ -116,6 +117,7 @@ public static byte[] Pack(ICollidableComponent comp, PackagedFiles files) Elasticity = comp.PhysicsElasticity, ElasticityFalloff = comp.PhysicsElasticityFalloff, Friction = comp.PhysicsFriction, + RollingResistance = comp.PhysicsRollingResistance, Scatter = comp.PhysicsScatter, Overwrite = comp.PhysicsOverwrite, AssetRef = files.AddAsset(comp.PhysicsMaterialReference), @@ -128,6 +130,7 @@ public static void Unpack(byte[] bytes, ICollidableComponent comp, PackagedFiles comp.PhysicsElasticity = data.Elasticity; comp.PhysicsElasticityFalloff = data.ElasticityFalloff; comp.PhysicsFriction = data.Friction; + comp.PhysicsRollingResistance = data.RollingResistance; comp.PhysicsScatter = data.Scatter; comp.PhysicsOverwrite = data.Overwrite; comp.PhysicsMaterialReference = files.GetAsset(data.AssetRef); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/PhysicsMaterialData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/PhysicsMaterialData.cs index b10894fd9..26c8833b6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/PhysicsMaterialData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/PhysicsMaterialData.cs @@ -15,6 +15,7 @@ // along with this program. If not, see . using System; +using Unity.Mathematics; namespace VisualPinball.Unity { @@ -23,6 +24,7 @@ public struct PhysicsMaterialData : IEquatable public float Elasticity; public float ElasticityFalloff; public float Friction; + public float RollingResistance; public float ScatterAngleRad; public bool UseElasticityOverVelocity; public bool UseFrictionOverVelocity; @@ -36,6 +38,7 @@ public readonly bool Equals(PhysicsMaterialData other) Elasticity == other.Elasticity && ElasticityFalloff == other.ElasticityFalloff && Friction == other.Friction && + RollingResistance == other.RollingResistance && ScatterAngleRad == other.ScatterAngleRad && UseElasticityOverVelocity == other.UseElasticityOverVelocity && UseFrictionOverVelocity == other.UseFrictionOverVelocity; @@ -51,7 +54,11 @@ public override readonly bool Equals(object obj) public override readonly int GetHashCode() { - return HashCode.Combine(Elasticity, ElasticityFalloff, Friction, ScatterAngleRad, UseElasticityOverVelocity, UseFrictionOverVelocity); + return HashCode.Combine(Elasticity, ElasticityFalloff, Friction, RollingResistance, + ScatterAngleRad, UseElasticityOverVelocity, UseFrictionOverVelocity); } + + internal static float SanitizeRollingResistance(float value) + => math.isfinite(value) ? math.max(0f, value) : 0f; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperColliderComponent.cs index 3d83e74c1..1810d0ab0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperColliderComponent.cs @@ -74,6 +74,11 @@ public override float PhysicsFriction set { } } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs index 7b25f6ed8..fdaeeb07e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs @@ -68,6 +68,7 @@ public abstract class ColliderComponent : SubComponent Friction = value; } + // Rolling resistance is unsupported until the equal-and-opposite + // generalized impulse can be applied to the flipper movement state. + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateColliderComponent.cs index c830a8539..d7d0e5aae 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Gate/GateColliderComponent.cs @@ -93,6 +93,11 @@ public override float PhysicsFriction { set => Friction = value; } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => 0; set { } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs index 685641bd7..82a15f0e0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetColliderComponent.cs @@ -47,6 +47,10 @@ public class DropTargetColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs index a42aafe9a..ad6bcd38b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/DropTargetPackable.cs @@ -65,6 +65,7 @@ public static byte[] PackReferences(DropTargetColliderComponent comp, PackagedFi Elasticity = comp.Elasticity, ElasticityFalloff = comp.ElasticityFalloff, Friction = comp.Friction, + RollingResistance = comp.PhysicsRollingResistance, Scatter = comp.Scatter, Overwrite = comp.OverwritePhysics, AssetRef = files.AddAsset(comp.PhysicsMaterial), @@ -80,6 +81,7 @@ public static void Unpack(byte[] bytes, DropTargetColliderComponent comp, Packag comp.Elasticity = data.PhysicalMaterial.Elasticity; comp.ElasticityFalloff = data.PhysicalMaterial.ElasticityFalloff; comp.Friction = data.PhysicalMaterial.Friction; + comp.PhysicsRollingResistance = data.PhysicalMaterial.RollingResistance; comp.Scatter = data.PhysicalMaterial.Scatter; comp.OverwritePhysics = data.PhysicalMaterial.Overwrite; comp.PhysicsMaterial = files.GetAsset(data.PhysicalMaterial.AssetRef); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetColliderComponent.cs index 32b3f5824..61f0a3ec1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetColliderComponent.cs @@ -44,6 +44,10 @@ public class HitTargetColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetPackable.cs index 38a36869f..f7a4f6421 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/HitTarget/HitTargetPackable.cs @@ -61,6 +61,7 @@ public static byte[] PackReferences(HitTargetColliderComponent comp, PackagedFil Elasticity = comp.Elasticity, ElasticityFalloff = comp.ElasticityFalloff, Friction = comp.Friction, + RollingResistance = comp.PhysicsRollingResistance, Scatter = comp.Scatter, Overwrite = comp.OverwritePhysics, AssetRef = files.AddAsset(comp.PhysicsMaterial), @@ -75,6 +76,7 @@ public static void Unpack(byte[] bytes, HitTargetColliderComponent comp, Package comp.Elasticity = data.PhysicalMaterial.Elasticity; comp.ElasticityFalloff = data.PhysicalMaterial.ElasticityFalloff; comp.Friction = data.PhysicalMaterial.Friction; + comp.PhysicsRollingResistance = data.PhysicalMaterial.RollingResistance; comp.Scatter = data.PhysicalMaterial.Scatter; comp.OverwritePhysics = data.PhysicalMaterial.Overwrite; comp.PhysicsMaterial = files.GetAsset(data.PhysicalMaterial.AssetRef); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs index ddc45c851..01f31948d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs @@ -68,6 +68,7 @@ internal void GetColliders(Player player, PhysicsEngine physicsEngine, ref Colli float PhysicsElasticity { get; set; } float PhysicsElasticityFalloff { get; set; } float PhysicsFriction { get; set; } + float PhysicsRollingResistance { get; set; } float PhysicsScatter { get; set; } bool PhysicsOverwrite { get; set; } PhysicsMaterialAsset PhysicsMaterialReference { get; set; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs index 90185a5c1..804fae373 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerColliderComponent.cs @@ -85,6 +85,11 @@ public override float PhysicsFriction { set { } } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideColliderComponent.cs index 279338d23..0e5630a8f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideColliderComponent.cs @@ -48,6 +48,10 @@ public class MetalWireGuideColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/PhysicsMaterialAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/PhysicsMaterialAsset.cs index c109329a2..8b92648ea 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/PhysicsMaterialAsset.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/PhysicsMaterialAsset.cs @@ -45,9 +45,18 @@ public class PhysicsMaterialAsset : ScriptableObject public bool UseFrictionOverVelocity => FrictionOverVelocity.keys.Length > 0; public AnimationCurve FrictionOverVelocity; + [Min(0f)] + [Tooltip("Dimensionless rolling-resistance coefficient (Crr). Affects sustained rolling, not impacts.")] + public float RollingResistance; + // public AnimationCurve FrictionOverAngularMomentum; public float ScatterAngle; + private void OnValidate() + { + RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + } + /// /// Returns a lookup-table of 128 values.
/// diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldColliderComponent.cs index 6c4f2c68d..1450a1dd6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldColliderComponent.cs @@ -44,6 +44,10 @@ public class PlayfieldColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerColliderComponent.cs index 92f73d7eb..db3b9c7ec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerColliderComponent.cs @@ -86,6 +86,11 @@ public override float PhysicsFriction set { } } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => 0; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitiveColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitiveColliderComponent.cs index 1197e3c40..9bd3708a9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitiveColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitiveColliderComponent.cs @@ -47,6 +47,10 @@ public class PrimitiveColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitivePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitivePackable.cs index 2cac2585d..d60f94ed7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitivePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Primitive/PrimitivePackable.cs @@ -69,6 +69,7 @@ public static byte[] PackReferences(PrimitiveColliderComponent comp, PackagedFil Elasticity = comp.PhysicsElasticity, ElasticityFalloff = comp.PhysicsElasticityFalloff, Friction = comp.PhysicsFriction, + RollingResistance = comp.PhysicsRollingResistance, Scatter = comp.PhysicsScatter, Overwrite = comp.PhysicsOverwrite, AssetRef = files.AddAsset(comp.PhysicsMaterialReference), @@ -84,6 +85,7 @@ public static void Unpack(byte[] bytes, PrimitiveColliderComponent comp, Package comp.PhysicsElasticity = pm.Elasticity; comp.PhysicsElasticityFalloff = pm.ElasticityFalloff; comp.PhysicsFriction = pm.Friction; + comp.PhysicsRollingResistance = pm.RollingResistance; comp.PhysicsScatter = pm.Scatter; comp.PhysicsOverwrite = pm.Overwrite; comp.PhysicsMaterialReference = files.GetAsset(pm.AssetRef); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampColliderComponent.cs index 90da833e0..ff35f0a1a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampColliderComponent.cs @@ -53,6 +53,10 @@ public class RampColliderComponent : ColliderComponent, [Tooltip("Friction of the material.")] public float Friction; + [Min(0f)] + [Tooltip("Dimensionless rolling-resistance coefficient (Crr). Affects sustained rolling, not impacts.")] + public float RollingResistance; + [Range(-90f, 90f)] [Tooltip("When hit, add a random angle between 0 and this value to the trajectory.")] public float Scatter; @@ -88,6 +92,11 @@ public override float PhysicsFriction { set => Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs index 2bf955476..323cce73f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs @@ -49,6 +49,10 @@ public class RubberColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs index d4278ef35..3a74b9d2c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs @@ -69,6 +69,11 @@ public override float PhysicsFriction { set { } } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => 0; set { } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceColliderComponent.cs index 8e96b725f..9c91d7db9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceColliderComponent.cs @@ -60,6 +60,10 @@ public class SurfaceColliderComponent : ColliderComponent Friction = value; } + public override float PhysicsRollingResistance { + get => PhysicsMaterialData.SanitizeRollingResistance(RollingResistance); + set => RollingResistance = PhysicsMaterialData.SanitizeRollingResistance(value); + } + public override float PhysicsScatter { get => Scatter; set => Scatter = value; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderComponent.cs index 6168cdabb..e73c3c9ff 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerColliderComponent.cs @@ -80,6 +80,11 @@ public override float PhysicsFriction { set { } } + public override float PhysicsRollingResistance { + get => 0; + set { } + } + public override float PhysicsScatter { get => 0; set { } From ecd3623c9751e7a813160fa66cdde890a4c3866e Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 04:00:44 +0200 Subject: [PATCH 4/6] physics: apply rolling resistance to contacts Apply a coupled linear and angular impulse once per ball after sustained Coulomb contacts have been solved. Use steady support load and relative surface motion so no-slip rolling decelerates analytically without affecting impacts. Select a deterministic eligible representative across seam, corner, and mixed material contacts to avoid multiplying gravity support. Keep flipper and ball-to-ball rolling resistance disabled until both bodies can be coupled. Cover analytic level rolling, no-slip preservation, contact aggregation, and mixed-contact eligibility with deterministic EditMode tests. --- .../Physics/BallContactRegressionTests.cs | 3 +- .../Physics/BallFrictionTests.cs | 3 +- .../Physics/BallRollingResistanceTests.cs | 219 ++++++++++++++++++ .../BallRollingResistanceTests.cs.meta | 2 + .../VisualPinball.Unity/Game/PhysicsCycle.cs | 1 + .../Physics/Collider/Collider.cs | 6 +- .../Physics/Collision/ContactBufferElement.cs | 52 +++++ .../Physics/Collision/ContactPhysics.cs | 77 +++++- .../VPT/Ball/BallCollider.cs | 99 +++++++- 9 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs index 94550d7d5..5242e12b1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallContactRegressionTests.cs @@ -49,8 +49,9 @@ public void ClearlySeparatingContactIsIgnoredAfterAnotherCollision() HitOrgNormalVelocity = -0.05f }; var gravity = new float3(0f, 0f, -1f); + var material = new PhysicsMaterialData { Friction = 0.2f }; - BallCollider.HandleStaticContact(ref ball, in collEvent, 0.2f, + BallCollider.HandleStaticContact(ref ball, in collEvent, in material, PhysicsConstants.PhysFactor, in gravity, float3.zero); AssertFloat3(ball.Velocity, initialVelocity); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs index 098113a6e..3b50adf56 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallFrictionTests.cs @@ -240,7 +240,8 @@ private static void SolveContact(ref BallState ball, in float3 normal, in float3 HitNormal = normal, HitOrgNormalVelocity = math.dot(ball.Velocity, normal) }; - BallCollider.HandleStaticContact(ref ball, in collEvent, Material.Friction, contactTime, in gravity, float3.zero); + BallCollider.HandleStaticContact(ref ball, in collEvent, in Material, contactTime, + in gravity, float3.zero); } private static BallState CreateBall(float mass, float radius, in float3 velocity) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs new file mode 100644 index 000000000..0ab6c5013 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs @@ -0,0 +1,219 @@ +// 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.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Test +{ + public class BallRollingResistanceTests + { + private const float GravityMagnitude = 1f; + private const float Mass = 1f; + private const float Radius = 25f; + private const float RollingResistance = 0.02f; + private const float RollingTolerance = 1e-4f; + + private static readonly float3 Gravity = new(0f, 0f, -GravityMagnitude); + private static readonly float3 Normal = new(0f, 0f, 1f); + private static readonly float3 Tangent = new(1f, 0f, 0f); + private static readonly PhysicsMaterialData Material = new() { + Friction = 0.2f, + RollingResistance = RollingResistance, + }; + + [Test] + public void LevelRollingDeceleratesAnalytically() + { + const float initialSpeed = 3f; + const int frames = 100; + var ball = CreateRollingBall(initialSpeed); + + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref ball, in Material); + } + + var elapsed = frames * PhysicsConstants.PhysFactor; + var expectedSpeed = initialSpeed + - 5f / 7f * RollingResistance * GravityMagnitude * elapsed; + Assert.That(math.dot(ball.Velocity, Tangent), + Is.EqualTo(expectedSpeed).Within(expectedSpeed * 0.005f)); + } + + [Test] + public void RollingResistancePreservesNoSlip() + { + var ball = CreateRollingBall(3f); + + for (var frame = 0; frame < 100; frame++) { + StepFrame(ref ball, in Material); + Assert.That(TangentialSlipSpeed(in ball), Is.LessThanOrEqualTo(RollingTolerance)); + } + } + + [Test] + public void CoplanarSeamDoesNotMultiplyRollingResistance() + { + var oneContact = CreateRollingBall(3f); + var duplicateContact = oneContact; + var candidate = CreateContact(RollingResistance, SupportImpulse(), in Normal); + + ApplySelectedContact(ref oneContact, candidate); + ApplySelectedContact(ref duplicateContact, candidate, candidate); + + AssertFloat3(duplicateContact.Velocity, oneContact.Velocity); + AssertFloat3(duplicateContact.AngularMomentum, oneContact.AngularMomentum); + } + + [Test] + public void CornerSelectsLargestRollingImpulseLimit() + { + var cornerNormal = new float3(0f, 1f, 0f); + var ball = CreateCornerRollingBall(3f, in cornerNormal); + var weaker = CreateContact(0.01f, 0.02f, in Normal); + var stronger = CreateContact(0.02f, 0.02f, in cornerNormal); + + var selected = SelectContact(in ball, weaker, stronger); + var selectedInReverseOrder = SelectContact(in ball, stronger, weaker); + + Assert.That(selected.RollingResistance, Is.EqualTo(stronger.RollingResistance)); + Assert.That(selected.SupportImpulse, Is.EqualTo(stronger.SupportImpulse)); + AssertFloat3(selected.ContactNormal, stronger.ContactNormal); + AssertFloat3(selectedInReverseOrder.ContactNormal, stronger.ContactNormal); + } + + [Test] + public void MixedMaterialContactsSelectLargestRollingImpulseLimit() + { + var ball = CreateRollingBall(3f); + var highCoefficient = CreateContact(0.04f, 0.01f, in Normal); + var highSupport = CreateContact(0.02f, 0.03f, in Normal); + + var selected = SelectContact(in ball, highCoefficient, highSupport); + var selectedInReverseOrder = SelectContact(in ball, highSupport, highCoefficient); + + Assert.That(selected.RollingResistance, Is.EqualTo(highSupport.RollingResistance)); + Assert.That(selected.SupportImpulse, Is.EqualTo(highSupport.SupportImpulse)); + Assert.That(selectedInReverseOrder.RollingResistance, + Is.EqualTo(highSupport.RollingResistance)); + } + + [Test] + public void IneligibleContactDoesNotShadowRollingSupport() + { + const float speed = 3f; + var staticOnly = CreateRollingBall(speed); + var mixedContacts = staticOnly; + var staticContact = CreateContact(RollingResistance, SupportImpulse(), in Normal); + var movingContact = staticContact; + movingContact.ColliderVelocity = speed * Tangent; + + ApplySelectedContact(ref staticOnly, staticContact); + ApplySelectedContact(ref mixedContacts, staticContact, movingContact); + + AssertFloat3(mixedContacts.Velocity, staticOnly.Velocity); + AssertFloat3(mixedContacts.AngularMomentum, staticOnly.AngularMomentum); + } + + private static void StepFrame(ref BallState ball, in PhysicsMaterialData material) + { + BallVelocityPhysics.UpdateVelocities(ref ball, Gravity, float2.zero); + var collEvent = new CollisionEventData { + IsContact = true, + HitNormal = Normal, + HitOrgNormalVelocity = math.dot(ball.Velocity, Normal), + }; + var supportImpulse = BallCollider.HandleStaticContact(ref ball, in collEvent, in material, + PhysicsConstants.PhysFactor, in Gravity, float3.zero); + var rollingContact = CreateContact(material.RollingResistance, supportImpulse, in Normal); + BallCollider.ApplyRollingResistance(ref ball, in rollingContact); + } + + private static void ApplySelectedContact(ref BallState ball, + params RollingContactData[] rollingContacts) + { + var selected = SelectContact(in ball, rollingContacts); + BallCollider.ApplyRollingResistance(ref ball, in selected); + } + + private static RollingContactData SelectContact(in BallState ball, + params RollingContactData[] rollingContacts) + { + using var contacts = new NativeList(Allocator.Temp); + foreach (var rollingContact in rollingContacts) { + contacts.Add(new ContactBufferElement(1, default) { + RollingContact = rollingContact, + }); + } + return ContactPhysics.SelectRollingContact(in contacts, 0, contacts.Length, in ball); + } + + private static RollingContactData CreateContact(float rollingResistance, float supportImpulse, + in float3 normal) + { + return new RollingContactData { + IsContact = true, + RollingResistance = rollingResistance, + SupportImpulse = supportImpulse, + ContactNormal = normal, + ColliderVelocity = float3.zero, + }; + } + + private static BallState CreateRollingBall(float speed) + { + var ball = new BallState { + Id = 1, + Position = Radius * Normal, + Velocity = speed * Tangent, + Radius = Radius, + Mass = Mass, + }; + var rollingAxis = math.normalize(math.cross(Normal, Tangent)); + ball.AngularMomentum = ball.Inertia * speed / Radius * rollingAxis; + return ball; + } + + private static BallState CreateCornerRollingBall(float speed, in float3 cornerNormal) + { + var ball = CreateRollingBall(speed); + var floorAxis = math.normalize(math.cross(Normal, Tangent)); + var cornerAxis = math.normalize(math.cross(cornerNormal, Tangent)); + ball.AngularMomentum = ball.Inertia * speed / Radius * (floorAxis + cornerAxis); + return ball; + } + + private static float SupportImpulse() + => Mass * GravityMagnitude * PhysicsConstants.PhysFactor; + + private static float TangentialSlipSpeed(in BallState ball) + { + var contactPoint = -ball.Radius * Normal; + var surfaceVelocity = BallState.SurfaceVelocity(in ball, in contactPoint); + var tangentialSlip = surfaceVelocity - Normal * math.dot(surfaceVelocity, Normal); + return math.length(tangentialSlip); + } + + private static void AssertFloat3(in float3 actual, in float3 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(1e-6f)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(1e-6f)); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(1e-6f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs.meta new file mode 100644 index 000000000..1a80f1560 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db60e9f3235785d4d8dd5947c4c1eb5d \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index f5f3c71f7..e73c70c81 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -158,6 +158,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov ContactPhysics.Update(ref contact, ref ball, ref state, ref state.Colliders, hitTime); } } + ContactPhysics.ApplyRollingResistance(ref _contacts, ref state); PerfMarkerContacts.End(); // clear contacts diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs index e10246139..2cdd3a793 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs @@ -108,9 +108,11 @@ internal static void FireHitEvent(ref BallState ball, ref NativeQueue } } - internal static void Contact(in ColliderHeader collHeader, ref BallState ball, in CollisionEventData collEvent, double hitTime, in float3 gravity, in float3 colliderVelocity) + internal static float Contact(in ColliderHeader collHeader, ref BallState ball, + in CollisionEventData collEvent, double hitTime, in float3 gravity, in float3 colliderVelocity) { - BallCollider.HandleStaticContact(ref ball, in collEvent, collHeader.Material.Friction, (float)hitTime, gravity, colliderVelocity); + return BallCollider.HandleStaticContact(ref ball, in collEvent, in collHeader.Material, + (float)hitTime, in gravity, in colliderVelocity); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs index dafe9c893..26bc312c8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactBufferElement.cs @@ -14,17 +14,69 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using Unity.Mathematics; + namespace VisualPinball.Unity { internal struct ContactBufferElement { public CollisionEventData CollEvent; public int BallId; + public RollingContactData RollingContact; public ContactBufferElement(int ballId, CollisionEventData collEvent) { BallId = ballId; CollEvent = collEvent; + RollingContact = default; + } + } + + internal struct RollingContactData + { + public bool IsContact; + public float RollingResistance; + public float SupportImpulse; + public float3 ContactNormal; + public float3 ColliderVelocity; + + public readonly bool IsValid => IsContact + && RollingResistance > 0f + && math.isfinite(RollingResistance) + && SupportImpulse > 0f + && math.isfinite(SupportImpulse) + && math.all(math.isfinite(ContactNormal)) + && math.isfinite(math.lengthsq(ContactNormal)) + && math.lengthsq(ContactNormal) > 0f + && math.all(math.isfinite(ColliderVelocity)) + && math.isfinite(RollingImpulseLimit); + + public readonly float RollingImpulseLimit => RollingResistance * SupportImpulse; + + public readonly bool IsPreferredOver(in RollingContactData other) + { + if (RollingImpulseLimit != other.RollingImpulseLimit) { + return RollingImpulseLimit > other.RollingImpulseLimit; + } + if (RollingResistance != other.RollingResistance) { + return RollingResistance > other.RollingResistance; + } + if (ContactNormal.x != other.ContactNormal.x) { + return ContactNormal.x > other.ContactNormal.x; + } + if (ContactNormal.y != other.ContactNormal.y) { + return ContactNormal.y > other.ContactNormal.y; + } + if (ContactNormal.z != other.ContactNormal.z) { + return ContactNormal.z > other.ContactNormal.z; + } + if (ColliderVelocity.x != other.ColliderVelocity.x) { + return ColliderVelocity.x > other.ColliderVelocity.x; + } + if (ColliderVelocity.y != other.ColliderVelocity.y) { + return ColliderVelocity.y > other.ColliderVelocity.y; + } + return ColliderVelocity.z > other.ColliderVelocity.z; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index 973fcd05e..f6a423f7b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -14,7 +14,9 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using Unity.Collections; using Unity.Mathematics; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -23,10 +25,12 @@ internal static class ContactPhysics internal static void Update(ref ContactBufferElement contact, ref BallState ball, ref PhysicsState state, ref NativeColliders colliders, float hitTime) { ref var collEvent = ref contact.CollEvent; + contact.RollingContact = default; if (collEvent.ColliderId > -1) { // collide with static collider var gravity = state.Env.Gravity; - if (!colliders.IsTransformed(collEvent.ColliderId)) { + var usesColliderSpace = !colliders.IsTransformed(collEvent.ColliderId); + if (usesColliderSpace) { ref var matrix = ref state.GetNonTransformableColliderMatrix(collEvent.ColliderId, ref colliders); var matrixInv = math.inverse(matrix); ball.Transform(matrixInv); @@ -35,28 +39,91 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball } ref var collHeader = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId); + var supportImpulse = 0f; + var colliderVelocity = float3.zero; if (collHeader.Type == ColliderType.Flipper) { + // Rolling resistance cannot be applied until the opposing generalized + // impulse can also be coupled into the flipper movement state. ref var flipperCollider = ref colliders.Flipper(collEvent.ColliderId); ref var flipperState = ref state.GetFlipperState(collEvent.ColliderId, ref colliders); flipperCollider.Contact(ref ball, ref flipperState.Movement, in collEvent, in flipperState.Static, in flipperState.Velocity, hitTime, in gravity); } else { // surface velocity of the collider at the contact point (zero unless kinematic and moving) - var colliderVelocity = state.GetKinematicSurfaceVelocity(in collEvent, ball.Position - ball.Radius * collEvent.HitNormal); - Collider.Contact(in collHeader, ref ball, in collEvent, hitTime, in gravity, in colliderVelocity); + colliderVelocity = state.GetKinematicSurfaceVelocity(in collEvent, + ball.Position - ball.Radius * collEvent.HitNormal); + supportImpulse = Collider.Contact(in collHeader, ref ball, in collEvent, hitTime, + in gravity, in colliderVelocity); } - if (!colliders.IsTransformed(collEvent.ColliderId)) { + if (usesColliderSpace) { ref var matrix = ref state.GetNonTransformableColliderMatrix(collEvent.ColliderId, ref colliders); + var supportVector = supportImpulse * collEvent.HitNormal; ball.Transform(matrix); collEvent.Transform(matrix); + colliderVelocity = matrix.MultiplyVector(colliderVelocity); + supportImpulse = math.length(matrix.MultiplyVector(supportVector)); + } + + if (collHeader.Type != ColliderType.Flipper) { + contact.RollingContact = new RollingContactData { + IsContact = collEvent.IsContact, + RollingResistance = PhysicsMaterialData.SanitizeRollingResistance( + collHeader.Material.RollingResistance), + SupportImpulse = supportImpulse, + ContactNormal = collEvent.HitNormal, + ColliderVelocity = colliderVelocity, + }; } } else if (collEvent.BallId != 0) { // collide with ball + // Ball-to-ball rolling resistance is intentionally unsupported in v1. var collHeader = collEvent.IsKinematic ? ref state.GetColliderHeader(ref state.KinematicColliders, contact.CollEvent.ColliderId) : ref state.GetColliderHeader(ref state.Colliders, contact.CollEvent.ColliderId); - BallCollider.HandleStaticContact(ref ball, in collEvent, collHeader.Material.Friction, hitTime, state.Env.Gravity, float3.zero); + var material = collHeader.Material; + material.RollingResistance = 0f; + BallCollider.HandleStaticContact(ref ball, in collEvent, in material, hitTime, + in state.Env.Gravity, float3.zero); + } + } + + internal static void ApplyRollingResistance(ref NativeList contacts, + ref PhysicsState state) + { + // Narrow phase appends every contact for one ball before moving to the + // next ball. Preserve that grouping so aggregation remains a single, + // allocation-free pass over the contact buffer. + var firstContact = 0; + while (firstContact < contacts.Length) { + var ballId = contacts[firstContact].BallId; + var endContact = firstContact + 1; + while (endContact < contacts.Length && contacts[endContact].BallId == ballId) { + endContact++; + } + + ref var ball = ref state.Balls.GetValueByRef(ballId); + var rollingContact = SelectRollingContact(in contacts, firstContact, endContact, in ball); + BallCollider.ApplyRollingResistance(ref ball, in rollingContact); + firstContact = endContact; + } + } + + internal static RollingContactData SelectRollingContact( + in NativeList contacts, int firstContact, int endContact, + in BallState ball) + { + // Applying one gravity-derived support impulse per representative keeps + // duplicate mesh contacts from multiplying rolling loss. Prefer the + // largest Crr * JnSupport; geometric data supplies deterministic ties. + var selected = default(RollingContactData); + for (var i = firstContact; i < endContact; i++) { + var candidate = contacts[i].RollingContact; + if (BallCollider.IsRollingContact(in ball, in candidate) + && (!selected.IsValid || candidate.IsPreferredOver(in selected))) { + selected = candidate; + } } + return selected; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs index 817c2b419..d44c14070 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs @@ -140,8 +140,17 @@ public static void Collide3DWall(ref BallState ball, in PhysicsMaterialData mate } } - public static void HandleStaticContact(ref BallState ball, in CollisionEventData collEvent, float friction, float dTime, in float3 gravity, in float3 colliderVelocity) + /// + /// The steady support impulse for this contact. Rolling resistance is + /// applied after all of the ball's contacts have been solved and aggregated. + /// + public static float HandleStaticContact(ref BallState ball, in CollisionEventData collEvent, + in PhysicsMaterialData material, float dTime, in float3 gravity, in float3 colliderVelocity) { + if (!collEvent.IsContact) { + return 0f; + } + // this should be zero, but only up to +/- PhysicsConstants.ContactVel // (relative to the surface, which may be moving if the collider is kinematic) var relativeNormalVelocity = math.dot(ball.Velocity - colliderVelocity, collEvent.HitNormal); @@ -150,13 +159,14 @@ public static void HandleStaticContact(ref BallState ball, in CollisionEventData // solved. In that case neither penetration correction nor friction is needed. var isClearlySeparating = relativeNormalVelocity > PhysicsConstants.ContactVel; if (isClearlySeparating) { - return; + return 0f; } var supportImpulse = SolveNormalContact(ref ball, in collEvent, dTime, in gravity, in colliderVelocity, relativeNormalVelocity); - ApplyCoulombContactImpulse(ref ball, collEvent.HitNormal, dTime, friction, supportImpulse, + ApplyCoulombContactImpulse(ref ball, collEvent.HitNormal, dTime, material.Friction, supportImpulse, gravity, colliderVelocity); + return supportImpulse; } private static float SolveNormalContact(ref BallState ball, in CollisionEventData collEvent, @@ -243,6 +253,89 @@ private static void ApplyCoulombContactImpulse(ref BallState ball, in float3 hit } } + internal static void ApplyRollingResistance(ref BallState ball, in RollingContactData contact) + { + if (!TryGetRollingState(in ball, in contact, out var tangentDirection, + out var rollingAxis, out var centerSpeed, out var angularRollingSpeed, + out var effectiveMass)) { + return; + } + + var deltaSpeed = contact.RollingImpulseLimit / effectiveMass; + if (!math.isfinite(deltaSpeed) || deltaSpeed <= 0f) { + return; + } + deltaSpeed = math.min(deltaSpeed, math.min(centerSpeed, angularRollingSpeed)); + + // Rolling resistance is a coupled linear/angular impulse pair. It is + // not a single impulse applied at the surface contact point. + var deltaMomentum = -ball.Mass * deltaSpeed * tangentDirection; + var deltaAngularMomentum = -(ball.Inertia / ball.Radius) * deltaSpeed * rollingAxis; + ball.Velocity += deltaMomentum * ball.InvMass; + ball.AngularMomentum += deltaAngularMomentum; + } + + internal static bool IsRollingContact(in BallState ball, in RollingContactData contact) + { + return TryGetRollingState(in ball, in contact, out _, out _, out _, out _, out _); + } + + private static bool TryGetRollingState(in BallState ball, in RollingContactData contact, + out float3 tangentDirection, out float3 rollingAxis, out float centerSpeed, + out float angularRollingSpeed, out float effectiveMass) + { + tangentDirection = default; + rollingAxis = default; + centerSpeed = 0f; + angularRollingSpeed = 0f; + effectiveMass = 0f; + + if (!contact.IsValid || !math.isfinite(ball.Mass) || !math.isfinite(ball.Radius) + || !math.isfinite(ball.Inertia) || ball.Mass <= 0f || ball.Radius <= 0f || ball.Inertia <= 0f) { + return false; + } + + var normalLengthSq = math.lengthsq(contact.ContactNormal); + if (!math.isfinite(normalLengthSq) || normalLengthSq <= 0f) { + return false; + } + var contactNormal = contact.ContactNormal * math.rsqrt(normalLengthSq); + var contactPoint = -ball.Radius * contactNormal; + var surfaceVelocity = BallState.SurfaceVelocity(in ball, in contactPoint) - contact.ColliderVelocity; + var tangentialSurfaceVelocity = surfaceVelocity + - contactNormal * math.dot(surfaceVelocity, contactNormal); + var slipSpeedSq = math.lengthsq(tangentialSurfaceVelocity); + if (!math.isfinite(slipSpeedSq) + || slipSpeedSq > PhysicsConstants.Precision * PhysicsConstants.Precision) { + return false; + } + + var centerVelocity = ball.Velocity - contact.ColliderVelocity; + var tangentialCenterVelocity = centerVelocity + - contactNormal * math.dot(centerVelocity, contactNormal); + centerSpeed = math.length(tangentialCenterVelocity); + if (!math.isfinite(centerSpeed) || centerSpeed <= 1e-6f) { + return false; + } + tangentDirection = tangentialCenterVelocity / centerSpeed; + rollingAxis = math.normalize(math.cross(contactNormal, tangentDirection)); + + var angularVelocity = ball.AngularMomentum / ball.Inertia; + var tangentialAngularVelocity = angularVelocity + - contactNormal * math.dot(angularVelocity, contactNormal); + angularRollingSpeed = math.dot(tangentialAngularVelocity, rollingAxis) * ball.Radius; + if (!math.isfinite(angularRollingSpeed) || angularRollingSpeed <= 0f + || math.abs(angularRollingSpeed - centerSpeed) > PhysicsConstants.Precision) { + return false; + } + + effectiveMass = ball.Mass + ball.Inertia / (ball.Radius * ball.Radius); + if (!math.isfinite(effectiveMass) || effectiveMass <= 0f) { + return false; + } + return true; + } + public static float HitTest(ref CollisionEventData collEvent, ref BallState otherBall, in BallState ball, float dTime) { var d = ball.Position - otherBall.Position; // delta position From 57882b89c3b8f0ac4bef65305d0ea18ac67221e0 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 04:16:19 +0200 Subject: [PATCH 5/6] physics: verify rolling resistance behavior Extend deterministic sustained-contact coverage across incline losses, near-stop clamping, monotonic energy, zero-coefficient equivalence, and relative kinematic surface motion. Prove rolling resistance remains independent of impact response, mass, radius, and substep partitioning. Isolate incline support loading with a differential zero-coefficient comparison that pins the cosine term. --- .../Physics/BallRollingResistanceTests.cs | 314 ++++++++++++++++-- 1 file changed, 294 insertions(+), 20 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs index 0ab6c5013..9c8239a8a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallRollingResistanceTests.cs @@ -130,18 +130,240 @@ public void IneligibleContactDoesNotShadowRollingSupport() AssertFloat3(mixedContacts.AngularMomentum, staticOnly.AngularMomentum); } + [Test] + public void InclineRollingIncludesRollingResistance() + { + const float angleDeg = 10f; + const float initialSpeed = 2f; + const int frames = 100; + Incline(angleDeg, out var normal, out var tangent); + var ball = CreateRollingBall(Mass, Radius, in normal, in tangent, initialSpeed, + float3.zero); + var zeroRollingBall = ball; + var zeroRollingMaterial = Material; + zeroRollingMaterial.RollingResistance = 0f; + + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref ball, in normal, in Gravity, in Material, float3.zero, + PhysicsConstants.PhysFactor); + StepFrame(ref zeroRollingBall, in normal, in Gravity, in zeroRollingMaterial, + float3.zero, PhysicsConstants.PhysFactor); + } + + var angle = math.radians(angleDeg); + var elapsed = frames * PhysicsConstants.PhysFactor; + var expectedSpeed = initialSpeed + 5f / 7f * GravityMagnitude + * (math.sin(angle) - RollingResistance * math.cos(angle)) * elapsed; + Assert.That(math.dot(ball.Velocity, tangent), + Is.EqualTo(expectedSpeed).Within(expectedSpeed * 0.005f)); + var expectedRollingLoss = 5f / 7f * RollingResistance * GravityMagnitude + * math.cos(angle) * elapsed; + var measuredRollingLoss = math.dot(zeroRollingBall.Velocity - ball.Velocity, tangent); + Assert.That(measuredRollingLoss, + Is.EqualTo(expectedRollingLoss).Within(expectedRollingLoss * 0.005f)); + Assert.That(TangentialSlipSpeed(in ball, in normal, float3.zero), + Is.LessThanOrEqualTo(RollingTolerance)); + } + + [Test] + public void RollingResistanceDoesNotReverseMotion() + { + var oneStepDeceleration = 5f / 7f * RollingResistance * GravityMagnitude + * PhysicsConstants.PhysFactor; + var ball = CreateRollingBall(0.5f * oneStepDeceleration); + + StepFrame(ref ball, in Material); + + Assert.That(math.dot(ball.Velocity, Tangent), Is.EqualTo(0f).Within(1e-7f)); + Assert.That(math.dot(ball.AngularMomentum, math.cross(Normal, Tangent)), + Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void RollingResistanceDissipatesEnergy() + { + var ball = CreateRollingBall(0.02f); + var previousEnergy = KineticEnergy(in ball); + + for (var frame = 0; frame < 30; frame++) { + StepFrame(ref ball, in Material); + var energy = KineticEnergy(in ball); + Assert.That(energy, Is.LessThanOrEqualTo(previousEnergy + 1e-7f), $"frame {frame}"); + previousEnergy = energy; + } + + Assert.That(previousEnergy, Is.EqualTo(0f).Within(1e-7f)); + } + + [Test] + public void ZeroRollingResistanceIsNoOp() + { + const float angleDeg = 10f; + Incline(angleDeg, out var normal, out var tangent); + var material = Material; + material.RollingResistance = 0f; + var withRollingPath = CreateRollingBall(Mass, Radius, in normal, in tangent, 2f, + float3.zero); + var coulombOnly = withRollingPath; + + BallVelocityPhysics.UpdateVelocities(ref withRollingPath, Gravity, float2.zero); + SolveContactAndRolling(ref withRollingPath, in normal, in Gravity, in material, + float3.zero, PhysicsConstants.PhysFactor); + BallVelocityPhysics.UpdateVelocities(ref coulombOnly, Gravity, float2.zero); + SolveContact(ref coulombOnly, in normal, in Gravity, in material, float3.zero, + PhysicsConstants.PhysFactor); + + AssertFloat3(withRollingPath.Velocity, coulombOnly.Velocity, 0f); + AssertFloat3(withRollingPath.AngularMomentum, coulombOnly.AngularMomentum, 0f); + } + + [Test] + public void RollingResistanceDoesNotAffectVerticalImpact() + { + var zeroRolling = ImpactMaterial(0f); + var nonzeroRolling = ImpactMaterial(0.2f); + var velocity = new float3(0f, 0f, -10f); + + var baseline = SolveImpact(in zeroRolling, in velocity); + var withRollingResistance = SolveImpact(in nonzeroRolling, in velocity); + + AssertFloat3(withRollingResistance.Velocity, baseline.Velocity, 0f); + AssertFloat3(withRollingResistance.AngularMomentum, baseline.AngularMomentum, 0f); + } + + [Test] + public void RollingResistanceDoesNotAffectObliqueImpact() + { + var zeroRolling = ImpactMaterial(0f); + var nonzeroRolling = ImpactMaterial(0.2f); + var velocity = new float3(3f, 0f, -10f); + + var baseline = SolveImpact(in zeroRolling, in velocity); + var withRollingResistance = SolveImpact(in nonzeroRolling, in velocity); + + AssertFloat3(withRollingResistance.Velocity, baseline.Velocity, 0f); + AssertFloat3(withRollingResistance.AngularMomentum, baseline.AngularMomentum, 0f); + } + + [Test] + public void RollingResistanceUsesRelativeSurfaceMotion() + { + const float relativeSpeed = 3f; + var colliderVelocity = 2f * Tangent; + var staticSurface = CreateRollingBall(relativeSpeed); + var movingSurface = CreateRollingBall(Mass, Radius, in Normal, in Tangent, + relativeSpeed, in colliderVelocity); + + StepFrame(ref staticSurface, in Material); + StepFrame(ref movingSurface, in Normal, in Gravity, in Material, + in colliderVelocity, PhysicsConstants.PhysFactor); + + AssertFloat3(movingSurface.Velocity - colliderVelocity, staticSurface.Velocity); + AssertFloat3(movingSurface.AngularMomentum, staticSurface.AngularMomentum); + } + + [Test] + public void RollingResistanceIsMassInvariant() + { + const float initialSpeed = 3f; + const int frames = 100; + var reference = CreateRollingBall(initialSpeed); + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref reference, in Material); + } + + foreach (var mass in new[] { 0.5f, 2f, 5f }) { + var ball = CreateRollingBall(mass, Radius, in Normal, in Tangent, initialSpeed, + float3.zero); + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref ball, in Material); + } + Assert.That(math.dot(ball.Velocity, Tangent), + Is.EqualTo(math.dot(reference.Velocity, Tangent)).Within(1e-5f), $"mass {mass}"); + } + } + + [Test] + public void RollingResistanceHasExpectedRadiusScaling() + { + const float initialSpeed = 3f; + const int frames = 100; + var reference = CreateRollingBall(initialSpeed); + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref reference, in Material); + } + + foreach (var radius in new[] { 10f, 50f }) { + var ball = CreateRollingBall(Mass, radius, in Normal, in Tangent, initialSpeed, + float3.zero); + for (var frame = 0; frame < frames; frame++) { + StepFrame(ref ball, in Material); + } + var rollingAxis = math.cross(Normal, Tangent); + var angularSpeed = math.dot(ball.AngularMomentum / ball.Inertia, rollingAxis); + Assert.That(math.dot(ball.Velocity, Tangent), + Is.EqualTo(math.dot(reference.Velocity, Tangent)).Within(1e-5f), + $"radius {radius}"); + Assert.That(angularSpeed * radius, + Is.EqualTo(math.dot(ball.Velocity, Tangent)).Within(1e-5f), + $"radius {radius}"); + } + } + + [Test] + public void RollingResistanceIsSubstepInvariant() + { + var fullStep = CreateRollingBall(3f); + var splitStep = fullStep; + + BallVelocityPhysics.UpdateVelocities(ref fullStep, Gravity, float2.zero); + SolveContactAndRolling(ref fullStep, in Normal, in Gravity, in Material, + float3.zero, PhysicsConstants.PhysFactor); + BallVelocityPhysics.UpdateVelocities(ref splitStep, Gravity, float2.zero); + SolveContactAndRolling(ref splitStep, in Normal, in Gravity, in Material, + float3.zero, PhysicsConstants.PhysFactor * 0.5f); + SolveContactAndRolling(ref splitStep, in Normal, in Gravity, in Material, + float3.zero, PhysicsConstants.PhysFactor * 0.5f); + + AssertFloat3(splitStep.Velocity, fullStep.Velocity); + AssertFloat3(splitStep.AngularMomentum, fullStep.AngularMomentum); + } + private static void StepFrame(ref BallState ball, in PhysicsMaterialData material) { - BallVelocityPhysics.UpdateVelocities(ref ball, Gravity, float2.zero); + StepFrame(ref ball, in Normal, in Gravity, in material, float3.zero, + PhysicsConstants.PhysFactor); + } + + private static void StepFrame(ref BallState ball, in float3 normal, in float3 gravity, + in PhysicsMaterialData material, in float3 colliderVelocity, float contactTime) + { + BallVelocityPhysics.UpdateVelocities(ref ball, gravity, float2.zero); + SolveContactAndRolling(ref ball, in normal, in gravity, in material, + in colliderVelocity, contactTime); + } + + private static void SolveContactAndRolling(ref BallState ball, in float3 normal, + in float3 gravity, in PhysicsMaterialData material, in float3 colliderVelocity, + float contactTime) + { + var supportImpulse = SolveContact(ref ball, in normal, in gravity, in material, + in colliderVelocity, contactTime); + var rollingContact = CreateContact(material.RollingResistance, supportImpulse, in normal); + rollingContact.ColliderVelocity = colliderVelocity; + BallCollider.ApplyRollingResistance(ref ball, in rollingContact); + } + + private static float SolveContact(ref BallState ball, in float3 normal, in float3 gravity, + in PhysicsMaterialData material, in float3 colliderVelocity, float contactTime) + { var collEvent = new CollisionEventData { IsContact = true, - HitNormal = Normal, - HitOrgNormalVelocity = math.dot(ball.Velocity, Normal), + HitNormal = normal, + HitOrgNormalVelocity = math.dot(ball.Velocity - colliderVelocity, normal), }; - var supportImpulse = BallCollider.HandleStaticContact(ref ball, in collEvent, in material, - PhysicsConstants.PhysFactor, in Gravity, float3.zero); - var rollingContact = CreateContact(material.RollingResistance, supportImpulse, in Normal); - BallCollider.ApplyRollingResistance(ref ball, in rollingContact); + return BallCollider.HandleStaticContact(ref ball, in collEvent, in material, + contactTime, in gravity, in colliderVelocity); } private static void ApplySelectedContact(ref BallState ball, @@ -176,16 +398,22 @@ private static RollingContactData CreateContact(float rollingResistance, float s } private static BallState CreateRollingBall(float speed) + { + return CreateRollingBall(Mass, Radius, in Normal, in Tangent, speed, float3.zero); + } + + private static BallState CreateRollingBall(float mass, float radius, in float3 normal, + in float3 tangent, float relativeSpeed, in float3 colliderVelocity) { var ball = new BallState { Id = 1, - Position = Radius * Normal, - Velocity = speed * Tangent, - Radius = Radius, - Mass = Mass, + Position = radius * normal, + Velocity = colliderVelocity + relativeSpeed * tangent, + Radius = radius, + Mass = mass, }; - var rollingAxis = math.normalize(math.cross(Normal, Tangent)); - ball.AngularMomentum = ball.Inertia * speed / Radius * rollingAxis; + var rollingAxis = math.normalize(math.cross(normal, tangent)); + ball.AngularMomentum = ball.Inertia * relativeSpeed / radius * rollingAxis; return ball; } @@ -203,17 +431,63 @@ private static float SupportImpulse() private static float TangentialSlipSpeed(in BallState ball) { - var contactPoint = -ball.Radius * Normal; - var surfaceVelocity = BallState.SurfaceVelocity(in ball, in contactPoint); - var tangentialSlip = surfaceVelocity - Normal * math.dot(surfaceVelocity, Normal); + return TangentialSlipSpeed(in ball, in Normal, float3.zero); + } + + private static float TangentialSlipSpeed(in BallState ball, in float3 normal, + in float3 colliderVelocity) + { + var contactPoint = -ball.Radius * normal; + var surfaceVelocity = BallState.SurfaceVelocity(in ball, in contactPoint) + - colliderVelocity; + var tangentialSlip = surfaceVelocity - normal * math.dot(surfaceVelocity, normal); return math.length(tangentialSlip); } - private static void AssertFloat3(in float3 actual, in float3 expected) + private static float KineticEnergy(in BallState ball) + { + var angularVelocity = ball.AngularMomentum / ball.Inertia; + return 0.5f * ball.Mass * math.lengthsq(ball.Velocity) + + 0.5f * ball.Inertia * math.lengthsq(angularVelocity); + } + + private static PhysicsMaterialData ImpactMaterial(float rollingResistance) + { + return new PhysicsMaterialData { + Elasticity = 0.8f, + Friction = 0.2f, + RollingResistance = rollingResistance, + }; + } + + private static BallState SolveImpact(in PhysicsMaterialData material, in float3 velocity) + { + var ball = new BallState { + Id = 1, + Position = Radius * Normal, + Velocity = velocity, + Radius = Radius, + Mass = Mass, + }; + var collEvent = new CollisionEventData(); + var state = new PhysicsState(); + BallCollider.Collide3DWall(ref ball, in material, in collEvent, in Normal, ref state); + return ball; + } + + private static void Incline(float angleDeg, out float3 normal, out float3 downhillTangent) + { + var angle = math.radians(angleDeg); + normal = new float3(math.sin(angle), 0f, math.cos(angle)); + downhillTangent = new float3(math.cos(angle), 0f, -math.sin(angle)); + } + + private static void AssertFloat3(in float3 actual, in float3 expected, + float tolerance = 1e-6f) { - Assert.That(actual.x, Is.EqualTo(expected.x).Within(1e-6f)); - Assert.That(actual.y, Is.EqualTo(expected.y).Within(1e-6f)); - Assert.That(actual.z, Is.EqualTo(expected.z).Within(1e-6f)); + 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)); } } } From fab237477a2c704e5bab432a1bfbf3f4ff8bb790 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 16 Jul 2026 04:27:08 +0200 Subject: [PATCH 6/6] doc: document rolling resistance calibration Explain how Coulomb friction and rolling resistance differ and document where the new authoring value is supported. Provide fixture setup, inverse formulas, data-sheet fields, fit and validation criteria, and rollout rules. Keep all defaults at zero until measured physical evidence and an HDRP table smoke support a nonzero preset. Record runtime limitations and the sustained-contact correction in the changelog. --- CHANGELOG.md | 2 + .../creators-guide/editor/materials.md | 12 +- .../editor/rolling-resistance-calibration.md | 139 ++++++++++++++++++ .../Documentation~/creators-guide/toc.yml | 2 + 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/editor/rolling-resistance-calibration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd2a6060..988fbf137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Built with Unity 6.5 ### Added +- Rolling resistance as a separate physics-material property for sustained ball contacts, including authoring, packaging, deterministic verification, and calibration guidance ([#363](https://github.com/freezy/VisualPinball.Engine/issues/363)). - Make packaging functional ([#557](https://github.com/freezy/VisualPinball.Engine/pull/557)) - New threading model ([#552](https://github.com/freezy/VisualPinball.Engine/pull/552)) - Free transformation ([#500](https://github.com/freezy/VisualPinball.Engine/pull/500)) @@ -67,6 +68,7 @@ Built with Unity 6.5 - Put game-, mesh-, collision- animation data into separate components ([#227](https://github.com/freezy/VisualPinball.Engine/pull/227), [Documentation](https://docs.visualpinball.org/creators-guide/editor/unity-components.html)). ### Fixed +- Sustained ball contact friction now uses mass- and substep-consistent Coulomb impulses while preserving impact friction behavior ([#363](https://github.com/freezy/VisualPinball.Engine/issues/363)). - Disappearing objects due to wrong bounding box ([#441](https://github.com/freezy/VisualPinball.Engine/pull/441)). - Default table import ([#434](https://github.com/freezy/VisualPinball.Engine/pull/434)) - Remaining ball spinning issue should now be solved ([#397](https://github.com/freezy/VisualPinball.Engine/pull/397)). diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/materials.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/materials.md index a0f1ba8d9..fe0479db2 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/editor/materials.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/materials.md @@ -25,10 +25,16 @@ We refer to how the material interacts with the ball during the physics simulati - **Elasticity** - The bounciness, how much the ball is thrown back when it collides. - **Elasticity Falloff** - Pinball tables have a lot of rubber parts, and rubber has a special attribute: it gets less bouncy when hit at higher velocity. The falloff parameter controls how much. -- **Friction** - How much friction is applied when the ball rolls along this material. +- **Friction** - The Coulomb coefficient that controls sliding, grip, and tangential collision and contact impulses. +- **Rolling Resistance** - A dimensionless coefficient (`Crr`) that removes energy from sustained near-no-slip rolling without changing impact response. - **Scatter** - Adds a random factor to the collision angle. -Physics materials are a way to group common behavior among certain objects, but contrarily to rendered materials, you can also *not* assign a physics material to an object and set each of those four parameters individually. +Physics materials are a way to group common behavior among certain objects, but contrarily to rendered materials, you can also *not* assign a physics material to an object and set each parameter individually. + +Friction and rolling resistance are separate controls. Increasing friction gives the ball more grip and changes oblique contact behavior. Increasing rolling resistance slows a ball that is already rolling without slip. Rolling resistance is applied only to sustained generic surface contacts; it does not affect impacts, ball-to-ball contacts, or the specialized flipper contact solver. + +> [!IMPORTANT] +> Rolling resistance defaults to zero. VPE does not recommend a nonzero preset until measurements on a physical playfield have been completed. See [Calibrating Rolling Resistance](xref:rolling-resistance-calibration) before choosing a value. > [!note] > In Visual Pinball, the physical parameters are part of the rendered material, so there is only one notion of material. @@ -42,4 +48,4 @@ As mentioned above, there are two differences between Visual Pinball and VPE how When importing a `.vpx` file, VPE converts the "visual part" of Visual Pinball materials into materials for the current render pipeline. It does that by creating a new material for every material/texture combination in Visual Pinball. The materials are then written to the `Materials` asset folder of the imported table where they can be easily edited and referenced. Since Visual Pinball uses different shaders than Unity, the results of the conversion are approximations and should be heavily tweaked. -Since VPE uses the same physics engine as Visual Pinball, the physical values of the materials don't need to be converted, they are copied 1:1 into a new physics material and saved in the asset folder. \ No newline at end of file +Since VPE uses the same physics engine as Visual Pinball, the physical values that exist in Visual Pinball are copied 1:1 into a new physics material and saved in the asset folder. VPX does not contain a rolling-resistance value, so imported physics materials start with `Rolling Resistance = 0` unless the table author opts in afterward. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/rolling-resistance-calibration.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/rolling-resistance-calibration.md new file mode 100644 index 000000000..5faa592ef --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/rolling-resistance-calibration.md @@ -0,0 +1,139 @@ +--- +uid: rolling-resistance-calibration +title: Calibrating Rolling Resistance +description: How to measure, fit, and validate rolling resistance for a pinball playfield. +--- + +# Calibrating Rolling Resistance + +Rolling resistance controls energy loss while a ball is in sustained, near-no-slip contact with a surface. It is represented by the dimensionless coefficient `Crr`. It is separate from **Friction**, which controls sliding, grip, and tangential collision impulses. + +> [!IMPORTANT] +> There is no recommended production preset yet. Existing packages, VPX imports, and new templates must use `Crr = 0` until a physical-table data set has been measured and validated. Do not choose a default from generic steel-on-steel coefficient tables. + +## Where the value applies + +A nonzero rolling-resistance value can be authored on a physics material asset or on the physics-material override for playfields, ramps, surfaces, primitives, rubbers, metal wire guides, hit targets, and drop targets. + +The first implementation deliberately does not apply rolling resistance to: + +- collision impacts; +- ball-to-ball contacts; +- the specialized flipper contact solver; +- spin about the contact normal, also called drilling or pivot friction; or +- a stationary ball on a shallow incline. + +The model uses one constant coefficient. It does not vary with speed, load, temperature, or wear. Measurements may justify a speed-dependent follow-up, but that should not be assumed in advance. + +Kinematic surface velocity is included when deciding whether the ball is rolling and which direction to damp. The v1 support load does not include kinematic surface acceleration, so the analytic guarantee applies to static surfaces and constant-velocity kinematic surfaces only. + +## Mechanical reference + +For the solid sphere used by VPE, ideal level rolling with a constant coefficient has deceleration: + +```text +a_rr = 5/7 Crr g +``` + +For a ball rolling downhill on an incline of angle `theta`: + +```text +a = 5/7 g (sin(theta) - Crr cos(theta)) +``` + +The solver clamps at zero rolling speed and does not reverse the ball. It does not model the set-valued static state that could hold a motionless ball when `Crr >= tan(theta)`. + +## Build a measurement fixture + +Use a clean, standard pinball ball and a representative playfield sample. The sample should include the finish and preparation state that the table is expected to use, such as clear coat, wax, paint, Mylar, inserts, or representative wear. + +Record these conditions before every measurement session: + +- ball diameter and mass; +- ball condition and cleaning method; +- surface material, finish, and preparation state; +- surface angle and how it was measured; +- starting speed or release height; +- position versus time, preferably from high-frame-rate video or photogates; +- temperature; and +- whether the surface is waxed. + +Keep the release repeatable and avoid imparting side spin. Measure enough of the trajectory to distinguish sustained rolling from the initial sliding-to-rolling transition. Discard a trial if the ball touches a guide, crosses a seam, or visibly slips during the fitted interval. + +## Level-coast method + +Measure the speed `v0` at the start of a confirmed rolling interval and the distance `d` from that point to rest. Use consistent units for distance and speed: + +```text +Crr = 7 v0^2 / (10 g d) +``` + +Do not estimate `v0` only from the release height unless the ball is already rolling without slip at the start of the measured interval. A photogate pair or a fitted position-versus-time curve is preferred. + +## Incline method + +Measure the downhill acceleration `a` on a surface inclined by `theta`: + +```text +Crr = (sin(theta) - 7 a / (5 g)) / cos(theta) +``` + +Choose an angle and starting speed that keep the ball moving downhill and rolling without slip throughout the fitted interval. Measure the actual surface angle rather than relying on a nominal cabinet setting. + +## Data sheet + +Keep raw observations as well as the derived coefficient. One row per trial is sufficient: + +| Field | Value | +| --- | --- | +| Trial ID and timestamp | | +| Ball diameter and mass | | +| Ball preparation | | +| Surface and finish | | +| Waxed or unwaxed | | +| Temperature | | +| Surface angle | | +| Release method or height | | +| Starting speed `v0` | | +| Coast distance `d` | | +| Fitted acceleration `a` | | +| Derived `Crr` | | +| Video or photogate source | | +| Exclusions or observations | | + +Preserve the position-versus-time samples or source video. A derived coefficient without the raw trajectory is not sufficient calibration evidence. + +## Fit and validate a constant + +1. Measure several starting speeds across the gameplay-relevant range. +2. Repeat every condition enough times to quantify trial-to-trial variation. +3. Fit one constant `Crr` to the retained trials and report the residuals. +4. Validate the fitted value on a second representative surface or preparation state. +5. Reproduce the fixture in VPE and compare position-versus-time or coast distance, not only the final stop time. +6. Record the Unity version, render-pipeline project, physics step settings, ball data, collider type, and material values used for the simulation. + +If residuals show a repeatable speed dependence large enough to affect gameplay, keep the constant default at zero and propose a separate velocity-dependent model with tests and migration rules. + +## Acceptance before recommending a preset + +A proposed nonzero preset should include: + +- the completed data sheet and raw trajectories; +- the fitted value and uncertainty or observed spread; +- results for more than one starting speed; +- validation on a second representative surface; +- a comparison between physical and simulated trajectories; +- an end-to-end table smoke in `VisualPinball.Unity.Project.Hdrp` using Unity 6000.5 or newer; and +- the known limitations listed on this page. + +The headless core test project does not contain a render-pipeline implementation, so its table import/export tests cannot substitute for the HDRP smoke. The smoke should include real transformed and non-transformed colliders and confirm that mesh seams do not multiply rolling loss. + +## Rollout policy + +- Existing `.vpe` packages keep `Crr = 0` when the field is absent. +- Imported VPX physics materials and overrides start at `Crr = 0`. +- Existing tables do not change unless an author opts in. +- New templates stay at `Crr = 0` until the acceptance evidence above is published. +- A future recommended value must be presented as a measured preset for a documented surface, not as a universal material constant. + +When tuning a table before a measured preset exists, keep rolling resistance at zero. Do not compensate by changing Friction: that also changes sliding, rubber behavior, and oblique impacts. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 2384a7fd8..38c3405f4 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -20,6 +20,8 @@ href: editor/unity-components.md - name: Materials href: editor/materials.md + - name: Calibrating Rolling Resistance + href: editor/rolling-resistance-calibration.md - name: Asset Library Style Guide href: editor/asset-library-styleguide.md - name: Switch Manager