Ship contact is a restitution impulse along the normal while closing; e is GameplaySettings.ContactRestitution (.5)

Ruling ship-bounce-restitution-setting: j = -(1+e)(v_rel.n)/(1/m1+1/m2), applied only while closing. The old
exchange formula (PostCollisionVelocity) is deleted. ContactTests now pin momentum, frame shift, restitution,
no impulse while separating, slow closing, tangent of both bodies, inactive hulls, the contact point radius
and the three-body pile. Not yet built or run; verification follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 18:28:29 +02:00
commit 806641ae62
3 changed files with 234 additions and 43 deletions

View file

@ -9,7 +9,10 @@ using float2 = CultMath.float2;
// A hull touching a hull (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns
// contact between entities. Each hull is a disc about its centre of mass that bounds every occupied cell, and
// two discs that touch while closing along the line between their centres exchange their normal velocity.
// two discs that touch while closing along the line between their centres take the normal impulse
// j = -(1 + e)(v_rel . n) / (1/m1 + 1/m2) (ruling ship-bounce-restitution-setting): momentum is conserved, the
// result is frame-independent, and a pair that has separated is not bounced again. e is
// GameplaySettings.ContactRestitution.
// Presentations read Zone.Contacted; no Unity collision callback writes an Entity.
public readonly struct ContactEvent
{
@ -44,16 +47,12 @@ public static class Contacts
return far * e.ItemManager.GameplaySettings.SchematicCellSize;
}
// The elastic exchange of one body's normal velocity: v1 and m1 the body's own, v2 and m2 the other's.
private static float PostCollisionVelocity(float v1, float m1, float v2, float m2)
{
return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2);
}
// For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along
// the normal, each one's normal velocity is replaced (both from the velocities the pair had on entering) and
// its tangent component kept, and the contact is published. A docked entity (it has a Parent and takes its
// parent's pose) and an inactive one are not hulls in the zone. No positional push-out.
// the normal, the impulse moves each one's velocity along the normal only (the tangent components are
// untouched), and the contact is published. Pairs resolve one after another in list order, each from the
// velocities the earlier pairs left, so a body touching two others in one tick depends on that order; the
// total momentum does not. A docked entity (it has a Parent and takes its parent's pose) and an inactive one
// are not hulls in the zone. No positional push-out.
public static void Step(Zone zone)
{
var entities = zone.Entities.Where(e => e.Active && e.Parent == null).ToArray();
@ -68,15 +67,12 @@ public static class Contacts
// Coincident centres have no normal.
if (distance <= 0f || distance >= radii[i] + radii[j]) continue;
var n = offset / distance;
var va = dot(n, a.Velocity);
var vb = dot(n, b.Velocity);
if (vb - va >= 0f) continue;
var closing = dot(n, b.Velocity - a.Velocity);
if (closing >= 0f) continue;
var tangent = float2(-n.y, n.x);
var vaNew = PostCollisionVelocity(va, a.Mass, vb, b.Mass);
var vbNew = PostCollisionVelocity(vb, b.Mass, va, a.Mass);
a.Velocity = n * vaNew + tangent * dot(tangent, a.Velocity);
b.Velocity = n * vbNew + tangent * dot(tangent, b.Velocity);
var impulse = -(1f + a.ItemManager.GameplaySettings.ContactRestitution) * closing / (1f / a.Mass + 1f / b.Mass);
a.Velocity -= n * (impulse / a.Mass);
b.Velocity += n * (impulse / b.Mass);
zone.Contacted.OnNext(new ContactEvent(a, b, a.Position.xz + n * radii[i], n));
}
}

View file

@ -268,6 +268,10 @@ public class GameplaySettings
// (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range.
public float SchematicCellSize = 2f;
// Restitution of a hull striking a hull (Contacts.Step): the speed at which a pair separates along the contact
// normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic.
public float ContactRestitution = .5f;
// The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts
// a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone
// (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll.

View file

