Cameras — which one is rendering, with what projection, into what
A physical camera on the machine (a webcam, a phone's front or back camera) is
camera input: cameraInput.open() shows its live picture as a texture, and
topics/camera-input covers it.
One snapshot per drawn frame
The observation is published once per frame that is drawn, from after the frame is drawn. So a read describes the last frame, not the state of the world at the instant of the call: a write to a camera and a read of the observation in the same script step return the frame that ran before the write. To compare a camera before and after a change, put a frame between them:
local before = camera.get(camId)
entity(camId).component.get("Camera").fov = 35
task.wait() -- let a frame be drawn
local after = camera.get(camId)
camera.observe().frame counts the frames observed, so two reads that report
the same number are one frame, and a poll can wait for it to advance.
Aiming a camera at something
A camera parked on a station has an eye point and a look point, and the
orientation between them is lookAt on the camera's own entity proxy:
local cam = entity.spawn("FilmCamera")
cam.localPosition = { 0, 1.35, -16 }
cam:lookAt({ 0, 4.5, 8 }) -- a point
cam:lookAt(subject) -- or whatever it is filming
cam:lookAt(subject, { 0.2, 1, 0 }) -- ... rolled into a dutch tilt
A camera looks down its entity's forward — its local -Z — which is the axis
lookAt aims, so the aim and the shot agree. It writes the WORLD rotation, so
a camera parented to a rig or a dolly still lands on the point named. The
trailing up hint decides the roll; world +Y stands in when it is left out, and
a target directly overhead or directly below is an ordinary case.
For a camera driven per frame from a component, the same call reads the same
way in update, and the orientation on its own —
transform.lookRotation(eyeX, eyeY, eyeZ, atX, atY, atZ) — hands back the
four quaternion components when the pose is being blended rather than written.
Why is this camera not showing what I expect
camera.observe() returns every camera in the world, for the frame that has
just been drawn. Each carries rendering, and when that is false a reason
naming the one cause:
for _, cam in ipairs(camera.observe().cameras) do
if not cam.rendering then
print(cam.name, "is dark because:", cam.reason)
end
end
The reasons are a closed set — every camera that drew nothing resolves to exactly one, and each is a state the engine reads rather than infers:
reason | What it means |
|---|---|
disabled | Camera.enabled is false, so the camera never competes for the viewport or renders into a target. |
entityInactive | The camera's entity, or an ancestor of it, is deactivated. |
targetMissing | The camera names a render target that has no surface. Create it with renderer.texture.create before pointing a camera at it. |
noLayers | The camera's render-layer mask is empty, so every object fails its membership test. |
outranked | Another camera won the viewport. camera.observe().viewport.camera names the winner. |
notDrawn | The camera was scheduled and the renderer produced no draw for it. |
paced | The frame pacer held this frame back while the frames before it are still on the GPU (renderer.framePacing() counts them in pacedFrames), and the camera's per-frame render into its texture is drawn on the next frame the pacer admits. lastRender names the frame it last drew. |
awaitingPipelines | The camera's one-shot render — a capture, a preview, a readback — is queued behind pipeline builds the scene asked for, and is taken the frame they land. pipelinesPending counts what it waits on, so a poll can tell a queue that is draining from one that is stuck — and a camera that DID draw carries it too, naming the builds the frame it drew was drawn WITHOUT, so one frame can be told from a frame of the settled scene. |
A follow camera that snaps in close to the player
The follow rigs (controller.orbital_follow, controller.third_person_follow)
keep the camera out of walls with a pull-in: each frame a sphere of the rig's
cameraRadius is swept from the point the camera aims at out to where the
framing puts the camera, and the camera stands where that sphere first meets a
collider. Every collider in the world stops the sweep, whether or not anything
draws it. An invisible wall or walk bound (a BoxCollider with no Model), a
trigger volume and a visible wall all pull the camera in alike; the follow
subject is the one entity the sweep passes by default.
So a camera that jumps in close to the player, or collapses onto it, with nothing visible behind the player is meeting a collider nobody can see. The rig's own fields settle it:
local rig = entity(camId).component.get("controller.orbital_follow")
rig.cameraIgnore = { entity.find("WalkBoundNorth"), entity.find("WalkBoundSouth") } -- see through these
rig.cameraRadius = 0 -- or no pull-in at all
cameraIgnore lists the entities whose colliders the camera sees through,
while the walls the player can see still pull it in. cameraRadius = 0 turns
the pull-in off. A rig written on controller.camera_rig gets the same
control by declaring a cameraIgnore list of its own and handing it to
CameraRig.pullInExclude, which builds the exclude list its pull-in sweep
passes through.
What the frame was drawn with, and what the component holds
Every camera carries two projections. authored is what the Camera component
holds; frame is what the renderer built for the frame just drawn. mismatch
names every field the two disagree on:
local cam = camera.get(camera.active())
print(cam.authored.far, cam.frame.far, table.concat(cam.mismatch, " "))
A non-empty mismatch says a value content wrote is not the value the frame was
drawn with. Reading the pair is the whole point of the surface: it replaces
unprojecting the inverse view-projection matrix by hand to find out what the
clip range really was.
frame, viewProj, frustum and the cost numbers describe a camera that
drew. A camera with rendering = false carries authored, its identity, its
layers and its reason, and leaves those four out.
What each camera renders into
output says where a camera's pixels go — kind is "viewport" or
"texture", target is the render-target guid for a texture camera, and
width / height are the pixel size actually drawn into:
for _, cam in ipairs(camera.observe().cameras) do
print(cam.name, cam.output.kind, cam.output.target, cam.output.width, cam.output.height)
end
Which render layers a camera resolves to
layers is the set of named render layers a camera's mask includes, and
layerMask is the raw mask. A camera whose spec names a layer nothing is a
member of draws nothing while every other reading looks healthy — the layer set
beside a cost.drawCalls of zero is what says so.
all means every layer, EditorUI and debug included — the editor's own
chrome and the debug overlays. A camera an author writes "all !ui" for
resolves to default debug sky EditorUI, and draws a gizmo or a debug overlay
whenever one is up. A camera whose frames a player or a reviewer sees wants
those two named out. A report is a reading, so the spec is written through the
camera's entity:
local report = camera.get(camera.active())
entity(report.entity).component.get("Camera").renderLayers = "all !ui !EditorUI !debug"
Read layers back to see which spec resolved to what.
A layer a camera names is only half of it — the other half is which entities are
on that layer, written with renderLayer.set ({ tree = true } for a whole
hierarchy) and read with renderLayer.get. The render-layers guide covers both,
and renderLayer.list shows every layer with its members and the cameras that
include or exclude it.
What each camera costs
cost is for the one frame the observation describes, not a sum since a reset:
for _, cam in ipairs(camera.observe().cameras) do
if cam.rendering then
print(cam.name, cam.cost.cpuMs, cam.cost.drawCalls, cam.cost.drawInstances)
end
end
cpuMs is wall time around the render that camera submitted. drawCalls and
drawInstances are the renderer's own draw counters read either side of that
submission, so they count the draws that camera issued — the per-camera answer
to "the frame got slow after I added the minimap". A camera the frame drew more
than once — its own target plus a target Camera:render(guid) named in the same
frame — reports every one of those renders together.
submits is the renderer's queue submissions read the same way. A submission
costs the same whatever it draws, so it is what a camera drawing a tiny target
still pays; a camera drawing into a texture submits its scene, its
post-processing and its display transform together, then its UI composite and
the pass carrying it to the texture.
Where the time of the frame's texture-camera renders goes, stage by stage, is
in profiler.stats() under system.schedule.render.render_offscreen_cameras.
(shadows, cull, post_display, scene_submit and the rest), each summed
over that frame's renders.
renders is how many of them that is, so the figures beside it divide
into a per-render cost. A frame draws the renders it can afford and queues or
drops the rest, so how many a camera got is the frame's answer rather than the
caller's: read renders on the frame in hand instead of counting the renders
you asked for.
local row = camera.observe().cameras[1]
if row.cost.renders > 0 then
print(("%.2f ms over %d render(s)"):format(row.cost.cpuMs, row.cost.renders))
end
Summed over the cameras that drew, they account for renderer.drawStats().draws
whenever every render in the frame belongs to a camera. A viewport no camera won
is still drawn, from the pose the renderer last held, and is credited to none —
so on such a frame the sum is short by what that render cost.
The whole inventory, and one camera
camera.observe()— the whole observation, including the viewport's owner and pixel size.camera.list()— just the per-camera reports, highest priority first.camera.get(entityId)— one camera's report.camera.viewData(entityId)— one camera's view data: position, projection, viewport size, the 6 world-space frustum planes and the view-projection matrix, plus which camera it describes, what it rendered into and its layers. Called with no argument it is the viewport camera's, which is the form a render feature reads per frame.
Which camera owns the viewport
camera.active()— the camera drawing the screen this frame.camera.main()— the highest-priority scene camera, never the editor fly-cam.camera.editor()— the editor fly-camera.camera.setEditorOverride(id)— give one camera the viewport outright, ignoring priority;nilclears it.
The viewport is decided by priority, highest wins, within the participation the camera competes in — an EditorOnly camera and a scene camera are elected separately, which is what keeps an authoring fly-cam above a scene's own cameras at any priority.
From the filesystem
The same observation is served at /zero/runtime/cameras, one entry per camera
entity:
vfs.list("/zero/runtime/cameras") -- one entry per camera entity
vfs.read("/zero/runtime/cameras") -- the whole observation as JSON
vfs.read("/zero/runtime/cameras/" .. camId) -- one camera's report
Both surfaces serialise one published snapshot, so they can never disagree.