Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/joints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/Box3D.NET.Samples/JointSamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
140 changes: 140 additions & 0 deletions src/Box3D.NET.Tests/ExplosionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: MIT

using System;
using System.Numerics;
using Xunit;

namespace Box3D.Tests;

/// <summary>
/// Holds <see cref="PhysicsWorld.Explode"/> to what its documentation says
/// responds and what does not.
/// </summary>
/// <remarks>
/// The documentation once read "only spheres, capsules and hulls respond", which
/// in an API where <see cref="Box"/> and <see cref="ConvexHull"/> 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.
/// </remarks>
[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}");
}
}
68 changes: 68 additions & 0 deletions src/Box3D.NET.Tests/ShapeDefinitionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT

using Xunit;

namespace Box3D.Tests;

/// <summary>
/// Covers the friction and restitution shortcuts on <see cref="ShapeDefinition"/>.
/// </summary>
[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);
}
}
5 changes: 4 additions & 1 deletion src/Box3D.NET/Body.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ public unsafe Shape AddCapsule(Capsule capsule, ShapeDefinition? definition = nu
/// <returns>The new shape.</returns>
/// <remarks>
/// 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
/// <see cref="ShapeType.Hull"/> as its <see cref="Shape.Type"/>, and behaves as
/// a hull everywhere, including in <see cref="PhysicsWorld.Explode"/>. There is
/// no need to build a box from eight corners with <see cref="ConvexHull"/>.
/// </remarks>
/// <example>
/// <code>
Expand Down
20 changes: 18 additions & 2 deletions src/Box3D.NET/PhysicsWorld.cs
Original file line number Diff line number Diff line change
Expand Up @@ -744,9 +744,25 @@ public WorldEvents Events
/// The impulse per unit of facing area. Negative values implode.
/// </param>
/// <param name="falloff">The distance beyond the radius over which the impulse fades to nothing.</param>
/// <param name="filter">Which shape categories are affected. Defaults to all of them.</param>
/// <param name="filter">
/// A category mask, not a <see cref="QueryFilter"/>: a shape is affected when
/// this has a bit in common with its <see cref="CollisionFilter.Categories"/>.
/// It plays the part of <see cref="QueryFilter.CollidesWith"/> in a query, but
/// only in that direction; the shape's own
/// <see cref="CollisionFilter.CollidesWith"/> is not consulted. Defaults to
/// every category.
/// </param>
/// <exception cref="ObjectDisposedException">The world has been disposed.</exception>
/// <remarks>Only spheres, capsules and hulls respond.</remarks>
/// <remarks>
/// <para>
/// Spheres, capsules and hulls respond, and boxes are hulls, so a shape added
/// with <see cref="Body.AddBox"/> 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.
/// </para>
/// <para>Sleeping bodies within reach are woken.</para>
/// </remarks>
public void Explode(
Vector3 center,
float radius,
Expand Down
47 changes: 46 additions & 1 deletion src/Box3D.NET/ShapeDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public enum ShapeType
HeightField = 2,

/// <summary>A convex hull.</summary>
/// <remarks>
/// Boxes are hulls too: a shape added with <see cref="Body.AddBox"/> reports
/// this type, since Box3D has no separate box shape.
/// </remarks>
Hull = 3,

/// <summary>A triangle mesh.</summary>
Expand Down Expand Up @@ -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,
/// };
///
Expand Down Expand Up @@ -76,6 +80,47 @@ public readonly record struct ShapeDefinition
/// <summary>Gets the surface properties.</summary>
public PhysicsMaterial Material { get; init; }

/// <summary>Gets the friction of <see cref="Material"/>.</summary>
/// <remarks>
/// <para>
/// A shortcut for the common case of changing only the friction, which
/// otherwise takes a nested <c>with</c>. Setting it replaces the material
/// with a copy that differs only in friction; nothing is stored apart from
/// <see cref="Material"/>.
/// </para>
/// <para>
/// Initializers run in order, so when setting both, set
/// <see cref="Material"/> first: a <see cref="Material"/> after this one
/// replaces the friction it set.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var grippy = ShapeDefinition.Default with { Friction = 0.9f };
/// // Same as:
/// var nested = ShapeDefinition.Default with
/// {
/// Material = PhysicsMaterial.Default with { Friction = 0.9f },
/// };
/// </code>
/// </example>
public float Friction
{
get => Material.Friction;
init => Material = Material with { Friction = value };
}

/// <summary>Gets the restitution, or bounciness, of <see cref="Material"/>.</summary>
/// <remarks>
/// A shortcut in the same way as <see cref="Friction"/>, and with the same
/// caveat: set <see cref="Material"/> first when setting both.
/// </remarks>
public float Restitution
{
get => Material.Restitution;
init => Material = Material with { Restitution = value };
}

/// <summary>Gets the collision filtering rules.</summary>
public CollisionFilter Filter { get; init; }

Expand Down
Loading