Skip to main content

TierConfig

Struct TierConfig 

Source
pub struct TierConfig {
    pub tier: Tier,
    pub post_cap: (u32, u32),
    pub bloom_levels: u32,
    pub attractor_particles: u32,
    pub attractor_particles_live_ceiling: u32,
    pub attractor_particles_offline_ceiling: u32,
    pub attractor_trail_cap: (u32, u32),
    pub swarm_particles: usize,
    pub emitter_objects: usize,
    pub mesh_grid: (u32, u32),
    pub max_segments: usize,
    pub collage_elements: usize,
}
Expand description

The capacity values a tier sets. Resolved once at renderer construction and read at construction/reconfigure time only — never branched on per frame.

Fields§

§tier: Tier

Which tier these values are, so a demotion and the overlay have one thing to read rather than a parallel field to keep in step.

§post_cap: (u32, u32)

Cap on a post stage’s internal grid (ADR-0034), width then height.

The floor value is NFR §12 memory arithmetic, redone for the linear-light composite (Plan 0045 Phase 3 / ADR-0046). Every intermediate upstream of the tonemap is now COMPOSITE_FORMAT — 8 bytes/texel, not 4 — so a stage offscreen costs twice what the surface format would charge, while the trails accumulation (PingPongField, two textures) was already float and did not move.

Per chain, both stages live, at this cap (1920x1080, 8 bytes/texel = 16.6 MB a texture):

bufferbeforeafter
trails composited8.316.6
trails accumulation (x2)33.233.2
kaleidoscope source8.316.6
per chain5066

Plan 0023’s dual-live dissolve holds two whole PostChains, so the peak is ~133 MB rather than ~100. Outside the chains the frame carries one more surface-sized float buffer beyond the chains — the tonemap’s input, 16.6 MB, the one allocation ADR-0046 genuinely adds — plus the blend’s snapshot/live pair at 16.6 MB each while a dissolve runs (8.3 at 8-bit), and ink’s 8.3 MB input, which stays 8-bit because the tonemap hands it display-referred pixels. Worst case — dual-live, both stages, ink on — is ~191 MB against NFR §12’s ~350 MB soft ceiling, which is mostly driver floor already.

At the rich cap (2560x1440) the same arithmetic is ~118 MB per chain and ~236 MB dual-live, up from ~88 and ~177 — the trade ADR-0034 priced and declined at floor budgets and the rich tier takes.

This cap is the relief lever if the float chain misses NFR §1 on a floor-tier iGPU: lower it rather than re-fixing the grids (ADR-0046), since bandwidth roughly doubled with the format and the grid policy is shared.

Bloom adds to this only when a preset switches it on (Plan 0045 Phase 4), and it is two allocations, not one. Its pyramid is two textures per level, each level a quarter of the last, so the pyramid converges to 2 * (1/4 + 1/16 + …) ≈ 2/3 of one grid-sized texture — ~11 MB at this cap. On top of that the stage owns its own grid-sized bloom-src offscreen, a full 16.6 MB at this cap, because a PostStage reads its input from a texture it owns. So the stage costs 16.6 + ~11 ≈ 28 MB on top of the ~66 MB per chain above, and ~55 MB in the dual-live worst case — which is what NFR §12’s table charges and what the ~246 MB worst case there is computed from. It is charged only against presets that bind bloom_amount, since an inactive stage builds nothing.

§bloom_levels: u32

How many levels deep the bloom pyramid goes (Bloom, ADR-0046).

This is a capacity, not a look: each level doubles the halo’s reach and costs three passes at a quarter of the previous level’s area, so the tail is cheap in pixels and not free in passes. The floor runs four (a halo reaching ~16 of the grid’s texels at the default radius); rich runs six, which is where the widest levels start to matter on a 1440p-class grid.

level_sizes clamps this down on a small render target, so a value here is an upper bound rather than a promise.

§attractor_particles: u32

