Log inGet started

Camera input: a webcam or phone camera in the world

Updated September 27, 2026
  • a live texture that shows the camera's current frame, usable anywhere a texture is: on a surface (an in-world TV, a mirror, a picture frame), in a UI image, and in an av.record recording, because it is scene content like any other;
  • the pixels of the current frame, for code that looks at the picture: colour picking, motion, QR codes, face or hand tracking.

The browser, Linux and Android builds capture: in the browser and on Android the feed runs once the person allows the camera, and on Linux once the device opens. A native Windows build lists no devices and opens every feed as failed, with a reason naming its platform; code written against cameraInput there runs unchanged in the builds that capture, where the picture appears. engine.platform says which build is running ("browser" or "native").

Show the webcam on a screen in the world

local feed, why = cameraInput.open({ width = 1280, height = 720 })
assert(feed, why)

-- A flat box for the screen, wearing an unlit session material whose picture
-- is the feed. A session material lives as long as this session, so a live
-- device's picture writes nothing into the world's files.
local screen = renderer.material.create(
    { shader = "unlit", textures = { base_color_texture = feed.texture } }, "tv_screen")
local tv = entity.spawn("TV", { position = { 0, 1.5, -3 }, scale = { 1.6, 0.9, 0.02 } })
tv.component.add("Model", { model = "cube" })
tv.component.get("Model"):applySessionMaterial(screen)

-- The browser asks the person at the machine first; wait for the answer.
local ok, reason = feed:awaitRunning(20)
if not ok then
    print("no camera picture:", reason)   -- e.g. "denied: camera access was refused ..."
end

feed.texture is a texture handle string. It names the feed's texture from the moment the feed opens, so it can go on a material before the first frame arrives; the surface shows the picture as soon as there is one. On a material that already exists, renderer.material.setTexture("tv_screen", "base_color_texture", feed.texture) puts it in the slot.

The same handle goes in UI, as an image in a game screen:

ui.registerScreen("selfie", {
    type = "vertical",
    style = { padding = 12 },
    children = {
        { type = "image", props = { src = feed.texture }, style = { width = 320, height = 180 } },
    },
}, 10)
ui.showScreen("selfie")

A feed is a state, not a call

cameraInput.open returns a feed at once. Opening a camera means asking the person at the machine for access and waiting for an answer that may never come, so where the feed stands is something you read:

feed:status().stateMeaning
permissionPendingThe platform is asking for camera access.
startingAccess was granted and the device is opening.
runningFrames are arriving.
deniedAccess was refused. reason carries the platform's message.
failedThe camera did not open or stopped. reason names why: no camera, a camera another program holds, a size or rate it cannot deliver, a platform with no capture backend.
idleThe feed was stopped.

feed:awaitRunning(timeout) waits out permissionPending and starting, is always bounded, and answers true, or false plus the state and reason.

A request that cannot be made at all answers nil plus the reason instead of a feed: a size or frame rate outside its range, a facing other than "front" or "back", a device the platform does not offer.

What the camera delivers

local s = feed:status()
print(s.width, s.height, s.fps)     -- what the device delivers, not what was asked
print(s.frames)                     -- advances once per new frame
print(s.rotation, s.mirrored)       -- how to show it upright and as the person expects

width, height and fps passed to open are hints: the camera answers with the nearest it can deliver, and status() says what that is.

frames counts every frame delivered. Two reads with the same count saw the same picture, so a loop that analyses frames skips the ones it has seen:

local seen = 0
task.spawn(function()
    while feed:status().state == "running" do
        local now = feed:status().frames
        if now ~= seen then
            seen = now
            -- a new frame
        end
        task.wait()
    end
end)

mirrored is true for a front camera: people expect to see themselves as in a mirror, so show the picture flipped left to right to the person in front of it. The unlit shader flips it with its UV transform:

local screen = renderer.material.create({
    shader = "unlit",
    properties = { uv_scale = { -1, 1 }, uv_offset = { 1, 0 } },
    textures = { base_color_texture = feed.texture },
}, "tv_screen_mirrored")

