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 thereforeCamera3D.forward) returnsworld_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 attargetin 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 (
fovonCamera3Dis 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 |
|---|---|---|
|
|
held while a pointer drag orbits |
|
|
orbits at |
|
|
zooms by a fraction of scale: twice as far apart, half the distance |
|
|
held while a drag pans, with |
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.