Log inGet started

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

A camera is an entity carrying a Camera component. The renderer draws the viewport from whichever camera wins it, and draws every camera naming a render target into that target. Which camera won,…

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.

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.

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.

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.

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.