Table of Contents

Junkyard (Box2D)

A faithful replica of the Box2D.NET BenchmarkJunkyard sample: 8,000 small five-sided rocks rain into a walled yard and a kinematic plow sweeps back and forth through the pile, driven by a target transform once per fixed step. Rendering works exactly like the Box2D testbed: no meshes, materials or entities - every shape is submitted each frame to the toolkit's ShapeBatch, whose shader (a port of the testbed's solid_polygon shader) draws them all in one instanced call with the 40%-alpha fill and pixel-constant border computed per fragment. Body states show as the testbed's colours - pink awake, salmon fast-movers, gray sleepers.

The Program.cs file shows how to:

  • Replicating a Box2D testbed benchmark scene in Stride, rendering included
  • Immediate-mode shape drawing with ShapeBatch - no meshes, materials or entities
  • An SDF shader computing fill, border and transparency per fragment, stable under any zoom
  • Entity-less physics bodies as the single source of truth, read directly each frame
  • Driving a kinematic body with SetTargetTransform once per fixed step
  • Hooking per-fixed-step logic through IBox2DSimulationUpdate
  • Colour-coding awake, fast and sleeping bodies straight from body state
Note

This example references Stride.CommunityToolkit.Box2D, which is not on NuGet yet. Run it from a clone of the repository, where the package is a project reference; a copy of the project on its own will not build.

Junkyard (Box2D)

View on GitHub.

using Box2D.NET;
using Stride.CommunityToolkit.Box2D;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.Overlay;
using Stride.CommunityToolkit.Shapes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using static Box2D.NET.B2Bodies;
using static Box2D.NET.B2Geometries;
using static Box2D.NET.B2MathFunction;
using static Box2D.NET.B2Shapes;

// A faithful replica of the Box2D.NET sample Benchmarks/BenchmarkJunkyard (MIT, (c) 2022 Erin Catto,
// (c) 2025 Choi Ikpil): a walled yard whose floor and walls are rows of overlapping static squares,
// 8,000 small five-sided "rocks" raining into it, and a kinematic pusher plowing back and forth
// through the pile at x = 60*sin(0.2t).
//
// Rendering works like the Box2D testbed: no meshes, materials or entities - every shape goes to
// the toolkit's ShapeBatch each frame, whose shader (a port of the testbed's solid_polygon shader)
// draws them all in one instanced call, computing the 40%-alpha fill and the pixel-constant border
// per fragment. Body states show in the testbed's colours - pale green statics, pink awake, salmon
// fast-movers, gray sleepers, royal blue pusher - on its dark gray background.

// --- the sample's numbers, verbatim
const float GridSize = 1.0f;
const int ColumnCount = 200;
const int RowCount = 40;
const float Radius = 0.25f;
const float YStart = 15.0f;

// The testbed paints continuous-collision candidates salmon: bodies sweeping more than roughly half
// their extent in one step. Approximated here as speed > 0.5 * radius / timestep.
const float FastSpeed = 0.5f * Radius * 60f;

// --- the testbed palette (b2HexColor values used by b2World_Draw, and the samples' GL clear colour)
var paleGreen = new Color(0x98, 0xFB, 0x98);
var pink = new Color(0xFF, 0xC0, 0xCB);
var salmon = new Color(0xFA, 0x80, 0x72);
var gray = new Color(0x80, 0x80, 0x80);
var royalBlue = new Color(0x41, 0x69, 0xE1);
var background = new Color(0.2f, 0.2f, 0.2f);

ShapeBatch shapeBatch = null!;

// Entity-less bodies: SubmitShapes reads the physics world directly every frame, like the testbed
List<B2BodyId> rockIds = [];
B2BodyId pusherId = default;

var pentagon = FibonacciPentagon(Radius);

// Static square outlines, reused for every floor and wall submission
var floorSquare = ShapeComponent.Box(new Vector2(1.1f * GridSize, GridSize));
var wallSquare = ShapeComponent.Box(new Vector2(GridSize, 1.1f * GridSize));

