Skip to content

feat(viz): mesh-link line-of-sight against terrain - #70

Merged
WomB0ComB0 merged 2 commits into
mainfrom
feat/webgpu-mesh-link-los
Apr 28, 2026
Merged

WomB0ComB0 merged 2 commits into
mainfrom
feat/webgpu-mesh-link-los

Conversation

@WomB0ComB0

Copy link
Copy Markdown
Member

Summary

PR #5 of the WebGPU raymarcher direction. First user-visible feature
of the whole arc
— mesh-link lines in the production viz now visibly
fade when terrain occludes the drone-pair line-of-sight.

The five PRs in sequence:

How it works

  1. effects.ts:_updateMeshLinks builds one LosRay per drone-pair each
    frame: origin = A, direction = normalize(B - A), maxT = ‖B - A‖,
    mask = MASK_OBSTACLES.
  2. Dispatches the batch to the LoS query manager landed in PR feat(viz): WebGPU sensor primitive on production route (foundation) #69.
    Skip-if-busy throttle keeps at most one query in flight — at sim
    rates (10 Hz) and 60 fps render this is plenty fresh, and the cache
    covers the gap.
  3. When the query resolves, caches per-pair opacity (occluded pairs
    fade to 25 % of base, e.g. 0.6 → 0.15 for normal links). The
    next frame's line teardown/rebuild reads from the cache, so opacity
    persists across the per-frame churn.

Bundle architecture (the load-bearing fix)

PR #69 set up app.ts with a dynamic import for the WebGPU stack so
the runtime lives in its own ~20 KB chunk and doesn't count against
the client-budget cap (800 KiB main).

