Skip to content

feat(viz): ray-batch sensor API + march_batch entry - #68

Merged
WomB0ComB0 merged 2 commits into
mainfrom
feat/webgpu-ray-batch
Apr 28, 2026
Merged

WomB0ComB0 merged 2 commits into
mainfrom
feat/webgpu-ray-batch

Conversation

@WomB0ComB0

@WomB0ComB0 WomB0ComB0 commented Apr 28, 2026 •

Copy link
Copy Markdown
Member

Summary

PR #3 of the WebGPU raymarcher direction. Introduces the ray-batch API
— a uniform Ray/RayHit wire format and a new march_batch compute
entry that walks the same brick-map DDA the camera path uses, but from
arbitrary ray sources.

This is the unification that pays off in PR #4+: LiDAR, mesh-link
line-of-sight against terrain, drone collision probes, and IR sensors
all become "different ray sets dispatched against the same kernel."

Zero impact on the production viz: still hidden behind /spike.html,
production build excludes everything (no multi-page input).

Files added

  • client/webgpu/rays.ts — Ray (48 B) / RayHit (32 B) packing helpers,
    mask flag constants (MASK_OBSTACLES + reserved MASK_DENSITY,
    MASK_TERRAIN_SDF), hit flag constants (HIT_HIT, HIT_OBSTACLE +
    reserved HIT_VOLUME, HIT_TERRAIN), and createRayBuffer /
    createHitBuffer / writeRay / readHit for host-side I/O.

Files modified

  • client/webgpu/shaders/march.wgsl

    • Replace Hit with RayHit (now carries t, plus flags and
      material).
    • Add Ray struct + mask/flag constants in sync with rays.ts.
    • Add @binding(5) / @binding(6) for the rays / hits arrays.
      Camera bindings 0..4 are untouched; auto-derived layouts pick up
      only what each entry actually references.
    • Refactor dda() to populate and return RayHit. The camera
      entry adapts its hit check to (h.flags & HIT_HIT) != 0u.
    • Add @compute @workgroup_size(64,1,1) march_batch — one thread
      per Ray, honors per-ray max_t (hits past max_t collapse to
      misses, matching the LiDAR/LoS use case).
  • client/spikeMain.ts — adds a one-shot startup probe that fires a
    single ray straight down through the terrain center, reads the hit
    back via mapAsync, and console.logs t/material/flags/normal.
    Proves the wire format and march_batch work end-to-end.

  • client/vite-env.d.ts — shim GPUMapMode runtime constants. TS 6's
    lib.dom declares the type but not the runtime object, same pattern
    as the previously-shimmed GPUBufferUsage and GPUTextureUsage.

Test plan

  • npm run typecheck passes
  • npm run build passes
  • dotnet build -c Release passes (pre-push hook)
  • Visit /spike.html via npm run dev; check the browser console
    for the [spike] sensor probe log line. Expected: isHit: true,
    t ≈ N - heightmap_height_at_center, normal: [0, 1, 0].
  • Render path still draws the heightmap identically to PR feat(viz): hierarchical brick-map DDA for /spike.html #67.

Why this matters for ResQ Viz

PR #4 will be the first user-visible feature of this whole arc:
mesh-link line-of-sight against terrain. It dispatches one Ray per
drone-pair (origin = drone A, direction = normalize(B − A), max_t =
distance), reads back the hits, and modulates the existing line-segment
opacity. Almost free now that march_batch exists.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added batched sensor ray computation for more efficient multi-ray detection and analysis.
    • Added a single-ray debug path to test/inspect ray results interactively.
  • Improvements

    • Hit results now include richer, flag-based reporting (e.g., hit vs. obstacle) for clearer visualization and filtering.
    • Improved bounds and max-range handling to reduce false positives.
  • Chores

    • Improved WebGPU mapping/compatibility support.

PR #3 of the WebGPU raymarcher direction. Introduces the ray-batch API
that PR #4+ will dispatch from many sources (LiDAR, mesh-link line-of-
sight, drone collision probes) — all walking the same brick-map DDA
the camera path uses.

New file:
- client/webgpu/rays.ts — Ray (48 B) / RayHit (32 B) packing helpers,
  mask flag constants (MASK_OBSTACLES + reserved DENSITY/SDF), and
  hit flag constants (HIT_HIT/HIT_OBSTACLE + reserved VOLUME/TERRAIN).

