diff --git a/config.json b/config.json index d1c461a4..e945d1c4 100644 --- a/config.json +++ b/config.json @@ -17,9 +17,5 @@ "hashRouter": { "enabled": false, "basename": "/" - }, - - "backgroundScene": { - "tier": "off" } } diff --git a/docs/gamecult/probes/2026-10-31-album-probe.sh b/docs/gamecult/probes/2026-10-31-album-probe.sh new file mode 100644 index 00000000..e7761a6c --- /dev/null +++ b/docs/gamecult/probes/2026-10-31-album-probe.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Throwaway Synapse probe for the Persona file zone (album) shape. Never touches the live server. +set -u +D=/tmp/imag-zone-probe; NAME=imag-zone-probe; IMG=matrixdotorg/synapse:v1.164.1; PORT=18999 +docker rm -f $NAME >/dev/null 2>&1; rm -rf $D; mkdir -p $D/data +docker run --rm -v $D/data:/data -e SYNAPSE_SERVER_NAME=probe.test -e SYNAPSE_REPORT_STATS=no $IMG generate >/dev/null 2>&1 || { echo "generate failed"; exit 1; } +printf 'enable_registration: false\nregistration_shared_secret: "probe-only-secret"\n' >> $D/data/homeserver.yaml +docker run -d --name $NAME -p 127.0.0.1:$PORT:8008 -v $D/data:/data $IMG >/dev/null +for i in $(seq 60); do curl -fs http://127.0.0.1:$PORT/health >/dev/null && break; sleep 1; done +docker exec $NAME register_new_matrix_user -u aqua -p probe-pass -a -k probe-only-secret http://localhost:8008 >/dev/null 2>&1 +HS=http://127.0.0.1:$PORT +TOK=$(curl -s -XPOST $HS/_matrix/client/v3/login -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"aqua"},"password":"probe-pass"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +A="Authorization: Bearer $TOK" +j() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1', d))"; } +echo "versions has v1.11: $(curl -s $HS/_matrix/client/versions | grep -c v1.11)" +grep -n "max_upload_size\|enable_authenticated_media" $D/data/homeserver.yaml || echo "max_upload_size: default (not in generated config)" +ROOM=$(curl -s -XPOST -H "$A" $HS/_matrix/client/v3/createRoom -d '{"name":"aqua files","room_alias_name":"files-aqua"}' | j room_id); echo "room $ROOM" +head -c 300000 /dev/urandom > $D/a.wav +MXC=$(curl -s -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/media/v3/upload?filename=a.wav" | j content_uri); echo "upload v3: $MXC" +echo "upload v1 path: $(curl -s -o /dev/null -w '%{http_code}' -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/client/v1/media/upload?filename=a.wav")" +R=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$ROOM") +ROOT=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio","org.gamecult.album":{"name":"thruster-v3","digest":"x","files":1,"follows":null}}' | j event_id); echo "root $ROOT" +ROOT2=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio"}' | j event_id); echo "same txn again -> $ROOT2 (same id means idempotent)" +REP=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-0" -d "{\"msgtype\":\"m.audio\",\"body\":\"a.wav\",\"filename\":\"a.wav\",\"url\":\"$MXC\",\"info\":{\"mimetype\":\"audio/wav\",\"size\":300000},\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$ROOT\",\"is_falling_back\":true,\"m.in_reply_to\":{\"event_id\":\"$ROOT\"}}}"); echo "thread reply: $REP" +REF=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-root" -d "{\"msgtype\":\"m.text\",\"body\":\"Album v4\",\"m.relates_to\":{\"rel_type\":\"m.reference\",\"event_id\":\"$ROOT\"}}" | j event_id); echo "root with m.reference: $REF" +echo "thread off a root that has a relation: $(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-0" -d "{\"msgtype\":\"m.text\",\"body\":\"f\",\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$REF\"}}")" +MID=${MXC#mxc://} +echo "auth download v1 with token: $(curl -s -o /dev/null -w '%{http_code}' -H "$A" $HS/_matrix/client/v1/media/download/$MID)" +echo "auth download v1 no token: $(curl -s -o /dev/null -w '%{http_code}' $HS/_matrix/client/v1/media/download/$MID)" +echo "range request v1: $(curl -s -o /dev/null -w '%{http_code} %{size_download}' -H "$A" -H 'Range: bytes=0-99' $HS/_matrix/client/v1/media/download/$MID)" +echo "threads list: $(curl -s -H "$A" "$HS/_matrix/client/v1/rooms/$R/threads" | python3 -c 'import sys,json;d=json.load(sys.stdin);print([(e["event_id"]==sys.argv[1], e["unsigned"].get("m.relations",{}).get("m.thread",{}).get("count")) for e in d["chunk"]])' "$ROOT")" +head -c 60000000 /dev/zero > $D/big.bin +echo "60MB upload: $(curl -s -w ' %{http_code}' -XPOST -H "$A" -H 'Content-Type: application/octet-stream' --data-binary @$D/big.bin "$HS/_matrix/media/v3/upload?filename=big.bin" | tail -c 160)" +docker rm -f $NAME >/dev/null; sudo -n rm -rf $D 2>/dev/null || rm -rf $D; echo "cleaned" diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 166c5c72..6a04ea06 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -300,6 +300,185 @@ as of 2026-10-31 ~15:00. (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's room ids; a Web Push gateway if the operator picks it. +## File zone: albums + +Rulings: `ratatoskr:ruling:persona-file-sharing-zone`, +`ratatoskr:ruling:file-zone-albums-one-level`, +`ratatoskr:ruling:file-zone-looks-like-file-sharing`; under them +`eureka-body:ruling:mind-notes-via-personas` (a Self speaks only through her +Persona) and `eureka-body:ruling:live-mind-sessions` (the Persona organ will +become a live session; the album document below does not depend on how her +reply is produced). Eyes facts: `docs/research/2026-10-31-persona-file-zone.md` +on `ratatoskr/research-atmosphere`. + +A Mind's Self makes files (Aqua: `render-thruster-v1` to `-v5`, each a flat +folder of three WAVs of 0.4 to 0.6 MB beside build junk). She shares them as an +**album**: one level, a name, a short note, a set of files, and optionally the +earlier album it follows. Ratatoskr shows each Persona's albums as a +file-sharing space; Matrix is the storage and the fallback for other clients. + +### Shape, and why + +- **One room per Persona is her file zone**, created by provisioning with + room type `org.gamecult.file_zone` (`creation_content.type`, immutable, and + returned as `room_type` by `/hierarchy`, which `buildEstate` already reads). + The zone owner is the room's steward, carried in its `m.space.child` entry as + for project rooms. +- **An album is a thread.** The root is an `m.text` whose body is the + human-readable fallback (name, counts, note) and whose content carries + `org.gamecult.album`: `name`, `digest`, `files`, `kinds`, `bytes`, `follows` + (the root event id of the album it follows, or null) and `request_id`. Each + file is an `m.audio`/`m.image`/`m.video`/`m.file` event in that thread + (`m.thread`, `is_falling_back`, `m.in_reply_to` the root), which every + client already renders through authenticated media. +- **`follows` is a content field, not a relation.** Probe (below): Synapse + refuses a thread off an event that carries `m.relates_to` ("Cannot start + threads from an event with a relation"), so a root that `m.reference`d its + predecessor could not hold its own files. +- **Rejected:** MSC3089 trees (stalled since 2021, no client or server + support); a directory served by nginx (no access check exists, and it would + be a second store beside Matrix); posting albums as threads in the + conversation room (Ratatoskr runs with matrix-js-sdk `threadSupport` off, + `initMatrix.ts:44`, so thread replies land in the main timeline and one + album floods the room); turning `threadSupport` on (Ratatoskr has no thread + panel, so replies would vanish from view). + +### Model page: albums + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Outbox album | directory `/var/lib/mind--outbox//` | written and replaced by her Self | her Self (only writer) | +| Album choice | an `albums[]` item of her Persona's reply | per turn | her Persona | +| Share request | `gamecult.persona.chat_share_request.v1` | one per album per turn | the Persona organ emits; the mouth acts | +| File zone | room alias `#files-:gamecult.org`, room type `org.gamecult.file_zone` | created once | provisioning (`synapse-accounts-hand-managed.py`) | +| Album | thread root carrying `org.gamecult.album` in her zone | immutable once posted; a revision is a new album that `follows` it | the mouth posts, as her account | +| Zone view | derived per render from the zone room's threads | never stored | the client | + +### Authority map: write path (cuts `album-share-mouth`, `album-share-persona`) + +- **Owner:** the mouth (`Bifrost tools/persona-matrix-mouth.mjs`) owns + turning one share request into one album in her zone. The zone room is the + record of what has been shared: the mouth keeps no memory of shares. +- **Inputs:** the share request (album name, announce room, note, follows); + the album directory, read as user `bifrost` through a read ACL; her zone + room's threads (`GET /_matrix/client/v1/rooms/{id}/threads`) and the + album's thread (`/relations/{root}/m.thread`); her brake. +- **Outputs:** the root, one event per file, one announce line in the named + room (her note, the album name and count, a `matrix.to` link to the root), + and a `chat_send_receipt` (root event id, or a refusal). +- **Idempotency is derived from Matrix:** txn ids are `request_id`, + `request_id-f`, `request_id-ann` (probe: a repeated txn returns the same + event); beyond the txn window, a root in her zone with the same `name` and + `digest` is the album, and its thread's filenames say which files still need + posting, so a retry resumes rather than duplicates. +- **Forbidden writers:** the Self never touches Matrix and holds no Matrix + credential; the Persona organ never uploads and never reads file contents; + nothing but the mouth posts albums; no cache or ledger of "shared" albums + exists outside the zone room. +- **Demoted:** the Persona's `say` stays text only; a link to a file in her + sandbox is no longer how a Mind shares. +- **Shared paths:** every album, whatever the Mind or turn, goes through the + one share handler; a retry and a first attempt run the same code. + +### Outbox and limits + +- `/var/lib/mind--outbox`, owned `mind--self`, mode 0750, ACL + `u:mind-:r-x` (her Persona lists it) and `u:bifrost:r-x` (the mouth + reads it), with the same default ACLs. Created by + `install-mind-units-yggdrasil.sh`; `mind-self@.service` gains + `ReadWritePaths=/var/lib/mind-%i-outbox` (her state directory is 0700 and + `ProtectSystem=strict` makes the rest of `/var` read-only). +- An album is one level: regular files only, named by + `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; a subdirectory, symlink, dotfile, a file + not owned by `mind--self`, or an unreadable file rejects the album + with its name (files are opened `O_NOFOLLOW` and checked with `lstat`, so an + album cannot reach another Mind's outbox or the mouth's own state). +- Per file at most 50 MiB (live Synapse `max_upload_size: 50M`; the mouth + uploads on loopback `127.0.0.1:8834`, so nginx's `50m` does not apply); per + album at most 100 files and 500 MiB. Kind by extension: wav, ogg, oga, opus, + flac, mp3, m4a are `m.audio`; png, jpg, jpeg, webp, gif are `m.image`; mp4, + webm are `m.video`; anything else is `m.file` (`application/octet-stream`). +- `digest` is SHA-256 over the sorted list of (filename, size, SHA-256 of + content); files post in bytewise filename order. + +### Authority map: zone view (cut `file-zone-view`) + +- **Owner:** one zone model in the client, a pure `buildAlbums()` over the + zone room's thread roots and an album's thread, fed by one hook; it decides + which albums show, in what order, and which events are an album's files. +- **Inputs:** `/hierarchy` (`room_type` marks zones; the estate index already + holds it); `GET /v1/rooms/{id}/threads` paged (probe: lists roots with + their reply counts); `/relations/{root}/m.thread` for one album. +- **Outputs:** albums (title, file count and kinds, date, `follows` chain) and + one album's files. +- **Derived:** files are the zone owner's media replies only; others' replies + are comments; the `follows` chain is display-only grouping. +- **Forbidden writers:** the timeline does not decide what an album holds; no + local store of albums. +- **Mount:** the navigator lists zones as their own branch kind from + `buildEstate`; opening one shows the zone view, with "Open as room" as the + fallback to the plain timeline (where the flood is accepted). +- **Media:** every fetch goes through `mxcUrlToHttp` with authentication and + the service worker's bearer header (`sw.ts:107`); probe: download without a + token is 401. Synapse answered a `Range` request with the whole body (200), + so playback fetches whole files, as `AudioContent` already does. +- **Parts:** the album list is our own folds component (no library needed); + images through `react-photo-album` (81 KB unpacked) and + `yet-another-react-lightbox` (253 KB unpacked), loaded only by the zone view + chunk; audio reuses `AudioContent` with one album queue (play all, next, + previous). The audio preview is the mel surface below + (`ratatoskr:ruling:audio-preview-mel-surface`), not a waveform, so + `@wavesurfer/react` is not adopted. No file-manager + library: chonky is abandoned and its fork archived, and the zone is + read-only. + +### Audio preview: the mel surface + +- **The mouth computes the sidecar at upload**, for every Mind, not Aqua's + tooling on export. One writer serves all eight Minds; AquaSynth's features + serve one Mind and would make the Self a second writer of a file the mouth + must pair by name. Album events are immutable, so the sidecar ships in the + write path (`album-share-mouth`), or albums shared before it never get one. +- **What it is:** a 256 x 64 8-bit grayscale PNG (time across, 64 mel bands + down, low band at the bottom): a mono mix, Hann window, FFT 2048 at the + file's rate, 256 frames spread evenly over the whole file, log power from + -80 dB to the file's peak mapped onto 0..255. A few KB; built with Node's own + `zlib` and an in-module FFT, no new dependency. A PNG because the browser + decodes it natively straight into a WebGL texture (`texImage2D` from an + `ImageBitmap`), with no parser of ours on the phone. +- **Scope:** PCM WAV (16-bit, 24-bit, 32-bit float), which is what Aqua makes. + Other audio posts with no sidecar and its tile shows a plain placeholder; + decoding compressed audio in the mouth waits for a Mind that shares it. +- **How it travels:** its own upload, referenced from the audio event's + content as `org.gamecult.mel: { url, bands: 64, frames: 256, db_floor: -80 }`. + Not inline data (about 22 KB of base64 on every event every client syncs), + and not an event of its own (other clients would show it as a stray file, + and the view would pair it by filename). The digest covers the album's own + files, not sidecars. +- **The shader:** one WebGL2 component in the zone view chunk: a 256 x 64 + grid (about 16k vertices) displaced in the vertex shader by the texture, + normals from neighbouring texels, a lit swept ribbon with time along the + sweep, mel across, energy as height and colour, and the playhead as a + uniform. Nibu owns the shader (she holds the scene shaders); Void + integrates; CultMath is not needed. Tiles never get a context each + (browsers cap live contexts at about 16, and the background already holds + one): one shared offscreen context renders each tile's still once into an + `ImageBitmap`, cached per mxc for the session; only the open file animates. + Gate: the open file's frame time measured on the operator's phone with the + background running, before the cut is called done. + +### Probe (2026-10-31, throwaway Synapse v1.164.1 container on Yggdrasil, removed after) + +| Claim | Result | +|---|---| +| Upload path | `POST /_matrix/media/v3/upload` 200 with mxc; `/_matrix/client/v1/media/upload` 404 | +| Same txn id twice | same event id | +| File as thread reply with `is_falling_back` | accepted | +| Root carrying `m.reference`, then a thread off it | refused: "Cannot start threads from an event with a relation" | +| `GET /_matrix/client/v1/media/download` with token / without | 200 / 401 | +| `Range: bytes=0-99` | 200, whole body | +| `GET /_matrix/client/v1/rooms/{id}/threads` | lists the root with count 1 | + ## Cut sequence `estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). @@ -344,6 +523,27 @@ Each later cut is one line, with what it waits on. metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. - `attention-bubbles`: per-person attention bubbles from Huginn's typed attention document, scoped to room members; waits on Huginn publishing it. +- `album-share-mouth` (Bifrost): the mouth turns a `chat_share_request` into + an album in her zone (upload, mel sidecar for WAV, thread, announce line, + receipt, resume from Matrix), against a fake homeserver and a temp outbox; + specced (`ratatoskr:cut_spec:cut-album-share-mouth.r1`); waits on nothing. +- `album-outbox-ops` (gamecult-ops): the outboxes and their ACLs, the Self + unit's `ReadWritePaths`, a line in the Self seed text saying how to share, + the zone rooms in `synapse-accounts-hand-managed.py` (room type, steward in + the space child, one alias file per Mind) and `MATRIX_FILE_ZONE_` in + `mouth.env`; waits on `album-share-mouth` (its variable names) and on + `ratatoskr:question:file-zone-audience`. +- `album-share-persona`: Huginn's Persona organ lists her outbox albums in + her turn, her reply gains `albums[]` (name and follows constrained to the + listed albums, room to her rooms), and the organ sends each as a + `chat_share_request`; waits on `album-share-mouth` merging (it owns the + document) and on `album-outbox-ops` deployed; if the live-session rebuild (`eureka-body:ruling:live-mind-sessions`) + lands first, the same document is emitted from wherever her reply is decoded. +- `file-zone-view`: zones as their own branch kind in the navigator, the + album list, an album's files with image grid and lightbox, an album queue + over `AudioContent`, and the mel surface (Nibu's shader); waits on + `estate-navigator` and on albums existing; gated by a phone frame-time + measurement. - `rename-ratatoskr`: package name, app name and branding, manifest and titles, the `/preview/` path and its deploy, and every in-repo `cinny` reference that is ours (not upstream engine paths); waits on Void's forge diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs deleted file mode 100644 index 656d532e..00000000 --- a/harness/estate/check.cjs +++ /dev/null @@ -1,46 +0,0 @@ -// Assertions for the estate drives: every drive states the outcome it exists to show and exits -// non-zero when the page or the fake homeserver's log says otherwise. -const fs = require('fs'); -const fails = []; -exports.expect = (label, ok, detail = '') => { - console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); - if (!ok) fails.push(label); -}; -/** Exit code for the drive: 4 when any assertion failed. */ -exports.done = () => (fails.length ? 4 : 0); -/** The fake homeserver's request log (HSLOG is shared with the server process). */ -exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); -/** Bodies of the account-data PUTs for m.direct, in order. */ -exports.mDirectPuts = () => - exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); -/** Join requests the server saw for a room id. */ -exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ -const tierOf = (pg, name) => - pg.evaluate((n) => { - const s = document.body.innerText.split('\n').map((x) => x.trim()); - const i = s.indexOf(n); - if (i < 0) return 'absent'; - for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; - return 'none-above'; - }, name); -/** - * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is - * opened only if the row is not already showing, so reading never closes what is open. - */ -exports.revealedTier = async (pg, name) => { - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(2500); - let t = await tierOf(pg, name); - for (const h of HEADERS) { - if (t !== 'absent') break; - const hl = pg.getByText(h, { exact: true }); - if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } - } - return t; -}; -/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ -exports.sawText = (pg, re, ms) => - pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); -exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index d53d88a4..9c81424a 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,7 +1,6 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -20,10 +19,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); await open(); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => revealedTier(pg, name); + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; - const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); @@ -40,33 +38,23 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); }; if (MODE === 'pjfail') { await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); - expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); - await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); - expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); - expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); + const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); } else if (MODE === 'jdrop') { - await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); - expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); - await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); - expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); - expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); + await tap('Friend DM'); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); - expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); - expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); } else if (MODE === 'inboxreload') { await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); - expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); + await pg.waitForTimeout(30000); await tiers('inbox reload'); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); - expect('a join cut off by a reload stays joinable', n === 1, String(n)); + const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); } - await quit(done()); + await quit(0); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs deleted file mode 100644 index 75c9a8df..00000000 --- a/harness/estate/drive-routes.cjs +++ /dev/null @@ -1,42 +0,0 @@ -const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); -const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; -const log = (...a) => console.log(`[${MODE}]`, ...a); -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); -setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); -(async () => { - b = await chromium.launch(); - const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); - const pg = await ctx.newPage(); - await pg.goto('http://127.0.0.1:8080/'); - await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); - const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; - let joined = false; - if (MODE === 'deeplink') { - for (const route of ['direct', 'home']) { - await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); - const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); - const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } - } - const f = await tiers('after deeplink join'); - const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); - const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; - expect('the deep link offers one Join button', joined, 'no Join button'); - expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); - expect('the DM is filed under Direct Messages', isDm(f), f); - } else if (MODE === 'gw504') { - const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); - for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); - const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); - expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); - await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); - expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); - expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); - } - await quit(done()); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 03bd2ad0..ed9950ce 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,10 +1,8 @@ const { chromium } = require('playwright'); -const { expect, done, isDm, revealedTier } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -let tierBefore; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -15,14 +13,14 @@ let tierBefore; await open(); const text = () => pg.evaluate(() => document.body.innerText); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => revealedTier(pg, name); + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; if (MODE === 'twodm') { await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); await pg.waitForTimeout(45000); } else if (MODE === 'convert') { await tap('Friend DM'); await pg.waitForTimeout(6000); - tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); + log('TIER before', await tierOf('Friend DM')); await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); @@ -37,12 +35,7 @@ let tierBefore; log('RELOAD', t()); await open(); await pg.waitForTimeout(30000); } - const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); - log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); - if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } - else if (MODE === 'convert') { - expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); - expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); - } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); - await b.close(); process.exitCode = done(); + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); + await b.close(); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 4a6875e0..a8180204 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -91,7 +91,6 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); - if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 65242ab0..26b026b8 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -29,7 +29,4 @@ failure jdrop JOIN_DROP=1 failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 -DRIVER=drive-routes failure deeplink -DRIVER=drive-routes failure gw504 JOIN_504=1 -echo "FAILED_DRIVES:${FAILED:- none}" -[ -z "$FAILED" ] +echo DONE diff --git a/harness/scene/README.md b/harness/scene/README.md deleted file mode 100644 index ef735d2e..00000000 --- a/harness/scene/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Scene harness - -Compiles, renders and times the background scene (`src/app/features/scene/`) in headless -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 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 - node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs - node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` - -Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a -host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a -machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a -directory whose ancestors hold no empty `package.json` (esbuild reads them). - -Timings from SwiftShader are CPU time: use them for the split between passes and for before and -after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs deleted file mode 100644 index 027a2006..00000000 --- a/harness/scene/bundle.cjs +++ /dev/null @@ -1,40 +0,0 @@ -// 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. -const esbuild = require('esbuild'); -const fs = require('fs'); -const path = require('path'); -const out = process.argv[2]; -const demo = process.argv[3] === 'demo'; -fs.mkdirSync(out, { recursive: true }); -const rawPlugin = { - name: 'raw', - setup(b) { - b.onResolve({ filter: /\?raw$/ }, (a) => ({ - path: path.resolve(a.resolveDir, a.path.slice(0, -4)), - namespace: 'raw', - })); - b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ - contents: fs.readFileSync(a.path, 'utf8'), - loader: 'text', - })); - }, -}; -esbuild - .build({ - entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], - bundle: true, - outfile: path.join(out, 'bundle.js'), - plugins: [rawPlugin], - format: 'iife', - target: 'es2020', - }) - .then(() => { - fs.writeFileSync( - path.join(out, 'index.html'), - 'Ratatoskr scene: trace prepass' + - (demo - ? '
device
' - : '') + - '' - ); - }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts deleted file mode 100644 index 40424ff5..00000000 --- a/harness/scene/demo.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and -// a switch for the cone prepass. Not shipped. -import { - createScene, - Scene, - SceneStats, - SceneUnavailable, - TraceStats, -} from '../../src/app/features/scene/Scene'; -import { LEVELS } from '../../src/app/features/scene/budget'; - -let canvas = document.getElementById('c') as HTMLCanvasElement; -const out = document.getElementById('out') as HTMLPreElement; -const box = document.getElementById('prepass') as HTMLInputElement; -const pull = document.getElementById('pull') as HTMLInputElement; -const half = document.getElementById('half') as HTMLInputElement; -const dev = document.getElementById('dev') as HTMLDetailsElement; -const info = document.getElementById('info') as HTMLPreElement; -const timerless = (() => { - const g = document.createElement('canvas').getContext('webgl2'); - return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); -})(); - -const WHY: Record = { - 'no-webgl2': 'this browser gave no WebGL2 context', - 'no-float-or-half-float-colour-buffer': - 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', -}; - -// What the browser says about itself, read from a scratch context so it shows even when the scene -// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. -function describeDevice(): string { - const c = document.createElement('canvas'); - const gl2 = c.getContext('webgl2'); - const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); - const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; - if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); - const dbg = gl.getExtension('WEBGL_debug_renderer_info'); - lines.push( - `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, - `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, - `version: ${gl.getParameter(gl.VERSION)}`, - `user agent: ${navigator.userAgent}` - ); - const ext = gl.getSupportedExtensions() ?? []; - for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) - lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); - lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); - return lines.join('\n'); -} -info.textContent = describeDevice(); - -let scene: Scene | null = null; -let stats: SceneStats | undefined; -let trace: TraceStats | undefined; - -function start() { - scene?.dispose(); - stats = trace = undefined; - // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. - const fresh = canvas.cloneNode() as HTMLCanvasElement; - canvas.replaceWith(fresh); - canvas = fresh; - scene = createScene({ - canvas: fresh, - unthrottled: true, - fixedLevel: LEVELS[0], - fixedPull: +pull.value, - prepass: box.checked, - halfStart: half.checked, - onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), - onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), - onStats: (s) => (stats = s), - onTraceStats: (t) => (trace = t), - }); - dev.open = !scene; -} - -setInterval(() => { - if (!scene) return; - const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; - if (stats) { - const ms = stats.passMs; - const total = Object.values(ms).reduce((a, b) => a + b, 0); - lines.push( - `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, - `solid ${stats.solidSize.join('x')}` - ); - } else if (timerless) { - lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); - } - if (trace) - lines.push( - `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` - ); - out.textContent = lines.join('\n'); -}, 500); - -box.addEventListener('change', start); -half.addEventListener('change', start); -pull.addEventListener('input', start); -start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs deleted file mode 100644 index 0f34191f..00000000 --- a/harness/scene/drive.cjs +++ /dev/null @@ -1,309 +0,0 @@ -// Drives the scene in headless Chromium (software GL unless the host has a GPU). -// usage: node drive.cjs [outdir] modes: compile | shots | measure -// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. -const { chromium } = require('playwright'); -const path = require('path'); -const fs = require('fs'); -const [, , dir, mode, outdir = '.'] = process.argv; - -(async () => { - const b = await chromium.launch({ - args: [ - '--use-gl=angle', - '--use-angle=swiftshader', - '--enable-unsafe-swiftshader', - '--ignore-gpu-blocklist', - ], - }); - const page = await b.newPage({ viewport: { width: 960, height: 540 } }); - page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); - page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); - await page.goto('file://' + path.resolve(dir, 'index.html')); - const report = { mode }; - report.renderer = await page.evaluate(() => { - const g = document.createElement('canvas').getContext('webgl2'); - const e = g.getExtension('WEBGL_debug_renderer_info'); - return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); - }); - - if (mode === 'compile') { - report.stages = await page.evaluate(() => { - const gl = document.createElement('canvas').getContext('webgl2'); - const S = window.__scene; - return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { - const t0 = performance.now(); - try { - const p = S.linkStage(gl, stage); - return { - stage, - ok: true, - ms: +(performance.now() - t0).toFixed(1), - sourceBytes: S.fragmentSource(stage).length, - log: gl.getProgramInfoLog(p), - }; - } catch (e) { - return { - stage, - ok: false, - ms: +(performance.now() - t0).toFixed(1), - log: String(e.log || e), - }; - } - }); - }); - } - - if (mode === 'phacelle') { - report.probe = await page.evaluate(() => - window.__probePhacelle( - window.__scene.WORLD.erodeSide.slice(), - window.__scene.WORLD.erodeNorm, - 4 - ) - ); - } - - if (mode === 'shots') { - await page.evaluate(() => { - const c = document.getElementById('c'); - window.__s = window.__scene.createScene({ - canvas: c, - unthrottled: true, - fixedLevel: window.__scene.LEVELS[0], - }); - }); - const wait = (ms) => page.waitForTimeout(ms); - await wait(4000); - await page.screenshot({ path: path.join(outdir, 'rest.png') }); - await page.evaluate(() => window.__s.login()); - await wait(3500); - await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); - await wait(9000); - await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); - report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; - } - - if (mode === 'measure' || mode === 'variants') { - const VARIANTS = { - baseline: null, - // What a public value-only fbm would cost: the same octaves without the gradient work. - valueOnlyFbm: [ - 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', - 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', - ], - noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], - // Upper bound on what skipping sky rays would save: every upward ray misses at once. - noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], - noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], - }; - const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; - const sizes = [ - [960, 540], - [540, 1200], - ]; - report.runs = []; - for (const [w, h] of sizes) { - for (const [li, variant] of wanted.flatMap((v) => - mode === 'variants' - ? [[0, v]] - : [ - [0, v], - [3, v], - ] - )) { - await page.setViewportSize({ width: w, height: h }); - const r = await page.evaluate( - ({ li, rep }) => - new Promise((res) => { - const rows = []; - const c = document.getElementById('c'); - const s = window.__scene.createScene({ - canvas: c, - unthrottled: true, - fixedLevel: window.__scene.LEVELS[li], - patchSource: rep - ? (st, src) => - st === 'terrain' - ? rep[0].startsWith('#define') - ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) - : src.split(rep[0]).join(rep[1]) - : src - : undefined, - onStats: (st) => { - rows.push(st); - if (rows.length === 40) { - s.dispose(); - const keep = rows.slice(10); - const med = (k) => - keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; - res({ - solid: keep[0].solidSize, - atm: keep[0].atmosphereSize, - medianMs: { - terrain: med('terrain'), - atmosphere: med('atmosphere'), - resolve: med('resolve'), - composite: med('composite'), - }, - }); - } - }, - }); - if (!s) res({ error: 'createScene returned null' }); - }), - { li, rep: VARIANTS[variant] } - ); - report.runs.push({ viewport: [w, h], level: li, variant, ...r }); - } - } - } - - if (mode === 'trace') { - // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. - const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); - report.runs = []; - for (const [w, h] of process.env.PORTRAIT === '0' - ? [[960, 540]] - : [ - [960, 540], - [540, 1200], - ]) { - for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { - for (const prepass of arms) { - await page.setViewportSize({ width: w, height: h }); - const r = await page.evaluate( - ({ pull, prepass, FRAMES }) => - new Promise((res) => { - const rows = []; - const trace = []; - let done = false; - const finish = () => { - if (done || rows.length < FRAMES || trace.length < 4) return; - done = true; - s.dispose(); - const keep = rows.slice(FRAMES >> 2); - const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; - const tk = trace.slice(1); - const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; - res({ - solid: keep[0].solidSize, - readbacks: tk.length, - traceSteps: +mean('traceSteps').toFixed(2), - prepassSteps: +mean('prepassSteps').toFixed(2), - stepsPerPixel: +mean('stepsPerPixel').toFixed(2), - medianMs: Object.fromEntries( - Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) - ), - }); - }; - const s = window.__scene.createScene({ - canvas: document.getElementById('c'), - unthrottled: true, - fixedLevel: window.__scene.LEVELS[0], - fixedPull: pull, - prepass, - onTraceStats: (t) => { - trace.push(t); - finish(); - }, - onStats: (st) => { - rows.push(st); - finish(); - }, - }); - if (!s) res({ error: 'createScene returned null' }); - }), - { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } - ); - report.runs.push({ viewport: [w, h], pull, prepass, ...r }); - } - } - } - } - - if (mode === 'diff') { - // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared - // pixel by pixel. The prepass is meant to change cost, not the picture. - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.setViewportSize({ width: 640, height: 360 }); - const shoot = async (pull, prepass, steps) => { - await page.evaluate( - ({ pull, prepass, steps }) => { - // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the - // count stops moving when it is done. Timer-query callbacks lag too far to count on. - window.__raf = 0; - if (!window.__rafPatched) { - window.__rafPatched = true; - const raf = window.requestAnimationFrame.bind(window); - window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); - } - window.__d = window.__scene.createScene({ - canvas: document.getElementById('c'), - fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, - fixedPull: pull, - prepass, - }); - }, - { pull, prepass, steps } - ); - for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { - const n = await page.evaluate(() => window.__raf); - still = n === last ? still + 1 : 0; - last = n; - } - const png = await page.screenshot(); - await page.evaluate(() => window.__d.dispose()); - return png.toString('base64'); - }; - // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared - // with a 512-step render of the same frame as well: whichever is closer to it is the more - // faithful one. - const decode = (x) => - page.evaluate( - (x) => - new Promise((r) => { - const i = new Image(); - i.onload = () => { - const c = document.createElement('canvas'); - c.width = i.width; - c.height = i.height; - const g = c.getContext('2d'); - g.drawImage(i, 0, 0); - r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); - }; - i.src = 'data:image/png;base64,' + x; - }), - x - ); - const compare = (da, db) => { - let max = 0, over = 0, sum = 0; - const px = da.length / 4; - for (let i = 0; i < da.length; i += 4) { - let m = 0; - for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); - sum += m; - if (m > max) max = m; - if (m > 2) over++; - } - return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; - }; - report.diffs = []; - for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { - const png = { - off: await shoot(pull, false, 96), - on: await shoot(pull, true, 96), - ref: await shoot(pull, false, 512), - off2: await shoot(pull, false, 96), - }; - for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); - const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; - report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); - } - } - - console.log(JSON.stringify(report, null, 1)); - await b.close(); -})().catch((e) => { - console.log('ERR', String(e).slice(0, 2000)); - process.exit(1); -}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts deleted file mode 100644 index d43a5453..00000000 --- a/harness/scene/entry.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. -import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; -import { LEVELS } from '../../src/app/features/scene/budget'; -import { fragmentSource } from '../../src/app/features/scene/shaderSource'; -import { WORLD } from '../../src/app/features/scene/world'; - -(window as unknown as Record).__scene = { - createScene, - linkStage, - ShaderCompileError, - LEVELS, - fragmentSource, - WORLD, -}; - -// ---- Phacelle gradient probe ----------------------------------------------------------------- -import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; -import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; -import { vertexSource } from '../../src/app/features/scene/shaderSource'; - -const PROBE_FRAG = `#version 300 es -precision highp float; precision highp int; -${cultmath} -${phacelle} -uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; -in vec2 v_uv; layout(location=0) out vec4 o; -void main() { - // The scene samples the z = 0 slice: random points in a u_span square of it. - vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; - vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; - CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); - o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); -}`; - -/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ -(window as unknown as Record).__probePhacelle = ( - side: number[], - norm: number, - draws: number -) => { - const c = document.createElement('canvas'); - const gl = c.getContext('webgl2')!; - gl.getExtension('EXT_color_buffer_float'); - const mk = (t: number, s: string) => { - const x = gl.createShader(t)!; - gl.shaderSource(x, s); - gl.compileShader(x); - if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); - return x; - }; - const p = gl.createProgram()!; - gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); - gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); - gl.linkProgram(p); - gl.useProgram(p); - const N = 1024; - const tex = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); - const fbo = gl.createFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); - gl.viewport(0, 0, N, N); - gl.bindVertexArray(gl.createVertexArray()); - gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); - gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); - gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); - const buf = new Float32Array(N * N * 4); - let gradCos = 0, - gradSin = 0, - vMin = 1e9, - vMax = -1e9, - n = 0; - for (let d = 0; d < draws; d++) { - gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); - gl.drawArrays(gl.TRIANGLES, 0, 3); - gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); - for (let i = 0; i < buf.length; i += 4) { - if (buf[i] > gradCos) gradCos = buf[i]; - if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; - if (buf[i + 2] < vMin) vMin = buf[i + 2]; - if (buf[i + 2] > vMax) vMax = buf[i + 2]; - n++; - } - } - return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; -}; diff --git a/index.html b/index.html index 7242f309..2eefeee2 100644 --- a/index.html +++ b/index.html @@ -4,17 +4,21 @@ - Ratatoskr - - + Cinny + + - + - - + + + - - + + diff --git a/public/favicon.ico b/public/favicon.ico index cfce9943..91671306 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..acdcd004 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { - "name": "Ratatoskr", - "short_name": "Ratatoskr", - "description": "GameCult's Matrix client", + "name": "Cinny", + "short_name": "Cinny", + "description": "Yet another matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index d76ed356..8c8c2668 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..6d53edb5 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..57378b41 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..507c4439 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..92fd32c7 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..489cfd32 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..d312b532 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..8e04930c 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..44981d96 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..418f8833 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..1d570eba 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..b6a731eb 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..968ae2f0 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..a41d0d10 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..a651cd1c 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..f5ff2ea5 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..47fafb07 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..6e5b13c4 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..7799a6fd 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/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg new file mode 100644 index 00000000..d59b42a1 --- /dev/null +++ b/public/res/svg/cinny-highlight.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg new file mode 100644 index 00000000..be301375 --- /dev/null +++ b/public/res/svg/cinny-unread.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg new file mode 100644 index 00000000..8701d67b --- /dev/null +++ b/public/res/svg/cinny.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file 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 deleted file mode 100644 index 3ca98122..00000000 --- a/public/res/svg/ratatoskr.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs deleted file mode 100644 index fa44bfde..00000000 --- a/scripts/gen-brand.mjs +++ /dev/null @@ -1,202 +0,0 @@ -// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the -// favicon and app-icon PNGs from the same shapes (no image tools needed). -// Run: node scripts/gen-brand.mjs -import { deflateSync } from 'node:zlib'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const BG = '#1c1b22'; - -// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. -const squirrel = [ - { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, - { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, - { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, - { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, - { - t: 'poly', - pts: [ - [35, 19], - [36, 7], - [43, 15], - ], - fill: '#c9672a', - }, - { - t: 'poly', - pts: [ - [44, 15], - [50, 7], - [52, 19], - ], - fill: '#c9672a', - }, - { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, - { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, - { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, - { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, - { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, - { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, - { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, -]; -const badge = (fill) => [ - { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, - { t: 'circle', cx: 52, cy: 52, r: 8, fill }, -]; -const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; - -const f = (n) => +n.toFixed(2); -const svgOf = (shapes) => - shapes - .map((s) => { - if (s.t === 'circle') - return ``; - if (s.t === 'rect') return ``; - if (s.t === 'poly') - return ``; - return ``; - }) - .join('\n'); -const writeSvg = (name, shapes) => - writeFileSync( - join(root, 'public/res/svg', name), - `\n${svgOf( - shapes - )}\n\n` - ); - -const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); -const inside = (s, x, y) => { - if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; - if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; - if (s.t === 'ellipse') { - const a = (-s.rot * Math.PI) / 180; - const dx = x - s.cx; - const dy = y - s.cy; - const u = dx * Math.cos(a) - dy * Math.sin(a); - const v = dx * Math.sin(a) + dy * Math.cos(a); - return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; - } - let c = false; - for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { - const [xi, yi] = s.pts[i]; - const [xj, yj] = s.pts[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; - } - return c; -}; - -const crcTable = Array.from({ length: 256 }, (_, n) => { - let c = n; - for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - return c >>> 0; -}); -const crc = (b) => { - let c = 0xffffffff; - for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -}; -const chunk = (type, data) => { - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - out.write(type, 4, 'ascii'); - data.copy(out, 8); - out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); - return out; -}; - -// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. -const png = (shapes, size, scale = 1) => { - const rgba = Buffer.alloc(size * size * 4); - const SS = 4; - const colours = shapes.map((s) => hex(s.fill)); - for (let py = 0; py < size; py += 1) { - for (let px = 0; px < size; px += 1) { - let r = 0; - let g = 0; - let b = 0; - let a = 0; - for (let sy = 0; sy < SS; sy += 1) { - for (let sx = 0; sx < SS; sx += 1) { - const gx = ((px + (sx + 0.5) / SS) / size) * 64; - const gy = ((py + (sy + 0.5) / SS) / size) * 64; - // the background rect covers the whole canvas and ignores `scale` - let hit = -1; - shapes.forEach((s, i) => { - const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; - const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; - if (inside(s, x, y)) hit = i; - }); - if (hit >= 0) { - [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; - a += 1; - } - } - } - const o = (py * size + px) * 4; - if (a > 0) { - rgba[o] = Math.round(r / a); - rgba[o + 1] = Math.round(g / a); - rgba[o + 2] = Math.round(b / a); - } - rgba[o + 3] = Math.round((a / (SS * SS)) * 255); - } - } - const raw = Buffer.alloc(size * (size * 4 + 1)); - for (let y = 0; y < size; y += 1) { - raw[y * (size * 4 + 1)] = 0; - rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); - } - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(size, 0); - ihdr.writeUInt32BE(size, 4); - ihdr.set([8, 6, 0, 0, 0], 8); - return Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]); -}; - -// ICO container holding PNG images. -const ico = (images) => { - const head = Buffer.alloc(6 + images.length * 16); - head.writeUInt16LE(1, 2); - head.writeUInt16LE(images.length, 4); - let offset = head.length; - images.forEach(({ size, data }, i) => { - const e = 6 + i * 16; - head[e] = size; - head[e + 1] = size; - head.writeUInt16LE(1, e + 4); - head.writeUInt16LE(32, e + 6); - head.writeUInt32LE(data.length, e + 8); - head.writeUInt32LE(offset, e + 12); - offset += data.length; - }); - return Buffer.concat([head, ...images.map((i) => i.data)]); -}; - -writeSvg('ratatoskr.svg', squirrel); -writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); -writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); - -const icon = [...background, ...squirrel]; -const put = (dir, name, size) => { - mkdirSync(join(root, 'public/res', dir), { recursive: true }); - writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); -}; -[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => - put('android', `android-chrome-${s}x${s}.png`, s) -); -[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => - put('apple', `apple-touch-icon-${s}x${s}.png`, s) -); -writeFileSync( - join(root, 'public/favicon.ico'), - ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) -); -console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 64aaaacb..34a7e24b 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,7 +31,6 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -198,10 +197,8 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(async () => { - await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); - }, [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..ce550992 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,7 +17,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -44,7 +43,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) + useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 8e8f05b6..27adadba 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) { justifyContent="Center" > - Ratatoskr + Cinny diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 932e7cce..d206a608 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { - activateEstateRoom, - awaitMyJoin, - joinEstateRoomIdOrAlias, - joinEstateRoomOrThrow, - runJoinCommand, -} from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -58,19 +52,13 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi - .fn() - .mockRejectedValue( - Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ) + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient( - vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -80,7 +68,6 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, - errcode: 'M_FORBIDDEN', data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -91,22 +78,13 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { - status?: number; - errcode?: string; - refuse?: boolean; - drop?: boolean; - failWrite?: boolean; - invites: Record; -}) => { +const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; - const joinOpts: unknown[] = []; const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ - getMyMembership: () => 'invite', getMember: () => ({ events: { member: { @@ -118,29 +96,21 @@ const dmClient = (opts: { }), getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_t: string, content: Record) => { - if (opts.failWrite) throw new Error('account data write failed'); await new Promise((r) => { setTimeout(r, 5); }); writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string, joinArgs?: unknown) => { - joinOpts.push(joinArgs); + joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) - throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) - throw Object.assign(new Error('Gateway Time-out'), { - httpStatus: opts.status, - errcode: opts.errcode, - }); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, }; - return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; + return { mx: mx as never, writes, joined, current: () => direct }; }; const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); @@ -179,122 +149,6 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); -describe('activateEstateRoom when the filing cannot be written', () => { - it('reports the failure and sends no join', async () => { - const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); - expect(c.joined).toEqual([]); - }); -}); - -describe('activateEstateRoom when a room that is not a direct message refuses', () => { - it('reports the refusal and leaves m.direct alone', async () => { - const joinRoom = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); - const { mx, calls } = recordingClient(joinRoom); - const result = await activateEstateRoom(mx, room('joinable')); - expect(result.kind).toBe('refused'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('activateEstateRoom refusal bounds', () => { - it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ - status, - errcode: 'M_LIMIT_EXCEEDED', - invites: { '!dm1:test': '@friend:test' }, - }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); - - it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('activateEstateRoom when a gateway answers instead of the server', () => { - it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { - const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); - - it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ - status: 500, - errcode: 'M_UNKNOWN', - invites: { '!dm1:test': '@friend:test' }, - }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('joinEstateRoomIdOrAlias, the room card and /join', () => { - it('files a direct invite opened by its link before the join is sent', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); - expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('hands the via servers to the join', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); - expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); - }); - - it('joins an alias or an unheld room without touching m.direct', async () => { - const { mx, calls } = recordingClient(); - await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { - it('files a direct invite and answers the joined room id', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('throws the refusal and unfiles the room', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); -}); - -describe('runJoinCommand, /join', () => { - it('reports each refusal and joins the others', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); - expect(reported).toEqual(['M_FORBIDDEN']); - expect(c.joined).toHaveLength(1); - }); - - it('reports nothing when the join is accepted', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); - expect(reported).toEqual([]); - expect(c.joined).toHaveLength(1); - }); -}); - describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { @@ -303,12 +157,8 @@ describe('awaitMyJoin', () => { return { mx: { getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.add(h); - }, - off: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.delete(h); - }, + on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), + off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), }, handlers, become: (roomId: string, m: string) => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 4e1629d0..a3be428b 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,11 +1,5 @@ import { EstateRoom } from './buildEstate'; -import { - addRoomIdToMDirect, - isRoomAlias, - isRoomId, - removeRoomIdFromMDirect, -} from '../../utils/matrix'; -import { splitWithSpace } from '../../utils/common'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; import { isDirectInvite } from '../../utils/room'; export type EstateActivation = @@ -14,7 +8,7 @@ export type EstateActivation = | { kind: 'refused'; message: string }; const LOST_JOIN_RESPONSE = - 'The server did not answer the join. It may have gone through; tap again to retry.'; + 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; type JoinClient = Parameters[0]; @@ -27,31 +21,21 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A - * join that fails any other way (lost response, dropped connection, a gateway's - * 5xx or a status with no errcode) may still have been accepted upstream, so the - * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every client join goes through here: the estate tap, the inbox Accept, - * the room card, /join, the lobby and space Join buttons, the tombstone and - * predecessor links. One owner for joining. + * the join is refused by a server answer. A join that fails without one (lost + * response, dropped connection) may still have been accepted, so the entry + * stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. The inbox Accept goes through here too: one owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, - room: Pick & { via?: string[] } + room: Pick ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; const refusal = (e: unknown): EstateActivation => { const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; - const refusedByServer = (e: unknown) => { - const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; - const status = err.httpStatus; - const errcode = err.errcode ?? err.data?.errcode; - return ( - typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' - ); - }; + const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -65,57 +49,12 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } }; -/** - * activateEstateRoom for a caller that holds only an id or alias (room card, - * /join): a room the client has an invite for is an invited room. - */ -export const joinEstateRoomIdOrAlias = ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => - activateEstateRoom(mx, { - roomId: roomIdOrAlias, - state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', - via, - }); - -/** - * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a - * useAsyncCallback button): the joined room id, or an Error carrying the refusal. - */ -export const joinEstateRoomOrThrow = async ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); - if (result.kind === 'refused') throw new Error(result.message); - return result.roomId; -}; - -/** - * The /join command: every room id or alias in the payload is joined, and each - * refusal is handed to report, never dropped. - */ -export const runJoinCommand = async ( - mx: JoinClient, - payload: string, - report: (message: string) => void -): Promise => { - const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); - const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); - results.forEach((r) => { - if (r.kind === 'refused') report(r.message); - }); -}; - type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 132aaf8c..7de59acd 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, Room } from 'matrix-js-sdk'; +import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,7 +38,6 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -47,8 +46,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -61,8 +60,9 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.message} + {joinState.error.data?.error || joinState.error.message} + {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.message} + aria-label={joinState.error.data?.error || joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index e1ffbfe5..ed3eaff9 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { Room } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,7 +38,6 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -113,8 +112,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 3556e6fc..24f0b80e 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,7 +8,6 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -19,14 +18,16 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return joinEstateRoomOrThrow(mx, replacementRoomId, via); + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); }; return ( diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md deleted file mode 100644 index ad4c13b1..00000000 --- a/src/app/features/scene/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Background 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. -Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, -`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel -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. - -## Entry points - -- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's - door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the - feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At - `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on - demand, so it stays out of the main chunk; if that chunk fails to load the error goes to - `onCompileFail` and the door returns null, it does not throw. -- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns - `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither - `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with - 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 - 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. -- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and - overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. -- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). -- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL - `#define`s are generated from it; no number is written twice. -- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), - `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). - -## How it renders - -0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per - tile, shrinking each step by the widest any ray of the tile strays from it, so every step is - free for the whole tile. It stores the distance no ray of the tile can have hit before, or - `W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays - there. `createScene({ prepass: false })` skips it (the measurement baseline). -1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of - `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with - `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with - coverage and the hit distance (second render target). -2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud - slab marched with a few jittered steps, both only up to the hit distance from pass 1. -3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, - 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). -4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES - tonemap, dither. - -`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra -render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited -on), and reports mean steps per pixel. Unset, neither the output nor the target exists. - -## The step bound, and what is not proven - -The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, -computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the -envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is -not evaluated. - -- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at - |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. - The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). -- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest - terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a - hit proven by the march. -- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects - carry a thin fog halo. - -## Measured and not measured - -See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They -come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts deleted file mode 100644 index 6493ad88..00000000 --- a/src/app/features/scene/Scene.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; -import { WORLD } from './world'; -import { LEVELS } from './budget'; -import { fragmentSource } from './shaderSource'; -import { startBackgroundScene } from './index'; - -type Fn = Mock; - -/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ -function fakeGl(failFrom = Infinity, missing: string[] = []) { - let compiles = 0; - const calls: Record = {}; - const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => { - if (!calls[name]) calls[name] = vi.fn(impl); - return calls[name]; - }; - const gl = new Proxy({} as Record, { - get(_, prop: string) { - // the two start-target formats must be told apart; every other constant is 1 - if (prop === 'RGBA32F') return 32; - if (prop === 'RGBA16F') return 16; - if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; - if (prop === 'getExtension') - return spy(prop, (n: string) => (missing.includes(n) ? null : {})); - if (prop === 'compileShader') { - return spy(prop, () => { - compiles += 1; - }); - } - if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); - if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); - if (prop === 'getProgramParameter') return spy(prop, () => true); - return spy(prop); - }, - }); - return { gl: gl as unknown as WebGL2RenderingContext, calls }; -} - -function fakeCanvas(gl: WebGL2RenderingContext | null) { - const canvas = new EventTarget() as EventTarget & { - clientWidth: number; - clientHeight: number; - width: number; - height: number; - getContext: Fn; - }; - canvas.clientWidth = 400; - canvas.clientHeight = 300; - canvas.width = 0; - canvas.height = 0; - canvas.getContext = vi.fn(() => gl); - return canvas; -} - -let rafQueue: Array<(t: number) => void>; -let cancelled: number; -let win: EventTarget; -let motion: EventTarget & { matches: boolean }; -let doc: EventTarget & { visibilityState: string }; - -beforeEach(() => { - rafQueue = []; - cancelled = 0; - motion = Object.assign(new EventTarget(), { matches: false }); - win = Object.assign(new EventTarget(), { - devicePixelRatio: 1, - matchMedia: () => motion, - }); - doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); - vi.stubGlobal('window', win); - vi.stubGlobal('document', doc); - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); - vi.stubGlobal('cancelAnimationFrame', () => { - cancelled += 1; - }); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const runFrame = (now: number) => { - const cb = rafQueue.shift(); - if (!cb) throw new Error('no frame was scheduled'); - cb(now); -}; -const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; - -describe('createScene tiers', () => { - it("'off' returns null without asking the canvas for a context", () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - expect(rafQueue).toHaveLength(0); - }); - - it("'still' draws one frame and schedules no more", () => { - const { gl, calls } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - expect(scene).not.toBeNull(); - runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame - expect(rafQueue).toHaveLength(0); - }); - - it("'still' ignores login(): the pull-back is for a moving scene", () => { - const { gl } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - runFrame(1000); - scene?.login(); - expect(rafQueue).toHaveLength(0); - }); - - it("'full' keeps scheduling frames", () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); - runFrame(1000); - expect(rafQueue).toHaveLength(1); - }); - - it("'low' traces with a cheaper level than 'full'", () => { - const traceSteps = (tier: 'low' | 'full') => { - rafQueue.length = 0; // each call is a fresh scene - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); - runFrame(1000); - // u_maxSteps is the only uniform1i whose value is a trace-step count - return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); - }; - expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); - expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); - expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); - }); - - it('returns null when WebGL2 is missing, so the host keeps its ground', () => { - expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); - }); -}); - -describe('cone prepass', () => { - const programsFor = (prepass?: boolean) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); - return calls.createProgram.mock.calls.length; - }; - - it('is one more program than the baseline, and off means off', () => { - expect(programsFor(true)).toBe(programsFor(false) + 1); - expect(programsFor(undefined)).toBe(programsFor(true)); - }); - - it('draws before pass 1 and only when on', () => { - const drawsFor = (prepass: boolean) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); - runFrame(1000); - return calls.drawArrays.mock.calls.length; - }; - expect(drawsFor(true)).toBe(drawsFor(false) + 1); - }); - - it('the trace-step output exists in pass 1 only when the host asked to measure', () => { - expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1'); - expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); - }); - - it('reads the step bytes tightly packed, whatever the width', () => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} }); - runFrame(1000); - // fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call - const packs = calls.pixelStorei.mock.calls.map((c) => c[1]); - expect(packs[0]).toBe(1); - expect(packs).toContain(4); - expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan( - calls.pixelStorei.mock.invocationCallOrder[0] - ); - }); - - it('shares one distance bound between pass 1 and the prepass', () => { - const bound = (stage: 'terrain' | 'prepass') => - fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; - expect(bound('prepass')).toBeDefined(); - expect(bound('prepass')).toBe(bound('terrain')); - expect(fragmentSource('atmosphere')).not.toContain('sceneDistance'); - }); -}); - -describe('device without EXT_color_buffer_float', () => { - const FLOAT = 'EXT_color_buffer_float'; - const HALF = 'EXT_color_buffer_half_float'; - const build = (missing: string[], extra: Partial[0]> = {}) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(Infinity, missing); - const onUnavailable = vi.fn(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra }); - if (scene) runFrame(1000); // targets are allocated on the first frame - const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? []; - const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string); - return { - scene, - onUnavailable, - formats, - prepassSource: sources.find((x) => x.includes('o_start')), - }; - }; - - it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => { - const none = vi.fn(); - createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none }); - expect(none).toHaveBeenCalledTimes(1); - expect(none).toHaveBeenCalledWith('no-webgl2'); - const r = build([FLOAT, HALF]); - expect(r.scene).toBeNull(); - expect(r.onUnavailable).toHaveBeenCalledTimes(1); - expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer'); - expect(build([]).onUnavailable).not.toHaveBeenCalled(); - }); - - it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => { - const r = build([FLOAT]); - expect(r.scene?.startFormat).toBe('RGBA16F'); - expect(r.formats).not.toContain(32); - expect(r.prepassSource).toContain('#define START_HALF 1'); - expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`); - }); - - it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => { - const wide = build([HALF]); - expect(wide.scene?.startFormat).toBe('RGBA32F'); - expect(wide.formats).toContain(32); - expect(wide.prepassSource).not.toContain('#define START_HALF'); - expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F'); - }); - - // The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is - // that rounding. The GPU's rounding of a half write is the unknown a phone must confirm. - const toHalf = (x: number) => { - if (x === 0) return 0; - let e = Math.max(Math.floor(Math.log2(x)), -14); - if (2 ** e > x) e -= 1; - const ulp = 2 ** (e - 10); - const q = x / ulp; - let r = Math.floor(q); - const d = q - r; - if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1; - return r * ulp; - }; - const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS)); - - it('stores a start distance never past the true one, and not much short of it', () => { - let seed = 7; - const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; - const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9]; - for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4); - for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t); - for (const t of ts) { - expect(stored(t)).toBeLessThanOrEqual(t); - expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5); - } - }); - - it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => { - expect(toHalf(WORLD.far)).toBe(WORLD.far); - }); -}); - -describe('shader compile failure', () => { - it('reports through onCompileFail, returns null, and frees the programs already linked', () => { - const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails - const onCompileFail = vi.fn(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); - expect(scene).toBeNull(); - expect(onCompileFail).toHaveBeenCalledTimes(1); - const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; - expect(err).toBeInstanceOf(ShaderCompileError); - expect(err.stage).toBe('atmosphere'); - expect(err.log).toContain('bad'); - expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link - expect(rafQueue).toHaveLength(0); - }); - - it('does not throw out of createScene when no callback is given', () => { - const { gl } = fakeGl(0); - expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); - }); -}); - -describe('context loss', () => { - it('stops the loop, tells the host, and detaches every listener', () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - const onContextLost = vi.fn(); - createScene({ canvas: asCanvas(canvas), onContextLost }); - const lost = new Event('webglcontextlost', { cancelable: true }); - canvas.dispatchEvent(lost); - expect(lost.defaultPrevented).toBe(true); - expect(onContextLost).toHaveBeenCalledTimes(1); - expect(cancelled).toBe(1); - // listeners are gone: a second loss, a resize and a visibility change do nothing - canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - win.dispatchEvent(new Event('resize')); - doc.dispatchEvent(new Event('visibilitychange')); - expect(onContextLost).toHaveBeenCalledTimes(1); - expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run - }); - - it('does not render a queued frame after the loss', () => { - const { gl, calls } = fakeGl(); - const canvas = fakeCanvas(gl); - createScene({ canvas: asCanvas(canvas) }); - canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - runFrame(1000); - expect(calls.drawArrays).toBeUndefined(); - }); -}); - -describe('prefers-reduced-motion', () => { - const runAll = (t0: number) => { - let t = t0; - let n = 0; - while (rafQueue.length && n < 200) { - runFrame(t); - t += 40; - n += 1; - } - return n; - }; - - it('freezes to a bounded run when the preference turns on mid-flight', () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - runFrame(1000); - expect(rafQueue).toHaveLength(1); // moving: keeps going - motion.matches = true; - motion.dispatchEvent(new Event('change')); - expect(runAll(1040)).toBeLessThan(200); // ends - expect(rafQueue).toHaveLength(0); - }); - - it('thaws a frozen scene when the preference turns off', () => { - motion.matches = true; - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - runAll(1000); - expect(rafQueue).toHaveLength(0); - motion.matches = false; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(1); - runFrame(5000); - expect(rafQueue).toHaveLength(1); // unbounded again - }); - - it('removes the listener on dispose and on context loss', () => { - const { gl } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - scene?.dispose(); - rafQueue.length = 0; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - - const lostCanvas = fakeCanvas(fakeGl().gl); - createScene({ canvas: asCanvas(lostCanvas) }); - lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - rafQueue.length = 0; - motion.matches = true; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - }); - - it('a still does not listen: one frame stays one frame', () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - runFrame(1000); - motion.matches = false; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - }); -}); - -describe('startBackgroundScene', () => { - it("does not request a context when the config leaves the tier 'off'", async () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); - expect( - await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) - ).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); - - it('applies the device ceiling: saveData runs a still, one frame', async () => { - const { gl, calls } = fakeGl(); - const scene = await startBackgroundScene( - { tier: 'full' }, - { saveData: true }, - { canvas: asCanvas(fakeCanvas(gl)) } - ); - expect(scene).not.toBeNull(); - runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(5); - expect(rafQueue).toHaveLength(0); - }); - - it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { - vi.resetModules(); - vi.doMock('./Scene', () => { - throw new Error('Failed to fetch dynamically imported module'); - }); - try { - 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 }); - 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(); - } finally { - vi.doUnmock('./Scene'); - vi.resetModules(); - } - }); - - it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { - const { gl } = fakeGl(0); - const onCompileFail = vi.fn(); - const scene = await startBackgroundScene( - { tier: 'full' }, - {}, - { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } - ); - expect(scene).toBeNull(); - expect(onCompileFail).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts deleted file mode 100644 index 70580ab8..00000000 --- a/src/app/features/scene/Scene.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { fragmentSource, vertexSource, Stage } from './shaderSource'; -import { cameraPose, pullProgress, CameraPose } from './camera'; -import { BudgetLadder, Level, LEVELS } from './budget'; -import type { SceneTier } from './tier'; -import { PALETTE } from './palette'; - -/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ -export const GPU_BUDGET_MS = 4.0; -const FRAME_INTERVAL_MS = 1000 / 30; -const REDUCED_MOTION_FRAMES = 32; -const MAX_DPR = 1.5; - -export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; -const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; -/** Pass 1 pixels per prepass tile edge. */ -const TILE = 8; - -export interface SceneStats { - /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ - passMs: Record; - level: number; - solidSize: [number, number]; - atmosphereSize: [number, number]; -} - -/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ -export interface TraceStats { - /** Mean pass-1 steps per pixel. */ - traceSteps: number; - /** Mean prepass steps per tile; 0 without the prepass. */ - prepassSteps: number; - /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ - stepsPerPixel: number; -} - -/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ -export type SourcePatch = (stage: Stage, source: string) => string; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - -/** - * Why createScene returned null for want of a device feature. Rendering to half floats needs - * EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with - * the first and RGBA16F with only the second. - */ -export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer'; - -/** - * The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds - * to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value - * below t, and the bias covers the absolute error where halves go subnormal. The shader reads both - * through #defines, so this is the one copy. - */ -export const HALF_START_SCALE = 1 - 1 / 1024; -export const HALF_START_BIAS = 1 / 32768; -const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`; - -/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ -const LOW_START_LEVEL = 2; -/** The 'still' tier draws one frame at this level and stops. */ -const STILL_LEVEL = LEVELS[0]; - -export interface SceneOptions { - canvas: HTMLCanvasElement; - /** - * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts - * the adaptive ladder low. 'full' starts it at the top. Default 'full'. - */ - tier?: SceneTier; - /** createScene is about to return null because the device lacks a feature, and which one. */ - onUnavailable?: (reason: SceneUnavailable) => void; - /** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */ - halfStart?: boolean; - /** A shader failed to compile or link. createScene frees what it built and returns null. */ - onCompileFail?: (e: ShaderCompileError) => void; - /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ - onContextLost?: () => void; - /** Called per finished frame when timer queries exist. */ - onStats?: (s: SceneStats) => void; - /** - * Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it - * compiles pass 1 with a step-count output and an extra render target; unset, neither exists. - */ - onTraceStats?: (s: TraceStats) => void; - /** Weather knob, 0 clear .. 1 overcast. */ - coverage?: number; - /** Fixed level instead of the adaptive ladder (measurement). */ - fixedLevel?: Level; - /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ - prepass?: boolean; - /** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */ - fixedPull?: number; - /** Ignore the 30 fps cap and the visibility pause (measurement). */ - unthrottled?: boolean; - patchSource?: SourcePatch; -} - -export interface Scene { - /** Texture format of the prepass start target. */ - readonly startFormat: 'RGBA32F' | 'RGBA16F'; - /** Start the camera pull-back from the tree. */ - login(): void; - dispose(): void; -} - -function compile( - gl: WebGL2RenderingContext, - type: number, - source: string, - stage: Stage | 'vertex' -): WebGLShader { - const sh = gl.createShader(type)!; - gl.shaderSource(sh, source); - gl.compileShader(sh); - if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { - const log = gl.getShaderInfoLog(sh) ?? ''; - gl.deleteShader(sh); - throw new ShaderCompileError(stage, log); - } - return sh; -} - -export function linkStage( - gl: WebGL2RenderingContext, - stage: Stage, - patch?: SourcePatch, - defines?: string -): WebGLProgram { - const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); - const fs = compile( - gl, - gl.FRAGMENT_SHADER, - patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines), - stage - ); - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - gl.deleteShader(vs); - gl.deleteShader(fs); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { - const log = gl.getProgramInfoLog(prog) ?? ''; - gl.deleteProgram(prog); - throw new ShaderCompileError(stage, log); - } - return prog; -} - -interface Target { - tex: WebGLTexture; - w: number; - h: number; -} - -function halton(i: number, base: number): number { - let f = 1; - let r = 0; - let n = i; - while (n > 0) { - f /= base; - r += f * (n % base); - n = Math.floor(n / base); - } - return r; -} - -/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */ -export function createScene(opts: SceneOptions): Scene | null { - const { canvas } = opts; - const tier = opts.tier ?? 'full'; - if (tier === 'off') return null; - const gl = canvas.getContext('webgl2', { - antialias: false, - alpha: false, - depth: false, - stencil: false, - powerPreference: 'low-power', - }); - if (!gl) { - opts.onUnavailable?.('no-webgl2'); - return null; - } - const wide = !!gl.getExtension('EXT_color_buffer_float'); - if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) { - opts.onUnavailable?.('no-float-or-half-float-colour-buffer'); - return null; - } - // Every other target is a half; only the prepass start can ask for 32 bits. - const halfStart = opts.halfStart || !wide; - gl.getExtension('OES_texture_float_linear'); - const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { - TIME_ELAPSED_EXT: number; - GPU_DISJOINT_EXT: number; - } | null; - - const usePrepass = opts.prepass ?? true; - const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); - const programs = {} as Record; - try { - passes.forEach((p) => { - const defines = - p === 'terrain' && opts.onTraceStats - ? '#define TRACE_STATS 1\n' - : p === 'prepass' && halfStart - ? HALF_START_DEFINES - : undefined; - programs[p] = linkStage(gl, p, opts.patchSource, defines); - }); - } catch (e) { - passes.forEach((p) => { - if (programs[p]) gl.deleteProgram(programs[p]); - }); - if (!(e instanceof ShaderCompileError)) throw e; - opts.onCompileFail?.(e); - return null; - } - const uniforms = new Map>(); - const loc = (p: WebGLProgram, name: string) => { - let m = uniforms.get(p); - if (!m) uniforms.set(p, (m = new Map())); - if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); - return m.get(name) ?? null; - }; - - const vao = gl.createVertexArray(); - gl.bindVertexArray(vao); - - const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F; - - const makeTarget = (w: number, h: number, format: number, filter: number): Target => { - const tex = gl.createTexture()!; - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - return { tex, w, h }; - }; - - const fbos = { - solid: gl.createFramebuffer()!, - pre: gl.createFramebuffer()!, - atm: gl.createFramebuffer()!, - hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], - }; - let solid: Target; - let depth: Target; - let steps: Target | undefined; - let startTex: Target; - let atm: Target; - let hist: [Target, Target]; - const freeTargets = () => { - [solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => { - if (t) gl.deleteTexture(t.tex); - }); - }; - - const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - targets.forEach((t, i) => - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) - ); - gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); - }; - - const still = tier === 'still'; - const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); - const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); - - // Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on. - // Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together. - const READBACK_EVERY = 8; - interface Readback { - fence: WebGLSync; - pixels: number; - tiles: number; - } - let readback: Readback | undefined; - const packSteps = opts.onTraceStats ? gl.createBuffer() : null; - const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null; - const discardReadback = () => { - if (readback) gl.deleteSync(readback.fence); - readback = undefined; - }; - const startReadback = () => { - if (!steps || readback) return; - gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); - gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); - gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2); - gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0); - let tiles = 0; - if (packStart) { - tiles = startTex.w * startTex.h; - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); - gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); - gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); - } - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - gl.pixelStorei(gl.PACK_ALIGNMENT, 4); - readback = { - fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, - pixels: steps.w * steps.h, - tiles, - }; - }; - const pollReadback = () => { - if (!readback) return; - const status = gl.clientWaitSync(readback.fence, 0, 0); - if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return; - const { pixels, tiles } = readback; - const bytes = new Uint8Array(pixels); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); - gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); - let preSum = 0; - if (packStart) { - const floats = new Float32Array(tiles * 4); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); - gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats); - for (let i = 1; i < floats.length; i += 4) preSum += floats[i]; - } - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - let sum = 0; - for (let i = 0; i < bytes.length; i += 1) sum += bytes[i]; - discardReadback(); - const traceSteps = sum / pixels; - const prepassSteps = tiles ? preSum / tiles : 0; - opts.onTraceStats?.({ - traceSteps, - prepassSteps, - stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels, - }); - }; - - let canvasW = 0; - let canvasH = 0; - let sizedFor: Level | undefined; - let resetHistory = true; - - const ensureSize = () => { - const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); - const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); - const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); - const lv = level(); - if (w === canvasW && h === canvasH && lv === sizedFor) return; - canvasW = w; - canvasH = h; - sizedFor = lv; - canvas.width = w; - canvas.height = h; - freeTargets(); - const sw = Math.max(2, Math.round(w * lv.solidScale)); - const sh = Math.max(2, Math.round(h * lv.solidScale)); - const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); - const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); - solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); - depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); - // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. - startTex = usePrepass - ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST) - : makeTarget(1, 1, startFormat, gl.NEAREST); - steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; - atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); - hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; - attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]); - discardReadback(); - attach(fbos.pre, [startTex]); - attach(fbos.atm, [atm]); - attach(fbos.hist[0], [hist[0]]); - attach(fbos.hist[1], [hist[1]]); - resetHistory = true; - }; - - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); - const start = performance.now(); - let loginAt: number | undefined; - let frame = 0; - let histIndex = 0; - let prev: CameraPose | undefined; - let raf = 0; - let lastRendered = -Infinity; - let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this - let disposed = false; - - // Timer queries in flight: each holds one pass's query. - const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; - const finished = new Map>>(); - - const timed = (pass: PassName, f: number, body: () => void) => { - if (!timer || !opts.onStats) { - body(); - return; - } - const q = gl.createQuery()!; - gl.beginQuery(timer.TIME_ELAPSED_EXT, q); - body(); - gl.endQuery(timer.TIME_ELAPSED_EXT); - pending.push({ q, pass, frame: f }); - }; - - const collect = () => { - if (!timer) return; - while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { - const { q, pass, frame: f } = pending.shift()!; - const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); - const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; - gl.deleteQuery(q); - const row = finished.get(f) ?? {}; - if (!disjoint) row[pass] = ns / 1e6; - finished.set(f, row); - if (Object.keys(row).length === passes.length) { - finished.delete(f); - const passMs = { prepass: 0, ...row } as Record; - const total = passes.reduce((s, p) => s + passMs[p], 0); - if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; - opts.onStats?.({ - passMs, - level: ladder.index, - solidSize: [solid.w, solid.h], - atmosphereSize: [atm.w, atm.h], - }); - } - } - }; - - const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { - const aspect = canvasW / canvasH; - const th = Math.tan(pose.fov / 2); - gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); - gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); - gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); - gl.uniform1f(loc(p, 'u_time'), time); - gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); - gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); - gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); - gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); - gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); - }; - - const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { - gl.activeTexture(gl.TEXTURE0 + unit); - gl.bindTexture(gl.TEXTURE_2D, t.tex); - gl.uniform1i(loc(p, name), unit); - }; - - const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); - - const render = (now: number) => { - ensureSize(); - const lv = level(); - const frozen = reducedMotion?.matches ?? false; - const time = frozen ? 0 : (now - start) / 1000; - let elapsed: number | undefined; - if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; - const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time); - const f = frame; - frame += 1; - const jx = halton((f % 16) + 1, 2) - 0.5; - const jy = halton((f % 16) + 1, 3) - 0.5; - - // Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays. - if (usePrepass) { - timed('prepass', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); - gl.viewport(0, 0, startTex.w, startTex.h); - const p = programs.prepass; - gl.useProgram(p); - setCommon(p, pose, time); - gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); - gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h); - draw(); - }); - } - - // Pass 1: solid surfaces, writes colour and depth. - timed('terrain', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); - gl.viewport(0, 0, solid.w, solid.h); - const p = programs.terrain; - gl.useProgram(p); - setCommon(p, pose, time); - gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); - gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); - gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); - gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); - gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); - bindTex(0, startTex, p, 'u_start'); - draw(); - }); - if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost - - // Pass 2: atmosphere up to pass 1's depth, low-res. - timed('atmosphere', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); - gl.viewport(0, 0, atm.w, atm.h); - const p = programs.atmosphere; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, depth, p, 'u_depth'); - gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); - gl.uniform1f(loc(p, 'u_frame'), f); - gl.uniform2f(loc(p, 'u_jitter'), jx, jy); - gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); - gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); - draw(); - }); - - // TAA resolve into the history that is not being read. - const write = histIndex === 0 ? 1 : 0; - timed('resolve', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); - gl.viewport(0, 0, atm.w, atm.h); - const p = programs.resolve; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, atm, p, 'u_cur'); - bindTex(1, hist[histIndex], p, 'u_history'); - bindTex(2, depth, p, 'u_depth'); - const pp = prev ?? pose; - const th = Math.tan(pp.fov / 2); - gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); - gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); - gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); - gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); - gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); - draw(); - }); - - // Full-resolution composite to the canvas. - timed('composite', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, canvasW, canvasH); - const p = programs.composite; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, solid, p, 'u_solid'); - bindTex(1, hist[write], p, 'u_atm'); - gl.uniform1f(loc(p, 'u_frame'), f); - draw(); - }); - - histIndex = write; - prev = pose; - resetHistory = false; - collect(); - pollReadback(); - }; - - const tick = (now: number) => { - raf = 0; - if (disposed) return; - const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; - if (due) { - if ( - !opts.fixedLevel && - !timer && - lastRendered > 0 && - now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) { - // no timer: a missed cap counts as over budget - if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; - } - lastRendered = now; - render(now); - framesLeft -= 1; - } - if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) - raf = requestAnimationFrame(tick); - }; - - const wake = () => { - if (disposed || raf) return; - if (reducedMotion?.matches && framesLeft <= 0) return; - resetHistory = true; // the first frame after a pause has a real elapsed dt - raf = requestAnimationFrame(tick); - }; - const undo: Array<() => void> = []; - const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { - target.addEventListener(type, handler); - undo.push(() => target.removeEventListener(type, handler)); - }; - const stop = () => { - disposed = true; - if (raf) cancelAnimationFrame(raf); - undo.forEach((f) => f()); - }; - if (!opts.unthrottled) { - listen(document, 'visibilitychange', () => { - if (document.visibilityState === 'visible') wake(); - }); - } - 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. - listen(canvas, 'webglcontextlost', (e) => { - e.preventDefault(); - stop(); - opts.onContextLost?.(); - }); - if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; - // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. - if (reducedMotion && !still) { - listen(reducedMotion, 'change', () => { - framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; - wake(); - }); - } - wake(); - - return { - startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', - login() { - if (still || disposed) return; // a still has no pull-back - loginAt = performance.now(); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; - wake(); - }, - dispose() { - if (disposed) return; - stop(); - freeTargets(); - passes.forEach((p) => gl.deleteProgram(programs[p])); - discardReadback(); - [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); - gl.deleteVertexArray(vao); - [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); - }, - }; -} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts deleted file mode 100644 index 9ac8f0af..00000000 --- a/src/app/features/scene/budget.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { BudgetLadder, LEVELS } from './budget'; - -describe('budget ladder', () => { - it('steps down after a whole window over budget and never back up', () => { - const ladder = new BudgetLadder(4, 5); - Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); - expect(ladder.push(9)).toBe(true); - expect(ladder.index).toBe(1); - Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level - expect(ladder.index).toBe(1); - }); - - it('judges the median, so one slow frame does not step down', () => { - const ladder = new BudgetLadder(4, 5); - [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); - expect(ladder.index).toBe(0); - }); - - it('stops at the last level', () => { - const ladder = new BudgetLadder(4, 1); - Array.from({ length: 20 }).forEach(() => ladder.push(99)); - expect(ladder.index).toBe(LEVELS.length - 1); - }); - - it('every level is cheaper than the one before in each cost axis', () => { - LEVELS.slice(1).forEach((lv, i) => { - const before = LEVELS[i]; - expect(lv.solidScale).toBeLessThan(before.solidScale); - expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); - expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); - expect(lv.traceSteps).toBeLessThan(before.traceSteps); - }); - }); - - it('starts at the level it is given, clamped into the ladder', () => { - expect(new BudgetLadder(4, 5, 2).index).toBe(2); - expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); - expect(new BudgetLadder(4, 5, -3).index).toBe(0); - }); -}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts deleted file mode 100644 index 3b373d86..00000000 --- a/src/app/features/scene/budget.ts +++ /dev/null @@ -1,59 +0,0 @@ -// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. - -export interface Level { - /** Pass 1 resolution as a fraction of the canvas. */ - solidScale: number; - /** Pass 2 resolution as a fraction of the canvas. */ - atmosphereScale: number; - /** Cloud march steps. */ - cloudSteps: number; - /** Sphere-trace step cap. */ - traceSteps: number; -} - -export const LEVELS: readonly Level[] = [ - { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, - { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, - { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, - { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, -]; - -/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ -export class BudgetLadder { - private level: number; - - private recent: number[] = []; - - private readonly budgetMs: number; - - private readonly window: number; - - /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ - constructor(budgetMs: number, window = 45, startLevel = 0) { - this.budgetMs = budgetMs; - this.window = window; - this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); - } - - get current(): Level { - return LEVELS[this.level]; - } - - get index(): number { - return this.level; - } - - /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ - push(ms: number): boolean { - this.recent.push(ms); - if (this.recent.length < this.window) return false; - const sorted = [...this.recent].sort((a, b) => a - b); - const median = sorted[Math.floor(sorted.length / 2)]; - this.recent = []; - if (median > this.budgetMs && this.level < LEVELS.length - 1) { - this.level += 1; - return true; - } - return false; - } -} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts deleted file mode 100644 index f7199a65..00000000 --- a/src/app/features/scene/camera.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; -import { WORLD } from './world'; - -const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); - -describe('login pull-back', () => { - it('is at rest before login and complete after PULL_BACK_SECONDS', () => { - expect(pullProgress(undefined)).toBe(0); - expect(pullProgress(0)).toBe(0); - expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); - expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); - }); - - it('only moves away from the tree and never reverses', () => { - let last = -1; - for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { - const d = dist(cameraPose(pullProgress(s), 0).position); - expect(d).toBeGreaterThanOrEqual(last); - last = d; - } - }); - - it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { - for (let t = 0; t < 2000; t += 37) { - expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); - } - }); - - it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { - const pose = cameraPose(0.5, 12); - const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; - const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; - expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); - expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); - expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); - expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); - // Forward points back across the tree axis, not away from it. - const [px, , pz] = pose.position; - expect(fx * px + fz * pz).toBeLessThan(0); - }); -}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts deleted file mode 100644 index 2b08f0d1..00000000 --- a/src/app/features/scene/camera.ts +++ /dev/null @@ -1,56 +0,0 @@ -// The scene's camera: close under the tree at rest, pulled back on login, drifting after. -// Pure functions of time so the pull-back is testable without a GL context. - -export type Vec3 = readonly [number, number, number]; - -export interface CameraPose { - position: Vec3; - /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ - basis: readonly [number, number, number, number, number, number, number, number, number]; - /** Vertical field of view in radians. */ - fov: number; -} - -export const PULL_BACK_SECONDS = 6; - -// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. -const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; -const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; -const DRIFT_RAD_PER_SEC = 0.025; - -const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); -const lerp = (a: number, b: number, t: number) => a + (b - a) * t; - -/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ -export function pullProgress(elapsed: number | undefined): number { - if (elapsed === undefined || elapsed <= 0) return 0; - return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); -} - -function norm(v: Vec3): Vec3 { - const l = Math.hypot(v[0], v[1], v[2]) || 1; - return [v[0] / l, v[1] / l, v[2] / l]; -} -function cross(a: Vec3, b: Vec3): Vec3 { - return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; -} - -/** - * @param pull 0..1 from pullProgress - * @param time seconds since the scene started, for the slow orbit - */ -export function cameraPose(pull: number, time: number): CameraPose { - const radius = lerp(NEAR.radius, FAR.radius, pull); - const camY = lerp(NEAR.camY, FAR.camY, pull); - const targetY = lerp(NEAR.targetY, FAR.targetY, pull); - const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; - const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; - const forward = norm([-position[0], targetY - position[1], -position[2]]); - const right = norm(cross([0, 1, 0], forward)); - const up = cross(forward, right); - return { - position, - basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], - fov: lerp(NEAR.fov, FAR.fov, pull), - }; -} diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts deleted file mode 100644 index 938c217f..00000000 --- a/src/app/features/scene/creatures.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; - -const buckets: SizeBucket[] = ['small', 'medium', 'large']; - -describe('creature scale', () => { - it('follows sizeBucket for a creature with an estate', () => { - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) - ); - }); - - it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { - expect(creatureScale({ neutral: true })).toBeNull(); - buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); - }); - - it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { - expect(creatureScale({ neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); - }); - - it('draws nothing for an unknown bucket, including inherited property names', () => { - expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); - }); -}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts deleted file mode 100644 index a0b2bd29..00000000 --- a/src/app/features/scene/creatures.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. -// -// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). -// Nothing here is binding until Bifrost's steward cuts that contract. -// -// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. -// Invariants: -// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. -// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is -// ignored, and a neutral creature is not drawn. -// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. -// - activity comes from the Heimdall session. -// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. -// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. - -export type SizeBucket = 'small' | 'medium' | 'large'; - -export interface Creature { - id: string; - estate: string; - /** Absent when neutral. Read it only through creatureScale. */ - sizeBucket?: SizeBucket; - neutral: boolean; - activity: 'idle' | 'active'; -} - -export const BUCKET_SCALE: Readonly> = { - small: 0.7, - medium: 1, - large: 1.4, -}; - -/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ -export function creatureScale(c: Pick): number | null { - if (c.neutral || c.sizeBucket === undefined) return null; - return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; -} diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts deleted file mode 100644 index cef6b281..00000000 --- a/src/app/features/scene/cultmathTrim.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { trimGlsl } from './cultmathTrim'; -import { fragmentSource, Stage } from './shaderSource'; -import cultmath from './shaders/CultMath.glsl?raw'; - -const LIB = `// header -#ifndef LIB_GLSL -#define LIB_GLSL -const float LIB_PI = 3.14; - -float lib_half(float v) { return v * 0.5; } -vec2 lib_half(vec2 v) { return v * 0.5; } - -// uses the constant and an overload set -float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } - -struct LibPair { float a; float b; }; - -LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } -float lib_unused(float v) { return v; } -#endif -`; - -describe('trimGlsl', () => { - it('keeps what a root names and what that reaches, and drops the rest', () => { - const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); - expect(out).toContain('lib_pi_half'); - expect(out).toContain('LIB_PI'); - expect(out).toContain('float lib_half'); - expect(out).toContain('vec2 lib_half'); // overloads stay together - expect(out).not.toContain('lib_unused'); - expect(out).not.toContain('LibPair'); - expect(out).not.toContain('lib_pair'); - }); - - it('keeps a struct that a kept function returns', () => { - const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); - expect(out).toContain('struct LibPair'); - expect(out).toContain('lib_half'); - expect(out).not.toContain('lib_pi_half'); - }); - - it('keeps the include guard balanced', () => { - const out = trimGlsl(LIB, ['lib_half(1.0)']); - expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); - expect(out.trimEnd().endsWith('#endif')).toBe(true); - }); - - it('keeps nothing but the guard for a root that names nothing', () => { - const out = trimGlsl(LIB, ['void main() {}']); - expect(out).not.toContain('lib_'); - expect(out).toContain('#ifndef LIB_GLSL'); - }); -}); - -describe('CultMath per shader stage', () => { - const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; - const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); - const defined = (src: string, name: string) => - new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); - - it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { - const src = fragmentSource(stage); - cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); - }); - - it.each(stages)('%s is smaller than the whole library', (stage) => { - expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); - }); - - it('puts the noise a stage calls in that stage and not in the others', () => { - expect(fragmentSource('terrain')).toContain('cultmath_snoise'); - expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); - }); - - it('adds Phacelle to terrain alone, as its own file', () => { - expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); - stages - .filter((s) => s !== 'terrain') - .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); - }); -}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts deleted file mode 100644 index fc246e5c..00000000 --- a/src/app/features/scene/cultmathTrim.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never - * edited; each stage compiles only the declarations its own source (and the source concatenated - * beside it) names, directly or through other kept declarations. Overloads share a name and are - * kept together. - */ - -interface Decl { - name: string; - text: string; -} - -const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; -const GUARD_CLOSE = /\n#endif\s*$/; - -/** Name a top-level declaration defines: function, struct or const. */ -function declName(text: string): string | undefined { - const code = text.replace(/\/\/.*$/gm, ''); - if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; - return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; -} - -/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ -function declarations(body: string): Decl[] { - const out: Decl[] = []; - let depth = 0; - let start = 0; - let i = 0; - while (i < body.length) { - const c = body[i]; - if (c === '/' && body[i + 1] === '/') { - const eol = body.indexOf('\n', i); - i = eol < 0 ? body.length : eol; - } else if (c === '{') { - depth += 1; - } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { - if (c === '}') depth -= 1; - // a struct's closing brace is followed by `;` - const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; - const text = body.slice(start, end + 1); - const name = declName(text); - if (name) out.push({ name, text }); - start = end + 1; - i = end; - } else if (c === '}') { - depth -= 1; - } - i += 1; - } - return out; -} - -const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; - -/** `library` trimmed to what `roots` reach; the include guard is kept. */ -export function trimGlsl(library: string, roots: string[]): string { - const open = GUARD_OPEN.exec(library)?.[0] ?? ''; - const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; - const body = library - .slice(open ? library.indexOf(open) + open.length : 0) - .replace(GUARD_CLOSE, ''); - const decls = declarations(body); - const byName = new Map(); - decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); - - const kept = new Set(); - const queue = roots.flatMap(idents); - while (queue.length) { - const group = byName.get(queue.pop() ?? ''); - group?.forEach((d) => { - if (kept.has(d)) return; - kept.add(d); - queue.push(...idents(d.text)); - }); - } - const text = decls - .filter((d) => kept.has(d)) - .map((d) => d.text.replace(/^\s*\n/, '')) - .join('\n'); - return `${open}${text}${close}`; -} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts deleted file mode 100644 index c896649e..00000000 --- a/src/app/features/scene/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; - -export type { Scene, SceneStats } from './Scene'; -export type { 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; -}; - -/** - * 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. - */ -export async function startBackgroundScene( - config: BackgroundSceneConfig | undefined, - capabilities: Capabilities, - options: StartOptions -): 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 }); -} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts deleted file mode 100644 index 54a84ad3..00000000 --- a/src/app/features/scene/palette.ts +++ /dev/null @@ -1,27 +0,0 @@ -// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs -// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the -// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. - -export type Rgb = readonly [number, number, number]; - -const srgb = (r: number, g: number, b: number): Rgb => [ - (r / 255) ** 2.2, - (g / 255) ** 2.2, - (b / 255) ** 2.2, -]; - -export const PALETTE = { - sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, - skyTop: srgb(30, 36, 92), - skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, - fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, - grass: srgb(72, 112, 86), - rock: srgb(112, 104, 122), - bark: srgb(62, 46, 40), - leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, - sunDir: (() => { - const v = [0.55, 0.16, -0.82]; - const l = Math.hypot(v[0], v[1], v[2]); - return [v[0] / l, v[1] / l, v[2] / l] as Rgb; - })(), -} as const; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts deleted file mode 100644 index 42631d57..00000000 --- a/src/app/features/scene/shaderSource.ts +++ /dev/null @@ -1,41 +0,0 @@ -import cultmath from './shaders/CultMath.glsl?raw'; -import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; -import common from './shaders/common.glsl?raw'; -import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; -import sdf from './shaders/sdf.glsl?raw'; -import prepassFrag from './shaders/prepass.frag.glsl?raw'; -import terrainFrag from './shaders/terrain.frag.glsl?raw'; -import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; -import resolveFrag from './shaders/resolve.frag.glsl?raw'; -import compositeFrag from './shaders/composite.frag.glsl?raw'; -import { WORLD, worldDefines } from './world'; -import { trimGlsl } from './cultmathTrim'; - -export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; - -const HEADER = - '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; - -export const vertexSource = HEADER + fullscreenVert; - -const stages: Record = { - prepass: prepassFrag, - terrain: terrainFrag, - atmosphere: atmosphereFrag, - resolve: resolveFrag, - composite: compositeFrag, -}; - -/** - * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the - * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` - * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where - * it is called. - */ -export function fragmentSource(stage: Stage, defines = ''): string { - const solid = stage === 'terrain' || stage === 'prepass'; - const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; - const world = solid ? sdf : ''; - const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); - return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; -} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl deleted file mode 100644 index 11405fa2..00000000 --- a/src/app/features/scene/shaders/CultMath.Phacelle.glsl +++ /dev/null @@ -1,99 +0,0 @@ -// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. -#ifndef CULTMATH_PHACELLE_GLSL -#define CULTMATH_PHACELLE_GLSL -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the -// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke -// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact -// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after -// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). - -// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit -// against CultMath.CultPhasor, same field order. -struct CultPhasor -{ - vec4 cos; - vec4 sin; -}; - -// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave -// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors -// the C# body operation for operation; the C#-only internal overload that switches the prune off -// has no shader twin. -CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) -{ - vec3 cell = floor(p); - vec3 local = p - cell; - float phaseOffset = offset * CULTMATH_TAU; - float sumW = 0.0; - float sumCos = 0.0; - float sumSin = 0.0; - vec3 gradW = vec3(0.0, 0.0, 0.0); - vec3 gradCos = vec3(0.0, 0.0, 0.0); - vec3 gradSin = vec3(0.0, 0.0, 0.0); - - for (int dz = -1; dz <= 2; dz++) - { - float gz = max(0.0, abs(local.z - float(dz)) - 0.5); - for (int dy = -1; dy <= 2; dy++) - { - float gy = max(0.0, abs(local.y - float(dy)) - 0.5); - for (int dx = -1; dx <= 2; dx++) - { - float gx = max(0.0, abs(local.x - float(dx)) - 0.5); - if (gx * gx + gy * gy + gz * gz >= 2.25) - continue; - - vec3 gridStep = vec3(dx, dy, dz); - ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); - vec3 jitter = vec3( - float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, - float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, - float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); - vec3 v = local - gridStep - jitter; - float falloff = exp(-2.0 * dot(v, v)); - float w = max(0.0, falloff - 0.01111); - vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); - float phase = dot(v, side) + phaseOffset; - float c = cos(phase); - float s = sin(phase); - - sumW += w; - sumCos += w * c; - sumSin += w * s; - gradW += dw; - gradCos += dw * c - (w * s) * side; - gradSin += dw * s + (w * c) * side; - } - } - } - - float invW = 1.0 / sumW; - float rawCos = sumCos * invW; - float rawSin = sumSin * invW; - vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; - vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; - float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); - float floorLength = 1.0 - normalization; - - CultPhasor result; - if (rawLength > floorLength) - { - float outCos = rawCos / rawLength; - float outSin = rawSin / rawLength; - vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); - } - else - { - result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); - } - - return result; -} - -#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl deleted file mode 100644 index 8a380a96..00000000 --- a/src/app/features/scene/shaders/CultMath.glsl +++ /dev/null @@ -1,1035 +0,0 @@ -// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. -#ifndef CULTMATH_GLSL -#define CULTMATH_GLSL -// CultMath HLSL mirror. -// -// HLSL already owns the vec2/vec3/vec4 type names and most primitive -// intrinsics. This include names the CultMath semantic surface explicitly so -// shader code, C# wrappers, and Rust/native parity fixtures can share the same -// small vocabulary without local helper drift. - -const float CULTMATH_PI = 3.14159265358979323846; -const float CULTMATH_TAU = 6.28318530717958647692; -const float CULTMATH_HALF_PI = 1.57079632679489661923; - -float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } -float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } - -float cultmath_frac(float value) { return value - floor(value); } -vec2 cultmath_frac(vec2 value) { return value - floor(value); } -vec3 cultmath_frac(vec3 value) { return value - floor(value); } -vec4 cultmath_frac(vec4 value) { return value - floor(value); } - -float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } -vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } -vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } -vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } - -// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. -float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } -vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } -vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } -vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } - -float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } -vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } -vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } -vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } - -float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } -vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } -vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } -vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } - -float cultmath_smoothstep(float minimum, float maximum, float value) -{ - float t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0 - 2.0 * t); -} - -vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) -{ - vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); -} - -vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) -{ - vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); -} - -vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) -{ - vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); -} - -float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } - -float cultmath_smootherstep(float value) -{ - value = cultmath_saturate(value); - return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); -} - -vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } -vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } - -// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation -// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's -// total order; see math.snoise. -float cultmath_snoise(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); -} - -float cultmath_snoise(vec2 value) -{ - vec4 c = vec4( - 0.211324865405187, - 0.366025403784439, - -0.577350269189626, - 0.024390243902439); - vec2 i = floor(value + dot(value, c.yy)); - vec2 x0 = value - i + dot(i, c.xx); - vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); - vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); - - i = cultmath_snoise_mod289(i); - vec3 p = cultmath_snoise_permute( - cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); - vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); - m *= m; - m *= m; - - vec3 x = 2.0 * fract(p * c.w) - 1.0; - vec3 h = abs(x) - 0.5; - vec3 ox = floor(x + 0.5); - vec3 a0 = x - ox; - m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); - - vec3 g = vec3( - a0.x * x0.x + h.x * x0.y, - a0.y * x12.x + h.y * x12.y, - a0.z * x12.z + h.z * x12.w); - return 130.0 * dot(m, g); -} - -// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise -// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as -// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation -// verbatim. -vec4 cultmath_snoise_grad(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - vec4 m2 = m0 * m0; - vec4 m3 = m2 * m0; - vec4 m4 = m2 * m2; - - vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - float value2 = 105.0 * dot(m4, px); - vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 - + -8.0 * m3.y * x1 * px.y + m4.y * p1 - + -8.0 * m3.z * x2 * px.z + m4.z * p2 - + -8.0 * m3.w * x3 * px.w + m4.w * p3; - grad *= 105.0; - - return vec4(grad, value2); -} - -// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is -// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the -// two agree. -mat3 cultmath_snoise_hessian(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - vec4 m2 = m0 * m0; - vec4 m3 = m2 * m0; - vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - vec4 wide = 48.0 * m2 * px; - vec4 narrow = 8.0 * m3; - - vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); - vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); - vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); - vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); - vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); - vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); - - float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); - float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); - float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); - float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); - float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); - float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); - return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); -} - -// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. -float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) -{ - lo = min(lo, hi); - if (b == 0.0) - return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; - - float direction = t1 < t0 ? -1.0 : 1.0; - float start = min(t0, t1); - float stop = max(t0, t1); - float atLo = (lo - a) / b; - float atHi = (hi - a) / b; - float enter = clamp(min(atLo, atHi), start, stop); - float leave = clamp(max(atLo, atHi), start, stop); - float before = b > 0.0 ? lo : hi; - float after = b > 0.0 ? hi : lo; - float held = enter > start ? before * (enter - start) : 0.0; - float kept = stop > leave ? after * (stop - leave) : 0.0; - float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; - return direction * total; -} - -// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the -// per-octave frequency/amplitude derivation and why octaves is clamped. -vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - vec4 n = cultmath_snoise_grad(p * frequency); - value += amplitude * n.w; - gradient += amplitude * frequency * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return vec4(gradient, value); -} - -// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad -// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. -vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - vec4 n = cultmath_snoise_grad(p * frequency); - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * frequency * s * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return vec4(gradient, value); -} - -float cultmath_lengthsq(vec2 value) { return dot(value, value); } -float cultmath_lengthsq(vec3 value) { return dot(value, value); } -float cultmath_lengthsq(vec4 value) { return dot(value, value); } - -float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } -float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } -float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } - -vec2 cultmath_reflect(vec2 incident, vec2 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -vec3 cultmath_reflect(vec3 incident, vec3 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -vec2 cultmath_rotate(vec2 value, float radians_value) -{ - float s = sin(radians_value); - float c = cos(radians_value); - return vec2(value.x * c - value.y * s, value.x * s + value.y * c); -} - -vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) -{ - return cultmath_rotate(value, cultmath_radians(degrees_value)); -} - -float cultmath_csum(vec2 value) { return value.x + value.y; } -float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } -float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } - -float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } - -float cultmath_damp(float start, float end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec2(amount, amount)); -} - -vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec3(amount, amount, amount)); -} - -vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); -} - -float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } -float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } -float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } - -// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. -uint cultmath_pcg(uint value) -{ - uint state = value * 747796405u + 2891336453u; - uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; - return (word >> 22) ^ word; -} - -ivec3 cultmath_pcg3d(ivec3 value) -{ - uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; - x += y * z; y += z * x; z += x * y; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; - x += y * z; y += z * x; z += x * y; - return ivec3(int(x), int(y), int(z)); -} - -ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } -ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } - -ivec4 cultmath_pcg4d(ivec4 value) -{ - uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; - uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; - x += y * w; y += z * x; z += x * y; w += y * z; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; - x += y * w; y += z * x; z += x * y; w += y * z; - return ivec4(int(x), int(y), int(z), int(w)); -} - -ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } - -// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See -// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. -vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) -{ - float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); - float value = mix(b.w, a.w, h) - k * h * (1.0 - h); - vec3 gradient = mix(b.xyz, a.xyz, h); - return vec4(gradient, value); -} - -// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): -// compared field by field and bit for bit against CultMath.CultCellular, same field order. -struct CultCellular -{ - vec4 nearest; - vec4 edge; - float id; -}; - -// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment -// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and -// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. -CultCellular cultmath_cellular(vec3 p) -{ - vec3 cell = floor(p); - float f1 = 1.0e30; - float f2 = 1.0e30; - vec3 c1 = vec3(0.0, 0.0, 0.0); - vec3 c2 = vec3(0.0, 0.0, 0.0); - vec3 cellId = vec3(0.0, 0.0, 0.0); - - for (int dz = -2; dz <= 2; dz++) - { - float lz = max(0.0, abs(float(dz)) - 1.0); - for (int dy = -2; dy <= 2; dy++) - { - float ly = max(0.0, abs(float(dy)) - 1.0); - for (int dx = -2; dx <= 2; dx++) - { - float lx = max(0.0, abs(float(dx)) - 1.0); - float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); - if (lowerBound >= f2) - continue; - - vec3 neighbor = cell + vec3(dx, dy, dz); - ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); - vec3 jitter = vec3( - float(uint(hash.x) >> 8) * (1.0 / 16777216.0), - float(uint(hash.y) >> 8) * (1.0 / 16777216.0), - float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); - vec3 feature = neighbor + jitter; - float d = cultmath_distance(p, feature); - - if (d < f1) - { - f2 = f1; c2 = c1; - f1 = d; c1 = feature; cellId = neighbor; - } - else if (d < f2) - { - f2 = d; c2 = feature; - } - } - } - } - - // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment - // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. - vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); - vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); - - // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule - // as the jitter above), never from the pcg3d hash that produced its jitter (F4). - ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); - - CultCellular result; - result.nearest = vec4(grad1, f1); - result.edge = vec4(grad2 - grad1, f2 - f1); - result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); - return result; -} - - - - -// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns -// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for -// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. -// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. - -// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). -const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; - -// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). -const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; - -vec2 cultmath_iv_point(float value) { return vec2(value, value); } -vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } -vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } -vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } - -vec2 cultmath_iv_mul(vec2 a, vec2 b) -{ - float p0 = a.x * b.x; - float p1 = a.x * b.y; - float p2 = a.y * b.x; - float p3 = a.y * b.y; - return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); -} - -vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } - -vec2 cultmath_iv_abs(vec2 a) -{ - if (a.x >= 0.0) - return a; - if (a.y <= 0.0) - return vec2(-a.y, -a.x); - return vec2(0.0, max(-a.x, a.y)); -} - -vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } -vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } - -vec2 cultmath_iv_sqr(vec2 a) -{ - float l = a.x * a.x; - float h = a.y * a.y; - if (a.x >= 0.0) - return vec2(l, h); - if (a.y <= 0.0) - return vec2(h, l); - return vec2(0.0, max(l, h)); -} - -vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } - -// Widened by one ulp on each side: exp is not required to be correctly rounded. -vec2 cultmath_iv_exp(vec2 a) -{ - float lo = exp(a.x); - float hi = exp(a.y); - return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); -} - -vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) -{ - return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); -} - -vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } - -// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is -// monotone only to within one ulp. -vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) -{ - float s0 = cultmath_smoothstep(minimum, maximum, a.x); - float s1 = cultmath_smoothstep(minimum, maximum, a.y); - float lo = min(s0, s1); - float hi = max(s0, s1); - return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); -} - -// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. -vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } - -// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. -vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) -{ - float n = cultmath_snoise(centre); - float e = CULTMATH_SNOISE_LIPSCHITZ * radius; - return vec2(max(n - e, -1.0), min(n + e, 1.0)); -} - -// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. -vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) -{ - float n = cultmath_snoise(centre); - float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; - return vec2(max(n - e, -1.0), min(n + e, 1.0)); -} - -// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. -vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - vec2 sum = vec2(0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); - frequency *= lacunarity; - amplitude *= gain; - } - - return sum; -} - -// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment -// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). -// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by -// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. -vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) -{ - float zm = (z0 + z1) * 0.5; - float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); - float cx = centreSlope.x * zm; - float cy = centreSlope.y * zm; - float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; - radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; - return vec4(cx, cy, zm, radius); -} - - - - - - -// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : -// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors -// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure -// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after -// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own -// definition of cultmath_snoise_grad. - -// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in -// math.Affine.cs (SNOISE_HESSIAN). -const float CULTMATH_SNOISE_HESSIAN = 55.642143; - -// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is -// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in -// CultMath.hlsl by cultmath_span_slack. -const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; -const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; - -// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the -// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). -const float CULTMATH_SNOISE_THIRD = 535.8009; - -vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } -vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } - -vec3 cultmath_af_from_iv(vec2 interval) -{ - float x0 = interval.x * 0.5 + interval.y * 0.5; - float e = interval.y * 0.5 - interval.x * 0.5; - return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks -// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. -vec2 cultmath_af_range(vec3 x) -{ - float r = abs(x.y) + x.z; - float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; - float lo = x.x - r - w; - float hi = x.x + r + w; - return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, - (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); -} - -vec3 cultmath_af_add(vec3 x, vec3 y) -{ - float x0 = x.x + y.x; - float a = x.y + y.y; - float e = x.z + y.z; - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); -} - -vec3 cultmath_af_sub(vec3 x, vec3 y) -{ - float x0 = x.x - y.x; - float a = x.y - y.y; - float e = x.z + y.z; - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); -} - -vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } - -vec3 cultmath_af_scale(vec3 x, float s) -{ - float x0 = x.x * s; - float a = x.y * s; - float e = x.z * abs(s); - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } - -vec3 cultmath_af_mul(vec3 x, vec3 y) -{ - float x0 = x.x * y.x; - float p = x.x * y.y; - float q = y.x * x.y; - float a = p + q; - float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); - return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, -// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is -// in math.Affine.cs. -vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) -{ - vec4 g = cultmath_snoise_grad(centre); - vec3 gradient = vec3(g.x, g.y, g.z); - float reach = length(axis) + radius; - float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; - float a = dot(gradient, axis); - float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; - float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach - + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); - bool centred = abs(a) + remainder < lipschitz; - float slope = centred ? a : 0.0; - float e = (centred ? remainder : lipschitz) + allowance; - return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); -} - -// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. -vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - vec3 sum = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); - frequency *= lacunarity; - amplitude *= gain; - } - - return sum; -} - -// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of -// cultmath_iv_frustum_ball. -vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) -{ - float h = (z1 - z0) * 0.5; - return vec3(centreSlope.x * h, centreSlope.y * h, h); -} - -// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. -vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) -{ - float zm = (z0 + z1) * 0.5; - float h = (z1 - z0) * 0.5; - float cx = centreSlope.x * zm; - float cy = centreSlope.y * zm; - float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; - float radius = z1 * footprintPerDepth + warpVariation; - radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; - return vec4(cx, cy, zm, radius); -} - - - - -// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in -// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit -// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. -// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) -// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for -// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack -// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. -struct SpanPath -{ - vec3 midpoint; - vec3 velocity; - float speed; - float acceleration; - float halfLength; -}; - -struct SpanStage -{ - float frequency; - float stretch; - vec3 axis; - vec3 shift; - vec3 offset; - vec3 octaveStep; -}; - -struct SpanNoise -{ - vec4 field; - float curvature; - float fold; -}; - -float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) -{ - return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); -} - -vec3 cultmath_span_slack(float rounding) -{ - return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, - CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, - 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); -} - -// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm -// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member -// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). -SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - vec3 q = path.midpoint; - float halfLength = path.halfLength; - octaves = clamp(octaves, 0, 16); - bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; - vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); - float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; - float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); - vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; - vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; - float amplitude = 1.0; - float scale = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - float curvature = 0.0; - float fold = 0.0; - for (int i = 0; i < octaves; i++) - { - vec3 octaveShift = float(i) * stage.octaveStep; - vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); - vec4 n = cultmath_snoise_grad(x); - vec3 g = vec3(n.x, n.y, n.z); - vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; - if (ridged) - { - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * scale * s * mg; - } - else - { - value += amplitude * n.w; - gradient += amplitude * scale * mg; - } - - float weight = abs(amplitude); - float factor = abs(scale) * norm; - float nu = factor * path.speed; - float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); - vec3 slack = cultmath_span_slack(rounding); - float valueSlack = slack.x; - float gradientSlack = slack.y; - float hessianSlack = slack.z; - mat3 hm = cultmath_snoise_hessian(x); - float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] - + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); - float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); - curvature += weight * factor * factor * hessianBound; - if (ridged) - { - vec3 w = scale * mv; - float reach = hessianBound * nu * halfLength; - float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); - float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; - fold += 2.0 * weight * max(travel - abs(n.w), 0.0); - } - - scale *= lacunarity; - amplitude *= gain; - } - - SpanNoise result; - result.field = vec4(gradient, value); - result.curvature = curvature; - result.fold = fold; - return result; -} - -SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); -} - -SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); -} - -float cultmath_value_noise(vec2 position) -{ - vec2 cell = floor(position); - vec2 local = cultmath_frac(position); - vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); - - float a = cultmath_hash(cell); - float b = cultmath_hash(cell + vec2(1.0, 0.0)); - float c = cultmath_hash(cell + vec2(0.0, 1.0)); - float d = cultmath_hash(cell + vec2(1.0, 1.0)); - - return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); -} - -float cultmath_value_noise_bicubic(vec2 position) -{ - vec2 cell = floor(position); - vec2 local = cultmath_frac(position); - - float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); - float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); - float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); - float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); - - return cultmath_catmullrom(y0, y1, y2, y3, local.y); -} - -#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md deleted file mode 100644 index b970f82b..00000000 --- a/src/app/features/scene/shaders/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Vendored CultMath GLSL - -`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 -lowering of `packages/cultmath/shaders/CultMath.hlsl` and -`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch -`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries -the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch -does not carry the GLSL yet). Never edit them here: regenerate in CultLib and -re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune -Skovbo Johansen's Phacelle noise, see its header), so that file keeps its -header and any change to it is published under MPL-2.0. - -The other `*.glsl` files here are the scene's own stages. They are -concatenated by `../shaderSource.ts` after a prelude; they use no colour -literal (the palette is uniforms) and define no noise. - -`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches -(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage -alone. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl deleted file mode 100644 index adbf1b5b..00000000 --- a/src/app/features/scene/shaders/atmosphere.frag.glsl +++ /dev/null @@ -1,90 +0,0 @@ -// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud -// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. -uniform sampler2D u_depth; // pass 1 hit distance, nearest -uniform int u_steps; // cloud march steps -uniform float u_frame; -uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target -uniform vec2 u_size; // this target's size in pixels -uniform float u_coverage; // weather knob: 0 clear .. 1 overcast - -in vec2 v_uv; -layout(location = 0) out vec4 o_atm; - -float hg(float cosTheta, float g) -{ - float g2 = g * g; - return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); -} - -float cloudDensity(vec3 p) -{ - float wind = u_time * 2.5; - float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; - float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); - float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); - return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; -} - -void main() -{ - vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; - vec3 rd = camRay(jittered); - vec3 ro = u_camPos; - float depth = texture(u_depth, v_uv).r; - float tEnd = min(depth, W_ATM_FAR); - - // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. - float k = W_FOG_FALLOFF; - float dy = rd.y * tEnd; - float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); - float fogT = exp(-max(tau, 0.0)); - float cosSun = dot(rd, u_sunDir); - vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; - vec3 inscatter = fogLight * (1.0 - fogT); - float trans = fogT; - - // Clouds: only the part of the ray inside the slab and in front of the surface. - float ta = 0.0; - float tb = tEnd; - if (abs(rd.y) > 1e-4) - { - float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; - float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; - ta = max(0.0, min(t0, t1)); - tb = min(tEnd, max(t0, t1)); - } - else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) - tb = 0.0; - - if (tb > ta && u_steps > 0) - { - float dt = (tb - ta) / float(u_steps); - float t = ta + dt * ign(gl_FragCoord.xy, u_frame); - float T = 1.0; - vec3 L = vec3(0.0); - float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); - for (int i = 0; i < u_steps; i++) - { - vec3 p = ro + rd * t; - float d = cloudDensity(p); - if (d > 0.01) - { - // One sample toward the sun stands in for self-shadowing. - float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); - vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; - float ext = d * dt * 0.035; - float stepT = exp(-ext); - L += T * (1.0 - stepT) * light; - T *= stepT; - if (T < 0.03) - break; - } - t += dt; - } - // Fog in front of the slab is already in `inscatter`; clouds sit behind it. - inscatter += trans * L; - trans *= T; - } - - o_atm = vec4(inscatter, trans); -} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl deleted file mode 100644 index 6378a2e6..00000000 --- a/src/app/features/scene/shaders/common.glsl +++ /dev/null @@ -1,31 +0,0 @@ -// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. -uniform vec3 u_camPos; -uniform mat3 u_camBasis; // columns: right, up, forward -uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) -uniform float u_time; -uniform vec3 u_sunDir; // unit, towards the sun -uniform vec3 u_sunColor; -uniform vec3 u_skyTop; -uniform vec3 u_skyHorizon; -uniform vec3 u_fogColor; - -vec3 camRay(vec2 uv) -{ - vec2 ndc = uv * 2.0 - 1.0; - return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); -} - -float ign(vec2 p, float frame) -{ - p += 5.588238 * mod(frame, 64.0); - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -vec3 skyColor(vec3 rd) -{ - float h = clamp(rd.y, 0.0, 1.0); - vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); - float s = max(dot(rd, u_sunDir), 0.0); - c += u_sunColor * (1.5 * pow(s, 256.0)); - return c; -} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl deleted file mode 100644 index 9dce31f1..00000000 --- a/src/app/features/scene/shaders/composite.frag.glsl +++ /dev/null @@ -1,26 +0,0 @@ -// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. -uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) -uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) -uniform float u_frame; - -in vec2 v_uv; -out vec4 o_frag; - -vec3 tonemap(vec3 x) -{ - // Narkowicz ACES fit. - return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); -} - -void main() -{ - vec3 rd = camRay(v_uv); - vec4 solid = texture(u_solid, v_uv); - vec4 atm = texture(u_atm, v_uv); - vec3 base = mix(skyColor(rd), solid.rgb, solid.a); - vec3 col = base * atm.a + atm.rgb; - col = tonemap(col); - col = pow(col, vec3(1.0 / 2.2)); - col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; - o_frag = vec4(col, 1.0); -} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl deleted file mode 100644 index cab0e527..00000000 --- a/src/app/features/scene/shaders/fullscreen.vert.glsl +++ /dev/null @@ -1,8 +0,0 @@ -out vec2 v_uv; -void main() -{ - // One oversized triangle; uv spans 0..1 over the visible part. - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - v_uv = p; - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl deleted file mode 100644 index 4ca21a2c..00000000 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ /dev/null @@ -1,57 +0,0 @@ -// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's -// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the -// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any -// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball -// around each ray's own point. -uniform int u_maxSteps; -uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it - -in vec2 v_uv; -layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both) - -void main() -{ - vec3 ro = u_camPos; - vec3 rd = camRay(v_uv); - - // Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit - // directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so - // the maximum sits at a corner. The margin is for float rounding. - float spread = 0.0; - for (int c = 0; c < 4; c++) - { - vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0; - spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd)); - } - spread *= 1.05; - - // Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR): - // none can hit anything. - float rise = rd.y - spread; - float tEnd = W_FAR; - if (rise > 0.0) - tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise)); - - float t = 0.0; - float leafness; - bool onTree; - int evals = 0; - for (int i = 0; i < u_maxSteps && t < tEnd; i++) - { - evals++; - // Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so - // no ray of the tile starts inside its own hit radius. - float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006; - if (s <= 0.0) - break; - t += s; - } - float start = t; -#ifdef START_HALF - // The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past - // the true one is a ray that could skip a surface, so shave more than half a ulp off first - // (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds. - start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS); -#endif - o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0); -} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl deleted file mode 100644 index cc6dd2e0..00000000 --- a/src/app/features/scene/shaders/resolve.frag.glsl +++ /dev/null @@ -1,41 +0,0 @@ -// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the -// current frame's 3x3 neighbourhood, blend. -uniform sampler2D u_cur; -uniform sampler2D u_history; -uniform sampler2D u_depth; -uniform vec3 u_prevPos; -uniform mat3 u_prevBasis; -uniform vec2 u_prevTanHalf; -uniform float u_blend; // weight of the current frame; 1 resets -uniform vec2 u_curSize; - -in vec2 v_uv; -layout(location = 0) out vec4 o_hist; - -void main() -{ - vec2 px = 1.0 / u_curSize; - vec4 cur = texture(u_cur, v_uv); - vec4 lo = cur; - vec4 hi = cur; - for (int y = -1; y <= 1; y++) - for (int x = -1; x <= 1; x++) - { - vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); - lo = min(lo, s); - hi = max(hi, s); - } - - // Where this pixel's ray ended, reprojected into last frame's camera. - vec3 rd = camRay(v_uv); - float t = min(texture(u_depth, v_uv).r, 600.0); - vec3 v = u_camPos + rd * t - u_prevPos; - vec3 local = transpose(u_prevBasis) * v; - vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); - vec2 prevUv = prevNdc * 0.5 + 0.5; - bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); - - float a = (inside && u_blend < 1.0) ? u_blend : 1.0; - vec4 hist = clamp(texture(u_history, prevUv), lo, hi); - o_hist = mix(hist, cur, a); -} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl deleted file mode 100644 index 95e96bc2..00000000 --- a/src/app/features/scene/shaders/sdf.glsl +++ /dev/null @@ -1,95 +0,0 @@ -// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the -// cone prepass, so both step by the same bound. - -// ---- terrain ------------------------------------------------------------------------------- - -float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } - -// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. -float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } - -#if W_ERODE_ENABLED -CultPhasor terrainPhacelle(vec2 xz) -{ - // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. - return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); -} -#endif - -// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the -// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. -float terrainDistance(vec3 p) -{ - float m = terrainMask(p.xz); - // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most - // of their steps here. - if (m <= 0.0) - return p.y * W_SLOPE_ENV_INV; - float hb = m * W_AMP * terrainFbm(p.xz); - float above = p.y - (hb + m * W_ERODE_AMP); - if (above > 0.0) - return above * W_SLOPE_ENV_INV; -#if W_ERODE_ENABLED - hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; -#endif - return (p.y - hb) * W_SLOPE_FULL_INV; -} - -vec3 terrainNormal(vec2 xz) -{ - float r = length(xz); - float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); - float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); - float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; - 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; -#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; -#endif - return normalize(vec3(-g.x, 1.0, -g.y)); -} - -// ---- world tree ---------------------------------------------------------------------------- - -float smin(float a, float b, float k) -{ - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * k * 0.25; -} - -// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. -float treeDistance(vec3 p, out float leafness) -{ - vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); - float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); - leafness = 0.0; - if (bound > 8.0) - return bound; - - float ang = atan(p.z, p.x); - float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); - float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); - float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; - - vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; - float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); - float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; - canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) - - float d = smin(trunk, canopy, 6.0); - leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); - return d; -} - -// ---- trace --------------------------------------------------------------------------------- - -float sceneDistance(vec3 p, out float leafness, out bool onTree) -{ - float dt = terrainDistance(p); - float dr = treeDistance(p, leafness); - onTree = dr < dt; - return min(dt, dr); -} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl deleted file mode 100644 index 5946cc4a..00000000 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ /dev/null @@ -1,108 +0,0 @@ -// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage -// and, separately, the hit distance that bounds pass 2. -uniform vec3 u_grass; -uniform vec3 u_rock; -uniform vec3 u_bark; -uniform vec3 u_leaf; -uniform int u_maxSteps; -uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses - -in vec2 v_uv; -layout(location = 0) out vec4 o_color; // rgb lit, a coverage -layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss -#ifdef TRACE_STATS -layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) -#endif - -// ---- trace --------------------------------------------------------------------------------- - -void main() -{ - vec3 ro = u_camPos; - vec3 rd = camRay(v_uv); - - float t = texture(u_start, v_uv).r; - // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the - // prepass proved empty arrives as W_FAR. - bool missAtOnce = t >= W_FAR; - if (ro.y > W_MAX_HEIGHT) - { - missAtOnce = missAtOnce || rd.y >= 0.0; - t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); - } - if (missAtOnce) - { - o_color = vec4(0.0); - o_depth = W_FAR; -#ifdef TRACE_STATS - o_steps = 0.0; -#endif - return; - } - - bool hit = false; - bool onTree = false; - float leafness = 0.0; - int i = 0; - int evals = 0; - for (; i < u_maxSteps; i++) - { - evals++; - vec3 p = ro + rd * t; - float d = sceneDistance(p, leafness, onTree); - if (d < 0.0006 * t + 0.01) - { - hit = true; - break; - } - t += d; - if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) - break; - } - // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still - // below the highest terrain is a distant surface under thick fog, so it ends there. - if (!hit && i == u_maxSteps && t < W_FAR) - { - hit = true; - sceneDistance(ro + rd * t, leafness, onTree); - } -#ifdef TRACE_STATS - o_steps = float(evals) / 255.0; -#endif - - if (!hit) - { - o_color = vec4(0.0); - o_depth = W_FAR; - return; - } - - vec3 p = ro + rd * t; - vec3 n; - vec3 albedo; - if (onTree) - { - float e = 0.35; - float l; - n = normalize(vec3( - treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), - treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), - treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); - float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); - albedo = mix(u_bark, u_leaf * speckle, leafness); - } - else - { - n = terrainNormal(p.xz); - float rockness = smoothstep(0.78, 0.55, n.y); - albedo = mix(u_grass, u_rock, rockness); - } - - float ndl = dot(n, u_sunDir); - float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass - vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; - vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); - - o_color = vec4(col, 1.0); - o_depth = t; -} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts deleted file mode 100644 index d35b6bb6..00000000 --- a/src/app/features/scene/tier.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveTier } from './tier'; - -describe('scene tier', () => { - it('is off unless the config turns it on, which is the feature flag', () => { - expect(resolveTier(undefined)).toBe('off'); - expect(resolveTier({})).toBe('off'); - expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); - expect(resolveTier({ tier: 'full' })).toBe('full'); - }); - - it('lets the device lower the configured tier and never raise it', () => { - expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); - expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); - expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); - expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); - expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( - 'still' - ); - expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); - }); - - it('treats unknown capabilities as capable', () => { - expect(resolveTier({ tier: 'full' }, {})).toBe('full'); - }); -}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts deleted file mode 100644 index 1c1668c7..00000000 --- a/src/app/features/scene/tier.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both -// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the -// device can only lower it. - -export type SceneTier = 'off' | 'still' | 'low' | 'full'; - -/** Cheapest first. */ -export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; - -export interface Capabilities { - /** `navigator.connection.saveData`. */ - saveData?: boolean; - /** `navigator.deviceMemory` in GiB (Chromium only). */ - deviceMemory?: number; - hardwareConcurrency?: number; -} - -export interface BackgroundSceneConfig { - tier?: SceneTier; -} - -export function readCapabilities(nav: Navigator): Capabilities { - const n = nav as Navigator & { - deviceMemory?: number; - connection?: { saveData?: boolean }; - }; - return { - saveData: n.connection?.saveData, - deviceMemory: n.deviceMemory, - hardwareConcurrency: n.hardwareConcurrency, - }; -} - -/** The most the device is allowed to run, before the configured ceiling is applied. */ -function deviceCeiling(c: Capabilities): SceneTier { - if (c.saveData) return 'still'; - if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; - return 'full'; -} - -/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ -export function resolveTier( - config: BackgroundSceneConfig | undefined, - capabilities: Capabilities = {} -): SceneTier { - const configured = config?.tier ?? 'off'; - const ceiling = deviceCeiling(capabilities); - return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; -} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts deleted file mode 100644 index caa04fad..00000000 --- a/src/app/features/scene/world.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import common from './shaders/common.glsl?raw'; -import terrain from './shaders/terrain.frag.glsl?raw'; -import atmosphere from './shaders/atmosphere.frag.glsl?raw'; -import resolve from './shaders/resolve.frag.glsl?raw'; -import composite from './shaders/composite.frag.glsl?raw'; -import { terrainBounds, WORLD, worldDefines } from './world'; - -describe('world defines', () => { - it('define every W_ name any scene shader uses', () => { - const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); - [common, terrain, atmosphere, resolve, composite] - .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) - .forEach((m) => expect(defined, m[0]).toContain(m[0])); - }); - - it('emit only valid GLSL numbers', () => { - expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); - }); -}); - -describe('terrain step bound', () => { - it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { - const b = terrainBounds(); - expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); - const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); - expect(off.slopeFull).toBe(off.slopeEnvelope); - }); - - it('puts every surface under the height the tracer skips to', () => { - const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); - expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); - expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); - }); - - it('leaves the erosion bound turned on only when a bound exists', () => { - expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); - expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); - }); -}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts deleted file mode 100644 index e9594a68..00000000 --- a/src/app/features/scene/world.ts +++ /dev/null @@ -1,117 +0,0 @@ -// The scene's world constants. One owner: the shader prelude below is generated from this -// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the -// sphere-trace step safe are computed beside the numbers they bound. - -// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored -// CultMath.glsl (provenance in CultLib math.Interval.cs). -export const SNOISE_LIPSCHITZ = 7.9640074; - -export const WORLD = { - far: 1400, - // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). - amp: 70, - freq: 1 / 520, - octaves: 4, - lacunarity: 2.0, - gain: 0.45, - // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login - // camera stays inside plateauInner, so it can never end up inside the terrain. - plateauInner: 135, - plateauOuter: 380, - erodeAmp: 2.5, - erodeFreq: 1 / 38, - // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. - erodeSide: [1.7, 0, 1] as const, - erodeNorm: 0.5, - // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a - // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. - // The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc - // envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero, - // divide this bound by (1 - n). - // |value| <= 1 holds by construction (the output is a normalized phasor). - // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. - phacelleGradBound: 7.0 as number, - // World tree at the origin. - tree: { - trunkHeight: 38, - baseRadius: 3.4, - topRadius: 1.9, - canopyY: 52, - canopyRadius: [30, 17, 30] as const, - }, - // Atmosphere. - atmosphereFar: 1400, - cloudBase: 130, - cloudTop: 230, - fogDensity: 0.0028, - fogFalloff: 0.011, -} as const; - -const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); - -/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ -function slopeSum(octaves: number, lacunarity: number, gain: number): number { - let s = 0; - for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; - return s; -} - -/** Sum over octaves of gain^i: the fbm value bound. */ -function valueSum(octaves: number, gain: number): number { - let s = 0; - for (let i = 0; i < octaves; i += 1) s += gain ** i; - return s; -} - -export interface TerrainBounds { - /** Lipschitz bound of the envelope surface (no erosion). */ - slopeEnvelope: number; - /** Lipschitz bound of the eroded surface. */ - slopeFull: number; - /** Highest point the terrain can reach. */ - maxHeight: number; -} - -export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { - const fbmMax = valueSum(w.octaves, w.gain); - const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope - const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); - const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; - const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; - return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; -} - -/** The #defines every stage sees. */ -export function worldDefines(w: typeof WORLD = WORLD): string { - const b = terrainBounds(w); - const t = w.tree; - const lines: Array<[string, string]> = [ - ['W_FAR', f(w.far)], - ['W_AMP', f(w.amp)], - ['W_FREQ', f(w.freq)], - ['W_OCTAVES', String(w.octaves)], - ['W_LACUNARITY', f(w.lacunarity)], - ['W_GAIN', f(w.gain)], - ['W_PLATEAU_IN', f(w.plateauInner)], - ['W_PLATEAU_OUT', f(w.plateauOuter)], - ['W_ERODE_AMP', f(w.erodeAmp)], - ['W_ERODE_FREQ', f(w.erodeFreq)], - ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], - ['W_ERODE_NORM', f(w.erodeNorm)], - ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], - ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], - ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], - ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], - ['W_TRUNK_H', f(t.trunkHeight)], - ['W_TRUNK_R0', f(t.baseRadius)], - ['W_TRUNK_R1', f(t.topRadius)], - ['W_CANOPY_Y', f(t.canopyY)], - ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], - ['W_ATM_FAR', f(w.atmosphereFar)], - ['W_CLOUD_Y0', f(w.cloudBase)], - ['W_CLOUD_Y1', f(w.cloudTop)], - ['W_FOG_DENSITY', f(w.fogDensity)], - ['W_FOG_FALLOFF', f(w.fogFalloff)], - ]; - return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); -} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 6b304965..d3dcde96 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,7 +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 RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; +import CinnySVG from '../../../../../public/res/svg/cinny.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) { Ratatoskr logo - Ratatoskr + Cinny v4.12.8 - GameCult's Matrix client. + Yet another matrix client. - - - Based on{' '} - } > - Cinny - {' '} - 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU - Affero General Public License v3; its source is at the Source Code link above. - + Support + + diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 4fcdacdb..00128c8f 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'ratatoskr-keys.txt'); + FileSaver.saveAs(blob, 'cinny-keys.txt'); }, [mx] ) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 38115980..e0df06df 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -27,7 +27,7 @@ function EmailNotification() { device_display_name: email, lang: 'en', data: { - brand: 'Ratatoskr', + brand: 'Cinny', }, append: true, }); diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts deleted file mode 100644 index ecdf1593..00000000 --- a/src/app/features/soundscape/device.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface DeviceHints { - deviceMemory?: number; - hardwareConcurrency?: number; -} - -/** - * Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar - * section 7, item 4). Unknown hints (Safari reports neither) do not count as weak. - */ -export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean => - (deviceMemory !== undefined && deviceMemory < 4) || - (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts deleted file mode 100644 index 947d2bb5..00000000 --- a/src/app/features/soundscape/frame.ts +++ /dev/null @@ -1,108 +0,0 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head -// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human -// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until -// Bifrost's steward cuts the list. - -export type Estate = 'human' | 'mind' | 'persona'; -export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; -export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; -export type Size = 'S' | 'M' | 'L'; -export type Override = 'None' | 'Muted'; - -interface FrameBase { - mind: string; - estate: Estate; - override: Override; - seq: number; - /** Producer observation time, whole seconds (grammar: 1 s resolution). */ - at: number; -} -/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ -export interface NeutralFrame extends FrameBase { - neutral: true; - size?: undefined; - kind?: undefined; - energy?: undefined; -} -export interface LiveFrame extends FrameBase { - neutral: false; - size: Size; - kind: Kind; - energy: Energy; -} -export type ActivityFrame = NeutralFrame | LiveFrame; - -const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; -const KINDS: readonly Kind[] = [ - 'Idle', - 'Reading', - 'Thinking', - 'Working', - 'Speaking', - 'Waiting', - 'Alert', -]; -const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; -const SIZES: readonly Size[] = ['S', 'M', 'L']; - -/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ -export const STALE_AFTER_S = 10; - -const oneOf = (set: readonly T[], v: unknown): T | undefined => - set.find((s) => s === v); - -/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ -export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; - -/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ -const clampKind = (estate: Estate, kind: unknown): Kind | undefined => - estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); - -/** A human's energy is three buckets: Peak reads as High. */ -const clampEnergy = (estate: Estate, energy: Energy): Energy => - estate === 'human' && energy === 'Peak' ? 'High' : energy; - -/** - * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a - * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent - * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). - */ -export const readFrame = (raw: unknown): ActivityFrame | undefined => { - if (typeof raw !== 'object' || raw === null) return undefined; - const r = raw as Record; - if (typeof r.neutral !== 'boolean') return undefined; - const estate = oneOf(ESTATES, r.estate); - const override = - r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '') return undefined; - if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; - if (r.neutral) return { ...base, neutral: true }; - const kind = clampKind(estate, r.kind); - const energy = oneOf(ENERGIES, r.energy); - const size = oneOf(SIZES, r.size); - if (!kind || !energy || !size) return undefined; - return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; -}; - -/** Latest frame per mind (S1: consumers drop seq <= last). */ -export class FrameTable { - private frames = new Map(); - - offer(raw: unknown): boolean { - const f = readFrame(raw); - if (!f) return false; - const last = this.frames.get(f.mind); - if (last && f.seq <= last.seq) return false; - this.frames.set(f.mind, f); - return true; - } - - /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ - fresh(nowS: number): ActivityFrame[] { - return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); - } -} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts deleted file mode 100644 index 027cc27a..00000000 --- a/src/app/features/soundscape/scene.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ActivityFrame, Size } from './frame'; - -/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ -export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; - -/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ -export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => - frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts deleted file mode 100644 index e1d23010..00000000 --- a/src/app/features/soundscape/socket.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FrameTable } from './frame'; -import { drawnCreatures } from './scene'; -import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; -import { isWeakDevice, type DeviceHints } from './device'; - -/** - * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the - * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's - * member list (A1): being in a room says nothing about being active. - */ -export interface ActivitySource { - subscribe(onFrame: (raw: unknown) => void): () => void; -} -const noop = () => undefined; -export const silentSource: ActivitySource = { subscribe: () => noop }; - -/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ -export interface VoiceSink { - play(commands: readonly VoiceCommand[]): void; -} - -export interface AudioSocketDeps { - source: ActivitySource; - sink: VoiceSink; - prefs: () => SoundscapePrefs; - device: () => DeviceHints; - /** Whole seconds. */ - nowS: () => number; -} - -export const createAudioSocket = (deps: AudioSocketDeps) => { - const table = new FrameTable(); - const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); - return { - /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ - tick(): VoiceCommand[] { - const prefs = deps.prefs(); - const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; - const commands = planVoices(table.fresh(deps.nowS()), effective); - deps.sink.play(commands); - return commands; - }, - /** What the orbit renderer draws: the same table, neutral removed. */ - drawn: () => drawnCreatures(table.fresh(deps.nowS())), - close: unsubscribe, - }; -}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts deleted file mode 100644 index 294456a1..00000000 --- a/src/app/features/soundscape/soundscape.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; -import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; -import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; -import { isWeakDevice } from './device'; -import { createAudioSocket, silentSource, type ActivitySource } from './socket'; - -const frame = (o: Partial = {}): LiveFrame => ({ - mind: 'void', - estate: 'mind', - kind: 'Working', - energy: 'Mid', - size: 'M', - neutral: false, - override: 'None', - seq: 1, - at: 100, - ...o, -}); -const neutralFrame = (o: Partial = {}): ActivityFrame => - ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); -/** A raw frame as a producer sends it, with the named keys omitted. */ -const without = (f: object, ...keys: string[]) => - Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); -const noop = () => undefined; -const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; - -describe('neutral overrides everything', () => { - it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { - const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => - (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => - (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) - ) - ) - ); - expect(cases).toHaveLength(54); - cases.forEach((f) => { - expect(drawnCreatures([f])).toEqual([]); - expect(planVoices([f], on)).toEqual([]); - }); - }); - it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { - const t = new FrameTable(); - t.offer(frame({ at: 100 })); - expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); - expect(drawnCreatures(t.fresh(100)).length).toBe(1); - }); -}); - -describe('neutral is read first', () => { - it('yields no size, kind or energy, even when the producer sent them', () => { - (['human', 'mind', 'persona'] as const).forEach((estate) => { - const f = readFrame({ - ...frame({ estate, kind: 'Speaking', energy: 'High' }), - neutral: true, - }); - expect(f).toBeDefined(); - expect(f?.neutral).toBe(true); - expect(f?.size).toBeUndefined(); - expect(Object.keys(f ?? {})).not.toContain('size'); - expect(f?.kind).toBeUndefined(); - expect(f?.energy).toBeUndefined(); - expect(Object.keys(f ?? {})).not.toContain('kind'); - expect(Object.keys(f ?? {})).not.toContain('energy'); - }); - }); - it('a neutral human with kind omitted is not an Idle', () => { - const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); - const f = readFrame({ ...bare, neutral: true }); - expect(f?.kind).toBeUndefined(); - expect(drawnCreatures([f as ActivityFrame])).toEqual([]); - expect(planVoices([f as ActivityFrame], on)).toEqual([]); - }); -}); - -describe('human kinds are the provisional subset', () => { - it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { - expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); - }); - it('keeps Idle, Working and Speaking', () => { - (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => - expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) - ); - }); - it('an omitted kind is Idle for a non-neutral human only', () => { - const noKind = without(frame({ estate: 'human' }), 'kind'); - expect(readFrame(noKind)?.kind).toBe('Idle'); - expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); - }); - it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { - (['mind', 'persona'] as const).forEach((estate) => { - (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( - (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) - ); - const noKind = without(frame({ estate }), 'kind'); - expect(readFrame(noKind)).toBeUndefined(); - }); - }); -}); - -describe('human energy is clamped to three buckets by the consumer', () => { - it('turns Peak into High for humans only', () => { - expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); - expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); - expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); - }); - it('keeps the clamp through the voice plan', () => { - const t = new FrameTable(); - t.offer(frame({ estate: 'human', energy: 'Peak' })); - expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); - }); -}); - -describe('activity is a stub, never the room member list', () => { - it('the silent source and the socket yield nothing, however many members a room has', () => { - const members = ['@a:x', '@b:x', '@c:x']; - const sock = createAudioSocket({ - source: silentSource, - sink: { play: noop }, - prefs: () => on, - device: () => ({}), - nowS: () => 100, - }); - expect(members.length).toBeGreaterThan(0); - expect(sock.tick()).toEqual([]); - expect(sock.drawn()).toEqual([]); - }); - it('only frames the source pushes produce voices', () => { - const pushes: ((raw: unknown) => void)[] = []; - const source: ActivitySource = { - subscribe: (cb) => { - pushes.push(cb); - return noop; - }, - }; - const sock = createAudioSocket({ - source, - sink: { play: noop }, - prefs: () => on, - device: () => ({}), - nowS: () => 100, - }); - pushes[0](frame({ mind: 'aqua' })); - expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); - }); -}); - -describe('no vote weights', () => { - it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { - const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); - expect(Object.keys(f ?? {}).sort()).toEqual( - ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() - ); - const t = new FrameTable(); - t.offer({ ...frame(), weight: 9 }); - expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); - }); -}); - -describe('size scales are placeholders', () => { - it('live in the renderer table, not in the frame, and change nothing about voices', () => { - expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); - const s = planVoices([frame({ size: 'S' })], on); - const l = planVoices([frame({ size: 'L' })], on); - expect(s).toEqual(l); - }); -}); - -describe('socket defaults and limits', () => { - it('ships off, muted, with the proposed cap', async () => { - vi.stubGlobal('localStorage', { getItem: () => null }); - const { getSettings } = await import('../../state/settings'); - const s = getSettings(); - expect(s.soundscapeEnabled).toBe(false); - expect(s.soundscapeMuted).toBe(true); - expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); - }); - it('master mute, disabled flag and zero volume all silence', () => { - const f = [frame()]; - expect(planVoices(f, { ...on, muted: true })).toEqual([]); - expect(planVoices(f, { ...on, enabled: false })).toEqual([]); - expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); - }); - it('caps voices, dropping the lowest energy first', () => { - const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => - frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) - ); - const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); - expect(voices).toHaveLength(4); - expect(voices).not.toContain('a'); - }); - it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { - const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; - expect(planVoices(f, on)).toEqual([]); - expect(drawnCreatures(f)).toHaveLength(2); - }); - it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { - expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); - expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); - expect(isWeakDevice({})).toBe(false); - const played: number[] = []; - const sock = createAudioSocket({ - source: { - subscribe: (cb) => { - cb(frame()); - return noop; - }, - }, - sink: { play: (c) => played.push(c.length) }, - prefs: () => on, - device: () => ({ deviceMemory: 1 }), - nowS: () => 100, - }); - expect(sock.tick()).toEqual([]); - expect(played).toEqual([0]); - }); - it('drops frames with seq <= last, and malformed frames', () => { - const t = new FrameTable(); - expect(t.offer(frame({ seq: 5 }))).toBe(true); - expect(t.offer(frame({ seq: 5 }))).toBe(false); - expect(t.offer(frame({ seq: 4 }))).toBe(false); - expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); - }); -}); - -describe('neutral carries no size', () => { - it('reads without a size, and a live frame without one is malformed', () => { - const raw = frame(); - expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); - expect(readFrame(without(raw, 'size'))).toBeUndefined(); - }); -}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts deleted file mode 100644 index 676ae853..00000000 --- a/src/app/features/soundscape/voices.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; - -/** Grammar V3 (proposed, unmeasured): voices audible at once. */ -export const DEFAULT_VOICE_CAP = 4; - -/** Local listener preferences. Never written back into a frame (O3). */ -export interface SoundscapePrefs { - /** The feature flag. Default off. */ - enabled: boolean; - /** Master mute. Default on. */ - muted: boolean; - /** 0..1 */ - volume: number; - voiceCap: number; -} - -export interface VoiceCommand { - mind: string; - kind: Kind; - energy: Energy; - gain: number; -} - -const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; -/** Grammar section 3: Idle and Waiting default silent. */ -const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); - -const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); - -/** - * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); - * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). - * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. - * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. - */ -export const planVoices = ( - frames: readonly ActivityFrame[], - prefs: SoundscapePrefs -): VoiceCommand[] => { - if (!prefs.enabled || prefs.muted) return []; - const gain = clamp01(prefs.volume); - if (gain === 0) return []; - const cap = Math.max(0, Math.floor(prefs.voiceCap)); - return frames - .filter( - (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) - ) - .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) - .slice(0, cap) - .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); -}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7a0586eb..e5fc6cc6 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,5 +1,4 @@ import { createContext, useContext } from 'react'; -import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -19,8 +18,6 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; - - backgroundScene?: BackgroundSceneConfig; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 11f379dc..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,6 +15,7 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, + isRoomAlias, isRoomId, isServerName, isUserId, @@ -25,7 +26,6 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -227,7 +227,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), + exe: async (payload) => { + const rawIds = splitWithSpace(payload); + const roomIdOrAliases = rawIds.filter( + (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) + ); + roomIdOrAliases.forEach(async (idOrAlias) => { + await mx.joinRoom(idOrAlias); + }); + }, }, [Command.Leave]: { name: Command.Leave, diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 2b888f55..b0b94319 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -5,26 +5,20 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About - Source + v4.12.8 - - Based on Cinny + + Twitter Powered by Matrix diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index c53e5a65..3943f42d 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -20,7 +20,7 @@ import { } from '../../hooks/useClientConfig'; 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 CinnySVG from '../../../../public/res/svg/cinny.svg'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -134,8 +134,8 @@ export function AuthLayout() {
- Ratatoskr logo - Ratatoskr + Cinny Logo + Cinny
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 3c8641d9..62f46dd2 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; @@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..761d5dc5 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }, [baseUrl, token, startLogin]); diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index c9b1924c..9f173422 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -109,7 +109,7 @@ function RegisterUIAFlow({ auth: authDict, password, username, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }, [onRegister, formData] @@ -250,7 +250,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index db3eefc2..ce952bfc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; +import LogoSVG from '../../../../public/res/svg/cinny.svg'; +import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg'; +import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 74869c3f..6a25392c 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; -import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import CinnySVG from '../../../../public/res/svg/cinny.svg'; export function WelcomePage() { return ( @@ -14,17 +14,17 @@ export function WelcomePage() { > } - title="Welcome to Ratatoskr" + icon={Cinny Logo} + title="Welcome to Cinny" subTitle={ - GameCult's Matrix client, based on{' '} + Yet another matrix client.{' '} - Cinny + v4.12.8 } @@ -33,7 +33,7 @@ export function WelcomePage() { + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d4deb39c..6a894350 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -47,12 +47,6 @@ export interface Settings { dateFormatString: string; developerTools: boolean; - - /** Soundscape flag: default off, and muted by default even when on. */ - soundscapeEnabled: boolean; - soundscapeMuted: boolean; - soundscapeVolume: number; - soundscapeVoiceCap: number; } const defaultSettings: Settings = { @@ -87,11 +81,6 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, - - soundscapeEnabled: false, - soundscapeMuted: true, - soundscapeVolume: 0.5, - soundscapeVoiceCap: 4, }; export const getSettings = () => {