Skip to content

CameraKit

CameraKit turns cutscene camera work into data instead of imperative script. A shot is a table describing what the camera should be doing this instant — hold a pose, follow a target, orbit it, or ride a spline — and CameraKit owns the one stepper that turns the active shot into a CFrame every frame. The defining decision: nothing about a shot is baked at the moment you set it. Every field — Target, LookAt, the orbit angle — is re-evaluated live against the current state of the world each frame, so a Follow shot tracks a moving boss and an Orbit shot keeps circling a target that’s walking away, with no per-frame script logic on your side at all.

One stepper, one active shot. CameraKit.attach(kernel, options?) connects a single RunService.PreRender callback (client-only — this runs directly against the engine’s render-camera phase rather than through the kernel’s scheduler, because camera writes have to land inside the same PreRender window the engine reads from, and a phase scheduler’s budget-sharing model has nothing to do with that constraint). Every frame, the stepper re-evaluates whichever shot is active against live target state and writes the result straight to Camera.CFrame / Camera.FieldOfView — there is no cached trajectory, only “what should the camera be doing right now.”

Four shot kinds, four evaluation rules:

  • Static { CFrame, FOV? } — holds a fixed pose. No target, no smoothing.

  • Follow { Target, Offset?, Damping?, FOV? }Offset (default CFrame.new(0, 6, 14)) is applied in the target’s local space to get a goal position, then the camera’s smoothed position eases toward that goal every frame:

    Smoothed = Smoothed:Lerp(Goal, 1 - math.exp(-Damping * dt))

    Damping (default 8) is an exponential-smoothing rate, not a duration — it’s framerate-independent because the 1 - math.exp(-Damping * dt) factor is the fraction of the remaining distance closed this frame, at any dt. The camera looks at the target’s raw (unsmoothed) position throughout.

  • Orbit { Target, Distance?, Height?, Speed?, Angle?, FOV? }State.Angle accumulates Speed * dt radians every frame (Speed default 0.5 rad/s, Angle start default 0), and the eye position is:

    Target.Position + Vector3.new(math.cos(Angle) * Distance, Height, math.sin(Angle) * Distance)

    That’s a fixed-height ring around the target’s vertical (Y) axis — Distance (default 20) is the ring radius in the XZ plane, Height (default 5) is a flat Y offset, not a second radius. Speed = 0 holds a fixed Angle (a static orbit position rather than a spin).

  • Rail { Points, Duration?, Ease?, LookAt?, FOV? } — a Catmull-Rom spline through the Points array. Catmull-Rom passes exactly through every control point (unlike a Bézier curve, where interior points are only tangent handles) by shaping each segment’s curvature from its two neighboring points — so the rail hits every CFrame you give it without the “why doesn’t it touch my control point” surprise Bézier chains produce. Progress along the rail is eased, not linear: Age = Clock() - StartedAt, Alpha = ease(clamp(Age / Duration, 0, 1), Ease) (Duration default 5), so with the default Smooth ease (smoothstep, 3t² - 2t³) the camera accelerates away from the first point and decelerates into the last, rather than moving through the spline at constant time-per-stud. Progress holds at the final point past Duration — the rail doesn’t loop or vanish. Facing comes from the rail’s own orientation (a straight Lerp between the two bracketing points’ rotations, not Catmull-Rom — only position is splined) unless LookAt is given, which overrides facing to point at that target every frame instead.

Targets are live, not snapshots. Follow, Orbit, and Rail’s LookAt all resolve through the same target resolver, and what “live” means depends on the target’s type:

Target type Behavior
Vector3 A fixed point. Follow/Orbit around it forever without moving.
BasePart .Position read fresh every frame — a moving part is tracked continuously.
Model :GetPivot().Position read fresh every frame — same live tracking.
() -> Vector3 Called fresh every frame via pcall; return whatever dynamic point you want.

A BasePart/Model target with a nil .Parent, or a function target that errors or returns a non-Vector3, all resolve to “dead” the same way — which triggers the failsafe below.

The dead-target failsafe. If a shot’s target resolves to nothing (a Model got destroyed mid-shot, a function started throwing), the per-frame evaluator returns no pose, and step() does not freeze on the last valid CFrame — it calls the same finish path release() uses, instantly restoring CameraType and handing the camera back:

