Table of Contents

Trigger Zones

Reacting to contact, three ways. A trigger zone is a static with no model whose handler says NoContactResponse: bodies pass through it and OnEnter and OnLeave say who came and went. A plate is a solid static with a handler of its own, counting what stands on it through OnStartedTouching and OnStoppedTouching. A crate is a dynamic body whose handler measures each hit from the contacts of the first touch. Space drops a ball over every station; the grabber carries bodies between them.

The Program.cs file shows how to:

  • ContactEventHandler on a collidable, and NoContactResponse deciding whether it is solid
  • Trigger with OnEnter and OnLeave on a static with no model, drawn as a debug box
  • IContactHandler on a solid static, OnStartedTouching and OnStoppedTouching keeping a set of touchers
  • IContactHandler on a dynamic body, the impact force of a first touch from its contacts
  • Callbacks run on the main thread after the physics step, so they may change materials and entities
  • F11 draws the solver's contact points and normals, Stride 4.4.1's debug view, through AddPhysicsDebug
  • Using helpers: SceneSetup3D, CreatePrimitive, AddGrabber, AddDebugShapes, AddPhysicsDebug, TextOverlay

Trigger Zones

View on GitHub.

using Stride.BepuPhysics;
using Stride.BepuPhysics.Definitions;
using Stride.BepuPhysics.Definitions.Colliders;
using Stride.BepuPhysics.Definitions.Contacts;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.DebugShapes;
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;
using Stride.Rendering;

// Reacting to contact. E05_3D_CollisionGroup and E05_3D_CollisionLayer decide what collides; this is
// what to do when it does. A collidable's ContactEventHandler hears about its contacts, and the handler
// says whether the collidable is solid. Three stations:
//
//   1. Zone: a Trigger on a static with no model. NoContactResponse is true, so bodies pass through it
//      as if it were not there, and OnEnter and OnLeave say who came and went.
//   2. Plate: a solid static with a handler of its own. Bodies rest on it like on any floor, and
//      OnStartedTouching and OnStoppedTouching count what stands on it.
//   3. Crate: a dynamic body with a handler that measures each hit, from the contacts of the first touch.
//
// Space drops a ball over every station, and the grabber carries bodies from one to another. The
// callbacks run on the main thread, after the physics step, so they may touch entities and materials.

const float StationSpacing = 5f;
const float BallRadius = 0.4f;
const int BallsKept = 30;

var zoneSize = new Vector3(3f, 2.2f, 3f);
var zoneCentre = new Vector3(-StationSpacing, zoneSize.Y * 0.5f, 0f);
var plateCentre = new Vector3(0f, 0.15f, 0f);
var crateStart = new Vector3(StationSpacing, 0.6f, 0f);

var idle = new Color(150, 150, 160);
var zoneColour = new Color(90, 200, 120);
var plateColour = new Color(255, 190, 60);
var crateColour = new Color(220, 90, 80);

HashSet<CollidableComponent> inside = [];
var entries = 0;
var plate = new PlateCounter();
var impact = new ImpactMeter();
List<Entity> balls = [];
var random = new Random(5);

Entity pad = null!;
Entity plateEntity = null!;
Entity crate = null!;
ImmediateDebugRenderSystem debugDraw = null!;
Material idleMaterial = null!;
Material zoneMaterial = null!;
Material plateMaterial = null!;
Material hitMaterial = null!;
Material crateMaterial = null!;
Material ballMaterial = null!;

using var game = new Game();

var sceneSetup = new SceneSetup3D
{
    Camera = CameraSetup.Perspective(position: new Vector3(1f, 8f, 13f), rotation: new Vector3(0f, -28f, 0f)).WithController(),
    Profiler = true,
    WindowTitle = "Trigger zones - Stride Community Toolkit",
    Resizable = true,
    DpiAware = true,
}.UseBepu().UseSkybox();

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

