Skip to content

WGSL → WESL conversion + shared shader library#39

Merged
rulkens merged 40 commits intomainfrom
feat/wesl-conversion
May 7, 2026
Merged

WGSL → WESL conversion + shared shader library#39
rulkens merged 40 commits intomainfrom
feat/wesl-conversion

Conversation

@rulkens
Copy link
Copy Markdown
Owner

@rulkens rulkens commented May 7, 2026

Summary

Full migration from raw WGSL to WESL (WebGPU Shading Extended Language) with build-time linking via wesl-plugin, plus a complete reorganisation of the shader tree.

  • Tooling: wesl-plugin@^0.6.74 wired into vite.config.ts and vitest.config.ts. All seven shaders renamed .wgsl.wesl; renderer imports switched from ?raw to ?static (build-time linked).
  • Shared lib/ of 10 modules under src/services/gpu/shaders/lib/:
    • math.wesl — saturate, rot2, sabs, toPolar, toRect, PI/TAU/LOG10
    • camera.weslCameraUniforms (80-byte universal prefix), worldToClip, worldToNdc, worldEyeDepth
    • billboard.wesl — quadCorner, quadUv, expandBillboardScreen
    • orientation.weslDiskAxes + diskAxes(posWS, paRad, cosI, sinI) for galaxy disk-plane math
    • colorIndex.weslramp(t) color-index → RGB
    • cloudFade.wesl — unified CloudUniforms + scalar applyCloudFade(alpha, opacity)
    • masks.weslcircularMask, lumAlpha, edgeBandMask
    • astro.wesldistanceModulus(appMag, distMpc)
    • tonemap.wesl — five tone-map curves (linear / reinhard / asinh / gamma2 / aces)
    • util.weslhash21, valueNoise2, raySphere, worldToGalactic, galacticToShader (orphan utilities)
  • Per-renderer 3-file split for every shader: <name>/{io,vertex,fragment}.wesl. points/ is special-cased with FOUR files — io, vertex, colorFragment (pointRenderer), pickFragment (pickRenderer). This eliminates the class of selection-on-wrong-galaxy bugs that came from sharing one shader module between two pipelines with diverging fragment paths.
  • Replaces the planned @if(PICK) conditional-compilation path with a clean two-fragment-file split.

Spec: docs/superpowers/specs/2026-05-07-wesl-conversion-design.md
Plan: docs/superpowers/plans/2026-05-07-wesl-conversion.md

Side benefits

  • Bundle: 449 kB / 141 kB gzipped (was 472 kB / 152 kB pre-migration). Smaller gzipped despite per-pipeline binding redeclaration because each pipeline's compiled WGSL is now disjoint and gzip handles the modest textual duplication well.
  • WebGPU resource labelling sweep: every createShaderModule, createBuffer, createTexture, createBindGroup, createRenderPipeline, createPipelineLayout, createBindGroupLayout now has a meaningful label for debuggability. Browser-console errors that previously said (unlabeled) now name the offending resource.
  • createShaderModuleWithDevLog helper (src/services/gpu/shaderCompileLogger.ts): every renderer routes shader-module creation through this wrapper, which logs the linked WGSL alongside any compile-time error in dev mode. Maps "WGSL line 142 error" back to the source .wesl file.

Findings baked into the codebase + plan

Seven concrete WESL-tooling gotchas surfaced and are documented in a personal skill at ~/.claude/skills/wesl-shaders/:

  1. WESL parser rejects backticks ` in any comment (// or /* */). Project-wide substitution `' applied to every shader.
  2. Package prefix is the literal package, not the npm name. import package::lib::math::saturate (verified against node_modules/wesl/dist/index.js).
  3. Imports must live at the TOP of the file — wesl-plugin@0.6.74 only resolves imports before code emission begins. Imports placed near call sites are passed through verbatim and Chrome's WGSL parser chokes.
  4. No brace-list importsimport path::{ a, b }; doesn't link in this plugin version. One identifier per line.
  5. Imports name a function FROM a module, not function-as-module — lib/math.wesl exports saturate, not lib/math/saturate.wesl.
  6. Vitest doesn't auto-inherit Vite pluginsvitest.config.ts registers wesl-plugin directly.
  7. TypeScript subpath types need a triple-slash referencesrc/@types/wesl.d.ts carries /// <reference types="wesl-plugin/suffixes" /> for *?static to resolve to string.

