A camera that follows
A camera that follows something is the point at which most codebases acquire their worst file. The usual shape is one camera object that four systems all write to, between the follow code, the cutscene, the aim-down-sights and the screen shake, and whichever ran last wins.
orblit_camera takes that away by making the camera the only thing allowed to
move the camera. You describe shots; a brain picks one.
import 'dart:math' as math;
import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:orblit_camera/orblit_camera.dart';import 'package:orblit_filament/orblit_filament.dart';import 'package:vector_math/vector_math_64.dart' hide Colors;
void main() => runApp(const FollowApp());
class FollowApp extends StatefulWidget { const FollowApp({super.key});
@override State<FollowApp> createState() => _FollowAppState();}
class _FollowAppState extends State<FollowApp> with SingleTickerProviderStateMixin { /// What the cameras follow. A real game implements [CameraTarget] over its /// own entity; this is the two-number version of the same thing. final FixedTarget _player = FixedTarget(Vector3.zero());
/// Behind and above, in the subject's own frame, so it stays behind when /// the player turns rather than staying north of them. late final VirtualCamera _chase = VirtualCamera( name: 'chase', priority: 20, follow: _player, lookAt: _player, body: FollowBody( offset: Vector3(0, 2.4, 7), // Per axis, because the axes want different answers: a camera may lag a // long way behind and must never float up and down. damping: Vector3(0.35, 0.18, 0.5), ), aim: const HardLookAt(), lens: const Lens(fieldOfView: 55), );
/// A fixed vantage point. Armed the whole time at a lower priority, so it /// takes over the moment the chase camera is switched off. late final VirtualCamera _tower = VirtualCamera( name: 'tower', priority: 10, lookAt: _player, body: StaticBody(Vector3(-16, 9, 16)), aim: const HardLookAt(), lens: const Lens(fieldOfView: 38), );
late final CameraBrain _brain = CameraBrain() ..add(_chase) ..add(_tower) ..snap();
late final Ticker _clock = createTicker(_tick)..start();
double _last = 0;
void _tick(Duration elapsed) { final now = elapsed.inMicroseconds / 1e6; // Clamped, because a frame lost to a stutter should not teleport a camera // that damps towards its target. final delta = (now - _last).clamp(0.0, 0.1); _last = now;
// Move the player along a path, facing the way it is going. final at = _pathAt(now); final ahead = _pathAt(now + 0.12); _player ..position = at ..rotation = lookRotation(ahead - at, null);
_brain.update(delta); setState(() {}); }
Vector3 _pathAt(double t) => Vector3(math.cos(t * 0.4) * 8, 0, math.sin(t * 0.6) * 8);
@override void dispose() { _clock.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Stack( children: [ Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { // The brain needs the aspect the shot is actually framed // at, and the widget is the only thing that knows it. _brain.aspect = constraints.maxWidth / constraints.maxHeight; return OrblitView(scene: _scene()); }, ), ), Positioned( left: 16, bottom: 16, // Cutting to another shot is raising a number. Nothing moves a // transform, and the blend between the two is the brain's job. child: FilledButton( onPressed: () => setState(() { _chase.enabled = !_chase.enabled; }), child: Text(_chase.enabled ? 'To the tower' : 'Back to chase'), ), ), ], ), ), ); }
OrblitScene _scene() { final state = _brain.state;
return OrblitScene( // The brain's answer, converted to what the renderer takes. camera: OrblitCamera( position: state.position, target: state.position + state.forward, fieldOfView: state.lens.fieldOfView, ), objects: [ OrblitObject( key: 1, transform: Matrix4.compose( _player.position + Vector3(0, 0.8, 0), _player.rotation, Vector3(0.5, 0.8, 0.9), ), colour: Vector3(0.85, 0.42, 0.16), ), // Something to move past, so the motion reads as motion. for (var i = 0; i < 14; i++) OrblitObject( key: 10 + i, transform: Matrix4.identity() ..setTranslation( Vector3( math.cos(i * 0.9) * (7 + (i % 4) * 3.5), 0.9, math.sin(i * 1.7) * (7 + (i % 5) * 2.5), ), ) ..scaleByDouble(0.7, 1.2, 0.7, 1), colour: Vector3(0.22, 0.24, 0.27), ), OrblitObject( key: 2, transform: Matrix4.identity() ..setTranslation(Vector3(0, -0.1, 0)) ..scaleByDouble(60, 0.1, 60, 1), colour: Vector3(0.08, 0.09, 0.1), castShadows: false, ), ], lights: [ OrblitLight( key: 3, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.5)..normalize(), intensity: 80000, ), ], sky: OrblitSky(ambient: 14000), ); }}Priority, not a stack
Section titled “Priority, not a stack”_chase is priority 20 and _tower is priority 10, and both are live the
whole time. The highest-priority enabled camera is the one you see.
A number rather than a stack means a camera can be armed long before it matters and take over the instant its situation arises. Think of a danger camera that raises itself when the player is spotted, without anything else in the game having to know it exists. There’s no push, no pop, and no way to leave the stack unbalanced after an early return.
Switching, therefore, is _chase.enabled = false. Nothing moves a transform,
and the brain blends from wherever it was to wherever the tower is. Every
camera keeps its own solution up to date whether or not it is live, so cutting
to one never starts from a stale position.
Body and aim are separate, and that is the point
Section titled “Body and aim are separate, and that is the point”A shot decides where to be (the body) and where to look (the aim), and those are independent problems.
“Orbit the player at four metres” and “keep the boss in the upper third of frame” are different sentences about different things. Building them as one object is how you end up with a camera that cannot do the second without undoing the first.
StaticBody |
Does not move. A fixed vantage point. |
FollowBody |
Holds an offset, damped per axis. Bind to the target’s rotation for over-the-shoulder, or leave it in world space for a camera that stays north. |
OrbitBody |
Yaw, pitch and distance around the target. |
FramingBody |
Backs off until what it has been given fits the frame. |
ScreenFollowBody |
Moves so the subject sits at a point on screen. What a flat or isometric game wants, since turning an orthographic view moves nothing through the frame. |
Aims pair with any of them: HardLookAt points straight at the target,
StaticAim holds a fixed rotation, and the composer aims hold the subject
inside a dead zone and ease after it through a soft zone.
The delta is clamped
Section titled “The delta is clamped”(now - _last).clamp(0.0, 0.1), in the ticker above, is not defensive
programming for its own sake.
A dropped frame, a breakpoint, or the window being dragged between monitors produces a delta of a second or more. Feeding that to a camera that damps towards its target moves it most of the way there in a single step, which reads as a teleport. A tenth of a second is five frames at sixty hertz: long enough to absorb a hitch, short enough that the camera never jumps.
Cameras that frame, not follow covers blends, noise and the composer’s dead and soft zones properly.