The attractor’s sample budget at REFERENCE_PX — the anchor of the density law, not the count drawn (ADR-0140).

attractor_budget scales this by target_px / REFERENCE_PX and clamps it between this value and one of the two ceilings below, so a target at or under the reference draws exactly this many and a larger one draws more. What a preset then draws out of that budget is round(budget * density) (ADR-0069), which is a different and smaller number again.

State is 48 bytes each (Particle), and the real ceiling is additive-blend fill rate, which is why the floor value was described as the number to validate against the 60 fps @ 1080p floor (ADR-0015 Risks).

This is a sample count and not a brightness: the additive draw divides its deposit by the active count (deposit_scale, ADR-0065), so raising it buys a smoother figure rather than a brighter one. Changing it changes shot noise and cost; it does not change exposure.

§attractor_particles_live_ceiling: u32

The largest budget attractor_budget may resolve for a scene drawing into a live surface — a window, or the plugin’s host surface.

Frame-time bound, and it is the number that keeps the law from spending a display’s whole budget on sample count. It is also the allocation: the particle buffer is sized here at construction and never resized, so a resize changes the active count and rebuilds no GPU resource. That costs ceiling * 48 B of GPU storage plus the same again for the CPU seed scatter the scene holds for re-upload, in every window, whether or not the target is large and whether or not an attractor preset is loaded (create_all builds every scene up front).

§Where these numbers come from

Measured, not chosen — Plan 0128 Phase 1, at 1920x1080 on attractor_leviathan, counts interleaved in one process so a throttling laptop GPU could not read as signal.

Rich: 600 000, four times the anchor. On the midrange-discrete reference NFR §1 calibrates Rich against, the rule was the largest swept count whose marginal p99 over today’s anchor stays inside 10 % of the 16.67 ms budget: 600 000 reads +1.454 ms (8.7 %) and the next step up, 1 200 000, reads +4.703 ms (28.2 %).

Floor: its own anchor, so the law is a no-op there. On integrated hardware — the baseline NFR §1’s floor commitment is about — 1080p at Floor already sits on the 16.67 ms budget at today’s 50 000 (p99 16.854 ms), and the law’s own 1080p Floor value of 450 000 takes it to 31.942 ms. NFR §1 promises Floor “values exactly the pre-tier engine’s”; this is what keeps that true at every target size.

§attractor_particles_offline_ceiling: u32

The same ceiling for a headless rendershot --render, where there is no present deadline and no governor, and the only bound is memory.

§Where these numbers come from

The bound is the device’s storage-buffer binding limit, not process memory, because it is reached first: at 48 B a particle, wgpu’s default max_storage_buffer_binding_size of 134 217 728 B holds 2 796 202 particles, and 5 400 000 — the law’s own unclamped 4K value — fails outright with Buffer binding 0 range 259200000 exceeds max_*_buffer_binding_size limit 134217728 (Plan 0128 Phase 1).

Rich takes 2 700 000, the largest whole multiple of its anchor under that wall — 18x, 129.6 MB, 3.4 % of headroom. Floor takes the same multiple rather than the same number, so a tier still means something offline: at one shared ceiling --tier floor --render and --tier rich --render would draw an identical count at 4K.

§attractor_trail_cap: (u32, u32)

Upper bound on each axis of the attractor’s trail accumulation grid.

The floor is the ceiling Plan 0027/0029 chose for a high-DPI display while keeping the worst case bounded on the iGPU: every frame pays a decay pass plus the full additive instance draw over this grid, so cost scales with its area. Rich lifts it to 4K so a 4K or ultrawide display sizes near 1:1 instead of degrading to a uniform upscale.

§swarm_particles: usize

How many particles the swarm simulates.

Plan 0043 left the floor value at an unmeasured iGPU cost (+0.5 ms per frame of depth math on the dev box, and not fill rate), which is why the plans index calls this a live tier candidate rather than a settled constant. If the on-device floor check misses, this is the lever — on the floor value, and that routes back through architect.

