Log inGet started

Cameras — which one is rendering, with what projection, into what

Updated October 7, 2026

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:

reasonWhat it means
disabledCamera.enabled is false, so the camera never competes for the viewport or renders into a target.
entityInactiveThe camera's entity, or an ancestor of it, is deactivated.
targetMissingThe camera names a render target that has no surface. Create it with renderer.texture.create before pointing a camera at it.
noLayersThe camera's render-layer mask is empty, so every object fails its membership test.
outrankedAnother camera won the viewport. camera.observe().viewport.camera names the winner.
notDrawnThe camera was scheduled and the renderer produced no draw for it.
pacedThe 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.
awaitingPipelinesThe 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; nil clears 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.