Table of Contents

Grabber

A gravity gun: click any body to pick it up, carry it on the end of the camera ray, and let go

  • with its velocity, so a flick throws it. GrabberScript on the camera entity does it with two servo constraints rather than a teleport, so the held body still collides and pushes, and the force caps scale with mass so a 100 kg cube drags like a 1 kg one. Cubes of five masses, balls to flick, a capsule with locked rotation, and a wall to throw at.

The Program.cs file shows how to:

  • Picking up and throwing bodies with GrabberScript, one line on the camera entity
  • Why servo constraints beat teleporting a kinematic body for a drag
  • Force caps scaled by mass, so heavy and light bodies feel the same in the hand
  • Locking a body's rotation through its BodyInertia, and what the grabber does about it
  • Reading the held body's mass and the servo cap from the script for the overlay
  • Using helpers: SetupBase3DScene, Create3DPrimitive, GetCameraEntity, DebugOverlay

Grabber

View on GitHub.

using BepuPhysics;
using Stride.BepuPhysics;
using Stride.BepuPhysics.Definitions.Colliders;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Scripts.Utilities;
using Stride.CommunityToolkit.Skyboxes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Input;

// A gravity gun: click any body to pick it up, carry it around on the end of the camera ray, and
// let go - with whatever velocity it had, so a flick throws it.
//
// The whole thing is one line: GrabberScript on the camera entity. Under it are two constraints,
// not a teleport. A linear servo pulls the grabbed point toward the cursor and an angular servo
// holds the orientation, so the solver stays in charge: the held body still collides, still pushes
// other bodies, and cannot be forced through the wall. The force caps scale with the body's mass,
// which is why the 100 kg cube drags exactly like the 1 kg one - and why it hits harder.
//
// The scene is built to be picked up: five cubes of the same size and very different masses, some
// spheres to flick, a body whose rotation is locked (the angular servo is skipped for it, and it
// slides without turning), and a wall to throw things at.

const float SpawnHeight = 0.5f;

var masses = new[] { 1f, 3f, 10f, 30f, 100f };
var spawned = new List<Entity>();

GrabberScript? grabber = null;
Entity? lockedBody = null;

using var game = new Game();

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

void Start(Scene scene)
{
    game.SetupBase3DScene();
    game.AddSkybox();
    game.AddProfiler();

    game.SetCameraPosition(new Vector3(0, 3.5f, -9));
    game.SetCameraRotation(new Vector3(180, -14, 0));

    // The one line. Left mouse grabs, the wheel changes the carry distance, T + mouse turns the body.
    grabber = new GrabberScript();
    game.GetCameraEntity().Add(grabber);

    BuildScene(scene);
    AddInstructions();
}

void Update(Scene scene, Stride.Games.GameTime time)
{
    if (game.Input.IsKeyPressed(Keys.R))
    {
        grabber?.Release();

        foreach (var entity in spawned)
            entity.Scene = null;

        spawned.Clear();
        BuildScene(scene);
    }
}

void BuildScene(Scene scene)
{
    // Five cubes, same size, masses from 1 to 100 kg. The mass is the collider's; the body sums its colliders.
    for (var i = 0; i < masses.Length; i++)
    {
        var shade = (byte)(230 - i * 35);
        var cube = game.Create3DPrimitive(PrimitiveModelType.Cube, new()
        {
            Material = game.CreateMaterial(new Color(shade, (byte)(120 + i * 20), (byte)(80 + i * 30))),
            Component = new BodyComponent { Collider = new CompoundCollider { Colliders = { new BoxCollider { Mass = masses[i] } } } },
            Position = new Vector3(3 - i * 1.5f, SpawnHeight, 0),
        });
        cube.Name = $"{masses[i]:0} kg";
        Place(cube, scene);
    }

    // Spheres to flick.
    for (var i = 0; i < 4; i++)
    {
        var sphere = game.Create3DPrimitive(PrimitiveModelType.Sphere, new()
        {
            Size = new Vector3(0.35f),
            Material = game.CreateMaterial(new Color(120, 200, 255)),
            Position = new Vector3(2.5f - i * 1.7f, SpawnHeight, 3),
        });
        sphere.Name = "ball";
        Place(sphere, scene);
    }

    // A capsule whose rotation is locked once it is in the simulation: it can be dragged, never turned.
    lockedBody = game.Create3DPrimitive(PrimitiveModelType.Capsule, new()
    {
        Material = game.CreateMaterial(new Color(255, 200, 80)),
        Position = new Vector3(-4.5f, 1, 1.5f),
    });
    lockedBody.Name = "locked rotation";
    Place(lockedBody, scene);

    var locked = lockedBody.Get<BodyComponent>();
    var inertia = locked.BodyInertia;
    locked.BodyInertia = new BodyInertia { InverseMass = inertia.InverseMass };   // zero inverse inertia: rotation locked

    // A wall to throw at.
    var wall = game.Create3DPrimitive(PrimitiveModelType.Cube, new()
    {
        Size = new Vector3(8, 3, 0.4f),
        Material = game.CreateMaterial(new Color(140, 140, 150)),
        Component = new StaticComponent { Collider = new CompoundCollider { Colliders = { new BoxCollider() } } },
        Position = new Vector3(0, 1.5f, 7),
    });
    wall.Name = "wall";
    Place(wall, scene);

    static void Place(Entity entity, Scene scene) => entity.Scene = scene;
}

void AddInstructions()
{
    var overlay = DebugOverlay.GetOrCreate(game);

    overlay.AddSection("Grabber", () =>
    {
        var held = grabber?.Held;
        var lines = new List<TextElement>
        {
            new("Left mouse", "Grab and carry; release to drop or throw"),
            new("Mouse wheel", "Carry distance"),
            new("T", "Hold and move the mouse to turn the held body"),
            new("R", "Reset the scene"),
            new(""),
        };

        if (held is null)
        {
            lines.Add(new("Holding nothing: click a cube, a ball or the capsule", Color.Gray));
        }
        else
        {
            var mass = held.BodyInertia.InverseMass > 0 ? 1 / held.BodyInertia.InverseMass : 0;
            var locked = Bodies.HasLockedInertia(held.BodyInertia.InverseInertiaTensor);

            lines.Add(new($"Holding {held.Entity.Name}, {mass:0.0} kg, at {grabber!.HoldDistance:0.0} m", Color.Yellow));
            lines.Add(new($"Servo {GrabberForce(held):0} N linear cap{(locked ? ", no angular servo" : "")}"));
        }

        return lines;
    });

    float GrabberForce(BodyComponent body)
        => body.BodyInertia.InverseMass > 0 ? grabber!.ForcePerKilogram / body.BodyInertia.InverseMass : 0;
}