simvx.core.math.matrices

Pure NumPy matrix utilities.

All matrices use NumPy’s native row-major layout. When sending to GLSL, matrices must be transposed (GLSL expects column-major); this transpose happens only at GPU boundary points, keeping the rest of the engine free of row-major vs column-major ambiguity.

Module Contents

Functions

quat_to_mat4

Convert a quaternion to a 4x4 rotation matrix.

identity

Create 4x4 identity matrix.

perspective

Create a reverse-Z perspective projection matrix.

oblique_near_plane

Replace proj’s near clip plane with an arbitrary world-space plane.

look_at

Create view matrix using look-at vectors.

translate

Create translation matrix.

rotate

Create rotation matrix using axis-angle representation.

scale

Create scale matrix.

orthographic

Create orthographic projection matrix.

mat4_from_trs

Build model matrix from position, rotation quaternion, and scale.

batch_mat4_from_trs

Build N model matrices from arrays of positions, quaternions, and scales.

mat4_to_bytes

Convert mat4 to bytes for GPU upload (64 bytes, row-major float32).

halton_jitter

Sub-pixel jitter offset in [-0.5, 0.5] from the Halton(base_x, base_y) sequence.

apply_jitter

Return a copy of proj translated by a sub-pixel jitter in clip space.

Data

API

simvx.core.math.matrices.log

‘getLogger(…)’

simvx.core.math.matrices.__all__