Modified:
- client/webgpu/shaders/march.wgsl
  - Replace `Hit` with `RayHit` (carries t now, plus flags + material).
  - Add `Ray` struct + mask/flag constants in sync with rays.ts.
  - Add @binding(5)/(6) for rays/hits arrays (camera bindings 0..4
    untouched; auto-derived layouts pick up only what each entry uses).
  - Refactor dda() to populate and return RayHit; the camera entry
    adapts its hit check to (h.flags & HIT_HIT) != 0u.
  - Add @compute @workgroup_size(64,1,1) march_batch — one thread per
    Ray, honors per-ray max_t (hits past max_t collapse to misses).

- client/spikeMain.ts — adds a single-ray probe at startup that fires
  straight down through the terrain center, reads the hit back, and
  console.logs t/material/flags/normal. Demonstrator that the wire
  format and the new compute entry work end-to-end.

- client/vite-env.d.ts — shim GPUMapMode runtime constants (TS 6's
  lib.dom omits these, same pattern as GPUBufferUsage/GPUTextureUsage).

Validation: npm run typecheck and npm run build both pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 28, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a WebGPU sensor-batch ray-marching path: CPU-side ray/hit packing helpers and types, a new march_batch compute kernel and shader output format, a debug single-ray dispatch in the spike entry, and a small ambient GPUMapMode declaration.

Changes

Cohort / File(s) Summary
Ray Buffer API
src/ResQ.Viz.Web/client/webgpu/rays.ts, src/ResQ.Viz.Web/client/vite-env.d.ts
New CPU helpers and types for packing/unpacking Ray and RayHit buffers (RAY_BYTES, RAY_HIT_BYTES, createRayBuffer, createHitBuffer, writeRay, readHit), mask/flag constants, and added GPUMapMode.READ/WRITE ambient constants.
Batch Marching Shader
src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl
Replaced boolean Hit with RayHit { t, material, flags, normal }; updated dda to accept max_t and return RayHit; added @compute fn march_batch with storage bindings for rays and hits; uses flag bits for hit/obstacle detection and enforces per-ray max_t.
Debug Integration
src/ResQ.Viz.Web/client/spikeMain.ts
Adds a debug path that builds a single Ray, writes it to GPU storage, dispatches march_batch, copies back the RayHit, decodes via readHit, and logs hit/obstacle flags.

Sequence Diagrams

sequenceDiagram
    participant Client as Client (spikeMain)
    participant GPU as WebGPU
    participant Shader as Compute Shader (march_batch)
    participant Buffers as GPU Buffers

    Client->>Client: createRayBuffer(1) & writeRay(origin, dir, maxT, mask)
    Client->>Buffers: create/upload ray storage buffer
    Client->>Buffers: create hit storage buffer
    Client->>GPU: submit compute pass & dispatchWorkgroups(1,1,1)
    GPU->>Shader: execute march_batch
    Shader->>Buffers: read rays[gid]
    Shader->>Shader: dda(ray.ro, ray.rd, ray.max_t) -> RayHit
    Shader->>Buffers: write hits[gid]
    GPU->>Client: mapAsync(hits, GPUMapMode.READ)
    Client->>Client: readHit -> parse flags/t/material/normal -> log
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I packed a ray with careful care,

Sent it down through GPU air,
The marcher ran, the flags did sing,
One hit, one hop, a tiny ping,
Buffers hummed — the rabbit's glad to share.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main changes: introducing a ray-batch sensor API and a new march_batch compute entry point for the WebGPU visualization system.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webgpu-ray-batch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ResQ.Viz.Web/client/webgpu/rays.ts`:
- Around line 72-89: writeRay lacks bounds checks so bad indices silently
corrupt/ignore data (and readHit currently masks out-of-range reads with "??
0"); update writeRay (and similarly the corresponding write/read functions
around the 108-120 block) to validate the computed byte/element offset before
writing or reading: compute o = i * 12 and assert that o + requiredMaxIndex <
views.f.length and o + requiredMaxIndex < views.u.length (or similar) and throw
a RangeError (or explicit error) when out of range so invalid ray/hit indices
fail fast instead of producing silent all-zero misses; reference functions:
writeRay and readHit.
- Around line 72-89: The writeRay helper currently writes direction as-is which
breaks distance math if callers pass non-unit directions; in writeRay (using
RayBufferViews, views.f and views.u) compute the direction length, if length is
zero reject the ray (e.g., set mask to 0 or skip) otherwise normalize the
direction written into views.f (direction components at f[o+4..o+6]) and scale
maxT by the original length before storing it to f[o+8] so march.wgsl’s
comparisons remain correct; ensure you still write the origin (f[o+0..o+2]) and
mask (u[o+9]) after these adjustments.

