"""Shared constants, numpy dtypes, and enums for SimVX Graphics."""
import logging
from dataclasses import dataclass
from enum import IntFlag
from importlib.resources import files
from pathlib import Path
from typing import Any, NamedTuple
import numpy as np
log = logging.getLogger(__name__)
__all__ = [
"Feature",
"MeshHandle",
"VertexStreams",
"Viewport",
"SHADING_DTYPE",
"EXTRAS_DTYPE",
"SKIN_DTYPE",
"VERTEX_DTYPE",
"TRANSFORM_DTYPE",
"AABB_DTYPE",
"MATERIAL_DTYPE",
"INDIRECT_DRAW_DTYPE",
"LIGHT_DTYPE",
"MAX_TEXTURES",
"MAX_LIGHTS",
"MAX_OBJECTS",
"SKINNED_VERTEX_DTYPE",
"FRAMES_IN_FLIGHT",
"ALPHA_OPAQUE",
"ALPHA_BLEND",
"ALPHA_CUTOFF",
"SHADER_DIR",
"UI_VERTEX_STRIDE",
]
# GLSL/SPIR-V shaders ship as a real subpackage so they are present in built
# wheels (uv_build only packages files under src/). ``importlib.resources.files``
# returns a real ``pathlib.Path`` for installed subpackages, which is what
# ``compile_shader`` (glslc subprocess) and the shader cache require.
SHADER_DIR: Path = Path(str(files("simvx.graphics.shaders")))
# UI/2D vertex stride: pos(vec2) + uv(vec2) + colour(vec4) = 32 bytes
UI_VERTEX_STRIDE = 32
# Alpha mode constants (match GLSL #defines)
ALPHA_OPAQUE: int = 0
ALPHA_BLEND: int = 1
ALPHA_CUTOFF: int = 2
# Limits
MAX_TEXTURES = 4096
MAX_LIGHTS = 1024
MAX_OBJECTS = 65536
FRAMES_IN_FLIGHT = 2
[docs]
class MeshHandle(NamedTuple):
"""Opaque handle to a registered GPU mesh.
``aabb_min``/``aabb_max`` are the per-axis minimum/maximum of the mesh's
LOCAL vertex positions (each a length-3 float32 array). Computed from the
same positions as ``bounding_radius``; an empty mesh yields a zero-extent
box at the origin. Both backends use identical semantics.
"""
id: int
vertex_count: int
index_count: int
bounding_radius: float
aabb_min: np.ndarray
aabb_max: np.ndarray
[docs]
@dataclass
class Viewport:
"""Viewport configuration for rendering."""
x: int
y: int
width: int
height: int
camera_view: np.ndarray # 4x4 view matrix
camera_proj: np.ndarray # 4x4 projection matrix
render_target: Any | None = None # None = main swapchain
[docs]
class Feature(IntFlag):
"""Material feature bitmask: matches shader #defines."""
NONE = 0
HAS_ALBEDO = 1 << 0
HAS_NORMAL = 1 << 1
HAS_METALLIC_ROUGHNESS = 1 << 2
HAS_EMISSIVE = 1 << 3
HAS_AO = 1 << 4
HAS_EMISSIVE_COLOR = 1 << 5
# Screen-reading material: the frag samples the copied scene
# colour (set0 b14) for refraction. Set when a Material declares
# ``needs_scene_colour``; drives the desktop pass-split scheduling.
HAS_SCREEN_READ = 1 << 6
# Planar-reflection albedo: the albedo texture is a
# PlanarReflection3D capture, sampled with a mirrored projective UV from
# the fragment's clip position instead of the mesh UV. Set when a
# Material's albedo feed node carries ``_is_planar_reflection``.
HAS_PLANAR_REFLECTION = 1 << 7
# UV transform (KHR_texture_transform): the shader remaps the mesh
# UV as ``uv' = rotate(uv * uv_scale) + uv_offset`` before sampling any
# material texture. Set only for a non-identity transform, so the common
# path never pays for it (feature-off frames are byte-identical).
HAS_UV_TRANSFORM = 1 << 8
# Global wetness: the mesh reads the FrameGlobals wetness
# (fg.wetness, driven by WorldEnvironment rain/wetness) and, while it is
# raining, darkens its albedo, drops roughness (a wet surface is glossier),
# and overlays an animated ripple normal on near-horizontal faces. Set only
# when a Material declares ``wetness_affected``; the shader block is
# additionally gated on ``fg.wetness > 0`` so a dry frame is byte-identical.
WETNESS_AFFECTED = 1 << 9
# Decal-receiver opt-out: set when a Material declares
# ``receives_decals=False``. The fragment then skips the decal projection loop
# and its thin-G-buffer A2 receiver flag reads 0. Default materials never set
# it (receive as before), so the common path is byte-identical.
NO_DECALS = 1 << 10
# ---------------------------------------------------------------------------
# Per-stream vertex dtypes (vertex stream split, D5)
# ---------------------------------------------------------------------------
# A 3D mesh uploads one GPU buffer per stream (see ``renderer/vertex_layouts.py``
# for the matching Vulkan binding layouts):
# binding 0: positions -- a plain C-contiguous (N, 3) float32 array (12 B)
# binding 1: SHADING_DTYPE normal + uv (20 B)
# binding 2: EXTRAS_DTYPE tangent + colour + uv2, optional (28 B)
# binding 3: SKIN_DTYPE joints + weights, skinned meshes only (24 B)
SHADING_DTYPE = np.dtype(
[
("normal", np.float32, 3),
("uv", np.float32, 2),
]
) # 20 bytes
EXTRAS_DTYPE = np.dtype(
[
("tangent", np.float32, 4), # xyz + w handedness
("colour", np.uint8, 4), # unorm8x4 vertex colour
("uv2", np.float32, 2), # second UV set
]
) # 28 bytes
SKIN_DTYPE = np.dtype(
[
("joints", np.uint16, 4), # 4 bone indices (8 bytes)
("weights", np.float32, 4), # 4 bone weights (16 bytes)
]
) # 24 bytes
[docs]
class VertexStreams(NamedTuple):
"""Per-stream vertex data for one mesh (vertex stream split, D5).
The canonical mesh payload flowing importer -> ``SceneAdapter`` ->
``MeshRegistry``; each present stream uploads as its own GPU vertex
buffer. ``extras`` and ``skin`` are ``None`` when the mesh has no such
attributes (nothing is uploaded or bound for absent streams).
"""
positions: np.ndarray # (N, 3) float32 -- binding 0
shading: np.ndarray # (N,) SHADING_DTYPE -- binding 1
extras: np.ndarray | None = None # (N,) EXTRAS_DTYPE -- binding 2
skin: np.ndarray | None = None # (N,) SKIN_DTYPE -- binding 3
[docs]
@property
def vertex_count(self) -> int:
return len(self.positions)
[docs]
@classmethod
def build(
cls,
positions: np.ndarray,
normals: np.ndarray | None = None,
uvs: np.ndarray | None = None,
*,
tangents: np.ndarray | None = None,
colours: np.ndarray | None = None,
uvs2: np.ndarray | None = None,
joints: np.ndarray | None = None,
weights: np.ndarray | None = None,
) -> VertexStreams:
"""Assemble streams from per-attribute arrays.
Missing normals/uvs zero-fill the shading stream. The extras stream is
built only when any of tangents/colours/uvs2 is given; absent fields
default to zero tangent/uv2 and opaque white colour. Float colours in
[0, 1] are converted to unorm8; uint8 colours pass through. The skin
stream is built when joints AND weights are given (packing unchanged:
uint16x4 joints + float32x4 weights).
"""
positions = np.ascontiguousarray(positions, dtype=np.float32).reshape(-1, 3)
n = len(positions)
shading = np.zeros(n, dtype=SHADING_DTYPE)
if normals is not None:
shading["normal"] = normals
if uvs is not None:
shading["uv"] = uvs
extras = None
if tangents is not None or colours is not None or uvs2 is not None:
extras = np.zeros(n, dtype=EXTRAS_DTYPE)
extras["colour"] = 255 # opaque white default
if tangents is not None:
extras["tangent"] = tangents
if colours is not None:
colours = np.asarray(colours)
if colours.dtype != np.uint8:
colours = np.round(np.clip(colours, 0.0, 1.0) * 255.0).astype(np.uint8)
extras["colour"] = colours.reshape(n, -1)[:, :4]
if uvs2 is not None:
extras["uv2"] = uvs2
skin = None
if joints is not None and weights is not None:
skin = np.zeros(n, dtype=SKIN_DTYPE)
skin["joints"] = joints
skin["weights"] = weights
return cls(positions, shading, extras, skin)
# ---------------------------------------------------------------------------
# Interleaved vertex dtypes -- web wire format ONLY
# ---------------------------------------------------------------------------
# The desktop renderer uploads split streams (above). The web runtime still
# ships interleaved 32/56-byte vertex buffers over its resource channel; these
# dtypes describe that wire format until the web stream split lands,
# which removes them. No desktop code may use them.
# position(vec3) + normal(vec3) + uv(vec2) = 32 bytes
VERTEX_DTYPE = np.dtype(
[
("position", np.float32, 3),
("normal", np.float32, 3),
("uv", np.float32, 2),
]
)
# GPU-aligned structured dtypes (match common.glsl layouts)
TRANSFORM_DTYPE = np.dtype(
[
("model", np.float32, (4, 4)), # mat4 (64 bytes)
("normal_mat", np.float32, (4, 4)), # mat4 (64 bytes)
("material_index", np.uint32), # uint (4 bytes)
("bone_offset", np.uint32), # base index into the joint SSBO for skinned instances (0 for static)
("render_layers", np.uint32), # per-instance render-layer bitmask (decal cull_mask; 0 = all)
("_pad", np.uint32), # padding to 16-byte alignment (4 bytes)
]
) # Total: 144 bytes
# Per-instance LOCAL-space AABB, slot-aligned 1:1 with TRANSFORM_DTYPE (one row
# per transform SSBO slot). Two std430 vec4s (min.xyz + pad, max.xyz + pad) so
# the GPU occlusion-cull compute can index it identically to the transform SSBO.
AABB_DTYPE = np.dtype(
[
("aabb_min", np.float32, 4), # vec4 (xyz = local min, w = pad)
("aabb_max", np.float32, 4), # vec4 (xyz = local max, w = pad)
]
) # Total: 32 bytes
MATERIAL_DTYPE = np.dtype(
[
("albedo", np.float32, 4), # vec4
("metallic", np.float32),
("roughness", np.float32),
("albedo_tex", np.int32), # bindless texture index (-1 = none)
("normal_tex", np.int32),
("metallic_roughness_tex", np.int32),
("emissive_tex", np.int32),
("ao_tex", np.int32),
("features", np.uint32), # Feature bitmask
("emissive_colour", np.float32, 4), # vec4 (rgb=colour, a=intensity)
("alpha_mode", np.uint32), # 0=OPAQUE, 1=ALPHA_BLEND, 2=ALPHA_CUTOFF
("alpha_cutoff", np.float32), # cutoff threshold (used when alpha_mode==2)
("double_sided", np.uint32), # 1=disable backface culling
# KHR_texture_transform: read only when HAS_UV_TRANSFORM is set.
("uv_rotation", np.float32), # rotation in radians (counter-clockwise)
("uv_offset", np.float32, 2), # UV offset (applied after rotate/scale)
("uv_scale", np.float32, 2), # UV scale (identity = 1, 1)
]
) # 96 bytes; mirrored by the GLSL Material struct and WGSL MaterialData
INDIRECT_DRAW_DTYPE = np.dtype(
[
("index_count", np.uint32),
("instance_count", np.uint32),
("first_index", np.uint32),
("vertex_offset", np.int32),
("first_instance", np.uint32),
]
)
LIGHT_DTYPE = np.dtype(
[
("position", np.float32, 4), # vec4 (w = type: 0=dir, 1=point, 2=spot)
("direction", np.float32, 4), # vec4
("colour", np.float32, 4), # vec4 (w = intensity)
("params", np.float32, 4), # vec4 (range, inner_cone, outer_cone, shadow_flag)
]
)
# Skinned vertex: standard(32) + joints(uint16×4, 8) + weights(vec4, 16) = 56 bytes
# (web wire format only, see above)
SKINNED_VERTEX_DTYPE = np.dtype(
[
("position", np.float32, 3),
("normal", np.float32, 3),
("uv", np.float32, 2),
("joints", np.uint16, 4), # 4 bone indices (8 bytes)
("weights", np.float32, 4), # 4 bone weights (16 bytes)
]
)
# Vulkan handle type aliases: the `vulkan` CFFI bindings return opaque ffi.CData pointers.
# These aliases document intent without introducing runtime dependencies on internal CFFI types.
# Private (underscore prefix): backend-internal annotations, not part of the public surface.
_VkInstance = Any
_VkDevice = Any
_VkPhysicalDevice = Any
_VkQueue = Any
_VkSurfaceKHR = Any
_VkRenderPass = Any
_VkPipeline = Any
_VkPipelineLayout = Any
_VkCommandBuffer = Any
_VkFramebuffer = Any
_VkShaderModule = Any
_VkImage = Any
_VkImageView = Any
_VkDeviceMemory = Any
_VkDescriptorPool = Any
_VkDescriptorSet = Any
_VkDescriptorSetLayout = Any
_VkSampler = Any
_VkDebugUtilsMessengerEXT = Any