Test plan

  • npm run typecheck green (both src and tools tsconfigs)
  • npm run build green
  • npm test green — 895/895 across 117 test files
  • Visual sanity check on localhost:5174 (this branch's worktree) — every renderer output identical to pre-migration: pan / zoom / rotate, click a galaxy (pick), tier-swap (cloudFade), tone-map dropdown cycles all five curves, milkyWay impostor renders procedural disk + stars, filaments + disks + quads + proceduralDisks all green.

Notes

  • Branch was renamed mid-flight from my-feature to feat/wesl-conversion. Original draft PR WGSL → WESL conversion + shared shader library (in progress) #36 was closed and superseded by this one (WGSL → WESL conversion + shared shader library #39).
  • points/ has TWO fragment files (colorFragment.wesl for pointRenderer, pickFragment.wesl for pickRenderer). Both renderers build their own vertex module from points/vertex.wesl?static — separate createShaderModule calls, no cross-renderer module sharing (would re-introduce the WebGPU bind-group-layout-auto-derived trap).
  • Shader bind-group declarations are REDECLARED in each consuming file with identical @group/@binding numbers. WESL has no global state, so binding declarations can't be imported across modules — but WGSL accepts redeclared bindings as long as the layout matches.

🤖 Generated with Claude Code

rulkens and others added 29 commits May 7, 2026 18:03
Captures the 15-task migration: wesl-plugin tooling bootstrap, lib/
extraction (math/, camera, billboard, orientation, colorIndex, cloudFade,
masks, astro, tonemap, util), and uniform vertex/fragment/io file split
across all 7 renderer shaders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bite-sized TDD-style plan for executing the WGSL→WESL refactor: tooling
bootstrap, lib/ extractions (math/, camera, billboard, orientation,
colorIndex, cloudFade, masks, astro, tonemap, util), and uniform
vertex/fragment/io split across all 7 shaders. Also corrects the spec
to use the actual wesl-plugin `?static` suffix and `::` import syntax
(verified against wesl-lang.dev — `?link` would have been wrong).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds wesl@^0.7.26 + wesl-plugin@^0.6.74 (build-time WESL→WGSL linker)
wired into Vite via the ?static import suffix. Renames toneMap.wgsl
→ toneMap.wesl as the smoke-test shader; toneMapPass.ts switches
?raw → ?static and gains a dev-mode getCompilationInfo log so we can
map browser shader-compile errors back to the linked WGSL output
(wesl-plugin doesn't yet emit sourcemaps that survive into Chrome's
WGSL diagnostics).

Three real findings from this smoke test, baked into the plan for
later tasks:

1. WESL parser rejects backticks in comments (// or /* */). Stripped
   from toneMap.wesl as a content change; task 2 globally replaces ` →
   ' across all remaining shaders. Mechanically reversible if upstream
   fixes the parser.

2. tsconfig types-array entry "wesl-plugin/suffixes" doesn't reliably
   resolve under moduleResolution=bundler. Required a triple-slash
   reference in src/@types/wesl.d.ts for TS to pick up the *?static
   ambient declarations.

3. Vitest doesn't auto-inherit Vite plugins from vite.config.ts. Added
   wesl-plugin to vitest.config.ts so the SSR transform pipeline can
   handle .wesl imports during tests.

Verification: npm run typecheck (green), npm run build (green), npm
test (880/880 green). Visual sanity check on tone-mapped scene is
pending the user — toneMap.wesl has zero imports so the linker is a
passthrough and output should be byte-identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bulk rename. Each renderer's ?raw import becomes ?static so the WESL
linker runs on every shader. Output WGSL is byte-identical until
imports are introduced in later tasks, save for one mechanical content
change: backticks in shader comments are replaced with single quotes
project-wide because the WESL parser tokenises ` regardless of comment
context. The single-quote replacement preserves the visual intent of
the inline-code callouts and is mechanically reversible if the parser
later loosens up.

Also refreshes pointRenderer's stale module-import docblock — it still
described ?raw + WGSL semantics from before Task 1's wesl-plugin wire-up.

Verification: typecheck green, build green, npm test 880/880 green.
Visual sanity check pending the user — every shader still has zero
imports so the linker output is byte-identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…r, constants

Pulls scalar constants (PI/TAU/LOG10) and five small primitives
(saturate, rot2, sabs, toPolar, toRect) out of points.wesl and
milkyWayImpostor.wesl into a shared module. Removes the inline
duplicates and renames GLSL-style 'rot' → 'rot2' to free the bare
'rot' name for a future axis-angle 3D rotation helper.

Two important findings about WESL idioms surfaced while building this
out — both folded back into the spec, plan, and an auto-memory note:

1. The literal package prefix is 'package::', not 'skymap::'. The npm
   package.json name doesn't resolve through wesl-plugin in this
   setup; only the literal token 'package' does. Verified empirically
   by debug-instrumenting findSource in node_modules/wesl/dist/.
2. WESL imports a function FROM a module, treating the last segment
   of the path as the function name. One-function-one-file (the
   pre-execution plan) forces a verbose duplicated leaf in the
   import path ('lib::math::saturate::saturate'). The idiomatic
   WESL shape is one cohesive multi-function module per file, so
   the six original lib/math/<fn>.wesl files were collapsed into a
   single lib/math.wesl with section-divider comments. Each fn
   keeps its own docblock; reading top-to-bottom mirrors the
   previous per-file sequence.

Verification: typecheck green, build green, 880/880 tests green.
Visual sanity check pending the user — the module body is
byte-equivalent to the previously-inline definitions plus the rot →
rot2 rename, so output should be identical.

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

Adds a shared module exposing the universal camera-uniform prefix
(viewProj + viewportPx + 8 B alignment pad = 80 B) plus three
helpers: worldToClip, worldToNdc, worldEyeDepth.

The struct is intentionally minimal. The plan's draft included
view, proj, kPerZ, dpr, timeSec, and cameraPos, but inventorying
the existing renderer Uniforms structs showed:

  - No renderer separates view/proj — all use combined viewProj.
  - kPerZ, dpr, timeSec are not in any uniform today.
  - cameraPos placement varies wildly across renderers (points,
    quads, disks, proceduralDisks, milkyWayImpostor each put
    different fields between viewport and the cameraPos slot).
  - filaments has no cameraPos at all.

So CameraUniforms holds only the truly-universal prefix; the
worldEyeDepth helper takes cameraPos as an explicit parameter
rather than reading it off the struct, letting renderers keep
their existing layout for that field.

No consumers yet — this commit is just the module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Embed the shared 'CameraUniforms' prefix from 'lib/camera.wesl' as the
first 80 bytes of the filaments Uniforms struct, then keep the two
renderer-specific scalars ('halfWidthPx', 'intensityScale') at offsets
80/84 with an 8-byte tail pad. Total uniform size grows from 80 to 96
bytes; the CPU-side uploader writes the scalars at f32-indices 20/21
(was 18/19) and leaves the new reserved pad slots zero.

Replaces the inline 'u.viewProj * vec4<f32>(p, 1.0)' projection with
the shared 'worldToClip' helper. NDC math still uses the local
endpoint clips because the perspective divide's 'w' is reused below
to restore clip space — calling 'worldToNdc' would project twice.

First adopter of the shared camera library; remaining renderers
(quads, disks, proceduralDisks, milkyWayImpostor, points) follow in
subsequent commits per the WESL conversion plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The quads uniform layout already matched 'CameraUniforms' byte-for-byte
in its first 80 bytes ('viewProj + viewport + _pad0 + _pad1'), so this
adoption is a pure renaming: 'u.viewProj' becomes 'worldToClip(u.cam, ...)'
and 'u.viewport' becomes 'u.cam.viewportPx'. The renderer-specific
'camPosWorld + pxPerRad' pair stays at offset 80, and the CPU-side
uniform writes at f32-indices 20..23 are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The disks uniform layout already matched 'CameraUniforms' byte-for-byte
in its first 80 bytes ('viewProj + viewport + _pad0 + _pad1'), so this
adoption is a pure renaming: 'u.viewProj' becomes 'worldToClip(u.cam, ...)'.
The disks vertex stage doesn't consult 'viewport' or 'camPos' — disk
orientation is an intrinsic, camera-independent galaxy property — so
only 'worldToClip' is imported, matching the restraint shown in the
filaments + quads adoptions. The renderer-specific 'camPos + _pad2'
pair stays at offset 80, and the CPU-side uniform writes at f32-indices
20..23 are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the inline viewProj/viewport/_pad0/_pad1 prefix with the shared
CameraUniforms struct from lib/camera.wesl, and route clip-space
projection through the worldToClip helper.  The procedural-disk vertex
stage is intentionally camera-independent (disk orientation is an
intrinsic galaxy property derived from Earth -> galaxy line of sight),
so we import only worldToClip; camPosWorld and pxPerRad remain in the
trailing renderer-specific tail at the same byte offsets, preserving
ABI with proceduralDiskRenderer.ts (no TS-side changes required).

Same prefix-rename pattern as the earlier disks.wesl adoption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Embed the shared 'CameraUniforms' prefix from 'lib/camera.wesl' as the
first 80 bytes of the milkyWayImpostor Uniforms struct. The previous
layout placed 'fadeAlpha' + 'iTime' at offsets 72/76, which collide
with the '_pad0/_pad1' slots that 'CameraUniforms' reserves — so we
can't drop the shared prefix in without relocating those scalars.

Resolution: put 'cam: CameraUniforms' first (occupies 0..79), then
pack the renderer-specific fields after the cam block. 'cameraPosWorld'
(vec3, 16-byte alignment) lands naturally at offset 80, and the two
f32 scalars 'fadeAlpha' + 'iTime' fall in at 92 / 96. The struct grows
from 96 to 112 bytes (round-up to the next 16-byte multiple).

CPU-side offset changes:
  - fadeAlpha:      f32 index 18 → 23 (offset 72 → 92)
  - iTime:          f32 index 19 → 24 (offset 76 → 96)
  - cameraPosWorld: f32 indices 20..22 unchanged (offset 80, vec3
                    repacks against the f32 immediately after rather
                    than against a dedicated _pad slot)

Replaces the inline 'u.viewProj * vec4<f32>(p, 1.0)' projection in the
vertex stage with the shared 'worldToClip(u.cam, p)' helper. The
fragment-stage references to 'u.cameraPosWorld' / 'u.fadeAlpha' stay as
top-level fields — those are renderer-specific and live outside the
cam block.

Also bumps the UNIFORM_BUFFER_SIZE constant test from 96 to 112 to
match the new layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Embed the shared 'CameraUniforms' prefix from 'lib/camera.wesl' as the
first 80 bytes of the points 'Uniforms' struct.  The pre-refactor
layout placed 'pointSizePx' + 'brightness' at offsets 72/76, which
collide with the '_pad0/_pad1' slots that 'CameraUniforms' reserves —
so we can't drop the shared prefix in without relocating those scalars.

Resolution: put 'cam: CameraUniforms' first (occupies 0..79), then
swap 'pointSizePx' + 'brightness' into the 8 bytes of slack that the
old layout already had at offsets 88..95 (the '_pad0/_pad1' u32s
required for vec3-alignment before 'camPosWorld').  Same total size
(176 bytes), same alignment, every field from offset 96 onward
unchanged.  Replaces the inline 'u.viewProj * vec4(p, 1.0)'
projection with the shared 'worldToClip(u.cam, p)' helper and
'u.viewport' references with 'u.cam.viewportPx'.

CPU-side offset changes:
  - pointSizePx: f32 index 18 -> 22 (offset 72 -> 88)
  - brightness:  f32 index 19 -> 23 (offset 76 -> 92)
  - selectedIndex: UNCHANGED at offset 80
  - camPosWorld + pxPerRad + everything after: UNCHANGED

pickRenderer.ts DID need updating: its mid-frame
'POINT_SIZE_OFFSET' write moves from 72 to 88.  The
'SELECTED_INDEX_OFFSET = 80' write stays put, because
'CameraUniforms' is exactly 80 bytes and 'selectedIndex' is the
first renderer-specific field — the same byte address it occupied
before the refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Vitest sets `import.meta.env.DEV = true` by default, so the dev-mode
WGSL logger added in the wesl-plugin bootstrap commit fires inside
postProcess.test.ts. The previous shader-module mock returned a bare
`{}`, so calling `module.getCompilationInfo()` blew up with a
TypeError. Add a no-op mock that resolves with an empty messages
array — exactly the shape the production logger pattern-matches.

This is the only mock site that needed updating; other GPU module
tests (pickRenderer, pointRenderer, ...) don't take this code path
because their shader-module call sites don't yet wire the logger.
That changes when the rest of the renderers gain `?static` imports
(later WESL conversion tasks); we'll bring their mocks up to the
same shape at that point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Regression: at any zoom where fadeAlpha rose above zero, the milkyWay
pipeline was invalidated and the whole frame went black. Root cause:
wesl-plugin@0.6.74's linker only resolves `import` statements that
appear at the top of the shader. The four `lib/math` imports were
sitting next to the call sites near line 392 (idiomatic in
TypeScript / Rust, but wrong here). The linker emitted the source
verbatim, the `import` keyword reached Chrome's WGSL parser, and
the shader module compile failed silently — surfacing as
"Invalid RenderPipeline (unlabeled)" only on the first frame the
renderer actually used the impostor.

Fix is mechanical: move the four `import package::lib::math::*`
lines up alongside the existing `lib::camera` import at the top of
the file. Production bundle now contains inlined `fn rot2`, `fn sabs`,
`fn toPolar`, `fn toRect` (verified by grep).

This is the same constraint that broke brace-list imports earlier
in this PR — wesl-plugin's parser is stricter than the WESL spec
suggests. Revisit when wesl-plugin gets bumped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `label` properties to every createShaderModule, createRenderPipeline,
createBindGroup, createBindGroupLayout, createPipelineLayout, createBuffer,
createTexture, and createSampler call across the renderer (41 new labels
across 11 files). Routes all shader-module creation through the new
src/services/gpu/shaderCompileLogger.ts helper so compile errors auto-dump
the linked WGSL alongside the labelled module name.

Pure metadata — zero behavioral change. Browser-console errors that
previously said "(unlabeled)" now name the offending resource, which
materially shortened the round-trip on the milkyWay zoom-regression
debug earlier in this branch (the upstream "Invalid ShaderModule" error
chain was much harder to trace without a label naming the failing module).

Test mocks (pointRenderer.test.ts, pickRenderer.test.ts, postProcess.test.ts)
gain a getCompilationInfo no-op shim so the helper's dev-mode logger path
runs cleanly under Vitest's default import.meta.env.DEV=true.

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

Standalone lib file with no consumers yet. Exports three helpers extracted from
the duplicated billboard-expansion patterns across points/quads/disks/
proceduralDisks:

- quadCorner(vid: u32) -> vec2<f32>: 6-vertex triangle-list corner offset
  in [-1,+1]², replacing the per-shader CORNERS constant array.
- quadUv(vid: u32) -> vec2<f32>: same as (quadCorner+1)*0.5, returning the
  unit-square UV in [0,1]².
- expandBillboardScreen(cam, centerClip, sizePx, corner) -> vec2<f32>: clip-XY
  offset for a screen-pixel-sized, screen-aligned billboard. Used by points.

Notable scope decisions captured in the file's docblock:

- The plan's draft 'expandBillboardWorld' helper is omitted. None of the four
  candidate renderers actually wants a generic view-aligned world basis —
  quads uses a celestial-north basis, disks/proceduralDisks use orientation-
  driven (PA + inclination) bases that belong in lib/orientation (Task 6),
  and points is screen-aligned. Adding an unused helper would also force a
  'view' matrix into CameraUniforms that no renderer currently needs.

- The 6-vertex corner ordering follows the (BL, BR, TR, BL, TR, TL) pattern
  used by 3 of 4 callers; points will be migrated to match in a follow-up
  commit. Both orderings produce identical fragment coverage and identical
  interpolated UVs at every pixel inside the square (CCW triangulations of
  the same convex region), so the migration is safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the inline 6-vec CORNERS const + '(corner + 1) * 0.5' UV
remap with calls to 'lib/billboard::quadCorner' and 'quadUv'. The
view-aligned celestial-north basis math (NORTH_WORLD / upClip / upPx)
stays renderer-specific. Also split the previously brace-listed
camera import into one-per-line per the wesl-plugin gotcha.

No TS-side changes; corner ordering matches the lib (BL, BR, TR,
BL, TR, TL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the inline 6-vec CORNERS const + '(corner + 1) * 0.5' UV
remap with calls to 'lib/billboard::quadCorner' and 'quadUv'. The
orientation-aligned disk-plane basis (PA + inclination → 'major' /
'minor_3d' in 3D world space) stays renderer-specific and untouched
— that math is camera-independent and belongs to Task 6's
'lib/orientation.wesl' rather than the screen-space billboard lib.
Also split the previously brace-listed camera import into one-per-
line per the wesl-plugin gotcha.

No TS-side changes; corner ordering matches the lib (BL, BR, TR,
BL, TR, TL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror of the disks.wesl sub-commit: replace the inline 6-vec
CORNERS const with a call to 'lib/billboard::quadCorner'. The
orientation-aligned disk-plane basis (PA + inclination →
'majorAxis' / 'minorAxis' in 3D world space) stays renderer-
specific and untouched — that math is camera-independent and
belongs to Task 6's 'lib/orientation.wesl' rather than the
screen-space billboard lib. Also split the previously brace-
listed camera import into one-per-line per the wesl-plugin
gotcha.

Unlike disks.wesl, this pass does NOT import 'quadUv': the
fragment uses the raw [-1, +1]² corner directly as the radial
coordinate ('length(in.uv)' for the bulge + disk profile), so
the [0, 1]² remap that 'quadUv' performs would be wrong here.
The 'out.uv = corner' forwarding is unchanged.

No TS-side changes; corner ordering matches the lib (BL, BR,
TR, BL, TR, TL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the inline QUAD const-array lookup and screen-space pixel-to-
clip expansion with the shared 'quadCorner' and 'expandBillboardScreen'
helpers from lib/billboard.wesl. Migrates from the points-specific
(BL, BR, TL, TL, BR, TR) corner ordering to the (BL, BR, TR, BL, TR, TL)
ordering used by quads/disks/proceduralDisks; both are CCW triangulations
of the same square and the points pipeline runs with the default
cullMode: 'none', so output is byte-identical. Per-instance sizeScale
(selection halo) is post-multiplied onto the helper's returned offset.

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

Standalone introduction of the disk-plane axis lib — no consumers
adopt it yet, so output WGSL is unchanged for existing renderers.
Both 'disks.wesl' and 'proceduralDisks.wesl' will adopt 'diskAxes' in
follow-up commits.

API: 'diskAxes(posWS, paRad, cosI, sinI) -> DiskAxes { major, minor }'.
Camera-independent by design: orientation is intrinsic to the galaxy
in 3D space (see disks.wesl's long header for why this is load-bearing).

Standardises the pole-degeneracy threshold on disks.wesl's wider ~8°
form ('abs(dot(northPole, losDir)) > 0.99') rather than
proceduralDisks.wesl's narrower ~exact-pole form ('length < 1e-4'). The
wider form wins because it eliminates a basis disagreement that could
otherwise show as an abrupt rotation at the crossfade boundary between
the procedural impostor and the textured thumbnail for any galaxy
within 8° of the celestial pole.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the inline los/north/east/major/minor derivation in disks.wesl's
vertex stage with a call to 'diskAxes' from 'lib/orientation.wesl'.
Byte-equivalent for disks: the lib standardised on the wider
'|dot(north, los)| > 0.99' (~8°) pole-fallback threshold that disks
already used, so no near-pole behaviour changes. The axisRatio 0.05
clamp + (cosI, sinI) trig pair stay at the call site by design — the
lib intentionally takes pre-clamped trig inputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Switches from the wider disks form ('abs(dot(northPole, losDir)) >
0.99', ~8° from pole, swap seed BEFORE projection) to the tighter
proceduralDisks form ('length(northTangentRaw) < 1e-4', ~exactly at
pole, swap result AFTER projection). Most galaxies within 8° of the
celestial pole still produce a usable in-sky north tangent — float
math near the pole is fine until length(seed - dot(seed, los) * los)
genuinely underflows, which only happens when |dot| is essentially 1.

Net effect: PA fidelity preserved for ~8° of sky around each pole.
Both renderers still agree at the (much narrower) genuine-degeneracy
region where the fallback fires.

This is technically a behavior change for disks (a few catalog
galaxies very close to the pole no longer take the early world-Y
fallback), but the new path produces the SAME result the
proceduralDisks impostor was already producing for those galaxies —
so cross-pass consistency improves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the inline los/north/east/major/minor derivation in
proceduralDisks.wesl's vertex stage with a call to 'diskAxes' from
'lib/orientation.wesl'. Byte-equivalent for proceduralDisks: the lib
standardised on the tight 'northLen < 1e-4' post-projection pole
fallback that this renderer already used, so no near-pole behaviour
changes. The axisRatio 0.05 clamp + (cosI, sinI) trig pair stay at the
call site by design — the lib intentionally takes pre-clamped trig
inputs. Final sub-commit of task 6's orientation-lib adoption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapses duplicate fn ramp(t) between points.wesl and proceduralDisks.wesl.
Both renderers now share a single color-index → RGB mapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds applyCloudFade(color, opacity) helper used by points.wesl and
filaments.wesl. The CloudUniforms struct itself stays per-renderer:
points carries a sourceCode field for pick-identity packing that
filaments has no equivalent for, so a shared struct would be a
fictional unification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both renderers now import the shared struct. Filaments doesn't read
sourceCode today, but the CPU-side CloudFade class already produces
that exact 16-byte layout for both, so a divergent shader struct was
a fictional separation.

Also collapses the helper to a scalar applyCloudFade(alpha, opacity)
to undo the vec4 ceremony at the call sites — both sites multiply
opacity into a scalar alpha alongside other modulators.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapses three recurring smoothstep mask shapes (circular cutoff,
luminance gate, axis edge-band) into named helpers. Naming the shape
makes intent visible at the call site and removes per-renderer copies
of the same three patterns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Names the distance-modulus formula that was inlined in points.wesl
(appMag + Mpc distance -> absolute magnitude) as 'distanceModulus' in
a new astronomy-flavoured lib module. Future shaders that need to
convert between apparent and absolute magnitudes get a domain-named
import rather than re-deriving log10 via the natural-log + LOG10
dance, and the inline 'let LOG10 = ...' shadowing in points.wesl
goes away.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 7, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
skymap cd7c630 Commit Preview URL

Branch Preview URL
May 07 2026, 10:57 PM

rulkens and others added 9 commits May 8, 2026 00:12
Moves the five tone-map curves (linear / reinhard / asinh / gamma2 /
aces) out of toneMap.wesl into a shared lib so future renderers that
want to do their own tone-mapping (e.g. an HDR thumbnail pass) can
reuse them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pull five orphan helpers out of milkyWayImpostor.wesl into a shared
'util' lib: hash21 (ex 'rand'), valueNoise2 (ex 'noise1'), raySphere,
worldToGalactic, galacticToShader. Renames are the real win — 'rand'
was a deterministic hash misnamed as a PRNG, and 'noise1' was a
numbered slot with no companion. Skipped the plan's linearToSRGB /
srgbToLinear / encodePickId candidates: zero current callers (sRGB)
or one-line literal at one site (pick id), so adding them would be
speculative dead code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ment,pickFragment}.wesl

Replaces the single ~1500-line points.wesl with four files under
points/:
- points/io.wesl              — shared structs (Uniforms, PerVertex, VSOut)
- points/vertex.wesl          — @vertex fn vs (used by both renderers)
- points/colorFragment.wesl   — @Fragment fn fs (pointRenderer)
- points/pickFragment.wesl    — @Fragment fn fsPick (pickRenderer)

Each renderer pipeline now builds a separate vertex + fragment shader
module from disjoint sources. This eliminates a class of selection-
on-wrong-galaxy bugs that came from sharing one module between two
pipelines with diverging fragment paths.

WESL has no global state, so '@group/@binding' declarations cannot
be exported across modules. The bindings ('u', 'cloud') are
re-declared in vertex.wesl and colorFragment.wesl using the structs
imported from io.wesl, so the layouts are guaranteed to match;
pickFragment.wesl declares no bindings since it only reads VSOut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ex,fragment}.wesl

Mirrors the points/* split (task 13). The procedural-galaxy helpers
(stars, height, galaxyNormal, shadeGalaxyDisk, renderGalaxy) stay in
the fragment file — they're fragment-only and not reused.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t}.wesl

Mirrors the points/ and milkyWay/ splits (tasks 13-14). Each
pipeline now builds a separate vertex + fragment shader module from
disjoint sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gment}.wesl

Mirrors the points/ and milkyWay/ splits (tasks 13-14). Each
pipeline now builds a separate vertex + fragment shader module from
disjoint sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the points/ and milkyWay/ splits (tasks 13-14). Each
pipeline now builds a separate vertex + fragment shader module from
disjoint sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the points/ and milkyWay/ splits (tasks 13-14). Each
pipeline now builds a separate vertex + fragment shader module from
disjoint sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…o,vertex,fragment}.wesl

Mirrors the points/ and milkyWay/ splits (tasks 13-14). Each
pipeline now builds a separate vertex + fragment shader module from
disjoint sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rulkens rulkens changed the title WGSL → WESL conversion + shared shader library (in progress) WGSL → WESL conversion + shared shader library May 7, 2026
@rulkens rulkens marked this pull request as ready for review May 7, 2026 22:51
rulkens and others added 2 commits May 8, 2026 00:52
Resolves package-lock.json conflict by taking main's lockfile and
regenerating with npm install to merge in wesl + wesl-plugin deps
alongside main's MSDF label deps (msdf-bmfont-xml).

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

Brings the MSDF labels foundation (added on main in #38) under the
WESL convention: directory layout, three-file split, lib/camera
adoption.

The Uniforms struct now embeds 'cam: CameraUniforms' (80-byte universal
prefix) instead of declaring its own viewProj+viewport pair — both
layouts are byte-equivalent at offsets 0..79, so no CPU-side change is
required when the labels renderer eventually wires up a uniform writer.

No consumer existed yet (foundation-only PR), so no renderer.ts to
update.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rulkens rulkens merged commit f099e70 into main May 7, 2026
2 checks passed
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