From 6bc125be1c769ebe2f11e07ad7421129991c39cd Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 21 May 2026 07:43:10 +0100 Subject: [PATCH] more --- .gitignore | 1 + e2e/app.spec.ts | 54 ++ index.html | 79 +- package.json | 2 +- public/manifest.webmanifest | 3 +- public/og-image.jpg | Bin 53367 -> 27787 bytes public/robots.txt | 4 + public/sitemap.xml | 6 + src/audio/garden-audio-config.ts | 2 +- src/audio/garden-audio.ts | 65 +- src/audio/generative-piano.ts | 14 +- src/audio/piano-sampler.ts | 111 +-- src/audio/piano-samples.ts | 126 ++-- {public/audio => src/audio/samples}/A0v12.m4a | Bin {public/audio => src/audio/samples}/A1v12.m4a | Bin {public/audio => src/audio/samples}/A2v12.m4a | Bin {public/audio => src/audio/samples}/A3v12.m4a | Bin {public/audio => src/audio/samples}/A4v12.m4a | Bin {public/audio => src/audio/samples}/A5v12.m4a | Bin {public/audio => src/audio/samples}/A6v12.m4a | Bin {public/audio => src/audio/samples}/A7v12.m4a | Bin {public/audio => src/audio/samples}/C1v12.m4a | Bin {public/audio => src/audio/samples}/C2v12.m4a | Bin {public/audio => src/audio/samples}/C3v12.m4a | Bin {public/audio => src/audio/samples}/C4v12.m4a | Bin {public/audio => src/audio/samples}/C5v12.m4a | Bin {public/audio => src/audio/samples}/C6v12.m4a | Bin {public/audio => src/audio/samples}/C7v12.m4a | Bin {public/audio => src/audio/samples}/C8v12.m4a | Bin .../audio/samples}/Dsharp1v12.m4a | Bin .../audio/samples}/Dsharp2v12.m4a | Bin .../audio/samples}/Dsharp3v12.m4a | Bin .../audio/samples}/Dsharp4v12.m4a | Bin .../audio/samples}/Dsharp5v12.m4a | Bin .../audio/samples}/Dsharp6v12.m4a | Bin .../audio/samples}/Dsharp7v12.m4a | Bin .../audio/samples}/Fsharp1v12.m4a | Bin .../audio/samples}/Fsharp2v12.m4a | Bin .../audio/samples}/Fsharp3v12.m4a | Bin .../audio/samples}/Fsharp4v12.m4a | Bin .../audio/samples}/Fsharp5v12.m4a | Bin .../audio/samples}/Fsharp6v12.m4a | Bin .../audio/samples}/Fsharp7v12.m4a | Bin {public/audio => src/audio/samples}/README.md | 0 src/config.ts | 9 +- src/config/color-interactions.ts | 4 +- src/config/default-settings.ts | 30 +- src/config/runtime-controls.ts | 55 +- src/config/types.ts | 13 +- src/config/vibe-presets.ts | 7 - src/{app-constants.ts => consts.ts} | 0 src/game-loop/agent-population.ts | 4 +- src/game-loop/export-snapshot-renderer.ts | 4 +- src/game-loop/game-loop-resources.ts | 33 +- src/game-loop/game-loop-types.ts | 3 +- src/game-loop/game-loop.ts | 43 +- src/game-loop/gpu-profiler.ts | 180 +++++ src/game-loop/internal-render-size.ts | 8 - ...stats-overlay.ts => perf-stats-overlay.ts} | 27 +- src/game-loop/simulation-frame.ts | 53 +- src/game-loop/simulation-textures.ts | 38 +- src/game-loop/stroke-output.ts | 2 +- src/index.scss | 1 - src/index.ts | 638 +++------------- src/page/audio-control.ts | 158 ++++ src/page/config-pane.ts | 164 ++-- src/page/eraser-size-control.ts | 68 ++ src/page/error-presenter.ts | 62 ++ src/page/mirror-segment-control.ts | 68 ++ src/page/palette-control.ts | 54 ++ src/page/splash-screen.ts | 47 ++ src/page/vibe-navigator.ts | 40 + src/pipelines/agents/agent-dispatch.ts | 3 +- .../agent-generation-pipeline.ts | 16 +- src/pipelines/agents/agent-limits.ts | 25 + src/pipelines/agents/agent-pipeline.ts | 187 ++--- src/pipelines/agents/agent.wgsl | 77 +- src/pipelines/brush/brush-pipeline.ts | 307 ++------ src/pipelines/brush/brush.wgsl | 59 +- src/pipelines/common-state/common-state.ts | 5 +- src/pipelines/common/line-segment-buffer.ts | 92 +++ src/pipelines/common/line-segment.wgsl | 40 + src/pipelines/diffusion/diffuse.wgsl | 31 +- src/pipelines/diffusion/diffusion-pipeline.ts | 7 +- src/pipelines/eraser/eraser-agent-pipeline.ts | 66 +- src/pipelines/eraser/eraser-agent.wgsl | 13 +- .../eraser/eraser-texture-pipeline.ts | 256 ++----- src/pipelines/eraser/eraser-texture.wgsl | 60 +- src/pipelines/render/render-pipeline.ts | 192 +++-- src/pipelines/render/render.wgsl | 41 +- src/style/_app-shell.scss | 51 +- src/style/_config-pane.scss | 147 +++- src/style/_motion.scss | 16 - src/style/_toolbar.scss | 713 +----------------- src/style/toolbar/_buttons.scss | 157 ++++ src/style/toolbar/_garden-controls.scss | 148 ++++ src/style/toolbar/_layout.scss | 137 ++++ src/style/toolbar/_responsive.scss | 156 ++++ src/style/toolbar/_shared.scss | 105 +++ src/utils/delta-time-calculator.ts | 19 +- src/utils/graphics/bind-group-cache.ts | 29 + src/utils/graphics/initialize-gpu.ts | 7 + src/utils/graphics/resizable-texture.ts | 55 +- vite.config.ts | 5 +- 104 files changed, 3088 insertions(+), 2414 deletions(-) create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml rename {public/audio => src/audio/samples}/A0v12.m4a (100%) rename {public/audio => src/audio/samples}/A1v12.m4a (100%) rename {public/audio => src/audio/samples}/A2v12.m4a (100%) rename {public/audio => src/audio/samples}/A3v12.m4a (100%) rename {public/audio => src/audio/samples}/A4v12.m4a (100%) rename {public/audio => src/audio/samples}/A5v12.m4a (100%) rename {public/audio => src/audio/samples}/A6v12.m4a (100%) rename {public/audio => src/audio/samples}/A7v12.m4a (100%) rename {public/audio => src/audio/samples}/C1v12.m4a (100%) rename {public/audio => src/audio/samples}/C2v12.m4a (100%) rename {public/audio => src/audio/samples}/C3v12.m4a (100%) rename {public/audio => src/audio/samples}/C4v12.m4a (100%) rename {public/audio => src/audio/samples}/C5v12.m4a (100%) rename {public/audio => src/audio/samples}/C6v12.m4a (100%) rename {public/audio => src/audio/samples}/C7v12.m4a (100%) rename {public/audio => src/audio/samples}/C8v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp1v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp2v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp3v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp4v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp5v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp6v12.m4a (100%) rename {public/audio => src/audio/samples}/Dsharp7v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp1v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp2v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp3v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp4v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp5v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp6v12.m4a (100%) rename {public/audio => src/audio/samples}/Fsharp7v12.m4a (100%) rename {public/audio => src/audio/samples}/README.md (100%) rename src/{app-constants.ts => consts.ts} (100%) create mode 100644 src/game-loop/gpu-profiler.ts rename src/game-loop/{dev-stats-overlay.ts => perf-stats-overlay.ts} (65%) create mode 100644 src/page/audio-control.ts create mode 100644 src/page/eraser-size-control.ts create mode 100644 src/page/error-presenter.ts create mode 100644 src/page/mirror-segment-control.ts create mode 100644 src/page/palette-control.ts create mode 100644 src/page/splash-screen.ts create mode 100644 src/page/vibe-navigator.ts create mode 100644 src/pipelines/agents/agent-limits.ts create mode 100644 src/pipelines/common/line-segment-buffer.ts create mode 100644 src/pipelines/common/line-segment.wgsl delete mode 100644 src/style/_motion.scss create mode 100644 src/style/toolbar/_buttons.scss create mode 100644 src/style/toolbar/_garden-controls.scss create mode 100644 src/style/toolbar/_layout.scss create mode 100644 src/style/toolbar/_responsive.scss create mode 100644 src/style/toolbar/_shared.scss diff --git a/.gitignore b/.gitignore index f06235c..0f59a68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules dist +test-results diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f74b88d..19f0efd 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -171,3 +171,57 @@ test('keeps audio focus outlines scoped to the active control', async ({ page }) await expect(volumeSlider).toHaveCSS('outline-style', 'solid'); await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); }); + +test('keeps the config overlay scrollable and dismissible on mobile', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 640 }); + await page.goto('/'); + + const startButton = page.getByRole('button', { name: 'Start' }); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await startButton.click(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, + }); + + const settingsButton = page.locator('button.settings'); + await settingsButton.click(); + + const pane = page.locator('.config-pane'); + const closeButton = page.locator('.config-pane-close'); + await expect(pane).toBeVisible(); + await expect(closeButton).toBeVisible(); + + const paneMetrics = await pane.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + bottom: rect.bottom, + clientHeight: element.clientHeight, + overflowY: style.overflowY, + scrollHeight: element.scrollHeight, + top: rect.top, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + width: rect.width, + }; + }); + + expect(paneMetrics.top).toBeGreaterThanOrEqual(0); + expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight); + expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8)); + expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight); + expect(['auto', 'scroll']).toContain(paneMetrics.overflowY); + + await pane.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(() => pane.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + await closeButton.click(); + await expect(pane).toBeHidden(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); +}); diff --git a/index.html b/index.html index 9c3e01a..25ae55e 100644 --- a/index.html +++ b/index.html @@ -7,24 +7,64 @@ content="width=device-width,initial-scale=1,viewport-fit=cover" /> + + + + + + + - - - - + + + + + + + + + + + + + + - + + + + Fleeting Garden @@ -43,6 +83,7 @@ to paint coloured paths, then use the toolbar to change colours, erase, export, adjust the config overlay, restart, or open more information.

+
@@ -50,7 +91,7 @@

Fleeting Garden

- Draw coloured paths and watch them bloom into a living WebGPU garden. + Tend it while you can. The garden returns to weather either way.

@@ -85,22 +126,24 @@

Fleeting Garden

- A living sketchpad where each stroke becomes a trail that agents follow, - branch from, and weave into the scene. + A garden is what we tend; the wild is what we get the moment we look away. + Both happen here at once. Your strokes plant colour, small agents follow them, + branch off, and slowly rewrite the patch you laid down into something you + didn't quite plan.

- Paint with the three colour swatches, carve space with the eraser, and raise - the mirror control when you want radial patterns instead of a single line. + Three swatches plant the line. The eraser carves a clearing. The mirror folds + one gesture into many, like footpaths around a hidden well.

- Switch vibes to recolour the whole garden without clearing your drawing. Add - or mute the generated piano, restart for a blank canvas, or export the current - frame as an internal buffer snapshot. + Switch vibes to change the season; your shapes stay, the light moves. Add or + quiet the piano. Restart when you want a fresh field. Take a snapshot if you + want to keep one particular instant of weather.

- Built with WebGPU and running locally in your browser. Source on - GitHubschmelczer.dev.

