Venus Lua API › Rendering

Camera

View component — projection, clear, culling, screen/world transforms.

authored lua/venus_lua/src/lua_camera.rs

Camera

A camera component attached to a GameObject: it defines the projection (FOV, clip planes, aspect),
what it clears, which layers it draws, its render order and viewport rect, and the point/ray
transforms between world, viewport, and screen space.

Static calls use the type table (Camera.Add(go)); instance methods use colon syntax on a handle
(camera:SetVerticalFOV(...)).

Camera.Add always creates a perspective camera (Camera::default), and there is no Lua
binding to switch a camera to orthographic — so in practice every Lua-facing camera is perspective.
The FOV / aspect accessors below note their orthographic behavior for completeness only.

Types

Camera — an opaque handle (userdata) to a camera component. A handle may be null or stale
(its component destroyed); it is safe to keep and pass around.

tostring(camera) always yields the literal Camera — it does not include the object name or a
NULL marker (unlike GameObject/Shader).

Creation & Destruction

Camera.Add( GameObject gameObject ) : Camera
Adds a default (perspective, 60° FOV) Camera component to gameObject and returns its handle.
Returns nil and logs InvalidArgument if the component cannot be added.

Camera.Get( GameObject gameObject ) : Camera
Returns the Camera component on gameObject, or nil if it has none (no warning).

Camera.Exists( Camera camera ) : boolean
Returns true if the handle refers to a live Camera component. Safe on null/stale/foreign handles
(returns false) — the one call here that never warns or raises.

camera:Destroy() removes the Camera component (the GameObject and its other components survive;
depth-gated, a no-op on a stale handle); a Camera is also removed with its GameObject.
Camera.GetInChildren(go) / Camera.GetInParent(go) extend Get across the hierarchy — see the
component model.

Functions

Read-only queries and projection transforms. Vector arguments must be native Vector3/Vector4
values; each call logs NotFound and returns nil on a null/stale handle.

Coordinate spaces. Viewport x, y are normalized to [0,1]. Screen x, y are in
logical pointsnot render pixels; on a HiDPI display the render target is larger by the
render scale factor — with origin at the top-left and +Y pointing down (the same space as
Input.MousePosition()). In both spaces the z component is reverse-Z NDC depth in
[0,1]: 1 = near plane, 0 = far plane. It is not a distance from the camera.

camera:WorldToViewportPoint( Vector3 worldPoint ) : Vector3
Projects a world-space point into viewport space (x, y normalized in [0,1]; z = reverse-Z NDC depth,
1 = near … 0 = far).

camera:ViewportToWorldPoint( Vector3 viewportPoint ) : Vector3
Unprojects a viewport-space point (x, y in [0,1]; z = reverse-Z NDC depth, 1 = near, 0 = far) back to
world space.

camera:WorldToScreenPoint( Vector3 worldPoint ) : Vector3
Projects a world-space point to screen coordinates (logical points; top-left origin, +Y down), using
the current screen size. z = reverse-Z NDC depth (1 = near, 0 = far).

camera:ScreenToWorldPoint( Vector3 screenPoint ) : Vector3
Unprojects a screen point (logical points; z = reverse-Z NDC depth, 1 = near, 0 = far) back to world
space, using the current screen size.

camera:ScreenPointToRay( Vector3 screenPoint ) : table { origin: Vector3, direction: Vector3 }
Builds a world-space ray through the given screen point (logical points), using the current screen
size. The input z is ignored (the ray spans near→far). The result is a table with origin and
direction fields (both Vector3).

camera:ViewportPointToRay( Vector3 viewportPoint ) : table { origin: Vector3, direction: Vector3 }
Builds a world-space ray through the given viewport point (x, y in [0,1]). Unlike
ScreenPointToRay, this needs no screen size — viewport coordinates are already normalized.