@ -12,8 +12,9 @@ using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Hull contact in the sim (aetheria-release cut sim-contact; ruling unity-presents-only-r3): Zone.Update steps
// Contacts, which exchanges the normal velocity of two touching hull discs and publishes the contact. Every test
// Hull contact in the sim (aetheria-release cut sim-contact; rulings unity-presents-only-r3 and
// ship-bounce-restitution-setting): Zone.Update steps Contacts, which applies the restitution impulse along the
// normal of two touching, closing hull discs and publishes the contact. Every test
// drives Zone.Update, the path the game takes. SchematicCellSize is 2, so a solid 5x5 hull has a radius of
// 2.5 * sqrt(2) * 2 = 7.07 and two such hulls ten apart touch.
public sealed class ContactTests : IDisposable
@ -73,7 +74,7 @@ public sealed class ContactTests : IDisposable
public void Step() => Zone.Update(.01f);
}
private Lab Build()
private Lab Build(float restitution = .5f)
{
var settings = new GameplaySettings
{
@ -81,7 +82,7 @@ public sealed class ContactTests : IDisposable
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f, ContactRestitution = restitution
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
@ -93,6 +94,7 @@ public sealed class ContactTests : IDisposable
};
cache.Upsert(Hull("Solid", Solid(5, 5), 1000));
cache.Upsert(Hull("Heavy", Solid(5, 5), 1000000));
cache.Upsert(Hull("Big", Solid(7, 7), 1000));
cache.Upsert(Hull("Ell", Ell(), 1000));
cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000));
cache.FlushAsync().Wait();
@ -109,19 +111,120 @@ public sealed class ContactTests : IDisposable
// The unit normal as the step saw it: from the first body's final position to the second's.
private static float2 NormalOf(Entity a, Entity b) => normalize(b.Position.xz - a.Position.xz);
// The formula is the deleted ShieldManager.PostCollisionVelocity verbatim (cut sim-contact moves it, it does
// not change it): with equal masses a body ends at half the other's incoming normal velocity.
[Fact]
public void HeadOnEqualMassesTakeHalfTheOthersNormalVelocity()
private static float2 Momentum(params Entity[] es)
{
var lab = Build();
var p = float2(0, 0);
foreach (var e in es) p += e.Mass * e.Velocity;
return p;
}
// An oblique contact between unequal masses with both ships moving; `shift` is added to both incoming
// velocities (a moving frame).
private (Ship a, Ship b, float2 closingBefore, Lab lab) Oblique(float e, float2 shift)
{
var lab = Build(e);
var va = float2(8, 3) + shift;
var vb = float2(-4, -2) + shift;
var a = lab.Add("Solid", float3(0, 0, 0), va);
var b = lab.Add("Mid", float3(7, 0, 7), vb);
lab.Step();
return (a, b, vb - va, lab);
}
// Restitution: the speed at which a pair separates along the normal is e times the speed at which it closed.
[Theory]
[InlineData(0f)]
[InlineData(.5f)]
[InlineData(1f)]
public void SeparatingNormalSpeedIsRestitutionTimesClosingSpeed(float e)
{
var (a, b, relBefore, lab) = Oblique(e, float2(0, 0));
var n = NormalOf(a, b);
var closing = -dot(n, relBefore);
Assert.True(closing > 0f);
Assert.Equal(e * closing, dot(n, b.Velocity - a.Velocity), 3);
Assert.Single(lab.Events);
}
// The same bounce at a closing speed far below and far above the others: restitution is the setting at any speed.
[Theory]
[InlineData(.5f, 2f)]
[InlineData(.5f, 40f)]
[InlineData(1f, 2f)]
[InlineData(1f, 40f)]
[InlineData(0f, 2f)]
public void RestitutionHoldsAtEveryClosingSpeed(float e, float speedEach)
{
var lab = Build(e);
var a = lab.Add("Solid", float3(0, 0, 0), float2(speedEach, 0));
var b = lab.Add("Heavy", float3(10, 0, 0), float2(-speedEach, 0));
lab.Step();
var n = NormalOf(a, b);
Assert.Equal(e * 2f * speedEach * n.x, dot(n, b.Velocity - a.Velocity), 3);
Assert.Single(lab.Events);
}
// Momentum is conserved for unequal masses, an oblique contact and both ships moving.
[Theory]
[InlineData(0f)]
[InlineData(.5f)]
[InlineData(1f)]
public void ContactConservesMomentum(float e)
{
var lab = Build(e);
var a = lab.Add("Solid", float3(0, 0, 0), float2(8, 3));
var b = lab.Add("Mid", float3(7, 0, 7), float2(-4, -2));
var before = Momentum(a, b);
lab.Step();
Assert.Single(lab.Events);
var after = Momentum(a, b);
Assert.Equal(before.x, after.x, 1);
Assert.Equal(before.y, after.y, 1);
Assert.True(length(a.Velocity - float2(8, 3)) > 1f, "the contact moved the first ship");
}
// A frame moving with the pair changes nothing: add the same velocity to both and each outcome shifts by exactly it.
[Theory]
[InlineData(.5f)]
[InlineData(1f)]
public void OutcomeShiftsByTheFrameVelocity(float e)
{
var shift = float2(25, -40);
var (a0, b0, _, _) = Oblique(e, float2(0, 0));
var (a1, b1, _, lab1) = Oblique(e, shift);
Assert.Single(lab1.Events);
Assert.Equal(a0.Velocity.x + shift.x, a1.Velocity.x, 2);
Assert.Equal(a0.Velocity.y + shift.y, a1.Velocity.y, 2);
Assert.Equal(b0.Velocity.x + shift.x, b1.Velocity.x, 2);
Assert.Equal(b0.Velocity.y + shift.y, b1.Velocity.y, 2);
}
// Two equal ships in a formation at 100 drift together at a closing speed of 1: they bounce apart at e, and
// the convoy keeps its momentum.
[Fact]
public void FormationBumpKeepsTheConvoyMoving()
{
var lab = Build(.5f);
var a = lab.Add("Solid", float3(0, 0, 0), float2(101, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0));
var before = Momentum(a, b);
lab.Step();
Assert.Single(lab.Events);
Assert.Equal(before.x, Momentum(a, b).x, 1);
Assert.True(a.Velocity.x > 99f && b.Velocity.x > 99f);
}
[Fact]
public void HeadOnEqualMassesAtFullRestitutionSwapNormalVelocity()
{
var lab = Build(1f);
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0));
lab.Step();
var n = NormalOf(a, b);
Assert.Equal(-5f, dot(n, a.Velocity), 4);
Assert.Equal(5f, dot(n, b.Velocity), 4);
Assert.Equal(-10f, dot(n, a.Velocity), 3);
Assert.Equal(10f, dot(n, b.Velocity), 3);
var contact = Assert.Single(lab.Events);
Assert.Equal(new[] { a, b }, new[] { contact.A, contact.B });
Assert.Equal(1f, length(contact.Normal), 5);
@ -130,22 +233,32 @@ public sealed class ContactTests : IDisposable
Assert.Equal(7.0711f, length(contact.Point - a.Position.xz), 2);
}
// The point is on A's disc, at A's radius, not B's: a small hull A against a larger hull B.
[Fact]
public void GlancingContactKeepsTangent()
public void ContactPointLiesOnTheFirstHullsDisc()
{
var lab = Build();
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Solid", float3(7.07f, 0, 7.07f), float2(0, 0));
var b = lab.Add("Big", float3(12, 0, 0), float2(0, 0));
lab.Step();
Assert.True(Contacts.Radius(b) > Contacts.Radius(a) + 1f);
var contact = Assert.Single(lab.Events);
Assert.Equal(Contacts.Radius(a), length(contact.Point - a.Position.xz), 3);
}
// Only the normal component changes: both bodies keep the tangent component they entered with.
[Fact]
public void BothBodiesKeepTheirTangentVelocity()
{
var lab = Build();
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Mid", float3(7.07f, 0, 7.07f), float2(0, 3));
lab.Step();
var n = NormalOf(a, b);
var t = float2(-n.y, n.x);
// A's incoming velocity (10, 0) has after the step the tangent component it had before, and so does B's (zero).
Assert.Equal(10f * t.x, dot(t, a.Velocity), 4);
Assert.Equal(0f, dot(t, b.Velocity), 4);
// Only the normal components changed: A handed half its incoming normal component 10 * n.x to B.
Assert.Equal(0f, dot(n, a.Velocity), 4);
Assert.Equal(5f * n.x, dot(n, b.Velocity), 4);
Assert.Equal(dot(t, float2(10, 0)), dot(t, a.Velocity), 4);
Assert.Equal(dot(t, float2(0, 3)), dot(t, b.Velocity), 4);
Assert.Single(lab.Events);
}
@ -161,6 +274,24 @@ public sealed class ContactTests : IDisposable
Assert.Empty(lab.Events);
}
// A pair that has separated is not bounced again: a heavy ship catching a light one is thrown clear by one
// contact, and the next ticks (the hulls still overlap, now opening) change and publish nothing.
[Fact]
public void NoImpulseWhileSeparating()
{
var lab = Build(.5f);
var a = lab.Add("Heavy", float3(0, 0, 0), float2(110, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0));
lab.Step();
var n = NormalOf(a, b);
Assert.True(dot(n, b.Velocity - a.Velocity) > 0f, "the bounce left the pair opening");
var (va, vb) = (a.Velocity, b.Velocity);
for (var i = 0; i < 5; i++) lab.Step();
Assert.Single(lab.Events);
Assert.Equal(va.x, a.Velocity.x, 3);
Assert.Equal(vb.x, b.Velocity.x, 3);
}
[Fact]
public void OneEventPerContact()
{
@ -169,7 +300,31 @@ public sealed class ContactTests : IDisposable
lab.Add("Solid", float3(10, 0, 0), float2(-10, 0));
lab.Step();
Assert.Single(lab.Events);
// The exchange left the pair opening: the next step publishes nothing.
// The bounce left the pair opening: the next step publishes nothing.
lab.Step();
Assert.Single(lab.Events);
}
// A pair closing slowly still touches: there is no speed below which hulls pass through each other.
[Fact]
public void SlowClosingStillBounces()
{
var lab = Build(.5f);
var a = lab.Add("Solid", float3(0, 0, 0), float2(2, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(-2, 0));
lab.Step();
var n = NormalOf(a, b);
Assert.Single(lab.Events);
Assert.Equal(.5f * 4f * n.x, dot(n, b.Velocity - a.Velocity), 3);
}
// The tiniest closing speed still bounces too.
[Fact]
public void BarelyClosingStillBounces()
{
var lab = Build(1f);
lab.Add("Solid", float3(0, 0, 0), float2(.1f, 0));
lab.Add("Solid", float3(10, 0, 0), float2(0, 0));
lab.Step();
Assert.Single(lab.Events);
}
@ -184,12 +339,12 @@ public sealed class ContactTests : IDisposable
Assert.Empty(lab.Events);
}
// Today's formula for a ship against a 1000x heavier body: the ship's normal velocity becomes
// (v1 (m1 - m2) + m2 v2) / (m1 + m2) = 10 * -999000 / 1001000, the heavy body's 10 * 1000 / 1001000.
// A ship against a 1000x heavier body: the heavy body takes (1 + e) m / (m + M) of the ship's closing speed
// and the ship rebounds with the rest of its momentum.
[Fact]
public void MassRatioIsTodaysFormula()
public void AShipAgainstAStationRebounds()
{
var lab = Build();
var lab = Build(.5f);
var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var station = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0));
Assert.Equal(1000f, ship.Mass);
@ -197,8 +352,44 @@ public sealed class ContactTests : IDisposable
lab.Step();
var n = NormalOf(ship, station);
Assert.Equal(10f * -999000f / 1001000f, dot(n, ship.Velocity), 3);
Assert.Equal(10f * 1000f / 1001000f, dot(n, station.Velocity), 4);
var closing = 10f * n.x;
Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, station.Velocity), 4);
Assert.Equal(closing - 1.5f * closing * 1000000f / 1001000f, dot(n, ship.Velocity), 3);
}
// An inactive hull is not in the zone: a ship flying through it is untouched.
[Fact]
public void AnInactiveHullTakesNoPartInContact()
{
var lab = Build();
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0));
b.Deactivate();
lab.Step();
Assert.Empty(lab.Events);
Assert.Equal(10f, a.Velocity.x, 4);
Assert.Equal(-10f, b.Velocity.x, 4);
}
// Pairs resolve one after another in list order, so a body touching two others in one tick can leave the pile
// differently when the list is reversed (order independence is not claimed); what the order does not change is
// the momentum of the pile and the two contacts it publishes.
[Fact]
public void ThreeBodyContactConservesMomentumInEitherListOrder()
{
foreach (var reversed in new[] { false, true })
{
var lab = Build(.5f);
var specs = new[] { ("Solid", float3(0, 0, 0), float2(10, 0)), ("Mid", float3(10, 0, 0), float2(0, 0)), ("Solid", float3(20, 0, 0), float2(-10, 0)) };
var ships = new Ship[3];
foreach (var i in reversed ? new[] { 2, 1, 0 } : new[] { 0, 1, 2 })
ships[i] = lab.Add(specs[i].Item1, specs[i].Item2, specs[i].Item3);
lab.Step();
Assert.Equal(2, lab.Events.Count);
var p = Momentum(ships);
Assert.Equal(0f, p.x, 1);
Assert.Equal(0f, p.y, 1);
}
}
// A docked hull rides its carrier's pose and velocity, so it is never a second body at the carrier's place: a