diff --git a/package.json b/package.json index dbc6789..4a6d8f7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.2.0", "private": true, "type": "module", - "description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 23a137e..c61d444 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,8 +1,9 @@ { "name": "Fleeting Garden", "short_name": "Garden", - "description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "start_url": "./", + "scope": "./", "display": "fullscreen", "background_color": "#10151f", "theme_color": "#10151f", diff --git a/public/og-image.jpg b/public/og-image.jpg index 98ae6a571b4d55dc8085758483fd7d5ada038e27..03c89396f7d990d6d20194579053a97f2b723bfa 100644 GIT binary patch literal 27787 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 Q&bO7@1pXre2j7hR2Ke<4IsgCw literal 53367 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

-NdSJZCpDvH2CPr!gZfi-yJota)w6XDl6^y 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 z zmrtB4$_lN4ul6Myka>aLKd>9W{P`utKoCDvay&lhB9kXhZ$uF}dU+AN9(QUUl+ zRJ)Ao7Wuuy)#~%3-QV|~F9>4xBJH~G!jIQpUFwQx3eGmBripfL{`-D}&HP>Zh)@3k zodwIUT>AJvgg$_(kM;Ip#{1Og%gUO+$;of%OUashQ^)j^yC)~5`koYA+PObb33>tK zRTa-}GD@>^a>NDsb3>=;le)9*w?!R@vlH&#w-zPkE6Y{b(JQ=Ln~|OQrhCxW+iFkH z`JGN${;^XwLms8;FwUaX-o`E_GjDc1EU5EqduCl*N@9m@H05IC(O7EOmHM%HP2I_` zJpkufvJWC$16^isou|x%{Vs*h6+D-^bWb_}lX@Bl4k9lF!!Ks@v0i25T(3fh-?g3Z zujT;&*hW-poFag&lVmh&zPC#Spg&`^IB@cs?ypxTBvT&c@%z4 z^R2bwQ9-^`0Y4gy42B;Z%|mz*+I{Nj2mTnn(uXEC(Y`wR)wV8^cl+EAQ^)$ZTPpe@ zE8j%fLd6sSkohO1M_A}5sU}_JTLT^S55L^9B-y-3XUjzy=|Ch zrp>#_zAYYPi`h@#_qt+xe^WX>J?ILG(Nzd5Ln>sIvdk9}#*yb?*-Ks!il%0=D#feR z`t#uJhM(6~_!ws@4yrq#_O;ws<5LE)wu|d==pr;ZU!VnmD2?H3j{uXcPE_r1Ct8$U zbibyIN5+p>m3BXT#4#IKx!wQAf$S>Ww)Z}3Ox|q?8DgN-$?G~~S5fyQ*Mz-U9qkmX z-gT@8X9im|7duHV?f{&m`z0FJT=x?KRibGp{EmM)-wNjfCe%BrYLPdjq*}Akt+Y<_ zRciZ3eGUiz_@4>W{^F+o3J9LR4D{dm`nbnm*FD6P2HOm!&ay)* z=t>_AWRW)nq`*F+(+@et>l6LHo&hdo$IRyZBA#~sGec;m337Kv-t5F(t|m?3QPjGl zVDpFih36AZ8306ifeq&9wOIxwN@eU2&El-m*o{ozG5DJ zVd-oFxc#hT+sH=3lulU4>?grz7(#~_Dc2Oq2;mgg6s9uH#l?A^+xZ8DRajF}21n~r zn9=Lch+v7O&T9_vt0EF*p3&FL>=&4It$r5Th)fZXjIL?!cL|ClJ6+|Fi~sr@%N|>6 zE{*H4ttIWrROdg~LqaTet^Pe;#{Z5@{HJ?oBY+k*?((5ph=|9+V8&l{J*;SO&ora^?QIk?f-8-?+pKM&B$;5 zB~j}&7jh9yaJuFmC9~)q*Vf3GbsCQ)B<%8#HRWy|%Rrl_F^kRwQ9FXSjDVy3WqzMG z;NMjFS(;L_x@STR&GtM1#mjfN13~&YB}Z&!eUyS++*(I01~L4ihOgfI2^VAQc1duT z=qhOUj~mvJ?5&GUK8~J8z~_UBDcUCsZRaZ6+>-D2fr-0$#Q-M*F;xA56+;umetuIJ ztTKYI!*Vbd2|(3kg(x(D)W6o;cz%(sbosJ(a!GAf8o>)i*F0?b?4i&*Ak#7}A_v0o z_h}j2a)oh%YNv7KeK+`)ym<|NCd%aK?UhssuQNh92NUP1Ab?r14;J$gfKA(nWB9>? z`;WNYYe-`tkU-62aw%vM6K)^R9~qia^M_En3MS1fOSz<8!H#7ws|<7pq?p-9&LWmA z2&^sP>ZHM$yOWh!4#>c987+J38^>8H$nnf_pHFgn!V~_1iQaU_=pv01rj%$F#PwIG zXhBb1@6U$`r*TjEP`n8gexH#n%7x#xa?{k<_jmJiVs_ADDGtbN04%L?bL*>QLPo>A z2Q>J#2j*skBJNpZDvyaDDfkjh+SQPW9l_OgykZ%}HTOMhH&-EW8P_nxF$Eo!!N^Pc zvAOFrC2l(C(h*J)bPnInY~SWRk52^?fMRd>BD zwBxwD^gJ|a0w_dB@vxREl)U{7!!lg(?fQ(f;OA-HfolapLIwokN<~Of0~}{cRaZdj zghzcg9riJ8@3}eHXQiENIjUQ34J{J8NC}FTsPEVl;YaTtN!4_VGaoV6&sgryYJD}V zZNAQAowyWv`(}RJi%TkFY|v6@T`6_}-3{TTEw$hNp>FL82LQABSl3$c75T(RHNGFK zr56npEObg zPm$sFjdF7{r>Sr1^3hq`{d0GJWcS0uRcv3{McK3$7B)JbYjE0fko41j^f`wC-%S)f z+{zu32k*(ef2|&EIzDV(Eo8@h8hh|WOL*_bNN{ePIv*QJ?}I)v9Z$)hB84$wP9N$% zKXsyba!B?>6MrN3x;y|6ej=5jP*9%uDo|7pgI^Le({3u84)+FHzOVw`@)V~8+EE8) zWQzV1L0flRvwTY3uyKzf37*3%4Q$lI3>;JP*x(;q9CkSHNCMthw4k;=UK6#o8iQ^m z*%uYMR>{N8Rmhl~F0_}Df}PAJU=rtDfJr>N*p-rC21n7_Tg)Z_@UTaIDHV6ZK`VWj z%06nP*D~JFOrf*0=P78UeYJVc;`O88boH3d!0P^8>tF0VD2GfYYr-QW=GHlPh<_#*GqxT0 zsBwGS(`i@;mm2&Mpgd(4qG23lGB&C&f*R2Y)kiK9S8%R()i288dn0WJ&SKD(C`B?y zpIS_wdFXJlIH`GF*_2@BJCA6Ovzr~ZE&LR6ZsV|QpOHC}t317-1}KFRR^q7o&F#CJ zW~sWnSKM(iCq#7r@#nCXGN7K*Z}6h*sLjI*0V^8zA(j9GUiCFd`X^r>UC)gKyG_PWX=s-b33UzW2pPHznm z!tgkr{^n*=EIPPGAfGmwDpN7TG3`j z9j15$uyy82x*}CmU>dAVMTFK$_Uv|WR&F>Cty*4@RtRA&R-@E@5RNeD$BgqrIV>T& zW5FrPTBrTbYHE_;znh-ASOOWp>$15=^7UFcVd&|Ybfar;oH_~b%QS_c4&IRZfzbIt zmOe`5#;%0t}4?WWu-N|u@ve_em6Xjt+_xO^k@)+$tS);M&6aWyB z6IR=I-yqr11X1gVdN%>LDd9!%a72S`gZ(#Kxoe$#BAqzbCFU4>GB#Tx6II7U8VM5G zuv{t57ph)w`}oMF+1dor?D#m?0||pfEk1WzyGW1I7bJ|iZjivwsPn@UtNx*f8H9)G6g0~d#ES4?2|pI z!m{JixBx;knViF8i%V;uDG6dw^ou{=x#jgu8s74S&UfVJj)$nG%|JoUR^FazYtoQ= z!aZuP97313N8RpH6$7zcPBzbuu-I<03u-zAGMCsy<7~WVjp@hK<;zVo5f>{ub?Eo#BT72>E3G)tB3*?D8?zW%vn(>&5`S%A9gGiSyg7V; zq~W9E!r!6q5i~<^&fbJ^HZw>ICrghoH6Kqtiov z>-gl=4Aw~1!Xy`2BEIYcDcYl-=!6D*mv7X|O5fboem13xce&sN#3JuUVlwg^FMZVus!)`E9~i7MCS7XSGLf1Bnp`S;r-RZ zs!xbKRDlcE2W1D~=m3byaYVYEg(;<+H{^pmmq>9Q*oxf;5Sg78UefD4t|b%c#~Ej! z`nD^=KSvx_HNl5gVdu+iPT#&EM@zm|(WDa;$1J_~`bUQF)ogAW%E3AVPtPx({BTMt z(8}x1mqvh`)<2cwJ!RS!fo^_mbTX(3{N=62kAK7dTmIwUx%NL5>HLPi=Kr-|XeA9T zjGLUWmAajhw=4!k%r}J|(egt}J1^2_4-odvOyqF5TX>1$RVa|rDLH@8&su#kMn)f4 z`eHD1FV8b=Ew!Yazw7(rTAL3pGexNkFK4Z#SYnmUXxZ_4+u=;ctE_Db!3W=_=?ZT3 zhEudktfNvNu(qfsjV80^d9p6dCdyc3Hv4*;Xe(A+R;zD8i#5@VkGXxVHMgW0ld{Ur z^e&^vC*@B1jVSdZfZo3ES!JWKso1jDS7)mA49rH?y>YXp+FPlW^Z`Q%Z#JS9Cmp(1hsW0}j#emNNrgN6HT}RzZG1+4$VlT+N_6c2~4J0*QDeO>JhuPN~Af zn^Ka&5&5TA+WDSml29!b?CSfWpc9_7jt^PVslul9i=8s8>Q?8nnb^|Y10s*qKrmGU z@l#8|7SSo9hXta=F?L0|?5To#w%u610b04+0g?{|zTWoG_R4#=*1U}Bmd?jp#4|`p z*f*IajT=-yZSN_i-Ro`puMeGBj|J*uD%Znxj8|y$XHjlTgxA|#p__N!yPC9H*WAC5 z#nI*Q(OF>@D;cxBM9w#SL`#R~j`u1|jKCIbf*-FrF|i5Akh7U}qgLo#71m0Q_ab!Z z0h-$%653$%SMDZP4-o`HhCgteSh2y#TjGnI7LWZV^hlMRtz<~{;OkSdyM@lUku?Sn zY+it}MXaBi|LtmJbX>@Gbw|MoA?~SSLbMQ%v|p{$bdQ*)JvOi{{aW=B zfN{CVI!et`=gGiOBWU?DoGfs18oxDsEMvHJ&2w*y_WIb(FFd_tlZN8vt&T7UnKgSK zW0G7}`dpllD!S0E)6zJz^Ne}>l3LxrIT*3JT|KW#u2w+?8#`Vxf7Z|)8HWRTutlU1 zmqtPa)IdhN&K&)2U1I8w4W^G{_?+MhnXFWa8z5DP%|EG1t2Em!J5xWC+{FX3hU2a2 zGH8$942})dk+|A5Di@&Kzn%I`?fQ5O%KqB($C@pIz}h7d|+Dse^EJ>PbG#g1uRo4itQt zFR)04eunLpN#~;e8D|wTCHOMU`he_de5iwLSX($b=<2s9yr-Da?tW%49XA+4S}@94 zm4b9v$$}cvHU^e>ffsxto+MZI7id#Q=erhd!m`eP;eWKX#?+s@RWYmbX~Au3jN!L6 zyi5;k%PS;IDnk!~R3$exsc_>_mu8Z_lY;%{zM zy^@+OiDqPdWNcx-T@Ms5ApI%U=(<@Q`vfSnhiztI7tP!p%v1nKcBQ?`Z@m=VBVT{L zO;;ixGI25+;S2eo*;UMD5dWd=c(x1}=DNqf|F?d>#Ni-PO8VIZ^7KobC?m*x`>xeJ zzx5gbDTu-Q76Z_oI!CK&A{5Y1NX)unv{@^}g_f@7P^g+bM7Y>GS8f9%XRjF167Z?P z1Gs>@pLMH1=W9QB!L2jsEXUjSEspC9mHGBBZ}KoPj!;Q8nv1jb9)m^c~ zA9q`Gu_O=!%>gu!J*q#4}I@ZP(~ z)%2=tR;aPHnvJ^X5sQ{?T?<-b{B0ldZ(Q`f9XuYukx!qR0&8ShL-*=puaW1?%$3dd zR5gH2m?9xOaiLmHzY5qord`ZEC&qyqo=*^qW)u0^Mkd4|$pWbER#77~N^mHYC-QY0 z?e6LlMbIZ{@dE%oyWDDb`XpCPjGwN&BrUT#>dJBI!sE{S#;Uj}?OxbyAglY25>#@r zpzyiMbx3@(*B?@F`C$p|ta@*hJnwKi_F=IBWxpscv^(;w>0-~T!Q_yZ6tS2(z^MjI?XrvyZqN9{Pe+yR@Np+^E027|7dl z0~l{IZjfrHUvJ|u7(%7Ac4Bdd!wD9gP*8Lty8aYG@7k=x>SuI_uIBv$V%z2N`_{Nn zf-1E+KF}gqsu5HnYg}{*E_2+~fN+lu(r*>Dq#XtXzHq!Kh+acT-&bA{wB$=DfaRsF zn*|3;W4c;S)J+e?ysDdaMiPuCCzq9=yEXapFkHGHYPHjajsvjMU4E^aL#g8hseXHa zGqhheLzrvYp#RjADb@C_WS)zV6;hJ-^Dm7x$ASeoIXIOoVxkffpfV<2E}_5P_LLA? z8;YZKv_t|X`?&X}C|w3N!_0&g$orqaDo}NSA9d3z)|RX2Ri#X z6`l$`l`x~zt4z8^26V;iPq%ub4uPo7lz7!(+`1vRZ!J#zFm`s+IuGHjj!kT7w+gIJ zah&++YKwWEKV~893d3RtaaHn=JhKwW4S)khWB%BwZkPwF2e;wxvL=58WJml^xT4EW zW3+^km6V(%mlW^CEg09(#PF17cw$50Ejva;FZzM*HvmIHCQx;o@_O3?x7Julm5Y?8 zYMd~Nc>A@k&~=O?6C>jYWLSn!U9zVsGZ;b)xD!7LM>y?!t{T2p7vFAh1TQC1yOPUV z$a!hVxMxjvy3Fhtn=?QcizT4N0lPDrmbwY=vPtL;#`Q;WQ|LIBtYEL_xdCo_@Y>}O z8wU(9J*#nkYhMhqjP#Ow~Y|U zRdI_8bNfMV8%j3QZZHiigJ+%DjGqkK0s>5BV=Ulm8k#ZPpKy_lJNgu4G8fkv{f0fS zy(1<(qHm^VY{?#beU4M!4~u$3@ze7t{rG@Ibsio$9~;M_n5F%VV-a9keQ7yA#J-s zCs?&f+sB`c7gLYx^FiX+J!QN+dmFEdb6Q2DrhBpwVW$}BLhpt&;`(v*XbX-H%`cUH zy7OW-1BNs5NniG%j`-b}g2^*wmjE^{i(L__sOMiOM3+o>Ff>^=&BkvMyzs~5Yk~@P zI9Qh&A1pu3+bgmW@uBif`=Z;6{)xR`U>*YnTtM8>PAN7zXgN_wNsOoEBzV^9@>XyLpIf)ainvCX0~zR=iZ<`if-tWbhptgUHE3!HAx^j0VdU0hXxqItl8ZWjI}aaR z(2A%mCkrb`Eo+>@e4@A^p9%L2O=~O62=*^dSUn9`GYgyOzho`wX{>XPPo~?-*@K0ZFTRKbR zvGG1^Fo1FF>e7S3;}pu0WHj(UgoP&(wO6nW;Yr}fVrHkFgjO3xr-Dhh)T6btOQR+? zEuFZ7aZ+-A@#NCVUx$R57Ay2OA>p@AU8t2aJmmd=3sqs4X?9rAq41h$0j(>*4hKzu z4R*<_A4&7ruGWn1o86dalk+4y?=c#Ri4EhW2Nak&OY@uJu8ubTgoxe2MRat<6{Hdu z%VGd)*sP%3YgPlf{hR%-`ln`cjkgW!E)ol>A6FcmpvpxSE+K)8!f>CATzEyp(#=}G z6wNCK+^b)2Yng5!1%`w^B|D8(A9$7B8r;`RRT*-~LPUYgmEmW7v-G2m?w!renL{0( zw{gNv*7fH1D@5hfAwng}AvoCXQ_6DKiGw|ieEFXBc`BVdn1lx<2fym>pw>ORq3feG z3()UZM9HEOeB3CHlZ78xhaTED`yYsR+91*gVdmD2{JpscA^emb!S|hRN|<~)nVlYh zo8@>TQ(G(z*hnq{v5L^|@I(!`?p(c$a7XOrBHF{8`ywoknQ-{U_PFUK*~344r^`4R zgM_+>s^k&<$+p=No&Bz$Y?H^iQvq`AY`cs_Ar5Z%5AsskBrm4u2f&>#5V$S%PupSh zAHdTd8%f9SUp|Plp4H;JU#U&rL66&DRp~_GS~xM|MG1v6y29*+aXdj*<-Iq1qD}%h zzWpc1nMl8<13C*%f7WNdf+2_6k;vlo;Fm&Zovv_2iaWcGRT;P=>SWZQ3O~VcIB*Dp z!#W9d(%yqHCz=KlRn6~L>ZaD`_Ho+@NApijap0HK_MPdf8ZAsaXmoVUnjZ8An7i??&BftHaG4MBZ(MZ~t9hZ>qKc77?I8Et-s9L9U0L2f2$A5={4Z1h2k zOV+NNLRx~Wx&A&(*%PkGDIAD%fSy=|q4(7{`=e7@Nx}I}H>G*1ew!#zR6oyUjY!9u zFA$1lB{cM6tbf$jBL9Xs_Uw3X*zt^GqivocS+TUO#wSTGy)*VUQQsL7tKE|wkjczJ zF9I;jcpcdOq1|9d=XDpv$iD}P?9@kc~7H%nTNnZ?N;A)A#U4N(rPo~fnrPRwX_)nXV-H&z}YhiDE4KRI<{Y8R<`D7bw> z@^vD2bY-4K>Ajh~Bnk(m?f52~f0IqMI5$L|0 zPO8TzkLCsdARnuYyH>zw2mC=ORCl|fnHgO&V!wU(My)y6e3J7jhGk);KjG1Ju6AEw>J8h3Hj687OVVfOnd38zhrU zMkSV0)rI{@+c{R)JUiHNq&3=#xezz{onn?t(j*LIe_J-|c`J#BpPgKDY?R4XS`Dp7HNY z2PmiuoL1G|Y=g~Az|iACetmVB-7%jEq%4E5|1{k}Hq#S9u3Cf{NRA19oMj-zHrMPS zbzZFwfy%&4sd+R(R@kcv?*%H3q8H)hhd1~+i%!Ld#^vbNhjF*simWft?TX4PGl(IS z?4mMAcO7QArb_x)cGu6f+I?Jd@iEenhdk6=Z8xN#o7%-GkLcn&iU(}n$0M{((G|GX z;!4>n*gx&d9ZWCUa~mbTvz<<{Jrbv>>XK^sRX!XHg?>nGY$ofiiT^}P#~H6I$Os}q zj9X%m?h|o!?l}-M7h6`?OR!!u?$G?siYo)S@$&=gu#7st<%@x;VGn- z?U*|dZ^Kd2k@>ONzCv2U(%E>{lc^H5mq2~HcPf_Iy0_d`P1sRLGoBWC?y{9dX0L!f z*6JTdyu>-@rR%C_UjOD^+)w%-c&_B!oe5KgZS3uStLN7RQM^tIDq_)Tp_E@~Jq-2*dEg1a?O6~-?<(IwaM5(0|FQFzby#>dEm+*q6Mtl;>m1dWW; zu=d>lNXFB`Jg@$&SJSxn)77j*Q*%q+1na;(mkBqD23zC@hIL2M=bt|IdWgIQWaKIn zrX=bK0efLMO~^RzQ-jRNJ!A(8j+?3y!v^)OCy}nIh|}KuygKh)QcePM#h|mTvTH3dbwjn1$$#K zMe5{ufD~PKK9U7hrLNhel5u53zUK2p<9xX+4-TKvbRr&^08(6=(1UlLCQr`z(&gCX zty8ze>s!YXELCq07!8LS>`i0P}QV~gI`(x#s4ooQ(vUy{i@keKPg zY9!M?1Rc~l6(1%@0(D!ldcxKS9Y%WI!qqAUMtQQ~nEX3a8?^0er;6-q&Xq`MfsNA( z_p@<`1T6;)S@Ee`cDg47X^RQrsv3WLn-ZcAkXtQ1Im(#&ehT`>;pK)QUw%gH6G;X8 zs1u!HZ+$F+=7Y(qTN<4e|42{MGLEknG(8XRyT|wb-+^++WJsHL%kmcR5+ioxq^2`V zp?wwXR|Euq#C)7`Ze!R1JRa4YQET_AvNvi;ZQB+lt7g$p1)6Tt8k zsdcUY_cEXJG|&P{4DCN+pF=XfDLlqAJszvuP>mfwd5j?D@VoG%z1KUVR2T%4mRCZriATop=>i5}N zZgRYwQNfPr+N=d)CSGQ$`sApfG=AE#&~s1RD@JwNvTF-Vf5U^{Sv@d+$68<+-bvfb ztA_U?6`UyS!aB6i{-x24;o-JG+G6Tf4uomDeoT?R-WJm9PMuCFAn`8Y-2<&W_OLzt zV{C;um>y=J6qT{kSs`*rZ}3HuPl47f-%+1?Q(!IQiP6e&@v^@RS}^fp4ppOHvDXc$ z|1Bj{-a<^~*tG;cQHF zM*dr$7rl!9G!Js~ab2&^e;Db!ro8c&hu}>unZkiqJn~=3;OH9XXcSGu(WS;;5|l9wn`PSq`}0 z1!Ht0%`3&s^4ZB4pX}^EZ;J%z5ROh-jhuNt2ZWfj!)>2Tor=g?ht|Wbo`;k3oxU(E z1>o0Sjn06?-VO6AX=s5xueE3(*-_c-Hj7#88TsG{HdUh_&wulK)NsnS1Apt$e#tce z>B`*r@Q;6ox$-X~zL}oK@_+fSA+x{a!{1uwSEx9B>+kT(2lzGGr~mR#e{0EKp@rpJ ze}`W_z&peL<+^)Y$#;hTwi{HjcKUlO z>34?zR_c2z;dh4rR_c2@;dh4rcKUl8>34?zHrjhj*>{HjmI{0e@pp#*7Rq~D(RYUb zwi^89^6w1)m+S7WCEpqTTdVLF5Ae?Lf3fP`Uhtjazr7BB=?L!(|Ceg+SCIRE!tlRL z$Qih_=N$H5FYP%K`VXH!J+Sb*Z$Iz<>bqlEP5=1Amo6`leDnk7H=kbFnfl?Mp1iy1 zzoP=5vX}Fl03G5#zuq=YJ{Ym)5A^%XeEE8t>F(XlP@w1J8os3#s37J9nld*7!&Wnu zpnGsqVaDi7(|f + + + https://schmelczer.dev/fleeting/ + + diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 6aeedc4..7fb3c3b 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,4 +1,4 @@ -import { DEFAULT_AUDIO_VOLUME } from '../app-constants'; +import { DEFAULT_AUDIO_VOLUME } from '../consts'; import type { PianoNoteRole } from './garden-audio-types'; export interface GardenAudioChord { diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 3f59886..951268b 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -38,6 +38,7 @@ export class GardenAudio { private stopPianoAt: number | null = null; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; + private startRequestId = 0; public constructor(private readonly config: GardenAudioConfig) { this.masterVolume = clamp01(config.masterVolume); @@ -56,6 +57,14 @@ export class GardenAudio { return; } + if ( + this.lifecycle === 'started' && + this.currentVibeId === vibe.id && + this.graph.context?.state === 'running' + ) { + return; + } + const context = this.graph.ensureContext(isUserGesture); if (!context) { return; @@ -108,25 +117,49 @@ export class GardenAudio { return; } + const startRequestId = ++this.startRequestId; + void this.piano + .load(context) + .then(() => { + if (!this.canCompleteStart(context, startRequestId)) { + return; + } + + this.activateStart(vibe, context, startupRampSeconds, true); + }) + .catch((error) => { + if (this.canCompleteStart(context, startRequestId)) { + this.activateStart(vibe, context, startupRampSeconds, false); + } + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load piano samples.', + severity: Severity.WARNING, + }); + }); + } + + private canCompleteStart(context: AudioContext, startRequestId: number): boolean { + return ( + this.graph.context === context && + this.lifecycle !== 'destroyed' && + !this.isMuted && + this.startRequestId === startRequestId + ); + } + + private activateStart( + vibe: VibePreset, + context: AudioContext, + startupRampSeconds: number, + cuePiano: boolean + ): void { this.lifecycle = 'started'; - this.applyVibe(vibe); - this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe)); + this.currentVibeId = vibe.id; + this.graph.applyDelayProfile(); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); - const pianoLoad = this.piano.loadIfIdle(context); - if (pianoLoad) { - void pianoLoad - .then(() => { - if (this.graph.context === context && this.lifecycle !== 'destroyed') { - this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); - } - }) - .catch((error) => { - ErrorHandler.addException(error, { - fallbackMessage: 'Could not load piano samples. Using synthesized audio.', - severity: Severity.WARNING, - }); - }); + if (cuePiano) { + this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); } } diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 3ccb46c..49f469f 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -46,13 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb type GardenAudioStyleIndex = 0 | 1 | 2; -interface TouchDownRequest { - vibe: VibePreset; - now: number; - strength: number; - maniaAmount?: number; -} - interface PitchCandidate { midi: number; preference: number; @@ -152,7 +145,12 @@ export class GenerativePianoEngine { now, strength, maniaAmount = 0, - }: TouchDownRequest): void { + }: { + vibe: VibePreset; + now: number; + strength: number; + maniaAmount?: number; + }): void { const normalizedStrength = clamp01(strength); const normalizedManiaAmount = clamp01(maniaAmount); const styleIndex = this.getStyleIndex(now); diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 142fcda..a8b7110 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -21,9 +21,6 @@ const pianoSamplerTuning = { minDurationSeconds: 0.08, minFadeSeconds: 0.08, minGain: 0.0001, - synthGainScale: 0.34, - synthMaxDurationSeconds: 1.8, - synthOscillatorType: 'triangle' as OscillatorType, tailStopExtraSeconds: 0.05, voiceStealFadeSeconds: 0.025, voiceStealStopSeconds: 0.05, @@ -39,9 +36,9 @@ export class PianoSampler { private readonly graph: GardenAudioGraph ) {} - public loadIfIdle(context: BaseAudioContext): Promise | null { - if (this.loadState !== 'idle') { - return null; + public load(context: BaseAudioContext): Promise { + if (this.loadState === 'loaded') { + return Promise.resolve(); } const loadedSamples = getLoadedPianoSamples(); @@ -80,82 +77,32 @@ export class PianoSampler { return; } + const sample = this.findNearestSample(midi); + if (!sample) { + return; + } + const scheduledStart = Math.max( context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, startTime ); const noteVelocity = clamp01(velocity); - const sample = this.findNearestSample(midi); - - if (sample) { - const noteGainValue = this.computeNoteGain(noteVelocity); - const sustainSeconds = - profileSustainSeconds * - (this.config.piano.sustainBase + - noteVelocity * this.config.piano.sustainVelocityRange); - const sustainAt = - scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); - const releaseAt = sustainAt + sustainSeconds; - const stopAt = releaseAt + this.config.piano.releaseSeconds; - const source = context.createBufferSource(); - - source.buffer = sample.buffer; - source.playbackRate.setValueAtTime( - Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), - scheduledStart - ); - - this.scheduleVoice({ - source, - scheduledStart, - stopAt, - pan, - lowpassHz, - delaySend, - eventBus, - configureGainEnvelope: (gain) => { - gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); - gain.gain.exponentialRampToValueAtTime( - noteGainValue, - scheduledStart + this.config.piano.gainAttackSeconds - ); - gain.gain.setTargetAtTime( - Math.max( - pianoSamplerTuning.minGain, - noteGainValue * this.config.piano.sustainLevel - ), - sustainAt, - Math.max( - pianoSamplerTuning.minFadeSeconds, - sustainSeconds * this.config.piano.sustainBase - ) - ); - gain.gain.setTargetAtTime( - pianoSamplerTuning.minGain, - releaseAt, - this.config.piano.releaseSeconds - ); - }, - }); - return; - } - - const noteGainValue = this.computeNoteGain( - noteVelocity, - pianoSamplerTuning.synthGainScale - ); - const releaseAt = - scheduledStart + - clamp( - durationSeconds + profileSustainSeconds * 0.5, - pianoSamplerTuning.minDurationSeconds, - pianoSamplerTuning.synthMaxDurationSeconds - ); + const noteGainValue = this.computeNoteGain(noteVelocity); + const sustainSeconds = + profileSustainSeconds * + (this.config.piano.sustainBase + + noteVelocity * this.config.piano.sustainVelocityRange); + const sustainAt = + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); + const releaseAt = sustainAt + sustainSeconds; const stopAt = releaseAt + this.config.piano.releaseSeconds; - const source = context.createOscillator(); + const source = context.createBufferSource(); - source.type = pianoSamplerTuning.synthOscillatorType; - source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart); + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), + scheduledStart + ); this.scheduleVoice({ source, @@ -171,6 +118,17 @@ export class PianoSampler { noteGainValue, scheduledStart + this.config.piano.gainAttackSeconds ); + gain.gain.setTargetAtTime( + Math.max( + pianoSamplerTuning.minGain, + noteGainValue * this.config.piano.sustainLevel + ), + sustainAt, + Math.max( + pianoSamplerTuning.minFadeSeconds, + sustainSeconds * this.config.piano.sustainBase + ) + ); gain.gain.setTargetAtTime( pianoSamplerTuning.minGain, releaseAt, @@ -312,6 +270,3 @@ export class PianoSampler { this.samples = samples.slice().sort((a, b) => a.midi - b.midi); } } - -const getMidiFrequency = (midi: number): number => - 440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE); diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index e132697..8b3b6c4 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,7 +1,38 @@ import type { LoadedPianoSample } from './garden-audio-types'; +import a0SampleUrl from './samples/A0v12.m4a?url&no-inline'; +import a1SampleUrl from './samples/A1v12.m4a?url&no-inline'; +import a2SampleUrl from './samples/A2v12.m4a?url&no-inline'; +import a3SampleUrl from './samples/A3v12.m4a?url&no-inline'; +import a4SampleUrl from './samples/A4v12.m4a?url&no-inline'; +import a5SampleUrl from './samples/A5v12.m4a?url&no-inline'; +import a6SampleUrl from './samples/A6v12.m4a?url&no-inline'; +import a7SampleUrl from './samples/A7v12.m4a?url&no-inline'; +import c1SampleUrl from './samples/C1v12.m4a?url&no-inline'; +import c2SampleUrl from './samples/C2v12.m4a?url&no-inline'; +import c3SampleUrl from './samples/C3v12.m4a?url&no-inline'; +import c4SampleUrl from './samples/C4v12.m4a?url&no-inline'; +import c5SampleUrl from './samples/C5v12.m4a?url&no-inline'; +import c6SampleUrl from './samples/C6v12.m4a?url&no-inline'; +import c7SampleUrl from './samples/C7v12.m4a?url&no-inline'; +import c8SampleUrl from './samples/C8v12.m4a?url&no-inline'; +import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline'; +import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline'; +import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline'; +import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline'; +import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline'; +import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline'; +import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline'; +import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline'; +import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline'; +import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline'; +import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline'; +import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline'; +import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline'; +import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; interface PianoSampleDefinition { midi: number; + path: string; url: string; } @@ -10,48 +41,39 @@ export interface PianoSampleLoadProgress { totalCount: number; } -const sampleFiles: Array<[fileName: string, midi: number]> = [ - ['A0v12.m4a', 21], - ['C1v12.m4a', 24], - ['Dsharp1v12.m4a', 27], - ['Fsharp1v12.m4a', 30], - ['A1v12.m4a', 33], - ['C2v12.m4a', 36], - ['Dsharp2v12.m4a', 39], - ['Fsharp2v12.m4a', 42], - ['A2v12.m4a', 45], - ['C3v12.m4a', 48], - ['Dsharp3v12.m4a', 51], - ['Fsharp3v12.m4a', 54], - ['A3v12.m4a', 57], - ['C4v12.m4a', 60], - ['Dsharp4v12.m4a', 63], - ['Fsharp4v12.m4a', 66], - ['A4v12.m4a', 69], - ['C5v12.m4a', 72], - ['Dsharp5v12.m4a', 75], - ['Fsharp5v12.m4a', 78], - ['A5v12.m4a', 81], - ['C6v12.m4a', 84], - ['Dsharp6v12.m4a', 87], - ['Fsharp6v12.m4a', 90], - ['A6v12.m4a', 93], - ['C7v12.m4a', 96], - ['Dsharp7v12.m4a', 99], - ['Fsharp7v12.m4a', 102], - ['A7v12.m4a', 105], - ['C8v12.m4a', 108], +const pianoSampleDefinitions: Array = [ + { url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 }, + { url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 }, + { url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 }, + { url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 }, + { url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 }, + { url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 }, + { url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 }, + { url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 }, + { url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 }, + { url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 }, + { url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 }, + { url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 }, + { url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 }, + { url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 }, + { url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 }, + { url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 }, + { url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 }, + { url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 }, + { url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 }, + { url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 }, + { url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 }, + { url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 }, + { url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 }, + { url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 }, + { url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 }, + { url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 }, + { url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 }, + { url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 }, + { url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 }, + { url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 }, ]; -const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`; - -const pianoSampleDefinitions: Array = sampleFiles - .map(([fileName, midi]) => ({ - midi, - url: `${sampleBaseUrl}${fileName}`, - })) - .sort((a, b) => a.midi - b.midi); - let loadedPianoSamples: Array | null = null; let pianoSampleLoadPromise: Promise> | null = null; @@ -111,11 +133,11 @@ export const loadPianoSamples = ( } ).then( (samples) => { - loadedPianoSamples = samples - .filter((sample): sample is LoadedPianoSample => sample !== null) - .sort((a, b) => a.midi - b.midi); - if (loadedPianoSamples.length === 0) { - throw new Error('Unable to load any piano samples.'); + loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi); + if (loadedPianoSamples.length !== pianoSampleDefinitions.length) { + throw new Error( + `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.` + ); } return [...loadedPianoSamples]; }, @@ -138,7 +160,7 @@ const loadPianoSample = async ( ): Promise => { const response = await fetch(sample.url, { signal }); if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.url}`); + throw new Error(`Unable to load piano sample ${sample.path}`); } const audioData = await response.arrayBuffer(); @@ -148,17 +170,13 @@ const loadPianoSample = async ( const loadPianoSampleBatch = async ( samples: Array, - loadSample: ( - sample: PianoSampleDefinition - ) => Promise -): Promise> => { - const results: Array = []; + loadSample: (sample: PianoSampleDefinition) => Promise +): Promise> => { + const results: Array = []; for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { const batch = samples.slice(index, index + sampleLoadTuning.concurrency); - const batchResults = await Promise.all( - batch.map((sample) => loadSample(sample).catch(() => null)) - ); + const batchResults = await Promise.all(batch.map((sample) => loadSample(sample))); results.push(...batchResults); } diff --git a/public/audio/A0v12.m4a b/src/audio/samples/A0v12.m4a similarity index 100% rename from public/audio/A0v12.m4a rename to src/audio/samples/A0v12.m4a diff --git a/public/audio/A1v12.m4a b/src/audio/samples/A1v12.m4a similarity index 100% rename from public/audio/A1v12.m4a rename to src/audio/samples/A1v12.m4a diff --git a/public/audio/A2v12.m4a b/src/audio/samples/A2v12.m4a similarity index 100% rename from public/audio/A2v12.m4a rename to src/audio/samples/A2v12.m4a diff --git a/public/audio/A3v12.m4a b/src/audio/samples/A3v12.m4a similarity index 100% rename from public/audio/A3v12.m4a rename to src/audio/samples/A3v12.m4a diff --git a/public/audio/A4v12.m4a b/src/audio/samples/A4v12.m4a similarity index 100% rename from public/audio/A4v12.m4a rename to src/audio/samples/A4v12.m4a diff --git a/public/audio/A5v12.m4a b/src/audio/samples/A5v12.m4a similarity index 100% rename from public/audio/A5v12.m4a rename to src/audio/samples/A5v12.m4a diff --git a/public/audio/A6v12.m4a b/src/audio/samples/A6v12.m4a similarity index 100% rename from public/audio/A6v12.m4a rename to src/audio/samples/A6v12.m4a diff --git a/public/audio/A7v12.m4a b/src/audio/samples/A7v12.m4a similarity index 100% rename from public/audio/A7v12.m4a rename to src/audio/samples/A7v12.m4a diff --git a/public/audio/C1v12.m4a b/src/audio/samples/C1v12.m4a similarity index 100% rename from public/audio/C1v12.m4a rename to src/audio/samples/C1v12.m4a diff --git a/public/audio/C2v12.m4a b/src/audio/samples/C2v12.m4a similarity index 100% rename from public/audio/C2v12.m4a rename to src/audio/samples/C2v12.m4a diff --git a/public/audio/C3v12.m4a b/src/audio/samples/C3v12.m4a similarity index 100% rename from public/audio/C3v12.m4a rename to src/audio/samples/C3v12.m4a diff --git a/public/audio/C4v12.m4a b/src/audio/samples/C4v12.m4a similarity index 100% rename from public/audio/C4v12.m4a rename to src/audio/samples/C4v12.m4a diff --git a/public/audio/C5v12.m4a b/src/audio/samples/C5v12.m4a similarity index 100% rename from public/audio/C5v12.m4a rename to src/audio/samples/C5v12.m4a diff --git a/public/audio/C6v12.m4a b/src/audio/samples/C6v12.m4a similarity index 100% rename from public/audio/C6v12.m4a rename to src/audio/samples/C6v12.m4a diff --git a/public/audio/C7v12.m4a b/src/audio/samples/C7v12.m4a similarity index 100% rename from public/audio/C7v12.m4a rename to src/audio/samples/C7v12.m4a diff --git a/public/audio/C8v12.m4a b/src/audio/samples/C8v12.m4a similarity index 100% rename from public/audio/C8v12.m4a rename to src/audio/samples/C8v12.m4a diff --git a/public/audio/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a similarity index 100% rename from public/audio/Dsharp1v12.m4a rename to src/audio/samples/Dsharp1v12.m4a diff --git a/public/audio/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a similarity index 100% rename from public/audio/Dsharp2v12.m4a rename to src/audio/samples/Dsharp2v12.m4a diff --git a/public/audio/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a similarity index 100% rename from public/audio/Dsharp3v12.m4a rename to src/audio/samples/Dsharp3v12.m4a diff --git a/public/audio/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a similarity index 100% rename from public/audio/Dsharp4v12.m4a rename to src/audio/samples/Dsharp4v12.m4a diff --git a/public/audio/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a similarity index 100% rename from public/audio/Dsharp5v12.m4a rename to src/audio/samples/Dsharp5v12.m4a diff --git a/public/audio/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a similarity index 100% rename from public/audio/Dsharp6v12.m4a rename to src/audio/samples/Dsharp6v12.m4a diff --git a/public/audio/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a similarity index 100% rename from public/audio/Dsharp7v12.m4a rename to src/audio/samples/Dsharp7v12.m4a diff --git a/public/audio/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a similarity index 100% rename from public/audio/Fsharp1v12.m4a rename to src/audio/samples/Fsharp1v12.m4a diff --git a/public/audio/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a similarity index 100% rename from public/audio/Fsharp2v12.m4a rename to src/audio/samples/Fsharp2v12.m4a diff --git a/public/audio/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a similarity index 100% rename from public/audio/Fsharp3v12.m4a rename to src/audio/samples/Fsharp3v12.m4a diff --git a/public/audio/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a similarity index 100% rename from public/audio/Fsharp4v12.m4a rename to src/audio/samples/Fsharp4v12.m4a diff --git a/public/audio/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a similarity index 100% rename from public/audio/Fsharp5v12.m4a rename to src/audio/samples/Fsharp5v12.m4a diff --git a/public/audio/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a similarity index 100% rename from public/audio/Fsharp6v12.m4a rename to src/audio/samples/Fsharp6v12.m4a diff --git a/public/audio/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a similarity index 100% rename from public/audio/Fsharp7v12.m4a rename to src/audio/samples/Fsharp7v12.m4a diff --git a/public/audio/README.md b/src/audio/samples/README.md similarity index 100% rename from public/audio/README.md rename to src/audio/samples/README.md diff --git a/src/config.ts b/src/config.ts index 7e8375c..e9dab15 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,9 @@ -import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants'; import { createGardenAudioConfig } from './audio/garden-audio-config'; import { defaultSettings } from './config/default-settings'; import { runtimeControls } from './config/runtime-controls'; import type { GardenAppConfig } from './config/types'; import { defaultVibeId, vibePresets } from './config/vibe-presets'; +import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts'; export type { GardenAppConfig, @@ -60,7 +60,9 @@ export const appConfig = { brushEffectFramesPerSecond: 60, clearColor: { r: 0, g: 0, b: 0, a: 0 }, initialAgentCount: 180_000, - maxDevicePixelRatio: 2, + // How long the source map continues to be diffused after a brush stroke ends. + // 600 frames at ~60 FPS ≈ 10 seconds. + sourceActiveFramesAfterWrite: 600, intro: { angleJitterRadians: Math.PI * 0.08, angleEaseEnd: 1, @@ -103,7 +105,6 @@ export const appConfig = { introMoveSpeedBaseMultiplier: 1.8, introMoveSpeedProgressMultiplier: 0.35, stroke: { - angleJitterRadians: Math.PI * 0.7, densityMultiplier: 110, maxAgentCount: 2_400, minAgentCount: 140, @@ -178,7 +179,7 @@ export const appConfig = { tuningPane: { showFpsOverlay: import.meta.env.DEV, startHidden: true, - title: 'Garden Config', + title: 'Garden Settings', }, vibes: { defaultVibeId, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index 0ca5d70..4e3ebc6 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -19,8 +19,8 @@ export const colorInteractionControl = (label: string): NumberControlConfig => ( max: 1, step: 1, options: { - Follow: 1, - Avoid: -1, + 'Move Toward': 1, Ignore: 0, + 'Move Away': -1, }, }); diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 77b3b36..a1ee786 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -1,6 +1,33 @@ +import { colorInteractionSettings } from './color-interactions'; +import { runtimeControls } from './runtime-controls'; import type { GardenAppConfig } from './types'; +// Mirrors the historical render-scale cap so the default render area stays +// roughly equivalent to native rendering on high-DPR phones without the +// pipeline applying its own clamp. The slider can override freely. +const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2; +const INTERNAL_RENDER_AREA_BOUNDS = { + min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5, + max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6, +}; + +const computeDefaultInternalRenderAreaMegapixels = (): number => { + const rawDpr = + typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio) + ? window.devicePixelRatio + : 1; + const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP); + const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920; + const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080; + const cssMegapixels = Math.max(cssWidth, 1) * Math.max(cssHeight, 1) / 1_000_000; + return Math.min( + INTERNAL_RENDER_AREA_BOUNDS.max, + Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels) + ); +}; + export const defaultSettings: GardenAppConfig['defaultSettings'] = { + ...colorInteractionSettings, selectedColorIndex: 0, turnWhenLost: 0.8, @@ -31,6 +58,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushCurveMirrorResolutionExponent: 0.5, brushCurveSegmentBrushRadiusRatio: 0.65, brushSmoothingMinSampleDistance: 0.5, + strokeAngleJitterRadians: Math.PI * 0.7, brushAlpha: 1, brushDiscardThreshold: 0.02, @@ -49,7 +77,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { adaptiveCapInitial: 1_000_000, adaptiveCapMin: 50_000, - internalRenderAreaMegapixels: 8.3, + internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), maxAgentCount: 700_000, renderTraceNormalizationFloor: 1, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index 4e6a429..8962456 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -2,49 +2,59 @@ import { colorInteractionControl } from './color-interactions'; import type { GardenAppConfig } from './types'; const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; +const formatRadiansAsDegrees = (value: number): string => + `${Math.round((value * 180) / Math.PI)} deg`; export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { - color1ToColor1: colorInteractionControl('1 -> 1'), - color1ToColor2: colorInteractionControl('1 -> 2'), - color1ToColor3: colorInteractionControl('1 -> 3'), - color2ToColor1: colorInteractionControl('2 -> 1'), - color2ToColor2: colorInteractionControl('2 -> 2'), - color2ToColor3: colorInteractionControl('2 -> 3'), - color3ToColor1: colorInteractionControl('3 -> 1'), - color3ToColor2: colorInteractionControl('3 -> 2'), - color3ToColor3: colorInteractionControl('3 -> 3'), + color1ToColor1: colorInteractionControl('Primary Follows Primary'), + color1ToColor2: colorInteractionControl('Primary Follows Secondary'), + color1ToColor3: colorInteractionControl('Primary Follows Accent'), + color2ToColor1: colorInteractionControl('Secondary Follows Primary'), + color2ToColor2: colorInteractionControl('Secondary Follows Secondary'), + color2ToColor3: colorInteractionControl('Secondary Follows Accent'), + color3ToColor1: colorInteractionControl('Accent Follows Primary'), + color3ToColor2: colorInteractionControl('Accent Follows Secondary'), + color3ToColor3: colorInteractionControl('Accent Follows Accent'), brushSize: { folder: 'Brush', - label: 'brush size', + label: 'Brush Size', min: 1, max: 60, step: 0.25, }, spawnPerPixel: { folder: 'Brush', - label: 'agents per brush pixel', + label: 'Agent Density', min: 0.01, max: 1, step: 0.001, }, + strokeAngleJitterRadians: { + folder: 'Brush', + format: formatRadiansAsDegrees, + label: 'Spawn Spread', + min: 0, + max: Math.PI * 2, + step: 0.01, + }, sensorOffsetDistance: { folder: 'Agents', - label: 'sensor distance', + label: 'Sensor Reach', min: 0, max: 200, step: 1, }, moveSpeed: { folder: 'Agents', - label: 'move speed', + label: 'Travel Speed', min: 10, max: 500, step: 1, }, turnSpeed: { folder: 'Agents', - label: 'turn speed', + label: 'Turning Speed', min: 1, max: 200, step: 1, @@ -52,28 +62,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { forwardRotationScale: { folder: 'Agents', format: formatPercent, - label: 'following sensor %', + label: 'Forward Focus', min: 0, max: 1, step: 0.01, }, turnWhenLost: { folder: 'Agents', - label: 'turn when lost', + label: 'Wander Turn', min: 0, max: 6.28, step: 0.01, }, individualTrailWeight: { folder: 'Agents', - label: 'individual trail weight', + label: 'Trail Strength', min: 0, max: 1, step: 0.001, }, decayRateTrails: { folder: 'Agents', - label: 'trail decay', + label: 'Trail Fade', min: 800, max: 1000, step: 1, @@ -81,14 +91,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { clarity: { folder: 'Look', - label: 'clarity', + label: 'Sharpness', min: 0.00001, max: 1, step: 0.001, }, backgroundGrainStrength: { folder: 'Look', - label: 'grain strength', + label: 'Background Grain', min: 0, max: 0.12, step: 0.001, @@ -97,14 +107,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { maxAgentCount: { folder: 'Performance', integer: true, - label: 'max agent count', + label: 'Agent Limit', min: 0, - max: 2_000_000, step: 10_000, }, internalRenderAreaMegapixels: { folder: 'Performance', - label: 'internal resolution (MP)', + label: 'Render Quality (MP)', min: 0.5, max: 16.6, step: 0.1, diff --git a/src/config/types.ts b/src/config/types.ts index df58ec4..2429bd0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -41,6 +41,7 @@ export type GardenRuntimeSettings = { maxAgentCount: number; selectedColorIndex: number; spawnPerPixel: number; + strokeAngleJitterRadians: number; } & AgentSettings & BrushSettings & DiffusionSettings & @@ -55,15 +56,6 @@ type GardenVibeSettings = Pick< | 'backgroundGrainStrength' | 'brushSize' | 'clarity' - | 'color1ToColor1' - | 'color1ToColor2' - | 'color1ToColor3' - | 'color2ToColor1' - | 'color2ToColor2' - | 'color2ToColor3' - | 'color3ToColor1' - | 'color3ToColor2' - | 'color3ToColor3' | 'decayRateTrails' | 'individualTrailWeight' | 'moveSpeed' @@ -144,7 +136,7 @@ export interface GardenAppConfig { brushEffectFramesPerSecond: number; clearColor: GPUColor; initialAgentCount: number; - maxDevicePixelRatio: number; + sourceActiveFramesAfterWrite: number; intro: { angleJitterRadians: number; angleEaseEnd: number; @@ -187,7 +179,6 @@ export interface GardenAppConfig { introMoveSpeedBaseMultiplier: number; introMoveSpeedProgressMultiplier: number; stroke: { - angleJitterRadians: number; densityMultiplier: number; maxAgentCount: number; minAgentCount: number; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index f05b72b..3eab271 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,5 +1,4 @@ import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; -import { colorInteractionSettings } from './color-interactions'; import { VibeId, type VibePreset } from './types'; const defaultAudioSettings = { @@ -24,7 +23,6 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 21, 31], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 14, clarity: 0.62, @@ -50,7 +48,6 @@ export const vibePresets: Array = [ ], backgroundColor: [23, 32, 22], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.014, brushSize: 16, clarity: 0.68, @@ -76,7 +73,6 @@ export const vibePresets: Array = [ ], backgroundColor: [15, 24, 34], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.022, brushSize: 13, clarity: 0.58, @@ -102,7 +98,6 @@ export const vibePresets: Array = [ ], backgroundColor: [20, 18, 29], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 12, clarity: 0.64, @@ -128,7 +123,6 @@ export const vibePresets: Array = [ ], backgroundColor: [25, 23, 22], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.024, brushSize: 15, clarity: 0.55, @@ -154,7 +148,6 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 24, 32], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.012, brushSize: 18, clarity: 0.7, diff --git a/src/app-constants.ts b/src/consts.ts similarity index 100% rename from src/app-constants.ts rename to src/consts.ts diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 172cdf2..064914f 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -165,9 +165,7 @@ export class AgentPopulation { const t = count === 1 ? 1 : i / (count - 1); const x = from[0] + (to[0] - from[0]) * t; const y = from[1] + (to[1] - from[1]) * t; - const angle = - baseAngle + - (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians; + const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians; const base = i * AGENT_FLOAT_COUNT; this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread; diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts index bf6b309..a788bfd 100644 --- a/src/game-loop/export-snapshot-renderer.ts +++ b/src/game-loop/export-snapshot-renderer.ts @@ -10,6 +10,7 @@ interface ExportSnapshotRendererOptions { getSourceSize: () => { width: number; height: number }; getColorTextureView: () => GPUTextureView; getSourceTextureView: () => GPUTextureView; + getSourceActive?: () => boolean; getVibeId: () => VibeId; } @@ -70,7 +71,8 @@ export class ExportSnapshotRenderer { commandEncoder, this.options.getColorTextureView(), this.options.getSourceTextureView(), - texture.createView() + texture.createView(), + this.options.getSourceActive?.() ?? true ); commandEncoder.copyTextureToBuffer( { texture }, diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 55650c2..183189c 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -12,6 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { settings } from '../settings'; import { initializeContext } from '../utils/graphics/initialize-context'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationTextures } from './simulation-textures'; @@ -36,6 +37,7 @@ export class GameLoopResources { public readonly eraserTexturePipeline: EraserTexturePipeline; public readonly diffusionPipeline: DiffusionPipeline; public readonly renderPipeline: RenderPipeline; + public readonly gpuProfiler: GpuProfiler | null; private readonly frameRenderer: SimulationFrameRenderer; @@ -52,7 +54,6 @@ export class GameLoopResources { this.commonState = new CommonState(this.device); this.commonState.setParameters({ canvasSize, - time: 0, }); this.agentGenerationPipeline = new AgentGenerationPipeline( @@ -73,15 +74,21 @@ export class GameLoopResources { this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.diffusionPipeline = new DiffusionPipeline(this.device); this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); + this.gpuProfiler = GpuProfiler.create(this.device); - this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { - agentPipeline: this.agentPipeline, - brushPipeline: this.brushPipeline, - eraserAgentPipeline: this.eraserAgentPipeline, - eraserTexturePipeline: this.eraserTexturePipeline, - diffusionPipeline: this.diffusionPipeline, - renderPipeline: this.renderPipeline, - }); + this.frameRenderer = new SimulationFrameRenderer( + this.device, + this.textures, + { + agentPipeline: this.agentPipeline, + brushPipeline: this.brushPipeline, + eraserAgentPipeline: this.eraserAgentPipeline, + eraserTexturePipeline: this.eraserTexturePipeline, + diffusionPipeline: this.diffusionPipeline, + renderPipeline: this.renderPipeline, + }, + this.gpuProfiler + ); } public resizeSimulationTo(nextSize: vec2): vec2 | null { @@ -93,6 +100,10 @@ export class GameLoopResources { this.frameRenderer.resetSourceMapActivity(); } + public get isSourceMapActive(): boolean { + return this.frameRenderer.isSourceMapActive; + } + public setFrameParameters({ time, deltaTime, @@ -107,7 +118,6 @@ export class GameLoopResources { }: FrameParameters): void { this.commonState.setParameters({ canvasSize, - time, }); this.agentPipeline.setParameters({ ...settings, @@ -130,11 +140,13 @@ export class GameLoopResources { this.diffusionPipeline.setParameters(settings); this.renderPipeline.setParameters({ ...settings, + backgroundGrainStrength: 0, channelColors, backgroundColor, }); this.eraserAgentPipeline.setParameters({ agentCount: activeAgentCount, + eraserSize: eraserPixelSize, eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, maskSize: canvasSize, }); @@ -163,6 +175,7 @@ export class GameLoopResources { this.eraserTexturePipeline.destroy(); this.diffusionPipeline.destroy(); this.renderPipeline.destroy(); + this.gpuProfiler?.destroy(); this.commonState.destroy(); this.textures.destroy(); } diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts index 6b3ecfe..2dba0c9 100644 --- a/src/game-loop/game-loop-types.ts +++ b/src/game-loop/game-loop-types.ts @@ -3,9 +3,10 @@ import { vec2 } from 'gl-matrix'; import type { RgbColor } from '../utils/rgb-color'; export interface GardenUi { - prompt: HTMLElement; eraserPreview: HTMLElement; exportStatus: HTMLElement; + grainOverlay: HTMLElement; + prompt: HTMLElement; toolbar: HTMLElement; } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 2f731af..370cf0a 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -6,7 +6,6 @@ import { activeVibe, settings } from '../settings'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { AgentPopulation } from './agent-population'; -import { DevStatsOverlay } from './dev-stats-overlay'; import { EraserPreview } from './eraser-preview'; import { ExportSnapshotRenderer } from './export-snapshot-renderer'; import { FramePerformance } from './frame-performance'; @@ -14,6 +13,7 @@ import { GameLoopResources } from './game-loop-resources'; import { GardenUi } from './game-loop-types'; import { getInternalRenderSize } from './internal-render-size'; import { IntroPrompt } from './intro-prompt'; +import { PerfStatsOverlay } from './perf-stats-overlay'; import { GardenPointerInput } from './pointer-input'; import { PipelineStrokeOutput } from './stroke-output'; import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; @@ -27,7 +27,7 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly framePerformance = new FramePerformance(); - private devStatsOverlay: DevStatsOverlay | null = null; + private perfStatsOverlay: PerfStatsOverlay | null = null; private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); @@ -36,6 +36,7 @@ export default class GameLoop { private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private previousAccentColor = ''; + private previousGrainStrength = Number.NaN; private hasFinished = false; private readonly finished = Promise.withResolvers(); @@ -43,7 +44,7 @@ export default class GameLoop { private readonly canvas: HTMLCanvasElement, private readonly device: GPUDevice, private readonly deltaTimeCalculator: DeltaTimeCalculator, - ui: GardenUi + private readonly ui: GardenUi ) { this.resize(); this.resources = new GameLoopResources( @@ -94,12 +95,13 @@ export default class GameLoop { }, getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(), getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), + getSourceActive: () => this.resources.isSourceMapActive, getVibeId: () => activeVibe.id, }); window.addEventListener('resize', this.resizeListener); this.eraserPreview.attach(); - this.syncDevStatsOverlay(); + this.syncPerfStatsOverlay(); } public attachPointerInput(): void { @@ -117,7 +119,7 @@ export default class GameLoop { public onVibeChanged(): void { this.agentPopulation.onVibeChanged(); - this.syncDevStatsOverlay(); + this.syncPerfStatsOverlay(); } public setAudioMuted(isMuted: boolean): void { @@ -152,8 +154,8 @@ export default class GameLoop { window.removeEventListener('resize', this.resizeListener); this.pointerInput.detach(); this.eraserPreview.detach(); - this.devStatsOverlay?.destroy(); - this.devStatsOverlay = null; + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; this.toolbarContrastMonitor.destroy(); this.introPrompt.destroy(); await this.agentPopulation.waitForCompaction(); @@ -183,6 +185,7 @@ export default class GameLoop { const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; this.updateAccentColor(accentColor); + this.updateGrainOverlay(settings.backgroundGrainStrength); this.audio.update({ vibe: activeVibe, isErasing, @@ -208,7 +211,7 @@ export default class GameLoop { this.pointerInput.clearSwipesIfIdle(); this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); - this.devStatsOverlay?.update({ + this.perfStatsOverlay?.update({ time, fps: this.framePerformance.measuredFps, agentCount: this.agentPopulation.activeAgentCount, @@ -220,16 +223,16 @@ export default class GameLoop { requestAnimationFrame(this.render); }; - private syncDevStatsOverlay(): void { + private syncPerfStatsOverlay(): void { if (appConfig.tuningPane.showFpsOverlay) { - this.devStatsOverlay ??= new DevStatsOverlay( + this.perfStatsOverlay ??= new PerfStatsOverlay( this.canvas.parentElement ?? document.body ); return; } - this.devStatsOverlay?.destroy(); - this.devStatsOverlay = null; + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; } private updateAccentColor(color: RgbColor): void { @@ -242,12 +245,22 @@ export default class GameLoop { document.documentElement.style.setProperty('--accent-color', accentColor); } + private updateGrainOverlay(strength: number): void { + const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0; + if (Object.is(this.previousGrainStrength, safeStrength)) { + return; + } + + this.previousGrainStrength = safeStrength; + this.grainOverlay.hidden = safeStrength <= 0; + this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength)); + } + private resize(): void { const rect = this.canvas.getBoundingClientRect(); const { width, height } = getInternalRenderSize({ clientHeight: rect.height || this.canvas.clientHeight, clientWidth: rect.width || this.canvas.clientWidth, - maxPixelScale: appConfig.simulation.maxDevicePixelRatio, maxTextureDimension: this.device.limits.maxTextureDimension2D, targetAreaMegapixels: settings.internalRenderAreaMegapixels, }); @@ -318,4 +331,8 @@ export default class GameLoop { Math.max(appConfig.toolbar.mirror.min, Math.round(count)) ); } + + private get grainOverlay(): HTMLElement { + return this.ui.grainOverlay; + } } diff --git a/src/game-loop/gpu-profiler.ts b/src/game-loop/gpu-profiler.ts new file mode 100644 index 0000000..7fd0288 --- /dev/null +++ b/src/game-loop/gpu-profiler.ts @@ -0,0 +1,180 @@ +const PASS_NAMES = [ + 'brush', + 'eraserTexture', + 'eraserAgent', + 'agent', + 'trailDiffusion', + 'render', + 'sourceDiffusion', +] as const; + +export type GpuPassName = (typeof PASS_NAMES)[number]; + +interface GpuProfilerSample { + frame: number; + passes: Partial>; + totalPassMs: number; +} + +interface FleetingGardenPerf { + latest?: GpuProfilerSample; + samples: Array; +} + +interface ActivePass { + endQueryIndex: number; + name: GpuPassName; + startQueryIndex: number; +} + +interface ReadbackSlot { + buffer: GPUBuffer; + state: 'idle' | 'encoding' | 'mapping'; +} + +declare global { + interface Window { + __fleetingGardenPerf?: FleetingGardenPerf; + } +} + +const MAX_QUERY_COUNT = PASS_NAMES.length * 2; +const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT; +const READBACK_SLOT_COUNT = 4; +const MAX_SAMPLE_COUNT = 600; + +export class GpuProfiler { + private readonly querySet: GPUQuerySet; + private readonly resolveBuffer: GPUBuffer; + private readonly readbackSlots: Array; + private activePasses: Array = []; + private nextQueryIndex = 0; + private frame = 0; + + public static create(device: GPUDevice): GpuProfiler | null { + if (!device.features.has('timestamp-query')) { + return null; + } + return new GpuProfiler(device); + } + + private constructor(device: GPUDevice) { + this.querySet = device.createQuerySet({ + type: 'timestamp', + count: MAX_QUERY_COUNT, + }); + this.resolveBuffer = device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC, + }); + this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({ + buffer: device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }), + state: 'idle' as const, + })); + } + + public beginFrame(): void { + this.frame += 1; + this.activePasses = []; + this.nextQueryIndex = 0; + } + + public timestampWrites( + name: GpuPassName + ): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined { + if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) { + return undefined; + } + + const startQueryIndex = this.nextQueryIndex; + const endQueryIndex = this.nextQueryIndex + 1; + this.nextQueryIndex += 2; + this.activePasses.push({ + endQueryIndex, + name, + startQueryIndex, + }); + + return { + querySet: this.querySet, + beginningOfPassWriteIndex: startQueryIndex, + endOfPassWriteIndex: endQueryIndex, + }; + } + + public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null { + const queryCount = this.nextQueryIndex; + if (queryCount === 0 || this.activePasses.length === 0) { + return null; + } + + const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle'); + if (!slot) { + return null; + } + + const byteLength = queryCount * QUERY_BYTES; + const passes = this.activePasses.slice(); + const frame = this.frame; + slot.state = 'encoding'; + commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0); + commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength); + + return () => { + slot.state = 'mapping'; + void slot.buffer + .mapAsync(GPUMapMode.READ, 0, byteLength) + .then(() => { + this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength)); + slot.buffer.unmap(); + slot.state = 'idle'; + }) + .catch(() => { + slot.state = 'idle'; + }); + }; + } + + public destroy(): void { + this.querySet.destroy(); + this.resolveBuffer.destroy(); + this.readbackSlots.forEach((slot) => { + slot.buffer.destroy(); + }); + } + + private publishSample( + frame: number, + passes: Array, + mappedRange: ArrayBuffer + ): void { + const timestamps = new BigUint64Array(mappedRange); + const sample: GpuProfilerSample = { + frame, + passes: {}, + totalPassMs: 0, + }; + + passes.forEach(({ endQueryIndex, name, startQueryIndex }) => { + const start = timestamps[startQueryIndex]; + const end = timestamps[endQueryIndex]; + if (end < start) { + return; + } + + const elapsedMs = Number(end - start) / 1_000_000; + sample.passes[name] = elapsedMs; + sample.totalPassMs += elapsedMs; + }); + + const perf = (window.__fleetingGardenPerf ??= { samples: [] }); + perf.latest = sample; + perf.samples.push(sample); + if (perf.samples.length > MAX_SAMPLE_COUNT) { + perf.samples.splice(0, perf.samples.length - MAX_SAMPLE_COUNT); + } + } +} diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts index 841416e..5184618 100644 --- a/src/game-loop/internal-render-size.ts +++ b/src/game-loop/internal-render-size.ts @@ -3,7 +3,6 @@ const MEGAPIXEL = 1_000_000; export interface InternalRenderSizeOptions { clientHeight: number; clientWidth: number; - maxPixelScale: number; maxTextureDimension: number; targetAreaMegapixels: number; } @@ -18,15 +17,9 @@ const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): numb ? targetAreaMegapixels : 1; -const getSafeMaxPixelScale = (maxPixelScale: number): number => - Number.isFinite(maxPixelScale) && maxPixelScale > 0 - ? maxPixelScale - : Number.POSITIVE_INFINITY; - export const getInternalRenderSize = ({ clientHeight, clientWidth, - maxPixelScale, maxTextureDimension, targetAreaMegapixels, }: InternalRenderSizeOptions): InternalRenderSize => { @@ -41,7 +34,6 @@ export const getInternalRenderSize = ({ const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight)); const dimensionScale = Math.min( areaScale, - getSafeMaxPixelScale(maxPixelScale), safeMaxTextureDimension / safeClientWidth, safeMaxTextureDimension / safeClientHeight ); diff --git a/src/game-loop/dev-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts similarity index 65% rename from src/game-loop/dev-stats-overlay.ts rename to src/game-loop/perf-stats-overlay.ts index b81ad38..f241017 100644 --- a/src/game-loop/dev-stats-overlay.ts +++ b/src/game-loop/perf-stats-overlay.ts @@ -1,9 +1,9 @@ -const DEV_STATS_REFRESH_MS = 200; +const PERF_STATS_REFRESH_MS = 200; const ZERO_STAT_TEXT = '0'; const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_RESOLUTION_TEXT = '0x0'; -interface DevStatsSnapshot { +interface PerfStatsSnapshot { time: DOMHighResTimeStamp; fps: number; agentCount: number; @@ -12,14 +12,14 @@ interface DevStatsSnapshot { renderHeight: number; } -export class DevStatsOverlay { +export class PerfStatsOverlay { private readonly element: HTMLDivElement; private previousUpdateTime = Number.NEGATIVE_INFINITY; private previousText = ''; public constructor(parent: HTMLElement) { this.element = document.createElement('div'); - this.element.className = 'dev-stats-overlay'; + this.element.className = 'perf-stats-overlay'; this.element.setAttribute('aria-hidden', 'true'); parent.append(this.element); } @@ -31,13 +31,14 @@ export class DevStatsOverlay { frameTimeMs, renderWidth, renderHeight, - }: DevStatsSnapshot): void { - if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) { + }: PerfStatsSnapshot): void { + if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) { return; } this.previousUpdateTime = time; - const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; + const gpuPassTimeMs = window.__fleetingGardenPerf?.latest?.totalPassMs; + const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; if (text !== this.previousText) { this.element.textContent = text; this.previousText = text; @@ -57,10 +58,14 @@ const formatAgentCount = (agentCount: number): string => ? Math.max(0, Math.round(agentCount)).toLocaleString('en-US') : ZERO_STAT_TEXT; -const formatFrameTime = (frameTimeMs: number): string => - Number.isFinite(frameTimeMs) - ? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms` - : ZERO_FRAME_TIME_TEXT; +const formatFrameTime = (frameTimeMs: number | undefined): string => { + if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) { + return ZERO_FRAME_TIME_TEXT; + } + + const safeFrameTimeMs = Math.max(0, frameTimeMs); + return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`; +}; const formatResolution = (width: number, height: number): string => Number.isFinite(width) && Number.isFinite(height) diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index f9c068f..538ed45 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -1,10 +1,13 @@ +import { appConfig } from '../config'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { settings } from '../settings'; import { CanvasReadbackRequest } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; import { SimulationTextures } from './simulation-textures'; interface SimulationFramePipelines { @@ -17,15 +20,14 @@ interface SimulationFramePipelines { } export class SimulationFrameRenderer { - private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600; - private sourceActiveFramesRemaining = 0; private sourceMapsCleared = true; public constructor( private readonly device: GPUDevice, private readonly textures: SimulationTextures, - private readonly pipelines: SimulationFramePipelines + private readonly pipelines: SimulationFramePipelines, + private readonly gpuProfiler: GpuProfiler | null = null ) {} public resetSourceMapActivity(): void { @@ -33,11 +35,16 @@ export class SimulationFrameRenderer { this.sourceMapsCleared = true; } + public get isSourceMapActive(): boolean { + return this.sourceActiveFramesRemaining > 0; + } + public execute( isErasing: boolean, canvasReadbackRequest?: CanvasReadbackRequest | null ): void { const commandEncoder = this.device.createCommandEncoder(); + this.gpuProfiler?.beginFrame(); this.textures.copyTrailMapAToB(commandEncoder); let wroteSourceMap = false; @@ -48,24 +55,29 @@ export class SimulationFrameRenderer { commandEncoder, eraserMask, this.textures.sourceMapA.getTextureView(), - this.textures.trailMapB.getTextureView() + this.textures.trailMapB.getTextureView(), + this.gpuProfiler?.timestampWrites('eraserTexture') + ); + this.pipelines.eraserAgentPipeline.execute( + commandEncoder, + eraserMask, + this.gpuProfiler?.timestampWrites('eraserAgent') ); - this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask); } } else { wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( commandEncoder, - this.textures.sourceMapA.getTextureView() + this.textures.sourceMapA.getTextureView(), + this.gpuProfiler?.timestampWrites('brush') ); } if (wroteSourceMap) { - this.sourceActiveFramesRemaining = - SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE; + this.sourceActiveFramesRemaining = getSourceActiveFrameCount(); this.sourceMapsCleared = false; } - const useSourceMap = this.sourceActiveFramesRemaining > 0; + const useSourceMap = this.isSourceMapActive; if (!useSourceMap && !this.sourceMapsCleared) { this.textures.clearSourceMaps(commandEncoder); this.sourceMapsCleared = true; @@ -74,19 +86,22 @@ export class SimulationFrameRenderer { this.pipelines.agentPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView() + this.textures.trailMapB.getTextureView(), + this.gpuProfiler?.timestampWrites('agent') ); this.pipelines.diffusionPipeline.execute( commandEncoder, this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getTextureView(), - this.textures.trailMapA.getSize() + this.textures.trailMapA.getSize(), + this.gpuProfiler?.timestampWrites('trailDiffusion') ); const canvasTexture = this.pipelines.renderPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), this.textures.sourceMapA.getTextureView(), - useSourceMap + useSourceMap, + this.gpuProfiler?.timestampWrites('render') ); canvasReadbackRequest?.encode(commandEncoder, canvasTexture); @@ -95,10 +110,13 @@ export class SimulationFrameRenderer { commandEncoder, this.textures.sourceMapA.getTextureView(), this.textures.sourceMapB.getTextureView(), - this.textures.sourceMapB.getSize() + this.textures.sourceMapB.getSize(), + this.gpuProfiler?.timestampWrites('sourceDiffusion') ); } + const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder); this.device.queue.submit([commandEncoder.finish()]); + afterGpuProfileSubmit?.(); canvasReadbackRequest?.afterSubmit(); if (useSourceMap) { this.textures.swapSourceMaps(); @@ -106,3 +124,12 @@ export class SimulationFrameRenderer { } } } + +const getSourceActiveFrameCount = (): number => { + const frameCount = + settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond; + if (Number.isFinite(frameCount) && frameCount > 0) { + return Math.ceil(frameCount); + } + return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite); +}; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 90eb9be..4c75928 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -1,14 +1,18 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; -import { ResizableTexture } from '../utils/graphics/resizable-texture'; +import { + ResizableTexture, + type PendingTextureResize, +} from '../utils/graphics/resizable-texture'; export class SimulationTextures { public readonly trailMapA: ResizableTexture; public readonly trailMapB: ResizableTexture; + public readonly eraserMask: ResizableTexture; + // A/B are swapped each frame to ping-pong the diffusion pass. public sourceMapA: ResizableTexture; public sourceMapB: ResizableTexture; - public eraserMask: ResizableTexture; public constructor( private readonly device: GPUDevice, @@ -28,11 +32,31 @@ export class SimulationTextures { } const scale = vec2.div(vec2.create(), nextSize, previousSize); - this.trailMapA.resize(nextSize); - this.trailMapB.resize(nextSize); - this.sourceMapA.resize(nextSize); - this.sourceMapB.resize(nextSize); - this.eraserMask.resize(nextSize); + const resizes = [ + this.trailMapA, + this.trailMapB, + this.sourceMapA, + this.sourceMapB, + this.eraserMask, + ] + .map((texture): [ResizableTexture, PendingTextureResize] | null => { + const resize = texture.prepareResize(nextSize); + return resize ? [texture, resize] : null; + }) + .filter((resize): resize is [ResizableTexture, PendingTextureResize] => + Boolean(resize) + ); + + if (resizes.length > 0) { + const commandEncoder = this.device.createCommandEncoder(); + resizes.forEach(([texture, resize]) => { + texture.encodeResize(commandEncoder, resize); + }); + this.device.queue.submit([commandEncoder.finish()]); + resizes.forEach(([texture, resize]) => { + texture.commitResize(resize); + }); + } return scale; } diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts index 6680525..1e9650c 100644 --- a/src/game-loop/stroke-output.ts +++ b/src/game-loop/stroke-output.ts @@ -22,7 +22,7 @@ export class PipelineStrokeOutput implements StrokeOutput { } public addEraseSegment(from: vec2, to: vec2): void { - this.eraserAgentPipeline.addSwipeSegment(); + this.eraserAgentPipeline.addSwipeSegment(from, to); this.eraserTexturePipeline.addSwipeSegment(from, to); } diff --git a/src/index.scss b/src/index.scss index c9ee7e1..d6d8b85 100644 --- a/src/index.scss +++ b/src/index.scss @@ -6,4 +6,3 @@ @use 'style/config-pane'; @use 'style/panels'; @use 'style/loading'; -@use 'style/motion'; diff --git a/src/index.ts b/src/index.ts index ee47f5b..c840a33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,356 +3,24 @@ import GameLoop from './game-loop/game-loop'; import './index.scss'; import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; -import { - APP_STORAGE_KEYS, - DEFAULT_AUDIO_VOLUME, - DISABLED_FLAG_VALUE, - ENABLED_FLAG_VALUE, - UNIT_INTERVAL_INPUT_MAX, - UNIT_INTERVAL_INPUT_MIN, -} from './app-constants'; import { preloadPianoSamples } from './audio/piano-samples'; +import { AudioControl } from './page/audio-control'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; +import { EraserSizeControl } from './page/eraser-size-control'; +import { ErrorPresenter } from './page/error-presenter'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; -import { activeVibe, applyVibeSettings, settings } from './settings'; -import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; +import { MirrorSegmentControl } from './page/mirror-segment-control'; +import { PaletteControl } from './page/palette-control'; +import { SplashScreen } from './page/splash-screen'; +import { VibeNavigator } from './page/vibe-navigator'; +import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits'; +import { activeVibe } from './settings'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; -import { queryRequiredElement, queryRequiredElements } from './utils/dom'; -import { - ErrorHandler, - getErrorMessage, - RuntimeError, - Severity, -} from './utils/error-handler'; +import { queryRequiredElement } from './utils/dom'; +import { ErrorHandler, Severity } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; -import { clamp01 } from './utils/math'; -import { rgbColorToCss } from './utils/rgb-color'; -import { VIBE_PRESETS } from './vibes'; - -const AUDIO_VOLUME_STEP = 0.01; - -const ERASER_CONTROL_SCALE_MAX = 1.33; -const ERASER_CONTROL_SCALE_MIN = 0.75; -const ERASER_SIZE_DEFAULT = 96; -const ERASER_SIZE_MAX = 240; -const ERASER_SIZE_MIN = 24; -const ERASER_SIZE_STEP = 1; - -const MIRROR_SEGMENT_DEFAULT = 1; -const MIRROR_SEGMENT_MAX = 12; -const MIRROR_SEGMENT_MIN = 1; -const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; -const MIRROR_SEGMENT_STEP = 1; -const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; - -const APP_SELECTORS = { - aside: 'aside', - canvas: 'canvas', - eraserPreview: '.eraser-preview', - eraserSizeControl: '.eraser-size-control', - eraserSizeSlider: '.eraser-size-slider', - errorContainer: '.errors-container', - export4k: '.export-4k', - exportStatus: '.export-status', - infoButton: 'button.info', - infoElement: '.info-page', - loadingBar: '.loading-bar', - loadingProgress: '.loading-progress', - loadingStatus: '.loading-status', - maximizeFullScreenButton: 'button.maximize-full-screen', - minimizeFullScreenButton: 'button.minimize-full-screen', - mirrorSegmentControl: '.mirror-segment-control', - mirrorSegmentSlider: '.mirror-segment-slider', - nextVibe: '.next-vibe', - previousVibe: '.previous-vibe', - prompt: '.garden-prompt', - restartButton: 'button.restart', - settingsButton: 'button.settings', - soundButton: 'button.sound', - splash: '.splash', - startButton: '.start-button', - swatches: '.color-swatch', - toolbarRow: '.toolbar-row', - volumeControl: '.volume-control', - volumeSlider: '.volume-slider', -} as const; - -const clampEraserSize = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; - return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); -}; - -const getEraserSizeRatio = (size: number): number => - (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); - -const clampMirrorSegmentCount = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; - return Math.min( - MIRROR_SEGMENT_MAX, - Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue)) - ); -}; - -const getMirrorSegmentRatio = (count: number): number => - (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); - -const formatMirrorSegmentCount = (count: number): string => - count === MIRROR_SEGMENT_DEFAULT - ? MIRROR_SEGMENT_OFF_LABEL - : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; - -const clampAudioVolume = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; - return clamp01(safeValue); -}; - -const getAudioVolumePercent = (volume: number): number => - Math.round(clampAudioVolume(volume) * 100); - -const readInitialAudioVolume = (): number => { - const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); - return storedVolume === null - ? DEFAULT_AUDIO_VOLUME - : clampAudioVolume(Number(storedVolume)); -}; - -const formatStoredAudioVolume = (volume: number): string => - clampAudioVolume(volume).toFixed(2); - -type RuntimeUiError = Parameters< - Parameters[0] ->[0]; - -const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { - const message = document.createElement('pre'); - message.className = error.severity; - message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; - message.setAttribute( - 'role', - error.severity === Severity.ERROR ? 'alert' : 'status' - ); - message.setAttribute( - 'aria-live', - error.severity === Severity.ERROR - ? 'assertive' - : 'polite' - ); - container.append(message); - - if (error.severity === Severity.ERROR) { - message.tabIndex = -1; - message.focus({ preventScroll: true }); - } -}; - -const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ - severity: Severity.ERROR, - message: getErrorMessage(exception), - ...(exception instanceof RuntimeError ? { code: exception.code } : {}), -}); - -const renderStartupException = (exception: unknown) => { - const existingContainer = document.querySelector(APP_SELECTORS.errorContainer); - const container = - existingContainer instanceof HTMLElement - ? existingContainer - : document.createElement('div'); - - if (!(existingContainer instanceof HTMLElement)) { - container.className = 'errors-container'; - document.body.append(container); - } - - container.setAttribute('aria-live', 'assertive'); - renderRuntimeMessage(container, getRuntimeUiError(exception)); -}; - -const queryAppElements = () => ({ - aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement), - toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement), - infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement), - infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement), - minimizeFullScreenButton: queryRequiredElement( - APP_SELECTORS.minimizeFullScreenButton, - HTMLButtonElement - ), - maximizeFullScreenButton: queryRequiredElement( - APP_SELECTORS.maximizeFullScreenButton, - HTMLButtonElement - ), - settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement), - soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement), - volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement), - volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement), - restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement), - canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement), - eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement), - errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement), - previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement), - nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement), - swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement), - eraserSizeControl: queryRequiredElement( - APP_SELECTORS.eraserSizeControl, - HTMLLabelElement - ), - eraserSizeSlider: queryRequiredElement( - APP_SELECTORS.eraserSizeSlider, - HTMLInputElement - ), - mirrorSegmentControl: queryRequiredElement( - APP_SELECTORS.mirrorSegmentControl, - HTMLLabelElement - ), - mirrorSegmentSlider: queryRequiredElement( - APP_SELECTORS.mirrorSegmentSlider, - HTMLInputElement - ), - export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement), - exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement), - prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement), - loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement), - loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement), - splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement), - loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement), - startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement), -}); - -type AppElements = ReturnType; - -let elements: AppElements; - -const setLoadingStage = (label: string, ratio: number) => { - const percent = Math.round(clamp01(ratio) * 100); - elements.loadingStatus.textContent = label; - elements.loadingProgress.style.setProperty( - '--loading-progress', - `${percent}%` - ); - elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); -}; - -let audioVolume = readInitialAudioVolume(); -let isAudioMuted = - readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || - audioVolume <= 0; -let isEraserActive = false; - -const persistAudioUiState = () => { - writeBrowserStorage( - APP_STORAGE_KEYS.audioMuted, - isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE - ); - writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume)); -}; - -const renderAudioUi = (game: GameLoop | null) => { - audioVolume = clampAudioVolume(audioVolume); - const isEffectivelyMuted = isAudioMuted || audioVolume <= 0; - const volumePercent = getAudioVolumePercent(audioVolume); - - elements.soundButton.classList.toggle('muted', isEffectivelyMuted); - elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); - elements.soundButton.setAttribute( - 'aria-label', - isEffectivelyMuted ? 'Unmute audio' : 'Mute audio' - ); - elements.soundButton.title = isEffectivelyMuted - ? 'Unmute audio' - : 'Mute audio'; - - elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; - elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; - elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); - elements.volumeSlider.value = formatStoredAudioVolume(audioVolume); - elements.volumeSlider.setAttribute( - 'aria-valuetext', - isEffectivelyMuted - ? `${'Muted'}, ${volumePercent}%` - : `${volumePercent}%` - ); - elements.volumeControl.classList.toggle('muted', isEffectivelyMuted); - elements.volumeControl.title = isEffectivelyMuted - ? `${'Muted'}, ${volumePercent}% ${'volume'}` - : `${volumePercent}% ${'volume'}`; - elements.volumeControl.style.setProperty( - '--volume-progress', - `${volumePercent}%` - ); - - game?.setAudioVolume(audioVolume); - game?.setAudioMuted(isEffectivelyMuted); -}; - -const renderPaletteUi = (game: GameLoop | null) => { - elements.swatches.forEach((swatch, index) => { - swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); - swatch.classList.toggle( - 'active', - settings.selectedColorIndex === index && !isEraserActive - ); - }); - elements.eraserSizeControl.classList.toggle('active', isEraserActive); - game?.setEraseMode(isEraserActive); - document.documentElement.style.setProperty( - '--garden-background', - rgbColorToCss(activeVibe.backgroundColor) - ); -}; - -const renderEraserSizeUi = (game: GameLoop | null) => { - const size = clampEraserSize(settings.eraserSize); - if (settings.eraserSize !== size) { - settings.eraserSize = size; - } - - elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString(); - elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString(); - elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString(); - elements.eraserSizeSlider.value = size.toString(); - elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`); - - const ratio = getEraserSizeRatio(size); - const scale = - ERASER_CONTROL_SCALE_MIN + - (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; - elements.eraserSizeControl.style.setProperty( - '--eraser-progress', - `${ratio * 100}%` - ); - elements.eraserSizeControl.style.setProperty( - '--eraser-control-scale', - scale.toFixed(3) - ); - game?.updateEraserPreview(); -}; - -const renderMirrorSegmentUi = () => { - const count = clampMirrorSegmentCount(settings.mirrorSegmentCount); - if (settings.mirrorSegmentCount !== count) { - settings.mirrorSegmentCount = count; - } - - elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString(); - elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString(); - elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString(); - elements.mirrorSegmentSlider.value = count.toString(); - - const label = formatMirrorSegmentCount(count); - const ratio = getMirrorSegmentRatio(count); - elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label); - elements.mirrorSegmentControl.title = label; - elements.mirrorSegmentControl.classList.toggle('active', count > 1); - elements.mirrorSegmentControl.style.setProperty( - '--mirror-progress', - `${ratio * 100}%` - ); - elements.mirrorSegmentControl.style.setProperty( - '--mirror-angle', - `${(360 / count).toFixed(3)}deg` - ); -}; const main = async () => { let hasRuntimeErrorListener = false; @@ -363,14 +31,13 @@ const main = async () => { let hasStarted = false; let game: GameLoop | null = null; let configPane: ConfigPane | null = null; + const getGame = () => game; - elements = queryAppElements(); - elements.errorContainer.setAttribute( - 'aria-live', - 'assertive' + const errorPresenter = new ErrorPresenter( + queryRequiredElement('.errors-container', HTMLElement) ); ErrorHandler.addOnErrorListener((error) => { - renderRuntimeMessage(elements.errorContainer, error); + errorPresenter.render(error); if (error.severity === Severity.ERROR) { document.body.classList.remove('is-loading'); game?.destroy(); @@ -379,174 +46,100 @@ const main = async () => { }); hasRuntimeErrorListener = true; - const syncRuntimeUi = (activeGame = game) => { - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderPaletteUi(activeGame); + const aside = queryRequiredElement('aside', HTMLElement); + const canvas = queryRequiredElement('canvas', HTMLCanvasElement); + const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement); + const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement); + const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement); + const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement); + const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement); + const settingsButton = queryRequiredElement('button.settings', HTMLButtonElement); + const restartButton = queryRequiredElement('button.restart', HTMLButtonElement); + const infoButton = queryRequiredElement('button.info', HTMLButtonElement); + const infoElement = queryRequiredElement('.info-page', HTMLElement); + const minimizeFullScreenButton = queryRequiredElement( + 'button.minimize-full-screen', + HTMLButtonElement + ); + const maximizeFullScreenButton = queryRequiredElement( + 'button.maximize-full-screen', + HTMLButtonElement + ); + const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement); + + const splash = new SplashScreen(); + const paletteControl = new PaletteControl({ + getGame, + onChange: () => configPane?.refresh(), + }); + const eraserSizeControl = new EraserSizeControl({ + getGame, + onActivate: () => paletteControl.setEraserActive(true), + onChange: () => configPane?.refresh(), + }); + const mirrorSegmentControl = new MirrorSegmentControl({ + onChange: () => { + paletteControl.setEraserActive(false); + configPane?.refresh(); + }, + }); + const audioControl = new AudioControl({ + getGame, + hasStarted: () => hasStarted, + startButton: splash.startButton, + }); + + const syncRuntimeUi = () => { + eraserSizeControl.render(); + mirrorSegmentControl.render(); + paletteControl.render(); }; - const infoPageHandler = new CollapsiblePanelAnimator( - elements.infoButton, - elements.infoElement, - elements.aside - ); - + const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside); new MenuHider( - elements.aside, + aside, () => FullScreenHandler.isInFullScreenMode() && !configPane?.isOpen && !infoPageHandler.isOpen ); new FullScreenHandler( - elements.minimizeFullScreenButton, - elements.maximizeFullScreenButton, + minimizeFullScreenButton, + maximizeFullScreenButton, document.documentElement ); - const startAudioFromUserGesture = (event: Event) => { - if ( - !hasStarted || - isAudioMuted || - (event.target instanceof Node && elements.startButton.contains(event.target)) || - (event.target instanceof Node && elements.soundButton.contains(event.target)) - ) { - return; - } - - game?.startAudio(true); - }; - - window.addEventListener('touchstart', startAudioFromUserGesture, { - capture: true, - passive: true, - }); - window.addEventListener('pointerdown', startAudioFromUserGesture, { - capture: true, - passive: true, - }); - window.addEventListener('touchend', startAudioFromUserGesture, { - capture: true, - passive: true, - }); - window.addEventListener('pointerup', startAudioFromUserGesture, { - capture: true, - passive: true, - }); - window.addEventListener('click', startAudioFromUserGesture, { - capture: true, - }); - window.addEventListener('keydown', startAudioFromUserGesture, { - capture: true, - }); - - elements.restartButton.addEventListener('click', () => game?.destroy()); - elements.soundButton.addEventListener('click', () => { - const shouldUnmute = isAudioMuted || audioVolume <= 0; - if (shouldUnmute && audioVolume <= 0) { - audioVolume = DEFAULT_AUDIO_VOLUME; - } - isAudioMuted = !shouldUnmute; - persistAudioUiState(); - renderAudioUi(game); - if (!isAudioMuted) { - game?.startAudio(true); - } - }); - elements.volumeSlider.addEventListener('input', () => { - audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); - isAudioMuted = audioVolume <= 0; - persistAudioUiState(); - renderAudioUi(game); - if (!isAudioMuted) { - game?.startAudio(true); - } - }); - - const selectRelativeVibe = (offset: number, source: string) => { - const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); - const vibe = - VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; - const activePreset = applyVibeSettings(vibe); - trackVibeChange({ - vibeId: activePreset.id, - vibeName: activePreset.name, - source, - }); - game?.onVibeChanged(); - syncRuntimeUi(); - configPane?.refresh(); - game?.playVibeChangeAudio(true); - }; - - elements.previousVibe.addEventListener('click', () => - selectRelativeVibe(-1, 'previous-button') - ); - - elements.nextVibe.addEventListener('click', () => - selectRelativeVibe(1, 'next-button') - ); - - elements.swatches.forEach((swatch, index) => { - swatch.addEventListener('click', () => { - settings.selectedColorIndex = index; - isEraserActive = false; - renderPaletteUi(game); + new VibeNavigator({ + onChange: ({ vibeId, vibeName, source }) => { + trackVibeChange({ vibeId, vibeName, source }); + game?.onVibeChanged(); + syncRuntimeUi(); configPane?.refresh(); - }); + game?.playVibeChangeAudio(true); + }, }); - const activateEraser = () => { - isEraserActive = true; - renderPaletteUi(game); - }; + restartButton.addEventListener('click', () => game?.destroy()); - elements.eraserSizeControl.addEventListener('pointerdown', activateEraser); - elements.eraserSizeControl.addEventListener('click', activateEraser); - elements.eraserSizeSlider.addEventListener('focus', activateEraser); - - elements.eraserSizeSlider.addEventListener('input', () => { - settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); - isEraserActive = true; - renderEraserSizeUi(game); - renderPaletteUi(game); - configPane?.refresh(); - }); - - elements.mirrorSegmentSlider.addEventListener('input', () => { - settings.mirrorSegmentCount = clampMirrorSegmentCount( - Number(elements.mirrorSegmentSlider.value) - ); - isEraserActive = false; - renderMirrorSegmentUi(); - renderPaletteUi(game); - configPane?.refresh(); - }); - - elements.export4k.addEventListener('click', async () => { - if (!game || elements.export4k.disabled) { + export4kButton.addEventListener('click', async () => { + if (!game || export4kButton.disabled) { return; } - elements.export4k.disabled = true; + export4kButton.disabled = true; try { await game.exportSnapshot(); trackExport({ vibeId: activeVibe.id }); } catch (error) { ErrorHandler.addException(error, { severity: Severity.WARNING }); } finally { - elements.export4k.disabled = false; + export4kButton.disabled = false; } }); - renderPaletteUi(game); - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderAudioUi(game); - - // Loading runs in the background while the splash (title + description + - // Start button) is shown. The Start tap is the user gesture that unlocks - // the AudioContext on iOS, and gates the intro. + // Samples load before Start is enabled so the first audible piano note + // always uses the sampler. The Start tap still unlocks the AudioContext. + splash.showLoadingBar(); const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not load fonts.', @@ -555,17 +148,18 @@ const main = async () => { }); const gpuPromise = initializeGpu(); - let isPreloadComplete = false; const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => { const ratio = totalCount > 0 ? loadedCount / totalCount : 0; - setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio); + splash.setLoadingStage( + `Loading piano samples ${loadedCount}/${totalCount}…`, + ratio + ); }).then( () => { - isPreloadComplete = true; - setLoadingStage('Ready', 1); + splash.setLoadingStage('Ready', 1); }, (error: unknown) => { - isPreloadComplete = true; + splash.setLoadingStage('Piano unavailable', 1); ErrorHandler.addException(error, { fallbackMessage: 'Could not preload piano samples.', severity: Severity.WARNING, @@ -575,7 +169,8 @@ const main = async () => { const gpu = await gpuPromise; configPane = new ConfigPane({ - settingsButton: elements.settingsButton, + maxSupportedAgentCount: getMaxSupportedAgentCount(gpu), + settingsButton, onConfigChange: () => { game?.onVibeChanged(); syncRuntimeUi(); @@ -584,59 +179,36 @@ const main = async () => { }); infoPageHandler.onOpen = configPane.close.bind(configPane); await fontsReady; + await preloadPromise; + splash.hideLoadingBar(); const deltaTimeCalculator = new DeltaTimeCalculator(); let isFirstStart = true; while (!shouldStop) { - game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { - toolbar: elements.toolbarRow, - prompt: elements.prompt, - eraserPreview: elements.eraserPreview, - exportStatus: elements.exportStatus, + game = new GameLoop(canvas, gpu, deltaTimeCalculator, { + toolbar: toolbarRow, + prompt: promptElement, + eraserPreview, + grainOverlay, + exportStatus, }); - renderPaletteUi(game); - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderAudioUi(game); + syncRuntimeUi(); + audioControl.render(); if (isFirstStart) { isFirstStart = false; // Splash is in the DOM by default; enable the button now that the // audio system (GameLoop) is constructed and ready to be unlocked. - elements.startButton.disabled = false; - await new Promise((resolve) => { - const onClick = () => { - elements.startButton.removeEventListener('click', onClick); - hasStarted = true; - game?.startAudio(true); - trackStart(); - elements.splash.hidden = true; - resolve(); - }; - elements.startButton.addEventListener('click', onClick); + await splash.awaitStart(() => { + hasStarted = true; + game?.startAudio(true); + trackStart(); }); - if (!isPreloadComplete) { - elements.loadingBar.hidden = false; - void preloadPromise.finally(() => { - elements.loadingBar.hidden = true; - }); - } - - // Keep the dev stats overlay hidden until the user actually starts drawing. - document.body.classList.add('pre-drawing'); - elements.canvas.addEventListener( - 'pointerdown', - () => document.body.classList.remove('pre-drawing'), - { once: true } - ); - requestAnimationFrame(() => - requestAnimationFrame(() => - document.body.classList.remove('is-loading') - ) + requestAnimationFrame(() => document.body.classList.remove('is-loading')) ); } game.attachPointerInput(); @@ -647,7 +219,7 @@ const main = async () => { if (hasRuntimeErrorListener) { ErrorHandler.addException(e); } else { - renderStartupException(e); + ErrorPresenter.renderStartup(e); ErrorHandler.addException(e); } console.error(e); diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts new file mode 100644 index 0000000..278194f --- /dev/null +++ b/src/page/audio-control.ts @@ -0,0 +1,158 @@ +import { + APP_STORAGE_KEYS, + DEFAULT_AUDIO_VOLUME, + DISABLED_FLAG_VALUE, + ENABLED_FLAG_VALUE, + UNIT_INTERVAL_INPUT_MAX, + UNIT_INTERVAL_INPUT_MIN, +} from '../consts'; +import type GameLoop from '../game-loop/game-loop'; +import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; +import { queryRequiredElement } from '../utils/dom'; +import { clamp01 } from '../utils/math'; + +const AUDIO_VOLUME_STEP = 0.01; + +const clampAudioVolume = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; + return clamp01(safeValue); +}; + +const readInitialAudioVolume = (): number => { + const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); + return storedVolume === null + ? DEFAULT_AUDIO_VOLUME + : clampAudioVolume(Number(storedVolume)); +}; + +const formatStoredAudioVolume = (volume: number): string => + clampAudioVolume(volume).toFixed(2); + +interface AudioControlOptions { + getGame: () => GameLoop | null; + hasStarted: () => boolean; + startButton: HTMLElement; +} + +export class AudioControl { + private readonly soundButton = queryRequiredElement( + 'button.sound', + HTMLButtonElement + ); + private readonly volumeControl = queryRequiredElement( + '.volume-control', + HTMLLabelElement + ); + private readonly volumeSlider = queryRequiredElement( + '.volume-slider', + HTMLInputElement + ); + + private audioVolume = readInitialAudioVolume(); + private isMutedState = + readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || + this.audioVolume <= 0; + + public constructor(private readonly options: AudioControlOptions) { + this.soundButton.addEventListener('click', this.onToggleMute); + this.volumeSlider.addEventListener('input', this.onVolumeInput); + + const passiveCaptureOptions = { capture: true, passive: true } as const; + const captureOptions = { capture: true } as const; + ( + [ + ['touchstart', passiveCaptureOptions], + ['pointerdown', passiveCaptureOptions], + ['touchend', passiveCaptureOptions], + ['pointerup', passiveCaptureOptions], + ['click', captureOptions], + ['keydown', captureOptions], + ] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]> + ).forEach(([event, opts]) => { + window.addEventListener(event, this.onUserGesture, opts); + }); + + this.render(); + } + + public get isMuted(): boolean { + return this.isMutedState || this.audioVolume <= 0; + } + + public render(): void { + this.audioVolume = clampAudioVolume(this.audioVolume); + const isEffectivelyMuted = this.isMuted; + const volumePercent = Math.round(this.audioVolume * 100); + + this.soundButton.classList.toggle('muted', isEffectivelyMuted); + this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); + const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'; + this.soundButton.setAttribute('aria-label', muteLabel); + this.soundButton.title = muteLabel; + + this.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; + this.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; + this.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); + this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume); + this.volumeSlider.setAttribute( + 'aria-valuetext', + isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%` + ); + this.volumeControl.classList.toggle('muted', isEffectivelyMuted); + this.volumeControl.title = isEffectivelyMuted + ? `Muted, ${volumePercent}% volume` + : `${volumePercent}% volume`; + this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`); + + const game = this.options.getGame(); + game?.setAudioVolume(this.audioVolume); + game?.setAudioMuted(isEffectivelyMuted); + } + + private readonly onToggleMute = () => { + const shouldUnmute = this.isMutedState || this.audioVolume <= 0; + if (shouldUnmute && this.audioVolume <= 0) { + this.audioVolume = DEFAULT_AUDIO_VOLUME; + } + this.isMutedState = !shouldUnmute; + this.persist(); + this.render(); + if (!this.isMutedState) { + this.options.getGame()?.startAudio(true); + } + }; + + private readonly onVolumeInput = () => { + this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value)); + this.isMutedState = this.audioVolume <= 0; + this.persist(); + this.render(); + if (!this.isMutedState) { + this.options.getGame()?.startAudio(true); + } + }; + + private readonly onUserGesture = (event: Event) => { + if ( + !this.options.hasStarted() || + this.isMutedState || + (event.target instanceof Node && + this.options.startButton.contains(event.target)) || + (event.target instanceof Node && this.soundButton.contains(event.target)) + ) { + return; + } + this.options.getGame()?.startAudio(true); + }; + + private persist(): void { + writeBrowserStorage( + APP_STORAGE_KEYS.audioMuted, + this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE + ); + writeBrowserStorage( + APP_STORAGE_KEYS.audioVolume, + formatStoredAudioVolume(this.audioVolume) + ); + } +} diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index b4917a8..ba3f38c 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -28,11 +28,11 @@ interface PaneState extends GardenAudioVibeSettings { color3: string; } -const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; +const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const; const COLOR_REACTION_STATES = [ - { id: 'follow', label: 'Follow', value: 1 }, + { id: 'follow', label: 'Move Toward', value: 1 }, { id: 'ignore', label: 'Ignore', value: 0 }, - { id: 'avoid', label: 'Avoid', value: -1 }, + { id: 'avoid', label: 'Move Away', value: -1 }, ] as const; const colorReactionRows = [ @@ -56,6 +56,7 @@ const colorReactionRows = [ const brushControlKeys = [ 'brushSize', 'spawnPerPixel', + 'strokeAngleJitterRadians', ] satisfies Array; const agentControlKeys = [ @@ -85,16 +86,17 @@ const MUSIC_CONTROLS: ReadonlyArray<{ max: number; step: number; }> = [ - { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 }, - { key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 }, - { key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 }, - { key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 }, - { key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 }, - { key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 }, - { key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 }, + { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 }, + { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 }, + { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 }, + { key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 }, + { key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 }, + { key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 }, + { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 }, ]; interface ConfigPaneOptions { + maxSupportedAgentCount: number; onConfigChange: () => void; onRuntimeChange: () => void; settingsButton: HTMLButtonElement; @@ -152,6 +154,7 @@ const getNextColorReactionState = ( export class ConfigPane { private readonly container: HTMLDivElement; + private readonly closeButton: HTMLButtonElement; private readonly pane: Pane; private readonly colorReactionButtons = new Map< ColorReactionKey, @@ -176,17 +179,15 @@ export class ConfigPane { public constructor(private readonly options: ConfigPaneOptions) { this.container = document.createElement('div'); this.container.className = 'config-pane-container'; - Object.assign(this.container.style, { - boxSizing: 'border-box', - maxHeight: 'calc(100vh - 24px)', - pointerEvents: 'none', - position: 'fixed', - right: 'max(12px, env(safe-area-inset-right, 0px))', - top: 'max(12px, env(safe-area-inset-top, 0px))', - width: - 'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))', - zIndex: '20', - }); + + this.closeButton = document.createElement('button'); + this.closeButton.className = 'config-pane-close'; + this.closeButton.type = 'button'; + this.closeButton.setAttribute('aria-label', 'Hide config overlay'); + this.closeButton.title = 'Hide config overlay'; + this.closeButton.addEventListener('click', () => this.setHidden(true)); + this.container.appendChild(this.closeButton); + document.body.appendChild(this.container); this.pane = new Pane({ @@ -196,13 +197,14 @@ export class ConfigPane { }); this.pane.hidden = appConfig.tuningPane.startHidden; this.pane.element.classList.add('config-pane'); - this.pane.element.style.boxSizing = 'border-box'; - this.pane.element.style.maxHeight = 'calc(100vh - 24px)'; - this.pane.element.style.overflowY = 'auto'; - this.pane.element.style.pointerEvents = 'auto'; - this.pane.element.style.width = '100%'; + this.pane.element.id = 'config-pane'; + this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id); this.options.settingsButton.addEventListener('click', this.toggle); + document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, { + passive: true, + }); + document.addEventListener('keydown', this.dismissOnEscape); this.setUpTuningPane(this.pane); this.syncOpenState(); @@ -228,6 +230,27 @@ export class ConfigPane { this.syncOpenState(); }; + private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => { + if (!this.isOpen || !(event.target instanceof Node)) { + return; + } + + if ( + this.container.contains(event.target) || + this.options.settingsButton.contains(event.target) + ) { + return; + } + + this.setHidden(true); + }; + + private readonly dismissOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.isOpen) { + this.setHidden(true); + } + }; + private setHidden(isHidden: boolean): void { this.pane.hidden = isHidden; this.syncOpenState(); @@ -252,26 +275,26 @@ export class ConfigPane { private setUpVibeSection(container: PaneContainer): void { const folder = container.addFolder({ - title: 'Vibe', + title: 'Colors', expanded: true, }); - this.addColorBinding(folder, 'color1', 'colour 1', (color) => { + this.addColorBinding(folder, 'color1', 'Primary Color', (color) => { activeVibe.colors[0] = color; }); - this.addColorBinding(folder, 'color2', 'colour 2', (color) => { + this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => { activeVibe.colors[1] = color; }); - this.addColorBinding(folder, 'color3', 'colour 3', (color) => { + this.addColorBinding(folder, 'color3', 'Accent Color', (color) => { activeVibe.colors[2] = color; }); - this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => { + this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => { activeVibe.backgroundColor = color; }); if (import.meta.env.DEV) { folder - .addButton({ title: 'Copy vibe preset' }) + .addButton({ title: 'Copy Vibe Preset' }) .on('click', () => void this.copyVibePresetToClipboard()); } } @@ -313,7 +336,7 @@ export class ConfigPane { } private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void { - const config = appConfig.runtimeSettings.controls[key]; + const config = this.getRuntimeControlConfig(key); if (!config) { return; } @@ -332,71 +355,75 @@ export class ConfigPane { }); } + private getRuntimeControlConfig( + key: RuntimeControlKey + ): NumberControlConfig | undefined { + const config = appConfig.runtimeSettings.controls[key]; + if (!config || key !== 'maxAgentCount') { + return config; + } + + return { + ...config, + max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)), + }; + } + private addFpsOverlayBinding(container: PaneContainer): void { container .addBinding(appConfig.tuningPane, 'showFpsOverlay', { - label: 'FPS overlay', + label: 'Show FPS', }) .on('change', () => this.options.onConfigChange()); } private addColorReactionMatrix(container: PaneContainer): void { const folder = container.addFolder({ - title: 'Follow / Ignore / Avoid', + title: 'Color Behavior', expanded: true, }); folder.element.classList.add('color-reaction-folder'); - const content = Array.from(folder.element.children).find((child) => - child.classList.contains('tp-fldv_c') - ); - if (!(content instanceof HTMLElement)) { - return; - } - - const doc = folder.element.ownerDocument; - const matrix = doc.createElement('div'); + const matrix = document.createElement('div'); matrix.className = 'color-reaction-matrix'; - matrix.appendChild(this.createColorReactionCorner(doc)); + matrix.appendChild(this.createColorReactionCorner()); colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); + matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); }); colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); + matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); row.keys.forEach((key, columnIndex) => { matrix.appendChild( - this.createColorReactionCell(doc, key, row.colorIndex, columnIndex) + this.createColorReactionCell(key, row.colorIndex, columnIndex) ); }); }); - content.appendChild(matrix); + const matrixBlade = folder.addBlade({ view: 'separator' }); + matrixBlade.element.classList.add('color-reaction-matrix-blade'); + matrixBlade.element.replaceChildren(matrix); this.syncColorReactionMatrix(); } - private createColorReactionCorner(doc: Document): HTMLDivElement { - const corner = doc.createElement('div'); + private createColorReactionCorner(): HTMLDivElement { + const corner = document.createElement('div'); corner.className = 'color-reaction-matrix__corner'; - corner.textContent = 'agent'; + corner.textContent = 'agents'; return corner; } - private createColorReactionHeader( - doc: Document, - colorIndex: number, - label: string - ): HTMLDivElement { - const header = doc.createElement('div'); + private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement { + const header = document.createElement('div'); header.className = 'color-reaction-matrix__header'; - const swatch = doc.createElement('span'); + const swatch = document.createElement('span'); swatch.className = 'color-reaction-matrix__swatch'; this.colorReactionSwatches.push({ colorIndex, element: swatch }); header.appendChild(swatch); - const text = doc.createElement('span'); + const text = document.createElement('span'); text.textContent = label; header.appendChild(text); @@ -404,12 +431,11 @@ export class ConfigPane { } private createColorReactionCell( - doc: Document, key: ColorReactionKey, sourceColorIndex: number, targetColorIndex: number ): HTMLDivElement { - const cell = doc.createElement('div'); + const cell = document.createElement('div'); cell.className = 'color-reaction-matrix__cell'; const config = appConfig.runtimeSettings.controls[key]; @@ -417,11 +443,11 @@ export class ConfigPane { return cell; } - const button = doc.createElement('button'); + const button = document.createElement('button'); button.className = 'color-reaction-matrix__button'; button.type = 'button'; - const icon = doc.createElement('span'); + const icon = document.createElement('span'); icon.className = 'color-reaction-matrix__icon'; button.appendChild(icon); @@ -470,15 +496,15 @@ export class ConfigPane { const state = getColorReactionState(settings[key]); const nextState = getNextColorReactionState(settings[key]); - const sourceLabel = sourceColorIndex + 1; - const targetLabel = targetColorIndex + 1; + const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex]; + const targetLabel = COLOR_REACTION_LABELS[targetColorIndex]; button.dataset.reaction = state.id; button.setAttribute( 'aria-label', - `Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}` + `${sourceLabel} agents ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}` ); - button.title = state.label; + button.title = `${sourceLabel} agents: ${state.label} ${targetLabel} trails`; } private setUpMusicSection(container: PaneContainer): void { @@ -541,5 +567,7 @@ export class ConfigPane { settingsButton.setAttribute('aria-expanded', String(this.isOpen)); settingsButton.setAttribute('aria-label', label); settingsButton.title = label; + this.container.classList.toggle('config-pane-container--open', this.isOpen); + this.closeButton.hidden = !this.isOpen; } } diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts new file mode 100644 index 0000000..4b4bb5d --- /dev/null +++ b/src/page/eraser-size-control.ts @@ -0,0 +1,68 @@ +import type GameLoop from '../game-loop/game-loop'; +import { settings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; + +const ERASER_CONTROL_SCALE_MAX = 1.33; +const ERASER_CONTROL_SCALE_MIN = 0.75; +const ERASER_SIZE_DEFAULT = 96; +const ERASER_SIZE_MAX = 240; +const ERASER_SIZE_MIN = 24; +const ERASER_SIZE_STEP = 1; + +const clampEraserSize = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; + return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); +}; + +const getEraserSizeRatio = (size: number): number => + (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); + +interface EraserSizeControlOptions { + getGame: () => GameLoop | null; + onActivate: () => void; + onChange: () => void; +} + +export class EraserSizeControl { + private readonly control = queryRequiredElement( + '.eraser-size-control', + HTMLLabelElement + ); + private readonly slider = queryRequiredElement( + '.eraser-size-slider', + HTMLInputElement + ); + + public constructor(private readonly options: EraserSizeControlOptions) { + this.control.addEventListener('pointerdown', this.options.onActivate); + this.control.addEventListener('click', this.options.onActivate); + this.slider.addEventListener('focus', this.options.onActivate); + this.slider.addEventListener('input', () => { + settings.eraserSize = clampEraserSize(Number(this.slider.value)); + this.options.onActivate(); + this.render(); + this.options.onChange(); + }); + } + + public render(): void { + const size = clampEraserSize(settings.eraserSize); + if (settings.eraserSize !== size) { + settings.eraserSize = size; + } + + this.slider.min = ERASER_SIZE_MIN.toString(); + this.slider.max = ERASER_SIZE_MAX.toString(); + this.slider.step = ERASER_SIZE_STEP.toString(); + this.slider.value = size.toString(); + this.slider.setAttribute('aria-valuetext', `${size}px`); + + const ratio = getEraserSizeRatio(size); + const scale = + ERASER_CONTROL_SCALE_MIN + + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; + this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); + this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); + this.options.getGame()?.updateEraserPreview(); + } +} diff --git a/src/page/error-presenter.ts b/src/page/error-presenter.ts new file mode 100644 index 0000000..835c902 --- /dev/null +++ b/src/page/error-presenter.ts @@ -0,0 +1,62 @@ +import { + ErrorHandler, + getErrorMessage, + RuntimeError, + Severity, +} from '../utils/error-handler'; + +type RuntimeUiError = Parameters< + Parameters[0] +>[0]; + +const ERROR_CONTAINER_SELECTOR = '.errors-container'; +const ERROR_CONTAINER_CLASS = 'errors-container'; + +const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => { + const message = document.createElement('pre'); + message.className = error.severity; + message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; + message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status'); + message.setAttribute( + 'aria-live', + error.severity === Severity.ERROR ? 'assertive' : 'polite' + ); + container.append(message); + + if (error.severity === Severity.ERROR) { + message.tabIndex = -1; + message.focus({ preventScroll: true }); + } +}; + +const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ + severity: Severity.ERROR, + message: getErrorMessage(exception), + ...(exception instanceof RuntimeError ? { code: exception.code } : {}), +}); + +export class ErrorPresenter { + public constructor(private readonly container: HTMLElement) { + container.setAttribute('aria-live', 'assertive'); + } + + public render(error: RuntimeUiError): void { + renderRuntimeMessage(this.container, error); + } + + public static renderStartup(exception: unknown): void { + const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR); + const container = + existingContainer instanceof HTMLElement + ? existingContainer + : document.createElement('div'); + + if (!(existingContainer instanceof HTMLElement)) { + container.className = ERROR_CONTAINER_CLASS; + document.body.append(container); + } + + container.setAttribute('aria-live', 'assertive'); + renderRuntimeMessage(container, getRuntimeUiError(exception)); + } +} diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts new file mode 100644 index 0000000..c484571 --- /dev/null +++ b/src/page/mirror-segment-control.ts @@ -0,0 +1,68 @@ +import { settings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; + +const MIRROR_SEGMENT_DEFAULT = 1; +const MIRROR_SEGMENT_MAX = 12; +const MIRROR_SEGMENT_MIN = 1; +const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; +const MIRROR_SEGMENT_STEP = 1; +const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; + +const clampMirrorSegmentCount = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; + return Math.min( + MIRROR_SEGMENT_MAX, + Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue)) + ); +}; + +const getMirrorSegmentRatio = (count: number): number => + (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); + +const formatMirrorSegmentCount = (count: number): string => + count === MIRROR_SEGMENT_DEFAULT + ? MIRROR_SEGMENT_OFF_LABEL + : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; + +interface MirrorSegmentControlOptions { + onChange: () => void; +} + +export class MirrorSegmentControl { + private readonly control = queryRequiredElement( + '.mirror-segment-control', + HTMLLabelElement + ); + private readonly slider = queryRequiredElement( + '.mirror-segment-slider', + HTMLInputElement + ); + + public constructor(private readonly options: MirrorSegmentControlOptions) { + this.slider.addEventListener('input', () => { + settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value)); + this.render(); + this.options.onChange(); + }); + } + + public render(): void { + const count = clampMirrorSegmentCount(settings.mirrorSegmentCount); + if (settings.mirrorSegmentCount !== count) { + settings.mirrorSegmentCount = count; + } + + this.slider.min = MIRROR_SEGMENT_MIN.toString(); + this.slider.max = MIRROR_SEGMENT_MAX.toString(); + this.slider.step = MIRROR_SEGMENT_STEP.toString(); + this.slider.value = count.toString(); + + const label = formatMirrorSegmentCount(count); + const ratio = getMirrorSegmentRatio(count); + this.slider.setAttribute('aria-valuetext', label); + this.control.title = label; + this.control.classList.toggle('active', count > 1); + this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`); + this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`); + } +} diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts new file mode 100644 index 0000000..316b11d --- /dev/null +++ b/src/page/palette-control.ts @@ -0,0 +1,54 @@ +import type GameLoop from '../game-loop/game-loop'; +import { activeVibe, settings } from '../settings'; +import { queryRequiredElement, queryRequiredElements } from '../utils/dom'; +import { rgbColorToCss } from '../utils/rgb-color'; + +interface PaletteControlOptions { + getGame: () => GameLoop | null; + onChange: () => void; +} + +export class PaletteControl { + private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement); + private readonly eraserControl = queryRequiredElement( + '.eraser-size-control', + HTMLLabelElement + ); + private isEraserActiveState = false; + + public constructor(private readonly options: PaletteControlOptions) { + this.swatches.forEach((swatch, index) => { + swatch.addEventListener('click', () => { + settings.selectedColorIndex = index; + this.isEraserActiveState = false; + this.render(); + this.options.onChange(); + }); + }); + } + + public get isEraserActive(): boolean { + return this.isEraserActiveState; + } + + public setEraserActive(active: boolean): void { + this.isEraserActiveState = active; + this.render(); + } + + public render(): void { + this.swatches.forEach((swatch, index) => { + swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); + swatch.classList.toggle( + 'active', + settings.selectedColorIndex === index && !this.isEraserActiveState + ); + }); + this.eraserControl.classList.toggle('active', this.isEraserActiveState); + this.options.getGame()?.setEraseMode(this.isEraserActiveState); + document.documentElement.style.setProperty( + '--garden-background', + rgbColorToCss(activeVibe.backgroundColor) + ); + } +} diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts new file mode 100644 index 0000000..fb25225 --- /dev/null +++ b/src/page/splash-screen.ts @@ -0,0 +1,47 @@ +import { queryRequiredElement } from '../utils/dom'; +import { clamp01 } from '../utils/math'; + +export class SplashScreen { + public readonly startButton = queryRequiredElement( + '.start-button', + HTMLButtonElement + ); + private readonly splash = queryRequiredElement('.splash', HTMLDivElement); + private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement); + private readonly loadingStatus = queryRequiredElement( + '.loading-status', + HTMLDivElement + ); + private readonly loadingProgress = queryRequiredElement( + '.loading-progress', + HTMLDivElement + ); + + public setLoadingStage(label: string, ratio: number): void { + const percent = Math.round(clamp01(ratio) * 100); + this.loadingStatus.textContent = label; + this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`); + this.loadingProgress.setAttribute('aria-valuenow', String(percent)); + } + + public awaitStart(onStart: () => void): Promise { + this.startButton.disabled = false; + return new Promise((resolve) => { + const onClick = () => { + this.startButton.removeEventListener('click', onClick); + onStart(); + this.splash.hidden = true; + resolve(); + }; + this.startButton.addEventListener('click', onClick); + }); + } + + public showLoadingBar(): void { + this.loadingBar.hidden = false; + } + + public hideLoadingBar(): void { + this.loadingBar.hidden = true; + } +} diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts new file mode 100644 index 0000000..2e37feb --- /dev/null +++ b/src/page/vibe-navigator.ts @@ -0,0 +1,40 @@ +import { activeVibe, applyVibeSettings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; +import { VIBE_PRESETS, type VibeId } from '../vibes'; + +interface VibeSelection { + source: string; + vibeId: VibeId; + vibeName: string; +} + +interface VibeNavigatorOptions { + onChange: (selection: VibeSelection) => void; +} + +export class VibeNavigator { + private readonly previousButton = queryRequiredElement( + '.previous-vibe', + HTMLButtonElement + ); + private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement); + + public constructor(private readonly options: VibeNavigatorOptions) { + this.previousButton.addEventListener('click', () => + this.select(-1, 'previous-button') + ); + this.nextButton.addEventListener('click', () => this.select(1, 'next-button')); + } + + private select(offset: number, source: string): void { + const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); + const vibe = + VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; + const activePreset = applyVibeSettings(vibe); + this.options.onChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source, + }); + } +} diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts index 3fd4477..bce5e18 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,5 +1,4 @@ -const AGENT_WORKGROUP_SIZE = 64; -export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE; +export const AGENT_WORKGROUP_SIZE = 64; export const dispatchAgentWorkgroups = ( passEncoder: GPUComputePassEncoder, diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index d76015f..a4022e7 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -2,13 +2,13 @@ import { vec2 } from 'gl-matrix'; import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache'; import { smartCompile } from '../../../utils/graphics/smart-compile'; -import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch'; +import { dispatchAgentWorkgroups } from '../agent-dispatch'; +import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits'; import compactionShader from './agent-compaction.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw'; -export const AGENT_FLOAT_COUNT = 8; -const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; +export { AGENT_FLOAT_COUNT } from '../agent-limits'; export class AgentGenerationPipeline { private static readonly UNIFORM_COUNT = 4; @@ -224,15 +224,7 @@ export class AgentGenerationPipeline { ? Math.floor(value) : 0; return Math.min( - Number.isFinite(this.maxAgentCountUpperLimit) - ? this.maxAgentCountUpperLimit - : Number.POSITIVE_INFINITY, - Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), - Math.floor( - ((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ?? - this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES - ), - AGENT_MAX_DISPATCHABLE_COUNT, + getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit), Math.max(0, requestedMaxAgentCount) ); } diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts new file mode 100644 index 0000000..560ad06 --- /dev/null +++ b/src/pipelines/agents/agent-limits.ts @@ -0,0 +1,25 @@ +import { AGENT_WORKGROUP_SIZE } from './agent-dispatch'; + +export const AGENT_FLOAT_COUNT = 8; +export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; + +export const getMaxSupportedAgentCount = ( + device: GPUDevice, + maxAgentCountUpperLimit = Number.POSITIVE_INFINITY +): number => { + const storageBufferBindingSize = + device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize; + const upperLimit = Number.isFinite(maxAgentCountUpperLimit) + ? Math.floor(maxAgentCountUpperLimit) + : Number.POSITIVE_INFINITY; + + return Math.max( + 0, + Math.min( + upperLimit, + Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), + Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), + Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * AGENT_WORKGROUP_SIZE + ) + ); +}; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 330fafd..7e67b53 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -1,3 +1,4 @@ +import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, @@ -38,43 +39,91 @@ export interface AgentSettings { randomTimeScale: number; } -export class AgentPipeline { - private static readonly UNIFORM_COUNT = 30; +const UNIFORM_COUNT = 30; +export class AgentPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; + private readonly normalPipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT); + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); - private readonly uniformCache = createCachedFloat32BufferWrite( - AgentPipeline.UNIFORM_COUNT - ); - private readonly bindGroupsByAgentsBuffer = new WeakMap< + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly bindGroupCache = createBindGroupCache3< GPUBuffer, - WeakMap> - >(); + GPUTextureView, + GPUTextureView + >((agentsBuffer, trailMapIn, trailMapOut) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: { buffer: agentsBuffer } }, + { binding: 2, resource: trailMapIn }, + { binding: 3, resource: trailMapOut }, + ], + }) + ); private agentCount = 0; + private useIntroPipeline = true; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState, - private readonly getAgentsBuffer: () => GPUBuffer // doesn't get destroyed + private readonly getAgentsBuffer: () => GPUBuffer ) { - this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout); + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'uniform' }, + }, + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'storage' }, + }, + { + binding: 2, + visibility: GPUShaderStage.COMPUTE, + texture: { sampleType: 'float' }, + }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + storageTexture: { format: 'rgba16float' }, + }, + ], + }); + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + agentSchema, + shader + ); + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], + }); this.pipeline = device.createComputePipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), + layout: pipelineLayout, compute: { - module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), + module: shaderModule, entryPoint: 'main', }, }); + this.normalPipeline = device.createComputePipeline({ + layout: pipelineLayout, + compute: { + module: shaderModule, + entryPoint: 'mainNormal', + }, + }); - this.uniforms = this.device.createBuffer({ - size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); } @@ -118,6 +167,7 @@ export class AgentPipeline { introProgress?: number; }) { this.agentCount = agentCount; + this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff; this.uniformValues[0] = moveSpeed * deltaTime; this.uniformValues[1] = turnSpeed * deltaTime; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; @@ -160,110 +210,27 @@ export class AgentPipeline { public execute( commandEncoder: GPUCommandEncoder, trailMapIn: GPUTextureView, - trailMapOut: GPUTextureView + trailMapOut: GPUTextureView, + timestampWrites?: GPUComputePassTimestampWrites ) { if (this.agentCount <= 0) { return; } - const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); - - const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(this.pipeline); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); + passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline); this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); + passEncoder.setBindGroup( + 1, + this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut) + ); dispatchAgentWorkgroups(passEncoder, this.agentCount); passEncoder.end(); } - private getBindGroup( - trailMapIn: GPUTextureView, - trailMapOut: GPUTextureView - ): GPUBindGroup { - const agentsBuffer = this.getAgentsBuffer(); - let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer); - if (!textureCache) { - textureCache = new WeakMap>(); - this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache); - } - - let outputCache = textureCache.get(trailMapIn); - if (!outputCache) { - outputCache = new WeakMap(); - textureCache.set(trailMapIn, outputCache); - } - - const cached = outputCache.get(trailMapOut); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 1, - resource: { - buffer: agentsBuffer, - }, - }, - { - binding: 2, - resource: trailMapIn, - }, - { - binding: 3, - resource: trailMapOut, - }, - ], - }); - - outputCache.set(trailMapOut, bindGroup); - return bindGroup; - } - public destroy() { this.uniforms.destroy(); } - - private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { - return { - entries: [ - { - binding: 0, - visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'uniform', - }, - }, - { - binding: 1, - visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'storage', - }, - }, - { - binding: 2, - visibility: GPUShaderStage.COMPUTE, - texture: { - sampleType: 'float', - }, - }, - { - binding: 3, - visibility: GPUShaderStage.COMPUTE, - storageTexture: { - format: 'rgba16float', - }, - }, - ], - }; - } } diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 025fe39..31b2081 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -1,3 +1,5 @@ +const PI: f32 = 3.14159265359; + struct Settings { moveRate: f32, turnRate: f32, @@ -142,7 +144,80 @@ fn main( let nextPosition = clamp(position + step, vec2(0, 0), maxPosition); if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { - rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5; + rotation = PI + random_float(randomSeed + 22695477u) - 0.5; + } + + var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); + trailBelow = vec4( + trailBelow.rgb + channelMask * settings.individualTrailWeight, + max(trailBelow.a, 0.0) + ); + + textureStore(trailMapOut, vec2(nextPosition), trailBelow); + agents[id].angle = angle + rotation; + agents[id].position = nextPosition; +} + +@compute @workgroup_size(64) +fn mainNormal( + @builtin(global_invocation_id) global_id: vec3 +) { + let id = get_id(global_id); + + if id >= settings.agentCount { + return; + } + + let colorIndex = agents[id].colorIndex; + if colorIndex < 0.0 || colorIndex >= 2.5 { + return; + } + + var position = agents[id].position; + var angle = agents[id].angle; + let channelMask = get_channel_mask(colorIndex); + let reactionMask = get_reaction_mask(colorIndex); + let randomSeed = random_seed(id); + let maxPosition = state.size - vec2(1.0, 1.0); + let randomTurn = random_float(randomSeed); + let direction = vec2(cos(angle), sin(angle)); + + let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); + let leftSensor = sensor_position( + position, + rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + let rightSensor = sensor_position( + position, + rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + + let trailForward = textureLoad(trailMapIn, forwardSensor, 0); + let trailLeft = textureLoad(trailMapIn, leftSensor, 0); + let trailRight = textureLoad(trailMapIn, rightSensor, 0); + + let weightForward = dot(trailForward.rgb, reactionMask); + let weightLeft = dot(trailLeft.rgb, reactionMask); + let weightRight = dot(trailRight.rgb, reactionMask); + + var rotation = (randomTurn - 0.5) * settings.turnWhenLost; + if weightForward >= weightLeft && weightForward >= weightRight { + rotation = rotation * settings.forwardRotationScale; + } else { + rotation += sign(weightLeft - weightRight) * settings.turnRate; + } + + let nextPosition = clamp( + position + direction * settings.moveRate, + vec2(0, 0), + maxPosition + ); + if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { + rotation = PI + random_float(randomSeed + 22695477u) - 0.5; } var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index d57b5a2..5192bce 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -7,6 +7,12 @@ import { } from '../../utils/graphics/cached-buffer-write'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; +import { + LINE_SEGMENT_VERTEX_BUFFER_LAYOUT, + LINE_SEGMENT_VERTICES, + LineSegmentBuffer, +} from '../common/line-segment-buffer'; +import lineSegmentShader from '../common/line-segment.wgsl?raw'; import shader from './brush.wgsl?raw'; export interface BrushSettings { @@ -20,12 +26,7 @@ export interface BrushSettings { brushGrainMaxStrength: number; } -interface LineSegment { - from: vec2; - to: vec2; -} - -interface BrushParameterSettings extends BrushSettings { +interface BrushParameters extends BrushSettings { pixelRatio?: number; selectedColorIndex: number; } @@ -35,6 +36,8 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number => ? pixelRatio : 1; +const UNIFORM_COUNT = 16; + const setBrushUniformValues = ( target: Float32Array, { @@ -48,15 +51,14 @@ const setBrushUniformValues = ( brushGrainMaxStrength, selectedColorIndex, pixelRatio, - }: BrushParameterSettings + }: BrushParameters ): void => { const safePixelRatio = getSafePixelRatio(pixelRatio); const brushRadius = (brushSize * safePixelRatio) / 2; target[0] = brushRadius; target[1] = brushRadius * brushRadius; - target[2] = 0; - target[3] = 0; + // target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader. target[4] = selectedColorIndex === 0 ? 1 : 0; target[5] = selectedColorIndex === 1 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0; @@ -70,78 +72,81 @@ const setBrushUniformValues = ( }; export class BrushPipeline { - private static readonly UNIFORM_COUNT = 16; - private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount; - private static readonly VERTICES_PER_LINE_SEGMENT = 6; - private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4; - private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; - private readonly multiTargetPipeline: GPURenderPipeline; + private readonly renderPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - BrushPipeline.UNIFORM_COUNT - ); - private readonly vertexBuffer: GPUBuffer; - private readonly vertexUploadData = new Float32Array( - BrushPipeline.MAX_LINE_COUNT * - BrushPipeline.VERTICES_PER_LINE_SEGMENT * - BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT - ); - - private lineSegments: Array = []; - private actualSegments: Array = []; + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly segments: LineSegmentBuffer; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState ) { - this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout); + this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount); - this.vertexBuffer = device.createBuffer({ - size: - BrushPipeline.MAX_LINE_COUNT * - BrushPipeline.VERTICES_PER_LINE_SEGMENT * - BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, - }); - - const shaderModule = smartCompile(device, CommonState.shaderCode, shader); - this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1); - - this.uniforms = this.device.createBuffer({ - size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - - this.bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, + this.bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, - resource: { - buffer: this.uniforms, - }, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, }, ], }); - } - public addSwipeSegment(from: vec2, to: vec2) { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + lineSegmentShader, + shader + ); + this.renderPipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT], + }, + fragment: { + module: shaderModule, + entryPoint: 'fragmentMrt', + targets: [ + { + format: 'rgba16float', + blend: { + color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, + alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, + }, + }, + ], + }, + primitive: { topology: 'triangle-list' }, + }); + + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + this.bindGroup = device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [{ binding: 0, resource: { buffer: this.uniforms } }], }); } - public clearSwipes() { - this.lineSegments.length = 0; - this.actualSegments.length = 0; + public addSwipeSegment(from: vec2, to: vec2): void { + this.segments.add(from, to); } - public setParameters(parameters: BrushParameterSettings) { + public clearSwipes(): void { + this.segments.clear(); + } + + public setParameters(parameters: BrushParameters): void { setBrushUniformValues(this.uniformValues, parameters); writeFloat32BufferIfChanged( this.device, @@ -149,188 +154,34 @@ export class BrushPipeline { this.uniformValues, this.uniformCache ); - - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length === 0) { - return; - } - - if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) { - this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments); - } - - const lineCount = this.lineCount; - let floatOffset = 0; - for (let i = 0; i < lineCount; i++) { - const segment = this.actualSegments[i]; - floatOffset = this.writeSegmentVertices( - this.vertexUploadData, - floatOffset, - segment.from, - segment.to - ); - } - - this.device.queue.writeBuffer( - this.vertexBuffer, - 0, - this.vertexUploadData, - 0, - floatOffset - ); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= BrushPipeline.MAX_LINE_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; - } - - private writeSegmentVertices( - target: Float32Array, - offset: number, - from: vec2, - to: vec2 - ): number { - target[offset++] = from[0]; - target[offset++] = from[1]; - target[offset++] = to[0]; - target[offset++] = to[1]; - return offset; + this.segments.flush(); } public executeMultiTarget( commandEncoder: GPUCommandEncoder, - sourceMapOut: GPUTextureView + sourceMapOut: GPUTextureView, + timestampWrites?: GPURenderPassTimestampWrites ): boolean { - return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ - sourceMapOut, - ]); - } - - private executeWithPipeline( - commandEncoder: GPUCommandEncoder, - pipeline: GPURenderPipeline, - textureViews: Array - ): boolean { - if (this.lineCount === 0) { + const lineCount = this.segments.activeCount; + if (lineCount === 0) { return false; } - const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: textureViews.map((view) => ({ - view, - loadOp: 'load', - storeOp: 'store', - })), - }; - - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(pipeline); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }], + timestampWrites, + }); + passEncoder.setPipeline(this.renderPipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.setVertexBuffer(0, this.vertexBuffer); - passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); + passEncoder.setVertexBuffer(0, this.segments.vertexBuffer); + passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount); passEncoder.end(); return true; } - public destroy() { - this.vertexBuffer.destroy(); + public destroy(): void { + this.segments.destroy(); this.uniforms.destroy(); } - - private createPipeline( - shaderModule: GPUShaderModule, - fragmentEntryPoint: string, - colorTargetCount: number - ): GPURenderPipeline { - return this.device.createRenderPipeline({ - layout: this.device.createPipelineLayout({ - bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: shaderModule, - entryPoint: 'vertex', - buffers: [ - { - arrayStride: - Float32Array.BYTES_PER_ELEMENT * BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT, - stepMode: 'instance', - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - ], - }, - ], - }, - fragment: { - module: shaderModule, - entryPoint: fragmentEntryPoint, - targets: Array.from( - { length: colorTargetCount }, - () => BrushPipeline.colorTarget - ), - }, - primitive: { - topology: 'triangle-list', - }, - }); - } - - private static get colorTarget(): GPUColorTargetState { - return { - format: 'rgba16float', - blend: { - color: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - alpha: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - }, - }; - } - - private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { - return { - entries: [ - { - binding: 0, - visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, - }, - ], - }; - } - - private get lineCount() { - return this.actualSegments.length; - } } diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index 946e8b7..30fb694 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -1,3 +1,5 @@ +const SEGMENT_LENGTH_EPSILON: f32 = 0.0001; + struct Settings { brushRadius: f32, brushRadiusSquared: f32, @@ -36,7 +38,7 @@ fn vertex( let direction = end - start; let denominator = dot(direction, direction); var inverseLengthSquared = 0.0; - if denominator > 0.0001 { + if denominator > SEGMENT_LENGTH_EPSILON { inverseLengthSquared = 1.0 / denominator; } let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius); @@ -68,7 +70,7 @@ fn brushStrength( direction: vec2, inverseLengthSquared: f32 ) -> f32 { - let distanceSquared = distanceSquaredFromLine( + let distanceSquared = distance_squared_from_segment( screenPosition, start, direction, @@ -78,11 +80,15 @@ fn brushStrength( return 0.0; } - let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared); - if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { + let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength); + if maxGrainStrength < settings.brushDiscardThreshold { return 0.0; } + if settings.brushGrainMinStrength == settings.brushGrainMaxStrength { + return settings.brushGrainMinStrength; + } + let grainNoise = textureSampleLevel( noise, noiseSampler, @@ -90,52 +96,9 @@ fn brushStrength( vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY), 0.0 ).r; - return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); + return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); } fn brushOutput(strength: f32) -> vec4 { return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); } - -fn distanceSquaredFromLine( - position: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> f32 { - let pa = position - start; - - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} - -fn segment_vertex_position( - vertexIndex: u32, - start: vec2, - end: vec2, - radius: f32 -) -> vec2 { - let directionVector = end - start; - let segmentLength = length(directionVector); - var direction = vec2(1.0, 0.0); - if segmentLength > 0.0 { - direction = directionVector / segmentLength; - } - let perpendicular = vec2(direction.y, -direction.x); - let corner = segment_vertex_corner(vertexIndex % 6u); - let center = mix(start, end, (corner.x + 1.0) * 0.5); - return center + direction * corner.x * radius + perpendicular * corner.y * radius; -} - -fn segment_vertex_corner(index: u32) -> vec2 { - let corners = array, 6>( - vec2(-1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(1.0, -1.0), - ); - return corners[index]; -} diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index 14a710e..97b223b 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -23,7 +23,7 @@ export class CommonState { public static readonly shaderCode = /* wgsl */ ` struct State { size: vec2, - time: f32, + _padding: vec2, }; @group(0) @binding(0) var state: State; @@ -96,10 +96,9 @@ export class CommonState { }); } - public setParameters({ canvasSize, time }: { canvasSize: vec2; time: number }) { + public setParameters({ canvasSize }: { canvasSize: vec2 }) { this.uniformValues[0] = canvasSize[0]; this.uniformValues[1] = canvasSize[1]; - this.uniformValues[2] = time; writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/common/line-segment-buffer.ts b/src/pipelines/common/line-segment-buffer.ts new file mode 100644 index 0000000..417e971 --- /dev/null +++ b/src/pipelines/common/line-segment-buffer.ts @@ -0,0 +1,92 @@ +import { vec2 } from 'gl-matrix'; + +export interface LineSegment { + from: vec2; + to: vec2; +} + +export const LINE_SEGMENT_VERTICES = 6; +const LINE_SEGMENT_ATTRIBUTES = 4; + +export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = { + arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES, + stepMode: 'instance', + attributes: [ + { shaderLocation: 0, format: 'float32x2', offset: 0 }, + { + shaderLocation: 1, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 2, + }, + ], +}; + +export class LineSegmentBuffer { + public readonly vertexBuffer: GPUBuffer; + + private readonly device: GPUDevice; + private readonly maxSegments: number; + private readonly uploadData: Float32Array; + + private pending: Array = []; + private active: Array = []; + + public constructor(device: GPUDevice, maxSegments: number) { + this.device = device; + this.maxSegments = maxSegments; + this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES); + this.vertexBuffer = device.createBuffer({ + size: this.uploadData.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + } + + public add(from: vec2, to: vec2): void { + this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) }); + } + + public clear(): void { + this.pending.length = 0; + this.active.length = 0; + } + + public get activeCount(): number { + return this.active.length; + } + + public flush(): void { + this.active = this.pending.slice(); + this.pending.length = 0; + + if (this.active.length === 0) { + return; + } + + if (this.active.length > this.maxSegments) { + this.active = subsample(this.active, this.maxSegments); + } + + let offset = 0; + for (const segment of this.active) { + this.uploadData[offset++] = segment.from[0]; + this.uploadData[offset++] = segment.from[1]; + this.uploadData[offset++] = segment.to[0]; + this.uploadData[offset++] = segment.to[1]; + } + + this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset); + } + + public destroy(): void { + this.vertexBuffer.destroy(); + } +} + +const subsample = (segments: Array, count: number): Array => { + const result: Array = []; + for (let i = 0; i < count; i++) { + const index = Math.round((i * (segments.length - 1)) / (count - 1)); + result.push(segments[index]); + } + return result; +}; diff --git a/src/pipelines/common/line-segment.wgsl b/src/pipelines/common/line-segment.wgsl new file mode 100644 index 0000000..8ac3035 --- /dev/null +++ b/src/pipelines/common/line-segment.wgsl @@ -0,0 +1,40 @@ +// Six corners forming two triangles for an instanced segment quad. +// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular. +fn segment_vertex_corner(index: u32) -> vec2 { + let isRight = index == 2u || index >= 4u; + let isTop = index == 0u || index == 2u || index == 4u; + return vec2( + select(-1.0, 1.0, isRight), + select(-1.0, 1.0, isTop) + ); +} + +fn segment_vertex_position( + vertexIndex: u32, + start: vec2, + end: vec2, + radius: f32 +) -> vec2 { + let directionVector = end - start; + let segmentLength = length(directionVector); + var direction = vec2(1.0, 0.0); + if segmentLength > 0.0 { + direction = directionVector / segmentLength; + } + let perpendicular = vec2(direction.y, -direction.x); + let corner = segment_vertex_corner(vertexIndex % 6u); + let center = mix(start, end, (corner.x + 1.0) * 0.5); + return center + direction * corner.x * radius + perpendicular * corner.y * radius; +} + +fn distance_squared_from_segment( + position: vec2, + start: vec2, + direction: vec2, + inverseLengthSquared: f32 +) -> f32 { + let pa = position - start; + let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0); + let nearestOffset = pa - direction * q; + return dot(nearestOffset, nearestOffset); +} diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 2ecdf0e..8f6db43 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -11,9 +11,13 @@ struct Settings { const WORKGROUP_SIZE_X = 16u; const WORKGROUP_SIZE_Y = 16u; +// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass +// can be served from workgroup memory without bounds checks for interior tiles. const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u; const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u; const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y; +// 1.0 / 2^32, used to map a 32-bit hash to [0, 1). +const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10; @group(0) @binding(0) var settings: Settings; @group(0) @binding(1) var trailMap: texture_2d; @@ -62,16 +66,8 @@ fn main( let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x; var current = tile[centerTileIndex]; let random = random_from_pixel(pixel); - let r2 = random * random; - let r4 = r2 * r2; - let r8 = r4 * r4; - let r16 = r8 * r8; let trailWeight = diffusion_weight( random, - r2, - r4, - r8, - r16, settings.inverseDiffusionRateTrails ); current += ( @@ -118,15 +114,13 @@ fn random_from_pixel(pixel: vec2) -> f32 { hash = (hash ^ (hash >> 16u)) * 2246822519u; hash = (hash ^ (hash >> 13u)) * 3266489917u; hash = hash ^ (hash >> 16u); - return f32(hash) * 2.3283064365386963e-10; + return f32(hash) * HASH_TO_UNIT_FLOAT; } +// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16) +// so we can vary diffusion sharpness without paying for a real pow() per pixel. fn diffusion_weight( r: f32, - r2: f32, - r4: f32, - r8: f32, - r16: f32, inverseRate: f32 ) -> f32 { if inverseRate < 1.0 { @@ -137,19 +131,22 @@ fn diffusion_weight( clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0) ); } - + let r2 = r * r; if inverseRate < 2.0 { return mix(r, r2, inverseRate - 1.0); } - + let r4 = r2 * r2; if inverseRate < 4.0 { + // (inverseRate - 2.0) / (4.0 - 2.0) return mix(r2, r4, (inverseRate - 2.0) * 0.5); } - + let r8 = r4 * r4; if inverseRate < 8.0 { + // (inverseRate - 4.0) / (8.0 - 4.0) return mix(r4, r8, (inverseRate - 4.0) * 0.25); } - + let r16 = r8 * r8; + // (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate. return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0)) * min(1.0, 16.0 / inverseRate); } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 75bdae3..3aeb3a2 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -133,11 +133,14 @@ export class DiffusionPipeline { commandEncoder: GPUCommandEncoder, trailMapIn: GPUTextureView, trailMapOut: GPUTextureView, - size: vec2 + size: vec2, + timestampWrites?: GPUComputePassTimestampWrites ) { const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); - const passEncoder = commandEncoder.beginComputePass(); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups( diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index c1dae6a..1f77a9d 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -10,8 +10,15 @@ import { dispatchAgentWorkgroups } from '../agents/agent-dispatch'; import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import shader from './eraser-agent.wgsl?raw'; +interface Bounds { + maxX: number; + maxY: number; + minX: number; + minY: number; +} + export class EraserAgentPipeline { - private static readonly UNIFORM_COUNT = 4; + private static readonly UNIFORM_COUNT = 8; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; @@ -35,6 +42,7 @@ export class EraserAgentPipeline { private pendingSegmentCount = 0; private activeSegmentCount = 0; + private pendingBounds: Bounds | null = null; private agentCount = 0; public constructor( @@ -84,32 +92,42 @@ export class EraserAgentPipeline { }); } - public addSwipeSegment(): void { + public addSwipeSegment(from: vec2, to: vec2): void { this.pendingSegmentCount += 1; + this.pendingBounds = includeSegment(this.pendingBounds, from, to); } public clearSwipes(): void { this.pendingSegmentCount = 0; this.activeSegmentCount = 0; + this.pendingBounds = null; } public setParameters({ agentCount, eraserMaskAlphaThreshold, + eraserSize, maskSize, }: { agentCount: number; eraserMaskAlphaThreshold: number; + eraserSize: number; maskSize: vec2; }): void { this.agentCount = agentCount; this.activeSegmentCount = this.pendingSegmentCount; + const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize); this.pendingSegmentCount = 0; + this.pendingBounds = null; this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount)); this.uniformValues[1] = eraserMaskAlphaThreshold; this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0])); this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1])); + this.uniformValues[4] = activeBounds.minX; + this.uniformValues[5] = activeBounds.minY; + this.uniformValues[6] = activeBounds.maxX; + this.uniformValues[7] = activeBounds.maxY; writeFloat32BufferIfChanged( this.device, this.uniforms, @@ -122,12 +140,18 @@ export class EraserAgentPipeline { return this.activeSegmentCount > 0; } - public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void { + public execute( + commandEncoder: GPUCommandEncoder, + eraserMask: GPUTextureView, + timestampWrites?: GPUComputePassTimestampWrites + ): void { if (!this.hasActiveMask() || this.agentCount === 0) { return; } - const passEncoder = commandEncoder.beginComputePass(); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask)); dispatchAgentWorkgroups(passEncoder, this.agentCount); @@ -138,3 +162,37 @@ export class EraserAgentPipeline { this.uniforms.destroy(); } } + +const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => { + const minX = Math.min(from[0], to[0]); + const minY = Math.min(from[1], to[1]); + const maxX = Math.max(from[0], to[0]); + const maxY = Math.max(from[1], to[1]); + if (!bounds) { + return { maxX, maxY, minX, minY }; + } + return { + maxX: Math.max(bounds.maxX, maxX), + maxY: Math.max(bounds.maxY, maxY), + minX: Math.min(bounds.minX, minX), + minY: Math.min(bounds.minY, minY), + }; +}; + +const expandBoundsToMask = ( + bounds: Bounds | null, + radius: number, + maskSize: vec2 +): Bounds => { + const maxX = Math.max(0, maskSize[0] - 1); + const maxY = Math.max(0, maskSize[1] - 1); + if (!bounds) { + return { maxX, maxY, minX: 0, minY: 0 }; + } + return { + maxX: Math.min(maxX, bounds.maxX + radius), + maxY: Math.min(maxY, bounds.maxY + radius), + minX: Math.max(0, bounds.minX - radius), + minY: Math.max(0, bounds.minY - radius), + }; +}; diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 63452fe..b1ff6ee 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -3,6 +3,8 @@ struct Settings { eraserMaskAlphaThreshold: f32, maskWidth: u32, maskHeight: u32, + boundsMin: vec2, + boundsMax: vec2, }; @group(1) @binding(0) var settings: Settings; @@ -23,9 +25,18 @@ fn main( return; } + let position = agents[id].position; + let outsideBounds = position.x < settings.boundsMin.x || + position.y < settings.boundsMin.y || + position.x > settings.boundsMax.x || + position.y > settings.boundsMax.y; + if outsideBounds { + return; + } + let maskSize = vec2(i32(settings.maskWidth), i32(settings.maskHeight)); let maskPosition = clamp( - vec2(agents[id].position), + vec2(position), vec2(0, 0), maskSize - vec2(1, 1) ); diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index 2f6ac26..b6fd9ff 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -7,97 +7,94 @@ import { } from '../../utils/graphics/cached-buffer-write'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; +import { + LINE_SEGMENT_VERTEX_BUFFER_LAYOUT, + LINE_SEGMENT_VERTICES, + LineSegmentBuffer, +} from '../common/line-segment-buffer'; +import lineSegmentShader from '../common/line-segment.wgsl?raw'; import shader from './eraser-texture.wgsl?raw'; -interface LineSegment { - from: vec2; - to: vec2; +interface EraserTextureParameters { + eraserSize: number; + eraserLineDistanceEpsilon: number; + eraserClearRed: number; + eraserClearGreen: number; + eraserClearBlue: number; + eraserClearAlpha: number; } -export class EraserTexturePipeline { - private static readonly UNIFORM_COUNT = 8; - private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount; - private static readonly VERTICES_PER_LINE_SEGMENT = 6; - private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4; +const UNIFORM_COUNT = 8; +const TARGET_FORMATS: Array = ['r8unorm', 'rgba16float', 'rgba16float']; +export class EraserTexturePipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly combinedPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - EraserTexturePipeline.UNIFORM_COUNT - ); - private readonly vertexBuffer: GPUBuffer; - private readonly vertexUploadData = new Float32Array( - EraserTexturePipeline.MAX_LINE_COUNT * - EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT - ); - - private lineSegments: Array = []; - private actualSegments: Array = []; + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly segments: LineSegmentBuffer; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState ) { + this.segments = new LineSegmentBuffer( + device, + appConfig.pipelines.eraser.maxTextureLineCount + ); + this.bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, + buffer: { type: 'uniform' }, }, ], }); - this.vertexBuffer = device.createBuffer({ - size: - EraserTexturePipeline.MAX_LINE_COUNT * - EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + lineSegmentShader, + shader + ); + this.combinedPipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT], + }, + fragment: { + module: shaderModule, + entryPoint: 'fragmentCombined', + targets: TARGET_FORMATS.map((format) => ({ format })), + }, + primitive: { topology: 'triangle-list' }, }); - const shaderModule = smartCompile(device, CommonState.shaderCode, shader); - this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [ - 'r8unorm', - 'rgba16float', - 'rgba16float', - ]); - - this.uniforms = this.device.createBuffer({ - size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.bindGroup = this.device.createBindGroup({ + this.bindGroup = device.createBindGroup({ layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - ], + entries: [{ binding: 0, resource: { buffer: this.uniforms } }], }); } public addSwipeSegment(from: vec2, to: vec2): void { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), - }); + this.segments.add(from, to); } public clearSwipes(): void { - this.lineSegments.length = 0; - this.actualSegments.length = 0; + this.segments.clear(); } public setParameters({ @@ -107,14 +104,7 @@ export class EraserTexturePipeline { eraserClearGreen, eraserClearBlue, eraserClearAlpha, - }: { - eraserSize: number; - eraserLineDistanceEpsilon: number; - eraserClearRed: number; - eraserClearGreen: number; - eraserClearBlue: number; - eraserClearAlpha: number; - }): void { + }: EraserTextureParameters): void { const eraserRadius = eraserSize / 2; this.uniformValues[0] = eraserRadius * eraserRadius; @@ -131,45 +121,18 @@ export class EraserTexturePipeline { this.uniformCache ); - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length === 0) { - return; - } - - if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) { - this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments); - } - - const lineCount = this.lineCount; - let floatOffset = 0; - for (let i = 0; i < lineCount; i++) { - const segment = this.actualSegments[i]; - floatOffset = this.writeSegmentVertices( - this.vertexUploadData, - floatOffset, - segment.from, - segment.to - ); - } - - this.device.queue.writeBuffer( - this.vertexBuffer, - 0, - this.vertexUploadData, - 0, - floatOffset - ); + this.segments.flush(); } public executeCombined( commandEncoder: GPUCommandEncoder, eraserMaskOut: GPUTextureView, sourceMapOut: GPUTextureView, - trailMapOut: GPUTextureView + trailMapOut: GPUTextureView, + timestampWrites?: GPURenderPassTimestampWrites ): void { - if (this.lineCount === 0) { + const lineCount = this.segments.activeCount; + if (lineCount === 0) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { @@ -179,12 +142,13 @@ export class EraserTexturePipeline { storeOp: 'store', }, ], + timestampWrites, }); passEncoder.end(); return; } - const renderPassDescriptor: GPURenderPassDescriptor = { + const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { view: eraserMaskOut, @@ -192,107 +156,21 @@ export class EraserTexturePipeline { loadOp: 'clear', storeOp: 'store', }, - { - view: sourceMapOut, - loadOp: 'load', - storeOp: 'store', - }, - { - view: trailMapOut, - loadOp: 'load', - storeOp: 'store', - }, + { view: sourceMapOut, loadOp: 'load', storeOp: 'store' }, + { view: trailMapOut, loadOp: 'load', storeOp: 'store' }, ], - }; - - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + timestampWrites, + }); passEncoder.setPipeline(this.combinedPipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.setVertexBuffer(0, this.vertexBuffer); - passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); + passEncoder.setVertexBuffer(0, this.segments.vertexBuffer); + passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount); passEncoder.end(); } public destroy(): void { - this.vertexBuffer.destroy(); + this.segments.destroy(); this.uniforms.destroy(); } - - private createPipeline( - shaderModule: GPUShaderModule, - fragmentEntryPoint: string, - targetFormats: Array - ): GPURenderPipeline { - return this.device.createRenderPipeline({ - layout: this.device.createPipelineLayout({ - bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: shaderModule, - entryPoint: 'vertex', - buffers: [ - { - arrayStride: - Float32Array.BYTES_PER_ELEMENT * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT, - stepMode: 'instance', - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - ], - }, - ], - }, - fragment: { - module: shaderModule, - entryPoint: fragmentEntryPoint, - targets: targetFormats.map((format) => ({ format })), - }, - primitive: { - topology: 'triangle-list', - }, - }); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; - } - - private writeSegmentVertices( - target: Float32Array, - offset: number, - from: vec2, - to: vec2 - ): number { - target[offset++] = from[0]; - target[offset++] = from[1]; - target[offset++] = to[0]; - target[offset++] = to[1]; - return offset; - } - - private get lineCount(): number { - return this.actualSegments.length; - } } diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index 10948ef..0fccc10 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -49,7 +49,13 @@ fn fragmentCombined( @location(2) @interpolate(flat) direction: vec2, @location(3) @interpolate(flat) inverseLengthSquared: f32 ) -> EraserCombinedTargets { - if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) { + let distanceSquared = distance_squared_from_segment( + screenPosition, + start, + direction, + inverseLengthSquared + ); + if distanceSquared > settings.eraserRadiusSquared { discard; } @@ -69,55 +75,3 @@ fn getEraserClearValue() -> vec4 { settings.clearAlpha ); } - -fn shouldDiscardEraserFragment( - screenPosition: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> bool { - return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared; -} - -fn distanceSquaredFromLine( - position: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> f32 { - let pa = position - start; - - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} - -fn segment_vertex_position( - vertexIndex: u32, - start: vec2, - end: vec2, - radius: f32 -) -> vec2 { - let directionVector = end - start; - let segmentLength = length(directionVector); - var direction = vec2(1.0, 0.0); - if segmentLength > 0.0 { - direction = directionVector / segmentLength; - } - let perpendicular = vec2(direction.y, -direction.x); - let corner = segment_vertex_corner(vertexIndex % 6u); - let center = mix(start, end, (corner.x + 1.0) * 0.5); - return center + direction * corner.x * radius + perpendicular * corner.y * radius; -} - -fn segment_vertex_corner(index: u32) -> vec2 { - let corners = array, 6>( - vec2(-1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(1.0, -1.0), - ); - return corners[index]; -} diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index df6b56a..dc99434 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -17,17 +17,19 @@ export interface RenderSettings { backgroundGrainStrength: number; } -export class RenderPipeline { - private static readonly UNIFORM_COUNT = 20; +// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats. +const UNIFORM_COUNT = 20; +export class RenderPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPURenderPipeline; private readonly noSourcePipeline: GPURenderPipeline; + private readonly noGrainPipeline: GPURenderPipeline; + private readonly noSourceNoGrainPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - RenderPipeline.UNIFORM_COUNT - ); + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private useBackgroundGrain = true; private readonly getBindGroup = createBindGroupCache( (colorTexture, sourceTexture) => @@ -46,42 +48,83 @@ export class RenderPipeline { private readonly device: GPUDevice, private readonly commonState: CommonState ) { - this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout); + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, + }, + { + binding: 2, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: 'float' }, + }, + { + binding: 3, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: 'float' }, + }, + ], + }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); const vertex = setUpFullScreenQuad(device); - const format = navigator.gpu.getPreferredCanvasFormat(); - this.pipeline = this.createPipeline(format, vertex, 'fragment'); - this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource'); + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }); + this.pipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragment' + ); + this.noSourcePipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoSource' + ); + this.noGrainPipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoGrain' + ); + this.noSourceNoGrainPipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoSourceNoGrain' + ); - this.uniforms = this.device.createBuffer({ - size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); } private createPipeline( - format: GPUTextureFormat, + layout: GPUPipelineLayout, vertex: GPUVertexState, + shaderModule: GPUShaderModule, + format: GPUTextureFormat, fragmentEntryPoint: string ): GPURenderPipeline { return this.device.createRenderPipeline({ - layout: this.device.createPipelineLayout({ - bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], - }), + layout, vertex, fragment: { - module: smartCompile(this.device, CommonState.shaderCode, shader), + module: shaderModule, entryPoint: fragmentEntryPoint, - targets: [ - { - format, - }, - ], - }, - primitive: { - topology: 'triangle-list', + targets: [{ format }], }, + primitive: { topology: 'triangle-list' }, }); } @@ -101,15 +144,13 @@ export class RenderPipeline { this.uniformValues[0] = rgbChannelToUnit(a[0]); this.uniformValues[1] = rgbChannelToUnit(a[1]); this.uniformValues[2] = rgbChannelToUnit(a[2]); - this.uniformValues[3] = 0; + // uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding. this.uniformValues[4] = rgbChannelToUnit(b[0]); this.uniformValues[5] = rgbChannelToUnit(b[1]); this.uniformValues[6] = rgbChannelToUnit(b[2]); - this.uniformValues[7] = 0; this.uniformValues[8] = rgbChannelToUnit(c[0]); this.uniformValues[9] = rgbChannelToUnit(c[1]); this.uniformValues[10] = rgbChannelToUnit(c[2]); - this.uniformValues[11] = 0; this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]); this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]); this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]); @@ -118,6 +159,7 @@ export class RenderPipeline { this.uniformValues[17] = renderBrushColorBase; this.uniformValues[18] = renderBrushColorStrengthMultiplier; this.uniformValues[19] = backgroundGrainStrength; + this.useBackgroundGrain = backgroundGrainStrength !== 0; writeFloat32BufferIfChanged( this.device, this.uniforms, @@ -130,28 +172,18 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView, - useSourceTexture = true + useSourceTexture = true, + timestampWrites?: GPURenderPassTimestampWrites ): GPUTexture { - const bindGroup = this.getBindGroup(colorTexture, sourceTexture); const canvasTexture = this.context.getCurrentTexture(); - - const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: canvasTexture.createView(), - clearValue: { r: 0, g: 0, b: 0, a: 1 }, - loadOp: 'clear', - storeOp: 'store', - }, - ], - }; - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline); - this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); - passEncoder.draw(3, 1); - passEncoder.end(); - + this.encodePass( + commandEncoder, + colorTexture, + sourceTexture, + canvasTexture.createView(), + useSourceTexture, + timestampWrites + ); return canvasTexture; } @@ -159,56 +191,54 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView, - outputTexture: GPUTextureView + outputTexture: GPUTextureView, + useSourceTexture = true, + timestampWrites?: GPURenderPassTimestampWrites ) { - const bindGroup = this.getBindGroup(colorTexture, sourceTexture); + this.encodePass( + commandEncoder, + colorTexture, + sourceTexture, + outputTexture, + useSourceTexture, + timestampWrites + ); + } + private encodePass( + commandEncoder: GPUCommandEncoder, + colorTexture: GPUTextureView, + sourceTexture: GPUTextureView, + output: GPUTextureView, + useSourceTexture: boolean, + timestampWrites?: GPURenderPassTimestampWrites + ) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { - view: outputTexture, + view: output, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', }, ], + timestampWrites, }); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(this.getPipeline(useSourceTexture)); this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); + passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture)); passEncoder.draw(3, 1); passEncoder.end(); } + private getPipeline(useSourceTexture: boolean): GPURenderPipeline { + if (useSourceTexture) { + return this.useBackgroundGrain ? this.pipeline : this.noGrainPipeline; + } + return this.useBackgroundGrain ? this.noSourcePipeline : this.noSourceNoGrainPipeline; + } + public destroy() { this.uniforms.destroy(); } - - private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { - return { - entries: [ - { - binding: 0, - visibility: GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, - }, - { - binding: 2, - visibility: GPUShaderStage.FRAGMENT, - texture: { - sampleType: 'float', - }, - }, - { - binding: 3, - visibility: GPUShaderStage.FRAGMENT, - texture: { - sampleType: 'float', - }, - }, - ], - }; - } } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 4495601..67651c0 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -1,10 +1,10 @@ struct Settings { colorA: vec3, - backgroundColorPadding0: f32, + _colorAPadding: f32, colorB: vec3, - backgroundColorPadding1: f32, + _colorBPadding: f32, colorC: vec3, - backgroundColorPadding2: f32, + _colorCPadding: f32, backgroundColor: vec3, clarity: f32, traceNormalizationFloor: f32, @@ -24,18 +24,32 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { let pixel = vec2(position.xy); let traces = textureLoad(trailMap, pixel, 0); let sources = textureLoad(sourceMap, pixel, 0); - return renderColor(traces, sources, pixel); + return renderColor(traces, sources, getTexturedBackground(pixel)); } @fragment fn fragmentNoSource(@builtin(position) position: vec4) -> @location(0) vec4 { let pixel = vec2(position.xy); let traces = textureLoad(trailMap, pixel, 0); - return renderColor(traces, vec4(0.0), pixel); + return renderColor(traces, vec4(0.0), getTexturedBackground(pixel)); } -fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4 { - let background = getTexturedBackground(pixel); +@fragment +fn fragmentNoGrain(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + let sources = textureLoad(sourceMap, pixel, 0); + return renderColor(traces, sources, getFlatBackground()); +} + +@fragment +fn fragmentNoSourceNoGrain(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + return renderColor(traces, vec4(0.0), getFlatBackground()); +} + +fn renderColor(traces: vec4, sources: vec4, background: vec3) -> vec4 { let tracesMax = maxComponent(traces.rgb); let sourcesMax = maxComponent(sources.rgb); if max(tracesMax, sourcesMax) <= 0.0 { @@ -93,7 +107,11 @@ fn maxComponent(v: vec3) -> f32 { } fn clarity(strength: f32) -> f32 { - return pow(clamp(strength, 0, 1), settings.clarity); + let clamped = clamp(strength, 0, 1); + if settings.clarity == 1.0 { + return clamped; + } + return pow(clamped, settings.clarity); } fn normalizeColorIntensity(color: vec3) -> vec3 { @@ -101,10 +119,11 @@ fn normalizeColorIntensity(color: vec3) -> vec3 { return color / max(settings.traceNormalizationFloor, brightestChannel); } +fn getFlatBackground() -> vec3 { + return clamp(settings.backgroundColor, vec3(0), vec3(1)); +} + fn getTexturedBackground(pixel: vec2) -> vec3 { - if settings.backgroundGrainStrength == 0.0 { - return clamp(settings.backgroundColor, vec3(0), vec3(1)); - } let noiseCoord = vec2(vec2(pixel) & vec2(NOISE_TEXTURE_MASK)); let grain = textureLoad(noise, noiseCoord, 0).r - 0.5; diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index acb3ae9..7da1f51 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -1,8 +1,10 @@ -html > body.pre-drawing .dev-stats-overlay, -html > body.is-loading .dev-stats-overlay { +html > body.is-loading .perf-stats-overlay { display: none; } +$grain-noise-a: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='257' height='257' viewBox='0 0 257 257'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' seed='17' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='257' height='257' filter='url(%23n)'/%3E%3C/svg%3E"); +$grain-noise-b: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='389' height='389' viewBox='0 0 389 389'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.53' numOctaves='5' seed='41' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='389' height='389' filter='url(%23n)'/%3E%3C/svg%3E"); + html > body { width: 100%; height: 100vh; @@ -20,16 +22,57 @@ html > body { overflow: hidden; > canvas { + position: relative; + z-index: 0; height: 100%; width: 100%; touch-action: none; } + > .garden-grain { + --garden-grain-strength: 0; + + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + contain: strict; + + &::before, + &::after { + content: ''; + position: absolute; + inset: 0; + } + + &::before { + opacity: clamp(0, calc(var(--garden-grain-strength) * 12), 0.44); + background-image: $grain-noise-a; + background-size: 257px 257px; + filter: contrast(190%) brightness(0.66); + mix-blend-mode: multiply; + } + + &::after { + opacity: clamp(0, calc(var(--garden-grain-strength) * 7), 0.24); + background-image: $grain-noise-b; + background-position: 73px 41px; + background-size: 389px 389px; + filter: contrast(170%) brightness(1.02); + mix-blend-mode: screen; + transform: rotate(0.01deg); + } + + &[hidden] { + display: none; + } + } + > .eraser-preview { position: absolute; top: 0; left: 0; - z-index: 1; + z-index: 3; width: var(--eraser-preview-size, 96px); height: var(--eraser-preview-size, 96px); border: 2px solid rgb(255 234 228 / 88%); @@ -52,7 +95,7 @@ html > body { } } - > .dev-stats-overlay { + > .perf-stats-overlay { position: absolute; top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index d21e043..95f6115 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -1,9 +1,150 @@ -.config-pane { - .color-reaction-folder > .tp-fldv_c { - padding: 6px 8px 8px; +@use 'mixins' as *; + +.config-pane-container { + --config-pane-available-height: calc( + 100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ); + + position: fixed; + top: max(12px, env(safe-area-inset-top, 0px)); + right: max(12px, env(safe-area-inset-right, 0px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 4px; + z-index: 20; + width: min( + 420px, + calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)) + ); + max-height: var(--config-pane-available-height); + pointer-events: none; + + @supports (height: 100dvh) { + --config-pane-available-height: calc( + 100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ); } } +.config-pane-container--open { + pointer-events: auto; +} + +.config-pane { + width: 100%; + max-height: calc(var(--config-pane-available-height) - 36px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + scrollbar-width: thin; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.config-pane-close { + position: relative; + justify-self: end; + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 0; + border-radius: 4px; + background: transparent; + color: rgb(235 238 245 / 82%); + cursor: pointer; + font: inherit; + font-size: 0; + pointer-events: auto; + transition: + background-color var(--transition-time), + color var(--transition-time); + + &::before, + &::after { + content: ''; + position: absolute; + width: 14px; + height: 2px; + border-radius: 999px; + background: currentColor; + } + + &::before { + transform: rotate(45deg); + } + + &::after { + transform: rotate(-45deg); + } + + &:hover { + background: rgb(255 255 255 / 10%); + color: white; + } + + &:focus-visible { + outline: 2px solid white; + outline-offset: -2px; + } + + &[hidden] { + display: none; + } +} + +@mixin mobile-config-pane() { + .config-pane-container { + --config-pane-available-height: min( + 64vh, + calc( + 100vh - 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ) + ); + + top: max(8px, env(safe-area-inset-top, 0px)); + right: auto; + left: 50%; + width: min(80vw, 420px); + transform: translateX(-50%); + + @supports (height: 100dvh) { + --config-pane-available-height: min( + 64dvh, + calc( + 100dvh - + 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ) + ); + } + } + + .config-pane { + --tp-blade-value-width: min(128px, 38vw); + --tp-container-unit-size: 18px; + + font-size: 11px; + } + + .config-pane-close { + width: 32px; + height: 32px; + } +} + +@include on-small-screen { + @include mobile-config-pane; +} + +@media (hover: none) and (pointer: coarse) { + @include mobile-config-pane; +} + +.color-reaction-matrix-blade { + padding: 6px 8px 8px; +} + .color-reaction-matrix { display: grid; grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr)); diff --git a/src/style/_motion.scss b/src/style/_motion.scss deleted file mode 100644 index 20d1e66..0000000 --- a/src/style/_motion.scss +++ /dev/null @@ -1,16 +0,0 @@ -@media (prefers-reduced-motion: reduce) { - html > body { - > aside.control-dock { - > .toolbar-row { - > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover, - > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover { - transform: none; - } - - > nav.buttons > button:hover::after { - transform: none; - } - } - } - } -} diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss index 2c89027..ef7ca8c 100644 --- a/src/style/_toolbar.scss +++ b/src/style/_toolbar.scss @@ -1,709 +1,4 @@ -@use 'mixins' as *; - -@mixin toolbar-track() { - height: 7px; - border-radius: 999px; - background: linear-gradient( - 90deg, - rgb(var(--control-rgb) / 72%) 0 var(--control-progress), - rgb(255 255 255 / 24%) var(--control-progress) 100% - ); - box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%); - cursor: ew-resize; -} - -@mixin toolbar-thumb() { - width: var(--thumb-width); - height: var(--thumb-height); - border: 2px solid rgb(255 255 255 / 92%); - border-radius: var(--thumb-radius); - background: var(--thumb-background); - box-shadow: - inset 0 1px 2px rgb(255 255 255 / 22%), - 0 4px 12px rgb(0 0 0 / 30%); - cursor: ew-resize; - transform: var(--thumb-transform); -} - -$toolbar-icons: ( - info: 'info', - maximize-full-screen: 'maximize', - minimize-full-screen: 'minimize', - settings: 'settings', - sound: 'sound', - export-4k: 'download', - restart: 'restart', -); - -html > body > aside.control-dock > .toolbar-row { - --toolbar-background-opacity: 0%; - --toolbar-background-strength: 0; - --toolbar-divider-space: clamp(6px, 1.8vw, 14px); - --toolbar-top-max-width: 594px; - - display: grid; - grid-template-areas: - 'previous controls next' - 'previous divider next' - 'previous buttons next'; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: stretch; - justify-content: center; - width: 100%; - max-width: 100%; - margin: 0 auto; - padding-inline: clamp(8px, 1.4vw, 14px); - column-gap: 0; - row-gap: 0; - border-radius: 12px; - color: rgb(245 250 244 / 92%); - background-color: rgb(5 8 13 / var(--toolbar-background-opacity)); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)), - inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)), - 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%)); - backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px)) - brightness(calc(1 - var(--toolbar-background-strength) * 0.38)) - saturate(calc(1 - var(--toolbar-background-strength) * 0.18)); - font-size: 13px; - font-weight: 400; - line-height: 1; - transition: - backdrop-filter var(--transition-time-long), - background-color var(--transition-time-long), - box-shadow var(--transition-time-long); - - &::after { - content: ''; - grid-area: divider; - align-self: center; - justify-self: center; - width: min(100%, var(--toolbar-top-max-width)); - height: 1px; - margin-block: var(--toolbar-divider-space); - background: rgb(255 255 255 / 12%); - } - - button { - min-width: 44px; - min-height: 44px; - border: 0; - font: inherit; - cursor: pointer; - transition: - background-color var(--transition-time), - border-color var(--transition-time), - color var(--transition-time), - box-shadow var(--transition-time), - opacity var(--transition-time), - transform var(--transition-time); - - &:disabled { - cursor: progress; - opacity: 0.58; - } - - &:focus-visible { - outline: 2px solid white; - outline-offset: 2px; - } - } - - > .toolbar-shell { - grid-area: controls; - display: grid; - grid-template-areas: 'swatches'; - grid-template-columns: minmax(0, 1fr); - align-items: center; - justify-content: center; - justify-self: center; - width: min(100%, var(--toolbar-top-max-width)); - min-width: 0; - padding: 8px 9px; - } - - > .vibe-button { - position: relative; - display: grid; - place-items: center; - width: 52px; - height: auto; - min-height: 66px; - flex: 0 0 auto; - padding: 0; - border-radius: 0; - background: transparent; - color: rgb(255 255 255 / 70%); - font-size: 0; - line-height: 1; - - &::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 18px; - height: 18px; - border-color: currentColor; - border-style: solid; - border-width: 0 0 3px 3px; - transform: translate(-35%, -50%) rotate(45deg); - } - - &.next-vibe::before { - border-width: 3px 3px 0 0; - transform: translate(-65%, -50%) rotate(45deg); - } - - &:hover { - color: color-mix(in srgb, var(--accent-color) 70%, white); - } - - &.previous-vibe:hover { - transform: translateX(-2px); - } - - &.next-vibe:hover { - transform: translateX(2px); - } - } - - > .previous-vibe { - grid-area: previous; - } - - > .next-vibe { - grid-area: next; - } - - > nav.buttons { - grid-area: buttons; - display: flex; - flex-wrap: nowrap; - align-items: center; - justify-content: center; - justify-self: center; - gap: 4px; - width: fit-content; - max-width: 100%; - min-width: 0; - - > button, - > .audio-control > button { - position: relative; - width: 44px; - height: 44px; - flex: 1 1 44px; - max-width: 54px; - min-width: 0; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - - &::after { - content: ''; - position: absolute; - inset: 0; - z-index: 1; - width: 20px; - height: 20px; - margin: auto; - background-color: rgb(245 250 244 / 76%); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - transition: - background-color var(--transition-time), - transform var(--transition-time); - } - - &:hover { - border-color: rgb(255 255 255 / 10%); - background: rgb(255 255 255 / 9%); - } - - &:hover::after { - transform: scale(1.08); - } - - &.active { - border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%); - background: color-mix(in srgb, var(--accent-color) 30%, transparent); - } - - &.active::after { - background-color: white; - } - - @each $class, $icon in $toolbar-icons { - &.#{$class}::after { - mask-image: url('../../assets/icons/#{$icon}.svg'); - } - } - - &.sound.muted::before { - content: ''; - position: absolute; - inset: 0; - z-index: 2; - width: 2px; - height: 28px; - margin: auto; - border-radius: 999px; - background: white; - transform: rotate(-45deg); - transform-origin: center; - } - - &.sound.muted::after { - background-color: rgb(255 255 255 / 46%); - } - } - - > .audio-control { - display: flex; - align-items: center; - width: 132px; - height: 44px; - flex: 2 1 132px; - max-width: 150px; - min-width: 0; - padding-right: 10px; - border: 1px solid transparent; - border-radius: 8px; - background: rgb(255 255 255 / 4%); - transition: - border-color var(--transition-time), - background-color var(--transition-time), - box-shadow var(--transition-time), - opacity var(--transition-time); - - &:hover { - border-color: rgb(255 255 255 / 10%); - background: rgb(255 255 255 / 7%); - } - - > button { - flex: 0 0 42px; - min-width: 42px; - border-color: transparent; - - &:focus-visible { - outline-offset: -4px; - } - } - - > .volume-control { - position: relative; - display: grid; - align-items: center; - height: 44px; - flex: 1 1 auto; - min-width: 0; - padding-left: 3px; - cursor: ew-resize; - opacity: 0.96; - transition: opacity var(--transition-time); - - &.muted { - opacity: 0.56; - } - } - - > .volume-control input[type='range'] { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - appearance: none; - background: transparent; - cursor: ew-resize; - outline: none; - touch-action: pan-y; - - &:focus-visible { - border-radius: 8px; - outline: 2px solid white; - outline-offset: -4px; - } - - &::-webkit-slider-runnable-track { - height: 4px; - border-radius: 999px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--accent-color) 62%, white 8%) 0 - var(--volume-progress, 42%), - rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100% - ); - box-shadow: - inset 0 1px 1px rgb(0 0 0 / 24%), - 0 1px 0 rgb(255 255 255 / 8%); - cursor: ew-resize; - } - - &::-webkit-slider-thumb { - width: 12px; - height: 12px; - border: 2px solid rgb(13 18 24); - border-radius: 50%; - background: rgb(245 250 244); - box-shadow: - 0 0 0 1px rgb(255 255 255 / 46%), - 0 3px 8px rgb(0 0 0 / 28%); - margin-top: -4px; - appearance: none; - transition: - box-shadow var(--transition-time), - transform var(--transition-time); - } - - &::-webkit-slider-thumb:hover { - box-shadow: - 0 0 0 1px rgb(255 255 255 / 56%), - 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent), - 0 4px 10px rgb(0 0 0 / 34%); - transform: scale(1.08); - } - - &::-moz-range-track { - height: 4px; - border: 0; - border-radius: 999px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--accent-color) 62%, white 8%) 0 - var(--volume-progress, 42%), - rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100% - ); - box-shadow: - inset 0 1px 1px rgb(0 0 0 / 24%), - 0 1px 0 rgb(255 255 255 / 8%); - } - - &::-moz-range-thumb { - width: 12px; - height: 12px; - border: 2px solid rgb(13 18 24); - border-radius: 50%; - background: rgb(245 250 244); - box-shadow: - 0 0 0 1px rgb(255 255 255 / 46%), - 0 3px 8px rgb(0 0 0 / 28%); - cursor: ew-resize; - } - } - } - - > .export-status { - flex: 0 1 140px; - min-height: 20px; - max-width: 140px; - overflow: hidden; - color: rgb(255 255 255 / 82%); - font-size: 13px; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; - - &:empty { - display: none; - } - } - } - - > .toolbar-shell > .garden-controls { - grid-area: swatches; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - min-width: 0; - padding: 0 4px; - - > .swatches { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - min-height: 58px; - padding: 6px 10px; - - > .color-swatch { - position: relative; - width: 44px; - height: 44px; - border: 2px solid rgb(255 255 255 / 54%); - border-radius: 50%; - box-shadow: - inset 0 0 0 1px rgb(0 0 0 / 16%), - 0 3px 10px rgb(0 0 0 / 22%); - - &:hover { - transform: translateY(-2px); - } - - &.active { - outline: 2px solid rgb(255 255 255 / 96%); - outline-offset: 3px; - box-shadow: - inset 0 0 0 1px rgb(0 0 0 / 14%), - 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent), - 0 7px 18px rgb(0 0 0 / 26%); - } - } - - > .eraser-size-control, - > .mirror-segment-control { - --thumb-hover-transform: scale(1.03); - --thumb-radius: 50%; - --thumb-transform: none; - - position: relative; - display: grid; - align-items: center; - width: 184px; - height: 46px; - flex: 0 0 184px; - padding: 0 12px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 14%); - border-radius: 8px; - background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 6%), - 0 3px 10px rgb(0 0 0 / 18%); - cursor: ew-resize; - transition: - border-color var(--transition-time), - background-color var(--transition-time), - box-shadow var(--transition-time), - transform var(--transition-time); - - &:hover { - border-color: rgb(255 255 255 / 24%); - transform: translateY(-2px); - } - - &.active { - border-color: rgb(var(--control-rgb) / 72%); - background-color: rgb(var(--control-rgb) / 11%); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 10%), - 0 0 0 5px rgb(var(--control-rgb) / 28%), - 0 6px 15px rgb(0 0 0 / 22%); - } - - input[type='range'] { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - appearance: none; - background: transparent; - cursor: ew-resize; - outline: none; - touch-action: pan-y; - - &:focus-visible { - border-radius: 8px; - outline: 2px solid white; - outline-offset: 2px; - } - - &::-webkit-slider-runnable-track { - @include toolbar-track(); - } - - &::-webkit-slider-thumb { - @include toolbar-thumb(); - margin-top: calc((7px - var(--thumb-height)) / 2); - appearance: none; - transition: - box-shadow var(--transition-time), - height var(--transition-time), - margin-top var(--transition-time), - transform var(--transition-time), - width var(--transition-time); - } - - &::-webkit-slider-thumb:hover { - box-shadow: - inset 0 1px 2px rgb(255 255 255 / 22%), - 0 0 0 4px rgb(var(--control-rgb) / 22%), - 0 5px 14px rgb(0 0 0 / 34%); - transform: var(--thumb-hover-transform); - } - - &::-moz-range-track { - @include toolbar-track(); - } - - &::-moz-range-thumb { - @include toolbar-thumb(); - } - } - } - - > .eraser-size-control { - --control-progress: var(--eraser-progress, 33%); - --control-rgb: 255 140 117; - --thumb-background: - linear-gradient( - 110deg, - transparent 0 12%, - rgb(255 255 255 / 44%) 13% 20%, - transparent 21% 100% - ), - linear-gradient( - 90deg, - #ff8fa3 0 52%, - rgb(54 46 51 / 78%) 53% 56%, - #f5eee5 57% 100% - ); - --thumb-height: calc(21px * var(--eraser-control-scale, 1)); - --thumb-hover-transform: rotate(-10deg) scale(1.03); - --thumb-radius: calc(6px * var(--eraser-control-scale, 1)); - --thumb-transform: rotate(-10deg); - --thumb-width: calc(34px * var(--eraser-control-scale, 1)); - } - - > .mirror-segment-control { - --control-progress: var(--mirror-progress, 0%); - --control-rgb: 148 233 203; - --thumb-background: - radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg) - ); - --thumb-height: 44px; - --thumb-width: 44px; - } - } - } - - @include on-small-screen { - --toolbar-divider-space: 4px; - --toolbar-top-max-width: 329px; - - grid-template-areas: - 'previous controls next' - '. divider .' - 'buttons buttons buttons'; - width: 100%; - padding-inline: 4px; - column-gap: 0; - row-gap: 0; - - > .vibe-button { - width: 36px; - min-height: 44px; - - &::before { - width: 14px; - height: 14px; - } - } - - > .toolbar-shell { - padding: 4px; - } - - > nav.buttons { - justify-self: stretch; - justify-content: space-between; - gap: clamp(1px, 0.55vw, 2px); - width: auto; - max-width: none; - margin-inline: -4px; - - > button { - width: auto; - height: 38px; - flex: 1 1 clamp(28px, 8vw, 38px); - max-width: 38px; - min-height: 38px; - - &::after { - width: 17px; - height: 17px; - } - } - - > .audio-control { - width: auto; - height: 38px; - flex: 2 1 clamp(58px, 18vw, 118px); - max-width: 118px; - padding-right: clamp(4px, 1.8vw, 9px); - - > button { - width: auto; - flex: 1 1 clamp(28px, 8vw, 38px); - min-width: 0; - } - - > .volume-control { - height: 38px; - } - } - - > .export-status { - flex-basis: 0; - max-width: 0; - text-align: center; - } - } - - > .toolbar-shell { - > .garden-controls { - padding: 2px 4px; - - > .swatches { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - justify-items: center; - justify-content: stretch; - width: 100%; - min-width: 0; - min-height: 48px; - flex: 1 1 100%; - padding: 3px 5px; - column-gap: 6px; - row-gap: 6px; - - > .color-swatch { - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; - - grid-column: span 2; - } - - > .eraser-size-control, - > .mirror-segment-control { - justify-self: stretch; - width: 100%; - min-width: 0; - height: 38px; - padding: 0 7px; - } - - > .eraser-size-control { - grid-column: 1 / span 3; - } - - > .mirror-segment-control { - --thumb-height: 34px; - --thumb-width: 34px; - - grid-column: 4 / span 3; - } - } - } - } - } -} +@use 'toolbar/layout'; +@use 'toolbar/buttons'; +@use 'toolbar/garden-controls'; +@use 'toolbar/responsive'; diff --git a/src/style/toolbar/_buttons.scss b/src/style/toolbar/_buttons.scss new file mode 100644 index 0000000..3551a3b --- /dev/null +++ b/src/style/toolbar/_buttons.scss @@ -0,0 +1,157 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row > nav.buttons { + grid-area: buttons; + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + justify-self: center; + gap: 4px; + width: fit-content; + max-width: 100%; + min-width: 0; + + > button, + > .audio-control > button { + position: relative; + width: 44px; + height: 44px; + flex: 1 1 44px; + max-width: 54px; + min-width: 0; + @include toolbar-control-surface(transparent, rgb(255 255 255 / 9%)); + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + width: 20px; + height: 20px; + margin: auto; + background-color: rgb(245 250 244 / 76%); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + transition: + background-color var(--transition-time), + transform var(--transition-time); + } + + &:hover::after { + transform: scale(1.08); + } + + &.active { + border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%); + background: color-mix(in srgb, var(--accent-color) 30%, transparent); + } + + &.active::after { + background-color: white; + } + + @each $class, $icon in $toolbar-icons { + &.#{$class}::after { + mask-image: url('../../../assets/icons/#{$icon}.svg'); + } + } + + &.sound.muted::before { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + width: 2px; + height: 28px; + margin: auto; + border-radius: 999px; + background: white; + transform: rotate(-45deg); + transform-origin: center; + } + + &.sound.muted::after { + background-color: rgb(255 255 255 / 46%); + } + } + + > .audio-control { + display: flex; + align-items: center; + width: 132px; + height: 44px; + flex: 2 1 132px; + max-width: 150px; + min-width: 0; + padding-right: 10px; + @include toolbar-control-surface(rgb(255 255 255 / 4%), rgb(255 255 255 / 7%)); + + > button { + flex: 0 0 42px; + min-width: 42px; + border-color: transparent; + + &:focus-visible { + outline-offset: -4px; + } + } + + > .volume-control { + --range-progress: var(--volume-progress, 42%); + --range-track-height: 4px; + --range-fill: color-mix(in srgb, var(--accent-color) 62%, white 8%); + --range-empty: rgb(255 255 255 / 18%); + --range-track-shadow: + inset 0 1px 1px rgb(0 0 0 / 24%), 0 1px 0 rgb(255 255 255 / 8%); + --range-thumb-width: 12px; + --range-thumb-height: 12px; + --range-thumb-border: 2px solid rgb(13 18 24); + --range-thumb-radius: 50%; + --range-thumb-background: rgb(245 250 244); + --range-thumb-shadow: 0 0 0 1px rgb(255 255 255 / 46%), 0 3px 8px rgb(0 0 0 / 28%); + --range-thumb-hover-shadow: + 0 0 0 1px rgb(255 255 255 / 56%), + 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent), + 0 4px 10px rgb(0 0 0 / 34%); + --range-thumb-hover-transform: scale(1.08); + --range-focus-outline-offset: -4px; + + position: relative; + display: grid; + align-items: center; + height: 44px; + flex: 1 1 auto; + min-width: 0; + padding-left: 3px; + cursor: ew-resize; + opacity: 0.96; + transition: opacity var(--transition-time); + + &.muted { + opacity: 0.56; + } + } + + > .volume-control input[type='range'] { + @include toolbar-range-input(); + } + } + + > .export-status { + flex: 0 1 140px; + min-height: 20px; + max-width: 140px; + overflow: hidden; + color: rgb(255 255 255 / 82%); + font-size: 13px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + + &:empty { + display: none; + } + } +} diff --git a/src/style/toolbar/_garden-controls.scss b/src/style/toolbar/_garden-controls.scss new file mode 100644 index 0000000..263dc50 --- /dev/null +++ b/src/style/toolbar/_garden-controls.scss @@ -0,0 +1,148 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row > .toolbar-shell > .garden-controls { + grid-area: swatches; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 4px; + + > .swatches { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 58px; + padding: 6px 10px; + + > .color-swatch { + position: relative; + width: 44px; + height: 44px; + border: 2px solid rgb(255 255 255 / 54%); + border-radius: 50%; + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 16%), + 0 3px 10px rgb(0 0 0 / 22%); + + &:hover { + transform: translateY(-2px); + } + + &.active { + outline: 2px solid rgb(255 255 255 / 96%); + outline-offset: 3px; + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 14%), + 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent), + 0 7px 18px rgb(0 0 0 / 26%); + } + } + + > .eraser-size-control, + > .mirror-segment-control { + --control-thumb-hover-transform: scale(1.03); + --control-thumb-radius: 50%; + --control-thumb-transform: none; + --range-progress: var(--control-progress); + --range-track-height: 7px; + --range-fill: rgb(var(--control-rgb) / 72%); + --range-empty: rgb(255 255 255 / 24%); + --range-track-shadow: inset 0 1px 2px rgb(0 0 0 / 24%); + --range-thumb-width: var(--control-thumb-width); + --range-thumb-height: var(--control-thumb-height); + --range-thumb-border: 2px solid rgb(255 255 255 / 92%); + --range-thumb-radius: var(--control-thumb-radius); + --range-thumb-background: var(--control-thumb-background); + --range-thumb-shadow: + inset 0 1px 2px rgb(255 255 255 / 22%), 0 4px 12px rgb(0 0 0 / 30%); + --range-thumb-hover-shadow: + inset 0 1px 2px rgb(255 255 255 / 22%), 0 0 0 4px rgb(var(--control-rgb) / 22%), + 0 5px 14px rgb(0 0 0 / 34%); + --range-thumb-hover-transform: var(--control-thumb-hover-transform); + --range-thumb-transform: var(--control-thumb-transform); + --range-thumb-transition: + box-shadow var(--transition-time), height var(--transition-time), + margin-top var(--transition-time), transform var(--transition-time), + width var(--transition-time); + + position: relative; + display: grid; + align-items: center; + width: 184px; + height: 46px; + flex: 0 0 184px; + padding: 0 12px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 8px; + background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 6%), + 0 3px 10px rgb(0 0 0 / 18%); + cursor: ew-resize; + transition: + border-color var(--transition-time), + background-color var(--transition-time), + box-shadow var(--transition-time), + transform var(--transition-time); + + &:hover { + border-color: rgb(255 255 255 / 24%); + transform: translateY(-2px); + } + + &.active { + border-color: rgb(var(--control-rgb) / 72%); + background-color: rgb(var(--control-rgb) / 11%); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 10%), + 0 0 0 5px rgb(var(--control-rgb) / 28%), + 0 6px 15px rgb(0 0 0 / 22%); + } + + input[type='range'] { + @include toolbar-range-input(); + } + } + + > .eraser-size-control { + --control-progress: var(--eraser-progress, 33%); + --control-rgb: 255 140 117; + --control-thumb-background: + linear-gradient( + 110deg, + transparent 0 12%, + rgb(255 255 255 / 44%) 13% 20%, + transparent 21% 100% + ), + linear-gradient( + 90deg, + #ff8fa3 0 52%, + rgb(54 46 51 / 78%) 53% 56%, + #f5eee5 57% 100% + ); + --control-thumb-height: calc(21px * var(--eraser-control-scale, 1)); + --control-thumb-hover-transform: rotate(-10deg) scale(1.03); + --control-thumb-radius: calc(6px * var(--eraser-control-scale, 1)); + --control-thumb-transform: rotate(-10deg); + --control-thumb-width: calc(34px * var(--eraser-control-scale, 1)); + } + + > .mirror-segment-control { + --control-progress: var(--mirror-progress, 0%); + --control-rgb: 148 233 203; + --control-thumb-background: + radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), + repeating-conic-gradient( + from -90deg, + rgb(218 255 241) 0 8deg, + rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg) + ); + --control-thumb-height: 44px; + --control-thumb-width: 44px; + } + } +} diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss new file mode 100644 index 0000000..8ee7737 --- /dev/null +++ b/src/style/toolbar/_layout.scss @@ -0,0 +1,137 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row { + --toolbar-background-opacity: 0%; + --toolbar-background-strength: 0; + --toolbar-divider-space: clamp(6px, 1.8vw, 14px); + --toolbar-top-max-width: 594px; + + display: grid; + grid-template-areas: + 'previous controls next' + 'previous divider next' + 'previous buttons next'; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: stretch; + justify-content: center; + width: 100%; + max-width: 100%; + margin: 0 auto; + padding-inline: clamp(8px, 1.4vw, 14px); + column-gap: 0; + row-gap: 0; + border-radius: 12px; + color: rgb(245 250 244 / 92%); + background-color: rgb(5 8 13 / var(--toolbar-background-opacity)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)), + inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)), + 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%)); + backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px)) + brightness(calc(1 - var(--toolbar-background-strength) * 0.38)) + saturate(calc(1 - var(--toolbar-background-strength) * 0.18)); + font-size: 13px; + font-weight: 400; + line-height: 1; + transition: + backdrop-filter var(--transition-time-long), + background-color var(--transition-time-long), + box-shadow var(--transition-time-long); + + &::after { + content: ''; + grid-area: divider; + align-self: center; + justify-self: center; + width: min(100%, var(--toolbar-top-max-width)); + height: 1px; + margin-block: var(--toolbar-divider-space); + background: rgb(255 255 255 / 12%); + } + + button { + min-width: 44px; + min-height: 44px; + border: 0; + font: inherit; + cursor: pointer; + @include toolbar-button-transition(); + + &:disabled { + cursor: progress; + opacity: 0.58; + } + + &:focus-visible { + outline: 2px solid white; + outline-offset: 2px; + } + } + + > .toolbar-shell { + grid-area: controls; + display: grid; + grid-template-areas: 'swatches'; + grid-template-columns: minmax(0, 1fr); + align-items: center; + justify-content: center; + justify-self: center; + width: min(100%, var(--toolbar-top-max-width)); + min-width: 0; + padding: 8px 9px; + } + + > .vibe-button { + position: relative; + display: grid; + place-items: center; + width: 52px; + height: auto; + min-height: 66px; + flex: 0 0 auto; + padding: 0; + border-radius: 0; + background: transparent; + color: rgb(255 255 255 / 70%); + font-size: 0; + line-height: 1; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + border-color: currentColor; + border-style: solid; + border-width: 0 0 3px 3px; + transform: translate(-35%, -50%) rotate(45deg); + } + + &.next-vibe::before { + border-width: 3px 3px 0 0; + transform: translate(-65%, -50%) rotate(45deg); + } + + &:hover { + color: color-mix(in srgb, var(--accent-color) 70%, white); + } + + &.previous-vibe:hover { + transform: translateX(-2px); + } + + &.next-vibe:hover { + transform: translateX(2px); + } + } + + > .previous-vibe { + grid-area: previous; + } + + > .next-vibe { + grid-area: next; + } +} diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss new file mode 100644 index 0000000..2e66635 --- /dev/null +++ b/src/style/toolbar/_responsive.scss @@ -0,0 +1,156 @@ +@use '../mixins' as *; + +html > body > aside.control-dock > .toolbar-row { + @include on-small-screen { + --toolbar-divider-space: 4px; + --toolbar-top-max-width: 329px; + + grid-template-areas: + 'previous controls next' + '. divider .' + 'buttons buttons buttons'; + width: 100%; + padding-inline: 4px; + column-gap: 0; + row-gap: 0; + + > .vibe-button { + width: 36px; + min-height: 44px; + + &::before { + width: 14px; + height: 14px; + } + } + + > .toolbar-shell { + padding: 4px; + } + + > nav.buttons { + justify-self: stretch; + justify-content: space-between; + gap: clamp(1px, 0.55vw, 2px); + width: auto; + max-width: none; + margin-inline: -4px; + + > button { + width: auto; + height: 38px; + flex: 1 1 clamp(28px, 8vw, 38px); + max-width: 38px; + min-height: 38px; + + &::after { + width: 17px; + height: 17px; + } + } + + > .audio-control { + width: auto; + height: 38px; + flex: 2 1 clamp(58px, 18vw, 118px); + max-width: 118px; + padding-right: clamp(4px, 1.8vw, 9px); + + > button { + width: auto; + flex: 1 1 clamp(28px, 8vw, 38px); + min-width: 0; + } + + > .volume-control { + height: 38px; + } + } + + > .export-status { + flex-basis: 0; + max-width: 0; + text-align: center; + } + } + + > .toolbar-shell > .garden-controls { + padding: 2px 4px; + + > .swatches { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + justify-items: center; + justify-content: stretch; + width: 100%; + min-width: 0; + min-height: 48px; + flex: 1 1 100%; + padding: 3px 5px; + column-gap: 6px; + row-gap: 6px; + + > .color-swatch { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + + grid-column: span 2; + } + + > .eraser-size-control, + > .mirror-segment-control { + justify-self: stretch; + width: 100%; + min-width: 0; + height: 38px; + padding: 0 7px; + } + + > .eraser-size-control { + grid-column: 1 / span 3; + } + + > .mirror-segment-control { + --control-thumb-height: 34px; + --control-thumb-width: 34px; + + grid-column: 4 / span 3; + } + } + } + } +} + +@media (prefers-reduced-motion: reduce) { + html > body > aside.control-dock > .toolbar-row { + > .vibe-button.previous-vibe:hover, + > .vibe-button.next-vibe:hover, + > .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover, + > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover, + > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover { + transform: none; + } + + > nav.buttons > button:hover::after, + > nav.buttons > .audio-control > button:hover::after { + transform: none; + } + + > nav.buttons > .audio-control > .volume-control input[type='range'] { + &::-webkit-slider-thumb:hover { + transform: none; + } + } + + > .toolbar-shell > .garden-controls > .swatches { + > .eraser-size-control input[type='range'], + > .mirror-segment-control input[type='range'] { + &::-webkit-slider-thumb:hover { + transform: var(--range-thumb-transform, none); + } + } + } + } +} diff --git a/src/style/toolbar/_shared.scss b/src/style/toolbar/_shared.scss new file mode 100644 index 0000000..ce4db3d --- /dev/null +++ b/src/style/toolbar/_shared.scss @@ -0,0 +1,105 @@ +$toolbar-icons: ( + info: 'info', + maximize-full-screen: 'maximize', + minimize-full-screen: 'minimize', + settings: 'settings', + sound: 'sound', + export-4k: 'download', + restart: 'restart', +); + +@mixin toolbar-button-transition() { + transition: + background-color var(--transition-time), + border-color var(--transition-time), + color var(--transition-time), + box-shadow var(--transition-time), + opacity var(--transition-time), + transform var(--transition-time); +} + +@mixin toolbar-control-surface($background, $hover-background) { + border: 1px solid transparent; + border-radius: 8px; + background: $background; + transition: + border-color var(--transition-time), + background-color var(--transition-time), + box-shadow var(--transition-time), + opacity var(--transition-time); + + &:hover { + border-color: rgb(255 255 255 / 10%); + background: $hover-background; + } +} + +@mixin toolbar-range-track() { + height: var(--range-track-height); + border: var(--range-track-border, 0); + border-radius: 999px; + background: linear-gradient( + 90deg, + var(--range-fill) 0 var(--range-progress), + var(--range-empty) var(--range-progress) 100% + ); + box-shadow: var(--range-track-shadow); + cursor: ew-resize; +} + +@mixin toolbar-range-thumb() { + width: var(--range-thumb-width); + height: var(--range-thumb-height); + border: var(--range-thumb-border); + border-radius: var(--range-thumb-radius); + background: var(--range-thumb-background); + box-shadow: var(--range-thumb-shadow); + cursor: ew-resize; + transform: var(--range-thumb-transform, none); +} + +@mixin toolbar-range-input() { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + appearance: none; + background: transparent; + cursor: ew-resize; + outline: none; + touch-action: pan-y; + + &:focus-visible { + border-radius: 8px; + outline: 2px solid white; + outline-offset: var(--range-focus-outline-offset, 2px); + } + + &::-webkit-slider-runnable-track { + @include toolbar-range-track(); + } + + &::-webkit-slider-thumb { + @include toolbar-range-thumb(); + margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) / 2); + appearance: none; + transition: var( + --range-thumb-transition, + box-shadow var(--transition-time), + transform var(--transition-time) + ); + } + + &::-webkit-slider-thumb:hover { + box-shadow: var(--range-thumb-hover-shadow, var(--range-thumb-shadow)); + transform: var(--range-thumb-hover-transform, var(--range-thumb-transform, none)); + } + + &::-moz-range-track { + @include toolbar-range-track(); + } + + &::-moz-range-thumb { + @include toolbar-range-thumb(); + } +} diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts index 200a7f6..759f04f 100644 --- a/src/utils/delta-time-calculator.ts +++ b/src/utils/delta-time-calculator.ts @@ -4,10 +4,7 @@ import { clamp } from './math'; export class DeltaTimeCalculator { private previousTime: DOMHighResTimeStamp | null = null; - constructor( - private readonly maxDeltaTimeInSeconds?: number, - private readonly minDeltaTimeInSeconds?: number - ) { + constructor() { document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); } @@ -20,7 +17,11 @@ export class DeltaTimeCalculator { const delta = currentTime - this.previousTime; this.previousTime = currentTime; - return clamp(delta / 1000, this.minDeltaTime, this.maxDeltaTime); + return clamp( + delta / 1000, + appConfig.deltaTime.minDeltaTimeSeconds, + appConfig.deltaTime.maxDeltaTimeSeconds + ); } private handleVisibilityChange() { @@ -28,12 +29,4 @@ export class DeltaTimeCalculator { this.previousTime = null; } } - - private get maxDeltaTime(): number { - return this.maxDeltaTimeInSeconds ?? appConfig.deltaTime.maxDeltaTimeSeconds; - } - - private get minDeltaTime(): number { - return this.minDeltaTimeInSeconds ?? appConfig.deltaTime.minDeltaTimeSeconds; - } } diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts index d4fe444..28ecb84 100644 --- a/src/utils/graphics/bind-group-cache.ts +++ b/src/utils/graphics/bind-group-cache.ts @@ -17,3 +17,32 @@ export const createBindGroupCache = ( return bindGroup; }; }; + +export const createBindGroupCache3 = < + K1 extends object, + K2 extends object, + K3 extends object, +>( + factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup +): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => { + const outer = new WeakMap>>(); + return (key1, key2, key3) => { + let mid = outer.get(key1); + if (!mid) { + mid = new WeakMap(); + outer.set(key1, mid); + } + let inner = mid.get(key2); + if (!inner) { + inner = new WeakMap(); + mid.set(key2, inner); + } + const cached = inner.get(key3); + if (cached) { + return cached; + } + const bindGroup = factory(key1, key2, key3); + inner.set(key3, bindGroup); + return bindGroup; + }; +}; diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 48df957..9108060 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -94,15 +94,21 @@ export const initializeGpu = async (): Promise => { } const requiredLimits = getRequiredLimits(adapter.limits); + const requiredFeatures: Array = []; + if (adapter.features.has('timestamp-query')) { + requiredFeatures.push('timestamp-query'); + } ErrorHandler.addMetadata('webgpuAdapter', { features: Array.from(adapter.features).sort(), info: getAdapterInfo(adapter), + requiredFeatures, requiredLimits, }); let gpuDevice: GPUDevice; try { gpuDevice = await adapter.requestDevice({ + requiredFeatures, requiredLimits, }); } catch (error) { @@ -113,6 +119,7 @@ export const initializeGpu = async (): Promise => { cause: error, details: { causeMessage: getErrorMessage(error), + requiredFeatures, requiredLimits, }, } diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index d975edc..bd6ef19 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -6,6 +6,14 @@ interface ResizableTextureOptions { usage?: GPUTextureUsageFlags; } +export interface PendingTextureResize { + copySize: GPUExtent3DStrict; + newSize: vec2; + newTexture: GPUTexture; + newTextureView: GPUTextureView; + oldTexture: GPUTexture; +} + export class ResizableTexture { private texture: GPUTexture; private textureView: GPUTextureView; @@ -32,10 +40,22 @@ export class ResizableTexture { } public resize(size: vec2): void { - if (vec2.equals(this.size, size)) { + const resize = this.prepareResize(size); + if (!resize) { return; } + const commandEncoder = this.device.createCommandEncoder(); + this.encodeResize(commandEncoder, resize); + this.device.queue.submit([commandEncoder.finish()]); + this.commitResize(resize); + } + + public prepareResize(size: vec2): PendingTextureResize | null { + if (vec2.equals(this.size, size)) { + return null; + } + const newTexture = this.createTexture(size); const newTextureView = newTexture.createView(); const copySize = { @@ -43,11 +63,23 @@ export class ResizableTexture { height: Math.min(this.size[1], size[1]), }; - const commandEncoder = this.device.createCommandEncoder(); + return { + copySize, + newSize: vec2.clone(size), + newTexture, + newTextureView, + oldTexture: this.texture, + }; + } + + public encodeResize( + commandEncoder: GPUCommandEncoder, + resize: PendingTextureResize + ): void { const clearPass = commandEncoder.beginRenderPass({ colorAttachments: [ { - view: newTextureView, + view: resize.newTextureView, clearValue: this.clearValue, loadOp: 'clear', storeOp: 'store', @@ -56,16 +88,17 @@ export class ResizableTexture { }); clearPass.end(); commandEncoder.copyTextureToTexture( - { texture: this.texture }, - { texture: newTexture }, - copySize + { texture: resize.oldTexture }, + { texture: resize.newTexture }, + resize.copySize ); - this.device.queue.submit([commandEncoder.finish()]); - this.texture.destroy(); + } - this.size = vec2.clone(size); - this.texture = newTexture; - this.textureView = newTextureView; + public commitResize(resize: PendingTextureResize): void { + resize.oldTexture.destroy(); + this.size = resize.newSize; + this.texture = resize.newTexture; + this.textureView = resize.newTextureView; } public getSize(): vec2 { diff --git a/vite.config.ts b/vite.config.ts index cfe208b..6c4b293 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,10 @@ const esbuildTargets = browserslistToEsbuild(); export default defineConfig(({ command }) => ({ base: './', plugins: [ - viteSingleFile({ useRecommendedBuildConfig: false }), + viteSingleFile({ + inlinePattern: ['index-*.js', 'style-*.css'], + useRecommendedBuildConfig: false, + }), ...(command === 'serve' ? [basicSsl()] : []), ], css: {