Grok Zephyr is WebGPU-first: 1,048,576 satellites are propagated on a GPU compute shader and drawn through a multi-pass HDR pipeline. That output lives in a WebGPU swapchain that headless browsers, Playwright, and agents cannot read back, which makes automated and visual debugging of orbital geometry, Earth/atmosphere, glow, and bloom difficult.
The WebGL2 fallback renderer renders the same simulation through a readback-friendly WebGL2 path so the scene can be screenshotted and inspected by CI and agents, and so the project can serve as a working reference when porting large-scale GPU compute + rendering features.
| Action | How |
|---|---|
| Use the WebGL2 renderer | ?renderer=webgl |
| Force WebGPU (default) | ?renderer=webgpu |
| Persisted choice | Either value is saved to localStorage['zephyr.renderer'] and used on the next load |
| Reduce satellite count | &sats=100000 (clamped to [1, 1048576]; default is the full constellation) |
| Pick a view mode | &mode=0..4 (0 horizon, 1 god, 2 fleet, 3 ground, 4 moon) |
| Load real TLE data | &tle=starlink (shared with the WebGPU path) |
| Debug flags | &debug=wireframe,lod,points,noearth,nostars,nobloom,nosats |
| Image tuning (dev) | &bloomThreshold=1.8&bloomKnee=0.05&bloomIntensity=1.6&satCore=0.35&satFalloff=0.08 |
| Disable shader floors | &dev=1 — sliders use the full configured range (no max(threshold, 1.5) clamp) |
Example: http://localhost:5173/?renderer=webgl&cam=god&mode=1&sats=400000&debug=lod&bloomThreshold=1.6
When the WebGL path is active, window.zephyrGL is exposed:
window.zephyrGL.getDebug(); // current debug options
window.zephyrGL.setDebug({ showBloom: false }); // toggle a pass at runtime
window.zephyrGL.capture(); // PNG data URL of the current canvas
window.zephyrGL.renderer.hdrEnabled; // true if RGBA16F targets are availablepreserveDrawingBuffer is enabled on the WebGL2 context, so canvas.toDataURL()
and gl.readPixels() both return the rendered frame.
The IMAGE TUNING collapsible section in the controls panel (WebGPU and WebGL)
adjusts bloom threshold, knee, composite intensity, and satellite core radius/falloff
at runtime. Values persist in localStorage['zephyr.imageTuning'].
Query params seed the initial session (overriding stored values when present):
Per-view tuning profiles (src/core/ViewTuningProfile.ts) override bloom threshold,
knee, intensity, satellite kernel, halo strength, and distance cull per camera mode.
Profiles blend smoothly during mode transitions; the HUD Tuning line shows the
active profile. Dev IMAGE TUNING sliders override profiles when moved.
| Param | Range | Default | Maps to |
|---|---|---|---|
bloomThreshold |
0.5–3.0 | 1.5 | Bloom bright-pass cutoff |
bloomKnee |
0.01–0.3 | 0.05 | Soft knee around threshold |
bloomIntensity |
0–3.0 | 2.25 | Composite bloom multiplier |
satCore |
0.20–0.55 | 0.40 | Satellite smoothstep outer edge |
satFalloff |
0.02–0.30 | 0.10 | Satellite smoothstep inner edge |
dev |
1 |
off | Disables shipping shader floors for full slider range |
An on-screen debug panel (bottom-right, "◈ WEBGL2") mirrors these toggles.
Headless WebGL captures guard against bloom washout, satellite kernel drift, and other appearance regressions that unit tests cannot see.
# Install browsers once (Chromium + SwiftShader software GL)
npx playwright install chromium --with-deps
# Run against committed baselines
npm run test:visual
# Regenerate baselines after an intentional visual change
UPDATE_BASELINES=1 npm run test:visual
# or:
npm run test:visual:updateWhen to rebaseline
- Intentional shader / bloom / kernel / view-tuning changes (
ViewTuningProfile,ImageTuning, satellite WGSL, ground-observer CSS) - New view mode or ground preset added to
tests/visual/webgl-views.spec.ts - SwiftShader or Playwright Chromium version bump (rare — review diff PNGs first)
Do not rebaseline to silence a failing test without reviewing
tests/visual/diffs/<case>-diff.png.
Harness URL params (used by tests/visual/webgl-views.spec.ts):
| Param | Purpose |
|---|---|
renderer=webgl |
Boot the readback-friendly path |
sats=30000 |
Reduced constellation for CI speed |
seed=42 |
Deterministic Walker shell jitter |
demo=0 |
Disable auto demo cinematic |
simTime=180 |
Fixed orbital phase |
timescale=0 |
Freeze simulation after load |
mode=0..5 |
View mode (horizon / god / fleet / ground / moon / skyline) |
ground=houseWindow |
Ground observer preset when mode=3 (beachNight, carWindshield, rooftop, airplaneWindow, …) |
pattern=0..2 |
Beam pattern (chaos / GROK / 𝕏) — harness smoke on WebGL |
animation=3..5 |
Constellation animation (smile / rain / heartbeat) — WebGPU renders; WebGL harness only |
Baseline inventory — see tests/visual/baselines/README.md for the full
table (10 view/preset cases + 2 pattern harness cases).
Each scene captures the canvas via window.zephyrGL.capture() after warmup
frames (60 for views, 90 for patterns). Assertions combine:
- Golden PNG comparison (
tests/visual/baselines/*.png) with a per-scene pixel-diff ceiling tolerant of SwiftShader noise. - Mean luminance and bright-pixel ratio bands (
*.jsonsidecars) so bloom floor removal or alpha falloff widening fails even when pixels differ slightly.
On failure, a red-highlight diff PNG is written to
tests/visual/diffs/<case>-diff.png for local debugging.
A dedicated bloom pass guard toggles showBloom off via window.zephyrGL.setDebug
and asserts mid-tone energy drops — proving the bloom stack is active. Shipping
appearance (threshold floors, satellite kernel) is guarded by the golden baselines.
CI runs npm run test:visual via .github/workflows/visual-regression.yml:
- Every push to
main - Pull requests that touch
src/shaders/,ViewTuningProfile,ground-observer.css, WebGL/render paths, ortests/visual/
Unit tests and type-check run separately on every PR (.github/workflows/test.yml).
Visual diff artifacts upload on CI failure.
Shared (single source of truth):
- Orbital element data + Keplerian math —
src/core/OrbitalElements.ts. Both the WebGPUSatelliteGPUBufferand the WebGL renderer use this; there is no second copy of the orbit generation, TLE parsing, or position/velocity formulae. - Camera —
CameraController(calculateCamera,buildViewProjection) drives both backends, so all five view modes and the idle cinematic behave identically. - Earth geometry —
genSphere()fromsrc/utils/math.ts.
Reimplemented in GLSL ES 3.00 (src/webgl/shaders.ts):
- Satellite billboards, Earth + atmosphere, starfield, bloom, ACES tonemap + grade.
- Distance-based satellite LOD kernels (near / mid / far) match
src/shaders/render/satellites.ts.
Not (yet) ported to WebGL: volumetric god-ray beams, ribbon trails, TAA, motion blur, depth-of-field, and the J2 / RK4 physics modes. The WebGL path uses the simple-mode circular propagation only. These are intentionally WebGPU-only; the WebGL renderer is a reference/inspection tool, not a feature-parity clone.
main.ts (GrokZephyrApp)
├─ backend = resolveRendererBackend() // ?renderer / localStorage
├─ initialize()
│ └─ backend === 'webgl' → initializeWebGL() (skips all WebGPU setup)
└─ renderWebGL() loop → WebGLRenderer.renderFrame(frame)
src/webgl/
rendererSelection.ts backend + ?sats resolution
glUtils.ts context, program compile/link, FBO/texture helpers
shaders.ts GLSL ES 3.00 sources (mirror of the WGSL passes)
WebGLRenderer.ts orchestrator: starfield → Earth → satellites → bloom → composite
WebGLDebug.ts ?debug parsing, on-screen panel, window.zephyrGL
renderWebGL reuses the exact WebGPU frame setup (timing, demo cinematic, camera
state) and passes a compact WebGLFrame to the renderer:
{
(viewProj, cameraPos, sunDir, simTime, time, backgroundMode);
}viewProj is the column-major matrix from CameraController.buildViewProjection,
used directly as a GLSL mat4 (uniformMatrix4fv(loc, false, m)).
The WGSL compute pass (orbital_compute.wgsl) writes a position buffer that the
satellite vertex shader reads. WebGL2 has no compute shaders, so the WebGL path
moves the simple-mode propagation into the satellite vertex shader. Orbital
elements (raan, inclination, meanAnomaly0, shellData) are uploaded once as a
per-vertex attribute; each GL_POINTS vertex computes its own position:
M = meanAnomaly0 + meanMotion(shell) * uSimTime;
pos = orbitR(shell) * rotate(raan, inclination, M);This keeps the full 1,048,576-satellite set entirely on the GPU with zero
per-frame CPU work — gl.drawArrays(GL_POINTS, 0, count) in a single call. The
formula is identical to OrbitalElements.calculatePosition(); an invariant test
(OrbitalElements.test.ts) asserts every satellite stays on its shell radius.
When using this WebGL path as a reference for a WebGPU port (or vice-versa):
| Concern | WebGL2 (fallback) | WebGPU (production) |
|---|---|---|
| Propagation | per-vertex in the vertex shader | compute shader → storage buffer, read by the vertex shader |
| Per-instance data | vertex attribute (vertexAttribPointer) or a data texture sampled by gl_VertexID/gl_InstanceID |
storage buffer indexed by instance_index |
| Draw | drawArrays(POINTS, 0, N) (1 call) |
draw(6, N) instanced billboards |
| HDR target | RGBA16F via EXT_color_buffer_float (falls back to RGBA8) |
native rgba16float |
| Frustum cull / LOD | in the vertex shader or on CPU | in the compute pass (writes visibility into position.w) |
| Bloom | threshold + 2× separable Gaussian (half-res) | Kawase dual-filter pyramid (up to 5 levels) |
Rule of thumb: anything WebGPU does in a compute dispatch over a storage
buffer maps to either a WebGL vertex-shader computation over an instanced
attribute (when each output is consumed by exactly one draw vertex) or a
transform-feedback / data-texture pass (when outputs must be read back or
shared across passes). Prototype geometry and shading bugs in WebGL2 where you
can readPixels, then port the validated math into WGSL.