// The pusher plow: a 4 x 8 box whose shape sits 4 units above the body origin
Vector2[] pusherBoxVertices = [new(-2, 0), new(2, 0), new(2, 8), new(-2, 8)];

using var game = new Game();
using var simulation = new Box2DSimulation();

var sceneSetup = new SceneSetup2D
{
    ClearColor = background,
    // Centred on the sample's (8, 25). The testbed's zoom 60 is a half-height (120 units tall);
    // OrthographicSize is the full height, so this view is 60 units tall, twice as close
    Camera = CameraSetup.Orthographic(position: new Vector3(8, 25, 50), orthographicSize: 60).WithController(),
    Ground = null,
    Profiler = true,
    WindowTitle = "Junkyard Box2D Example - Stride Community Toolkit",
    Resizable = true,
    DpiAware = true,
};

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

void Start(Scene rootScene)
{
    shapeBatch = game.AddShapeBatch();
    shapeBatch.BorderWidth = 1f;
    shapeBatch.Fill.Alpha = 0.4f;

    // The testbed steps once per rendered frame, with no catch-up; the default three catch-up steps
    // would only deepen the slow motion on a loaded machine.
    simulation.MaxStepsPerFrame = 1;

    // Left mouse picks a rock up and throws it - the one interaction the replica adds to the testbed
    game.AddGrabber2D(simulation);

    TextOverlay.GetOrCreate(game).AddSection("Junkyard", static () =>
    [
        new("Left mouse", "Pick a rock up, carry it, throw it", Color.Yellow),
    ]);

    CreateGround();
    SpawnRocks();
    CreatePusher();
}

/// <summary>
/// One static body carrying every floor and wall shape: 161 slightly overlapping squares across the
/// bottom and 50 up each side wall at x = -80 and x = +80, exactly as the sample builds them.
/// </summary>
void CreateGround()
{
    var groundId = simulation.CreateStaticBody(Vector3.Zero);
    var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();

    var y = 0.0f;
    var x = -80.0f * GridSize;

    for (var i = 0; i < 161; ++i)
    {
        var box = b2MakeOffsetBox(0.55f * GridSize, 0.5f * GridSize, new B2Vec2(x, y), b2Rot_identity);
        b2CreatePolygonShape(groundId, in shapeDef, in box);
        x += GridSize;
    }

    y = GridSize;
    x = -80.0f * GridSize;

    for (var i = 0; i < 50; ++i)
    {
        var box = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(x, y), b2Rot_identity);
        b2CreatePolygonShape(groundId, in shapeDef, in box);
        y += GridSize;
    }

    y = GridSize;
    x = 80.0f * GridSize;

    for (var i = 0; i < 50; ++i)
    {
        var box = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(x, y), b2Rot_identity);
        b2CreatePolygonShape(groundId, in shapeDef, in box);
        y += GridSize;
    }
}

/// <summary>
/// The junk: 200 columns x 40 rows of five-sided rocks stacked from y = 15 upward, each column
/// zig-zagged sideways by the alternating 0.1 offset, exactly as the sample spawns them.
/// </summary>
void SpawnRocks()
{
    var side = -0.1f;

    for (var i = 0; i < ColumnCount; ++i)
    {
        var x = 1.5f * (2.0f * i - ColumnCount) * Radius;

        for (var j = 0; j < RowCount; ++j)
        {
            var y = 4.0f * j * Radius + YStart;
            var position = new Vector3(x + side, y, 0);
            side = -side;

            var bodyId = simulation.CreateDynamicBody(position);

            ShapeFixtureBuilder.AttachPolygon(pentagon, bodyId);

            rockIds.Add(bodyId);
        }
    }
}

