@@ -89,7 +89,11 @@ private static void ApplyImpulse(in Vector<float> inverseMassA, in Vector<float>
8989 }
9090
9191 [ MethodImpl ( MethodImplOptions . AggressiveInlining ) ]
92- static void ComputeJacobian ( in Vector3Wide positionA , in Vector3Wide positionB , in Vector3Wide positionC , out Vector < float > normalLength , out Vector3Wide negatedJacobianA , out Vector3Wide jacobianB , out Vector3Wide jacobianC )
92+ static void ComputeJacobian ( in Vector3Wide positionA , in Vector3Wide positionB , in Vector3Wide positionC ,
93+ out Vector < float > normalLength ,
94+ out Vector3Wide negatedJacobianA , out Vector3Wide jacobianB , out Vector3Wide jacobianC ,
95+ out Vector < float > contributionA , out Vector < float > contributionB , out Vector < float > contributionC ,
96+ out Vector < float > inverseJacobianLength )
9397 {
9498 //Area of a triangle with vertices a, b, and c is:
9599 //||ab x ac|| * 0.5
@@ -118,52 +122,69 @@ static void ComputeJacobian(in Vector3Wide positionA, in Vector3Wide positionB,
118122 Vector3Wide . CrossWithoutOverlap ( normal , ab , out jacobianC ) ;
119123 //Similar to the volume constraint, we could create a similar expression for jacobianA, but it's cheap to just do a couple of adds.
120124 Vector3Wide . Add ( jacobianB , jacobianC , out negatedJacobianA ) ;
125+ //Normalize the jacobian to unit length. The jacobians are cross products of edges with the unit normal,
126+ //giving magnitudes proportional to edge lengths (~L). Without normalization, the inverse effective mass
127+ //scales with L², causing the accumulated impulse scale to vary with triangle size and making warm starting unstable.
128+ //Normalizing gives a unit-length effective jacobian J_eff = inverseJacobianLength * J_raw.
129+ //The inverse effective mass becomes a weighted average of inverse masses (always bounded),
130+ //and the physical impulse is identical because the scaling factors cancel in the solve.
131+ Vector3Wide . Dot ( negatedJacobianA , negatedJacobianA , out contributionA ) ;
132+ Vector3Wide . Dot ( jacobianB , jacobianB , out contributionB ) ;
133+ Vector3Wide . Dot ( jacobianC , jacobianC , out contributionC ) ;
134+ var jacobianLengthSquared = contributionA + contributionB + contributionC ;
135+ //Guard against the degenerate case where edges are parallel/antiparallel (triangle collapses to a line).
136+ jacobianLengthSquared = Vector . Max ( new Vector < float > ( 1e-14f ) , jacobianLengthSquared ) ;
137+ inverseJacobianLength = MathHelper . FastReciprocalSquareRoot ( jacobianLengthSquared ) ;
121138 }
122139
123140 public static void WarmStart (
124141 in Vector3Wide positionA , in QuaternionWide orientationA , in BodyInertiaWide inertiaA ,
125- in Vector3Wide positionB , in QuaternionWide orientationB , in BodyInertiaWide inertiaB ,
126- in Vector3Wide positionC , in QuaternionWide orientationC , in BodyInertiaWide inertiaC ,
142+ in Vector3Wide positionB , in QuaternionWide orientationB , in BodyInertiaWide inertiaB ,
143+ in Vector3Wide positionC , in QuaternionWide orientationC , in BodyInertiaWide inertiaC ,
127144 ref AreaConstraintPrestepData prestep , ref Vector < float > accumulatedImpulses , ref BodyVelocityWide wsvA , ref BodyVelocityWide wsvB , ref BodyVelocityWide wsvC )
128145 {
129- ComputeJacobian ( positionA , positionB , positionC , out _ , out var negatedJacobianA , out var jacobianB , out var jacobianC ) ;
130- ApplyImpulse ( inertiaA . InverseMass , inertiaB . InverseMass , inertiaC . InverseMass , negatedJacobianA , jacobianB , jacobianC , accumulatedImpulses , ref wsvA , ref wsvB , ref wsvC ) ;
146+ ComputeJacobian ( positionA , positionB , positionC , out _ , out var negatedJacobianA , out var jacobianB , out var jacobianC , out _ , out _ , out _ , out var inverseJacobianLength ) ;
147+ //The accumulated impulse is in unit-jacobian space. Replay through J_eff = inverseJacobianLength * J_raw.
148+ //Since |J_eff| = 1, the warm start magnitude is bounded by |accumulated| * max(invMass), same as a distance constraint.
149+ ApplyImpulse ( inertiaA . InverseMass , inertiaB . InverseMass , inertiaC . InverseMass , negatedJacobianA , jacobianB , jacobianC , inverseJacobianLength * accumulatedImpulses , ref wsvA , ref wsvB , ref wsvC ) ;
131150 }
132151
133152 public static void Solve ( in Vector3Wide positionA , in QuaternionWide orientationA , in BodyInertiaWide inertiaA , in Vector3Wide positionB , in QuaternionWide orientationB , in BodyInertiaWide inertiaB , in Vector3Wide positionC , in QuaternionWide orientationC , in BodyInertiaWide inertiaC , float dt , float inverseDt , ref AreaConstraintPrestepData prestep , ref Vector < float > accumulatedImpulses , ref BodyVelocityWide wsvA , ref BodyVelocityWide wsvB , ref BodyVelocityWide wsvC )
134153 {
135- ComputeJacobian ( positionA , positionB , positionC , out var normalLength , out var negatedJacobianA , out var jacobianB , out var jacobianC ) ;
136-
137- Vector3Wide . Dot ( negatedJacobianA , negatedJacobianA , out var contributionA ) ;
138- Vector3Wide . Dot ( jacobianB , jacobianB , out var contributionB ) ;
139- Vector3Wide . Dot ( jacobianC , jacobianC , out var contributionC ) ;
140-
141- //Protect against singularity by padding the jacobian contributions. This is very much a hack, but it's a pretty simple hack.
142- //Less sensitive to tuning than attempting to guard the inverseEffectiveMass itself, since that is sensitive to both scale AND mass.
143-
144- //Choose an epsilon based on the target area. Note that area ~= width^2 and our jacobian contributions are things like (ac x N) * (ac x N).
145- //Given that N is perpendicular to AC, ||(ac x N)|| == ||ac||, so the contribution is just ||ac||^2. Given the square, it's proportional to area and the area is a decent epsilon source.
146- var epsilon = 5e-4f * prestep . TargetScaledArea ;
147- contributionA = Vector . Max ( epsilon , contributionA ) ;
148- contributionB = Vector . Max ( epsilon , contributionB ) ;
149- contributionC = Vector . Max ( epsilon , contributionC ) ;
150- var inverseEffectiveMass = contributionA * inertiaA . InverseMass + contributionB * inertiaB . InverseMass + contributionC * inertiaC . InverseMass ;
154+ //The area jacobians (ac x normal, normal x ab) have magnitude ~L (edge lengths).
155+ //Without normalization, the inverse effective mass scales with L², making the accumulated impulse
156+ //scale vary with triangle size and causing warm start instability.
157+ //We normalize to a unit-length effective jacobian: J_eff = J_raw * inverseJacobianLength, where inverseJacobianLength = 1/|J_raw|.
158+ //The inverse effective mass becomes a weighted average of inverse masses (always bounded),
159+ //keeping the accumulated impulse well-scaled across substeps.
160+ //
161+ //The position error is scaled to match: error = (targetArea - area) * inverseJacobianLength.
162+ //The physical impulse (inverseJacobianLength * csi applied through J_raw) is identical to the raw formulation
163+ //because the inverseJacobianLength factors cancel.
164+ ComputeJacobian ( positionA , positionB , positionC , out var normalLength , out var negatedJacobianA , out var jacobianB , out var jacobianC , out var contributionA , out var contributionB , out var contributionC , out var inverseJacobianLength ) ;
165+ var inverseJacobianLengthSquared = inverseJacobianLength * inverseJacobianLength ;
166+
167+ //With the unit-length jacobian, the inverse effective mass is a weighted average of inverse masses, always bounded.
168+ //Guard against degenerate configurations (e.g. triangle collapsed to a line) where all jacobian contributions are zero,
169+ //which would cause a division by zero when computing the effective mass.
170+ var inverseEffectiveMass = Vector . Max ( new Vector < float > ( 1e-14f ) ,
171+ inverseJacobianLengthSquared * ( contributionA * inertiaA . InverseMass + contributionB * inertiaB . InverseMass + contributionC * inertiaC . InverseMass ) ) ;
151172
152173 SpringSettingsWide . ComputeSpringiness ( prestep . SpringSettings , dt , out var positionErrorToVelocity , out var effectiveMassCFMScale , out var softnessImpulseScale ) ;
153174
154175 var effectiveMass = effectiveMassCFMScale / inverseEffectiveMass ;
155176 //Compute the position error and bias velocities. Note the order of subtraction when calculating error- we want the bias velocity to counteract the separation.
156- var biasVelocity = ( prestep . TargetScaledArea - normalLength ) * positionErrorToVelocity ;
177+ var biasVelocity = ( prestep . TargetScaledArea - normalLength ) * inverseJacobianLength * positionErrorToVelocity ;
157178
158179 //csi = projection.BiasImpulse - accumulatedImpulse * projection.SoftnessImpulseScale - (csiaLinear + csiaAngular + csibLinear + csibAngular);
159180 Vector3Wide . Dot ( negatedJacobianA , wsvA . Linear , out var negatedVelocityContributionA ) ;
160181 Vector3Wide . Dot ( jacobianB , wsvB . Linear , out var velocityContributionB ) ;
161182 Vector3Wide . Dot ( jacobianC , wsvC . Linear , out var velocityContributionC ) ;
162- var csv = velocityContributionB + velocityContributionC - negatedVelocityContributionA ;
183+ var csv = inverseJacobianLength * ( velocityContributionB + velocityContributionC - negatedVelocityContributionA ) ;
163184 var csi = ( biasVelocity - csv ) * effectiveMass - accumulatedImpulses * softnessImpulseScale ;
164185 accumulatedImpulses += csi ;
165186
166- ApplyImpulse ( inertiaA . InverseMass , inertiaB . InverseMass , inertiaC . InverseMass , negatedJacobianA , jacobianB , jacobianC , csi , ref wsvA , ref wsvB , ref wsvC ) ;
187+ ApplyImpulse ( inertiaA . InverseMass , inertiaB . InverseMass , inertiaC . InverseMass , negatedJacobianA , jacobianB , jacobianC , inverseJacobianLength * csi , ref wsvA , ref wsvB , ref wsvC ) ;
167188 }
168189
169190 public static bool RequiresIncrementalSubstepUpdates => false ;
0 commit comments