void Start(Scene scene)
{
    debugDraw = game.AddDebugShapes();
    debugDraw.Visible = true;

    // F11 shows the debug meshes with the contacts the solver is resolving, which is what this example is about
    game.AddPhysicsDebug(contactsVisible: true);
    game.AddGrabber();

    idleMaterial = game.CreateMaterial(idle, metalness: 0f, glossiness: 0.3f);
    zoneMaterial = game.CreateMaterial(zoneColour, metalness: 0f, glossiness: 0.3f);
    plateMaterial = game.CreateMaterial(plateColour, metalness: 0f, glossiness: 0.3f);
    hitMaterial = game.CreateMaterial(Color.White, metalness: 0f, glossiness: 0.3f);
    crateMaterial = game.CreateMaterial(crateColour, metalness: 0f, glossiness: 0.3f);
    ballMaterial = game.CreateMaterial(new Color(80, 170, 255), metalness: 0f, glossiness: 0.6f);

    BuildZone(scene);
    BuildPlate(scene);
    BuildCrate(scene);
    AddLabel(scene, "1. Zone", zoneCentre.X);
    AddLabel(scene, "2. Plate", plateCentre.X);
    AddLabel(scene, "3. Crate", crateStart.X);

    // A first batch, so the stations have something to report straight away
    DropBalls(scene);

    TextOverlay.GetOrCreate(game).AddSection("Trigger zones", BuildOverlayLines);
}

void Update(Scene scene, GameTime time)
{
    if (game.Input.IsKeyPressed(Keys.Space))
    {
        DropBalls(scene);
    }

    if (game.Input.IsKeyPressed(Keys.R)) Reset();

    // The zone has no model: the box is drawn here, in the colour of its state
    debugDraw.DrawCube(zoneCentre, zoneSize, color: inside.Count > 0 ? zoneColour : idle);

    pad.Get<ModelComponent>().Materials[0] = inside.Count > 0 ? zoneMaterial : idleMaterial;
    plateEntity.Get<ModelComponent>().Materials[0] = plate.Touching.Count > 0 ? plateMaterial : idleMaterial;

    // The crate flashes white for a moment after a hit
    impact.FlashLeft = MathF.Max(0f, impact.FlashLeft - (float)time.Elapsed.TotalSeconds);
    crate.Get<ModelComponent>().Materials[0] = impact.FlashLeft > 0f ? hitMaterial : crateMaterial;
}

/// <summary>
/// A trigger is a handler that says NoContactResponse: Bepu still finds the contacts, so the events fire,
/// but the solver never pushes anything apart. The static needs no model; the pad under it is the visual.
/// </summary>
void BuildZone(Scene scene)
{
    var trigger = new Trigger();

    trigger.OnEnter += (_, other) =>
    {
        if (inside.Add(other)) entries++;
    };

    trigger.OnLeave += (_, other) => inside.Remove(other);

    var zone = new Entity("Zone")
    {
        new StaticComponent
        {
            Collider = new CompoundCollider { Colliders = { new BoxCollider { Size = zoneSize } } },
            ContactEventHandler = trigger,
        },
    };

    zone.Transform.Position = zoneCentre;
    zone.Scene = scene;

    pad = game.CreatePrimitive(Primitive3D.Box(new Vector3(zoneSize.X, 0.06f, zoneSize.Z)), new BepuPhysicsOptions
    {
        EntityName = "Zone pad",
        Material = idleMaterial,
        Body = BodyMode.None,
        Position = new Vector3(zoneCentre.X, 0.03f, zoneCentre.Z),
    });

    pad.Scene = scene;
}

/// <summary>A solid static with a handler: a floor that also knows what stands on it.</summary>
void BuildPlate(Scene scene)
{
    plateEntity = game.CreatePrimitive(Primitive3D.Box(new Vector3(3f, 0.3f, 3f)), new BepuPhysicsOptions
    {
        EntityName = "Plate",
        Material = idleMaterial,
        Component = new StaticComponent { Collider = new CompoundCollider(), ContactEventHandler = plate },
        Position = plateCentre,
    });

    plateEntity.Scene = scene;
}

