Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
aa2eef089d Merge pull request 'ci: drop Cinny-upstream workflows that cannot run on the forge' (#25) from void/ci-drop-cinny-leftovers into main 2026-11-07 11:39:21 +00:00
void
d670fc5391 ci: drop Cinny-upstream workflows that cannot run on the forge
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Check PR title / lint (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 3m37s
Docker check / docker-build (pull_request) Failing after 15s
Remove cla, pr-title, deploy-pull-request, netlify-dev and prod-deploy;
drop the deleted prod-deploy path from docker-pr's trigger filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:28 +00:00
14d9fa6246 Merge pull request 'brand: Ratatoskr replaces Cinny branding (do not merge)' (#11) from void/brand into main 2026-11-05 10:51:59 +00:00
68f10ce414 Merge pull request 'Estate navigator stack: background scene, soundscape, cone prepass (opt-in)' (#15) from ratatoskr/estate-navigator into main 2026-11-05 00:31:47 +00:00
7e632e8605 Merge pull request 'scene: cone prepass for pass 1, opt-in steps/pixel readout' (#12) from nibu/scene-trace-prepass into ratatoskr/estate-navigator
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-04 22:56:29 +00:00
Nibu
293885089a scene README: createScene needs either float colour-buffer extension
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:39:58 +00:00
Nibu
7e05866437 scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to
RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so
only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes
t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still
starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs
neither float extension; Scene.startFormat and options.halfStart (force, for tests and the
harness). The standalone page shows the reason, renderer, vendor, UA and extension list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:39:22 +00:00
Nibu
50ffd90560 harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:47:12 +00:00
Nibu
12469671c9 scene: pack step bytes tightly; keep the hit tolerance clear at the far end of a prepass step
Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so
at widths not divisible by 4 the buffer was too small (readPixels fails) and padding
would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by
1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:35:11 +00:00
Nibu
b055b2a5af harness: trace and diff modes, standalone demo page with steps/pixel readout
trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms.
diff: one frozen frame with and without the prepass, against a 512-step render.
bundle.cjs demo: full-window page with a prepass switch and the readout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Nibu
87c9ca6030 scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout
Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest
any ray of the tile strays from it, and stores the distance no ray of the tile
can have hit before (W_FAR when the whole tile climbs out above the highest
geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both
passes step by the same function.

onTraceStats adds a step-count render target to pass 1 and reads it back every
8th frame through a pixel-pack buffer and fence, outside the timed span. Unset,
neither the output nor the target exists. prepass:false is the baseline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Void
9e12f6cbe4 brand: Ratatoskr replaces Cinny in the user-facing surface
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Squirrel logo, favicons and app icons generated from shapes by scripts/gen-brand.mjs;
titles, meta, manifest, device names, welcome, auth, splash and About are Ratatoskr.
About, the auth footer and the welcome page credit Cinny (AGPL-3.0) with source links.
LICENSE and notices untouched; storage keys and protocol names are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 15:53:30 +00:00
95e24aadb5 Merge pull request 'scene: Phacelle bound says measured-not-proved; neutral creature draws nothing' (#8) from nibu/phacelle-7-neutral-creature into ratatoskr/estate-navigator 2026-11-03 11:37:05 +00:00
Nibu
5eb0a63caa scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 08:59:54 +00:00
Void
5e79a1853a soundscape: neutral carries no size; cite grammar head 0091d01
NeutralFrame drops size alongside kind and energy; the reader ignores a size a producer sends on a neutral frame, and a live frame without a valid size is malformed. The human kind subset (Idle/Working/Speaking) stays PROVISIONAL, still proposed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:29:34 +00:00
Void
190e2e724b Merge PR #7: Soundscape neutral read first, human kind subset (provisional) 2026-11-02 18:28:16 +00:00
Void
f9cf99faff Merge PR #6: Soundscape audio socket over Aqua activity frame (flag off, muted) 2026-11-02 18:28:16 +00:00
Nibu
fff46492ca scene: Phacelle bound says measured-not-proved; neutral creature draws nothing
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
world.ts already carries Libby's 7.0 (CultLib PR #1 990521b3); the comment now
states the 6.63 sup was measured, not proved.

creatures.ts follows creature-projection-v2: a neutral creature publishes no
sizeBucket, and one that arrives is ignored. creatureScale returns null (draw
nothing) for neutral, absent or unknown buckets; it never defaults to medium.
NEUTRAL_SCALE is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:09:42 +00:00
686f72bcf1 Merge pull request 'Background scene: two-pass WebGL2 terrain and world tree with login pull-back' (#1) from nibu/background-scene into ratatoskr/estate-navigator 2026-11-02 16:50:13 +00:00
Void
cc99a3439d soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an
Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes
Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is
provisional until Bifrost's steward cuts the list.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:46:03 +00:00
Nibu
1f4341dab6 scene: live reduced-motion, chunk-load failure, per-stage CultMath
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
- matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss
- startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw
- shaderSource compiles each stage with only the CultMath declarations it reaches
  (cultmathTrim.ts); Phacelle stays a separate file, terrain only
- README: 'still' is a live rendered frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 14:43:33 +00:00
Void
c4c00d7127 soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a
per-mind table, plans voices (neutral overrides all, Muted, cap, master mute,
weak-device off) and the drawn set. No activity source exists yet
(ratatoskr❓activity-feed-source) and no audio assets, so nothing sounds.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 14:39:00 +00:00
Nibu
0ec34ff19f creatures: cite creature-projection-v2
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Header cites heimdall-body:follow_up:ratatoskr-creature-projection-v2, which
supersedes heimdall-body/ratatoskr-creature-projection. Citation only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 10:14:52 +00:00
Nibu
8c7113bcaf creatures: cite the Bifrost creature-props contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Header names heimdall-body/ratatoskr-creature-projection, the shape,
invariants and consent rule, and marks the bucket scales as placeholders.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 07:22:58 +00:00
Nibu
093230a2c1 Background scene: lint and type clean, integration contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Fixes the 21 eslint errors and 2 tsc errors Void's review found in
features/scene (WORLD.phacelleGradBound is typed number, not the literal 7).

Adds the contract the host needs: client config backgroundScene.tier as flag and
ceiling (off by default, lowered by saveData / memory / cores), lazy
startBackgroundScene, still and low tiers, onCompileFail (createScene frees
linked programs and returns null), webglcontextlost handling, and creatureScale
where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 06:20:53 +00:00
Nibu
7a498584e6 Background scene: sky costs no noise; one sun term
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Sky rays were ~90% of pass 1 (noSkyMarch variant: 292 -> 26 ms, SwiftShader 960x540 L0).
terrainDistance now returns early where the plateau mask is 0 (height exactly 0), skipping
fbm and Phacelle there: pass 1 292 -> 159 ms, same step values. skyColor keeps one sun term
(the wide glow is gone; screenshots differ by that and by TAA/time, not compared pixelwise).
Harness: noSkyMarch variant, ONLY=<names> selects variants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:08:50 +00:00
Nibu
b2d6d32cc4 Background scene: phacelleGradBound 6.3 -> 7.0 from Libby's measured sup
Libby measured sup |grad| 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head
990521b3); 7.0 adds margin. The shader passes roughness 0 as a literal, so no
1/(1-n) factor is needed; the comment records it. Pass 1 under SwiftShader:
terrain 288.8 ms vs 287.5 at 960x540 level 0, 75.3 vs 75.7 at level 3 (noise).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 06:37:01 +00:00
e377d9f722 Background scene: two-pass WebGL2 terrain and world tree with login pull-back
Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the
world tree with a conservative Lipschitz step, writing colour and depth. Pass 2
marches fog and clouds only up to that depth at low resolution with TAA. The
camera pulls back from the tree on login. Vendors CultLib's generated GLSL
(658c09e). The Phacelle gradient bound is an empirical probe, not a proof.
Headless harness compiles, renders and times it; nothing run on a GPU.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:43:40 +00:00
89 changed files with 4875 additions and 368 deletions

View file

@ -1,36 +0,0 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: 'signatures.json'
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'main'
allowlist: ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: cinnyapp
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

View file

@ -1,60 +0,0 @@
name: Deploy PR to Netlify
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on:
workflow_run:
workflows: ["Build pull request"]
types: [completed]
jobs:
deploy-pull-request:
name: Deploy pull request
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Validate and output pr number
id: pr
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
- name: Download artifact
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: preview
path: dist
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
alias: ${{ steps.pr.outputs.id }}
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
number: ${{ steps.pr.outputs.id }}
message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️

View file

@ -5,7 +5,6 @@ on:
paths:
- 'Dockerfile'
- '.github/workflows/docker-pr.yml'
- '.github/workflows/prod-deploy.yml'
jobs:
docker-build:

View file

@ -1,39 +0,0 @@
name: Deploy to Netlify (dev)
on:
push:
branches:
- dev
jobs:
deploy-to-netlify:
name: Deploy to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: nightly
github-deployment-description: 'Nightly deployment on each commit to dev branch'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
timeout-minutes: 1

View file

@ -1,15 +0,0 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- edited
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,100 +0,0 @@
name: Production deploy
on:
release:
types: [published]
jobs:
deploy-and-tarball:
name: Netlify deploy and tarball
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: stable
github-deployment-description: 'Stable deployment on each release'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
timeout-minutes: 1
- name: Get version from tag
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
run: |
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
# Sadly a few lines in the private key match a few lines in the public key,
# As a result just --export --armor gives us a few lines replaced with ***
# making it useless for importing the signing key. Instead, we dump it as
# non-armored and hex-encode it so that its printable.
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ github.ref_name }}
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -17,5 +17,9 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},
"backgroundScene": {
"tier": "off"
}
}

21
harness/scene/README.md Normal file
View file

@ -0,0 +1,21 @@
# 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.

40
harness/scene/bundle.cjs Normal file
View file

@ -0,0 +1,40 @@
// 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>'
);
});

