Cameras (3D)

Camera3D (in simvx.core.nodes_3d) is a Node3D subclass that supplies view + projection matrices to the renderer. The first Camera3D found in the scene tree is the active camera; render layers can be masked via cull_mask.

from simvx.core import Camera3D, Node, Vec3

class Scene(Node):
    def on_ready(self):
        self.add_child(Camera3D(
            position=(0, 5, 10),
            look_at=Vec3(0, 0, 0),
            fov=70.0,
            near=0.1,
            far=200.0,
        ))

Conventions

SimVX uses the following conventions for 3D math and cameras. They are load-bearing: read them once and assume they hold everywhere unless a specific subsystem (e.g. the Vulkan clip-space transposition) states otherwise.

Coordinate space

  • Right-handed, Y-up by default.

  • Forward = local -Z. Node3D.forward (and therefore Camera3D.forward) returns world_rotation * (0, 0, -1). This matches Godot, glTF, Maya, Blender’s runtime export, and the OpenGL family. It clashes with Unity and Unreal (+Z forward), so ports from those engines need to negate the forward vector once at import.

  • Right = local +X, up = local +Y.

Look-at / face-along

  • Node3D.look_at(target, up=…) rotates the node so its local -Z axis points at target in world space.

  • Node3D.face_along(direction, up=…) does the same with a direction vector, skipping the target subtraction. Convenient for steering by velocity.

  • Quat.look_at(direction, up=…) is the underlying primitive. It already bakes the -Z negation: pass the direction the node should face, not the direction the local +Z axis should land on.

Matrices

  • All matrices are stored row-major as NumPy arrays.

  • Vulkan shaders read column-major; the renderer transposes once at the GPU boundary.

  • Camera3D.projection_matrix() flips the Y axis (proj[1, 1] *= -1) to match Vulkan’s clip-space Y-down convention. Other renderers (web / WebGPU) apply their own clip-space fixup; the row-major matrix that leaves Camera3D is the same.

Angles

  • All internal angles are in radians (fov on Camera3D is the exception: declared in degrees for the inspector and converted at matrix-build time).

  • UI / debug code converts to degrees for display. There is no separate “radians camera” type.

Rays and screen positions

Camera3D.ray_at(screen_pos) is the world-space Ray through a point in the camera’s view, in the units of viewport_size, so Input.mouse_position passes straight in. A Ray has a unit direction, at(distance) and intersect_plane(point, normal), and its distances are the ones a physics RayHit reports:

ray = camera.ray_at(Input.mouse_position)
hit = self.physics.raycast(ray.origin, ray.direction)
ground = ray.intersect_plane((0, 0, 0), (0, 1, 0))   # None when parallel or behind

Camera3D.world_to_screen(point) is the inverse. A point outside the view still gets its (off-screen) position; a point level with or behind the camera returns None. Ray.from_screen(screen_pos, screen_size, view, proj) is for a view whose size or matrices come from no camera node, such as an editor panel.

Built-in picking (CollisionShape3D.pickable and on_picked) casts through the camera the scene renders through, and hits a body’s real collider through its physics world; a pickable shape with no body in a world is tested against its bounding sphere.

OrbitCamera3D

A turntable camera with yaw, pitch, distance, and pivot Properties. Useful for editor viewports and model viewers; not intended for first-person games. Its position and orientation follow from those four: assigning any of them, from code, a scene file or the inspector, moves the camera at once. Pitch is clamped just short of the poles on every assignment.

It takes its own input, so adding the node is enough:

Action

Default bindings

Does

orbit_camera.drag

Pointer.PRIMARY

held while a pointer drag orbits

orbit_camera.orbit

Controller.RIGHT_STICK

orbits at stick_speed

orbit_camera.zoom

Touch.PINCH, Mouse.WHEEL * 0.1, the triggers .per_second(1.0)

zooms by a fraction of scale: twice as far apart, half the distance

orbit_camera.pan

Pointer.SECONDARY, Mouse.MIDDLE

held while a drag pans, with pan_enabled

The zoom is one action in the pinch’s unit, the frame’s change of scale, and each other source is put into it by its binding: a wheel notch is a tenth, and a trigger held fully doubles the view a second. A scroll a UI control took (a list scrolling) moves it not at all. To zoom faster on the wheel, a game binds Mouse.WHEEL * 0.2 in its own input_context. Tuning is in Properties: drag_sensitivity, stick_speed, pan_speed, invert_x and invert_y, and the limits min_distance, max_distance, min_pitch and max_pitch, which orbit() and zoom() respect. A player rebinds an entry with InputMap.rebind("orbit_camera.drag", Mouse.RIGHT); a game replaces the whole set by assigning the camera’s input_context.

A press a UI control claimed never reaches the camera. A paused camera, or one under a context that hides everything (a pause menu), stands still. Every orbit camera in the tree answers, so set interactive = False on one that is not being looked through, such as the camera behind a RenderView.

Multiple cameras

One Camera3D is active for the main view at a time. For a second view of the world, place another (non-current) Camera3D and point a RenderView at it; the captured texture can be shown on any Material.albedo_map (an in-world monitor or mirror). A SubViewport renders its own subtree with its own camera and can additionally feed a Sprite2D. Split-screen (several cameras compositing to on-screen rectangles) has no public API yet.