diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..964add54 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -4,12 +4,14 @@ on: pull_request: types: ['opened', 'synchronize'] +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build-pull-request: name: Build pull request runs-on: ubuntu-latest - env: - PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -24,17 +26,3 @@ jobs: env: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: preview - path: dist - retention-days: 1 - - name: Save pr number - run: echo ${PR_NUMBER} > ./pr.txt - - name: Upload pr number - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr - path: ./pr.txt - retention-days: 1 diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 997aba6d..24b9389c 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -6,6 +6,10 @@ on: - 'Dockerfile' - '.github/workflows/docker-pr.yml' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: docker-build: runs-on: ubuntu-latest diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index a9cc1a34..e96d2818 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -5,6 +5,10 @@ on: paths: - 'package-lock.json' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: lockfile_changes: runs-on: ubuntu-latest diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml new file mode 100644 index 00000000..47f22b7e --- /dev/null +++ b/.github/workflows/rat-dev-preview.yml @@ -0,0 +1,46 @@ +name: Rat dev preview + +on: + push: + branches: ['dev'] + +# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run +# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other +# groups; they never cancel it. +concurrency: + group: rat-dev-preview + cancel-in-progress: true + +jobs: + build: + name: Build dev preview + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".node-version" + package-manager-cache: false + - name: Install dependencies + run: npm ci + - name: Build app + env: + CINNY_BASE: /void/rat-dev/ + VITE_PREVIEW_DEMO: '1' + NODE_OPTIONS: '--max_old_space_size=3072' + run: npm run build + - name: Set background scene tier and the pretend homeserver + run: node scripts/preview-config.js low --demo + - name: Add the demo pose art + run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/ + - name: Upload artifact + # v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath + # (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169). + uses: forgejo/upload-artifact@v3 + with: + name: rat-dev-dist + path: dist + retention-days: 3 diff --git a/.gitignore b/.gitignore index 1af58a97..4e7d929d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea +# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed. +public/poses/* +!public/poses/.gitkeep diff --git a/.prettierignore b/.prettierignore index 036ee8bc..c81e668b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ package.json package-lock.json LICENSE harness +src/app/components/message/pose/catalogue.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859eab14..2f2ce386 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to Cinny +# Contributing to Ratatoskr First off, thanks for taking the time to contribute! ❤️ -This document describes the process of contributing to Cinny. It is intended +This document describes the process of contributing to Ratatoskr. It is intended for anyone considering opening an **issue**, **discussion** or **pull request**. For people who are interested in developing Cinny and technical details behind it, please check out our ["Developing Cinny"](HACKING.md) document as well. @@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n ## AI Usage -The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** +The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** ## Quick Guide @@ -35,7 +35,7 @@ The Cinny project stictly does not allow any AI generated code. Any pull request > When contributing to this project, you must agree that you have authored 100% > of the content, that you have the necessary rights to the content and that > the content you contribute may be provided under the project license. You will -> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request. +> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request. [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. diff --git a/HACKING.md b/HACKING.md index 30c4960f..bb6d3c5d 100644 --- a/HACKING.md +++ b/HACKING.md @@ -1,4 +1,4 @@ -# Developing Cinny +# Developing Ratatoskr > [!TIP] > We recommend using a version manager as versions change very quickly. @@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can build the container like so: ``` -docker build -t cinny:latest . +docker build -t ratatoskr:latest . ``` You can then run the container you've built with a command similar to this: ``` -docker run -p 8080:80 cinny:latest +docker run -p 8080:80 ratatoskr:latest ``` This will forward your `localhost` port 8080 to the container's port 80. diff --git a/README.md b/README.md index 46c7a67b..dfc722ad 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,43 @@ -# Cinny -

- - GitHub release downloads - - DockerHub downloads - - Follow on Mastodon - - Follow on Twitter - - Sponsor Cinny -

