Repulsive Generator¶
The repulsive generator grows each environment’s closed centerline from a small seed
circle under a hard ratcheting length constraint, while a tangent-point (TP) energy
keeps the curve self-avoiding as it is confined to a disc domain seeded with per-env random
disc obstacles. Growth runs coarse-to-fine (N = 64 → 128 → 256) with an area-based
stall-stop, then hands the grown loop to the unmodified standard tail (constant-spacing
resample → XPBD relax → inflate → validity). The result is dense, paper-quality serpentine
circuits threading between the obstacles — a visibly different shape family from the other
five generators, all of which assemble a curve from a fixed set of sampled/steered control
points in a single deterministic pass.
The method follows Henrich and Kötter, Generating Race Tracks With Repulsive Curves (IEEE, document 10645670), extended in the journal version From Generation to Gameplay: Authoring Race Tracks With Repulsive Curves (IEEE Transactions on Games, 2025, doi:10.1109/TG.2025.3561107 — see Track Generation State of the Art for a fuller discussion), which itself builds on Yu, Schumacher, and Crane, Repulsive Curves (SIGGRAPH 2021 / arXiv 2020).
Warning
repulsive is a host-driven, non-graph-capturable optimizer — it is the first
registered generator that does not participate in CUDA-graph capture. On an RTX 4090 it
takes roughly 0.2 s at E=64 and 5 s at E=8192 (~3 ms/track amortized), versus
~2 ms per batch for bezier — ~1000× slower. It runs eagerly on CUDA every call
(no captured replay); graph capture of the growth loop is future work (see
warp_generate_repulsive.py’s module docstring and the design spike): the loop still
drives stage transitions, per-window stall readbacks, and the early exit from the host,
which is illegal inside a capture region. (The per-iteration wp.Tape that used to be
the interior blocker is gone — the gradient is now hand-written analytic adjoints — so
capture is just a wiring exercise.) If you use repulsive in a training loop, prefer a slow regeneration
cadence (regenerate a batch every N steps, not every step) or staggered per-env
slices (regenerate a fraction of environments per step) rather than calling
generate() every frame.
Sample tracks produced by the repulsive generator across several seeds.¶
How It Works¶
Seed-driven obstacle layout.
_sample_obstacles_kdraws, per environment, a domain-wall ring at radiusr_dom(n_wall = 96points, weight 1.0) plusk ~ randi[repulsive_obstacle_count_min, repulsive_obstacle_count_max]inner-disc obstacle rings (n_disc = 12points each, weight 0.25), one per2π/kangular wedge with a per-env random phase. Each disc’s radius is drawn from[repulsive_obstacle_radius_min_frac, repulsive_obstacle_radius_max_frac] · r_dom, and its center radius from an analytic band that clears the seed circle by0.05·r_dom— no rejection loop, deterministic per(seed, env).Seed the target length and the initial circle. A per-env target-perimeter multiplier
grow_mult ~ U(repulsive_grow_mult_min, repulsive_grow_mult_max)setsL_final = grow_mult · 2π·r_init._init_circle_kwrites the radius-r_initcircle into the coarsest stage’s buffer prefix.Coarse-to-fine ratcheted growth. Each iteration: the target length
L_targetratchets towardL_finalbyrepulsive_ratchet_rate; the gradient of the total energy (tangent-point self-repulsion + inverse-power obstacle repulsion + a small length regularizer) is evaluated by hand-written analytic adjoint kernels — a per-vertex gather with no atomics; the gradient is preconditioned by a fractional-Sobolev filter (FFT-free — a precomputed circulant convolution) and projected to be orthogonal to the length direction; a normalized, barycenter-pinned step is applied; and the curve is hard-rescaled back toL_target. Growth starts atN=64and subdivides to 128 then 256 (arc-length resample) as the mean edge length doubles — the coarse start forces the ratcheted length into low-frequency folds rather than high-frequency wiggle.Deactivation + stall-stop. Once an env’s length reaches
L_final, its inner-disc obstacles are zeroed out (whenrepulsive_deactivate_obstacles, the wall stays live) so the settle phase can close the halos left around each disc. Everyrepulsive_stall_windowiterations, an env freezes once it is past target length and its enclosed area (shoelace, reparameterization- invariant) has stopped changing by more thanrepulsive_stall_area_tol; once every env is frozen the loop exits early.Hand off to the standard tail. The final
N=256closed loop is periodically arc-length reparameterized and copied into the sharedout_centerlinebuffer without_valid_wp = 1for every env — the same contract as every other generator. The untouched pipeline (constant-spacing resample at0.6·half_width→ XPBD relax → inflate → validity) then runs; feeding it a curve at the tail’s calibrated spacing (rather than the rawN=256growth resolution) is what lifts post-tail yield to the target band.
Math¶
Tangent-point energy (dense, O(N²) pairs per env, constant ±2 circular
exclusion):
where wᵢⱼ is the tangent-point wedge term (the signed distance of xⱼ from the
tangent line at xᵢ), wᵢ/wⱼ are lumped-edge quadrature weights, and α =
repulsive_alpha, β = repulsive_beta.
Obstacle energy (inverse power p = β − α):
summed over live obstacle-ring points o_m with mass/weight m_w (wall 1.0, inner
discs 0.25, zero once deactivated).
Sobolev-preconditioned, length-orthogonal step. The raw gradient g = ∇(E_TP + E_obs +
E_len) is preconditioned by the fractional-Sobolev inverse A⁻¹ (a circular convolution
against a precomputed circulant row — the real-space form of 1/(λₖˢ + ε) on the ring
Laplacian spectrum), then projected to remove the length-increasing component before a
normalized, barycenter-pinned step and a hard rescale back to the ratcheted target length —
the same TP-Sobolev flow as Yu/Schumacher/Crane, adapted with a hard length constraint
(ratchet + rescale) in place of their soft penalty, which the spike found necessary: the
obstacle-energy gradient outweighs a soft length penalty by 10³–10⁵×, so only a hard
constraint reliably grows the curve to target length.
Parameters¶
The following TrackGenConfig fields are owned by the repulsive generator. All other
fields (num_points, scale, half_width, etc.) are shared pipeline parameters
described in the configuration reference.
repulsive_grow_mult_min/repulsive_grow_mult_max(default 4.5 / 5.5)Bounds of the per-env target-perimeter multiplier draw
U(min, max); the grown loop’s target perimeter isgrow_mult · 2π·r_init. Higher → denser mazes at some yield cost.repulsive_domain_frac(default 0.35)Confinement-disc radius as a fraction of the world-scale reference length (
r_dom = repulsive_domain_frac · scale_ref · config.scale). Tighter → richer folds.repulsive_domain_init_ratio(default 4.0)Ratio
r_dom / r_initof the domain radius to the initial seed-circle radius.repulsive_obstacle_count_min/repulsive_obstacle_count_max(default 8 / 12)Bounds of the per-env inner-disc obstacle count
k ~ randi[min, max](~9.9/env at defaults).repulsive_obstacle_radius_min_frac/repulsive_obstacle_radius_max_frac(default 0.02 / 0.045) Inner-disc radius as a fraction of
r_dom.repulsive_ratchet_rate(default 0.012)Per-iteration length-growth factor of the hard ratchet.
<= 0.013holds full yield with folds;>= 0.016collapses the folds (a physical limit of fold formation, not a stall artifact).repulsive_alpha/repulsive_beta(default 3.0 / 6.0)Tangent-point energy exponents; the obstacle inverse power is
p = beta - alpha.repulsive_tau(default 0.4)Normalized flow step size.
repulsive_w_len(default 30.0)Weight of the small inert length regularizer (the hard ratchet + rescale does the actual length enforcement; this is a live secondary nudge).
repulsive_stages(default(64, 128, 256))Coarse-to-fine resolution schedule. Must be non-empty, strictly increasing, each a positive multiple of 4, and its last entry must equal
num_points.repulsive_settle_iters(default 40)Settle-phase iteration budget above the ratchet.
repulsive_resample_every(default 25)Periodic arc-length reparameterization interval (iterations).
repulsive_stall_window(default 16)Iterations between stall checks / early-exit readbacks.
repulsive_stall_area_tol(default 0.05)Freeze an env once it is past target length and its enclosed-area relative change over a stall window is below this tolerance.
repulsive_deactivate_obstacles(defaultTrue)Zero the inner-disc obstacle weights once an env reaches its target length (the wall stays live), closing the disc halos during the settle phase.
What Makes It Distinct¶
repulsive is the only generator that constructs its output by iterative physical
simulation rather than a single deterministic sampling + smoothing pass, and consequently
the only one that is not CUDA-graph-capturable (GeneratorSpec(capturable=False)); see
CUDA Graph — capture, replay, and buffer reuse and Writing a Generator for the capture
contract this opts out of.
Key contrasts:
Foldiest, most serpentine family (compactness ≈ 0.15). The ratcheted overfill forces the curve to buckle into dense multi-lobe circuits threading between the obstacles, well below the compactness of any of the other five generators.
Host-driven control flow. Stage transitions, per-window stall readbacks, and the global early exit are host-side Python, not static Warp launch topology — legal only because the generator is not captured.
~1000× slower. See the warning above; this is the dominant practical tradeoff against the shape richness.
Determinism. Output is byte-identical per device — same config and seeds give the same centerline run-to-run, bit for bit, on both CPU and CUDA. The gradient is hand-written analytic adjoints (a per-vertex gather with no atomics), so there is no nondeterministic float-summation order to amplify through the chaotically sensitive flow. Cross-device equality is not claimed: fp32 rounding differs between CPU and CUDA, so a CPU track and a CUDA track for the same seed are statistically equivalent but not bit-identical to each other. See the module docstring in
track_gen/_src/warp_generate_repulsive.pyfor the full account.
Fallback and Validity¶
repulsive has no generator-local self-intersection fallback: it writes
out_valid_wp = 1 for every environment and relies entirely on the shared post-relax
inflation validity gate (turning number, thickness floor, no NaN, width floor). A
self-crossing born during growth drives the local thickness toward zero, which the gate
catches downstream — no generator-side retry is needed. In the spike’s validated
configuration this yields 64/64 (100%) post-tail, with the XPBD tail doing modest polish
(opening sub-half-width pinches) rather than untangling gross tangles.