feat(ui): AAA-grade WebGL atmosphere world behind every screen - #197
Open
adrienbrault wants to merge 26 commits into
Open
adrienbrault wants to merge 26 commits into
adrienbrault wants to merge 26 commits into
Conversation
The Three.js atmosphere needs a testable seam between gameplay and the scene: game cues (place, conflict, completion…) flow through a tiny bus into a pure director that owns energy, shake, flash and victory state. This test pins the contract before the scene exists.
The Three.js scene needs game cues without the game layer knowing about it: a tiny pub/sub bus carries place/erase/note/hint/conflict/ complete events, and a pure director turns them into energy, shake, flash, victory-glow and ring-queue state the renderer samples per frame. Keeping the state machine pure keeps it testable in node.
The atmosphere scene listens on the bus; feedback is the single seam where every game event already funnels, so cues must ride with sound and haptics rather than being emitted from call sites.
Feedback is the single seam every game event already funnels through, so the WebGL scene gets its choreography for free — no call-site sprawl. The bus swallows listener errors, keeping gameplay safe.
Pins the contract that turns the atmosphere director into pixels: canvas mount, RAF loop with visibility pause, bus subscribe/unsubscribe, mood-driven bloom baseline, cue-driven bloom peaks, graceful absence of WebGL, and a single still frame under prefers-reduced-motion. three.js is the system boundary — every GL class is stubbed so the lifecycle logic itself is what gets tested.
The AAA-grade ambience — nebula backdrop, bloom, particle dust — renders through a fullscreen canvas behind the DOM UI. three ships as a regular dependency; @types/three for the typed scene code.
GLSL shaders and the scene graph it builds are inherently long; the one-file-one-responsibility split (shaders + scene + component) already exists. Splitting further would only scatter related code.
The bus, director and feedback test were committed while biome's formatter and organize-imports assist still flagged them (3 lint errors on HEAD). Running --write closes that debt so CI can go green again before the WebGL layer lands.
Mock class fields must keep computed keys: a bare 'render' key inside a vi.mock factory collides with RTL's render import in vitest's mock-hoist analysis and crashes the suite at init. The rule's suggested fix would reintroduce that TDZ crash.
noUncheckedIndexedAccess types rings[0] as possibly undefined; lint was failing before typecheck could ever surface these three errors on the committed test. Optional chaining keeps the pin: undefined simply fails the comparison.
The app read as a website with a sudoku in it. This adds a fullscreen Three.js backdrop — drifting nebula, perspective grid, dust motes, ghost digits, orbiting glow orbs — composited with bloom, film grain and vignette, and driven by the atmosphere director so placing, conflicting and completing have physical weight: camera trauma shakes, cue-coloured shockwave rings, flash tint and a victory bloom swell. React stays a thin shell (engine lifecycle, bus subscription, visibility and reduced-motion gating); all scene logic lives in atmosphere-scene.ts where WebGL is the mocked system boundary. If WebGL is unavailable the layer silently degrades to the CSS gradient behind the transparent screen.
The app shell must be able to prove which mood a screen requested without reaching into the WebGL engine; a data-mood attribute on the layer element is the observable contract the routing tests will assert against.
A data-mood attribute gives routing a DOM-observable handle on which mood the shell requested, so App-level tests can assert the screen-to-mood mapping without touching the WebGL engine.
In-play screens (solo, daily, multiplayer) drive the tense game ambience; menus and meta screens stay in the calm menu mood. The mapping is extracted as a pure function so App can stay a thin router without carrying an untested conditional.
A pure lookup so App derives menu vs game ambience from screen.name without threading mood through every screen component or adding an untested conditional in the router.
The WebGL world must exist app-wide, not per-screen: remounting it between menu and game would cut the cross-fade the director builds. App derives the mood from screen.name via the pure moodForScreen lookup and renders Atmosphere as a persistent sibling of content.
The WebGL backdrop lives at z-index 0 with pointer-events disabled; screens lift to z-10 and drop their opaque bg-primary fills so the nebula, dust and bloom show through behind the cards. The .screen radial glow stays as the CSS-only fallback tint for machines where WebGL initialization fails and the layer falls back to flat bg-primary.
Biome formatter normalizes the file spacing introduced by the mood mapping commit.
Weak GPUs (integrated laptops, software rasterizers) should not stay pinned at full resolution while the world chugs; sustained sub-30fps frames must drop the renderer and composer to a cheaper scale.
A smoothed frame-time EMA drops the canvas pixel ratio and the composer render targets to half size once, on machines (software rasterizers, weak integrated GPUs) that stay under 30fps after warmup. Screenshots CI and low-end phones keep a responsive game instead of a pinned GPU.
SwiftShader software rendering burns 100ms+ per frame, and a fake clock's runFor() replays every queued rAF tick synchronously — the atmosphere frame loop stalled landing/challenge/multiplayer timing tests past their 30s budget on desktop-sized viewports. Returning null for webgl contexts routes every scene through the tested CSS-fallback path; scenes that want the real world opt in per test.
The backdrop plane was scaled once per resize using its own depth of 40 as the measurement distance, but the camera sits ~10 units in front of it — the real frustum slice at that plane is ~50 units deep, so the plane was undersized and its edges showed as a hard rectangle. The camera also drifts every frame with mood and trauma shake. fitBackdrop() now recomputes coverage from camera.position.z + depth each frame, with a 1.3 margin covering parallax sway and roll.
Every atmosphere particle was AdditiveBlending: adding light onto a near-white sky clamps to white, so grid, dust, glyphs and orbs simply vanished in light mode. setTheme now swaps those materials to normal blending — the same particles read as ink on the dawn sky. The light palette was also washed out by bloom: the whole background sat above the 0.78 threshold, bloomed uniformly and flattened to white. The threshold now clears the sky (~0.95) so only flashes and victory bloom, and the gradient was deepened to real mint aqua. Glyph and orb alphas got matching light bumps, and the vignette joined the palette (0.55 dark / 0.28 light) so the light world keeps its airiness.
The CSS ambient glow must stand down when the shader paints its own sky — the two washes stack and flatten the world. The layer needs an observable mode for that contract, so the tests pin data-atmosphere to webgl on boot and fallback when the engine cannot start.
data-atmosphere now reports webgl or fallback on the layer. With a live shader the radial screen glow stacks on top of the world's own gradient — a 50% mint wash that flattened the light theme's sky. The glow keeps guarding only the fallback case via a sibling selector.
The atmosphere layer was invisible to the screenshot pipeline: the fixture stubs WebGL off by default (SwiftShader rendered every scene at a crawl), so nothing proved the shader world composes. Four new scenes opt into real WebGL, assert the canvas mounts with the right mood, then land a landing (menu) and solo (game) capture per theme. Combine sheets gain an atmosphere group and meta labels so the world lands in the contact sheets next to the DOM UI.
Deploying sudoku with
|
| Latest commit: |
db672ea
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d4591c66.sudoku-4cc.pages.dev |
| Branch Preview URL: | https://dsh-flash-next-2.sudoku-4cc.pages.dev |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The app read like a sudoku website, not a game. This stack mounts a live Three.js world behind the DOM UI: a nebula sky, starfield, perspective grid floor, drifting digit glyphs and glow orbs, graded with ACES tone mapping, bloom, film grain and vignette — all purely decorative (pointer-events: none), so the DOM stays the game.
What's in the stack
src/lib/atmosphere.ts): pub/sub of game cues (place, erase, hint, win, conflict…) consumed by a pure state machine (mood, ambient, trauma shake, victory, flash rings) sampled per frame by the scene.gameFeedbackgained the cue seam;screen-mood.tsmaps the active route tomenu/game.src/components/atmosphere-scene.ts): EffectComposer chain (Render → UnrealBloom → grade shader → Output), FogExp2, per-theme palettes. Light theme renders particles with normal blending (additive clamps to white on a near-white sky) and a bloom threshold above the sky so the frame doesn't wash out; the backdrop plane is refit against the live camera frustum every frame.Atmosphere.tsx): owns RAF loop, resize, visibility pause, pointer parallax, theme via MutationObserver, reduced-motion (one still frame), anddata-atmosphere="webgl|fallback"so the CSS screen glow retires when the shader paints its own sky.webgl: truescenes capture the world in both themes and both moods across all four devices, with a new combine-sheet group.Verification
bun run cigreen: lint + typecheck + 969 unit tests.bun run screenshotsgreen: 196/196 across iPhone SE / 14, iPad Mini, Desktop → 18 contact sheets; DOM scenes unchanged (CSS fallback), atmosphere sheets show the world full-bleed in both themes.Notes
git config commit.gpgsign true.git rebase --autosquashbefore push.