In `@src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl`:
- Around line 257-276: The code only bounds-checks the ray index against
arrayLength(&rays) before writing hits[i], which can overrun a shorter hit
buffer; update the guard so the kernel verifies i is within both arrays (e.g.,
check i < arrayLength(&rays) && i < arrayLength(&hits) or return early if i >=
arrayLength(&hits)) before reading rays[i] or writing hits[i], keeping the
existing logic around dda(r.origin, r.direction), h.flags/HIT_HIT, and max_t
handling unchanged.
- Around line 263-276: The march shader currently records obstacle hits even
when the ray's r.mask excludes obstacles; update the post-dda handling so before
accepting an obstacle hit you check r.mask against the obstacle bit and treat it
as a miss if the mask doesn't include obstacles: after calling dda(r.origin,
r.direction) and before setting hits[i], if (h.flags & HIT_OBSTACLE) is set but
(r.mask & OBSTACLE_BIT) == 0u (or equivalent mask constant used in your code),
clear h.t, h.material, h.flags and h.normal (same way you do for max_t) so
obstacle voxels are ignored when r.mask excludes them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13fc998c-d143-4f97-88c5-1c747967ed68

📥 Commits

Reviewing files that changed from the base of the PR and between bc22d1f and 53d9af3.

📒 Files selected for processing (4)
  • src/ResQ.Viz.Web/client/spikeMain.ts
  • src/ResQ.Viz.Web/client/vite-env.d.ts
  • src/ResQ.Viz.Web/client/webgpu/rays.ts
  • src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl

Comment thread src/ResQ.Viz.Web/client/webgpu/rays.ts
Comment thread src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl Outdated
Comment thread src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a sensor-batch API for WebGPU, enabling the processing of multiple rays in a single compute pass. It introduces TypeScript utilities for ray and hit data management, updates the march.wgsl shader with a march_batch entry point, and provides a demonstration in spikeMain.ts. The review feedback suggests documenting the requirement for normalized direction vectors and optimizing the DDA traversal by allowing early termination based on the maximum ray distance.

Comment thread src/ResQ.Viz.Web/client/webgpu/rays.ts
Comment thread src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl Outdated
march.wgsl:
- MAJOR: bounds-check both rays AND hits buffers in march_batch.
  Previously only checked arrayLength(&rays), so a host-side mismatch
  where the hit buffer was shorter than the ray buffer would overrun.
- MAJOR: respect r.mask. A ray with MASK_OBSTACLES unset (or mask=0)
  no longer reports HIT_OBSTACLE — it short-circuits to a zero RayHit
  before consulting the brick map. Future DENSITY/TERRAIN_SDF masks
  will branch here too.
- PERF: thread max_t into dda() so it can early-exit during traversal
  rather than post-filtering at the end. Pre-loop guard covers the
  trivially-out-of-range case (t_enter > max_t — short-range LiDAR
  rays skip the loop entirely). Top-of-loop guard covers empty-cell
  strides that cross the bound mid-walk. Camera entry passes 1e30 as
  the sentinel "no bound" max_t.

rays.ts:
- writeRay/readHit throw RangeError on out-of-bounds index instead of
  silently writing past or returning all-zero misses. The previous
  `?? 0` fallback in readHit made packing/count bugs look like
  legitimate "no hit" results.
- writeRay JSDoc clarifies that `direction` must be a unit vector
  for max_t and the hit `t` to be in world-space units.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@WomB0ComB0

Copy link
Copy Markdown
Member Author

Addressed all 5 review threads in 3b5cc83:

Major (CodeRabbit)

  • march_batch now bounds-checks arrayLength(&hits) alongside arrayLength(&rays) so a short hit buffer can no longer overrun.
  • march_batch honors r.mask: a ray with MASK_OBSTACLES unset (or mask = 0) short-circuits to a zero RayHit before consulting the brick map. Future MASK_DENSITY / MASK_TERRAIN_SDF paths will branch here too.

Minor (CodeRabbit)

  • writeRay and readHit throw RangeError on out-of-bounds index. The ?? 0 silent-fallback in readHit is gone — packing/count bugs now surface immediately instead of looking like legitimate misses.