102
harness/scene/demo.ts Normal file
View file

@ -0,0 +1,102 @@
// 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();

309
harness/scene/drive.cjs Normal file
View file

@ -0,0 +1,309 @@
// 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);
});

87
harness/scene/entry.ts Normal file
View file

@ -0,0 +1,87 @@
// 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 };
};

View file

@ -4,21 +4,17 @@
<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>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<title>Ratatoskr</title>
<meta name="name" content="Ratatoskr" />
<meta name="author" content="GameCult" />
<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="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.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:title" content="Ratatoskr" />
<meta property="og:url" content="https://gamecult.org" />
<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."
@ -29,8 +25,8 @@
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="application-name" content="Ratatoskr" />
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -1,7 +1,7 @@
{
"name": "Cinny",
"short_name": "Cinny",
"description": "Yet another matrix client",
"name": "Ratatoskr",
"short_name": "Ratatoskr",
"description": "GameCult's Matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 817 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,19 +0,0 @@
<!-- 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>

Before

Width:  |  Height:  |  Size: 871 B

View file

@ -0,0 +1,17 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,17 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 1,001 B

202
scripts/gen-brand.mjs Normal file
View file

@ -0,0 +1,202 @@
// 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');

View file

@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
justifyContent="Center"
>
<Text size="H2" align="Center">
Cinny
Ratatoskr
</Text>
</Box>
</Box>

View file

@ -0,0 +1,79 @@
# 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.

View file

@ -0,0 +1,443 @@
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);
});
});