/// <summary>A dynamic body with a handler: it is hit, pushed about, and reports how hard.</summary>
void BuildCrate(Scene scene)
{
    crate = game.CreatePrimitive(Primitive3D.Cube(1.2f), new BepuPhysicsOptions
    {
        EntityName = "Crate",
        Material = crateMaterial,
        Component = new BodyComponent { Collider = new CompoundCollider(), InterpolationMode = InterpolationMode.Interpolated, ContactEventHandler = impact },
        Position = crateStart,
    });

    crate.Scene = scene;
}

/// <summary>One ball over each station, a little off centre so they do not all land the same way.</summary>
void DropBalls(Scene scene)
{
    foreach (var x in new[] { zoneCentre.X, plateCentre.X, crateStart.X })
    {
        var ball = game.CreatePrimitive(Primitive3D.Sphere(BallRadius), new BepuPhysicsOptions
        {
            EntityName = $"Ball {balls.Count + 1}",
            Material = ballMaterial,
            ShareModel = true,
            Position = new Vector3(x + random.NextSingle() * 0.6f - 0.3f, 6f, random.NextSingle() * 0.6f - 0.3f),
        });

        ball.Scene = scene;

        balls.Add(ball);
    }

    while (balls.Count > BallsKept)
    {
        balls[0].Scene = null;
        balls.RemoveAt(0);
    }
}

/// <summary>The balls go, the crate goes home, and the counters start again.</summary>
void Reset()
{
    foreach (var ball in balls) ball.Scene = null;

    balls.Clear();
    inside.Clear();
    entries = 0;
    plate.Reset();
    impact.Reset();

    crate.Get<BodyComponent>().TeleportAtRest(crateStart);
}

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, 2.6f);
    label.Scene = scene;
}

IReadOnlyList<TextElement> BuildOverlayLines() =>
[
    new("Space", "Drop a ball over each station", Color.Gold),
    new("Left mouse", "Grab a ball and carry it", Color.Gold),
    new("R", "Reset", Color.Gold),
    new("P", "Draw the collider gizmos", Color.Gold),
    new("F11", "Draw the debug meshes and the contacts", Color.Gold),
    new(""),
    new($"Zone: {inside.Count} inside, entered {entries} times", zoneColour),
    new($"Plate: {plate.Touching.Count} on it, {plate.Landings} landings", plateColour),
    new($"Crate: last hit {impact.Last:0.0} by {impact.LastBy}, hardest {impact.Hardest:0.0}", crateColour),
];

/// <summary>
/// A handler for a solid: NoContactResponse is false, so the bodies rest on the plate as on any floor,
/// and the two callbacks keep the set of what touches it. The same body can touch through more than one
/// contact, so the set, not a counter, is the truth.
/// </summary>
sealed class PlateCounter : IContactHandler
{
    public bool NoContactResponse => false;

    internal HashSet<CollidableComponent> Touching { get; } = [];

    internal int Landings { get; private set; }

    internal void Reset()
    {
        Touching.Clear();
        Landings = 0;
    }

    void IContactHandler.OnStartedTouching<TManifold>(Contacts<TManifold> contacts)
    {
        if (Touching.Add(contacts.Other)) Landings++;
    }

    void IContactHandler.OnStoppedTouching<TManifold>(Contacts<TManifold> contacts) => Touching.Remove(contacts.Other);
}

/// <summary>
/// A handler that measures hits: on the first touch of a pair, the impact force of every contact in it is
/// added up. The ground and the plate are statics and do not count; only bodies hit the crate.
/// </summary>
sealed class ImpactMeter : IContactHandler
{
    public bool NoContactResponse => false;

    internal float Last { get; private set; }

    internal float Hardest { get; private set; }

    internal string LastBy { get; private set; } = "nothing yet";

    internal float FlashLeft { get; set; }

    internal void Reset()
    {
        Last = 0f;
        Hardest = 0f;
        LastBy = "nothing yet";
    }

    void IContactHandler.OnStartedTouching<TManifold>(Contacts<TManifold> contacts)
    {
        if (contacts.Other is not BodyComponent) return;

        var force = 0f;

        foreach (var contact in contacts) force += contacts.ComputeImpactForce(contact).Length();

        Last = force;
        LastBy = contacts.Other.Entity.Name;
        Hardest = MathF.Max(Hardest, force);
        FlashLeft = 0.4f;
    }
}