camera:Active( ) : boolean or nil
The component’s own enabled flag — what Activate()/Deactivate() write. This is intent,
not effect: a camera whose own flag is enabled but whose GameObject is deactivated still reports
true. The nil on a stale handle keeps “destroyed” distinguishable from “disabled”.

camera:ActiveInHierarchy( ) : boolean or nil
true only when the own flag is enabled and the owning GameObject is active in hierarchy —
i.e. the component is actually running. This is the predicate the engine uses to gate update
dispatch.

Properties (get / set)

Getters warn (NotFound) and return nil on a null/stale handle; setters silently no-op on a
null/stale handle. Clip-plane setters (SetNearClip/SetFarClip) rebuild the projection matrix
immediately; SetVerticalFOV/SetAspect defer the rebuild to the next cull pass (see notes).

Aspect

camera:Aspect( ) : number — viewport aspect ratio (width / height). Returns 0.0 for an
orthographic camera.
camera:SetAspect( number aspect ) : void — no-op on an orthographic camera. See the known
issue below.

Known issue: on a perspective camera SetAspect does not stick. Every frame on_pre_cull
recomputes aspect = (viewportRect.width · screenWidth) / (viewportRect.height · screenHeight)
and rebuilds the projection (engine/scenegraph/src/camera.rs:507-518), overwriting whatever you
set. Aspect() therefore reports the auto-derived ratio, not your argument. To change the framed
aspect, change the window size or SetViewportRect, not SetAspect.

VerticalFOV

camera:VerticalFOV( ) : number — vertical field of view in degrees (the stored value; the
projection converts it to radians internally via (fov/2).to_radians()). Returns 0.0 for an
orthographic camera.
camera:SetVerticalFOV( number degrees ) : void — sets the vertical FOV, in degrees. No-op
on an orthographic camera. The setter mutates the stored FOV but does not rebuild the projection on
the spot (unlike SetNearClip/SetFarClip); the change is picked up by the per-frame rebuild in
on_pre_cull, so it takes visible effect on the next frame.

NearClip

camera:NearClip( ) : number — near clip-plane distance.
camera:SetNearClip( number near ) : void — rebuilds the projection immediately.

FarClip

camera:FarClip( ) : number — far clip-plane distance.
camera:SetFarClip( number far ) : void — rebuilds the projection immediately.

LayerMask

camera:LayerMask( ) : integer — bitmask of render layers this camera draws.
camera:SetLayerMask( integer mask ) : void

RenderOrder

camera:RenderOrder( ) : integer — draw order among cameras (lower renders first).
camera:SetRenderOrder( integer order ) : void

ClearColor

camera:ClearColor( ) : Vector4 — clear color as sRGB RGBA (converted back from the linear
value stored internally).
camera:SetClearColor( Vector4 srgba ) : void — accepts an sRGB RGBA Vector4 (stored as
linear). Requires a native Vector4.

ClearDepth

camera:ClearDepth( ) : number — depth clear value.
camera:SetClearDepth( number depth ) : void

Reverse-Z — do not set this to 1.0. The engine uses a reverse-Z depth buffer (near → 1,
far → 0) with a GreaterEqual depth test. The correct clear value is 0.0 (the far plane),
which is the default. Setting 1.0 — the forward-Z habit from Unity/OpenGL — clears to the near
plane, so no fragment passes the depth test and the scene renders empty.

ClearMode

camera:ClearMode( ) : string — one of "DontClear", "ClearColorOnly", "ClearDepthOnly",
"ClearColorAndDepth".
camera:SetClearMode( string mode ) : void — takes the same four names the getter returns, so
a:SetClearMode(b:ClearMode()) round-trips. Matched exactly — no case folding, and numbers are
rejected. An unrecognized value raises a Lua error listing the four spellings; it no longer falls
back to color + depth, which was both the most expensive mode and the one that hid the mistake
behind plausible-looking output. There is no constants table for these — pass the string literally.

CullTolerance

camera:CullTolerance( ) : number — frustum-cull tolerance expansion.
camera:SetCullTolerance( number tolerance ) : void