View file

@ -0,0 +1,638 @@
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));
},
};
}

View file

@ -0,0 +1,41 @@
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);
});
});

View file

@ -0,0 +1,59 @@
// 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;
}
}

View file

@ -0,0 +1,42 @@
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);
});
});

View file

@ -0,0 +1,56 @@
// 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),
};
}

View file

@ -0,0 +1,27 @@
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();
});
});

View file

@ -0,0 +1,37 @@
// 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;
}

View file

@ -0,0 +1,82 @@
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'));
});
});

View file

@ -0,0 +1,81 @@
/**
* 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}`;
}

View file

@ -0,0 +1,33 @@
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 });
}

View file

@ -0,0 +1,27 @@
// 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;

View file

@ -0,0 +1,41 @@
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]}`;
}

View file

@ -0,0 +1,99 @@
// 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# 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.

View file

@ -0,0 +1,90 @@
// 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);
}

View file

@ -0,0 +1,31 @@
// 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;
}

View file

@ -0,0 +1,26 @@
// 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);
}

View file

@ -0,0 +1,8 @@
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);
}

View file

@ -0,0 +1,57 @@
// 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);
}

View file

@ -0,0 +1,41 @@
// 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);
}

View file

@ -0,0 +1,95 @@
// 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);
}

View file

@ -0,0 +1,108 @@
// 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;
}

View file

@ -0,0 +1,26 @@
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');
});
});

View file

@ -0,0 +1,49 @@
// 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;
}

View file

@ -0,0 +1,40 @@
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');
});
});

View file

@ -0,0 +1,117 @@
// 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');
}

View file

@ -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 CinnySVG from '../../../../../public/res/svg/cinny.svg';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.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={CinnySVG}
alt="Cinny logo"
src={RatatoskrSVG}
alt="Ratatoskr logo"
/>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="H3">Ratatoskr</Text>
<Text size="T200">v4.12.8</Text>
</Box>
<Text>Yet another matrix client.</Text>
<Text>GameCult&apos;s Matrix client.</Text>
</Box>
<Box gap="200" wrap="Wrap">
<Button
as="a"
href="https://github.com/cinnyapp/cinny"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
rel="noreferrer noopener"
target="_blank"
variant="Secondary"
@ -65,20 +65,19 @@ export function About({ requestClose }: AboutProps) {
>
<Text size="B300">Source Code</Text>
</Button>
<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 />}
>
<Text size="B300">Support</Text>
</Button>
</Box>
<Text size="T300">
Based on{' '}
<a
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
>
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>
</Box>
</Box>
<Box direction="Column" gap="100">

View file

@ -28,7 +28,7 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'cinny-keys.txt');
FileSaver.saveAs(blob, 'ratatoskr-keys.txt');
},
[mx]
)

View file

@ -27,7 +27,7 @@ function EmailNotification() {
device_display_name: email,
lang: 'en',
data: {
brand: 'Cinny',
brand: 'Ratatoskr',
},
append: true,
});

View file

@ -0,0 +1,12 @@
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);

View file

@ -0,0 +1,108 @@
// 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);
}
}

View file

@ -0,0 +1,8 @@
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);

View file

@ -0,0 +1,47 @@
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,
};
};

View file

@ -0,0 +1,233 @@
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();
});
});

View file

@ -0,0 +1,51 @@
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 }));
};

View file

@ -1,4 +1,5 @@
import { createContext, useContext } from 'react';
import type { BackgroundSceneConfig } from '../features/scene/tier';
export type HashRouterConfig = {
enabled?: boolean;
@ -18,6 +19,8 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
backgroundScene?: BackgroundSceneConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -5,20 +5,26 @@ 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://cinny.in" target="_blank" rel="noreferrer">
<Text as="a" size="T300" href="https://gamecult.org" target="_blank" rel="noreferrer">
About
</Text>
<Text
as="a"
size="T300"
href="https://github.com/ajbura/cinny/releases"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
target="_blank"
rel="noreferrer"
>
v4.12.8
Source
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
<Text
as="a"
size="T300"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer"
>
Based on Cinny
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
Powered by Matrix

View file

@ -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 CinnySVG from '../../../../public/res/svg/cinny.svg';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.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={CinnySVG} alt="Cinny Logo" />
<Text size="H3">Cinny</Text>
<img className={css.AuthLogo} src={RatatoskrSVG} alt="Ratatoskr logo" />
<Text size="H3">Ratatoskr</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">

View file

@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: username,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
};
@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: mxIdUsername,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
address: email,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
};

View file

@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
}, [baseUrl, token, startLogin]);

View file

@ -109,7 +109,7 @@ function RegisterUIAFlow({
auth: authDict,
password,
username,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
},
[onRegister, formData]
@ -250,7 +250,7 @@ export function PasswordRegisterForm({
auth: {
session: authData.session,
},
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Ratatoskr Web',
});
};

View file

@ -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/cinny.svg';
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
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 NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';

View file

@ -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 CinnySVG from '../../../../public/res/svg/cinny.svg';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
export function WelcomePage() {
return (
@ -14,17 +14,17 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
title="Welcome to Cinny"
icon={<img width="70" height="70" src={RatatoskrSVG} alt="Ratatoskr logo" />}
title="Welcome to Ratatoskr"
subTitle={
<span>
Yet another matrix client.{' '}
GameCult&apos;s Matrix client, based on{' '}
<a
href="https://github.com/cinnyapp/cinny/releases"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
>
v4.12.8
Cinny
</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://github.com/cinnyapp/cinny"
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
target="_blank"
rel="noreferrer noopener"
before={<Icon size="200" src={Icons.Code} />}
@ -42,18 +42,6 @@ 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>

View file

@ -47,6 +47,12 @@ 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 = {
@ -81,6 +87,11 @@ const defaultSettings: Settings = {
dateFormatString: 'D MMM YYYY',
developerTools: false,
soundscapeEnabled: false,
soundscapeMuted: true,
soundscapeVolume: 0.5,
soundscapeVoiceCap: 4,
};
export const getSettings = () => {