§emitter_objects: usize

How many objects the emitter’s pool holds (ADR-0057).

Unlike every other count here this is a ceiling on a varying population, not the population: the emitter spawns and retires, so a preset’s spawn_rate * lifetime decides how many objects are actually alive and this decides how many can be. Spawns past it are dropped rather than queued or allocated for — that is the phase’s whole real-time hazard — so raising it does not brighten a preset that never reaches it, and lowering it below one that does thins the shower rather than changing its motion.

Not an accumulating count in the sense the module docs warn about: each object is one sprite drawn once per frame, so the light in the frame is population * brightness and the population is a preset’s own arithmetic. The tier only says where that arithmetic is cut off.

The floor holds a shipped preset’s shower with room to spare (the emitter family runs a few hundred objects live); rich triples it for the denser looks a discrete GPU can carry. Cheap either way — see NFR §12: the pool and its instance buffer are well under a megabyte at both tiers.

§mesh_grid: (u32, u32)

Ceiling on the warp mesh’s grid, in cells, width then height (Plan 0100 Phase 1).

A capacity in the strictest sense: the grid is a resolution for the per-vertex program, not a shape (ADR-0037), so raising it refines the warp’s spatial detail and changes nothing about what the scene draws. The vertex count is (x + 1) * (y + 1), and every one of those vertices costs one evaluation of each [per_vertex] binding on the render thread, which is what this bounds.

The upper bound either tier may name is the .milk format’s own — meshx <= 128, meshy <= 96 — so a converted preset’s requested grid is representable at the top of the range and clamped below it. warp_mesh::clamp_grid is the one place the clamp happens, shared by the loader and the scene.

§Where these numbers come from

Measured, not chosen — Plan 0100 Phase 1’s done-when. The rule it set was: raise the grid until one frame of per-vertex evaluation costs more than 1 ms — 6 % of the 16.67 ms NFR §1 commits to at 1080p — and cap the floor one step below.

mesh_cost_by_grid in scenes/warp_mesh/tests.rs is the measurement and prints the ladder on every run. Taken 2026-08-16 on the development box (Windows 10, desktop CPU, --release), evaluating a four-binding [per_vertex] program of the shape a real preset writes — two runs, agreeing to about 1 %:

 grid      vertices    per frame     share of 16.67 ms
 16x12        221       0.036 ms      0.2 %
 32x24        825       0.129 ms      0.8 %
 48x36      1 813       0.280 ms      1.7 %
 64x48      3 185       0.488 ms      2.9 %   <- Floor
 72x54      4 015       0.616 ms      3.7 %
 80x60      4 941       0.755 ms      4.5 %
 88x66      5 963       0.909 ms      5.5 %   <- Rich
 96x72      7 081       1.081 ms      6.5 %   <- the bar is crossed here
112x84      9 605       1.483 ms      8.9 %
128x96     12 513       1.918 ms     11.5 %

The bar is crossed between 88x66 and 96x72, so 88x66 is the largest grid the rule admits and it is what Rich takes. The format’s own maximum is therefore refused: at 1.92 ms it is 11.5 % of the frame on a desktop CPU, which is not a number any tier should spend on one parameter surface. The grid is lowered because it did not measure clean, which is the whole of the rule.

Floor sits a step further down than the rule alone would put it, and deliberately. The rig above is a desktop CPU; NFR §1’s floor tier targets a ~2015 iGPU-class machine whose single-thread performance this box does not model, and this is CPU work on the render thread, so a slower machine pays proportionally more of a budget it is already struggling to hold. 64x48 is 2.9 % here and leaves room for that machine to be several times slower before the surface is a problem.

When the floor tier is next exercised on real target hardware this is the constant to re-measure, and the ladder prints exactly what that needs.

§max_segments: usize