If effects.ts had imported getSensorContext directly from
sensors.ts, that static import would have defeated the chunk split
(Vite's INEFFECTIVE_DYNAMIC_IMPORT warning) and pulled the entire
WebGPU runtime back into the main bundle (824 KB → over budget).

Fix: a new tiny registry.ts module — runtime-free aside from a
singleton let + getter/setter — holds the cached context.
sensors.ts calls setSensorContext() once boot finishes;
effects.ts reads via getSensorContext() from the registry.
Static dependency on registry.ts is fine because it has no
transitive WebGPU runtime deps (the SensorContext type is imported
type-only).

Files added

  • client/webgpu/registry.ts (~25 LOC): singleton getter/setter for
    the sensor context. Pure plumbing — type-only WebGPU imports.

Files modified

  • client/effects.ts:
    • imports getSensorContext from registry, LosRay type from
      los, MASK_OBSTACLES + HIT_OBSTACLE from rays
    • EffectsManager gains _linkOpacityCache: Map<string, number>
      and _losQueryInFlight: Promise<void> | null fields
    • _updateMeshLinks reads cached opacity, builds rays in the same
      pass as line creation, dispatches one throttled LoS query per
      call, populates cache on resolve
  • client/webgpu/sensors.ts:
    • removes getSensorContext export + the local _ctx cache
    • calls setSensorContext(ctx) at end of bootSensors() to publish
      via the registry

Bundle impact

Main JS Sensors chunk
Pre-PR (#69 baseline) 803 KB 20 KB
This PR 805 KB 19 KB
Cap 800 KiB (819,200 B) n/a

+1.4 KB to main (the new effects.ts cache logic + the tiny registry
module). Comfortably under cap.

Test plan

  • npm run typecheck passes
  • npm run build passes (no INEFFECTIVE_DYNAMIC_IMPORT warning)
  • dotnet build -c Release passes (pre-push hook)
  • Visit / via npm run dev with two or more drones spawned;
    observe mesh-link lines visibly fade when one drone moves behind
    a hill relative to another. Lines should snap back to full
    opacity once line-of-sight is restored.
  • On a non-WebGPU browser (or before bootSensors() resolves),
    mesh-link lines render with their original opacity — no LoS
    modulation, no errors. Graceful fallback verified.
  • DevTools network tab on production build: confirm
    sensors-*.js is a separate chunk loaded async, not part of
    the main index-*.js bundle.

🤖 Generated with Claude Code

PR #5 of the WebGPU raymarcher direction. **First user-visible feature**
of the whole arc — mesh-link lines in the production viz now visibly
fade when terrain occludes the drone-pair line-of-sight.

How it works:
1. effects.ts:_updateMeshLinks builds one LosRay per drone-pair each
   frame: origin = A, direction = normalize(B - A), max_t = ‖B - A‖,
   mask = MASK_OBSTACLES.
2. Dispatches the batch to the WebGPU sensor primitive landed in PR #4
   (skip-if-busy throttle: at most one query in flight at a time, so
   queries don't queue unboundedly when GPU+readback can't keep up
   with the render).
3. When the query resolves, caches per-pair opacity (occluded pairs
   fade to 25% of base). The next frame's line teardown/rebuild reads
   from this cache, so opacity persists across the per-frame churn.

Bundle architecture fix:
- effects.ts importing getSensorContext from sensors.ts statically
  defeated the dynamic-import chunk split (Vite's INEFFECTIVE_DYNAMIC_
  IMPORT warning), pulling the WebGPU runtime into the main bundle and
  blowing the client-budget cap.
- New registry.ts holds the singleton context — runtime-free aside
  from a let + getter/setter — so effects.ts can import the getter
  without dragging the WebGPU stack along. sensors.ts calls
  setSensorContext() once boot finishes; getSensorContext() in
  registry.ts is what effects.ts reads.

New file:
- client/webgpu/registry.ts (~25 LOC): tiny singleton registry. Type-
  only import of SensorContext; no runtime WebGPU dependencies.

Modified:
- client/effects.ts: imports getSensorContext from registry, LosRay
  type from los, MASK_OBSTACLES + HIT_OBSTACLE constants from rays.
  EffectsManager gains _linkOpacityCache + _losQueryInFlight fields.
  _updateMeshLinks reads cached opacity, builds rays, dispatches one
  throttled LoS query per call, populates cache on resolve.
- client/webgpu/sensors.ts: removes _ctx local cache + getSensorContext
  export, calls setSensorContext(ctx) at end of bootSensors() instead.

Bundle: main 805 KB (under 800 KiB cap), sensors chunk 19 KB.

Validation: typecheck, vite build, dotnet Release all 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

Warning

Rate limit exceeded

@WomB0ComB0 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 5 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 479e7872-c4df-468a-87ee-65cdbff7e1b2

📥 Commits

Reviewing files that changed from the base of the PR and between 35b3d87 and b62bff7.

📒 Files selected for processing (3)
  • src/ResQ.Viz.Web/client/effects.ts
  • src/ResQ.Viz.Web/client/webgpu/registry.ts
  • src/ResQ.Viz.Web/client/webgpu/sensors.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webgpu-mesh-link-los

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.

@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 Line-of-Sight (LoS) modulated opacity for mesh links, allowing occluded links to appear faded. It introduces a caching mechanism for link visibility and a throttling system for LoS queries to maintain performance. Additionally, a singleton registry is added to manage the WebGPU sensor context, preventing large static imports. Review feedback recommends caching the boolean occlusion state rather than the final opacity value to handle dynamic changes in base opacity, and using unique drone IDs instead of array indices for cache keys to ensure stability across frames.

Comment thread src/ResQ.Viz.Web/client/effects.ts Outdated
Comment thread src/ResQ.Viz.Web/client/effects.ts Outdated
Comment thread src/ResQ.Viz.Web/client/effects.ts Outdated
effects.ts (Gemini medium x3):
- Cache the occlusion BOOLEAN, not the computed opacity. The previous
  Map<string, number> went stale whenever baseOpacity changed (e.g.
  mesh.partitioned toggling between true/false would have continued
  rendering at the cached opacity until the next LoS query landed).
  Now the cache is Map<string, boolean> and opacity is computed at
  line-creation time from baseOpacity * occludedFactor — base changes
  take effect immediately.
- Key the cache by canonical drone IDs (`<idA>--<idB>` lexicographi-
  cally sorted) instead of array indices. Indices are not stable
  across reorderings of the drones array — using IDs prevents the
  cache from applying the wrong drone-pair's occlusion if the array
  happens to be sorted differently next frame. DroneState.id is the
  same field other EffectsManager state (trails, detections) keys on.
- Field renamed _linkOpacityCache → _meshLinkOccluded to reflect the
  new semantics. occludedFactor (0.25) lives at the call site.

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

Copy link
Copy Markdown
Member Author

Addressed all 3 review threads in b62bff7:

Cache the boolean, not the opacity (Gemini medium ×2)

  • The cache was Map<string, number> storing computed opacity, which went stale whenever baseOpacity changed (e.g. mesh.partitioned toggling). Now it's Map<string, boolean> for occlusion state, and opacity is computed at line-creation time as baseOpacity * occludedFactor (0.25 for occluded). mesh.partitioned toggles take effect on the next frame without invalidating any cache entries.

Key by drone IDs, not array indices (Gemini medium)

  • Array indices are unstable across reorderings of the drones array — the wrong pair's occlusion could be applied if the array were sorted differently next frame. Cache key is now <idA>--<idB> (canonical lexicographic sort of DroneState.id), the same stable field other EffectsManager state (trails, detections) keys on.

Field renamed _linkOpacityCache → _meshLinkOccluded to reflect the new semantics.

Build green: typecheck, Vite (main 805 KB), dotnet Release.

@WomB0ComB0
WomB0ComB0 merged commit b80ed27 into main Apr 28, 2026
37 checks passed
@WomB0ComB0
WomB0ComB0 deleted the feat/webgpu-mesh-link-los branch April 28, 2026 21:56
WomB0ComB0 added a commit that referenced this pull request Apr 28, 2026
)

Replaces the single-slot serialization in LosQueryManager with a ring
of N slots. Each slot owns its own rayBuf/hitBuf/readBuf/bindGroup and
serializes only its own pending queries via a .then()/.catch() chain
on its inFlight tail. Round-robin slot selection ensures successive
queries pick different slots so concurrent dispatches across slots
don't race on shared buffers.

Why now:
- The mesh-link LoS path landed in PR #70 doesn't need the ring
  (effects.ts has its own skip-if-busy throttle so at most one query
  is ever in flight). But high-rate dispatches like LiDAR scans —
  next on the WebGPU sensor roadmap — would otherwise stall queueing
  behind one slot. Ring buffer is foundation work to unblock them.

Why backward compatible:
- Constructor adds an optional `slotCount: number = 2` parameter.
  Existing call site `new LosQueryManager(device, world, 256)` in
  sensors.ts gets a 2-slot ring with no other change. Throughput-wise,
  effects.ts's skip-if-busy throttle still keeps at most one query
  in flight, so observable behaviour is unchanged for mesh-link LoS.
- query() / capacity() / LosRay public surface unchanged.

Memory: each slot allocates `maxRays * (RAY_BYTES + 2 * RAY_HIT_BYTES)`.
For the existing call (maxRays=256, slotCount=2) that's ~40 KB total,
negligible. LiDAR with 65k rays would want slotCount=3 ≈ 16 MB.

New `slotCount` getter exposes the ring depth for callers that want to
tune their throttle.

Validation: typecheck passes, vite build 805 KB (under 819,200 cap),
dotnet Release passes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WomB0ComB0 added a commit that referenced this pull request Apr 28, 2026
)

