---
title: "Cameras — which one is rendering, with what projection, into what"
description: "A camera is an entity carrying a Camera component."
section: "Topics"
slug: "topics-cameras"
canonical: "https://origozero.ai/docs/topics-cameras"
updated: "2026-10-07T15:56:23.524205585+00:00"
---

# 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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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.

```lua
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:

```lua
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.
