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