SkipCulling

camera:SkipCulling( ) : boolean — whether frustum culling is bypassed for this camera.
camera:SetSkipCulling( boolean skip ) : void

ViewportRect

camera:ViewportRect( ) : Vector4 — viewport rect (x, y, width, height) in [0,1] normalized
coords.
camera:SetViewportRect( Vector4 rect ) : void(x, y, width, height) in [0,1]. Values are
clamped to a valid range (x,y ≥ 0; width/height in [0.001, 1 − offset]). Requires a native
Vector4.

Methods

Actions that mutate the camera. All silently no-op on a null/stale handle.

camera:SetRenderTarget( integer gpuHandle ) : void
Directs this camera’s color output to an off-screen target. gpuHandle is a packed 4-byte
GpuHandle<GpuRenderTarget> passed as an integer — obtain it from a
RenderTexture via rt:GpuColorHandle(). The integer is reinterpreted directly
(transmute) with no validation, so an integer from any other source yields an undefined target
rather than an error.

camera:SetDepthRenderTarget( integer gpuHandle ) : void
As above for the depth target; obtain the integer from rt:GpuDepthHandle().

Planar reflection

camera:SetReflectionPlane( Vector4 plane ) : void
Switches this camera into planar-reflection mode: it renders the scene mirrored across plane,
with an oblique near-clip at the plane (so geometry behind the mirror is clipped away) and triangle
winding corrected, into whatever render target the camera has. plane is a world-space
(nx, ny, nz, d) satisfying n·p + d = 0 — a water surface at height h is
Vector4.new(0, 1, 0, -h). Requires a native Vector4; loose numbers raise.

Parent this camera’s GameObject to the main camera, so the reflection inherits the view being
reflected. Pair it with SetRenderTarget / SetDepthRenderTarget and sample the result by screen
UV — the stored image is vertically mirrored.

camera:ClearReflectionPlane( ) : void
Returns the camera to ordinary (unmirrored) rendering. Unlike ClearCubemapTarget this is safe at
any time — reflection mode never changed the camera’s render-queue allocation.

Reflection only shows what is on screen. It mirrors the source view’s frustum, so sky
overhead, geometry behind the camera, and anything off-screen are simply absent — which shows up
badly at steep viewing angles. For all-angle content use render-to-cubemap
instead, or in addition.

Render-to-cubemap

camera:SetCubemapTarget( integer gpuHandle [, integer faceMask] ) : void
Switches this camera into render-to-cubemap mode: instead of emitting one pass, it renders the
scene into the six faces of a cube color target about its own world position, producing an
environment cubemap (for reflections, or any lookup that needs off-screen and behind-the-camera
content). gpuHandle is the packed integer from cube:GpuColorHandle(), where cube came from
RenderTexture.CreateCube; as with SetRenderTarget it is reinterpreted with no
validation.

Optional faceMask — bit f selects face f in the cube layer order +X, −X, +Y, −Y, +Z, −Z;
default 0x3F (all six). Only the low six bits are read (the argument is masked with 0x3F), so
higher bits are silently ignored rather than an error. This is the per-frame amortization lever: a
face the mask excludes costs nothing at all — no cull, no draw pass, no render-queue entry — and
its cube layer keeps whatever it was last captured with. Rotate the mask across frames to spread the
six faces over several frames — see the render-to-cubemap example at the end of this page.

A face is never captured partially: the mask is read once per frame, before culling, and a selected
face renders whole or not at all.

You must also call camera:SetDepthRenderTarget( cube:GpuDepthHandle() ). It is required, not
advisory
— with a null depth target the face passes get no depth attachment at all and the whole
capture renders with no depth testing (nearest-surface wins by draw order). All six faces share that
one face-sized depth buffer, which is cleared before each face.

Configure it before or after the GameObject is activated, whichever suits — the next cull pass
establishes the per-face allocation either way.

