"""Mesh Level of Detail (LOD) system.
Provides automatic mesh decimation via vertex clustering and distance-based
LOD selection for efficient rendering of 3D scenes.
Usage::
from simvx.core import Mesh, MeshLOD, generate_lods
mesh = Mesh.sphere(radius=1.0, rings=32, segments=32)
lod = generate_lods(mesh, levels=3, ratios=[1.0, 0.5, 0.25])
level = lod.select_lod(distance=25.0)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
from .graphics.mesh import Mesh
__all__ = ["LODLevel", "MeshLOD", "generate_lods"]
log = logging.getLogger(__name__)
[docs]
@dataclass
class LODLevel:
"""A single level of detail.
Attributes:
mesh: The mesh geometry for this LOD level.
ratio: Decimation ratio (1.0 = original, 0.5 = half the triangles).
max_distance: Maximum camera distance at which this level is used.
``float('inf')`` for the coarsest level (always a fallback).
"""
mesh: Mesh
ratio: float = 1.0
max_distance: float = float("inf")
[docs]
class MeshLOD:
"""Container for multiple LOD levels of a single mesh.
Levels are stored sorted by ascending ``max_distance`` so that
``select_lod`` can do a simple linear scan.
"""
__slots__ = ("_levels",)
def __init__(self, levels: list[LODLevel] | None = None) -> None:
self._levels: list[LODLevel] = sorted(levels or [], key=lambda lv: lv.max_distance)
# -- Access ---------------------------------------------------------------
@property
def levels(self) -> list[LODLevel]:
"""All LOD levels, ordered by max_distance (nearest first)."""
return self._levels
@property
def level_count(self) -> int:
return len(self._levels)
[docs]
def add_level(self, level: LODLevel) -> None:
"""Insert a level, maintaining sorted order."""
self._levels.append(level)
self._levels.sort(key=lambda lv: lv.max_distance)
# -- Selection ------------------------------------------------------------
[docs]
def select_lod(self, distance: float, bias: float = 0.0) -> LODLevel:
"""Return the appropriate LOD level for *distance*.
Args:
distance: Camera-to-object distance (world units).
bias: Additive bias applied to thresholds. Positive values push
selection toward coarser levels (cheaper), negative toward
finer (higher quality).
Returns:
The best ``LODLevel`` for the given distance.
Raises:
ValueError: If no levels are registered.
"""
if not self._levels:
raise ValueError("MeshLOD has no levels")
for level in self._levels:
if distance < level.max_distance + bias:
return level
return self._levels[-1]
# ============================================================================
# Vertex-clustering mesh decimation
# ============================================================================
def _decimate_mesh(mesh: Mesh, ratio: float) -> Mesh:
"""Decimate *mesh* to approximately *ratio* of its original triangle count.
Uses a uniform-grid vertex clustering approach:
1. Compute the mesh bounding box.
2. Partition space into a grid whose cell count targets the desired vertex
count (derived from *ratio*).
3. Merge all vertices falling into the same cell — average their positions,
normals, and UVs.
4. Re-index triangles using the merged vertex IDs; discard degenerate
triangles (all three corners in the same cell).
"""
if ratio >= 1.0:
return mesh
positions = mesh.positions
n_verts = len(positions)
target_verts = max(4, int(n_verts * ratio))
# Bounding box with small epsilon to avoid zero-size axes
bb_min = positions.min(axis=0)
bb_max = positions.max(axis=0)
extent = bb_max - bb_min
extent = np.maximum(extent, 1e-8)
# Grid resolution — cube root of target vertex count gives a uniform grid
# that yields roughly the desired number of non-empty cells.
grid_res = max(2, int(np.cbrt(target_verts) + 0.5))
# Map each vertex to a grid cell
normalised = (positions - bb_min) / extent # [0, 1]
cell_coords = np.clip((normalised * grid_res).astype(np.int32), 0, grid_res - 1)
cell_ids = cell_coords[:, 0] * grid_res * grid_res + cell_coords[:, 1] * grid_res + cell_coords[:, 2]
# Cluster: average attributes per cell
unique_cells, inverse = np.unique(cell_ids, return_inverse=True)
n_clusters = len(unique_cells)
new_positions = np.zeros((n_clusters, 3), dtype=np.float32)
counts = np.zeros(n_clusters, dtype=np.float32)
np.add.at(new_positions, inverse, positions)
np.add.at(counts, inverse, 1.0)
new_positions /= counts[:, None]
new_normals = None
if mesh.normals is not None:
new_normals = np.zeros((n_clusters, 3), dtype=np.float32)
np.add.at(new_normals, inverse, mesh.normals)
lengths = np.linalg.norm(new_normals, axis=1, keepdims=True)
np.divide(new_normals, lengths, where=lengths > 1e-10, out=new_normals)
new_texcoords = None
if mesh.texcoords is not None:
new_texcoords = np.zeros((n_clusters, 2), dtype=np.float32)
np.add.at(new_texcoords, inverse, mesh.texcoords)
new_texcoords /= counts[:, None]
# Re-index triangles
if mesh.indices is not None:
old_tris = mesh.indices.reshape(-1, 3)
else:
old_tris = np.arange(n_verts, dtype=np.uint32).reshape(-1, 3)
new_tri_indices = inverse[old_tris] # (T, 3) mapped to cluster IDs
# Remove degenerate triangles (where 2+ corners map to the same cluster)
non_degenerate = (
(new_tri_indices[:, 0] != new_tri_indices[:, 1])
& (new_tri_indices[:, 1] != new_tri_indices[:, 2])
& (new_tri_indices[:, 0] != new_tri_indices[:, 2])
)
new_tri_indices = new_tri_indices[non_degenerate]
new_indices = new_tri_indices.ravel().astype(np.uint32) if len(new_tri_indices) > 0 else None
result = Mesh(new_positions, new_indices, new_normals, new_texcoords, topology=mesh.topology)
return result
[docs]
def generate_lods(
mesh: Mesh,
levels: int = 3,
ratios: list[float] | None = None,
distances: list[float] | None = None,
) -> MeshLOD:
"""Generate a ``MeshLOD`` from *mesh* by decimating at each ratio.
Args:
mesh: Source mesh (highest quality).
levels: Number of LOD levels to produce.
ratios: Per-level decimation ratios (descending), e.g. ``[1.0, 0.5, 0.25]``.
Defaults to evenly spaced ratios from 1.0 down to ``1/levels``.
distances: Per-level max-distance thresholds. Defaults to
``[10, 25, inf]`` style geometric progression.
Returns:
A ``MeshLOD`` containing the requested levels.
"""
if ratios is None:
ratios = [1.0 - i * (1.0 / levels) + (1.0 / levels) for i in range(levels)]
# e.g. levels=3 -> [1.0, 0.667, 0.333]
ratios = [1.0 / (i + 1) * levels / levels for i in range(levels)]
ratios = [max(1.0 - i / levels, 1.0 / levels) for i in range(levels)]
if distances is None:
# Default geometric progression: 10, 25, inf
distances = []
for i in range(levels - 1):
distances.append(10.0 * (2.5 ** i))
distances.append(float("inf"))
if len(ratios) != levels:
raise ValueError(f"Expected {levels} ratios, got {len(ratios)}")
if len(distances) != levels:
raise ValueError(f"Expected {levels} distances, got {len(distances)}")
lod_levels: list[LODLevel] = []
for ratio, dist in zip(ratios, distances, strict=True):
decimated = _decimate_mesh(mesh, ratio)
lod_levels.append(LODLevel(mesh=decimated, ratio=ratio, max_distance=dist))
return MeshLOD(lod_levels)