[‘identity’, ‘perspective’, ‘oblique_near_plane’, ‘look_at’, ‘translate’, ‘rotate’, ‘scale’, ‘orthog…

simvx.core.math.matrices.quat_to_mat4(q, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Convert a quaternion to a 4x4 rotation matrix.

Args: q: Quaternion: any object with .w, .x, .y, .z attributes (e.g. Quat) or a 4-element array [w, x, y, z]. dtype: NumPy data type (default float32)

Returns: 4x4 rotation matrix as numpy array

simvx.core.math.matrices.identity(dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create 4x4 identity matrix.

Args: dtype: NumPy data type (default float32)

Returns: 4x4 identity matrix as numpy array

simvx.core.math.matrices.perspective(fov: float, aspect: float, near: float, far: float, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create a reverse-Z perspective projection matrix.

Args: fov: Field of view in radians aspect: Aspect ratio (width / height) near: Near clipping plane far: Far clipping plane dtype: NumPy data type (default float32)

Returns: 4x4 perspective projection matrix

Note: Depth maps to z_ndc in [0, 1] with the near plane at 1 and the far plane at 0, matching :func:orthographic. Two things follow, and the engine’s pipelines depend on both: the depth attachment clears to 0.0, and every camera-space depth test compares GREATER or GREATER_OR_EQUAL.

The reversal is what makes a float depth buffer usable over a wide
range. A float's exponent crowds its precision near zero, and a
perspective divide crowds depth near the near plane; forward-mapping
puts both concentrations in the same place and wastes them, while
reversing lines the float's fine steps up with the far field where the
projection's own steps are coarsest.

The older OpenGL ``[-1, 1]`` form is not merely a different spelling
here. The hardware clip volume is ``0 <= z_clip <= w_clip`` on both
backends, so that form put the effective near plane at
``2 f n / (f + n)``: a camera asking for ``near = 0.1`` really clipped
at ``0.1998``, and nothing said so.

Does NOT include Y-flip for Vulkan. Caller must do:
proj[1, 1] *= -1  # Flip Y-axis for Vulkan
simvx.core.math.matrices.oblique_near_plane(proj: numpy.ndarray, view: numpy.ndarray, plane: tuple[float, float, float, float] | numpy.ndarray, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Replace proj’s near clip plane with an arbitrary world-space plane.

The oblique-frustum technique (Lengyel, “Oblique View Frustum Depth Projection and Clipping”): the projection’s z row is rewritten so one of the GPU’s two fixed depth clip planes coincides with plane, scaled so the opposite plane still touches the corner of the original frustum that lies deepest on the kept side. Used by planar reflections: the mirrored camera must not render geometry behind the reflection plane.

The engine is reverse-Z, so the near clip is z_clip <= w_clip rather than z_clip >= 0 and the substituted row is w_row - alpha * plane rather than Lengyel’s alpha * plane. Both forms clip identically; only the reversed one leaves depth running near-to-far the way the pipelines’ GREATER comparison expects, so the textbook form would silently invert depth testing inside every planar-reflection pass.

Args: proj: 4x4 reverse-Z projection matrix (row-major): hardware clip volume 0 <= z_clip <= w_clip with the near plane at z_clip = w_clip. Both backends and both of this module’s projections qualify. view: 4x4 view matrix of the camera the projection belongs to. plane: World-space plane (nx, ny, nz, d) with the equation n . x + d = 0; geometry on the n . x + d > 0 side is kept.

Returns: A new 4x4 projection matrix (input is not modified). Depth values are redistributed (inherent to the technique); depth testing within the pass stays consistent. Returns proj unchanged when the plane does not face any part of the view frustum (degenerate input).

simvx.core.math.matrices.look_at(eye: numpy.ndarray | tuple[float, float, float], center: numpy.ndarray | tuple[float, float, float], up: numpy.ndarray | tuple[float, float, float], dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create view matrix using look-at vectors.

Args: eye: Camera position center: Point to look at up: Up vector (should be normalized) dtype: NumPy data type (default float32)

Returns: 4x4 view matrix

simvx.core.math.matrices.translate(t: numpy.ndarray | tuple[float, float, float], dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create translation matrix.

Args: t: Translation vector (x, y, z) dtype: NumPy data type (default float32)

Returns: 4x4 translation matrix

simvx.core.math.matrices.rotate(axis: numpy.ndarray | tuple[float, float, float], angle: float, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create rotation matrix using axis-angle representation.

Args: axis: Rotation axis (should be normalized) angle: Rotation angle in radians dtype: NumPy data type (default float32)

Returns: 4x4 rotation matrix

Uses Rodrigues’ rotation formula.

simvx.core.math.matrices.scale(s: numpy.ndarray | tuple[float, float, float] | float, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create scale matrix.

Args: s: Scale factors (x, y, z) or uniform scale factor dtype: NumPy data type (default float32)

Returns: 4x4 scale matrix

simvx.core.math.matrices.orthographic(left: float, right: float, bottom: float, top: float, near: float, far: float, dtype: numpy.dtype = np.float32) numpy.ndarray[source]

Create orthographic projection matrix.

Args: left: Left plane right: Right plane bottom: Bottom plane top: Top plane near: Near plane far: Far plane dtype: NumPy data type (default float32)

Returns: 4x4 orthographic projection matrix

Note: Depth maps to z_ndc in [0, 1], the clip volume both backends (Vulkan, WebGPU) actually enforce, with the near plane at 1 and the far plane at 0 so an orthographic camera shares the reverse-Z convention :func:perspective uses and the same clear value and depth comparison serve both.

An OpenGL-style [-1, 1] ortho would put half the depth range at
negative ``z_clip`` and the hardware would clip away half the scene:
unlike the perspective case there is no non-linearity to hide it.

Reversal buys an orthographic camera no precision, since its depth is
linear in distance. It is here for uniformity: one clear value and one
compare op across both projections is what keeps the two from needing
separate pipelines.

Does NOT include the Y-flip for Vulkan. Caller must do:
proj[1, 1] *= -1
simvx.core.math.matrices.mat4_from_trs(pos: tuple[float, float, float] | numpy.ndarray, rot, scl: tuple[float, float, float] | numpy.ndarray) numpy.ndarray[source]

Build model matrix from position, rotation quaternion, and scale.

Args: pos: Position (x, y, z) rot: Rotation quaternion: Quat or any object with .w/.x/.y/.z scl: Scale (x, y, z)

Returns: 4x4 model matrix as numpy array (Translate * Rotate * Scale)

simvx.core.math.matrices.batch_mat4_from_trs(positions: numpy.ndarray, rotations: numpy.ndarray, scales: numpy.ndarray) numpy.ndarray[source]

Build N model matrices from arrays of positions, quaternions, and scales.

Args: positions: (N, 3) float32 positions rotations: (N, 4) float32 quaternions [w, x, y, z] scales: (N, 3) float32 scale factors

Returns: (N, 4, 4) float32 model matrices (Translate * Rotate * Scale)

simvx.core.math.matrices.mat4_to_bytes(m: numpy.ndarray) bytes[source]

Convert mat4 to bytes for GPU upload (64 bytes, row-major float32).

simvx.core.math.matrices.halton_jitter(index: int, base_x: int = 2, base_y: int = 3) tuple[float, float][source]

Sub-pixel jitter offset in [-0.5, 0.5] from the Halton(base_x, base_y) sequence.

Used for TAA: each frame samples a different sub-pixel location so the temporal accumulation reconstructs detail below one pixel. index is the frame counter (callers typically cycle it modulo the history length, e.g. 8). Mirrors the web runtime’s TAAPass.haltonJitter exactly so desktop and web jitter identically.

Returns: (jx, jy) offsets in pixels, each in [-0.5, 0.5].

simvx.core.math.matrices.apply_jitter(proj: numpy.ndarray, jx: float, jy: float, width: float, height: float) numpy.ndarray[source]

Return a copy of proj translated by a sub-pixel jitter in clip space.

proj is a row-major Vulkan projection matrix (post Y-flip). The pixel-space jitter (jx, jy) is converted to NDC (2 / dim per pixel) and added to the clip-x/clip-y rows of the third column (proj[0, 2] / proj[1, 2]), the same slots the renderer transposes to the GPU’s column-2 rows 0/1. The original matrix is left untouched so callers keep an unjittered copy for culling and motion-vector reprojection.

Under an orthographic projection w is a constant 1 rather than -z_eye, so a third-column offset would shear with depth instead of translating. The offset moves to the translation column, negated to land on the same NDC shift the perspective path produces (+p02 divided by w = -z_eye); the TAA resolve inverts the same signed jitter for both.