MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
MutationClaimProcessor.hpp
Go to the documentation of this file.
1#pragma once
2
4
6class PhysicsOperator;
7}
8
9namespace MayaFlux::Buffers {
10
11/**
12 * @struct MutationConfig
13 * @brief Parameters for the deterministic pairwise claim protocol built on
14 * top of a completed spatial hash.
15 *
16 * Composes SpatialHashConfig rather than duplicating its fields: the claim
17 * pass needs the same grid geometry and vertex layout the hash was built
18 * from, plus one addition, the distance under which one particle claims
19 * another.
20 */
23 float absorb_radius; ///< A claims B when length(pos(B) - pos(A)) < absorb_radius and index(A) < index(B).
24
25 /**
26 * @brief Declare the state fields the claim pipeline reads and writes.
27 * @param buffer Buffer to declare on. Already-present names are left
28 * untouched, matching NetworkGeometryBuffer::declare_state's own
29 * documented behaviour.
30 *
31 * Declares mutation_claimed_by and mutation_swallow_count at
32 * hash.particle_count elements each, single-slot: every cycle's claim
33 * pass starts from a fresh claimed_by[i] = i and swallow_count[i] = 0
34 * and overwrites the whole array, so there is nothing to carry across
35 * cycles in a second slot.
36 *
37 * Also declares mutation_claim_events, a single uint32 counter reset to
38 * 0 each cycle by ClaimInitProcessor and incremented by ClaimProcessor
39 * for every neighbour pair it finds within absorb_radius. Its sole
40 * purpose is letting ClaimAccumulateProcessor decide, with a cheap
41 * single-word readback, whether the full claimed_by/swallow_count
42 * arrays are worth downloading this cycle at all.
43 *
44 * Also declares mutation_accreted_mass, one float per particle. Unlike
45 * the fields above it is not reset each cycle: ClaimInitProcessor never
46 * touches it. ClaimAccumulateProcessor uploads PhysicsOperator's own
47 * accreted mass into it each cycle (see that class's own doc), and
48 * ClaimProcessor reads it back to scale capture_growth. swallow_count
49 * cannot serve this role because ClaimInitProcessor zeroes it before
50 * ClaimProcessor runs in the same cycle, so a read of swallow_count here
51 * would always see 0.
52 *
53 * Does not declare a cluster id field of its own: hash_cluster_id
54 * belongs to SpatialHashConfig::declare_fields, since it is shared with
55 * HashDensityColorProcessor and must exist even when mutation is never
56 * enabled at all.
57 */
58 void declare_fields(const std::shared_ptr<NetworkGeometryBuffer>& buffer) const;
59};
60
61/**
62 * @class ClaimInitProcessor
63 * @brief Resets mutation_claimed_by[i] to i and mutation_swallow_count[i]
64 * to 0 for every particle.
65 *
66 * Standard union-find initialisation: each particle starts as its own root,
67 * meaning unclaimed and having swallowed nothing. Must run before
68 * ClaimProcessor each cycle, the same "explicit clear stage" reasoning
69 * HashClearProcessor uses for hash_cell_count: ClaimProcessor only ever
70 * lowers claimed_by via atomicMin and ClaimAccumulateProcessor only ever
71 * raises swallow_count via atomicAdd, so a stale value from a previous
72 * cycle would never be corrected back the other way.
73 */
75public:
76 explicit ClaimInitProcessor(const MutationConfig& config);
77
78protected:
79 void on_buffer_ready() override;
80
81private:
82 struct Params {
84 };
85
87};
88
89/**
90 * @class ClaimProcessor
91 * @brief Deterministic pairwise claim over the completed spatial hash.
92 *
93 * One thread per particle i. Walks the cells within reach of i's own cell,
94 * where reach is 1 (the same 27-cell neighbourhood HashDensityColorProcessor
95 * uses) unless capture_growth (below) has grown i's effective capture radius
96 * past one cell_size, in which case reach grows to match: a fixed +/-1 reach
97 * would otherwise silently re-cap growth the moment a body's capture radius
98 * exceeds the grid's own cell size, defeating the point of capture_growth.
99 * For every neighbour j with j > i and length(pos(j) - pos(i)) < i's current
100 * capture radius, does atomicMin(claimed_by[j], i).
101 *
102 * atomicMin rather than atomicCompSwap: multiple particles below j's index
103 * may race to claim it in the same dispatch, and atomicMin converges to the
104 * smallest i regardless of which thread's atomic op lands first, which is
105 * what makes "lower index wins" a property of the result rather than of
106 * timing. Restricting to j > i is what keeps every claim pointing toward a
107 * strictly smaller index, which is what makes the chains ClaimFlattenProcessor
108 * resolves finite and acyclic.
109 *
110 * Must run after HashScatterProcessor (needs the completed hash) and
111 * ClaimInitProcessor (needs claimed_by reset) in the same cycle.
112 *
113 * When capture_growth is nonzero, the cube root of i's own accreted mass
114 * (mutation_accreted_mass, uploaded fresh each cycle by
115 * ClaimAccumulateProcessor from PhysicsOperator::get_accreted_mass_span; see
116 * that field's own doc for why this exists as a separate field rather than
117 * reusing swallow_count) scales its effective capture radius beyond
118 * absorb_radius, cube root specifically rather than mass directly so the
119 * growth rate stays proportional to mass (ordinary exponential growth)
120 * rather than to mass cubed (a finite-time singularity): see
121 * SpatialFieldConfig::capture_growth's own doc. Live-tunable via
122 * GpuFieldOperator::set_capture_growth: checked in on_before_execute
123 * the same way ClaimSwallowProcessor checks its own tuning values, so a
124 * change takes effect on this processor's next dispatch with no rebuild.
125 *
126 * A candidate j is skipped when hash_cluster_id[j] differs from i's own
127 * cluster_id, unless SpatialFieldConfig::cross_cluster is true: by default
128 * two particles from different PhysicsOperator collections never claim each
129 * other, however close they sit, so several independent populations can
130 * share one hash grid and one dispatch without their claim graphs bleeding
131 * into each other. Every particle carries cluster_id 0 unless the operator
132 * holds more than one collection (see SpatialHashConfig::declare_fields),
133 * so the guard is a no-op for the ordinary single-population case. Read
134 * fresh alongside capture_growth on every revision change.
135 */
136class MAYAFLUX_API ClaimProcessor : public NetworkStateFieldProcessor {
137public:
138 /**
139 * @param config Grid/particle parameters shared with the claim stages.
140 * @param particle_op Owning operator; only its capture_growth tuning
141 * value and revision() are read, live, in on_before_execute.
142 * @param gate_alive When true, binds mutation_alive and refuses to let
143 * a dead particle claim anything: a candidate j can never be
144 * dead (HashCountProcessor/HashScatterProcessor already exclude
145 * dead particles from every cell), but i itself still runs
146 * unless this guard skips it, and a dead claimant absorbing a
147 * living particle would be a scavenging corpse, not a fixed
148 * population. False (the default) emits exactly today's shader.
149 */
151 const MutationConfig& config,
152 std::shared_ptr<Nodes::Network::GpuFieldOperator> particle_op,
153 bool gate_alive = false);
154
155protected:
156 void on_buffer_ready() override;
157
158 /** @brief Re-sync capture_growth from particle_op when its revision changes. */
159 bool on_before_execute(
161 const std::shared_ptr<VKBuffer>& buffer) override;
162
163private:
164 struct Params {
166 uint32_t stride_words;
174 uint32_t dim_x;
175 uint32_t dim_y;
176 uint32_t dim_z;
178 };
179
181 std::shared_ptr<Nodes::Network::GpuFieldOperator> m_particle_op;
183};
184
185/**
186 * @class ClaimFlattenProcessor
187 * @brief Resolves absorption chains to their root via parallel pointer
188 * jumping.
189 *
190 * ClaimProcessor can leave chains: i' claims i, i claims j, so
191 * claimed_by[j] == i rather than i's own eventual root i'. One round of
192 * claimed_by[k] = claimed_by[claimed_by[k]] halves the distance from any
193 * element to its root; ceil(log2(particle_count)) rounds is always enough
194 * regardless of chain length, since no chain can exceed particle_count
195 * links. This is the standard parallel union-find flattening technique.
196 *
197 * Implemented as one kernel dispatched multiple times via
198 * ComputeProcessor's iteration mechanism (set_iteration_count), with
199 * on_iteration_barrier overridden to barrier mutation_claimed_by
200 * specifically: the default barriers the attached buffer's own vertex
201 * storage, which is the wrong resource here, since the read-after-write
202 * hazard is entirely on the state field between rounds.
203 *
204 * After this runs, claimed_by[i] == i means i survives; otherwise
205 * claimed_by[i] names the surviving particle i is absorbed into.
206 */
208public:
209 explicit ClaimFlattenProcessor(const MutationConfig& config);
210
211protected:
212 void on_buffer_ready() override;
213
214 void on_iteration_barrier(
216 const std::shared_ptr<VKBuffer>& buffer,
217 uint32_t index) override;
218
219private:
220 struct Params {
222 };
223
225};
226
227/**
228 * @class ClaimAccumulateProcessor
229 * @brief Counts how many particles each survivor swallowed this cycle.
230 *
231 * One thread per particle. An absorbed particle (claimed_by[i] != i) does
232 * atomicAdd(swallow_count[claimed_by[i]], 1u); a survivor does nothing.
233 * Deliberately a separate dispatch from ClaimSwallowProcessor rather than
234 * folded into it: ClaimSwallowProcessor's survivor branch needs to read the
235 * *final* count for its own index, and within a single dispatch there is no
236 * ordering guarantee that every absorbed particle's atomicAdd into that
237 * survivor's slot has landed before the survivor's own thread reads it,
238 * since a survivor and its absorbed particles can fall in different
239 * workgroups. Chaining this as its own processor makes the count
240 * fully-written before ClaimSwallowProcessor's dispatch begins, the same
241 * reasoning HashCountProcessor and HashScanProcessor are kept separate for.
242 *
243 * Must run after ClaimFlattenProcessor and before ClaimSwallowProcessor.
244 *
245 * Also the point where mutation_claimed_by is made available to the CPU
246 * simulation: after its own dispatch, processing_function reads back the
247 * single mutation_claim_events counter and, only if it is nonzero (meaning
248 * at least one particle pair was actually claimed this cycle), downloads
249 * the full claimed_by array and hands it to physics_op's bond table. A zero
250 * count clears any existing bonds instead, since the claim graph itself is
251 * fully rebuilt from scratch every cycle (see ClaimInitProcessor) and
252 * carries no memory of previous cycles either. This keeps the expensive
253 * readback conditional on the algorithm having found something to report,
254 * rather than paid every cycle regardless.
255 *
256 * Also uploads physics_op's own accreted mass (PhysicsOperator::get_accreted_mass_span)
257 * into mutation_accreted_mass every cycle, unconditionally: unlike the
258 * download above, this one is cheap regardless (one float per particle,
259 * already computed CPU-side) and ClaimProcessor needs a fresh copy every
260 * cycle to scale capture_growth, so there is no "nothing to report" case to
261 * gate it on.
262 */
264public:
265 /**
266 * @param config Grid/particle parameters shared with the claim stages.
267 * @param physics_op Non-owning; must outlive this processor. Receives
268 * claimed_by via sync_bonds_from_claims when claim_events is
269 * nonzero, clear_bonds() otherwise. May be null to disable the
270 * readback entirely (GPU claim/swallow still runs unaffected).
271 * @param live_count Nonzero enables population dynamics: binds
272 * mutation_alive and sets it to 0 for any particle absorbed this
273 * cycle (destroy-on-absorption), and the CPU-facing side of this
274 * processor is truncated to exactly this many entries, both on
275 * the claimed_by readback handed to physics_op and on the
276 * accreted_mass upload (padded with 0 beyond live_count). This is
277 * the one enforcement point for SpatialFieldConfig::reserve_fraction's
278 * decoupling: PhysicsOperator never learns config.hash.particle_count
279 * exceeds its own simulated particle count, regardless of how much
280 * reserve capacity the GPU has spawned into. Also binds vertices
281 * and zeros a destroyed particle's size, every cycle for as long
282 * as it stays dead: nothing else keeps it invisible against
283 * NetworkGeometryProcessor's own unconditional re-upload of
284 * PhysicsOperator's CPU-simulated (size-unaware) data each cycle.
285 * 0 (the default) disables all of this and matches today's
286 * behaviour exactly, using config.hash.particle_count throughout.
287 * @param particle_op Required (must be non-null) when live_count is
288 * nonzero: supplies the vertex layout's size attribute offset.
289 * Ignored when live_count is 0.
290 * @param transfer_on_claim When true, binds hash_cluster_id and treats a
291 * claim whose root sits in a different cluster as a transfer, not a
292 * consumption: that absorbed vertex is neither counted into its
293 * root's swallow_count nor (when live_count is nonzero) destroyed,
294 * leaving ClaimTransferProcessor to relabel it. False (the default)
295 * emits exactly today's shader, no cluster binding, no branch.
296 */
298 const MutationConfig& config,
300 uint32_t live_count = 0,
301 const std::shared_ptr<Nodes::Network::GpuFieldOperator>& particle_op = nullptr,
302 bool transfer_on_claim = false);
303
304protected:
305 void on_buffer_ready() override;
306
307 /** @brief Dispatch as usual, then conditionally read back the result. */
308 void processing_function(const std::shared_ptr<Buffer>& buffer) override;
309
310private:
311 struct Params {
313 uint32_t stride_words;
314 uint32_t size_offset;
315 };
316
319 uint32_t m_live_count;
320 std::vector<uint32_t> m_claimed_by_readback;
321 std::vector<float> m_accreted_mass_padded;
322 std::shared_ptr<VKBuffer> m_readback_staging;
323 std::shared_ptr<VKBuffer> m_upload_staging;
324};
325
326/**
327 * @class ClaimSwallowProcessor
328 * @brief Visually swallows absorbed particles into their survivor, which
329 * grows and heats up with how much it has swallowed.
330 *
331 * One thread per particle. Colour and size share one underlying quantity
332 * rather than being independent choices: size is base_size grown by
333 * swallow_count[root] (clamped at max_size), and colour is the same
334 * ember-to-white-hot ramp HashDensityColorProcessor uses, driven by how far
335 * that size sits between base_size and max_size. A cluster that has
336 * swallowed nothing stays base-size and ember-cool; one near the ceiling
337 * glows white-hot. This ties "how much has this cluster grown" to a single
338 * visible signal instead of an arbitrary per-cluster hue that carries no
339 * information about the cluster itself.
340 *
341 * A survivor (claimed_by[i] == i) keeps its own position and takes the heat
342 * colour at full brightness. An absorbed particle (claimed_by[i] != i) reads
343 * its root's current position directly out of the same vertex buffer and
344 * overwrites its own position with it, then takes its root's heat colour
345 * (computed from the root's own swallow_count, not its own) at a fraction of
346 * its brightness, so it still reads as "belongs to that cluster" while
347 * visibly dimmer than the survivor it vanished into.
348 *
349 * Safe to read another particle's position and swallow_count in the same
350 * dispatch that writes positions and colours: only an absorbed particle's
351 * own slot is ever written here, a root's position and swallow_count are
352 * written by nobody in this kernel (swallow_count was already finalised by
353 * ClaimAccumulateProcessor's own, earlier dispatch), so there is no
354 * read/write hazard between threads.
355 *
356 * Still no compaction: nothing is removed from the buffer and no mass
357 * actually transfers to the survivor, only its rendered size. Every
358 * absorbed particle collapses onto its root's position freshly each cycle,
359 * since ClaimInitProcessor resets the whole claim graph and PhysicsOperator
360 * keeps simulating every particle underneath this regardless of whether it
361 * was absorbed last cycle. The visible effect is a live, continuously
362 * re-evaluated swallow rather than a permanent one: particles that drift
363 * back out of absorb_radius reappear at their own simulated position and
364 * size next cycle instead of staying merged.
365 *
366 * Must run after ClaimAccumulateProcessor.
367 *
368 * Reads swallow_base_size/swallow_growth_rate/swallow_max_size/
369 * swallow_dim_factor from the owning GpuFieldOperator fresh whenever
370 * its revision() changes (checked in on_before_execute, the same hook
371 * VertexFieldProcessor::sync_revision() uses), so the matching setters on
372 * GpuFieldOperator take effect on this processor's next dispatch with
373 * no rebuild.
374 */
376public:
377 /**
378 * @param config Grid/particle parameters shared with the claim stages.
379 * @param particle_op Owning operator. Colour word offset is resolved
380 * from its vertex layout (DataModality::VERTEX_COLORS_RGB);
381 * size word offset by VertexAttributeLayout::name ("size", the
382 * field VertexFormats.hpp tags DataModality::UNKNOWN, so it
383 * isn't reachable through the modality-based lookup). Throws
384 * std::invalid_argument when either is missing or misaligned.
385 * @param transfer_on_claim When true, binds hash_cluster_id and leaves an
386 * absorbed vertex whose root is in a different cluster untouched
387 * (no position snap, no dim): it is defecting, not dying, and
388 * ClaimTransferProcessor relabels it. False (the default) emits
389 * today's shader.
390 */
392 const MutationConfig& config,
393 std::shared_ptr<Nodes::Network::GpuFieldOperator> particle_op,
394 bool transfer_on_claim = false);
395
396protected:
397 void on_buffer_ready() override;
398
399 bool on_before_execute(
401 const std::shared_ptr<VKBuffer>& buffer) override;
402
403private:
404 struct Params {
406 uint32_t stride_words;
408 uint32_t color_offset;
409 uint32_t size_offset;
412 float max_size;
414 };
415
417 std::shared_ptr<Nodes::Network::GpuFieldOperator> m_particle_op;
419};
420
421/**
422 * @class ClaimTransferProcessor
423 * @brief Relabels every vertex absorbed across a cluster boundary into its
424 * claimant's cluster: the "hop" / transfer that
425 * SpatialFieldConfig::transfer_on_claim enables.
426 *
427 * One thread per vertex. For an absorbed vertex (mutation_claimed_by[i] != i)
428 * whose flattened root sits in a different cluster, writes
429 * hash_cluster_id[i] = hash_cluster_id[root]. Survivors and same-cluster
430 * absorptions are left alone.
431 *
432 * Chained last in the claim group, after ClaimAccumulateProcessor and
433 * ClaimSwallowProcessor: those two each read hash_cluster_id to decide a
434 * defector is not theirs to consume or snap, and must see the pre-hop value
435 * to do so. Everything downstream (a cluster-scoped VertexFieldProcessor
436 * postprocessor, next cycle's ClaimProcessor and HashDensityColorProcessor)
437 * then reads the new membership.
438 *
439 * hash_cluster_id is single-slot and, unlike destroy-on-absorption, this
440 * write is not undone by anything: NetworkGeometryProcessor re-uploads the
441 * vertex record every cycle but never touches state fields, and
442 * ensure_cluster_ids()/build_cluster_ids() only run once at wiring time. So a
443 * hop persists with no per-cycle re-assertion, and accumulated hops are
444 * discarded only on a full reseed.
445 *
446 * No live tuning: whether this processor exists at all is
447 * SpatialFieldConfig::transfer_on_claim, fixed at construction.
448 */
450public:
451 explicit ClaimTransferProcessor(const MutationConfig& config);
452
453protected:
454 void on_buffer_ready() override;
455
456private:
457 struct Params {
459 };
460
462};
463
464} // namespace MayaFlux::Buffers
uint32_t index
Definition VKDevice.cpp:142
Counts how many particles each survivor swallowed this cycle.
Resolves absorption chains to their root via parallel pointer jumping.
Resets mutation_claimed_by[i] to i and mutation_swallow_count[i] to 0 for every particle.
std::shared_ptr< Nodes::Network::GpuFieldOperator > m_particle_op
Deterministic pairwise claim over the completed spatial hash.
std::shared_ptr< Nodes::Network::GpuFieldOperator > m_particle_op
Visually swallows absorbed particles into their survivor, which grows and heats up with how much it h...
Relabels every vertex absorbed across a cluster boundary into its claimant's cluster: the "hop" / tra...
ComputeProcessor operating on named state fields of a NetworkGeometryBuffer, plus optionally the buff...
N-body physics simulation with point rendering.
void declare_fields(const std::shared_ptr< NetworkGeometryBuffer > &buffer) const
Declare the state fields the claim pipeline reads and writes.
float absorb_radius
A claims B when length(pos(B) - pos(A)) < absorb_radius and index(A) < index(B).
Parameters for the deterministic pairwise claim protocol built on top of a completed spatial hash.
Uniform grid parameters shared by every stage of the hash build.