simvx.graphics.renderer.caster_slots

Which point and spot lights get an atlas row, and which row.

Backend-neutral: this is numpy over the shared LIGHT_DTYPE array both renderers build, with no GPU call in it, so the Vulkan shadow pass and the browser runtime apply one budget by one rule. It lives apart from

mod:

.shadow_renderer because that module imports vulkan at module level and the web bundle refuses such a module outright.

Module Contents

Classes

CasterRows

Which lights held which atlas rows at the previous selection, per kind.

Functions

assign_caster_slots

Give each shadow-casting point/spot light a caster slot, in params.w.

Data

API

simvx.graphics.renderer.caster_slots.__all__

[‘CASTER_HYSTERESIS’, ‘CasterRows’, ‘assign_caster_slots’]

simvx.graphics.renderer.caster_slots.CASTER_HYSTERESIS

0.1

class simvx.graphics.renderer.caster_slots.CasterRows

Which lights held which atlas rows at the previous selection, per kind.

Each array lists winning light indices in atlas-row order, so points[2] is the light that owned point caster row 2. Handing the same instance back to :func:assign_caster_slots every frame is what makes selection sticky: an incumbent is only unseated by a clearly stronger light (:data:CASTER_HYSTERESIS), and one that survives keeps the row it had – whether or not the set was over budget – so a light joining or leaving it does not move the rows of the lights around it. A budget that shrinks past a row re-seats its holder, who then queues like a newcomer. Without a carrier every call decides from scratch and rows follow array order.

Lights are identified by their index in the array the caller built, which is the only identity a light has here; anything that changes the array – adding or removing a light, hiding one, or editing a cull mask – shifts the later indices and forfeits one frame of stickiness.

points: numpy.ndarray | None

None

spots: numpy.ndarray | None

None

simvx.graphics.renderer.caster_slots.assign_caster_slots(lights: numpy.ndarray | None, budget: int, camera_view: numpy.ndarray | None = None, rows: simvx.graphics.renderer.caster_slots.CasterRows | None = None) tuple[int, int, int, int]

Give each shadow-casting point/spot light a caster slot, in params.w.

For a positional light params.w reads: 0 asked for no shadow, slot + 1 owns that atlas row, and -1 asked for one but did not fit the budget. The forward shader darkens a light with its OWN row and with no other, and the demoted marker keeps the request visible, so raising the budget later re-slots the same light. Directional lights are left alone: their params.w is the CSM opt-in flag, and there is one CSM.

A light whose range (or, for a spot, outer cone) is zero lights nothing and is not a candidate. When more lights of a kind qualify than budget can hold, the slots go to the highest shadow_priority (carried in direction.w), then to the lights with the strongest influence at the camera camera_view describes – intensity * range / distance, so a lamp is judged by what it lights and not only by how close it is. A light already holding a row in rows has its influence multiplied by 1 +

Data:

CASTER_HYSTERESIS first, so it is the sitting incumbent and not array order that takes a tie – unless the incumbent is worth nothing at all, which scaling cannot lift. Array order, the order the caller built the light array in, decides only what priority and influence leave equal. Without a camera there is no influence to measure and every candidate weighs the same, which leaves those same three keys: priority, then the incumbent, then array order. Returns (point_casters, spot_casters, point_candidates, spot_candidates) so the caller can report a budget that could not be honoured.

Pass the same :class:CasterRows as rows every frame to make selection sticky: it is read for the previous winners and rewritten with the new ones. Without it each call decides afresh, which is right for a one-shot caller and wrong for a render loop, where it would churn the caster set and the atlas rows as the camera drifts.

Written against whole columns rather than per light: within budget the column form costs a flat ~12 us, and a few microseconds more on the one frame the caster set changes and the rows are re-seated; once the ranking runs it grows slowly with the candidate count (~31 us at 8 candidates, ~57 us at 512, measured), where the per-light loop passes it at about six candidates and is some forty times slower at 512. The adapter rebuilds the light array and calls set_lights unconditionally, so a light-heavy scene pays this every frame; the ranking above is skipped entirely while the scene fits its budget, which is what a one-light scene always does.