Builtin Library
README.md
camera_rig
Shared internals for the camera rigs — the parts every follow camera needs before it gets to have a personality.
The built-in rigs (chase, mount, cinematic, birds_eye,
third_person_follow, orbital_follow and the rest) resolve their follow
target the same way, read where it is drawn the same way, ease toward their
framing measured from the subject the same way, turn a look-around the same
way, avoid geometry the same way, and write their pose the same way. Only the
placement of the camera relative to the subject differs, so everything else
lives here and the rigs stay small enough to read in one screen.
local CameraRig = require("controller.camera_rig")
local follow = CameraRig.newTrail()
function update(dt)
local target = CameraRig.resolveFollow(public.entity.id, public.follow)
if target == nil then return end
local tx, ty, tz = CameraRig.drawnPosition(target)
if tx == nil then return end
local px, py, pz = tx, ty + public.height, tz
local h, radius = CameraRig.ringAt(public.rings, public.orbitT)
local cx = px + math.sin(public.yaw) * radius
local cy = py + h
local cz = pz + math.cos(public.yaw) * radius
cx, cy, cz = CameraRig.pullIn(px, py, pz, cx, cy, cz, 0.3,
CameraRig.pullInExclude(target, public.cameraIgnore))
cx, cy, cz = CameraRig.trail(follow, tx, ty, tz, cx, cy, cz,
public.followTau, public.followTau, public.followTau, dt)
CameraRig.applyPose(public.entity.id, cx, cy, cz, px, py, pz)
end
Exports
CameraRig.damp(current, goal, tau, dt) -> number— exponential convergence towardgoal.tauis the time constant in seconds: the smaller it is the tighter the camera tracks, and0snaps. Frame-rate independent, so a hitched 100 ms frame and six clean 16 ms frames land in the same place, and no frame length overshoots the goal. Damp each component separately.CameraRig.drawnPosition(id),CameraRig.drawnForward(id),CameraRig.drawnPose(id) -> (px, py, pz, qx, qy, qz, qw): where a subject is drawn this frame, which way it faces there, and its whole drawn pose. A body the simulation moves is drawn blended between physics ticks; a rig placed from these holds it steady on screen at any frame rate.drawnPoseis what a rig riding the body (a seat, a turret) places itself in the body's frame from.CameraRig.newTrail()andCameraRig.trail(state, subjectX, subjectY, subjectZ, goalX, goalY, goalZ, tauX, tauY, tauZ, dt) -> (x, y, z): ease the camera toward its goal measured as an OFFSET from the subject. The subject's own motion carries the camera at any speed, so a camera framed 6 m behind stays 6 m behind at 5 m/s and at 200 m/s, and the time constants soften only a change of the framing. Damping the camera's world position instead leaves itspeed x taubehind a moving subject.CameraRig.tauOf(remaining) -> number: the time constant of a per-second blend field (the share of a gap left after one second), fordampandtrail.CameraRig.newLook()andCameraRig.lookAround(state, dx, dy, limits, dt) -> (yaw, pitch): a look-around turned by the look control withinlimits({ sensitivity, maxYaw, minPitch, maxPitch, returnDelay, returnTime, invertY? }, radians and seconds) that comes back to restreturnDelayseconds after the input stops. Yaw is positive right, pitch positive up.CameraRig.newPullIn()andCameraRig.pullInEased(state, pivotX, pivotY, pivotZ, camX, camY, camZ, radius, exclude, outTime, dt) -> (x, y, z):pullInthat comes in on the frame a collider gets in the way, so no frame is drawn from inside it, and eases back out overoutTimeonce the way is clear.CameraRig.ringAt(rings, t) -> (number, number)— height and radius of the orbit attalong the three-ring spline, whereringsis{ bottom = { height, radius }, center = ..., top = ... }.truns 0 at the bottom ring to 1 at the top, passing exactly through the centre ring at 0.5; adjacent rings are blended with a smoothstep so the seam has no visible kink. Out-of-rangetclamps rather than extrapolating.CameraRig.resolveFollow(selfId, followField) -> string?— the entity this rig follows. Takes the rig's ownfollowfield (an entity id or an entity reference) when it is set, and otherwise falls back to theCamera.followslot, which is the standard place every camera behavior reads its target from — that is what makes "swap the behavior, keep the follow target" work. Returnsnilwhen nothing is set AND when the resolved id has no live entity, which is the normal state for the first frames of play mode while the avatar is still spawning.CameraRig.pullIn(pivotX, pivotY, pivotZ, camX, camY, camZ, radius, exclude?) -> (number, number, number): the camera position moved in along the pivot-to-camera line until no collider is between the two. A sphere ofradiusis swept from the pivot, and every collider in the world stops it, whether or not anything draws it: an invisible walk bound and a trigger volume pull the camera in exactly as a wall does.radiusis also the distance kept off the surface the camera lands on;0turns the pull-in off and returns the input untouched.excludeis an entity id, or a list of them, whose colliders the sweep passes through: the subject the camera is looking at, plus anything the camera should see through.CameraRig.pullInExclude(subjectId, ignore) -> { string }: the exclude list a rig handspullIn: the subject first, then every entity in the rig'scameraIgnorelist (ids or entity proxies).orbital_followandthird_person_followboth build theirs with it from theircameraIgnorefield. A rig written on this module gives its authors the same control by declaring acameraIgnorelist of its own (Field.list(Field.entityRef(nil, Sync), Sync)) and passing it here.CameraRig.applyPose(selfId, camX, camY, camZ, lookX, lookY, lookZ)— place the camera and aim it at a point. Both the pose and the aim point are in the camera entity's own transform frame: parent-relative when the camera is parented to its subject, world space when it is standalone. The write goes to the local transform, so transform propagation composes the world matrix fromparent_world * localinstead of being fought by a world-space write.