Generating Gate Sequences for Drone Courses

For drone-style courses where road width is irrelevant, track_gen provides GateGenerator. It emits gate centers and orientations directly from the centerline-generator anchors and skips constant-spacing resampling, XPBD relaxation, and inflation — making it faster than the full track pipeline.

See also

Gate courses are 2.5D: this tutorial covers the plan-view layout and the runtime loop, while 3D gate courses covers altitude — the four z_profile families, the gate_align modes, the 3D localizer, and frame collision.

Prerequisites

Install track_gen with the dev extra:

uv pip install -e ".[dev]"

Building the Gate Config

GateGenConfig controls every gate-generation parameter. The key fields are generator (which centerline generator to use), gate_ordering, num_envs, max_gates, gate_width, gate_radius, and gate_solve_iters.

import warp as wp
wp.init()

from track_gen import GateGenConfig, GateGenerator, PerEnvSeededRNG

E, device = 64, "cuda"
config = GateGenConfig(
    generator="bezier",
    gate_ordering="random_pairs",
    num_envs=E,
    max_gates=32,
    gate_width=0.4,
    gate_radius=0.025,
    gate_solve_iters=8,
    device=device,
)
rng = PerEnvSeededRNG(seeds=0, num_envs=E, device=device)

Generating the Gate Sequence

gates = GateGenerator(config, rng).generate()

position = wp.to_torch(gates.position).view(E, config.max_gates, 3)
tangent  = wp.to_torch(gates.tangent).view(E, config.max_gates, 3)
valid    = wp.to_torch(gates.valid).bool()

GateGenerator pre-allocates all buffers in __init__, exactly like TrackGenerator. On cuda, the first generate() captures the pipeline into a wp.Graph; subsequent calls replay it with updated seeds.

Reading the Output Fields

Field

Shape

Meaning

position

[E, G, 3]

vec3f gate centers (G = max_gates). Slots at index i >= count[e] hold NaN padding.

tangent

[E, G, 3]

vec3f unit tangent at each gate (direction of traversal).

orientation

[E, G] quatf

Roll-free gate pose wp.quatf(x, y, z, w) with local x = gate forward, y = left, z = up. Replaces the old planar normal field; recover the left axis as wp.quat_rotate(orientation[e, i], wp.vec3f(0, 1, 0)).

half_size

[E, G] float32

Square-opening half-extent (0.5 * gate_width) for each gate.

left, right

[E, G, 3]

vec3f gate endpoints: position ± half_size * left_axis. With the default gate_width=0.0 (hence half_size=0) these collapse onto the gate center (point gates).

valid

[E] bool

Per-sequence validity. A positive gate_width additionally invalidates any sequence whose gate bars cross.

count

[E] int

Real gates per environment. Slots at index i >= count[e] are NaN-padded.

Slicing Real Gates

Like Track, the GateSequence arrays are NaN-padded past each environment’s real gate count. Use count to slice valid gates:

count = wp.to_torch(gates.count)

n0 = int(count[0])
pos_env0  = position[0, :n0]   # real gate centers for env 0
tan_env0  = tangent[0,  :n0]   # real gate tangents for env 0

Gate Figure

../_images/readme-gate-strip.png

Gate self-collision relaxation. Top: raw anchors (gate_solve_iters=0); bottom: after the solve, with gate tangents and gate_width bars.

Gate Ordering per Generator

The ordering of gates in the output sequence is controlled by GateGenConfig(gate_ordering=...). Not all orderings are supported by every generator:

Generator

Supported orderings

"bezier"

"ccw", "random_pairs"

"hull"

"ccw", "random_pairs"

"polar"

"ccw", "raw"

"voronoi"

"ccw", "raw"

"checkpoint"

"ccw", "raw"

Note

The "ccw" ordering name is kept for API compatibility. Its current centroid-angle convention is clockwise in standard xy coordinates.

The gate_solve_iters Collision Behavior

Gate centers are treated as disks with radius gate_radius. The center spacing target between adjacent gates is 2 * gate_radius. Setting gate_solve_iters > 0 runs up to that many iterations of a deterministic pairwise push that separates overlapping gate disks before recomputing tangents.

Set gate_solve_iters=0 to inspect the raw anchors before the collision solve. The anchors are still ordered and bounding-box normalized — they are just not spread apart yet, so close gates may overlap.

When gate_solve_iters > 0, the collision solve may expand the final bounding box when necessary to satisfy the requested gate_radius. A positive gate_width causes any sequence whose gate bars still cross after the solve to be marked invalid.

See also

Gate relaxation — collision solve documents the gate collision solve in depth — the sphere model and 2 * gate_radius target, the per-env Gauss-Seidel semantics, the determinism of the coincident-gate tie-break, and its behaviour under graph capture.

Note

max_gates must be at least the chosen generator’s reachable gate count. min_gates rejects a config that the generator cannot satisfy. Both bounds are checked at GateGenerator construction time.

Buffer Reuse and Snapshots

The same GateSequence instance and its underlying Warp buffers are reused on every generate() call. For an independent snapshot use gates.clone():

snapshot = gates.clone()   # independent copy; safe to keep across generate()

Flying Through the Gates: Progress and Post Collision for RL

Generation gives you the gate geometry; an RL or controller loop also needs to know, each step, did the drone just clear the next gate? and did it clip a gate post? The Course facade bundles gate generation with the checkpoint/progress tracker and an optional gate-post collision checker in one object, so you never wire those together by hand. Its lifecycle is construct → bind → generate → step / reset — identical to track mode, only the config and the contact type differ.

