diff --git a/CHANGELOG.md b/CHANGELOG.md index 022b272..8d5bccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`ShapeDefinition.Friction` and `ShapeDefinition.Restitution`**, shortcuts for + the two material properties changed most often. Changing one used to take a + nested `with`; now `ShapeDefinition.Default with { Friction = 0.7f }` does it. + Both read and write `Material` and store nothing of their own, so a definition + built either way is equal to the other. Initializers run in order, so set + `Material` before them when using both. + +### Changed + +- **`PhysicsWorld.Explode` says which shapes respond.** It read "only spheres, + capsules and hulls respond", which in an API where `Box` and `ConvexHull` are + different types reads as "boxes do not". They do: a box is a hull. The remarks + now say so, and that meshes, height fields and compounds never respond, + because they only go on static bodies and an explosion only reaches dynamic + ones; kinematic bodies are not pushed either. `Body.AddBox` and + `ShapeType.Hull` note that a box reports `ShapeType.Hull`. `ExplosionTests` + pins all of it. + +- **The joint guide warns about mass ratios.** A chain of light links holding a + heavy body — about 1450:1 across the last joint — lets the links reach + hundreds of metres per second, while 20:1 hangs still. That is the solver, not + the binding, but nothing said so. `docs/guides/joints.md` now recommends + keeping the ratio across each joint to roughly 10:1–20:1 or less, with the + measurement, and the `chain` sample says the same where its links are made. + +- **`Explode`'s `filter` is documented as the mask it is.** The name suggests a + filter type, and the queries take a `QueryFilter`, but this is a `ulong` + compared with each shape's `CollisionFilter.Categories`, in that direction + only: the shape's own `CollidesWith` is not consulted. The parameter keeps its + name, since renaming it would break callers passing it by name. No + `QueryFilter` overload was added, because it would have to ignore + `QueryFilter.Categories` without saying so. + ## [0.5.0] - 2026-09-23 An engine release, and a minor version for two reasons at once: the idiomatic diff --git a/docs/guides/joints.md b/docs/guides/joints.md index 1bcff95..30943ff 100644 --- a/docs/guides/joints.md +++ b/docs/guides/joints.md @@ -51,6 +51,27 @@ Build the assembly in its rest pose. A chain assembled already displaced has every joint violated on the first step and snaps — give it angular velocity instead. +## Keep mass ratios modest + +> **Keep the mass ratio between jointed bodies modest — roughly 10:1 to 20:1 or +> less.** Light links holding a heavy body jitter, stretch or fly apart. Make +> the links heavier or the load lighter. + +This is how the solver behaves, not a fault in a particular joint. It resolves +each joint iteratively, and a light body between a heavy one and an anchor +barely moves the heavy one, so the error never converges. Measured with a chain +of ten `Spherical` joints, links of radius 0.14 and a ball of radius 1.1: + +| Link density | Ball density | Mass ratio | Fastest link | Worst stretch at the ball | +| ---: | ---: | ---: | ---: | ---: | +| 2 | 6 | about 1450:1 | 427 m/s | 0.66 m | +| 60 | 2.5 | about 20:1 | 3.1 m/s | 0.003 m | + +The masses that matter are the ones either side of each joint, so a chain of +equal links is fine however heavy it is overall; it is the last link against the +load that has to be kept in proportion. Density is the lever: mass follows from +it and the volume. + ## Limits and motors Most joints take limits, a motor, or both. The pattern is the same everywhere: diff --git a/src/Box3D.NET.Samples/JointSamples.cs b/src/Box3D.NET.Samples/JointSamples.cs index 57044d2..7116c6f 100644 --- a/src/Box3D.NET.Samples/JointSamples.cs +++ b/src/Box3D.NET.Samples/JointSamples.cs @@ -107,6 +107,9 @@ public static void Run() { Vector3 position = new(0.0f, 10.0f - ((i + 1) * LinkSpacing), 0.0f); + // Every link weighs the same. Hanging something heavy off the end of + // light links is what makes a chain jitter or fly apart; keep the + // mass ratio across each joint to about 10:1 or 20:1 at most. Body link = world.CreateBody(BodyDefinition.Dynamic(position)); link.AddSphere(new Sphere(0.12f), ShapeDefinition.Default with { Density = 2000.0f }); links[i] = link; diff --git a/src/Box3D.NET.Tests/ExplosionTests.cs b/src/Box3D.NET.Tests/ExplosionTests.cs new file mode 100644 index 0000000..115cd6a --- /dev/null +++ b/src/Box3D.NET.Tests/ExplosionTests.cs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT + +using System; +using System.Numerics; +using Xunit; + +namespace Box3D.Tests; + +/// +/// Holds to what its documentation says +/// responds and what does not. +/// +/// +/// The documentation once read "only spheres, capsules and hulls respond", which +/// in an API where and are different +/// types reads as "boxes do not". They do, because a box is a hull, and these +/// tests pin that alongside the cases that genuinely do not respond. +/// +[Collection(NativeCollection.Name)] +public class ExplosionTests : IDisposable +{ + private readonly PhysicsWorld _world = new(WorldSettings.Default with { Gravity = Vector3.Zero }); + + public void Dispose() + { + _world.Dispose(); + GC.SuppressFinalize(this); + } + + // At the default density a one-metre cube weighs a tonne, so the impulse is sized to + // move it at about a metre per second. + private void Blast(ulong mask = ulong.MaxValue) => + _world.Explode(Vector3.Zero, radius: 5.0f, impulsePerArea: 1000.0f, filter: mask); + + [NativeFact] + public void A_box_is_a_hull() + { + Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f)); + Shape box = body.AddBox(Box.Cube(0.5f)); + + Assert.Equal(ShapeType.Hull, box.Type); + } + + [NativeFact] + public void A_box_is_pushed_away_from_the_blast() + { + Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f)); + body.AddBox(Box.Cube(0.5f)); + + Blast(); + + Assert.True(body.LinearVelocity.X > 0.1f, $"the box should be pushed along +x, velocity {body.LinearVelocity}"); + } + + [NativeFact] + public void Spheres_and_capsules_are_pushed_too() + { + Body sphere = _world.CreateDynamicBody(new Vector3(0.0f, 2.0f, 0.0f)); + sphere.AddSphere(new Sphere(0.5f)); + + Body capsule = _world.CreateDynamicBody(new Vector3(0.0f, 0.0f, 2.0f)); + capsule.AddCapsule(Capsule.Upright(1.0f, 0.25f)); + + Blast(); + + Assert.True(sphere.LinearVelocity.Y > 0.1f, $"sphere velocity {sphere.LinearVelocity}"); + Assert.True(capsule.LinearVelocity.Z > 0.1f, $"capsule velocity {capsule.LinearVelocity}"); + } + + [NativeFact] + public void A_kinematic_body_is_not_pushed() + { + Body body = _world.CreateBody(BodyDefinition.Kinematic(new Vector3(2.0f, 0.0f, 0.0f))); + body.AddBox(Box.Cube(0.5f)); + + Blast(); + + Assert.Equal(Vector3.Zero, body.LinearVelocity); + } + + [NativeFact] + public void A_body_beyond_the_radius_and_falloff_is_not_pushed() + { + Body body = _world.CreateDynamicBody(new Vector3(20.0f, 0.0f, 0.0f)); + body.AddBox(Box.Cube(0.5f)); + + Blast(); + + Assert.Equal(Vector3.Zero, body.LinearVelocity); + } + + [NativeFact] + public void A_sleeping_body_is_woken_and_pushed() + { + Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f)); + body.AddBox(Box.Cube(0.5f)); + body.IsAwake = false; + + Blast(); + + Assert.True(body.IsAwake, "the blast should wake the body"); + Assert.True(body.LinearVelocity.X > 0.1f, $"velocity {body.LinearVelocity}"); + } + + [NativeFact] + public void The_filter_is_a_category_mask() + { + var debris = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { Categories = 0x2 } }; + var scenery = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { Categories = 0x4 } }; + + Body affected = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f)); + affected.AddBox(Box.Cube(0.5f), debris); + + Body spared = _world.CreateDynamicBody(new Vector3(-2.0f, 0.0f, 0.0f)); + spared.AddBox(Box.Cube(0.5f), scenery); + + // The mask is compared with each shape's categories, not with what the + // shape collides with. + Blast(mask: 0x2); + + Assert.True(affected.LinearVelocity.X > 0.1f, $"the debris should be pushed, velocity {affected.LinearVelocity}"); + Assert.Equal(Vector3.Zero, spared.LinearVelocity); + } + + [NativeFact] + public void The_filter_ignores_what_the_shape_collides_with() + { + // Unlike a query, which also needs the shape to accept the query's + // categories, an explosion checks one direction only. A shape that + // collides with nothing is still pushed. + var aloof = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { CollidesWith = 0 } }; + + Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f)); + body.AddBox(Box.Cube(0.5f), aloof); + + Blast(); + + Assert.True(body.LinearVelocity.X > 0.1f, $"velocity {body.LinearVelocity}"); + } +} diff --git a/src/Box3D.NET.Tests/ShapeDefinitionTests.cs b/src/Box3D.NET.Tests/ShapeDefinitionTests.cs new file mode 100644 index 0000000..c9d1ffc --- /dev/null +++ b/src/Box3D.NET.Tests/ShapeDefinitionTests.cs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +using Xunit; + +namespace Box3D.Tests; + +/// +/// Covers the friction and restitution shortcuts on . +/// +[Collection(NativeCollection.Name)] +public class ShapeDefinitionTests +{ + [NativeFact] + public void The_friction_shortcut_changes_only_the_friction() + { + ShapeDefinition def = ShapeDefinition.Default with { Friction = 0.7f }; + + Assert.Equal(0.7f, def.Friction); + Assert.Equal(PhysicsMaterial.Default with { Friction = 0.7f }, def.Material); + } + + [NativeFact] + public void The_restitution_shortcut_changes_only_the_restitution() + { + ShapeDefinition def = ShapeDefinition.Default with { Restitution = 0.8f }; + + Assert.Equal(0.8f, def.Restitution); + Assert.Equal(PhysicsMaterial.Default with { Restitution = 0.8f }, def.Material); + } + + [NativeFact] + public void A_shortcut_is_the_same_definition_as_the_nested_form() + { + ShapeDefinition shortcut = ShapeDefinition.Default with { Friction = 0.7f, Restitution = 0.2f }; + ShapeDefinition nested = ShapeDefinition.Default with + { + Material = PhysicsMaterial.Default with { Friction = 0.7f, Restitution = 0.2f }, + }; + + // Nothing is stored outside Material, so equality and hashing agree. + Assert.Equal(nested, shortcut); + Assert.Equal(nested.GetHashCode(), shortcut.GetHashCode()); + } + + [NativeFact] + public void A_shortcut_after_a_material_refines_it() + { + var ice = PhysicsMaterial.Default with { Friction = 0.02f, UserMaterialId = 7 }; + + ShapeDefinition def = ShapeDefinition.Default with { Material = ice, Restitution = 0.5f }; + + Assert.Equal(0.02f, def.Friction); + Assert.Equal(0.5f, def.Restitution); + Assert.Equal(7UL, def.Material.UserMaterialId); + } + + [NativeFact] + public void The_shortcuts_reach_the_shape() + { + using var world = new PhysicsWorld(); + Body body = world.CreateDynamicBody(); + + Shape shape = body.AddBox(Box.Cube(0.5f), ShapeDefinition.Default with { Friction = 0.7f, Restitution = 0.3f }); + + Assert.Equal(0.7f, shape.Friction); + Assert.Equal(0.3f, shape.Restitution); + } +} diff --git a/src/Box3D.NET/Body.cs b/src/Box3D.NET/Body.cs index 2bcb85e..0355b30 100644 --- a/src/Box3D.NET/Body.cs +++ b/src/Box3D.NET/Body.cs @@ -290,7 +290,10 @@ public unsafe Shape AddCapsule(Capsule capsule, ShapeDefinition? definition = nu /// The new shape. /// /// The box is a convex hull underneath, but it is self-contained, so nothing - /// needs to be released afterwards. + /// needs to be released afterwards. Because it is a hull, the shape reports + /// as its , and behaves as + /// a hull everywhere, including in . There is + /// no need to build a box from eight corners with . /// /// /// diff --git a/src/Box3D.NET/PhysicsWorld.cs b/src/Box3D.NET/PhysicsWorld.cs index 837593a..3035204 100644 --- a/src/Box3D.NET/PhysicsWorld.cs +++ b/src/Box3D.NET/PhysicsWorld.cs @@ -744,9 +744,25 @@ public WorldEvents Events /// The impulse per unit of facing area. Negative values implode. /// /// The distance beyond the radius over which the impulse fades to nothing. - /// Which shape categories are affected. Defaults to all of them. + /// + /// A category mask, not a : a shape is affected when + /// this has a bit in common with its . + /// It plays the part of in a query, but + /// only in that direction; the shape's own + /// is not consulted. Defaults to + /// every category. + /// /// The world has been disposed. - /// Only spheres, capsules and hulls respond. + /// + /// + /// Spheres, capsules and hulls respond, and boxes are hulls, so a shape added + /// with responds too. Meshes, height fields and + /// compounds do not: they can only be attached to static bodies, and an + /// explosion only reaches dynamic ones. Kinematic bodies are not pushed + /// either. + /// + /// Sleeping bodies within reach are woken. + /// public void Explode( Vector3 center, float radius, diff --git a/src/Box3D.NET/ShapeDefinition.cs b/src/Box3D.NET/ShapeDefinition.cs index 014ed9d..f5c59bc 100644 --- a/src/Box3D.NET/ShapeDefinition.cs +++ b/src/Box3D.NET/ShapeDefinition.cs @@ -19,6 +19,10 @@ public enum ShapeType HeightField = 2, /// A convex hull. + /// + /// Boxes are hulls too: a shape added with reports + /// this type, since Box3D has no separate box shape. + /// Hull = 3, /// A triangle mesh. @@ -48,7 +52,7 @@ public enum ShapeType /// var def = ShapeDefinition.Default with /// { /// Density = 2000.0f, -/// Material = PhysicsMaterial.Default with { Friction = 0.9f }, +/// Friction = 0.9f, /// EnableContactEvents = true, /// }; /// @@ -76,6 +80,47 @@ public readonly record struct ShapeDefinition /// Gets the surface properties. public PhysicsMaterial Material { get; init; } + /// Gets the friction of . + /// + /// + /// A shortcut for the common case of changing only the friction, which + /// otherwise takes a nested with. Setting it replaces the material + /// with a copy that differs only in friction; nothing is stored apart from + /// . + /// + /// + /// Initializers run in order, so when setting both, set + /// first: a after this one + /// replaces the friction it set. + /// + /// + /// + /// + /// var grippy = ShapeDefinition.Default with { Friction = 0.9f }; + /// // Same as: + /// var nested = ShapeDefinition.Default with + /// { + /// Material = PhysicsMaterial.Default with { Friction = 0.9f }, + /// }; + /// + /// + public float Friction + { + get => Material.Friction; + init => Material = Material with { Friction = value }; + } + + /// Gets the restitution, or bounciness, of . + /// + /// A shortcut in the same way as , and with the same + /// caveat: set first when setting both. + /// + public float Restitution + { + get => Material.Restitution; + init => Material = Material with { Restitution = value }; + } + /// Gets the collision filtering rules. public CollisionFilter Filter { get; init; }