"""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_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_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)