In mode="gates" the gates themselves become the checkpoints (CheckpointSet.from_gates → ProgressTracker), so there is no checkpoint_spacing — passing it is rejected, as is the track-mode collision= option. Gate-post collision is opt-in via post_radius > 0: a DiscChecker over the gate-bar endpoints (left/right), with the posts array rebuilt device-side on every regeneration. It requires gate_width > 0 (a width-0 gate has no bar and no posts to hit).

Build a Course in gates mode, bind the sim’s own state buffers (they are read in place every step), generate the batch, then in the step loop read the progress events for gate rewards and contacts (a DiscContact here) for a post-collision penalty:

import numpy as np
import warp as wp
wp.init()

from track_gen import GateGenConfig
from track_gen.course import Course, CourseConfig

E, device = 4, "cpu"

course = Course(CourseConfig(
    mode="gates",
    gen=GateGenConfig(num_envs=E, gate_width=0.4, gate_radius=0.025,
                      max_gates=32, device=device),
    seeds=7,
    post_radius=0.03,          # > 0 enables DiscChecker gate-post collision;
                               # 0 (default) = progress-only. No collision= /
                               # checkpoint_spacing here — those are track-mode.
))

# Bind the sim's own buffers: position (vec3f) drives gate progress;
# orientation (quatf) + half_extents (vec2f) are the oriented box tested
# against the posts. The sim writes these in place each step. (With
# post_radius == 0, bind position alone.)
position     = wp.zeros(E, dtype=wp.vec3f, device=device)
orientation  = wp.array(np.tile([0.0, 0.0, 0.0, 1.0], (E, 1)).astype(np.float32),
                        dtype=wp.quatf, device=device)   # identity quats
half_extents = wp.array(np.full((E, 2), 0.01, np.float32),
                        dtype=wp.vec2f, device=device)
course.bind(position=position, orientation=orientation, half_extents=half_extents)

seq = course.generate()        # whole batch + posts rebuild + progress reset

for step in range(40):
    # sim.step() writes `position` (and `orientation`) in place here.

    res    = course.step()                     # events + contacts, no args
    passed = res.events.passed.numpy()         # [E] int32: cleared a gate this step
    dist   = res.events.dist_to_next.numpy()   # [E] float32: distance to next gate
    hit    = res.contacts.hit.numpy()          # [E] int32: 1 == box touched a post
    disc   = res.contacts.disc.numpy()         # [E] int32: deepest post, -1 == none

    reward = 10.0 * passed - 5.0 * hit         # gate bonus, post penalty (shaping)

# Per-env respawn on the SAME course: clear progress only where mask[e] == 1.
done = np.zeros(E, np.int32)
done[0] = 1
course.reset(wp.array(done, dtype=wp.int32, device=device))

# New courses for everyone: whole-batch regenerate + full progress reset.
# The posts are rebuilt device-side onto the new gates automatically.
course.generate(seeds=123)

res.events gives the progress signals — passed for a gate-clear bonus, dist_to_next for a negative-delta-distance shaping term, and progress for the total gates cleared since the last reset (next_checkpoint is the current target gate). res.contacts is a DiscContact (not the BoxContact of track mode): hit is the reward/termination signal, disc the deepest-penetrating post (gate = disc // 2 since posts interleave left/right), and depth / nearest drive a contact response. With post_radius == 0 the course is progress-only and res.contacts is None.

Post collision backend

Gates mode has no out-of-bounds band — there is nothing to leave — so the only collision is the optional gate posts. post_radius > 0 builds a DiscChecker over the interleaved left/right bar endpoints; NaN padding past each env’s real gate count carries over and NaN posts are skipped, so no per-env count bookkeeping is needed. See Out-of-bounds & obstacle collision for the disc-obstacle checker (the same one makes cones physical) and Checkpoints & progress for the checkpoint/reward contract that gates reuse.

CUDA-graph composability

step() and reset() are warp-native and capture-ready: flip the single shared capture flag with track_gen.set_capturing(True) and the whole step — gate progress update plus post-collision query — records into your own sim graph alongside the physics. Keep writing into the SAME bound buffers after capture (rebinding leaves the captured graph reading the old pointers). See Integrating the CUDA Graph into a Batched RL Sim Loop for the capture-and-replay pattern.

Full Example

import warp as wp
wp.init()

from track_gen import GateGenConfig, GateGenerator, PerEnvSeededRNG

E, device = 64, "cuda"
config = GateGenConfig(
    generator="bezier",
    gate_ordering="random_pairs",
    num_envs=E,
    max_gates=32,
    gate_width=0.4,
    gate_radius=0.025,
    gate_solve_iters=8,
    device=device,
)
rng   = PerEnvSeededRNG(seeds=0, num_envs=E, device=device)
gates = GateGenerator(config, rng).generate()

position = wp.to_torch(gates.position).view(E, config.max_gates, 3)
tangent  = wp.to_torch(gates.tangent).view(E, config.max_gates, 3)
left     = wp.to_torch(gates.left).view(E, config.max_gates, 3)
right    = wp.to_torch(gates.right).view(E, config.max_gates, 3)
valid    = wp.to_torch(gates.valid).bool()
count    = wp.to_torch(gates.count)

n0 = int(count[0])
print(f"env 0: {n0} gates, valid={bool(valid[0])}")
print("gate centers:", position[0, :n0])