+# Ratatoskr -A Matrix client focusing primarily on simple, elegant and secure interface. -The main goal is to have an instant messaging application that is easy on -people and has a modern touch. -- [Roadmap](https://github.com/orgs/cinnyapp/projects/1) -- [Contributing](./CONTRIBUTING.md) +GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). +It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, +and your own place in it) instead of an invite list. Mobile first. -> [!IMPORTANT] -We are currently in the process of [replacing] the matrix-js-sdk with our -own SDK. As a result, we will not be accepting any pull requests until -further notice. Thank you for your understanding. +**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a +Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits +(opt-in setting), a background scene and soundscape (opt-in, off by default). +**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) +for the target and cut sequence. Internal names that are protocol or stored +state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event +type, `CINNY_BASE`) are unchanged on purpose. -[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704 +Entry points: `src/app/` (client UI), `src/app/features/scene/` (background +scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate +drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons). - +## Upstream +Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0. +Cinny's own README, desktop app and releases live at +. We do not ship its releases or images. -## Getting started -The web app is available at [app.cinny.in] and gets updated on each new -release. The `dev` branch is continuously deployed at [dev.cinny.in] -but keep in mind that it could have things broken. - -You can also download our desktop app from the [cinny-desktop repository]. - -[app.cinny.in]: https://app.cinny.in -[dev.cinny.in]: https://dev.cinny.in -[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop - -## Contributing and Developing - -If you have any ideas, issues, etc. regarding Cinny, or would like to -contribute to Cinny through pull requests, please check out our -["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like -to get involved with Cinny's development as well should also read the -["Developing Cinny"](HACKING.md) document for more technical details. +## Developing +See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md). ## Self-hosting -To host Cinny on your own, simply download the tarball from -[GitHub releases], and serve the files from `dist/` using your preferred -webserver. Alternatively, you can just pull the docker image from -[DockerHub] or [GitHub Container Registry]. +Build with `npm ci && npm run build` and serve `dist/` with your webserver. * The default homeservers and explore pages are defined in [`config.json`](config.json). - -* You need to set up redirects to serve the assests. Example configurations; -[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), -[caddy](contrib/caddy/caddyfile). - * If you have trouble configuring redirects you can - [enable hash routing](config.json#L35) — the url in the browser will have - a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` - instead of `app.cinny.in/home/`) but you won't have to configure your webserver. - -* To deploy on subdirectory, you need to rebuild the app youself after -updating the `base` path in [`build.config.ts`](build.config.ts). - * For example, if you want to deploy on `https://cinny.in/app`, then - set `base: '/app'`. - -[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest -[DockerHub]: https://hub.docker.com/r/ajbura/cinny -[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny - -
PGP Public Key to verify tarball - -``` ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj -6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY -FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE -K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0 -leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU -Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb -Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm -jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG -4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J -AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+ -vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK -YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj -AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5 -Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs -K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf -atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8 -AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c -SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P -3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM -ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+ -5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0 -Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq -QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul -s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH -vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c -LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx -H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx -NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF -CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1 -XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2 -KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB -gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo -N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V -M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj -n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa -Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5 -mxFo+ioe/ABCufSmyqFye0psX3Sp -=WtqZ ------END PGP PUBLIC KEY BLOCK----- -``` -
+* Serving the assets needs redirects to `index.html`. Examples: +[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf), +[caddy](contrib/caddy/caddyfile). Without them you can +[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`. +* To deploy on a subdirectory, rebuild with the `base` path set, either in +[`build.config.ts`](build.config.ts) or through `CINNY_BASE` +(for example `CINNY_BASE=/app/ npm run build`). ## License -Cinny Project +Ratatoskr is a fork of the Cinny project Copyright © 2024–present Ajay Bura https://cinny.in diff --git a/config.json b/config.json index d1c461a4..dd4c887b 100644 --- a/config.json +++ b/config.json @@ -2,16 +2,15 @@ "defaultHomeserver": 1, "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, - "featuredCommunities": { "openAsDefault": false, "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", - "#freesoftware:matrix.org", + "#free-software-movement:matrix.org", "#gentoo:matrix.org" ], - "servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] + "servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] }, "hashRouter": { diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile index d23dcccb..a686b16b 100644 --- a/contrib/caddy/caddyfile +++ b/contrib/caddy/caddyfile @@ -1,6 +1,6 @@ # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas -cinny.domain.tld { - root * /path/to/cinny/dist +ratatoskr.domain.tld { + root * /path/to/ratatoskr/dist try_files {path} / index.html file_server } diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/ratatoskr.domain.tld.conf similarity index 85% rename from contrib/nginx/cinny.domain.tld.conf rename to contrib/nginx/ratatoskr.domain.tld.conf index 02c7ead9..7df64900 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/ratatoskr.domain.tld.conf @@ -1,7 +1,7 @@ server { listen 80; listen [::]:80; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { return 301 https://$host$request_uri; @@ -15,10 +15,10 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { - root /opt/cinny/dist/; + root /opt/ratatoskr/dist/; rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/docs/file-zone.md b/docs/file-zone.md new file mode 100644 index 00000000..01b5a4c5 --- /dev/null +++ b/docs/file-zone.md @@ -0,0 +1,106 @@ +# File zone: design sketch + +Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body. + +Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`. + +## What it is + +A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change. + +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around. + +## Matrix event shapes (stock events only) + +- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest). +- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root. +- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: , is_falling_back: true, "m.in_reply_to": { event_id: } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact. +- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. +- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. + +## Marks (reactions, plus an optional reply) + +A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply: + +- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: , key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it. +- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither. +- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it. +- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue. +- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file. +- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason. +- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default. + +## Export + +Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. + +`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: + +``` +{ schema: "aqua.listen.verdict.v1", + kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body + exportedAt, // ISO time of export + seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN) + listener, // sender display name + device, // headphones|speakers|null, the sender's most-used mark device, or null + notes, // free text; zone leaves "" in v1 + summary: { total, keep, fix, drop, unanswered }, + clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }], + pairs: [{ a, b, preferred }] } // empty array when the album has no pairs +``` + +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog. +- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. +- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. +- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. +- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. +- JSON here is a xenos-boundary export, not state; the room events are the state. + +## Mobile-first layout + +- Phone width is the design width; desktop adds columns. +- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. +- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. +- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal. +- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. + +## Upload + +Reuse the composer's pipeline, pointed at an album instead of the main timeline: + +- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. +- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. +- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. + +## Existing code to reuse (verified present in the repo) + +| need | where | +|---|---| +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | + +Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). + +## First slice (what to build first) + +Rough lines of TypeScript, tests excluded. + +1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. +2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". + +Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. + +## Open questions for the Meta + +- How the client identifies the zone room (config id recommended). +- Whether exports are per listener (proposed) or a merged all-listeners file. +- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..624d4b0b 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -6,6 +6,7 @@ Chromium. Not shipped, not run by CI. node bundle.cjs # esbuild bundle of entry.ts plus an index.html node drive.cjs compile # compile and link each stage, JSON report node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node gradAxis.cjs # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 027a2006..31accf37 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -1,10 +1,12 @@ // Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. +// usage: node bundle.cjs [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march +// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout). const esbuild = require('esbuild'); const fs = require('fs'); const path = require('path'); const out = process.argv[2]; const demo = process.argv[3] === 'demo'; +const ridge = process.argv[3] === 'ridge'; fs.mkdirSync(out, { recursive: true }); const rawPlugin = { name: 'raw', @@ -21,7 +23,7 @@ const rawPlugin = { }; esbuild .build({ - entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], + entryPoints: [path.join(__dirname, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')], bundle: true, outfile: path.join(out, 'bundle.js'), plugins: [rawPlugin], @@ -29,6 +31,13 @@ esbuild target: 'es2020', }) .then(() => { + if (ridge) { + fs.writeFileSync( + path.join(out, 'index.html'), + 'Ratatoskr ridge scene
' + ); + return; + } fs.writeFileSync( path.join(out, 'index.html'), 'Ratatoskr scene: trace prepass' + diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs new file mode 100644 index 00000000..48b96fa6 --- /dev/null +++ b/harness/scene/gradAxis.cjs @@ -0,0 +1,30 @@ +// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), +// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. +// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders +const { chromium } = require('playwright'); const fs=require('fs'); +const S=process.argv[2]; +const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); +const fs_=`#version 300 es +precision highp float; precision highp int; +${cm} +${ph} +out vec4 o; +const float F=1.0/520.0; +float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} +float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} +void main(){ + vec2 p=vec2(1234.5,-777.7); float e=0.5; + int k=int(gl_FragCoord.x); + if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z + else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} + else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} + else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} +}`; +(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); +const pg=await b.newPage(); +const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); +const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; +const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); +const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); +const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); +console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts new file mode 100644 index 00000000..931a1ad4 --- /dev/null +++ b/harness/scene/ridgeDemo.ts @@ -0,0 +1,12 @@ +// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the +// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped. +import { startBackgroundScene } from '../../src/app/features/scene'; +import type { SceneTier } from '../../src/app/features/scene'; + +const q = new URLSearchParams(location.search); +const tier = (q.get('tier') ?? 'full') as SceneTier; +const canvas = document.getElementById('c') as HTMLCanvasElement; +startBackgroundScene({ tier }, {}, { canvas }).then((scene) => { + (window as unknown as { scene: unknown }).scene = scene; + document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)'; +}); diff --git a/index.html b/index.html index 7242f309..2585704c 100644 --- a/index.html +++ b/index.html @@ -19,11 +19,11 @@ property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." /> - + - + diff --git a/package-lock.json b/package-lock.json index dc774000..1915ec32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", "@fontsource/montserrat": "5.2.5", @@ -85,6 +86,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -2192,6 +2194,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource-variable/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", @@ -5085,6 +5096,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", + "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", diff --git a/package.json b/package.json index 298fbf36..69f589c6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", @@ -10,6 +10,7 @@ "scripts": { "start": "vite", "build": "vite build", + "build:preview": "vite build && node scripts/preview-config.js low", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", "check:eslint": "eslint src/*", @@ -19,7 +20,9 @@ "prepare": "husky install", "commit": "git-cz", "bump": "node scripts/update-version.js", - "test": "vitest run" + "bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts", + "test": "vitest run", + "sync:poses": "node scripts/sync-poses.mjs" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -37,7 +40,11 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -88,10 +95,7 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35", - "@fontsource/ubuntu": "5.2.5", - "@fontsource/montserrat": "5.2.5", - "@fontsource/ibm-plex-mono": "5.2.5" + "ua-parser-js": "1.0.35" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -110,6 +114,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", diff --git a/preview-demo/poses/README.md b/preview-demo/poses/README.md new file mode 100644 index 00000000..a1287f2d --- /dev/null +++ b/preview-demo/poses/README.md @@ -0,0 +1,13 @@ +# Demo pose thumbnails + +These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own +Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note: +the image model that made the art and the terms it was made under are not recorded. + +They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits. +The dev workflow copies them into `dist/public/poses`; real builds sync the full art with +`npm run sync:poses` and do not use this folder. + +Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose +art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken +images; nothing else depends on them. diff --git a/preview-demo/poses/eureka/amused.png b/preview-demo/poses/eureka/amused.png new file mode 100644 index 00000000..eff70ffe Binary files /dev/null and b/preview-demo/poses/eureka/amused.png differ diff --git a/preview-demo/poses/eureka/annoyed.png b/preview-demo/poses/eureka/annoyed.png new file mode 100644 index 00000000..312a66c8 Binary files /dev/null and b/preview-demo/poses/eureka/annoyed.png differ diff --git a/preview-demo/poses/eureka/concerned.png b/preview-demo/poses/eureka/concerned.png new file mode 100644 index 00000000..f3486432 Binary files /dev/null and b/preview-demo/poses/eureka/concerned.png differ diff --git a/preview-demo/poses/eureka/determined.png b/preview-demo/poses/eureka/determined.png new file mode 100644 index 00000000..59771a80 Binary files /dev/null and b/preview-demo/poses/eureka/determined.png differ diff --git a/preview-demo/poses/eureka/happy.png b/preview-demo/poses/eureka/happy.png new file mode 100644 index 00000000..ab4a5752 Binary files /dev/null and b/preview-demo/poses/eureka/happy.png differ diff --git a/preview-demo/poses/eureka/hood-smug.png b/preview-demo/poses/eureka/hood-smug.png new file mode 100644 index 00000000..2545a64e Binary files /dev/null and b/preview-demo/poses/eureka/hood-smug.png differ diff --git a/preview-demo/poses/eureka/inspect.png b/preview-demo/poses/eureka/inspect.png new file mode 100644 index 00000000..44786969 Binary files /dev/null and b/preview-demo/poses/eureka/inspect.png differ diff --git a/preview-demo/poses/eureka/neutral.png b/preview-demo/poses/eureka/neutral.png new file mode 100644 index 00000000..f27fb141 Binary files /dev/null and b/preview-demo/poses/eureka/neutral.png differ diff --git a/preview-demo/poses/eureka/probe.png b/preview-demo/poses/eureka/probe.png new file mode 100644 index 00000000..0a9c3c7b Binary files /dev/null and b/preview-demo/poses/eureka/probe.png differ diff --git a/preview-demo/poses/eureka/report.png b/preview-demo/poses/eureka/report.png new file mode 100644 index 00000000..5a8a6e5c Binary files /dev/null and b/preview-demo/poses/eureka/report.png differ diff --git a/preview-demo/poses/eureka/surprised.png b/preview-demo/poses/eureka/surprised.png new file mode 100644 index 00000000..34305ef9 Binary files /dev/null and b/preview-demo/poses/eureka/surprised.png differ diff --git a/preview-demo/poses/eureka/thinking.png b/preview-demo/poses/eureka/thinking.png new file mode 100644 index 00000000..31a4e917 Binary files /dev/null and b/preview-demo/poses/eureka/thinking.png differ diff --git a/preview-demo/poses/nibu/amused.png b/preview-demo/poses/nibu/amused.png new file mode 100644 index 00000000..a3e02a9d Binary files /dev/null and b/preview-demo/poses/nibu/amused.png differ diff --git a/preview-demo/poses/nibu/annoyed.png b/preview-demo/poses/nibu/annoyed.png new file mode 100644 index 00000000..e962e628 Binary files /dev/null and b/preview-demo/poses/nibu/annoyed.png differ diff --git a/preview-demo/poses/nibu/console.png b/preview-demo/poses/nibu/console.png new file mode 100644 index 00000000..20a7fef4 Binary files /dev/null and b/preview-demo/poses/nibu/console.png differ diff --git a/preview-demo/poses/nibu/glitch.png b/preview-demo/poses/nibu/glitch.png new file mode 100644 index 00000000..315acd52 Binary files /dev/null and b/preview-demo/poses/nibu/glitch.png differ diff --git a/preview-demo/poses/nibu/happy.png b/preview-demo/poses/nibu/happy.png new file mode 100644 index 00000000..4f4ed096 Binary files /dev/null and b/preview-demo/poses/nibu/happy.png differ diff --git a/preview-demo/poses/nibu/neutral.png b/preview-demo/poses/nibu/neutral.png new file mode 100644 index 00000000..e3ada05d Binary files /dev/null and b/preview-demo/poses/nibu/neutral.png differ diff --git a/preview-demo/poses/nibu/pout.png b/preview-demo/poses/nibu/pout.png new file mode 100644 index 00000000..796449b4 Binary files /dev/null and b/preview-demo/poses/nibu/pout.png differ diff --git a/preview-demo/poses/nibu/resolute.png b/preview-demo/poses/nibu/resolute.png new file mode 100644 index 00000000..bab06adf Binary files /dev/null and b/preview-demo/poses/nibu/resolute.png differ diff --git a/preview-demo/poses/nibu/sheepish.png b/preview-demo/poses/nibu/sheepish.png new file mode 100644 index 00000000..f9ec933f Binary files /dev/null and b/preview-demo/poses/nibu/sheepish.png differ diff --git a/preview-demo/poses/nibu/surprised.png b/preview-demo/poses/nibu/surprised.png new file mode 100644 index 00000000..4fa62657 Binary files /dev/null and b/preview-demo/poses/nibu/surprised.png differ diff --git a/preview-demo/poses/nibu/thinking.png b/preview-demo/poses/nibu/thinking.png new file mode 100644 index 00000000..b02967ed Binary files /dev/null and b/preview-demo/poses/nibu/thinking.png differ diff --git a/preview-demo/poses/nibu/triumph.png b/preview-demo/poses/nibu/triumph.png new file mode 100644 index 00000000..31994236 Binary files /dev/null and b/preview-demo/poses/nibu/triumph.png differ diff --git a/preview-demo/poses/void/amused.png b/preview-demo/poses/void/amused.png new file mode 100644 index 00000000..a8332ed2 Binary files /dev/null and b/preview-demo/poses/void/amused.png differ diff --git a/preview-demo/poses/void/annoyed.png b/preview-demo/poses/void/annoyed.png new file mode 100644 index 00000000..4dc43e7a Binary files /dev/null and b/preview-demo/poses/void/annoyed.png differ diff --git a/preview-demo/poses/void/deadpan.png b/preview-demo/poses/void/deadpan.png new file mode 100644 index 00000000..d65dcb3e Binary files /dev/null and b/preview-demo/poses/void/deadpan.png differ diff --git a/preview-demo/poses/void/determined.png b/preview-demo/poses/void/determined.png new file mode 100644 index 00000000..f095d292 Binary files /dev/null and b/preview-demo/poses/void/determined.png differ diff --git a/preview-demo/poses/void/glare.png b/preview-demo/poses/void/glare.png new file mode 100644 index 00000000..44fcc81f Binary files /dev/null and b/preview-demo/poses/void/glare.png differ diff --git a/preview-demo/poses/void/happy.png b/preview-demo/poses/void/happy.png new file mode 100644 index 00000000..ca6027de Binary files /dev/null and b/preview-demo/poses/void/happy.png differ diff --git a/preview-demo/poses/void/neutral.png b/preview-demo/poses/void/neutral.png new file mode 100644 index 00000000..6e2a286d Binary files /dev/null and b/preview-demo/poses/void/neutral.png differ diff --git a/preview-demo/poses/void/point.png b/preview-demo/poses/void/point.png new file mode 100644 index 00000000..fae15ebf Binary files /dev/null and b/preview-demo/poses/void/point.png differ diff --git a/preview-demo/poses/void/reading.png b/preview-demo/poses/void/reading.png new file mode 100644 index 00000000..3fff2cd8 Binary files /dev/null and b/preview-demo/poses/void/reading.png differ diff --git a/preview-demo/poses/void/shrug.png b/preview-demo/poses/void/shrug.png new file mode 100644 index 00000000..aa4aee5d Binary files /dev/null and b/preview-demo/poses/void/shrug.png differ diff --git a/preview-demo/poses/void/surprised.png b/preview-demo/poses/void/surprised.png new file mode 100644 index 00000000..73816f29 Binary files /dev/null and b/preview-demo/poses/void/surprised.png differ diff --git a/preview-demo/poses/void/thinking.png b/preview-demo/poses/void/thinking.png new file mode 100644 index 00000000..ab93f2cc Binary files /dev/null and b/preview-demo/poses/void/thinking.png differ diff --git a/public/favicon.ico b/public/favicon.ico index cfce9943..bda9855c 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/manifest.json b/public/manifest.json index 81fd714c..ef0a3510 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,8 +7,8 @@ "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#fff", - "theme_color": "#fff", + "background_color": "#07111a", + "theme_color": "#07111a", "icons": [ { "src": "./public/android/android-chrome-36x36.png", diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index d76ed356..2dfde532 100644 Binary files a/public/res/android/android-chrome-144x144.png and b/public/res/android/android-chrome-144x144.png differ diff --git a/public/res/android/android-chrome-192x192.png b/public/res/android/android-chrome-192x192.png index 7689b6d0..d696f20f 100644 Binary files a/public/res/android/android-chrome-192x192.png and b/public/res/android/android-chrome-192x192.png differ diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 2f61f46b..07e5cbde 100644 Binary files a/public/res/android/android-chrome-256x256.png and b/public/res/android/android-chrome-256x256.png differ diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 91b26af2..3e1babf7 100644 Binary files a/public/res/android/android-chrome-36x36.png and b/public/res/android/android-chrome-36x36.png differ diff --git a/public/res/android/android-chrome-384x384.png b/public/res/android/android-chrome-384x384.png index ab06a074..61e2afe4 100644 Binary files a/public/res/android/android-chrome-384x384.png and b/public/res/android/android-chrome-384x384.png differ diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index f8fafcd9..a5ba15e6 100644 Binary files a/public/res/android/android-chrome-48x48.png and b/public/res/android/android-chrome-48x48.png differ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index 2c584f3d..e979125e 100644 Binary files a/public/res/android/android-chrome-512x512.png and b/public/res/android/android-chrome-512x512.png differ diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 32d5d516..7aa6caab 100644 Binary files a/public/res/android/android-chrome-72x72.png and b/public/res/android/android-chrome-72x72.png differ diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 5da68e4b..5ab44fe9 100644 Binary files a/public/res/android/android-chrome-96x96.png and b/public/res/android/android-chrome-96x96.png differ diff --git a/public/res/apple/apple-touch-icon-114x114.png b/public/res/apple/apple-touch-icon-114x114.png index 1ff43a12..3a08841c 100644 Binary files a/public/res/apple/apple-touch-icon-114x114.png and b/public/res/apple/apple-touch-icon-114x114.png differ diff --git a/public/res/apple/apple-touch-icon-120x120.png b/public/res/apple/apple-touch-icon-120x120.png index d10efb31..8443c98f 100644 Binary files a/public/res/apple/apple-touch-icon-120x120.png and b/public/res/apple/apple-touch-icon-120x120.png differ diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index d76ed356..2dfde532 100644 Binary files a/public/res/apple/apple-touch-icon-144x144.png and b/public/res/apple/apple-touch-icon-144x144.png differ diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 76590ddf..8ab7ad50 100644 Binary files a/public/res/apple/apple-touch-icon-152x152.png and b/public/res/apple/apple-touch-icon-152x152.png differ diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index 3f26f91b..963c024d 100644 Binary files a/public/res/apple/apple-touch-icon-167x167.png and b/public/res/apple/apple-touch-icon-167x167.png differ diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index ed498370..631741a0 100644 Binary files a/public/res/apple/apple-touch-icon-180x180.png and b/public/res/apple/apple-touch-icon-180x180.png differ diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index 2e2081f5..5cad75b2 100644 Binary files a/public/res/apple/apple-touch-icon-57x57.png and b/public/res/apple/apple-touch-icon-57x57.png differ diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 87c8fc2b..6303a2eb 100644 Binary files a/public/res/apple/apple-touch-icon-60x60.png and b/public/res/apple/apple-touch-icon-60x60.png differ diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 32d5d516..7aa6caab 100644 Binary files a/public/res/apple/apple-touch-icon-72x72.png and b/public/res/apple/apple-touch-icon-72x72.png differ diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 1164b72d..1448098d 100644 Binary files a/public/res/apple/apple-touch-icon-76x76.png and b/public/res/apple/apple-touch-icon-76x76.png differ diff --git a/public/res/svg/ratatoskr-favicon-highlight.svg b/public/res/svg/ratatoskr-favicon-highlight.svg new file mode 100644 index 00000000..f9a95e82 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-highlight.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon-unread.svg b/public/res/svg/ratatoskr-favicon-unread.svg new file mode 100644 index 00000000..f5bcb7d8 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-unread.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon.svg b/public/res/svg/ratatoskr-favicon.svg new file mode 100644 index 00000000..0c7cc393 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg deleted file mode 100644 index 661442d4..00000000 --- a/public/res/svg/ratatoskr-highlight.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg deleted file mode 100644 index 69a08c95..00000000 --- a/public/res/svg/ratatoskr-unread.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg index 3ca98122..1092ffc4 100644 --- a/public/res/svg/ratatoskr.svg +++ b/public/res/svg/ratatoskr.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs new file mode 100644 index 00000000..827110c2 --- /dev/null +++ b/scripts/bake-sky.mjs @@ -0,0 +1,19 @@ +// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the +// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. +// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. +import { writeFileSync } from 'node:fs'; +import { bakeSky } from '../src/sky/skyTokens.js'; +import { bakeScatter } from '../src/sky/scatter.js'; + +const out = new URL('../src/sky/baked.ts', import.meta.url); +const baked = { + midnight: bakeSky('midnight'), + noonday: bakeSky('noonday'), + scatter: bakeScatter(), +}; +writeFileSync( + out, + `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + + `/* eslint-disable */\n` + + `export const baked = ${JSON.stringify(baked, null, 2)} as const;\n` +); diff --git a/scripts/make-pose-thumbs.mjs b/scripts/make-pose-thumbs.mjs new file mode 100644 index 00000000..f78fc19e --- /dev/null +++ b/scripts/make-pose-thumbs.mjs @@ -0,0 +1,203 @@ +// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the +// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box). +// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a +// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src] +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateSync, deflateSync, crc32 } from 'node:zlib'; + +const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'preview-demo/poses'); +const demoMinds = ['eureka', 'void', 'nibu']; + +function decode(buf) { + let off = 8; + let w, h; + const idat = []; + while (off < buf.length) { + const len = buf.readUInt32BE(off); + const type = buf.toString('latin1', off + 4, off + 8); + const data = buf.subarray(off + 8, off + 8 + len); + if (type === 'IHDR') { + w = data.readUInt32BE(0); + h = data.readUInt32BE(4); + if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) + throw new Error('need 8-bit RGBA, non-interlaced'); + } else if (type === 'IDAT') idat.push(data); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = w * 4; + const px = Buffer.alloc(h * stride); + for (let y = 0; y < h; y++) { + const f = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? px[y * stride + x - 4] : 0; + const b = y > 0 ? px[(y - 1) * stride + x] : 0; + const c = x >= 4 && y > 0 ? px[(y - 1) * stride + x - 4] : 0; + let p = 0; + if (f === 1) p = a; + else if (f === 2) p = b; + else if (f === 3) p = (a + b) >> 1; + else if (f === 4) { + const pa = Math.abs(b - c), + pb = Math.abs(a - c), + pc = Math.abs(a + b - 2 * c); + p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + px[y * stride + x] = (line[x] + p) & 255; + } + } + return { w, h, px }; +} + +// Separable area average over premultiplied RGBA floats. +function resize({ w, h, px }, nw, nh) { + const pre = new Float32Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + const a = px[i * 4 + 3] / 255; + for (let c = 0; c < 3; c++) pre[i * 4 + c] = px[i * 4 + c] * a; + pre[i * 4 + 3] = px[i * 4 + 3]; + } + const pass = (data, sw, sh, dw, dh, horiz) => { + const out = new Float32Array((horiz ? dw * sh : sw * dh) * 4); + const n = horiz ? dw : dh; + const sn = horiz ? sw : sh; + const scale = sn / n; + for (let o = 0; o < n; o++) { + const s0 = o * scale, + s1 = (o + 1) * scale; + for (let i = Math.floor(s0); i < Math.min(sn, Math.ceil(s1)); i++) { + const wt = Math.min(i + 1, s1) - Math.max(i, s0); + const lines = horiz ? sh : sw; + for (let l = 0; l < lines; l++) { + const si = (horiz ? l * sw + i : i * sw + l) * 4; + const di = (horiz ? l * dw + o : o * dw + l) * 4; + for (let c = 0; c < 4; c++) out[di + c] += (data[si + c] * wt) / scale; + } + } + } + return out; + }; + const mid = pass(pre, w, h, nw, h, true); + const fin = pass(mid, nw, h, nw, nh, false); + const out = Buffer.alloc(nw * nh * 4); + for (let i = 0; i < nw * nh; i++) { + const a = fin[i * 4 + 3]; + out[i * 4 + 3] = Math.round(a); + for (let c = 0; c < 3; c++) + out[i * 4 + c] = a > 0 ? Math.min(255, Math.round((fin[i * 4 + c] * 255) / a)) : 0; + } + return out; +} + +// Median cut to 256 colours over (r,g,b,a); alpha is a quantised channel too. +function quantise(px, n) { + const key = (i) => [px[i * 4], px[i * 4 + 1], px[i * 4 + 2], px[i * 4 + 3]]; + const hist = new Map(); + for (let i = 0; i < n; i++) { + const q = key(i).map((v) => v & 0xf8); + const k = (q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3]; + const e = hist.get(k) ?? { c: q, n: 0 }; + e.n++; + hist.set(k, e); + } + let boxes = [[...hist.values()]]; + while (boxes.length < 256) { + let bi = -1, + best = 0, + ch = 0; + boxes.forEach((b, i) => { + if (b.length < 2) return; + for (let c = 0; c < 4; c++) { + const vs = b.map((e) => e.c[c]); + const r = (Math.max(...vs) - Math.min(...vs)) * Math.sqrt(b.reduce((s, e) => s + e.n, 0)); + if (r > best) { + best = r; + bi = i; + ch = c; + } + } + }); + if (bi < 0) break; + const b = boxes[bi].sort((x, y) => x.c[ch] - y.c[ch]); + const half = b.reduce((s, e) => s + e.n, 0) / 2; + let acc = 0, + cut = 1; + for (; cut < b.length - 1 && acc + b[cut - 1].n < half; cut++) acc += b[cut - 1].n; + boxes.splice(bi, 1, b.slice(0, cut), b.slice(cut)); + } + const pal = boxes.map((b) => { + const t = b.reduce((s, e) => s + e.n, 0); + return [0, 1, 2, 3].map((c) => Math.round(b.reduce((s, e) => s + e.c[c] * e.n, 0) / t)); + }); + const idx = new Uint8Array(n); + const cache = new Map(); + for (let i = 0; i < n; i++) { + const k = (px[i * 4] << 24) | (px[i * 4 + 1] << 16) | (px[i * 4 + 2] << 8) | px[i * 4 + 3]; + let j = cache.get(k); + if (j === undefined) { + let bd = Infinity; + pal.forEach((p, pi) => { + const d = [0, 1, 2, 3].reduce((s, c) => s + (p[c] - px[i * 4 + c]) ** 2, 0); + if (d < bd) { + bd = d; + j = pi; + } + }); + cache.set(k, j); + } + idx[i] = j; + } + return { pal, idx }; +} + +function chunk(type, data) { + const t = Buffer.from(type, 'latin1'); + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + t.copy(out, 4); + data.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([t, data])) >>> 0, 8 + data.length); + return out; +} + +function encode(w, h, { pal, idx }) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 3; + const raw = Buffer.alloc(h * (w + 1)); + for (let y = 0; y < h; y++) + Buffer.from(idx.subarray(y * w, (y + 1) * w)).copy(raw, y * (w + 1) + 1); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('PLTE', Buffer.from(pal.flatMap((p) => p.slice(0, 3)))), + chunk('tRNS', Buffer.from(pal.map((p) => p[3]))), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); +let total = 0; +for (const mind of demoMinds) { + const m = catalogue.minds[mind]; + for (const f of [...Object.values(m.shared), ...Object.values(m.signatures)]) { + const img = decode(readFileSync(join(src, f))); + const nw = Math.min(WIDTH, img.w); + const nh = Math.round((img.h * nw) / img.w); + const png = encode(nw, nh, quantise(resize(img, nw, nh), nw * nh)); + mkdirSync(dirname(join(outDir, f)), { recursive: true }); + writeFileSync(join(outDir, f), png); + total += png.length; + console.log(f, `${nw}x${nh}`, png.length); + } +} +console.log('total', total); diff --git a/scripts/preview-config.js b/scripts/preview-config.js new file mode 100644 index 00000000..dbd50049 --- /dev/null +++ b/scripts/preview-config.js @@ -0,0 +1,33 @@ +// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json +// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch +// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it +// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend +// server as the only homeserver. Usage: +// node scripts/preview-config.js [tier] [style] [--demo] +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const args = process.argv.slice(2); +const demo = args.includes('--demo'); +const [tier = 'low', style] = args.filter((a) => !a.startsWith('--')); +const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); +const config = JSON.parse(fs.readFileSync(file, 'utf8')); +config.hashRouter = { enabled: true, basename: '/' }; +config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; +if (demo) { + config.previewDemo = true; + config.defaultHomeserver = 0; + config.homeserverList = ['demo.invalid']; + config.allowCustomHomeservers = false; + config.featuredCommunities = { + openAsDefault: false, + spaces: ['#gamecult:demo.invalid'], + rooms: [], + servers: ['demo.invalid'], + }; +} +fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); +console.log( + `${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}` +); diff --git a/scripts/sync-poses.mjs b/scripts/sync-poses.mjs new file mode 100644 index 00000000..9a5b1f83 --- /dev/null +++ b/scripts/sync-poses.mjs @@ -0,0 +1,24 @@ +// Copies the pose catalogue and the art it names from the cast-art directory. +// The catalogue is the only list of poses the client knows; the art is not committed. +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); + +copyFileSync( + join(src, 'catalogue.json'), + join(root, 'src/app/components/message/pose/catalogue.json') +); +const files = Object.values(catalogue.minds).flatMap((m) => [ + ...Object.values(m.shared), + ...Object.values(m.signatures), +]); +files.forEach((f) => { + const dest = join(root, 'public/poses', f); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(src, f), dest); +}); +console.log(`synced catalogue and ${files.length} images from ${src}`); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index bd848dd5..b7e6d2fe 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,6 +23,9 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { composerSpellcheckAttributes } from './spellcheck'; const initialValue: CustomElement[] = [ { @@ -90,6 +93,7 @@ export const CustomEditor = forwardRef( }, ref ) => { + const [composerSpellcheck] = useSetting(settingsAtom, 'composerSpellcheck'); const renderElement = useCallback( (props: RenderElementProps) => , [] @@ -146,6 +150,7 @@ export const CustomEditor = forwardRef( onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} + {...composerSpellcheckAttributes(composerSpellcheck)} /> {after && ( diff --git a/src/app/components/editor/spellcheck.test.ts b/src/app/components/editor/spellcheck.test.ts new file mode 100644 index 00000000..fa0535cb --- /dev/null +++ b/src/app/components/editor/spellcheck.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { composerSpellcheckAttributes, defaultComposerSpellcheck } from './spellcheck'; + +const media = (coarse: boolean) => (query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, +}); + +describe('composer spellcheck default', () => { + it('is off when the primary pointer is coarse (phone)', () => { + expect(defaultComposerSpellcheck(media(true))).toBe(false); + }); + it('is on for a fine pointer (desktop)', () => { + expect(defaultComposerSpellcheck(media(false))).toBe(true); + }); + it('is on when matchMedia is unavailable', () => { + expect(defaultComposerSpellcheck(undefined)).toBe(true); + }); +}); + +describe('composer spellcheck attributes', () => { + it('off disables both spellcheck and autocorrect', () => { + expect(composerSpellcheckAttributes(false)).toEqual({ spellCheck: false, autoCorrect: 'off' }); + }); + it('on enables spellcheck and leaves autocorrect to the platform', () => { + expect(composerSpellcheckAttributes(true)).toEqual({ spellCheck: true }); + }); +}); diff --git a/src/app/components/editor/spellcheck.ts b/src/app/components/editor/spellcheck.ts new file mode 100644 index 00000000..825676ef --- /dev/null +++ b/src/app/components/editor/spellcheck.ts @@ -0,0 +1,11 @@ +/** Touch devices (phones) get the keyboard's own dictionary; desktop gets the browser's. */ +export const defaultComposerSpellcheck = ( + matchMedia: ((query: string) => { matches: boolean }) | undefined +): boolean => !matchMedia?.('(pointer: coarse)').matches; + +/** + * Attributes for the composer's editable. Off must also switch autocorrect off: + * spellCheck alone leaves Gboard's suggestion strip and correction modal in place. + */ +export const composerSpellcheckAttributes = (enabled: boolean) => + enabled ? ({ spellCheck: true } as const) : ({ spellCheck: false, autoCorrect: 'off' } as const); diff --git a/src/app/components/logo/logoMark.css.ts b/src/app/components/logo/logoMark.css.ts new file mode 100644 index 00000000..4c967088 --- /dev/null +++ b/src/app/components/logo/logoMark.css.ts @@ -0,0 +1,9 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +/** + * The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class + * of this name darkens it to the dark ink; put it on every that shows the mark on a themed surface. The favicon and + * notification icons are files the browser draws and cannot take it. + */ +export const LogoMark = style({}); +globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' }); diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 93ff84bc..066a827f 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -28,36 +28,80 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) { type BubbleLayoutProps = { hideBubble?: boolean; + /** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */ + own?: boolean; + /** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */ + pose?: ReactNode; + /** On phones the portrait floats on the other side. */ + alt?: boolean; + /** Delay in ms before the first-appearance animation; undefined plays none. */ + enterDelay?: number; before?: ReactNode; header?: ReactNode; }; export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, before, header, children, ...props }, ref) => ( - - - {before} - - - {header} - {hideBubble ? ( - children - ) : ( - - - {before ? : null} - {children} - + ({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => { + // `false` is a Mind's message continuing a run: aligned with the portraits, showing none. + const poseRow = pose !== undefined; + const arrow = !!before && !poseRow && !own; + const entering = enterDelay !== undefined; + return ( + + {!poseRow && !own && ( + + {before} )} + + {header && ( +
+ {header} +
+ )} + {hideBubble ? ( + children + ) : ( + + + {arrow ? : null} + {pose} + {children} + + + )} +
-
- ) + ); + } ); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c6..a724241c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,4 +1,4 @@ -import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; +import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; @@ -143,6 +143,7 @@ export const BubbleContent = style({ padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, borderRadius: config.radii.R500, position: 'relative', }); @@ -151,6 +152,194 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); +// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits +// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top +// edge, and the text wraps around its silhouette (shape-outside on the alpha channel). +const PHONE = 'screen and (max-width: 700px)'; +const PHONE_PORTRAIT = 'min(38vw, 160px)'; +const PHONE_TEXT_FLOOR = toRem(170); +const PORTRAIT_OVERHANG = 58; +const PORTRAIT = toRem(124); +const POSE_GUTTER = toRem(14); +const BUBBLE_CORNER = toRem(6); +const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; +const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; + +export const BubbleRowPose = style({ + position: 'relative', + paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`, + '@media': { + [PHONE]: { + paddingLeft: 0, + }, + }, +}); + +// A row that shows a portrait: room for it to hang above the bubble on phones. +export const BubbleRowPortrait = style({ + minHeight: PORTRAIT, + '@media': { + [PHONE]: { + minHeight: 0, + }, + }, +}); + +export const BubbleRowOwn = style({ + justifyContent: 'flex-end', +}); + +export const BubbleColumnOwn = style({ + alignItems: 'flex-end', + maxWidth: toRem(640), +}); + +export const BubbleHeaderOwn = style({ + alignSelf: 'flex-end', +}); + +// Phone: name and time are one plain line in flow above the bubble, on the side opposite the +// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name +// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by +// the column: a short bubble's column is narrower than its own name, and a percentage cap there +// truncated names to one letter. +export const BubbleHeaderPose = style({ + '@media': { + [PHONE]: { + alignSelf: 'flex-end', + // The band above the bubble where the portrait overhangs: the header sits at its foot, beside + // the portrait, so the overhang needs no margin of its own. + minHeight: toRem(PORTRAIT_OVERHANG), + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + margin: `0 ${toRem(14)} ${toRem(2)}`, + width: 'max-content', + maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, + textAlign: 'right', + }, + }, +}); + +export const BubbleHeaderPoseAlt = style({ + '@media': { + [PHONE]: { + alignSelf: 'flex-start', + textAlign: 'left', + }, + }, +}); + +// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin. +globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, { + '@media': { + [PHONE]: { + marginTop: toRem(PORTRAIT_OVERHANG), + }, + }, +}); + +export const BubbleColumnPose = style({ + position: 'relative', +}); + +export const BubbleContentPose = style({ + borderRadius: config.radii.R500, + borderTopLeftRadius: BUBBLE_CORNER, + '@media': { + [PHONE]: { + display: 'flow-root', + // A bubble with a portrait is at least portrait + a readable column wide, or the text + // beside the portrait collapses to a strip. + minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`, + borderTopLeftRadius: config.radii.R500, + padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, + }, + }, +}); + +export const BubbleContentOwn = style({ + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + border: `1px solid ${color.Primary.ContainerLine}`, + borderRadius: config.radii.R500, + borderTopRightRadius: BUBBLE_CORNER, +}); + +export const PoseButton = style({ + position: 'absolute', + top: 0, + left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, + width: PORTRAIT, + height: PORTRAIT, + padding: 0, + border: 'none', + background: 'none', + cursor: 'pointer', + lineHeight: 0, + transformOrigin: '50% 90%', + '@media': { + [PHONE]: { + position: 'static', + float: 'left', + width: PHONE_PORTRAIT, + height: 'auto', + margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, + shapeMargin: toRem(7), + WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', + maskImage: 'linear-gradient(#000 84%, transparent)', + }, + }, +}); + +export const PoseButtonAlt = style({ + '@media': { + [PHONE]: { + float: 'right', + margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, + }, + }, +}); + +export const PosePortrait = style({ + width: '100%', + height: '100%', + objectFit: 'contain', + objectPosition: 'top', + '@media': { + [PHONE]: { + height: 'auto', + }, + }, +}); + +const pop = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` }, + to: { opacity: 1, transform: 'none' }, +}); +const rise = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` }, + to: { opacity: 1, transform: 'none' }, +}); + +// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay +// (a --enter-delay on the row) staggers messages that arrive together. +export const EnterPortrait = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`, + }, + }, +}); + +export const EnterBubble = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`, + }, + }, +}); + export const BubbleLeftArrow = style({ width: toRem(9), height: toRem(8), @@ -205,3 +394,76 @@ export const MessageTextBody = recipe({ }); export type MessageTextBodyVariants = RecipeVariants; + +// The message content is a flex column, which forms its own block formatting context and so sits +// whole beside the floated portrait: the text stays as narrow as the gap beside it for the +// bubble's full height. As a plain block its lines wrap around the silhouette and then run the +// bubble's full width below it. +globalStyle(`${BubbleContentPose} > div`, { + '@media': { + [PHONE]: { + display: 'block', + }, + }, +}); + +// The name wraps instead of ellipsising: the header already caps its own width, and Username's own +// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…". +globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { + '@media': { + [PHONE]: { + whiteSpace: 'normal', + overflow: 'visible', + textOverflow: 'clip', + overflowWrap: 'break-word', + }, + }, +}); + +// A name too long for one line takes its own lines and the time drops below it, instead of the +// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The +// row hugs the side the header sits on, so a dropped time stays under the name's edge. +globalStyle(`${BubbleHeaderPose} > div`, { + '@media': { + [PHONE]: { + flexWrap: 'wrap', + justifyContent: 'flex-end', + }, + }, +}); + +globalStyle(`${BubbleHeaderPoseAlt} > div`, { + '@media': { + [PHONE]: { + justifyContent: 'flex-start', + }, + }, +}); + +// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so +// beside a left portrait the number lands under the art. Inside, it is a word on the first line +// and wraps around the silhouette like the rest. +globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { + '@media': { + [PHONE]: { + listStylePosition: 'inside', + paddingLeft: 0, + }, + }, +}); + +// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in +// the app: its own colour at half strength over a blur of the scene, so the words stay readable +// and the ridge shows through. The arrow wedge is opaque, so it goes. +const scene = ':root[data-scene]'; +const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`; +globalStyle(`${scene} ${BubbleContent}`, { + backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)], + backdropFilter: 'blur(12px)', +}); +globalStyle(`${scene} ${BubbleContentOwn}`, { + backgroundColor: [color.Primary.Container, half(color.Primary.Container)], +}); +globalStyle(`${scene} ${BubbleLeftArrow}`, { + display: 'none', +}); diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx new file mode 100644 index 00000000..75a4b333 --- /dev/null +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import * as css from '../layout/layout.css'; +import { poseSrc } from './poses'; +import { silhouette } from './silhouette'; + +type PoseAvatarProps = { + mind: string; + pose: string; + userId: string; + /** Float on the right on phones. */ + alt?: boolean; + /** Play the first-appearance pop. */ + enter?: boolean; + onClick: React.MouseEventHandler; +}; + +export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { + const src = poseSrc(mind, pose, import.meta.env.BASE_URL); + const imgRef = useRef(null); + const [shape, setShape] = useState(); + + // On phones the bubble text wraps around the portrait's silhouette. Retraced when the art + // loads and when the portrait's width changes with the viewport. + useEffect(() => { + const img = imgRef.current; + if (!img) return undefined; + const trace = () => { + if (!img.complete || img.naturalWidth === 0) return; + try { + setShape(silhouette(img, !alt)); + } catch { + setShape(undefined); + } + }; + trace(); + img.addEventListener('load', trace); + const resize = new ResizeObserver(trace); + resize.observe(img); + return () => { + img.removeEventListener('load', trace); + resize.disconnect(); + }; + }, [src, alt]); + + return ( + + ); +} diff --git a/src/app/components/message/pose/catalogue.json b/src/app/components/message/pose/catalogue.json new file mode 100644 index 00000000..87e7ba71 --- /dev/null +++ b/src/app/components/message/pose/catalogue.json @@ -0,0 +1,242 @@ +{ + "shared": [ + "neutral", + "happy", + "amused", + "thinking", + "annoyed", + "surprised" + ], + "minds": { + "eureka": { + "sheet": "r1b", + "shared": { + "neutral": "eureka/neutral.png", + "happy": "eureka/happy.png", + "amused": "eureka/amused.png", + "thinking": "eureka/thinking.png", + "annoyed": "eureka/annoyed.png", + "surprised": "eureka/surprised.png" + }, + "signatures": { + "probe": "eureka/probe.png", + "inspect": "eureka/inspect.png", + "report": "eureka/report.png", + "hood-smug": "eureka/hood-smug.png", + "concerned": "eureka/concerned.png", + "determined": "eureka/determined.png" + }, + "signature_descriptions": { + "probe": "Frowning, pencil raised to her chin, working a problem out.", + "inspect": "Frowning, one finger pointing at the thing to look at.", + "report": "Smirking over a folder of findings.", + "hood-smug": "Eyes shut under the hood, self-satisfied smirk.", + "concerned": "Worried frown, pencil at her chin.", + "determined": "Hard-set frown, pencil in hand, resolved." + } + }, + "aqua": { + "sheet": "r1b", + "shared": { + "neutral": "aqua/neutral.png", + "happy": "aqua/happy.png", + "amused": "aqua/amused.png", + "thinking": "aqua/thinking.png", + "annoyed": "aqua/annoyed.png", + "surprised": "aqua/surprised.png" + }, + "signatures": { + "concerned": "aqua/concerned.png", + "fretful": "aqua/fretful.png", + "weary": "aqua/weary.png", + "glare": "aqua/glare.png", + "smug": "aqua/smug.png", + "sigh": "aqua/sigh.png", + "charging": "aqua/charging.png", + "delighted": "aqua/delighted.png", + "content": "aqua/content.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows pinched.", + "fretful": "Anxious, a fin pressed to her chin.", + "weary": "Droopy half-lidded eyes; worn out.", + "glare": "Scowling hard, a fin held out to the side.", + "smug": "Sidelong look and a small smirk.", + "sigh": "Eyes shut, breathing out.", + "charging": "Eyes shut, a charging cable plugged into her mouth; recharging.", + "delighted": "Wide open-mouthed grin, fins up.", + "content": "Calm, small contented smile." + } + }, + "heimdall": { + "sheet": "r1a", + "shared": { + "neutral": "heimdall/neutral.png", + "happy": "heimdall/happy.png", + "amused": "heimdall/amused.png", + "thinking": "heimdall/thinking.png", + "annoyed": "heimdall/annoyed.png", + "surprised": "heimdall/surprised.png" + }, + "signatures": { + "concerned": "heimdall/concerned.png", + "determined": "heimdall/determined.png", + "badge": "heimdall/badge.png", + "barrier": "heimdall/barrier.png", + "protect": "heimdall/protect.png", + "revoke": "heimdall/revoke.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows raised.", + "determined": "Hard scowl, jaw set.", + "badge": "Stern, holding a gold key out as his credentials.", + "barrier": "Palm raised behind a translucent blue shield: stop.", + "protect": "Scowling, cloak swept forward like a guard.", + "revoke": "Stern, two keys in hand; taking access away." + } + }, + "kiko": { + "sheet": "r1b", + "shared": { + "neutral": "kiko/neutral.png", + "happy": "kiko/happy.png", + "amused": "kiko/amused.png", + "thinking": "kiko/thinking.png", + "annoyed": "kiko/annoyed.png", + "surprised": "kiko/surprised.png" + }, + "signatures": { + "shy-smile": "kiko/shy-smile.png", + "uneasy": "kiko/uneasy.png", + "tinker": "kiko/tinker.png", + "confetti": "kiko/confetti.png", + "sprint": "kiko/sprint.png", + "debug": "kiko/debug.png" + }, + "signature_descriptions": { + "shy-smile": "Soft, shy smile; blushing.", + "uneasy": "Blushing, tongue peeking out, unsure.", + "tinker": "Delighted, holding up a small gadget.", + "confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.", + "sprint": "Eyes shut, laughing, one arm up, mid-dash.", + "debug": "Squinting through a magnifying glass at a bug." + } + }, + "weksa": { + "sheet": "r1a", + "shared": { + "neutral": "weksa/neutral.png", + "happy": "weksa/happy.png", + "amused": "weksa/amused.png", + "thinking": "weksa/thinking.png", + "annoyed": "weksa/annoyed.png", + "surprised": "weksa/surprised.png" + }, + "signatures": { + "concerned": "weksa/concerned.png", + "skeptical": "weksa/skeptical.png", + "map": "weksa/map.png", + "fieldwork": "weksa/fieldwork.png", + "argue": "weksa/argue.png", + "interlingua": "weksa/interlingua.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, wide eyes, scroll on her back.", + "skeptical": "Half-lidded, flat frown; unconvinced.", + "map": "Worried, unfurling a hand-drawn map.", + "fieldwork": "Frowning in focus, holding a carved idol and a brush.", + "argue": "Open-mouthed, pointing, holding up a tablet as evidence.", + "interlingua": "Shouting, a glyph tablet in each hand." + } + }, + "void": { + "sheet": "r1b", + "shared": { + "neutral": "void/neutral.png", + "happy": "void/happy.png", + "amused": "void/amused.png", + "thinking": "void/thinking.png", + "annoyed": "void/annoyed.png", + "surprised": "void/surprised.png" + }, + "signatures": { + "determined": "void/determined.png", + "glare": "void/glare.png", + "shrug": "void/shrug.png", + "reading": "void/reading.png", + "point": "void/point.png", + "deadpan": "void/deadpan.png" + }, + "signature_descriptions": { + "determined": "Hood bowed, narrowed eyes; set on the task.", + "glare": "Narrowed eyes under the hood; hostile stare.", + "shrug": "Both hands open, eyes wide: no idea.", + "reading": "Holding a sheet of paper, reading it.", + "point": "Half-lidded eyes, one finger pointing off to the side.", + "deadpan": "Flat lines for eyes; unimpressed." + } + }, + "libby": { + "sheet": "r1a", + "shared": { + "neutral": "libby/neutral.png", + "happy": "libby/happy.png", + "amused": "libby/amused.png", + "thinking": "libby/thinking.png", + "annoyed": "libby/annoyed.png", + "surprised": "libby/surprised.png" + }, + "signatures": { + "concerned": "libby/concerned.png", + "sad": "libby/sad.png", + "stamp": "libby/stamp.png", + "research": "libby/research.png", + "shush": "libby/shush.png", + "schema": "libby/schema.png" + }, + "signature_descriptions": { + "concerned": "Worried, a book clutched to her chest.", + "sad": "Downcast, a book clutched to her chest.", + "stamp": "Smiling, stamping a page of an open book.", + "research": "Eager over an open book, an ink bottle at hand.", + "shush": "Speaking over an open book, one finger on the page.", + "schema": "Raising a finger beside a glowing flow-diagram panel." + } + }, + "nibu": { + "sheet": "r1b", + "shared": { + "neutral": "nibu/neutral.png", + "happy": "nibu/happy.png", + "amused": "nibu/amused.png", + "thinking": "nibu/thinking.png", + "annoyed": "nibu/annoyed.png", + "surprised": "nibu/surprised.png" + }, + "signatures": { + "sheepish": "nibu/sheepish.png", + "pout": "nibu/pout.png", + "resolute": "nibu/resolute.png", + "console": "nibu/console.png", + "glitch": "nibu/glitch.png", + "triumph": "nibu/triumph.png" + }, + "signature_descriptions": { + "sheepish": "Arms crossed, awkward toothy grin.", + "pout": "Arms crossed, pouting frown.", + "resolute": "Arms crossed, scowling; standing her ground.", + "console": "Frowning at a handheld console.", + "glitch": "Frowning, palm up, a glitch tearing across her eyes.", + "triumph": "Eyes shut, fist raised; quietly pleased with a win." + } + } + }, + "shared_descriptions": { + "neutral": "At rest, no particular feeling.", + "happy": "Pleased; smiling.", + "amused": "Quietly entertained, as if at a private joke.", + "thinking": "Working something out.", + "annoyed": "Irritated; patience running thin.", + "surprised": "Caught off guard." + } +} \ No newline at end of file diff --git a/src/app/components/message/pose/entrance.test.ts b/src/app/components/message/pose/entrance.test.ts new file mode 100644 index 00000000..6f16a384 --- /dev/null +++ b/src/app/components/message/pose/entrance.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { claimEntrance } from './entrance'; + +describe('claimEntrance', () => { + it('animates a message once, never on a remount', () => { + expect(claimEntrance('$once', 1_000_000)).toBe(0); + expect(claimEntrance('$once', 1_000_500)).toBeUndefined(); + }); + it('staggers messages that mount together, capped so a long backlog still lands fast', () => { + const t = 2_000_000; + const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t)); + expect(delays.slice(0, 3)).toEqual([0, 300, 600]); + expect(Math.max(...(delays as number[]))).toBe(900); + }); + it('lets a lone later message in at once', () => { + claimEntrance('$x', 3_000_000); + expect(claimEntrance('$y', 3_010_000)).toBe(0); + }); +}); diff --git a/src/app/components/message/pose/entrance.ts b/src/app/components/message/pose/entrance.ts new file mode 100644 index 00000000..8386f80b --- /dev/null +++ b/src/app/components/message/pose/entrance.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +// A message animates in the first time it is shown, not each time the virtualised timeline +// remounts it. Messages mounting together are staggered so a screenful does not pop at once. +const STEP_MS = 300; +const MAX_DELAY_MS = 900; +const SEEN_CAP = 2000; + +const seen = new Set(); +let nextSlot = 0; + +export const claimEntrance = (id: string, now: number): number | undefined => { + if (seen.has(id)) return undefined; + if (seen.size >= SEEN_CAP) seen.clear(); + seen.add(id); + const start = Math.max(nextSlot, now); + nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS); + return Math.min(start - now, MAX_DELAY_MS); +}; + +export const useEntranceDelay = (id: string | undefined): number | undefined => + useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0]; diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts new file mode 100644 index 00000000..c6089f62 --- /dev/null +++ b/src/app/components/message/pose/poses.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import catalogue from './catalogue.json'; +import { + mindOfSender, + poseSrc, + resolvePose, +} from './poses'; + +describe('resolvePose', () => { + it('keeps shared and own-signature labels from the catalogue', () => { + expect(resolvePose('aqua', 'amused')).toBe('amused'); + expect(resolvePose('aqua', 'fretful')).toBe('fretful'); + expect(resolvePose('kiko', ' Debug ')).toBe('debug'); + expect(resolvePose('void', 'reading')).toBe('reading'); + }); + it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => { + expect(resolvePose('aqua', undefined)).toBe('neutral'); + expect(resolvePose('aqua', 'conduct')).toBe('neutral'); + expect(resolvePose('aqua', 7)).toBe('neutral'); + expect(resolvePose('aqua', 'probe')).toBe('neutral'); + expect(resolvePose('nobody', 'neutral')).toBe('neutral'); + expect(resolvePose('aqua', 'constructor')).toBe('neutral'); + }); + it('knows every label the catalogue lists, and only those', () => { + Object.entries(catalogue.minds).forEach(([mind, entry]) => { + [...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) => + expect(resolvePose(mind, l)).toBe(l) + ); + }); + }); +}); + +describe('mindOfSender', () => { + it('matches localpart or first display-name word', () => { + expect(mindOfSender('Eureka')).toBe('eureka'); + expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby'); + expect(mindOfSender('void')).toBe('void'); + }); + it('ignores humans and Object prototype names', () => { + expect(mindOfSender('alice', 'Alice')).toBeUndefined(); + expect(mindOfSender('constructor')).toBeUndefined(); + expect(mindOfSender()).toBeUndefined(); + }); +}); + +describe('poseSrc', () => { + it('builds the asset path from the catalogue entry', () => { + expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png'); + expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png'); + }); +}); + + + diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts new file mode 100644 index 00000000..69429090 --- /dev/null +++ b/src/app/components/message/pose/poses.ts @@ -0,0 +1,42 @@ +import catalogue from './catalogue.json'; + +// Pose labels travel in the event content under POSE_KEY. The catalogue +// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only +// list of Minds, labels and art paths the client knows. +export const POSE_KEY = 'org.gamecult.pose'; +export const NEUTRAL = 'neutral'; + +type MindEntry = { + shared: Record; + signatures: Record; +}; +const minds = catalogue.minds as Record; +export const MINDS = Object.keys(minds); + +// A sender is a known Mind when its Matrix localpart or the first word of its +// display name is the Mind's name, case-insensitively. +export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => { + const candidates = [localpart, displayName?.split(/\s+/)[0]]; + return candidates + .map((c) => c?.toLowerCase()) + .find((c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(minds, c)); +}; + +const artPath = (mind: string, label: string): string | undefined => { + const entry = Object.prototype.hasOwnProperty.call(minds, mind) ? minds[mind] : undefined; + if (!entry) return undefined; + const has = (o: Record) => Object.prototype.hasOwnProperty.call(o, label); + if (has(entry.shared)) return entry.shared[label]; + return has(entry.signatures) ? entry.signatures[label] : undefined; +}; + +// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral. +export const resolvePose = (mind: string, label: unknown): string => { + if (typeof label !== 'string') return NEUTRAL; + const l = label.trim().toLowerCase(); + return artPath(mind, l) ? l : NEUTRAL; +}; + +export const poseSrc = (mind: string, pose: string, base = '/'): string => + `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; + diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts new file mode 100644 index 00000000..9deb3f4f --- /dev/null +++ b/src/app/components/message/pose/silhouette.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { silhouette } from './silhouette'; + +// A w x h image whose opaque pixels are the given columns [from, to) on each row. +const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y += 1) { + const span = solid(y); + if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255; + } + const canvas = { + width: 0, + height: 0, + getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + return { clientWidth: w, clientHeight: h } as HTMLImageElement; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('silhouette', () => { + // Art in columns 20..60 from row 8 down; rows 0..7 are empty. + const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]); + + it('hugs the right edge of the art when the text sits to its right', () => { + const poly = silhouette(fakeImage(100, 16, art), true); + expect(poly).toBe( + 'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box' + ); + }); + + it('hugs the left edge of the art when the text sits to its left', () => { + const poly = silhouette(fakeImage(100, 16, art), false); + expect(poly).toBe( + 'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box' + ); + }); + + it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => { + // Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it. + const poly = silhouette( + fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)), + true + ); + expect(poly).toContain('90px 8px'); + }); + + it('has no shape before the image has a size or where there is no canvas', () => { + expect( + silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) + ).toBeUndefined(); + }); +}); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts new file mode 100644 index 00000000..e9596ded --- /dev/null +++ b/src/app/components/message/pose/silhouette.ts @@ -0,0 +1,34 @@ +const SOLID = 0.35 * 255; +const ROW = 4; + +// The edge of the portrait that the text wraps against, as a polygon in border-box pixels. +// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and +// anchors it at the margin box, which the phone layout's negative top margin shifts down by the +// overhang, so the exclusion neither matches the art's size nor its place. +export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { + const w = Math.round(img.clientWidth); + const h = Math.round(img.clientHeight); + const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0, 0, w, h); + const { data } = ctx.getImageData(0, 0, w, h); + const outer = textOnRight ? 0 : w; + const edge: string[] = []; + for (let y = 0; y <= h; y += ROW) { + let x = outer; + // A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon. + for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) { + for (let i = 0; i < w; i += 1) { + const px = textOnRight ? w - 1 - i : i; + if (data[(r * w + px) * 4 + 3] > SOLID) { + x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px); + break; + } + } + } + edge.push(`${x}px ${y}px`); + } + return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`; +}; diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 48b2a4d3..e5db6c47 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,6 +1,16 @@ -import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; +import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; + +// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows +// are three distinct steps above them. +// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash. +const SCRIM_LABEL = '0%'; +const SCRIM_ROW = '0%'; +const SCRIM_HOVER = '14%'; +const SCRIM_SELECTED = '22%'; +const SCRIM_PRESSED = '48%'; export const NavCategory = style([ DefaultReset, @@ -11,6 +21,18 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, + '@media': { + // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; + // a phone's list is plain text). + 'screen and (min-width: 751px)': { + selectors: { + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, + borderRadius: config.radii.R400, + }, + }, + }, + }, }); export const NavLink = style({ @@ -44,6 +66,12 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ }, }); +// The room list is plain text on a phone: no pill, slab or border behind any row, and hover, +// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows +// and the wash. +const WIDE = 'screen and (min-width: 751px)'; +const PHONE = 'screen and (max-width: 750px)'; + const NavItemBase = style({ width: '100%', display: 'flex', @@ -54,19 +82,45 @@ const NavItemBase = style({ outline: 'none', minHeight: toRem(36), + '@media': { + [PHONE]: { + backgroundColor: 'transparent', + borderRadius: 0, + }, + [WIDE]: { + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: ContainerHover, + }, + '&[data-hover=true]': { + backgroundColor: ContainerHover, + }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. + // Hover, the selected row and a press each strengthen it a step. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, + }, + [`&:has(.${NavLink}:active)`]: { + backgroundColor: ContainerActive, + }, + '&[aria-selected=true]': { + backgroundColor: ContainerActive, + }, + }, + }, + }, selectors: { - '&:hover, &:focus-visible': { - backgroundColor: ContainerHover, - }, - '&[data-hover=true]': { - backgroundColor: ContainerHover, - }, - [`&:has(.${NavLink}:active)`]: { - backgroundColor: ContainerActive, - }, - '&[aria-selected=true]': { - backgroundColor: ContainerActive, - }, [`&:has(.${NavLink}:focus-visible)`]: { outline: `${config.borderWidth.B600} solid ${ContainerLine}`, outlineOffset: `calc(-1 * ${config.borderWidth.B600})`, @@ -126,3 +180,76 @@ export const NavItemContent = style({ export const NavItemOptions = style({ paddingRight: config.space.S200, }); + +// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600, +// at heading strength) and states itself in the text: hover and focus underline it, the selected +// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene +// a halo of the theme's own surface keeps it readable without a box. +export const NavLabel = style({}); +const label = `.${NavLabel}.${NavLabel}`; +const phoneLabel = (selector: string, rule: StyleRule) => + globalStyle(selector, { '@media': { [PHONE]: rule } }); + +phoneLabel(label, { + fontSize: toRem(12), + lineHeight: toRem(16), + fontWeight: config.fontWeight.W600, + textTransform: 'uppercase', + opacity: 0.75, + textDecorationThickness: '1px', + textUnderlineOffset: toRem(4), +}); +phoneLabel( + [ + `.${NavItemBase}:hover ${label}`, + `.${NavItemBase}:focus-visible ${label}`, + `.${NavItemBase}[data-hover=true] ${label}`, + `.${NavItemBase}[data-highlight=true] ${label}`, + ].join(', '), + { opacity: 1 } +); +phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, { + textDecorationLine: 'underline', +}); +phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, { + opacity: 1, + fontWeight: config.fontWeight.W700, + textDecorationLine: 'underline', + textDecorationThickness: '2px', +}); +phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { + opacity: 0.6, + textDecorationLine: 'underline', +}); +phoneLabel(`:root[data-scene] ${label}`, { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +}); + +// Icons, dots and the Join action share the label's halo over the scene. +globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, { + '@media': { + [PHONE]: { + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, +}); + +// The Join action on a phone is a plain underlined word, not a pill. +export const NavAction = style({ + '@media': { + [PHONE]: { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, + }, + }, + }, + }, +}); diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index cbcd626a..9015796e 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,9 +1,10 @@ import { JoinRule } from 'matrix-js-sdk'; -import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; +import { AvatarFallback, AvatarImage, Icon, Icons } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; type RoomAvatarProps = { roomId: string; @@ -21,7 +22,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps if (!src || error) { return ( {renderFallback()} diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..0c6b10df 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,7 +1,9 @@ +import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; +import { SkyPlate } from '../../features/scene/skyPlate.css'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -23,7 +25,7 @@ export type RoomIntroProps = { room: Room; }; -export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { +export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -50,7 +52,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( - + {renderFallback()} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 1fb97650..430219c6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,7 @@ import React, { RefObject, useRef } from 'react'; +import classNames from 'classnames'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { SkyPlate } from '../scene/skyPlate.css'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; @@ -162,7 +164,7 @@ export function CallView() { return ( diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 6fa4bf48..56b554fa 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,7 +1,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -10,6 +10,7 @@ import { NavItem, NavItemContent, } from '../../components/nav'; +import { NavAction, NavLabel } from '../../components/nav/styles.css'; import { UseStateProvider } from '../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -37,10 +38,11 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateRoom, EstateTier } from './buildEstate'; +import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; @@ -76,9 +78,14 @@ function JoinRow({ room }: { room: EstateRoom }) { - - {room.name} - + + + + + + {room.name} + + {state.status === AsyncStatus.Error && ( )} + {group.rooms.map((r) => ( + + ))} + + ); +} + +/** + * Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is + * remembered per device like any other section. + */ +function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + const key = makeNavCategoryId('estate', 'browse', tierId); + const open = toggled.has(key); + const count = groups.reduce((n, g) => n + g.rooms.length, 0); + + return ( + + + setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })} + > + {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} + + + {open && groups.map((g) => )} + + ); +} + function Tier({ tier }: { tier: EstateTier }) { const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + const { front, browse } = useMemo(() => splitGroups(tier), [tier]); return ( {tier.loose.map((r) => ( ))} - {tier.groups.map((g) => ( - - {g.rooms.map((r) => ( - - ))} - + {front.map((g) => ( + ))} + {browse.length > 0 && } ); } @@ -276,6 +312,8 @@ export function EstateNavigator() { const scrollRef = useRef(null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + // A phone's list is the estate and nothing else: no Create Room, no Join with Address. + const mobile = useScreenSizeContext() === ScreenSize.Mobile; return ( @@ -286,6 +324,7 @@ export function EstateNavigator() { {title} + - + {!mobile && } {rootId && } {!rootId && error && ( diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 8f898a0f..7b63eac5 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; +import { + buildEstate, + EstateHierarchyRoom, + EstateInput, + splitGroups, + stewardOfChild, +} from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; @@ -324,3 +330,34 @@ describe('direct invites', () => { ]); }); }); + +describe('splitGroups', () => { + const projects = (membership: EstateInput['membership']) => + buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!; + const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind); + + it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => { + const tier = projects(new Map()); + const { front, browse } = splitGroups(tier); + expect(labels(front)).toEqual([]); + expect(labels(browse).sort()).toEqual(['eureka', 'void']); + expect(tier.loose.map((r) => r.name)).toEqual(['Misc']); + }); + + it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => { + const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']]))); + expect(labels(front)).toEqual(['eureka']); + expect(labels(browse)).toEqual(['void']); + }); + + it('keeps a group up front for an invite, which is something to act on', () => { + const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']]))); + expect(labels(front)).toEqual(['void']); + }); + + it('loses no group: front and browse together are the tier groups', () => { + const tier = projects(new Map([['!cultlib:gamecult.org', 'join']])); + const { front, browse } = splitGroups(tier); + expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index b9232beb..ca656211 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -71,6 +71,20 @@ export type EstateInput = { mindLabels?: ReadonlyMap; }; +/** + * A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is + * something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first + * screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away. + */ +export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => { + const front: EstateGroup[] = []; + const browse: EstateGroup[] = []; + tier.groups.forEach((g) => + (g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g) + ); + return { front, browse }; +}; + export const DIRECT_TIER_ID = 'direct'; export const ELSEWHERE_TIER_ID = 'elsewhere'; diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx new file mode 100644 index 00000000..a500f920 --- /dev/null +++ b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { RoomNavCategoryButton } from './RoomNavCategoryButton'; + +const html = (closed?: boolean) => + renderToStaticMarkup(Browse); + +describe('RoomNavCategoryButton', () => { + it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => { + expect(html(true)).toContain('aria-expanded="false"'); + }); + + it('says it is expanded when open', () => { + expect(html(false)).toContain('aria-expanded="true"'); + }); + + it('claims neither when the section does not toggle', () => { + expect(html(undefined)).not.toContain('aria-expanded'); + }); +}); diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 3af8aeac..e232b856 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> } + aria-expanded={closed === undefined ? undefined : !closed} {...props} ref={ref} > diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 08341113..ff23930e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -21,6 +21,7 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { NavLabel } from '../../components/nav/styles.css'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; @@ -358,7 +359,13 @@ export function RoomNavItem({ )} - + {roomName} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b5701a6f..b9655923 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,8 +1,26 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, + '@media': { + // A section heading on a phone is plain text: no pill behind it. + 'screen and (max-width: 750px)': { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + }, + ':root[data-scene] &&': { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, + }, + }, }); export const CategoryButtonIcon = style({ opacity: config.opacity.P400, diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428ed..d798f62c 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -18,7 +18,7 @@ export const TimelineFloat = recipe({ top: config.space.S400, }, Bottom: { - bottom: config.space.S400, + bottom: config.space.S200, }, }, }, diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx new file mode 100644 index 00000000..8f09fdcf --- /dev/null +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -0,0 +1,27 @@ +import { Text, color, config } from 'folds'; +import { Room } from 'matrix-js-sdk'; +import React from 'react'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { + ROOM_NOTICE_EVENT_TYPE, + parseRoomNotice, + roomTrainingLine, +} from '../../utils/trainingNotice'; + +// The room's notice state event decides the line; no event, or a malformed one, no line. The +// collector reads the version from the same event, so nothing is stored in the browser. +export function RoomTrainingLine({ room }: { room: Room }) { + const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE); + const { previewDemo } = useClientConfig(); + const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo); + if (!line) return null; + return ( + + {line} + + ); +} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a4f0d7af..3fbd81fa 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -13,6 +13,7 @@ import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; +import { RoomTrainingLine } from './RoomTrainingLine'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; import { useKeyDown } from '../../hooks/useKeyDown'; @@ -104,6 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
+ {tombstoneEvent ? ( ( ); + const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); + const mind = + poseBubbles && messageLayout === MessageLayout.Bubble + ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) + : undefined; + + const own = senderId === mx.getUserId(); + const enterDelay = useEntranceDelay(mEvent.getId()); + // Minds alternate sides on phones, each always on the same one. + const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1; + const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( ); + const poseJSX = !collapse && mind && ( + + ); + const msgContentJSX = ( {reply} @@ -1135,7 +1161,15 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( - + {msgContentJSX} )} diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx new file mode 100644 index 00000000..c447b4f0 --- /dev/null +++ b/src/app/features/scene/BackgroundScene.tsx @@ -0,0 +1,55 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { getFallbackSession } from '../../state/sessions'; +import { mountBackgroundScene } from './host'; +import { readCapabilities, resolveTier } from './tier'; +import { getSceneStyleOverride } from './sceneStyle'; +import './sceneShell.css'; +import './sceneButtons.css'; + +/** Set on while a scene runs, so signed-out screens can let it show through. */ +export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; + +/** + * The one host for the background scene, mounted once at the app root. Fixed behind everything + * (z-index -1 sits above the body's background and below all normal content; the signed-out + * screens and the signed-in shell, see sceneShell.css.ts, are clear so the scene shows through). The ridge scene + * draws its layers into this wrapper beside the canvas, so the wrapper is the positioned box. + * Tier 'off' renders nothing and loads nothing. + */ +export function BackgroundScene() { + const configured = useClientConfig().backgroundScene; + const config = useMemo(() => { + const style = getSceneStyleOverride(); + return configured && style ? { ...configured, style } : configured; + }, [configured]); + const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off'; + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!enabled || !canvas) return undefined; + const root = document.documentElement; + const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, { + loggedIn: !!getFallbackSession(), + onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''), + onInactive: () => root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE), + // eslint-disable-next-line no-console + onFail: (e) => console.warn('Background scene unavailable:', e), + }); + return () => { + dispose(); + root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE); + }; + }, [config, enabled]); + + if (!enabled) return null; + return ( +
+ +
+ ); +} diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index ad4c13b1..fa7565b0 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -1,5 +1,35 @@ # Background scene +**Default: the ridge scene** (`ridge/`), no WebGL. `startBackgroundScene` runs it unless the client +config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still +means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene. + +- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or + null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's + value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain + 2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between + layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a + CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame. + The host canvas and the layers' parent must be positioned and the canvas should fill the parent. +- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played. + `tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements. +- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over + 33 ms the scene flattens to a still image (`ridge/probe.ts`) and stops. `?probe=1` (or `probe: true`) keeps sampling + and shows `n / median / p95` in a corner readout. +- `ridge/skyColour.ts` `skyColours()` is the one place sky colours come from: a fixed dusk placeholder until Void's + `src/sky/sun.js` `light()` lands (PR #20); replace its body, keep the signature. +- Aerial perspective (`ridge/ridgeWorld.ts` `AERIAL`, `transmittance`, `layerColour`, `edgeBlur`, `scatterAlpha`) is applied + once while the layers are baked, from the `SkyColours` the one seam returns (`sunAzimuth` optional: omit it for no + in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is + `T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1, + via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the + sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer + rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light. +- `harness/scene/bundle.cjs ridge` builds a standalone page (`?tier=`, `?probe=1`). +- Not yet measured on a phone: see the PR description. + +## Ray-march scene + A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back from the tree on login. It is a self-contained module; nothing in the client imports it yet. @@ -8,6 +38,36 @@ Integration (a canvas under the app, `startBackgroundScene`, `login()` when the are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure callbacks and the creature sizing rule. Nothing has drawn a creature. +## Terrain scene (`style: 'terrain'`): the baked sky + +`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in +`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, +color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an +RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). +Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the +fragment's azimuth, so far ground fades into the sky behind it at dusk too. + +Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them +(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as +the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. + +Without WebGL2 (`createTerrainScene` returns null) the door mounts `terrain/TerrainSky2D.ts` on the same canvas: the sky alone, painted once on a +96 px wide 2D canvas from `skyColour` (the CPU twin of `SKY_FRAG`, same `?sky=` `?sun=` `?az=` choice via `chooseSky`), the star layer on +top when the sky is dark, nothing moving, no sun disc. Below the horizon the image continues in the horizon's colour. A canvas that already +holds a GL context gives no 2D one, so a GL failure after context creation (compile, framebuffer) still ends in the CSS ground. + +- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a + sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, + no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, + where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, + because the table dips toward the ground there and would draw a seam against the haze. +- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; + the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as + before (midnight shows, noonday does not). +- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop + x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than + the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's @@ -22,8 +82,8 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context - stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to - show. It caps at 30 fps, pauses when + stops the scene for good and calls `onContextLost`; the scene never rebuilds itself. `host.ts` disposes it, drops + `data-scene` so the CSS ground shows, and starts a fresh scene on `webglcontextrestored`. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 6493ad88..1da90711 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -397,7 +397,7 @@ describe('startBackgroundScene', () => { it('applies the device ceiling: saveData runs a still, one frame', async () => { const { gl, calls } = fakeGl(); const scene = await startBackgroundScene( - { tier: 'full' }, + { tier: 'full', style: 'raymarch' }, { saveData: true }, { canvas: asCanvas(fakeCanvas(gl)) } ); @@ -416,13 +416,19 @@ describe('startBackgroundScene', () => { const { startBackgroundScene: start } = await import('./index'); const onCompileFail = vi.fn(); const canvas = fakeCanvas(fakeGl().gl); - const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + const scene = await start( + { tier: 'full', style: 'raymarch' }, + {}, + { canvas: asCanvas(canvas), onCompileFail } + ); expect(scene).toBeNull(); expect(onCompileFail).toHaveBeenCalledTimes(1); expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); expect(canvas.getContext).not.toHaveBeenCalled(); // and with no callback it still does not throw - expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await start({ tier: 'full', style: 'raymarch' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); } finally { vi.doUnmock('./Scene'); vi.resetModules(); @@ -433,7 +439,7 @@ describe('startBackgroundScene', () => { const { gl } = fakeGl(0); const onCompileFail = vi.fn(); const scene = await startBackgroundScene( - { tier: 'full' }, + { tier: 'full', style: 'raymarch' }, {}, { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } ); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 70580ab8..c2f0423a 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -100,8 +100,8 @@ export interface SceneOptions { } export interface Scene { - /** Texture format of the prepass start target. */ - readonly startFormat: 'RGBA32F' | 'RGBA16F'; + /** Texture format of the ray-march scene's prepass start target; the ridge scene has none. */ + readonly startFormat?: 'RGBA32F' | 'RGBA16F'; /** Start the camera pull-back from the tree. */ login(): void; dispose(): void; @@ -599,8 +599,9 @@ export function createScene(opts: SceneOptions): Scene | null { } listen(window, 'resize', () => wake()); // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault: otherwise the browser never - // restores the context, which is what we want, and a restore would find a dead scene. + // scene ends and the host decides what to show. preventDefault is what lets the browser restore + // the context later (without it a lost context stays lost); the host then builds a new scene on + // `webglcontextrestored`, and this one stays dead. listen(canvas, 'webglcontextlost', (e) => { e.preventDefault(); stop(); diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx new file mode 100644 index 00000000..b664dc07 --- /dev/null +++ b/src/app/features/scene/SkyStateControl.tsx @@ -0,0 +1,136 @@ +import React, { MouseEventHandler, useState } from 'react'; +import { + Box, + config, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '../../utils/keyboard'; +import { useSkyState } from '../../hooks/useSkyState'; +import { SKY_STATES, setSkyState, type SkyState } from './skyState'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { + getSceneStyleOverride, + SERVED_SCENE_STYLES, + setSceneStyle, + type ServedSceneStyle, +} from './sceneStyle'; + +const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' }; + +const LABEL: Record = { + auto: 'Auto', + day: 'Day', + dusk: 'Dusk', + night: 'Night', +}; + +/** + * One title-bar button that opens the four sky states. A single button, not a row, so the bar + * still fits a 320 px phone beside the search button; the menu items carry the state names. + */ +export function SkyStateControl() { + const state = useSkyState(); + const [cords, setCords] = useState(); + const backgroundScene = useClientConfig().backgroundScene; + const configuredStyle = backgroundScene?.style; + const sceneStyle: ServedSceneStyle = + getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {SKY_STATES.map((s) => ( + { + setSkyState(s); + setCords(undefined); + }} + after={state === s ? : undefined} + > + + {LABEL[s]} + + + ))} + {backgroundScene && ( + <> + + Background + + {SERVED_SCENE_STYLES.map((st) => ( + { + setCords(undefined); + if (st !== sceneStyle) setSceneStyle(st); + }} + after={sceneStyle === st ? : undefined} + > + + {STYLE_LABEL[st]} + + + ))} + + )} + + + + } + > + + + + + ); +} diff --git a/src/app/features/scene/debugOverlays.test.ts b/src/app/features/scene/debugOverlays.test.ts new file mode 100644 index 00000000..e1a4e61e --- /dev/null +++ b/src/app/features/scene/debugOverlays.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debugOverlaysAllowed, overlayRequested } from './debugOverlays'; + +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('debugOverlaysAllowed', () => { + it('a production build allows nothing by default', () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed(undefined)).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full' })).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full', debugOverlays: false })).toBe(false); + }); + + it('a dev build, a demo preview build or an explicit config flag allows them', () => { + vi.stubEnv('DEV', true); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', '1'); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed({ debugOverlays: true })).toBe(true); + }); +}); + +describe('overlayRequested', () => { + it('needs both the query and the permission', () => { + search = '?probe=1'; + expect(overlayRequested('probe', true)).toBe(true); + expect(overlayRequested('probe', false)).toBe(false); + expect(overlayRequested('tierlabel', true)).toBe(false); + search = '?tierlabel=1'; + expect(overlayRequested('tierlabel', true)).toBe(true); + search = ''; + expect(overlayRequested('probe', true)).toBe(false); + }); +}); diff --git a/src/app/features/scene/debugOverlays.ts b/src/app/features/scene/debugOverlays.ts new file mode 100644 index 00000000..a02d9e92 --- /dev/null +++ b/src/app/features/scene/debugOverlays.ts @@ -0,0 +1,15 @@ +import type { BackgroundSceneConfig } from './tier'; + +/** + * Whether the page URL may switch on the scene's diagnostic overlays (`?probe=1` frame readout, `?tierlabel=1` path + * label). Only a dev build, a demo preview build (VITE_PREVIEW_DEMO=1), or a config that says `debugOverlays: true` + * allows it; a production visitor adding a query string gets nothing. + */ +export const debugOverlaysAllowed = (config: BackgroundSceneConfig | undefined): boolean => + import.meta.env?.DEV === true || + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + config?.debugOverlays === true; + +/** The page URL asks for this overlay (`?name=1`), and the overlays are allowed. */ +export const overlayRequested = (name: 'probe' | 'tierlabel', allowed: boolean): boolean => + allowed && new URLSearchParams(window.location.search).get(name) === '1'; diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts new file mode 100644 index 00000000..b987d0cc --- /dev/null +++ b/src/app/features/scene/host.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Scene } from './index'; +import { mountBackgroundScene } from './host'; + +const { startBackgroundScene } = vi.hoisted(() => ({ startBackgroundScene: vi.fn() })); +vi.mock('./index', () => ({ startBackgroundScene })); + +let canvas: HTMLCanvasElement; +const config = { tier: 'low' as const }; +const caps = { hardwareConcurrency: 8 }; + +function fakeScene(): Scene & { + login: ReturnType; + dispose: ReturnType; +} { + return { login: vi.fn(), dispose: vi.fn() }; +} +const flush = () => + new Promise((r) => { + setTimeout(r, 0); + }); + +beforeEach(() => { + canvas = new EventTarget() as HTMLCanvasElement; +}); + +afterEach(() => { + startBackgroundScene.mockReset(); +}); + +describe('mountBackgroundScene', () => { + it('starts the scene on the canvas with the config and capabilities', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledWith( + config, + caps, + expect.objectContaining({ canvas }) + ); + expect(onActive).toHaveBeenCalledOnce(); + expect(scene.login).not.toHaveBeenCalled(); + }); + + it('disposes the scene on unmount, once', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + dispose(); + dispose(); + expect(scene.dispose).toHaveBeenCalledOnce(); + }); + + it('disposes a scene that resolves after unmount, without activating or logging in', async () => { + const scene = fakeScene(); + let resolve!: (s: Scene) => void; + startBackgroundScene.mockReturnValue( + new Promise((r) => { + resolve = r; + }) + ); + const onActive = vi.fn(); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + dispose(); + resolve(scene); + await flush(); + expect(scene.dispose).toHaveBeenCalledOnce(); + expect(scene.login).not.toHaveBeenCalled(); + expect(onActive).not.toHaveBeenCalled(); + }); + + it('leaves the ground alone when there is no scene', async () => { + startBackgroundScene.mockResolvedValue(null); + const onActive = vi.fn(); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + await flush(); + expect(onActive).not.toHaveBeenCalled(); + expect(dispose).not.toThrow(); + }); + + it('reports a failed start instead of throwing', async () => { + startBackgroundScene.mockRejectedValue(new Error('boom')); + const onFail = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail }); + await flush(); + expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); + + it('forwards the scene own compile failure to onFail', async () => { + startBackgroundScene.mockImplementation(async (_c, _k, o) => { + o.onCompileFail(new Error('shader')); + return null; + }); + const onFail = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail }); + await flush(); + expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'shader' })); + }); + + it('calls login() once the scene starts when a session already exists', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + mountBackgroundScene(config, caps, canvas, { loggedIn: true }); + await flush(); + expect(scene.login).toHaveBeenCalledOnce(); + }); + + it('hands the door its onInactive and onActive, for a sky-state rebuild that makes no scene', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const onInactive = vi.fn(); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive, onActive }); + await flush(); + const given = startBackgroundScene.mock.calls[0][2]; + onActive.mockClear(); + given.onInactive(); + given.onActive(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).toHaveBeenCalledOnce(); + }); + + describe('a lost WebGL context', () => { + const lose = () => + startBackgroundScene.mock.calls[ + startBackgroundScene.mock.calls.length - 1 + ][2].onContextLost(); + const restore = () => canvas.dispatchEvent(new Event('webglcontextrestored')); + + it('disposes the dead scene and puts the ground back, without rebuilding yet', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + expect(scene.dispose).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('starts a fresh scene on restore, activates it and logs in when a session exists', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledTimes(2); + expect(onActive).toHaveBeenCalledTimes(2); + expect(second.login).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('rebuilds on every loss and restore, disposing each dead scene once', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => startBackgroundScene.mockResolvedValueOnce(s)); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + restore(); + await flush(); + lose(); + restore(); + await flush(); + expect(scenes.map((s) => s.dispose.mock.calls.length)).toEqual([1, 1, 0]); + }); + + it('ignores a restore while a scene is running', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('leaves the ground when the rebuild yields no scene', async () => { + startBackgroundScene.mockResolvedValueOnce(fakeScene()).mockResolvedValueOnce(null); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + }); + + it('does not rebuild after dispose, and removes its restore listener', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + dispose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('removes the exact restore listener it added when it is disposed', async () => { + const add = vi.spyOn(canvas, 'addEventListener'); + const remove = vi.spyOn(canvas, 'removeEventListener'); + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + const added = add.mock.calls.find(([type]) => type === 'webglcontextrestored'); + expect(added).toBeDefined(); + expect(remove).not.toHaveBeenCalled(); + dispose(); + expect(remove).toHaveBeenCalledWith('webglcontextrestored', added?.[1]); + }); + + it('treats a second loss from the same scene as nothing', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + lose(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(scene.dispose).toHaveBeenCalledOnce(); + }); + + it('ignores the old scene loss callback once a new scene has started', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + const oldLost = startBackgroundScene.mock.calls[0][2].onContextLost; + lose(); + restore(); + await flush(); + onInactive.mockClear(); + oldLost(); + expect(second.dispose).not.toHaveBeenCalled(); + expect(onInactive).not.toHaveBeenCalled(); + }); + + it('drops a scene that resolves after a loss that arrived while it started', async () => { + const late = fakeScene(); + let resolve!: (s: Scene) => void; + startBackgroundScene.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }) + ); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + lose(); + resolve(late); + await flush(); + expect(late.dispose).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts new file mode 100644 index 00000000..9a31c812 --- /dev/null +++ b/src/app/features/scene/host.ts @@ -0,0 +1,81 @@ +import { startBackgroundScene } from './index'; +import type { Scene } from './index'; +import type { BackgroundSceneConfig, Capabilities } from './tier'; + +export type HostOptions = { + /** A Matrix session already exists, so the scene starts past its login moment. */ + loggedIn: boolean; + /** The scene is running: the host may now let it show through. Not called when there is none. */ + onActive?: () => void; + /** The scene stopped because its GL context was lost: put the CSS ground back until it restores. */ + onInactive?: () => void; + /** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */ + onFail?: (e: Error) => void; +}; + +/** + * Start the background scene on `canvas` and return the function that ends it. The start is async, + * so a dispose that arrives first wins: the scene is disposed the moment it resolves. A null scene + * (tier off, no 2D context, load failure) leaves the canvas untouched and the ground showing. + * + * A lost WebGL context ends the scene it killed: the host disposes it, calls `onInactive` so the + * CSS ground shows, and starts a fresh scene when the canvas's context is restored. The 2D sky + * cannot take over on this canvas (a canvas that has held a WebGL context gives no 2D one), so the + * ground is the fallback while the context is gone; a restore that cannot rebuild leaves it there. + */ +export function mountBackgroundScene( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities, + canvas: HTMLCanvasElement, + { loggedIn, onActive, onInactive, onFail }: HostOptions +): () => void { + let cancelled = false; + let scene: Scene | null = null; + let generation = 0; + + const start = () => { + generation += 1; + const mine = generation; + const stale = () => cancelled || mine !== generation; + startBackgroundScene(config, capabilities, { + canvas, + onCompileFail: onFail, + onInactive, + onActive, + onContextLost: () => { + if (stale()) return; + generation += 1; + scene?.dispose(); + scene = null; + onInactive?.(); + }, + }).then( + (started) => { + if (stale()) { + started?.dispose(); + return; + } + scene = started; + if (!scene) return; + onActive?.(); + if (loggedIn) scene.login(); + }, + (e) => { + if (!stale()) onFail?.(e instanceof Error ? e : new Error(String(e))); + } + ); + }; + + const onRestored = () => { + if (!cancelled && !scene) start(); + }; + canvas.addEventListener('webglcontextrestored', onRestored); + start(); + + return () => { + cancelled = true; + canvas.removeEventListener('webglcontextrestored', onRestored); + scene?.dispose(); + scene = null; + }; +} diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts new file mode 100644 index 00000000..23c6b297 --- /dev/null +++ b/src/app/features/scene/index.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; +import { baked } from '../../../sky/baked'; +import type { Scene } from './Scene'; +import { startBackgroundScene } from './index'; +import { setSkyClock, setSkyState } from './skyState'; +import { ridgeSkyFor } from './skyForState'; + +const { createRidgeScene, createScene } = vi.hoisted(() => ({ + createRidgeScene: vi.fn(), + createScene: vi.fn(), +})); +vi.mock('./ridge/Ridge', () => ({ createRidgeScene })); +vi.mock('./Scene', () => ({ createScene })); + +const canvas = {} as HTMLCanvasElement; +const config = { tier: 'low' as const }; +const caps = { hardwareConcurrency: 8 }; +const flush = () => + new Promise((r) => { + setTimeout(r, 0); + }); +// Resolves after `n` further turns of the microtask queue. +const microtasks = (n: number) => + Array.from({ length: n }).reduce>( + (p) => p.then(() => undefined), + Promise.resolve() + ); +const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +beforeEach(() => { + vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + // Local midnight: 'auto' resolves to night. + setSkyClock(() => atStandardTime(0, 0)); + setSkyState('auto'); + createRidgeScene.mockReset(); + createScene.mockReset(); +}); +afterEach(() => { + setSkyClock(); + setSkyState('auto'); + vi.unstubAllGlobals(); +}); + +describe('startBackgroundScene follows the sky state', () => { + it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => { + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + scene?.dispose(); + }); + + it('does not rebuild when auto is replaced by the explicit state it already resolves to', async () => { + const a = fakeScene(); + createRidgeScene.mockReturnValueOnce(a); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('night'); + await flush(); + expect(a.dispose).not.toHaveBeenCalled(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('starts on the stored state', async () => { + setSkyState('night'); + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + scene?.dispose(); + }); + + it('rebuilds on a change: the old scene is disposed, the new one draws the new sky', async () => { + const first = fakeScene(); + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(first).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + scene?.dispose(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('rebuilds for a change that landed while the first build was awaited', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const started = startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + const scene = await started; + await flush(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + expect(a.dispose).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(b.dispose).toHaveBeenCalledOnce(); + }); + + it('replays a login onto the rebuilt scene, but only if one happened', async () => { + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(b.login).not.toHaveBeenCalled(); + scene?.login(); + expect(b.login).toHaveBeenCalledOnce(); + setSkyState('dusk'); + await flush(); + expect(c.login).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('puts the ground back when a rebuild makes no scene, and lets it show when one returns', async () => { + const [a, c] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(null).mockReturnValueOnce(c); + const onInactive = vi.fn(); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive, onActive }); + expect(onInactive).not.toHaveBeenCalled(); + setSkyState('day'); + await flush(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + setSkyState('dusk'); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('does not report a dropped rebuild as inactive', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(null); + const onInactive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(onInactive).not.toHaveBeenCalled(); + }); + + it('removes the subscription on dispose', async () => { + const first = fakeScene(); + createRidgeScene.mockReturnValue(first); + const scene = await startBackgroundScene(config, caps, { canvas }); + scene?.dispose(); + scene?.dispose(); + setSkyState('day'); + await flush(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('builds nothing for a rebuild that is disposed before it starts', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + }); + + it('keeps only the latest of two quick changes: the last sky is built, the others are not left running', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + setSkyState('dusk'); + await flush(); + // The sky last built is dusk's, and it is the live scene: a login reaches it and nothing else. + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( + hex(ridgeSkyFor('dusk').top) + ); + const live = scenes[built - 1]; + scene?.login(); + expect(live.login).toHaveBeenCalledOnce(); + scenes.filter((s) => s !== live).forEach((s) => expect(s.login).not.toHaveBeenCalled()); + // Every scene but the live one was disposed, the live one not yet. + scenes.slice(0, built).forEach((s) => { + expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); + }); + scene?.dispose(); + expect(live.dispose).toHaveBeenCalledOnce(); + }); + + // A second sky change `ticks` microtasks after the first. True when it landed mid-build: the build for the first + // change finished, was found stale and dropped (never shown), and only the newest sky was announced. + const changeAfter = async (ticks: number) => { + createRidgeScene.mockReset(); + setSkyState('auto'); + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onActive }); + setSkyState('day'); + await microtasks(ticks); + setSkyState('dusk'); + await flush(); + const at = `at ${ticks} microtasks`; + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top), at).toBe( + hex(ridgeSkyFor('dusk').top) + ); + // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. + const live = [a, b, c][built - 1]; + scene?.login(); + expect(live.login, at).toHaveBeenCalledOnce(); + [a, b, c].slice(0, built).forEach((x) => { + expect(x.dispose, at).toHaveBeenCalledTimes(x === live ? 0 : 1); + if (x !== live) expect(x.login, at).not.toHaveBeenCalled(); + }); + scene?.dispose(); + expect(live.dispose, at).toHaveBeenCalledOnce(); + return built === 3 && onActive.mock.calls.length === 1; + }; + + it('a sky change at any point of a build leaves only the newer build live; the stale one is disposed', async () => { + // The offsets are swept, not pinned, so a shift in the awaits inside the build cannot move the change off the + // build: at least one offset must land mid-build, or the sweep proves nothing. + const offsets = Array.from({ length: 21 }, (_, i) => i); + const landed = await offsets.reduce>( + (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), + Promise.resolve([]) + ); + // This can fail after a correct refactor that changes the await count in create/skyKey and moves every offset + // off the build. Then widen the range of offsets until some land again; do not weaken the assertion. + expect(landed.length, 'at least one offset must land mid-build').toBeGreaterThan(0); + }); + + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { + const inner = fakeScene() as unknown as Scene; + createScene.mockReturnValue(inner); + const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { canvas }); + expect(scene).toBe(inner); + setSkyState('day'); + await flush(); + expect(createScene).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index c896649e..bc041672 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,19 +1,80 @@ import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; +import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { debugOverlaysAllowed } from './debugOverlays'; +import { getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; -export type { SceneTier } from './tier'; +export type { SceneStyle, SceneTier } from './tier'; export type StartOptions = Omit & { /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ onCompileFail?: (e: Error) => void; + /** A rebuild for a new sky state made no scene: the host puts the CSS ground back (as on a lost context). */ + onInactive?: () => void; + /** A rebuild made a scene again: the host lets it show. */ + onActive?: () => void; }; +async function create( + config: BackgroundSceneConfig | undefined, + options: StartOptions & { tier: SceneTier; debug?: boolean }, + state: SkyState +): Promise { + let createScene: (o: SceneOptions) => Scene | null; + try { + // Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables. + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const sky = terrainSkyFor(state); + const scene = createTerrainScene({ ...options, ...sky }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, ...sky }); + } else { + const { createRidgeScene } = await import('./ridge/Ridge'); + return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); + } + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } + return createScene(options); +} + +/** + * What a rebuild is for: the sky options the scene would be built from. A change of choice that draws the same sky + * (Day picked while the clock says day, 'auto' to the fixed state it resolves to) is not a rebuild; the page URL steers + * only 'auto' (`terrainSkyFor`), so there the two differ and the key says so. + */ +async function skyKey( + config: BackgroundSceneConfig | undefined, + state: SkyState +): Promise { + try { + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); + return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state)); + } catch { + // The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure). + return null; + } +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the - * main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline, - * stale deploy): the failure goes to onCompileFail and nothing throws. + * main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain' (which falls back to its + * sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means + * "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the + * failure goes to onCompileFail and nothing throws. + * + * The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) by + * being rebuilt on the same canvas when it changes: the door subscribes on start and the returned + * scene's `dispose` unsubscribes. The pull-back restarts from the tree; a later `login()` is + * replayed on the new scene. The ray-march scene has one fixed dusk and ignores the state. */ export async function startBackgroundScene( config: BackgroundSceneConfig | undefined, @@ -22,12 +83,56 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - let createScene: typeof import('./Scene').createScene; - try { - ({ createScene } = await import('./Scene')); - } catch (e) { - options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); - return null; - } - return createScene({ ...options, tier }); + const debug = debugOverlaysAllowed(config); + const build = (state: SkyState) => create(config, { ...options, tier, debug }, state); + const state = getSkyState(); + const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); + const first = await build(state); + if (!first || config?.style === 'raymarch') return first; + + let inner: Scene | null = first; + let built = key0; + let loggedIn = false; + let disposed = false; + let generation = 0; + // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. + const rebuild = async () => { + // The choice can move while the key is awaited; that change has its own rebuild, so this one stands down. + const next = getSkyState(); + const key = await skyKey(config, next); + if (getSkyState() !== next) return; + if ((key !== null && key === built) || disposed) return; + generation += 1; + const mine = generation; + built = key; + inner?.dispose(); + inner = null; + const scene = await build(next); + if (disposed || mine !== generation) { + scene?.dispose(); + return; + } + inner = scene; + if (!scene) options.onInactive?.(); + else { + options.onActive?.(); + if (loggedIn) scene.login(); + } + }; + const off = subscribe(rebuild); + // A change that landed while the first build was awaited was missed by the subscription. + rebuild(); + return { + login() { + loggedIn = true; + inner?.login(); + }, + dispose() { + if (disposed) return; + disposed = true; + off(); + inner?.dispose(); + inner = null; + }, + }; } diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts new file mode 100644 index 00000000..adee2717 --- /dev/null +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -0,0 +1,690 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRidgeScene } from './Ridge'; +import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise'; +import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe'; +import { + AERIAL, + FBM, + LAYERS, + edgeBlur, + hazeColour, + layerColour, + layerSize, + profile, + ridgeY, + scatterAlpha, + transmittance, +} from './ridgeWorld'; +import { skyColours, SkyColours } from './skyColour'; +import { makeStars } from './stars'; +import { startBackgroundScene } from '../index'; + +describe('noise', () => { + it('is deterministic and in [0, 1)', () => { + expect(hash2(3, 5)).toBe(hash2(3, 5)); + for (let i = 0; i < 200; i += 1) { + const v = valueNoisePeriodic(i * 0.37, 2, 7); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('wraps: the lattice repeats every `period` cells', () => { + for (const x of [0, 0.4, 3.9, 6.99]) { + expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12); + } + }); + + it('different rows are different profiles', () => { + expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3); + }); +}); + +describe('ridge profile', () => { + it('every layer is periodic in t, so a drift of one period loops seamlessly', () => { + for (const spec of LAYERS) { + for (const t of [0, 0.13, 0.5, 0.97]) { + expect(Number.isFinite(profile(spec, t))).toBe(true); + } + // the end of one period meets the start of the next + expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3); + } + }); + + it('every octave count is an integer lattice, which periodicity needs', () => { + for (const spec of LAYERS) { + for (let k = 0; k < FBM.octaves; k += 1) { + expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true); + } + } + }); + + it('stays in [0, 1] and layers differ', () => { + for (const spec of LAYERS) { + for (let i = 0; i < 100; i += 1) { + const p = profile(spec, i / 100); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThanOrEqual(1); + } + } + expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3); + }); + + it('ridge tops lie inside the layer band and rise with farther base', () => { + const size = layerSize(400, 800, 1); + for (const spec of LAYERS) { + for (let x = 0; x < size.period; x += 17) { + const y = ridgeY(spec, x, size.period, size.height, 800); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(size.height); + } + } + }); + + it('layers get darker and slower toward the viewer, and their sizes are integers', () => { + const sky = skyColours(); + const lum = (c: readonly number[]) => c[0] + c[1] + c[2]; + for (let i = 1; i < LAYERS.length; i += 1) { + expect(lum(layerColour(LAYERS[i], sky))).toBeLessThan(lum(layerColour(LAYERS[i - 1], sky))); + expect(LAYERS[i].seconds).toBeLessThan(LAYERS[i - 1].seconds); + } + for (const [w, h, s] of [ + [393, 851, 2], + [1280, 720, 1], + [412.5, 915.3, 2], + ] as const) { + const z = layerSize(w, h, s); + expect( + Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height) + ).toBe(true); + expect(z.width).toBe(2 * z.period); + } + }); +}); + +describe('aerial perspective', () => { + const sky = skyColours(); + const lum = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + const chroma = (c: readonly number[]) => Math.max(...c) - Math.min(...c); + const far = LAYERS[0]; + const near = LAYERS[LAYERS.length - 1]; + + it('transmittance is Beer-Lambert in distance: 1 at the viewer, exp(-k d) behind, strictly falling', () => { + expect(transmittance(near)).toBe(1); + for (const l of LAYERS) + expect(transmittance(l)).toBeCloseTo(Math.exp(-AERIAL.extinction * (1 - l.depth)), 12); + for (let i = 1; i < LAYERS.length; i += 1) + expect(transmittance(LAYERS[i])).toBeGreaterThan(transmittance(LAYERS[i - 1])); + // two layers' optical depths add: T(a)*T(b) = T(a+b) + const t = (d: number) => Math.exp(-AERIAL.extinction * d); + expect(t(0.3) * t(0.4)).toBeCloseTo(t(0.7), 12); + }); + + it('the nearest layer is exactly the ground colour; the farthest tends to the haze', () => { + expect(layerColour(near, sky)).toEqual([...sky.ground]); + const h = hazeColour(sky); + const f = layerColour(far, sky); + const dist = (a: readonly number[], b: readonly number[]) => + Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + expect(dist(f, h)).toBeLessThan(dist(sky.ground, h) * (transmittance(far) + 0.2)); + }); + + it('colour moves toward the haze and loses chroma with distance, beyond what the mix alone does', () => { + const h = hazeColour(sky); + for (const l of LAYERS.slice(0, -1)) { + const t = transmittance(l); + const c = layerColour(l, sky); + const mixed = [0, 1, 2].map((i) => sky.ground[i] * t + h[i] * (1 - t)); + expect(chroma(c)).toBeLessThan(chroma(mixed)); + expect(Math.abs(lum(c) - lum(mixed))).toBeLessThan(1.5); // desaturation keeps luma + } + }); + + it('edge softening grows with distance and is zero at the viewer', () => { + expect(edgeBlur(near)).toBe(0); + for (let i = 1; i < LAYERS.length; i += 1) + expect(edgeBlur(LAYERS[i])).toBeLessThan(edgeBlur(LAYERS[i - 1])); + expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx); + }); + + it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => { + const az = 0.3; + const s = { ...sky, sunAzimuth: az }; + expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5)); + expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12); + expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter); + expect(scatterAlpha(near, s, az)).toBe(0); + expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az)); + expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0); + }); +}); + +describe('frame sampler', () => { + const feed = (s: FrameSampler, dt: number, frames: number, from = 0) => { + let t = from; + let done = false; + for (let i = 0; i < frames && !done; i += 1) { + t += dt; + done = s.push(t); + } + return done; + }; + + it('median and p95 of known intervals', () => { + const v = Array.from({ length: 100 }, (_, i) => (i < 90 ? 16 : 50)); + expect(frameStats(v)).toEqual({ n: 100, medianMs: 16, p95Ms: 50 }); + expect(frameStats([])).toEqual({ n: 0, medianMs: 0, p95Ms: 0 }); + }); + + it('does not complete before 2 s of measured frames, and ignores the warm-up', () => { + const s = new FrameSampler(); + expect(feed(s, 16, 100)).toBe(false); // 1.6 s + expect(feed(s, 16, 40, 1600)).toBe(true); + expect(s.stats().n).toBeLessThan(100 + 40 - WARMUP_FRAMES + 1); + }); + + it('a smooth 60 Hz run is not slow; a run of 40 ms frames is', () => { + const good = new FrameSampler(); + feed(good, 16.7, 400); + expect(good.slow()).toBe(false); + const bad = new FrameSampler(); + feed(bad, 100, 400); // ~100 ms frames, the PR #13 readout + expect(bad.stats().p95Ms).toBeGreaterThan(SLOW_P95_MS); + expect(bad.slow()).toBe(true); + }); + + it('a long warm-up frame does not poison the window', () => { + const s = new FrameSampler(); + let t = 0; + s.push(t); + for (let i = 0; i < WARMUP_FRAMES; i += 1) s.push((t += 300)); // setup, upload, JIT + feed(s, 16.7, 200, t); + expect(s.slow()).toBe(false); + expect(WINDOW_MS).toBe(2000); + }); + + it('reset forgets a hidden-tab gap', () => { + const s = new FrameSampler(); + feed(s, 16, 20); + s.reset(); + expect(s.stats().n).toBe(0); + }); +}); + +// A minimal DOM: just what createRidgeScene touches. +function fakeEl(tag = 'div') { + const el: any = { + tag, + style: {}, + children: [] as any[], + parentElement: null as any, + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + attrs: {} as Record, + setAttribute(k: string, v: string) { + this.attrs[k] = v; + }, + getContext: vi.fn(() => ctx()), + remove() { + if (this.parentElement) + this.parentElement.children = this.parentElement.children.filter((c: any) => c !== this); + this.parentElement = null; + }, + appendChild(c: any) { + c.parentElement = this; + this.children.push(c); + return c; + }, + animate: vi.fn(() => { + const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() }; + el.anims.push(a); + return a; + }), + anims: [] as any[], + }; + return el; +} +// A day sky has no stars, so the scene tests below see only the ridge loop; the dusk default +// carries stars and a twinkle overlay, tested in 'the starfield in the scene'. +const DAY: SkyColours = { + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], + ground: [7, 17, 26], + sunAzimuth: 0.3, +}; +const draws: string[] = []; +function ctx(withFilter = false) { + const c: any = { + globalCompositeOperation: 'source-over', + createLinearGradient: () => ({ addColorStop: vi.fn() }), + fillRect() { + draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky'); + }, + beginPath() {}, + moveTo() {}, + lineTo() {}, + closePath() {}, + // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. + clearRect() { + c.overlay = true; + draws.push('clear'); + }, + arc() { + draws.push(c.overlay ? 'twinkle' : 'star'); + c.arcing = true; + }, + fill: () => { + if (c.arcing) c.arcing = false; + else draws.push('fill'); + }, + drawImage: () => draws.push('flatten'), + set fillStyle(_v: unknown) {}, + }; + if (withFilter) c.filter = 'none'; + return c; +} + +let rafQueue: Array<(t: number) => void>; +let motion: { matches: boolean; addEventListener: any; removeEventListener: any }; +let doc: any; +let search: string; + +beforeEach(() => { + rafQueue = []; + draws.length = 0; + search = ''; + motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }; + doc = Object.assign(new EventTarget(), { + visibilityState: 'visible', + createElement: (t: string) => fakeEl(t), + }); + vi.stubGlobal('document', doc); + vi.stubGlobal( + 'window', + Object.assign(new EventTarget(), { + devicePixelRatio: 2, + innerWidth: 400, + innerHeight: 800, + matchMedia: () => motion, + location: { + get search() { + return search; + }, + }, + setTimeout, + }) + ); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + rafQueue = []; + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +function host() { + const parent = fakeEl('div'); + const canvas = fakeEl('canvas'); + parent.appendChild(canvas); + return { parent, canvas }; +} +const runFrames = (dt: number, n: number, from = 0) => { + let t = from; + for (let i = 0; i < n && rafQueue.length; i += 1) { + t += dt; + // One display frame: every callback queued for it sees the same timestamp. + rafQueue.splice(0).forEach((cb) => cb(t)); + } + return t; +}; + +describe('createRidgeScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = host(); + expect(createRidgeScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when 2D context is missing, so the host keeps its CSS ground', () => { + const { canvas } = host(); + canvas.getContext = vi.fn(() => null); + expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); + }); + + it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(s).not.toBeNull(); + const layers = parent.children.slice(1); + expect(layers).toHaveLength(LAYERS.length); + layers.forEach((l: any) => { + expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true); + expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide + expect(l.animate).toHaveBeenCalledTimes(1); + }); + expect(draws.filter((d) => d === 'sky')).toHaveLength(1); + expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length); + s.dispose(); + }); + + it('after the layers are painted, a smooth run draws nothing more and ends its rAF loop', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const before = draws.length; + runFrames(16.7, 400); + expect(draws.length).toBe(before); + expect(rafQueue).toHaveLength(0); // no per-frame work left + s.dispose(); + }); + + it('p95 over 33 ms across 2 s flattens to a still image and removes the layers', () => { + const { parent, canvas } = host(); + const onProbe = vi.fn(); + const s = createRidgeScene({ canvas, tier: 'full', onProbe, sky: DAY })!; + runFrames(100, 100); + expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); + expect(parent.children).toEqual([canvas]); + expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it("'still' tier flattens immediately and never starts a loop", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still' }); + expect(parent.children).toEqual([canvas]); + expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); + expect(rafQueue).toHaveLength(0); + }); + + it('prefers-reduced-motion: layers are built but never played, and nothing is sampled', () => { + motion.matches = true; + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims)); + expect(anims.length).toBe(LAYERS.length); + anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('pauses the drift when the tab is hidden and resumes when it is visible', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const anims = parent.children.slice(1).flatMap((l: any) => l.anims); + anims.forEach((a: any) => a.pause.mockClear()); + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + anims.forEach((a: any) => expect(a.pause).toHaveBeenCalled()); + anims.forEach((a: any) => a.play.mockClear()); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + anims.forEach((a: any) => expect(a.play).toHaveBeenCalled()); + s.dispose(); + }); + + it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { + search = '?probe=1'; + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, debug: true })!; + const readout = parent.children[parent.children.length - 1]; + runFrames(16.7, 300); + expect(rafQueue.length).toBe(1); // still sampling + expect(readout.textContent).toMatch(/median 16\.\d ms · p95 1[67]\.\d ms/); + s.dispose(); + expect(parent.children.some((c: any) => c === readout)).toBe(false); + }); + + it('?probe=1 does nothing where the overlays are not allowed: no readout, the loop ends', () => { + search = '?probe=1'; + const { parent, canvas } = host(); + const before = parent.children.length; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(parent.children.length).toBe(before + LAYERS.length); + runFrames(16.7, 300); + expect(rafQueue.length).toBe(0); + s.dispose(); + }); + + it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => { + const { parent, canvas } = host(); + canvas.clientWidth = 0; + canvas.clientHeight = 0; + (window as any).innerWidth = 0; + (window as any).innerHeight = 0; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY }); + expect(s).not.toBeNull(); + expect(parent.children).toEqual([canvas]); + expect(draws).toHaveLength(0); + // Still zero: a resize does not paint either. + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children).toEqual([canvas]); + (window as any).innerWidth = 400; + (window as any).innerHeight = 800; + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children.slice(1)).toHaveLength(LAYERS.length); + expect(draws.filter((d) => d === 'sky')).toHaveLength(1); + s!.dispose(); + expect(parent.children).toEqual([canvas]); + }); + + it('dispose cancels the loop, the animations and the layers', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const anims = parent.children.slice(1).flatMap((l: any) => l.anims); + s.dispose(); + anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe('the door', () => { + it('runs the ridge scene by default and never requests a WebGL context', async () => { + const { canvas } = host(); + const getContext = vi.fn((kind: string) => (kind === '2d' ? ctx() : null)); + canvas.getContext = getContext; + const scene = await startBackgroundScene({ tier: 'full' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(getContext.mock.calls.every(([k]) => k === '2d')).toBe(true); + scene!.dispose(); + }); + + it('the tier flag still defaults to off: nothing imported, no context', async () => { + const { canvas } = host(); + expect(await startBackgroundScene(undefined, {}, { canvas })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); +}); + +describe('fbm', () => { + it('is normalised into [0, 1]', () => { + for (let i = 0; i < 100; i += 1) { + const v = fbmPeriodic(i / 100, 0, 3, FBM); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); +}); + +describe('aerial perspective in the scene', () => { + it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(draws.filter((d) => d === 'scatter')).toHaveLength(LAYERS.length - 1); + const before = draws.length; + runFrames(16.7, 400); + expect(draws.length).toBe(before); + s.dispose(); + }); + + it('a sky without a sun bakes no scatter', () => { + const { canvas } = host(); + const s = createRidgeScene({ + canvas, + tier: 'full', + sky: { ...DAY, sunAzimuth: undefined }, + })!; + expect(draws.filter((d) => d === 'scatter')).toHaveLength(0); + s.dispose(); + }); + + it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => { + const { parent, canvas } = host(); + const filters: string[] = []; + const orig = (document as any).createElement; + (document as any).createElement = (t: string) => { + const el = orig(t); + el.getContext = vi.fn(() => { + const c = ctx(true); + let f = 'none'; + Object.defineProperty(c, 'filter', { + get: () => f, + set: (v: string) => { + f = v; + filters.push(v); + }, + }); + return c; + }); + return el; + }; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const blurs = filters.filter((f) => f !== 'none'); + expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp + expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2 + expect(parent.children).toHaveLength(1 + LAYERS.length); + s.dispose(); + }); + + it("'still' tier keeps the sky and flattens the aerial layers without extra elements", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still' }); + expect(parent.children).toEqual([canvas]); + expect(draws.filter((d) => d === 'sky')).toHaveLength(1); + expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); + }); +}); + +describe('the starfield in the scene', () => { + const count = (kind: string) => draws.filter((d) => d === kind).length; + const night = { + ...DAY, + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], + } as SkyColours; + const N = makeStars(night, 400, 800).length; + const hear = (name: string) => { + const calls = motion.addEventListener.mock.calls.filter((c: any[]) => c[0] === name); + return calls[calls.length - 1][1] as () => void; + }; + // Everything but the overlay's own marks: the bake and the ridges. + const baked = () => draws.filter((d) => d !== 'twinkle' && d !== 'clear').length; + + it('bakes every star once into the sky; per frame only the overlay is touched', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(count('star')).toBe(N); + expect(count('sky')).toBe(1); + expect(parent.children.indexOf(parent.children[1])).toBe(1); + expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges + expect(parent.children).toHaveLength(2 + LAYERS.length); + const before = baked(); + runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps + expect(baked()).toBe(before); + expect(count('clear')).toBeGreaterThan(60); + expect(count('clear')).toBeLessThanOrEqual(101); + // each pass paints at most SLOTS stars + expect(count('twinkle')).toBeLessThanOrEqual(count('clear') * 32); + expect(count('twinkle')).toBeGreaterThan(0); + s.dispose(); + }); + + it('a day sky has no stars and no overlay layer', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(count('star')).toBe(0); + expect(parent.children).toHaveLength(1 + LAYERS.length); + runFrames(16.7, 60); + expect(count('clear')).toBe(0); + s.dispose(); + }); + + it("tier 'still' bakes the stars and has no overlay or loop", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still', sky: night }); + expect(count('star')).toBe(N); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); + + it('stops twinkling under reduced motion, live, and resumes when it is lifted', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + expect(count('twinkle')).toBeGreaterThan(0); + motion.matches = true; + hear('change')(); + t = runFrames(16.7, 5, t); + const stopped = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(stopped); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + hear('change')(); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(stopped); + s.dispose(); + }); + + it('starts steady, with nothing painted over the stars, when reduced motion is already on', () => { + motion.matches = true; + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(rafQueue).toHaveLength(0); + runFrames(16.7, 60); + expect(count('twinkle')).toBe(0); + s.dispose(); + }); + + it('stops while the tab is hidden and resumes when it is visible', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + const hidden = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(hidden); + expect(rafQueue).toHaveLength(0); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(hidden); + s.dispose(); + }); + + it('flattening to a still (slow frames) drops the overlay and leaves every star baked under the ridges', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + runFrames(100, 100); + expect(parent.children).toEqual([canvas]); + expect(count('star')).toBe(N); + expect(draws.indexOf('flatten')).toBeGreaterThan(draws.lastIndexOf('star')); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('dispose removes the overlay and stops the loop', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + s.dispose(); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts new file mode 100644 index 00000000..fd40a967 --- /dev/null +++ b/src/app/features/scene/ridge/Ridge.ts @@ -0,0 +1,415 @@ +import type { Scene, SceneOptions } from '../Scene'; +import { overlayRequested } from '../debugOverlays'; +import { FrameSampler, FrameStats } from './probe'; +import { skyColours, SkyColours, Srgb } from './skyColour'; +import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; +import { + LAYERS, + LayerSpec, + edgeBlur, + hazeColour, + layerColour, + layerSize, + ridgeY, + scatterAlpha, +} from './ridgeWorld'; + +export type RidgeOptions = Pick & { + /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */ + probe?: boolean; + /** The diagnostic overlays may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; + /** Each time the readout would update, and once with `still` when the fallback fires. */ + onProbe?: (s: FrameStats & { still: boolean }) => void; + /** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */ + sky?: SkyColours; +}; + +const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`; +const mix = (a: Srgb, b: Srgb, t: number): Srgb => [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), +]; + +function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null { + return c.getContext('2d'); +} + +function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean { + const ctx = context2d(canvas); + if (!ctx) return false; + const g = ctx.createLinearGradient(0, 0, 0, canvas.height); + g.addColorStop(0, css(sky.top)); + g.addColorStop(0.5, css(sky.mid)); + g.addColorStop(0.78, css(sky.horizon)); + g.addColorStop(1, css(sky.horizon)); + ctx.fillStyle = g; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return true; +} + +const SCATTER_STOPS = 16; + +/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */ +function scatter( + ctx: CanvasRenderingContext2D, + spec: LayerSpec, + sky: SkyColours, + size: { period: number; width: number; height: number } +) { + if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return; + const g = ctx.createLinearGradient(0, 0, size.width, 0); + const [r, gr, b] = sky.horizon; + for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) { + const t = (k % SCATTER_STOPS) / SCATTER_STOPS; + g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`); + } + ctx.globalCompositeOperation = 'source-atop'; + ctx.fillStyle = g; + ctx.fillRect(0, 0, size.width, size.height); + ctx.globalCompositeOperation = 'source-over'; +} + +/** + * Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not + * Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below, + * then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders. + */ +function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement { + const probe = context2d(src); + if (!probe || px <= 0 || !('filter' in probe)) return src; + const pad = Math.ceil(3 * px); + // The unfiltered tiling: blurring tile by tile would fade each tile's own border. + const tiled = document.createElement('canvas'); + tiled.width = src.width + 2 * period; + tiled.height = src.height + pad; + const tctx = context2d(tiled); + const out = document.createElement('canvas'); + out.width = src.width; + out.height = src.height; + const ctx = context2d(out); + if (!tctx || !ctx) return src; + for (let k = 0; k < 4; k += 1) { + tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height); + tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad); + } + ctx.filter = `blur(${px}px)`; + ctx.drawImage(tiled, -period, 0); + ctx.filter = 'none'; + return out; +} + +/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */ +function paintLayer( + spec: LayerSpec, + sky: SkyColours, + size: { period: number; width: number; height: number }, + viewH: number, + scale: number +): HTMLCanvasElement | null { + const el = document.createElement('canvas'); + el.width = size.width; + el.height = size.height; + const ctx = context2d(el); + if (!ctx) return null; + const top: number[] = []; + for (let x = 0; x < size.period; x += 1) + top.push(ridgeY(spec, x, size.period, size.height, viewH)); + const c = layerColour(spec, sky); + const g = ctx.createLinearGradient(0, 0, 0, size.height); + g.addColorStop(0, css(c)); + g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth)))); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(0, size.height); + for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]); + ctx.lineTo(size.width, size.height); + ctx.closePath(); + ctx.fill(); + scatter(ctx, spec, sky, size); + return soften(el, edgeBlur(spec) * scale, size.period); +} + +interface Built { + layers: HTMLCanvasElement[]; + anims: Animation[]; + /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ + twinkle?: HTMLCanvasElement; +} + +/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */ +const TWINKLE_FRAME_MS = 50; + +/** + * The ridge scene: same contract as `createScene` ({ login, dispose }, null for "keep the CSS + * ground"), no WebGL. The layers are painted once on the CPU; per frame the only work is the + * browser compositor sliding each layer with a CSS transform (a Web Animation, no script). The host + * canvas holds the baked sky; it and the layers' parent must be positioned, and the canvas should fill it. + */ +export function createRidgeScene(opts: RidgeOptions): Scene | null { + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const canvas = opts.canvas; + const parent = canvas.parentElement; + if (!parent) return null; + const sky = opts.sky ?? skyColours(); + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + + let built: Built | undefined; + let field: Star[] = []; + let twRaf = 0; + let twLast = 0; + let still = tier === 'still'; + let disposed = false; + let raf = 0; + let readout: HTMLElement | undefined; + let resizeTimer = 0; + const sampler = new FrameSampler(); + + const viewSize = () => { + const w = canvas.clientWidth || window.innerWidth; + const h = canvas.clientHeight || window.innerHeight; + return { w, h, scale: (window.devicePixelRatio || 1) >= 2 ? 2 : 1 }; + }; + + const teardownLayers = () => { + built?.anims.forEach((a) => a.cancel()); + built?.layers.forEach((l) => l.remove()); + built?.twinkle?.remove(); + cancelAnimationFrame(twRaf); + twRaf = 0; + built = undefined; + }; + + /** True while there is nothing to paint on: the canvas has no client size and the window itself is 0 x 0 (`viewSize` falls back to the window). */ + const hasNoArea = () => { + const { w, h, scale } = viewSize(); + const size = layerSize(w, h, scale); + return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1; + }; + + /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ + const build = (): boolean => { + teardownLayers(); + // A zero-size canvas throws on drawImage; wait for the first resize that gives the host an area. + if (hasNoArea()) return false; + const { w, h, scale } = viewSize(); + const size = layerSize(w, h, scale); + canvas.width = size.period; + canvas.height = Math.round(h * scale); + if (!paintSky(canvas, sky)) return false; + const layers: HTMLCanvasElement[] = []; + // Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame + // pass clears and paints over. 'still' has no overlay. + const stars = makeStars(sky, w, h); + const skyCtx = context2d(canvas); + if (skyCtx) drawStars(skyCtx, stars, canvas.width, canvas.height, scale); + let twinkle: HTMLCanvasElement | undefined; + if (stars.length && tier !== 'still') { + twinkle = document.createElement('canvas'); + twinkle.width = canvas.width; + twinkle.height = canvas.height; + twinkle.setAttribute('aria-hidden', 'true'); + Object.assign(twinkle.style, { + position: 'absolute', + left: '0', + top: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + parent.appendChild(twinkle); + } + field = stars; + for (const spec of LAYERS) { + const el = paintLayer(spec, sky, size, canvas.height, scale); + if (!el) { + layers.forEach((l) => l.remove()); + twinkle?.remove(); + return false; + } + el.setAttribute('aria-hidden', 'true'); + Object.assign(el.style, { + position: 'absolute', + left: '0', + bottom: '0', + width: `${size.width / scale}px`, + height: `${size.height / scale}px`, + pointerEvents: 'none', + willChange: 'transform', + }); + layers.push(el); + } + // DOM order is paint order: farthest first, so the nearest ridge ends up on top. + layers.forEach((l) => parent.appendChild(l)); + const anims = layers + .map((el, i) => + el.animate?.( + [ + { transform: 'translate3d(0,0,0)' }, + { transform: `translate3d(${-size.period / scale}px,0,0)` }, + ], + { duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' } + ) + ) + .filter((a): a is Animation => !!a); + built = { layers, anims, twinkle }; + syncPlayState(); + return true; + }; + + const moving = () => + !still && + !disposed && + !(reducedMotion?.matches ?? false) && + document.visibilityState === 'visible'; + + /** Paint the overlay at t seconds (stars away from base), or just clear it with no t (all at base). */ + const drawTwinkle = (t?: number) => { + const el = built?.twinkle; + const ctx = el && context2d(el); + if (!el || !ctx) return; + const active = t === undefined ? [] : activeAt(field.length, t); + paintTwinkle(ctx, field, active, el.width, el.height, viewSize().scale); + }; + + // The same guard as the drift: not still, not disposed, not reduced motion, tab visible. Not moving + // stops the loop and leaves the stars steady at base brightness. + const twTick = (now: number) => { + twRaf = 0; + if (!moving() || !built?.twinkle) return; + if (now - twLast >= TWINKLE_FRAME_MS) { + twLast = now; + drawTwinkle(now / 1000); + } + twRaf = requestAnimationFrame(twTick); + }; + + function syncPlayState() { + built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); + if (!built?.twinkle) return; + if (moving()) { + if (!twRaf) twRaf = requestAnimationFrame(twTick); + } else { + cancelAnimationFrame(twRaf); + twRaf = 0; + drawTwinkle(); + } + } + + /** Flatten the sky and layers into the host canvas and drop the layers: a still image, no compositor layers left. */ + const settleToStill = () => { + if (still && !built) return; + still = true; + const ctx = context2d(canvas); + if (ctx && built) { + const { anims } = built; + built.layers.forEach((l, i) => { + // Freeze where the drift is, so the swap to a still does not jump. + const a = anims[i]; + const t = Number(a?.currentTime ?? 0); + const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000); + ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height); + }); + } + teardownLayers(); + cancelAnimationFrame(raf); + raf = 0; + report(true); + }; + + const report = (stillNow: boolean) => { + const s = { ...sampler.stats(), still: stillNow }; + opts.onProbe?.(s); + if (readout) { + readout.textContent = `ridge · n ${s.n} · median ${s.medianMs.toFixed( + 1 + )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; + } + }; + + let sinceReport = 0; + const tick = (now: number) => { + raf = 0; + if (!moving()) return; + const full = sampler.push(now); + if (full && sampler.slow() && !still) { + settleToStill(); + return; + } + // After the window the series keeps rolling only for the readout; otherwise the loop ends here. + if (full && !probe) return; + if (probe && now - sinceReport > 500) { + sinceReport = now; + report(false); + } + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + syncPlayState(); + if (!moving() || raf) return; + sampler.reset(); + raf = requestAnimationFrame(tick); + }; + + // No area yet is not a failure: the resize listener below builds once the host has one. + if (!build() && !hasNoArea()) return null; + if (probe) { + readout = document.createElement('div'); + Object.assign(readout.style, { + position: 'absolute', + left: '8px', + top: '8px', + font: '12px monospace', + color: '#fff', + background: 'rgba(0,0,0,.55)', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(readout); + } + if (still) settleToStill(); + else wake(); + + const onVisibility = () => { + sampler.reset(); + wake(); + }; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + const wasStill = still; + if (!build()) return; + if (wasStill) settleToStill(); + else { + sampler.reset(); + wake(); + } + }, 250); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + reducedMotion?.addEventListener?.('change', syncPlayState); + + return { + login() { + // No camera to pull back; the ridges are the same before and after. + }, + dispose() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(resizeTimer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + reducedMotion?.removeEventListener?.('change', syncPlayState); + teardownLayers(); + readout?.remove(); + }, + }; +} diff --git a/src/app/features/scene/ridge/noise.ts b/src/app/features/scene/ridge/noise.ts new file mode 100644 index 00000000..77af507d --- /dev/null +++ b/src/app/features/scene/ridge/noise.ts @@ -0,0 +1,51 @@ +// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the +// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied +// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last +// digits; nothing here has to match a shader. + +const frac = (v: number): number => v - Math.floor(v); + +/** `cultmath_hash(vec2)`. */ +export const hash2 = (x: number, y: number): number => + frac(Math.sin(x * 127.1 + y * 311.7) * 43758.5453); + +const smooth = (t: number): number => t * t * (3 - 2 * t); + +/** + * `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period` + * cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D + * original is the identity. + */ +export function valueNoisePeriodic(x: number, row: number, period: number): number { + const cell = Math.floor(x); + const u = smooth(x - cell); + const wrap = (c: number) => ((c % period) + period) % period; + const a = hash2(wrap(cell), row); + const b = hash2(wrap(cell + 1), row); + return a + (b - a) * u; +} + +export interface FbmShape { + octaves: number; + lacunarity: number; + gain: number; +} + +/** + * Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells + * per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an + * integer for the sum to be periodic (checked by the caller's config, not here). + */ +export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number { + let sum = 0; + let norm = 0; + let amp = 1; + let cells = baseCells; + for (let k = 0; k < s.octaves; k += 1) { + sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells); + norm += amp; + amp *= s.gain; + cells *= s.lacunarity; + } + return sum / norm; +} diff --git a/src/app/features/scene/ridge/probe.ts b/src/app/features/scene/ridge/probe.ts new file mode 100644 index 00000000..5ab9c57d --- /dev/null +++ b/src/app/features/scene/ridge/probe.ts @@ -0,0 +1,60 @@ +// Frame cadence from rAF timestamps: what `?probe=1` shows, and what the still-image fallback +// decides on. Pure, so it is tested without a browser. + +export interface FrameStats { + /** Frame intervals counted. */ + n: number; + medianMs: number; + p95Ms: number; +} + +/** The fallback's threshold and window, from the brief: p95 over 33 ms across 2 s. */ +export const SLOW_P95_MS = 33; +export const WINDOW_MS = 2000; +/** The first frames pay for layout, canvas upload and JIT; they say nothing about steady cadence. */ +export const WARMUP_FRAMES = 5; + +const quantile = (sorted: readonly number[], q: number): number => + sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; + +export function frameStats(intervals: readonly number[]): FrameStats { + const s = [...intervals].sort((a, b) => a - b); + return { n: s.length, medianMs: quantile(s, 0.5), p95Ms: quantile(s, 0.95) }; +} + +export class FrameSampler { + private last: number | undefined; + private skipped = 0; + private startedAt: number | undefined; + private readonly intervals: number[] = []; + + /** Feed each rAF timestamp. Returns true once a full window has been measured. */ + push(now: number): boolean { + const prev = this.last; + this.last = now; + if (prev === undefined) return false; + if (this.skipped < WARMUP_FRAMES) { + this.skipped += 1; + return false; + } + this.startedAt ??= prev; + this.intervals.push(now - prev); + return now - this.startedAt >= WINDOW_MS; + } + + /** A pause (hidden tab, resize) breaks the series: its gap is not a slow frame. */ + reset(): void { + this.last = undefined; + this.skipped = 0; + this.startedAt = undefined; + this.intervals.length = 0; + } + + stats(): FrameStats { + return frameStats(this.intervals); + } + + slow(): boolean { + return this.stats().p95Ms > SLOW_P95_MS; + } +} diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts new file mode 100644 index 00000000..048d1b50 --- /dev/null +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -0,0 +1,106 @@ +// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM. +import { fbmPeriodic, FbmShape } from './noise'; +import type { Srgb, SkyColours } from './skyColour'; + +export interface LayerSpec { + /** 0 farthest. Also the noise row, so layers never share a profile. */ + index: number; + /** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */ + baseCells: number; + /** Peak-to-valley height of the silhouette, as a fraction of the layer band. */ + relief: number; + /** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */ + crag: number; + /** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */ + base: number; + /** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */ + depth: number; + /** Seconds for the layer to drift one period. Farther layers are slower. */ + seconds: number; +} + +/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */ +export const LAYERS: readonly LayerSpec[] = [ + { index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 }, + { index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 }, + { index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 }, + { index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 }, +]; + +export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 }; + +/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */ +export function profile(spec: LayerSpec, t: number): number { + const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM); + const crag = 1 - Math.abs(2 * n - 1); + return n + (crag - n) * spec.crag; +} + +/** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */ +export function hazeColour(sky: SkyColours): Srgb { + const m = (a: number, b: number) => Math.round(a + (b - a) * 0.4); + return [ + m(sky.mid[0], sky.horizon[0]), + m(sky.mid[1], sky.horizon[1]), + m(sky.mid[2], sky.horizon[2]), + ]; +} + +/** + * Aerial perspective, applied once at bake time. A layer at distance d (0 nearest, 1 farthest) is seen + * through Beer-Lambert transmittance T = exp(-extinction * d): it shows T of its own colour and 1 - T of + * the haze, then loses `desaturate * (1 - T)` of its chroma, is softened by up to `blurPx` (canvas + * pixels at scale 1) and takes a faint warm in-scatter of at most `scatter` on the sun side. + */ +export const AERIAL = { extinction: 1.6, desaturate: 0.5, blurPx: 2, scatter: 0.16 }; + +/** Distance of a layer from the viewer, 0 (nearest) .. 1 (farthest). */ +export const layerDistance = (spec: LayerSpec): number => 1 - spec.depth; + +export const transmittance = (spec: LayerSpec): number => + Math.exp(-AERIAL.extinction * layerDistance(spec)); + +/** Layer colour as seen: its own ground colour thinned by the haze between it and the eye. */ +export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb { + const h = hazeColour(sky); + const t = transmittance(spec); + const seen = (i: number) => sky.ground[i] * t + h[i] * (1 - t); + const luma = 0.2126 * seen(0) + 0.7152 * seen(1) + 0.0722 * seen(2); + const keep = 1 - AERIAL.desaturate * (1 - t); + const out = (i: number) => Math.round(luma + (seen(i) - luma) * keep); + return [out(0), out(1), out(2)]; +} + +/** Edge softening for a layer, in canvas pixels at scale 1; 0 for the nearest. */ +export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec)); + +/** + * Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has + * no sun; grows with distance, peaks at the sun's azimuth. + */ +export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number { + if (sky.sunAzimuth === undefined) return 0; + const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth)); + return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe; +} + +/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */ +export function layerSize(cssW: number, cssH: number, scale: number) { + const period = Math.round(cssW * scale); + const band = Math.round(cssH * scale * 0.6); + return { period, width: period * 2, height: band }; +} + +/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */ +export function ridgeY( + spec: LayerSpec, + x: number, + period: number, + bandH: number, + viewH: number +): number { + const amp = spec.relief * viewH; + const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom + const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period); + return bandH - Math.min(bandH, topFromBottom); +} diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts new file mode 100644 index 00000000..7917d89e --- /dev/null +++ b/src/app/features/scene/ridge/skyColour.ts @@ -0,0 +1,30 @@ +// The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, +// cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them +// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, +// and bakes the result. + +export type Srgb = readonly [number, number, number]; + +export interface SkyColours { + /** Top of the sky gradient, sRGB 0..255. */ + top: Srgb; + /** Mid-sky band. */ + mid: Srgb; + /** Colour at the horizon, which the far ridges fade into. */ + horizon: Srgb; + /** Nearest ridge: the ground colour the farther layers mix away from. */ + ground: Srgb; + /** Where the sun sits across one drift period, 0..1; the layers pick up a faint warm in-scatter there. Omit for none. */ + sunAzimuth?: number; +} + +export function skyColours(_now: Date = new Date()): SkyColours { + return { + top: [170, 145, 220], + mid: [225, 130, 165], + horizon: [240, 150, 80], + ground: [7, 17, 26], + sunAzimuth: 0.3, + }; +} diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts new file mode 100644 index 00000000..e14f2a7c --- /dev/null +++ b/src/app/features/scene/ridge/stars.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable no-restricted-syntax, no-continue -- test loops over sample times */ +import { describe, expect, it } from 'vitest'; +import { skyColours } from './skyColour'; +import { + MAX_STARS, + NIGHT_LUMA, + SLOTS, + TWINKLE_GAIN, + TWINKLE_PERIOD, + activeAt, + makeStars, + skyLuma, + slotCount, + slotOffset, + slotPeriod, + starCount, + twinkleGain, +} from './stars'; + +const DAY = { + ...skyColours(), + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], +} as const; +const NIGHT = { + ...skyColours(), + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], +} as const; +const N = makeStars(NIGHT, 390, 844).length; + +function hash2(a: number, b: number) { + return ((a * 7919 + b * 104729) % 1000) / 1000; +} + +describe('star placement', () => { + it('is deterministic: the same sky and size give the identical field', () => { + expect(makeStars(NIGHT, 390, 844)).toEqual(makeStars(NIGHT, 390, 844)); + expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); + }); + + it('draws none in a day sky or the lavender dusk, and some in a night one', () => { + expect(makeStars(DAY, 390, 844)).toHaveLength(0); + expect(makeStars(skyColours(), 390, 844)).toHaveLength(0); + expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50); + }); + + it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { + for (const [w, h] of [ + [390, 844], + [1920, 1080], + [100, 100], + ] as const) { + expect(makeStars(NIGHT, w, h).length).toBeLessThanOrEqual(starCount(w, h)); + expect(starCount(w, h)).toBeLessThanOrEqual(MAX_STARS); + } + expect(N).toBeGreaterThan(150); + }); + + it('thins toward the horizon: more than twice the stars in the top third than the bottom third of the sky', () => { + const all = makeStars(NIGHT, 390, 844); + expect(all.filter((s) => s.y < 0.26).length).toBeGreaterThan( + 2 * all.filter((s) => s.y > 0.52).length + ); + }); + + it('fades with the sky: every star sits where the sky is dark, alpha within bounds', () => { + for (const s of makeStars(NIGHT, 390, 844)) { + expect(skyLuma(NIGHT, s.y)).toBeLessThan(NIGHT_LUMA); + expect(s.alpha).toBeLessThanOrEqual(0.9); + expect(s.alpha).toBeGreaterThanOrEqual(0.05); + } + }); + + it('varies in size and brightness', () => { + const all = makeStars(NIGHT, 390, 844); + expect(new Set(all.map((s) => s.r.toFixed(2))).size).toBeGreaterThan(50); + expect( + Math.max(...all.map((s) => s.alpha)) - Math.min(...all.map((s) => s.alpha)) + ).toBeGreaterThan(0.3); + }); +}); + +describe('twinkle schedule', () => { + const times = Array.from({ length: 400 }, (_, i) => i * 0.37); + + it('is a pure function of (seed, time): same t, same set and values', () => { + for (const t of [0, 0.4, 13.7, 3599.9]) expect(activeAt(N, t)).toEqual(activeAt(N, t)); + }); + + it('the set is SLOTS stars at every sampled time (inside 24..48), never more than the field has', () => { + expect(SLOTS).toBeGreaterThanOrEqual(24); + expect(SLOTS).toBeLessThanOrEqual(48); + for (const t of times) expect(activeAt(N, t)).toHaveLength(SLOTS); + expect(activeAt(10, 5)).toHaveLength(10); + expect(slotCount(10)).toBe(10); + expect(activeAt(0, 5)).toHaveLength(0); + }); + + it('no star is twice in the set at one instant, and every index is a real star', () => { + for (const t of times) { + const ids = activeAt(N, t).map((a) => a.star); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach((i) => expect(i >= 0 && i < N).toBe(true)); + } + }); + + it("periods are each slot's own, within 2..7 s, and the offsets differ", () => { + for (let j = 0; j < SLOTS; j += 1) { + expect(slotPeriod(j)).toBeGreaterThanOrEqual(TWINKLE_PERIOD.min); + expect(slotPeriod(j)).toBeLessThanOrEqual(TWINKLE_PERIOD.max); + } + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotPeriod(j))).size).toBe(SLOTS); + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotOffset(j))).size).toBe(SLOTS); + for (const a of activeAt(N, 100)) expect(a.period).toBeCloseTo(slotPeriod(a.slot), 12); + }); + + it('over an hour every star twinkles at least once', () => { + const seen = new Set(); + for (let t = 0; t < 3600; t += 1) for (const a of activeAt(N, t + 0.5)) seen.add(a.star); + expect(seen.size).toBe(N); + }); + + it('a star joins at its base brightness and leaves at exactly base, with no pop', () => { + const seed = { flip: false, ripple: 3, phase: 1 }; + expect(twinkleGain(0, seed)).toBe(1); + expect(twinkleGain(1, seed)).toBe(1); + for (const flip of [true, false]) + for (const e of [1e-4, 1 - 1e-4]) + expect(Math.abs(twinkleGain(e, { ...seed, flip }) - 1)).toBeLessThan(0.01); + // through the schedule: the instant a twinkle ends the slot is on a new star's first instant, at gain 1 + for (let j = 0; j < SLOTS; j += 1) { + const end = slotOffset(j) + 40 * slotPeriod(j); + const at = activeAt(N, end).find((a) => a.slot === j)!; + expect(at.gain).toBeCloseTo(1, 9); + } + }); + + it('the star that joins is never the one that just left', () => { + for (let j = 0; j < SLOTS; j += 1) { + let prev = -1; + for (let k = 0; k < 300; k += 1) { + const t = slotOffset(j) + (k + 0.5) * slotPeriod(j); + const { star } = activeAt(N, t).find((a) => a.slot === j)!; + expect(star).not.toBe(prev); + prev = star; + } + } + }); + + it('joins never coincide: every (slot, cycle) start over an hour is distinct', () => { + const starts: number[] = []; + for (let j = 0; j < SLOTS; j += 1) + for (let k = 0; slotOffset(j) + k * slotPeriod(j) < 3600; k += 1) + starts.push(slotOffset(j) + k * slotPeriod(j)); + starts.sort((a, b) => a - b); + let min = Infinity; + for (let i = 1; i < starts.length; i += 1) min = Math.min(min, starts[i] - starts[i - 1]); + expect(min).toBeGreaterThan(0); + console.log(`joins/hour ${starts.length}, min gap between joins ${(min * 1000).toFixed(3)} ms`); + }); + + it('dims deeper than it brightens, within 0.35x..1.15x', () => { + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < 20; j += 1) + for (let k = 0; k < 20; k += 1) { + const seed = { flip: hash2(j, k) < 0.5, ripple: 2 + (k % 2), phase: j + k }; + for (let i = 0; i <= 200; i += 1) { + const g = twinkleGain(i / 200, seed); + lo = Math.min(lo, g); + hi = Math.max(hi, g); + } + } + expect(lo).toBeGreaterThanOrEqual(TWINKLE_GAIN.dim - 1e-9); + expect(hi).toBeLessThanOrEqual(TWINKLE_GAIN.bright + 1e-9); + expect(1 - lo).toBeGreaterThan(2 * (hi - 1)); + }); + + it('stars outside the set are steady: only the active ones have a gain to apply', () => { + const inSet = new Set(activeAt(N, 50).map((a) => a.star)); + expect(inSet.size).toBeLessThan(N / 4); + }); +}); diff --git a/src/app/features/scene/ridge/stars.ts b/src/app/features/scene/ridge/stars.ts new file mode 100644 index 00000000..0e6d2628 --- /dev/null +++ b/src/app/features/scene/ridge/stars.ts @@ -0,0 +1,249 @@ +// The Midnight starfield: placed once from a seeded PRNG and baked once into the sky canvas. A +// rolling window of them twinkles on an overlay canvas: SLOTS slots, each running back-to-back +// twinkles of one star at a time, so the active set is always SLOTS stars and every star gets its turn. +// The schedule is a pure function of (seed, time): no state accumulates and no Math.random runs +// after the field is built, so any instant maps straight to the active set and its brightnesses. + +/* eslint-disable no-bitwise -- the seeded PRNG and hash are 32-bit integer arithmetic */ +import type { SkyColours, Srgb } from './skyColour'; + +/** + * Stars show when the sky behind them is darker than this sRGB luma (0..255). The ridge scene has + * no night flag (the sky theme's Midnight/Noonday is a body class chosen by the app, not seen here), + * so night is the sky's own darkness: a star's alpha is 1 - luma/NIGHT_LUMA at its height in the + * gradient, and a sky whose zenith is lighter than this gets no stars at all. + */ +export const NIGHT_LUMA = 110; +/** The sky gradient runs from the top (0) through mid (0.5) to the horizon glow (0.78 and below). */ +export const SKY_STOPS = { mid: 0.5, horizon: 0.78 }; +export const MAX_STARS = 400; +/** Twinkling stars at any instant (fewer only if the field has fewer stars). */ +export const SLOTS = 32; +export const TWINKLE_PERIOD = { min: 2, max: 7 } as const; +/** Gain at the dimmest and brightest swing: dimming goes deeper than brightening. */ +export const TWINKLE_GAIN = { dim: 0.35, bright: 1.15 } as const; +const SEED = 0x57a45eed; + +export interface Star { + /** Position as fractions of the sky canvas width and height. */ + x: number; + y: number; + /** Radius in CSS px. */ + r: number; + /** Base alpha 0..0.9 (brightness times how dark the sky is there). */ + alpha: number; + /** CSS colour, white tinted blue or warm. */ + fill: string; + /** The sky gradient's colour at this height: dimming paints it over the baked star. */ + cover: string; +} + +const luma = (c: Srgb) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** The baked gradient's colour at fraction t of the canvas height (the canvas interpolates in sRGB). */ +function skyAt(sky: SkyColours, t: number): Srgb { + const mix = (a: Srgb, b: Srgb, k: number): Srgb => [ + Math.round(lerp(a[0], b[0], k)), + Math.round(lerp(a[1], b[1], k)), + Math.round(lerp(a[2], b[2], k)), + ]; + if (t <= SKY_STOPS.mid) return mix(sky.top, sky.mid, t / SKY_STOPS.mid); + if (t <= SKY_STOPS.horizon) + return mix(sky.mid, sky.horizon, (t - SKY_STOPS.mid) / (SKY_STOPS.horizon - SKY_STOPS.mid)); + return sky.horizon; +} + +export const skyLuma = (sky: SkyColours, t: number): number => luma(skyAt(sky, t)); +export const isNight = (sky: SkyColours): boolean => luma(sky.top) < NIGHT_LUMA; + +/** mulberry32: 32-bit seeded PRNG, one float in [0, 1) per call. */ +function prng(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Stateless seeded hash of up to three integers to [0, 1). */ +export function hash(a: number, b = 0, c = 0): number { + let h = Math.imul(a | 0, 0x9e3779b1) ^ SEED; + h = Math.imul(h ^ (h >>> 15) ^ Math.imul(b | 0, 0x85ebca6b), 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 13) ^ Math.imul(c | 0, 0x27d4eb2f), 0x165667b1); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +/** How many stars a viewport carries: about one per 1100 CSS px squared, 150..MAX_STARS. */ +export const starCount = (cssW: number, cssH: number): number => + Math.min(MAX_STARS, Math.max(150, Math.round((cssW * cssH) / 1100))); + +/** + * Seeded placement. Each candidate draws all its random numbers whether it is kept or not, so a + * given seed and sky always give the same field and the prefix does not shift with the count. + * Heights are squeezed toward the zenith (y = 0.78 u^1.7) and alpha falls with the sky's luma, so + * the stars thin out and fade into the horizon glow. None for a day sky. + */ +export function makeStars(sky: SkyColours, cssW: number, cssH: number): Star[] { + if (!isNight(sky)) return []; + const rnd = prng(SEED); + const n = starCount(cssW, cssH); + // Every candidate draws all its numbers, kept or not, so the prefix is stable. + const candidates = Array.from({ length: MAX_STARS }, () => ({ + x: rnd(), + y: SKY_STOPS.horizon * rnd() ** 1.7, + r: 0.35 + 1.0 * rnd() ** 4, + bright: 0.35 + 0.65 * rnd() ** 1.5, + tint: rnd(), + })).slice(0, n); + return candidates.flatMap(({ x, y, r, bright, tint }) => { + const alpha = Math.min(0.9, bright * Math.max(0, 1 - skyLuma(sky, y) / NIGHT_LUMA)); + if (alpha < 0.05) return []; + // White, pulled toward blue or toward warm by the tint draw. + const to: Srgb = tint < 0.5 ? [165, 195, 255] : [255, 222, 185]; + const k = 0.55 * Math.abs(tint - 0.5) * 2; + const c = [255, 255, 255].map((v, j) => Math.round(lerp(v, to[j], k))); + const s = skyAt(sky, y); + return [ + { + x, + y, + r, + alpha, + fill: `rgb(${c[0]},${c[1]},${c[2]})`, + cover: `rgb(${s[0]},${s[1]},${s[2]})`, + }, + ]; + }); +} + +// ---- The schedule --------------------------------------------------------------------------- + +export const slotCount = (stars: number): number => Math.min(SLOTS, stars); +/** A slot's twinkle period in seconds: its own, in TWINKLE_PERIOD. */ +export const slotPeriod = (j: number): number => + TWINKLE_PERIOD.min + (TWINKLE_PERIOD.max - TWINKLE_PERIOD.min) * hash(j, 1); +/** A slot's phase: seconds into its first cycle at t = 0. */ +export const slotOffset = (j: number): number => hash(j, 2) * slotPeriod(j); + +/** Stars in slot j are those with index = j (mod slots), `m` of them; no star is in two slots. */ +const partitionSize = (stars: number, slots: number, j: number): number => + Math.floor((stars - 1 - j) / slots) + 1; + +/** The k-th star slot j twinkles: epochs of a seeded shuffle of its partition, never repeating across the seam. */ +function partitionPick(stars: number, slots: number, j: number, k: number): number { + const m = partitionSize(stars, slots, j); + const shuffled = (e: number) => { + const order = Array.from({ length: m }, (_, q) => q).sort( + (a, b) => hash(j, e, a + 10) - hash(j, e, b + 10) || a - b + ); + return order; + }; + const e = Math.floor(k / m); + const order = shuffled(e); + if (m > 1 && order[0] === shuffled(e - 1)[m - 1]) [order[0], order[1]] = [order[1], order[0]]; + return j + slots * order[k - e * m]; +} + +/** + * Brightness multiplier at fraction u of one twinkle (0..1): exactly 1 at both ends, so a star + * joins and leaves at its base brightness. One swing, a little unrelated shimmer, and the + * asymmetric map below: dimming goes to TWINKLE_GAIN.dim, brightening only to .bright. + */ +export function twinkleGain( + u: number, + seed: { flip: boolean; ripple: number; phase: number } +): number { + if (u <= 0 || u >= 1) return 1; + const main = Math.sin(2 * Math.PI * u) * (seed.flip ? -1 : 1); + const shimmer = Math.sin(2 * Math.PI * seed.ripple * u + seed.phase) * Math.sin(Math.PI * u); + const s = Math.max(-1, Math.min(1, 0.85 * main + 0.15 * shimmer)); + return s >= 0 ? 1 + (TWINKLE_GAIN.bright - 1) * s : 1 + (1 - TWINKLE_GAIN.dim) * s; +} + +export interface Twinkling { + slot: number; + star: number; + /** Seconds since this twinkle started, and its length. */ + start: number; + period: number; + gain: number; +} + +/** The active set at time `t` seconds for a field of `stars` stars, in slot order. */ +export function activeAt(stars: number, t: number): Twinkling[] { + const slots = slotCount(stars); + return Array.from({ length: slots }, (_, j) => { + const period = slotPeriod(j); + const off = slotOffset(j); + const k = Math.floor((t - off) / period); + const start = off + k * period; + const seed = { + flip: hash(j, k, 3) < 0.5, + ripple: 2 + Math.floor(hash(j, k, 4) * 2), + phase: 2 * Math.PI * hash(j, k, 5), + }; + return { + slot: j, + star: partitionPick(stars, slots, j, k), + start, + period, + gain: twinkleGain((t - start) / period, seed), + }; + }); +} + +// ---- Drawing -------------------------------------------------------------------------------- + +/** Bake `stars` into `ctx` (canvas `w` x `h` device px, `scale` device px per CSS px) at base alpha. */ +export function drawStars( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + w: number, + h: number, + scale: number +) { + stars.forEach((s) => { + ctx.globalAlpha = s.alpha; + ctx.fillStyle = s.fill; + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} + +/** + * The overlay pass: clear, then for each active star whose gain is off base, paint over the baked + * star. Dimming covers it with the sky's own colour at (1 - gain), which leaves exactly + * alpha * gain of the star; brightening adds a second star on top. A star at base draws nothing. + */ +export function drawTwinkle( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + active: readonly Twinkling[], + w: number, + h: number, + scale: number +) { + ctx.clearRect(0, 0, w, h); + active.forEach((a) => { + const s = stars[a.star]; + if (!s || Math.abs(a.gain - 1) < 0.01) return; + if (a.gain < 1) { + ctx.globalAlpha = 1 - a.gain; + ctx.fillStyle = s.cover; + } else { + ctx.globalAlpha = Math.min(1, ((a.gain - 1) * s.alpha) / (1 - s.alpha)); + ctx.fillStyle = s.fill; + } + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale + (a.gain < 1 ? 1 : 0), 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts new file mode 100644 index 00000000..5c96e71b --- /dev/null +++ b/src/app/features/scene/sceneButtons.css.ts @@ -0,0 +1,32 @@ +import { globalStyle } from '@vanilla-extract/css'; + +// Meta, 2026-11-10: "buttons are big and ugly, too, they don't need chrome either". Over the scene a folds Button or +// Chip is its label in the page's ink, with a faint wash on hover and focus. Menus and dialogs (#portalContainer) keep +// folds' look. The selectors are folds' hashed recipe base classes (Button epr39zd, Chip _1bv8u1w6). +const scene = ':root[data-scene] #root'; +const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', '); +globalStyle(controls, { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + color: 'inherit', +}); +globalStyle( + [ + `${scene} .epr39zd:hover`, + `${scene} .epr39zd:focus-visible`, + `${scene} ._1bv8u1w6:hover`, + `${scene} ._1bv8u1w6:focus-visible`, + ].join(', '), + { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } +); +// A visible keyboard focus ring in the control's own ink, on top of the hover wash. +globalStyle([`${scene} .epr39zd:focus-visible`, `${scene} ._1bv8u1w6:focus-visible`].join(', '), { + outline: '2px solid currentColor', + outlineOffset: '2px', +}); +globalStyle(`${scene} .epr39zd`, { + fontWeight: 700, + textDecoration: 'underline', + textUnderlineOffset: '0.25em', +}); diff --git a/src/app/features/scene/sceneShell.css.ts b/src/app/features/scene/sceneShell.css.ts new file mode 100644 index 00000000..18911a1b --- /dev/null +++ b/src/app/features/scene/sceneShell.css.ts @@ -0,0 +1,30 @@ +import { createVar, fallbackVar, globalStyle } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// While the background scene runs (html[data-scene], set by BackgroundScene) the signed-in shell +// leaves the ridge visible behind the whole app: the page, header, sidebar and room surfaces are +// clear, with only a faint wash for hover and press. Chat bubbles (message/layout/layout.css.ts) +// and the composer (editor/Editor.css.ts) are the filled surfaces. The tokens are redefined on +// #root only, so everything that renders into #portalContainer (menus, dialogs, tooltips, +// popouts) keeps its themed fill: that is the sparse menu chrome. +const wash = (pct: number) => `color-mix(in srgb, currentColor ${pct}%, transparent)`; + +globalStyle(':root[data-scene] #root', { + vars: { + [color.Background.Container]: 'transparent', + [color.Background.ContainerHover]: wash(8), + [color.Background.ContainerActive]: wash(12), + [color.Background.ContainerLine]: wash(14), + [color.Surface.Container]: 'transparent', + [color.Surface.ContainerHover]: wash(8), + [color.Surface.ContainerActive]: wash(12), + [color.Surface.ContainerLine]: wash(14), + }, +}); + +// The themed surface colour as text on a coloured ground (avatar initials) must survive the clear +// shell above. It is captured on , above the #root override, and read back with its themed +// fallback when no scene runs. +const surfaceInkVar = createVar(); +globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } }); +export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container); diff --git a/src/app/features/scene/sceneStyle.ts b/src/app/features/scene/sceneStyle.ts new file mode 100644 index 00000000..7c31233e --- /dev/null +++ b/src/app/features/scene/sceneStyle.ts @@ -0,0 +1,28 @@ +// Temporary: which background the viewer picked (terrain or ridges), kept in one localStorage key so the two scenes we +// serve can be compared on any device. Absent, the client config's backgroundScene.style decides. +import type { SceneStyle } from './tier'; + +export type ServedSceneStyle = Extract; +export const SERVED_SCENE_STYLES: readonly ServedSceneStyle[] = ['terrain', 'ridge']; +export const SCENE_STYLE_KEY = 'ratatoskr.sceneStyle'; + +export function getSceneStyleOverride(): ServedSceneStyle | undefined { + try { + const v = localStorage.getItem(SCENE_STYLE_KEY); + return SERVED_SCENE_STYLES.includes(v as ServedSceneStyle) + ? (v as ServedSceneStyle) + : undefined; + } catch { + return undefined; + } +} + +/** Store the pick and reload, so the scene host mounts the other scene from a clean page. */ +export function setSceneStyle(s: ServedSceneStyle): void { + try { + localStorage.setItem(SCENE_STYLE_KEY, s); + } catch { + return; + } + window.location.reload(); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl index 95e96bc2..fd5e6363 100644 --- a/src/app/features/scene/shaders/sdf.glsl +++ b/src/app/features/scene/shaders/sdf.glsl @@ -44,10 +44,10 @@ vec3 terrainNormal(vec2 xz) vec2 dir = r > 0.0 ? xz / r : vec2(0.0); vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; + vec2 g = m * W_AMP * W_FREQ * fg.xy + dm * W_AMP * f * dir; #if W_ERODE_ENABLED CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xy + dm * W_ERODE_AMP * ph.cos.w * dir; #endif return normalize(vec3(-g.x, 1.0, -g.y)); } diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts new file mode 100644 index 00000000..e38f676a --- /dev/null +++ b/src/app/features/scene/skyBand.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand'; +import type { Linear } from './skyBand'; +import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders'; + +const near = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +describe('shapeToBand', () => { + const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue + const { day } = SKY_BAND; + + it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => { + const c = shapeToBand(dark, day); + expect(near(luma(c), day.floor)).toBe(true); + // Between the sky and the tint on every channel: a mix, not a grey clamp. + [0, 1, 2].forEach((i) => { + expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i])); + expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i])); + }); + expect(c[2]).toBeGreaterThan(c[0]); // still blue + }); + + it('lifts black to the tint scaled in, not to grey', () => { + const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); + expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); + expect(c[2]).toBeGreaterThan(c[0]); // the dusk tint is Day's pale blue + }); + + it('leaves a sky already in the band as it is, and is continuous at the floor', () => { + const ok: Linear = [0.5, 0.6, 0.7]; + expect(shapeToBand(ok, day)).toEqual(ok); + const lo = shapeToBand([0.31, 0.31, 0.31], day); + const below = shapeToBand([0.3099, 0.3099, 0.3099], day); + expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3); + }); + + it('scales a sky above the ceiling down to it, keeping its hue', () => { + const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night); + expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true); + expect(near(c[0] / c[2], 0.9)).toBe(true); + }); + + it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => { + expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]); + const once = shapeToBand(dark, day); + shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12)); + }); +}); + +describe('the bands', () => { + it('have a tint above the floor to lift toward', () => { + Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); + }); + + it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => { + const grey = shapeToBand([0, 0, 0], SKY_BAND.day); + const shown = displayedLuminance(grey, 'low'); + expect(shown).toBeGreaterThanOrEqual(0.2983); + expect(shown).toBeLessThan(SKY_BAND.day.floor); + }); + + it('decode hex as linear light', () => { + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + }); +}); + +describe('the GLSL twin', () => { + it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => { + expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);'); + expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);'); + expect(SHAPE_SKY_GLSL).toContain( + 'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));' + ); + expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);'); + }); + + it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => { + [SKY_FRAG, GRID_FRAG].forEach((src) => { + expect(src).toContain(SHAPE_SKY_GLSL); + expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/); + }); + }); +}); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts new file mode 100644 index 00000000..a74784b3 --- /dev/null +++ b/src/app/features/scene/skyBand.ts @@ -0,0 +1,90 @@ +// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink +// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). +// +// day Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// dusk The same ink, and its link #2a4f27 (L 0.0623), which needs a backdrop of at least 0.4554. The floor is +// the link bound (0.4744 as drawn), so text clears 4.5:1 with room (6.6) and the link clears too. +// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. +// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. +// +// Night's link ink is not part of the band: see skyTheme.ts. +// +// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: +// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the +// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band +// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends. +export type FixedSkyState = 'day' | 'dusk' | 'night'; +export type Linear = readonly [number, number, number]; + +/** WCAG luma weights, applied to linear RGB. */ +export const LUMA: Linear = [0.2126, 0.7152, 0.0722]; +export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2]; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Linear { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +/** How a displayed colour is read: as encoded, or moved one 8-bit step down or up in every channel ('low', 'high'). */ +export type Reading = 'exact' | 'low' | 'high'; + +/** + * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with + * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' move every + * encoded channel one 8-bit step down or up first: rounding to 8 bits (half a step) plus the sky pass's dither (half a + * step) cannot move a channel further, so they bound what the screen shows. A smooth bound, so a search can climb it. + */ +export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number { + const shift = { exact: 0, low: -1 / 255, high: 1 / 255 }[reading]; + const ch = (v: number) => { + const e = Math.min(1, Math.max(0, Math.min(1, Math.max(0, v)) ** (1 / 2.2) + shift)); + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }; + return luma([ch(c[0]), ch(c[1]), ch(c[2])]); +} + +export interface SkyBand { + /** Linear luma the shaped colour is at least (0: no floor). */ + floor: number; + /** Linear luma the shaped colour is at most (1: no ceiling). */ + ceiling: number; + /** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */ + lift: Linear; +} + +/** + * The shaping targets, in linear luma. Day's floor sits 0.019 above the text bound (0.2983), Dusk's 0.019 above the link + * bound (0.4554), which skyForState.test.ts derives from the ink and ties to these: the screen encoding is + * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky + * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound + * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows + * (skyState.ts), so a sky the page URL steers at night, scaled down to the ceiling, cannot show lighter than the baked one. + */ +export const SKY_BAND: Record = { + day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + // Dusk lifts toward Day's pale blue rather than a rose: mixing a third of the sky toward rose washed the twilight pink, and + // a rose lighter than the floor shifts hues by 47 to 72 degrees where Day's tint shifts them by 18 (measured, PR body). + dusk: { floor: 0.4744, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, +}; + +/** + * Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount + * that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the + * ceiling. The colour is clamped to 0..1 first, as the screen does. + */ +export function shapeToBand(c: Linear, band: SkyBand): Linear { + const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear; + const l = luma(k); + if (l < band.floor) { + const t = (band.floor - l) / (luma(band.lift) - l); + return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear; + } + if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear; + return k; +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts new file mode 100644 index 00000000..a8199b1b --- /dev/null +++ b/src/app/features/scene/skyForState.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; +import { baked } from '../../../sky/baked'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import { + DUSK_SUN_ELEVATION, + TERRAIN_REACH_DEG, + ridgeDrawn, + ridgeSkyFor, + terrainDrawn, + terrainSkyFor, +} from './skyForState'; +import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; +import type { FixedSkyState } from './skyBand'; +import { setSkyClock, skyBackdrop } from './skyState'; +import { view } from './terrain/terrainWorld'; +import { skyThemeId } from './skyTheme'; + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const EPS = 1e-9; +const STATES = ['day', 'dusk', 'night'] as const; +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; + +/** + * The band each state needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, night 0.0931; dusk is bound by its + * link (0.4554), the darker ink to clear, whose floor also holds the text. + */ +const needs = (state: FixedSkyState) => { + const sky = SKY[skyThemeId(state)]; + const l = luminance(sky.tokens.Background.OnContainer); + const link = luminance(sky.link); + return state === 'night' + ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } + : { floor: AA * ((state === 'dusk' ? link : l) + 0.05) - 0.05, ceiling: 1 }; +}; + +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + setSkyClock(); + vi.unstubAllGlobals(); +}); + +describe('the bands the text ink needs', () => { + it('are the numbers the shaping was set from', () => { + expect(needs('day').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.4554, 3); + expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); + }); + + // The drawn floor and the lift are tied to those bounds, not to themselves: nudge either constant and this fails. + it('give Dusk a floor 0.019 above the link bound (as Day has above the text bound) and a lift Day shares', () => { + expect(SKY_BAND.dusk.floor).toBeCloseTo(needs('dusk').floor + 0.019, 4); + expect(SKY_BAND.day.floor).toBeCloseTo(needs('day').floor + 0.019, 3); + expect(SKY_BAND.dusk.lift).toEqual(SKY_BAND.day.lift); + expect(luma(SKY_BAND.dusk.lift)).toBeGreaterThan(SKY_BAND.dusk.floor); + }); + + it.each(['day', 'dusk'] as const)( + '%s: the darkest the shaping can show reads at the bound and under the floor', + (state) => { + const shown = displayedLuminance(shapeToBand([0, 0, 0], SKY_BAND[state]), 'low'); + expect(shown).toBeGreaterThanOrEqual(needs(state).floor); + expect(shown).toBeLessThan(SKY_BAND[state].floor); + } + ); +}); + +describe('ridgeSkyFor', () => { + it('draws the gradient of the sky auto resolves to, never nothing', () => { + setSkyClock(() => atStandardTime(12, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); + setSkyClock(() => atStandardTime(0, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); + setSkyClock(() => atStandardTime(18, 10)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); + }); + + it('draws a lavender, rose and orange twilight for dusk', () => { + const d = ridgeSkyFor('dusk'); + expect(d.ground).toEqual(skyColours().ground); + const [t, m, h] = [d.top, d.mid, d.horizon]; + expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green + expect(t[0]).toBeGreaterThan(t[1]); + expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue + expect(m[2]).toBeGreaterThan(m[1]); + expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue + expect(h[1]).toBeGreaterThan(h[2]); + }); + + it('draws the baked gradients for day and night', () => { + const day = ridgeSkyFor('day'); + const night = ridgeSkyFor('night'); + expect(hex(day.top)).toBe(baked.noonday.gradient[0]); + expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]); + expect(hex(night.top)).toBe(baked.midnight.gradient[0]); + expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]); + }); + + it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { + const { floor, ceiling } = needs(state); + const r = ridgeDrawn(state); + expect(r.darkest).toBeGreaterThanOrEqual(floor); + expect(r.lightest).toBeLessThanOrEqual(ceiling); + }); +}); + +// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) +// plus a 0.05 degree grid across the sun, five times finer than the generator's. +const dirAt = (el: number, az: number): [number, number, number] => { + const rad = Math.PI / 180; + return [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; +}; +/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */ +const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { + const rad = Math.PI / 180; + const out = { low: Infinity, high: -Infinity, exactMin: Infinity }; + const see = (el: number, az: number) => { + const c = skyColour(sky, dirAt(el, az)); + out.low = Math.min(out.low, displayedLuminance(c, 'low')); + out.high = Math.max(out.high, displayedLuminance(c, 'high')); + out.exactMin = Math.min(out.exactMin, displayedLuminance(c)); + }; + for (let i = 0; i < N; i += 1) { + const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl + see(Math.asin(y) / rad, (i * 137.50776405) % 360); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz); + } + } + return out; +}; + +describe('the terrain camera reach', () => { + it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { + const tanHalf = Math.tan((55 * Math.PI) / 360); + const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) => + [0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => { + const v = view(aspect, turns); + return [-1, 0, 1].map((nx) => { + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i] + ); + return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI; + }); + }) + ); + const top = Math.max(...tops); + expect(top).toBeCloseTo(14.5, 1); + expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top); + expect(TERRAIN_REACH_DEG - top).toBeLessThan(2); + }); +}); + +describe('terrainSkyFor', () => { + it('draws the sky auto resolves to, with that state band', () => { + setSkyClock(() => atStandardTime(12, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); + setSkyClock(() => atStandardTime(0, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); + }); + + it('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => { + setSkyClock(() => atStandardTime(0, 0)); // night + search = '?sun=30&az=120'; + expect(terrainSkyFor('auto')).toEqual({ + sun: { elevation: 30, azimuth: 120 }, + band: SKY_BAND.night, + }); + search = '?sky=noonday'; + expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night }); + search = '?sky=garbage'; + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); + setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured + search = '?sun=2&az=250'; + expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 }); + }); + + it('keeps a URL-chosen sun inside the band of the resolved state', () => { + setSkyClock(() => atStandardTime(12, 0)); // day: floor + search = '?sun=-30'; + const day = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(day.low).toBeGreaterThanOrEqual(needs('day').floor); + setSkyClock(() => atStandardTime(0, 0)); // night: ceiling + search = '?sun=60'; + const night = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); + }); + + it('keeps a URL-steered sky at night no lighter than the Night backdrop the text was checked against', () => { + setSkyClock(() => atStandardTime(0, 0)); + ['?sun=60', '?sun=10&az=40', '?sun=2&az=250', '?sky=noonday'].forEach((q) => { + search = q; + expect(terrainDrawn('auto').lightest).toBeLessThanOrEqual(skyBackdrop('night').lightest); + }); + }); + + it('lets an explicit state beat the URL, as before auto had one', () => { + search = '?sun=30&az=120&sky=midnight'; + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION }); + }); + + it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); + expect(terrainSkyFor('dusk')).toEqual({ + sun: { elevation: DUSK_SUN_ELEVATION }, + band: SKY_BAND.dusk, + }); + }); +}); + +describe('the terrain sky lies in the band its text ink needs', () => { + const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); + + it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { + const { sun } = terrainSkyFor('dusk'); + expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02); + expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor); + }); + + it.each(STATES)('%s', (state) => { + const { floor, ceiling } = needs(state); + const l = sweep(state); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); + }); + + it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { + const { floor, ceiling } = needs(state); + for (let e = -90; e <= 90; e += 10) { + const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); + } + }); + + it('and the terrain dusk still reaches the sun glow the ridge dusk does not', () => { + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); + }); +}); + +describe('skyBackdrop holds the union of what the scenes draw', () => { + // An independent extremum of the screen reading ('low' for the darkest, 'high' for the lightest: one 8-bit step each way) over the terrain camera's reach: not terrainDrawn's grid and compass search but alternating golden-section + // line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), the bracket + // shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search always + // moves to the better end of its bracket. + const extremes = (state: FixedSkyState) => { + const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + const f = (el: number, az: number, reading: 'low' | 'high') => { + const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; + const a = az * rad; + return displayedLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]), + reading + ); + }; + const P = 0.6180339887; + const line = (g: (x: number) => number, mid: number, w: number) => { + let a = mid - w; + let b = mid + w; + for (let k = 0; k < 40; k += 1) { + const c = b - P * (b - a); + const d = a + P * (b - a); + if (g(c) > g(d)) b = d; + else a = c; + } + return (a + b) / 2; + }; + const hunt = (sign: 1 | -1) => { + const reading = sign === 1 ? 'high' : 'low'; + let best = -Infinity; + for (let i = 0; i < 60; i += 1) { + let el = + i % 2 + ? sky.elevation + (((i * 7919) % 1600) - 800) / 100 + : Math.asin(((i + 0.5) / 60) * Math.sin(TERRAIN_REACH_DEG * rad)) / rad; + let az = + i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360; + for (let w = 8; w > 1e-3; w /= 2) { + const [el0, az0] = [el, az]; + el = line((x) => sign * f(Math.max(0, x), az0, reading), el0, w); + const el1 = el; + az = line((x) => sign * f(el1, x, reading), az0, w); + best = Math.max(best, sign * f(el, az, reading)); + } + } + return sign * best; + }; + return { darkest: hunt(-1), lightest: hunt(1) }; + }; + + // At night a sky the page URL steers (?sun= ?sky=) is shaped to the band's ceiling, so the lightest it can show is one + // 8-bit step above the ceiling's own grey: the Night constant must cover it too, or text is checked against a sky + // lighter than the one it sits on. + const steeredNight = displayedLuminance( + [SKY_BAND.night.ceiling, SKY_BAND.night.ceiling, SKY_BAND.night.ceiling], + 'high' + ); + + const computed = (state: FixedSkyState) => { + const r = [ridgeDrawn(state), terrainDrawn(state)]; + const ind = extremes(state); + return { + darkest: Math.min(...r.map((x) => x.darkest), ind.darkest), + lightest: Math.max( + ...r.map((x) => x.lightest), + ind.lightest, + ...(state === 'night' ? [steeredNight] : []) + ), + }; + }; + + // The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6): + // a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error. + const SEARCH = 1e-8; + it.each(STATES)( + '%s: the constant is the swept extreme rounded outward, within 1e-6 above it', + (state) => { + const c = computed(state); + const b = skyBackdrop(state); + expect(b.darkest).toBeLessThanOrEqual(c.darkest); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); + expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); + } + ); + + it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => { + // The sweep is not the generator's grid (see above), so it cannot share its blind spots. + const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); + const b = skyBackdrop(state); + const r = ridgeDrawn(state); + expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(l.high).toBeLessThanOrEqual(b.lightest + EPS); + expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4); + expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts new file mode 100644 index 00000000..71e07ca6 --- /dev/null +++ b/src/app/features/scene/skyForState.ts @@ -0,0 +1,193 @@ +// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it +// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn +// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in +// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, +// and the login text was checked against that bound. +import { baked } from '../../../sky/baked'; +import { chooseSky, skyColour, urlSky } from './terrain/terrainSky'; +import type { SkyName } from './terrain/terrainSky'; +import type { SkyColours, Srgb } from './ridge/skyColour'; +import { skyColours } from './ridge/skyColour'; +import { resolveSkyState } from './skyState'; +import type { SkyState } from './skyState'; +import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; +import type { SkyBand } from './skyBand'; +import { luminance } from '../../../sky/skyTokens.js'; +import { CAMERA } from './terrain/terrainWorld'; + +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +const rgb = (hex: string): Srgb => + [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; + +const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ + top: rgb(g[0]), + mid: rgb(g[Math.floor(g.length / 2)]), + horizon: rgb(g[g.length - 1]), + ground, +}); + +/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ +export const DUSK_SUN_ELEVATION = 2; + +const toByte = (v: number): number => + Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); + +/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */ +const inBand = (c: Srgb, band: SkyBand): Srgb => + shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb; + +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */ +export function ridgeSkyFor(state: SkyState): SkyColours { + const { ground } = skyColours(); + switch (resolveSkyState(state)) { + case 'dusk': { + const twilight = skyColours(); + const band = SKY_BAND.dusk; + return { + ...twilight, + top: inBand(twilight.top, band), + mid: inBand(twilight.mid, band), + horizon: inBand(twilight.horizon, band), + }; + } + case 'day': + return fromGradient(baked.noonday.gradient, ground); + default: + return fromGradient(baked.midnight.gradient, ground); + } +} + +/** + * The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto' + * draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky, + * shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band. + */ +export function terrainSkyFor(state: SkyState): { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; + band: SkyBand; +} { + const fixed = resolveSkyState(state); + const band = SKY_BAND[fixed]; + if (state === 'auto') { + const url = urlSky(); + if (url) return { ...url, band }; + } + switch (fixed) { + case 'day': + return { sky: 'noonday', band }; + case 'night': + return { sky: 'midnight', band }; + default: + return { sun: { elevation: DUSK_SUN_ELEVATION }, band }; + } +} + +/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ +export interface Range { + readonly darkest: number; + readonly lightest: number; +} + +/** + * The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates + * encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled). + */ +export function ridgeDrawn(state: SkyState): Range { + const { top, mid, horizon } = ridgeSkyFor(state); + const l: number[] = []; + [ + [top, mid], + [mid, horizon], + [top, horizon], + ].forEach(([a, b]) => { + for (let k = 0; k <= 100; k += 1) { + l.push( + luminance( + rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb) + ) + ); + } + }); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +} + +/** + * How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns + * (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the + * field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is + * over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour. + */ +export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5); + +const SAMPLE_DEG = 3; +const SUN_DEG = 0.1; +const SUN_SPAN_DEG = 8; +const HORIZON_DEG = 0.5; +const REFINE_FROM = 6; + +/** + * The terrain sky's range as the 8-bit screen shows it (`displayedLuminance`, read 'low' for the darkest and 'high' for the + * lightest: one 8-bit step each way, rounding and dither at their worst): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches + * (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree + * ring of the horizon, the top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the + * best few is then refined by a compass search whose step halves from 1 degree to under 5e-7, because the sun's glow peaks + * (and the sky's darkest patch sits) between any fixed grid's samples. + */ +export function terrainDrawn(state: SkyState): Range { + const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + type Point = { el: number; az: number; read: number; score: number }; + const extreme = (reading: 'low' | 'high', sign: 1 | -1): number => { + const point = (el: number, az: number): Point => { + const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; + const a = az * rad; + const c = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]); + const read = displayedLuminance(c, reading); + return { el, az, read, score: read }; + }; + const seen: Point[] = []; + const at = (el: number, az: number) => seen.push(point(el, az)); + for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) { + for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); + } + for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); + at(TERRAIN_REACH_DEG, 0); + for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { + for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { + at(sky.elevation + dEl, sky.azimuth + dAz); + } + } + const around = (p: Point, step: number): Point[] => + [ + [step, 0], + [-step, 0], + [0, step], + [0, -step], + ].map(([dEl, dAz]) => + point(Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)), p.az + dAz) + ); + const refine = (from: Point) => { + let best = from; + for (let step = 1; step > 5e-7; step /= 2) { + let improved = true; + while (improved) { + const cur = best; + best = around(cur, step).reduce((b, c) => (sign * c.score > sign * b.score ? c : b), cur); + improved = best !== cur; + } + } + return best.read; + }; + const ranked = [...seen].sort((p, q) => sign * (q.score - p.score)); + return sign === 1 + ? Math.max(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine)) + : Math.min(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine)); + }; + return { darkest: extreme('low', -1), lightest: extreme('high', 1) }; +} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts new file mode 100644 index 00000000..ebd117fd --- /dev/null +++ b/src/app/features/scene/skyPlate.css.ts @@ -0,0 +1,61 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; +import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { Editor } from '../../components/editor/Editor.css'; +import { CallStatus } from '../call-status/styles.css'; +import { PLATE_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; + +// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text +// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both +// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate +// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was +// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag. +const scene = ':root[data-scene]'; +const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; +const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; + +/** A region with no class of its own (the room intro, the call view) joins the plate with this. */ +export const SkyPlate = style({}); + +// A recipe's first class is its base style. +const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; + +// Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare +// ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks). +export const plateRegions = [ + `.${SkyPlate}`, + // The composer and the call status bar. + `.${Editor}`, + `.${CallStatus}`, + // A message that is not a bubble (Modern, Compact) is plain text on the scene. + `${base(MessageBase)}:not(:has(.${BubbleContent}))`, +]; + +// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. +const within = (ancestor: string) => + plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + +globalStyle(within(scene), { + // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). + backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + color: text, + // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. + vars: { + [color.Background.OnContainer]: text, + [color.Surface.OnContainer]: text, + [color.SurfaceVariant.OnContainer]: text, + }, +}); +globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 }); + +// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it; +// the other themes take the fallback, as the sign-in card does. +globalStyle(within(`${scene} body:not(.gamecult-theme)`), { + vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, +}); + +globalStyle(`${scene} #root .${SkyPlate}`, { + borderRadius: config.radii.R400, +}); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts new file mode 100644 index 00000000..b82a3026 --- /dev/null +++ b/src/app/features/scene/skyPlate.ts @@ -0,0 +1,28 @@ +// The plate: the opaque surface text sits on wherever the shell is clear over the background +// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour +// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for +// the card holds here. The other themes take these fallbacks. +export const SKY_PLATE_FALLBACK = { + fill: '#0d1a26', + text: '#b7c7d9', + link: '#99c7ff', +} as const; + +/** + * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the + * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1. The browser + * mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55. The + * terrain is not black: in the shots at 390x844 (terrain scene, tier low) the darkest pixel in the footer's region is + * 0.0203 at Dusk, 0.0936 at Day (dark ink) and 0.0052 at Night (light ink). Measured on the drawn pixels with the band + * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. + */ +export const FOOTER_TINT = 0.82; + +/** + * How much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind a plate (title bars, lists, the + * composer, the sign-in card). Meta, 2026-11-09: "The app shouldn't need chrome, and where it does help for readability it + * should be like the chat bubbles, blur with a bit of tint towards the background color. If that's not working, the fix + * is in the background, not covering it up." + */ +export const PLATE_TINT = 0.45; diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts new file mode 100644 index 00000000..ae4d9e83 --- /dev/null +++ b/src/app/features/scene/skyState.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { + SKY_STATES, + SKY_STATE_KEY, + resolveSkyState, + setSkyClock, + skyBackdrop, + type SkyState, +} from './skyState'; + +type Mod = typeof import('./skyState'); + +function fakeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + }; +} +const fresh = (): Promise => { + vi.resetModules(); + return import('./skyState'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sky state', () => { + let storage: ReturnType; + beforeEach(() => { + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + + it('defaults to auto when nothing is stored', async () => { + expect((await fresh()).getSkyState()).toBe('auto'); + }); + + it.each(['night', 'day', 'dusk', 'auto'] as const)('restores a stored %s', async (s) => { + storage.data.set(SKY_STATE_KEY, s); + expect((await fresh()).getSkyState()).toBe(s); + }); + + it.each(['', 'noon', 'NIGHT', '"night"', 'null'])( + 'falls back to auto on stored %j', + async (v) => { + storage.data.set(SKY_STATE_KEY, v); + expect((await fresh()).getSkyState()).toBe('auto'); + } + ); + + it('falls back to auto when storage throws', async () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + }); + const m = await fresh(); + expect(m.getSkyState()).toBe('auto'); + m.setSkyState('night'); + expect(m.getSkyState()).toBe('night'); + }); + + it('persists a set state under one key and reads it back after a reload', async () => { + (await fresh()).setSkyState('dusk'); + expect([...storage.data.entries()]).toEqual([[SKY_STATE_KEY, 'dusk']]); + expect((await fresh()).getSkyState()).toBe('dusk'); + }); + + it('ignores an invalid state', async () => { + const m = await fresh(); + m.setSkyState('night'); + m.setSkyState('noon' as SkyState); + expect(m.getSkyState()).toBe('night'); + expect(storage.data.get(SKY_STATE_KEY)).toBe('night'); + }); + + it('notifies subscribers on change only, and stops after unsubscribe', async () => { + const m = await fresh(); + const cb = vi.fn(); + const off = m.subscribe(cb); + m.setSkyState('day'); + m.setSkyState('day'); + expect(cb.mock.calls).toEqual([['day']]); + off(); + m.setSkyState('night'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('tells a late subscriber nothing until the next change', async () => { + const m = await fresh(); + m.setSkyState('day'); + const cb = vi.fn(); + m.subscribe(cb); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +describe('skyBackdrop', () => { + it('puts night below day and dusk, and dusk reaches the sun glow', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest); + }); + + it('holds the baked gradients for day and night', () => { + const lum = (g: readonly string[]) => g.map(luminance); + expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual( + Math.max(...lum(baked.noonday.gradient)) - 1e-9 + ); + expect(skyBackdrop('night').darkest).toBeLessThanOrEqual( + Math.min(...lum(baked.midnight.gradient)) + 1e-9 + ); + }); + + it('is the band of the sky auto resolves to, not the union of all three', () => { + const at = (h: number) => setSkyClock(() => atStandardTime(h, 0)); + at(12); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); + at(0); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('night')); + setSkyClock(); + }); + + it('never has darkest above lightest', () => { + SKY_STATES.forEach((s) => + expect(skyBackdrop(s).darkest).toBeLessThanOrEqual(skyBackdrop(s).lightest) + ); + }); +}); + +describe('auto follows the clock', () => { + const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock + let storage: ReturnType; + beforeEach(() => { + vi.useFakeTimers(); + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => { + expect(resolveSkyState('auto', local(12))).toBe('day'); + expect(resolveSkyState('auto', local(0))).toBe('night'); + expect(resolveSkyState('auto', local(18, 10))).toBe('dusk'); + expect(resolveSkyState('auto', local(5, 50))).toBe('dusk'); + expect(resolveSkyState('day', local(0))).toBe('day'); + }); + + it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const cb = vi.fn(); + const off = m.subscribe(cb); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).not.toHaveBeenCalled(); + now = local(18, 10); + vi.advanceTimersByTime(60_000); + expect(cb.mock.calls).toEqual([['dusk']]); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).toHaveBeenCalledTimes(1); + off(); + }); + + it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()]; + m.subscribe(a); + const offB = m.subscribe(b); + vi.advanceTimersByTime(30_000); + now = local(18, 10); // the clock has crossed; no tick has seen it yet + const offLate = m.subscribe(late); + offB(); + vi.advanceTimersByTime(60_000); + expect(a.mock.calls).toEqual([['dusk']]); + expect(b).not.toHaveBeenCalled(); + expect(late.mock.calls).toEqual([['dusk']]); + offLate(); + }); + + it('keeps no timer without listeners or when a fixed state is chosen', async () => { + const m = await fresh(); + const off = m.subscribe(() => undefined); + expect(vi.getTimerCount()).toBe(1); + m.setSkyState('night'); + expect(vi.getTimerCount()).toBe(0); + m.setSkyState('auto'); + expect(vi.getTimerCount()).toBe(1); + off(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('notifies a change of choice with the resolved sky', async () => { + const m = await fresh(); + m.setSkyClock(() => local(12)); + const cb = vi.fn(); + m.subscribe(cb); + m.setSkyState('night'); + m.setSkyState('auto'); + expect(cb.mock.calls).toEqual([['night'], ['day']]); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts new file mode 100644 index 00000000..0b60d99b --- /dev/null +++ b/src/app/features/scene/skyState.ts @@ -0,0 +1,138 @@ +// Which sky the signed-out page is drawn against. The choice is one value, kept in one +// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and +// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the +// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the +// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three. +import { sunElevation } from '../../../sky/sun.js'; +import type { FixedSkyState } from './skyBand'; + +export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; + +export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; +export const SKY_STATE_KEY = 'ratatoskr.skyState'; + +const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState); + +function load(): SkyState { + try { + const stored = localStorage.getItem(SKY_STATE_KEY); + return isSkyState(stored) ? stored : 'auto'; + } catch { + return 'auto'; + } +} + +export type { FixedSkyState }; + +/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */ +const LAT = 45; +/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */ +export const DAY_FROM = 6; + +let clock: () => Date = () => new Date(); +/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */ +export const standardOffsetMinutes = (now: Date): number => + Math.max( + new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), + new Date(now.getFullYear(), 6, 1).getTimezoneOffset() + ); + +/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ +export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { + if (s !== 'auto') return s; + const lon = -standardOffsetMinutes(now) / 4; + const el = sunElevation(now, LAT, lon) as number; + if (el >= DAY_FROM) return 'day'; + return el <= -DAY_FROM ? 'night' : 'dusk'; +} + +let current: SkyState | undefined; +const listeners = new Set<(s: FixedSkyState) => void>(); +let timer: ReturnType | undefined; +let shown: FixedSkyState | undefined; +const TICK_MS = 60_000; + +/** The choice (what the control shows). */ +export const getSkyState = (): SkyState => { + current ??= load(); + return current; +}; + +/** The sky being drawn: the choice, resolved. */ +export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState()); + +const emit = () => { + shown = getResolvedSkyState(); + [...listeners].forEach((cb) => cb(shown as FixedSkyState)); +}; + +// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells +// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the +// first listener arrives, never by a later arrival or departure). Otherwise there is no timer. +function sync(): void { + clearTimeout(timer); + timer = undefined; + if (listeners.size === 0 || getSkyState() !== 'auto') return; + const tick = () => { + if (getResolvedSkyState() !== shown) emit(); + timer = setTimeout(tick, TICK_MS); + }; + timer = setTimeout(tick, TICK_MS); +} + +/** Replace the clock (tests); no argument restores the real one. */ +export function setSkyClock(now?: () => Date): void { + clock = now ?? (() => new Date()); + sync(); +} + +export function setSkyState(s: SkyState): void { + if (!isSkyState(s)) return; + const changed = s !== getSkyState(); + current = s; + try { + localStorage.setItem(SKY_STATE_KEY, s); + } catch { + // Storage is refused (private mode, quota): the choice holds for this page load only. + } + if (changed) { + sync(); + emit(); + } +} + +/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ +export function subscribe(cb: (s: FixedSkyState) => void): () => void { + if (listeners.size === 0) shown = getResolvedSkyState(); + listeners.add(cb); + sync(); + return () => { + listeners.delete(cb); + sync(); + }; +} + +/** + * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of + * the sky it resolves to now. + */ +export interface SkyBackdrop { + readonly darkest: number; + readonly lightest: number; +} + +// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from +// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so +// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering +// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts +// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). +// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts). +// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. +const BACKDROPS: Record = { + day: { darkest: 0.461063, lightest: 0.901108 }, + dusk: { darkest: 0.460352, lightest: 0.968744 }, + night: { darkest: 0.05987, lightest: 0.081642 }, +}; + +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts new file mode 100644 index 00000000..c7e460f3 --- /dev/null +++ b/src/app/features/scene/skyTheme.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; +import { baked } from '../../../sky/baked'; +import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; +import { Editor } from '../../components/editor/Editor.css'; +import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { CallStatus } from '../call-status/styles.css'; +import { MembersDrawer } from '../room/MembersDrawer.css'; +import { RoomViewTyping } from '../room/RoomViewTyping.css'; +import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; +import { SkyPlate, plateRegions } from './skyPlate.css'; +import { + getResolvedSkyState, + setSkyClock, + skyBackdrop, + subscribe as subscribeSky, +} from './skyState'; +import type { SkyBackdrop } from './skyState'; +import type { FixedSkyState } from './skyBand'; +import { sceneThemeId, skyThemeId } from './skyTheme'; + +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; +const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, id] as const] : []; +}); + +// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the +// sky can show any luminance between its darkest and lightest, and contrast is lowest where the +// backdrop is closest to the ink, which is the ink's own luminance clamped into that range. +const against = (ink: string, bg: number) => { + const li = luminance(ink); + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; +const ratio = (ink: string, b: SkyBackdrop) => + against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); +const r2 = (n: number) => n.toFixed(2); + +describe('skyThemeId', () => { + it('picks a theme for each explicit state', () => { + expect(skyThemeId('day')).toBe('noonday-sky'); + expect(skyThemeId('dusk')).toBe('noonday-sky'); + expect(skyThemeId('night')).toBe('midnight-sky'); + }); + + it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { + [0, 6, 12, 18, 23].forEach((h) => { + setSkyClock(() => atStandardTime(h, 10)); + expect(skyThemeId('auto')).toBeUndefined(); + }); + setSkyClock(); + }); + + describe('sceneThemeId, the theme the page wears', () => { + it('a fixed state always picks its theme, whatever the scene or the person chose', () => { + [true, false].forEach((scene) => + [true, false].forEach((chose) => { + expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky'); + expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky'); + }) + ); + }); + + it('auto follows the resolved sky only while a scene is on and the person chose no theme', () => { + expect(sceneThemeId('auto', 'day', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'dusk', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'night', true, false)).toBe('midnight-sky'); + expect(sceneThemeId('auto', 'day', false, false)).toBeUndefined(); + expect(sceneThemeId('auto', 'day', true, true)).toBeUndefined(); + }); + }); + + it('the resolved sky a hook subscribes to changes as the clock crosses, so theme classes follow', () => { + vi.useFakeTimers(); + let hour = 12; + setSkyClock(() => atStandardTime(hour, 10)); + const seen: string[] = []; + const off = subscribeSky((s) => seen.push(s)); + expect(getResolvedSkyState()).toBe('day'); + hour = 0; + setSkyClock(() => atStandardTime(hour, 10)); + vi.advanceTimersByTime(61_000); + expect(seen).toEqual(['night']); + expect(getResolvedSkyState()).toBe('night'); + off(); + vi.useRealTimers(); + setSkyClock(); + }); + + it('is what a wiring that follows the sky reads: the resolved state names a theme', () => { + setSkyClock(() => atStandardTime(12)); + expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky'); + setSkyClock(() => atStandardTime(0)); + expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky'); + setSkyClock(); + }); +}); + +describe('ink of the theme a state picks, on bare sky', () => { + // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes + // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears + // in every state: the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded as + // they are measured. Midnight's link is brightened in its own hue until it clears the lightest Night sky + // (NIGHT_SKY_LIGHTEST); Dusk's dark link needs a backdrop of about 0.4554, which the dusk floor is set above. + // 'auto' resolves from the clock to one of these three and has the same numbers. + const cells: [FixedSkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], + ['dusk', 'noonday-sky', '#342d25', '6.59', '#2a4f27', '4.54'], + ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], + ]; + + it.each(cells)( + '%s with %s: text %s is %s, link %s is %s', + (state, id, ink, want, link, wantLink) => { + const sky = SKY[id as keyof typeof SKY]; + expect(sky.tokens.Background.OnContainer).toBe(ink); + expect(sky.link).toBe(link); + expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); + expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); + } + ); + + it.each(cells)('%s: text clears AA', (state, _id, ink) => { + expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); + }); + + it('link clears AA in every state', () => { + const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); + expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); + expect(link('dusk', 'noonday-sky')).toBeGreaterThanOrEqual(AA); + expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); + }); + + it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { + expect(NIGHT_SKY_LIGHTEST).toBe(skyBackdrop('night').lightest); + }); + + it.each([ + [12, 'day'], + [18, 'dusk'], + [0, 'night'], + ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { + setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0)); + const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY; + expect(id).toBe(skyThemeId(state)); + const { tokens, link } = SKY[id]; + const text = tokens.Background.OnContainer; + expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state)))); + expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state)))); + setSkyClock(); + }); +}); + +describe('the plate, where text cannot rest on bare sky', () => { + // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. + const plates = [ + ['midnight-sky', baked.midnight, '12.17', '11.28'], + ['noonday-sky', baked.noonday, '6.70', '4.62'], + ] as const; + + it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => { + expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio); + expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio); + expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + }); + + it('the other themes take fallback colours that clear AA', () => { + const { fill, text, link } = SKY_PLATE_FALLBACK; + expect(r2(contrast(text, fill))).toBe('10.21'); + expect(r2(contrast(link, fill))).toBe('10.03'); + }); + + it('every container of the picked themes carries its text at AA', () => { + fixed.forEach(([, id]) => { + Object.values(SKY[id].tokens).forEach((g) => { + expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA); + }); + }); + }); +}); + +describe('regions that sit on the plate', () => { + // Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink + // and link of that plate are pinned above; this pins who is on it. + const on = (cls: string) => expect(plateRegions).toContain(cls); + + it('plates the composer, the call status bar and the call view', () => { + on(`.${Editor}`); + on(`.${CallStatus}`); + on(`.${SkyPlate}`); + }); + + it('plates a message that is not a bubble, and nothing else of the signed-in shell', () => { + expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); + expect(plateRegions).toHaveLength(4); + }); + + // Meta, 2026-11-09: no chrome. These are bare ink on the sky, not plates. + it('leaves title bars, the room list, list pages, the drawer and the bars over the composer bare', () => { + [ + `.${MembersDrawer}`, + `.${PageNavContent}`, + `.${PageContent}`, + `.${RoomViewTyping}`, + '#root header', + ].forEach((cls) => expect(plateRegions).not.toContain(cls)); + }); +}); + +describe('the sign-in footer band', () => { + // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene, + // as the browser does: per channel, in encoded sRGB (not in linear light, which flatters dark scenes). The scene + // behind it is the terrain, taken as grey: as dark as 0, as light as the sky state's lightest backdrop. Ink + // contrast is lowest at one of those two ends, so both are checked. + const encode = (l: number) => (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); + const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const hexOf = (c: number[]) => + `#${c + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, '0') + ) + .join('')}`; + const bandOver = (fill: string, behind: number) => + hexOf(channels(fill).map((f) => FOOTER_TINT * f + (1 - FOOTER_TINT) * encode(behind))); + it.each(fixed)( + '%s: the ink clears AA over the darkest and the lightest scene behind it', + (state, id) => { + const { Other, OtherText } = SKY[id].bubbles; + [0, skyBackdrop(state).lightest].forEach((behind) => { + expect(contrast(OtherText, bandOver(Other, behind))).toBeGreaterThanOrEqual(AA); + }); + } + ); +}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts new file mode 100644 index 00000000..5dff3e06 --- /dev/null +++ b/src/app/features/scene/skyTheme.ts @@ -0,0 +1,39 @@ +// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme +// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme +// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here. +// +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's +// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on +// bare sky: Day at least 0.2983 (dark ink), Night at most 0.0931 (light ink). Dusk's floor is higher, the bound of +// the dark link (0.4554), so its link clears as well. skyTheme.test.ts pins the measured text and link ratios. Night's +// link ink is not in the band, and the themes' link colours are derived outside this code (src/sky/skyTokens.js). +// Midnight Sky's link is derived there against the lightest Night sky +// (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending +// its removal. +import type { FixedSkyState, SkyState } from './skyState'; + +export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; + +const BY_STATE: Record = { + day: 'noonday-sky', + dusk: 'noonday-sky', + night: 'midnight-sky', +}; + +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; + +/** + * The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the + * clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none. + * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. + */ +export const sceneThemeId = ( + state: SkyState, + resolved: FixedSkyState, + sceneOn: boolean, + userChoseTheme: boolean +): SkyThemeId | undefined => { + if (state !== 'auto') return BY_STATE[state]; + return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; +}; diff --git a/src/app/features/scene/standardTime.testkit.ts b/src/app/features/scene/standardTime.testkit.ts new file mode 100644 index 00000000..a6966238 --- /dev/null +++ b/src/app/features/scene/standardTime.testkit.ts @@ -0,0 +1,7 @@ +import { standardOffsetMinutes } from './skyState'; + +/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */ +export const atStandardTime = (h: number, m = 0): Date => { + const probe = new Date(2026, 8, 22); + return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000); +}; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts new file mode 100644 index 00000000..ef1623c4 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function -- fake GL and DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainScene } from './Terrain'; +import { GRID, triangleCount } from './terrainWorld'; +import { groundLight, SKY, skyAtSun } from './terrainSky'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; +import { SKY_FRAG } from './terrainShaders'; +import { SKY_BAND } from '../skyBand'; +import { terrainSkyFor } from '../skyForState'; +import { startBackgroundScene } from '../index'; + +/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ +function fakeGl(over: Record = {}) { + const calls: Record = {}; + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name in over) return over[name]; + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return (...args: unknown[]) => { + (calls[name] ??= []).push(args); + return {}; + }; + }, + } + ); + return { gl, calls }; +} + +let rafQueue: Array<(t: number) => void>; +let visibility: string; +let search: string; +beforeEach(() => { + rafQueue = []; + visibility = 'visible'; + search = ''; + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get visibilityState() { + return visibility; + }, + createElement: () => ({ style: {}, remove() {} }), + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get location() { + return { search }; + }, + setTimeout, + }); + vi.stubGlobal('performance', { now: () => 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + rafQueue = []; + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function canvasWith(gl: unknown) { + return { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: vi.fn() }, + getContext: vi.fn(() => gl), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any; +} + +describe('createTerrainScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null without WebGL2, so the host keeps its CSS ground', () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'full' })).toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + }); + + it('returns null with no vertex texture units, or an incomplete bake target', () => { + expect( + createTerrainScene({ canvas: canvasWith(fakeGl({ getParameter: () => 0 }).gl), tier: 'full' }) + ).toBeNull(); + const { gl, calls } = fakeGl({ checkFramebufferStatus: () => 0 }); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })).toBeNull(); + expect(calls.deleteProgram).toHaveLength(3); + }); + + it('reports a shader that does not compile to onCompileFail and returns null', () => { + const { gl } = fakeGl({ getShaderParameter: () => false, getShaderInfoLog: () => 'bad' }); + const onCompileFail = vi.fn(); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onCompileFail })).toBeNull(); + expect(onCompileFail).toHaveBeenCalledOnce(); + expect(onCompileFail.mock.calls[0][0].log).toContain('terrain height'); + }); + + it('bakes the height map once, then draws sky and grid each frame and keeps turning', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'low' })!; + expect(scene).not.toBeNull(); + const bakes = calls.drawArrays.length; + expect(bakes).toBe(1); + rafQueue.shift()!(16); + rafQueue.shift()!(32); + expect(calls.drawArrays).toHaveLength(bakes + 2); // one sky draw per frame + expect(calls.drawElements).toHaveLength(2); + expect(calls.drawElements[0][1]).toBe(triangleCount(GRID.low.grid) * 3); + expect(calls.texImage2D).toHaveLength(1); // the baked map is never re-uploaded + expect(rafQueue).toHaveLength(1); + scene.dispose(); + }); + + it("'still' draws one frame and queues nothing", () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!; + expect(calls.drawElements).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + scene.dispose(); + }); + + it('lights with the sky chosen: option, then ?sky=, then the night default', () => { + const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... + return calls.uniform3fv + .filter((a) => (a[1] as ArrayLike).length === 3) + .map((a) => Array.from(a[1] as ArrayLike)); + }; + const night = uploaded({}); + const day = uploaded({ sky: 'noonday' }); + const nightA = Array.from(groundLight(SKY.midnight).ambient); + const dayA = Array.from(groundLight(SKY.noonday).ambient); + const has = (all: number[][], v: number[]) => + all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(night, nightA)).toBe(true); + expect(has(night, dayA)).toBe(false); + expect(has(day, dayA)).toBe(true); + search = '?sky=noonday'; + expect(has(uploaded({}), dayA)).toBe(true); + search = '?sky=garbage'; + expect(has(uploaded({}), nightA)).toBe(true); + }); + + it.each(['day', 'dusk', 'night'] as const)( + 'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass', + (state) => { + const { gl, calls } = fakeGl(); + const opts = terrainSkyFor(state); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + const { floor, ceiling, lift } = SKY_BAND[state]; + const pairs = calls.uniform2fv.filter( + (a) => + Math.abs((a[1] as number[])[0] - floor) < 1e-6 && + Math.abs((a[1] as number[])[1] - ceiling) < 1e-6 + ); + expect(pairs).toHaveLength(2); + const lifts = calls.uniform3fv.filter((a) => + Array.from(a[1] as ArrayLike).every((x, i) => Math.abs(x - lift[i]) < 1e-6) + ); + expect(lifts).toHaveLength(2); + } + ); + + it('uploads no band (0, 1) and a zero lift without one', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose(); + expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2); + }); + + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { + const ambients = (opts: Record) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); + }; + const dusk = skyAtSun(-1, 300); + const has = (all: number[][], v: readonly number[]) => + all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( + true + ); + search = '?sky=noonday&sun=-1&az=300'; + expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); + search = '?sun=garbage'; + expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); + }); + + it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.texImage3D).toHaveLength(2); + const a = calls.texImage3D[0]; + expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); + expect(a[9]).toEqual(skyViewWords()); + const ap = calls.texImage3D[1]; + expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); + expect(ap[9]).toEqual(apVolumeWords()); + expect(calls.deleteTexture).toHaveLength(3); + }); + + it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { + expect(SKY_FRAG).toContain('- 0.5'); + expect(SKY_FRAG).toContain('dither / 255.0'); + }); + + it('falls back to a still frame when the measured p95 is over 33 ms', () => { + const { gl, calls } = fakeGl(); + const onProbe = vi.fn(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!; + let t = 0; + for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50)); + expect(rafQueue).toHaveLength(0); + expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); + expect(calls.drawElements.length).toBeLessThan(400); + scene.dispose(); + }); + + it('does not draw while the tab is hidden, or under reduced motion', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!; + const before = (calls.drawElements ?? []).length; + visibility = 'hidden'; + rafQueue.shift()!(16); + expect((calls.drawElements ?? []).length).toBe(before); + scene.dispose(); + vi.stubGlobal('window', { + ...(window as object), + matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), + }); + visibility = 'visible'; + const g2 = fakeGl(); + const still = createTerrainScene({ canvas: canvasWith(g2.gl), tier: 'full' })!; + expect(rafQueue).toHaveLength(0); + expect(g2.calls.drawElements).toHaveLength(1); // one frame, then still + still.dispose(); + }); + + it('a lost context stops the scene and tells the host', () => { + const { gl, calls } = fakeGl(); + const canvas = canvasWith(gl); + const onContextLost = vi.fn(); + createTerrainScene({ canvas, tier: 'full', onContextLost })!; + const handler = canvas.addEventListener.mock.calls.find( + (c: unknown[]) => c[0] === 'webglcontextlost' + )[1]; + handler({ preventDefault() {} }); + expect(onContextLost).toHaveBeenCalledOnce(); + const n = (calls.drawElements ?? []).length; + rafQueue.splice(0).forEach((f) => f(16)); + expect((calls.drawElements ?? []).length).toBe(n); + }); + + it('dispose frees its programs, buffers and texture and queues nothing more', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.deleteProgram).toHaveLength(3); + expect(calls.deleteBuffer).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(3); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe("startBackgroundScene style 'terrain'", () => { + it('requests webgl2 and returns a scene once the tier is on', async () => { + const { gl } = fakeGl(); + const canvas = canvasWith(gl); + const scene = await startBackgroundScene({ tier: 'still', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + scene!.dispose(); + }); + + it('stays off by default, and the default style is still the ridge (no WebGL2)', async () => { + const canvas = canvasWith(null); + expect(await startBackgroundScene({ style: 'terrain' }, {}, { canvas })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + const { gl } = fakeGl(); + const c2 = { ...canvasWith(gl), getContext: vi.fn(() => null) }; + await startBackgroundScene({ tier: 'still' }, {}, { canvas: c2 }); + expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); + }); +}); + +describe('stars in the terrain scene', () => { + let overlays: any[]; + let arcs: number; + const withOverlays = () => { + overlays = []; + arcs = 0; + (document as any).createElement = () => { + const el: any = { + style: {}, + remove: vi.fn(), + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => (arcs += 1), + }), + }; + overlays.push(el); + return el; + }; + }; + const scene = (sky: 'midnight' | 'noonday', tier: 'still' | 'low' = 'low') => { + withOverlays(); + const canvas = canvasWith(fakeGl().gl); + const s = createTerrainScene({ canvas, tier, sky })!; + return { s, canvas }; + }; + + it('a midnight sky gets one star overlay beside the canvas, painted with the terrain', () => { + const { s, canvas } = scene('midnight'); + expect(overlays).toHaveLength(1); + expect(canvas.parentElement.appendChild).toHaveBeenCalledWith(overlays[0]); + rafQueue.shift()!(16); + expect(arcs).toBeGreaterThan(0); + s.dispose(); + expect(overlays[0].remove).toHaveBeenCalledOnce(); + }); + + it('a noonday sky gets no overlay and paints no stars', () => { + const { s } = scene('noonday'); + expect(overlays).toHaveLength(0); + expect(arcs).toBe(0); + s.dispose(); + }); + + it("'still' paints the stars once at base and queues nothing", () => { + const { s } = scene('midnight', 'still'); + expect(arcs).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('a hidden tab or reduced motion paints no more stars than the first frame', () => { + visibility = 'hidden'; + const { s } = scene('midnight'); + const n = arcs; + expect(n).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); // the terrain loop never starts, so nothing repaints the stars + s.dispose(); + visibility = 'visible'; + (window as any).matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + const r = scene('midnight'); + expect(rafQueue).toHaveLength(0); + expect(arcs).toBe(n); + r.s.dispose(); + }); + + it('stars turn with the terrain: a later frame is painted at a later heading', () => { + let clock = 0; + vi.stubGlobal('performance', { now: () => (clock += 1000) }); + const { s } = scene('midnight'); + rafQueue.shift()!(16); + const first = arcs; + rafQueue.shift()!(1000); + expect(arcs).toBeGreaterThan(first); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts new file mode 100644 index 00000000..a1807305 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.ts @@ -0,0 +1,443 @@ +import type { Scene, SceneOptions } from '../Scene'; +import type { SkyBand } from '../skyBand'; +import { FrameSampler, FrameStats } from '../ridge/probe'; +import { albedoPalette } from './terrainAlbedo'; +import { sunIndex } from '../../../../sky/scatter.js'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; +import { + nightness, + glowColour, + groundLight, + chooseSky, + SkyName, + START_TURNS, + sunDirection, +} from './terrainSky'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + FULLSCREEN_VERT, + GRID_FRAG, + GRID_VERT, + heightFragmentSource, + SKY_FRAG, +} from './terrainShaders'; +import { createTerrainStars } from './terrainStars'; +import { overlayRequested } from '../debugOverlays'; +import { showTierLabel } from './tierLabel'; + +export type TerrainOptions = Pick< + SceneOptions, + 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' +> & { + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */ + sky?: SkyName; + /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ + sun?: { elevation: number; azimuth?: number }; + /** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */ + band?: SkyBand; + /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ + seed?: number; + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`, where `debug` allows it. */ + probe?: boolean; + /** The diagnostic overlays (readout, tier label) may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; + onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; +}; + +/** Same shape as Scene.ts's ShaderCompileError, redeclared so this chunk does not import the ray-march scene. */ +class ShaderCompileError extends Error { + constructor(readonly stage: 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: string): WebGLProgram { + const make = (type: number, src: string, which: string) => { + const s = gl.createShader(type); + if (!s) throw new Error('createShader failed'); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s) ?? ''; + gl.deleteShader(s); + throw new ShaderCompileError('vertex', `terrain ${name} ${which} shader: ${log}`); + } + return s; + }; + const v = make(gl.VERTEX_SHADER, vs, 'vertex'); + const f = make(gl.FRAGMENT_SHADER, fs, 'fragment'); + const p = gl.createProgram(); + if (!p) throw new Error('createProgram failed'); + gl.attachShader(p, v); + gl.attachShader(p, f); + gl.linkProgram(p); + gl.deleteShader(v); + gl.deleteShader(f); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(p) ?? ''; + gl.deleteProgram(p); + throw new ShaderCompileError('vertex', `terrain ${name} link: ${log}`); + } + return p; +} + +/** + * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the + * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an + * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the + * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the + * ridge scene. + */ +export function createTerrainScene(opts: TerrainOptions): Scene | null { + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const { canvas } = opts; + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + }) as WebGL2RenderingContext | null; + if (!gl) return null; + const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); + if (typeof units === 'number' && units < 1) return null; + + const sky = chooseSky(opts); + const spec = GRID[tier]; + + let skyProg: WebGLProgram; + let gridProg: WebGLProgram; + let bakeProg: WebGLProgram; + try { + bakeProg = program(gl, 'height', FULLSCREEN_VERT, heightFragmentSource()); + skyProg = program(gl, 'sky', FULLSCREEN_VERT, SKY_FRAG); + gridProg = program(gl, 'grid', GRID_VERT, GRID_FRAG); + } catch (e) { + opts.onCompileFail?.(e as ShaderCompileError); + return null; + } + + // The height map, baked once. + const heightTex = gl.createTexture(); + const fbo = gl.createFramebuffer(); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. + const skyTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGB16F, + SKY_VIEW.width, + SKY_VIEW.height, + SKY_VIEW.depth, + 0, + gl.RGB, + gl.HALF_FLOAT, + skyViewWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). + const apTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGBA16F, + AP_VOLUME.width, + AP_VOLUME.height, + AP_VOLUME.depth, + 0, + gl.RGBA, + gl.HALF_FLOAT, + apVolumeWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); + const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + const free: Array<() => void> = []; + const release = () => { + free.forEach((f) => f()); + gl.deleteTexture(heightTex); + gl.deleteTexture(skyTex); + gl.deleteTexture(apTex); + gl.deleteFramebuffer(fbo); + gl.deleteProgram(bakeProg); + gl.deleteProgram(skyProg); + gl.deleteProgram(gridProg); + }; + if (!complete) { + release(); + return null; + } + gl.viewport(0, 0, spec.tex, spec.tex); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(bakeProg); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uSpan'), TERRAIN.span); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uTexels'), spec.tex); + gl.uniform2fv(gl.getUniformLocation(bakeProg, 'uSeed'), seedOffset(opts.seed ?? 0)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // The displaced grid. + const g = buildGrid(spec.grid); + const vao = gl.createVertexArray(); + const vbuf = gl.createBuffer(); + const ibuf = gl.createBuffer(); + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbuf); + gl.bufferData(gl.ARRAY_BUFFER, g.uv, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.indices, gl.STATIC_DRAW); + gl.bindVertexArray(null); + free.push( + () => gl.deleteVertexArray(vao), + () => gl.deleteBuffer(vbuf), + () => gl.deleteBuffer(ibuf) + ); + + const sun = [...sunDirection(sky)]; + const sunLight = [...sky.direct]; + const ground = groundLight(sky); + const ambient = [...ground.ambient]; + const heading = START_TURNS; + const glow = [...glowColour(sky)]; + const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1]; + const lift = [...(sky.band?.lift ?? [0, 0, 0])]; + const sunTexel = sunIndex(sky.elevation); + const night = nightness(sky.elevation); + const moonTexel = sunIndex(Math.abs(sky.elevation)); + const palette = albedoPalette(sky); + const triangles = triangleCount(spec.grid); + + const stars = createTerrainStars(canvas, sky); + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const sampler = new FrameSampler(); + let still = tier === 'still'; + let disposed = false; + let lost = false; + let raf = 0; + let readout: HTMLElement | undefined; + let resizeTimer = 0; + let turnsAtPause = 0; + let playedFrom: number | undefined; + + /** Size the canvas: low renders at 0.75 of the CSS size; full at device pixel ratio capped to 2. */ + const size = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1); + canvas.width = Math.max(1, Math.round((canvas.clientWidth || window.innerWidth) * dpr)); + canvas.height = Math.max(1, Math.round((canvas.clientHeight || window.innerHeight) * dpr)); + }; + + const draw = (turns: number) => { + if (lost) return; + gl.viewport(0, 0, canvas.width, canvas.height); + const v = view(canvas.width / canvas.height, (turns + heading) % 1); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(skyProg); + const us = (n: string) => gl.getUniformLocation(skyProg, n); + gl.uniform3fv(us('uSun'), sun); + gl.uniform3fv(us('uSunColour'), sunLight); + gl.uniform3fv(us('uGlow'), glow); + gl.uniform2fv(us('uBand'), band); + gl.uniform3fv(us('uLift'), lift); + gl.uniform1f(us('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(us('uSky'), 1); + gl.uniform3fv(us('uRight'), v.right); + gl.uniform3fv(us('uUp'), v.up); + gl.uniform3fv(us('uForward'), v.forward); + gl.uniform2f(us('uSize'), canvas.width, canvas.height); + gl.uniform1f(us('uTanHalf'), Math.tan(CAMERA.fov / 2)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + + gl.enable(gl.DEPTH_TEST); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.useProgram(gridProg); + const u = (n: string) => gl.getUniformLocation(gridProg, n); + gl.uniformMatrix4fv(u('uViewProj'), false, v.viewProj); + gl.uniform3fv(u('uCamera'), v.eye); + gl.uniform1f(u('uSpan'), TERRAIN.span); + gl.uniform1i(u('uTex'), spec.tex); + gl.uniform3fv(u('uSun'), sun); + gl.uniform3fv(u('uLightDir'), ground.dir); + gl.uniform3fv(u('uLight'), ground.colour); + gl.uniform3fv(u('uAmbient'), ambient); + gl.uniform3fv(u('uGlow'), glow); + gl.uniform2fv(u('uBand'), band); + gl.uniform3fv(u('uLift'), lift); + gl.uniform1f(u('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(u('uSky'), 1); + gl.uniform1f(u('uNight'), night); + gl.uniform1f(u('uMoonIndex'), moonTexel); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.uniform1i(u('uAP'), 2); + gl.uniform3fv(u('uSoil'), palette.soil); + gl.uniform3fv(u('uScree'), palette.scree); + gl.uniform3fv(u('uRock'), palette.rock); + gl.uniform3fv(u('uSnow'), palette.snow); + gl.uniform1f(u('uSnowLine'), palette.snowLine); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.uniform1i(u('uHeight'), 0); + gl.bindVertexArray(vao); + gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); + gl.bindVertexArray(null); + stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); + }; + + const moving = () => + !still && + !disposed && + !lost && + !(reducedMotion?.matches ?? false) && + document.visibilityState === 'visible'; + + const turnsNow = (now: number) => + turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (CAMERA.seconds * 1000)); + + const report = (stillNow: boolean) => { + const s = { ...sampler.stats(), still: stillNow, triangles }; + opts.onProbe?.(s); + if (readout) { + readout.textContent = `terrain · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed( + 1 + )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; + } + }; + + const settleToStill = () => { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + still = true; + cancelAnimationFrame(raf); + raf = 0; + draw(turnsAtPause); + report(true); + }; + + let sinceReport = 0; + // The first window is measured (guard against a slow device); after it the loop only draws. + let measuring = true; + const frame = (now: number) => { + raf = 0; + if (!moving()) return; + draw(turnsNow(now)); + if (!measuring) { + raf = requestAnimationFrame(frame); + return; + } + const full = sampler.push(now); + if (full) { + measuring = false; + if (sampler.slow()) { + settleToStill(); + return; + } + if (probe) report(false); + } else if (probe && now - sinceReport > 500) { + sinceReport = now; + report(false); + } + raf = requestAnimationFrame(frame); + }; + const wake = () => { + if (!moving()) { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + return; + } + if (raf) return; + playedFrom = performance.now(); + if (measuring) sampler.reset(); + raf = requestAnimationFrame(frame); + }; + + const onLost = (e: Event) => { + e.preventDefault(); + lost = true; + cancelAnimationFrame(raf); + raf = 0; + opts.onContextLost?.(); + }; + canvas.addEventListener('webglcontextlost', onLost); + + size(); + if (probe && canvas.parentElement) { + readout = document.createElement('div'); + Object.assign(readout.style, { + position: 'absolute', + left: '8px', + top: '8px', + font: '12px monospace', + color: '#fff', + background: 'rgba(0,0,0,.55)', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', + }); + canvas.parentElement.appendChild(readout); + } + const removeLabel = showTierLabel(canvas, tier, opts.debug === true); + if (still) settleToStill(); + else wake(); + if (!still && !moving()) draw(0); + + const onVisibility = () => { + sampler.reset(); + wake(); + }; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed || lost) return; + size(); + stars?.resize(); + draw(turnsNow(performance.now())); + sampler.reset(); + }, 250); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + reducedMotion?.addEventListener?.('change', onVisibility); + + return { + login() { + // The camera is the same before and after. + }, + dispose() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(resizeTimer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + reducedMotion?.removeEventListener?.('change', onVisibility); + canvas.removeEventListener('webglcontextlost', onLost); + readout?.remove(); + removeLabel?.(); + stars?.dispose(); + release(); + }, + }; +} diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..d85d8f01 --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,286 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; +import { terrainSkyFor } from '../skyForState'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('the sky-only tier label', () => { + const labels = (parentAppend: ReturnType) => + parentAppend.mock.calls.filter(([e]) => e?.textContent === 'terrain · sky-only'); + + it('shows for ?tierlabel=1 only where the overlays are allowed', () => { + search = '?tierlabel=1'; + const off = canvasWith(); + createTerrainSkyScene({ canvas: off.canvas, tier: 'low' })!.dispose(); + expect(labels(off.parentAppend)).toHaveLength(0); + const on = canvasWith(); + createTerrainSkyScene({ canvas: on.canvas, tier: 'low', debug: true })!.dispose(); + expect(labels(on.parentAppend)).toHaveLength(1); + }); + + it('the door allows it from the config flag, and not from a plain production config', async () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + search = '?tierlabel=1'; + const plain = canvasWith(); + ( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas: plain.canvas }) + )?.dispose(); + expect(labels(plain.parentAppend)).toHaveLength(0); + const flagged = canvasWith(); + ( + await startBackgroundScene( + { tier: 'low', style: 'terrain', debugOverlays: true }, + {}, + { canvas: flagged.canvas } + ) + )?.dispose(); + expect(labels(flagged.parentAppend)).toHaveLength(1); + vi.unstubAllEnvs(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); + +describe('the 2D sky lies in the band of its state', () => { + // The painted pixels, read back through the sRGB curve as a screen shows them. + const lumOf = (rgb: number[]) => { + const [r, g, b] = rgb.map((v) => { + const e = v / 255; + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const painted = (state: 'day' | 'dusk' | 'night') => { + const c = canvasWith(); + createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose(); + const img = c.painted[0]; + const out: number[] = []; + for (let y = 0; y < img.height; y += 1) { + for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y))); + } + return out; + }; + + it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => { + expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983); + }); + + it('night: no pixel above the text bound 0.0931', () => { + expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..4653d470 --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,90 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick< + TerrainOptions, + 'canvas' | 'tier' | 'sky' | 'sun' | 'band' | 'debug' +>; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const heading = START_TURNS; + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, heading); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(heading); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + const removeLabel = showTierLabel(canvas, 'sky-only', opts.debug === true); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + removeLabel?.(); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts new file mode 100644 index 00000000..6afac79d --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -0,0 +1,226 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './terrainSky'; +import { + ALBEDO, + albedoAt, + albedoPalette, + bilinearHeight, + CAVITY, + cavityAt, + curvatureAt, + grainAt, + triWeights, + wobble, + wobbleAt, +} from './terrainAlbedo'; +import { GRID_FRAG } from './terrainShaders'; + +const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => + a.every((v, i) => Math.abs(v - b[i]) < eps); +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +// A point where the wobble is zero, so band edges sit exactly where the palette says. +const FLAT_X = Math.PI / 0.043; +// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. +const facing = (ny: number) => [0, ny, 0] as const; +const at = (sky: keyof typeof SKY, h: number, ny: number) => + albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); + +describe('albedo by slope and height', () => { + it('the test point really has no wobble', () => { + expect(Math.abs(wobble(FLAT_X, 0))).toBeLessThan(1e-9); + }); + + it('steep faces are rock at every height, gentle low ones are soil, gentle high ones are snow', () => { + const p = albedoPalette(SKY.noonday); + for (const h of [10, 90, 160, 220]) expect(near(at('noonday', h, 0.3), p.rock)).toBe(true); + expect(near(at('noonday', 10, 1), p.soil)).toBe(true); + expect(near(at('noonday', 220, 1), p.snow)).toBe(true); + }); + + it('soil grades to scree by height on gentle ground, halfway at the middle of the band', () => { + const p = albedoPalette(SKY.noonday); + const half = p.soil.map((v, i) => (v + p.scree[i]) / 2); + expect(near(at('noonday', (ALBEDO.screeFrom + ALBEDO.screeTo) / 2, 1), half)).toBe(true); + }); + + it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { + const l = [1, 0.8, 0.5].map((ny) => luma(at('noonday', 220, ny))); + expect(l[0]).toBeGreaterThan(l[1]); + expect(l[1]).toBeGreaterThan(l[2]); + }); + + it('is continuous: a small step in height or slope never jumps a channel by more than a tenth', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = 0; h < 250; h += 1) + for (let ny = 0.3; ny <= 1; ny += 0.05) { + const a = at(sky, h, ny); + const b = at(sky, h + 1, ny + 0.01); + a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(0.1)); + } + }); + + it('never returns a negative or a super-white channel', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = -20; h < 300; h += 7) + for (let ny = 0; ny <= 1; ny += 0.1) + for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + it('the wobble moves band edges but stays inside its stated amplitude', () => { + for (let i = 0; i < 400; i += 1) + expect(Math.abs(wobble(i * 13.7, i * -9.1))).toBeLessThanOrEqual(1); + }); +}); + +describe('per-sky palette', () => { + const mid = albedoPalette(SKY.midnight); + const noon = albedoPalette(SKY.noonday); + + it('midnight snows lower than noonday', () => { + expect(mid.snowLine).toBeLessThan(noon.snowLine); + }); + + it('midnight is tinted toward the cool horizon haze, noonday toward the warm sun', () => { + // blue share of the same material is higher at night + const blueShare = (c: readonly number[]) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(mid.scree)).toBeGreaterThan(blueShare(noon.scree)); + expect(blueShare(mid.snow)).toBeGreaterThan(blueShare(noon.snow)); + }); + + it('a sun at the horizon lands between the two palettes', () => { + const dusk = albedoPalette({ ...SKY.noonday, elevation: 2 }); + expect(dusk.snowLine).toBeLessThan(noon.snowLine); + expect(dusk.snowLine).toBeGreaterThan(mid.snowLine); + }); + + it('keeps every material in 0..1', () => { + for (const p of [mid, noon]) + for (const c of [p.soil, p.scree, p.rock, p.snow]) + c.forEach((v) => { + expect(v).toBeGreaterThan(0); + expect(v).toBeLessThanOrEqual(1); + }); + }); +}); + +describe('shader agrees with the pure function on its numbers', () => { + it('takes every band number from ALBEDO, and reads no texture for colour', () => { + expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); + expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); + expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); + expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); + expect(GRID_FRAG).toContain(`#define ALB_FINE_WARP ${ALBEDO.fineWarp.toFixed(1)}`); + expect(GRID_FRAG).toContain('waves3(warpq(qy),'); + // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); + }); +}); + +const UP = [0, 1, 0] as const; +const EAST = [1, 0, 0] as const; + +describe('grain', () => { + const samples = (rock: number, distance: number) => + Array.from({ length: 400 }, (_, i) => + grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) + ); + const spread = (v: number[]) => Math.max(...v) - Math.min(...v); + + it('varies the surface near the eye, never goes negative, and averages about 1', () => { + const v = samples(0, 50); + expect(spread(v)).toBeGreaterThan(0.4); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); + }); + + it('drops its fine octave with distance, so far ground does not shimmer', () => { + const rangeOf = (d: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); + expect(rangeOf(900)).toBeLessThan(rangeOf(50)); + }); + + it('layers the rock: strata show only where the face is rock', () => { + const height = (rock: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); + expect(height(1)).toBeGreaterThan(0.2); + expect(height(0)).toBeLessThan(1e-9); + }); +}); + +describe('triplanar projection', () => { + it('weights sum to 1 and go to the plane the face looks along', () => { + for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { + const w = triWeights(n); + expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); + } + expect(triWeights(UP)).toEqual([0, 1, 0]); + expect(triWeights(EAST)).toEqual([1, 0, 0]); + expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); + }); + + it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { + const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); + const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); + const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); + expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); + expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); + expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); + expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); + }); + + it('level ground takes its pattern over (x, z) alone, as before', () => { + expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); + expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); + }); +}); + +describe('cavity shading', () => { + // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. + const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); + const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); + // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. + const worstStep = (read: (px: number, pz: number) => number) => { + let worst = 0; + for (let px = 14; px < 26; px += 0.01) + worst = Math.max( + worst, + Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) + ); + return worst; + }; + + it('curvature is the mean of four neighbours at the radius minus the centre', () => { + expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); + expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); + }); + + it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { + const t = (i: number, j: number) => i * 10 + j * 100; + expect(bilinearHeight(t, 3, 4)).toBe(430); + expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); + }); + + it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { + const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); + const blocky = worstStep(nearestAt); + expect(blocky).toBeGreaterThan(0.1); + expect(smooth).toBeLessThan(0.02); + }); + + it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { + expect(cavityAt(0)).toBe(1); + expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); + expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + }); + + it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { + expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); + expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts new file mode 100644 index 00000000..48dc2b42 --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -0,0 +1,208 @@ +// Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader +// (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. +// Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky +// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour +// as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. +import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; + +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +/** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ +export const ALBEDO = { + /** soil fades to scree across these heights. */ + screeFrom: 80, + screeTo: 160, + /** half-width in metres of the snow line's fade. */ + snowBand: 22, + /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ + rockLo: 0.78, + rockHi: 0.95, + /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ + snowLo: 0.74, + snowHi: 0.93, + /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ + wobbleHeight: 14, + wobbleSlope: 0.05, + /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ + grainCoarse: 0.35, + grainFine: 0.3, + /** Metres the fine octave's coordinates are pushed by a slower sine, so its parallel stripes bend and do not read as hatching. */ + fineWarp: 7, + /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ + fineNear: 250, + fineFar: 700, + strata: 0.22, +} as const; + +export interface AlbedoPalette { + soil: Vec3; + scree: Vec3; + rock: Vec3; + snow: Vec3; + /** Metres where snow is half in. */ + snowLine: number; +} + +const BASE = { + soil: [0.045, 0.08, 0.02], + scree: [0.3, 0.235, 0.16], + rock: [0.075, 0.062, 0.06], + snow: [0.72, 0.75, 0.8], +} as const; + +const SNOW_LINE_DAY = 165; +const SNOW_LINE_NIGHT = 135; + +const unit = (c: Vec3): Vec3 => { + const m = Math.max(c[0], c[1], c[2], 1e-6); + return [c[0] / m, c[1] / m, c[2] / m]; +}; + +/** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ +export function albedoPalette(d: SkyData): AlbedoPalette { + const cold = coldness(d); + const sun = unit(d.direct); + const haze = unit(hazeColour(d)); + const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); + const k = mix(0.15, 0.5, cold); + const tinted = (c: Vec3): Vec3 => + [0, 1, 2].map((i) => c[i] * mix(1, tint[i], k)) as unknown as Vec3; + return { + soil: tinted(BASE.soil), + scree: tinted(BASE.scree), + rock: tinted(BASE.rock), + snow: tinted(BASE.snow), + snowLine: mix(SNOW_LINE_DAY, SNOW_LINE_NIGHT, cold), + }; +} + +/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ +export const wobble = (x: number, z: number): number => + Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); + +/** + * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing + * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by + * these (triplanar), so a cliff is not the floor pattern smeared down it. + */ +export function triWeights(n: Vec3): Vec3 { + const w = n.map((c) => Math.abs(c) ** 4); + const sum = w[0] + w[1] + w[2] || 1; + return [w[0] / sum, w[1] / sum, w[2] / sum]; +} + +/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ +export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { + const w = triWeights(n); + return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); +} + +/** + * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. + * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow + * line, thinning to nothing as the face steepens. + */ +export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { + const w = wobbleAt(x, y, z, n); + const h = y + ALBEDO.wobbleHeight * w; + const flat = n[1] + ALBEDO.wobbleSlope * w; + const ground = (i: number) => + mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); + const snow = + smooth(p.snowLine - ALBEDO.snowBand, p.snowLine + ALBEDO.snowBand, h) * + smooth(ALBEDO.snowLo, ALBEDO.snowHi, flat); + const rock = 1 - smooth(ALBEDO.rockLo, ALBEDO.rockHi, flat); + return [0, 1, 2].map((i) => + mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) + ) as unknown as Vec3; +} + +const COARSE = [ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], +] as const; +const FINE = [ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], +] as const; +const waves = (k: ReadonlyArray, a: number, b: number) => + k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; +/** `waves` of coordinates warped by a sine each (the shader's `warpq`): three plane waves alone make a regular lattice. */ +const wavesWarped = (k: ReadonlyArray, a: number, b: number) => + waves( + k, + a + ALBEDO.fineWarp * Math.sin(b * 0.55 + 1.3), + b + ALBEDO.fineWarp * Math.sin(a * 0.47 + 0.7) + ); + +/** + * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit + * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the + * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the + * height map and has no twin here. + */ +export function grainAt( + x: number, + y: number, + z: number, + n: Vec3, + rock: number, + distance: number +): number { + const w = triWeights(n); + const over = ( + k: ReadonlyArray, + f: typeof waves + ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); + const coarse = over(COARSE, waves); + const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); + return Math.max( + 0, + 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers + ); +} + +/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ +export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; + +/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ +export function bilinearHeight( + texel: (i: number, j: number) => number, + px: number, + pz: number +): number { + const i = Math.floor(px); + const j = Math.floor(pz); + const fx = px - i; + const fz = pz - j; + return mix( + mix(texel(i, j), texel(i + 1, j), fx), + mix(texel(i, j + 1), texel(i + 1, j + 1), fx), + fz + ); +} + +/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ +export function curvatureAt( + height: (px: number, pz: number) => number, + px: number, + pz: number, + r: number +): number { + return ( + 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - + height(px, pz) + ); +} + +/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ +export const cavityAt = (curv: number): number => + 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts new file mode 100644 index 00000000..c153eb5f --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable no-restricted-syntax -- tests walk table texels */ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { + aerialRay, + AP_ELEVATIONS, + AP_SLICES, + apElevation, + apSliceKm, + decode, + SUN_ELEVATIONS_DEG, + viewElevation, +} from '../../../../sky/scatter.js'; +import { + AERIAL_GLSL, + aerialAt, + AP_VOLUME, + apVolumeWords, + SCATTER_GLSL, + scatterRadiance, + skyViewWords, + SKY_VIEW, + tableWords, + transmittanceAt, +} from './terrainScatter'; + +const S = baked.scatter.sky_view; + +describe('the table upload', () => { + it('decodes the baked base64 to one half-float word per channel per texel', () => { + const w = skyViewWords(); + expect(w.length).toBe(S.width * S.height * S.depth * S.channels); + expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); + expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); + }); + + it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { + expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ + 0x1234, 0x7bff, + ]); + }); +}); + +describe('scatterRadiance (the CPU twin of the sampler)', () => { + it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { + const w = skyViewWords(); + for (const [z, y, x] of [ + [10, 31, 0], + [8, 5, 3], + [5, 20, 6], + [11, 0, 2], + ]) { + const base = ((z * S.height + y) * S.width + x) * 3; + const got = scatterRadiance( + S.sun_elevations_deg[z], + viewElevation(y), + x * S.azimuth_step_deg + ); + got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); + } + }); + + it('interpolates between texels and clamps beyond the table', () => { + const a = scatterRadiance(25, 10, 0)[2]; + const b = scatterRadiance(45, 10, 0)[2]; + const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis + expect(mid).toBeCloseTo((a + b) / 2, 9); + expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); + expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); + }); + + it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { + scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); + const noon = scatterRadiance(45, 85, 90); + expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); + }); +}); + +describe('transmittanceAt', () => { + it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { + const z = transmittanceAt(90); + [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); + const h = transmittanceAt(2); + expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); + }); + + it('is zero where the ground is in the way', () => { + expect(transmittanceAt(-4)).toEqual([0, 0, 0]); + }); +}); + +describe('the GLSL sampler', () => { + it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { + expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); + expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); + expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); + expect(SCATTER_GLSL).toContain('/ 64.0'); + expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); + expect(SCATTER_GLSL).toContain('* 6.0'); + }); +}); + +describe('the aerial perspective volume as the terrain reads it', () => { + it('uploads RGBA half floats in the baked layout', () => { + const a = baked.scatter.aerial_perspective; + expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); + expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); + }); + + it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { + const sunI = 8; + const ei = 5; + const slice = 6; + const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; + const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); + for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); + expect(got.alpha).toBeCloseTo(want[3], 3); + }); + + it('between texels it is near the model: the filtering error is small against the signal', () => { + const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; + const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); + expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); + expect(got.L[0] / want[0]).toBeGreaterThan(0.6); + expect(got.L[0] / want[0]).toBeLessThan(1.6); + }); + + it('fades to nothing at the observer, and clamps past the last slice', () => { + expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); + const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); + const beyond = aerialAt(45, -2, 90, 1e6); + expect(beyond.alpha).toBeCloseTo(last.alpha, 9); + }); + + it('the shader lookup carries the same constants as the table', () => { + const a = baked.scatter.aerial_perspective; + expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); + expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); + expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts new file mode 100644 index 00000000..72ebfb5c --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -0,0 +1,179 @@ +// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene +// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and +// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. +import { baked } from '../../../../sky/baked'; +import { + AP_DISTANCE_SCALE, + AP_ELEVATIONS, + AP_SLICES, + apElevationIndex, + apSliceIndex, + decode, + sunIndex, + viewIndex, +} from '../../../../sky/scatter.js'; +import type { Vec3 } from './terrainSky'; + +const S = baked.scatter; +const AP = S.aerial_perspective; +const SKY = S.sky_view; +const VIEW = S.view_elevation_deg; + +/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ +export function tableWords(b64: string): Uint16Array { + const bytes = atob(b64); + return Uint16Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) + ); +} + +export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; +export const STORE_SCALE = S.store_scale; +export const skyViewWords = (): Uint16Array => tableWords(SKY.data); + +/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ +export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; +export const apVolumeWords = (): Uint16Array => tableWords(AP.data); +export { AP_DISTANCE_SCALE }; + +/** Decode a stored table to values, once. */ +const decoded = (data: string) => { + let values: Float32Array | undefined; + return (): Float32Array => { + if (!values) values = Float32Array.from(tableWords(data), decode); + return values; + }; +}; +const transmittanceTable = decoded(S.transmittance.data); +const skyTable = decoded(SKY.data); +const apTable = decoded(AP.data); + +/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ +export function transmittanceAt(elevationDeg: number): Vec3 { + const t = transmittanceTable(); + const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); + const i = Math.min(Math.floor(f), VIEW.samples - 2); + const k = f - i; + return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; +} + +/** + * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view + * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. + */ +export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { + const t = skyTable(); + const at = [ + Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), + viewIndex(viewElDeg), + sunIndex(sunElDeg), + ]; + const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); + const f = at.map((v, a) => v - lo[a]); + const out = [0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; + for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; + } + return [out[0], out[1], out[2]]; +} + +/** + * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity + * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, + * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the + * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). + */ +export function aerialAt( + lightElDeg: number, + viewElDeg: number, + azDeg: number, + km: number +): { L: Vec3; alpha: number } { + const t = apTable(); + const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); + const e = apElevationIndex(viewElDeg); + const z = sunIndex(lightElDeg); + const texel = (slice: number): number[] => { + const lo = [ + Math.min(Math.floor(x), AP.width - 2), + Math.min(Math.floor(e), AP_ELEVATIONS - 2), + Math.min(Math.floor(z), AP.depth - 2), + ]; + const f = [x - lo[0], e - lo[1], z - lo[2]]; + const out = [0, 0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = + (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * + 4; + for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; + } + return out; + }; + const f = apSliceIndex(km); + const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); + const k = Math.min(Math.floor(fs), AP_SLICES - 2); + const a = texel(k); + const b = texel(k + 1); + const fade = Math.min(Math.max(f + 1, 0), 1); + const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); + return { L: [v[0], v[1], v[2]], alpha: v[3] }; +} + +/** + * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree + * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the + * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. + */ +export const HORIZON_FLOOR = 1.5; + +/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ +export const SCATTER_GLSL = ` +uniform highp sampler3D uSky; +uniform float uSunIndex; +uniform vec3 uSun; +vec3 skyScatter(vec3 d) { + float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); + vec2 h = d.xz, s = uSun.xz; + float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 + : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); + float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( + 1 +)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; + float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; + return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ + SKY.height +}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; +}`; + +/** + * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. + * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a + * neighbouring slice; two fetches and a lerp per call. + */ +export const AERIAL_GLSL = ` +uniform highp sampler3D uAP; +vec4 apSlice(float k, float e, float x, float z) { + return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ + AP.height +}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; +} +vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { + float e = ${(AP.elevations - 1).toFixed( + 1 + )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); + float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; + float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; + float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); + float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); + return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts new file mode 100644 index 00000000..f6bcce37 --- /dev/null +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -0,0 +1,255 @@ +// GLSL ES 3.00. The height map is baked once by CultMath's Phacelle noise (vendored, unedited, MPL-2.0: Rune +// Skovbo Johansen's Phacelle noise, https://blog.runevision.com/2026/01/) composed into a slope-directed gully +// filter after the description in his March 2026 erosion-filter post. The composition is ours, written from +// that description; it is not his filter and has not been compared against it (see harness terrainDemo.ts on +// the terrain-tier-spike branch, from which this is promoted). The height is packed in two bytes of an RGBA8 +// target, so no float colour-buffer extension is needed. +import cultmath from '../shaders/CultMath.glsl?raw'; +import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; +import { trimGlsl } from '../cultmathTrim'; +import { LUMA } from '../skyBand'; +import { ALBEDO, CAVITY } from './terrainAlbedo'; +import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; +import { TERRAIN } from './terrainWorld'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; +const f1 = (x: number) => x.toFixed(1); +const lit = (x: number) => (Number.isInteger(x) ? x.toFixed(1) : String(x)); + +export const FULLSCREEN_VERT = `${HEADER} +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const HEIGHT_STAGE = ` +uniform float uSpan; +uniform vec2 uSeed; +uniform float uTexels; +out vec4 o; + +const float BASE_AMP = 60.0; +const float BASE_FREQ = 1.0 / 520.0; +const float BASE_LIFT = 110.0; +const float CELL0 = 160.0; +const float SIDE_LEN = 3.0; +const float NORM = 0.5; +const float SLOPE_REF = 0.55; +const float DETAIL = 1.5; +const float AMP0 = 11.0; +const float HLO = 20.0, HHI = 200.0; + +float easeOut(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - (1.0 - t) * (1.0 - t); } +float powInv(float t, float p) { return 1.0 - pow(1.0 - t, p); } + +float height(vec2 xz) { + vec4 fg = cultmath_fbm_grad(vec3(xz * BASE_FREQ, 0.0), 4, 2.0, 0.45); + float h = BASE_LIFT + BASE_AMP * fg.w; + vec2 g = BASE_AMP * BASE_FREQ * fg.xy; + float target = clamp((h - HLO) / (HHI - HLO) * 2.0 - 1.0, -1.0, 1.0); + float combi = 1.0; + float amp = AMP0; + float cell = CELL0; + for (int i = 0; i < OCTAVES; i++) { + float slope = length(g); + vec2 downhill = slope > 1e-4 ? -g / slope : vec2(1.0, 0.0); + vec2 across = vec2(-downhill.y, downhill.x); + CultPhasor ph = cultmath_phacelle(vec3(xz / cell, 0.5), vec3(across * SIDE_LEN, 0.0), 0.0, NORM); + float mask = easeOut(slope / SLOPE_REF) * combi; + float faded = mix(target, ph.cos.w, mask); + h += amp * faded; + g += amp * mask * ph.cos.xy / cell; + combi = powInv(combi, DETAIL) * (1.0 - abs(ph.sin.w)); + target = faded; + amp *= 0.5; + cell *= 0.5; + } + return h; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(uTexels); + float h = clamp(height((uv - 0.5) * uSpan + uSeed) / ${f1(TERRAIN.hmax)}, 0.0, 1.0); + float q = floor(h * 65535.0 + 0.5); + o = vec4(floor(q / 256.0) / 255.0, mod(q, 256.0) / 255.0, 0.0, 1.0); +}`; + +/** The height-bake fragment shader: header, the part of CultMath that Phacelle and the stage reach, Phacelle, the stage. */ +export function heightFragmentSource(): string { + const math = trimGlsl(cultmath, [phacelle, HEIGHT_STAGE]); + return `${HEADER}#define OCTAVES 4\n${math}\n${phacelle}\n${HEIGHT_STAGE}`; +} + +const FETCH = ` +uniform sampler2D uHeight; +uniform int uTex; +float hAt(ivec2 t) { + t = clamp(t, ivec2(0), ivec2(uTex - 1)); + vec4 s = texelFetch(uHeight, t, 0); + return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( + TERRAIN.hmax + )}; +} +// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). +float hBil(vec2 p) { + ivec2 i = ivec2(floor(p)); + vec2 f = fract(p); + return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); +}`; + +export const GRID_VERT = `${HEADER} +layout(location = 0) in vec2 a_uv; +uniform mat4 uViewProj; +uniform float uSpan; +out vec2 v_uv; +out vec3 v_world; +${FETCH} +void main() { + // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). + vec2 s = a_uv * 2.0 - 1.0; + vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); + // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); + float h = hBil(uv * float(uTex - 1)) * rim; + v_uv = uv; + v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); + gl_Position = uViewProj * vec4(v_world, 1.0); +}`; + +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the +// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). +// The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. +const ALBEDO_DEFINES = ` +#define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} +#define ALB_SCREE_TO ${lit(ALBEDO.screeTo)} +#define ALB_SNOW_BAND ${lit(ALBEDO.snowBand)} +#define ALB_ROCK_HI ${lit(ALBEDO.rockHi)} +#define ALB_ROCK_LO ${lit(ALBEDO.rockLo)} +#define ALB_SNOW_HI ${lit(ALBEDO.snowHi)} +#define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} +#define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} +#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} +#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_FINE_WARP ${lit(ALBEDO.fineWarp)} +#define ALB_STRATA ${lit(ALBEDO.strata)} +#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} +#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} +#define CAV_RADIUS ${lit(CAVITY.radius)} +#define CAV_RANGE ${lit(CAVITY.range)} +#define CAV_DARK ${lit(CAVITY.dark)} +#define CAV_LIGHT ${lit(CAVITY.light)} +`; +// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y. +// No band is uBand = (0, 1). +export const SHAPE_SKY_GLSL = ` +uniform vec2 uBand; +uniform vec3 uLift; +const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')}); +vec3 shapeSky(vec3 c) { + c = clamp(c, 0.0, 1.0); + float l = dot(c, LUMA); + if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l)); + if (l > uBand.y) return c * (uBand.y / l); + return c; +}`; +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into +// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already +// scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL} +uniform vec3 uGlow; +vec3 skyColour(vec3 d) { + return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + GLOW_ZENITH +)}, sqrt(clamp(d.y, 0.0, 1.0)))); +}`; + +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} +in vec2 v_uv; +in vec3 v_world; +uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; +uniform float uSpan, uSnowLine, uNight, uMoonIndex; +out vec4 o; +${FETCH} +// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). +float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } +float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } +// The fine octave's coordinates pushed by a slower sine each (terrainAlbedo wavesWarped): three plane waves alone make a regular lattice. +vec2 warpq(vec2 q) { return q + ALB_FINE_WARP * vec2(sin(q.y * 0.55 + 1.3), sin(q.x * 0.47 + 0.7)); } +void main() { + // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. + vec2 p = v_uv * float(uTex - 1); + float texel = uSpan / float(uTex - 1); + float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); + float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); + vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); + vec3 tw = pow(abs(n), vec3(4.0)); + tw /= tw.x + tw.y + tw.z; + vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; + // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). + float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); + float h = v_world.y + ALB_WOBBLE_H * w; + float flat_ = n.y + ALB_WOBBLE_S * w; + vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); + float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); + float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); + vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); + // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). + float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); + float fine = tw.x * waves3(warpq(qx), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(warpq(qy), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(warpq(qz), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); + float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); + float layers = sin(v_world.y * 0.9 + w * 3.0); + albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, + // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). + float cr = CAV_RADIUS / texel; + float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); + float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); + vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + vec3 toSurface = v_world - uCamera; + float dist = length(toSurface); + // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. + vec3 ray = toSurface / dist; + float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); + vec2 hz = ray.xz, sz = uSun.xz; + float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); + float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; + vec4 ap = aerial(uSunIndex, el, az, km); + vec3 L = ap.rgb; + // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. + if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( + ', ' + )}) * aerial(uMoonIndex, el, az, km).rgb; + vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( + lit +).join(', ')}); + // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). + float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); + vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); + o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); +}`; + +// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a +// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. +const DISC = { + inner: Math.cos((0.5 * Math.PI) / 180), + outer: Math.cos((1.1 * Math.PI) / 180), + gain: 6.0, +}; +export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} +uniform vec3 uSunColour; +uniform vec3 uRight, uUp, uForward; +uniform vec2 uSize; +uniform float uTanHalf; +out vec4 o; +void main() { + vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; + vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); + vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ + DISC.inner +}, dot(d, uSun)); + float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; + o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts new file mode 100644 index 00000000..f409471a --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -0,0 +1,322 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { AP_DISTANCE_SCALE } from './terrainScatter'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { + chooseSky, + coldness, + glowColour, + hazeColour, + hexToLinear, + nightness, + shade, + SKY, + skyAtSun, + skyData, + skyColour, + sunDirection, + aerialPerspective, + SKY_FADE, + metresToKm, + groundLight, + HAZE_K, + HAZE_POWER, + START_TURNS, + Vec3, +} from './terrainSky'; + +const UP = [0, 1, 0] as const; +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ +const look = (el: number, az: number): Vec3 => { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; +}; + +describe('the baked sky is read, not copied', () => { + it('SKY carries Void’s numbers: sun, ambient, horizon', () => { + for (const name of ['midnight', 'noonday'] as const) { + const b = baked[name]; + expect(SKY[name].elevation).toBe(b.sun.elevation_deg); + expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); + expect(SKY[name].direct).toEqual(b.sun.color_linear); + expect(SKY[name].ambient).toEqual(b.ambient_linear); + expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); + } + }); + + it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { + expect(SKY.midnight.elevation).toBeLessThan(0); + expect(SKY.noonday.elevation).toBeGreaterThan(30); + }); + + it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { + for (const d of Object.values(SKY)) { + const s = sunDirection(d); + expect(Math.hypot(...s)).toBeCloseTo(1, 9); + expect(s[1]).toBeCloseTo(Math.sin((d.elevation * Math.PI) / 180), 9); + } + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 0 })[2]).toBeCloseTo(-1, 9); + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 180 })[2]).toBeCloseTo(1, 9); + }); + + it('hexToLinear decodes sRGB', () => { + expect(hexToLinear('#000000')).toEqual([0, 0, 0]); + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); + }); +}); + +describe('a sun at any elevation (skyAtSun)', () => { + it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { + expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); + expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); + const at = (e: number) => luma(skyAtSun(e).direct); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeLessThan(at(60)); + }); + + it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { + const want = SKY.noonday.direct; + const got = skyAtSun(SKY.noonday.elevation).direct; + got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); + }); + + it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { + const lo = skyAtSun(2).direct; + const hi = skyAtSun(45).direct; + expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); + }); + + it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { + expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); + expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); + expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); + expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); + expect(skyAtSun(10, 270).azimuth).toBe(270); + }); +}); + +describe('lighting', () => { + it('noonday lights an upward face with the sun; midnight has none', () => { + expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); + expect(SKY.midnight.direct).toEqual([0, 0, 0]); + }); + + it('shade never goes negative on any normal at either sky, at any distance', () => { + for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) + for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) + for (const dist of [0, 100, 5000]) + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) + expect(ch).toBeGreaterThanOrEqual(0); + }); + + it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { + const toSun = sunDirection(SKY.noonday); + const away = [-toSun[0], 0, -toSun[2]] as const; + const view = look(-5, 0); + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); + expect(lit).toBeGreaterThan(3 * dark); + const m = (n: readonly [number, number, number]) => + shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); + expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black + expect(luma(m(UP))).toBeLessThan(0.12); + }); + + it('noon is much brighter than midnight, ambient included', () => { + expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const view = look(-5, 0); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( + 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) + ); + }); + + it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { + expect(coldness(SKY.noonday)).toBe(0); + expect(coldness(SKY.midnight)).toBe(1); + expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); + }); +}); + +describe('the scattered sky', () => { + it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { + const zenith = skyColour(SKY.noonday, [0, 1, 0]); + expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); + expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); + zenith.forEach((v) => expect(v).toBeLessThan(1)); + }); + + it('a sun at the horizon reddens the sky toward it and darkens it away', () => { + const dusk = skyAtSun(-1, 270); + const toward = skyColour(dusk, look(3, 270)); + const away = skyColour(dusk, look(3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + }); + + it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { + const d = SKY.midnight; + const horizon = skyColour(d, look(0, 180)); + const zenith = skyColour(d, [0, 1, 0]); + horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); + expect(luma(zenith)).toBeLessThan(luma(horizon)); + expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); + }); + + it('the night glow fades out as the sun reaches the horizon', () => { + expect(nightness(-10)).toBe(1); + expect(nightness(5)).toBe(0); + expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); + expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); + expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); + }); + + it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); + for (let e = -20; e < 30; e += 0.5) { + expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); + } + }); +}); + +describe('aerial perspective', () => { + const ray = (el: number, az: number): Vec3 => look(el, az); + const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => + aerialPerspective(d, ray(el, az), m); + + it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { + let prev = along(SKY.noonday, -2, 200, 0).alpha; + expect(prev).toBe(0); + for (const m of [10, 100, 400, 800, 1000, 1300]) { + const a = along(SKY.noonday, -2, 200, m).alpha; + expect(a).toBeGreaterThan(prev); + expect(a).toBeLessThanOrEqual(1); + prev = a; + } + }); + + it('leaves the near ground its own colour and takes the far ridges', () => { + expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); + expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); + }); + + it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { + const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; + expect(far[2]).toBeGreaterThan(far[0]); + }); + + it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { + const dusk = skyAtSun(2, 270); + const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; + const away = along(dusk, -1, 90, SKY_FADE[0]).colour; + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + expect(toward[0]).toBeGreaterThan(toward[2]); + }); + + it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { + const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; + expect(luma(c)).toBeGreaterThan(0.0003); + expect(luma(c)).toBeLessThan(0.08); + expect(c[2]).toBeGreaterThan(2.5 * c[0]); + expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); + }); + + it('moonlight lights the night haze toward the moon more than away from it', () => { + const moon = groundLight(SKY.midnight).dir; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; + expect(luma(toward)).toBeGreaterThan(luma(away)); + }); + + it('with distance a dark surface rises toward the haze and its own colour is lost', () => { + const d = SKY.noonday; + const view = ray(-4, 200); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); + expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); + const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; + expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); + }); + + it('is continuous in the sun elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); + for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); + }); + + it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { + for (const [d, az] of [ + [SKY.midnight, 0], + [skyAtSun(-1, 90), 90], + [skyAtSun(2, 270), 200], + [SKY.noonday, 120], + ] as const) { + const view = ray(-0.5, az); + const hz = skyColour(d, [view[0], 0, view[2]]); + const far = aerialPerspective(d, view, SKY_FADE[1]); + expect(far.alpha).toBe(1); + for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); + expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); + } + }); + + it('the metres-to-km law is the scene metres times the baked distance scale', () => { + expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); + expect(metresToKm(-5)).toBe(0); + }); +}); + +describe('ground light and haze', () => { + it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { + const night = groundLight(SKY.midnight); + expect(night.dir[1]).toBeGreaterThan(0.5); + expect(luma(night.colour)).toBeGreaterThan(0.03); + expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const day = groundLight(SKY.noonday); + expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); + expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); + expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); + }); + + it('the ground light is continuous through sunset', () => { + const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); + for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); + }); + + it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { + const yaw = 2 * Math.PI * START_TURNS; + const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; + const sun = sunDirection(SKY.noonday); + const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); + expect(Math.abs(along)).toBeLessThan(0.4); + }); +}); + +describe('chooseSky: an explicit sky wins over the page URL', () => { + const at = (search: string) => vi.stubGlobal('window', { location: { search } }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lets the URL steer when no option is given', () => { + at('?sun=40&az=90'); + expect(chooseSky({})).toEqual(skyAtSun(40, 90)); + at('?sky=noonday'); + expect(chooseSky({})).toEqual(skyData('noonday')); + }); + + it('prefers an explicit baked sky to ?sun= and ?sky=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + at('?sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + }); + + it('prefers an explicit sun to ?sun=, and takes no azimuth from ?az=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sun: { elevation: 2 } })).toEqual(skyAtSun(2, undefined)); + expect(chooseSky({ sun: { elevation: 2, azimuth: 10 } })).toEqual(skyAtSun(2, 10)); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts new file mode 100644 index 00000000..92a2ab22 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -0,0 +1,253 @@ +// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's +// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. +import { baked } from '../../../../sky/baked'; +import { hexToLinear, shapeToBand } from '../skyBand'; +import type { SkyBand } from '../skyBand'; +import { + AP_DISTANCE_SCALE, + aerialAt, + HORIZON_FLOOR, + scatterRadiance, + transmittanceAt, +} from './terrainScatter'; + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ + ambient: Vec3; + /** The horizon, linear RGB: the baked `horizon_hex`. */ + horizon: Vec3; + /** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */ + band?: SkyBand; +} + +const RAD = Math.PI / 180; +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => + [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; + +export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; + +export { hexToLinear }; + +const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ + elevation: b.sun.elevation_deg, + azimuth: b.sun.azimuth_deg, + direct: b.sun.color_linear, + ambient: b.ambient_linear, + horizon: hexToLinear(b.horizon_hex), +}); + +export const SKY: Record = { + midnight: fromBaked(baked.midnight), + noonday: fromBaked(baked.noonday), +}; + +export const skyData = (name: SkyName): SkyData => SKY[name]; + +/** + * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at + * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); + * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. + */ +export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { + const day = smooth(-8, 25, elevation); + const fade = smooth(-0.83, 1.5, elevation); + const t = transmittanceAt(Math.max(elevation, -4)); + return { + elevation, + azimuth, + direct: [t[0] * fade, t[1] * fade, t[2] * fade], + ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), + horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), + }; +} + +/** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ +export function sunDirection(d: SkyData): Vec3 { + const el = d.elevation * RAD; + const az = d.azimuth * RAD; + return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; +} + +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ +export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); + +/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ +export const hazeColour = (d: SkyData): Vec3 => d.horizon; + +/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ +export const SKY_EXPOSURE = 45; +/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ +export const GLOW_ZENITH = 0.75; + +/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ +export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); + +/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ +export const glowColour = (d: SkyData): Vec3 => { + const k = nightness(d.elevation); + const h = SKY.midnight.horizon; + return [h[0] * k, h[1] * k, h[2] * k]; +}; + +/** + * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering + * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits + * brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one. + * The fragment shader does the same. + */ +export function skyColour(d: SkyData, dir: Vec3): Vec3 { + const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const r = scatterRadiance(d.elevation, el, az); + const glow = glowColour(d); + const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); + const c = [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape + ) as unknown as Vec3; + return d.band ? shapeToBand(c, d.band) : c; +} + +/** + * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper + * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps + * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, + * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is + * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). + */ +export const metresToKm = (metres: number): number => + (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; + +/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ +export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; +/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ +export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; + +/** + * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own + * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim + * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. + */ +export const SKY_FADE: readonly [number, number] = [600, 1350]; + +/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ +export function aerialPerspective( + d: SkyData, + dir: Vec3, + distance: number +): { colour: Vec3; alpha: number } { + const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const km = metresToKm(distance); + const night = nightness(d.elevation); + const day = aerialAt(d.elevation, el, az, km); + const L = [...day.L]; + if (night > 0) { + // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. + const moon = aerialAt(Math.abs(d.elevation), el, az, km); + for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; + } + const horizon = skyColour(d, [dir[0], 0, dir[2]]); + const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); + const colour = [0, 1, 2].map( + (i) => + (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + + f * horizon[i] + ) as unknown as Vec3; + return { colour, alpha: (1 - f) * day.alpha + f }; +} + +/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ +export const NIGHT_AMBIENT_LIFT = 3; +/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ +export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; + +/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ +export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { + const sun = sunDirection(d); + const k = nightness(d.elevation); + return { + dir: [sun[0], Math.abs(sun[1]), sun[2]], + colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, + ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, + }; +} + +/** + * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light + * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. + */ +export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; + +/** + * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from + * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. + */ +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { + const light = groundLight(d); + const sun = light.dir; + const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); + const hemi = 0.5 + 0.5 * normal[1]; + const haze = aerialPerspective(d, view, distance); + return [0, 1, 2].map((i) => { + const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); + return lit * (1 - haze.alpha) + haze.colour[i]; + }) as unknown as Vec3; +} + +/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */ +export function urlSky(): + | { sky?: SkyName; sun?: { elevation: number; azimuth?: number } } + | undefined { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = number('az'); + return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } }; + } + const name = q.get('sky'); + return isSkyName(name) ? { sky: name } : undefined; +} + +/** + * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun + * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer + * (`urlSky`), else midnight. A band, when given, shapes whichever sky that is. + */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; + band?: SkyBand; +}): SkyData { + const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); + const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const }; + if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth)); + return banded(skyData(want.sky ?? 'midnight')); +} diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts new file mode 100644 index 00000000..c1231bd8 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -0,0 +1,241 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SKY, skyAtSun } from './terrainSky'; +import { + BRIGHTEN, + createTerrainStars, + elevationOf, + ELEVATION_TOP, + FRAME_MS, + horizonY, + PERIOD, + nightColours, + project, + starGain, +} from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; +import { isNight, makeStars } from '../ridge/stars'; + +/** A 2D context that only counts: arcs painted and clears since the last reset. */ +function fakeCtx() { + const c = { + arcs: [] as Array<[number, number, number]>, + clears: 0, + alpha: 1, + alphas: [] as number[], + }; + const ctx: any = { + clearRect: () => { + c.clears += 1; + c.arcs = []; + c.alphas = []; + }, + beginPath() {}, + fill() { + c.alphas.push(ctx.globalAlpha); + }, + arc: (x: number, y: number, r: number) => c.arcs.push([x, y, r]), + globalAlpha: 1, + fillStyle: '', + }; + return { c, ctx }; +} + +let overlay: any; +let ctx2d: ReturnType; +let parent: any; +beforeEach(() => { + ctx2d = fakeCtx(); + overlay = { + style: {}, + width: 0, + height: 0, + setAttribute: vi.fn(), + getContext: vi.fn(() => ctx2d.ctx), + remove: vi.fn(), + }; + parent = { appendChild: vi.fn() }; + vi.stubGlobal('document', { createElement: () => overlay }); + vi.stubGlobal('window', { devicePixelRatio: 1, innerWidth: 390, innerHeight: 844 }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const glCanvas = (w = 390, h = 844) => + ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); + +describe('which skies get stars', () => { + it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { + expect(isNight(nightColours())).toBe(true); + }); + + it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { + expect(starGain(-44.6)).toBe(1); + expect(starGain(-12)).toBe(1); + expect(starGain(-6)).toBe(0); + expect(starGain(45.2)).toBe(0); + expect(starGain(-9)).toBeCloseTo(0.5, 9); + let prev = 1; + for (let e = -12; e <= -6; e += 0.25) { + expect(starGain(e)).toBeLessThanOrEqual(prev); + prev = starGain(e); + } + }); + + it('noonday gets no layer and no element', () => { + expect(createTerrainStars(glCanvas(), SKY.noonday)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); + + it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { + createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); + const half = Math.max(...ctx2d.c.alphas); + ctx2d.c.alphas = []; + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + expect(half).toBeGreaterThan(0); + expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); + expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); + expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); + }); + + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + expect(parent.appendChild).toHaveBeenCalledWith(overlay); + s.dispose(); + expect(overlay.remove).toHaveBeenCalledOnce(); + }); + + it('is absent without a parent or a 2D context', () => { + expect( + createTerrainStars({ ...glCanvas(), parentElement: null } as any, SKY.midnight) + ).toBeNull(); + overlay.getContext = () => null; + expect(createTerrainStars(glCanvas(), SKY.midnight)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); +}); + +describe('sky only', () => { + it('the overlay is as tall as the horizon line is low, never the whole screen', () => { + createTerrainStars(glCanvas(), SKY.midnight); + expect(overlay.height).toBe(Math.ceil(horizonY(844))); + expect(overlay.height).toBeLessThan(844 / 2); + expect(overlay.style.pointerEvents).toBe('none'); + }); + + it('a star at the horizon lands on the horizon line, one above it lands above', () => { + for (const aspect of [0.46, 1.78]) { + const h = 800; + const w = h * aspect; + const onLine = project(0.2, 0, w, h)!; + expect(onLine.y).toBeCloseTo(horizonY(h), 5); + expect(project(0.2, 0.05, w, h)!.y).toBeLessThan(horizonY(h)); + } + }); + + it('the camera eye clears the peaks, so the horizon line bounds the terrain', () => { + expect(CAMERA.eye).toBeGreaterThan(256); + }); + + it('every painted star is above the horizon line, at any heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + for (let k = 0; k < 40; k += 1) { + s.draw(k / 40); + for (const [, y] of ctx2d.c.arcs) expect(y).toBeLessThanOrEqual(overlay.height); + } + }); + + it('agrees with the camera the terrain draws: the projection matches view()', () => { + const w = 390; + const h = 844; + const turns = 0.137; + const v = view(w / h, turns); + const yaw = 2 * Math.PI * turns; + const az = yaw + 0.1; + const e = 0.15; + const d = [Math.sin(az) * Math.cos(e), Math.sin(e), -Math.cos(az) * Math.cos(e)]; + const dot = (a: number[], b: readonly number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const t = Math.tan(CAMERA.fov / 2); + const f = dot(d, v.forward); + const nx = dot(d, v.right) / f / (t * (w / h)); + const ny = dot(d, v.up) / f / t; + const at = project(0.1, e, w, h)!; + expect(at.x).toBeCloseTo(((nx + 1) / 2) * w, 6); + expect(at.y).toBeCloseTo(((1 - ny) / 2) * h, 6); + }); +}); + +describe('turning with the camera', () => { + it('stars are on screen, and slide sideways as the camera turns', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0); + const first = ctx2d.c.arcs.slice(); + expect(first.length).toBeGreaterThan(10); + s.draw(0.002); + const later = ctx2d.c.arcs; + expect(later).not.toEqual(first); + const m = (a: number[][]) => a.reduce((t, p) => t + p[0], 0) / a.length; + expect(m(later)).not.toBeCloseTo(m(first), 1); + }); + + it('repeats every PERIOD of heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.1); + const a = ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3))); + s.draw(0.1 + PERIOD / (2 * Math.PI)); + expect(ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3)))).toEqual(a); + }); + + it('the field fills the screen top and thins toward the horizon, inside the elevation band', () => { + expect(elevationOf(0)).toBeCloseTo(ELEVATION_TOP, 9); + expect(elevationOf(0.78)).toBeCloseTo(0, 9); + }); +}); + +describe('brightness', () => { + it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); + ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); + // and it is brighter than the ridge field's own top alpha + const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); + expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); + }); +}); + +describe('motion', () => { + it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const alphas = ctx2d.c.alphas.slice(); + s.draw(0.3); + expect(ctx2d.c.alphas).toEqual(alphas); + }); + + it('with the clock the twinklers swing away from base, and the repaint is throttled', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const still = ctx2d.c.alphas.slice(); + let moved = false; + for (let t = 1; t < 60 && !moved; t += 1.7) { + s.draw(0.3, t); + moved = ctx2d.c.alphas.some((a, i) => a !== still[i]); + } + expect(moved).toBe(true); + const clears = ctx2d.c.clears; + s.draw(0.3, 100); + s.draw(0.3, 100 + FRAME_MS / 4000); + expect(ctx2d.c.clears).toBe(clears + 1); + }); + + it('resize refits the canvas and the next draw repaints at once', () => { + const c = glCanvas(); + const s = createTerrainStars(c, SKY.midnight)!; + s.draw(0.1, 5); + c.clientHeight = 400; + s.resize(); + expect(overlay.height).toBe(Math.ceil(horizonY(400))); + const clears = ctx2d.c.clears; + s.draw(0.1, 5.001); + expect(ctx2d.c.clears).toBe(clears + 1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts new file mode 100644 index 00000000..a7275062 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -0,0 +1,173 @@ +// The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). +// +// The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the +// horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak +// (TERRAIN.hmax), so terrain never rises above that line and a star above the horizon is never over terrain. +// +// Stars turn with the camera, as sky does: the ridge field's x becomes azimuth, repeated every PERIOD (so 400 +// stars fill a sky that only ever shows 55 deg to ~120 deg of it), and y becomes elevation from the horizon +// up to ELEVATION_TOP, the top of the screen. Placement is projected with the terrain's own camera (CAMERA, +// the yaw the terrain draws at), nothing is baked, and the whole overlay is redrawn at most every FRAME_MS: a +// visible star is a few arcs on a canvas a quarter of the screen tall. The stars stop when the terrain stops: +// the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) +// paints every star at base brightness. + +import { baked } from '../../../../sky/baked'; +import type { SkyColours, Srgb } from '../ridge/skyColour'; +import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './terrainSky'; +import { CAMERA } from './terrainWorld'; + +/** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ +export const PERIOD = (2 * Math.PI) / 3; +/** Elevation of the field's top, radians: a little above the top edge of the screen at the camera's pitch. */ +export const ELEVATION_TOP = (16 * Math.PI) / 180; +/** The overlay repaints at most this often while the terrain turns (12 px/s on a phone: under half a pixel a step). */ +export const FRAME_MS = 33; +/** The twinkle schedule is re-read this often; the swings are 2 to 7 s. */ +const TWINKLE_MS = 50; + +const hex = (h: string): Srgb => + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; + +/** + * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked + * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth + * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. + */ +export function starGain(elevation: number): number { + const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); + return 1 - t * t * (3 - 2 * t); +} + +/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ +export function nightColours(): SkyColours { + const g = baked.midnight.gradient; + return { + top: hex(g[0]), + mid: hex(g[Math.floor(g.length / 2)]), + horizon: hex(g[g.length - 1]), + ground: [0, 0, 0], + }; +} + +/** Screen height in px below which there is no sky: where the horizon plane meets the image, a straight line. */ +export const horizonY = (height: number): number => + height * (0.5 - Math.tan(CAMERA.pitch) / Math.tan(CAMERA.fov / 2) / 2); + +export interface Placed { + x: number; + y: number; +} + +/** + * Where a star at azimuth `da` from the camera's heading and elevation `e` (radians) lands in a `w` x `h` px + * viewport, or null if it is behind or off the screen. The same camera as `view()` in terrainWorld.ts + * (forward, right and up are its basis written out for a camera with yaw and pitch only). + */ +export function project(da: number, e: number, w: number, h: number): Placed | null { + const p = CAMERA.pitch; + const ce = Math.cos(e); + const front = ce * Math.cos(p) * Math.cos(da) - Math.sin(e) * Math.sin(p); + if (front <= 0.01) return null; + const tanHalf = Math.tan(CAMERA.fov / 2); + const nx = (ce * Math.sin(da)) / front / (tanHalf * (w / h)); + const ny = (ce * Math.sin(p) * Math.cos(da) + Math.sin(e) * Math.cos(p)) / front / tanHalf; + if (Math.abs(nx) > 1.05) return null; + return { x: ((nx + 1) / 2) * w, y: ((1 - ny) / 2) * h }; +} + +/** Elevation in radians for a ridge-field height fraction y (0 zenith side .. SKY_STOPS.horizon at the horizon). */ +export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_STOPS.horizon); + +const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); + +/** The terrain overlay draws the ridge field's stars brighter and a little larger: a phone's small, hazy sky needs it to read. */ +export const BRIGHTEN = 2.4; +export const GROW = 1.3; + +export interface TerrainStars { + /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ + draw(turns: number, seconds?: number): void; + /** Re-fit the overlay to the canvas's current size; the next draw repaints. */ + resize(): void; + dispose(): void; +} + +/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ +export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { + const colours = nightColours(); + const gain = starGain(sky.elevation); + const parent = canvas.parentElement; + if (!parent || gain <= 0) return null; + const el = document.createElement('canvas'); + const ctx = el.getContext?.('2d'); + if (!ctx) return null; + el.setAttribute?.('aria-hidden', 'true'); + Object.assign(el.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' }); + parent.appendChild(el); + + let w = 1; + let h = 1; + let scale = 1; + let field: Star[] = []; + let lit: Array<{ star: Star; az: number; e: number }> = []; + let gains = new Map(); + let gainsAt = -Infinity; + let last = -Infinity; + + const fit = () => { + w = canvas.clientWidth || window.innerWidth; + h = canvas.clientHeight || window.innerHeight; + scale = Math.min(window.devicePixelRatio || 1, 2); + const sky = Math.max(1, Math.ceil(horizonY(h))); + el.width = Math.max(1, Math.round(w * scale)); + el.height = Math.max(1, Math.round(sky * scale)); + el.style.width = `${w}px`; + el.style.height = `${sky}px`; + field = makeStars(colours, w, h); + lit = field.map((star) => ({ star, az: star.x * PERIOD, e: elevationOf(star.y) })); + gains = new Map(); + gainsAt = -Infinity; + }; + + const paint = (turns: number, seconds: number | undefined) => { + ctx.clearRect(0, 0, el.width, el.height); + if (seconds !== undefined && seconds * 1000 - gainsAt >= TWINKLE_MS) { + gains = new Map(activeAt(field.length, seconds).map((a) => [a.star, a.gain])); + gainsAt = seconds * 1000; + } + const yaw = 2 * Math.PI * turns; + lit.forEach(({ star, az, e }, i) => { + const da = wrap(az - yaw); + const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; + ctx.fillStyle = star.fill; + [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { + const at = project(d, e, w, h); + if (!at || at.y < 0 || at.y > el.height / scale) return; + ctx.beginPath(); + ctx.arc(at.x * scale, at.y * scale, star.r * GROW * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + }); + ctx.globalAlpha = 1; + }; + + fit(); + return { + draw(turns, seconds) { + if (seconds !== undefined && seconds * 1000 - last < FRAME_MS) return; + last = seconds === undefined ? -Infinity : seconds * 1000; + paint(turns, seconds); + }, + resize() { + fit(); + last = -Infinity; + }, + dispose() { + el.remove(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts new file mode 100644 index 00000000..70a9d763 --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -0,0 +1,140 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { + buildGrid, + CAMERA, + GRID, + seedOffset, + TERRAIN, + triangleCount, + view, + warpUv, +} from './terrainWorld'; +import { heightFragmentSource } from './terrainShaders'; + +describe('seed', () => { + it('same seed, same offset, so the baked height map is the same; a different seed moves it', () => { + expect(seedOffset(7)).toEqual(seedOffset(7)); + expect(seedOffset(0)).toEqual([0, 0]); + expect(seedOffset(1)).not.toEqual(seedOffset(2)); + }); + + it('the height-bake source is a pure function of nothing but the world constants', () => { + expect(heightFragmentSource()).toBe(heightFragmentSource()); + const src = heightFragmentSource(); + expect(src.startsWith('#version 300 es')).toBe(true); + expect(src).toContain('cultmath_phacelle'); + expect(src).toContain(`/ ${TERRAIN.hmax.toFixed(1)}`); + expect(src).toContain('uSeed'); + }); +}); + +describe('grid', () => { + it('has (n+1)^2 vertices in 0..1 and 2n^2 triangles that index them all in range', () => { + const n = 8; + const g = buildGrid(n); + expect(g.uv.length).toBe((n + 1) * (n + 1) * 2); + expect(g.indices.length / 3).toBe(triangleCount(n)); + expect(Math.max(...g.indices)).toBe((n + 1) * (n + 1) - 1); + expect(Math.min(...g.uv)).toBe(0); + expect(Math.max(...g.uv)).toBe(1); + }); + + it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { + // 524k at 512 quads (low), 1.18M at 768 (full): Meta asked for denser terrain on 2026-11-10, up from 205k at 320. No + // phone frame time is measured (SwiftShader only); kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(540000); + expect(triangleCount(GRID.full.grid)).toBeLessThan(1200000); + (['still', 'low', 'full'] as const).forEach((t) => + expect((GRID[t].grid + 1) ** 2).toBeLessThan(2 ** 32) + ); + expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); + expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); + }); +}); + +describe('camera', () => { + const project = (m: Float32Array, p: number[]) => { + const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]; + const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; + const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]; + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + return { x: x / w, y: y / w, z: z / w, w }; + }; + + it('a point straight ahead lands in the middle of the screen, in front, inside the depth range', () => { + for (const turns of [0, 0.25, 0.6]) { + const v = view(0.46, turns); + const p = v.eye.map((e, i) => e + 800 * v.forward[i]); + const s = project(v.viewProj, p); + expect(s.w).toBeGreaterThan(0); + expect(s.x).toBeCloseTo(0, 5); + expect(s.y).toBeCloseTo(0, 5); + expect(Math.abs(s.z)).toBeLessThan(1); + } + }); + + it('looks down by the pitch, and one full turn comes back to the same heading', () => { + const a = view(1, 0); + const b = view(1, 1); + expect(a.forward[1]).toBeCloseTo(-Math.sin(CAMERA.pitch), 9); + for (let i = 0; i < 3; i += 1) expect(a.forward[i]).toBeCloseTo(b.forward[i], 9); + }); + + it('sits above the highest possible terrain, so the eye is never inside a hill', () => { + expect(CAMERA.eye).toBeGreaterThan(TERRAIN.hmax); + }); + + it('the basis is orthonormal', () => { + const v = view(0.5, 0.37); + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot(v.right, v.up)).toBeCloseTo(0, 9); + expect(dot(v.right, v.forward)).toBeCloseTo(0, 9); + expect(dot(v.up, v.forward)).toBeCloseTo(0, 9); + expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); + }); +}); + +describe('grid resolution', () => { + // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the + // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone + // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets + // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). + const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); + const spacingAt = (grid: number, metres: number) => { + for (let i = 0; i < grid; i += 1) { + const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; + if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; + } + throw new Error('beyond the patch'); + }; + + it.each(['still', 'low', 'full'] as const)( + 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', + (tier) => { + const { grid, tex } = GRID[tier]; + expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); + } + ); + + it('a dearer tier never has a coarser mesh', () => { + expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); + expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); + }); +}); + +describe('warpUv', () => { + it('maps the patch onto itself, monotonic, centre fixed', () => { + expect(warpUv(0)).toBe(0); + expect(warpUv(1)).toBe(1); + expect(warpUv(0.5)).toBe(0.5); + for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); + }); + + it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { + const plain = 1 / 192; + const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); + expect(step(0.5)).toBeLessThan(0.6 * plain); + expect(step(0.99)).toBeGreaterThan(1.3 * plain); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts new file mode 100644 index 00000000..82a16bfb --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -0,0 +1,140 @@ +// The terrain scene's fixed numbers, grid and camera, as pure functions. No DOM, no GL. +// The height field itself is a function of world position written in GLSL (terrainShaders.ts), baked once +// into a texture; the CPU owns only where it is looked at from, and the seed offset it is sampled at. + +/** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ +export const TERRAIN = { span: 3000, hmax: 256 }; + +/** + * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The + * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh + * under shading read from the height map shows as hard-edged facets and stepped crests. + */ +export const GRID = { + still: { grid: 320, tex: 1024 }, + low: { grid: 512, tex: 1024 }, + full: { grid: 768, tex: 2048 }, +} as const; + +/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ +export const CAMERA = { + eye: 330, + pitch: (13 * Math.PI) / 180, + fov: (55 * Math.PI) / 180, + near: 10, + far: 6000, + /** Seconds for one full turn. */ + seconds: 420, +}; + +/** The height map's sample offset in metres for a seed: same seed, same terrain. Seed 0 is the origin. */ +export function seedOffset(seed: number): [number, number] { + const s = Math.trunc(seed); + return [(s * 7919) % 65536, (s * 104729) % 65536]; +} + +/** + * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the + * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the + * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). + */ +export const warpUv = (u: number): number => { + const s = 2 * u - 1; + return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); +}; + +export const triangleCount = (grid: number): number => 2 * grid * grid; + +/** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ +export function buildGrid(grid: number): { uv: Float32Array; indices: Uint32Array } { + const uv = new Float32Array((grid + 1) * (grid + 1) * 2); + for (let j = 0, k = 0; j <= grid; j += 1) + for (let i = 0; i <= grid; i += 1, k += 2) { + uv[k] = i / grid; + uv[k + 1] = j / grid; + } + const indices = new Uint32Array(grid * grid * 6); + for (let j = 0, k = 0; j < grid; j += 1) + for (let i = 0; i < grid; i += 1, k += 6) { + const a = j * (grid + 1) + i; + indices.set([a, a + grid + 1, a + 1, a + 1, a + grid + 1, a + grid + 2], k); + } + return { uv, indices }; +} + +export interface View { + /** Column-major perspective * view. */ + viewProj: Float32Array; + eye: [number, number, number]; + /** Camera basis in world space, for the sky pass. */ + right: [number, number, number]; + up: [number, number, number]; + forward: [number, number, number]; +} + +const norm = (a: number[]): [number, number, number] => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +}; +const cross = (a: number[], b: number[]): [number, number, number] => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; + +/** The camera `turns` full turns round (0..1 wraps), for a viewport aspect (width / height). */ +export function view(aspect: number, turns: number): View { + const yaw = 2 * Math.PI * turns; + const cp = Math.cos(CAMERA.pitch); + const forward = norm([Math.sin(yaw) * cp, -Math.sin(CAMERA.pitch), -Math.cos(yaw) * cp]); + const right = norm(cross(forward, [0, 1, 0])); + const up = cross(right, forward); + const eye: [number, number, number] = [0, CAMERA.eye, 0]; + const t = (a: number[]) => -(a[0] * eye[0] + a[1] * eye[1] + a[2] * eye[2]); + const v = [ + right[0], + up[0], + -forward[0], + 0, + right[1], + up[1], + -forward[1], + 0, + right[2], + up[2], + -forward[2], + 0, + t(right), + t(up), + -t(forward), + 1, + ]; + const f = 1 / Math.tan(CAMERA.fov / 2); + const { near: n, far } = CAMERA; + const p = [ + f / aspect, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + (far + n) / (n - far), + -1, + 0, + 0, + (2 * far * n) / (n - far), + 0, + ]; + const viewProj = new Float32Array(16); + for (let c = 0; c < 4; c += 1) + for (let r = 0; r < 4; r += 1) { + let s = 0; + for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * v[c * 4 + k]; + viewProj[c * 4 + r] = s; + } + return { viewProj, eye, right, up, forward }; +} diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts new file mode 100644 index 00000000..e3f18871 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showTierLabel } from './tierLabel'; + +let search: string; +let appended: any[]; +const canvas = (parent: boolean) => + ({ + parentElement: parent + ? { + appendChild: (e: any) => appended.push(e), + } + : null, + } as any); + +beforeEach(() => { + search = ''; + appended = []; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); + vi.stubGlobal('document', { + createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('showTierLabel', () => { + it('adds nothing in a normal build', () => { + expect(showTierLabel(canvas(true), 'full', true)).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { + search = '?tierlabel=1'; + const remove = showTierLabel(canvas(true), 'sky-only', true); + expect(appended).toHaveLength(1); + expect(appended[0].textContent).toBe('terrain · sky-only'); + remove!(); + expect(appended[0].remove).toHaveBeenCalled(); + }); + + it('ignores ?tierlabel=1 where the overlays are not allowed (a production page)', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(true), 'full', false)).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('has nowhere to go without a parent', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(false), 'full', true)).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts new file mode 100644 index 00000000..4afc8a69 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -0,0 +1,32 @@ +import { overlayRequested } from '../debugOverlays'; + +// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), +// so a screenshot of a preview says what it shows. Built into preview builds (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1` +// where the diagnostic overlays are allowed (debugOverlays.ts). +const wanted = (allowed: boolean): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || overlayRequested('tierlabel', allowed); + +/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ +export function showTierLabel( + canvas: HTMLCanvasElement, + text: string, + allowed: boolean +): (() => void) | null { + const parent = canvas.parentElement; + if (!parent || !wanted(allowed)) return null; + const el = document.createElement('div'); + el.textContent = `terrain · ${text}`; + Object.assign(el.style, { + position: 'absolute', + left: '2px', + top: '16px', + font: '10px monospace', + color: 'rgba(255,255,255,.75)', + background: 'rgba(0,0,0,.4)', + padding: '1px 5px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(el); + return () => el.remove(); +} diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index 1c1668c7..ba338f37 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -15,8 +15,17 @@ export interface Capabilities { hardwareConcurrency?: number; } +/** + * 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and + * clouds. 'terrain': a baked height map on a displaced grid, lit and hazed by the baked sky (WebGL2). + */ +export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; + export interface BackgroundSceneConfig { tier?: SceneTier; + style?: SceneStyle; + /** Let the page URL switch on the diagnostic overlays (`?probe=1`, `?tierlabel=1`) in a production build. Default: no. */ + debugOverlays?: boolean; } export function readCapabilities(nav: Navigator): Capabilities { diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 6b304965..9ec74305 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; +import { LogoMark } from '../../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -37,6 +38,7 @@ export function About({ requestClose }: AboutProps) { Ratatoskr logo} /> + + + } + /> + + + } + /> + (null); diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts new file mode 100644 index 00000000..c32979ae --- /dev/null +++ b/src/app/hooks/useSkyState.ts @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from 'react'; +import { + getResolvedSkyState, + getSkyState, + subscribe, + type SkyState, +} from '../features/scene/skyState'; +import type { FixedSkyState } from '../features/scene/skyBand'; +import { readCapabilities, resolveTier } from '../features/scene/tier'; +import { useClientConfig } from './useClientConfig'; + +/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `useResolvedSkyState`. */ +export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); + +/** The sky being drawn: day, dusk or night. Unlike the choice it changes as the clock crosses, so it re-renders. */ +export const useResolvedSkyState = (): FixedSkyState => + useSyncExternalStore(subscribe, getResolvedSkyState); + +/** Whether a background scene runs on this device and config (the tier the scene host will mount). */ +export const useSceneOn = (): boolean => + resolveTier(useClientConfig().backgroundScene, readCapabilities(navigator)) !== 'off'; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 4bfe01da..60f89757 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,10 +1,19 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; +import { + butterTheme, + darkTheme, + midnightSkyTheme, + noondaySkyTheme, + silverTheme, +} from '../../colors.css'; import '../../gamecult.css'; -import { settingsAtom } from '../state/settings'; +import '../../sky.css'; +import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; +import { sceneThemeId } from '../features/scene/skyTheme'; +import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -39,14 +48,23 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; -export const GameCultTheme: Theme = { - id: 'gamecult-theme', +export const MidnightSkyTheme: Theme = { + id: 'midnight-sky', kind: ThemeKind.Dark, - classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['gamecult-theme', 'midnight-sky', midnightSkyTheme, onDarkFontWeight, 'prism-dark'], +}; + +export const NoondaySkyTheme: Theme = { + id: 'noonday-sky', + kind: ThemeKind.Light, + classNames: ['gamecult-theme', 'noonday-sky', noondaySkyTheme, onLightFontWeight, 'prism-light'], }; export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo( + () => [MidnightSkyTheme, NoondaySkyTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], + [] + ); return themes; }; @@ -54,7 +72,8 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ - [GameCultTheme.id]: 'GameCult', + [MidnightSkyTheme.id]: 'Midnight Sky', + [NoondaySkyTheme.id]: 'Noonday Sky', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -83,24 +102,42 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; +const toTheme = (id: ReturnType): Theme | undefined => + id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; + +/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */ +const chosenTheme = (useSystemTheme: boolean, themeId?: string) => + useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId); + +/** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */ +export const useSkyTheme = (): Theme | undefined => + toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); + export const useActiveTheme = (): Theme => { + const sky = useSkyState(); + const resolved = useResolvedSkyState(); + const sceneOn = useSceneOn(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); + + if (skyTheme) return skyTheme; if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme - : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? MidnightSkyTheme + : themes.find((theme) => theme.id === lightThemeId) ?? NoondaySkyTheme; return selectedTheme; }; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 52ec7f20..7b0a1f91 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -11,6 +11,7 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; +import { BackgroundScene } from '../features/scene/BackgroundScene'; import { useCompositionEndTracking } from '../hooks/useComposingCheck'; const queryClient = new QueryClient(); @@ -35,6 +36,7 @@ function App() { > {(clientConfig) => ( + diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 983ca684..b7c50d93 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,20 +1,24 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - GameCultTheme, + MidnightSkyTheme, ThemeContextProvider, useActiveTheme, + useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { + // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the + // system scheme. + const theme = useSkyTheme() ?? MidnightSkyTheme; + useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - // The GameCult identity is single-theme: signed-out pages ignore the system scheme. - document.body.classList.add(...GameCultTheme.classNames); - }, []); + document.body.classList.add(...theme.classNames); + }, [theme]); return null; } diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index c53e5a65..49c7b774 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -21,6 +21,8 @@ import { import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import { LogoMark } from '../../components/logo/logoMark.css'; +import { SkyStateControl } from '../../features/scene/SkyStateControl'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -134,9 +136,14 @@ export function AuthLayout() {
- Ratatoskr logo + Ratatoskr logo Ratatoskr +
diff --git a/src/app/pages/auth/TrainingNotice.tsx b/src/app/pages/auth/TrainingNotice.tsx new file mode 100644 index 00000000..6aeaf9d4 --- /dev/null +++ b/src/app/pages/auth/TrainingNotice.tsx @@ -0,0 +1,43 @@ +import { Box, Checkbox, Text, color, config } from 'folds'; +import React from 'react'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { trainingNoticeText } from '../../utils/trainingNotice'; + +type TrainingNoticeProps = { + checked: boolean; + onToggle: () => void; +}; + +// Registration only: ticking it is the newcomer's acceptance of the current notice version. +export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) { + const { previewDemo } = useClientConfig(); + return ( + + + + {trainingNoticeText(previewDemo)} + + + ); +} diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts new file mode 100644 index 00000000..97febbbb --- /dev/null +++ b/src/app/pages/auth/footerBand.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { AuthFooter } from './styles.css'; + +// The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. +const emitted = async () => { + const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; + return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); +}; +const declarations = (css: string, selector: string) => + [...css.matchAll(/^([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => m[1] === selector) + .map((m) => + m[2] + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter(Boolean) + ); + +describe('the sign-in footer band, as emitted', () => { + const over = `:root[data-scene] .${AuthFooter}`; + + it('is one rule over the scene, so no later rule can override its mask', async () => { + expect(declarations(await emitted(), over)).toHaveLength(1); + }); + + it('puts the plate fill before the color-mix tint, so a browser without color-mix keeps the fill', async () => { + const [rule] = declarations(await emitted(), over); + const fills = rule.filter((d) => d.startsWith('background-color:')); + expect(fills).toHaveLength(2); + expect(fills[0]).toBe('background-color: var(--sky-bubble, #0d1a26)'); + expect(fills[1]).toContain('color-mix('); + }); + + it('blurs what is behind it by 12px, the radius of the chat bubbles', async () => { + const [rule] = declarations(await emitted(), over); + expect(rule.filter((d) => d.startsWith('backdrop-filter'))).toEqual([ + 'backdrop-filter: blur(12px)', + ]); + }); + + it('declares exactly these properties, so nothing else (a shadow, a stray override) rides along', async () => { + const [rule] = declarations(await emitted(), over); + // -webkit-backdrop-filter is the one prefixed property, for Safari 14; it must blur exactly as the standard one does. + const blur = (prop: string) => + rule + .find((d) => d.startsWith(`${prop}:`)) + ?.split(':')[1] + .trim(); + expect(blur('-webkit-backdrop-filter')).toBe(blur('backdrop-filter')); + expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ + '-webkit-backdrop-filter', + 'backdrop-filter', + 'background-color', + 'background-color', + 'color', + 'mask-composite', + 'mask-image', + 'padding-bottom', + 'padding-top', + ]); + }); + + it('fades along both axes over a soft ramp, not a hard edge', async () => { + const [rule] = declarations(await emitted(), over); + const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; + expect(rule).toContain('mask-composite: intersect'); + ['bottom', 'right'].forEach((axis) => { + const ramp = mask.match( + new RegExp( + `linear-gradient\\(to ${axis}, transparent, #000 (\\d+)px, #000 calc\\(100% - (\\d+)px\\), transparent\\)` + ) + ); + expect(ramp).not.toBeNull(); + // Each end ramps from clear to opaque over at least 8px. + expect(Number(ramp?.[1])).toBeGreaterThanOrEqual(8); + expect(Number(ramp?.[2])).toBeGreaterThanOrEqual(8); + }); + }); + + it('has no border or radius, in the scene rule or in the base footer rule', async () => { + const css = await emitted(); + const [rule] = declarations(css, over); + const base = declarations(css, `.${AuthFooter}`); + expect(base).toHaveLength(1); + [rule, base[0]].forEach((r) => { + expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); + }); + // The base rule is only the footer's padding: nothing visible (a shadow, a fill) is hidden outside the scene rule. + expect(base[0].map((d) => d.slice(0, d.indexOf(':')))).toEqual(['padding']); + }); +}); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 2f04a733..9d890bef 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -36,7 +36,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar export function Login() { const server = useAuthServer(); - const { hashRouter } = useClientConfig(); + const { hashRouter, previewDemo } = useClientConfig(); const { loginFlows } = useAuthFlows(); const [searchParams] = useSearchParams(); const loginSearchParams = useLoginSearchParams(searchParams); @@ -65,7 +65,7 @@ export function Login() { {parsedFlows.password && ( <> diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index c9b1924c..99feed1a 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -32,6 +32,7 @@ import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsy import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil'; import { FieldError } from '../FiledError'; +import { TrainingNotice } from '../TrainingNotice'; import { AutoDummyStageDialog, AutoTermsStageDialog, @@ -192,6 +193,7 @@ export function PasswordRegisterForm({ const [formData, setFormData] = useState(); const [ongoingFlow, setOngoingFlow] = useState(); + const [trainingAck, setTrainingAck] = useState(false); const [registerEmailState, registerEmail] = useRegisterEmail(mx); @@ -389,7 +391,8 @@ export function PasswordRegisterForm({ )} -