/// <summary>
/// The pusher: a kinematic 4 x 8 plow whose shape sits 4 units above its body origin. Its sweep is
/// driven per fixed step by <see cref="PusherDriver"/> through the simulation update hook, exactly
/// like the sample's StepJunkyard.
/// </summary>
void CreatePusher()
{
    pusherId = simulation.CreateKinematicBody(Vector3.Zero);

    var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();
    var pusherBox = b2MakeOffsetBox(2.0f, 4.0f, new B2Vec2(0.0f, 4.0f), b2Rot_identity);
    b2CreatePolygonShape(pusherId, in shapeDef, in pusherBox);

    simulation.RegisterSimulationUpdate(new PusherDriver(pusherId));
}

void Update(Scene rootScene, GameTime time)
{
    // Fixed-step accumulation and the pusher drive happen inside
    simulation.Update(time.Elapsed);

    SubmitShapes();
}

/// <summary>
/// Submits every shape for this frame straight from the physics world: the static yard, then the
/// rocks coloured by their state, the pusher on top.
/// </summary>
void SubmitShapes()
{
    var x = -80.0f * GridSize;

    for (var i = 0; i < 161; ++i)
    {
        shapeBatch.DrawSolidPolygon(floorSquare, new Vector2(x, 0f), 0f, paleGreen);
        x += GridSize;
    }

    var y = GridSize;

    for (var i = 0; i < 50; ++i)
    {
        shapeBatch.DrawSolidPolygon(wallSquare, new Vector2(-80.0f * GridSize, y), 0f, paleGreen);
        shapeBatch.DrawSolidPolygon(wallSquare, new Vector2(80.0f * GridSize, y), 0f, paleGreen);
        y += GridSize;
    }

    foreach (var bodyId in rockIds)
    {
        SubmitBodyPolygon(pentagon, b2Body_GetTransform(bodyId), BodyColor(bodyId));
    }

    SubmitBodyPolygon(pusherBoxVertices, b2Body_GetTransform(pusherId), royalBlue);
}

void SubmitBodyPolygon(Vector2[] vertices, B2Transform transform, Color color)
{
    shapeBatch.DrawSolidPolygon(vertices, new Vector2(transform.p.X, transform.p.Y), MathF.Atan2(transform.q.s, transform.q.c), color);
}

// The testbed's body colouring: gray asleep, salmon fast, pink otherwise
Color BodyColor(B2BodyId bodyId)
{
    if (!b2Body_IsAwake(bodyId)) return gray;

    var velocity = b2Body_GetLinearVelocity(bodyId);

    return velocity.X * velocity.X + velocity.Y * velocity.Y > FastSpeed * FastSpeed ? salmon : pink;
}

/// <summary>
/// The sample's rock outline: five points on a circle of the given radius by the Fibonacci sphere
/// algorithm, sorted by angle so the convex hull and the shader's SDF see them in CCW order.
/// </summary>
static Vector2[] FibonacciPentagon(float radius)
{
    var phi = MathF.PI * (MathF.Sqrt(5.0f) - 1.0f);
    var points = new Vector2[5];

    for (var i = 0; i < 5; ++i)
    {
        var theta = phi * i;
        points[i] = new Vector2(radius * MathF.Cos(theta), radius * MathF.Sin(theta));
    }

    return [.. points.OrderBy(p => MathF.Atan2(p.Y, p.X))];
}

/// <summary>
/// Drives the kinematic pusher along the sample's sweep, x = 60*sin(0.2t), by setting its target
/// transform once per fixed step - Box2D derives the kinematic velocity from target and time step.
/// </summary>
public sealed class PusherDriver(B2BodyId pusherId) : IBox2DSimulationUpdate
{
    private int _stepCount;

    public void SimulationUpdate(Box2DSimulation simulation, float deltaTime)
    {
        var time = deltaTime * _stepCount;
        _stepCount++;

        var cosSin = b2ComputeCosSin(0.2f * time);
        var target = new B2Transform(new B2Vec2(60.0f * cosSin.sine, 0.0f), b2Rot_identity);

        b2Body_SetTargetTransform(pusherId, in target, deltaTime, true);
    }
}