Skip to content

#542 WebGPU scenery parity: per-primitive colour, lazy streaming, real-tier density, shader grass, reeds/rocks/cone-stack trees - #594

Open
MaximumTrainer wants to merge 5 commits into
next/svelte-webgpufrom
claude/project-thread-9st41b
Open

MaximumTrainer wants to merge 5 commits into
next/svelte-webgpufrom
claude/project-thread-9st41b

Conversation

@MaximumTrainer

Copy link
Copy Markdown
Owner

Pre-cutover visual-parity pass on the WebGPU /row scene, verified side-by-side against current-main React on a real Intel Gen-9 GPU. Supersedes #591 — this branch contains #591's two commits plus the scenery-density follow-up Dan asked for, so it's one PR to review and merge; I'll close #591 in favour of this.

What changed (5 commits)

  1. Per-primitive scenery colour (sceneryMesh.{ts,wgsl}, sceneryMeshBuffers.ts) — each GLB primitive now draws in its own baked baseColor via a per-vertex colour attribute, instead of collapsing a whole multi-material model to primitives[0]'s colour. Fixes trees/buildings/landforms that read as pale single-colour blocks.
  2. Lazy route-progressive GLB streaming (sceneryStreamPlan.ts, rideScene.ts) — scenery models load as the boat nears their first instance along the route (400 m look-ahead, a few per tick) instead of one up-front burst that blocks the first frame and can 503 a static host.
  3. Real-tier plumbing (+page.svelte, sceneryRoute.ts) — the scenery builder now uses the device's actual probed tier (resolvePerformanceMode), threaded into both toEngineRoute and the engine attach so they can't diverge. Previously it was hard-wired to medium, which suppressed the GLB kit trees entirely (they're high+ only, matching React's own policy). Density now tracks React tier-for-tier.
  4. Biome-density grass colour (biomeDensity.ts, shaderGrass.ts, bank.{ts,wgsl}) — this is what React means by "shader grass" (Density-field grass on bank slopes #496 Phase 2): a biome-density ground-colour modulation of the bank material, not standing blades. On for medium+, __VIRTUALROW_SHADER_GRASS=false kill switch, tier-scaled strength.
  5. Reeds + rocks + cone-stack trees (reedRockPlan.ts, floraSolidPool.ts, floraSolid.wgsl, scenery.wgsl, widened sproutWindowFor) — the three layers that close the remaining medium-tier gap vs React: reeds (REED_MAX=120) and rocks (ROCK_MAX=40) placed deterministically on the bank with per-profile density thinning, and the foliage trees given React's 4-cone-stack silhouette instead of a single-crown blob. The foliagePlan slot/species/x/z schedule is untouched, so the existing foliage parity test doesn't drift.

Shading on both the solid-flora and GLB-mesh passes moved from a flat term to half-lambert + sky/ground hemisphere ambient so meshes stop reading as flat cards.

Verification

  • npm run verify green: lint, tsc (app + playwright), 2121 unit tests pass / 0 fail, wgsl.test.ts parses all 20 shaders (bank, floraSolid, scenery, sceneryMesh). New unit tests for biomeDensity, reedRockPlan, shaderGrass, floraSolidPool per-species geometry, and tier-ordered scenery counts in sceneryRoute.
  • Coverage floors unchanged; no three imported under src/lib/renderer.
  • GPU-confirmed on Intel Gen-9 (isSoftware:false, deviceLostCount:0, no console errors) across HOCR + Willowbrook, including a forced-low-tier capture proving density scales down end-to-end.

Known / out of scope


Generated by Claude Code

claude and others added 5 commits October 26, 2026 10:46
The scenery-mesh pass collapsed every primitive of a GLB into one mesh and
painted the whole thing in primitive-0's baseColor, so multi-material models
(trunk/leaves, walls/roof/windows) rendered as a single pale block. glTF
splits a model into one primitive per material; bake each primitive's colour
per vertex and carry it through sceneryMesh.wgsl as a vertex attribute, so
parts stay distinct — matching how the React scene binds per-primitive
materials. Single-colour callers (boat primitives) omit `colors` and the pass
fills every vertex from `baseColor`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b
Scenery GLBs were all fetched up-front at attach (a Promise.all over every
model), which blocks the first frame and can trip a static-host 503 — the same
burst the React scene avoids by chunking its loads. Defer them: plan where each
model first appears along the route, then load models from the frame loop as
the boat comes within a look-ahead window, capped a few per tick. Course
furniture and GLB scenery share one instance-transform map; a model's instances
are set the moment its GLB registers, so nothing draws before it is ready and
distant scenery streams in as the row progresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b
…ss flat shading

The WebGPU engine was rendering the river banks noticeably sparser and
flatter than the React scene it ports. Four changes close most of the
visible gap on an Intel Gen-9 (parity captures in #591 follow-up):

- toEngineRoute: the mode was hard-wired to `medium`, which both ran the
  placement loop at 0.7 budget AND suppressed GLB kit trees entirely
  (keepsKitTrees is true only on high/extra-high). React used the viewer's
  own PerformanceMode, so most sessions got `high`. Add an optional `tier`
  parameter defaulted to `high` — no caller change required.
- floraSolid.wgsl: the near-tree solid pool had one mono-green crown with
  no species variation and the sprite pool (scenery.wgsl) had three species
  palettes, so the near/far handoff never matched colour. Decode the species
  index from the centres.w metadata already packed by foliagePlan.ts and
  look up the same BASE_PAL/CROWN_PAL the sprite pass uses. The shading
  model moves from a `0.5*dot+0.6` flat clamp to half-lambert plus a
  sky/ground hemisphere ambient so leaves pick up some depth cue instead
  of reading as painted cardboard.
- sceneryMesh.wgsl: same hemisphere ambient + half-lambert replaces the
  flat `0.35 + 0.65*diffuse` so GLB kit trees and OSM buildings carry
  depth under the sun.
- uniforms.ts sproutWindowFor: the per-tier fade `far` was 6-15 m, which
  left most of the mid-range as sprite-only cards. Push each tier ~60%
  farther (medium 10 -> 16 m, high 12 -> 20 m) so the solid mesh carries
  the mid where silhouette matters most; the sprite pass still owns beyond.
  floraLod.test.ts picks up the new numbers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to b154bb6. Two pieces.

PIECE 1 — Tier plumbing.
`toEngineRoute`'s mode was pinned to a sensible default ('high' after the
first follow-up, 'medium' before), which meant the scenery builder had no
idea what the device could handle and couldn't match React's density
curve. React resolves the real tier through `resolvePerformanceMode()`
(honoring `__VIRTUALROW_PERFORMANCE_MODE` and the Playwright flag), so
`/row/+page.svelte` now calls the same resolver and threads the result
into BOTH `toEngineRoute(route, enrichment, tier)` and `Canvas3DHost`'s
`tier` prop. The two can't diverge. `DEFAULT_SCENERY_TIER` ('medium')
keeps the no-arg callers in tests deterministic. A new test asserts
`sceneryGlbs` count follows `low <= medium <= high` and that
`foliagePlan` pools stay deterministic across tiers.

PIECE 2 — Biome-density ground colour (#496 Phase 2, ported).
React's "shader grass" is a biome density texture modulating the bank's
grass albedo, not standing blade geometry (`bankTriplanar.glsl`
`grassDensityModulation`). The WebGPU bank pass now ports that chunk:
`bank.wgsl` samples an `r8unorm` density tile on a 32 m plan-view grid
and modulates the ground colour by `grass.strength`, with `strength = 0`
leaving the colour pass-through. A new `biomeDensity.ts` paints the
tile from a seeded noise + biome-mean bias byte-for-byte like
`foliageTexture.ts`'s `paintBiomeDensityTile`; `biomeDensity.test.ts`
asserts the ±0.15 mean invariant React's own tile test asserts. A new
`shaderGrass.ts` ports `drawsShaderGrass` / `isShaderGrassEnabled` and
adds `shaderGrassStrengthFor`, so the pass tier-scales (0 on basic/low,
0.6/0.85/1.0 on medium/high/extra-high) and honors the global
`__VIRTUALROW_SHADER_GRASS = false` kill switch. `rideScene.ts` wires
them, picking the route's dominant biome profile via a new optional
`EngineRoute.dominantProfile` the adapter now carries.

Follow-up (NOT in this change, per coordinator): reeds + rocks +
background cone-tree scatter — the other three vegetation layers React
has that the WebGPU engine doesn't.

Also lands:
- `_mockWebGPU.ts` adds a `writeTexture` stub on the mock queue; the
  bank pass now uploads one on construction so the stub keeps the
  existing attach / AC4 tests honest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to b154bb6 and 95324b3. The remaining medium-tier gap vs React
was three vegetation layers the WebGPU engine didn't have and a sprout
window too tight to let the solid flora mesh carry mid-range silhouette.

Four changes.

1. Four-cone-stack trees.
`floraSolidPool.buildFloraSolidMesh` species 0-2 now mirrors React's
`vegetationComponents.tsx` `CONE_LAYERS = [[2.8, 1.4, 2.2], [3.8, 1.1,
2.0], [4.6, 0.8, 1.8], [5.3, 0.4, 1.2]]` — a trunk prism + four tapered
foliage cones at the same y-stops and radii. Trees read as a proper
Christmas-tree silhouette instead of the pre-port single-ring blob.
Mesh grows from ~32 to ~60 triangles; 1k instances per pool × 60 tri
is comfortably inside the medium-tier budget on Intel Gen-9.

2. Reeds (species 3) and rocks (species 4) as ground props.
New `scenery/reedRockPlan.ts` ports React's REED_MAX=120 / ROCK_MAX=40
layout from `vegetationComponents.tsx`. The layout picks a progress
`t ∈ [0, 1]` for each prop, perpendicular-offsets by 12-20 m from the
water centre (sized against the actual OSM shore width + the scenery
margin + an inner band), and thins per-instance by the segment's
`SCENERY_PROFILES[...].groundCover.density` the same way React's
`densityScale` thins its counts. Both layers reuse the existing
`FoliageTree` shape with species 3/4 so `batchFoliagePools` bins them
alongside trees — no new pool kind, no new render pass.
Mesh variants in `buildFloraSolidMesh`:
  - 3 = three crossed thin vertical quads (6-blade reed cluster, both
    faces), ~12 tris.
  - 4 = seeded irregular 6-sided prism with a top fan, ~18 tris.

3. Palette extensions.
`floraSolid.wgsl` grows `TRUNK_PAL`/`BASE_PAL`/`CROWN_PAL` from 3 to 5
entries: species 3 lands on a dry warm reed tan, species 4 on a
mid-grey rock tone. `scenery.wgsl` (sprite billboard pass) grows its
`SPECIES_HEIGHTS`/`SPECIES_WIDTHS` to 5 with the new entries at zero,
collapsing the billboard to a point for reeds and rocks (the solid
pass owns their visual at close range anyway); the sprite's own 3-entry
palette arrays stay as is behind a `min(species, 2u)` clamp. Both
shaders still parse under `wgsl.test.ts`.

4. Wider sprout LOD window.
`sproutWindowFor` far distances step up again (medium 16 → 60 m,
high 20 → 100 m, extra-high 26 → 160 m). React's scene has no sprite
handoff at all — its foliage is real geometry to the clip plane — so
the WebGPU hybrid LOD has to carry solid trees well past the river
bank's own distance (~10-30 m from the chase camera) to approach the
same look. The sprite pass still fills in beyond `far` to the fog.
`floraLod.test.ts` updates the asserted numbers; the
monotonic-with-tier invariant still holds.

Tests:
- `floraSolidPool.test.ts` splits the per-tree budget assertion and
  adds species 3 (12 tri) and 4 (18 tri) exact-count assertions.
- `reedRockPlan.test.ts` (new) asserts the layout returns empty on no
  curve, every prop carries the right species index, counts stay under
  the React caps, output is deterministic, both banks get props, and
  no prop sits inside the river itself.
- `floraLod.test.ts` picks up the new sprout numbers.

Measured on this hardware (Intel Gen-9, HOCR + Willowbrook, 22 s ride):
~57-83 reeds and 21-25 rocks per route, foliage pool count rises from
~90 to ~204 as the two new species each add their own chunk buckets.
No console errors, no device-lost, `__VIRTUALROW_WEBGPU_HEALTH` clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants