Skip to content

CameraKit

Drives the client camera from declarative shots, re-evaluated against live targets every frame, with blends from the real current pose and a render binding held only while it owns the camera.

Realm
Client
Require
ReplicatedStorage.ChloeKernel.CameraKit
Since
v0.7.0
Source
CameraKit.luau

CameraKit turns cutscene camera work into data. A shot is a table that says what the camera does right now: hold a pose, follow a target, orbit it, or ride a spline. CameraKit evaluates the active shot against live targets every frame, so a Follow shot tracks a moving boss and an Orbit keeps circling a target that walks away, with no per-frame code on your side. Nothing about a shot is baked when you set it. CutsceneKit drives CameraKit from timelines, and Impact layers its shake on top.

While the kit holds the camera it binds one render step with RunService:BindToRenderStep(RenderName, Enum.RenderPriority.Camera.Value + 1, ...). That runs right after the player camera script (which runs at Camera), so CameraKit’s write wins for the frame, and before Impact’s shake at Camera + 2, so the shake lands on top of the shot. The binding is made on the first cut or blend and removed when the kit releases the camera. A kit that is not holding the camera has no per-frame work.

Each kit instance binds under its own name, CKCamera_<n>, stored in the RenderName field, so one kit’s release never unbinds another kit’s step. Names starting with CKCamera_ are reserved; do not bind your own steps under them. SkipLoop = true never binds, and specs call step(dt) by hand.

With no Camera option the kit follows workspace.CurrentCamera. On every capture and every step it checks whether the current camera changed. If it did while the kit holds the camera, the kit takes over the new one: it records the new camera’s state as the one to restore and sets it Scriptable. An injected Camera (specs) is used as given.

  • Static: holds CFrame (default: the camera’s current CFrame).
  • Follow: Offset (default CFrame.new(0, 6, 14)) is applied in the target’s space to get a goal position. The camera’s position eases toward the goal with Smoothed:Lerp(Goal, 1 - math.exp(-Damping * dt)), where Damping (default 8) is a rate per second, so the smoothing is the same at any frame rate. The camera looks at the target’s raw position.
  • Orbit: the angle advances by Speed * dt radians per frame (Speed default 0.5, Angle start default 0). The eye sits at target + Vector3.new(math.cos(Angle) * Distance, Height, math.sin(Angle) * Distance): a ring of radius Distance (default 20) in the XZ plane at a flat Height (default 5). Speed = 0 holds a fixed angle.
  • Rail: a Catmull-Rom spline through Points, which passes through every point. Progress is ease(clamp(age / Duration, 0, 1), Ease) with Duration default 5 and the Smooth ease by default, so the camera speeds up away from the first point and slows into the last. It holds the last point after Duration. Only position is splined: facing lerps between the two bracketing points’ rotations, unless LookAt is set, in which case the camera looks at that target every frame.
  • FOV is optional on any shot. A shot without it leaves FieldOfView as it is.

A Scriptable camera gets no automatic Focus, and the engine uses Focus to decide where to spend lighting and level of detail. Every step sets Camera.Focus to what the shot looks at: the target for Follow and Orbit, LookAt for a Rail that has one, and a point 20 studs ahead of the pose for Static and a Rail without LookAt. The focus from before capture is restored on release.

Follow, Orbit and the rail’s LookAt resolve their target every frame:

Target Resolves to
Vector3 That fixed point.
BasePart .Position, read each frame.
Model :GetPivot().Position, read each frame.
() -> Vector3 The function’s result, called under pcall each frame.

A BasePart or Model with no Parent, or a function that errors or returns something other than a Vector3, counts as dead. A shot whose target is dead has no pose, and the step hands the camera back at once through the same finish path as release, instead of freezing on the last frame.

cut and blend both call capture(), which records the camera’s CameraType, CFrame, FieldOfView and Focus once and sets CameraType to Scriptable. A second capture while holding does nothing, so a chain of shots never overwrites the original pose.

  • cut(shot) starts the shot and steps once with dt = 0: the camera jumps to the shot’s pose this frame.
  • blend(shot, seconds?, ease?) takes the camera’s current CFrame and FieldOfView as the start and eases toward the new shot’s live pose over seconds (default 1). The start is the camera’s real pose, so blending away from a shot that is still tracking a moving target never jumps.
  • release(seconds?) blends to the recorded CFrame and FieldOfView as a Static shot over seconds, then restores CameraType, FieldOfView, Focus and CFrame and unbinds the render step. With seconds omitted or <= 0 it does all of that at once, so the camera lands exactly where capture found it rather than where the last shot pointed. When the restored type is Custom, the player camera re-solves from the character on the next frame anyway.

A cutscene that never releases leaves the camera Scriptable and the player unable to steer it. release, destroy and the dead-target path all end in the same restore.

CameraKit.ease and CameraKit.rail are pure functions: plain values in, no kernel or camera, so tooling and specs can test the curves and the spline directly.

A chase camera that blends into a rail reveal when the player reaches a gate, then blends back:

src/Client/Game/Controllers/RevealController.luau
--!strict
-- RevealController: the chase camera and the gate reveal.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CameraKit = require(ReplicatedStorage.ChloeKernel.CameraKit)
local RevealController = {
Name = "RevealController",
}
function RevealController.init(self: any, kernel: any)
self.Kernel = kernel
self.Camera = CameraKit.attach(kernel)
end
function RevealController.chase(self: any, character: Model)
self.Camera:cut({
Type = "Follow",
Target = character,
Offset = CFrame.new(0, 6, 14), -- behind and above, in the target's space
Damping = 8,
FOV = 70,
})
end
function RevealController.reveal(self: any, gate: Model)
-- 1.5 s from wherever the camera is now into the rail
self.Camera:blend({
Type = "Rail",
Points = {
CFrame.new(-40, 15, 0),
CFrame.new(0, 20, -20),
CFrame.new(40, 15, 0),
},
Duration = 4,
Ease = "Smooth",
LookAt = gate,
FOV = 50,
}, 1.5)
if self.ReleaseHandle then
self.ReleaseHandle:cancel()
end
self.ReleaseHandle = self.Kernel.Scheduler:after(5.5, function()
self.ReleaseHandle = nil
self.Camera:release(1.2) -- ease back and restore the player camera
end)
end
function RevealController.stop(self: any)
if self.ReleaseHandle then
self.ReleaseHandle:cancel()
end
if self.Camera then
self.Camera:destroy()
end
end
return RevealController
Option Type Default Description
Camera Camera? follows workspace.CurrentCamera A fixed camera. Specs inject a double.
Clock (() -> number)? os.clock Time source for blends and rails.
SkipLoop boolean? false Never bind the render step. Specs drive step(dt).
Member Description
kit:cut(shot) Captures the camera (once) and snaps to the shot’s pose this frame.
kit:blend(shot, seconds?, ease?) Captures the camera (once) and eases from its real current pose to the shot, evaluated live. seconds defaults to 1.
kit:release(seconds?) Blends back to the captured pose, then restores CameraType, FieldOfView, Focus and CFrame and unbinds. Omitted or <= 0 restores at once. Does nothing if the camera was never captured.
kit:capture() Takes Scriptable ownership and binds the render step. cut and blend call it.
kit:current() -> Shot? The active shot, or nil.
kit:step(dt) Evaluates the active shot and writes the camera. The render binding calls it.
kit:destroy() Finishes at once: restores a held camera and unbinds.
kit.RenderName This kit’s render step name, CKCamera_<n>.
Field Type Applies to Default Description
Type string all required "Static", "Follow", "Orbit" or "Rail".
CFrame CFrame? Static the camera’s current CFrame The held pose.
Target Vector3, BasePart, Model or () -> Vector3 Follow, Orbit required Resolved every frame.
Offset CFrame? Follow CFrame.new(0, 6, 14) Goal offset in the target’s space.
Damping number? Follow 8 Smoothing rate per second toward the goal.
Distance number? Orbit 20 Ring radius in the XZ plane.
Height number? Orbit 5 Flat Y offset above the target.
Speed number? Orbit 0.5 Radians per second. 0 holds Angle.
Angle number? Orbit 0 Starting angle in radians.
Points { CFrame }? Rail required Catmull-Rom control points.
Duration number? Rail 5 Seconds to cover the rail. Holds the last point after it.
Ease string? Rail "Smooth" "Linear", "Smooth" (smoothstep), "In" (quadratic) or "Out" (quadratic). blend takes its own ease argument.
LookAt same as Target Rail nil Overrides the rail’s facing. Resolved every frame.
FOV number? all unchanged Field of view for this shot.
Member Description
CameraKit.ease(alpha, style?) -> number Clamps alpha to 0-1 and applies the named curve.
CameraKit.rail(points, alpha) -> CFrame The Catmull-Rom pose at alpha (0 is the first point, 1 the last). One point returns that point.

CameraKit publishes no Bus topics and defines no hooks. CutsceneKit wraps cut, blend and release in timeline events and publishes the scene lifecycle.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.