Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2eef089d | |||
|
|
d670fc5391 | ||
| 14d9fa6246 | |||
| 68f10ce414 | |||
| 7e632e8605 | |||
|
|
293885089a | ||
|
|
7e05866437 | ||
|
|
50ffd90560 | ||
|
|
12469671c9 | ||
|
|
b055b2a5af | ||
|
|
87c9ca6030 | ||
|
|
9e12f6cbe4 | ||
| 95e24aadb5 | |||
|
|
5eb0a63caa | ||
|
|
5e79a1853a | ||
|
|
190e2e724b | ||
|
|
f9cf99faff | ||
|
|
fff46492ca | ||
| 686f72bcf1 | |||
|
|
cc99a3439d | ||
|
|
1f4341dab6 | ||
|
|
c4c00d7127 | ||
|
|
0ec34ff19f | ||
|
|
8c7113bcaf | ||
|
|
093230a2c1 | ||
|
|
7a498584e6 | ||
|
|
b2d6d32cc4 | ||
| e377d9f722 |
36
.github/workflows/cla.yml
vendored
|
|
@ -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
|
||||
60
.github/workflows/deploy-pull-request.yml
vendored
|
|
@ -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. ⚠️
|
||||
1
.github/workflows/docker-pr.yml
vendored
|
|
@ -5,7 +5,6 @@ on:
|
|||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
|
|
|
|||
39
.github/workflows/netlify-dev.yml
vendored
|
|
@ -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
|
||||
15
.github/workflows/pr-title.yml
vendored
|
|
@ -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 }}
|
||||
100
.github/workflows/prod-deploy.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -17,5 +17,9 @@
|
|||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
harness/scene/README.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 };
|
||||
};
|
||||
20
index.html
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.3 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
17
public/res/svg/ratatoskr-highlight.svg
Normal 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 |
17
public/res/svg/ratatoskr-unread.svg
Normal 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 |
15
public/res/svg/ratatoskr.svg
Normal 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
|
|
@ -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');
|
||||
|
|
@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
|||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Cinny
|
||||
Ratatoskr
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
79
src/app/features/scene/README.md
Normal 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.
|
||||
443
src/app/features/scene/Scene.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
638
src/app/features/scene/Scene.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/app/features/scene/budget.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
59
src/app/features/scene/budget.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/app/features/scene/camera.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
src/app/features/scene/camera.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
27
src/app/features/scene/creatures.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
37
src/app/features/scene/creatures.ts
Normal 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;
|
||||
}
|
||||
82
src/app/features/scene/cultmathTrim.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
81
src/app/features/scene/cultmathTrim.ts
Normal 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}`;
|
||||
}
|
||||
33
src/app/features/scene/index.ts
Normal 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 });
|
||||
}
|
||||
27
src/app/features/scene/palette.ts
Normal 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;
|
||||
41
src/app/features/scene/shaderSource.ts
Normal 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]}`;
|
||||
}
|
||||
99
src/app/features/scene/shaders/CultMath.Phacelle.glsl
Normal 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
|
||||
1035
src/app/features/scene/shaders/CultMath.glsl
Normal file
19
src/app/features/scene/shaders/README.md
Normal 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.
|
||||
90
src/app/features/scene/shaders/atmosphere.frag.glsl
Normal 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);
|
||||
}
|
||||
31
src/app/features/scene/shaders/common.glsl
Normal 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;
|
||||
}
|
||||
26
src/app/features/scene/shaders/composite.frag.glsl
Normal 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);
|
||||
}
|
||||
8
src/app/features/scene/shaders/fullscreen.vert.glsl
Normal 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);
|
||||
}
|
||||
57
src/app/features/scene/shaders/prepass.frag.glsl
Normal 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);
|
||||
}
|
||||
41
src/app/features/scene/shaders/resolve.frag.glsl
Normal 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);
|
||||
}
|
||||
95
src/app/features/scene/shaders/sdf.glsl
Normal 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);
|
||||
}
|
||||
108
src/app/features/scene/shaders/terrain.frag.glsl
Normal 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;
|
||||
}
|
||||
26
src/app/features/scene/tier.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
49
src/app/features/scene/tier.ts
Normal 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;
|
||||
}
|
||||
40
src/app/features/scene/world.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
117
src/app/features/scene/world.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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'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">
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function EmailNotification() {
|
|||
device_display_name: email,
|
||||
lang: 'en',
|
||||
data: {
|
||||
brand: 'Cinny',
|
||||
brand: 'Ratatoskr',
|
||||
},
|
||||
append: true,
|
||||
});
|
||||
|
|
|
|||
12
src/app/features/soundscape/device.ts
Normal 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);
|
||||
108
src/app/features/soundscape/frame.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
src/app/features/soundscape/scene.ts
Normal 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);
|
||||
47
src/app/features/soundscape/socket.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
233
src/app/features/soundscape/soundscape.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
51
src/app/features/soundscape/voices.ts
Normal 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 }));
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||