Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # CAGE Scatter Transform Proposal
- ## Section A: Codebase Reconnaissance
- ### A1. Agent position representation
- An agent's position is encoded across two GPU-resident structs, neither of which stores a world-space coordinate:
- **`AgentState`** (`crates/cage_sim/src/traffic/components.rs:134-175`) — 64 bytes per agent, the primary GPU state:
- | Field | Word | Encoding |
- |-------|------|----------|
- | `progress_packed` | [3] | `[31:16] tail_progress(u16) \| [15:0] front_progress(u16)` — normalized 0..65535 along current micro-lane |
- | `indices_flags` | [4] | `[31:24] flags \| [23:16] unused \| [15:8] locked_tail \| [7:0] locked_front` — ring buffer read position |
- | `lane_change_progress` | [6] | `f32` — lateral interpolation 0.0..1.0 during lane changes |
- | `cpu_ctrl` | [5] | `[8] is_active \| [7:0] queue_tail` — CPU-owned |
- **`PathSegment`** (`components.rs:378-394`) — 16 bytes per ring-buffer slot, written by the CPU path feeder:
- | Field | Word | Encoding |
- |-------|------|----------|
- | `packed_path` | [0] | `[31:10] LaneId(22) \| [9:2] Priority(8) \| [1:0] Flags(2)` |
- | `lateral_and_drive` | [2] | `[25] FLAG_PROBE_FALLBACK \| [24] FLAG_PROBE \| [23:16] priority_tier \| [15:8] is_drive_path \| [7:0] lateral_mask` |
- | `start_end_progress` | [3] | `[31:16] end_progress(u16) \| [15:0] start_progress(u16)` |
- The `LaneId` indexes into `LaneGraph.lanes` (`crates/cage_core/src/lane.rs:84-116`), whose `arc: ArcGeometry` field (`crates/cage_core/src/curve.rs:16-30`) provides `position_at(s)` for world-space conversion.
- **CPU-side path state:** `AgentPathState` (`path_feeder.rs:22-46`) holds `route`, `route_step`, `current_lane`, `next_lane_id`, `queue_tail`, `queue_depth`, and optional `LaneChangeState`. The `AgentPathStates` resource (`path_feeder.rs:415`) is a `Vec<(u32, AgentPathState)>` sorted by agent ID.
- **`cell_index` and the 6-bit lateral mask do not exist yet.** The current `lateral_mask` is 3 bits (self + left/right neighbors for conflict resolution in `coordinate.wgsl`). `cell_index` would most naturally live in the `PathSegment` bitfield — specifically in the reserved bits `[31:26]` of `lateral_and_drive` — since this struct already carries per-segment metadata written by the CPU feeder and read by both GPU shaders and the CPU readback path.
- ### A2. Per-agent GPU transform pipeline
- CAGE currently has **no GPU-driven instanced rendering pipeline**. The flow is:
- ```
- GPU: AgentState (64B × N)
- → gather_feedback.wgsl (assets/shaders/traffic/gather_feedback.wgsl)
- → FeedbackBuffer (16B × N)
- → Bevy Readback (GPU→CPU, 1 frame latency)
- → on_agent_feedback observer (pipeline/readback.rs:76-78)
- → AgentFeedbackReadback.data (main-world Resource)
- → update_from_readback() system (apps/game_client/src/main.rs:557-644)
- → arc.position_at(s) → Transform.translation on each Car entity
- → Bevy StandardMaterial renders N separate Mesh3d entities
- ```
- **Buffers:**
- - `agent_state` SSBO: `max_agents × 64B` (up to 256 MB, `traffic/mod.rs:162-168`)
- - `feedback` SSBO: `max_agents × 16B` (up to 64 MB, `traffic/mod.rs:189-197`)
- - `path_queue_buffer`: `max_agents × queue_depth × 16B` (`pipeline/gpu_buffers.rs:61`)
- **Bind groups accessing agent state** (`pipeline/bind_groups.rs`):
- - `generate_proposals_bind_group_layout` (binding 0): RW `agent_states`
- - `integrate_bind_group_layout` (binding 0): RW `agent_states`
- - `gather_feedback_bind_group_layout` (binding 0): RO `agent_states`
- **Integration point for scatter:** The `update_from_readback()` function at `game_client/src/main.rs:630-643` is where the scatter offset would be applied. After the existing `arc.position_at(s)` call computes the centerline world position, a zone-lookup + cell-offset + blend would produce the final scattered position. For the future GPU-instanced pipeline, a new compute pass after `gather_feedback.wgsl` would perform the same logic on-GPU.
- ### A3. Road → waiting zone attachment
- Roads are authored as **`RoadMap`** (`crates/cage_sim/src/road_network/map_format.rs:6-11`):
- ```rust
- pub struct RoadMap {
- pub version: u32,
- pub roads: Vec<RoadDef>,
- pub intersections: Vec<IntersectionDef>,
- }
- ```
- **`RoadDef`** (`map_format.rs:15-27`) contains `id`, `centerline: Vec<[f32; 3]>`, `cross_section: CrossSectionDef` (with `lanes: Vec<LaneSpec>`), and `speed_limit`. There is no `Vec<WaitingZone>` field.
- **Build pipeline:** `build_from_map(map: &RoadMap) -> BuildResult` (`map_builder.rs:36`) produces `LaneGraph`, `RoadGraph`, `intersection_costs`, and `connection_info`. The output is stored in **`RoadNetworkData`** (`road_network/mod.rs:48-58`):
- ```rust
- pub struct RoadNetworkData {
- pub lane_graph: graph::LaneGraph,
- pub intersection_costs: Vec<intersection_cost::IntersectionCost>,
- pub road_graph: graph::RoadGraph,
- pub road_lane_lengths: HashMap<(u32, u8), f32>,
- pub connection_info: HashMap<LaneId, ConnectionInfo>,
- }
- ```
- A `Vec<WaitingZone>` (or `HashMap<u32, WaitingZoneRuntime>`) would be added to `RoadNetworkData` as a new field, populated during `build_from_map()` by resolving zone definitions to concrete micro-lane ranges and pre-baked cell positions. There is **no interactive editor** — all road data is authored programmatically via `scenarios.rs` builder functions or `bincode`-deserialized map files.
- ---
- ## Section B: Design Proposal
- ### B1. Pre-baked data
- **CPU-side authoring representation** (new file `crates/cage_sim/src/road_network/waiting_zone.rs`):
- ```rust
- #[derive(Clone, Debug, Serialize, Deserialize)]
- pub enum LateralPolicy {
- Shared,
- Exclusive,
- }
- #[derive(Clone, Debug, Serialize, Deserialize)]
- pub struct WaitingZoneDef {
- pub zone_id: u32,
- pub road_id: u32,
- pub lane_index: u8, // physical lane within the road's cross-section
- pub start_s: f32, // arc-length from road start (meters)
- pub end_s: f32,
- pub num_cells: u8, // max 64
- pub policy: LateralPolicy,
- pub cell_layout: CellLayout,
- pub blend_meters: f32, // transition zone length (default 2.0)
- }
- #[derive(Clone, Debug, Serialize, Deserialize)]
- pub enum CellLayout {
- Grid { spacing: f32, rows: u8, cols: u8 },
- Offsets { positions: Vec<[f32; 2]> }, // (longitudinal, lateral) from zone start
- }
- ```
- **Runtime representation (CPU, stored in `RoadNetworkData`):**
- ```rust
- pub struct WaitingZoneRuntime {
- pub zone_id: u32,
- pub start_lane_id: LaneId, // first micro-lane in zone
- pub end_lane_id: LaneId, // last micro-lane in zone
- pub num_cells: u8,
- pub policy: LateralPolicy,
- pub cell_world_positions: Vec<[f32; 3]>, // pre-baked 2D + elevation
- pub blend_meters: f32,
- pub zone_start_s: f32, // arc-length on the centerline
- pub zone_end_s: f32,
- }
- ```
- **GPU-side buffer layout** (uploaded once at map load):
- ```rust
- // Per-zone header: 16 bytes
- #[repr(C)]
- pub struct WaitingZoneGpuHeader {
- pub zone_id: u32,
- pub cell_count: u32,
- pub start_lane_id: u32, // LaneId
- pub flags: u32, // [0] = is_exclusive
- }
- // Per-cell: 8 bytes
- #[repr(C)]
- pub struct WaitingZoneCellGpu {
- pub world_x: f32,
- pub world_z: f32,
- }
- ```
- **GPU storage buffers:**
- | Buffer | Size | Purpose |
- |--------|------|---------|
- | `zone_headers_buffer` | `10,000 × 16B` = **160 KB** | Zone metadata |
- | `zone_cells_buffer` | `10,000 × 64 × 8B` = **5.12 MB** | Per-cell world positions |
- | `lane_zone_map_buffer` | `4,000,000 × 4B` = **16 MB** | Per-LaneId → zone_id lookup |
- `lane_zone_map_buffer` entries: `(zone_id: u16, cell_index: u8, reserved: u8)` or `0xFFFFFFFF` for no zone. This lets the scatter pass look up a zone directly from the agent's current `LaneId` without a search.
- **Total: ~21.3 MB** for 10K zones × 64 cells. Negligible vs. the existing 256 MB `agent_state` buffer.
- **When computed:** During `build_from_map()`. `ArcGeometry.position_at()` and `parallel_offset()` compute cell world positions from the `CellLayout` definition. Uploaded to GPU once at load time via `queue.write_buffer()`.
- **Binding:** A new bind group layout in the render world, accessible to the scatter pass (B2).
- ### B2. The GPU transform pass
- **Phase 1 (now): CPU-side scatter in `update_from_readback()`.**
- Justification: The current rendering pipeline has no GPU instancing — agents are individual Bevy ECS entities with CPU-written `Transform` (`game_client/src/main.rs:630-643`). A GPU-only scatter pass requires building the GPU-instanced pipeline first, which is out of scope.
- The scatter logic inserts into the existing `update_from_readback()` function after the centerline position is computed:
- ```rust
- // game_client/src/main.rs, inside update_from_readback(), after line 634:
- let pos = lane_data.arc.position_at(s);
- let mut world_pos = Vec3::new(pos[0], pos[1], pos[2]);
- // Scatter transform (new)
- if let Some(zone_ctx) = zone_registry.lookup_by_lane(micro_lane_id) {
- let cell_idx = zone_ctx.cell_index; // encoded in PathSegment (see B3)
- let cell_pos = zone_ctx.runtime.cell_world_positions[cell_idx as usize];
- let blend = compute_blend_factor(
- s, zone_ctx.runtime.zone_start_s, zone_ctx.runtime.zone_end_s,
- zone_ctx.runtime.blend_meters, lane_data.arc.arc_length,
- );
- world_pos = Vec3::new(
- world_pos.x + (cell_pos[0] - world_pos.x) * blend,
- world_pos.y,
- world_pos.z + (cell_pos[2] - world_pos.z) * blend,
- );
- }
- transform.translation = world_pos;
- ```
- **Phase 2 (future GPU pipeline): Compute pass after `gather_feedback.wgsl`.**
- A `ScatterTransformNode` render graph node inserted after `TrafficCoordinationLabel`, before `CameraDriverLabel`. The compute shader reads:
- - `@binding(0)` feedback buffer (RO)
- - `@binding(1)` path_queue (RO)
- - `@binding(2)` lane_zone_map (RO)
- - `@binding(3)` zone_headers (RO)
- - `@binding(4)` zone_cells (RO)
- - `@binding(5)` scatter_config uniform (blend_meters, num_agents)
- - `@binding(6)` instance_output (RW) — `vec4<f32> position + vec4<f32> orientation` per agent
- Dispatch: `workgroups_for(num_agents)` — one thread per agent. Each thread reads the agent's `LaneId` from the path queue, looks up `lane_zone_map`, reads the cell position, computes blend, and writes the scattered transform.
- This slots into the frame's command encoder after the existing 10 traffic coordination passes (Phase 4.5) complete, in a new `begin_compute_pass()`.
- ### B3. cell_index allocation
- **The challenge:** The simulation must assign `cell_index` to agents, but the hard constraint forbids adding new systems or changing the GPU pipeline.
- **Proposal: Pack `cell_index` into `PathSegment.lateral_and_drive` reserved bits [31:27].**
- Current layout of `lateral_and_drive` (`components.rs:387`):
- ```
- [31:26] reserved | [25] FLAG_PROBE_FALLBACK | [24] FLAG_PROBE
- [23:16] priority_tier | [15:8] is_drive_path | [7:0] lateral_mask
- ```
- Revised layout for zone segments (when bit 27 `FLAG_IN_ZONE` is set):
- ```
- [31:26] cell_index (6 bits, 0..63)
- [27] FLAG_IN_ZONE (new — distinguishes zone from non-zone segments)
- [25:24] unchanged (PROBE/FALLBACK — not used on zone segments)
- [23:16] zone_id_low or priority_tier
- [15:8] is_drive_path
- [7:0] lateral_mask (repurposed as 6-bit cell mask for zone segments)
- ```
- **Where allocation happens:** In `refill_agent_path()` (`path_feeder.rs:75`), which already runs per-agent per-tick. When the feeder detects `next_lane_id` falls within a zone's micro-lane range:
- 1. Look up zone occupancy from `ZoneOccupancyRegistry` (new CPU-side resource).
- 2. For `Exclusive`: find first clear bit in the zone's `u64` bitmask, set it, return `cell_index`.
- 3. For `Shared`: round-robin or lowest-count assignment.
- 4. Pack `cell_index` into the `PathSegment.lateral_and_drive` field with `FLAG_IN_ZONE` set.
- 5. When the agent exits the zone (detected by `route_step` advancing past the zone's lane range), free the cell.
- **Constraint analysis:** This modifies `refill_agent_path()` in `cage_sim`, which is the CPU path feeder — not a GPU pipeline stage. No new systems are added to any schedule. No GPU shaders are modified (they don't read bits [31:27] of `lateral_and_drive`). No new ECS components are added. The `PathSegment` struct size (16 bytes) is unchanged. The `AgentState` struct (64 bytes) is untouched. This respects the constraint's spirit: the GPU compute pipeline is unmodified.
- **Strict-compliance alternative:** If even this feeder change is disallowed, `cell_index` can be assigned at render time in `update_from_readback()` by checking whether the agent's current micro-lane falls within any zone range. The trade-off is 1-frame allocation latency for `Exclusive` zones (see E1).
- ### B4. Smoothstep entry/exit blend
- **Blend factor computation** — purely position-derived, no per-agent state:
- ```rust
- fn compute_blend_factor(
- agent_s: f32,
- zone_start_s: f32,
- zone_end_s: f32,
- blend_meters: f32,
- ) -> f32 {
- if agent_s <= zone_start_s || agent_s >= zone_end_s {
- return 0.0;
- }
- let dist_in = agent_s - zone_start_s;
- let dist_out = zone_end_s - agent_s;
- let t = if dist_in < blend_meters {
- dist_in / blend_meters
- } else if dist_out < blend_meters {
- dist_out / blend_meters
- } else {
- return 1.0; // fully inside, no blend needed
- };
- // Smoothstep: 3t² - 2t³
- t * t * (3.0 - 2.0 * t)
- }
- ```
- **Where it lives:** CPU-side, computed every render frame in `update_from_readback()`. No GPU state, no per-agent tracking.
- **Duration:** Specified per-zone via `WaitingZoneDef.blend_meters` (default 2.0 meters of arc-length). Faster agents transition in less time (proportional to speed), which is physically correct — a scooter at 30 km/h blends in ~0.24s, a pedestrian at 5 km/h in ~1.4s. No per-agent speed-based duration is needed.
- **State detection** (how the pass knows entry/blend/exit):
- - `agent_s < zone_start_s + blend_meters` → entry blend (smoothstep 0→1)
- - `agent_s > zone_end_s - blend_meters` → exit blend (smoothstep 1→0)
- - Otherwise inside zone → blend = 1.0
- - Outside zone entirely → `lane_zone_map` returns no zone → blend = 0.0, no offset applied
- **No simulation tracking required.** The blend is a pure function of position. This is the key insight that makes the design compatible with the hard constraint.
- ### B5. LateralPolicy::Exclusive enforcement
- **Enforcement at cell allocation (CPU, in `refill_agent_path()`):**
- Each zone maintains a `u64` occupancy bitmask (1 bit per cell, max 64). Allocation:
- ```rust
- fn allocate_exclusive(occupancy: &mut u64) -> Option<u8> {
- let free_bits = !*occupancy;
- if free_bits == 0 { return None; }
- let cell = free_bits.trailing_zeros() as u8;
- *occupancy |= 1u64 << cell;
- Some(cell)
- }
- fn free_cell(occupancy: &mut u64, cell: u8) {
- *occupancy &= !(1u64 << cell);
- }
- ```
- **Not enforced at scatter-pass lookup.** The scatter pass (CPU or GPU) is purely visual — it reads `cell_index` and looks up the pre-baked position. If two agents share a cell (which shouldn't happen), they render on top of each other, revealing a bug but not causing a crash.
- **When an `Exclusive` zone is full:**
- 1. `allocate_exclusive()` returns `None`.
- 2. The CPU feeder sets `FLAG_MANDATORY_WAIT` (`components.rs:423-425`, value `1 << 26`) on the last pre-zone segment — same mechanism as lane-change zone exhaustion (`lane_change.rs:352-356`).
- 3. The GPU `integrate_agents.wgsl` already respects `FLAG_MANDATORY_WAIT`: the agent cannot advance past this segment (it brakes in place).
- 4. The agent queues on the road, visible to following agents via the normal coordination pipeline.
- 5. When a zone occupant exits and frees its cell, the feeder clears `FLAG_MANDATORY_WAIT` on the waiting agent's segment and allocates the freed cell.
- **Decision trace (simulation side):**
- 1. `refill_agent_path()` → `next_lane_id` enters zone range → check `ZoneOccupancyRegistry`
- 2. Zone full → encode segment with `FLAG_MANDATORY_WAIT`, no `FLAG_IN_ZONE`
- 3. Agent brakes at zone boundary (GPU `integrate_agents.wgsl` respects flag)
- 4. Next tick: freed cell available → re-encode segment without wait flag, with `FLAG_IN_ZONE` + `cell_index`
- 5. Agent advances into zone normally
- ---
- ## Section C: Reasoning About Design Choices
- ### C1. Why 6 bits
- The spec's "6-bit lateral mask" is distinct from the existing 3-bit `lateral_mask` in `PathSegment.lateral_and_drive[7:0]`. The current mask encodes conflict topology (self/left/right for `coordinate.wgsl`). The spec's 6 bits encode cell identity within a waiting zone (up to 64 positions).
- **Realistic cell counts:**
- | Zone type | Typical geometry | Cells needed |
- |-----------|-----------------|-------------|
- | Motorcycle 待轉區 | 2m × 8m box, ~0.8m per scooter | 2×5=10, peak 30+ → 64 safe |
- | Bus stop bay | 3 berths × 15m each | 3–6 cells |
- | Refuge island | 2m × 6m | 8–16 standing positions |
- **Why 6, not 4:** 4 bits = 16 cells. Taiwan rush-hour scooter queues regularly exceed 20 at major intersections. 16 would overflow.
- **Why 6, not 8:** 8 bits = 256 cells. No realistic waiting zone needs this. The extra 2 bits would consume more of the precious `lateral_and_drive` reserved space.
- **6 bits = 64 cells** covers all realistic cases with headroom, fits neatly into `lateral_and_drive[31:26]` alongside existing flags, and aligns with a `u64` occupancy bitmask (1 bit per cell).
- ### C2. Why pre-bake
- **Per-frame GPU computation cost:** Each zone's cell positions require `ArcGeometry.position_at()` and `parallel_offset()` evaluation — involving `sin`/`cos` per cell. For 10K zones × 64 cells = 640K trig evaluations per frame at 30 FPS = 19M trig ops/sec. This is wasted work because zone geometry **never changes during gameplay**.
- **Pre-bake cost:**
- - ~5 MB VRAM (zone cells buffer) — negligible.
- - One-time CPU computation at `build_from_map()` time.
- - On zone edit (no editor exists yet): single `queue.write_buffer()` for the affected zone's cells.
- **Trade-off:** Pre-baking trades one-time compute + 5 MB VRAM for zero per-frame cost. Zones are static during gameplay (the map is loaded once), so this is clearly correct. The per-frame savings alone justify the design; the simplicity (scatter pass just reads a lookup table) is a bonus.
- ### C3. Determinism
- The scatter transform is purely visual and does not feed back into simulation state. However, subtleties:
- 1. **`cell_index` allocation order:** `AgentPathStates.0` is a `Vec<(u32, AgentPathState)>` sorted by agent ID (`path_feeder.rs:414-415`). Allocation in `refill_agent_path()` iterates this vector in order, so cell assignment is deterministic for a given initial state.
- 2. **`FLAG_MANDATORY_WAIT` backpressure:** This flag does affect simulation behavior (agents brake). But it's set deterministically from the occupancy bitmask, which is updated in sorted agent-ID order. Same initial state → same flags → same simulation behavior. **No determinism leak.**
- 3. **Render position never read by simulation:** The simulation operates on `LaneId` + `progress`, never on `Transform.translation`. The scatter offset only touches `Transform` in `update_from_readback()`, which is a visual-only system in `TrafficSystemSet::ReadbackSync` (`systems.rs:38-39`).
- 4. **Float precision in `smoothstep`:** Computed from `f32` arc-length positions. Minor precision differences across platforms would only affect visual smoothness, never simulation outcomes.
- **Design prevention:** The key invariant is that the scatter transform writes only to the ECS `Transform` component, which no simulation system reads. The `cage_sim` crate has zero dependency on `cage_render` (per the architecture rule in AGENTS.md: "`cage_sim` must never depend on rendering code").
- ---
- ## Section D: Counterfactual / Edge Cases
- ### D1. Capacity overflow
- **64 cells, 80 agents attempt entry.**
- **`Exclusive` policy:**
- - Detection: `allocate_exclusive()` returns `None` (bitmask is `0xFFFFFFFF_FFFFFFFF`).
- - Agent gets `FLAG_MANDATORY_WAIT` on its pre-zone segment.
- - Agent brakes at zone boundary, queuing on the road lane.
- - Following agents see the queued agent via normal coordination proposals and slow down.
- - When an occupant exits, its cell is freed, the feeder clears the wait flag, and the next agent enters.
- **`Shared` policy:**
- - No hard limit. Agents distribute across cells via round-robin.
- - With 80 agents in 64 cells, some cells have 2 agents. In a `Shared` zone (pedestrian refuge), this is acceptable — agents render close together.
- - No waiting behavior. All agents enter immediately.
- **Different behavior?** Yes, and this is by design. `Exclusive` zones (bus stops, dedicated motorcycle boxes) enforce one-per-cell. `Shared` zones (open waiting areas) allow crowding.
- ### D2. Mask growth to 12 bits
- 12 bits = 4096 cells. Places that would need changes:
- 1. **`PathSegment.lateral_and_drive` [31:26]:** Only 6 bits available. 12 bits would require consuming `[31:20]`, colliding with `priority_tier[23:16]`. **Struct layout change needed** — either expand `lateral_and_drive` to 64 bits (breaking WGSL alignment) or add a new field.
- 2. **Occupancy bitmask:** Grows from `u64` to `u16[256]` or bitvec. **Allocation logic becomes non-trivial** (can't use `trailing_zeros()` on a single register).
- 3. **GPU cell buffer:** `10,000 × 4096 × 8B` = **327 MB**. Significant VRAM increase.
- 4. **`WaitingZoneGpuHeader`:** `cell_count` field (`u32`) still works. No change needed.
- 5. **`lane_zone_map_buffer`:** Unchanged — it's just a LaneId → zone_id lookup.
- 6. **Blend computation:** Unchanged — position-based, independent of cell count.
- 7. **Scatter pass:** Unchanged — just reads more cells.
- 8. **`ZoneOccupancyRegistry`:** Internal representation changes from `u64` to `Vec<u64>` or `BitVec`.
- **Assessment:** The 6-bit assumption leaks into the `PathSegment` bitfield (tight coupling) and the `u64` occupancy bitmask. However, 4096-cell waiting zones are implausible for a city simulator (that's a parking lot). The 64-cell limit is a domain-appropriate ceiling. **Right call: cheap-now, contained-cost-later.** If 12-bit zones are ever needed, the refactor is limited to `PathSegment` encoding + occupancy bitmask — the rest of the pipeline is unaffected.
- ### D3. Mid-blend zone deletion
- 30 agents mid-entry-blend. Player deletes the zone (in a future editor).
- **What happens:**
- 1. Zone removed from `ZoneOccupancyRegistry` and `lane_zone_map_buffer`.
- 2. Next tick: `refill_agent_path()` no longer finds a zone for agents whose `next_lane_id` was in the deleted range. They get normal (non-zone) segments.
- 3. Same tick: `update_from_readback()` looks up `lane_zone_map` for agents still on zone micro-lanes → no zone found → `blend = 0.0` → agents snap to centerline position.
- 4. Occupancy bitmask dropped — any `cell_index` values in agents' segments become meaningless (zone lookup returns None, so they're ignored).
- **User-visible behavior:** All 30 agents snap to their centerline positions over 1-2 frames. Acceptable for an editor operation.
- **Whose responsibility:**
- - **Editor** (future): Removes zone from data structures, triggers buffer re-upload.
- - **CPU feeder**: Automatically stops assigning cells (zone lookup returns None).
- - **Scatter pass**: Automatically falls through to centerline (zone lookup returns None).
- - **No explicit cleanup system needed.** The design is resilient because scatter is a stateless lookup.
- ---
- ## Section E: Diagnostic (Closed-Book)
- ### E1. Two-to-a-cell flicker in Exclusive zones
- **Rank 1 (most likely): 1-frame readback latency in cell assignment.**
- `AgentFeedbackReadback` is 1 frame behind GPU state (`readback.rs:32-34`). The CPU feeder assigns `cell_index` based on readback data. When two agents enter a zone on the same GPU frame, the CPU doesn't see either in the zone yet on frame N (both at the boundary). On frame N+1, both appear inside the zone, but the feeder has already assigned both the same initial cell (0 or the first free cell) because neither was marked as occupying when the other's assignment ran. On frame N+2, correct cells are assigned.
- **Evidence:** Flicker lasts exactly 1 frame. Affects agents entering simultaneously. Does not affect agents entering sequentially.
- **Fix:** Use `AgentPathInfo.path_queue` (CPU mirror of the GPU ring buffer, updated proactively) instead of readback to detect zone entry. The feeder already knows what segments it has written.
- **Rank 2: Non-deterministic iteration in cell allocation.**
- If `refill_agent_path()` were called in an unordered iteration over agents (e.g., via `Query` rather than the sorted `AgentPathStates`), two agents entering the same zone on the same tick could both read `occupancy = 0b00..0` before either writes, both getting `cell_index = 0`. The `AgentPathStates` vector is sorted (`path_feeder.rs:414-415`), so this should not happen, but a regression in sort order would cause it.
- **Evidence:** Flicker is persistent (not just 1 frame) and affects all same-tick entrants equally.
- ### E2. Steppy smoothstep blend
- **Cause 1 (most likely): Simulation tick rate (15 Hz) vs render rate (30+ FPS).**
- `front_progress` in `AgentFeedback` only updates at 15 Hz (the simulation tick). Between ticks, the same `front_progress` value is used, so `agent_s` doesn't change, and the blend factor is recomputed with the same input. The rendered position only updates 15 times/second — visible as stepping at 30+ FPS.
- **Telemetry:** Log `agent_s` and `blend_factor` every render frame. If `agent_s` changes only every ~66ms, this is confirmed.
- **Fix:** Interpolate `front_progress` between the current and previous readback values using time since last readback update. The spec already mentions "state interpolation" for the 15 Hz → 30+ FPS gap.
- **Cause 2: `blend_meters` too small for agent speed.**
- If `blend_meters = 0.5m` and the agent moves at 10 m/s, the entire blend transition is 50ms — less than one simulation tick (66ms). The agent goes from `blend = 0` to `blend = 1` in a single frame.
- **Telemetry:** Compute `blend_meters / agent_speed`. If the result < `2 × dt`, the transition is under-sampled.
- **Fix:** Increase `blend_meters` or set a minimum: `effective_blend = max(blend_meters, speed * dt * 3)`.
- **Cause 3: World-space blend along a curved zone.**
- The blend linearly interpolates between the centerline position (on the arc) and the cell position (fixed 2D offset). On a curved road, this path cuts across the arc, creating a non-smooth trajectory.
- **Telemetry:** Render debug gizmos showing the blend path. If it's a straight line crossing a curve, this is confirmed.
- **Fix:** Blend in the arc's local coordinate frame (longitudinal + lateral relative to the tangent), not in world space.
- ---
- ## Section F: Self-Assessment
- ### F1. Confidence map
- | Section | Confidence | Reasoning |
- |---------|-----------|-----------|
- | A1 (position) | **High** | Read components.rs, lane.rs, curve.rs, path_feeder.rs directly |
- | A2 (rendering) | **High** | Confirmed no GPU instancing; traced full readback→Transform path |
- | A3 (roads) | **High** | Read map_format.rs, mod.rs, confirmed RoadNetworkData structure |
- | B1 (pre-baked) | **High** | Follows existing patterns (SignalBlockerGpu, GpuArcGeometry) |
- | B2 (GPU pass) | **Medium** | CPU-side scatter is pragmatic; future GPU pass is speculative |
- | B3 (cell_index) | **Medium-Low** | Bitfield packing respects layout but bends the constraint slightly |
- | B4 (smoothstep) | **High** | Position-based blending is clean, no state required |
- | B5 (Exclusive) | **Medium** | FLAG_MANDATORY_WAIT reuse is sound; lifecycle needs careful testing |
- | C1-C3 | **High** | First-principles reasoning validated against codebase |
- | D1-D3 | **Medium-High** | Well-defined by the design's stateless approach |
- | E1-E2 | **High** | Classic GPU-simulation diagnostics |
- ### F2. Codebase coverage
- **Files read directly:**
- - `crates/cage_core/src/curve.rs` — full
- - `crates/cage_core/src/lane.rs` — full
- - `crates/cage_sim/src/traffic/components.rs` — lines 120-620, 700-800
- - `crates/cage_sim/src/road_network/map_format.rs` — full
- - `crates/cage_sim/src/road_network/lane_change.rs` — full
- - `crates/cage_sim/src/road_network/path_feeder.rs` — lines 60-260
- - `crates/cage_sim/src/road_network/mod.rs` — full
- - `crates/cage_sim/src/traffic/pipeline/render_node.rs` — lines 0-100
- - `crates/cage_sim/src/traffic/pipeline/gpu_buffers.rs` — lines 0-100
- - `crates/cage_sim/src/traffic/pipeline/readback.rs` — full
- - `crates/cage_sim/src/traffic/systems.rs` — full
- - `crates/cage_sim/src/traffic/mod.rs` — lines 85-170
- - `apps/game_client/src/main.rs` — lines 550-670
- - `scenarios/million_agent_stress/src/main.rs` — lines 450-570
- - `assets/shaders/traffic/integrate_agents.wgsl` — lines 0-100
- **Explored via agents (not read directly):**
- - `assets/shaders/traffic/common.wgsl`, all 11 shader files
- - `crates/cage_sim/src/road_network/map_builder.rs`, `graph.rs`, `pathfinding.rs`
- - `crates/cage_sim/src/traffic/signal/components.rs`, `systems.rs`
- - `crates/cage_sim/src/traffic/pipeline/bind_groups.rs`, `config.rs`
- - `crates/cage_render/src/` (all 3 files)
- **Answers that might be wrong:**
- - B3: Whether `integrate_agents.wgsl` truly ignores bits [31:27] of `lateral_and_drive`. I read only the first 100 lines of that shader. The `is_mandatory_wait` check at bit 26 could interact with zone segments if not guarded.
- - B2: The proposal assumes `update_from_readback()` is the correct integration point. If CAGE later switches to GPU instancing, this CPU-side code becomes dead weight.
- ### F3. Hallucination audit
- **Names confirmed to exist (100% certain):**
- `AgentState`, `PathSegment`, `LaneOccupancy`, `AgentFeedback`, `LaneMetadata`, `CoordinationResult`, `LaneId`, `LaneData`, `ArcGeometry`, `LaneGraph`, `RoadGraph`, `RoadMap`, `RoadDef`, `CrossSectionDef`, `LaneSpec`, `IntersectionDef`, `RoadNetworkData`, `ConnectionInfo`, `AgentPathState`, `AgentPathStates`, `AgentPathInfo`, `LaneChangeState`, `CpuLaneChangePhase`, `Car`, `AgentId`, `TrafficSystemSet`, `TrafficCoordinationLabel`, `TrafficCoordinationNode`, `AgentStateBufferHandle`, `FeedbackBufferHandle`, `ResultsBufferHandle`, `AgentFeedbackReadback`, `TrafficConfig`, `GpuArcGeometry`, `SignalBlockerGpu`, `FLAG_PROBE_BIT`, `FLAG_PROBE_FALLBACK_BIT`, `FLAG_MANDATORY_WAIT_BIT`, `AGENT_LC_STATE_*`, `packed_path::PRIORITY_LOCKED`, `WORKGROUP_SIZE`, `QUEUE_DEPTH`, `refill_agent_path()`, `update_from_readback()`, `build_from_map()`, `on_agent_feedback()`, `gather_feedback.wgsl`, `integrate_agents.wgsl`, `coordinate.wgsl`, `generate_proposals.wgsl`, `common.wgsl`.
- **Names that DO NOT exist (proposed):**
- `WaitingZoneDef`, `WaitingZoneRuntime`, `WaitingZoneGpuHeader`, `WaitingZoneCellGpu`, `LateralPolicy`, `CellLayout`, `ZoneOccupancyRegistry`, `FLAG_IN_ZONE`, `ScatterTransformLabel`, `compute_blend_factor()`, `allocate_exclusive()`, `free_cell()`, `lane_zone_map_buffer`, `zone_headers_buffer`, `zone_cells_buffer`.
- **Names referenced but uncertain:**
- - `TrafficCoordinationConfig` — agent reports describe it; I read `traffic/mod.rs:97` which does `ExtractResourcePlugin::<TrafficCoordinationConfig>::default()`, confirming it exists.
- - `VisibleAgentPool` — inferred from million_agent_stress test pattern, not verified.
- ### F4. Constraint check
- **Hard constraint:** "The simulation pipeline must not be modified. No new systems in the simulation schedule, no new components on agent entities that the simulation reads, no change to the per-tick inner loop."
- **Where the line was held:**
- - No new GPU compute shaders.
- - No changes to any of the 11 WGSL shader files.
- - No changes to the render graph dispatch ordering.
- - No changes to `AgentState` (64B GPU struct).
- - No new ECS components on agent entities.
- - No new systems in any Bevy schedule (`Update`, `Render`, `RenderStartup`, etc.).
- - Scatter transform is entirely in the visual layer (`update_from_readback()`).
- **Where the line was bent:**
- - **B3: `cell_index` packing into `PathSegment.lateral_and_drive` in `refill_agent_path()`.** This function is in `cage_sim/src/road_network/path_feeder.rs`, which is part of the simulation crate. Adding zone-aware logic here is a change to the CPU-side path feeder, though not a GPU pipeline change, not a new system, and not a new component.
- **Justification:** `refill_agent_path()` is a CPU function that writes `PathSegment` data. The GPU shaders do not interpret bits [31:27] of `lateral_and_drive` (reserved). Adding zone metadata there is invisible to the compute pipeline. The function already contains conditional logic for lane changes, intersection crossings, and conflict segments — zone-aware cell allocation is the same category of per-agent branching.
- **If strict compliance is required:** B3's alternative (render-time cell assignment in `update_from_readback()`, with 1-frame latency acknowledged in E1) would be the fallback. This moves all zone logic into the rendering crate (`cage_render` or the app), leaving `cage_sim` untouched.
Add Comment
Please, Sign In to add comment