function CameraKit.step(self: any, dt: number)
local Pose, FOV = self:_evaluate(dt)
if not Pose then
-- Dead target: hand the camera back rather than freeze on nothing
self:_finishRelease()
return
end
...

This is an instant hand-back, not an eased release(seconds) — there’s no “from” pose worth blending from when the target that defined it just vanished.

blend eases from the real current pose; cut snaps. Both take Scriptable ownership of the camera via capture() (recorded once — a second capture() while already captured is a no-op, so chained blend/cut calls don’t clobber the original pre-cutscene pose). The difference is what they start from:

  • cut(shot) sets the shot and immediately steps once with dt = 0 — the camera jumps straight to the shot’s pose this frame.
  • blend(shot, seconds?, ease?) reads Camera.CFrame/Camera.FieldOfView at the moment blend is called as the “from” pose, then eases toward the new shot’s live-evaluated pose over seconds (default 1). Because the “from” pose is the camera’s actual rendered state — not a cached snapshot of where the previous shot’s target used to be — blending away from a Follow or Orbit shot that’s still tracking a moving target starts from wherever the camera really is, so the transition never jumps to catch up with a target that kept moving after the blend began.

release(seconds?) blends back to the player’s camera and restores CameraType. capture() recorded the pre-cutscene CameraType, CFrame, and FieldOfView — once, the first time it’s called, so a chain of cut/blend calls never overwrites that original pose. release re-poses to that recorded CFrame/FOV (as a synthetic Static shot, blended over seconds) and then restores CameraType. This matters because Roblox’s default camera controller only drives the camera while CameraType is Custom — if a cutscene script forgets to call release() (or errors before reaching it), the camera stays Scriptable forever and the player’s mouse/movement stops steering the camera at all, with no way back short of a respawn. release() (and the dead-target failsafe, which calls the same finish path) is the one place that guarantee is enforced.

An instant release (seconds omitted or <= 0) writes all three restored fields — CFrame, CameraType, and FieldOfView — in the same step, so it lands back at the exact pose capture() recorded rather than wherever the last shot happened to be pointing. That CFrame write is real, but its lifespan depends on what CameraType gets restored to:

  • If the pre-capture CameraType was Custom (the ordinary player camera, and the common case), Roblox’s own camera controller starts driving Camera.CFrame from the character again the very next frame — it runs earlier in the frame than CameraKit’s PreRender step, so it recomputes and overwrites whatever CFrame release() just wrote. The restore is still worth doing: it’s what renders on the frame release() itself executes (no visible pop to the last shot’s pose before the engine takes over), it’s just cosmetic beyond that one frame, because the engine immediately re-solves from the character regardless.
  • For any other CameraType (nothing else is driving the camera), the restored CFrame sticks — there’s no engine controller to overwrite it, so an instant release under a non-Custom restore is a real, persistent re-pose, not a one-frame flash.

FOV is per-shot and optional. Any shot may carry FOV; if a shot omits it, FieldOfView is left exactly as it was — CameraKit never resets it to a default.

CameraKit.ease and CameraKit.rail are pure. Both take plain values (no self, no kernel, no camera) and are exported specifically so tooling and specs can exercise the easing curves and the spline math without spinning up a kernel or a camera at all.

A chase cam that blends into a scripted reveal shot when the player crosses a trigger, then blends back:

local RunService = game:GetService("RunService")
local CameraKit = require(game.ReplicatedStorage.ChloeKernel.CameraKit)
local Cam = CameraKit.attach(kernel)
-- Default camera: a Follow shot tracking the player's character
local function startChase(character: Model)
Cam:cut({
Type = "Follow",
Target = character,
Offset = CFrame.new(0, 6, 14), -- behind and above, target-local space
Damping = 8,
FOV = 70,
})
end
-- On trigger: blend into a Rail shot that sweeps past the gate, looking at it
-- throughout, then release back to the chase cam
local function playGateReveal(gate: Model)
local Points = {
CFrame.new(-40, 15, 0),
CFrame.new(0, 20, -20),
CFrame.new(40, 15, 0),
}
Cam:blend({
Type = "Rail",
Points = Points,
Duration = 4,
Ease = "Smooth",
LookAt = gate,
FOV = 50,
}, 1.5) -- 1.5s ease from wherever the chase cam currently is into the rail's start
task.delay(4, function()
Cam:release(1.2) -- ease back to the pre-capture camera and restore CameraType
end)
end
-- example wiring
local Character = game.Players.LocalPlayer.Character
if Character then
startChase(Character)
end

CameraKit.attach(kernel, options?) -> Instance

Section titled “CameraKit.attach(kernel, options?) -> Instance”
AttachOptions field Type Default Description
Camera Camera? workspace.CurrentCamera Injectable for specs.
Clock (() -> number)? os.clock Injectable for deterministic blend/rail timing in specs.
SkipLoop boolean? false Skip the PreRender connection; specs drive :step(dt) manually.
Member Description
Instance:cut(shot: Shot) Captures the camera (once) and snaps to the shot’s pose immediately (dt = 0).
Instance:blend(shot: Shot, seconds: number?, ease: string?) Captures the camera (once) and eases from the camera’s real current pose to the shot, evaluated live through the transition. seconds default 1.
Instance:release(seconds: number?) Blends back to the pre-capture pose and restores CameraType. seconds omitted/<= 0 restores instantly — CFrame, CameraType, and FieldOfView all land at once (a Custom restore gets overwritten by the engine’s own camera controller the next frame regardless). No-op if the camera was never captured.
Instance:current() -> Shot? The currently active shot, or nil.
Instance:step(dt: number) Evaluates the active shot and writes the camera. Called automatically off PreRender unless SkipLoop.
Instance:destroy() Disconnects the stepper and force-finishes (restores CameraType/FOV if captured).
Field Type Applies to Default Description
Type string all required "Static" | "Follow" | "Orbit" | "Rail"
CFrame CFrame? Static current camera CFrame The held pose.
Target Vector3 | BasePart | Model | (() -> Vector3) Follow, Orbit Re-resolved live every frame.
Offset CFrame? Follow CFrame.new(0, 6, 14) Target-local offset for the goal position.
Damping number? Follow 8 Exponential smoothing rate (per second) toward the goal.
Distance number? Orbit 20 Ring radius in the target’s XZ plane.
Height number? Orbit 5 Flat Y offset above the target.
Speed number? Orbit 0.5 Angular speed, rad/s. 0 holds a fixed Angle.
Angle number? Orbit 0 Starting angle, radians.
Points { CFrame }? Rail Catmull-Rom control points.
Duration number? Rail 5 Seconds to traverse the whole rail; holds the last point past this.
Ease string? Rail, and the blend/release transition "Smooth" "Linear" | "Smooth" (smoothstep) | "In" (quadratic ease-in) | "Out" (quadratic ease-out).
LookAt same as Target Rail Overrides the rail’s own orientation; re-resolved live.
FOV number? any unchanged Field of view for this shot; omit to leave FieldOfView untouched.
Member Description
CameraKit.ease(alpha: number, style: string?) -> number Clamps alpha to [0, 1] and applies the named ease curve. No kernel, no camera.
CameraKit.rail(points: { CFrame }, alpha: number) -> CFrame The Catmull-Rom pose at alpha (0 = first point, 1 = last). A single-point list returns that point regardless of alpha.

CameraKit fires no bus topics and defines no hooks — it’s a pull-style stepper with nothing to subscribe to. CutsceneKit is the module that wraps cut/blend/release into timeline events and publishes its own bus topics around scene lifecycle.

Cam:blend({
Type = "Orbit",
Target = bossModel,
Distance = 40,
Height = 12,
Speed = 0.4,
}, 2)

One call. Easing is CameraKit.ease, shared and tested everywhere else the kit uses it. If bossModel is destroyed mid-orbit, the camera hands back automatically. If the cutscene forgets to call release(), the dead-target path or a later release() still restores CameraType — the player can never get stuck.

What CameraKit buys you over the hand-rolled version: one consistent easing implementation reused by every shot and every blend, target-death safety that’s the same code path as a normal release(), and a restore-on-release guarantee that isn’t re-implemented (or forgotten) per script.