Table of Contents

Cube Fountain

A fountain of cubes, spheres and cylinders that runs on the physics clock. A nozzle launches bodies at a steady rate counted in ISimulationUpdate, the callback Bepu makes once per fixed physics step, so the fountain keeps the simulation's time: slow the simulation or pause it and the fountain slows or stops with it. Slow motion also shows what body interpolation is for, and a key turns it off to compare. Once the fountain owns as many bodies as it may, the oldest one's place is reused, so it runs indefinitely with a fixed number of bodies. One instancing master per shape draws them all.

The Program.cs file shows how to:

  • A spawn rate counted in ISimulationUpdate, with the fraction of a body carried from step to step
  • Why the physics clock - the fountain follows BepuSimulation.TimeScale with no code of its own
  • BodyComponent.InterpolationMode - smooth motion between physics steps, seen in slow motion
  • Reusing a body - Teleport, then a new velocity, and Awake so a sleeping body takes it
  • A cap on the number of bodies, with the oldest one's place reused
  • Throwing a body along the camera's forward vector
  • Bodies without a model of their own, drawn by one BufferedEntityInstancing master per shape
  • A DebugTextDropdown in the overlay to choose the shape

Cube Fountain

View on GitHub.

using E05_3D_CubeFountain;
using Stride.BepuPhysics;
using Stride.BepuPhysics.Definitions;
using Stride.BepuPhysics.Definitions.Colliders;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.Instancing;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
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;

// A fountain of cubes, spheres and cylinders. A nozzle launches them at a steady rate, they fall
// into a basin, and once the fountain owns as many as it may it takes the oldest one's place for
// the next launch, so it runs for as long as you watch with a fixed number of bodies.
//
// The lesson is WHERE the rate is counted. CubeFountain.cs counts it in ISimulationUpdate, the
// callback Bepu makes once per fixed physics step, so the fountain belongs to the simulation's
// time. Press - to slow the simulation, down to a pause, and + to speed it up to five times: the
// fountain slows, stops and speeds up with it, and no line of the fountain's code knows about the
// keys.
//
// Slow motion shows a second thing. At a quarter speed the simulation steps fifteen times a second
// while the screen draws sixty, so a body that is only moved on a physics step visibly jerks. The
// bodies here are interpolated: between two steps each is drawn part of the way from its last pose
// to its current one. Press I in slow motion to turn that off and see the steps.
//
// Two things are deliberately not taught again here:
//   ISimulationUpdate itself    - see E02_3D_GiveMeACube_SimulationUpdate.
//   Drawing many bodies at once - see E10_3D_Instancing_EntityTransform. The bodies here have no
//                                 model of their own; one master entity per shape draws them all.

WindowsDpiManager.EnablePerMonitorV2();

const float NozzleHeight = 1.4f;
const float BasinHalfSize = 5f;
const float ThrowSpeed = 22f;

// What the fountain can launch, each shape with its dimensions
(string Name, Primitive3D Type, Color Colour)[] shapes =
[
    ("Cube", Primitive3D.Cube(0.35f), new Color(70, 160, 235)),
    ("Sphere", Primitive3D.Sphere(0.2f), new Color(240, 150, 60)),
    ("Cylinder", Primitive3D.Cylinder(0.18f, 0.36f), new Color(110, 200, 110)),
];

// The simulation speeds + and - step through, from paused to five times as fast
float[] timeScales = [0f, 0.01f, 0.0625f, 0.125f, 0.25f, 0.5f, 1f, 2f, 3f, 4f, 5f];

// One model and one instancing master per shape, in the order of the shapes
var models = new Model[shapes.Length];
var masters = new BufferedEntityInstancing[shapes.Length];

CubeFountain? fountain = null;
DebugTextDropdown? shapeMenu = null;
var timeScaleIndex = Array.IndexOf(timeScales, 1f);
var interpolated = true;

using var game = new Game();

var sceneSetup = new SceneSetup3D
{
    Camera = CameraSetup.Perspective(position: new Vector3(0f, 6.5f, 15f), rotation: new Vector3(0f, -14f, 0f)).WithController(),
    Profiler = true
}.UseBepu().UseSkybox();

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