What cubemap mode ignores. Every face is forced to a 90° vertical FOV, aspect 1, and the
full viewport — six 90° faces are exactly what tiles a cube — so SetVerticalFOV, SetAspect and
SetViewportRect have no effect on the capture. SetRenderTarget is ignored too: the cube target
replaces color attachment 0. Still honored, and shared by all six faces: SetNearClip /
SetFarClip, SetClearColor / SetClearDepth, SetLayerMask, SetRenderOrder,
SetReplacementShader, SetCullTolerance / SetSkipCulling.

SetClearMode: the color half is yours, the depth half is not. On a capture camera the
depth-clear bit is forced on, so "DontClear" behaves as "ClearDepthOnly" and
"ClearColorOnly" as "ClearColorAndDepth". All six faces share one depth buffer, and the depth
values face f−1 wrote live in that face’s clip space — there is nothing face f can do with
them. Preserved, they silently reject geometry on faces 1–5 under the engine’s reverse-Z.

The color bit is honored per face exactly as authored: "DontClear" / "ClearDepthOnly" make a
face pass load its own cube layer, accumulating over that face’s previous capture. ClearMode()
keeps returning what you set — the override is applied to the emitted face passes, not to the
camera.

Feedback. If the object that samples the cubemap is itself visible to the capture camera, the
capture feeds on its own previous result. Exclude it with SetLayerMask, or place it beyond
SetFarClip.

Cost. All six faces means six full culling and draw passes per frame, each with its own
frustum, every frame the camera is active. Keep SetFarClip tight and SetLayerMask narrow, and
spread the faces across frames with faceMask — cost scales with the number of selected faces, so
a two-face wedge per frame is a third of the work at a third of the refresh rate. Deactivate the
camera to stop paying entirely.

Set an explicit SetRenderOrder below every camera that samples the cube. This is a
requirement, not a tuning knob. All six face passes inherit the camera’s render order, and
Camera.Add defaults to 1 — the same order every ordinary 3D camera uses. Cameras tied at the
same order are sequenced by an internal slot index that is not allocation order and can change
at runtime as cameras are created and destroyed. Left at the default, a viewer may sample last
frame’s cube, and an unrelated camera can be scheduled between face 2 and face 3 and sample a
half-updated cube — intermittently, with no error.

camera:ClearCubemapTarget( ) : void
Returns the camera to ordinary single-pass mode.

Known issue — only reliable while the camera is inactive. ClearCubemapTarget clears the mode
flag, but it does not re-establish the ordinary camera’s render-queue entry (cubemap mode released
it) and does not release the six face entries (those are reclaimed on the next deactivate). Called
on an active camera, the camera stops contributing its normal pass and the six face entries
keep rendering every frame from a frozen viewpoint — six wasted scene passes into a capture
that no longer follows the camera. Deactivate and reactivate the camera (or its GameObject) to
reconcile. Clearing while inactive, or simply destroying the camera, is fine.

Known issue — the projection queries return garbage on a cubemap camera. In cubemap mode the
per-frame update returns early, before the camera’s view and projection matrices are recomputed, so
they keep whatever values they held when the mode was switched on (identity, if it was never an
ordinary camera). The floating-origin sector is still refreshed each frame, so the stale matrices
and the live sector disagree and drift further apart as the camera moves. Every method in
Functions that projects or unprojects — WorldToViewportPoint,
ViewportToWorldPoint, WorldToScreenPoint, ScreenToWorldPoint, ScreenPointToRay — returns
incoherent results, with no error. A capture camera has no viewport, so these were never meaningful
on one; just don’t call them. Use a separate ordinary camera for picking and projection.

There is no getter for the cubemap target or face mask (matching SetRenderTarget, which also has
none) — track the mode in your own script state if you need it.

camera:SetReplacementShader( Shader shader ) : void
Overrides every draw for this camera with shader (e.g. a depth-only or debug pass). Pass nil to
clear the override. If a live Shader handle is given, its pipeline is resolved and
installed; if the handle cannot be resolved, logs InvalidArgument and clears the override. A
non-nil argument that is not a Shader userdata raises a type error.