Medium (Gemini)

  • writeRay JSDoc spells out that direction must be a unit vector — max_t and the returned hit t are world-space distances.
  • dda() now takes max_t: f32 and early-exits during traversal: pre-loop guard for t_enter > max_t (short-range rays skip the loop entirely), top-of-loop guard for empty-cell strides that cross the bound mid-walk. Camera entry passes 1e30 as the sentinel "no bound" value.

Build is green: npm run typecheck, npm run build, dotnet build -c Release all pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/ResQ.Viz.Web/client/webgpu/rays.ts (1)

79-100: ⚠️ Potential issue | 🟠 Major

Reject negative and fractional indices explicitly.

Line 87 and Line 124 only enforce an upper bound. i = -1 or i = 0.5 slips through, and JS typed-array access with those keys does not raise, so writeRay() can silently skip/overlap a record and readHit() can decode garbage even though both doc comments promise RangeError.

Suggested fix
 export function writeRay(
     views: RayBufferViews,
     i: number,
     origin: Vec3,
     direction: Vec3,
     maxT: number,
     mask: number,
 ): void {
+    if (!Number.isInteger(i) || i < 0) {
+        throw new RangeError(`writeRay: index ${i} out of bounds`);
+    }
     const o = i * 12;
     const { f, u } = views;
     if (o + 11 >= f.length) {
         const cap = Math.floor(f.length / 12);
         throw new RangeError(`writeRay: index ${i} out of bounds (buffer holds ${cap} rays)`);
@@
 export function readHit(views: RayBufferViews, i: number): ParsedHit {
+    if (!Number.isInteger(i) || i < 0) {
+        throw new RangeError(`readHit: index ${i} out of bounds`);
+    }
     const o = i * 8;
     const { f, u } = views;
     if (o + 7 >= f.length) {
         const cap = Math.floor(f.length / 8);
         throw new RangeError(`readHit: index ${i} out of bounds (buffer holds ${cap} hits)`);

Run this to verify the current guard and the underlying TypedArray behavior:

#!/bin/bash
set -euo pipefail

sed -n '79,136p' src/ResQ.Viz.Web/client/webgpu/rays.ts

node - <<'NODE'
const f = new Float32Array(12);
f[-1] = 123;
f[0.5] = 456;

console.log({
  firstElement: f[0],
  negativeRead: f[-1],
  fractionalRead: f[0.5],
  hasNegativeIndex: Object.prototype.hasOwnProperty.call(f, "-1"),
  hasFractionalIndex: Object.prototype.hasOwnProperty.call(f, "0.5"),
});
NODE

Expected result: the invalid assignments do not throw and do not produce a valid element write, which is why the explicit Number.isInteger(i) && i >= 0 guard is still needed.

Also applies to: 123-129

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ResQ.Viz.Web/client/webgpu/rays.ts` around lines 79 - 100, The
upper-bound check in writeRay (and similarly in readHit) allows negative or
non-integer indices (e.g., -1 or 0.5) to pass, which TypedArrays silently
ignore; add an explicit guard at the start of writeRay (and mirror in readHit)
that validates Number.isInteger(i) && i >= 0 and throws a RangeError if not,
then keep the existing bounds check using the computed offset (o = i * 12) to
report the buffer capacity; reference the writeRay function name (and readHit
where present) when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/ResQ.Viz.Web/client/webgpu/rays.ts`:
- Around line 79-100: The upper-bound check in writeRay (and similarly in
readHit) allows negative or non-integer indices (e.g., -1 or 0.5) to pass, which
TypedArrays silently ignore; add an explicit guard at the start of writeRay (and
mirror in readHit) that validates Number.isInteger(i) && i >= 0 and throws a
RangeError if not, then keep the existing bounds check using the computed offset
(o = i * 12) to report the buffer capacity; reference the writeRay function name
(and readHit where present) when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d16bcbf-5bd5-4045-84d7-cefaf0376010

📥 Commits

Reviewing files that changed from the base of the PR and between 53d9af3 and 3b5cc83.

📒 Files selected for processing (2)
  • src/ResQ.Viz.Web/client/webgpu/rays.ts
  • src/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl

@WomB0ComB0
WomB0ComB0 merged commit a752f05 into main Apr 28, 2026
37 checks passed
@WomB0ComB0
WomB0ComB0 deleted the feat/webgpu-ray-batch branch April 28, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant