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:
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. |
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;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.