Source code for simvx.graphics.render2d.item_list

"""The render Item as a flat struct-of-arrays (design §2.1, §2.4 design D-A).

A render Item is the unit produced by the collection pass and consumed by
sort/batch/submit. It is **not** baked geometry; it is a *reference* (geometry +
transform + texture handles) plus a *key* (``(layer, z, seq)``). This module
stores the items as parallel typed numpy columns -- the SoA layout the design
mandates because the columns are GPU-uploadable, so later GPU-side sort/batch
land additively on the same data (§2.4 "GPU-future-proof").

Nothing here walks a scene tree, touches the GPU, or imports the live render
path; that is the point of increment 1. The ItemBuilder walk seam, retention,
and submit arrive in later increments and consume these columns.

Column layout
-------------
=============  ============  ====================================================
column         dtype         meaning (design §2.1)
=============  ============  ====================================================
``layer``      ``int16``     ordering band (CanvasLayer / coarse band)
``z``          ``int32``     ``absolute_z_index`` within the band
``seq``        ``uint32``    structure-derived, frame-stable order key (§2.2)
``pipeline``   ``uint8``     :class:`PipelineKind` {FILL, LINE, GLYPH, TEXTURED}
``clip_scope`` ``int32``     index into the :class:`ClipScopeTable` (§2.5)
``blend``      ``uint8``     :class:`BlendMode` {alpha, add, multiply}
``transform``  ``int32``     row index into the per-item LOCAL transform column
``geometry``   ``int32``     handle into the retained geometry store
``texture``    ``int32``     bindless texture slot (-1 = none)
``flags``      ``uint8``     :class:`ItemFlags` bitfield (is_msdf, lit, ...)
``item_id``    ``int64``     stable retention key across frames
=============  ============  ====================================================

The global draw-order sort is ``(layer, seq)`` -- **NOT** ``(layer, z, seq)``
(design §2.2 / P0 gate 9). ``z`` is inherited (``absolute_z_index``), so a
global lexsort on it reproduces the walk only 12/300 trials: a child must draw
adjacent to its parent regardless of how its inherited ``z`` compares to other
subtrees. The fix the design mandates is that the *walk* folds per-sibling-group
local-z into ``seq`` (it emits siblings in resolved-z order, assigning monotonic
``seq``), so the global sort is "``seq`` alone" within a band. The ``z`` column
is retained as data (banding, the z-dirty re-walk decision, debugging), never as
a global sort input. ``(layer, seq)`` is packed into one ``uint64`` so the sort
is a single vectorised ``argsort``.
"""

from __future__ import annotations

from collections.abc import Iterator
from enum import IntEnum, IntFlag
from typing import NamedTuple

import numpy as np

__all__ = [
    "BlendMode",
    "ItemFlags",
    "ItemList",
    "ItemView",
    "PipelineKind",
    "pack_sort_key",
    "unpack_sort_key",
]


