Table of Contents

Third-Person Camera

A character on a stepped pyramid with the toolkit's follow camera behind it: the camera trails the character's steps with a short lag, swings round it on a right-drag, zooms on the wheel, and comes forward rather than look through a pillar or a step, through a raycast the game lends it. WASD walks the character relative to the camera's yaw, with a nose that turns to face the way it goes.

The Program.cs file shows how to:

  • Basic3DFollowCameraController on the camera entity, with a Target and a TargetOffset
  • An ObstacleTest from a Bepu raycast, with the character on a layer the ray ignores
  • Walking relative to the camera's yaw through CharacterComponent.MoveVector
  • A child entity that turns while the physics capsule keeps its orientation
  • Using helpers: SceneSetup3D, UseBepu, UseSkybox, Add3DGround, CreatePrimitive, CreateMaterial, GetCameraEntity, RayCast, TextOverlay

Third-Person Camera

View on GitHub.

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

// A third-person camera on a character that walks a stepped pyramid. The camera is the toolkit's follow
// controller: it hangs behind and above the character, trails its steps with a short lag, swings round
// it on a right-drag and zooms on the wheel, like the fly and orbit controllers it sits beside. What the
// controller cannot know is where the walls are, so the game lends it a raycast: before the camera
// settles it asks whether anything stands between the character and the spot it wants, and comes
// forward to stay in front of a pillar or a step instead of looking through it.
//
// The character is Bepu's CharacterComponent on a capsule, as in E05_3D_FirstPersonCharacter, and WASD
// moves it relative to the camera's yaw, so "forward" is always away from the viewer. A nose on the
// capsule turns to face the way it walks; the capsule itself keeps its orientation, since the character's
// move vector is read in that frame.

const float PyramidStep = 1.6f;
const int PyramidLevels = 6;
const float CharacterSpeed = 6f;
const float SprintMultiplier = 1.8f;
const float NoseTurnSpeed = 10f;

CharacterComponent character = null!;
Entity nose = null!;
Basic3DFollowCameraController follow = null!;
BepuSimulation simulation = null!;
var noseYaw = 0f;

// The character is on its own layer, so the camera's ray ignores the capsule and sees only the scenery
const CollisionLayer CharacterLayer = CollisionLayer.Layer2;
const CollisionMask SceneryMask = CollisionMask.Everything & ~CollisionMask.Layer2;

using var game = new Game();

// No WithController: the follow controller is the camera's script here
var sceneSetup = new SceneSetup3D
{
    // The setup's ground is a small one; the pyramid and the walk around it want a field
    Ground = null,
    Camera = CameraSetup.Perspective(),
    WindowTitle = "Third-person camera - Stride Community Toolkit",
    Resizable = true,
    DpiAware = true,
}.UseBepu().UseSkybox();

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

void Start(Scene scene)
{
    BuildScenery(scene);

    character = new CharacterComponent
    {
        Speed = CharacterSpeed,
        JumpForce = 7f,
        Collider = new CompoundCollider(),
        CollisionLayer = CharacterLayer,
    };

    var body = game.CreatePrimitive(Primitive3D.Capsule(0.35f, 0.5f), new BepuPhysicsOptions
    {
        EntityName = "Character",
        Component = character,
        Material = game.CreateMaterial(new Color(240, 200, 80), metalness: 0f, glossiness: 0.5f),
        Position = new Vector3(4f, 1f, 13f),
    });

    // The nose: a child that turns to face the walk direction, since the capsule does not turn
    nose = game.CreatePrimitive(Primitive3D.Box(new Vector3(0.2f, 0.2f, 0.5f)), new BepuPhysicsOptions
    {
        EntityName = "Nose",
        Body = BodyMode.None,
        Material = game.CreateMaterial(new Color(60, 60, 70)),
    });

    nose.Transform.Position = new Vector3(0f, 0.3f, -0.4f);
    body.AddChild(nose);
    body.Scene = scene;

    simulation = character.Simulation!;

    // The camera starts behind the character and a little above; the controller reads its angles from there
    var camera = game.GetCameraEntity();

    camera.Transform.Position = body.Transform.Position + new Vector3(0f, 3.5f, 7f);

    follow = new Basic3DFollowCameraController
    {
        Target = body,
        TargetOffset = new Vector3(0f, 1.2f, 0f),
        ObstacleTest = ClearLine,
    };

    camera.Add(follow);

    TextOverlay.GetOrCreate(game).AddSection("Third-person camera", BuildOverlayLines);
}