* feat(viz): terrain-edit invalidation for the brick-map sensor stack

Closes the highest-risk loose end from PR #70 — `setActivePreset` and
`setHeightmapOverride` at runtime previously left the brick map silently
stale, so mesh-link LoS and LiDAR scans would lie about occlusion until
the next page load.

Architecture (event-driven, no terrain↔WebGPU coupling):
- terrain.ts exports `onTerrainChange(listener) -> unsubscribe`. Fires
  after `setActivePreset` and `setHeightmapOverride` mutate the active
  height field. Listener errors are caught + logged via the project
  logger so a failing subscriber doesn't break the others.
- world.ts adds `rebuildWorld(device, heightFn, world)` — re-voxelizes
  via a shared `_voxelize` helper into the existing `voxelBuf` and
  re-runs `buildBrickMap` against the existing top_grid / brick_pool.
  Zero new GPU buffer allocation; the transient CPU Uint32Array gets
  GC'd after `writeBuffer` copies it.
- sensors.ts subscribes to `onTerrainChange` after publishing the
  context. Listener calls `rebuildWorld` with the world it just built.
- effects.ts subscribes too, clears `_meshLinkOccluded` (cached
  occlusion booleans go stale instantly on terrain change) and resets
  the LiDAR `Points` draw range to 0 (next scan repopulates).

Logger consolidation (per session feedback "I have a logger remember
that"): converted all WebGPU sensor stack `console.warn` / `console.info`
calls to the project's `getLogger` pattern. terrain.ts already used it;
sensors.ts and the LiDAR + LoS catch handlers in effects.ts are now
consistent. Module names: `webgpu/sensors`, `effects`. Structured
context via the `{ error, ... }` second-arg style matching terrain.ts.

Bundle: main 808,316 B (under 819,200 cap), +424 B for the event API +
logger imports + the `rebuildWorld` helper.

Validation: typecheck, vite build, dotnet Release all pass.

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

* fix(viz): address PR #74 review feedback

sensors.ts (Gemini high + medium):
- bootSensors() is now sequentially idempotent. Previously, calling
  bootSensors() a second time after the first resolved would re-init
  the device, allocate fresh GPU buffers (leaking the old ones), AND
  register a duplicate onTerrainChange listener — every preset change
  would trigger N rebuildWorld() calls instead of one. Now we check
  getSensorContext() first; if a context already exists, return it
  directly without re-running boot.
- Added the corresponding `getSensorContext` import alongside
  `setSensorContext` from registry.

effects.ts (Gemini medium):
- Capture the unsubscribe handle returned by onTerrainChange() into a
  new private `_terrainUnsub` field. Add a `dispose()` method that
  calls it. EffectsManager is long-lived in the production viz, but
  holding the handle is best practice for hot-reload, tests, or
  scenario teardown — and avoids the linter complaint about an
  ignored return value.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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