[docs] class PipelineKind(IntEnum): """The pipeline class an item draws through (design §2.1). Compatible with the legacy ``draw2d_ops.OpKind`` values (FILL=0, LINE=1, GLYPH/TEXT=2, TEXTURED/TEX=3); the design renames TEXT->GLYPH and TEX->TEXTURED but keeps the integer codes so the two paths agree. """ FILL = 0 LINE = 1 GLYPH = 2 TEXTURED = 3
[docs] class BlendMode(IntEnum): """Per-item blend mode (design §2.1; legacy ``draw2d_ops.BLEND_MODES``).""" ALPHA = 0 ADD = 1 MULTIPLY = 2
[docs] class ItemFlags(IntFlag): """The ``flags`` column bitfield (design §2.1, §7.4/§7.5).""" NONE = 0 IS_MSDF = 1 << 0 LIT = 1 << 1 SCREEN_SPACE = 1 << 2 # HDR-lane override (N1, 2D-in-HDR). By default a world-space item (SCREEN_SPACE # clear) renders into the HDR target before tonemap when post-processing is on, # and a screen-space item stays post-tonemap LDR. These two bits force a node # explicitly into / out of the HDR lane regardless of its screen-space role # (the ``Drawable2D.hdr`` override: True -> HDR_OPT_IN, False -> HDR_OPT_OUT). HDR_OPT_IN = 1 << 3 HDR_OPT_OUT = 1 << 4
# --------------------------------------------------------------------------- # sort_key packing (design §2.2 / §2.4) # # The global draw-order key is (layer, seq) -- NOT (layer, z, seq). z is # inherited (absolute_z_index), so a global lexsort on it reproduces the walk # only 12/300 trials (P0 gate 9): a child must draw adjacent to its parent. The # walk therefore folds per-sibling-group local-z into seq (siblings emitted in # resolved-z order), so the global sort is "seq alone" within a band. Pack layer # (16 bits, biased so negative layers sort first) into the high bits and seq (48 # bits) into the low bits; unsigned ascending order then equals lexicographic # ascending (layer, seq) -- back-to-front. The z column is kept as data (banding # / the z-dirty re-walk decision / debugging), never as a global sort input. # --------------------------------------------------------------------------- _LAYER_BITS = 16 _SEQ_BITS = 48 _LAYER_BIAS = 1 << (_LAYER_BITS - 1) # int16 -> uint16 _LAYER_MASK = (1 << _LAYER_BITS) - 1 _SEQ_MASK = (1 << _SEQ_BITS) - 1 # The seq column is uint32, comfortably below the 48-bit packed ceiling; the # constant is exported so the later walk can renumber/guard against aliasing. SEQ_MAX = (1 << 32) - 1
[docs] def pack_sort_key(layer: np.ndarray, seq: np.ndarray) -> np.ndarray: """Pack ``(layer, seq)`` columns into one order-preserving ``uint64``. Ascending unsigned order of the result equals ascending lexicographic ``(layer, seq)`` -- the §2.2 back-to-front ordering law with z already folded into seq by the walk. ``layer`` is biased into an unsigned range so negative layers sort first. """ layer_u = ((layer.astype(np.int64) + _LAYER_BIAS) & _LAYER_MASK).astype(np.uint64) seq_u = (seq.astype(np.int64) & _SEQ_MASK).astype(np.uint64) return (layer_u << np.uint64(_SEQ_BITS)) | seq_u
[docs] def unpack_sort_key(key: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Inverse of :func:`pack_sort_key` -> ``(layer, seq)`` columns.""" key = np.asarray(key, dtype=np.uint64) seq = (key & np.uint64(_SEQ_MASK)).astype(np.uint32) layer = ((key >> np.uint64(_SEQ_BITS)) & np.uint64(_LAYER_MASK)).astype(np.int64) - _LAYER_BIAS return layer.astype(np.int16), seq
[docs] class ItemView(NamedTuple): """A single render Item read back out of the SoA columns (design §2.1).""" layer: int z: int seq: int pipeline: PipelineKind clip_scope: int blend: BlendMode transform: int geometry: int texture: int flags: ItemFlags item_id: int
# Parallel column spec: (name, dtype). The order is the canonical SoA layout. _COLUMNS: tuple[tuple[str, np.dtype], ...] = ( ("layer", np.dtype(np.int16)), ("z", np.dtype(np.int32)), ("seq", np.dtype(np.uint32)), ("pipeline", np.dtype(np.uint8)), ("clip_scope", np.dtype(np.int32)), ("blend", np.dtype(np.uint8)), ("transform", np.dtype(np.int32)), ("geometry", np.dtype(np.int32)), ("texture", np.dtype(np.int32)), ("flags", np.dtype(np.uint8)), ("item_id", np.dtype(np.int64)), )
[docs] class ItemList: """A growable flat struct-of-arrays of render Items (design §2.1, §2.4). Stores each Item field as its own typed numpy column, all sharing one capacity. Provides :meth:`add`/:meth:`remove`/iteration and :meth:`sorted_order` -- the vectorised stable sort into draw order. The columns grow geometrically (design §2.7 arena policy, applied here to the item array itself), never silently truncating like the legacy fixed ``MAX_TEX_VERTS`` buffers. This is a *data structure*, not a renderer: it neither walks a tree nor issues GPU work. Items are added with explicit references/keys; producing those references is the later ItemBuilder's job. """ __slots__ = ("_cols", "_count", "_capacity") def __init__(self, capacity: int = 64) -> None: capacity = max(1, int(capacity)) self._capacity = capacity self._count = 0 self._cols: dict[str, np.ndarray] = {name: np.empty(capacity, dtype=dt) for name, dt in _COLUMNS} # -- size / capacity -----------------------------------------------------
[docs] def __len__(self) -> int: return self._count
[docs] @property def capacity(self) -> int: return self._capacity
[docs] def clear(self) -> None: """Drop all items (keeps allocated capacity for reuse).""" self._count = 0
def _grow(self, needed: int) -> None: """Geometric growth so amortised append is O(1) (design §2.7).""" new_cap = self._capacity while new_cap < needed: new_cap *= 2 for name, dt in _COLUMNS: grown = np.empty(new_cap, dtype=dt) grown[: self._count] = self._cols[name][: self._count] self._cols[name] = grown self._capacity = new_cap # -- column access -------------------------------------------------------
[docs] def column(self, name: str) -> np.ndarray: """Return a view of the live (length-``len(self)``) slice of a column.""" return self._cols[name][: self._count]
# -- mutation ------------------------------------------------------------
[docs] def add( self, *, layer: int, z: int, seq: int, pipeline: PipelineKind | int, clip_scope: int, blend: BlendMode | int = BlendMode.ALPHA, transform: int = -1, geometry: int = -1, texture: int = -1, flags: ItemFlags | int = ItemFlags.NONE, item_id: int, ) -> int: """Append one Item; return its current row index. The row index is positional and unstable across :meth:`remove`; use ``item_id`` for the cross-frame stable identity (design §2.1). """ i = self._count if i >= self._capacity: self._grow(i + 1) c = self._cols c["layer"][i] = layer c["z"][i] = z c["seq"][i] = seq c["pipeline"][i] = int(pipeline) c["clip_scope"][i] = clip_scope c["blend"][i] = int(blend) c["transform"][i] = transform c["geometry"][i] = geometry c["texture"][i] = texture c["flags"][i] = int(flags) c["item_id"][i] = item_id self._count = i + 1 return i
[docs] def maybe_grow(self, needed: int) -> bool: """Ensure capacity for ``needed`` items, growing geometrically if short. The explicit arena-growth entry point (design §2.7): the item columns are a growable arena that doubles at a frame boundary, NEVER the legacy fixed ``MAX_TEX_VERTS`` cap that silently truncated (>59% of a 10k-sprite scene vanished, P0 gate 6). Returns ``True`` if a grow happened. :meth:`add` already grows on demand; this lets a collector reserve up front (one realloc for a known batch instead of log2(N) doublings). """ if needed <= self._capacity: return False self._grow(needed) return True
[docs] def set_appearance( self, index: int, *, pipeline: PipelineKind | int, blend: BlendMode | int, texture: int, flags: ItemFlags | int, ) -> None: """Overwrite the appearance columns of one row in place (P2 item-level patch). Rewrites only what a colour/texture/blend/msdf change can affect (``pipeline``/``blend``/``texture``/``flags``); ``geometry``/``transform``/ ``seq``/``layer``/``z``/``item_id`` stay put so the row's stable retention identity is preserved (design §2.7 item granularity). Colour lives in the geometry verts, refreshed alongside via the geometry store. """ if not 0 <= index < self._count: raise IndexError(f"item index {index} out of range (count={self._count})") c = self._cols c["pipeline"][index] = int(pipeline) c["blend"][index] = int(blend) c["texture"][index] = int(texture) c["flags"][index] = int(flags)
[docs] def remove(self, index: int) -> None: """Remove the item at ``index`` (swap-with-last; order is not preserved). Positional removal does not preserve draw order -- callers re-derive order via :meth:`sorted_order` from ``(layer, z, seq)``, so the physical column order is free to be a swap-remove for O(1) deletion. """ n = self._count if not 0 <= index < n: raise IndexError(f"item index {index} out of range (count={n})") last = n - 1 if index != last: for name in self._cols: self._cols[name][index] = self._cols[name][last] self._count = last
# -- read-back -----------------------------------------------------------
[docs] def get(self, index: int) -> ItemView: """Return the Item at the given physical row as an :class:`ItemView`.""" n = self._count if not 0 <= index < n: raise IndexError(f"item index {index} out of range (count={n})") c = self._cols return ItemView( layer=int(c["layer"][index]), z=int(c["z"][index]), seq=int(c["seq"][index]), pipeline=PipelineKind(int(c["pipeline"][index])), clip_scope=int(c["clip_scope"][index]), blend=BlendMode(int(c["blend"][index])), transform=int(c["transform"][index]), geometry=int(c["geometry"][index]), texture=int(c["texture"][index]), flags=ItemFlags(int(c["flags"][index])), item_id=int(c["item_id"][index]), )
[docs] def __iter__(self) -> Iterator[ItemView]: for i in range(self._count): yield self.get(i)
# -- sort (design §2.2, §2.4) --------------------------------------------
[docs] def sort_keys(self) -> np.ndarray: """Return the packed ``uint64`` ``(layer, seq)`` key per live item.""" return pack_sort_key(self.column("layer"), self.column("seq"))
[docs] def sorted_order(self) -> np.ndarray: """Return physical row indices in ascending ``(layer, seq)`` order. STABLE: ties on the ``(layer, seq)`` key keep insertion (= physical) order. ``seq`` is the structure-derived order key (design §2.2) into which the walk has folded per-sibling-group z, so this is the back-to-front draw order. The sort is **not** a global z lexsort (P0 gate 9). Vectorised: one ``argsort`` over the packed key. ``kind="stable"`` makes the tie policy explicit even though the packed key normally makes ties impossible (distinct items get distinct ``seq`` within a band). """ return np.argsort(self.sort_keys(), kind="stable").astype(np.int64)
[docs] def iter_sorted(self) -> Iterator[ItemView]: """Iterate items in materialised draw order (design §2.1 "draw order").""" for i in self.sorted_order(): yield self.get(int(i))