void Update(Scene scene, GameTime time)
{
    var input = game.Input;

    // Forward is away from the camera along the ground, whatever the camera's pitch
    var forward = follow.Forward;
    var right = follow.Right;
    var move = Vector3.Zero;

    if (input.IsKeyDown(Keys.W) || input.IsKeyDown(Keys.Up)) move += forward;
    if (input.IsKeyDown(Keys.S) || input.IsKeyDown(Keys.Down)) move -= forward;
    if (input.IsKeyDown(Keys.A) || input.IsKeyDown(Keys.Left)) move -= right;
    if (input.IsKeyDown(Keys.D) || input.IsKeyDown(Keys.Right)) move += right;

    if (move.LengthSquared() > 0f)
    {
        move.Normalize();

        if (input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift)) move *= SprintMultiplier;

        // The nose eases round to the walk direction; its yaw is the direction's angle in the ground plane
        noseYaw = TurnTowards(noseYaw, MathF.Atan2(-move.X, -move.Z), NoseTurnSpeed * (float)time.Elapsed.TotalSeconds);
    }

    // The move vector's Y is the world's Z: the capsule keeps the identity orientation, so the frames agree
    character.MoveVector = new Vector2(move.X, move.Z);
    nose.Transform.Rotation = Quaternion.RotationY(noseYaw);
    nose.Transform.Position = Vector3.Transform(new Vector3(0f, 0.3f, -0.4f), nose.Transform.Rotation);

    if (input.IsKeyPressed(Keys.Space)) character.TryJump();
}

/// <summary>An angle eased towards another the short way round, by a fraction of the gap clamped to the whole of it.</summary>
static float TurnTowards(float current, float target, float fraction)
{
    var gap = MathF.IEEERemainder(target - current, MathUtil.TwoPi);

    return current + gap * MathF.Min(fraction, 1f);
}

/// <summary>The camera's wall test: the distance to the first piece of scenery on the line, or null for a clear one.</summary>
float? ClearLine(Vector3 from, Vector3 to)
{
    var line = to - from;
    var length = line.Length();

    if (length < MathUtil.ZeroTolerance) return null;

    return simulation.RayCast(from, line / length, length, out var hit, SceneryMask) ? hit.Distance : null;
}

/// <summary>A stepped pyramid of static blocks to climb and hide behind, and a ring of pillars to walk between.</summary>
void BuildScenery(Scene scene)
{
    var stone = game.CreateMaterial(new Color(150, 140, 130), metalness: 0f, glossiness: 0.3f);
    var pillarMaterial = game.CreateMaterial(new Color(110, 130, 170), metalness: 0.1f, glossiness: 0.4f);

    game.Add3DGround(new Vector2(70f), new() { EntityName = "Ground", Material = game.CreateMaterial(new Color(120, 125, 120), metalness: 0f, glossiness: 0.2f) });

    // Each level is a square of blocks one block narrower on each side than the one below
    for (var level = 0; level < PyramidLevels; level++)
    {
        var half = PyramidLevels - level;

        for (var x = -half; x < half; x++)
        {
            for (var z = -half; z < half; z++)
            {
                // Only the rim of each level is a block: the inside is never seen or touched
                if (x > -half && x < half - 1 && z > -half && z < half - 1) continue;

                var block = game.CreatePrimitive(Primitive3D.Cube(PyramidStep), new BepuPhysicsOptions
                {
                    EntityName = "Step",
                    Body = BodyMode.Static,
                    Material = stone,
                    ShareModel = true,
                    Position = new Vector3((x + 0.5f) * PyramidStep, (level + 0.5f) * PyramidStep, (z + 0.5f) * PyramidStep),
                });

                block.Scene = scene;
            }
        }
    }

    // Eight pillars round the pyramid, none of them straight behind the character's start
    for (var i = 0; i < 8; i++)
    {
        var angle = (i + 0.5f) * MathUtil.TwoPi / 8f;
        var pillar = game.CreatePrimitive(Primitive3D.Cylinder(0.5f, 4f), new BepuPhysicsOptions
        {
            EntityName = "Pillar",
            Body = BodyMode.Static,
            Material = pillarMaterial,
            ShareModel = true,
            Position = new Vector3(MathF.Cos(angle) * 21f, 2f, MathF.Sin(angle) * 21f),
        });

        pillar.Scene = scene;
    }
}

IReadOnlyList<TextElement> BuildOverlayLines() =>
[
    new(["W", "A", "S", "D"], "Walk, relative to the camera", Color.Gold),
    new("Shift", "Sprint", Color.Gold),
    new("Space", "Jump", Color.Gold),
    new("Right drag", "Swing the camera round", Color.Gold),
    new("Mouse wheel", "Closer or further", Color.Gold),
    new(""),
    new("Walk behind a pillar or down a step: the camera comes forward", Color.White),
];