-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsim.c
More file actions
2174 lines (1979 loc) · 106 KB
/
Copy pathsim.c
File metadata and controls
2174 lines (1979 loc) · 106 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Window-agnostic simulation core. See sim.h for the front-end contract.
// This translation unit emits the glad / dropt / inih single-header impls, so it
// must be linked into every binary exactly once (and the front-ends must not
// re-emit them).
#include "sim.h"
#include "sim_def.h"
#include <ctype.h> // toupper, for the per-sim --help section titles
#include <math.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define DROPT_IMPLEMENTATION // header-only dropt: this is the single TU that emits the impl
#include <dropt.h> // cli option parser (lib/dropt/)
#define INI_IMPLEMENTATION // header-only inih: this is the single TU that emits the impl
#include <ini.h> // inih: .ini config parser (lib/ini.h)
#include <limits.h> // INT_MAX/INT_MIN for parse_int range check
#include <string.h>
#include <strings.h> // strcasecmp
#include "default_params.h" // generated by nob.c from default-params: DEFAULT_PARAMS[]
// Declarations only (no NOB_IMPLEMENTATION): this is for nob_da_append on the GL-name pools.
// The nob_ prefix is kept -- NOB_STRIP_PREFIX would drop Cmd/String_Builder/temp_sprintf/shift
// and friends into this file's namespace.
// Small initial capacity (vs nob's 256) so the pools genuinely realloc while claiming a
// handful of slots. Buffers point at the pool structs rather than into their items precisely
// so that is safe -- at 256 the growth path would never run and the guarantee would rot untested.
#define NOB_DA_INIT_CAP 4
#include <nob.h>
#define GLAD_GL_IMPLEMENTATION // header-only glad: this is the single TU that emits the impl
#include <gl.h>
#include <cglm/cglm.h>
#include <cglm/noise.h>
// width/height are the INTERNAL render (sim) resolution -- logical points divided
// by render_scale. Everything (physics-independent visual passes, density/trail
// buffers, window_shape) is computed against this, then nearest-upscaled to the
// actual framebuffer (window_width/height). This decouples fill cost from device
// DPI (retina renders at 1x) and gives a single perf/quality knob via render_scale.
int width = 0;
int height = 0;
int window_width = 800; // actual framebuffer size in device px; the upscale target
int window_height = 600;
// upscale source-crop (uv), pushed by sim_present. Identity = straight upscale, the
// default for every front-end; macwp -m=-2 narrows it to a centred sub-rect so a
// shared field presents 1:1 on each monitor (crop, not stretch) -- see main-macwp.m.
float present_uv_min[2] = {0.0f, 0.0f};
float present_uv_max[2] = {1.0f, 1.0f};
double render_scale = 0; // internal res = logical points / render_scale (>1 = lower res, faster); --core.render-scale
// SIM_SCALE fixes the simulation coordinate space at logical/SIM_SCALE px, INDEPENDENT of render_scale.
// This makes render_scale a pure fidelity knob (changing -r never alters the dynamics) and a window
// resize just grows/shrinks the field. Set to the historical default render_scale so the default look
// is byte-for-byte preserved; all sim length constants are interpreted in this space.
const double SIM_SCALE = 2.0;
int sim_width = 0, sim_height = 0; // simulation field size in sim px (logical/SIM_SCALE), set in sim_set_dims
float mouse_scale = 1.0f; // logical-point -> sim-space scale
const char *title = "Pixel Goo";
// runtime-tunable via cli (see parse_args). NOTE: the default VALUES live in the
// default-params file (baked in at build, applied first in parse_args), not here.
// flag-backed globals below init to 0 purely as storage -- default-params is the
// single source of truth and overwrites them before the sim starts. (P is the
// exception: it has no default-params entry, so its fallback constant stays.)
bool fullscreen = true;
bool vsync = true;
bool profile = false; // --window.profile: glFinish per pass + print ms (disables pipelining)
int max_iterations = 0; // -N: exit after N PRESENTED frames (0 = unlimited); for benchmarking
int warmup = 0; // --core.warmup: simulate this many frames before presenting (skip the boring ramp)
int fps_cap = 0; // --window.fps: throttle the average fps to this (0 = uncapped)
bool no_keyfocus_steal = false; // --window.no-keyfocus-steal: show window w/out grabbing key focus
bool no_border = false; // --window.no-border: hide the title bar / border in windowed mode
bool no_mouse = false; // --core.no-mouse: disable the mouse repel (park the cursor far off)
bool mouse_debug = false; // --core.mouse-debug: draw green dot + trail at cursor
bool corners_debug = false; // --window.corners-debug: draw green squares in the window corners
bool exclusions_debug = false; // --core.exclusions-debug: tint excluded regions green
bool edge_debug = false; // --core.edge-debug: tint the bounding-edge repel bands green
char *dump_path = NULL; // --window.dump <prefix>: debug. at exit, write frame + density + trail + repel to <prefix>_*.ppm
const char *dump_params_fmt = NULL; // --dump-params[=FORMAT]: print resolved params and exit (NULL = don't)
char *headless_path = NULL; // --window.headless <out>: pipe raw render-res frames to ffmpeg, no window present
FILE *ffmpeg_pipe = NULL; // popen handle for the headless encode
unsigned char *headless_px = NULL; // reusable readback buffer (width*height*3)
int rng_seed = 0; // --core.seed <N>: fixed RNG seed for reproducible runs (0 = time-based)
double init_warp = 0; // --core.init-warp: magnitude of the perlin perturbation on initial positions (0 = pure uniform)
double init_density = 0; // --core.density/-d: particles per logical-pixel^2. DEFAULT oracle for P unless -p given.
bool p_given = false; // whether -p/--core.particles was passed -- if so it wins over --core.density
int whichMonitor = 0;
// Texture + framebuffer pools, shared by every sim (see pool_claim). Slots are handed out at
// setup in claim order, so these indices are only valid once buffer_setup has run.
GLuintArray pool_textures = {0};
GLuintArray pool_framebuffers = {0};
PBindex positionBufferIndex1 = -1;
PBindex positionBufferIndex2 = -1;
PBindex velocityBufferIndex1 = -1;
PBindex velocityBufferIndex2 = -1;
PBindex trailBufferIndex1 = -1;
PBindex trailBufferIndex2 = -1;
PBindex renderBufferIndex = -1; // internal colour target, upscaled to the window
PBindex repelBufferIndex = -1; // force-potential (r) + dispersion (g); single-buffered, stateless
// Physics buffer dims (square-ish texture holding P particles), set in buffer_setup.
int PB_width = 0;
int PB_height = 0;
// PBOs holding position/velocity: filled each frame by glReadPixels(FBO->PBO) and bound
// as vertex attributes for the point passes (faster than vertex texture fetch on this gpu).
//
// Position is a RING. pbo_pos is filled + bound as the point attribute (loc 0) each frame,
// but it is ALSO CPU-mapped to build the tile-coherent draw order. Mapping it for read is the
// stall: on macos GL the map does a conservative pipeline sync -- it blocks even for a slot
// whose readback landed frames ago, because the buffer is part of vertex-array state -- a
// periodic ~4ms (up to ~15ms) hit every sort-every frames. So: (1) ring the buffer, filling +
// attribute-binding a rotating slot each frame; (2) fence each slot when its readback is issued;
// (3) the sort maps the OLDEST slot with GL_MAP_UNSYNCHRONIZED_BIT (skips the driver sync), but
// only once that slot's fence says the readback has actually landed -- otherwise it skips the
// rebuild and keeps the prior order. The sort is a coherence-only optimisation (any positions
// yield a valid permutation), so a skipped or slightly-stale rebuild never affects correctness.
// Velocity is single-buffered: it is never mapped, only streamed as loc 1.
#define POS_PBO_N 4
GLuint pbo_pos[POS_PBO_N] = {0};
int pos_pbo_ring = 0; // next ring slot to write (this frame's positions)
GLsync pos_fence[POS_PBO_N] = {0}; // per-slot fence: signalled when that slot's readback lands
GLuint pbo_vel = 0;
Shader screenShader = {.name = "screenShader"};
Shader densityShader = {.name = "densityShader"};
Shader positionShader = {.name = "positionShader"};
Shader velocityShader = {.name = "velocityShader"};
Shader copyShader = {.name = "copyShader"};
Shader trailShader = {.name = "trailShader"};
Shader upscaleShader = {.name = "upscaleShader"};
Shader debugShader = {.name = "debugShader"}; // unified debug overlay: exclusions/edge/mouse (fullscreen fill + markers)
Shader repelShader = {.name = "repelShader"};
// Include shader source files. The per-sim ones live in that sim's TU -- these headers
// define the source strings at file scope, so including one in two TUs is a duplicate symbol.
#include "core/debug.h"
#include "core/density.h"
#include "core/position.h"
#include "core/repel.h"
#include "core/upscale.h"
Buffer trailBuffer = {.name = "Trail Buffer", .textures = &pool_textures, .framebuffers = &pool_framebuffers, .current = 0, .other = 1};
Buffer positionBuffer = {.name = "Position buffer", .textures = &pool_textures, .framebuffers = &pool_framebuffers, .current = 0, .other = 1};
Buffer velocityBuffer = {.name = "Velocity buffer", .textures = &pool_textures, .framebuffers = &pool_framebuffers, .current = 0, .other = 1};
Buffer screenBuffer = {.name = "Screen buffer", .textures = NULL, .framebuffers = NULL, .current = 0, .other = 1};
// Internal render target: the visual passes draw here at render resolution, then
// upscaleShader nearest-samples it onto screenBuffer (the actual window).
Buffer renderBuffer = {.name = "Render buffer", .textures = &pool_textures, .framebuffers = &pool_framebuffers, .current = 0, .other = 1};
// Force-potential (r) + dispersion (g) field: single-buffered and stateless -- fully cleared
// and re-splatted (edges + exclusion rects + mouse) from scratch every sim_step, no ping-pong.
Buffer repelBuffer = {.name = "Repel buffer", .textures = &pool_textures, .framebuffers = &pool_framebuffers, .current = 0, .other = 0};
// Alpha blending of each of the fragments
double densityAlpha = 0; // --goo.dens-alpha: per-particle density deposit
double kernelRadius = 0; // --goo.dens-kernel: density splat radius (sim px)
// Force-field tuning (velocity.frag uniforms). All in sim px / sim units, render_scale-independent.
double densityForce = 0; // --goo.dens-force: density-gradient repel strength
double trailForce = 0; // --goo.trail-force: trail-gradient attract strength
double repel = 0; // --core.repel: overall interaction repel strength (walls + exclusions + mouse)
double dragCoefficient = 0; // --core.drag: quadratic velocity damping, both sims
double mouseDrag = 0; // --core.mouse-drag: gain of the cursor motion sweep (repel field's ba)
int switchFrames = 120; // --core.switch-frames: length of a sim transition, in FRAMES
double switchEvery = 0; // --core.switch-every: auto-cycle cadence in seconds (0 = off)
double reach = 0; // --core.reach: density/interaction VDI sampling radius (sim px)
double trailReach = 0; // --goo.trail-reach: trail sampling radius (sim px)
double antiStick = 0; // --core.anti-stick: tiny cardinal drift unsticking motionless particles in repel zones
// The density/trail buffers are heavily downsampled, lerped physics fields -- not
// the rendered image. Scattering every Nth particle into them (with the per-point
// alpha scaled up to compensate) cuts the overdraw/ROP cost without a visible
// change, since the final screen pass still draws all P points. 1 = no subsample.
int densitySubsample = 0; // --goo.dens-sub
int trailSubsample = 0; // --goo.trail-sub
int dens_every = 0; // --goo.dens-every: rebuild density field every N frames (reuse between)
int trail_every = 0; // --goo.trail-every: deposit a rotating 1/N trail subset per frame (decay every frame)
// Screen render: density-weighted cull (--core.cull), 0 = off .. 1 = maximum thinning.
double cullAmount = 0; // --core.cull
// Screen colormap auto-exposure. See parse_args help for the knobs.
double renderHeadroom = 0.0; // --core.headroom: fixed value (0 = auto-track)
double headroomMargin = 0; // --core.headroom-margin: tracked-max multiplier
double headroomAttack = 0; // --core.headroom-attack: EMA alpha when density RISES
double headroomRelease = 0; // --core.headroom-release: EMA alpha when density FALLS
double renderGamma = 0; // --core.gamma: colormap curve (1 = linear/punchy, <1 lifts faint)
double alphaSpeed = 0; // --core.alpha-speed: particle speed that reaches full opacity
bool densityNearest = false; // --goo.dens-nearest: GL_NEAREST density (chunky pixels) vs default GL_LINEAR
double headroomPct = 0; // --core.headroom-pct: track this density percentile, not the max
// Buffer resolution divisor: the density field is render-res / this.
int densityBufferDownsampling = 0;
int repelBufferDownsampling = 0; // --core.repel-downsample: interaction (repel) buffer resolution divisor
int repel_width = 0; // computed in buffer_setup, like density_width
int repel_height = 0;
double ditherCoefficient = 0; // --core.dither: density-scaled random kick magnitude, both sims
double ditherDensityGain = 0; // --core.dither-gain: gain on the density-scaled dither
double ditherOrtho = 0; // --core.dither-ortho: density^2-scaled jitter perpendicular to velocity
bool legacyWedge = false; // --goo.legacy-wedge: old acos trail-integral heading (drops sign of vy)
// Alpha blending of each of the fragments
double trailIntensity = 0; // --goo.trail-intensity: per-particle trail deposit
double trailTau = 0; // --goo.trail-tau: trail decay time constant in FRAMES (decays every frame)
double trailRadius = 0; // --goo.trail-kernel: trail splat radius (sim px)
int trailBufferDownsampling = 0; // trail field resolution = render-res / this. --goo.trail-downsample.
double trailVelocityFloor = 0; // --goo.trail-floor: min velocity for a particle to deposit trail
int trail_width = 0;
int trail_height = 0;
int P = 200000; // --core.particles
// --core.exclusions "rect(x,y,w,h);...": exclusion primitives in logical px. Count 0 = no exclusion
// (today's behaviour). Core, so every front-end exposes it -- see the note in sim.h.
float exclusionRects[MAX_EXCLUSION_RECTS][4] = {{0}};
int exclusionRectCount = 0;
// per-pass GPU timing (only populated under --window.profile). Core's passes first, then the
// active sim's -- pass_name is filled in by sim_setup once that sim is known.
static const char *const core_pass_names[] = {"rdbk", "sort", "draw", "up"};
const int sim_pass_base = (int)(sizeof core_pass_names / sizeof core_pass_names[0]);
double pass_ms[MAX_PASSES] = {0};
const char *pass_name[MAX_PASSES] = {0};
int pass_count = 0;
double pass_t0 = 0; // running lap mark for LAP(); chains across the SimDef calls
// Every registered simulation. All of their cli flags are merged at startup regardless of which one
// runs, since selecting a sim is a runtime decision. Only goo is unconditional; the others need
// their define (see nob.c) -- so this registry, and nothing else, is what makes a sim reachable.
static const SimDef *const sims[] = {
&SIM_GOO,
#ifdef GOO_SIM_POLLOCK
&SIM_POLLOCK,
#endif
#ifdef GOO_SIM_BLANK
&SIM_BLANK,
#endif
};
#define NUM_SIMS (sizeof sims / sizeof sims[0])
// --help section titles, one per sim. dropt keeps the pointer for as long as the context lives, so
// these outlive parse_args rather than being built on its stack.
static char sim_help_headers[MAX_SIMS][32];
// --core.sim's help text, with the registered names listed. Same lifetime reason as the headers
// above, and filled by the same pass over sims[] in parse_args.
static char sim_names_help[128];
// The --window.profile pass set differs per sim (goo has a trail pass, pollock does not), so the
// labels are assembled from whichever sim is active -- at setup AND on every switch, or the
// columns keep the previous sim's names over the new sim's numbers.
static void sim_build_pass_table(void) {
pass_count = sim_pass_base + active_sim->n_passes;
if (pass_count > MAX_PASSES) {
fprintf(stderr, "goo: sim '%s' declares too many profile passes\n", active_sim->name);
exit(EXIT_FAILURE);
}
for (int i = 0; i < sim_pass_base; i++)
pass_name[i] = core_pass_names[i];
for (int i = 0; i < active_sim->n_passes; i++)
pass_name[sim_pass_base + i] = active_sim->pass_names[i];
for (int i = 0; i < MAX_PASSES; i++)
pass_ms[i] = 0.0; // a switch must not inherit the previous sim's timings
}
// ---- live sim switching ----
// A switch is not a cut: over --core.switch-frames the physics DUTY-CYCLES between the two sims,
// each frame running wholly one or wholly the other, with the incoming share ramping 0 -> 1.
// Force is integrated twice before it becomes visible position, so the particle itself low-passes
// the alternation into a genuine blend -- no extra buffers, no extra passes, one branch here.
// The schedule is error diffusion rather than a random draw: at a 50% share a random draw gives
// runs of four or five identical frames, which is the stutter the whole idea exists to avoid.
//
// Presentation does NOT duty-cycle. The physics integrates the alternation; the display does not,
// so alternating whole frames between two colormaps would strobe. Both sims draw every transition
// frame instead, weighted by the same ramp.
static const SimDef *transition_from = NULL;
static int transition_frame = 0; // 0 .. switchFrames
static double transition_acc = 0.0; // error-diffusion accumulator
static volatile sig_atomic_t switch_requested = 0;
bool sim_transitioning(void) { return transition_from != NULL; }
void sim_request_switch(void) { switch_requested = 1; }
static void sim_handle_sigusr1(int sig) {
(void)sig;
switch_requested = 1; // only a flag: sim_step picks it up
}
void sim_install_signal_handlers(void) {
signal(SIGUSR1, sim_handle_sigusr1);
}
// Begin a move to the next registered sim, in registry order. Dropped if one is already running:
// queueing them would let a held-down key stack transitions faster than they can play out.
static void sim_begin_switch(void) {
if (transition_from)
return;
size_t at = 0;
for (size_t i = 0; i < NUM_SIMS; i++)
if (sims[i] == active_sim)
at = i;
const SimDef *next = sims[(at + 1) % NUM_SIMS];
if (next == active_sim)
return; // only one sim registered
transition_from = active_sim;
active_sim = next;
transition_frame = 0;
transition_acc = 0.0;
// Per-pair setup, before the incoming sim's first duty frame. The outgoing sim's fields are
// still live and untouched at this point, which is what makes deriving from them possible.
if (active_sim->enter_from)
active_sim->enter_from(transition_from);
sim_build_pass_table(); // the incoming sim's pass set, not the outgoing one's
fprintf(stdout, "sim: %s -> %s over %d frames\n", transition_from->name, active_sim->name, switchFrames);
}
// The simulation currently running. --core.sim picks the starting one; tab, SIGUSR1 and
// --core.switch-every move it along the registry above.
const SimDef *active_sim = &SIM_GOO;
double gl_refresh_seconds = 30 * 60; // --core.gl-refresh: GL-context recreate cadence (default 30m; 0 = off); wallpaper only
// When set (via sim_restore), the next buffer_setup uploads this saved state
// instead of generating a fresh random field. Consumed (cleared) by buffer_setup.
static const SimSnapshot *g_restore = NULL;
// Monotonic seconds; replaces glfwGetTime
double get_time(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
// Sleep for ms milliseconds (frame pacing). <=0 returns immediately.
void sleep_ms(double ms) {
if (ms <= 0.0)
return;
struct timespec req = {
.tv_sec = (time_t)(ms / 1000.0),
.tv_nsec = (long)(fmod(ms, 1000.0) * 1e6),
};
nanosleep(&req, NULL);
}
// Uniform random float in [0, 1]; replaces glm::linearRand
static float frand01(void) {
return (float)rand() / (float)RAND_MAX;
}
// Exponential moving average; seeds on first sample (prev == 0).
double ema(double prev, double sample, double a) {
return prev == 0.0 ? sample : a * sample + (1.0 - a) * prev;
}
// Debug dump tooling (--window.dump). Read a framebuffer back and write it as a PPM so it can be
// eyeballed as an image. glReadPixels is bottom-up, so flip to top-down on write.
void dump_ppm_rgb(const char *path, int w, int h, GLuint fbo) {
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
unsigned char *px = (unsigned char *)malloc((size_t)w * h * 3);
// tightly-packed rgb24: default GL_PACK_ALIGNMENT=4 pads rows when w*3 % 4 != 0
// (e.g. w=125 -> 375), shearing the readback. force byte packing.
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, w, h, GL_RGB, GL_UNSIGNED_BYTE, px);
FILE *f = fopen(path, "wb");
if (f) {
fprintf(f, "P6\n%d %d\n255\n", w, h);
for (int y = h - 1; y >= 0; y--)
fwrite(px + (size_t)y * w * 3, 1, (size_t)w * 3, f);
fclose(f);
fprintf(stdout, "dumped %s\n", path);
}
free(px);
}
// Single-channel R32F field -> grayscale PPM, normalised by its own max (printed too, so
// the actual value range is visible -- important once density goes unbounded/additive).
void dump_ppm_rgb_scaled(const char *path, int w, int h, GLuint fbo) {
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
size_t n = (size_t)w * h * 3;
float *v = (float *)malloc(n * sizeof(float));
glReadPixels(0, 0, w, h, GL_RGB, GL_FLOAT, v);
float mx = 0.0f;
for (size_t i = 0; i < n; i++)
if (v[i] > mx)
mx = v[i];
// One shared divisor across all channels, not per-channel: normalising each independently
// would erase the relative abundance between them, which is the point of the dump.
float norm = mx > 0.0f ? mx : 1.0f;
unsigned char *px = (unsigned char *)malloc(n);
for (size_t i = 0; i < n; i++) {
float t = v[i] / norm;
px[i] = (unsigned char)(255.0f * (t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t)));
}
FILE *f = fopen(path, "wb");
if (f) {
fprintf(f, "P6\n%d %d\n255\n", w, h);
for (int y = h - 1; y >= 0; y--)
fwrite(px + (size_t)y * w * 3, 1, (size_t)w * 3, f);
fclose(f);
fprintf(stdout, "dumped %s (max value %.4f)\n", path, mx);
}
free(px);
free(v);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
}
void dump_ppm_scalar(const char *path, int w, int h, GLuint fbo) {
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
float *v = (float *)malloc((size_t)w * h * sizeof(float));
glReadPixels(0, 0, w, h, GL_RED, GL_FLOAT, v);
float mx = 0.0f;
for (size_t i = 0; i < (size_t)w * h; i++)
if (v[i] > mx)
mx = v[i];
float norm = mx > 0.0f ? mx : 1.0f;
unsigned char *px = (unsigned char *)malloc((size_t)w * h);
for (size_t i = 0; i < (size_t)w * h; i++) {
float t = v[i] / norm;
px[i] = (unsigned char)(255.0f * (t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t)));
}
FILE *f = fopen(path, "wb");
if (f) {
fprintf(f, "P5\n%d %d\n255\n", w, h);
for (int y = h - 1; y >= 0; y--)
fwrite(px + (size_t)y * w, 1, (size_t)w, f);
fclose(f);
fprintf(stdout, "dumped %s (max value %.4f)\n", path, mx);
}
free(px);
free(v);
}
// Tile-coherent draw order. The screen pass binds the goo's scattered points into
// random TBDR tiles, which is the bottleneck; drawing them in screen-tile order via
// an index buffer fixes it. The order is rebuilt by an O(P) counting sort over the
// positions read back into pbo_pos (mapped on the CPU). sort_every controls cadence.
GLuint ibo = 0;
unsigned int *sort_idx = NULL; // P entries: particle indices in tile order
int *sort_key = NULL; // P entries: per-particle tile bucket (scratch)
int *sort_counts = NULL; // (nbuckets+1) histogram / prefix-sum scratch
const int sort_tile = 32; // screen tile size in px (~AGX tile granularity)
int sort_every = 0; // rebuild the order every N frames (1 = every frame)
// Counting-sort the P particle indices by screen tile, using positions from the OLDEST pbo_pos
// ring slot -- mapped GL_MAP_UNSYNCHRONIZED_BIT (no driver sync) once its fence proves the
// readback landed (see the ring note above). Uploads the result into ibo. O(P), two linear
// passes. Out-of-bounds positions are clamped (only affects coherence, never correctness).
static void rebuild_sort_order(void) {
int oldest = (pos_pbo_ring + 1) % POS_PBO_N; // least-recently written this ring cycle
// Only read a slot whose readback has actually landed. Poll the fence (timeout 0, no flush --
// prior frames' swaps already flushed it). Not signalled, or never filled (startup priming),
// -> skip this rebuild and keep the prior order (the sort is coherence-only).
GLsync f = pos_fence[oldest];
if (f == NULL)
return;
GLenum st = glClientWaitSync(f, 0, 0);
if (st != GL_ALREADY_SIGNALED && st != GL_CONDITION_SATISFIED)
return;
int tilesW = (width + sort_tile - 1) / sort_tile;
int tilesH = (height + sort_tile - 1) / sort_tile;
int nb = tilesW * tilesH;
float inv_tile = 1.0f / (float)sort_tile;
size_t map_bytes = (size_t)PB_width * PB_height * 2 * sizeof(float);
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo_pos[oldest]);
float *pos = (float *)glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, map_bytes, GL_MAP_READ_BIT | GL_MAP_UNSYNCHRONIZED_BIT);
if (!pos) {
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return;
}
for (int i = 0; i <= nb; i++)
sort_counts[i] = 0;
for (int i = 0; i < P; i++) {
int tx = (int)(pos[2 * i] * inv_tile);
int ty = (int)(pos[2 * i + 1] * inv_tile);
tx = tx < 0 ? 0 : (tx >= tilesW ? tilesW - 1 : tx);
ty = ty < 0 ? 0 : (ty >= tilesH ? tilesH - 1 : ty);
int b = ty * tilesW + tx;
sort_key[i] = b;
sort_counts[b + 1]++;
}
for (int i = 0; i < nb; i++)
sort_counts[i + 1] += sort_counts[i];
for (int i = 0; i < P; i++)
sort_idx[sort_counts[sort_key[i]]++] = (unsigned int)i;
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, (size_t)P * sizeof(unsigned int), sort_idx, GL_DYNAMIC_DRAW);
}
// dropt int handler that accepts scientific notation ("500", "5e2", "1e6", "2e-1").
// dropt's own dropt_handle_int uses strtol and stops at the 'e'; this uses strtod so
// every int flag takes sci notation too. fractional values round to nearest.
dropt_error parse_int(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL || arg[0] == '\0')
return dropt_error_insufficient_arguments;
char *end;
double v = strtod(arg, &end);
if (end == arg || *end != '\0')
return dropt_error_mismatch;
// round to nearest, then range-check: sci notation makes it trivial to write a
// value outside int range (e.g. 1e20), and casting an out-of-range double to int
// is UB. fail loudly instead.
double r = v >= 0 ? floor(v + 0.5) : ceil(v - 0.5);
if (r > (double)INT_MAX)
return dropt_error_overflow;
if (r < (double)INT_MIN)
return dropt_error_underflow;
*(int *)dest = (int)r;
return dropt_error_none;
}
// dropt handler for --core.render-scale: optional val; bare -r enables low-res mode at 2.0.
static dropt_error parse_render_scale(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL) {
*(double *)dest = 2.0;
return dropt_error_none;
}
return dropt_handle_double(ctx, opt, arg, dest);
}
// dropt handler for --core.cull: optional val; bare --core.cull enables culling at 0.8.
static dropt_error parse_cull(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL) {
*(double *)dest = 0.8;
return dropt_error_none;
}
return dropt_handle_double(ctx, opt, arg, dest);
}
// dropt handler for --core.seed: optional val; bare --core.seed picks a random seed and prints it.
static dropt_error parse_seed(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL) {
int s = (int)(time(NULL) & 0x7fffffff);
fprintf(stdout, "goo: seed %d\n", s);
*(int *)dest = s;
return dropt_error_none;
}
return parse_int(ctx, opt, arg, dest);
}
// dropt handler for the particle count: same sci-notation parse as parse_int, but
// also flags that -p was given so it wins over --core.density.
static dropt_error parse_count(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
dropt_error e = parse_int(ctx, opt, arg, dest);
if (e == dropt_error_none)
p_given = true;
return e;
}
// --core.gl-refresh: a duration with an optional unit suffix s/m/h/d (bare = seconds).
// e.g. "10s", "30m", "1h", "1d", "0". Stored as seconds (double).
static dropt_error parse_duration(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL || arg[0] == '\0')
return dropt_error_insufficient_arguments;
char *end;
double v = strtod(arg, &end);
if (end == arg || v < 0)
return dropt_error_mismatch;
double mult = 1.0;
if (*end != '\0') {
switch (*end) {
case 's':
mult = 1.0;
break;
case 'm':
mult = 60.0;
break;
case 'h':
mult = 3600.0;
break;
case 'd':
mult = 86400.0;
break;
default:
return dropt_error_mismatch;
}
if (end[1] != '\0') // only a single-char unit suffix is allowed
return dropt_error_mismatch;
}
*(double *)dest = v * mult;
return dropt_error_none;
}
// --core.density/-d: plain double, but flag that it was explicitly set (for -p/-d exclusion).
static dropt_error parse_density(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL || arg[0] == '\0')
return dropt_error_insufficient_arguments;
char *end;
double v = strtod(arg, &end);
if (end == arg || *end != '\0')
return dropt_error_mismatch;
*(double *)dest = v;
return dropt_error_none;
}
// --core.exclusions "rect(x,y,w,h);rect(x,y,w,h);...": exclusion primitives to cut from the sim
// domain, in logical px. Primitive-call syntax: a bare name token followed by a parenthesised,
// comma-separated argument list, ';'-separated calls. Hand-rolled split (no strtok -- same
// manual pointer-walk style as token_flag_name below). Only "rect" (4 numeric args: x,y,w,h)
// is implemented today; any other primitive name, or a malformed call (missing/extra parens,
// wrong arg count, non-numeric field, trailing junk), is a hard reject -- same convention as
// any other flag -- not a silent skip. A primitive count over MAX_EXCLUSION_RECTS is clamped
// with a warning instead, since truncating the tail is a much less surprising failure mode
// than refusing to start.
//
// Storage stays a plain float[4] per rect (no type tag/union) since only one primitive kind
// exists, but the dispatch is keyed off the primitive NAME (not off a fixed field count/
// position), so a second primitive kind later is a new `else if` branch, not a rewrite of the
// call-splitting logic above it.
static dropt_error parse_exclusions(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
if (arg == NULL || arg[0] == '\0')
return dropt_error_insufficient_arguments;
int count = 0;
const char *call_start = arg;
while (*call_start != '\0') {
const char *call_end = strchr(call_start, ';');
size_t call_len = call_end ? (size_t)(call_end - call_start) : strlen(call_start);
if (call_len > 0) { // tolerate a trailing/empty ';' segment
const char *paren = memchr(call_start, '(', call_len);
if (paren == NULL || call_start[call_len - 1] != ')')
return dropt_error_mismatch; // no "name(...)" shape at all
size_t name_len = (size_t)(paren - call_start);
const char *args_start = paren + 1;
size_t args_len = call_len - name_len - 2; // minus "name(" and the trailing ")"
if (name_len == 4 && strncmp(call_start, "rect", 4) == 0) {
if (count >= MAX_EXCLUSION_RECTS) {
fprintf(stderr, "goo: --core.exclusions: more than %d primitives given, ignoring the rest\n", MAX_EXCLUSION_RECTS);
break;
}
float v[4];
const char *field_start = args_start;
for (int n = 0; n < 4; n++) {
char *end;
v[n] = strtof(field_start, &end);
if (end == field_start || (n < 3 && *end != ','))
return dropt_error_mismatch;
field_start = (n < 3) ? end + 1 : end;
}
if (field_start != args_start + args_len) // trailing junk before the closing ')'
return dropt_error_mismatch;
exclusionRects[count][0] = v[0];
exclusionRects[count][1] = v[1];
exclusionRects[count][2] = v[2];
exclusionRects[count][3] = v[3];
count++;
} else {
return dropt_error_mismatch; // unrecognised primitive name
}
}
call_start += call_len;
if (*call_start == ';')
call_start++;
}
*(int *)dest = count;
return dropt_error_none;
}
// --dump-params[=FORMAT]: optional value. Bare flag defaults to "ini". Just records the format
// string; the actual dump + exit happens at the end of parse_args (once all params are resolved).
static dropt_error parse_dump_params(dropt_context *ctx, const dropt_option *opt, const char *arg, void *dest) {
(void)ctx;
(void)opt;
*(const char **)dest = (arg == NULL || arg[0] == '\0') ? "ini" : arg;
return dropt_error_none;
}
// Config-from-stdin support. `goo -` reads an .ini config from stdin and parses
// it through the SAME dropt options as the cli, so every flag works in either
// place with one validation path. Each `key = value` becomes a `--key=value`
// token; the cli tokens are appended AFTER the config tokens so the cli wins
// (dropt takes the last value). A key present in both warns to stderr.
//
// NOTE: override detection is by long-flag name only. setting a key in config
// and its short alias on the cli (e.g. `particles` in config + `-p` on cli)
// still overrides correctly but is NOT warned about. add a short->long map here
// if that silent case starts mattering.
// tokens (config + borrowed cli) live for the whole process: dropt's string
// handlers (--window.dump, --window.headless) keep pointers into them, so we never free.
struct token_list {
char **items;
size_t count, cap;
};
static void tl_push(struct token_list *tl, char *tok) {
if (tl->count == tl->cap) {
tl->cap = tl->cap ? tl->cap * 2 : 16;
tl->items = realloc(tl->items, tl->cap * sizeof *tl->items);
if (!tl->items) {
fprintf(stderr, "goo: out of memory building config tokens\n");
exit(EXIT_FAILURE);
}
}
tl->items[tl->count++] = tok;
}
// inih handler: turn `[section] name = value` into a `--section.name=value` token, the flag
// spelling those keys have on the cli. A key outside any section keeps its bare name, which is
// then simply not a valid flag -- dropt reports it rather than silently ignoring it.
// Returns 1 (inih: nonzero = ok).
static int config_handler(void *user, const char *section, const char *name, const char *value) {
struct token_list *tl = user;
bool sectioned = section && section[0];
size_t s = sectioned ? strlen(section) + 1 : 0;
size_t n = strlen(name), v = strlen(value);
char *tok = malloc(2 + s + n + 1 + v + 1); // "--" + "section." + name + "=" + value + NUL
if (!tok) {
fprintf(stderr, "goo: out of memory parsing config\n");
exit(EXIT_FAILURE);
}
if (sectioned)
sprintf(tok, "--%s.%s=%s", section, name, value);
else
sprintf(tok, "--%s=%s", name, value);
tl_push(tl, tok);
return 1;
}
// slurp all of stdin into a malloc'd NUL-terminated buffer.
static char *slurp_stdin(void) {
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
fprintf(stderr, "goo: out of memory reading stdin\n");
exit(EXIT_FAILURE);
}
for (;;) {
if (len + 1 >= cap) {
cap *= 2;
buf = realloc(buf, cap);
if (!buf) {
fprintf(stderr, "goo: out of memory reading stdin\n");
exit(EXIT_FAILURE);
}
}
size_t got = fread(buf + len, 1, cap - 1 - len, stdin);
len += got;
if (got == 0)
break;
}
buf[len] = '\0';
return buf;
}
// the long-flag name a token sets, or NULL if it's not a "--name[=...]" token.
// writes the name (sans "--", sans "=value") into out (size cap); returns out or NULL.
static const char *token_flag_name(const char *tok, char *out, size_t cap) {
if (tok[0] != '-' || tok[1] != '-')
return NULL;
const char *p = tok + 2, *eq = strchr(p, '=');
size_t len = eq ? (size_t)(eq - p) : strlen(p);
if (len == 0 || len >= cap)
return NULL;
memcpy(out, p, len);
out[len] = '\0';
return out;
}
// Flags that only make sense for a real window (geometry, input, file output).
// goo-wlwp (wallpaper) excludes these from its option table AND skips any matching
// baked-in default, so they neither show in --help nor parse on its cli.
// NOTE: exclusions and the *-debug overlays are core, not window -- they work on the wallpapers
// via the shared sim core (exclusion physics + the sim_step debug overlay pass). corners-debug is
// window: it's drawn by glScissor in the RGFW front-end loops, which the wallpapers don't have.
static bool is_window_only(const char *name) {
return strncmp(name, "window.", 7) == 0;
}
// Dump every resolved value param as `key = value`, matching the default-params.ini format, by
// walking the (merged) option table and formatting each entry by its handler. Doubles/ints/bools
// only -- string params (--window.dump, --window.headless) and the meta flags (help, dump-params) are skipped,
// as is `particles` (P isn't derived until sim_setup; the resolved oracle is `density`).
static void dump_params_ini(FILE *out, const dropt_option *opts, size_t n) {
// Grouped under [section] headers with the prefix stripped, so the output is a config the
// ini reader accepts verbatim: `goo --dump-params | goo -` has to round-trip.
const char *emitted[16];
size_t n_emitted = 0;
for (size_t pass = 0; pass < n; pass++) {
const char *want = NULL;
size_t want_len = 0;
// pick the next section in first-appearance order
for (size_t i = 0; i < n && want == NULL; i++) {
const char *ln = opts[i].long_name;
const char *dot = ln ? strchr(ln, '.') : NULL;
if (!dot || opts[i].dest == NULL)
continue;
size_t len = (size_t)(dot - ln);
bool done = false;
for (size_t k = 0; k < n_emitted; k++)
if (strlen(emitted[k]) == len && strncmp(emitted[k], ln, len) == 0)
done = true;
if (!done) {
want = ln;
want_len = len;
}
}
if (want == NULL)
break; // every section covered
if (n_emitted >= sizeof emitted / sizeof emitted[0])
break;
char *name = malloc(want_len + 1);
if (!name) {
fprintf(stderr, "goo: out of memory dumping params\n");
exit(EXIT_FAILURE);
}
memcpy(name, want, want_len);
name[want_len] = '\0';
emitted[n_emitted++] = name;
fprintf(out, "%s[%s]\n", n_emitted > 1 ? "\n" : "", name);
for (size_t i = 0; i < n; i++) {
const dropt_option *o = &opts[i];
if (o->long_name == NULL || o->dest == NULL)
continue; // section headers / valueless entries
const char *dot = strchr(o->long_name, '.');
if (!dot || (size_t)(dot - o->long_name) != want_len || strncmp(o->long_name, name, want_len) != 0)
continue;
const char *key = dot + 1;
dropt_option_handler_func h = o->handler;
// parse_duration's dest is plain seconds, and a bare number parses back as seconds,
// so it dumps as a double like the rest.
if (h == dropt_handle_double || h == parse_density || h == parse_render_scale || h == parse_cull || h == parse_duration)
fprintf(out, "%s = %g\n", key, *(const double *)o->dest);
else if (h == parse_int || h == parse_seed)
fprintf(out, "%s = %d\n", key, *(const int *)o->dest);
else if (h == dropt_handle_bool)
fprintf(out, "%s = %d\n", key, (int)*(const dropt_bool *)o->dest);
else if (strcmp(key, "sim") == 0)
fprintf(out, "%s = %s\n", key, active_sim->name); // resolved, so the dump round-trips
else if (h == parse_exclusions) {
// Re-emit the primitive-call syntax parse_exclusions accepts, so a config with
// exclusion rects survives a dump/pipe. Skipped entirely when there are none,
// rather than emitting an empty value the parser would reject.
if (exclusionRectCount > 0) {
fprintf(out, "%s = ", key);
for (int r = 0; r < exclusionRectCount; r++)
fprintf(out, "%srect(%g,%g,%g,%g)", r ? ";" : "",
exclusionRects[r][0], exclusionRects[r][1],
exclusionRects[r][2], exclusionRects[r][3]);
fprintf(out, "\n");
}
}
// parse_count (particles), string handlers, etc. -> skipped
}
}
for (size_t k = 0; k < n_emitted; k++)
free((void *)emitted[k]);
}
// Parse command-line options into the runtime globals above. Exits the process
// on --help or on a bad option; returns normally to let the sim start.
void parse_args(int argc, char **argv, bool wlwp, bool macwp) {
dropt_bool show_help = 0;
#ifdef GOO_VERSION
dropt_bool show_version = 0; // --version: only compiled in when GOO_VERSION is baked (CI tag builds)
#endif
dropt_bool windowed = 0;
dropt_bool no_vsync = 0;
dropt_bool prof = 0;
// __APPLE__, not RGFW_MACOS: that's defined by RGFW.h, which this file never includes.
#ifdef __APPLE__
dropt_bool no_focus = 0;
#endif
dropt_bool nomouse = 0;
dropt_bool core_dens_near = 0;
char *sim_name = NULL;
// Names for --core.sim's help, from the registry. Each snprintf is guarded on there being room
// left, since snprintf reports the length it WANTED and that can run past the buffer.
size_t at_help = 0;
at_help += (size_t)snprintf(sim_names_help, sizeof sim_names_help, "Which simulation to run (");
for (size_t i = 0; i < NUM_SIMS && at_help < sizeof sim_names_help; i++)
at_help += (size_t)snprintf(sim_names_help + at_help, sizeof sim_names_help - at_help,
"%s%s", i ? ", " : "", sims[i]->name);
if (at_help < sizeof sim_names_help)
snprintf(sim_names_help + at_help, sizeof sim_names_help - at_help, ").");
// Core options: the shared simulation, exposed by every front-end. A sim's own flags
// live in its SimDef and are merged in below; window/wallpaper flags come from the
// flavour table.
dropt_option core_opts[] = {
{'h', "help", "Show this help and exit.", NULL,
dropt_handle_bool, &show_help, dropt_attr_halt},
#ifdef GOO_VERSION
{'\0', "version", "Print the version and exit.", NULL,
dropt_handle_bool, &show_version, dropt_attr_halt},
#endif
{'\0', NULL, "\nPARTICLES", NULL, NULL, NULL},
{'p', "core.particles", "Number of particles to simulate (plain or scientific notation, e.g. 200000, 1e6).", "N",
parse_count, &P},
{'d', "core.density", "Particles per logical-pixel^2 (sim-area + render-scale independent; default 0.015; excludes -p).", "F",
parse_density, &init_density},
{'\0', "core.init-warp", "Magnitude of the perlin warp on initial positions (0 = pure uniform; default 0.13).", "F",
dropt_handle_double, &init_warp},
{'\0', "core.repel", "Interaction repel strength: walls + exclusions + mouse (default 0.11).", "F",
dropt_handle_double, &repel},
{'\0', "core.drag", "Quadratic velocity damping, both sims (higher = more damping; unrelated to --core.mouse-drag; default 0.19).", "F",
dropt_handle_double, &dragCoefficient},
{'\0', "core.mouse-drag", "Gain of the cursor motion sweep (drag).", "F",
dropt_handle_double, &mouseDrag},
{'\0', "core.switch-frames", "Length of a sim transition, in frames (physics duty-cycles across it).", "N",
parse_int, &switchFrames},
{'\0', "core.switch-every", "Auto-cycle to the next sim every <dur> (s/m/h; 0 = never).", "DUR",
parse_duration, &switchEvery},
{'\0', NULL, "\nDENSITY", NULL, NULL, NULL},
{'\0', "core.dens-force", "Density-gradient force strength (default 0.005).", "F",
dropt_handle_double, &densityForce},
{'\0', "core.dens-reach", "Density force sampling radius in sim px (default 20).", "F",
dropt_handle_double, &reach},
{'\0', "core.dens-alpha", "Per-particle density deposit (default 0.005).", "F",
dropt_handle_double, &densityAlpha},
{'\0', "core.dens-kernel", "Density splat radius in sim px (default 30).", "F",
dropt_handle_double, &kernelRadius},
{'\0', "core.dens-sub", "Scatter every Nth particle into EACH density channel (higher = less overdraw).", "N",
parse_int, &densitySubsample},
{'\0', "core.dens-downsample", "Density field resolution divisor (render-res / N; higher = coarser/cheaper).", "N",
parse_int, &densityBufferDownsampling},
{'\0', "core.dens-every", "Rebuild the density field every N frames (reuse between; cheaper).", "N",
parse_int, &dens_every},
{'\0', "core.dens-nearest", "Sample the density field with GL_NEAREST (chunky pixels) instead of GL_LINEAR.", NULL,
dropt_handle_bool, &core_dens_near},
// Dither scales with the local density field, so it reads after the DENSITY block above.
{'\0', NULL, "\nDITHER", NULL, NULL, NULL},
{'\0', "core.dither", "Density-scaled random kick magnitude, both sims (default 0.025).", "F",
dropt_handle_double, &ditherCoefficient},
{'\0', "core.dither-gain", "Gain on the density-scaled dither (>1 = more jitter in dense regions; default 8.5).", "F",
dropt_handle_double, &ditherDensityGain},
{'\0', "core.dither-ortho", "Density^2-scaled jitter perpendicular to velocity (default 0.05).", "F",
dropt_handle_double, &ditherOrtho},
{'\0', NULL, "\nMISC", NULL, NULL, NULL},
{'\0', "core.anti-stick", "Anti-stick drift: tiny per-particle cardinal force freeing motionless particles stuck in repel zones (default 7).", "F",
dropt_handle_double, &antiStick},
{'\0', NULL, "\nCOLORMAP", NULL, NULL, NULL},
{'\0', "core.gamma", "Colormap curve: 1 = linear/punchy, <1 lifts faint regions.", "F",
dropt_handle_double, &renderGamma},
{'\0', "core.alpha-speed", "Particle speed that reaches full opacity, both sims (default 1.0).", "F",
dropt_handle_double, &alphaSpeed},
{'\0', "core.cull", "Screen density cull, 0..1 (bare --core.cull = 0.8; thins denser regions).", "F",
parse_cull, &cullAmount, dropt_attr_optional_val},
{'\0', "core.headroom", "Fixed colormap headroom (0 = auto-track the density max).", "F",
dropt_handle_double, &renderHeadroom},
{'\0', "core.headroom-margin", "Auto-headroom: multiplier on the tracked max (>1 darker, <1 brighter).", "F",
dropt_handle_double, &headroomMargin},
{'\0', "core.headroom-attack", "Auto-headroom: EMA speed when density rises / closes (0..1).", "F",
dropt_handle_double, &headroomAttack},
{'\0', "core.headroom-release", "Auto-headroom: EMA speed when density falls / opens (0..1; lower = brightens slower).", "F",
dropt_handle_double, &headroomRelease},
{'\0', "core.headroom-pct", "Auto-headroom: track this density percentile (1 = max; <1 ignores hot-spots).", "F",
dropt_handle_double, &headroomPct},
{'\0', NULL, "\nDENSITY / TRAIL", NULL, NULL, NULL},
{'\0', "core.repel-downsample", "Repel (interaction) buffer resolution divisor (render-res / N; higher = coarser edges/exclusions/mouse).", "N",
parse_int, &repelBufferDownsampling},
{'\0', NULL, "\nMISC", NULL, NULL, NULL},
{'r', "core.render-scale", "Internal render resolution divisor (>1 = lower res, faster, chunkier; bare -r = 2).", "N",
parse_render_scale, &render_scale, dropt_attr_optional_val},
{'\0', "core.seed", "Fixed RNG seed (bare --core.seed = random but printed for replay; 0 = silent random).", "N",
parse_seed, &rng_seed, dropt_attr_optional_val},
{'\0', "core.sort-every", "Rebuild tile-coherent draw order every N frames (0 = never).", "N",
parse_int, &sort_every},
{'\0', "core.warmup", "Simulate N frames before presenting -- skips the boring startup ramp (both windowed + headless).", "N",
parse_int, &warmup},
{'\0', "dump-params", "Print the resolved params (bare = 'ini' format) and exit.", "FORMAT",