camera:Activate( ) : void
Sets the component’s own enabled flag. Its enable callback runs only if the owning GameObject is
also active.

camera:Deactivate( ) : void
Clears the own enabled flag; the disable callback runs only if the component was actually active.

Known issue — SetActive is listed in the bindings but does not build. CAMERA_METHODS
registers a SetActive entry pointing at cmp_set_active, a function the committed
component_lifecycle_methods! macro does not generate (it generates cmp_active, the getter). A
clean checkout of master therefore fails with E0425: cannot find value cmp_set_active
(lua_camera.rs:960) — it only compiles in a tree that also carries the unlanded macro change.
SetActive is deliberately not documented as available here until that resolves; use
Activate() / Deactivate(), which are complete and stable.

Example

-- Attach a perspective camera, frame the scene, and pick under the mouse.
-- (Adapted from data/scripts/session_setup.lua + editor_pick.lua.)
function Script.OnEnable(self)
    local cam = Camera.Add(self.object)
    cam:SetVerticalFOV(60)          -- degrees
    cam:SetNearClip(0.1)
    cam:SetFarClip(1000)
    cam:SetClearColor(Vector4.new(0.02, 0.02, 0.04, 1))  -- sRGB RGBA
    self.cam = cam
end

function Script.Update(self)
    if not Input.LeftMouseDown() then return end
    local mx, my = Input.MousePosition()
    -- There is no Camera:Pick — build a ray and raycast the physics world.
    local ray = self.cam:ScreenPointToRay(Vector3.new(mx, my, 0))
    local hit = Physics.Raycast(ray.origin, ray.direction, self.pickMask)
    if hit then self.hovered = hit.gameObject end
end

Render-to-cubemap

-- Capture an environment cubemap at a fixed point and bind it to a material.
-- (Adapted from data/scripts/cubemap_calibration.lua.)
function Script.OnEnable(self)
    local PF = RenderTexture.PixelFormat
    local cube = RenderTexture.CreateCube("env_probe", 256, PF.RGBA16F, PF.D32)
    if not cube then return end

    local go = GameObject.CreateInactive()
    go:SetLocalPosition(4, 14, 4)                    -- the capture point
    local cam = Camera.Add(go)
    cam:SetRenderOrder(0)                            -- REQUIRED: below every camera that samples it
    cam:SetNearClip(0.1)
    cam:SetFarClip(50)                               -- tight: cost, and keeps the sampler out
    cam:SetCubemapTarget(cube:GpuColorHandle())      -- all six faces every frame
    cam:SetDepthRenderTarget(cube:GpuDepthHandle())  -- required — faces share one depth
    go:Activate()

    -- Bind the cube into a material slot. The shader must declare that slot
    -- TextureCube<float4> and sample it by direction, not UV.
    self.envMaterial:SetTexture(0, cube:ColorTexture())

    -- Keep the camera and the packed handle: changing the face mask means
    -- re-calling SetCubemapTarget, and neither value has a getter.
    self.captureCam = cam
    self.cubeHandle = cube:GpuColorHandle()
    self.captureGO = go   -- self.captureGO:Deactivate() stops paying entirely
end

Amortize it — two faces per frame, so the whole cube refreshes every third frame for a third of the
per-frame cost. Nothing ends up half-captured: a selected face renders whole, and an unselected face
keeps its last capture and costs nothing. The mask is read during the cull pass, after Update, so a
change lands the same frame.

local WEDGES = { 0x03, 0x0C, 0x30 }   -- {+X,−X}, {+Y,−Y}, {+Z,−Z}

function Script.Update(self)
    if not self.captureCam then return end
    self.wedge = (self.wedge or 0) % #WEDGES + 1
    self.captureCam:SetCubemapTarget(self.cubeHandle, WEDGES[self.wedge])
end