rotation is the degrees clockwise the picture turns to stand upright on the display, turned first and mirrored after. On a phone it follows the display: a phone camera's sensor is mounted at an angle to the phone, and when the screen rotates the status reports the new turn within a quarter of a second. After a quarter turn (90 or 270) the upright picture is height wide and width tall.

Reading the pixels

local cpu, why = feed:readPixels()                          -- the whole frame
local patch = feed:readPixels({ x = 0, y = 0, w = 8, h = 8 }) -- a rectangle
if cpu then
    local r, g, b, a = cpu:readPixel(10, 20)
    local png = cpu:encodePng()
    cpu:unload()
end

The answer is a TextureCpuHandle of RGBA8 pixels, the same kind renderer.texture.loadCpu gives, so :readPixel, :rgba, :histogram and :encodePng all work on it. Capture keeps running while you read. nil plus the reason comes back while no frame has arrived yet, after the feed stopped, and for a rectangle outside the frame. :unload() a handle once you are done with it.

Choosing a camera

for _, d in ipairs(cameraInput.devices()) do
    print(d.id, d.name, d.facing, d.default)
end

cameraInput.open({ facing = "back" })          -- a phone's main camera
cameraInput.open({ device = "…an id from devices()…" })

Until camera access has been granted, devices() lists each camera with an empty id and name: the list is part of what the permission protects. Open by facing, or with no device to take the default, and the list names the cameras once access is granted. cameraInput.onDeviceChange(function(devices) ... end) hears cameras being plugged in and removed, and the list being named after a grant; it returns a function that removes the listener, and a listener a component registered ends when that component is removed.

Two feeds on the same camera share it: the second open reads the same frames and the same texture, and the camera is opened once.

Ending a feed

feed:stop() stops the feed, and the camera is let go once no feed is reading it, which is what turns the platform's camera light off. A feed also ends when the content that opened it ends: the component that opened it is removed, the module that opened it reloads, the world unloads. cameraInput.feeds() lists every open feed with the context that holds it (the same tag scopes.list() shows), and cameraInput.stopAll() ends them all.

Platforms

The browser build captures from cameras: it asks the browser for the camera, so the browser's own permission prompt and camera indicator are what the person sees.

The Linux build captures from USB (UVC) webcams through their /dev/video* device nodes, and from the cameras PipeWire offers that no device node does, which is where a laptop's MIPI camera appears. A device node the user may not open is listed by name and opens as denied, saying so. Inside a Flatpak sandbox it asks the desktop's camera portal instead: the feed reads permissionPending while the desktop asks the person, and denied with the portal's answer when access is refused. It opens each camera at the size and rate asked for where the camera offers them, else at the nearest size it offers and the nearest rate at that size, and the feed's status reports what it delivers. Cameras that are plugged in or removed show up in devices() and fire onDeviceChange within about a second. The engine's --camera-route option names the route (v4l2, pipewire or portal) when the default is not the one wanted, and --fake-camera pattern presents the test harnesses' fake camera in place of the machine's own.

The Android build captures from the phone's front and back cameras and from an external USB camera, each listed with its facing, through Android's camera service. The first open asks the person for the Camera permission: the feed reads permissionPending while the system's prompt is up, then running, or denied when they refuse; a refusal the system will not ask about again ("Don't allow" chosen twice, or "don't ask again") reads denied naming where in Settings the permission is granted, and a feed opened after granting it there runs. A camera switched off by the Camera access privacy toggle (Android 12 and later) reads denied naming the toggle. rotation combines the camera's sensor mounting with the display's rotation and follows the screen as it turns, and the front camera reads mirrored. When the app goes to the background the system takes the camera from it: every feed lets its camera go and reads starting, and opens it again when the app returns. The build runs on Android 8.0 (API 26) and later.

A build with no capture backend for its platform (a native Windows build) lists no devices and opens every feed as failed, with a reason naming the platform, so content reads why there is no picture.

Finding the rest

lsp.methods("cameraInput") lists the calls. topics/render-textures covers where a texture can go; topics/frame-streaming carries a texture's frames off the machine; topics/microphone is the same shape for sound coming in.