foreach (var master in masters) master?.Dispose();

void Start(Scene scene)
{
    game.Window.AllowUserResizing = true;
    game.Window.Title = "Cube fountain - Stride Community Toolkit";

    for (var i = 0; i < shapes.Length; i++)
    {
        // The model, owned by a prototype parked out of sight
        var prototype = game.CreatePrimitive(shapes[i].Type, new Primitive3DEntityOptions
        {
            IncludePhysics = false,
            Material = game.CreateMaterial(shapes[i].Colour, metalness: 0f, glossiness: 0.5f),
        });

        prototype.Transform.Position = new Vector3(0f, -100f, 0f);
        prototype.Scene = scene;
        models[i] = prototype.Get<ModelComponent>().Model;

        // The master: the one entity that is drawn, once for every body of this shape. An instance
        // shares its master's model and material, which is why each shape and colour has its own.
        // The buffered kind stops gathering and uploading once every body has gone to sleep
        masters[i] = new BufferedEntityInstancing(new BepuEntityInstancing());

        scene.Entities.Add(new Entity($"{shapes[i].Name} master") { new ModelComponent(models[i]), new InstancingComponent { Type = masters[i] } });
    }

    game.AddInstancingBufferUpload(masters);

    BuildBasin(scene);

    fountain = new CubeFountain
    {
        Create = (kind, position) => CreateBody(scene, kind, position),
        Remove = RemoveBody,
    };

    // The fountain's entity is the mouth of the nozzle: bodies start where it is
    var mouth = new Entity("Fountain") { fountain };

    mouth.Transform.Position = new Vector3(0f, NozzleHeight + 0.4f, 0f);
    scene.Entities.Add(mouth);

    // The shape to launch, as a dropdown in the overlay: O opens it, a digit picks
    shapeMenu = new DebugTextDropdown
    {
        Title = "Shape",
        ToggleKey = Keys.O,
        TitleColor = Color.Gold,
        Items = [.. shapes.Index().Select(pair => new DebugTextDropdownItem(
            (Keys)(Keys.D1 + pair.Index), pair.Item.Name, () => fountain.Kind = pair.Index, pair.Item.Colour))],
    };

    DebugOverlay.GetOrCreate(game).AddSection("Cube fountain", OverlayLines);
}

void Update(Scene scene, GameTime time)
{
    if (fountain is null) return;

    shapeMenu?.Update(game.Input);

    if (game.Input.IsKeyPressed(Keys.J)) fountain.Rate = MathF.Max(fountain.Rate - 5f, 0f);
    if (game.Input.IsKeyPressed(Keys.K)) fountain.Rate = MathF.Min(fountain.Rate + 5f, 120f);

    // Plus and minus on the main row or on the number pad
    var faster = game.Input.IsKeyPressed(Keys.OemPlus) || game.Input.IsKeyPressed(Keys.Add);
    var slower = game.Input.IsKeyPressed(Keys.OemMinus) || game.Input.IsKeyPressed(Keys.Subtract);

    if ((faster || slower) && fountain.Simulation is { } simulation)
    {
        timeScaleIndex = Math.Clamp(timeScaleIndex + (faster ? 1 : -1), 0, timeScales.Length - 1);

        // The one line that slows, stops or speeds up the fountain. Nothing is sent to the fountain
        simulation.TimeScale = timeScales[timeScaleIndex];

        // Five times the speed is five physics steps a frame at 60 frames a second, and this caps
        // the steps a frame, three by default (Stride 4.4.0 ignored the cap; 4.4.1 applies it)
        simulation.MaxStepPerFrame = 8;
    }

    if (game.Input.IsKeyPressed(Keys.I))
    {
        interpolated = !interpolated;

        foreach (var body in fountain.Bodies) body.InterpolationMode = Interpolation();
    }

    if (game.Input.IsMouseButtonPressed(MouseButton.Left))
    {
        // A thrown body comes from the same store as the fountain's, so the count stays capped
        var camera = game.GetCameraEntity().Transform;
        var forward = Vector3.TransformNormal(-Vector3.UnitZ, camera.WorldMatrix);

        forward.Normalize();

        fountain.Launch(camera.WorldMatrix.TranslationVector + forward * 1.5f, forward * ThrowSpeed);
    }
}

