feat(viz): ray-batch sensor API + march_batch entry - #68
Conversation
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>
📝 WalkthroughWalkthroughAdds a WebGPU sensor-batch ray-marching path: CPU-side ray/hit packing helpers and types, a new Changes
Sequence DiagramssequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/ResQ.Viz.Web/client/spikeMain.tssrc/ResQ.Viz.Web/client/vite-env.d.tssrc/ResQ.Viz.Web/client/webgpu/rays.tssrc/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl
There was a problem hiding this comment.
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.
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>
|
Addressed all 5 review threads in 3b5cc83: Major (CodeRabbit)
Minor (CodeRabbit)
Medium (Gemini)
Build is green: |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/ResQ.Viz.Web/client/webgpu/rays.ts (1)
79-100:⚠️ Potential issue | 🟠 MajorReject negative and fractional indices explicitly.
Line 87 and Line 124 only enforce an upper bound.
i = -1ori = 0.5slips through, and JS typed-array access with those keys does not raise, sowriteRay()can silently skip/overlap a record andreadHit()can decode garbage even though both doc comments promiseRangeError.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"), }); NODEExpected result: the invalid assignments do not throw and do not produce a valid element write, which is why the explicit
Number.isInteger(i) && i >= 0guard 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
📒 Files selected for processing (2)
src/ResQ.Viz.Web/client/webgpu/rays.tssrc/ResQ.Viz.Web/client/webgpu/shaders/march.wgsl
Summary
PR #3 of the WebGPU raymarcher direction. Introduces the ray-batch API
— a uniform
Ray/RayHitwire format and a newmarch_batchcomputeentry 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+ reservedMASK_DENSITY,MASK_TERRAIN_SDF), hit flag constants (HIT_HIT,HIT_OBSTACLE+reserved
HIT_VOLUME,HIT_TERRAIN), andcreateRayBuffer/createHitBuffer/writeRay/readHitfor host-side I/O.Files modified
client/webgpu/shaders/march.wgslHitwithRayHit(now carriest, plusflagsandmaterial).Raystruct + mask/flag constants in sync withrays.ts.@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.
dda()to populate and returnRayHit. The cameraentry adapts its hit check to
(h.flags & HIT_HIT) != 0u.@compute @workgroup_size(64,1,1) march_batch— one threadper Ray, honors per-ray
max_t(hits pastmax_tcollapse tomisses, matching the LiDAR/LoS use case).
client/spikeMain.ts— adds a one-shot startup probe that fires asingle ray straight down through the terrain center, reads the hit
back via
mapAsync, and console.logst/material/flags/normal.Proves the wire format and
march_batchwork end-to-end.client/vite-env.d.ts— shimGPUMapModeruntime constants. TS 6'slib.domdeclares the type but not the runtime object, same patternas the previously-shimmed
GPUBufferUsageandGPUTextureUsage.Test plan
npm run typecheckpassesnpm run buildpassesdotnet build -c Releasepasses (pre-push hook)/spike.htmlvianpm run dev; check the browser consolefor the
[spike] sensor probelog line. Expected:isHit: true,t≈ N - heightmap_height_at_center,normal: [0, 1, 0].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_batchexists.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
Chores