Table of Contents

Mesh Colliders

A collider made from real geometry, two ways: a convex hull wraps the model in the smallest convex shape that holds it, so a ring becomes a disc, and a mesh collider is the model's own triangles, so the hole stays a hole. A ball rests on the hull's invisible lid and falls through the mesh's hole; a mesh sphere shows that a mesh is a surface with no inside; a moving mesh ring lands on a mesh ring. The overlay prints where each dropped thing came to rest, and P draws the colliders.

The Program.cs file shows how to:

  • ConvexHullCollider from a model through TorusCollider, and what a hull does to a hole
  • MeshCollider from a model, attached once the model exists, Closed for the inertia
  • A mesh is a surface with no inside, so a body that starts inside is not pushed out
  • Meshes meet meshes, triangle against triangle, which is why what moves is better off convex
  • Reading where a body came to rest from its position and velocity, with Teleport for the replay
  • Using helpers: SceneSetup3D, CreatePrimitive, TorusCollider, AddPhysicsDebug, TextOverlay

Mesh Colliders

View on GitHub.

using Stride.BepuPhysics;
using Stride.BepuPhysics.Definitions;
using Stride.BepuPhysics.Definitions.Colliders;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Rendering.Overlay;
using Stride.CommunityToolkit.Rendering.Text;
using Stride.CommunityToolkit.Skyboxes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Input;

// A collider made from real geometry, two ways. A convex hull wraps the model in the smallest convex
// shape that holds it, so a ring becomes a disc: the hole is filled. A mesh collider is the model's
// own triangles, so the hole stays a hole. Four stations show what each one does, and what a mesh
// is not:
//
//   1. Hull ring, a ball dropped at the centre: the ball rests on a lid that is not drawn. P shows it.
//   2. Mesh ring, the same drop: the ball falls through the hole to the ground.
//   3. Mesh sphere, one ball dropped onto it and one started inside it. A mesh is a surface with no
//      inside: the outer ball rests on top, and the overlay says what became of the inner one.
//   4. A moving mesh ring dropped onto a mesh ring: meshes do meet, triangle against triangle.
//
// So the rule: a mesh collider is for what the scene is made of, the level, the ground, a bowl, and
// it is usually static. What moves is better off convex, a hull or a compound of several, because a
// convex pair is cheap and a convex shape has an inside.
//
// A mesh collider is built from the model, so it is attached once the model exists. The entity's
// scale scales the mesh with it. Closed says the surface has no holes, which is what the inertia of a
// moving mesh is computed from; the ring is closed, a bowl or a terrain is not.

const float RingRadius = 1.2f;      // From the centre to the middle of the tube
const float TubeRadius = 0.35f;     // So the hole is 0.85 across from the centre, and the ball fits
const float ShellRadius = 1.2f;
const float BallRadius = 0.4f;
const float RestHeight = 2.5f;      // The static shapes float here: a static needs no support
const float DropHeight = 3f;        // Above the shape
const float StationSpacing = 3.8f;

var hullColour = new Color(240, 160, 70);
var meshColour = new Color(90, 190, 200);
var ballColour = new Color(235, 235, 240);

List<DropStation> stations = [];
var launched = false;

using var game = new Game();

var sceneSetup = new SceneSetup3D
{
    // Above and in front of the stations, shifted right so they stay clear of the overlay
    Camera = CameraSetup.Perspective(position: new Vector3(4.8f, 9.5f, 15.5f), rotation: new Vector3(0f, -30f, 0f)).WithController(),
    Profiler = true,
    WindowTitle = "Mesh colliders - Stride Community Toolkit",
    Resizable = true,
    DpiAware = true,
}.UseBepu().UseSkybox();

game.Run(sceneSetup, start: Start, update: Update);

void Start(Scene scene)
{
    // P draws every collider, so the hull's lid and the mesh's triangles can be seen
    game.AddPhysicsDebug();

    var x = -1.5f * StationSpacing;

    BuildRingStation(scene, "1. Hull", x, mesh: false);
    BuildRingStation(scene, "2. Mesh", x + StationSpacing, mesh: true);
    BuildShellStation(scene, "3. Mesh shell", x + 2 * StationSpacing);
    BuildMovingRingStation(scene, "4. Mesh on mesh", x + 3 * StationSpacing);

    TextOverlay.GetOrCreate(game).AddSection("Mesh colliders", BuildOverlayLines);
}

void Update(Scene scene, GameTime time)
{
    // A teleport only takes on a body the simulation already knows, so the first drop waits for it
    var ready = stations.Count > 0 && stations[^1].Body.Simulation is not null;

    if (ready && (!launched || game.Input.IsKeyPressed(Keys.R)))
    {
        launched = true;

        foreach (var station in stations) station.Replay();
    }
}

/// <summary>A static ring with the collider the station is about, and a ball dropped at its centre.</summary>
void BuildRingStation(Scene scene, string label, float x, bool mesh)
{
    AddRing(scene, new Vector3(x, RestHeight, 0f), mesh, moving: false);

    var ball = AddBall(scene, new Vector3(x, RestHeight + DropHeight, 0f));

    stations.Add(new DropStation(label, ball, mesh ? meshColour : hullColour,
        static y => y > RestHeight ? "on the lid" : "through the hole"));

    AddLabel(scene, label, x);
}

/// <summary>A static mesh sphere, hollow as every mesh is, with a ball dropped onto it and a ball started inside it.</summary>
void BuildShellStation(Scene scene, string label, float x)
{
    var shell = game.CreatePrimitive(Primitive3D.Sphere(ShellRadius), new BepuPhysicsOptions
    {
        Material = game.CreateMaterial(meshColour, metalness: 0f, glossiness: 0.4f),
        Body = BodyMode.None,
        Position = new Vector3(x, RestHeight, 0f),
    });

    shell.Add(new StaticComponent { Collider = new MeshCollider { Model = shell.Get<ModelComponent>().Model, Closed = true } });
    shell.Scene = scene;

    var outer = AddBall(scene, new Vector3(x, RestHeight + DropHeight, 0f));
    var inner = AddBall(scene, new Vector3(x, RestHeight, 0f));

    stations.Add(new DropStation("3. Shell, outside", outer, meshColour,
        static y => y > RestHeight + ShellRadius ? "on top" : "through it"));

    stations.Add(new DropStation("3. Shell, inside", inner, meshColour,
        static y => y > RestHeight - ShellRadius ? "still inside" : "fell out"));

    AddLabel(scene, label, x);
}

/// <summary>A static mesh ring, and a moving mesh ring dropped onto it.</summary>
void BuildMovingRingStation(Scene scene, string label, float x)
{
    AddRing(scene, new Vector3(x, RestHeight, 0f), mesh: true, moving: false);

    var ring = AddRing(scene, new Vector3(x, RestHeight + DropHeight, 0f), mesh: true, moving: true);

    stations.Add(new DropStation(label, ring.Get<BodyComponent>(), meshColour,
        static y => y > RestHeight ? "on the ring" : "through it, on the ground"));

    AddLabel(scene, label, x);
}

/// <summary>
/// One ring of the shared model. A hull ring gets <see cref="TorusCollider"/>, the convex hull of the
/// same mesh, inside a compound like any other shape; a mesh ring gets a <see cref="MeshCollider"/>
/// built from its model once the model exists.
/// </summary>
Entity AddRing(Scene scene, Vector3 position, bool mesh, bool moving)
{
    var options = new BepuPhysicsOptions
    {
        Material = game.CreateMaterial(mesh ? meshColour : hullColour, metalness: 0f, glossiness: 0.4f),
        Position = position,
    };

    if (mesh)
    {
        // The mesh collider needs the model, so the physics is attached below
        options.Body = BodyMode.None;
    }
    else
    {
        var hull = new CompoundCollider { Colliders = { TorusCollider.Create(RingRadius, TubeRadius) } };

        options.Component = moving
            ? new BodyComponent { Collider = hull, InterpolationMode = InterpolationMode.Interpolated }
            : new StaticComponent { Collider = hull };
    }

    var ring = game.CreatePrimitive(Primitive3D.Torus(RingRadius, TubeRadius), options);

    if (mesh)
    {
        // The model is all the collider needs: when the component attaches, Bepu reads the model's vertex
        // and index buffers and builds the triangles from them, scaled by the entity's transform. No
        // shape is written by hand and no asset is involved, so any model will do, loaded or procedural
        var collider = new MeshCollider { Model = ring.Get<ModelComponent>().Model, Closed = true };

        ring.Add(moving
            ? new BodyComponent { Collider = collider, InterpolationMode = InterpolationMode.Interpolated }
            : new StaticComponent { Collider = collider });
    }

    ring.Scene = scene;

    return ring;
}

/// <summary>A ball with the default sphere collider, drawn between physics steps too so its fall is smooth.</summary>
BodyComponent AddBall(Scene scene, Vector3 position)
{
    var ball = game.CreatePrimitive(Primitive3D.Sphere(BallRadius), new BepuPhysicsOptions
    {
        Material = game.CreateMaterial(ballColour, metalness: 0f, glossiness: 0.6f),
        Position = position,
    });

    ball.Scene = scene;

    return ball.Get<BodyComponent>();
}

/// <summary>The station's name on the ground in front of it.</summary>
void AddLabel(Scene scene, string text, float x)
{
    var label = new Entity("Label")
    {
        new EntityTextComponent { Text = text, FontSize = 14, Anchor = Anchor.MiddleCenter, EnableShadow = true },
    };

    label.Transform.Position = new Vector3(x, 0f, RingRadius + 0.8f);
    label.Scene = scene;
}

IReadOnlyList<TextElement> BuildOverlayLines()
{
    List<TextElement> lines =
    [
        new("R", "Drop everything again", Color.Gold),
        new("P", "Draw the collider gizmos", Color.Gold),
        new("F11", "Draw the debug meshes", Color.Gold),
        new(""),
        new("Where each came to rest", Color.LightGray),
    ];

    foreach (var station in stations)
    {
        lines.Add(new(station.Line, station.Colour));
    }

    return lines;
}

/// <summary>
/// The thing a station drops, a ball or a ring, where it starts, and the <paramref name="Verdict"/> its
/// height earns once it has stopped.
/// </summary>
sealed record DropStation(string Label, BodyComponent Body, Color Colour, Func<float, string> Verdict)
{
    /// <summary>Where the body was made, which is where it goes back to.</summary>
    internal Vector3 Start { get; } = Body.Entity.Transform.Position;

    /// <summary>The label, the height, and the verdict, or "falling" while the body still moves.</summary>
    internal string Line
    {
        get
        {
            var y = Body.Position.Y;
            var verdict = Body.LinearVelocity.LengthSquared() > 0.01f ? "falling" : Verdict(y);

            return $"{Label}: {y:0.00} m, {verdict}";
        }
    }

    /// <summary>Puts the body back where it started, still.</summary>
    internal void Replay() => Body.TeleportAtRest(Start);
}