Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f1d22ff0 | ||
|
|
5d93dd2d12 |
|
|
@ -17,9 +17,5 @@
|
|||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
docs/gamecult/probes/2026-10-31-album-probe.sh
Normal file
|
|
@ -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"
|
||||
|
|
@ -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-<slug>-outbox/<album>/` | 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-<slug>: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<i>`, `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-<slug>-outbox`, owned `mind-<slug>-self`, mode 0750, ACL
|
||||
`u:mind-<slug>: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-<slug>-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_<SLUG>` 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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); });
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
@ -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; });
|
||||
|
|
|
|||
|
|
@ -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('<html>504 Gateway Time-out</html>'); }
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <outdir> # esbuild bundle of entry.ts plus an index.html
|
||||
node drive.cjs <outdir> compile # compile and link each stage, JSON report
|
||||
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
|
||||
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
|
||||
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
|
||||
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
||||
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
|
||||
node drive.cjs <outdir> 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.
|
||||
|
|
@ -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 <outdir> [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'),
|
||||
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
|
||||
(demo
|
||||
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
|
||||
: '') +
|
||||
'<script src=bundle.js></script>'
|
||||
);
|
||||
});
|
||||
|
|
@ -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<SceneUnavailable, string> = {
|
||||
'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();
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
|
||||
// usage: node drive.cjs <bundledir> <mode> [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);
|
||||
});
|
||||
|
|
@ -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<string, unknown>).__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<string, unknown>).__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 };
|
||||
};
|
||||
20
index.html
|
|
@ -4,17 +4,21 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Ratatoskr</title>
|
||||
<meta name="name" content="Ratatoskr" />
|
||||
<meta name="author" content="GameCult" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Ratatoskr" />
|
||||
<meta property="og:url" content="https://gamecult.org" />
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
|
|
@ -25,8 +29,8 @@
|
|||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Ratatoskr" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 32 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.1 KiB |
13
public/res/svg/cinny-highlight.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_1961)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_1961">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
13
public/res/svg/cinny-unread.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_2015)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_2015">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
19
public/res/svg/cinny.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
|
||||
<defs>
|
||||
</defs>
|
||||
<g>
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 871 B |
|
|
@ -1,17 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||
<circle cx="52" cy="52" r="8" fill="#45B83B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,17 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||
<circle cx="52" cy="52" r="8" fill="#989898"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,15 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1,001 B |
|
|
@ -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 `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="${s.fill}"/>`;
|
||||
if (s.t === 'rect') return `<rect width="${s.w}" height="${s.h}" fill="${s.fill}"/>`;
|
||||
if (s.t === 'poly')
|
||||
return `<polygon points="${s.pts.map((p) => p.join(',')).join(' ')}" fill="${s.fill}"/>`;
|
||||
return `<ellipse cx="${s.cx}" cy="${s.cy}" rx="${s.rx}" ry="${s.ry}" transform="rotate(${s.rot} ${s.cx} ${s.cy})" fill="${s.fill}"/>`;
|
||||
})
|
||||
.join('\n');
|
||||
const writeSvg = (name, shapes) =>
|
||||
writeFileSync(
|
||||
join(root, 'public/res/svg', name),
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">\n${svgOf(
|
||||
shapes
|
||||
)}\n</svg>\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');
|
||||
|
|
@ -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<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
|
||||
}, [mx, roomIdOrAlias, viaServers])
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
|||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Ratatoskr
|
||||
Cinny
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
}) => {
|
||||
const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<string, string> }) => {
|
||||
let direct: Record<string, string[]> | undefined;
|
||||
const writes: Record<string, string[]>[] = [];
|
||||
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<string, string[]>) => {
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<typeof addRoomIdToMDirect>[0];
|
||||
|
||||
|
|
@ -27,31 +21,21 @@ type JoinClient = Parameters<typeof addRoomIdToMDirect>[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<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
|
||||
room: Pick<EstateRoom, 'roomId' | 'state' | 'via'>
|
||||
): Promise<EstateActivation> => {
|
||||
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<EstateActivation> =>
|
||||
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<string> => {
|
||||
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<void> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, Error, []>(
|
||||
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
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) {
|
|||
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text style={{ wordBreak: 'break-word' }} size="T400">
|
||||
{joinState.error.message}
|
||||
{joinState.error.data?.error || joinState.error.message}
|
||||
</Text>
|
||||
<Text size="T200">{joinState.error.name}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -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<string, Error, []>(
|
||||
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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<string, Fn> = {};
|
||||
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<string, unknown>, {
|
||||
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<typeof fakeCanvas>) => 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<Parameters<typeof createScene>[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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<PassName, number>;
|
||||
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<PassName, WebGLProgram>;
|
||||
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<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
|
||||
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<number, Partial<Record<PassName, number>>>();
|
||||
|
||||
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<PassName, number>;
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<SizeBucket, number>> = {
|
||||
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<Creature, 'sizeBucket' | 'neutral'>): number | null {
|
||||
if (c.neutral || c.sizeBucket === undefined) return null;
|
||||
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : 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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, Decl[]>();
|
||||
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
|
||||
|
||||
const kept = new Set<Decl>();
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
|
||||
/** 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<Scene | null> {
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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<Stage, string> = {
|
||||
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]}`;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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) {
|
|||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={RatatoskrSVG}
|
||||
alt="Ratatoskr logo"
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Ratatoskr</Text>
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v4.12.8</Text>
|
||||
</Box>
|
||||
<Text>GameCult's Matrix client.</Text>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Secondary"
|
||||
|
|
@ -65,19 +65,20 @@ export function About({ requestClose }: AboutProps) {
|
|||
>
|
||||
<Text size="B300">Source Code</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
<Text size="T300">
|
||||
Based on{' '}
|
||||
<a
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
target="_blank"
|
||||
<Button
|
||||
as="a"
|
||||
href="https://cinny.in/#sponsor"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Heart} size="100" filled />}
|
||||
>
|
||||
Cinny
|
||||
</a>{' '}
|
||||
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.
|
||||
</Text>
|
||||
<Text size="B300">Support</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function EmailNotification() {
|
|||
device_display_name: email,
|
||||
lang: 'en',
|
||||
data: {
|
||||
brand: 'Ratatoskr',
|
||||
brand: 'Cinny',
|
||||
},
|
||||
append: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 = <T extends string>(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<string, unknown>;
|
||||
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<string, ActivityFrame>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Size, number> = { 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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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> = {}): 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> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Energy, number> = { Low: 0, Mid: 1, High: 2, Peak: 3 };
|
||||
/** Grammar section 3: Idle and Waiting default silent. */
|
||||
const SILENT_KINDS: ReadonlySet<Kind> = new Set<Kind>(['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 }));
|
||||
};
|
||||
|
|
@ -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<ClientConfig | null>(null);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,26 +5,20 @@ import * as css from './styles.css';
|
|||
export function AuthFooter() {
|
||||
return (
|
||||
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||
<Text as="a" size="T300" href="https://gamecult.org" target="_blank" rel="noreferrer">
|
||||
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
|
||||
About
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
size="T300"
|
||||
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
|
||||
href="https://github.com/ajbura/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Source
|
||||
v4.12.8
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
size="T300"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Based on Cinny
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
|
||||
Powered by Matrix
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={RatatoskrSVG} alt="Ratatoskr logo" />
|
||||
<Text size="H3">Ratatoskr</Text>
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<img width="70" height="70" src={RatatoskrSVG} alt="Ratatoskr logo" />}
|
||||
title="Welcome to Ratatoskr"
|
||||
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
|
||||
title="Welcome to Cinny"
|
||||
subTitle={
|
||||
<span>
|
||||
GameCult's Matrix client, based on{' '}
|
||||
Yet another matrix client.{' '}
|
||||
<a
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
href="https://github.com/cinnyapp/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Cinny
|
||||
v4.12.8
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export function WelcomePage() {
|
|||
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
before={<Icon size="200" src={Icons.Code} />}
|
||||
|
|
@ -42,6 +42,18 @@ export function WelcomePage() {
|
|||
Source Code
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://cinny.in/#sponsor"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
fill="Soft"
|
||||
before={<Icon size="200" src={Icons.Heart} />}
|
||||
>
|
||||
<Text as="span" size="B400" truncate>
|
||||
Support
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHero>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||