IReadOnlyList<TextElement> OverlayLines()
{
    List<TextElement> lines = [];

    if (shapeMenu is not null) lines.AddRange(shapeMenu.GetLines());

    if (fountain is null) return lines;

    lines.AddRange(
    [
        new(["J", "K"], "Fewer or more bodies per second", Color.Gold),
        new(["+", "-"], "Simulation speed, from paused to 5x", Color.Gold),
        new("I", interpolated ? "Interpolation on: smooth between steps" : "Interpolation off: moved on steps only", Color.Gold),
        new("Left mouse", "Throw a body where you look", Color.Gold),
        new(""),
        new($"Launching: {shapes[fountain.Kind].Name}", shapes[fountain.Kind].Colour),
        new($"{fountain.Rate:0} bodies per simulated second", Color.LightGreen),
        new($"{fountain.Count} of {fountain.Capacity} bodies, {fountain.Recycled} places reused", Color.LightGreen),
        new(timeScales[timeScaleIndex] > 0f ? $"Simulation at {timeScales[timeScaleIndex]:0.##}x" : "Simulation paused", Color.LightGreen),
        new("The rate is counted in physics steps,", Color.LightGray),
        new("so the fountain keeps the simulation's time.", Color.LightGray),
    ]);

    return lines;
}

InterpolationMode Interpolation() => interpolated ? InterpolationMode.Interpolated : InterpolationMode.None;

/// <summary>
/// One body: a dynamic Bepu body with the collider that fits its shape and no model of its own.
/// The shape's master draws it.
/// </summary>
Entity CreateBody(Scene scene, int kind, Vector3 position)
{
    // AddBepuPhysics derives the collider from the shape; it needs a model
    // component to be present, which is then taken off again
    var body = new Entity(shapes[kind].Name) { new ModelComponent(models[kind]) };

    body.AddBepuPhysics(shapes[kind].Type, new Bepu3DPhysicsOptions
    {
        // A body is not interpolated unless asked: by default it is moved on physics steps only
        Component = new BodyComponent { Collider = new CompoundCollider(), InterpolationMode = Interpolation() },
    });

    body.Remove<ModelComponent>();

    body.Transform.Position = position;
    body.Scene = scene;

    masters[kind].AddInstance(body);

    return body;
}

/// <summary>Takes a body out of its master's instances and out of the scene.</summary>
void RemoveBody(Entity body)
{
    foreach (var master in masters) master.RemoveInstance(body);

    body.Scene = null;
}

/// <summary>The nozzle in the middle and four low walls around it, all static.</summary>
void BuildBasin(Scene scene)
{
    var stone = game.CreateMaterial(new Color(150, 150, 158), metalness: 0f, glossiness: 0.3f);

    Static(Primitive3D.Cylinder(0.3f, NozzleHeight), new Vector3(0f, NozzleHeight * 0.5f, 0f));

    const float WallHeight = 0.8f;
    const float WallThickness = 0.3f;

    var length = BasinHalfSize * 2f + WallThickness;

    Static(Primitive3D.Box(new Vector3(length, WallHeight, WallThickness)), new Vector3(0f, WallHeight * 0.5f, BasinHalfSize));
    Static(Primitive3D.Box(new Vector3(length, WallHeight, WallThickness)), new Vector3(0f, WallHeight * 0.5f, -BasinHalfSize));
    Static(Primitive3D.Box(new Vector3(WallThickness, WallHeight, length)), new Vector3(BasinHalfSize, WallHeight * 0.5f, 0f));
    Static(Primitive3D.Box(new Vector3(WallThickness, WallHeight, length)), new Vector3(-BasinHalfSize, WallHeight * 0.5f, 0f));

    void Static(Primitive3D shape, Vector3 position)
    {
        // An empty compound collider: CreatePrimitive adds the collider that fits the shape. A
        // collider given here as well would be a second one, at its default size
        var entity = game.CreatePrimitive(shape, new Bepu3DPhysicsOptions
        {
            Material = stone,
            Component = new StaticComponent { Collider = new CompoundCollider() },
        });

        entity.Transform.Position = position;
        entity.Scene = scene;
    }
}