Source code for simvx.core.nodes_3d.multimesh

"""MultiMesh and MultiMeshInstance3D -- instanced rendering."""

from __future__ import annotations

import numpy as np

from ..descriptors import Property
from ..helpers import mat4_from_trs
from .node3d import Node3D


[docs] class MultiMesh: """Resource holding per-instance transforms for mass rendering. Stores transforms (and optional per-instance colours / custom data) as flat numpy arrays suitable for bulk GPU upload. Used by MultiMeshInstance3D. Usage:: mm = MultiMesh(mesh=Mesh.cube(), instance_count=1000) for i in range(1000): mm.set_instance_transform(i, mat4_from_trs(Vec3(i, 0, 0), Quat(), Vec3(1))) """ __slots__ = ("mesh", "instance_count", "transforms", "colours", "custom_data", "_dirty", "_colour_materials") def __init__(self, mesh=None, instance_count: int = 0): self.mesh = mesh self.instance_count = instance_count self.transforms: np.ndarray = np.tile(np.eye(4, dtype=np.float32), (instance_count, 1, 1)) self.colours: np.ndarray | None = None self.custom_data: np.ndarray | None = None self._dirty = True self._colour_materials: list | None = None # -- Per-instance setters --
[docs] def set_instance_transform(self, index: int, transform: np.ndarray) -> None: """Set the 4x4 model matrix for a single instance.""" self.transforms[index] = np.asarray(transform, dtype=np.float32).reshape(4, 4) self._dirty = True
[docs] def set_instance_colour(self, index: int, colour: tuple[float, ...]) -> None: """Set per-instance RGBA colour override. Allocates colour array on first use.""" if self.colours is None: self.colours = np.ones((self.instance_count, 4), dtype=np.float32) self.colours[index] = colour[:4] if len(colour) >= 4 else (*colour[:3], 1.0) self._dirty = True
[docs] def set_instance_custom_data(self, index: int, data: tuple[float, ...] | np.ndarray) -> None: """Set per-instance vec4 custom data. Allocates array on first use.""" if self.custom_data is None: self.custom_data = np.zeros((self.instance_count, 4), dtype=np.float32) d = np.asarray(data, dtype=np.float32).ravel() self.custom_data[index, : len(d)] = d[:4] self._dirty = True
# -- Bulk setters --
[docs] def set_all_transforms(self, transforms: np.ndarray) -> None: """Bulk-set all instance transforms from an (N, 4, 4) array.""" arr = np.asarray(transforms, dtype=np.float32) if arr.shape != (self.instance_count, 4, 4): raise ValueError(f"Expected shape ({self.instance_count}, 4, 4), got {arr.shape}") self.transforms = arr self._dirty = True
[docs] def set_all_colours(self, colours: np.ndarray) -> None: """Bulk-set all instance colours from an (N, 4) array.""" arr = np.asarray(colours, dtype=np.float32) if arr.shape != (self.instance_count, 4): raise ValueError(f"Expected shape ({self.instance_count}, 4), got {arr.shape}") self.colours = arr self._dirty = True
[docs] def set_all_custom_data(self, data: np.ndarray) -> None: """Bulk-set all custom data from an (N, 4) array.""" arr = np.asarray(data, dtype=np.float32) if arr.shape != (self.instance_count, 4): raise ValueError(f"Expected shape ({self.instance_count}, 4), got {arr.shape}") self.custom_data = arr self._dirty = True
[docs] def set_buffer(self, transforms: np.ndarray) -> None: """Bulk-set transforms from an (N, 4, 4) array, resizing if needed.""" arr = np.asarray(transforms, dtype=np.float32) if arr.ndim == 3 and arr.shape[1:] == (4, 4): if arr.shape[0] != self.instance_count: self.resize(arr.shape[0]) self.transforms = arr self._dirty = True else: raise ValueError(f"Expected (N, 4, 4) array, got shape {arr.shape}")
# -- Instance count management --
[docs] def set_instance_count(self, count: int) -> None: """Set instance count, allocating/resizing buffers as needed.""" self.resize(count)
[docs] def resize(self, new_count: int) -> None: """Resize the instance arrays, preserving existing data where possible.""" old = self.instance_count new_transforms = np.tile(np.eye(4, dtype=np.float32), (new_count, 1, 1)) copy_n = min(old, new_count) if copy_n > 0: new_transforms[:copy_n] = self.transforms[:copy_n] self.transforms = new_transforms if self.colours is not None: new_colours = np.ones((new_count, 4), dtype=np.float32) if copy_n > 0: new_colours[:copy_n] = self.colours[:copy_n] self.colours = new_colours if self.custom_data is not None: new_custom = np.zeros((new_count, 4), dtype=np.float32) if copy_n > 0: new_custom[:copy_n] = self.custom_data[:copy_n] self.custom_data = new_custom self.instance_count = new_count self._dirty = True
[docs] class MultiMeshInstance3D(Node3D): """Renders thousands of instances of a single mesh efficiently. Attach a :class:`MultiMesh` resource and the renderer will submit all instances in a single batch using the existing multi-draw indirect pipeline. Usage:: mm = MultiMesh(mesh=Mesh.cube(), instance_count=500) # ... populate transforms ... node = MultiMeshInstance3D(multi_mesh=mm) scene.add_child(node) """ multi_mesh = Property(None, hint="MultiMesh resource", group="MultiMesh") material = Property(None, hint="Material override for all instances", group="MultiMesh") @property def instance_count(self) -> int: """Total instance count from the attached MultiMesh (0 if none).""" mm = self.multi_mesh return mm.instance_count if mm is not None else 0 @property def visible_instance_count(self) -> int: """Number of visible instances. Returns -1 to indicate all instances are visible.""" return getattr(self, "_visible_instance_count", -1) @visible_instance_count.setter def visible_instance_count(self, value: int) -> None: self._visible_instance_count = value @property def model_matrix(self) -> np.ndarray: """Global model matrix for this node (applied to all instances).""" return mat4_from_trs(self.world_position, self.world_rotation, self.world_scale)