The one capacity value here that a preset can see: past it geometry is truncated, and ADR-0007 requires that be surfaced rather than silently cut. So a preset whose mirror pushes over the floor cap reports an overflow at the floor and not at rich — the message is the tier’s most visible edge for the content lane, which is why shipped presets are authored against the floor.

§collage_elements: usize

Cap on how many flat elements a shape_collage canvas may hold (ADR-0123).

The one capacity here that bounds a per-pixel loop, which is what makes it load-bearing rather than a memory number. Every other count in this struct bounds work paid once per particle, per vertex or per segment; this one bounds work every fragment pays, so a frame costs elements x pixels and ADR-0123 prices the bounding-box reject alone — before anything is drawn — at roughly 6N operations per pixel. The buffer is irrelevant at any value either tier would take: 64 bytes an element, so 128 elements is 8 KB.

§Where the floor value comes from

Measured, then decided by a human — Plan 0113 Phases 2 and 3. core/tests/collage_cost.rs sweeps the count on hardware, prints the ladder on every run, and its module docs own the readings and the trap in quoting them. Two tables are not interchangeable: the pre-roster ladder is what the Phase 3 gate read, and the post-roster table is what a canvas costs today — the eight-kind roster made the loop cheaper, because rings, sectors and checker patches shade far less of their own bounding box than a quad does.

40 is the reference set’s own top, not a budget line. The gate was a look judgement and the cost was not the binding constraint: the user’s working density is 8 to 14 elements, which on the system as shipped costs 8.2 % of a 60 Hz frame at eight and 10.7 % at sixteen, and denser canvases were rejected on sight long before they were rejected on cost. The ceiling is the densest canvas in ADR-0123’s roster, Kandinsky’s On White II, counted at just above 40 once its lines and arcs are included — so this value sits exactly on that canvas, and a collage_onwhite needing a forty-first element moves this number rather than being quietly truncated.

Rich is provisional in the sense every RICH value is — see that constant’s own note.

§It clamps, and it does not yet say so

shape_collage::applied_count holds a bound count to this value silently, unlike max_segments, which ADR-0007 requires surface an overflow. That was harmless while the cap sat far above any authored canvas and is not harmless now that it sits on one. Recorded as a followup on Plan 0113 rather than fixed there: the surfaced channel is CapOverflow, whose context enum is shared with the line scenes, so widening it is an architect call.

Implementations§

Source§

impl TierConfig

Source

pub const FLOOR: Self

The iGPU floor: the pre-tier engine’s constants, unchanged.

Source

pub const RICH: Self

The midrange-discrete tier.

These are provisional multipliers, not measurements. Plan 0044 Phase 4 runs the standalone pinned here on the target GPU at native fullscreen across the heaviest preset of each family and records the frame times; the values that ship are the ones that hold the display rate. A number that misses gets lowered, and no number here is invented upward to look good. Until that phase closes, treat every field below as a starting point.

Source

pub const fn for_tier(tier: Tier) -> Self

The config for tier.

Source§

impl TierConfig

Source

pub fn attractor_budget_live(&self, target_px: u32) -> u32

attractor_budget against this tier’s live ceiling — what a window and the plugin’s host surface resolve.

Source

pub fn attractor_budget_offline(&self, target_px: u32) -> u32

attractor_budget against this tier’s offline ceiling — what a headless render resolves, where the bound is memory rather than frame time.

Trait Implementations§

Source§

impl Clone for TierConfig

Source§

fn clone(&self) -> TierConfig

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for TierConfig

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for TierConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl PartialEq for TierConfig

Source§

fn eq(&self, other: &TierConfig) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, and should not be overridden without very good reason.
Source§

impl Copy for TierConfig

Source§

impl Eq for TierConfig

Source§

impl StructuralPartialEq for TierConfig

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

§

impl<T> WasmNotSend for T
where T: Send,

§

impl<T> WasmNotSendSync for T
where T: WasmNotSend + WasmNotSync,

§

impl<T> WasmNotSync for T
where T: Sync,