diff --git a/.claude/settings.json b/.claude/settings.json index 7d1df9e3c2..c8769deec1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,28 +11,6 @@ } ] } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ponytail-context.mjs\"", - "timeout": 5 - } - ] - } - ], - "SubagentStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ponytail-context.mjs\"", - "timeout": 5 - } - ] - } ] } } diff --git a/.github/actions/workspace-dist/action.yml b/.github/actions/workspace-dist/action.yml index 1033671f32..473f885680 100644 --- a/.github/actions/workspace-dist/action.yml +++ b/.github/actions/workspace-dist/action.yml @@ -22,6 +22,16 @@ inputs: workspace product. required: false default: "true" + shared-artifact: + description: >- + Name of this run's uploaded workspace to take the compiled bundles from, instead of restoring + them from the cache or building them again. Empty for the job that produces the workspace. + Consumers set it because they all `needs:` that producer: an unfiltered `needs: scope` starts + them in parallel with it, so every one of them looked for a key nobody had saved yet and + compiled the workspace itself (measured at 71-95s each, nine times a run, PRD-481). Such a + consumer must keep `save-bundles: "false"` — there is no key of its own to publish. + required: false + default: "" runs: using: composite @@ -32,6 +42,14 @@ runs: run: | echo "node=$(node --version)" >> "$GITHUB_OUTPUT" echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" + # The producer's own path is below: restore the key, or build it when the key misses. Every + # consumer takes the upload instead, which is this run's exact bytes for this commit rather than + # whichever earlier run last published this key. + - name: Take the compiled workspace from this run's producer + if: inputs.shared-artifact != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.shared-artifact }} # The key names everything the bundles are built from and nothing else. `src/**`, the package # manifests, the tsup configs, the per-package generators that write files into the bundle, and # the script that decides which packages get built at all. Templates are deliberately absent: @@ -43,6 +61,7 @@ runs: # rebuild nobody needed. - name: Restore the compiled workspace id: dist + if: inputs.shared-artifact == '' uses: actions/cache/restore@v4 env: # Rebuilding these small bundles is cheaper than the default ten-minute segment wait. @@ -62,13 +81,13 @@ runs: echo "workspace dist restored for these sources; skipping the build" node packages/core/scripts/bundle-engine-mcp.mjs - name: Build missing workspace bundles - if: steps.dist.outputs.cache-hit != 'true' + if: steps.dist.outputs.cache-hit != 'true' && inputs.shared-artifact == '' shell: bash run: | set -euo pipefail pnpm tsx scripts/workspace-packages.ts build - name: Pack current workspace files - if: inputs.pack-archives != 'false' + if: inputs.pack-archives != 'false' && inputs.shared-artifact == '' shell: bash run: | set -euo pipefail @@ -159,9 +178,9 @@ runs: if: always() shell: bash env: - DIST_CACHE_HIT: ${{ steps.dist.outputs.cache-hit }} + DIST_SOURCE: ${{ inputs.shared-artifact != '' && format('artifact `{0}`', inputs.shared-artifact) || format('cache hit `{0}`', steps.dist.outputs.cache-hit || 'unavailable') }} DIST_SAVER: ${{ inputs.save-bundles == 'true' && 'yes' || 'no' }} DIST_STARTED: ${{ steps.toolchain.outputs.started }} run: | - printf 'Workspace bundle cache hit: `%s`; publish: `%s`; restore/build/repack/validation execution: `%ss`. This is product reuse, not a test verdict.\n' \ - "${DIST_CACHE_HIT:-unavailable}" "${DIST_SAVER:-unavailable}" "$(( $(date +%s) - ${DIST_STARTED:-$(date +%s)} ))" >> "$GITHUB_STEP_SUMMARY" + printf 'Workspace bundles from: %s; publish: `%s`; restore/build/repack/validation execution: `%ss`. This is product reuse, not a test verdict.\n' \ + "${DIST_SOURCE:-unavailable}" "${DIST_SAVER:-unavailable}" "$(( $(date +%s) - ${DIST_STARTED:-$(date +%s)} ))" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci-janitor.yml b/.github/workflows/ci-janitor.yml new file mode 100644 index 0000000000..cc74e1ce53 --- /dev/null +++ b/.github/workflows/ci-janitor.yml @@ -0,0 +1,107 @@ +name: CI janitor + +# A merged or closed pull request's runs prove nothing: no verdict can change, no artifact gets +# read, and the head ref keeps holding runners until each one finishes on its own clock. That +# cost another 5.0k runner-minutes over 2026-09-18 to 10-02 (PRD-380). Cancelling the head ref's +# in-flight runs — and the merge group's, which GitHub starts for a queued pull request and then +# leaves running after it leaves the queue — is the only cleanup a repository can do for itself, and +# `pull_request: closed` reaches it within a minute of the merge. +on: + pull_request: + types: [closed] + # A failed CI run whose every failure is a known environmental flake reruns only its failed jobs, + # once. Runs from the default branch's copy of this file (a workflow_run rule). + workflow_run: + workflows: [CI] + types: [completed] + +# Cancelling a run is an Actions write, and this is the one workflow here that asks for one. A +# token that cannot do it reports a green job while cancelling nothing, which is the exact +# failure this workflow exists to prevent — so the grant is explicit rather than inherited. +permissions: + actions: write + +jobs: + retry-flakes: + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.run_attempt == 1 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Rerun the failed jobs once when every failure is a known flake + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + # Environmental signatures seen on 2026-10-02/03: SwiftShader device loss, a bridge call + # outliving its budget, an unreadable WebGPU adapter, a dev server not up yet, a 5xx on a + # download, and a runner shut down under its job. Anything else is a real failure. + FLAKE: TN_PLAYTEST_SOFTWARE_DEVICE_LOST|Bridge operation '[a-z]+' exceeded|TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING|TN_GOLDEN_PATH_DEV_UNAVAILABLE|ERR_CONNECTION_REFUSED|HTTP 50[0-9]|runner has received a shutdown signal + run: | + set -euo pipefail + # Aggregates (ci-required, golden-path, run-summary) only echo another job's failure. + failed="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" --paginate \ + --jq '.jobs[] | select(.conclusion == "failure") | select(.name | test("^(ci-required|golden-path|run-summary)$") | not) | .id')" + [ -n "$failed" ] || { echo "No primary job failed; nothing to retry."; exit 0; } + for id in $failed; do + if ! gh api "repos/$GITHUB_REPOSITORY/actions/jobs/$id/logs" 2>/dev/null | grep -qE "$FLAKE"; then + echo "Job $id failed for a reason that is not a known flake; leaving the run red." + exit 0 + fi + done + gh run rerun "$RUN_ID" --failed --repo "$GITHUB_REPOSITORY" + echo "Reran the failed jobs of run $RUN_ID once: every failure matched a known flake." >> "$GITHUB_STEP_SUMMARY" + + cancel: + # A fork's runs live in another repository, and its runs cannot be cancelled from here. Trying + # would put a red check on every fork PR close, so the guard is the whole job. + # A promotion PR's head is `develop`: its runs are develop's own CI, never a lane to sweep. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref != 'develop' && github.event.pull_request.head.ref != 'main' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The head ref is branch text chosen by whoever opened the PR. Interpolated into `run:` it + # is a command injection into a job holding `actions: write`, so it arrives through `env:` + # and every other value below is quoted. + - name: Cancel the head ref's and the merge group's queued and in-progress runs + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SELF_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + # A merge-group run reports its branch as `gh-readonly-queue//pr--`. + # GitHub drops the pull request from the queue on close but never cancels the run it has + # already started, so that run holds runners for a verdict nothing will read: on + # 2026-10-02 run 37093098790 kept nine self-hosted jobs busy for #404 after it merged. + # Built from the event's own base ref and number, so it reaches that group and no other. + group_prefix="gh-readonly-queue/${BASE_REF}/pr-${PR_NUMBER}-" + # Collected before the loop, so a failed lookup fails this step instead of quietly + # cancelling nothing under `set -e`. + runs="$(gh run list --repo "$GITHUB_REPOSITORY" --branch "$HEAD_REF" \ + --json databaseId,status,headBranch \ + --jq '.[] | select(.status == "queued" or .status == "in_progress") | [.databaseId, .headBranch] | @tsv' + gh run list --repo "$GITHUB_REPOSITORY" --json databaseId,status,headBranch --limit 100 \ + | jq -r --arg prefix "$group_prefix" '.[] | select(.status == "queued" or .status == "in_progress") | select(.headBranch | startswith($prefix)) | [.databaseId, .headBranch] | @tsv' + )" + cancelled=0 + while IFS=$'\t' read -r run_id branch; do + # A fork's branch may legitimately share this repository's ref name; never reach + # past the event's own repository for something to cancel. + [ -n "$run_id" ] || continue + [ "$run_id" != "$SELF_RUN_ID" ] || continue + # A run that completed between the list and the cancel is the normal race, not a + # failure: the point of the cancel is already satisfied. + # force-cancel, not `gh run cancel`: a plain cancel can hang on a job still queued for a + # self-hosted label and keep the run's concurrency slot (run 37098559814). + if gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/force-cancel" >/dev/null; then + cancelled=$((cancelled + 1)) + printf 'Cancelled run %s on %s.\n' "$run_id" "$branch" + else + printf 'Run %s finished before the cancel; nothing to stop.\n' "$run_id" + fi + done <<< "$runs" + printf 'Janitor finished for %s: %s run(s) cancelled.\n' "$HEAD_REF" "$cancelled" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b68167224..59d5fcc437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,16 @@ on: # Drafts run nothing; marking one ready for review starts the board. types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: {} # Explicit full audit; never a selectable exemption. + # PRD-481: the merge queue. GitHub tests the exact result a merge would produce, so two PRs + # merged back to back can no longer put a tree on develop that nothing tested. An unchanged base + # means the queue's tree is the pull request's tree, and tree reuse makes that instant. + merge_group: + types: [checks_requested] schedule: - cron: "17 3 * * *" concurrency: - group: ci-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && 'latest' || github.run_id }} + group: ci-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && 'latest' || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Read-only. The workflow used to hold Actions write access so a failing job could cancel its own @@ -29,7 +34,7 @@ jobs: scope: name: Change scope if: ${{ !github.event.pull_request.draft }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 outputs: scope: ${{ steps.classify.outputs.scope }} @@ -37,6 +42,11 @@ jobs: reason: ${{ steps.classify.outputs.reason }} plan: ${{ steps.classify.outputs.plan }} candidate_sha: ${{ steps.classify.outputs.candidate_sha }} + # PRD-481 tree reuse: the classification asks the Actions API which successful run tested this + # exact tree. Read-only, and nothing else in the workflow gains from it. + permissions: + contents: read + actions: read steps: - uses: actions/checkout@v7 with: @@ -47,10 +57,13 @@ jobs: name: Classify the complete pull-request diff env: TN_CI_EVENT: ${{ github.event_name }} - TN_CI_TARGET: ${{ github.base_ref }} + # A merge group reports no `base_ref`; its target is on the event. Passing it is what keeps + # a develop merge group on the reduced native matrix and a main one on the full matrix. + TN_CI_TARGET: ${{ github.base_ref || github.event.merge_group.base_ref }} TN_CI_FORCE_FULL: ${{ vars.TN_CI_FORCE_FULL }} TN_CI_BASE: ${{ github.event.pull_request.base.sha }} TN_CI_HEAD: ${{ github.event.pull_request.head.sha }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail extra=() @@ -79,9 +92,9 @@ jobs: # job that discards its own node_modules — it only added a serial edge in front of the run. # The cheap gates now start together, and the expensive ones name the gate they wait on. typecheck: - needs: scope - if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + needs: [scope, build-artifacts] + if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 15 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -97,6 +110,9 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" pack-archives: "false" - run: pnpm typecheck @@ -104,9 +120,11 @@ jobs: lint: needs: scope # A Markdown-only change runs no CI job: the docs lane is re-validated on the nightly run and - # at promotion, so this job is skipped rather than spending a runner on inert prose. - if: needs.scope.outputs.selection != 'prose' - runs-on: ubuntu-latest + # at promotion, so this job is skipped rather than spending a runner on inert prose. A reused + # tree skips it too — every gate it would run here already passed in the run that tested this + # exact tree, and `!= 'prose'` would otherwise have bought a second full lint. + if: needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 10 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -162,7 +180,7 @@ jobs: test: needs: scope if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 45 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -192,18 +210,29 @@ jobs: # that job that does: the docs check, the build and the package walk each make sense once. # # Every test still runs. `vitest --shard` slices the same file list, and the slices were checked - # to add back up: 117 + 117 + 117 = 351 files, union identical to the unsharded suite with no - # file in two shards. `scripts/__tests__/ci-structure.spec.ts` holds the matrix to that shape, + # to add back up: the slices partition the file list, union identical to the unsharded suite with + # no file in two shards. `scripts/__tests__/ci-structure.spec.ts` holds the matrix to that shape, # and `run-test-suite.sh` refuses a phase selection that would run nothing at all. + # + # Four slices, not three, because `--shard` sorts the file list by a hash of its path and slices + # that order, so the split is honest about file count and blind to duration. Measured on run + # 37049488719 the three legs ran 163s / 285s / 472s of vitest over 397s / 712s / 709s of test + # time, and replaying vitest's own `sha1(path)` partition over those per-file durations + # reproduces all three assignments exactly. At four slices the same replay predicts + # 80s / 114s / 211s / 320s of vitest, which with the 40-121s of job overhead measured on the same + # run puts three legs inside six minutes. The fourth cannot: it carries + # `create-threenative/__tests__/template-assets-compile.spec.ts` at 320s on its own, and no shard + # count splits a single file. A fifth slice would leave both of those numbers unchanged and buy + # one more runner, so four is the whole of it. test-unit: - needs: scope - if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-24.04 + needs: [scope, build-artifacts] + if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 30 strategy: fail-fast: false matrix: - shard: ["1/3", "2/3", "3/3"] + shard: ["1/4", "2/4", "3/4", "4/4"] env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} TN_SUITE_PHASES: unit @@ -225,15 +254,18 @@ jobs: # this job takes the cached result rather than repeating it three times over. - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" pack-archives: "false" - uses: ./.github/actions/playwright-chromium - run: pnpm test test-native: - needs: scope + needs: [scope, build-artifacts] if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 75 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -356,6 +388,14 @@ jobs: - name: Restore the compiled native tree uses: actions/cache@v4 with: + # No `.github/workflows/ci.yml` in this key. It named this job's own workflow file, so + # every edit to ci.yml — this one included — produced a key no run had ever saved, and the + # three pull requests that followed a workflow change each compiled the whole Linux host + # and stored their own 1.35 GiB copy. What ci.yml actually contributes to this build is + # the toolchain above, and `dpkg-query -W` in that identity already covers every package + # this job installs. Exact key only, with no prefix fallback: a tree restored from a + # different commit carries objects newer than the sources `restore-source-mtimes.mjs` + # stamps below, and ninja would read that as up to date. # Cargo's output belongs here too. With the C++ compile eliminated by #78, the host build # step was still ~112s and all of it was Rust: `threenative_native_physics` and the # `ui-overlay` crate with its webkit2gtk bindings. Measured locally, physics alone is a @@ -375,7 +415,7 @@ jobs: packages/runtime-native/build/tn-linux-quickjs packages/runtime-native/.runtime/physics-target packages/runtime-native/native/ui-overlay/target - key: native-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.native-toolchain.outputs.identity }}-${{ hashFiles('packages/runtime-native/scripts/**', 'packages/runtime-native/*.json', 'pnpm-lock.yaml', '.github/workflows/ci.yml', 'packages/runtime-native/CMakeLists.txt', 'packages/runtime-native/CMakePresets.json', 'packages/runtime-native/**/*.cmake', 'packages/runtime-native/src/**', 'packages/runtime-native/include/**', 'packages/runtime-native/tests/**', 'packages/runtime-native/native/**/build.rs', 'packages/runtime-native/native/**/.cargo/**', 'packages/runtime-native/native/**/src/**', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/Cargo.lock') }} + key: native-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.native-toolchain.outputs.identity }}-${{ hashFiles('packages/runtime-native/scripts/**', 'packages/runtime-native/*.json', 'pnpm-lock.yaml', 'packages/runtime-native/CMakeLists.txt', 'packages/runtime-native/CMakePresets.json', 'packages/runtime-native/**/*.cmake', 'packages/runtime-native/src/**', 'packages/runtime-native/include/**', 'packages/runtime-native/tests/**', 'packages/runtime-native/native/**/build.rs', 'packages/runtime-native/native/**/.cargo/**', 'packages/runtime-native/native/**/src/**', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/Cargo.lock') }} # `ccache --show-stats` at the end of the job reports one number for three builds, and that # number has been stuck at `Hits: 184 / 574 (32.06%)` on every run measured — identical # before and after each lane got its own restore-key namespace, and with the restore @@ -452,6 +492,9 @@ jobs: # imported from '.../packages/runtime-native/tests/production-profile.test.mjs' - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" # The pump-silence observer drives the real host with the public native-smoke bundle. Keep # this explicit because the shared action only builds packages/*/dist, not examples/*/dist. @@ -495,9 +538,9 @@ jobs: if-no-files-found: error test-browser: - needs: scope + needs: [scope, build-artifacts] if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 30 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -525,6 +568,9 @@ jobs: # so they build it themselves — otherwise the run dies on a missing assets/dist/index.js. - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" - uses: ./.github/actions/playwright-chromium # `playwright.config.ts` scaffolds its starter projects from packed tarballs, and without @@ -546,9 +592,9 @@ jobs: if-no-files-found: ignore test-playtest: - needs: scope + needs: [scope, build-artifacts] if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 30 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -573,6 +619,9 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" - uses: ./.github/actions/playwright-chromium - run: pnpm test:playtest:ci @@ -586,7 +635,10 @@ jobs: template: - starter - platformer - runs-on: ubuntu-latest + # Hosted on purpose: ~25 short template legs run all at once on hosted runners while builds and + # tests hold the local pool. On 5 local slots these legs waited 17-25 min for a runner (run + # 37070815769); sharing the board across both pools is what shortens wall time. + runs-on: ubuntu-24.04 # The scenario arithmetic this number came from moved out to `template-nonvisual`, which now # owns the non-visual sweep for all eight templates. What is left is `verify:golden-path` — # pack, scaffold, install, dev, test, build, assert, once per matrix leg — which measured 309s @@ -716,10 +768,25 @@ jobs: # 33734775193 `minimal` finished in 80s and `platformer` took 407s, and the run is only ever as # fast as its slowest leg. Timestamping platformer's step splits it as ~74s of scaffold install # and typecheck plus 15 scenarios at ~20s each, so the scenarios are what makes it long and - # they shard cleanly. Platformer and starter run in thirds. Puzzle was measured at 399s of - # scenario work on run 33843502361 (carry 134s, swing 255s), so it runs in two halves as well; - # every other template is already well under the leg it would be sharded against, and splitting - # them would buy nothing and pay the 74s setup twice. + # they shard cleanly. Every slice then has to absorb its own ~65s of leg overhead — 36-51s of + # checkout, install and Playwright before the step, 10-42s of target install, typecheck and + # display setup inside it — so the number of shards is set by the slowest slice plus that + # overhead, not by the total. + # + # Per-scenario seconds on run 37049488719 — sailing again on 37071464562, which measured its + # two slices at 168s and 467s — and the slowest slice each count predicts: + # platformer 15 at ~20s -> 3 -> ~150s measured. starter 24 -> 3 -> ~210s measured. + # puzzle 10, 399s of work -> 2 -> ~197s measured. runner/snow/rts/racing/tower-defense/ + # action-rpg/minimal keep one leg each; all measured under 280s. + # shooter 26 at 32-47s, 922s total -> 4 -> 363s, 5 -> 356s, 6 -> 288s. Six is the count that + # clears six minutes, and 65s of that is the leg overhead. + # rain 5 at 331/202/222/78/61 -> 5 -> 396s. Five is its floor: five scenarios, so one per + # slice, and its heaviest scenario alone is 331s of software rendering. It cannot reach six + # minutes at any shard count and is left honest rather than split past its scenario count. + # sailing 6 at 55/247/30/98/28/59 -> 2 -> 467s measured, 3 -> 335s. The second slice is + # the change: at two it carried the 247s float-after-the-run-ends alone with the sails and + # win scenarios for 404s of scenario work, 63s over the leg budget. Three puts one of the + # heavy scenarios on each leg and the worst of them at ~275s of work plus ~60s of overhead. # # Every scenario still runs. `shard` selects a slice of the same classifier output the whole # lane has always used, and the step fails closed if the slices do not add back up to the list. @@ -737,15 +804,30 @@ jobs: - { template: puzzle, shard: "1/2" } - { template: puzzle, shard: "2/2" } - { template: racing, shard: "1/1" } + - { template: rain, shard: "1/5" } + - { template: rain, shard: "2/5" } + - { template: rain, shard: "3/5" } + - { template: rain, shard: "4/5" } + - { template: rain, shard: "5/5" } - { template: rts, shard: "1/1" } - { template: runner, shard: "1/1" } - - { template: sailing, shard: "1/1" } - - { template: shooter, shard: "1/1" } + - { template: sailing, shard: "1/3" } + - { template: sailing, shard: "2/3" } + - { template: sailing, shard: "3/3" } + - { template: shooter, shard: "1/6" } + - { template: shooter, shard: "2/6" } + - { template: shooter, shard: "3/6" } + - { template: shooter, shard: "4/6" } + - { template: shooter, shard: "5/6" } + - { template: shooter, shard: "6/6" } + - { template: snow, shard: "1/1" } - { template: starter, shard: "1/3" } - { template: starter, shard: "2/3" } - { template: starter, shard: "3/3" } - { template: tower-defense, shard: "1/1" } - runs-on: ubuntu-latest + # Balanced like the rest: the pool advertises itself only while a runner is idle, so these legs + # take local slots when free and overflow to hosted otherwise, never queueing behind the pool. + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 30 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -844,7 +926,7 @@ jobs: # as cancelled, so the skip is correct there; it still cannot be mistaken for a pass, because a # cancelled run has no passing checks to merge on. if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -861,9 +943,9 @@ jobs: test "$result" = "success" benchmark: - needs: scope + needs: [scope, build-artifacts] if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 25 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -879,6 +961,9 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" pack-archives: "false" - run: pnpm tsx scripts/count-loc.ts --check @@ -914,7 +999,7 @@ jobs: build: needs: [scope, build-artifacts] if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 2 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -935,8 +1020,8 @@ jobs: # the protected build join above requires both this producer and the complete native lane. build-artifacts: needs: scope - if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 20 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -980,7 +1065,7 @@ jobs: performance-contracts: needs: scope if: ${{ needs.scope.outputs.selection == 'full' && !cancelled() }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 2 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -1044,9 +1129,9 @@ jobs: if-no-files-found: warn budgets: - needs: scope - if: ${{ needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + needs: [scope, build-artifacts] + if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 15 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -1069,6 +1154,9 @@ jobs: # reads like a guarantee it does not make. The shared action is what actually supplies dist. - uses: ./.github/actions/workspace-dist with: + # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is + # ordered behind it above, so the upload always exists and the cache key never misses. + shared-artifact: workspace-packages save-bundles: "false" pack-archives: "false" - run: pnpm budgets @@ -1099,8 +1187,9 @@ jobs: supply-chain: needs: scope # Skipped for Markdown-only PRs: the nightly develop run and promotions still scan the full - # git history, so an inert prose PR does not spend a runner here. - if: needs.scope.outputs.selection != 'prose' + # git history, so an inert prose PR does not spend a runner here. Skipped for a reused tree for + # the same reason every other gate is: the run that tested this exact tree already scanned it. + if: needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' # PR-only until 2026-09-01 (owner call): a push to main skipped the whole job, so the board # read a skip where the secrets scan should have been. Pushes now scan the repository's full # git history — gitleaks' default git mode — which is strictly stronger than the incremental @@ -1168,8 +1257,13 @@ jobs: ci-required: needs: [scope, native-platforms, typecheck, lint, test, test-unit, test-native, test-browser, test-playtest, golden-path-template, template-nonvisual, golden-path, benchmark, build, build-artifacts, budgets, performance-contracts, supply-chain] if: ${{ always() && !github.event.pull_request.draft }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 + # A reused verdict is only a pass if the run it cites passed its own ci-required job, and that + # is an Actions API read rather than anything this repository can compute. + permissions: + contents: read + actions: read steps: - uses: actions/checkout@v7 with: @@ -1183,6 +1277,7 @@ jobs: TN_CI_HEAD_REF: ${{ github.head_ref }} TN_CI_BASE_SHA: ${{ github.event.pull_request.base.sha }} TN_CI_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GH_TOKEN: ${{ github.token }} run: node scripts/ci-required.mjs # PRD-296 item 3. Three gates once sat behind a permanently red one and were skipped on every @@ -1221,8 +1316,8 @@ jobs: - ci-required # `!cancelled()` and not `always()`, for the reason golden-path gives above: a cancelled run # has nothing to report and its summary would only describe the cancellation. - if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-latest + if: ${{ !cancelled() && (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci') }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/integration-csg.yml b/.github/workflows/integration-csg.yml deleted file mode 100644 index 9237afd587..0000000000 --- a/.github/workflows/integration-csg.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Integration csg -on: - pull_request: - paths: - - 'examples/integrations/csg/**' - - '.github/workflows/integration-csg.yml' - push: - paths: - - 'examples/integrations/csg/**' - - '.github/workflows/integration-csg.yml' -permissions: - contents: read -concurrency: - group: integration-csg-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: true -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: examples/integrations/csg - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '22' - - run: npm install --ignore-scripts --no-audit --no-fund - - id: build - run: npm run build - - run: npm run test:contracts - - if: ${{ !cancelled() && steps.build.outcome == 'success' }} - run: npm run test:integration diff --git a/.github/workflows/integration-ik.yml b/.github/workflows/integration-ik.yml deleted file mode 100644 index 39f70a8622..0000000000 --- a/.github/workflows/integration-ik.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Integration ik -on: - pull_request: - paths: - - 'examples/integrations/ik/**' - - '.github/workflows/integration-ik.yml' -permissions: - contents: read -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: examples/integrations/ik - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '22' - - run: npm ci --ignore-scripts --no-audit --no-fund - - run: npm test diff --git a/.github/workflows/integration-vegetation.yml b/.github/workflows/integration-vegetation.yml deleted file mode 100644 index e1b2b9a31e..0000000000 --- a/.github/workflows/integration-vegetation.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Integration vegetation -on: - pull_request: - paths: - - 'examples/integrations/vegetation/**' - - '.github/workflows/integration-vegetation.yml' -permissions: - contents: read -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: examples/integrations/vegetation - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '22' - - run: npm ci --ignore-scripts --no-audit --no-fund - - run: npm test diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000000..51e898d1e7 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,246 @@ +name: Integration lanes + +# One workflow, one job per feature. A per-feature workflow file means a per-feature set of +# triggers, permissions, concurrency groups and review surface, none of which any of these lanes +# needs of its own: each job below keeps its own paths gate, its own draft guard, its own runner and +# its own artifact names, so what a pull request runs is unchanged. +on: + pull_request: + # `ready_for_review` is what makes the draft guards below safe: without it the workflow never + # fires on the event that un-skips a job, and the lanes go quiet instead of cheap. + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +# `pull_request` alone, never `pull_request` plus `push`: one commit pushed to a branch reaches both +# triggers, so the pair ran the identical proof twice for the same tree. A per-branch `push` filter is +# the other shape that outlives its pull request — `integration-decals` carried +# `fix/vq11-decal-material-lifetime` for a merged pull request #394 and kept a 30-minute lane alive +# for as long as the branch existed. +# +# The supersession `integration-csg` and `integration-decals` declared, kept once for the set: a newer +# commit on the pull request's head cancels the integration run it replaces. +concurrency: + group: integration-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true + +jobs: + # The per-lane trigger filters these five files used to carry, as one gate: the pull request's own + # changed paths decide which lanes run, and a lane this commit does not touch is skipped rather than + # spending a runner. Editing this workflow keeps every lane's gate true, which is what + # `.github/workflows/integration-.yml` did for each of them. + paths: + name: lanes this pull request touches + if: ${{ !github.event.pull_request.draft }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + csg: ${{ steps.filter.outputs.csg }} + decals: ${{ steps.filter.outputs.decals }} + ik: ${{ steps.filter.outputs.ik }} + tone: ${{ steps.filter.outputs.tone }} + vegetation: ${{ steps.filter.outputs.vegetation }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + # One API call and a regex per lane, translated from each lane's former `paths:` globs. + run: | + set -euo pipefail + files="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename')" + lane() { if grep -qE "$2" <<<"$files"; then echo "$1=true"; else echo "$1=false"; fi >> "$GITHUB_OUTPUT"; } + lane csg '^examples/integrations/csg/.*$|^\.github/workflows/integration\.yml$' + lane decals '^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^\.github/workflows/integration\.yml$' + lane ik '^examples/integrations/ik/.*$|^\.github/workflows/integration\.yml$' + lane tone '^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^\.github/workflows/integration\.yml$' + lane vegetation '^examples/integrations/vegetation/.*$|^\.github/workflows/integration\.yml$' + + csg: + name: csg + if: ${{ needs.paths.outputs.csg == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 10 + defaults: + run: + working-directory: examples/integrations/csg + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm install --ignore-scripts --no-audit --no-fund + - id: build + run: npm run build + - run: npm run test:contracts + - if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: npm run test:integration + + ik: + name: ik + if: ${{ needs.paths.outputs.ik == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 10 + defaults: + run: + working-directory: examples/integrations/ik + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test + + vegetation: + name: vegetation + if: ${{ needs.paths.outputs.vegetation == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 10 + defaults: + run: + working-directory: examples/integrations/vegetation + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test + + decals: + name: decals + if: ${{ needs.paths.outputs.decals == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/workspace-dist + with: + pack-archives: 'false' + save-bundles: 'false' + - uses: ./.github/actions/playwright-chromium + - run: pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts packages/create-threenative/__tests__/template-runtime-cost.spec.ts + - name: Capture actual receiver projection, motion, removal and restart + run: node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts + - uses: actions/upload-artifact@v4 + if: always() + with: + name: vq11-decals-${{ github.event.pull_request.head.sha || github.sha }} + path: artifacts/vq11-decals + if-no-files-found: error + + decals-native: + name: Linux native decal correctness + needs: [decals] + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + env: + CMAKE_BUILD_PARALLEL_LEVEL: '2' + TN_PLAYTEST_ALLOW_SOFTWARE: '1' + TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: pnpm install --frozen-lockfile + - name: Build this source's public JavaScript packages + uses: ./.github/actions/workspace-dist + with: + save-bundles: 'false' + pack-archives: 'false' + - name: Verify native preflight and capture rejection controls + run: pnpm exec vitest run packages/create-threenative/__tests__/shooter-decals-native.spec.ts --maxWorkers=1 + - name: Restore checksum-provisioned native dependencies + uses: actions/cache/restore@v4 + with: + path: packages/runtime-native/third_party + key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }} + - name: Install the existing native lane's system prerequisites + run: >- + sudo apt-get update && sudo apt-get install -y + libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev + libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev + libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev + libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr + libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - name: Build the ordinary ARM desktop host + run: pnpm --filter @threenative/runtime-native native:build + - name: Run the same packaged decal scenarios and unchanged pixel gates + env: + THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral + run: node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts + - name: Retain native screenshots, host console and executable identities + if: always() + uses: actions/upload-artifact@v7 + with: + name: vq11-decals-native-${{ github.event.pull_request.head.sha || github.sha }} + path: | + artifacts/vq11-decals-native/ + !artifacts/vq11-decals-native/variants/** + retention-days: 30 + if-no-files-found: warn + + tone: + name: exposure + if: ${{ needs.paths.outputs.tone == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/playwright-chromium + - name: Build this commit's standalone bridge and runner + run: pnpm --filter @threenative/playtest build + - name: Validate histogram and fail-closed assertion paths + run: pnpm exec vitest run --maxWorkers=1 packages/playtest/__tests__/capture.spec.ts packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts scripts/__tests__/tone-capture-proof.spec.ts + - name: Render underexposed and restored scene through the real runner + run: pnpm test:tone + - name: Retain runtime screenshots and exact adapter evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: tone-exposure-${{ github.event.pull_request.head.sha }} + path: artifacts/tone-exposure + if-no-files-found: error diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index a9ad933fe1..3fad8e2b40 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -53,7 +53,10 @@ jobs: # The caller is the single selection authority. Dispatch has no exemption inputs and is full. scope: name: Validate caller selection and candidate - runs-on: ubuntu-latest + # PRD-480. `github.event` inside a `workflow_call` is the caller's event payload, so this + # expression reads the same `pull_request` fork flag ci.yml did when it called this file: a fork + # PR falls through to hosted runners and never reaches the owner's machine. + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 outputs: scope: ${{ steps.classify.outputs.scope }} @@ -61,6 +64,10 @@ jobs: reason: ${{ steps.classify.outputs.reason }} plan: ${{ steps.classify.outputs.plan }} candidate_sha: ${{ steps.classify.outputs.candidate_sha }} + # PRD-380 phase 2: `full` for a main push, the nightly, an explicit audit, a merge group or pull + # request into main; `reduced` for an ordinary pull request, which runs the Linux rows only. + native_tier: ${{ steps.classify.outputs.native_tier }} + starter_rows: ${{ steps.rows.outputs.starter_rows }} steps: - uses: actions/checkout@v7 with: @@ -78,6 +85,34 @@ jobs: fi grep -Fx "candidate_sha=$candidate" "$RUNNER_TEMP/ci-scope.outputs" cat "$RUNNER_TEMP/ci-scope.outputs" >> "$GITHUB_OUTPUT" + # A job's `if` cannot read `matrix` (GitHub's context table lists github, needs, vars and + # inputs there), so a row an ordinary pull request does not owe is dropped by shaping the + # matrix instead. x64 and arm64 run the identical release-container proof; the arm64 leg is + # GitHub's free hosted `ubuntu-24.04-arm` runner, because there is no V8 or Dawn prebuilt for + # Linux arm64 (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64) and a + # source V8 build is not allowed in CI — so that host is built with QuickJS over wgpu-native, + # the one WebGPU backend publishing an arm64 Linux archive, which scripts/download-deps.mjs + # and scripts/native-build.mjs select from `process.arch`. A reduced run keeps x64 alone, and + # x64 routes through the same TN_RUNNER switch every other movable Linux job uses (PRD-480). + - id: rows + env: + TN_NATIVE_TIER: ${{ steps.classify.outputs.native_tier }} + run: | + set -euo pipefail + node -e ' + const rows = [ + { platform: "linux-x64", runner: "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}" }, + { platform: "linux-arm64", runner: "ubuntu-24.04-arm" }, + ]; + // Reduced only when the tier says so: an unreadable tier keeps every row. + const reduced = process.env.TN_NATIVE_TIER === "reduced"; + // A keyed object, never a bare array. GitHub expands `strategy.matrix` from an axis + // vector or from `include`, and run 37100382876 wrote the array: the rows were set, the + // step passed, and `starter-linux` never started — no job, no error — while the reusable + // workflow still concluded failure, so `ci-required` read a red native lane with every + // leg green. `{"include":[…]}` is the same two rows in the shape GitHub expands. + process.stdout.write(`starter_rows=${JSON.stringify({ include: reduced ? rows.slice(0, 1) : rows })}\n`); + ' >> "$GITHUB_OUTPUT" web-reference: needs: scope @@ -89,7 +124,7 @@ jobs: if: >- needs.scope.outputs.selection == 'full' && inputs.ios_only == false - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 30 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -136,7 +171,7 @@ jobs: name: Android V8 source payload needs: scope if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} # 180-minute build ceiling plus 30 minutes for actions/cache/save, per the action's note. timeout-minutes: 210 outputs: @@ -186,6 +221,9 @@ jobs: needs.android-v8-source.outputs.published == 'false' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + # PRD-480 leaves this one on GitHub's machines: it is the release write itself — `contents: + # write` and a `gh release upload` below — and no pool container is handed a token that can + # publish. It is named on the hosted allow-list in ci-structure.spec.ts. runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -213,7 +251,7 @@ jobs: name: Native collector evidence coverage needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator] if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 5 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -248,7 +286,7 @@ jobs: name: Networking qualification matrix needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator] if: ${{ always() && needs.scope.outputs.selection == 'full' }} - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -316,6 +354,8 @@ jobs: needs.scope.outputs.selection == 'full' && inputs.ios_only != true && needs.android-v8-source.result == 'success' + # Hosted, measured: SwiftShader renders the emulator's GPU on the CPU, and on a 4-thread tn-local + # slot run 37082733117 spent 41 min of this job's 45 still running APKs (hosted finishes in ~30). runs-on: ubuntu-24.04 timeout-minutes: 45 env: @@ -447,8 +487,14 @@ jobs: run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm + # `|| true` on both udev calls, because the routed `tn-local` container cannot do what + # they do: no udev is running in it and its /sys is read-only, so `udevadm trigger` + # fails there for a reason that has nothing to do with acceleration. The container does + # not need them — `scripts/ci-runners.sh` passes `--device /dev/kvm` and the image's + # entrypoint sets its mode before the job starts — and a step that fails on the way to + # the report below costs this lane the whole emulator run. + sudo udevadm control --reload-rules || true + sudo udevadm trigger --name-match=kvm || true # Report the mode, never assert it. `test -w /dev/kvm` as the last line of this step — # copied from native-release.yml, where nothing has exercised it — turned "this runner # has no KVM" into a failed job, which is worse than the slow boot it was meant to fix. @@ -740,7 +786,7 @@ jobs: if: >- needs.scope.outputs.selection != 'prose' && inputs.ios_only == false - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 20 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -812,7 +858,7 @@ jobs: if: >- needs.scope.outputs.selection == 'full' && inputs.ios_only != true - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} timeout-minutes: 75 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} @@ -1003,9 +1049,11 @@ jobs: name: ${{ matrix.platform }} desktop core needs: scope # Runs on every event since 2026-09-01 (owner call) — see android-emulator-parity. The only - # remaining gate is the manual `ios_only` dispatch toggle, which exists to run the iOS lane - # alone; it is not a skip of the default flow. - if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true + # remaining gates are the manual `ios_only` dispatch toggle, which exists to run the iOS lane + # alone, and the tier: macOS and Windows are not Linux rows, so an ordinary pull request's + # reduced matrix (PRD-380 phase 2) skips them rather than holding two hosted runners per push. + # No label exempts them, and main, the nightly and an audit keep the full tier. + if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true && needs.scope.outputs.native_tier == 'full' strategy: fail-fast: false matrix: @@ -1242,8 +1290,12 @@ jobs: # The release container is a ZIP on macOS and Windows and desktop-distribution.mjs shells # out to `zip -r -q`. A missing archiver must be a red job, never a silent pass. if [ "${{ matrix.platform }}" = "Windows" ]; then - choco install zip -y --no-progress - choco install nsis --version=3.11.0 -y --no-progress + # The community feed drops requests now and then ("package was not found" for a listed + # version, run 37079046851). Retry twice; makensis -VERSION below still fails closed. + for attempt in 1 2 3; do + choco install zip -y --no-progress && choco install nsis --version=3.11.0 -y --no-progress && break + [ "$attempt" = 3 ] || sleep 30 + done nsis='/c/Program Files (x86)/NSIS' "$nsis/makensis.exe" -VERSION cygpath -w "$nsis" >> "$GITHUB_PATH" @@ -1757,18 +1809,17 @@ jobs: if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true strategy: fail-fast: false - matrix: - include: - # x64 and arm64 run the identical release-container proof. The arm64 leg is GitHub's - # free hosted `ubuntu-24.04-arm` runner: there is no V8 or Dawn prebuilt for Linux arm64 - # (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64) and a source V8 - # build is not allowed in CI, so the arm64 host is built with QuickJS over wgpu-native — - # the one WebGPU backend that publishes an arm64 Linux archive. scripts/download-deps.mjs - # and scripts/native-build.mjs select that route from `process.arch`. - - platform: linux-x64 - runner: ubuntu-24.04 - - platform: linux-arm64 - runner: ubuntu-24.04-arm + # The arm64 row is not a row an ordinary pull request owes: it is the free hosted + # `ubuntu-24.04-arm`, kept for main, the nightly and an explicit audit so the arm64 host keeps + # proving itself on the branch that ships it. `scope` shapes the rows from the tier it + # resolved, so this job runs linux-x64 alone on a reduced run and both rows otherwise. The + # routing values live beside the shaping, on the runner PRD-480 fixed: the movable x64 leg + # goes through the TN_RUNNER switch and falls back to the same hosted image, arm64 stays + # hosted because the owner's machine is x64. That shaping is never empty — a run owing no + # native lane at all is skipped at the call site in ci.yml, so the tiers that reach this job + # always owe x64 — and a leg the tier does drop is skipped by the `desktop` and + # `ios-simulator` `if:` gates, which GitHub reports as skipped, not failed. + matrix: ${{ fromJSON(needs.scope.outputs.starter_rows) }} runs-on: ${{ matrix.runner }} timeout-minutes: 50 env: @@ -2002,7 +2053,10 @@ jobs: ios-simulator: name: iOS simulator runtime and no-Xcode consumer handoff needs: scope - if: needs.scope.outputs.selection == 'full' + # A macOS-15 runner and an Apple toolchain are the opposite of a Linux row, so an ordinary pull + # request's reduced matrix (PRD-380 phase 2) skips this leg. It still runs on main, the nightly, + # an explicit audit and any merge group or pull request into main. + if: needs.scope.outputs.selection == 'full' && needs.scope.outputs.native_tier == 'full' # Runs on every event since 2026-09-01 (owner call) — see android-emulator-parity. runs-on: macos-15 timeout-minutes: 60 diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 71893d361d..f7b630adcd 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -15,11 +15,14 @@ on: tags: - "runtime-native-v*" pull_request: - paths: - - ".github/workflows/native-release.yml" - - "scripts/__tests__/native-release-proof.spec.ts" - - "scripts/__tests__/native-release-android-staging.spec.ts" - - "packages/runtime-native/tests/native-platform-workflow.test.mjs" + # The proof is the most expensive board in this repository (macOS, Windows, Linux and a + # 210-minute Android producer) and an ordinary PR push never needed it: over 2026-09-18 to + # 10-02 it cost 7.0k runner-minutes against a pool that holds about three runs. It runs for + # a promotion PR into main, or when a PR carries the `release-proof` label - `labeled` is + # what makes that route reachable, and the open/reopen events are what make a promotion PR + # reach it. The `paths` filter is gone: it was the thing that re-ran the board on every push + # to four files, one of them touched by ordinary runtime-native PRs. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: permissions: @@ -27,24 +30,24 @@ permissions: actions: read concurrency: - # Measured over this workflow's entire history, re-counted 2026-09-11: 41 runs, 18 cancelled, - # 17 failed, 4 succeeded. The four successes are PRD-078's non-publishing hosted proof, and they - # arrived only after this setting stopped evicting them - the earlier count behind this choice was - # 18 runs with zero successes. Every cancellation was a proof run killed by the next push to its - # own branch. `clean-consumer` sits behind a ~20 minute build matrix, so on an actively-pushed - # branch it cannot reach its own first line no matter what its timeout is - which is why this sits - # alongside the timeout raise rather than instead of it. Evidence here is candidate-keyed, so a - # superseded run's output is still valid for the SHA it was produced from and is worth letting - # finish. - # `github.event_name` is in the group because a manual proof on main and an automatic one would - # otherwise share `native-release-refs/heads/main` and evict each other. - # `github.event.workflow_run.head_sha` is in the group because every `workflow_run` run shares - # `github.ref` (the default branch) and `github.event_name` (`workflow_run`): without the head - # SHA they all serialize behind each other in one group while the Actions queue drains. A new CI - # completion for a newer main SHA gets its own group instead of queueing behind a superseded - # evidence run; a re-run for the same SHA still shares its group. Tag publication stays - # per-ref as before. - group: native-release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha }}-${{ github.ref }} + # One group for every run of this workflow, whatever triggered it. It used to carry + # `github.event_name`, the triggering CI head SHA and `github.ref`, so a promotion PR, a manual + # dispatch, a tag push and each main CI completion each held a runner simultaneously - and a + # two-minute `build` join waited over an hour behind them (PRD-380). + # + # What that keying bought: a new CI completion for a newer main SHA got its own group instead of + # queueing behind a superseded evidence run. That is now the intended trade - the queue behind a + # superseded proof is a few minutes of one runner, where running them beside each other was the + # whole pool. + # + # `cancel-in-progress: false` is unchanged and load-bearing: every cancellation in this + # workflow's history was a proof run killed by the next push to its own branch, and a + # candidate-keyed result is still valid for the SHA it was produced from. + # + # Only a run that will prove joins the shared group. Every ordinary PR push still starts this + # workflow (its `gates` job then refuses it), and in one shared group those no-op runs would queue + # behind a real proof and replace a pending one, since a group keeps a single pending run. + group: ${{ (github.event_name != 'pull_request' || github.event.pull_request.base.ref == 'main' || contains(github.event.pull_request.labels.*.name, 'release-proof')) && 'native-release-proof' || format('native-release-skip-{0}', github.run_id) }} cancel-in-progress: false jobs: @@ -117,8 +120,15 @@ jobs: gates: needs: validate-tag # Scheduled/manual CI also completes on main, but only push CI qualifies a release. + # And the pull_request trigger fires on every push now (the label needs `labeled`), so this + # job is where an ordinary PR is refused: PRD-373 retired the `promotion/` ref, so a PR + # targeting main is the promotion, and `release-proof` is the owner's manual route for any + # other PR. Every proof job below needs `gates`, so refusing here spends nothing. if: >- ${{ !cancelled() && + (github.event_name != 'pull_request' || + github.event.pull_request.base.ref == 'main' || + contains(github.event.pull_request.labels.*.name, 'release-proof')) && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'push') && (github.ref_type != 'tag' || needs.validate-tag.result == 'success') }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/pipeline-cache.yml b/.github/workflows/pipeline-cache.yml index f35b4da29b..e143871462 100644 --- a/.github/workflows/pipeline-cache.yml +++ b/.github/workflows/pipeline-cache.yml @@ -9,8 +9,12 @@ on: - packages/runtime-native/** - packages/playtest/** - patches/wgpu-native@25.0.2.2.patch + # No `feat/prd-368-persistent-pipeline-cache`: the lane it was opened for landed on 2026-09-10 and + # the branch only kept a 31-minute android-arm64 ABI job alive on every push nobody was reading + # (2.4k runner-minutes over 41 runs, PRD-481). A per-branch trigger outlives its pull request by + # default; `scripts/__tests__/ci-structure.spec.ts` now fails closed on the next one. push: - branches: [main, feat/prd-368-persistent-pipeline-cache] + branches: [main] paths: - .github/workflows/pipeline-cache.yml - packages/runtime-native/** diff --git a/AGENTS.md b/AGENTS.md index b78c59b2e0..abac05e411 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro - Branch from `origin/develop` (not a local branch that may be ahead of it), target `develop`, squash-merge. Set `git config threenative.integrationBranch develop` per checkout. - The commit that finishes a PRD also `git mv`s it to `docs/PRDs/done/`. - `main` takes only the ordinary full-checked `develop -> main` PR, merged with a merge commit, never squash or rebase. +- **Push once per run**: while your PR's CI runs, commit locally; push to fix that run or after it finishes. A PRD tick rides with the next code push, never alone. +- **No workflow file per feature**: add its CI proof as a job in an existing workflow. - Before retargeting, inventory `gh pr list` and `pnpm worktree:status`; retarget in-flight PRs one at a time and never rewrite another worktree. ## Where a change goes @@ -75,7 +77,7 @@ pnpm native:build # opt-in; downloads deps, compiles th pnpm native:verify:desktop # 300 native frames + a non-blank screenshot ``` -**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; everything else, plus main PRs, main pushes and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. A new commit on a PR head re-runs its checks. Never cache test verdicts. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. +**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; a change touching only CI configuration (`.github/workflows/*.yml`, `scripts/ci-*`, their specs, `tools/ci-runners/`) runs the `ci` selection — `typecheck`, `lint`, `budgets`, `supply-chain` and the unit shards; everything else, plus main PRs, main pushes, the merge queue and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. An ordinary pull request runs that lane's Linux rows only — macOS, Windows, iOS and arm64 are the full matrix, kept for `main` pushes, the nightly, an audit and anything targeting `main` — so a PR's own evidence never carries those legs. A new commit on a PR head re-runs its checks. Reuse a verdict only for an identical whole-repo tree that CI itself passed. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file. TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed. @@ -87,7 +89,7 @@ TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions c Results go in the existing PRD, PR or response; a separate evidence file only when the user asks or a workflow consumes it. Prose edits need only the doc checks and mirror regeneration — no implementation checkpoints or artificial negative controls. **Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour. -**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. +**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree. ## Working outside the repo diff --git a/CLAUDE.md b/CLAUDE.md index a210fa8869..dada8c70f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro - Branch from `origin/develop` (not a local branch that may be ahead of it), target `develop`, squash-merge. Set `git config threenative.integrationBranch develop` per checkout. - The commit that finishes a PRD also `git mv`s it to `docs/PRDs/done/`. - `main` takes only the ordinary full-checked `develop -> main` PR, merged with a merge commit, never squash or rebase. +- **Push once per run**: while your PR's CI runs, commit locally; push to fix that run or after it finishes. A PRD tick rides with the next code push, never alone. +- **No workflow file per feature**: add its CI proof as a job in an existing workflow. - Before retargeting, inventory `gh pr list` and `pnpm worktree:status`; retarget in-flight PRs one at a time and never rewrite another worktree. ## Where a change goes @@ -77,7 +79,7 @@ pnpm native:build # opt-in; downloads deps, compiles th pnpm native:verify:desktop # 300 native frames + a non-blank screenshot ``` -**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; everything else, plus main PRs, main pushes and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. A new commit on a PR head re-runs its checks. Never cache test verdicts. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. +**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; a change touching only CI configuration (`.github/workflows/*.yml`, `scripts/ci-*`, their specs, `tools/ci-runners/`) runs the `ci` selection — `typecheck`, `lint`, `budgets`, `supply-chain` and the unit shards; everything else, plus main PRs, main pushes, the merge queue and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. An ordinary pull request runs that lane's Linux rows only — macOS, Windows, iOS and arm64 are the full matrix, kept for `main` pushes, the nightly, an audit and anything targeting `main` — so a PR's own evidence never carries those legs. A new commit on a PR head re-runs its checks. Reuse a verdict only for an identical whole-repo tree that CI itself passed. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file. TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed. @@ -89,7 +91,7 @@ TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions c Results go in the existing PRD, PR or response; a separate evidence file only when the user asks or a workflow consumes it. Prose edits need only the doc checks and mirror regeneration — no implementation checkpoints or artificial negative controls. **Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour. -**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. +**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree. ## Working outside the repo diff --git a/README.md b/README.md index 1dfd725431..020658274a 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,9 @@ lifecycle. | `tower-defense` | Four tower types, three upgrade levels, twelve waves and an orbital strike on a polished diorama | | `racing` | Three laps, checklines, rescue sectors, boost pads | | `sailing` | Wind, waves, buoyancy, and an ordered course | +| `rain` | A procedural coastal storm, weather controls, and a free-fly camera | | `shooter` | Hitscan and projectile weapons, hunting targets, timed pickups | +| `snow` | Deformable snow footprints, a ball that carves its own track, blizzard weather | Details in [`create-threenative`](packages/create-threenative/README.md). diff --git a/biome.json b/biome.json index 4309380656..f13b6b48f4 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,6 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignore": [ "**/dist/**", @@ -16,6 +17,7 @@ ".worktrees/**", ".claude/worktrees/**", ".opencode/**", + ".leanpi/**", ".linchpin/**", ".tnshots/**", ".mcp.json", diff --git a/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md b/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md index 60a87235b0..77d9e903f3 100644 --- a/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md +++ b/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md @@ -6,7 +6,7 @@ prd_contract: v1 **Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch: [docs/PRDs/AAA-visuals](./README.md). Judged with -[PRD-341](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md), which is the only way to +[PRD-341](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md), which is the only way to tell whether this landed. Source studied: [TheLongSilence](https://github.com/achimala/TheLongSilence) `src/gfx/PostFX.js`, the `LUM_FRAG` / `REDUCE_FRAG` / `ADAPT_FRAG` chain. diff --git a/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md b/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md deleted file mode 100644 index b4f8fdd707..0000000000 --- a/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -prd_contract: v1 ---- - -# PRD-341 — a frame's tone is a number, and the number is a gate - -**Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch: -[docs/PRDs/AAA-visuals](./README.md). **Land this first** — it is what makes every other PRD in the -batch judgeable, and it is the cheapest thing here. Source studied: -[TheLongSilence](https://github.com/achimala/TheLongSilence) `tools/levels.mjs`, `tools/judgeset.mjs`. - -**Goal: "it looks flat" stops being an opinion.** The reference states the rule plainly and this -repository already believes it — *playtest is the eyes, not screenshots* — but the eyes currently -report two numbers where five are needed. - -**Complexity:** a histogram over pixels already decoded, a new assertion kind, a CLI report = -**LOW**. An afternoon, if `assert.tone` reuses the existing capture path. - -## The problem, measured at `43d03e6a` - -### 1. The capture path measures enough to detect a blank frame and nothing more - -`packages/playtest/src/capture.ts` computes `visiblePixels`, `maxLuminance`, `brightPixels`, a mean -and a standard deviation, and spends all of it on `CAPTURE_GUARD_LIMITS` — is this frame blank, is -it suspiciously dark. Those are liveness checks. None of them can tell a correctly exposed frame -from one rendered three stops under, because a three-stop-under frame has plenty of variance and -plenty of visible pixels. It just looks wrong. - -### 2. Every look PRD in this repository currently ends in an argument - -The batch's own history is the evidence: the recorded method for calibrating a render against a -reference is *quantiles plus a one-build ablation*, written down after eyeballing picked the wrong -cause four times. That method exists, it works, and it is not available as an assertion — so it gets -re-hand-rolled per investigation and never becomes a gate. - -The reference names the exact shape that ends the argument: **mean, p1, p50, p99, clip%, black%**, -per frame, with an average row. `0.00% of pixels clip and the 99th percentile is 165` is a sentence -someone can act on, and it is what the reference measured immediately before fixing its highlight -range. - -### 3. Fail closed, or it measures nothing - -The repository's own rule: an empty assertion set is a failure, a missing observation is a failure. -A tone assertion whose capture never happened must go red, not skip — this is the exact shape of the -v1 harness bug that reported green on scenarios asserting nothing. - -## What ships - -### `packages/playtest` — `assert.tone` - -A new assertion kind over a captured frame: - -```jsonc -{ - "assert": "tone", - "atStep": "landed", - "mean": { "min": 60, "max": 140 }, // 0-255, display-referred, after tonemap - "p99": { "min": 150 }, // the frame has highlights - "p1": { "max": 40 }, // and it has shadows - "clipFraction": { "max": 0.005 }, // 0.5% of pixels at display white - "blackFraction": { "max": 0.25 } -} -``` - -Every bound is optional; **an assertion with no bound at all throws at schema validation** rather -than passing vacuously. Percentiles are computed from a 256-bin luminance histogram over the decoded -frame — the reference decodes to a reduced size on the grounds that the statistics are unchanged and -a full-res decode of twenty frames is pointlessly slow, and the same applies here. - -### `packages/playtest` — `tone` in the CLI - -`node packages/playtest/dist/runner/cli.js tone ` prints the reference's table verbatim in -shape: one row per shot, an average row, no browser needed. This is the tool an agent runs on a -directory of captures without writing a scenario, and it is what makes a set judgeable at once. - -### Reporting - -`TN_TONE` per captured frame, in the observation record, so a run that captured frames and asserted -nothing still leaves the numbers behind for the next round. - -## What does not ship - -- No verdict. This measures; it does not decide that a frame is good. Pixel metrics never replace - semantic image review — the sculpt server's rule holds. -- No colour statistics, no per-channel histograms, no saturation. Luminance only in v1. -- No baseline comparison. `pnpm visuals` / `visuals:ab` already own A/B against a baseline; this - bounds a frame on its own terms, which is what a scenario with no baseline needs. - -## Acceptance criteria - -1. **A misexposed frame is red.** The example fixture renders a scene with `toneMappingExposure` - pinned two stops under; a `tone` assertion bounding `mean` and `p99` fails, and the failure text - names both the measured and the required value. - *Red-green:* the mutation **is** the pinned exposure — restore it and the same assertion passes. - Paste both. -2. **An empty bound set throws.** A `tone` assertion with no bounds fails schema validation with a - named error before the run starts. - *Red-green:* soften the schema to allow it; `assertion-schema.spec.ts` goes red. -3. **A missing capture fails, it does not skip.** A `tone` assertion at a step that produced no - frame reports a failure naming the step. - *Red-green:* return `undefined` from the evaluator when the capture is absent; the fail-closed - spec goes red. -4. **The CLI table matches the assertion.** `cli.js tone` on a frame and `assert.tone` on the same - frame report the same six numbers, to the printed precision. - *Red-green:* change the histogram bin count in one path only; the cross-check spec goes red. -5. **It works where captures work.** The delete-test lane rule applies — this needs a GPU lane, so - the scenario lives on `pnpm test:templates`, not on CI's frame-less template runner. - -## Out of scope - -Baseline diffing, perceptual metrics, and any automatic judgement of composition. diff --git a/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md b/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md index 68c3673fd3..4ee8e4f494 100644 --- a/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md +++ b/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md @@ -6,7 +6,7 @@ prd_contract: v1 **Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch: [docs/PRDs/AAA-visuals](./README.md). Judged with -[PRD-341](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md). Source studied: +[PRD-341](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md). Source studied: [TheLongSilence](https://github.com/achimala/TheLongSilence) `src/world/Fleet.js:57-140`, the `BEACON_VERT` / `BEACON_FRAG` pair and the `BEACON_HDR` note. diff --git a/docs/PRDs/AAA-visuals/README.md b/docs/PRDs/AAA-visuals/README.md index ce80012039..654f07b572 100644 --- a/docs/PRDs/AAA-visuals/README.md +++ b/docs/PRDs/AAA-visuals/README.md @@ -15,7 +15,7 @@ rather than arguable. | # | PRD | Layer | Cost | Why it is here | | --- | --- | --- | --- | --- | -| 1 | [341 — a frame's tone is a number, and the number is a gate](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | `playtest` | LOW | `assert.tone`: mean, p1/p50/p99, clip%, black%. "It looks flat" stops being an opinion. | +| 1 | [341 — a frame's tone is a number, and the number is a gate](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | `playtest` | LOW | `assert.tone`: mean, p1/p50/p99, clip%, black%. "It looks flat" stops being an opinion. | | 2 | [339 — the frame sets its own exposure](./PRD-339-the-frame-sets-its-own-exposure.md) | `core` + templates | MEDIUM | There is no auto exposure in the repository. Largest single look gap. | | 3 | [342 — where the frame goes: pass-cost ablation](./PRD-342-where-the-frame-goes-pass-cost-ablation.md) | `playtest` | LOW-MED | Every `quality.ts` tier is currently an unmeasured guess. | | 4 | [340 — evaluate a scatter once per instance](./PRD-340-evaluate-a-scatter-once-per-instance.md) | `core` | MED-HIGH | Density is most of the AAA exterior, and the naive form is vertex-bound where the resolution scaler cannot reach it. | diff --git a/docs/PRDs/CI/EXECUTION-ORDER.md b/docs/PRDs/CI/EXECUTION-ORDER.md new file mode 100644 index 0000000000..86763a3831 --- /dev/null +++ b/docs/PRDs/CI/EXECUTION-ORDER.md @@ -0,0 +1,84 @@ +# CI PRDs — execution order + +Updated 2026-10-02. Goal: CI fast and reliable, with as much of the work as possible on the owner's +machine. + +Baseline, measured over 2026-09-18 to 10-02: 115.5k runner-min, about 58k a week. On run 37043413533, +jobs spent 239 min waiting for a runner against 146 min of work. The full audit is in +[PRD-481](PRD-481-ci-does-each-piece-of-work-once.md). + +```mermaid +flowchart TD + A["PRD-482 phase 1
pre-push hook green, under 15 s"] --> P["push the local develop backlog"] + T(["owner: fork-PR approval"]) --> B["PRD-480
Linux CI on tn-local runners"] + P --> B + P --> C["PRD-481 phase 2
each run does work once"] + M(["owner: merge queue on develop"]) --> D["PRD-481 phase 1
tree reuse + merge queue"] + C --> D + B --> E["PRD-482 phase 2
push rule, lean instructions"] + B --> F["PRD-380
release proof and native matrix off PRs, janitor"] + P --> G["PRD-481 phase 3
triggers fire only when they prove something"] +``` + +## Order + +| # | Work | State (2026-10-03) | Blocked on | +|---|---|---|---| +| 1 | [PRD-482](../tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md) phase 1 | Done in #403: hook green, 9 s | — | +| 2 | [PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md) phases 1–2 | Done in #404: AC-1 green, run 37070815769 (33 jobs on `tn-local`, 5 on the light lane) | — | +| 2 | PRD-481 phases 2–3 | Merged (#405): dist built once, shards under 6 min, one `integration.yml` | native cache box: `develop` must warm the new key | +| 3 | PRD-481 phase 1 | Merged (#405): reuse only for an identical tree whose validation profile covers this run; merge queue on | live reuse and merge-group proofs | +| 3 | [PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) phases 1 and 3 | #406 in the merge queue | phase 2 (reduced PR native matrix) not started | +| 3 | PRD-480 phase 3 | In #404: Linux native legs routed; the Android emulator stays hosted (CPU-bound SwiftShader overran its 45 min) | a green `native-platforms` run | +| 4 | PRD-482 phase 2 | Done in #403: push rule, ponytail once per session, playtest AGENTS.md 1,113 words | — | +| 5 | Flaky template lanes | NEW. `tower-defense` (SwiftShader `DEVICE_LOST`, timeouts) and `rain` (`CAPTURE_PROVENANCE_MISSING`) fail about half the time on hosted and local. They have bounced the merge queue three times. Needs a fix in the template or the playtest bridge, not a quarantine | an owner for the lane | + +Each PRD's 7-day runner-minute acceptance (PRD-380, 481, 482 AC-1) can only be measured a week after its phases land. +PRD-480 AC-2 (summed queue wait < 60 min) is measured and not met: 317 min, with 42 min wall against 39 hosted. +It needs an owner call: more slots, or overflow part of the board to hosted runners. + +Rows with the same number can run as parallel lanes. Before parallel lanes edit `ci.yml`, they agree on +which jobs each one touches. + +## Owner actions + +- ~~**Runner token**~~ — done 2026-10-02; it lives in the operator's untracked runner env file, never + in the repository. +- ~~**Fork-PR approval**~~ — done 2026-10-02: `all_external_contributors`, applied with the owner's approval. +- **Merge queue:** the owner approved enabling it on `develop` (2026-10-02). It waits until `ci.yml`'s + `merge_group` trigger is on `develop`, since the ruleset requires `ci-required` and a queue whose + check never reports stalls every merge. Start with a build concurrency of 2 and raise it only on measured + queue time. + +## Working this file as a goal + +- **Done when:** PRD-380, PRD-480, PRD-481 and PRD-482 are all in `docs/PRDs/done/`, each with its + acceptance boxes ticked from real runs. +- **Pick work:** take the lowest-numbered row whose blocker is clear. When a row is blocked on an owner + action, record that under the PRD's `## Blocked on`, move on to the next independent row, and ask the + owner once. +- **Rules:** one draft PR per PRD, branched from `origin/develop` (root `AGENTS.md`). Tick a box only on + a green proof. Update this table's sizes and blockers as rows land. + +## Review by Astra (2026-10-02), adopted + +- **Same tree is not same validation.** A reduced PR matrix (PRD-380 phase 2) passing on tree T must not + satisfy a promotion PR on T, whose full native matrix never ran. PRD-481's reuse now needs an identical + tree *and* a source run whose validation profile covers the current one: the full board, every + currently required check and matrix leg passed, the same target tier and the same runner profile. + Tests prove that a reduced pass cannot satisfy a promotion, and that a profile change refuses reuse. +- **Light lane.** `scope`, `ci-required` and the summaries get a reserved runner (PRD-480), so a join never + queues behind a 20-minute build. +- **Build once before reusing verdicts.** PRD-481 phase 2 (artifact sharing, cache warming, shards) runs + ahead of phase 1. That was already the order. +- **Not now:** narrower affected-check selection (a follow-up PRD, once the planner's exemptions are + proven one at a time), a remote compiler cache, more machines, Nx/Bazel/ARC. Measure first. + +## Triage of the older CI PRDs (2026-10-02) + +| PRD | Verdict | Action taken | +|---|---|---| +| PRD-303 CI closes its regression doors | Done by other work. All five doors are closed on `develop`, though no box was ever ticked | Moved to [`done/PRD-303.md`](../done/PRD-303.md) with a superseded note; its cache-key item went to PRD-481 phase 2 | +| PRD-364 remove gate bloat | Junk. A stale draft copy that a merge (`e8ee4770f`) brought back; the real one is COMPLETE in [`done/`](../done/PRD-364-remove-development-gate-bloat.md) (PRs #129 and #131) | Deleted the `CI/` copy and repointed its one inbound link | +| PRD-379 native release follows CI completion | Finished. Its last phase was observed on `main`: CI 36134527157 → native-release 36140281930 and 36142531873, `gates` 39 s | Ticked with that evidence and moved to [`done/`](../done/PRD-379-native-release-follows-ci-completion.md) | +| PRD-380 a PR never starves the runner pool | Still valid: 12.0k runner-min per 14 days | Reshaped to current PRD rules (3 phases, a proof on every box); runs after PRD-480 | diff --git a/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md b/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md deleted file mode 100644 index 15bad66e1e..0000000000 --- a/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -prd_contract: v1 ---- - -# PRD-364 — Remove repeated verification and bookkeeping from the development loop - -**Status:** PROPOSED. Planning only; no implementation authorized by this document. -**Date:** 2026-09-06 (America/Vancouver). **Inspected checkout:** `713ca111`. -**Layer:** repository tooling and contributor policy, not engine or game behavior. -**Complexity: 5 → MEDIUM mode:** more than ten implementation files (+3), multi-package verification (+2). - -A developer should be able to change prose or fix one defect without rebuilding the framework repeatedly or repairing unrelated evidence accounting before pushing. - -## Decision - -Remove redundant executions and arbitrary accounting blockers first. Then exempt a deliberately narrow class of prose-only PRs from compilation and game matrices. Preserve complete verification for every executable change, main, nightly and releases. No new package, dependency graph framework, performance budget, dashboard, or persistent verification cache. - -This proposes a policy change to the blanket local gates in root AGENTS.md and the every-PR policy documented in CI. Those rules remain binding until their implementation phase edits them. It does not silently supersede PRD-303: its full template and platform coverage remains for executable changes, main and nightly. Native parity already has label conditions; preserve those independently. - -## Integration ledger - -Rows describe planned changes, not completed integration. Line references point to the inspected baseline; implementation records replacement caller lines and observed controls. - -| ID | Changed/new thing | Existing live caller | Replaces | Removal phase | Negative control | -|---|---|---|---|---|---| -| L1 | Evidence report without file/line/duplicate-count rejection | package.json `budgets` → scripts/check-evidence-budget.ts:289 | Small-file and narrative-length blocking thresholds | 1 | Large bytes and tracked generated agent instructions still exit nonzero | -| L2 | Census drift becomes advisory; single manifest freshness call | scripts/check-budgets.ts:462; package.json `budgets` | Census tolerance failure and repeated manifest command | 2 | Stale capability manifest still fails the live budgets command | -| L3 | Bounded pre-push checks | githooks/pre-push → scripts/ci-fast.sh:20 | Whole-workspace typecheck and budget chain on every push | 3 | Deliberate lint error still blocks the hook | -| L4 | Reuse the current invocation's completed build locally | scripts/ci-local.sh:25 → scripts/run-test-suite.sh | Extra test-phase build and build-backed package test scripts | 4 | Remove required dist output and prebuilt execution fails | -| L5 | NEW scripts/ci-change-scope.mjs, internal CLI only | .github/workflows/ci.yml `scope` output → expensive jobs | Unconditional heavyweight jobs for prose-only PRs | 5 | Mixed prose/core change selects full verification | -| L6 | Same narrow scope for native workflow | .github/workflows/native-platforms.yml event → jobs | Native builds for prose-only PRs | 6 | Shared TypeScript or unknown path selects existing native behavior | - -## Findings and evidence - -### 1. The expensive part is broad execution, not just the budgets job - -Read-only `gh run list --workflow ci.yml --limit 8 --json databaseId,conclusion,createdAt,updatedAt,headSha,event` returned eight PR runs. Seven succeeded, one failed. Created-to-updated elapsed time ranges from **6m36s to 12m06s**, median **8m08s** across the eight; these timestamps include orchestration and are not pure execution time. This small sample is not a percentile estimate. - -Latest green [run 34068539162](https://github.com/ThreeNativeHQ/threenative/actions/runs/34068539162), head `7b0f0ea7ed1b242c028f7f422df6f9999e070dd9`, was created at 2026-09-07T00:02:19Z and updated at 00:09:48Z (7m29s). `gh run view 34068539162 --json jobs` returned 32 jobs: - -| Observed job | Started → completed, UTC | Elapsed | -|---|---|---| -| build | 00:02:23 → 00:03:00 | 37s | -| budgets | 00:03:03 → 00:04:00 | 57s | -| test-native | 00:02:23 → 00:06:41 | 4m18s | -| template-nonvisual (puzzle, 2/2) | 00:05:27 → 00:09:18 | 3m51s | -| run-summary | 00:09:21 → 00:09:47 | 26s | - -The late template start is observed; account concurrency as its cause is an inference, not proven by the job API. Removing budgets alone cannot eliminate this critical path. Failed run `34068103200` failed `test-unit (2/3)`; its cause was not investigated and is not classified as bloat. - -### 2. The hook repeats mandatory work regardless of changed paths - -`githooks/pre-push` invokes `ci-fast.sh`, which serially runs lint, docs, whole-workspace typecheck, the entire budgets chain, agent sync checking and selected scaffold/drift tests. Root instructions independently require typecheck/lint/test before completion. A push repeats checks without knowing whether source changed. Keep the inexpensive drift protection; remove heavyweight mandatory hook execution. Developers retain explicit complete commands, and CI remains authoritative. - -### 3. Accounting has become a product-change dependency - -`scripts/check-evidence-budget.ts` rejects evidence tree file counts, Markdown length over 1,000 lines and duplicate bytes, alongside useful total-byte limits and detection of generated sweep instructions. `docs/PRDs/AGENTS.md` requires a separate justification commit to raise caps and owner approval for deletion. This can turn adding proof into consolidation work. Retain byte limits (72 MiB verification, 200 MiB benchmark) and generated-instruction protection. Remove file-count, per-file-line and duplicate-byte thresholds as blockers; retain measurements and duplicate inspection tools. Do not delete any evidence as part of this PRD. - -`check-budgets.ts:462–478` also fails when the dated native LOC census drifts by more than five lines. Make numeric drift advisory; retain malformed/missing required record checks and native coverage. Framework/native LOC triggers already warn: removing those would not unblock CI. - -Historical corroboration, not a current failure claim: [PRD-330](../done/PRD-330-documentation-footprint-reduction.md) records a stale retention-index budget failure and an unrelated pre-push budget failure during documentation cleanup. Retention integrity stays enforced in this proposal; it needs separate consumer analysis before removal. - -### 4. Build and manifest work is repeated through live callers - -`ci-local.sh` builds packages before `pnpm test`; `run-test-suite.sh` defaults to `docs,build,package-test,unit`, so it builds again. Standalone package tests for assets, ueformat, raw-unreal and site rebuild; the runner already has `TN_SUITE_PREBUILT=1` and pure-check replacements. Reuse those within one invocation, with a complete root build first. Never trust an arbitrary old dist tree. - -`package.json` runs `capabilities:check` in budgets, and `enforceBudgets` calls `capabilityManifestErrors` again. Remove the duplicate root-chain invocation, preserve the standalone command and the enforcement call. Workspace caching, native build caching, unit sharding and template scenario sharding already exist; do not reimplement them. - -### 5. Workflow topology is overbroad for prose, but broad runtime coverage is intentional - -Both workflows trigger for all PRs to main. Native desktop/starter/iOS lanes remain broad; web-reference and parity legs already have PR-label conditions. A core or playtest edit can break native, so a native-directory-only filter is rejected. Initial exemption is only Markdown under `docs/PRDs/` and `docs/verification/`, with explicit exclusions below. All other changes run today's board. This targets planning/evidence prose without pretending ordinary README/template instructions are inert. - -## Scope and success criteria - -| Change class | Local completion / PR behavior after implementation | -|---|---| -| Strict prose-only class | Docs links, agent mirrors, evidence integrity, relevant docs/evidence contract tests; no build, typecheck, native compilation or game matrix | -| Any executable, config, generated contract, unknown path or uncertain diff | Complete current verification; full local gate before completion | -| main, nightly, release, manual full invocation | Complete existing board; no prose exemption | - -Prose classification uses the merge-base diff, including both names of renames and deleted files. Exclude all AGENTS.md/CLAUDE.md, generated retention indices, native census/coverage records and any Markdown read by executable fixtures, parsers or gate inputs. Enumerate those consumers before enabling exemption; reuse evidence-citations.ts knowledge where appropriate, but directory citation alone does not prove semantic harmlessness. Unknown consumer classification falls back to full. Empty diff, missing merge base, Git error and malformed inputs select full or fail; never claim an empty run passed. - -Acceptance targets are one-time evaluation criteria, **not new CI timing gates**: reduce prose PR job count by at least 75% versus the 32-job sample; demonstrate zero compiler/browser/native invocations on a qualifying PR; eliminate the second root build from a full local run; prove above-line/file evidence no longer blocks while oversized evidence still fails. Measure elapsed times on three comparable before/after runs, reporting cache state and runner differences. Do not promise runtime PR speedups from prose scoping. - -No database, API, user-facing UI or data migration. Internal CLI/workflow output is the user interface: print the selected scope, reason, commands run and checks explicitly not applicable. - -## Architecture and sequence - -```mermaid -flowchart TD - E[PR event and complete Git diff] --> S[Existing workflow invokes narrow scope CLI] - S -->|Strict prose class| D[Docs and evidence checks] - S -->|Mixed, unknown or full event| F[Existing full board] - D --> A[Existing summary verifies selected checks] - F --> A - D -->|Failure| R[Named red check] - F -->|Failure| R -``` - -```mermaid -sequenceDiagram - participant Dev as Developer - participant Hook as Pre-push hook - participant CI as Existing workflows - participant Scope as Scope CLI - Dev->>Hook: Push - Hook->>Hook: Bounded lint/docs/drift checks - alt Check fails - Hook-->>Dev: Exact failing check and log - else Checks pass - Hook->>CI: Existing PR event - CI->>Scope: Event and merge-base diff - Scope-->>CI: Prose or full with reason - CI->>CI: Execute selected gates - CI-->>Dev: Results; unexpected skips fail - end -``` - -## Implementation phases - -Every phase is independently revertible and has an automated checkpoint. No phase may add more than five implementation files; this PRD's checklist/evidence updates are tracked separately. Run controls in an isolated in-repository worktree; never mutate a collaborator's tree. After each phase, use an independent prd-work-reviewer (or equivalent) to inspect the diff, run the targeted checks and audit live invocation. Only PASS releases the next phase. This is a future implementation requirement, not a claim that implementation has occurred. - -### Phase 1 — Adding proof does not require file-count or line-count repairs - -**Files:** EDIT scripts/check-evidence-budget.ts (keep hard integrity/bytes, report other measurements), scripts/__tests__/evidence-budget.spec.ts (behavior controls), docs/PRDs/AGENTS.md (remove retired threshold policy), docs/PRDs/CLAUDE.md (generated mirror). - -L1: remove file and duplicate threshold rejection, line-cap enforcement and its exception inventory. Keep exported measurement helpers only where real consumers require them; remove tests solely pinning retired constants. Keep total-byte errors, Git/read failures, generated sweep instruction rejection and canonical duplicate tooling. Existing `pnpm budgets` is the caller; no new command. - -Tests: `should accept long evidence when total bytes remain within the cap`; `should accept many small files when total bytes remain within the cap`; `should reject evidence when tracked bytes exceed the cap`; `should reject generated sweep instructions when tracked`. Drive the existing CLI against real scratch Git repositories, not just mocked findings. - -Red/revert: current code rejects long/many-file fixtures; restoring threshold rejection must reproduce that failure after the change. Oversized and generated-instruction fixtures must still fail after it. User action: stage a qualifying long evidence record and run the evidence checker; see exit 0 with factual measurements. - -Verify: `pnpm exec vitest run scripts/__tests__/evidence-budget.spec.ts`; `pnpm sync:agents`; `pnpm sync:agents --check`; shared full verification below. No evidence deletion and no byte-cap increase. - -### Phase 2 — A native edit does not require a LOC census refresh - -**Files:** EDIT scripts/check-budgets.ts, scripts/__tests__/budgets.spec.ts, package.json. - -L2: remove nativeCensusDrift from enforceBudgets errors; retain the existing CLI advisory report. Keep nativeCensusErrors and native coverage hard failures. Remove only the redundant `pnpm capabilities:check` entry from the root budgets chain; keep its standalone script and capabilityManifestErrors in enforceBudgets. - -Tests: `should report census drift without failure when native source line counts change`; `should reject stale capabilities when budgets run`; `should check the capability manifest once when the root budget chain runs`. Record CLI execution/call counts with distinct fixtures rather than comparing strings alone. - -Red/revert: current numeric drift fixture fails; restoring enforcement fails it again. Corrupt a public capability entry and observe budgets fail after removing the duplicate command. User action: run `pnpm budgets` after a harmless source-line change; see drift advisory rather than failure solely from line counts. - -Verify: `pnpm exec vitest run scripts/__tests__/budgets.spec.ts`; `pnpm budgets`; `pnpm capabilities:check`; shared full verification. - -### Phase 3 — A push stops repeating whole-workspace validation - -**Files:** EDIT scripts/ci-fast.sh, githooks/pre-push, AGENTS.md, CLAUDE.md (generated); NEW scripts/__tests__/ci-fast.spec.ts. - -L3: remove typecheck and budgets from the mandatory fast runner; retain lint, docs, agent mirrors and current selected drift tests. Keep truthful hook help and explicit full verification instructions. Root policy permits the narrow prose completion lane defined above only after phases 5–6 prove it; until then full completion gates still apply. State that passing the hook alone never proves runtime correctness. No successful-result cache or skip-by-HEAD marker. - -Tests: `should run bounded checks when the pre-push hook executes`; `should fail the hook when lint fails`; `should not invoke build typecheck or budgets when fast checks run`. Invoke the real hook with a PATH command recorder and failure injection; also execute `pnpm ci:fast` normally. - -Red/revert: old runner records typecheck/budgets calls; reintroducing either fails the bounded-execution assertion. User action: `pnpm ci:fast`; see the smaller board and time per check. - -Verify: `pnpm exec vitest run scripts/__tests__/ci-fast.spec.ts`; `pnpm sync:agents`; `pnpm ci:fast`; shared full verification. - -### Phase 4 — Full local verification builds once per invocation - -**Files:** EDIT scripts/ci-local.sh, scripts/run-test-suite.sh only if existing prebuilt support needs adjustment; NEW scripts/__tests__/ci-local.spec.ts. - -L4: make full ci:local start with `pnpm build` (includes generated files, examples and site), then run the suite with `TN_SUITE_PREBUILT=1 TN_SUITE_PHASES=docs,package-test,unit`. Preserve the explicit core boundary gate. Build failure prevents dependent test execution and produces nonzero exit. `ci:local test` retains standalone building behavior; never select prebuilt solely because a dist folder exists. Unknown local job selection must fail rather than exit 0 without work. - -Tests: `should build once when the full local board runs`; `should build prerequisites when only test is selected`; `should fail when the prerequisite build fails`; `should reject prebuilt execution when required outputs are missing`. Use a command trace of the real shell runner, then drive the real full local board. - -Red/revert: original full board records both its own build and the suite build. Remove a required output after the completed build in an isolated negative run; test must fail loudly. User action: `pnpm ci:local`; see one preparation build and complete package checks, including site and native checks. - -Verify: `pnpm exec vitest run scripts/__tests__/ci-local.spec.ts`; `pnpm ci:local`; shared full verification. If browser/golden-path steps internally rebuild, record separately; removing those is outside this bounded slice. - -### Phase 5 — A planning-only PR skips the game board safely - -**Files:** NEW scripts/ci-change-scope.mjs; EDIT .github/workflows/ci.yml, scripts/__tests__/ci-structure.spec.ts, scripts/ci-workflow.ts, scripts/__tests__/ci-needs.spec.ts. - -L5: add a small native-Node scope job (no workspace install), expose its decision as job output and wire the existing expensive jobs to it. Extend the existing graph guard to recognize that exact scheduling prerequisite; do not generally permit coverage jobs to gate other coverage jobs. Run prose docs/evidence assertions in an existing job, with explicit Vitest file selection from the inspected contract consumers. Validate that these commands do not build or launch browsers before calling the lane lightweight. Reuse existing summary/golden-path aggregation and teach their inline decisions to accept only deliberate not-applicable results, never unexpected skipped/failed/cancelled selected jobs. Preserve check names; inspect actual branch protection requirements before changing aggregation behavior. - -Tests in existing CI suites: `should choose full when shared core changes`; `should choose full when a consumed Markdown record changes`; `should choose full when diff discovery is incomplete`; `should choose prose when only inert PRD text changes`; `should reject unexpected skipped jobs when full scope is selected`. Invoke the actual scope CLI on scratch Git histories, including renames, deletions and mixed changes. Collect the production repository's Markdown reader census before freezing the conservative exclusions. - -Red/revert: baseline workflow runs build for a real PRD-only diff. Removing scope conditions restores that behavior and fails the workflow contract. Replacing a valid prose file with a core change must select full. User action: open/update an implementation test PR with only this PRD text changed; see docs results, explicit scope reasons and zero expensive jobs executed. Hosted proof must record job API results, not just YAML tests. Publishing that test PR is a separate implementation action, not part of this drafting task. - -Verify: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`; `pnpm check:docs`; `gh run view --json jobs`; repeat on mixed/core and main/full events. Shared full verification. If required-check settings or a semantic Markdown consumer cannot be verified, keep full selection; phase remains incomplete. - -### Phase 6 — Native scheduling shares the proven prose exemption - -**Files:** EDIT .github/workflows/native-platforms.yml, scripts/__tests__/ci-structure.spec.ts, docs/PRDs/CI/PRD-303.md (explicit reconciliation of scope policy). - -L6: invoke the same scope CLI and apply its output to currently broad native jobs, composed with existing label/input conditions. Do not widen or remove parity label conditions. Preserve workflow_call, workflow_dispatch (including ios_only), main and scheduled behavior. No separate native-only source classifier. Update PRD-303 to point at this later policy decision without marking its unfinished coverage work complete. - -Tests: `should preserve full native scheduling when shared TypeScript changes`; `should omit native compilation when a proven prose-only PR changes`; `should preserve caller and manual input behavior when invoked outside a PR`. Assert all current platform legs and both parity conditions remain represented. - -Red/revert: baseline starts broad native jobs on qualifying prose PRs; restoring unconditional scheduling fails the exemption contract. Core/config/unknown-path mutations select full. User action: inspect the same hosted prose PR across both workflows; no native compilation executes. Observe a full-scope run retaining current platform execution/label semantics. - -Verify: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`; `gh run view --json jobs`; shared full verification and independent review. Timing comparison is manual inspection of actual run data, not a new pass/fail service. - -## Verification evidence and completion - -For every implementation phase run `pnpm typecheck && pnpm lint && pnpm test`, plus its targeted commands and `pnpm budgets`. Run real runtime/native scenarios when verification tooling changes affect them. Paste exit codes, selected test names/counts, raw command output, live-caller census and negative-control output in phase evidence. Existing gate status/doctor commands remain available; no new evidence framework. Before calling a gate PASS, observe its relevant mutation fail and then restore and rerun it. - -| Phase | Required evidence | Current result | -|---|---|---| -| 1–2 | Real budget CLI red/green, retained hard failures, manifest call count | NOT RUN — proposed | -| 3 | Hook command trace, injected failure, actual fast run | NOT RUN — proposed | -| 4 | Full local command trace, missing-output failure, actual full board | NOT RUN — proposed | -| 5–6 | Scratch Git classifier controls, hosted prose/full job lists, timing comparisons | NOT RUN — proposed | -| All | Full prescribed gates and independent phase review | NOT RUN — proposed | - -Drafting evidence: read-only source inspection and GitHub run queries above were executed. No performance improvement, current budget failure, branch-protection configuration or future gate PASS is claimed. Source-based repetition findings are not timing measurements. Draft validation is recorded in [the drafting verification record](../../verification/prd-364-draft-audit-2026-09-06.md). - -## Acceptance checklist - -- [ ] Evidence file/line/duplicate-count and census drift cannot block a change; byte/integrity/capability/native coverage failures still do. -- [ ] Pre-push stops invoking full typecheck/budgets; full local verification prepares its root build once and standalone commands still work. -- [ ] Proven prose PRs run at least 75% fewer CI jobs with zero compile/browser/native calls; executable/unknown changes retain complete applicable coverage. -- [ ] Every phase has observed controls, full verification, live wiring and independent review; timing observations identify cache/runner conditions. -- [ ] Existing policy and PRD-303 agree with the implemented behavior; no evidence deletion, new timing gate, new dependency or competing verification framework was introduced. - -First implementation action: reproduce Phase 1's long-file and many-small-files failures in scratch Git fixtures. Estimate: 10 minutes to establish the initial red; overall implementation estimate 3–5 working days, dominated by hosted scheduling proof rather than script edits. diff --git a/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md b/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md index 71d9c42800..2694308dc7 100644 --- a/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md +++ b/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md @@ -4,70 +4,97 @@ prd_contract: v1 # PRD-380 — A pull request never starves the runner pool -**Status:** NOT STARTED +**Status:** PARTIAL — phases 1 and 2 complete; phase 3's janitor is in and its proof is a live merged PR **Complexity:** 6 → MEDIUM-HIGH (+1 workflow triggers, +1 reduced PR matrix, +1 scheduled janitor, +1 shared release concurrency, +1 guarded specs, +1 npm-release wiring). **Owner:** CI release tooling. **Problem:** The org's GitHub-hosted runner concurrency is small (roughly three runs at once) and it is consumed by matrices that a pull request does not need. On 2026-09-12 thirteen CI runs sat queued, five of them `main` pushes, while three heavy runs held the pool; a two-minute `build` join waited over an hour. Three multipliers: `native-release.yml` runs its macOS/Windows/Linux proof on **every** PR that touches one of four paths (including `native-platform-workflow.test.mjs`, which ordinary runtime-native PRs touch), `ci.yml` runs the **full** `native-platforms` matrix for any `selection == 'full'` PR, and nothing cancels a run whose PR has already merged, so dead runs keep holding runners. The owner's ask: pushing to a branch must not restart or stall the whole board. Baseline: `develop`. [PRD-373](../done/PRD-373-selective-ci-and-develop-promotion.md) owns the selective-CI classifier and the develop→main promotion rule; this PRD narrows what the selection is allowed to spend a runner on. PRD-379 changes *how* `native-release` learns about CI completion; this PRD changes *what triggers* it. -### Phase 1 — The release proof stops running on every PR push - -**Progress:** - -- [ ] Callers wired and building: `.github/workflows/native-release.yml`, `.github/workflows/npm-release.yml`, `scripts/__tests__/ci-structure.spec.ts`, `scripts/__tests__/native-release-proof.spec.ts` -- [ ] Required test green: the release proof's PR trigger is label-gated (e.g. `release-proof`) or `workflow_dispatch`-only, and a `develop`→`main` promotion PR still runs it; a `v*` npm release still triggers the native release -- [ ] Observed red recorded, then restored green -- [ ] User verification performed on the named platform -- [ ] Evidence record written -- [ ] Independent reviewer returned PASS - -### Phase 2 — PRs run a reduced native matrix; the full matrix stays on main and nightly - -**Progress:** +## Decisions + +- 2026-10-02 (João, via the CI audit): reshaped to the current PRD rules (proof on every box, no ceremony + boxes, at most 3 phases). The old phase 4, one concurrency group for release proofs, folds into phase 1 + because it edits the same workflow. The janitor is a `pull_request: closed` trigger instead of a + scheduled script. Linux native rows move to the owner's machine under + [PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md), so phase 2 is mainly about the hosted + macOS, Windows and iOS legs. Measured 2026-09-18 to 10-02: native-release PR runs cost 7.0k + runner-min, and runs still going after their PR merged or closed cost 5.0k. +- 2026-10-02 (phase 1): a promotion PR is `develop -> main`. PRD-373 retired the `promotion/` + ref on 2026-09-12, so the proof's PR route keys on `github.event.pull_request.base.ref == 'main'` + (which also covers the `hotfix/` emergency path, that branch's own full qualification) rather than + on a branch prefix. The one shared concurrency group reverses the `head_sha`-in-the-group choice + above on purpose: a superseded proof now queues instead of running beside a live one. -- [ ] Callers wired and building: `.github/workflows/ci.yml`, `.github/workflows/native-platforms.yml`, `scripts/__tests__/ci-efficiency.spec.ts` -- [ ] Required test green: a PR selection emits the Linux-only native rows; a `main` push or nightly emits the full matrix, and the full set cannot be label-exempted away -- [ ] Observed red recorded, then restored green -- [ ] User verification performed on the named platform -- [ ] Evidence record written -- [ ] Independent reviewer returned PASS - -### Phase 3 — Runs for merged or closed PRs are cancelled automatically - -**Progress:** - -- [ ] Callers wired and building: new `.github/workflows/ci-janitor.yml` + `scripts/ci-janitor.ts`, registered in `scripts/__tests__/ci-structure.spec.ts` -- [ ] Required test green: the janitor cancels queued/in-progress runs for a merged or closed PR head and leaves open-PR and `main` runs alone -- [ ] Observed red recorded, then restored green -- [ ] User verification performed on the named platform -- [ ] Evidence record written -- [ ] Independent reviewer returned PASS +### Phase 1 — The release proof stops running on every PR push -### Phase 4 — Release-proof workflows share one repo-wide concurrency group +**Status:** COMPLETE -**Progress:** +**Files:** `.github/workflows/native-release.yml`, `.github/workflows/npm-release.yml`, +`.github/workflows/release-candidate.yml`, `scripts/__tests__/native-release-proof.spec.ts`, +`scripts/__tests__/ci-structure.spec.ts`. -- [ ] Callers wired and building: `.github/workflows/native-release.yml`, `.github/workflows/release-candidate.yml`, `scripts/__tests__/ci-structure.spec.ts` -- [ ] Required test green: at most one native release proof matrix runs at a time across branches, and a superseded proof is not evicted mid-run -- [ ] Observed red recorded, then restored green -- [ ] User verification performed on the named platform -- [ ] Evidence record written -- [ ] Independent reviewer returned PASS +- [x] Release proof never runs on an ordinary PR push. proof: `pnpm exec vitest run scripts/__tests__/native-release-proof.spec.ts`, + which also asserts it still runs on a promotion PR, the `release-proof` label, manual dispatch and an npm `v*` release. + Evidence: `native-release-proof.spec.ts` 2026-10-02, 61 passed; 3 new assertions red before the workflow change. + The `pull_request.paths` filter is replaced by `types: [opened, synchronize, reopened, labeled]`, and + `gates` — which every proof job `needs:` — now refuses an ordinary develop PR, so the whole board costs nothing. + npm-release.yml and release-candidate.yml trigger no part of this proof and are unchanged. +- [x] At most one release proof holds runners at a time across branches. proof: `pnpm exec vitest run + scripts/__tests__/ci-structure.spec.ts`, with a case asserting the shared concurrency group. + Evidence: `ci-structure.spec.ts` 2026-10-02, 2 assertions red before the workflow change, 123 passed after. + `group: native-release-proof`, `cancel-in-progress: false` kept. -**Files (maximum five):** +### Phase 2 — PRs run a reduced native matrix; the full matrix stays on main and nightly -- EDIT `.github/workflows/native-release.yml` — PR trigger gate, `v*` release input, shared concurrency group. -- EDIT `.github/workflows/native-platforms.yml` — reduced PR rows, full rows for main/nightly. -- EDIT `.github/workflows/ci.yml` — pass the selection to the native call. -- ADD `.github/workflows/ci-janitor.yml` and `scripts/ci-janitor.ts` — cancel runs for merged/closed PR heads. -- EDIT `scripts/__tests__/ci-efficiency.spec.ts`, `scripts/__tests__/ci-structure.spec.ts` — guards for every change above. +**Status:** COMPLETE + +**Files:** `.github/workflows/ci.yml`, `.github/workflows/native-platforms.yml`, +`scripts/ci-change-scope.mjs`, `scripts/ci-required.mjs`, +`scripts/__tests__/ci-efficiency.spec.ts`, `scripts/__tests__/ci-needs.spec.ts`, +`packages/runtime-native/tests/starter-desktop.test.mjs`. + +- [x] An ordinary PR selection emits only the Linux native rows. proof: `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts`. + Evidence: `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts` + 2026-10-02, 216 passed; 2 new assertions and 4 `ci-needs` cases red before the change, plus + `packages/runtime-native` `native-platform-workflow` + `starter-desktop` 79 passed after it. + The plan carries `nativeTier` (`full` | `reduced` | `none`), decided once in + `scripts/ci-change-scope.mjs` from the change's native reach and its target branch, and + `native-platforms.yml` reads it: `desktop` (macOS, Windows) and `ios-simulator` skip unless it is + `full`, and `starter-linux` runs `linux-x64` alone — the arm64 row is shaped into the matrix by + the `scope` job, because a job's `if` cannot read `matrix`. `web-reference`, `android-v8-source`, + `android-emulator-parity` and `desktop-parity` are Linux rows and still run. +- [x] `main` pushes, the nightly and pull requests into `main` keep the full matrix. proof: `pnpm exec vitest run + scripts/__tests__/ci-efficiency.spec.ts`, which also asserts no label exempts it. + Evidence: same run as above, 2026-10-02, 216 passed; the case fails on the pre-change files. + (This box read `promotion/*` PRs; phase 1 retired that ref, so the promotion route is + `base.ref == 'main'` — see the 2026-10-02 phase-1 decision.) A `main` push, the nightly, an + explicit `workflow_dispatch` and any merge group or pull request into `main` all classify + `nativeTier: full`, no job in `native-platforms.yml` reads a pull-request label, and + `ci-required` records the tier in its verdict — a reduced pass can therefore never stand in for a + full requirement (PRD-481), which `ci-needs.spec.ts` proves in three cases. + +### Phase 3 — A merged or closed PR stops spending runners + +**Status:** IN PROGRESS — janitor landed, live proof pending. + +**Files:** NEW `.github/workflows/ci-janitor.yml` (`pull_request: types: [closed]`, cancels the head +ref's queued and in-progress runs with `gh run cancel`), `scripts/__tests__/ci-structure.spec.ts`. + +- [x] Closing or merging a PR cancels its head ref's in-flight runs within a minute. proof: the janitor run id + and the cancelled run ids on one merged PR. + The workflow and its guards are in: `ci-structure.spec.ts` 2026-10-02, 124 passed (the case was red + with no `ci-janitor.yml`). Box stays open — the proof is a live merged PR, and until one merges the + only executed evidence is a local `gh` stub, which is not the claim. + Evidence: janitor run 37096061684 cancelled run 37095819714 when #410 merged; janitor run 37091826027 + cancelled run 37091514862 when #408 merged (2026-10-03). + +`ci-janitor.yml` is the one workflow here holding `actions: write`, so the existing "no job cancels +its own run" guard now names it as its single exception rather than dropping the ban: the janitor +fires on `closed`, never from a failure step, scopes every cancel to the event's own repository, and +skips its own run id. ## Acceptance criteria -- [ ] A push to an open runtime-native PR starts only the reduced Linux native rows, not the macOS/Windows/Android simulation matrix. -- [ ] The full native matrix still runs on every `main` push and nightly, and a frozen `promotion/*` PR. -- [ ] The native release proof runs on a promotion PR, on a label or manual dispatch, and on an npm `v*` release — never on an ordinary PR push. -- [ ] A merged or closed PR's queued and in-progress runs are cancelled within one janitor interval. -- [ ] Two release proofs on different branches cannot hold runners at the same time. -- [ ] `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/native-release-proof.spec.ts` is green. +- [ ] AC-1 [shared]: proof: the PRD-481 audit script over the 7 days after phase 3 lands. Native-release + PR runs plus post-close runs fall from 12.0k to under 2k runner-min per 14 days. Evidence: pending. diff --git a/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md b/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md new file mode 100644 index 0000000000..58a7fb12a6 --- /dev/null +++ b/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md @@ -0,0 +1,266 @@ +--- +prd_contract: v1 +--- + +# PRD-480 — Linux CI runs on the owner's machine + +**Status:** PARTIAL — phases 1 and 3 verified (#404, #410); AC-1 green; AC-2 measured, needs the owner's call +**Complexity:** 5 (HIGH) +**Owner:** CI tooling +**Depends on:** None + +Complexity: 5 → MEDIUM (about 8 files +2, new system +2, GitHub runner API +1); risk override → HIGH because a +public repository gains a code-execution path onto a private machine. + +## Context + +CI's critical path is the queue, not the work. On 2026-10-02 the runs were: + +| Run | Job work | Waiting for a runner | Longest wait for one job | +|---|---|---|---| +| 37043413533 (PRD-475 PR) | 146 min | 239 min | 19.3 min | +| 37047989282 (`develop` PR) | 266 min | 127 min | 17.6 min | + +`ThreeNativeHQ/threenative` is public and has 0 self-hosted runners. Every job therefore shares +the org's hosted concurrency cap with every other open PR. A full board is about 45 jobs. The branch-local +`integration-*.yml` workflows (decals, exposure, exposure cold boot, …) started 124 runs since +2026-10-01, against 72 `CI` runs, and they draw on the same pool. + +Job setup is already cheap (install 4 s, `workspace-dist` 81 s), so trimming jobs moves little. The +pool size is the lever. [PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) narrows what a +PR spends runners on. This PRD adds runners. + +Files inspected: `.github/workflows/ci.yml` (19 Linux `runs-on`), `.github/workflows/native-platforms.yml` +(Linux jobs plus `macos-15`, `windows-2025`, `ubuntu-24.04-arm`), `.github/workflows/integration-*.yml`, +`.github/actions/playwright-chromium/action.yml` (`--with-deps` runs apt), `scripts/ci-local.sh`, +`scripts/__tests__/ci-structure.spec.ts`. + +## Solution + +Ephemeral GitHub Actions runners run in Ubuntu 24.04 containers on the owner's machine, registered +**to this repository only** with the label `tn-local`. Every Linux job that can move picks its runner +from one expression: + +```yaml +runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} +``` + +- **Kill switch:** delete the `TN_RUNNER` repository variable and every job goes back to hosted + runners. `scripts/ci-runners.sh up` sets `TN_RUNNER` after the runners come online, and `down` clears it + before they stop. After a crash or power loss, recover with `gh variable delete TN_RUNNER`. +- **Fork guard:** a PR from a fork always runs on hosted runners and never reaches the machine. +- **Same verdicts, no caching:** the job still runs inside the workflow, on a fresh checkout of the + candidate SHA, and `ci-required` is unchanged. The rule "Never cache test verdicts" stands. +- **One full run per candidate:** while `TN_RUNNER` is set, agents run only the focused checks for what + they changed (the touched spec, the affected playtest), then push. The full board runs once, on + `tn-local`. Today the agent runs `pnpm test` or `pnpm ci:local --full` here, then CI repeats the same + board, so this removes the duplicate. With `TN_RUNNER` unset, CI is hosted and slow again, and the + local full run before pushing comes back. +- **Comparable timing:** each container is pinned (`--cpuset-cpus`) to two whole cores, four threads, + the CPU shape of a hosted `ubuntu-24.04` runner, so `nproc` reads 4 and every worker pool sizes itself + to that. A `--cpus 4` quota is not enough: `nproc` still reads every host CPU and vitest ran 8 workers + in a 4-core budget. This is a controlled starting point, not a promise of identical timing. +- **Host stays stable:** five slots by default, and two cores are never given to a slot. Each slot gets + `--memory 12g --memory-swap 12g --oom-score-adj 800`, so under memory pressure the kernel kills a CI + job before the owner's processes. +- **Light lane:** `scope`, `ci-required`, `run-summary` and other small joins run on one more, + unpinned `tn-local-light` runner (`--cpus 1`, 2 GB) that heavy jobs never select. Otherwise a 20-minute + build on every heavy slot holds a 10-second join in the queue. +- **Runs itself:** the operator installs `scripts/ci-runners.sh up`/`down` as a boot service (a user + service with lingering on Linux) that first checks out the latest `develop`. So the pool starts with + the machine, takes image fixes on the next start, and clears `TN_RUNNER` on shutdown. `up` and `down` + also delete offline `tn-local*` registrations: an idle runner that is stopped never deregisters. + `pnpm ci:runners:install` does the whole setup; the operator guide is + [tools/ci-runners/README.md](../../../tools/ci-runners/README.md). +- **Ephemeral:** each container takes one job, exits and is recreated by a host-side `docker run --rm` + loop. No `/tmp`, port, Xvfb display or workspace state crosses jobs. + +**Stays hosted:** `native-platforms` macOS, Windows, iOS and `linux-arm64` legs; the `supply-chain` +job (its gitleaks step needs `docker run`, and mounting the Docker socket would give jobs root on the +host); release workflows; `site-docs`. + +```mermaid +flowchart LR + E[PR push / dispatch] --> X{fork PR or
TN_RUNNER unset?} + X -- yes --> H[hosted ubuntu-24.04] + X -- no --> L[tn-local container
4 CPU / 16 GB, ephemeral] + L --> R[ci-required] + H --> R + E --> N[macOS / Windows / iOS / arm64] --> H +``` + +**Image:** `ghcr.io/actions/actions-runner` (Ubuntu 24.04 base with passwordless sudo) plus what the +moving jobs take from the hosted image: build-essential, cmake, ninja, ccache, clang, xvfb, and the +Playwright Chromium system dependencies. Phase 3 adds JDK 17, the Android SDK and emulator, with +`/dev/kvm` passed through. + +**Credential:** ephemeral runners re-register for every job, so the stack mints registration +tokens with an owner-supplied fine-grained token (this repository, "Administration: read & write"). +The token lives in an untracked env file outside the repo and is never printed. + +**Risks:** a timing-shaped test that passes or fails only under the 4-CPU cap. Shared host disk filling +from container layers (see the btrfs and tmpfs notes in the owner's memory). The machine sleeping +while `TN_RUNNER` is set: jobs queue until the switch is cleared. + +## Acceptance Criteria + +- [x] AC-1 [shared]: proof: CI run id plus `runner_name` per job from `gh api …/runs//jobs`. A non-draft + PR's `CI` run executes every Linux job except `supply-chain` on a `tn-local` runner, and `ci-required` + passes. Evidence: pending. + Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): conclusion success, `ci-required` success. 39 jobs succeeded: 33 on `tn-local`, 5 on `tn-local-light`, 1 hosted (`supply-chain`); `native-platforms` skipped by scope. +- [ ] AC-2 [shared]: proof: per-job `started_at − created_at` summed with the baseline's script. Total time + jobs spent waiting for a runner on a full-board PR drops below 60 min (baseline 239 min, run + 37043413533). Measured, not met: run 37070815769 waited 317 min in total across 40 jobs on 5 heavy + + 1 light slots, with 42 min wall against the baseline's 39 min wall and zero hosted minutes. A 40-job board on + six slots queues by construction, so this criterion needs either more slots or overflow to hosted + runners. Owner decision pending. + +## Blocked on + +- Fork PR workflow approval set to "Require approval for all outside collaborators" (the API reads + `first_time_contributors` on 2026-10-02; it must read `all_external_contributors`) — unblocked by João. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Linux CI job runs on the owner's machine | PR push → `ci.yml` job `runs-on` expression → `tn-local` container; fill `file:line` | Hosted `ubuntu-*` stays as the fallback branch of the same expression | AC-1 | +| Runner lifecycle and kill switch | `scripts/ci-runners.sh up/down` → compose stack + `TN_RUNNER` variable | New | Phase 1 | + +## Decisions + +- 2026-10-03 (measured): the pool is overflow capacity, not the default. The repository is public, so + hosted runners cost nothing and run ~20 jobs at once; routing every Linux job to 5 local slots made + boards slower (run 37099132853: 20 min in, 15 jobs queued locally). A balancer advertises + `TN_RUNNER`/`TN_RUNNER_LIGHT` only while a runner is idle (two polls in a row) and withdraws them at once + when none is. + +- 2026-10-02 (João, during the first live runs): use more of the machine, but never at the cost of + desktop stability. Five pinned slots of two cores each, two cores reserved for the host, 12 GB caps + with CI as the OOM victim, and a reserved light lane (review by Astra the same day). + +- 2026-10-02 (João): the runner registration token exists. It is a fine-grained token on + `ThreeNativeHQ/threenative` only, with Administration read & write, stored in the operator's untracked + runner env file. Verified by listing the repository's runners (0). + +- 2026-10-02 (João): self-hosted runners rather than local runs vouching for a commit. A self-hosted + runner gives the same offload with no forgeable status and no stale merge-ref verdict, and it keeps + "Never cache test verdicts". +- 2026-10-02 (João): the cost is accepted. Actions minutes on public repositories stay free on + self-hosted runners; GitHub's announced $0.002/min self-hosted charge is postponed and exempted + public repos. +- 2026-10-02 (João): the runner must also remove the duplicate run, not only the queue. Agents stop + running the full board locally while `TN_RUNNER` is set. Skipping a CI job because an identical tree + already passed it would break "Never cache test verdicts", so it is out of scope; it would be its own PRD. + +## Execution Phases + +#### Phase 1: The runner stack comes up and takes jobs +**Status:** COMPLETE — `pnpm ci:runners:install` runs it as a boot service +**Files:** NEW `tools/ci-runners/Dockerfile`, NEW `tools/ci-runners/entrypoint.sh`, NEW +`scripts/ci-runners.sh` (`up [N]`, `down`, `status`; default N = 4, read from the env file). + +Deviation from the plan below, owner-approved 2026-10-02: **no compose.yml.** A host-side loop of +`docker run --rm` replaces the compose service, because `restart: always` restarts the *same* +container — same filesystem, same hostname — and the ephemeral claim here is a fresh one of both +per job. + +**Implementation:** Ubuntu 24.04 runner image with the tool set above. One loop per slot +(`setsid nohup`), `cpus 4`, `memory 16g`, and ephemeral repo-scoped registration with labels +`tn-local`. `up` waits for N online runners via `gh api repos/{owner}/{repo}/actions/runners` +before `gh variable set TN_RUNNER --body tn-local`. `down` deletes the variable first, then stops +the stack. The script fails closed when the env file or token is missing. + + +- [x] `scripts/ci-runners.sh up` brings 4 runners online with label `tn-local`. proof: + `scripts/ci-runners.sh status` lists 4 online runners. Evidence: 2026-10-02, `up` printed "4 online; + TN_RUNNER=tn-local" and `status` listed tn-23e70acfd553, tn-365ab3636a2e, tn-4d83d32a4c75 and + tn-6071a30e949e, all online. The first `up` timed out with 0 online (wrong WORKDIR), cleared the + variable and stopped the pool, which is the fail-closed path working. Not started: `status` reports + `TN_RUNNER=unset`, 0 containers, 0 online runners on 2026-10-02. +- [x] A runner container recreates itself after its job and keeps no state from it. proof: two + consecutive dispatched jobs report different container hostnames and an empty `/tmp`. + Not started: the image's `/tmp` is empty in a fresh container (`ls -A /tmp | wc -l` → 0), which + is one container's worth of the claim. + Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): 38 `tn-local*` jobs ran on 38 distinct runner names (`tn-`) from 6 slots, so every job got a fresh container; each starts from the image, whose `/tmp` is empty (`ls -A /tmp | wc -l` = 0 in the smoke). + +#### Phase 2: `ci.yml` and the integration workflows route through the switch + +**Status:** COMPLETE +**Files:** EDIT `.github/workflows/ci.yml`, `.github/workflows/integration-*.yml`, +`scripts/__tests__/ci-structure.spec.ts`; the `integration-*.yml` template wherever agents copy it from; +`AGENTS.md` ("Nearest lane first, CI last") plus its regenerated `CLAUDE.md` mirror. +**Implementation:** Replace each movable Linux `runs-on` with the routing expression. `supply-chain` keeps +`ubuntu-latest`. Extend `ci-structure.spec.ts` so every Linux job in these workflows either uses the +exact expression or is on a named hosted allow-list (`supply-chain`), and no macOS, Windows or arm64 +job ever does. 23 lines routed (18 in `ci.yml`, one each in five `integration-*.yml`); `supply-chain` +and `integration-decals.yml`'s `ubuntu-24.04-arm` job are the only Linux `runs-on` left hosted, and +`native-platforms.yml` is untouched until Phase 3. + +- [x] Structure spec enforces the routing. proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts` + — 142 passed, 0 failed. Red first: with no workflow edited the new test failed on + `.github/workflows/ci.yml scope runs on 'runs-on: ubuntu-latest'`; after routing, setting `budgets` + (ci.yml:1051) back to `ubuntu-latest` failed it again on that job and it was restored. Also green: + `pnpm lint` (exit 0), `pnpm check:docs` (2496 links), `pnpm sync:agents --check` (22 mirrors). + One pre-existing assertion had to be narrowed: `template-nonvisual` forbade the substring + `pull_request` anywhere in the job, which the routing expression mentions without gating on it. + +- [x] With `TN_RUNNER` set, a PR run lands its Linux jobs on `tn-local`. proof: AC-1 run. + Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02), as AC-1. +- [x] `AGENTS.md` says focused checks then push, not the full board, while `TN_RUNNER` is set. proof: + `pnpm sync:agents --check` and `pnpm exec vitest run scripts/__tests__/sync-agent-docs.spec.ts` pass. + Land it only after the AC-1 run is green. + Evidence: landed after AC-1; `pnpm sync:agents` + sync-agent-docs, primary-docs and instruction-budget specs, 24 passed (2026-10-02). +- [x] Light jobs never wait behind heavy ones (proof: `ci-structure.spec.ts` case, plus a full-board run + whose `ci-required` starts within 60 s of its last `needs` finishing): `scope`, `golden-path`, `build`, + `ci-required` and `run-summary` route to `tn-local-light`, and no heavy job can. + Evidence: ci-structure.spec.ts routing case (red with `typecheck` on the light lane), and CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): `ci-required` started 22:48:50Z, 3 s after the last job it needs finished at 22:48:47Z. +- [x] With `TN_RUNNER` unset, the same workflow runs fully hosted. proof: `workflow_dispatch` run id with + every `runner_name` hosted. + Evidence: `workflow_dispatch` run 37093694594 on `develop` with both variables deleted: all 43 jobs that started + ran hosted (38 `ubuntu-24.04`, 1 `ubuntu-latest`, 1 arm64, 2 macOS, 1 Windows), none on `tn-local`. Cancelled + after that proof so it would stop holding the hosted pool. + +#### Phase 3: The Linux `native-platforms` legs move too + +**Status:** COMPLETE — Linux legs on `tn-local`; the Android emulator stays hosted (measured exception) +**Files:** EDIT `.github/workflows/native-platforms.yml`, `tools/ci-runners/Dockerfile` (JDK 17, Android +SDK, emulator, `/dev/kvm`), `scripts/__tests__/ci-structure.spec.ts`. +**Implementation:** Route `web-reference`, `desktop-parity`, `android-emulator-parity`, `release-reports`, +the remaining `ubuntu-24.04` jobs and the `linux-x64` rows of the `desktop` and `starter-linux` matrices. +Keep `macos-15`, `windows-2025`, `ubuntu-24.04-arm` and `ios-simulator` hosted. The `android-emulator-runner` +action needs `/dev/kvm` access inside the container. + +Decisions this phase had to make, 2026-10-02: + +- **`publish-android-v8` stays hosted.** It is the release write itself — `permissions: contents: write` + and a `gh release upload` — and no pool container is handed a token that can publish. + `android-v8-source` does move: it needs no secret, it is a published-asset download on every run but + a cold one, and its own action records a two-core cold build finishing inside the 210-minute ceiling, + which is this pool's slot shape. +- **Light lane: `scope` and `networking-matrix` only.** Both are a script with no `pnpm install` and no + toolchain. `performance-coverage` and `release-reports` are joins too, but each installs the workspace + and `performance-coverage` has a five-minute ceiling, so they take a heavy slot. +- **`desktop` has no Linux row.** Its matrix is `macOS` and `Windows` only; the movable Linux matrix row + is `starter-linux/linux-x64`. +- **The KVM step had to stop asserting udev.** In the container there is no udev and `/sys` is read-only, + so `udevadm control --reload-rules` and `udevadm trigger` both exit 1 and took the whole emulator lane + with them. They are now `|| true`, which is what the step's own "report the mode, never assert it" + rule already says; `scripts/ci-runners.sh` passes `--device /dev/kvm` and the image's entrypoint sets + the node's mode before the job can read it. +- **The image carries the pinned SDK, not the emulator.** `cmdline-tools` 16.0 with licenses accepted, + `platform-tools`, `platforms;android-35`, `platforms;android-36` (`compileSdk` in + `android/app/build.gradle.kts`), `build-tools;35.0.0` and `ndk;28.2.13676358` (`ndkVersion` there, + and what the workflows' own `sdkmanager` line installs), owned by `runner` because sdkmanager writes + into `ANDROID_HOME` at job time. `android-emulator-runner` installs `emulator` and the system image + itself, which is the only job that wants them. Image: 9.28 GB against the pool's 5.25 GB. + +- [x] A `native-platforms` run passes with its Linux legs on `tn-local`. proof: run id plus per-job + `runner_name`. + Exception, measured: `android-emulator-parity` stays hosted. SwiftShader renders its GPU on the CPU, and on a + 4-thread slot run 37082733117 spent 41 of the job's 45 minutes still running APKs, where hosted takes ~30. + Evidence: CI run 37089715252 (PR #404, merged d99a6281c), latest attempt: every native-platforms leg success. + On `tn-local`: starter linux-x64, Android V8 source payload, web conformance reference, desktop web/native + parity, release evidence reports, collector coverage. On `tn-local-light`: caller selection, networking + matrix. Hosted as designed: macOS, Windows, iOS, linux-arm64, and the emulator exception above. +- [ ] Waiting time meets AC-2. proof: the AC-2 measurement on a full-board PR after this phase. diff --git a/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md b/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md new file mode 100644 index 0000000000..35cee21c0b --- /dev/null +++ b/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md @@ -0,0 +1,209 @@ +--- +prd_contract: v1 +--- + +# PRD-481 — CI does each piece of work once + +**Status:** PARTIAL — all three phases landed (#405) and the merge queue is on; live reuse, merge-group and cache-hit proofs pending; AC-1 audits the 7 days from 2026-10-03 +**Complexity:** 5 (HIGH) +**Owner:** CI tooling +**Depends on:** None ([PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md) and +[PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) are complementary, not prerequisites) + +Complexity: 5 → MEDIUM (about 8 files +2, verdict reuse is new logic +2, GitHub API +1); risk override → HIGH +because a wrong reuse key passes an untested tree. + +## Context + +Audit of `ThreeNativeHQ/threenative` Actions history from 2026-09-18 to 2026-10-02: 1,651 runs, 35,168 jobs, +115.5k runner-minutes (about 58k a week). Waste by bucket: + +| Bucket | Runner-min (14 days) | Evidence | +|---|---|---| +| PR runs cancelled by a newer push | 43.1k (37%) | 349 runs, median 25 min before cancellation; 56 of the cancelling pushes were Markdown-only (37 PRD ticks) | +| Same tree tested again on the way to `main` | 10.1k | 27 promotion-PR reruns; 9 of 10 `push: main` runs repeat a tree the promotion PR passed (36095260574, 36134527157, 36960030428) | +| Nightly on an unchanged tree | 4.2k | 14 of 15 scheduled runs; `af60e210` red 7 nights running (36230464827 to 36847832850) | +| Runs still going after the PR merged or closed | 5.0k | 41 runs; 36372446379 ran 382 min after merge | +| Pipeline cache verification on unrelated PRs | 2.4k | android-arm64 ABI job, 31 min median, 41 runs; still lists dead branch `feat/prd-368-persistent-pipeline-cache` | + +Within one full run (37043413533): + +- The workspace dist compiles about 9 times. Every consumer `needs: scope` only, so all miss the cache + `build-artifacts` saves (`.github/actions/workspace-dist/action.yml:54`, `ci.yml:953`). That is 71–95 s each. +- Shards are mis-sized. `template-nonvisual (shooter)` takes 967 s in one shard, `rain` 654 s and + `sailing` 542 s, while `starter` is split into three ~210 s shards that each pay about 90 s of setup + (`ci.yml:730-747`). `test-unit (3/3)` takes 605 s against 340 s for 1/3 (`ci.yml:206`). +- `test-native` compiles the Linux host every run. Its cache key hashes `ci.yml` (`ci.yml:~354`), and + `develop` never runs CI on push, so no PR ever restores a shared cache. Three PRs each hold a 1.35 GiB copy. + +Already owned elsewhere, not repeated here: the queue (PRD-480); release proof on PRs, the native +matrix on PRs, and cancelling merged-PR runs (PRD-380 phases 1–3). The ~3.5k/week of +`Native runtime release` PR runs is PRD-380 phase 1. + +**Why "Never cache test verdicts" exists:** PR #33 (2026-09-01) let `golden-path` skip on a hash of a +hand-listed set of inputs. PRD-373 (2026-09-11) removed it, because a key that misses one input +reuses a stale pass. The same failure happened earlier with `native-platforms` path filters, which left three reds on +`main`. The danger is a partial key, not reuse itself. + +## Solution + +**Tree reuse.** The `scope` job computes the tree SHA of the candidate it checked out (for a PR, the +merge ref). It then looks for a completed, successful `CI` run whose checked-out candidate has that +exact tree, found through the Actions API, never by a name a job can write. An identical tree is not +the same validation, so a source run also has to cover what this run proves. All five must hold or +the board runs: + +1. the checked-out candidate trees are identical; +2. the source is a completed CI run of this repository, read through the Actions API, and not a + `workflow_dispatch` — an audit a person asked for is never the evidence another run leans on; +3. the source ran the full board: every board job and matrix leg this run requires concluded + `success` there, none skipped. `native-platforms` arrives from the Actions API as one check, + because a reusable workflow's legs stay inside the caller; +4. the source's validation profile is equal or stronger, axis by axis — the target tier + (`main` > `develop` > unproven) and the native matrix tier (`full` > `reduced` > `none`). A run + record in this repository reports no `base_ref`, so a source's target comes from the pull requests + its commit belongs to, and an unprovable one ranks lowest; +5. the source's jobs ran on this run's own runner class (hosted or `tn-local`, from the jobs API), and + its `ci-required` concluded `success` — the one part of a verdict no repository can compute for + itself. + +On a hit the job emits `selection: reused` with the source run id and the profile that justified it. +Every board job skips, and `ci-required` re-reads that run and repeats 3–5 before it passes. + +- **Applies to:** the `develop → main` promotion PR, `push: main`, re-pushes and rebases with an + unchanged tree, and the merge group below. +- **Never applies to:** `workflow_dispatch`, which stays an explicit full audit as a reuse target and + as a reuse source. Nightly reuses Monday to Saturday; Sunday's nightly is always full, because its + subject is the runner image and the network, which an unchanged tree cannot vouch for. + +**Merge queue on `develop`.** `ci.yml` gains `merge_group`. GitHub then tests the exact result a merge +would produce before it lands, which closes today's gap: two PRs merged back to back put a tree +on `develop` that no run tested. When `develop` has not moved, the merge group's tree equals the +PR's tested tree, and tree reuse makes the queue instant. + +**Work once per run.** Consumers download the `build-artifacts` dist instead of rebuilding it. The +native host cache key drops `ci.yml`, and a build-only job on `develop` pushes warms it. Shards are +sized to land at about 4–6 min each. + +**Pushes that cancel a running board.** With PRD-480 the board finishes in about 15–20 min instead of +40+, which shrinks the cancellation window. The rule that cuts the rest is in PRD-482. + +## Acceptance Criteria + +- [ ] AC-1 [shared]: proof: the audit script in the PR body re-run over the 7 days after the last phase lands. + Weekly runner-minutes outside the PRD-380 and PRD-480 buckets drop by at least 7.5k against the + 2026-09-18 to 10-02 baseline. Evidence: pending. + +## Blocked on + +- Merge queue enabled on `develop` in the repository ruleset — unblocked by João (org admin). + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Tree reuse | `scope` job → `scripts/ci-change-scope.mjs` → `selection: reused` → `ci-required` | Replaces nothing; full board stays the miss path | Phase 1 | +| Merge queue | `merge_group` event → `ci.yml` | `develop` merges go through the queue instead of direct squash | Phase 1 | +| Shared dist | `build-artifacts` upload → consumers' download step | Per-job `workspace-dist` builds are deleted | Phase 2 | + +## Decisions + +- 2026-10-02 (João): verdict reuse is allowed when it stays reliable. Reuse is keyed only on the + whole-repo tree, recorded only by a successful CI run, and never on a hand-listed set of inputs. AGENTS.md + "Never cache test verdicts" becomes "Reuse a verdict only for an identical whole-repo tree that CI + itself passed". +- 2026-10-02 (review by Astra, adopted): same tree is not same validation. A reduced pass must never + satisfy a fuller requirement — PRD-380 gives an ordinary pull request a reduced native matrix, and + this planner already lets a clean develop pull request skip `native-platforms` — so reuse needs an + identical tree **and** a source run whose validation profile covers this run's. A git tree also + carries neither repository variables (`vars.TN_CI_FORCE_FULL`, `vars.TN_DEVELOP_CI_ENABLED`) nor a + mutable runner image, so the profile is read back from the Actions API and anything unreadable is a + miss that runs the board. The weekly full run is the backstop for what a tree cannot see, not a + substitute for it. + +## Execution Phases + +#### Phase 1: The same tree is tested once + +**Status:** IN PROGRESS — code landed, live proofs pending +**Files:** EDIT `scripts/ci-change-scope.mjs`, `scripts/ci-required.mjs`, `.github/workflows/ci.yml` +(`merge_group`, schedule weekday/Sunday split), `AGENTS.md` (+ mirror), `scripts/__tests__/ci-structure.spec.ts`. +**Implementation:** lookup by tree via `gh api` with the job's `GITHUB_TOKEN` (`actions: read`). A miss, +an API error or an ambiguous match falls back to the full board. Fail closed means run the work, never +skip it. + +- [ ] A promotion PR whose tree already passed reports `reused`, under 2 min. proof: CI run id plus the + source run id it cites. +- [x] (proof: `pnpm exec vitest run scripts/__tests__/ci-needs.spec.ts`) A tree that changed by one byte + runs the full board, and a tree that already passed is reused only when a source run covers this run's + validation profile; the spec carries a case where the trees differ by one + file, a case where the API errors, and the four coverage cases: a develop pull request's pass cited + for a promotion, a source that never ran a matrix leg this run requires, a source whose jobs ran on + another runner class, and a promotion pass satisfying a develop pull request. Evidence: 2026-10-02, + 30 passed (those four plus the reused verdict were red before the implementation); the same run also + clears `ci-structure`, `ci-efficiency`, `sync-agent-docs` and `primary-docs`. The suite stubs `gh` on + `PATH`; the miss reasons are asserted too, so a full run that never looked cannot read like one that + looked and found nothing. +- [ ] `develop` merges go through the merge queue, and an unchanged-base merge group reuses. proof: merge + group run id. + +#### Phase 2: Each run does each piece of work once + +**Status:** IN PROGRESS — dist built once and shards under 6 min proven; the cache box needs a `develop`-warmed key +**Files:** EDIT `.github/workflows/ci.yml`, `.github/actions/workspace-dist/action.yml`, +`.github/workflows/native-platforms.yml` (the Linux host built once), `scripts/__tests__/ci-structure.spec.ts`. + +- [x] Only `build-artifacts` compiles the workspace dist; every other job downloads it. proof: a full CI run + where no consumer's log contains the dist build step. + Evidence: hosted CI run 37071464562 (PR #405, 2026-10-02): of every non-native job, only `build-artifacts` + logs `scripts/workspace-packages.ts build`; typecheck, test-unit, test-browser and test-playtest log + "Take the compiled workspace from this run's producer" (download-artifact). +- [ ] The native host cache restores on a PR from a `develop`-warmed key. proof: a `test-native` log with a + cache hit and a build time under 60 s. +- [x] No template or unit shard runs longer than 6 min. proof: per-job durations of one full CI run. + Measured on run 37071464562, two legs were over: `test-unit (4/4)` at 481s and + `template-nonvisual (sailing, 2/2)` at 467s, with `rain (3/5)` 358s and `rain (2/5)` 299s next and + every other unit and template leg under 360s. This commit takes sailing from two slices to three + — its `float-after-the-run-ends` scenario measured 247s and sat alone on the second slice with + two more scenarios, 404s of work against a ~63s overhead — and splits + `template-assets-compile.spec.ts`, 317s of test time in one file, into the two halves of the + template list, which is the one thing `vitest --shard` cannot divide. Replaying vitest's own + `sha1(path)` partition over that run's per-file durations puts every unit leg at ~137-240s of + test time and sailing's worst slice at ~335s. The box stays unticked: its proof is a full CI run's + per-job durations, and nothing here has run on GitHub. + Evidence: hosted CI run 37078784853 (PR #405 head 8e1436307, 2026-10-02): slowest legs test-unit (1/4) 329 s, + rain 4/5 319 s, sailing 2/3 289 s; every template and unit shard under 360 s. + +#### Phase 3: Triggers fire only when they prove something + +**Status:** COMPLETE +**Files:** EDIT `.github/workflows/pipeline-cache.yml`, DELETE `.github/workflows/integration-*.yml` for +`.github/workflows/integration.yml`, `scripts/workspace-packages.ts` (a hand-listed package set is one +run block, not one file), `scripts/__tests__/ci-structure.spec.ts`, +`scripts/__tests__/workspace-packages.spec.ts` (workflow-file allow-list, so per-branch workflows cannot +land unreviewed). + +- [x] `pipeline-cache.yml` fires only on paths its proof reads, with no dead branch. proof: + `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts`. Evidence: 2026-10-02, 126 passed; + the new assertion fails on the pre-change file and passes after `feat/prd-368-persistent-pipeline-cache` + is gone from `push.branches`. +- [x] Integration workflows skip drafts and fire once per commit. proof: `pnpm exec vitest run + scripts/__tests__/ci-structure.spec.ts`, rejecting a `push` plus `pull_request` pair. Evidence: + 2026-10-02, 126 passed; the pair case is a fixture in the spec and the assertion fails on the + pre-change files. `integration-csg`'s unfiltered `push` and `integration-decals`' per-branch `push` + (pull request #394 merged 2026-10-02) are both removed; every integration job now carries + `!github.event.pull_request.draft`, and each trigger gained `ready_for_review` so that guard cannot + silence the lane. +- [x] (proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts`) The per-feature + `integration-*.yml` files fold into one `integration.yml`, one job per feature with its own `paths` + gate, draft guard and routing expression kept per job — its allow-list lists one integration + workflow and rejects a new `integration-*.yml`. Evidence: 2026-10-02, 131 passed, each of the two + new assertions red first: a lane without `needs: paths` and a lane gate without + `!github.event.pull_request.draft` both fail, and adding `integration-newlane.yml` fails the + allow-list. The five lanes' steps, permissions, timeouts, runners, checkout refs and artifact names + are unchanged; `dorny/paths-filter` (pinned to the v4.0.3 commit) reads the pull request's own + changed paths once, so a lane this commit does not touch is skipped rather than given a runner, and + the csg and decals supersession groups become one. What changes: a job id's check name + (`test` becomes `csg` / `ik` / `vegetation`, which were three indistinguishable `test` checks), a + ~30-second gate job on every pull request, and cancellation applying to the three lanes that queued + behind a superseded run instead of cancelling it. diff --git a/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md b/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md new file mode 100644 index 0000000000..752539a8be --- /dev/null +++ b/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md @@ -0,0 +1,208 @@ +# PRD-476 — Fluid Lab: particle water on the GPU at 60 fps + +**Status:** PROPOSED +**Complexity:** 9 (HIGH); risk override: none. +**Owner:** Engine implementation agent +**Depends on:** None +**Progress:** 0/8 required boxes verified + +## Context + +Absorb `/home/joao/Downloads/fluid-lab-v2.html` ("Fluid Lab 02", 238,714 bytes, SHA-256 +`e389fe7f5198c3ce4365b343281c1e34317c55082514741815e9217ad14f4ef7`) as engine capabilities, remove +the frame-rate wall that holds it near 30 fps, and rebuild it as a sandbox game on those +capabilities. This request authorizes the PRD only; nothing below has started. + +The file is a raw Three.js 0.180 (WebGL2) + Rapier 0.19 lab with eight experiments: dam break, +splash tank, buoyancy, waterfall, fountain, whirlpool, viscosity, and ocean & rain. Its readable +source is embedded as a JSON map at `fluid-lab-v2.html:69` (`window.FLUID_LAB_SOURCES`); lines +70–1280 are the bundled module. The Downloads file is an input, never a runtime dependency. + +### What the source does, and where it goes + +| Source (`FLUID_LAB_SOURCES` path) | Behaviour | Destination | +| --- | --- | --- | +| `src/physics/fluid.js` | CPU Position Based Fluids: uniform-grid neighbours, density constraints, XSPH viscosity, cohesion, vorticity confinement, box/sphere collider projection, emit/drain/stir | **New** `FluidParticles3D` in `packages/core`, on GPU compute | +| `src/render/surface.js` | CPU density lattice + surface nets mesh, gradient normals, per frame | **New** density volume on `FluidParticles3D` (data); the surface look is game `src/render/` | +| `src/physics/coupling.js` | Rapier bodies, sampled Archimedes lift and drag, particle→body reaction impulses | Existing `Buoyancy3D` (`@threenative/physics`) on the fluid's height source; colliders feed the solver | +| `src/physics/waves.js` | Damped heightfield wave equation, rain/pointer impulses | Existing `RippleField` (+ `WaveField` swell) — no new engine code | +| `src/physics/secondary.js` | Spray droplets, crown sheets, advected foam, bubbles | Existing `GPUParticles3D` with a game process function; foam and crown shading in game `src/render/` | +| `src/render/shaders.js`, `water-renderer.js`, `stage.js` | Refraction, absorption, Fresnel, exit-depth pass, debug views | Game `src/render/` (rule (b): it decides the look) | +| `src/simulation.js`, `presets.js`, `ui.js`, `main.js` | Eight scenes, actions, inspector, fixed-step loop | Sandbox game scenes, HUD, and the engine's fixed-step dispatch | + +### Why it runs at ~30 fps (measured) + +`src/physics/fluid.js` and `src/render/surface.js` are dependency-free, so they were timed directly +in Node 20.19.6 on this machine (splash-tank fill, 60 steps after a 30-step warm-up): + +| Quality | Particles | `FluidSolver.step` | `FluidSurface.update` | +| --- | --- | --- | --- | +| Light | 760 | 14.3 ms | 4.2 ms | +| Balanced (default) | 1,625 | 29.9 ms | 6.6 ms | +| High | 2,175 | 40.9 ms | 9.6 ms | + +At the default quality the CPU solver alone is 1.8× the 16.7 ms frame, before the surface rebuild +and four scene renders per frame (`water-renderer.js:118–123`: scene target, back faces, composite, +quad). `main.js` runs up to three 1/60 s steps per frame and caps the accumulator at three steps, +so when a step outruns the frame the display drops to the 30 fps vsync bucket and the simulation +falls into slow motion. Light quality still misses the budget once rendering is added. Making the +CPU code a few times faster would not leave room for rendering on a phone; the neighbour search +and constraint passes are data-parallel work that belongs on the GPU. + +### Existing mechanisms reused + +Capability search and detail were run for the full request and for each mechanic. + +- `FluidField2D` (`packages/core/src/fluid-field.ts`, 748 lines; PRD-249): the precedent shape — + TSL compute passes, `IComputeDriven`, `ctx.add` lifetime, numeric samplers, game-owned look, + conformance case `77-fluid-field`, example `examples/prd249-fluid-field` with web and desktop + playtests. `FluidParticles3D` copies this shape; it does not replace the 2D field. +- `ComputeDrivenRegistry` / `IComputeDriven` (`packages/core/src/compute-driven.ts`): pass order, + warm-up, release. +- `GPUReadback`: throttled GPU→CPU copy with `staleFrames`, for stats and the height source. +- `Buoyancy3D` (`packages/physics/src/Buoyancy3D.ts`): hull points, density, drag, any height + source. Covers the lab's buoyancy and splash-slowdown. +- `RippleField`, `WaveField`: the ocean scene, unchanged. +- `GPUParticles3D`: spray and bubbles. A search for "splash spray droplets with lifetime and + gravity" returned verdict `none`; this is a manifest recall gap, fixed in Phase 3. +- `WaterSurface3D` is a level-plane refraction/reflection helper; it does not fit a free-form + particle surface and is not used here. + +No 3D particle fluid, SPH/PBF solver, or isosurface extraction exists in `packages/`. + +Complexity: 2 for 6–10 implementation files, 2 for a new system, 2 for GPU pass ordering and +cross-frame readback state, 2 for crossing the native host and the sandbox tarball boundary, +1 for none of the rest → 9. + +## Solution + +```mermaid +flowchart LR + G[game scene] -- fill / emit / drain / stir / force Fn --> F[FluidParticles3D] + R[Rapier bodies] -- collider transforms each fixed step --> F + F -- positions, velocities storage --> SP[GPUParticles3D spray process Fn] + F -- density volume --> M[game src/render water material] + F -- column heights via GPUReadback --> B[Buoyancy3D] + B -- forces --> R + F -- stats via GPUReadback --> H[HUD] +``` + +**`FluidParticles3D`** (`packages/core/src/fluid-particles.ts`, exported from the core index with +`@situation` tags) is a GPU Position Based Fluids solver that draws nothing: + +- Options: `capacity`, `spacing`, `bounds`, `iterations`, `viscosity`, `cohesion`, `vorticity`, + `gravity`, plus an optional game TSL `force` function (the whirlpool's tangential drive). Every + option has a default measured from the source's balanced preset; invalid values throw. +- Per fixed step, as ordered compute passes: predict, grid hash + counting sort, density/λ, + position delta (repeat `iterations`), collider projection, velocity update with XSPH viscosity, + cohesion and vorticity confinement. +- Game API: `fill(min, max, velocity)`, `emit(position, velocity)`, `drain(region)`, + `stir(point, strength, radius)`, `setColliders([...])` (spheres and oriented boxes, updated each + step from Rapier transforms; static and dynamic). +- Data out: `positions` / `velocities` storage nodes; a `density` 3D texture splatted each step; + `heightAt(x, z)` from a column-height grid copied through `GPUReadback` (reports `staleFrames`); + `stats` (`count`, `meanCompression`, `maxSpeed`) through the same throttled readback. +- Fails closed: on a renderer without compute (WebGL2 backend) the constructor throws with a + named error rather than drawing nothing. + +**The look stays in the game.** The sandbox game's `src/render/` raymarches the density volume +(refraction, absorption, Fresnel, debug views ported from `shaders.js`) and owns spray, foam and +crown shading. A game can replace all of it without touching `packages/`. + +**The sandbox game** `../sandbox/fluid-lab/` is scaffolded and installed from tarballs like a +user's, with all eight experiments as scenes, the lab's actions and keyboard, a HUD with the four +metrics, and a `FRICTION.md` logged as it is built. + +**Risks.** 3D storage textures and atomics in TSL on the native Dawn host (proved by the +conformance case before the sandbox depends on them); readback lag making floating bodies bob +(`Buoyancy3D` already runs on fixed steps; `staleFrames` is reported, not hidden); particle +tunnelling at the lab's 18 m/s velocity clamp (keep the clamp as an option). + +## Acceptance Criteria + +Phase boxes below are the acceptance criteria; each names its proof. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| GPU particle fluid | `ctx.add(new FluidParticles3D(...))` in `examples/prd476-fluid-particles/src/game.ts` and the sandbox scenes | New; the lab's CPU `FluidSolver` is not ported | Phase 1 boxes | +| Fluid → body buoyancy | `new Buoyancy3D({ surface: fluid, ... })` in sandbox buoyancy/splash scenes | Replaces `coupling.js` sampling and reaction impulses | Phase 2 box 1 | +| Native host | Conformance registry case on the desktop host | New case beside `77-fluid-field` | Phase 2 box 2 | +| Discovery | `engine_search_capabilities` → manifest built by `pnpm build` | Adds `@situation` tags; no hand-edited JSON | Phase 3 box 1 | + +## Decisions + +- 2026-10-01 (planning agent, proposed; owner may overturn): GPU compute, not an optimised CPU + port. Measured 29.9 ms per balanced step on a desktop CPU; even a 3× gain leaves no frame budget + on a phone, and the charter makes a backend the game cannot write portably the engine's job. +- 2026-10-01 (planning agent, proposed): a density volume the game raymarches, not GPU surface + nets. Surface nets need stream compaction and indirect draws on both hosts; the volume is data, + which keeps the look in `src/render/` like `FluidField2D`'s dye. +- 2026-10-01 (planning agent, proposed): no particle→body reaction impulses. `Buoyancy3D` lift + and drag already slow and float bodies; add impulses when a scene shows a body passing through + water without slowing. +- 2026-10-01 (planning agent, proposed): name `FluidParticles3D`, pairing `FluidField2D` and + `GPUParticles3D`; Godot has no fluid node to borrow. +- Out of scope: Android/iOS frame-rate claims (no result here claims a platform it did not run + on); a follow-up PRD takes the Pixel 8 measurement once Phase 3 lands. + +## Execution Phases + +#### Phase 1: `FluidParticles3D` runs a dam break on the GPU at 60 fps + +**Status:** NOT STARTED +**Files:** `packages/core/src/fluid-particles.ts` (new), `packages/core/src/index.ts` (export + +JSDoc tags), `packages/core/__tests__/fluid-particles.spec.ts` (new), +`examples/prd476-fluid-particles/` (new, copied from the `prd249-fluid-field` layout: `game.ts`, +`main.ts`, `render/`, `conformance.js`, `playtests/`). +**Implementation:** Port the `fluid.js` algorithm pass for pass into TSL compute behind +`IComputeDriven`; keep the source's kernel constants and parameter defaults so behaviour can be +compared. The example renders particles as points from `positions` (debug look only) and exposes +`GameState` with `stats`, `steps`, and a front-position probe. + +- [ ] Option validation fails closed, passes dispatch in the documented order, `fill`/`emit`/ `drain` respect capacity, and scene removal releases buffers. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts` +- [ ] A released dam-break column runs across the tank and settles with mean compression ≤ 0.05 and every particle inside `bounds` (GameState resource assertions). proof: `node packages/playtest/dist/runner/cli.js examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json --url http://127.0.0.1:5173 --server-command "pnpm --filter prd476-fluid-particles dev --host 127.0.0.1" --browser-recipe webgpu` +- [ ] With 6,000 particles (the source's High capacity) the splash tank holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter (`adapter.info` recorded, not SwiftShader). proof: `measure-steady-state-fps` skill lane against the example + +**Verification:** run the three proofs above; record particle count, p95 frame time and adapter +beside the third box. + +#### Phase 2: bodies splash and float, and the native host runs the solver + +**Status:** NOT STARTED +**Files:** `packages/core/src/fluid-particles.ts` (colliders, column heights, `heightAt`), +`examples/prd476-fluid-particles/src/game.ts` (a sphere and two boxes), +`packages/runtime-native/conformance/registry.json` (new case), +`examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json` (new). +**Implementation:** `setColliders` uploads sphere/box transforms from Rapier each fixed step and +projects particles out of them; the column-height grid feeds `heightAt` through `GPUReadback`; +`Buoyancy3D` consumes it unchanged. Register a conformance case next to `77-fluid-field` with +`desktopGate: true`, using the example's `conformance.js`. + +- [ ] A 1,900 kg/m³ sphere dropped into the tank displaces the surface and comes to rest on the floor, while a 550 kg/m³ box ends floating with its centre within 0.2 m of `heightAt` after 5 s (GameState assertions). proof: the Phase 1 playtest command with the coupling scenario +- [ ] The desktop host runs the solver and matches the web capture within the case tolerance. proof: `pnpm parity` (case `fluid-particles`) and the `fluid-particles-desktop` playtest + +**Verification:** both proofs; regenerate the census in the same commit as the registry change +(`pnpm census`). + +#### Phase 3: agents find it, and the sandbox Fluid Lab runs all eight experiments at 60 fps + +**Status:** NOT STARTED +**Files:** `packages/core/src/index.ts` (`@situation` tags for `FluidParticles3D`; spray/droplet +situations on `GPUParticles3D`), `packages/create-threenative/templates/sailing/AGENTS.md` or the +capability reference the scaffold reads (one line, inside the template caps), +`../sandbox/fluid-lab/` (new game: scenes, `src/render/`, HUD, playtests, `FRICTION.md`). +**Implementation:** `pnpm build` regenerates both capability manifests. Pack core and physics to +a private staging directory with content-hashed tarball names (never `pnpm sandbox`, which wipes +the shared `.packages`), scaffold `fluid-lab`, port the eight experiments onto +`FluidParticles3D`, `Buoyancy3D`, `RippleField`/`WaveField` and `GPUParticles3D`, and the optics +from `shaders.js` into `src/render/`. Commit and push the game to the sandbox remote as soon as it +runs, per the sandbox `AGENTS.md`. + +- [ ] `engine_search_capabilities` returns `FluidParticles3D` first for "pour water into a tank and drop a ball in it" and `GPUParticles3D` for "splash spray droplets with lifetime and gravity"; template caps still pass. proof: `pnpm build && pnpm test`, then both searches +- [ ] A playtest cycles all eight scenes through keys 1–8 and asserts each is non-blank, changes over time, and reports a non-zero particle or wave state. proof: `node packages/playtest/dist/runner/cli.js ../sandbox/fluid-lab/playtests/experiments.playtest.json --url --browser-recipe webgpu` +- [ ] The sandbox Splash Tank at the High preset holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter — the case the source drops to 30 fps. proof: `measure-steady-state-fps` skill lane against the sandbox build + preview + +**Verification:** the three proofs; `score-build-experience` on the finished sandbox build goes +in the PR body, not a box. diff --git a/docs/PRDs/UI/PRD-native-overlay-utility-styling.md b/docs/PRDs/UI/PRD-native-overlay-utility-styling.md new file mode 100644 index 0000000000..8dc475c7ea --- /dev/null +++ b/docs/PRDs/UI/PRD-native-overlay-utility-styling.md @@ -0,0 +1,425 @@ +--- +prd_contract: v1 +--- + +# PRD — Standard Tailwind and CSS, rendered as native UI + +**Status:** NOT STARTED — revised design; no implementation or runtime parity is claimed. +**Date:** 2026-10-01 (America/Vancouver). +**Audit baseline:** `develop` at `49ba2c49d6867ce075a9ebe42b994e3464ec0283`. +**Delivery:** Replace the unimplemented proposal in draft PR #388; target `develop`. +**Scope:** An opt-in, native CSS-capable HUD backend with a tested compatibility profile. +**Product requirement:** Use the same Tailwind and CSS authoring interface, but output native UI. + +## Decision and correction + +**Agree with the product goal; revise the proposed implementation.** Run the real Tailwind build +pipeline, retain ordinary CSS as the styling contract, and render a React element tree through a +native CSS/layout/text/paint backend. Do not invent Tailwind-looking utilities, require conversion +to `IOverlayStyle`, or replace CSS with a public ThreeNative style language. + +Native means an in-process, GPU-composited UI surface, without a WebView, Chromium/CEF, a browser +renderer process, or an HTML screenshot service. It does not mean platform-default widgets: +authored CSS still determines the appearance. A native stylesheet engine and an internal +DOM-like element tree are allowed. They are necessary implementation machinery, not a browser +JavaScript DOM exposed to game code. + +The previous requirements for no CSS at runtime, mandatory static style IDs, and a Tailwind-to- +proprietary-style compiler are withdrawn. Parse CSS when loaded or changed; compute styles when +state changes. Build-time optimization is welcome only when it preserves the cascade, variables, +responsive rules, dynamic classes and interaction behavior. No parsing on every game frame. + +This PRD delivers the **Core HUD profile** below, not all CSS or arbitrary web applications. +Additional profiles require their own PRDs and native proof; implementing this profile must not be +marketed as complete Tailwind/browser compatibility. There are no speculative coverage percentages. + +## Existing architecture and fit + +These sources were inspected at the audit baseline; observations describe current code, not this +proposal's future API. + +| Source | Observed behavior | Design consequence | +| --- | --- | --- | +| [Workspace catalog](../../../pnpm-workspace.yaml) | Tailwind and its Vite plugin are both pinned to `4.3.3`. | Use these actual packages and lockfile versions, not a hand-maintained utility table. | +| [React entry](../../../packages/core/src/react.ts) | Optional React subpath; `View`/`Text` accept native `style` objects. | Keep existing callers working; standard JSX needs a separate host adapter. | +| [React host](../../../packages/core/src/react-host.ts) | Custom reconciler, CanvasLayer quads and bitmap glyphs; other element types fail. | Reuse lifecycle patterns, not the limited drawing model as a CSS target. | +| [Layout](../../../packages/core/src/react-layout.ts) | Fixed/shrink-wrapped boxes, simple row/column placement, no CSS parser; mobile WASM is explicitly refused. | A utility parser cannot supply Flexbox, CSS paint or font semantics. Native dependencies must not require mobile WASM. | +| [UI contract](../../../packages/ui/AGENTS.md) | Shared UI reads published game state and emits intents; the game owns gameplay. | Keep the state/intent boundary and portable-entry isolation. | +| [Charter](../../architecture/CHARTER.md) | Web-standard UI defaults to a platform WebView; native quads are a limited opt-in; public IRs and a second scene renderer are excluded. | Add a bounded UI-only architectural exception explicitly; do not claim this is already permitted/shipped. | + +The framework owns the portable mechanism; the game owns every appearance choice. Three.js keeps +ownership of the 3D scene, camera and game rendering. Neither React nor the CSS backend takes over +the scene graph or game loop. + +### Required architectural amendment + +The implementation must amend the Charter and affected native-overlay documentation before the +new backend is presented as supported. The proposed exception is: an optional, third-party native +HTML/CSS UI engine may maintain a UI-only element tree and paint an offscreen HUD surface; it may +not render the 3D game, expose a game/scene IR, replace Three.js, or impose a framework-owned look. +This document proposes that exception; it does not silently amend the binding Charter. + +Keep existing `ui.renderer: "web"` and `"native"` semantics unchanged. Add a proposed +`ui.renderer: "native-css"` opt-in rather than silently changing the existing bitmap-native path. +No default switch, completed-PRD rewrite, or mandatory dependency cost for other applications. + +## User-facing contract + +The example below is a **required future acceptance fixture**, not a currently available API. +The component and stylesheet are identical in browser and native-CSS builds; only the renderer +selection/bootstrap differs. Do not make users replace `div` with `View` or rewrite styles per target. + +```tsx +import "./hud.css"; + +export function Inventory({ onClose }: { onClose: () => void }) { + return ( +
+

Inventory

+

12 items

+
+
Medkit
+
Battery
+
+ +
+ ); +} +``` + +```css +@import "tailwindcss"; + +@font-face { + font-family: "HudSans"; + src: url("./fonts/hud-sans.ttf") format("truetype"); + font-weight: 100 900; +} + +@theme { + --font-sans: "HudSans", sans-serif; + --color-brand: #2563eb; +} + +.inventory { + font-family: var(--font-sans); + --inventory-border: var(--color-zinc-700); + border-color: var(--inventory-border); +} + +.inventory > h2 { + letter-spacing: -0.02em; +} +``` + +The fixture must supply a licensed variable TTF supporting the declared weight range; the example +path is an asset requirement, not a claim that this font exists in the repository. + +Required interfaces are ordinary JSX intrinsics, `className`, `id`, `data-*`, appropriate `aria-*` +attributes, CSS imports and the React CSS-style object convention, including unitless properties +and custom properties. `style={{ width: "50%" }}` must retain percentage semantics. The native +host implements the supported React property/event contract; it does not load `react-dom` against +the game's compatibility `document` stub. + +Custom CSS must work without Tailwind. Tailwind's `@theme`, `@utility`, `@apply`, `@source` and +`@custom-variant` remain upstream build-time features; ordinary CSS is their output. Supported +plugins are those whose emitted CSS stays within the declared native profile. Do not promise +arbitrary plugin or DOM-dependent component-library compatibility. + +## Selected approach and alternatives + +| Approach | Assessment | +| --- | --- | +| Existing WebView path | Preserves the broad web contract, but does not meet the requested no-WebView output. Retain it for existing users. | +| Utility parser plus the current quad renderer | Cannot preserve CSS layout, cascade, typography or interaction. Rejected for this goal. | +| Taffy plus a new hand-built style/paint system | Layout is only one subsystem; leaves ThreeNative owning most of a CSS engine. Not the default plan. | +| Reuse a modular native HTML/CSS engine behind the existing UI boundary | Recommended: retain real CSS and concentrate new work on React, native-host integration and conformance. | + +**First candidate: Blitz's modular core, not a switch from React to Dioxus.** Its upstream design +combines Stylo for CSS, Taffy for box layout and Parley for text, with a separate renderer. Its +README still describes a beta, missing features and no ready-made JavaScript bindings. The HTML +preview wrapper is not an interactive React backend. Its texture example is evidence of an +integration direction, not proof of compatibility with ThreeNative's GPU device or input system. +See the [upstream architecture and status](https://github.com/DioxusLabs/blitz). + +[Taffy](https://github.com/DioxusLabs/taffy) is a useful layout dependency, not a replacement for +CSS resolution, text, paint and events. A maintained native engine is preferred over assembling +those subsystems from scratch. Backend selection is provisional until the first runnable slice +passes below; a failing candidate does not authorize silently shrinking the product requirement. + +## Technical design + +```mermaid +flowchart TD + A["Shared React JSX and CSS sources"] --> B["Existing Vite build and real Tailwind"] + B --> C["Ordinary CSS, fonts, images and source maps"] + A --> D["Native React host: UI element mutations"] + C --> E["Native CSS engine: cascade and computed styles"] + D --> E + F["Viewport, theme, focus, pointer and scroll state"] --> E + E --> G["CSS layout and text shaping"] + G --> H["Native UI paint into composited GPU surface"] + H --> I["Existing game presentation: Three.js remains scene renderer"] + B --> J["Browser React DOM reference using identical CSS"] +``` + +### Build and stylesheet ownership + +Use the workspace Tailwind/Vite pipeline and preserve the resulting CSS, source order and assets. +Do not call private Tailwind candidate APIs as the public integration, hardcode spacing/palettes, +or transpile utility strings into proprietary style objects. CSS Modules may use the existing +bundler transformation; their final class names still enter the same stylesheet engine. + +Bundle fonts/images offline through existing asset plumbing. CSS imports in a native build become +registered stylesheet assets, not `document.head` calls. Development stylesheet updates replace a +sheet atomically, invalidate affected styles, and preserve component state. Production loads the +same styling artifact without invoking the Tailwind compiler. Pin the Tailwind version and engine +revision used by each conformance run. + +Follow upstream [class detection](https://tailwindcss.com/docs/detecting-classes-in-source-files): +conditional complete strings work; arbitrary `bg-${color}-500` interpolation does not gain new +runtime CSS generation. Users register extra candidates through ordinary Tailwind sources. +A class with no matching rule is legal CSS and must not automatically throw: it may be a custom +selector hook. Report missing generated candidates diagnostically without inventing utilities. + +### Native host and composition + +Keep a UI-only retained element tree with tag, attributes, ordered children, text, interaction +state and inline declarations. Batch React mutations at commit boundaries. Preserve keyed-node +identity, selector-relevant ancestry and sibling order. Runtime style resolution remains necessary; +a single static style ID cannot replace context-dependent computed styles. + +Use a coarse, versioned native binding with bounded message buffers and explicit ownership for +create/update/remove, stylesheet loading, input, resize and disposal. This is an internal ABI, not +a new public scene/style format. Callbacks return to JavaScript after native work, avoiding +re-entrant mutation. Rust, where selected, compiles to a native library behind the C++ host; no +mobile WASM, additional game-language requirement, or Dioxus authoring requirement. + +The first slice must demonstrate the actual UI texture/surface path with the host's active GPU +backend. A Rust `wgpu` texture and a Dawn texture are not interchangeable handles. Validate device +ownership, native-handle interoperability, synchronization, formats, premultiplied alpha, color +space, resize and device-loss teardown. An explicit GPU-side copy may be acceptable if measured; +a per-frame GPU readback and CPU re-upload is not the production solution. Do not force an +unrelated 3D-renderer migration to make the UI backend fit. + +Retain the state/intent contract: UI presents game-owned state; user actions become intents. +Theme changes, scrolling, transitions and hover must update without rerendering the game scene or +reconciling every React component each frame. Reuse current publication semantics; do not impose +a new fixed 10 Hz bridge on the strength of older Charter examples. + +### CSS semantics are the contract + +Preserve cascade layers, selector specificity, inheritance, source order, inline declarations and +`!important`, including reversed layer precedence for important declarations. Class-string order +is not a generic last-token-wins rule. Normal inline styles do not beat important stylesheet rules. +Shorthands/longhands must resolve like CSS, not object-spread order. + +Include the real Preflight output and an appropriate supported-element default style sheet. +Reset/default display, box sizing and text metrics are observable behavior. Do not disable +Preflight or swap fonts to make a visual test pass. See [Preflight](https://tailwindcss.com/docs/preflight) +and [theme variables](https://tailwindcss.com/docs/theme). + +Resolve `var()` with inheritance, fallbacks and invalid/cyclic-value behavior; support the +registered custom-property behavior required by emitted Tailwind `@property` rules. Retain +`calc()`, `min()`, `max()` and `clamp()` when values depend on layout or runtime variables. Modern +color functions and alpha modifiers must retain authored semantics; theme values are not all hex. +Tailwind's [compatibility documentation](https://tailwindcss.com/docs/compatibility) makes these +CSS dependencies part of the integration, not optional decoration. + +Measure layout in CSS pixels, not framebuffer pixels or Three.js world units. Map DPR once at +rasterization/input conversion. `rem`, `em`, percentages and viewport units retain their respective +reference contexts. Responsive media-query units follow CSS rules, not a hardcoded spacing scale. +Viewport resize, root/element font changes and inherited custom-property changes invalidate the +right descendants. Test non-default root font sizes and DPR 1/2/3. + +## Core HUD compatibility profile + +All rows marked **Core** are requirements, not statements of existing backend support. Each must +have browser-reference fixtures. The implementation publishes the exact tested property/value/ +selector matrix, including restrictions, instead of claiming entire utility families blindly. + +| Area | Core requirement | Separate future profile | +| --- | --- | --- | +| Elements | `div`, `section`, `aside`, headings, `p`, `span`, `button`, `img`, ordinary text and fragments; inherited text styles and mixed inline runs. | Forms, editable inputs/IME, native select/dialog behavior, links/navigation, general HTML. | +| Selectors/cascade | Type, class, ID, attributes, descendant/child/sibling combinators; `:is`, `:where`, `:not`, first/last/nth-child; layers, variables, inline styles and importance. | `:has`, complex generated content and unsupported selector constructs. | +| Box layout | Block/inline flow, box sizing, per-edge margins/padding/borders, auto/min/max/intrinsic sizing, percentages, aspect ratio; static/relative/absolute/fixed positioning. | Floats, multicolumn, tables, sticky positioning and alternate writing modes. | +| Flexbox | Row/column, wrapping, gaps, basis/grow/shrink, order, auto margins and cross/main alignment, with intrinsic-size interactions. | Any value/edge case not yet demonstrated must remain visibly unsupported, not approximated. | +| Grid | Explicit/repeated tracks, `fr`, `minmax`, basic auto-placement, spans and row/column gaps. | Subgrid, masonry and dense-placement claims beyond tested cases. | +| Paint | Solid colors including OKLCH/alpha, per-edge borders, per-corner radii, outlines/rings, layered shadows, linear gradients, raster images/object-fit, clipping and stacking contexts. | Filters, backdrop blur, masks, blend modes, arbitrary SVG and 3D CSS transforms. | +| Typography | Bundled fonts and weights, shaping, fallback, baseline, line height, wrapping, whitespace, letter spacing, alignment and ellipsis. | Rich text editing, browser text selection and complete script/font feature coverage. | +| State/environment | Hover/active/focus/focus-visible/disabled, data/ARIA selectors, group/peer variants through normal selectors, viewport breakpoints, dark mode and reduced motion. | Container queries and additional device/environment features. | +| Motion | CSS transitions for colors, opacity and 2D transforms; real timing/delay/interruption, origin and hit-test transforms. | General keyframe animations and layout-property animation. | +| Interaction | Button activation, tab navigation, visible focus, pointer/touch/wheel handling, nested scrolling, clipping-aware hit tests and pointer-events. | Drag/drop, portals, browser event/DOM API completeness and web-component libraries. | + +Group opacity composites the subtree; it is not copied independently to every child's paint. +Rounded clipping applies to descendants and hit testing. Hover observes input capabilities rather +than simulating sticky mouse hover on touch-only devices. Disabled buttons cannot emit activation; +keyboard focus must not also trigger game controls. Preserve existing intentional HUD click-through +and interactive-region conventions. + +Supported interactive nodes carry accessible names, roles, disabled state and focus through the +backend's accessibility interface. Platform accessibility support is reported per tested target; +an untested bridge is not an accessibility pass. Absence of a platform bridge blocks production +accessibility claims, not documentation work. No claim of a general accessible forms toolkit. + +The Core profile is deliberately narrower than the complete product ambition. Advanced effects, +forms/IME, extensive SVG, additional platforms and DOM-dependent UI libraries get separate PRDs, +not hidden subtasks here. Grid, typography, custom CSS and interaction are not optional shortcuts +for completing this Core profile. + +### Unsupported features and failures + +Emit a source-mapped compatibility report for properties, values, at-rules, selectors and element +APIs outside the profile. CSS parsing must retain normal recovery and legal unknown-class behavior. +Do not reinterpret syntactically invalid CSS as a new native language. + +Production native-CSS builds fail for unsupported active author rules unless the rule is behind an +explicit, correctly evaluated `@supports` branch with an authored supported fallback. Compute +feature queries from real capability, not parser recognition alone. Treat inactive vendor-specific +Preflight rules and unsupported element defaults through a documented baseline audit; blindly +rejecting every rule in upstream Preflight would make a normal Tailwind import impossible. + +Runtime-only failures name the source/element/property through the existing visible error path. +Development HMR may retain the last good stylesheet while visibly reporting failure. Never +silently drop paint, silently substitute a font, or automatically attach a WebView. Failed bundles +must not be reported as native-compatible just because their JavaScript compiled. + +## Browser-reference verification + +The oracle is **the same JSX and emitted CSS rendered by a pinned browser**, not the old +inline-native overlay. Capture the exact component source, CSS hash, font/image hashes, viewport, +DPR, platform, graphics backend and tool versions. Wait for fonts/assets and freeze time/randomness. +The browser reference may use browser rendering; native acceptance may not. + +Use the existing playtest and native-conformance infrastructure. Planned fixtures should cover: + +| Fixture family | Required observations | +| --- | --- | +| Tailwind plus custom CSS | Example above, custom theme, `@apply`/custom utility, stylesheet import/HMR and a plain-CSS-only arm. | +| Cascade and selectors | Reverse class-string order; layers and important overrides; inherited/cyclic variables, structural/group/peer/data rules. | +| Layout | Flex shrinking/wrapping and long text; grid tracks; relative/absolute/fixed boxes; root-font, viewport and DPR changes. | +| Paint and fonts | Rounded clipping, shadows/rings, gradients, subtree opacity, image fitting; weights, multiline text, Portuguese and mixed-direction sample text. | +| Input and motion | Mouse and touch states, focus traversal, disabled activation, nested scrolling, transformed hit tests, interrupted transitions, reduced motion and no game-input leakage. | +| Lifecycle and rejection | Class/style removal, theme updates, keyed reordering, mount/dispose, asset reload, unsupported CSS and device-loss recovery. | + +Proposed correctness thresholds: every measured box edge and text baseline within **1 CSS pixel** +of the reference; identical line breaks, visible strings, state outcomes and focus order. Compare +the full UI crop at a fixed time, targeting **SSIM >= 0.99**. Any text-rasterization tolerance must +be fixed in advance and accompanied by text metrics; do not mask missing glyphs, wrong wrapping, +missing shadows or interactive regions. A high image score cannot override semantic failures. +These are acceptance targets, not measured results; disclose any necessary tolerance amendment. + +Run a native desktop lane and Android-emulator lane separately. Require nonblank actual captures, +correct interaction output, and instrumentation showing the selected native-CSS backend with no +WebView creation/load. Test stubs and offscreen HTML screenshots are not native proof. Report the +specific desktop OS; one desktop run proves nothing about other desktop platforms or iOS. + +### Performance and resource boundaries + +Benchmark a deterministic 1,000-node HUD, 120 warm-up frames followed by 1,000 measured frames: +unchanged, 10% changed at 10 Hz, and an active transition/scroll case. Record UI CPU work, GPU time, +upload/copy cost, total game-frame p50/p95/p99, input-to-present latency, heap/RSS and GPU resources. +Compare enabled native-CSS to UI-off and the existing web backend on the same hardware. Measure +startup and binary/package size separately. An emulator is functional evidence, not phone-speed proof. + +An unchanged UI performs no repeated stylesheet parse, full-tree style/layout pass, text shaping +or React reconciliation. Cache reuse is observable; dirty updates invalidate only affected work +where semantics permit. Rendering/composition may still occur each game frame. Bound caches and +release per-root resources on unmount; repeated mount/dispose must not grow retained resources. + +Opted-out applications initialize no new engine and import no new UI dependencies. Packaging must +avoid mandatory native-CSS binaries for builds that do not select the feature. No claim that this +backend is faster, smaller or lower-power than WebView is made in advance. Freeze any numerical +release budget against named reference hardware before declaring performance readiness; report +actual data rather than selecting a flattering FPS threshold afterward. + +## Integration boundaries + +Use `packages/ui` for shared authoring/state/intent plumbing, `packages/runtime-native` for the +optional native binding/surface, and `packages/playtest` for proofs. Discover actual build-loader +entry points through the capability manifest before writing source. Package placement is subject +to the existing dependency-isolation rule; this proposal does not invent a shipped +`@threenative/tailwind` package. The existing Tailwind dependency already owns that interface. + +Do not expand `IOverlayStyle` into a second CSS dialect or redirect existing `View`/`Text` callers. +Keep the game portable entry free from `react-dom`, CSS-injection DOM calls and new browser shims. +The native UI host and browser React DOM host consume the same authored component tree through +separate bootstrap adapters. Cross-target state-store identity and subscription behavior must be +preserved. Public authoring documentation changes only when the corresponding API ships. + +Backend admission must demonstrate the unchanged acceptance component, native surface integration, +a native Android build, input dispatch and usable license/dependency boundaries before extending +the profile. Inspect upstream and transitive licenses, including font redistribution; do not +assert that the top-level renderer license covers every dependency. Failure triggers a documented +backend decision or upstream fix, not an unrequested return to the utility-only proposal. + +## Execution phases + +All test paths and fixture names below are **planned**; none are claimed to exist or pass today. +These three phases implement only the Core HUD profile. Larger follow-on profiles require separate +PRDs/PRs rather than extending this checklist indefinitely. + +### Phase 1 — Standard CSS pipeline and native admission slice + +- [ ] Standard UI sources produce the same Tailwind/CSS styling artifact for both targets. proof: planned `packages/ui/__tests__/native-css-build.spec.ts` covering the pinned upstream compiler, plain CSS, theme/custom utilities, assets, source maps and dependency isolation. +- [ ] The unchanged acceptance component mounts on an actual native CSS backend. proof: planned `native-css-admission.playtest.json` through the existing desktop runner, including native surface ownership, button dispatch, no WebView and required architectural/documentation updates. + +### Phase 2 — Core HUD semantics + +- [ ] Core cascade and layout match the browser oracle. proof: planned `native-css-layout.playtest.json` plus focused selector/cascade unit cases covering the Core matrix, geometry, text wrapping and viewport/font changes. +- [ ] Core paint and typography match the browser oracle. proof: planned `native-css-paint.playtest.json` with the documented image/metric thresholds, real bundled fonts, alpha/clip/shadow/gradient cases and asset hashes. +- [ ] Core interaction behavior matches the browser oracle. proof: planned `native-css-input.playtest.json` covering focus, disabled controls, mouse/touch/wheel, scroll, transition interruption and game-input isolation. + +### Phase 3 — Cross-target proof + +- [ ] The complete Core HUD corpus passes on a named native desktop platform. proof: the planned Core scenarios through the existing playtest runner with `--target desktop`, actual captures and backend identity; record OS and GPU backend. +- [ ] The complete Core HUD corpus passes on an Android emulator without a WebView. proof: the same planned scenarios through the existing runner with `--target android`; record emulator/API/graphics configuration and actual interactions. + +## Acceptance criteria + +- [ ] Native-CSS lifecycle and invalidation meet the resource contract. proof: planned `native-css-lifecycle.playtest.json`, instrumented unchanged/dirty/mount-dispose tests and the matched resource/performance run described above; document measured costs without claiming unrun platform speedups. + +Core delivery additionally requires the documented strict compatibility diagnostics and public +support matrix: each feature claim points to a passing fixture. Checklist completion cannot waive +the normative source-parity, no-WebView, dependency-isolation or supported-profile requirements. + +## Blocked on + +No external dependency blocks reviewing or editing this PRD. No implementation has been attempted. +Backend selection/interop, the bounded Charter amendment and the Core engine gaps are work inside +this proposal, not facts already established by a dependency README. + +Physical-device performance and platform accessibility readiness require the corresponding +hardware/OS test lane; the implementing agent must try the available lane and name the actual +missing prerequisite before reporting it blocked. iOS and other untested desktop platforms have +no readiness claim from the desktop/Android-emulator checks in this PRD. + +## Decisions and rollout + +On 2026-10-01 the owner clarified: the same Tailwind and CSS interface must output native UI. +That requirement supersedes the previous bounded utility-parser design. Its eight unchecked +items are replaced by this Core HUD plan because they proved the wrong contract; no completed +work or evidence is removed. The existing filename is retained to preserve PR #388 links. + +This proposal recommends a reused standards-oriented native UI engine, with CSS allowed at +runtime and no public proprietary style IR. The candidate library and UI-only Charter exception +remain design proposals pending their stated implementation evidence; the owner did not select +a specific Rust library or authorize a silent default-renderer change. + +Ship as experimental opt-in after the native proofs. Preserve the web and lightweight-native +backends and their existing tests. Rollback changes renderer selection/bootstrap, not authored +JSX/CSS. Rollback must be explicit: no hidden WebView fallback. Default promotion, advanced CSS +profiles, forms/IME and additional platform certification are separate decisions and PRDs. + +## Verification of this documentation change + +Only this Markdown PRD is changed. Repository inspection is pinned to the audit commit, with +external references checked on the document date. Structural/progress-check results belong in the +PR body. Keep all implementation boxes open and the draft at `prd:0%` until real evidence exists. +No full repo build, native execution, visual parity, performance improvement or CI-green claim is +implied by creating this document. diff --git a/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md b/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md index 34f5237cc9..f4324fc13d 100644 --- a/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md +++ b/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md @@ -14,7 +14,7 @@ last execution covered **0 rows**. **Depends on:** nothing to start. Overlaps [PRD-295](../done/PRD-295-the-native-platform-lane-has-never-been-green.md) (which made the lane advisory and is fixing its desktop legs) and -[PRD-303](../CI/PRD-303.md) (which pays for added CI coverage from measured minutes). **This PRD +[PRD-303](../done/PRD-303.md) (which pays for added CI coverage from measured minutes). **This PRD owns the Android leg only**; it does not promote the lane to required — that is PRD-295's call — and it must not duplicate PRD-303's nightly-schedule work. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md b/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md new file mode 100644 index 0000000000..17a273b524 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md @@ -0,0 +1,53 @@ +# Execute this batch + +## Agent handoff + +```text +Read AGENTS.md, docs/PRDs/AGENTS.md and this folder's README.md. +Work from current origin/develop in an isolated branch/worktree. +Choose the highest-value unblocked item from the execution queue. +For an existing owner, read and update its canonical PRD rather than writing a replacement. +Reconcile the current code, tests and merged/open PRs before treating any older claim as missing. +Search capabilities and inspect the installed Three pin and its patches before adding a system. +Keep appearance in generated game source. Reuse the renderer, mixer, loader and frame lifecycle. +Open one implementation PR for that PRD, not one PR for this entire implementation batch. +Implement the smallest bounded phase, run its named tests and playtest, and update actual results inline. +Do not tick another platform's box from browser proof or a green metadata/build check. +Rebase/reconcile shared files between dependent tasks; do not merge parallel changes blindly. +Stop at a failed admission gate with the measured reason, or complete/archive the PRD honestly. +``` + +## Proof setup + +Each new PRD names future test/scenario paths. Those paths are not claimed to exist today. Add the smallest opt-in fixture to the existing `examples/abyss-framework` example, or reuse a better existing fixture and change the proof path in the canonical PRD. Its normal scene must remain unchanged. Do not create a new engine package merely to hold an example. + +`VQ_URL` must point to the built/served fixture. `VQ_NATIVE_EXECUTABLE` must name that same fixture's packaged Linux executable. `VQ_ANDROID_DEVICE` is the chosen online emulator/device serial for VQ-01. Build/serve/package using the repository's existing commands; variables with `:?` deliberately refuse an unset value. Capture the fixture route, exact cohort/commit and adapter with the result. Do not run a scenario against an unrelated default scene and report success. + +For new public mechanisms, use the existing capability manifest/MCP and update generated guidance. Inspect the installed pin (`three@0.185.1` at the audit baseline) before using an addon seen in online documentation. An incompatible addon can be declined, scoped down, or introduced through an explicitly qualified upgrade; none licenses an automatic fork of the renderer. + +## Checks and progress + +Run `pnpm prd:progress ` before work and after each phase. New specs have three phases, six phase boxes and two acceptance boxes, with `proof:` on each. All start at zero; a written plan is not implementation progress. Existing PRDs may need their old checklist shape repaired **without deleting completed work**. + +For implementation, use the root AGENTS.md runtime gates and the exact feature playtests. For this documentation-only collection, use the repository's prose lane: + +```sh +pnpm check:docs +pnpm exec vitest run scripts/__tests__/check-doc-links.spec.ts scripts/__tests__/evidence-budget.spec.ts scripts/__tests__/evidence-citations.spec.ts scripts/__tests__/sync-agent-docs.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts +``` + +Those commands are required execution guidance, not a claim they were run while filing this collection. No runtime test, new benchmark result or human visual approval is supplied by this planning change. + +## Concurrency and shared ownership + +| Workstream | Can proceed independently | Serialize or reconcile | +|---|---|---| +| Build/runtime correctness | VQ-01 | Runtime artifact capability contract and loader/cook changes | +| Native output correctness | VQ-02 | `renderer.ts`, output attachments and native replay | +| Temporal/exposure | PRD-269, then PRD-455; PRD-339 after output is reliable | `chain.ts`, `velocity.ts`, `worldEnvironment.ts`, quality source | +| Animation | VQ-04 followed by VQ-05 | One mixer/action owner; do not let both reset action weights | +| World transitions | PRD-460, then proxy/impostor follow-ups | CPU and GPU WorldCells selection, per-instance identity, shadow alpha | +| Lighting | VQ-03 and upstream admission for VQ-06 | Material lighting contribution, probe capture budgets and PRD-457 | +| Optional look work | Decals and material fixture authoring | Shared generated render plumbing and temporal/alpha semantics | + +Hardware access, rights to assets and human aesthetic approval are named blockers, not progress boxes. Browser and Linux-native qualification is not a promise of phone frame rate. Release scope is unchanged; this folder does not declare all visual experiments release-critical. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md b/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md new file mode 100644 index 0000000000..48a86d9185 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md @@ -0,0 +1,43 @@ +# Existing canonical PRDs in this batch + +These are **references to existing work, not duplicate specifications**. Their files, evidence, completed boxes and associated PRs stay at their canonical paths. This preserves history and in-flight branches while providing one execution entry point. + +Baseline inspected: `d72778382b134ef8763f58825cb4d4fd8cc0f6e3` on `develop`. Old status prose is not authoritative over newer code. Before execution, compare the relevant implementation and tests, preserve all proved work, add only the unresolved scope and normalize missing per-phase/proof markers in the canonical document. Never turn a stale `PROPOSED` heading into a rewrite order. + +| Queue | Owner | Outcome | Execution note | Dependency / coordination | +|---|---|---|---|---| +| Wave 0 | [PRD-269](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md) | Velocity/history correctness | Source exists; reconcile old PROPOSED text with current velocity code. Qualify gaps; do not rebuild it. | Before 455, VQ-13 and temporal hair/fog consumers. | +| Wave 1 | [PRD-455](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md) | Temporal AA and dynamic-resolution reconstruction | Reuse existing owner. Same-resolution temporal qualification is its first bounded milestone; an upstream TRAA node is not by itself an upscaler. | 269; coordinate MSAA, jitter, camera cuts and instance identity. | +| Wave 1 | [PRD-339](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md) | Auto exposure / eye adaptation | Reuse; adapt the current generated-source exposure seam, not a second tone mapper. | VQ-02 output correctness; include dark/bright transition traces. | +| Wave 1 | [PRD-460](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-460-invisible-streaming-transitions.md) | Prop arrival and LOD crossfades | Reuse; terrain morphing already exists. Apply the remaining work to CPU and GPU-selected scatter routes. | Existing WorldCells and PR #375; coordinate temporal reactive/disocclusion behavior. | +| Wave 2 | [PRD-456](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-456-distant-world-cell-proxies.md) | Distant cell HLOD / silhouettes | Reuse; do not confuse per-asset impostors or shadow proxies with cell HLOD. | Existing world-resource accounting; 460 handoffs. | +| Wave 2 | [PRD-457](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-457-virtual-shadows-scale-by-measurement.md) | Shadow scheduling, sparse-page admission, local-light boundary | Reuse. Directional virtual shadows already exist; phase 3 also owns local-light work. | Coordinate VQ-06; preserve a correct cached-level fallback. | +| Wave 2 | [PRD-344](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md) | Cheap baked contact occlusion | Reuse; distinguish this outcome from existing lightmaps and imported AO textures. | Use current cook/mesh data; retain mobile low-tier benefit. | +| Wave 2 | [PRD-268](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md) | Static diffuse probes / off-screen lighting | Source exists; reconcile implementation and remaining proof before any extension. | Precondition for VQ-09; not a replacement for specular VQ-03. | +| Cross-cutting | [PRD-341](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | Tone and luminance assertions | Implemented and archived by [PR #392](https://github.com/ThreeNativeHQ/threenative/pull/392), merged 2026-10-02. Reuse its captured-tone observations, assertions and CLI; a luminance score is not an aesthetic verdict. | Useful for 339, VQ-02 and matched material tests. | +| Cross-cutting | [PRD-342](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-342-where-the-frame-goes-pass-cost-ablation.md) | Measured post-stage ablation | Reuse rather than build another profiler. Inspect current frame/pass instrumentation first. | Useful for 455 and every optional expensive effect. | + +## Canonical checkout paths + +```text +docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md +docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md +docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md +docs/PRDs/unreal-like-features/PRD-460-invisible-streaming-transitions.md +docs/PRDs/unreal-like-features/PRD-456-distant-world-cell-proxies.md +docs/PRDs/unreal-like-features/PRD-457-virtual-shadows-scale-by-measurement.md +docs/PRDs/AAA-visuals/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md +docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md +docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md +docs/PRDs/AAA-visuals/PRD-342-where-the-frame-goes-pass-cost-ablation.md +``` + +## Companion work: resume, do not re-file + +- [PRD-454](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-454-worldcells-budget-real-resources.md): actual resource budgets; precondition to texture-residency extensions. +- [PRD-458](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-458-open-worlds-hold-60-fps-by-default.md) and [open-world PRD-473](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md): preserve merged GPU/LOD/shadow work and finish remaining visible/performance qualification, including impostor coverage/shadows. Their merged [PR #375](https://github.com/ThreeNativeHQ/threenative/pull/375) still describes open acceptance work. A merge does not establish 60/120 fps. +- [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md): VQ-03 is the bounded local-reflection child for row 5; VQ-08 covers the cloud/overhead-field outcome related to row 4. Record these child links when executing that parent's own admission work; filing a child does not mean its gate passed. Local-shadow row 8 remains coordinated through PRD-457. +- [PRD-343](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md) and [PRD-345](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md): optional existing work for subpixel emitters and authored backlit readability. Do not implement a compulsory rim light in core. +- [Merged rain/snow PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382): reuse the kits and preserved completed PRDs. Only the reported native bloom regression is added here as VQ-02. Snow rolling resistance is nonvisual physics follow-up and is not silently included in this visual batch. + +The repository already has two different PRDs numbered 473. Use **full paths and titles**, not the number alone. New `VQ-01` through `VQ-15` identifiers intentionally avoid allocating more conflicting global integers. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md new file mode 100644 index 0000000000..bd944151f2 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-01 — Native asset compatibility follows the selected runtime and actual decoders + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 0 / correctness. +**Dependencies:** None. This is a build/runtime contract repair, not permission to remove compatibility guards. + +## Grounding and intended outcome + +[packages/create-threenative/src/build.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/create-threenative/src/build.ts) still rejects compressed assets by mobile target in `assertNativeAssetsCompatible`, while the runtime documentation identifies Android V8 as the default. The source establishes inconsistent capability selection; it does not prove that shipping V8 alone makes the Basis, Meshopt or Draco loading paths work. Workers, bundling, file access, decoder payloads and GPU formats are separate requirements. + +**Outcome:** A packaged game either decodes its declared assets on its selected runtime or is refused before publication with an accurate codec-specific reason. Android V8 and QuickJS must not be conflated; desktop QuickJS must not bypass checks simply because it is desktop. + +## Design and ownership + +Resolve one runtime capability record from the exact selected artifact/cohort before cooking. Thread it through asset cooking, the compatibility guard, native bundling and the runtime loader. Record engine, WASM support, packaged decoder versions and supported texture formats independently. An unknown capability takes an explicit decoder-free path or fails; it is never inferred from the build machine or from a desktop executable used to package Android. Keep QuickJS rollback supported. Do not silently change an authored asset or trust a manifest that disagrees with the packaged bytes. + +Reuse `runtimeHasWebAssembly`, `assertNativeAssetsCompatible`, asset compiler decoder options and `packages/runtime-native/scripts/bundle.mjs`. Extend the artifact capability contract only where necessary. No new renderer, codec implementation, or blanket Android whitelist. Preserve current WebGL and decoder-free fallbacks. + +## Required behavior + +- A table-driven suite covers web, desktop V8, desktop QuickJS, Android V8, Android QuickJS and unknown artifacts, with each codec independently allowed or refused. +- The loader validates actual KTX2 block formats and fallback transcodes. Mesh compression is separately tested; smaller download bytes are not reported as smaller GPU vertex buffers. +- Cook-cache keys include decoder capabilities and cohort identity. Switching engine or codec cannot reuse incompatible output. +- Diagnostics identify the asset, codec, selected artifact and missing capability. Build failure must leave the previous packaged artifact intact. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — One capability decision + +- [ ] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [ ] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + +### Phase 2 — The packaged bytes are authoritative + +- [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`. + +## Acceptance criteria + +- [ ] All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record encoded bytes, decoded texture/geometry bytes and peak load memory per codec. Enabling a path requires correct pixels and bounded lifetime, not a promised compression ratio. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Physical Android performance and thermals require a named phone and are separate from emulator correctness. Missing codec qualifications must remain individual exclusions. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md new file mode 100644 index 0000000000..4e8b3a9cad --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-02 — A requested post-processing stage never turns the native world into a blank frame + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 0 / correctness. +**Dependencies:** None. Can start alongside VQ-01. + +## Grounding and intended outcome + +The merged [PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) reports that bloom at threshold 0.92 blanked the native snow world while web worked; the kit shipped bloom disabled. This is a reported regression, not a reproduced root cause. [packages/core/src/render/chain.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/chain.ts) reports graph installation, which alone cannot prove visible output. + +**Outcome:** A minimal emissive fixture and the snow kit render correctly with bloom enabled on browser and native, or the stage is explicitly rejected before a blank frame is presented. The repair belongs to the identified failing layer, not to a hidden game-only workaround. + +## Design and ownership + +First reduce the reported snow case using its exact exposure, threshold, attachments and renderer cohort. Trace graph output, tone mapping, MRT allocation and native texture lifetime without assuming which is faulty. Keep one tone-map/output transform. A stage being constructed is not a visual success criterion. Preserve the no-post path, lazy attachment allocation and existing stage-disposal contract. + +Inspect the snow generated render source, shared `worldEnvironment.ts`, `RenderChain`, renderer output installation and native WebGPU replay only as the reproduction directs. No unrelated post-stack rewrite and no arbitrary brightness changes disguised as a bug fix. + +## Required behavior + +- Run bloom off/on, thresholds below/at/above 0.92, resize, scene switch and quality-tier transitions on the same fixture. +- Assert finite pixels, a nonblank world region and a measurable halo around the emissive test patch; inspect the world separately from the HUD. +- An unsupported stage reports its reason and preserves a valid world output; it must not be reported as visually qualified. +- Keep enabled and disabled resource lifetimes bounded across at least 20 toggles. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Reproduce and repair the failing layer + +- [ ] Add the regression fixture and an assertion that detects the reported blank world independently of the HUD. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`. +- [ ] Fix the demonstrated source of failure while preserving exposure, tone mapping and the authored snow appearance. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`. + +### Phase 2 — Keep graph replacement safe + +- [ ] Cover resize, stage replacement and tier transitions without stale targets or duplicate output transforms. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`. +- [ ] Add effect-region assertions and allocation-lifetime checks, then restore bloom only on qualified paths. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The emissive fixture and snow scene pass the off/on and threshold matrix on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-postprocessing-parity.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same fixture and authored snow settings pass on a packaged Linux native host, with real world captures and zero GPU validation errors. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-postprocessing-parity.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The original reported case is either reproduced and fixed or explicitly reclassified with the exact non-reproducing cohort; no unrelated change is presented as its fix. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record bloom cost separately from world rendering. No unbounded render-target growth across 20 toggles and resizes. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Other native operating systems and physical Android require their own runs before extending the qualification claim. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md new file mode 100644 index 0000000000..a394698848 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-03 — Off-screen specular reflections come from a complete local probe + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / lighting. +**Dependencies:** Coordinate with PRD-381 row 5; this is its bounded child specification. Do not duplicate its capture-completeness work elsewhere. + +## Grounding and intended outcome + +[packages/core/src/render/probe-volume.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/probe-volume.ts) owns diffuse irradiance, not local specular reflection. The existing [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md) already identifies a dynamic local reflection probe. SSR and a global environment remain useful incumbents. + +**Outcome:** A polished object in a room reflects an off-camera landmark from that room. Entering a second room changes the fallback smoothly, without a half-updated cubemap, self-capture recursion or global-environment replacement for unrelated objects. + +## Design and ownership + +Reuse Three cube capture and PMREM. Maintain separate capture, completed and published generations; a six-face capture is published only after prefilter completion. A scene/light revision change invalidates an inconsistent generation. Bound capture work and coalesce refresh requests. Exclude the reflective receiver and viewmodel explicitly. Supply box-projection data and completed textures as mechanism; the generated material owns roughness, intensity and the SSR-to-probe-to-global composition. SSR confidence must not double-add specular energy. + +One box probe first, then deterministic blending of two overlapping probes. New shared code is limited to capture scheduling, versioning and ownership and must beat the two-consumer direct baseline. Shader sampling and all look choices stay in generated `src/render/`. + +## Required behavior + +- A failed or cancelled capture retains the last complete result; disposal releases owned targets without disposing borrowed scene assets. +- The two-room test uses a contrasting off-screen landmark and verifies a changed reflection without changing the visible room lighting. +- Box projection handles translation and declared scale; unsupported transforms fail explicitly. +- Material assignment is receiver-specific, and no enabled probe adds a second uncontrolled game loop. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Complete captures and bounded ownership + +- [ ] Implement generation-safe incremental cube capture and PMREM publication using the existing render lifecycle. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`. +- [ ] Implement cancellation, coalesced invalidation and bounded two-probe resource ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`. + +### Phase 2 — Compose the local specular fallback + +- [ ] Add game-owned box-projected sampling and deterministic overlap blending. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`. +- [ ] Integrate the existing SSR fallback without double-counting specular light and without capturing the viewmodel or receiver recursively. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The two-room off-screen-landmark scenario verifies local reflection, overlap continuity and refresh behavior on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-local-reflection-probes.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same package executes the two-room scenario on Linux native with complete-generation markers and matching probe selection. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-local-reflection-probes.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The off-screen reflected landmark survives a camera turn that removes it from screen space; every visible probe generation is complete. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Report capture faces, prefilter cost, capture age and resident bytes. Bound active probes and skip redundant captures; no claim that dynamic probes are free. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Physical-device quality/performance tiers are qualified separately; native support must not be extrapolated from a browser cube render. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md new file mode 100644 index 0000000000..74d5cca547 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-04 — Locomotion blends by speed and direction without restarting its gait + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / character motion. +**Dependencies:** Uses existing AnimationPlayer, SkeletalMesh3D and stride synchronization. Coordinate its action ownership with VQ-05. + +## Grounding and intended outcome + +[packages/core/src/animation.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/animation.ts) already implements clip playback and crossfades. This PRD does not rebuild those. The remaining product outcome is continuously weighted, phase-coherent locomotion across speed and direction. Historical animation-state-machine restrictions remain in force: this is not a graph editor or serialized animation IR. + +**Outcome:** A licensed rig moves smoothly from idle through walking to running and strafes in a 2D direction space. Changing direction or speed does not repeatedly restart clips or introduce a one-frame pose pop. + +## Design and ownership + +Use ordinary Three AnimationActions with one authoritative updater. Put locomotion states, clip selection, thresholds and transitions in editable game TypeScript. Admit shared numerical weight/phase helpers only after two consumers demonstrate less total code. Use sorted intervals for 1D and a deterministic declared interpolation domain for 2D; handle points outside that domain explicitly. Preserve normalized phase for compatible gait clips, with an explicit opt-out for incompatible authored cycles. Do not confuse stride rate with root-motion extraction. + +Idle/walk/run and four-direction locomotion only. No motion matching, full Animator graph, visual editor, new asset format or automatic semantic inference from clip filenames. + +## Required behavior + +- Weights remain finite and nonnegative and sum to 1 within 1e-6; duplicate samples and degenerate 2D domains fail or use an explicitly documented fallback. +- Identical input replay is deterministic, and no independent mixer loop is created. +- A speed sweep preserves gait phase across compatible clips; a large instantaneous intent change has a bounded authored transition. +- Test rapid idle/walk/run/idle reversal and returning to a still-contributing action, not just a fresh third clip. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Continuous weights with explicit boundaries + +- [ ] Implement and test deterministic 1D and 2D weight evaluation, including degenerate and out-of-domain inputs. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`. +- [ ] Integrate weighted action ownership and phase synchronization without breaking existing play/once/stride behavior. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`. + +### Phase 2 — Two real locomotion consumers + +- [ ] Drive a first-person-visible body and a third-person rig through the same weight/phase mechanism with game-owned settings. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`. +- [ ] Expose selected clips, weights and phase in existing gameplay observations and verify rapid reversals against sampled poses. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The real-rig speed/strafe sweep passes weight, phase and pose-continuity assertions on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-locomotion-blend-spaces.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The identical input trace passes on Linux native; inspect the transition sequence rather than a single still frame. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-locomotion-blend-spaces.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] Continuous speed/direction changes require no game-specific replacement mixer, and a same-rig trace produces matching clip weights across the two qualified runtimes. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Count active actions and allocations. Keep nonparticipants at the existing AnimationPlayer cost and document any extra per-rig work. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +A license-clear multi-clip rig is required. Use repository-owned fixtures where possible; downloading commercial animations is not assumed. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md new file mode 100644 index 0000000000..7f1cbf3732 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-05 — Upper-body actions and additive reactions compose over locomotion + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / character motion. +**Dependencies:** Coordinate with VQ-04 through one action owner. Existing constrained IK must remain after authored animation evaluation. + +## Grounding and intended outcome + +[packages/core/src/animation.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/animation.ts) exposes the Three mixer and existing clip transitions. Additive animation is an upstream Three capability, not missing mathematics. What needs qualification is masked layer composition, reference-pose handling, lifecycle and ordering for actual ThreeNative characters. + +**Outcome:** A character continues walking while aiming or reloading; recoil and breathing add to the pose without replacing the lower-body gait. Removing a layer restores the underlying animation with no accumulated drift. + +## Design and ownership + +Keep state selection and masks in game-owned TypeScript. Resolve masks against cloned rigs, filter or weight tracks through a single mixer owner, and reuse upstream additive-clip preparation against an explicit reference pose. Never modify shared source clips. Distinguish override layers from additive deltas and define quaternion normalization. Order: locomotion/base, override/additive layers, IK, render; root movement retains its existing owner. A generalized animation graph is explicitly out of scope. + +One base layer, one masked override and two bounded additive layers; masks are explicit bone names with optional descendant expansion. Face-control and root-motion ownership conflicts must be refused or explicitly excluded, not silently mixed. + +## Required behavior + +- With layer weight zero, sampled bone transforms equal the base-only result within numeric tolerance. +- Two cloned characters sharing source clips can use different masks and weights without cross-talk. +- Missing bones, incompatible tracks and missing additive reference poses are actionable errors. +- Loop/once completion, interrupted reloads, repeated weight ramps and removal/disposal leave no residual pose or live action. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Correct layer data and ownership + +- [ ] Implement mask resolution and immutable additive preparation using ordinary Three clips/actions. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`. +- [ ] Add deterministic composition and reference-pose tests, including independent cloned characters. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`. + +### Phase 2 — Integrate locomotion and IK ordering + +- [ ] Compose reload/aim and recoil over the VQ-04 or existing locomotion consumer with one updater. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`. +- [ ] Verify root ownership, IK-after-animation ordering, cancellation and layer removal through pose observations. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] A walking/reloading/recoiling character passes lower-body preservation and layer-removal assertions on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-masked-additive-animation.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The identical layered trace passes on Linux native with sampled bone transforms and retained source-clip hashes. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-masked-additive-animation.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] Layer weight zero is a base-only identity, and removing all added layers restores base animation without pose drift or shared-rig mutation. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Bound layer and action counts. Measure CPU per rig; no mandatory overhead for characters using only the existing player. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Physical-device crowd scaling and additional character rigs need separate qualification; these do not block the two-runtime correctness slice. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md new file mode 100644 index 0000000000..6627765051 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-06 — Many local lights use a qualified upstream tiled or clustered path + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / scalable lighting. +**Dependencies:** Use PRD-457 for local shadow work. This PRD owns direct-light selection, not a new shadow atlas. + +## Grounding and intended outcome + +The reviewed render surface does not expose a qualified many-light route. Current upstream documents [TiledLightsNode](https://threejs.org/docs/pages/TiledLightsNode.html), [ClusteredLightsNode](https://threejs.org/docs/pages/ClusteredLightsNode.html) and [DynamicLightsNode](https://threejs.org/docs/pages/DynamicLightsNode.html). [pnpm-workspace.yaml](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/pnpm-workspace.yaml) pins Three 0.185.1: online availability is not proof that an addon exists or works in this pin. + +**Outcome:** A corridor/city-night fixture with 256 unshadowed point lights renders the same intended lighting at a lower measured cost than the ordinary per-light baseline on a workload where light-list culling helps. + +## Design and ownership + +Inspect the installed Three pin and patches before selecting a donor. Prefer its supported tiled/clustered or dynamic-light implementation. Use an upstream-compatible upgrade only with regression qualification; do not write a parallel Forward+ renderer from the audit wording. Unsupported light types, cookies and shadowed lights retain an explicit stock route. Cluster overflow must preserve illumination through a correct fallback rather than dropping lights. Game source owns light intensities, colors and geometry. + +Point-light throughput first; explicitly enumerate support for spots, directional lights, transparency and shadow-casting lights. Keep the existing renderer, material system and directional shadow path. + +## Required behavior + +- A correctness fixture compares the optimized and reference direct-light results before temporal or post effects. +- Overflow, camera clipping, light movement, addition/removal and depth-separated lights are covered. +- A missing addon or unsupported native feature is reported, not replaced with an unqualified backend. +- The existing PBR, animated/instanced and probe-consuming material fixtures keep their intended lighting. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Admit the installed upstream path + +- [ ] Integrate the smallest compatible upstream lighting route with explicit per-light support and a correct fallback. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`. +- [ ] Cover light-list correctness, overflow and scene-lifetime changes against the ordinary Three path. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`. + +### Phase 2 — Connect real materials and measured selection + +- [ ] Exercise the route on animated, instanced and transparent receiver fixtures without replacing their material ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`. +- [ ] Add direct-light work counters and a measured opt-in/admission decision for the representative many-light workload. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The 256-light scene passes image/correctness checks and a paired GPU-cost comparison on a named WebGPU adapter. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-many-local-lights.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same fixture passes on Linux native and records its own cost result; a browser speedup is not reused as a native result. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-many-local-lights.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The optimized route preserves the reference illumination and wins on the declared many-light workload; otherwise keep it opt-in or decline it with the measurement. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Measure direct-light and shadow cost separately. No claim that 256 shadowed lights or every spot-light feature is supported by a point-light cluster implementation. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Additional hardware and Three upgrades require their own compatibility runs before default-on promotion. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md new file mode 100644 index 0000000000..89257e20a3 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-07 — Local volumetric fog composes with depth, lights and existing atmosphere + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / atmosphere. +**Dependencies:** Independent of clouds. Reuse VQ-06 only for any local-light feature it has actually qualified. + +## Grounding and intended outcome + +ThreeNative already has atmosphere LUTs and god-ray composition. Current upstream [VolumeNodeMaterial](https://threejs.org/docs/pages/VolumeNodeMaterial.html) and volumetric lighting machinery are reuse candidates, not proof of a finished ThreeNative fog product. Recheck the installed pin and the merged rain kit before introducing a new algorithm. + +**Outcome:** A bounded mist volume in an interior/exterior fixture responds to the main light and declared local lights, is occluded by solid geometry and blends into the existing atmosphere without becoming a uniformly bright screen overlay. + +## Design and ownership + +Start with the supported upstream volume/material or existing kit implementation. Density functions, phase function, color and artistic controls remain generated TSL/game source. Share only depth access, lifetime and ordered dispatch that the current render loop cannot already supply. Composite radiance with transmittance in the scene-linear graph before its one output transform; specify the relationship with aerial perspective and god rays so the same medium is not added twice. Keep history optional until its disocclusion behavior is qualified. + +A bounded volume and height-dependent density, one directional source and an explicitly supported local-light subset. No mandatory froxel engine, general weather manager, or promise that all point/spot shadow types work. + +## Required behavior + +- Zero density produces the no-fog image; an opaque foreground wall hides fog behind it. +- The fixture tests the camera both outside and inside the volume, a light toggle and overlapping density bounds. +- Step count and resolution are quality controls, with an explicit off/unsupported route and no dormant GPU allocations. +- Resizes, camera cuts and streamed occluder changes do not reuse invalid depth/history. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Depth-correct participating volume + +- [ ] Implement game-owned volumetric composition using the admitted upstream/kit mechanism and shared scene depth. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. +- [ ] Cover zero density, inside/outside camera positions and opaque occlusion with analytic or reference fixtures. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. + +### Phase 2 — Compose with the existing environment + +- [ ] Integrate directional and the declared local-light subset without double-applying atmosphere or god rays. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. +- [ ] Add bounded quality settings, history invalidation where used and deterministic resource disposal. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The fog fixture verifies occlusion, light response and zero-density identity on browser WebGPU with fixed-camera captures. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same authored volume runs on Linux native and remains valid through resize and scene teardown. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] Fog is depth-correct and light-responsive, and turning it off restores the original graph and allocation baseline. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record pixels, ray steps, light count and cost on the same fixture. Choose tier admission from measurements, not an arbitrary AAA label. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Phone performance and a wider local-shadow set are separate qualification work. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md new file mode 100644 index 0000000000..39bb5aa638 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-08 — Clouds and their ground attenuation share one authored field + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / atmosphere. +**Dependencies:** Reuse the merged rain/snow kits and PRD-381 row 4. Coordinate atmosphere composition with VQ-07; do not recreate the kits. + +## Grounding and intended outcome + +[PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) is merged and owns rain/snow starter outcomes. [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md) already identifies an overhead attenuation-field seam. The remaining target is coherent cloud rendering and moving cloud shade, not a claim that weather or atmosphere is absent. + +**Outcome:** An authored cloud layer moves above an outdoor scene and the associated ground attenuation follows the same field and wind. A sun/coverage change does not leave stale shadow tiles or replace the sky with a detached flat overlay. + +## Design and ownership + +Inventory the actual rain render source first; keep everything that already meets the outcome. Reuse the atmosphere data and upstream volume tools. The game owns the density field, noise assets, optical controls and motion. Only if duplication justifies it, add the PRD-381 numeric transmittance capture/lifetime seam: texel-snapped world coordinates, bounded coverage, complete generations and declared update age. The visible cloud and ground sampler share parameters, not separately tuned animations. + +One regional cloud layer and one directional attenuation field. No global weather simulation, lightning rewrite, astronomical ephemeris rewrite or universal multi-scattering solver. An optional 2D fallback must be named as such. + +## Required behavior + +- Zero coverage preserves the existing sky and unit ground transmittance; density is finite and transmittance stays in [0,1]. +- A deterministic wind trace moves a known cloud and its ground footprint consistently in world coordinates. +- Teleport, sun-direction changes and quality changes invalidate stale coverage without showing an incomplete generation. +- Occluded sky pixels and fog/atmosphere ordering remain correct; cloud attenuation does not erase local artificial lighting. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — One cloud field with reusable inputs + +- [ ] Reuse or extend kit cloud rendering so density, wind and lighting are driven by one declared game-owned parameter set. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`. +- [ ] Produce bounded directional transmittance from that same field, admitting shared capture machinery only when it reduces duplicate code. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`. + +### Phase 2 — Stable world-space composition + +- [ ] Integrate cloud radiance and direct-sun attenuation with the existing atmosphere and lighting graph. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`. +- [ ] Implement complete-generation publication, coverage invalidation and quality/resource limits for moving viewpoints. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The cloud/ground-footprint trace passes on WebGPU, including zero coverage, sun rotation and teleport. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-clouds-and-overhead-transmittance.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same trace and parameter set pass on Linux native; record the actual cloud path and attenuation generation. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-clouds-and-overhead-transmittance.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] Visible cloud motion and ground attenuation agree on the authored field, while no-cloud mode preserves the preexisting scene. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Measure the visible volume and overhead-field capture separately. Do not enable a costly cloud tier by default without the paired frame budget. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +The optional shared overhead-field extraction is gated by PRD-381 reuse/admission rules; missing artistic noise assets need license-clear substitutes. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md new file mode 100644 index 0000000000..82db4fe160 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-09 — Diffuse probes refresh bounded changed regions without a visible half-bake + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / gated GI extension. +**Dependencies:** First reconcile and qualify existing PRD-268. The incumbent ProbeVolume remains the single diffuse-probe owner. + +## Grounding and intended outcome + +[packages/core/src/render/probe-volume.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/probe-volume.ts) already performs static diffuse capture and exposes rebaking. Existing [PRD-268](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md) owns that path. This proposal is only for measured gaps in incremental relighting; manual rebaking must be evaluated before new code is admitted. + +**Outcome:** Changing an off-screen lamp or opening a door updates the affected diffuse lighting within a declared latency budget while unaffected regions retain a complete valid result. This is bounded probe relighting, not Lumen parity. + +## Design and ownership + +Use the existing ProbeVolume capture/sample infrastructure and explicit scene/light revision invalidation. Coalesce dirty regions and schedule work through the existing lifecycle. Publish completed coefficient generations atomically; no reader sees a half-updated atlas. Define previous-versus-current bounce inputs to avoid energy accumulation. Keep artistic response and application in generated materials. If the existing rebake path already satisfies the tests and budget, close this proposal as covered rather than adding a scheduler. + +A small indoor volume, one changed lamp and one door/occluder case. No hardware RT dependency, whole-world fully dynamic GI, infinite bounces, or replacement of SSGI. + +## Required behavior + +- The off-screen source test changes diffuse response even when the lamp remains outside the camera. +- A completed refresh converges toward a full rebake reference within the declared fixture tolerance. +- Unchanged regions do not continually rebake, requests coalesce and cancelled generations never publish. +- Total energy remains bounded over repeated lamp toggles and repeated bake cycles. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Bounded dirty-region updates + +- [ ] Extend only the demonstrated gaps in existing rebaking with revision-aware dirty regions and bounded scheduling. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`. +- [ ] Implement atomic atlas/coefficients publication and cancellation without a second diffuse-probe cache. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`. + +### Phase 2 — Convergence and stable composition + +- [ ] Verify changed-lamp and door response against the full-rebake reference, including repeated bounce cycles. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`. +- [ ] Integrate the refreshed diffuse term with the existing material and SSGI composition without duplicate contribution. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The lamp/door scenario records refresh latency, unchanged-region work and radiance convergence on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-incremental-diffuse-relighting.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same scenario passes on Linux native with its own measured refresh budget and complete-generation observations. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-incremental-diffuse-relighting.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The admitted incremental route performs less work than a full rebake for localized changes while meeting a predeclared image and latency bound; otherwise retain the incumbent. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Pin probe count, density, bounce count and changed region before comparison. A 500 ms response goal is an initial fixture target, not a claimed current engine capability. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +PRD-268 qualification and a representative dynamic-lighting consumer precede default admission; hardware-specific quality tiers remain separate. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md new file mode 100644 index 0000000000..55c0f8415a --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-10 — Texture residency releases real GPU memory rather than only biasing sampling + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / memory-gated. +**Dependencies:** Reconcile PRD-454 resource budgets and VQ-01 decoder selection first. + +## Grounding and intended outcome + +Existing asset cooking and world-cell residency already manage models, compressed textures and lifetimes. [PRD-454](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-454-worldcells-budget-real-resources.md) owns truthful world-resource budgeting. This slice addresses a measured residual texture-memory bottleneck; it does not assume that lack of virtual texturing is itself a defect. + +**Outcome:** A detailed world uses high-detail textures near the player, evicts unnecessary high-detail representations under a hard byte budget and restores them without black materials, stale binds or unbounded cache growth. + +## Design and ownership + +Begin with separately loadable cooked resolution variants or an available portable mip-loading mechanism. Merely changing LOD bias or sampler LOD limits does not count as memory reclamation. The existing asset owner holds shared references; replace representations atomically and retire GPU resources only after in-flight use is safe. Prioritize current view demand with hysteresis and cancellation, preserve a valid low-detail fallback, and report both committed and pending GPU bytes. No sparse-texture assumption or full runtime virtual-texturing system in v1. + +Color/normal/roughness texture families in a representative streamed world; preserve color-space, normal-map and material binding semantics. No tile-feedback renderer or unrelated geometry streaming rewrite. + +## Required behavior + +- Shared textures are counted once, and active materials never reference disposed textures. +- A hard budget includes pending replacement overlap and a reserved fallback; over-budget pressure is reported rather than hidden. +- Teleport, cancellation and two out-and-back routes return resident resources to the expected baseline. +- Improvement is shown in real allocation/residency accounting, not only smaller files or a visually blurrier frame. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Cooked variants and truthful ownership + +- [ ] Extend the existing cook/manifest only with the required variant records and capability-aware cache identity. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`. +- [ ] Implement shared variant lifetime and byte accounting including pending uploads and in-flight retirement. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`. + +### Phase 2 — Bounded demand and safe replacement + +- [ ] Add budgeted demand, hysteresis and cancellation while retaining a valid fallback representation. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`. +- [ ] Wire atomic material replacement and verify repeated streaming/teleport cycles release actual high-detail allocations. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] A fixed world route stays inside the declared allocation budget and demonstrates near-detail restoration on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-texture-mip-residency.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same cooked package and budget contract pass on Linux native, with explicit decoder/format reporting. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-texture-mip-residency.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The route reduces measured resident texture bytes while preserving near-field quality and avoiding missing-texture frames; no demonstrated bottleneck means defer the feature. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Use a fixed 256 MiB test budget only for the synthetic fixture, not as a shipping default. Include overlap and fallback bytes; measure upload hitches separately. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +A world with an observed texture-memory bottleneck is required for admission. Mobile claims require decoder qualification and a named device run. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md new file mode 100644 index 0000000000..1cf6e4ea52 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-11 — Impact and environment decals use a bounded portable lifecycle + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / high-payoff detail. +**Dependencies:** Independent. Share receiver/base-geometry selection with existing LOD and picking paths. + +## Grounding and intended outcome + +Upstream [DecalGeometry](https://threejs.org/docs/pages/DecalGeometry.html) already provides mesh projection. The earlier audit must not be interpreted as permission to rewrite that algorithm. The useful product gap is qualified receiver handling, pooling, lifetime and game-owned material composition for real impacts. + +**Outcome:** Repeated bullet impacts and environment marks remain attached to their receivers, respect normals and occlusion, and stay within a fixed resource cap as the player moves or the scene unloads. + +## Design and ownership + +Start from ordinary Three decal geometry and generated materials. Reuse existing hit results, object transforms and authored LOD0 geometry rather than raycasting the whole world again. Admit a shared lifecycle owner only when at least two consumers need it. Keep material, atlas choice, color, depth bias and fade curves in generated source. A world-static mark and a rigid-moving receiver are separate supported cases; skinned or deforming decals are explicitly refused in v1. + +Surface-color impacts and environment marks on static/rigid opaque receivers. No deferred G-buffer decal renderer, normal/roughness channel rewrite, skinned tattoo system or virtual-texture painting. + +## Required behavior + +- A cap of 256 decals in the fixture is enforced with deterministic eviction, and receiver teardown removes its marks. +- A mark stays on a rigid receiver under translation/rotation; nonuniform or unsupported transforms have a specified behavior. +- Receiver isolation avoids marks projecting through nearby unrelated geometry; corner distortion is documented. +- LOD changes do not alter the gameplay hit surface or strand a decal on disposed geometry. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Reuse projection and bound lifetime + +- [ ] Integrate upstream decal projection with existing picking/base-geometry data and explicit receiver support. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`. +- [ ] Implement deterministic pool limits, eviction, receiver cleanup and borrowed-resource ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`. + +### Phase 2 — Preserve authored appearance and receiver motion + +- [ ] Add game-owned decal material/atlas composition with bounded bias and fade controls. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`. +- [ ] Verify rigid receiver motion, LOD changes and repeated scene teardown without detached marks or allocation growth. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The impact sequence covers the 256-mark cap, moving receiver and LOD boundary on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-bounded-decals.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same sequence runs on Linux native with matching pool/receiver observations and visible marks. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-bounded-decals.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The supported receiver cases retain their marks under motion and teardown, and resource use is bounded independently of total shots fired. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Measure draw calls and geometry bytes at the cap. Batch only compatible marks when it improves the measured cost; do not trade correctness for an advertised one-draw system. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Skinned/deforming receivers require a separate demand-backed extension and are not silently treated as rigid meshes. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md new file mode 100644 index 0000000000..35ce8b0598 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-12 — Overlapping transparent effects have an explicit correct or bounded-approximate path + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / scene-gated transparency. +**Dependencies:** Coordinate attachment/history ownership with VQ-02 and PRD-455. Preserve existing alpha-cutout foliage behavior. + +## Grounding and intended outcome + +[packages/core/src/world-cells.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/world-cells.ts) distinguishes blended scatter from alpha cutout because individual instances are not ordinarily sorted. This is not a lack of all transparency support. A demand-backed improvement must compare sorting, cutout/alpha hashing and an optional OIT route before adding another pass. + +**Outcome:** A deterministic scene of intersecting smoke cards no longer visibly changes just because emitter insertion order changes, while glass/transmission and foliage keep their explicitly chosen existing paths. + +## Design and ownership + +Use upstream supported sorting/alpha techniques first. If the fixture still demonstrates a material gap, admit a bounded weighted-blended OIT route for eligible nonrefractive effects, with independent accumulation/revealage attachments and explicit composition. This is an approximation, not physically exact multi-layer refraction. Define eligibility, color space, premultiplied alpha and depth semantics. Unsupported materials stay sorted or fail with a reason; never silently route refractive glass into a smoke approximation. + +Nonrefractive smoke/particle cards first. No universal hair/glass/water fix, depth peeling, arbitrary material substitution or new renderer backend. + +## Required behavior + +- Insertion-order permutations produce equivalent eligible-effect output within a declared floating-point tolerance. +- Opaque geometry occludes eligible effects; alpha zero is an identity and dense overlap remains finite. +- Water/transmission, cutout foliage and custom depth materials retain their original route unless explicitly admitted. +- Resize, MSAA changes, scene switch and temporal-history invalidation release/recreate the right attachments once. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Eligibility and the smallest useful technique + +- [ ] Implement explicit material-route selection using the existing renderer and test the cheaper sorted/cutout controls. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`. +- [ ] Only where needed, add bounded accumulation/composition for eligible effects with numerical alpha and order-permutation tests. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`. + +### Phase 2 — Compose safely with the world + +- [ ] Integrate opaque depth, premultiplied color and the existing output graph without altering excluded materials. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`. +- [ ] Cover target lifetime, resize, quality fallback and temporal-history invalidation for the admitted route. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The overlapping-effects scene passes order permutations, occlusion and exclusion checks on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-transparent-effects-composition.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same material-route and composition cases pass on Linux native with no unsupported attachment fallback hidden. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-transparent-effects-composition.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The technique materially improves the demonstrated artifact and stays within its declared cost; otherwise retain the simpler existing routes and record a decline. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record attachment bytes, fill rate and total effect cost. A 50-card stress fixture is a test, not a universal scene limit. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +A real overlapping-effects consumer is the admission gate. Correct multi-layer refraction and strand hair remain out of scope. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md new file mode 100644 index 0000000000..e9a870b3be --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-13 — Depth of field and motion blur are qualified optional stages, not a second camera pipeline + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / optional cinematics. +**Dependencies:** PRD-269 motion correctness and PRD-455 history/reset rules; VQ-02 output lifetime qualification. + +## Grounding and intended outcome + +Current upstream supplies [DepthOfFieldNode](https://threejs.org/docs/pages/DepthOfFieldNode.html); the repository already lists motion-blur/temporal stage names and velocity infrastructure. The missing work is coherent generated-source integration and qualification, not implementing blur mathematics from scratch. + +**Outcome:** A cinematic route changes focus between a near and far target and applies motion blur to actual moving world geometry without smearing a camera cut, the HUD or an explicitly excluded first-person viewmodel. + +## Design and ownership + +Reuse installed upstream nodes after verifying the pinned versions. Keep focus, bokeh scale, shutter controls and enablement in generated render source. Reuse scene depth and velocity; specify stage ordering relative to reconstruction, bloom and the single output transform. Focus distance uses the node API's documented world units, not an invented millimeter lens equation. Camera cuts and projection/size changes reset any history. Disabled stages allocate and dispatch nothing. + +One optional DOF stage and one velocity-driven motion-blur stage in an existing generated-source consumer. No sequencer editor, autofocus AI, camera-physics package or forced gameplay blur. + +## Required behavior + +- A depth-target fixture keeps the focus plane sharp while the out-of-focus region changes, rather than blurring the entire image. +- Stationary geometry under a stationary camera has no motion blur; a moving rig and instance use their real motion history. +- The HUD remains outside the world post graph, and viewmodel inclusion is an explicit game decision. +- Off/on, cut, resize and tier switching preserve a valid frame and bounded targets. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Reuse the existing stage seams + +- [ ] Add generated-source factories for installed DOF and motion-blur nodes with documented controls and no duplicate scene pass. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`. +- [ ] Validate depth, velocity, stage order and unsupported-camera/material conditions before installation. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`. + +### Phase 2 — Make transitions and exclusions correct + +- [ ] Implement history resets and explicit HUD/viewmodel composition boundaries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`. +- [ ] Add focus-plane, stationary-motion and stage-off identity assertions plus repeated-toggle disposal checks. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The focus/motion/camera-cut route passes on browser WebGPU with effect-region image checks. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-cinematic-stage-integration.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same optional stages and camera route pass on Linux native, including disabled-path parity. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-cinematic-stage-integration.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] Both effects respond to the intended depth/motion inputs and disappear cleanly when disabled; no new rendering pipeline is introduced. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Measure each stage independently. Keep optional/cinematic by default; no quality improvement is inferred from blur alone. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Additional camera models and physical mobile performance are separate qualifiers, not assumed support. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md new file mode 100644 index 0000000000..e4024ce504 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-14 — Skin shading reuses upstream material support and preserves facial animation + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / character-quality gate. +**Dependencies:** Use a license-clear rigged head. Preserve existing MetaHuman/GLTF binding and expression ownership. + +## Grounding and intended outcome + +Current upstream documents experimental [MeshSSSNodeMaterial](https://threejs.org/docs/pages/MeshSSSNodeMaterial.html). The prior audit's wording should not be read as absence of every subsurface-scattering primitive. Verify the installed pin; the product task is a controllable, portable skin look with real assets and explicit limits. + +**Outcome:** A rigged head retains expressions, normals and authored texture detail while thin regions respond plausibly to backlighting; the result is compared with its ordinary PBR baseline rather than declared AAA from an effect name. + +## Design and ownership + +Use the installed physical/SSS material or a supported upgrade, with appearance in generated source. Keep thickness, albedo, roughness, normal detail and artistic scattering controls asset/game-owned. Distinguish an approximate transmission term from a full diffusion model. Preserve skinning and morph-target paths and avoid counting the same lighting twice when probes or SSGI are active. No new mandatory core material class or MetaHuman-only render dependency. + +One textured, expression-capable head plus a synthetic thin-surface test. No offline path-traced skin, texture generation, pore synthesis, face-rig rewrite or claim that an approximate SSS node matches a production diffusion renderer. + +## Required behavior + +- The baseline and candidate share geometry, pose, exposure and lights; only the intended material route changes. +- Thickness-zero and disabled-scattering controls reduce to the declared baseline behavior. +- Expression/morph changes preserve texture bindings, tangent/normal continuity and independent character instances. +- A low tier has an explicit PBR fallback and does not fetch unused scattering assets. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Admit the pinned material path + +- [ ] Implement the smallest generated-source skin route using compatible upstream support and declared thickness/texture inputs. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`. +- [ ] Cover disabled/thickness controls, finite output and immutable borrowed asset data. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`. + +### Phase 2 — Preserve the animated character + +- [ ] Exercise expressions, skinning and morph targets with the candidate material and existing lighting composition. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`. +- [ ] Add an explicit low-cost fallback and scene/material lifetime checks across two independent head instances. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The neutral/backlit expression sweep runs on WebGPU with matched baseline/candidate captures and numeric control cases. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-skin-material-qualification.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same head, expressions and generated material run on Linux native without feature loss or unexpected target errors. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-skin-material-qualification.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The authored scattering controls are effective, facial animation remains correct, and the human reviewer can compare matched images without an automatic aesthetic score being invented. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Report added shader/texture cost and frame contribution. Keep experimental/opt-in until the visual and platform evidence supports broader use. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +The owner supplies or approves redistribution rights for any third-party head. Human aesthetic approval is a named external checkpoint, not a falsely ticked test. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md new file mode 100644 index 0000000000..63ff644019 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md @@ -0,0 +1,64 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-15 — Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers + +**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. +**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / character-quality gate. +**Dependencies:** Uses qualified temporal/alpha behavior from PRD-455; VQ-12 is optional and not assumed to solve hair. + +## Grounding and intended outcome + +Ordinary Three physical materials, anisotropy and alpha techniques are the first candidates. A dedicated strand renderer is not established as necessary by a manifest search. The bounded target is a license-clear hair-card asset that survives camera motion, lighting changes and native rendering. + +**Outcome:** A character's hair cards show authored directional highlights without losing silhouette coverage, sorting into obvious layers or casting an unrelated opaque block as their shadow. + +## Design and ownership + +Keep tangents, root-tip texture data, alpha thresholds, anisotropy and colors in generated materials. Prefer cutout/alpha-to-coverage or a qualified temporal technique when it preserves the asset. Do not silently use the smoke OIT approximation for hair, and do not promise order-independent physical scattering. Match visible and shadow alpha semantics. A low tier is an authored cheaper material/card representation, not an automatic deletion of random cards. + +Hair cards only, one rigged character asset and a synthetic crossed-card fixture. No procedural grooming, individual strands, hair simulation, multiple-scattering solver or new core material system. + +## Required behavior + +- Tangent and alpha inputs are validated; missing data has an explicit fallback or error. +- The synthetic test exercises crossed cards, back/front views, moving highlights and shadow silhouettes. +- Camera motion and quality switches are evaluated as frame sequences for coverage/flicker, not one screenshot. +- Two character instances keep independent materials where controls differ and share immutable assets safely. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Coverage and directional shading + +- [ ] Implement a game-owned hair-card material from admitted upstream features with explicit alpha and tangent contracts. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`. +- [ ] Add coverage, highlight-direction and visible/shadow agreement checks on the synthetic fixture. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`. + +### Phase 2 — Integrate the real character and tiers + +- [ ] Apply the material to the rigged hair-card consumer without breaking skinning or borrowed resource ownership. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`. +- [ ] Implement explicit lower-cost tiers and temporal/cutout compatibility with scene-switch disposal tests. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The moving-camera/head/light trace passes coverage and shadow checks on browser WebGPU with matched sequence review. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-hair-card-material-qualification.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The same hair asset and tier trace run on Linux native, reporting the actual alpha and temporal route. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-hair-card-material-qualification.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. + +## Acceptance criteria + +- [ ] The supported hair-card path preserves silhouette and shadow coverage through motion and tier changes; no strand-renderer support is implied. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record alpha overdraw, card triangles, shader cost and temporal artifacts by tier. A visually worse but faster thinning is not accepted as a quality fix. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +A redistributable hair-card asset and human visual approval are external requirements. Grooming and physical-device scaling remain separate. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/README.md b/docs/PRDs/batch-2026-10-01-visual-quality/README.md new file mode 100644 index 0000000000..a8c8fda8f3 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/README.md @@ -0,0 +1,75 @@ +# Visual quality execution batch — 2026-10-01 + +**Purpose:** close the functional gaps that most visibly affect ThreeNative games, without rebuilding working engine systems or turning a visual audit into a second renderer. + +**Contents:** 15 new bounded PRDs, 10 existing canonical execution owners in [EXISTING-PRDS.md](EXISTING-PRDS.md), companion-work links, and an [agent execution handoff](EXECUTE.md). Existing PRDs intentionally remain at their canonical paths; this folder is the single queue, not a second copy of their status/evidence. + +**Baseline:** `ThreeNativeHQ/threenative` `develop` at [d72778382b134ef8763f58825cb4d4fd8cc0f6e3](https://github.com/ThreeNativeHQ/threenative/commit/d72778382b134ef8763f58825cb4d4fd8cc0f6e3); Three is pinned to 0.185.1. Reconcile against current `origin/develop` before each task. This is a planning snapshot, not a runtime qualification report or an exhaustive fresh line-by-line audit. + +## Agreement with the audit, with corrections + +The visual priorities are useful. Several literal absence claims were too broad: + +- Current upstream has tiled/clustered lighting, volumetric material, DOF, additive animation and experimental skin-material primitives. Verify the installed pin and prefer integration/qualification over replacing them. Their existence upstream is not proof they are shipped in ThreeNative. +- [PR #375](https://github.com/ThreeNativeHQ/threenative/pull/375) and [PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) are merged. GPU-driven open-world work and rain/snow are not new greenfield tasks. Their remaining limitations must be separated from their completed work. +- A target-based compressed-asset guard remains in [build.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/create-threenative/src/build.ts#L140-L202), but V8 alone does not prove Basis/Meshopt/Draco work in a packaged Android app. VQ-01 repairs and tests the complete capability contract rather than simply deleting the guard. +- The merged snow PR reports native bloom blanking the world. VQ-02 starts by reproducing that report; it does not pretend the root cause has already been found. +- Existing velocity and diffuse-probe source are not greenfield even though their older PRDs still say PROPOSED. Reconcile the proof, not just the headings. Likewise, animation crossfades exist, but the previous broad claim of interruption correctness must be tested for rapid reversals and returning to an already weighted action. +- Static probe relighting, texture residency, OIT, skin and hair are **gated extensions**, not mandatory replacements. No Lumen/Nanite/DLSS/Unreal-parity label is awarded by this folder. + +## First execution wave + +Start VQ-01 and VQ-02 independently. In parallel, qualify the unresolved PRD-269 motion/history cases. Then prioritize PRD-455 temporal reconstruction, PRD-339 exposure and PRD-460 visible prop transitions. For a character-heavy game, VQ-04/VQ-05 can run as a separate sequence; for reflective interiors, VQ-03 is the next lighting task. + +| Wave | New PRD | Player-visible outcome | Main dependency | +|---|---|---|---| +| 0 / correctness | [VQ-01](PRD-VQ-01-native-asset-capabilities.md) | Native asset compatibility follows the selected runtime and actual decoders | Selected runtime/cohort truth | +| 0 / correctness | [VQ-02](PRD-VQ-02-native-postprocessing-parity.md) | A requested post-processing stage never turns the native world into a blank frame | Reproduce reported native bloom failure | +| 1 / lighting | [VQ-03](PRD-VQ-03-local-reflection-probes.md) | Off-screen specular reflections come from a complete local probe | PRD-381 local-probe child; existing SSR | +| 1 / character motion | [VQ-04](PRD-VQ-04-locomotion-blend-spaces.md) | Locomotion blends by speed and direction without restarting its gait | Existing animation/stride player | +| 1 / character motion | [VQ-05](PRD-VQ-05-masked-additive-animation.md) | Upper-body actions and additive reactions compose over locomotion | Coordinate with VQ-04; IK ordering | +| 2 / scalable lighting | [VQ-06](PRD-VQ-06-many-local-lights.md) | Many local lights use a qualified upstream tiled or clustered path | Installed upstream pin; PRD-457 for shadows | +| 2 / atmosphere | [VQ-07](PRD-VQ-07-volumetric-fog.md) | Local volumetric fog composes with depth, lights and existing atmosphere | Existing atmosphere/kit and upstream volume path | +| 2 / atmosphere | [VQ-08](PRD-VQ-08-clouds-and-overhead-transmittance.md) | Clouds and their ground attenuation share one authored field | Merged rain kit; PRD-381 overhead field | +| 3 / gated GI extension | [VQ-09](PRD-VQ-09-incremental-diffuse-relighting.md) | Diffuse probes refresh bounded changed regions without a visible half-bake | Qualify PRD-268 first | +| 3 / memory-gated | [VQ-10](PRD-VQ-10-texture-mip-residency.md) | Texture residency releases real GPU memory rather than only biasing sampling | PRD-454 resource accounting; VQ-01 | +| 2 / high-payoff detail | [VQ-11](PRD-VQ-11-bounded-decals.md) | Impact and environment decals use a bounded portable lifecycle | Existing hit/base-geometry path | +| 3 / scene-gated transparency | [VQ-12](PRD-VQ-12-transparent-effects-composition.md) | Overlapping transparent effects have an explicit correct or bounded-approximate path | Demonstrated overlap artifact; temporal/output contract | +| 3 / optional cinematics | [VQ-13](PRD-VQ-13-cinematic-stage-integration.md) | Depth of field and motion blur are qualified optional stages, not a second camera pipeline | PRD-269/455; VQ-02 | +| 3 / character-quality gate | [VQ-14](PRD-VQ-14-skin-material-qualification.md) | Skin shading reuses upstream material support and preserves facial animation | License-clear animated head; upstream material | +| 3 / character-quality gate | [VQ-15](PRD-VQ-15-hair-card-material-qualification.md) | Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers | Hair-card fixture; temporal/alpha qualification | + +The ten reused owners are **269, 455, 339, 460, 456, 457, 344, 268, 341 and 342**. Their full paths, residual work and companion PRDs are in [EXISTING-PRDS.md](EXISTING-PRDS.md). Read it before starting either a new or reused item. PRD-341 is now implemented and archived by [PR #392](https://github.com/ThreeNativeHQ/threenative/pull/392); reuse its tone gate rather than reopening that work. + +## Dependency outline + +```mermaid +flowchart TD + A[VQ-01 native asset capabilities] --> T[VQ-10 texture residency] + B[VQ-02 native output regression] --> U[PRD-455 temporal reconstruction] + V[PRD-269 motion/history qualification] --> U + B --> E[PRD-339 auto exposure] + U --> C[VQ-13 cinematic integration] + L[PRD-460 prop transitions] --> H[PRD-456 distant proxies] + W[VQ-04 locomotion] --> M[VQ-05 masked additive layers] + P[PRD-268 diffuse qualification] --> G[VQ-09 incremental relighting] + F[PRD-454 resource accounting] --> T +``` + +These arrows are actual integration dependencies, not a claim that all independent features must wait for the entire preceding wave. Shared render and world files still require reconciliation before merging. + +## Scope and admission rules + +The deliverable is a **visible result with a lifecycle and a qualified platform path**, not a class name or an `applied` marker. Retain ordinary Three.js APIs. Shader/material choices, fog/cloud appearance, camera controls and animation state choices stay generated game source. Shared core code is limited to justified mechanisms. No animation graph editor/IR, new scene format, ECS, second frame loop or global material package is authorized. + +Numbers in new PRDs are proposed fixture targets and must be pinned before implementation; none are measured results. Use matched input routes, the same adapter/cohort and separate CPU, GPU and presented-cadence observations. Effect-region and temporal-sequence checks complement human visual review; they do not replace it with an invented score. + +No implementation is included. Open **one implementation PR per canonical PRD** when executing it; this collection's planning PR is not an umbrella implementation PR. Keep existing work and evidence intact and archive through the repository's established PRD lifecycle. + +## Upstream references checked during filing + +- [Tiled lighting](https://threejs.org/docs/pages/TiledLightsNode.html), [clustered lighting](https://threejs.org/docs/pages/ClusteredLightsNode.html), [dynamic analytic lights](https://threejs.org/docs/pages/DynamicLightsNode.html). +- [Temporal AA](https://threejs.org/docs/pages/TRAANode.html), [depth of field](https://threejs.org/docs/pages/DepthOfFieldNode.html), [decal geometry](https://threejs.org/docs/pages/DecalGeometry.html). +- [Volume material](https://threejs.org/docs/pages/VolumeNodeMaterial.html), [experimental skin material](https://threejs.org/docs/pages/MeshSSSNodeMaterial.html). + +These links describe current upstream, not necessarily the pinned 0.185.1 distribution. Pin/patch compatibility is part of admission. diff --git a/docs/PRDs/CI/PRD-303.md b/docs/PRDs/done/PRD-303.md similarity index 97% rename from docs/PRDs/CI/PRD-303.md rename to docs/PRDs/done/PRD-303.md index 52bb9ed80d..407a2ae7b5 100644 --- a/docs/PRDs/CI/PRD-303.md +++ b/docs/PRDs/done/PRD-303.md @@ -4,7 +4,8 @@ prd_contract: v1 # PRD-303 — CI closes its regression doors, and pays for the added coverage from measured minutes -**Status:** OPEN, filed 2026-08-31 against `1c2f1550`. Planning only. Every number in §1 is a read +**Status:** DONE +**Superseded 2026-10-02:** no box here was ever ticked, but all five doors this plan named are now closed by other work, checked on `develop` (`d930716b9`): `template-nonvisual` scaffolds every template, `desktop-parity` runs in `native-platforms.yml`, `ci.yml` has a `schedule:`, `supply-chain` runs `dependency-review-action`, and `native-platforms` blocks the merge (root `AGENTS.md`). Its trims also landed: the KVM step, the `scaffold-from-tarballs` action, and the starter/platformer golden-path matrix. The open cache-key item moves to [PRD-481](../CI/PRD-481-ci-does-each-piece-of-work-once.md) phase 2. Kept for the rationale only. of the workflow files or of two GitHub Actions runs of that same day — `33440190780` (green `ci.yml` push run on `main`) and `33440190473` (`native-platforms`, red per PRD-295). No workflow was edited while gathering them. diff --git a/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md b/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md new file mode 100644 index 0000000000..c89dbd9973 --- /dev/null +++ b/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md @@ -0,0 +1,245 @@ +--- +prd_contract: v1 +--- + +# PRD-341 — a frame's tone is a number, and the number is a gate + +**Status:** DONE — implementation and all five acceptance criteria qualified on browser +SwiftShader WebGPU at `0ea7e29a0ff18717d33b8bce3300ec0568fea842`; required full CI is +still pending before merge. No native or hardware-performance qualification is claimed. +Implementation started 2026-10-02 from `d7277838`; original proposal +filed 2026-09-03, measured at `43d03e6a`. Batch: +[docs/PRDs/AAA-visuals](../AAA-visuals/README.md). **Land this first** — it is what makes every other PRD in the +batch judgeable, and it is the cheapest thing here. Source studied: +[TheLongSilence](https://github.com/achimala/TheLongSilence) `tools/levels.mjs`, `tools/judgeset.mjs`. + +**Goal: "it looks flat" stops being an opinion.** The reference states the rule plainly and this +repository already believes it — *playtest is the eyes, not screenshots* — but the eyes currently +report two numbers where five are needed. + +**Complexity:** a histogram over pixels already decoded, a new assertion kind, a CLI report = +**LOW**. An afternoon, if `assert.tone` reuses the existing capture path. + +## Implementation phases (2026-10-02) + +### Phase 1 — one measurement and a strict contract + +- [x] Compute the six display-luminance metrics in the existing PNG decode. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/capture.spec.ts` — 9 passed after two missing-metric failures in the fresh reconstruction. +- [x] Reject empty, malformed and contradictory tone bounds at scenario load. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts` — 28 passed after 25 missing-feature failures in the fresh reconstruction. + +### Phase 2 — shared capture evidence and CLI + +- [x] Evaluate tone bounds against named captures and fail on missing evidence. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts` — 28 passed after 25 missing-feature failures in the fresh reconstruction. +- [x] Record `TN_TONE` on browser/device captures and print identical six-number CLI rows plus an average. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts` — 33 passed; five missing-producer failures observed first. + +### Phase 3 — scenario proof and qualification + +- [x] Ship a discoverable GPU-lane scenario and document the exact statistic/capture contract. proof: `node --import tsx scripts/check-doc-links.ts` and scenario schema test in `tone.spec.ts` — 52 focused fixture/capture/doc tests passed; isolated production Vite bundle and playtest ESM/declarations built. +- [x] Run the scenario with normal and two-stops-under exposure on a capture-capable GPU lane. proof: `pnpm test:tone`, hosted [run 36988379892](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892) at `0ea7e29a0ff18717d33b8bce3300ec0568fea842` — passed on browser SwiftShader WebGPU. Actual underexposed/restored PNGs inspected; underexposed mean 89.56 / p99 136 fails exactly four intended bounds, restored mean 170.50 / p99 254 passes with clean diagnostics. This is rendered-pixel correctness, not native or hardware-performance proof. + +## Implementation decisions + +- 2026-10-02: keep the canonical PRD and historical measurements. The current harness uses `assert: { kind: ... }`; implement `assert.tone` as an array of frame-bound requests within that established schema rather than the historical illustrative `assert: "tone"` shape. +- Reuse the existing PNG decode without resampling; no new decoder, render policy, or GPU readback is introduced. A 256-bin display-referred Rec.709 luminance histogram uses rounded byte bins; wholly transparent pixels are excluded, consistently with the capture guard. No visible pixels means no tone observation. + +## The problem, measured at `43d03e6a` + +### 1. The capture path measures enough to detect a blank frame and nothing more + +`packages/playtest/src/capture.ts` computes `visiblePixels`, `maxLuminance`, `brightPixels`, a mean +and a standard deviation, and spends all of it on `CAPTURE_GUARD_LIMITS` — is this frame blank, is +it suspiciously dark. Those are liveness checks. None of them can tell a correctly exposed frame +from one rendered three stops under, because a three-stop-under frame has plenty of variance and +plenty of visible pixels. It just looks wrong. + +### 2. Every look PRD in this repository currently ends in an argument + +The batch's own history is the evidence: the recorded method for calibrating a render against a +reference is *quantiles plus a one-build ablation*, written down after eyeballing picked the wrong +cause four times. That method exists, it works, and it is not available as an assertion — so it gets +re-hand-rolled per investigation and never becomes a gate. + +The reference names the exact shape that ends the argument: **mean, p1, p50, p99, clip%, black%**, +per frame, with an average row. `0.00% of pixels clip and the 99th percentile is 165` is a sentence +someone can act on, and it is what the reference measured immediately before fixing its highlight +range. + +### 3. Fail closed, or it measures nothing + +The repository's own rule: an empty assertion set is a failure, a missing observation is a failure. +A tone assertion whose capture never happened must go red, not skip — this is the exact shape of the +v1 harness bug that reported green on scenarios asserting nothing. + +## What ships + +### `packages/playtest` — `assert.tone` + +A new assertion kind over a captured frame: + +```jsonc +{ + "assert": { "tone": [{ + "atStep": "landed", + "mean": { "min": 60, "max": 140 }, // 0-255, display-referred, after tonemap + "p99": { "min": 150 }, // the frame has highlights + "p1": { "max": 40 }, // and it has shadows + "clipFraction": { "max": 0.005 }, // 0.5% of pixels at display white + "blackFraction": { "max": 0.25 } + }] } +} +``` + +Every bound is optional; **an assertion with no bound at all throws at schema validation** rather +than passing vacuously. Percentiles are computed from a 256-bin luminance histogram over the decoded +frame — the reference decodes to a reduced size on the grounds that the statistics are unchanged and +a full-res decode of twenty frames is pointlessly slow, and the same applies here. + +### `packages/playtest` — `tone` in the CLI + +`node packages/playtest/dist/runner/cli.js tone ` prints the reference's table verbatim in +shape: one row per shot, an average row, no browser needed. This is the tool an agent runs on a +directory of captures without writing a scenario, and it is what makes a set judgeable at once. + +### Reporting + +`TN_TONE` per captured frame, in the observation record, so a run that captured frames and asserted +nothing still leaves the numbers behind for the next round. + +## What does not ship + +- No verdict. This measures; it does not decide that a frame is good. Pixel metrics never replace + semantic image review — the sculpt server's rule holds. +- No colour statistics, no per-channel histograms, no saturation. Luminance only in v1. +- No baseline comparison. `pnpm visuals` / `visuals:ab` already own A/B against a baseline; this + bounds a frame on its own terms, which is what a scenario with no baseline needs. + +## Acceptance criteria + +- [x] **A misexposed frame is red.** proof: `pnpm test:tone`. The example fixture renders a scene with `toneMappingExposure` + pinned two stops under; a `tone` assertion bounding `mean` and `p99` fails, and the failure text + names both the measured and the required value. + *Red-green:* the mutation **is** the pinned exposure — restore it and the same assertion passes. + Paste both. Verified: [run 36988379892](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892) + measured underexposed mean 89.55505316840278 / p99 136 versus required mean 140..210 / + p99 >=230; exactly four named/final bounds failed. Restored mean 170.49870985243055 / + p99 254 passed the same bounds. Both images and full failure text are retained below. +- [x] **An empty bound set throws.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts`. A `tone` assertion with no bounds fails schema validation with a + named error before the run starts. + *Red-green:* soften the schema to allow it; `assertion-schema.spec.ts` goes red. + Verified in the current `tone.spec.ts` schema cases: temporarily removing the two empty-bound + guards caused three expected failures (`{}`, `{atStep: "landed"}`, `{mean: {}}`); restored + source passed. The built loader throws `TN_PLAYTEST_SCENARIO_INVALID` before execution. +- [x] **A missing capture fails, it does not skip.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts`. A `tone` assertion at a step that produced no + frame reports a failure naming the step. + *Red-green:* return `undefined` from the evaluator when the capture is absent; the fail-closed + spec goes red. Verified: replacing missing/invalid-capture handling with `return undefined` + caused all four missing-capture cases to fail. Restored source reports + `TN_PLAYTEST_TONE_UNOBSERVED` naming `landed`; removing observations from the real restored + report likewise names `rendered`. The registry fallback also remains fail-closed. +- [x] **The CLI table matches the assertion.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts`. `cli.js tone` on a frame and `assert.tone` on the same + frame report the same six numbers, to the printed precision. + *Red-green:* change the histogram bin count in one path only; the cross-check spec goes red. + Verified: temporarily selecting 128 bins only for the CLI made the cross-check fail + (p1 2.01 instead of 2.00; p50 126.50 instead of 127.00; clip/black 0.78% instead of 0.39%). + Restored built `cli.js tone` matched all six decoded and asserted values on all four hosted + PNGs, including the frame-average row, to printed precision. +- [x] **It works where captures work.** proof: `pnpm test:tone` plus the frameless-lane exclusion test in `tone.spec.ts`. The delete-test lane rule applies — this needs a GPU lane, so + the scenario lives on `pnpm test:templates`, not on CI's frame-less template runner. + Verified: `test:templates` invokes `test:tone`; the tone-only exclusion regression passes, + and the dedicated hosted WebGPU lane produced inspected real captures at the source SHA above. + +## Out of scope + +Baseline diffing, perceptual metrics, and any automatic judgement of composition. + + +## Reconstruction checkpoint — 2026-10-02 + +The first executor was replaced before its implementation commit was published. This is new +work from the surviving draft branch, with fresh tests; the lost local commit is not evidence for +this branch. The histogram slice reuses the guard's existing PNG decode. Remaining phase boxes +stay open until their implementation and proofs are rerun. + +## Required runtime screenshot proof + +The user explicitly requires screenshot proofs for every PR. Actual underexposed/restored WebGPU +runtime captures, adapter provenance and source SHA must be inspected and linked before this PR +leaves draft. Synthetic PNG unit-test inputs and pictures of test output do not satisfy this gate. + + +Fresh schema/CLI checkpoint: 118 tests passed across six focused files; scoped playtest TypeScript +passed. The shared evaluator now rejects missing/invalid captures and reports measured/bounded +values. Offline CLI parity includes different-sized frames to prove equal per-frame averaging. +Browser/device production of the observation remains the next open slice. + + +Fresh capture-wiring checkpoint: 52 tests passed across histogram, tone, target-driver and runner +orchestration. Scoped playtest TypeScript passed. All four target paths reuse the guard's decoded +metrics; host observations survive report assembly. The driver tests are not runtime screenshots. + + +Fresh screenshot-lane checkpoint: the maintained read-only `integration-tone` workflow builds this +commit's public bridge, then uses the same headed WebGPU/canvas-capture approach as native +conformance. The script requires both exposure verdicts, actual PNG files, nonempty adapter +identity and WebGPU provenance; it never accepts a missing capture as proof. Local playtest and +fixture typechecks pass; fixture/capture/primary-doc focused suite: 52 tests passed. Runtime +screenshots must still be produced by the hosted run and inspected before promotion. + +Independent review and hosted retry checkpoint: the first hosted run (36986610377) built and +passed 43 tests, then failed before capture because the source-imported runner's serialized +callback referenced a tsx helper. The verifier now uses the built public runner and preserves +attempt/failure diagnostics. Three driver regressions reproduced an earlier `after.png` capture +incorrectly satisfying a final-frame assertion; newest matching final observations now win. +The pixel-proof verifier separately rejects software-device-loss warnings and unrelated errors +even in its expected-underexposed arm. Runtime screenshot qualification remains open until the +corrected hosted run produces inspected evidence; none of these CPU checks substitutes for it. + + +## Runtime screenshot evidence — 2026-10-02 + +The dedicated [hosted run](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892) passed at `0ea7e29a0ff18717d33b8bce3300ec0568fea842`. Both exact artifact images were visually inspected: identical sphere/cube/calibration-ramp geometry, visibly darker underexposed output, brighter restored output, and no blank or visibly corrupt frame. [Provenance and measured values](../../verification/prd341/provenance.json) retain source SHA, artifact digest, adapter and image hashes. Browser SwiftShader proves these pixel/statistic cases; native parity and hardware performance are not claimed. Required full CI is still pending. + +Underexposed (expected tone assertion failure): + +![Underexposed runtime calibration](../../verification/prd341/underexposed.png) + +Restored (tone assertions pass): + +![Restored runtime calibration](../../verification/prd341/restored.png) + + +## Acceptance audit — 2026-10-02 + +At exact source `0ea7e29a0ff18717d33b8bce3300ec0568fea842`, each mutation above was applied +in an isolated worktree, observed failing for its intended contract, then removed. +`node node_modules/vitest/vitest.mjs run --maxWorkers=1` over `capture.spec.ts`, `tone.spec.ts`, +`tone-runner.spec.ts`, `generated-assertion-validators.spec.ts` (all under +`packages/playtest/__tests__/`) and `scripts/__tests__/tone-capture-proof.spec.ts` passed +56 tests after exact-source restoration. No mutation was published. + +The built offline CLI, histogram decode and six exact-bound assertions agreed on both named +and final PNGs from each runtime arm: + +| Frame | mean | p1 | p50 | p99 | clip% | black% | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Underexposed (named and final) | 89.56 | 6.00 | 95.00 | 136.00 | 0.00 | 0.39 | +| Restored (named and final) | 170.50 | 22.00 | 180.00 | 254.00 | 0.78 | 0.39 | +| Four-frame average | 130.03 | 14.00 | 137.50 | 195.00 | 0.39 | 0.39 | + +The ordinary package build wrapper hit the local tsx IPC `EPERM` restriction. Its equivalent +steps passed using `node --import tsx scripts/generate-assertion-validators.ts --check`, +`pnpm --filter @threenative/playtest exec tsup --config tsup.config.ts`, and +`pnpm --filter @threenative/playtest exec publint --strict`: 30 validators current, ESM and +declarations built, strict package lint passed. This does not claim a full-root test pass. + +All original feature acceptance criteria are satisfied. Required full CI and final merge +qualification remain pending; archiving the implemented PRD does not waive those gates. +Earlier checkpoints above retain the state and blockers observed at each point in the work. + +Archive checks: `node --import tsx scripts/check-doc-links.ts` passed 2,398 links; +`node --import tsx scripts/check-evidence-budget.ts` passed the unchanged byte caps; the citation +scanner classifies both screenshots and provenance as `cited-by-done-prd`. The six-file prose +regression lane passed 178 tests and hit the existing tsx IPC restriction in two evidence-budget +subprocess cases (long evidence record and many small files); their `EPERM` exits are not passes. +The real-tree budget regression passed, and required hosted CI retains responsibility for the +restricted subprocess checks. diff --git a/docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md b/docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md similarity index 90% rename from docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md rename to docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md index dcb025b0b5..e7bc3abeb6 100644 --- a/docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md +++ b/docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md @@ -1,6 +1,6 @@ # PRD-379 — native-release follows CI completion instead of polling for it -**Status:** PARTIAL (Phase 1 landed + verified locally; Phase 2 needs merge to main + observation) +**Status:** DONE (Phase 2 observed on `main` 2026-09-25; recorded 2026-10-02) **Complexity:** 3 → LOW; risk override: none (release-gate sensitivity is carried by fail-closed ACs, not extra process) **Owner:** jonit-dev **Depends on:** None (follows PRD-373, which owns the develop→main flow this plugs into) @@ -30,7 +30,7 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so - [x] AC-1 [local; actor: agent]: A main-push CI completion starts native-release via `workflow_run`, with no `push`-to-`main` trigger left on the evidence path — Evidence: `workflow_run: workflows: [CI], types: [completed], branches: [main]` in `.github/workflows/native-release.yml:10-13`; push block is tags-only; proven by `main evidence arrives via CI completion` spec + E1 green. - [x] AC-2 [local; actor: agent]: No `gates` job polls or holds a runner beyond a short verdict (no `sleep 60` loop, no 150-min wait); it reads conclusion + head SHA from `github.event.workflow_run` and refuses anything but exact-candidate success — Evidence: `gates` timeout-minutes 5, `Require the triggering CI completion` step, `rg "sleep 60"` clean; proven by `the main prerequisite verdict reads the event` + 5 `runWorkflowRunGate` specs + E1 green. - [x] AC-3 [local; actor: agent]: Tag publish path and PR proof-without-publication path behave exactly as before — Evidence: tag `push: tags` trigger, `validate-tag`/`publish`/`finalize` conditions untouched; `Require a green CI run for this commit` script byte-identical (single-shot, wait deleted); existing `runGate` accept/refuse specs unchanged + green. Host proof: native-release PR run 34673827042 (first push of PR #209) fully green — `gates`, all three desktop builds, `build-android`, `clean-consumer` success on the `pull_request` event with the new workflow file. (Second-push run 34673874789 hit a transient `fetch failed` on `libuv`/`quiche` dep downloads with identical code; assets verified reachable, failed jobs re-run.) -- [ ] AC-4 [shared; actor: CI on merge to main]: After merge, one CI completion on `main` triggers exactly one native-release run whose `gates` job finishes in minutes with no polling step — Evidence: pending (run URLs). +- [x] AC-4 [shared; actor: CI on merge to main]: After merge, one CI completion on `main` triggers exactly one native-release run whose `gates` job finishes in minutes with no polling step — Evidence: CI push run 36134527157 on `main` (`279adb2c`) completed twice (run_attempt 2); each completion triggered exactly one `workflow_run` native-release run: 36140281930 (attempt 1) and 36142531873 (attempt 2); `gates` in 36142531873 ran 39 s (13:41:18–13:41:57Z), and its steps contain no wait or poll. - [x] AC-5 [local; actor: agent]: Main evidence runs no longer serialize behind superseded pushes in a shared `native-release-*` group — Evidence: `group: native-release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha }}-${{ github.ref }}` (per-SHA groups for evidence, per-ref as before otherwise); proven by extended concurrency spec + E1 green. ## Integration Ledger @@ -67,7 +67,7 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so #### Phase 2: Prove one real main completion flows through -**Status:** NOT STARTED +**Status:** DONE **ACs:** AC-4 **Files:** none (observation only) **Implementation:** @@ -79,5 +79,5 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so **Checkpoint:** pending - [x] Draft PR opened, `prd:` label applied from `pnpm prd:progress` — PR #209 (draft vs develop, `prd:50%`) -- [ ] Post-merge CI completion triggered exactly one native-release run (URLs recorded) -- [ ] Triggered run's `gates` finished in minutes with no polling step +- [x] Post-merge CI completion triggered exactly one native-release run (URLs recorded) — CI push run 36134527157 on `main` (`279adb2c`) completed twice (run_attempt 2); each completion triggered exactly one `workflow_run` native-release run: 36140281930 (attempt 1) and 36142531873 (attempt 2). +- [x] Triggered run's `gates` finished in minutes with no polling step — 39 s in 36142531873; steps run from "Require the triggering CI completion" to the end, with no wait or poll step. diff --git a/docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md b/docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md similarity index 60% rename from docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md rename to docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md index 02baaba8a4..b14dbb0cb2 100644 --- a/docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md +++ b/docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md @@ -1,10 +1,10 @@ # PRD-469 — ICEFIELD snow abstractions and starter kit -**Status:** PROPOSED +**Status:** COMPLETE **Complexity:** 9 (HIGH); risk override: none. **Owner:** Engine implementation agent **Depends on:** None -**Progress:** 0/8 required boxes verified +**Progress:** 8/8 required boxes verified ## Context @@ -101,6 +101,9 @@ The eight countable criteria live in the owning phases below; this table maps pr | AC-5 | local | The generated game demonstrates the same contact/surface contract on native Linux desktop | | AC-6 / AC-7 / AC-8 | local | Packed starter works, MCP discovers the abstractions, and repository gates pass | +- [x] Browser WebGPU lane: the packed kit's physics, footsteps, weather and touch scenarios pass on a real GPU adapter. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — exit 0, 7 scenarios, NVIDIA Turing (AC-3, AC-6) +- [x] Native Linux desktop lane: the same snow-physics contract passes on the native host. proof: `pnpm exec threenative-playtest --scenario native-playtests/snow-physics.playtest.json --target desktop` in the packed `snow-proof` — 4/4 on the merged tree, 5/5 before the merge (AC-5) + Browser and native desktop behavior are required here. Android/iOS use the same portable source/backend seam and receive build checks where their SDKs are available; this PRD makes no mobile runtime claim without a target run. During implementation, record any unavailable required qualification under `Blocked on` with the actual attempted command and missing SDK/device. Build success cannot substitute for physics execution. Do not tick an unreachable proof or mark the PRD complete with a required platform result pending. ## Integration Ledger @@ -119,47 +122,93 @@ Proposed file paths become actual entry-point references when their owning phase - 2026-09-30 (João): extract the HTML's existing raw snow code into abstractions and include them in engine capability discovery; write the PRD now. - 2026-09-30 (planning): physics acceptance explicitly includes spheres, not just character steps. Reuse the existing terrain, bodies, particles and scaffold mechanisms; the snow starter owns its look. +- 2026-10-01 (implementation, measured): native snow surfaces are Rapier heightfields, not the planned canonical-sample trimesh. The trimesh adapter worked (web/native surfaces agreed within 1e-4 m) but rebuilding it on native took 101 ms at 321 samples and 179 ms at 401 (cargo micro-benchmark), so every footstep hitched the desktop host; a heightfield rebuild copies samples only (0.24 ms / 0.38 ms). Native now receives the same column-major samples the web backend hands Rapier, so both runtimes build one surface by construction. +- 2026-10-01 (implementation, measured, withdrawn): powder takes no energy from what moves through it. Building the sandbox demo showed a 10 kg ball at rest on the kit's 7% glade slope rolls about 4 m off its tee, and a pushed ball is still moving at 0.7 m/s four seconds later. Three rolling-resistance models were added to `attachSnowPhysics` and measured on real Rapier (soft-ground `sqrt(sinkage/width)` drag at the centre, the same at the rolling lever, compaction work), plus a force-couple torque swept headless; none held on both backends (native desktop over-spun, roll ratio 5.9, reach 0.47, so the kit's native scenario failed 3/3), so the feature was reverted rather than shipped half-right. The binding deforms snow without taking energy; snow rolling resistance is open engine work. The attempt surfaced a real seam bug, now fixed on web and native with tests: `applyForceAtPoint`'s torque was never cleared, so it kept spinning a body up after its step. +- 2026-10-01 (merge of develop PRD-470/471/472/474, root-caused and fixed): after the merge the kit's `production-performance` read frame p95 39.9–44 ms against its 33 ms ceiling. Develop's loop now leaves boot frames out of the window, which exposed an existing cost. The slow frames are the runner's `waitTicks` bursts, about 8–10 fixed steps per presented frame, and each step cost about 2.5–3 ms on web. Most of that was the explorer's `CharacterBody3D`: Rapier sweeps a character capsule against every collider triangle under it, so one capsule `castShape` costs 0.04 ms on a 51² heightfield and 0.63 ms on the kit's 401² (6 cm) snow. Squeezing 401² into 6 m costs 8 ms, and splitting it into 8×8 tiles changes nothing, so the cost is triangles under the sweep, not collider size. Disabling the explorer's movement alone took the physics step from about 1.3 to 0.4 ms. Fix, in the kit: the explorer collides with a coarse walk surface (ground plus fresh snow, 101², 24 cm; `walkSurface`/`WALK_SAMPLES` in `src/terrain.ts`), on its own collision layers that leave the snow collider to the ball, crate and log. Its drawn height and boots already came from the canonical snow, so nothing visible changes. Engine: the binding now compares only the window written since its last collider rebuild, through the new `Heightfield.trackChanges()`, instead of the whole field each step. Result, threshold unchanged: the scenario's p95 is 27.5 and 30.0 ms (27.8 on the first run); `TN_TEMPLATE_ONLY=snow pnpm test:templates` exit 0 with all 7 scenarios; native `snow-physics` 3/3 and `survives` pass. + - What a player gets, as distinct from the runner's tick bursts: a wall-clock run (`__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"`, 10 s of walking at 1920×1080, NVIDIA Turing). Before: 130–195 fps, per-frame p95 14.2–17.1 ms, update p95 2.8–3.8 ms, 1,024-sample p95 16.7 ms. After: 158–248 fps, per-frame p95 12.8–14.3 ms, update p95 1.7–2.3 ms, 1,024-sample p95 15.2 ms. Both were already above 60 fps on this desktop; the step cost matters most on slower devices. + - Trade-off: the explorer's capsule no longer drops into its own prints or the ball's crater; it walks at fresh-snow level over a 24 cm surface. Its drawn body and boots never followed the capsule (they come from the canonical snow and the gait's IK), so the frame does not change. A re-capture of the walking kit (`snow-kit-webgpu-1440x900.jpg`, `snow-kit-webgpu-surface-view-1440x900.jpg`, refreshed) shows the same prints, and the boots plant in them. AC-3/AC-4 are untouched: the ball, crate and log still press the 6 cm snow through `attachSnowPhysics` (`snow-contacts.spec.ts` 14/14 unchanged), and footfalls still stamp it. + - `Heightfield.trackChanges()` stays because it is not a micro-win. On a kit-sized 401² field where the surface changes every step (a rolling ball), the binding cost 1.29–1.45 ms per step comparing the whole field and 0.89–0.91 ms comparing only the changed window, about 0.4 ms a step (node, real Rapier). Existing callers see no behaviour change: `updateHeights` only unions into live trackers, and `takeDirtyRegion` is untouched. Unit-tested in `world-heightfield.spec.ts`; the class's capability entry names it. +- 2026-10-01 (implementation, unexplained engine issue): in the snow kit a bloom threshold of 0.92 left the native Linux desktop frame blank behind the HUD (Dawn/Vulkan, RTX 2080) while the web rendered it correctly; thresholds 0.2 and 0.7 rendered on native. Not root-caused. The kit ships with bloom off (its glints come from the snow material), recorded in `templates/snow/src/render/quality.ts`; the coordinator shared the finding with the rain lane, which also uses bloom natively. + +## Current state (this branch) + +Phases 1 and 2 are complete and verified. Phase 2 landed the persistent-contact and in-place +collider-refresh operations on both backends (`readContacts`, `setColliderShape`; Rust +`tn_physics_read_contacts`/`tn_physics_set_heightfield_shape`), `attachSnowPhysics` with +`observe()`/`loadOf()`, and the generated snow game's `snow-physics.playtest.json` passing on +browser WebGPU and on the native Linux desktop host. Phase 3's kit passes all seven packed web +scenarios, capability discovery is verified through the shipped MCP server, and the repository +gates pass on the final branch. The rain kit (PRD-473) is merged into this branch. + +Visual record, `docs/verification/PRD-469/` (JPEG, about 0.7 MB for the kit set): the kit at +`snow-kit-webgpu-1440x900.jpg`, `-1024x768.jpg`, `-390x844-touch.jpg`, `-blizzard-1440x900.jpg` +and `-surface-view-1440x900.jpg`; physics proof at +`physics-sphere-track-box-capsule-dents-webgpu.jpg`, `physics-compaction-view-webgpu.jpg` and +`physics-sphere-track-native-desktop-1280x720.jpg`; the supplied ICEFIELD document at the same +three viewports as `reference-icefield-*.jpg`. + +## Sandbox demo + +**Snow Putt**, a small game built cold on the kit: `sandbox/snow-demo` in the shared sandbox +repository, commit `08b84cb1ee55691a242aadee463123aa034dc295` (local, not pushed), scaffolded with +`--template snow` from this branch's packed tarballs. Push the ball into a cup pressed into the +`SnowField`, within six pushes, before a 90 s clock runs out; the cup and the tee lie are +`snow.stamp` calls, the ball's roll and crater come from `attachSnowPhysics`. + +| Scenario | Target | Result | +| --- | --- | --- | +| `playtests/putt-win` | browser WebGPU (NVIDIA Turing) | pass, holed in 2, identical across 3 runs | +| `playtests/putt-lose` | browser WebGPU | pass: clock runs out (lost, reason time), R opens a fresh round | +| `survives`, `footsteps`, `weather`, `touch-controls`, `performance` | browser WebGPU | pass | +| `native-playtests/putt-win` | native Linux desktop | pass 6/6 (holed in 3–5) | +| `native-playtests/survives` | native Linux desktop | pass | + +Friction (the demo's `FRICTION.md`): powder takes no energy from a rolling ball (open engine work, +see Decisions); `applyForceAtPoint` left its torque behind (fixed, `f2812dd60`); native runs of one +build vary; the kit's own ball leaves its tee at load. Captures: +`sandbox-snow-putt-won-webgpu-1280x720.jpg`, `sandbox-snow-putt-mobile-webgpu-390x844.jpg`, +`sandbox-snow-putt-won-native-desktop-1280x720.jpg` (0.19 MB). ## Execution Phases ### Phase 1: Source-derived numerical snow abstractions -**Status:** NOT STARTED +**Status:** COMPLETE **Files:** proposed `packages/core/src/snow-field.ts`; existing `packages/core/src/world.ts` and `world-package.ts`; focused `packages/core/__tests__/snow-field.spec.ts`; `docs/architecture/CHARTER.md` and core ownership instructions. **Implementation:** Recover/type the source's state, sampling, deformation and sparse recovery. Add the smallest canonical heightfield mutation path. Separate boot geometry, movement tuning, texture encoding and weather appearance from numerical response. Preserve a minimal source-derived regression fixture. Add public export metadata with compilable examples; do not invent a new GPU solver. -- [ ] AC-1 [local, actor: implementation agent]: Public `SnowField` reproduces the source's footprint-state and deposition behavior, including reset, depth changes and invalid-input rejection. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts packages/core/__tests__/world-heightfield.spec.ts` — Evidence: pending. -- [ ] AC-2 [local, actor: implementation agent]: Generalized pressure/profile input produces distinct boot and circular imprints with area-dependent response and presentation-rate-independent integration. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts -t "contact profiles"` — Evidence: pending. +- [x] AC-1 [local, actor: implementation agent]: Public `SnowField` reproduces the source's footprint-state and deposition behavior, including reset, depth changes and invalid-input rejection. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts packages/core/__tests__/world-heightfield.spec.ts` — Evidence: 32/32 pass (18 snow-field, 14 world-heightfield). `packages/core/__tests__/fixtures/snow-field-source.json` was produced by evaluating the supplied document's own `SnowField` (lines 85–193, sha256 `85b1708…02f7`) over one deterministic scenario; the public `SnowField` replays it to within 1e-9 on all four channels at 672 sample points with an identical active-cell count at every stage (995 stamps → 875 recovered → 875 depth-changed → 0 reset). Includes penetration equality per contact, reset/depth behaviour, canonical-surface composition, render/collider agreement < 0.001 m, and rejection of malformed area/load/duration/extent/depth. +- [x] AC-2 [local, actor: implementation agent]: Generalized pressure/profile input produces distinct boot and circular imprints with area-dependent response and presentation-rate-independent integration. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts -t "contact profiles"` — Evidence: 5/5 pass. A source boot print and a `snowDiscFootprint` of equal area leave measurably different imprints; a `π/2` rotation transposes the boot's long axis; the same load concentrated over 0.02 m² digs > 0.05 m deeper than over 0.4 m² and > 0.02 m deeper than an eighth of the load; one 0.3 s contact equals eighteen 1/60 s contacts to 1e-6; a rotated box footprint leaves a rectangular print; and a rolling sphere leaves a connected track rather than one repeated hole. **Verification:** Tests invoke the exported abstraction, compare source-derived expectations, and assert dimensional/edge handling and canonical-surface invariants. Write test-first missing-behavior assertions during implementation, not artificial failures during this planning task. ### Phase 2: Physical bodies deform and collide with the same snow -**Status:** NOT STARTED +**Status:** COMPLETE **Files:** proposed `packages/physics/src/snow.ts`; existing physics exports, `simulation.ts`, `plugin.ts`, native `host.ts` and relevant runtime-native physics bridge/Rust implementation if the missing bulk operations require them; `packages/physics/__tests__/snow-contacts.spec.ts`; shared snow scene and `snow-physics.playtest.json` fixtures introduced for the starter. **Implementation:** Wire solved supported contacts to `SnowField`; add minimal persistent-contact and dirty-collider refresh operations at the shared backend seam. Reuse native trimesh support first. Preserve ordering, collision filters, body identity, wakeup and cleanup. Exercise real Rapier with source-derived material values; do not mock the solver for acceptance. Include an airborne interval, drop, rest, lateral push, loaded recovery and a field reset. -- [ ] AC-3 [local, actor: implementation agent]: A real sphere falls, settles within the stated surface tolerance and rotates through a continuous circular snow track; airborne intervals do not stamp. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t sphere` plus the browser `snow-physics.playtest.json` included in AC-6's generated-project run — Evidence: pending. -- [ ] AC-4 [local, actor: implementation agent]: Box/capsule support uses shape-appropriate contacts and survives recovery/reset without stale colliders or cross-field deformation. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t "box|capsule|lifecycle"` — Evidence: pending. -- [ ] AC-5 [local, actor: implementation agent]: The generated snow game meets the sphere/contact and canonical-surface assertions on the native Linux desktop host. proof: `pnpm build:desktop && pnpm exec threenative-playtest --scenario playtests/snow-physics.playtest.json --target desktop --executable dist-native/snow-proof` from the packed generated project named `snow-proof` — Evidence: pending. +- [x] AC-3 [local, actor: implementation agent]: A real sphere falls, settles within the stated surface tolerance and rotates through a continuous circular snow track; airborne intervals do not stamp. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t sphere` plus the browser `snow-physics.playtest.json` included in AC-6's generated-project run — Evidence: 6/6 sphere tests pass against real Rapier 0.19.3 (no solver mock): 0 loads during 15 airborne steps, settled underside within 0.02 m of the surface it pressed, push rolls ≥ 1 m with turn×r/distance in 0.8–1.2 and every sample along the path pressed (connected, circular, nothing beyond 1.6 r), collider within 0.001 m of canonical after every step and ≤ 1 step behind a game-side reset, triangle interiors within 0.01 m by ray cast, a sleeping sphere re-seats after `setDepth`, 30/60/120 Hz presentation within 0.001 m, a sideways shove digs no pit. Browser: `TN_TEMPLATE_ONLY=snow pnpm test:templates` exit 0 (2026-10-01, NVIDIA Turing adapter on every run) — `snow-physics` passes: airborne at `falling`, settled at `settled`, 0 airborne loads, reach ≥ 1 m, roll ratio in band, 0 track gaps, `colliderError`/`renderError` ≤ 0.001, `loadProvenance` `solver-impulse-per-step`. +- [x] AC-4 [local, actor: implementation agent]: Box/capsule support uses shape-appropriate contacts and survives recovery/reset without stale colliders or cross-field deformation. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t "box|capsule|lifecycle"` — Evidence: 8/8 pass. A yawed box presses its own rotated face (1.0 m side along z, untouched 0.4 m off its 0.25 m half-width); a fallen capsule presses a trough along its spine and an upright one a disc; x- and z-sloped fields hold a box at surface + half-height/cos within 0.03 m (a transposed collider is > 0.09 m off); recovery and `reset` keep the same collider identity with the sphere re-seated within 0.02 m; two fields in one world route contacts only to the touched field (the other: 0 steps, 0 active cells, 0 contacts); scenery-supported bodies press nothing and a removed body stops stamping; unsupported shapes throw naming sphere, box, capsule. +- [x] AC-5 [local, actor: implementation agent]: The generated snow game meets the sphere/contact and canonical-surface assertions on the native Linux desktop host. proof: `pnpm build:desktop && pnpm exec threenative-playtest --scenario playtests/snow-physics.playtest.json --target desktop --executable dist-native/snow-proof` from the packed generated project named `snow-proof` — Evidence: exit 0 on 5/5 consecutive runs (2026-10-01, after the native heightfield change; 3/3 before it) from a `snow-proof` scaffolded outside the workspace from packed tarballs; `runtime: native`, NVIDIA RTX 2080 via Vulkan; `native-playtests/survives.playtest.json` exit 0 on the same build. The packed runtime ships no prebuilt host, so `THREENATIVE_RUNTIME_BINARY` pointed `build:desktop` at this branch's `pnpm native:build` output (exit 0). Below the game: `cargo test --release` in `runtime-native/native/physics` green including `tests/snow_contacts.rs` (heightfield create/refresh, airborne → 0 records, resting load within 2× of m·g, sleeping → 0, a lowered surface wakes and re-seats the ball, malformed refreshes refused); `native-contract.spec.ts` 23/23 (heightfields cross as their own column-major samples; old runtimes fail closed). Native frame: `docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg`. **Verification:** Use the same physics scenario/telemetry on browser and desktop, with captured nonblank frames and solver-derived poses. Report canonical/render/collider versions, maximum surface error, load provenance and supported-contact counts through the existing registry/state observation bridge. A changed snow surface alone does not prove physical support; assert both deformation and the sphere's actual solved height/rotation. Native behavior proof lands with its portable seam changes. ### Phase 3: Discoverable, packed snow starter kit -**Status:** NOT STARTED +**Status:** COMPLETE **Files:** `packages/create-threenative/templates/snow/` with portable scene/entities, editable `src/render/`, controls, instructions and playtests; existing scaffold/playtest/look/convention tests and `scripts/visual-gate.ts` template inventory; manifest/reference generators, existing MCP search/server tests and recall corpus. Generated mirrors/manifests follow their generators. **Implementation:** Recover the supplied scene/effects into the smallest useful kit, reusing the new public snow APIs rather than copying their implementation back into the template. Install local tarballs through the existing template harness. Extend capability situations and constraints, synchronize instructions/MCP files, and inspect browser/native captures against the supplied snow appearance. Keep weather transitions and sound/game feel editable in source. -- [ ] AC-6 [local, actor: implementation agent]: A packed `--template snow` game boots and plays on browser WebGPU with source-derived footsteps/weather/powder controls and the sphere physics scenario. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — Evidence: pending; extend the existing harness to include the kit's nonempty snow scenarios and inspect its captures. -- [ ] AC-7 [local, actor: implementation agent]: Snow mechanic queries discover the public field/binding through actual MCP search/detail, and returned examples compile against packed exports. proof: `pnpm build && pnpm capabilities:check && pnpm caps:recall` and `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts packages/engine-mcp/__tests__/server.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` — Evidence: pending; exercise the shipped manifest from the generated project as well. -- [ ] AC-8 [local, actor: implementation agent]: The completed implementation passes repository checks without changing unrelated games or weakening native/assertion guards. proof: `pnpm typecheck && pnpm lint && pnpm test && pnpm budgets && pnpm check:docs` — Evidence: pending. +- [x] AC-6 [local, actor: implementation agent]: A packed `--template snow` game boots and plays on browser WebGPU with source-derived footsteps/weather/powder controls and the sphere physics scenario. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — Evidence: exit 0 (2026-10-01, on the rain + snow merge; the kit is byte-identical to the final branch), NVIDIA Turing adapter on every scenario: `snow-real-frame-boot`, `survives`, `snow-footsteps`, `snow-weather`, `snow-touch-controls`, `snow-physics` and `production-performance` all pass. Native re-run on the merged tree: `native-playtests/snow-physics` 4/4 and `survives` exit 0 from a freshly packed `snow-proof`. Captures inspected: see the visual record above. +- [x] AC-7 [local, actor: implementation agent]: Snow mechanic queries discover the public field/binding through actual MCP search/detail, and returned examples compile against packed exports. proof: `pnpm build && pnpm capabilities:check && pnpm caps:recall` and `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts packages/engine-mcp/__tests__/server.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` — Evidence: `pnpm build` 0; `pnpm capabilities:check` fresh (374 entries, 362/362 package-backed entries resolvable from scaffolds); `pnpm caps:recall` 0 (83 rows, recall 0.916, 9 snow rows); engine-mcp search/server/capability-examples 71/71. In the packed `snow-proof`, the shipped server (`node_modules/@threenative/core/mcp/engine.mjs` over stdio, as its `.mcp.json` launches it) answers "leave footprints in deep snow that stay where the player walked" with `attachSnowPhysics`, `SnowField` first and "drop a heavy ball into powder snow so it sinks and carves a track" with `attachSnowPhysics`, `SnowField`, `GPUParticles3D`, `snowDiscFootprint`; `engine_capability_detail` returns both with their `@threenative/core/world` / `@threenative/physics` imports, and the kit that calls both type-checks and builds against the packed exports. +- [x] AC-8 [local, actor: implementation agent]: The completed implementation passes repository checks without changing unrelated games or weakening native/assertion guards. proof: `pnpm typecheck && pnpm lint && pnpm test && pnpm budgets && pnpm check:docs` — Evidence: all exit 0 on `de581effa` (2026-10-01, rain + snow merged). `pnpm test`: root 526 files / 6,567 tests passed (9 skipped), runtime-native 132 files / 1,558 tests; `pnpm budgets` includes the 72 MB evidence cap (docs/verification 67.4 MB before this PRD's 0.77 MB of captures) and the regenerated native coverage report; `pnpm sync:agents --check` and the MCP config check are clean. No other template's scaffold hash moved; no assertion or native guard was relaxed (the powder-resistance attempt that would have widened the kit's roll-ratio band was reverted, see Decisions). **Verification:** Before runtime claims, scaffold `snow-proof` with the existing local-package overrides and run its browser/native scripts. Check source-independent installation and default-exported native game entry. Run `pnpm sync:agents` and `pnpm sync:mcp` for changed generated instructions/configs. Keep routine results beside these boxes; create no separate verification report. Run `pnpm prd:progress` after each phase; finish the implementation in one PR targeting `develop`, and move this PRD to `done/` only when every required result is verified. diff --git a/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md b/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md new file mode 100644 index 0000000000..0f8a7840fe --- /dev/null +++ b/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md @@ -0,0 +1,242 @@ +# PRD-476 — Fluid Lab: particle water on the GPU at 60 fps + +**Status:** DONE +**Complexity:** 9 (HIGH); risk override: none. +**Owner:** Engine implementation agent +**Depends on:** None +**Progress:** 8/8 required boxes verified (the two frame-time boxes carry both frame cost and presented fps; see Presented fps) + +## Context + +Absorb `/home/joao/Downloads/fluid-lab-v2.html` ("Fluid Lab 02", 238,714 bytes, SHA-256 +`e389fe7f5198c3ce4365b343281c1e34317c55082514741815e9217ad14f4ef7`) as engine capabilities, remove +the frame-rate wall that holds it near 30 fps, and rebuild it as a sandbox game on those +capabilities. Phases 1-3 are implemented on PR #389; the two frame-time boxes carry measured frame cost and presented fps on the real display. + +The file is a raw Three.js 0.180 (WebGL2) + Rapier 0.19 lab with eight experiments: dam break, +splash tank, buoyancy, waterfall, fountain, whirlpool, viscosity, and ocean & rain. Its readable +source is embedded as a JSON map at `fluid-lab-v2.html:69` (`window.FLUID_LAB_SOURCES`); lines +70–1280 are the bundled module. The Downloads file is an input, never a runtime dependency. + +### What the source does, and where it goes + +| Source (`FLUID_LAB_SOURCES` path) | Behaviour | Destination | +| --- | --- | --- | +| `src/physics/fluid.js` | CPU Position Based Fluids: uniform-grid neighbours, density constraints, XSPH viscosity, cohesion, vorticity confinement, box/sphere collider projection, emit/drain/stir | **New** `FluidParticles3D` in `packages/core`, on GPU compute | +| `src/render/surface.js` | CPU density lattice + surface nets mesh, gradient normals, per frame | **New** density volume on `FluidParticles3D` (data); the surface look is game `src/render/` | +| `src/physics/coupling.js` | Rapier bodies, sampled Archimedes lift and drag, particle→body reaction impulses | Existing `Buoyancy3D` (`@threenative/physics`) on the fluid's height source; colliders feed the solver | +| `src/physics/waves.js` | Damped heightfield wave equation, rain/pointer impulses | Existing `RippleField` (+ `WaveField` swell) — no new engine code | +| `src/physics/secondary.js` | Spray droplets, crown sheets, advected foam, bubbles | Existing `GPUParticles3D` with a game process function; foam and crown shading in game `src/render/` | +| `src/render/shaders.js`, `water-renderer.js`, `stage.js` | Refraction, absorption, Fresnel, exit-depth pass, debug views | Game `src/render/` (rule (b): it decides the look) | +| `src/simulation.js`, `presets.js`, `ui.js`, `main.js` | Eight scenes, actions, inspector, fixed-step loop | Sandbox game scenes, HUD, and the engine's fixed-step dispatch | + +### Why it runs at ~30 fps (measured) + +`src/physics/fluid.js` and `src/render/surface.js` are dependency-free, so they were timed directly +in Node 20.19.6 on this machine (splash-tank fill, 60 steps after a 30-step warm-up): + +| Quality | Particles | `FluidSolver.step` | `FluidSurface.update` | +| --- | --- | --- | --- | +| Light | 760 | 14.3 ms | 4.2 ms | +| Balanced (default) | 1,625 | 29.9 ms | 6.6 ms | +| High | 2,175 | 40.9 ms | 9.6 ms | + +At the default quality the CPU solver alone is 1.8× the 16.7 ms frame, before the surface rebuild +and four scene renders per frame (`water-renderer.js:118–123`: scene target, back faces, composite, +quad). `main.js` runs up to three 1/60 s steps per frame and caps the accumulator at three steps, +so when a step outruns the frame the display drops to the 30 fps vsync bucket and the simulation +falls into slow motion. Light quality still misses the budget once rendering is added. Making the +CPU code a few times faster would not leave room for rendering on a phone; the neighbour search +and constraint passes are data-parallel work that belongs on the GPU. + +### Existing mechanisms reused + +Capability search and detail were run for the full request and for each mechanic. + +- `FluidField2D` (`packages/core/src/fluid-field.ts`, 748 lines; PRD-249): the precedent shape — + TSL compute passes, `IComputeDriven`, `ctx.add` lifetime, numeric samplers, game-owned look, + conformance case `77-fluid-field`, example `examples/prd249-fluid-field` with web and desktop + playtests. `FluidParticles3D` copies this shape; it does not replace the 2D field. +- `ComputeDrivenRegistry` / `IComputeDriven` (`packages/core/src/compute-driven.ts`): pass order, + warm-up, release. +- `GPUReadback`: throttled GPU→CPU copy with `staleFrames`, for stats and the height source. +- `Buoyancy3D` (`packages/physics/src/Buoyancy3D.ts`): hull points, density, drag, any height + source. Covers the lab's buoyancy and splash-slowdown. +- `RippleField`, `WaveField`: the ocean scene, unchanged. +- `GPUParticles3D`: spray and bubbles. A search for "splash spray droplets with lifetime and + gravity" returned verdict `none`; this is a manifest recall gap, fixed in Phase 3. +- `WaterSurface3D` is a level-plane refraction/reflection helper; it does not fit a free-form + particle surface and is not used here. + +No 3D particle fluid, SPH/PBF solver, or isosurface extraction exists in `packages/`. + +Complexity: 2 for 6–10 implementation files, 2 for a new system, 2 for GPU pass ordering and +cross-frame readback state, 2 for crossing the native host and the sandbox tarball boundary, +1 for none of the rest → 9. + +## Solution + +```mermaid +flowchart LR + G[game scene] -- fill / emit / drain / stir / force Fn --> F[FluidParticles3D] + R[Rapier bodies] -- collider transforms each fixed step --> F + F -- positions, velocities storage --> SP[GPUParticles3D spray process Fn] + F -- density volume --> M[game src/render water material] + F -- column heights via GPUReadback --> B[Buoyancy3D] + B -- forces --> R + F -- stats via GPUReadback --> H[HUD] +``` + +**`FluidParticles3D`** (`packages/core/src/fluid-particles.ts`, exported from the core index with +`@situation` tags) is a GPU Position Based Fluids solver that draws nothing: + +- Options: `capacity`, `spacing`, `bounds`, `iterations`, `viscosity`, `cohesion`, `vorticity`, + `gravity`, plus an optional game TSL `force` function (the whirlpool's tangential drive). Every + option has a default measured from the source's balanced preset; invalid values throw. +- Per fixed step, as ordered compute passes: predict, grid hash + counting sort, density/λ, + position delta (repeat `iterations`), collider projection, velocity update with XSPH viscosity, + cohesion and vorticity confinement. +- Game API: `fill(min, max, velocity)`, `emit(position, velocity)`, `drain(region)`, + `stir(point, strength, radius)`, `setColliders([...])` (spheres and oriented boxes, updated each + step from Rapier transforms; static and dynamic). +- Data out: `positions` / `velocities` storage nodes; a `density` 3D texture splatted each step; + `heightAt(x, z)` from a column-height grid copied through `GPUReadback` (reports `staleFrames`); + `stats` (`count`, `meanCompression`, `maxSpeed`) through the same throttled readback. +- Fails closed: on a renderer without compute (WebGL2 backend) `attachRenderer` throws with a + named error rather than drawing nothing (the constructor has no renderer to ask). + +**The look stays in the game.** The sandbox game's `src/render/` raymarches the density volume +(refraction, absorption, Fresnel, debug views ported from `shaders.js`) and owns spray, foam and +crown shading. A game can replace all of it without touching `packages/`. + +**The sandbox game** `../sandbox/fluid-lab/` is scaffolded and installed from tarballs like a +user's, with all eight experiments as scenes, the lab's actions and keyboard, a HUD with the four +metrics, and a `FRICTION.md` logged as it is built. + +**Risks.** 3D storage textures and atomics in TSL on the native Dawn host (proved by the +conformance case before the sandbox depends on them); readback lag making floating bodies bob +(`Buoyancy3D` already runs on fixed steps; `staleFrames` is reported, not hidden); particle +tunnelling at the lab's 18 m/s velocity clamp (keep the clamp as an option). + +## Acceptance Criteria + +Phase boxes below are the acceptance criteria; each names its proof. + +- [x] Presented 60 fps on the real display: the sandbox Splash Tank (High) and the example's 6,000-particle stress scene read p50/p95/p99 16.7/16.8/16.8 ms against a 59.97 fps empty-page control. proof: the `Presented fps` section (rAF deltas on `:0`, vsync on, 30 s per scene) +- [x] The water reads as water, not jelly or plastic: the raymarch is the example's default and the solver defaults are pinned. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts` (12/12, incl. "defaults to water-like damping, not a jelly") and the PR screenshots +- [x] The gates pass on the final defaults. proof: `pnpm typecheck`, `pnpm lint` (0 errors), the example dam-break and coupling playtests, the lab playtest, and the desktop `fluid-particles` conformance case + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| GPU particle fluid | `ctx.add(new FluidParticles3D(...))` in `examples/prd476-fluid-particles/src/game.ts` and the sandbox scenes | New; the lab's CPU `FluidSolver` is not ported | Phase 1 boxes | +| Fluid → body buoyancy | `new Buoyancy3D({ surface: fluid, ... })` in sandbox buoyancy/splash scenes | Replaces `coupling.js` sampling and reaction impulses | Phase 2 box 1 | +| Native host | Conformance registry case on the desktop host | New case beside `77-fluid-field` | Phase 2 box 2 | +| Discovery | `engine_search_capabilities` → manifest built by `pnpm build` | Adds `@situation` tags; no hand-edited JSON | Phase 3 box 1 | + +## Presented fps + +Measured 2026-10-02 on the real display (`:0`, HDMI-A-1 59.96 Hz, `nvidia/turing`, RTX 2080) after the owner approved one window on it: Chromium via Playwright, vsync on, 1280x720, `requestAnimationFrame` deltas over 30 s (about 1,800 frames) after a 12 s settle, an empty-page control first in the same window (59.97 fps, p50/p95/p99 16.7/16.8/16.8 ms, so the lane is valid), frame timestamps only, no screenshots of the desktop. Final code (core defaults 8e-3 / 0.03 / 0.015, raymarch through the glass), host load 4-18 from other sessions: + +| scene | p50 / p95 / p99 ms | mean fps | worst frame | +|---|---|---|---| +| control (empty page) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms | +| sandbox Splash Tank, High (4,896 particles) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms | +| sandbox Waterfall, High | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms | +| sandbox Whirlpool | 16.7 / 16.8 / 16.8 | 59.93 | 33.4 ms (1 frame) | +| sandbox Ocean and raft | 16.7 / 16.8 / 16.8 | 59.93 | 33.4 ms (1 frame) | +| example stress (6,000 particles) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms | +| example dam break | 16.7 / 16.8 / 16.8 | 59.70 | 150 ms (1 frame, load 18) | + +Headroom with the final shader and defaults (uncapped `requestAnimationFrame` intervals, vsync off, private Xvfb, host load 6-9, about 13,000 frames per scene): example 6,000-particle stress p50 1.0 / p95 4.8 / p99 7.8 ms and 6.9 ms on the rerun; sandbox Splash Tank High p50 1.0-1.1 / p95 4.6-4.7 / p99 8.8-9.1 ms; sandbox Waterfall High p50 1.0-1.1 / p95 4.9-5.0 / p99 8.7-10.3 ms. Mean 1.4-1.6 ms, so about 10x under the 16.7 ms budget with the through-the-glass raymarch included. + +Native: the `fluid-particles-desktop` playtest bundles the example, which now defaults to the raymarch, and passes on the native host (770 frames); it asserts state, not pixels. The `fluid-particles` conformance case renders its own scene through `conformance.js` and still matches the web capture (pixel mismatch 0, ΔE 0) with the new defaults. + +Known limits of the raymarch look (fresh-eyes review, not fixed): a ray that starts under water ends at the first sparse voxel (a spray gap) and shows the underside colour, so a splash seen through the glass can show holes; the sample clamp reads the lowest 0.26 m (1.2 spacings) as full-density, so a film thinner than that can vanish; rays that end on a side or back wall show a flat water colour. The surface seen from above is unaffected. + +An earlier pass with the previous defaults and shader gave the same p50/p95/p99 on Splash High, Dam, Ocean and the 6,000-particle stress scene (one 100 ms hitch in a first Splash High run, none on the rerun). Read the 16.8 ms p95 as the display period (59.96 Hz is 16.68 ms) plus rAF timestamp resolution of about 0.1 ms, not as a missed frame: every counted frame landed on a vsync, and the only misses are the three single-frame hitches below. Presented fps is display-locked at 60 Hz, so this proves the frame budget holds, not headroom beyond it; headroom is the frame cost above (about 1 ms GPU per frame). Single isolated hitches (33-150 ms) appeared three times in about 12,600 frames while other sessions loaded the host; not attributed to the solver. + +## Decisions + +- 2026-10-01 (planning agent, proposed; owner may overturn): GPU compute, not an optimised CPU + port. Measured 29.9 ms per balanced step on a desktop CPU; even a 3× gain leaves no frame budget + on a phone, and the charter makes a backend the game cannot write portably the engine's job. +- 2026-10-01 (planning agent, proposed): a density volume the game raymarches, not GPU surface + nets. Surface nets need stream compaction and indirect draws on both hosts; the volume is data, + which keeps the look in `src/render/` like `FluidField2D`'s dye. +- 2026-10-01 (planning agent, proposed): no particle→body reaction impulses. `Buoyancy3D` lift + and drag already slow and float bodies; add impulses when a scene shows a body passing through + water without slowing. +- 2026-10-01 (planning agent, proposed): name `FluidParticles3D`, pairing `FluidField2D` and + `GPUParticles3D`; Godot has no fluid node to borrow. +- 2026-10-01 (implementation agent): floating bodies carry one centre hull point (heave only). + Four to eight points fed splash noise into torque and tumbled the box (ejected to 27 m) because + Rapier angular damping is not on the body options; `sample` box-filters open columns for the + same reason. +- 2026-10-01 (implementation agent): the lab's HUD is a DOM readout, not scene-drawn; spray is a + `GPUParticles3D` that borrows fast fluid particles' state; the volume raymarch is game source. +- Out of scope: Android/iOS frame-rate claims (no result here claims a platform it did not run + on); a follow-up PRD takes the Pixel 8 measurement once Phase 3 lands. + +## Execution Phases + +#### Phase 1: `FluidParticles3D` runs a dam break on the GPU at 60 fps + +**Status:** DONE (3 of 3 boxes; frame cost and presented fps measured) +**Files:** `packages/core/src/fluid-particles.ts` (new), `packages/core/src/index.ts` (export + +JSDoc tags), `packages/core/__tests__/fluid-particles.spec.ts` (new), +`examples/prd476-fluid-particles/` (new, copied from the `prd249-fluid-field` layout: `game.ts`, +`main.ts`, `render/`, `conformance.js`, `playtests/`). +**Implementation:** Port the `fluid.js` algorithm pass for pass into TSL compute behind +`IComputeDriven`; keep the source's kernel constants and parameter defaults so behaviour can be +compared. The example renders particles as points from `positions` (debug look only) and exposes +`GameState` with `stats`, `steps`, and a front-position probe. + +- [x] Option validation fails closed, passes dispatch in the documented order, `fill`/`emit`/ `drain` respect capacity, and scene removal releases buffers. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts` — 9/9 pass +- [x] A released dam-break column runs across the tank and settles with mean compression ≤ 0.05 and every particle inside `bounds` (GameState resource assertions). proof: `node packages/playtest/dist/runner/cli.js examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json --url http://127.0.0.1:5173 --server-command "pnpm --filter prd476-fluid-particles dev --host 127.0.0.1" --browser-recipe webgpu --headed` — pass on `nvidia/turing`: 1,638 particles, peak front x 2.9 m, final mean compression 0.0012, max speed 0.047 m/s, inBounds 1, 0 console errors (headless Chromium reports no adapter here, so `--headed` on the private Xvfb is required) +- [x] With 6,000 particles (the source's High capacity) the splash tank holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter (`adapter.info` recorded, not SwiftShader). proof: `measure-steady-state-fps` skill lane against the example + Frame cost on a hardware adapter, plus presented fps on `:0` (see Presented fps: p95 16.8 ms, 59.97 fps): GPU timestamp queries (`?bench=1&stress=1`, `window.__fluidGpuMs(30)`, `nvidia/turing`) put one solver step at 0.30–0.32 ms of GPU time at 6,000 particles; uncapped frame deltas over 6,213 frames are p50 1.2 / p95 7.4 / p99 15 ms with the host at load average ~50 (a no-fluid control showed p99 14–16 ms). The private Xvfb cannot present a trivial WebGPU page above 17–20 fps, so presented fps was taken on `:0`. + +**Verification:** run the three proofs above; record particle count, p95 frame time and adapter +beside the third box. + +#### Phase 2: bodies splash and float, and the native host runs the solver + +**Status:** DONE (2 of 2 boxes) +**Files:** `packages/core/src/fluid-particles.ts` (colliders, column heights, `heightAt`), +`examples/prd476-fluid-particles/src/game.ts` (a sphere and two boxes), +`packages/runtime-native/conformance/registry.json` (new case), +`examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json` (new). +**Implementation:** `setColliders` uploads sphere/box transforms from Rapier each fixed step and +projects particles out of them; the column-height grid feeds `heightAt` through `GPUReadback`; +`Buoyancy3D` consumes it unchanged. Register a conformance case next to `77-fluid-field` with +`desktopGate: true`, using the example's `conformance.js`. + +- [x] A 1,900 kg/m³ sphere dropped into the tank displaces the surface and comes to rest on the floor, while a 550 kg/m³ box ends floating with its centre within 0.2 m of `heightAt` after 5 s (GameState assertions). proof: the Phase 1 playtest command with the coupling scenario — pass x2 (`fluid-particles-coupling.playtest.json --url http://127.0.0.1:5173/?scene=coupling --headed`): sphere rests at y 0.299 m with speed 0, the 550 kg/m³ box ends 0.04 m from `heightAt` (limit 0.2), surface disturbance seen, 0 console errors. Hull is one centre point (heave only): off-centre points fed splash noise into torque and tumbled the box. +- [x] The desktop host runs the solver and matches the web capture within the case tolerance. proof: `pnpm parity` (case `fluid-particles`) and the `fluid-particles-desktop` playtest — `run-conformance.mjs --target web` then `--target desktop --only-tests fluid-particles` against `build/tn-linux/mystral`: pass, pixelMismatchRatio 0, perceptualDeltaE 0 (tolerance 0.08 / 6.0), 0 GPU validation errors; `--target desktop` playtest passes (peak front 2.9 m, final max speed 0.09 m/s, stats readback works natively); web capture on `nvidia/turing` + +**Verification:** both proofs; regenerate the census in the same commit as the registry change +(`pnpm census`). + +#### Phase 3: agents find it, and the sandbox Fluid Lab runs all eight experiments at 60 fps + +**Status:** DONE (3 of 3 boxes; frame cost and presented fps measured) +**Files:** `packages/core/src/index.ts` (`@situation` tags for `FluidParticles3D`; spray/droplet +situations on `GPUParticles3D`), `packages/create-threenative/templates/sailing/AGENTS.md` or the +capability reference the scaffold reads (one line, inside the template caps), +`../sandbox/fluid-lab/` (new game: scenes, `src/render/`, HUD, playtests, `FRICTION.md`). +**Implementation:** `pnpm build` regenerates both capability manifests. Pack core and physics to +a private staging directory with content-hashed tarball names (never `pnpm sandbox`, which wipes +the shared `.packages`), scaffold `fluid-lab`, port the eight experiments onto +`FluidParticles3D`, `Buoyancy3D`, `RippleField`/`WaveField` and `GPUParticles3D`, and the optics +from `shaders.js` into `src/render/`. Commit and push the game to the sandbox remote as soon as it +runs, per the sandbox `AGENTS.md`. + +- [x] `engine_search_capabilities` returns `FluidParticles3D` first for "pour water into a tank and drop a ball in it" and `GPUParticles3D` for "splash spray droplets with lifetime and gravity"; template caps still pass. proof: `pnpm build && pnpm test`, then both searches — `searchCapabilities` over the regenerated `packages/core/capabilities.json`: "pour water into a tank and drop a ball in it" → FluidParticles3D (0.70) first; "splash spray droplets with lifetime and gravity" → GPUParticles3D (3.60) first. `pnpm build` ok; `pnpm test` (create-threenative + scripts + the new spec, 2,457 tests) 2,456 pass; the one red (`quality-json.spec`, 3 unwaived suppressions in `fluid-particles.ts`) was fixed and rerun green; `pnpm typecheck`, `pnpm lint` (0 errors) and `pnpm budgets` exit 0 +- [x] A playtest cycles all eight scenes through keys 1–8 and asserts each is non-blank, changes over time, and reports a non-zero particle or wave state. proof: `node packages/playtest/dist/runner/cli.js ../sandbox/fluid-lab/playtests/experiments.playtest.json --url --browser-recipe webgpu --headed` — pass on `nvidia/turing` (`../sandbox/fluid-lab`, pushed as ThreeNativeHQ/examples 0cdeb86): per scene `experiment`, `alive` (particles or wave energy) and `moved` (speed over 0.2 m/s or wave energy) hold at every step; the eight per-scene screenshots are 5-11 % non-background pixels (ocean 90 %); 0 console errors +- [x] The sandbox Splash Tank at the High preset holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter — the case the source drops to 30 fps. proof: `measure-steady-state-fps` skill lane against the sandbox build + preview + Frame cost on a hardware adapter, plus presented fps on `:0` (see Presented fps: p95 16.8 ms, 59.97 fps): on the High preset (4,896 particles) a fixed step is 0.26–0.33 ms of GPU time (`?bench=1&exp=2&quality=high`, `window.__fluidGpuMs(30)`, timestamp queries) and the raymarch render pass 0.28 ms (`TN_FRAME_BUDGET` `gpuMain`), under 1 ms of GPU per 16.7 ms frame; uncapped frame intervals read p95 8.5–13.4 ms over 5,000–6,500 frames per run with the host at load average 14–50, where a no-fluid control already read p99 14–16 ms. The p99 tail (30–60 ms) tracks host load, not the solver (it also appears with readback off). Presented p95 was taken on `:0` (see Presented fps). + +**Verification:** the three proofs; `score-build-experience` on the finished sandbox build goes +in the PR body, not a box. diff --git a/docs/PRDs/done/PRD-VQ-11-bounded-decals.md b/docs/PRDs/done/PRD-VQ-11-bounded-decals.md new file mode 100644 index 0000000000..d3d576922e --- /dev/null +++ b/docs/PRDs/done/PRD-VQ-11-bounded-decals.md @@ -0,0 +1,208 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-11 — Impact and environment decals use a bounded portable lifecycle + +**Status:** COMPLETE — 2026-10-02 at `8f85fff19913c955a44b7b85d2cc117b972bd19c`. All six phase items and both acceptance criteria are verified by CPU ownership tests plus actual browser WebGPU and packaged Linux ARM64 QuickJS/wgpu captures. The merge with develop `924b92f8` changes the playtest runner and is undergoing the same browser/native qualification and required CI before promotion. Software-adapter correctness only; no mobile or hardware-performance claim. +**Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 2 / high-payoff detail. +**Dependencies:** Independent. Share receiver/base-geometry selection with existing LOD and picking paths. + +## Grounding and intended outcome + +Upstream [DecalGeometry](https://threejs.org/docs/pages/DecalGeometry.html) already provides mesh projection. The earlier audit must not be interpreted as permission to rewrite that algorithm. The useful product gap is qualified receiver handling, pooling, lifetime and game-owned material composition for real impacts. + +**Outcome:** Repeated bullet impacts and environment marks remain attached to their receivers, respect normals and occlusion, and stay within a fixed resource cap as the player moves or the scene unloads. + +## Design and ownership + +Start from ordinary Three decal geometry and generated materials. Reuse existing hit results, object transforms and authored LOD0 geometry rather than raycasting the whole world again. Admit a shared lifecycle owner only when at least two consumers need it. Keep material, atlas choice, color, depth bias and fade curves in generated source. A world-static mark and a rigid-moving receiver are separate supported cases; skinned or deforming decals are explicitly refused in v1. + +Surface-color impacts and environment marks on static/rigid opaque receivers. No deferred G-buffer decal renderer, normal/roughness channel rewrite, skinned tattoo system or virtual-texture painting. + +### Opt-in projection implementation slice + +Extend the existing game-owned shooter `DecalField`; preserve `place(point, normal, variant, scale)` and its authored default scene. Add `project(receiver, geometry, point, normal, variant, { depth, scale? })`, where the game passes its existing hit object and public `baseGeometryOf(receiver)` result. The render source remains ordinary Three.js with no framework import. Upstream `DecalGeometry` clips only that receiver's borrowed geometry; the field owns the resulting buffers, keeps forward-facing triangles, applies the authored normal offset, and transforms the result into receiver-local space. + +Reuse existing material/slot budgets and deterministic per-variant round-robin eviction. A fixture with one 256-slot family proves the required total cap. Release projected buffers on slot replacement, clear, receiver removal and field disposal. Direct receiver removal triggers cleanup; `update()` also cleans receivers whose ancestor subtree left the field's parent. Rigid translation/rotation and positive uniform scale are supported. Reject skinned, morph/deforming, instanced/batched, transparent/cutout, mirrored, singular, sheared or nonuniformly scaled receivers explicitly. Source geometry is borrowed, is never reassigned or disposed, and may differ from the current render LOD. + +CPU proof belongs in `packages/create-threenative/__tests__/shooter-decals-projection.spec.ts`, alongside existing lifetime proof. An opt-in fixture will exercise real framework picking/base geometry, receiver motion, cap/eviction and teardown. Projection corner distortion and same-receiver overlapping forward-facing surfaces remain upstream limitations; the caller supplies a narrow projection depth. Browser/native and actual relevant screenshot gates remain open until executed. + +### Authored atlas/fade continuation (2026-10-02, CPU-verified; runtime pending) + +Keep this extension in the existing generated `DecalField`. A projected mark may provide `uvRect: [u0, v0, u1, v1]`, normalized and ordered inside the borrowed atlas, plus `fade: { duration, opacity(progress) }`. Map only the owned projected UV buffer; do not clone or rewrite the borrowed texture. The optional duration is positive finite seconds; progress runs from 0 toward 1 through `update(dt)`, and the authored curve must return finite opacity in [0, 1]. At expiry, release only the projected geometry/listener and recycle the slot. Reuse resets opacity; omitted controls keep the existing persistent, full-map appearance. Projection bias must be nonnegative and at most half the supplied projector depth, rejecting invalid input before evicting a live mark. + +Capability searches inspected the installed atlas rewrite/texel helpers and mesh merger. Those are asset cooking/framework surfaces; generated render source cannot import the framework, and the owned projected buffer needs only a direct UV affine transform. The full-request search also inspected `SpriteAnimator3D`; this is projected surface geometry rather than a sequenced sprite, and the render-source import rule applies. No installed authored fade/lifetime or GPU residency capability matched. CPU proof covers UV isolation, opacity progression, expiry/reuse/removal, malformed input and repeated lifecycle ownership. The existing hosted real fixture has optional atlas/fade and repeated scene-reset observations while retaining its pinned visible-mark and hidden-material gates. Native execution remains a separate unverified platform gate. + +### Linux native qualification plan (2026-10-02) + +Use the supported hosted Ubuntu ARM QuickJS build and the built public `runDesktopPlaytest`. Bundle the same factory through explicit standard/atlas/hidden/hidden-during-fade native entries, then use the existing desktop packager to embed the unchanged cooked receiver GLB and manifest. Publish existing measured scene observations into the native-supported state resource seam. Map every authored component predicate to a resource predicate; native readiness uses startup assertions, and the original pixel assertions run unchanged against actual device PNG bytes through the maintained evaluator. Native browser-only assertion families are not advertised as supported. + +Before hosting, test each scenario against the real mailbox capability/preflight implementation using the captured minimal native handshake shape. Reject missing assertions, unready startup, wrong renderer/capture provenance, unnamed adapters, device loss and host console errors. Record source, runtime, bundle, packaged executable and asset hashes and verify them after execution. Software llvmpipe evidence is correctness-only. No native or full acceptance box closes until the actual packaged fixture and its repeated lifecycle have run. + +## Required behavior + +- A cap of 256 decals in the fixture is enforced with deterministic eviction, and receiver teardown removes its marks. +- A mark stays on a rigid receiver under translation/rotation; nonuniform or unsupported transforms have a specified behavior. +- Receiver isolation avoids marks projecting through nearby unrelated geometry; corner distortion is documented. +- LOD changes do not alter the gameplay hit surface or strand a decal on disposed geometry. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests unless a result is explicitly recorded. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Reuse projection and bound lifetime + +- [x] Integrate upstream decal projection with existing picking/base-geometry data and explicit receiver support. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts` — 20 tests passed, including a real cooked GLB through the asset loader, 128-to-2-triangle LOD selection, framework picking and `baseGeometryOf`; renderer proof remains Phase 3. +- [x] Implement deterministic pool limits, eviction, receiver cleanup and borrowed-resource ownership. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts` — 23 tests passed; 1,024 impacts retained 256 slots, released 768 evicted projected buffers, and released the remaining 256 once at teardown while retaining borrowed source geometry/map. + - Partial, 2026-10-02: the existing generated shooter `DecalField` now releases each cloned slot material, family material and shared geometry once; its texture remains borrowed. `node node_modules/vitest/vitest.mjs run packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts` passed 4 tests after reproducing 4 failures on the original implementation. This repairs the existing 224-slot template; it does not implement the proposed 256-decal receiver/projection fixture or introduce an engine pool. + - Caller ownership follow-up, 2026-10-02: `Play` registers its procedural decal texture in the existing scene registry after the borrowing field. The real `Play.load`/`enter` and registry teardown regression proves all 224 material disposals precede one texture disposal on each of three scene lifecycles; repeated cleanup does not redispose it, and the borrowed sky texture is retained. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/template-runtime-cost.spec.ts -t 'releases the shooter-owned decal texture'` — missing-disposal failure before the fix, pass afterward. + +### Phase 2 — Preserve authored appearance and receiver motion + +- [x] Add game-owned decal material/atlas composition with bounded bias and fade controls. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts` plus the runtime fixture. + - Materials, tints, map, offset, projection depth and size remain game-authored. Optional atlas/fade controls and bounded projection bias are CPU-verified; actual atlas, visible partial fade, expiry and hidden-during-fade cases pass at `021e6223` in run 37003284983. +- [x] Verify rigid receiver motion, LOD changes and repeated scene teardown without detached marks or allocation growth. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts` plus the Phase 3 lifecycle scenario. + - Receiver/ancestor rigid motion, positive uniform scale, direct and ancestor removal, explicit cleanup, legacy-slot reuse and actual loader/picker LOD independence are CPU-verified. [Run 37005420971](https://github.com/ThreeNativeHQ/threenative/actions/runs/37005420971), source `3bb9942c`, passes six actual rendered scene instances with identical 259 geometries, 4 textures and 6,522,894 GPU-tracked bytes; three stable completed-render intervals precede each sample. + - Partial, 2026-10-02: the lifetime regression exercises 20 field construction/teardown cycles, 2,000 pooled placements, clear/reuse, zero-slot teardown and repeated `dispose()`. Real Three disposal events/spies verify owned-resource release and retention of the borrowed map. Rigid receivers, LOD changes, GPU memory and game-scene transitions are not qualified by these unit tests. + +### Phase 3 — Qualify the visible result + +- [x] The impact sequence covers the 256-mark cap, moving receiver and LOD boundary on browser WebGPU. proof: `node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts` / `.github/workflows/integration-decals.yml` — [run 36991562324](https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324) passed at `4cfc3c20`, 960 × 540 Google SwiftShader WebGPU; four actual captures satisfy receiver-region pixel checks and pool/motion/LOD observations. The hidden-material control retains 256 live slots but fails exactly both mark-pixel assertions. Correctness only; native and hardware performance are not qualified. +- [x] The same sequence runs on Linux native with matching pool/receiver observations and visible marks. proof: `node packages/playtest/dist/runner/cli.js packages/create-threenative/__tests__/fixtures/bounded-decals/motion.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}` — [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669) passes all ten native cases at `8f85fff19`, 960 × 540 Mesa llvmpipe, through the built public desktop runner and packaged QuickJS/wgpu host. State predicates, original external pixel gates, both hidden-material controls and strict startup/console/provenance guards pass. + +## Acceptance criteria + +- [x] The supported receiver cases retain their marks under motion and teardown, and resource use is bounded independently of total shots fired. proof: [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669), source `8f85fff19`, passes the complete bounded-decals browser/native sequence; six native generations retain 259 geometries, 4 textures and 6,522,894 tracked bytes with all growth measures zero. The CPU cap regression fires 1,024 impacts through 256 slots and proves exact eviction/final disposal. Rigid transform and removal tests plus actual motion/teardown PNGs verify receiver attachment. +- [x] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: 57 focused decal/lifetime/native-proof and adjacent template tests pass, including legacy persistent `place()`, unsupported-receiver refusal, fade expiry/reuse and exact borrower/owner teardown; [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669) verifies scene restart, expiry to zero marks/bytes and six repeated native lifecycles. The default shooter appearance remains unchanged. + +## Performance and promotion + +Measure draw calls and geometry bytes at the cap. Batch only compatible marks when it improves the measured cost; do not trade correctness for an advertised one-draw system. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Qualification limits + +Skinned/deforming receivers remain an explicit v1 refusal and require a separate demand-backed extension. Hosted Chromium SwiftShader and packaged Linux ARM64 QuickJS/wgpu on Mesa llvmpipe qualify the requested browser/native correctness. Mobile and hardware performance are outside this proof. The local container cannot provide GPU/display or tsx Unix IPC execution; hosted exact-source gates supply the actual runtime evidence. Promotion still requires the combined develop-merge run and mandatory CI. + +## Completion record + +All original phase and acceptance items are verified at `8f85fff19`; archived individually under the batch filing rule while sibling PRDs remain active. The dated records below preserve earlier partial and failed attempts as history, not current status. The current qualification record follows them. Promotion gates are tracked separately and are not waived by archival. + +2026-10-02 initial bounded ownership repair at `d134451e` (historical results from the prior environment; not a fresh aggregate pass for later changes): + +- Red/green: the final four-test lifetime regression failed all four cases on the original `decals.ts` and passed all four with the repair. No Three disposal implementation is replaced; listeners and call-through spies observe the real methods. +- `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts packages/create-threenative/__tests__/world-environment-lifetime.spec.ts packages/create-threenative/__tests__/shooter-rig.spec.ts packages/create-threenative/__tests__/template-runtime-cost.spec.ts`: 4 files, 13 tests passed after building local package prerequisites. Existing Rapier/Three warnings were emitted. +- `pnpm typecheck` and `pnpm --filter create-threenative build`: exit 0 after building package prerequisites. `pnpm lint`: exit 0 with 1,000 existing warnings. `node --import tsx scripts/check-doc-links.ts`: exit 0, 2,395 links checked. The doc-link/evidence-budget/evidence-citations suites passed 31 tests using the `node --import tsx` loader in place of the CLI's unavailable IPC socket. +- Full `pnpm test` was attempted and stopped at the `tsx` IPC socket with `EPERM`; its loader-based retry was interrupted during aggregate builds to avoid running the batch's full suite concurrently. This is not a full-suite pass. No browser, native, GPU-allocation or visible-result claim is made. +- All six phase boxes and both acceptance boxes remain open. The proposed 256-mark projection fixture, receiver motion/removal, LOD handling, appearance controls and platform scenarios are still to be implemented or qualified. + +2026-10-02 caller texture ownership follow-up, verified in the recovered environment: + +- Fresh red/green on the actual shooter caller: its generated map emitted zero disposal events after scene/registry teardown before the fix. After the fix, the new regression passes through three scene construction/teardown cycles, checks borrower-before-texture disposal ordering and idempotent cleanup, and leaves the borrowed sky map untouched. +- The same four-file focused/adjacent command above passes 14/14 tests with `--maxWorkers 1`, including the original four field-ownership regressions. Fixture Rapier/Three warnings remain visible; no real disposal implementation is replaced. +- `node node_modules/typescript/bin/tsc --noEmit -p packages/create-threenative/tsconfig.json`: exit 0. Biome check on the changed caller and test: exit 0 with six existing complexity warnings. `node --import tsx scripts/check-doc-links.ts`: exit 0, 2,395 links checked. +- This follow-up does not rerun aggregate root build/typecheck/test lanes while parallel rendering work is active. Browser/native qualification, actual relevant runtime screenshot proof, required CI and independent review remain open; the PR stays draft. Projection/receiver feature expansion is not included in this ownership repair. + +2026-10-02 opt-in receiver projection milestone: + +- Initial projection tests: 17 failed because the opt-in API was absent; all passed after implementation. Additional invalid-normal regression failed before its validation repair; the final projection suite passes 19 tests. The real GLB loader/LOD/picking integration passes one additional test. +- Focused and adjacent validation: 6 files, 34 tests passed with `--maxWorkers 1` (projection, fixture, lifetime, template-runtime-cost, shooter-rig, world-environment-lifetime). Package typecheck passes. Changed-file Biome check passes with the existing texture-generator complexity warning and two fixture complexity warnings. No root aggregate build/test pass is claimed. +- The portable real engine fixture builds through Vite, and all four scenarios pass the maintained schema validator. The local pixel-producing run reaches the maintained runner but fails closed because no usable X display/Xvfb is available. No local screenshot is claimed. +- The hosted workflow uses the built public runner and retains actual reports/PNGs before result checks. Exact source SHA and adapter accompany each capture; software rendering is correctness-only. Error diagnostics or `TN_PLAYTEST_SOFTWARE_DEVICE_LOST` prevent qualification even if a runner report says pass. Actual screenshot progress must be inspected and attached to the PR, including incomplete gates, before any readiness claim. + +2026-10-02 actual hosted screenshot progress, source `7396738b33a2b3018a2e6235a0e59ca07620a324`: + +- [Run 36989297734](https://github.com/ThreeNativeHQ/threenative/actions/runs/36989297734) passed all four authored scenarios at 960 × 540 using WebGPU, Google SwiftShader. Artifact `11218713068` SHA-256: `f432d6d5ca32cc09ee0633d3c3560445dc01ebc6e13b0280fa657774d2842652`. Software-adapter correctness evidence only; no hardware performance claim. +- Exact unmodified captures: [static](../../verification/vq11-decals-36989297734/static.png), [rigid motion](../../verification/vq11-decals-36989297734/motion.png), [receiver teardown](../../verification/vq11-decals-36989297734/teardown.png). Both plates visibly carry marks, the right plate and its marks move together, and removing it leaves only the left plate/marks. [Raw summary](../../verification/vq11-decals-36989297734/summary.json) preserves capture provenance and observations: cap 256, 321 creations, 65 releases, 275,028 geometry bytes and 516 maximum reported draw calls; teardown leaves 128 marks/193 releases; restart reaches generation 2. +- The summary's historical `qualified: true` means the then-current verifier passed. Independent review found that counters and whole-frame nonblank pixels alone do not prove visible decals. These images are human-inspected progress; a same-pose visible-effect discriminator and hidden-decal negative control are being added before automated visible qualification. Phase 3 remains open. +- Native execution, authored atlas/fade controls, repeated projected-scene GPU residency, full acceptance and independent final review remain open. The PR stays draft. + +2026-10-02 automatic visible-mark gate repair: + +- A regression evaluated the real retained teardown PNG against the static scenario: the old whole-frame nonblank gate incorrectly accepted the missing right receiver. Five regression cases failed before adding receiver-region assertions and pass afterward. The checks reuse maintained playtest pixel measurement/evaluation; no screenshot is generated or edited by unit tests. +- Each present receiver now has an interior ROI (left 270,185 / 170 × 125; right 550,190 / 95 × 90). At maximum luminance 0.35 the dark-pixel ratio must be between 0.01 and 0.20, excluding both empty bright plates and the dark background. Actual retained marked captures measure roughly 0.052–0.057. These thresholds are pinned before the new hosted run. +- The verifier also runs the same static fixture with projected materials hidden. Picking, projection buffers, slot counts and LOD remain active; only real mark rendering is disabled. The control must fail exactly both receiver dark-pixel assertions while all other assertions pass and no unrelated diagnostics appear. At implementation commit `4cfc3c20`, hosted execution was pending; the result is recorded below. +- Fresh scoped validation: 39/39 tests pass across six focused/adjacent suites; package typecheck and real Vite fixture build pass. Changed-file lint passes with two existing fixture complexity warnings. Root aggregate checks are not rerun here. Phase and acceptance boxes remain unchanged pending the hosted control and remaining PRD work. + +2026-10-02 visible-mark discrimination verified at `4cfc3c2024da1cb37533dc67fd07679e364c27f6`: + +- [Hosted run 36991562324](https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324) completed successfully, including all four real marked scenarios and the fifth hidden-material control. Artifact `11218994493` SHA-256 `a84190c40b3cdd6679ff9fea858dd6cfe2373646f090192ee531411fbaba1070` was independently verified after download. [Raw summary](../../verification/vq11-decals-36991562324/summary.json) records the exact source, 960 × 540 viewport, Google SwiftShader WebGPU adapter, diagnostics and pixel measurements. +- Both marked receiver interiors measure dark-pixel ratios 0.0534 and 0.0566 in the static frame (moving right receiver 0.0518), passing the pinned 0.01–0.20 bounds. The [actual hidden-material frame](../../verification/vq11-decals-36991562324/hidden-decals.png) has the same lit plates and zero mark pixels in both regions. Its [original raw report](https://github.com/ThreeNativeHQ/threenative/blob/e1566472ee439650c74109a92e7fabae1770f71a/docs/verification/vq11-decals-36991562324/hidden-report.json) fails exactly `visual.1.region.darkPixels` and `visual.2.region.darkPixels`; all remaining assertions pass with 256 slots, 321 placements and real LOD selection. The expected two pixel errors are the only diagnostics; positive runs have none, and none report device loss. +- All five PNGs were visually inspected. Static, motion, teardown and restart PNGs are byte-identical to the retained prior captures (restart equals static); the existing [static](../../verification/vq11-decals-36989297734/static.png), [motion](../../verification/vq11-decals-36989297734/motion.png) and [teardown](../../verification/vq11-decals-36989297734/teardown.png) files therefore also preserve the exact new-run bytes without duplicate binaries. +- Browser Phase 3 box is now verified. Phase 2 atlas/fade controls, repeated scene GPU residency, Linux-native execution and both full acceptance criteria remain open. Required general CI is skipped while draft; this is not a full-CI or hardware-performance pass. +- Proof-attachment doc checks: 2,405 links pass; the six-suite documentation run has 178 passes and two environment-blocked CLI tests (`evidence-budget`: long record and many small files). Both subprocesses fail at the `tsx` Unix IPC socket with `EPERM`, before evaluating their fixtures. The actual staged tree passes `node --import tsx scripts/check-evidence-budget.ts`; no aggregate documentation-suite pass is claimed. + +2026-10-02 atlas/fade local review checkpoint (not published or runtime-qualified yet): + +- Thirteen new projection tests failed on the prior implementation and pass with the controls. A further real Registry test spans 20 lifecycles/240 projected buffers; every eviction/expiry/removal/teardown buffer disposal occurs exactly once, and borrowed receiver geometry is retained. The projection suite now has 33 tests. The six focused/adjacent suites pass 53/53 after a non-force merge of develop `416ffd7c`. +- Package typecheck and real Vite fixture build pass. Changed-file lint passes with six complexity warnings (existing texture generator, projection method, and fixture callbacks); no lint rule is relaxed. Eight runtime scenario files validate through the maintained schema. The original visible-mark and hidden-material bounds are unchanged. New atlas/fading/expiry cases use an authored two-cell atlas and a two-second linear curve; six scene instances sample actual renderer geometry/texture counts and total GPU-tracked bytes, requiring no growth across the five warmed instances. These new runtime observations have not run yet. +- The actual combined scaffold test first failed only the shooter hash after merging develop; it measured `c035066c49f50a3c8ff894604a17ca6db3729179d6112e8470cb83f43d6eccc0`, now recorded. The full scaffold/looks/playtest/doctor group has 238 passes and one failure: the starter mobile-assets case cannot resolve Three's Basis transcoder from its temporary scaffold (`TN_ASSETS_TRANSCODER_MISSING`). This is a reported failed gate, not an aggregate pass; the byte-stability test itself passes. +- Fresh `pnpm test` was attempted and fails before test execution at the `tsx` Unix IPC socket with `EPERM`. No root aggregate pass is claimed. The local actual browser run builds, then refuses capture because Xvfb/display is absent; doctor exits with missing `adb`. Current capability checks also find no `/dev/dri`, `/dev/kvm`, `Xvfb` or native host on PATH. +- Native continuation: use the maintained desktop builder with `nativeEntry` pointing to the existing portable `native.ts`, stage the cooked receiver GLB and manifest from `public`, and execute the same cases through the built playtest runner against the resulting host. Native packaging/runtime has not been executed. No platform or residency box is ticked from CPU tests or older browser frames. + +2026-10-02 review correction before publishing atlas/fade: + +- Independent review reproduced a proof gap: the retained real hidden-material PNG passed all intermediate-fade visual assertions. A new regression reproduced that acceptance before the fix, then rejected exactly two missing positive-mark regions after it. The original full-opacity disappearance checks remain; two distinct inner regions now require intermediate-luminance mark coverage (left luminance ≤0.65, 1–30%; right ≤0.60, 10–65%). The real empty receiver PNG measures zero at both thresholds. Actual partially faded pixels remain pending the hosted run. +- A tenth capture, `hidden-fading`, runs the same atlas/fading scenario but disables material rendering only when the fade input starts. Real opacity progression, geometry, pool and LOD observations stay active; the control must fail exactly the two positive intermediate-pixel assertions and have no unrelated diagnostics. The original four scenario JSON files are byte-identical to `e1566472`. +- Fresh scoped checks after this correction: 54/54 tests, package typecheck, changed-fixture lint and Vite fixture build pass; all eight authored scenarios validate. The source atlas/fade arithmetic and ownership were independently reviewed without a blocker. Independent review also reproduced the starter Basis-transcoder failure on unchanged baseline `e1566472`; the temporary test scaffold lacks project-relative Three Basis files. That named gate remains failed; no production change or blanket waiver is made. + +2026-10-02 actual atlas/fade proof with an open residency failure: + +- [Run 37003284983](https://github.com/ThreeNativeHQ/threenative/actions/runs/37003284983), source `021e622380c61695b8c5dc0e7b9ffc604e956b8c`, captured all ten cases at 960 × 540 on Google SwiftShader WebGPU. Artifact `11225280829` SHA-256 `8fad5bcda560bedc2bb9899f747ba6e6e5aa250c50fccade4d89eca02ec586aa` was verified after download. [Raw summary](../../verification/vq11-decals-37003284983/summary.json) retains every verdict and diagnostic. +- [Atlas selection](../../verification/vq11-decals-37003284983/atlas.png) visibly separates the impact and environment-mark cells; [partial fade](../../verification/vq11-decals-37003284983/fading.png) visibly retains translucent marks at measured material opacity 0.5. Positive intermediate pixel coverage is 6.55% left and 43.44% right. Expiry removes all 256 live marks and releases all 321 created buffers; its exact PNG equals the retained [empty receiver frame](../../verification/vq11-decals-36991562324/hidden-decals.png). These real captures were inspected. +- Both hidden controls pass their required rejection checks. Hidden-during-fade retains opacity 0.5 and 256 slots but fails exactly the two positive intermediate-pixel assertions. Positive appearance cases have no error/device-loss diagnostics. The original static/motion/removal/restart cases also pass. Authored appearance is now qualified on this software adapter, not native or hardware performance. +- The overall run fails the new residency case, which remains open: warmed samples alternate 258/259 geometries and 6,522,318/6,522,894 bytes, yielding +1 geometry/+576 bytes; textures stay at 4. The first sample sees only one geometry before its eventual 256-mark frame, demonstrating that 60 simulated update ticks do not guarantee world-render completion. Sampling/render timing and LOD transitions are under investigation. The zero-growth limits remain unchanged; this is not a full run pass. + +2026-10-02 residency sampling correction, awaiting runtime rerun: + +- Replace the simulated-tick sample with the proven completed-render boundary used by VQ07: observe a changed cumulative `renderer.info.render.calls` value on the next game update, count one interval regardless of internal passes, and require three consecutive stable geometry/texture/byte observations. Only accept the world at its actual projected-mark draw count with the real two-triangle LOD and post-LOD hit present. +- The lifecycle scenario uses bounded 30-second `waitForResource` waits for each of six sampled scene generations, allowing actual presentation between polls. A unit regression rejects 90 simulated ticks with no render-call advance, resets stability when counts change, and rejects missing/nonfinite memory. All 55 focused/adjacent tests, typecheck, fixture build, lint and lifecycle schema validation pass. Production decal code, the original four scenario files and zero-growth ceilings are unchanged. The prior +1/+576 failure is preserved above and is not yet declared fixed by browser evidence. + +2026-10-02 completed-render residency and full browser sequence verified: + +- [Run 37005420971](https://github.com/ThreeNativeHQ/threenative/actions/runs/37005420971) passes at source `3bb9942c41dfd5c4a8690354373ad9c53259d0ee`: eight positive captures and both expected hidden-control rejections. Artifact `11225394658` SHA-256 `9c2811282094efd8229fc341f2a126dea451feefef7587c37882426365488493` was verified. [Raw summary](../../verification/vq11-decals-37005420971/summary.json) retains every capture, counter and diagnostic. +- Across all six scene generations, actual completed-render samples are identical: 259 geometries, 4 textures, 6,522,894 tracked bytes. Cumulative render calls advance 56, 64, 72, 80, 88, 96; every sample has three stable observed intervals. Geometry, texture and byte growth are all zero. The previously reported +1/+576 variation was caused by sampling before stable world rendering; no ceiling changed. This is renderer-reported software-adapter residency over the six observed instances, not a hardware memory benchmark or an unlimited-duration claim. +- All ten actual PNGs are byte-identical to already retained captures, so no duplicate image files were added. The inspected [atlas](../../verification/vq11-decals-37003284983/atlas.png), [partial fade](../../verification/vq11-decals-37003284983/fading.png), [empty/expired](../../verification/vq11-decals-36991562324/hidden-decals.png), [static/restarted lifecycle](../../verification/vq11-decals-36989297734/static.png), [motion](../../verification/vq11-decals-36989297734/motion.png) and [teardown](../../verification/vq11-decals-36989297734/teardown.png) files preserve the exact bytes of this run. Positive captures have no error/device-loss diagnostics; each negative control has only its two intended pixel failures. +- Phases 1 and 2 and browser Phase 3 are verified. Linux-native remains unexecuted; the existing VQ07 hosted ARM desktop route provides a concrete next integration path, so this is not claimed to be blocked solely by the local missing display/GPU. Both full acceptance boxes, required aggregate gates and final promotion review remain open. PR stays draft; no auto-merge is enabled. + +2026-10-02 native lane local review checkpoint (execution pending): + +- Add an ARM native job after the unchanged browser capture job, reusing the existing ordinary QuickJS host build, dependency cache and package scripts. Four native entry bundles build locally through the maintained bundler. The desktop packager will embed the unchanged receiver GLB/manifest into each debug executable; exact runtime-prefix, runtime/bundle/package/asset hashes are verified. The actual executable's `--version` must identify QuickJS, and the execution process must be Linux ARM64. No unmerged helper from PR396 is required. +- Native receives the same actual scene observations through state resources. Every original component predicate is mapped one-to-one; unsupported browser diagnostics/visual families are replaced by strict native startup/host-console/provenance validation and the unchanged pixel predicates evaluated against actual device PNGs. A crashed host with no `console.json` retains its original report diagnostics. +- Three preflight regressions failed before implementation, then pass: all ten arms pass the maintained mailbox handshake/capability checker using the captured minimal QuickJS handshake and actual shared transport capability set; malformed/unready/unnamed/browser-substitute capture evidence is rejected; actual faded and hidden PNGs preserve the positive/negative visual distinction. These are protocol/unit proofs, not native rendering. +- Fresh validation: 58/58 focused/adjacent tests across seven suites, 141 workflow tests, package typecheck, changed-file lint (four existing fixture complexity warnings), browser fixture build and all four native entry bundle builds pass. Production decals and all authored scenario JSON files are unchanged. Native execution, full acceptance and promotion remain pending. + +2026-10-02 first real packaged native progress, incomplete qualification: + +- [Run 37010879478](https://github.com/ThreeNativeHQ/threenative/actions/runs/37010879478), source `563ab23e9448a993013cb0d58c75ab6ab83917c9`, builds the ordinary ARM64 QuickJS/wgpu-native host and packages all four variants with the exact receiver assets. Actual native static, motion, teardown and one-restart cases pass supported resource/startup predicates, strict host console/provenance and every unchanged external pixel predicate at 960 × 540 on named Mesa llvmpipe (LLVM 20.1.2). Artifact `11228900169` SHA-256 `0ee5614f0ec8f859d8a23055062e58792f1c94d2af9c8bfd92b15d0e1f327a55` is verified. +- Exact inspected native images: [static](../../verification/vq11-decals-native-37010879478/static.png), [rigid motion](../../verification/vq11-decals-native-37010879478/motion.png), [receiver teardown](../../verification/vq11-decals-native-37010879478/teardown.png). The native restart PNG is byte-identical to static. [Raw summary and artifact identities](../../verification/vq11-decals-native-37010879478/summary.json) preserve source/runtime/bundle/package/asset hashes and named native adapter/device-screenshot provenance. This is native software correctness progress, not hardware performance. +- Native qualification remains incomplete: the [lifecycle report](../../verification/vq11-decals-native-37010879478/lifecycle-report.json) times out waiting for generation 1 residency readiness. [Actual native console](../../verification/vq11-decals-native-37010879478/static-console.json) shows 256 visible marks/321 placements but `maxDrawCalls=0` at the game-update sample; that update boundary does not retain the browser's per-frame draw counter. The actual render/reset ordering is being traced before changing the observation boundary. No residency limit or pixel predicate is relaxed. Atlas/fade/expiry/hidden native cases were not reached, and all native/full acceptance boxes remain open. + +### Native residency observation correction — 2026-10-02 + +The first native run's generation-1 timeout is an observation defect, not evidence of a missing visible render: all four completed native captures passed the authored pixel gates. Three's `Info.reset()` zeros `render.drawCalls` but retains cumulative `render.calls`; its internal Animation RAF can reset the former before the fixture's update. Core also resets metrics before the world pass. This ordinary fixture's existing `Scene.render` hook runs after the world render returns. The native host then replays/submits/presents the RAF command stream in `endDawnFrame` before its next timer/mailbox/update iteration (`packages/runtime-native/src/runtime.cpp`). + +The fixture now snapshots the real renderer counters and resource tuple in that post-world hook, then consumes the snapshot once on the next update. Missing snapshots, repeated hooks without new render calls and simulation ticks alone cannot advance residency. The original post-LOD hit, 2-triangle receiver, at-least-256-draw requirement, three stable intervals and all zero-growth ceilings are unchanged. Scene exit removes the capture closure. This is a fixture observation correction; no engine loop, renderer counter or production decal behavior changes. + +The actual fixture scene regression uses the real loader/picker/LOD path and Three `Info.update/reset`, with CPU-supplied memory measurements (no GPU claim). It reproduces the reset-before-update failure before the fix, then proves 256 draws qualify while 0/255, missing snapshots and repeated snapshots do not. Focused decal/lifetime/native-proof and adjacent template runtime suites pass 57 tests; the package typecheck, four native bundles and browser fixture build pass. Biome has no errors and retains the four existing fixture complexity warnings. The root `pnpm test` attempt still fails before test execution at tsx's Unix IPC listener with `EPERM`; this does not establish a full-suite pass. Native and full acceptance remain open until the corrected exact source runs all native cases, including unchanged visible-effect controls and repeated residency. + + +### Complete browser/native qualification — 2026-10-02 + +[Run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669), exact source `8f85fff19913c955a44b7b85d2cc117b972bd19c`, passed both browser and Linux native jobs. All ten native cases pass the maintained guards: static marks, rigid motion, removal, restart, six-generation residency, atlas selection, visible intermediate fade, expiry, hidden marks and hidden-during-fade rejection. Each negative control fails only its two intended authored pixel predicates while its state, startup, console and capture identity remain valid. Actual native partial opacity is approximately 0.475; expiry releases all 321 created projected buffers and retains zero active marks/geometry bytes. + +Six measured native generations each have 259 geometries, 4 textures and 6,522,894 renderer-tracked bytes after three stable completed-render intervals; geometry, texture and byte growth are exactly zero. At the cap the fixture retains 256 marks / 275,076 owned projected bytes and observes a maximum 516 world draw calls. This reports measured cost without a hardware-performance or single-draw claim. Native captures use packaged ARM64 QuickJS/wgpu, Mesa llvmpipe (LLVM 20.1.2, 128 bits), `device.screenshot`, 960 × 540. + +[Minimal provenance](../../verification/vq11-decals-native-37015640669/provenance.json) retains source/build/runtime/asset/PNG hashes and scoped results. Artifact `11231490134` ZIP SHA-256 is `07925a63d4091f2a53d06ef7b178619d775ef9185890949afb4645c83e9d519f`; full raw reports and host logs remain in that workflow artifact. The new [atlas](../../verification/vq11-decals-native-37015640669/atlas.png), [partial fade](../../verification/vq11-decals-native-37015640669/fading.png) and [expired](../../verification/vq11-decals-native-37015640669/expired.png) PNGs are copied unchanged and visually inspected. The other seven captures reuse byte-identical prior/empty frames recorded in provenance. An independent whole-PR review reran 57 tests, all ten native report/console/pixel evaluations and adversarial empty-image controls, and cleared the original implementation and acceptance scope. + +### Combined develop promotion preparation — 2026-10-02 + +Merged develop `924b92f825d602485ef4e682f434b4757a825cd7` without rewriting ancestry. The changed playtest runner was rebuilt with its validator-generation check, tsup/declarations and publint; browser and all four native fixture bundles build successfully. Root `pnpm typecheck` passes serially across the workspace. Root `pnpm lint` passes with existing warnings after formatting evidence JSON. The historical raw report is preserved in its original immutable commit and workflow artifact; the current-tree file contains minimal provenance, as described below. Documentation links (2,429), all 22 generated agent mirrors and the staged evidence budget pass. + +The expanded affected suite has 463 passes and one failure: `create-threenative > keeps the starter's shipped assets mobile-shippable`, `TN_ASSETS_TRANSCODER_MISSING`, because its temporary scaffold cannot resolve the project-relative Three Basis transcoder. Independent review already reproduced that exact fixture failure on unchanged `e1566472`; it remains reported, not skipped or treated as green. The focused decal tests and changed playtest/tone, schema and workflow tests all pass. Root `pnpm build` and `pnpm test` were each attempted and stop at the environment's tsx Unix IPC `EPERM`; the affected package/fixture builds were executed through Node's loader instead. This is not a full-suite or hosted-CI pass. The combined source's actual browser/native rerun and mandatory CI must pass before promotion. + +Additional template/platformer/package/API/docs/mirror drift checks report 99 passes and two initial failures. The pristine-scaffold typecheck passed on rerun with the shared pnpm tool/cache environment (its first attempt used an unavailable default pnpm home); the remaining Xvfb-wrapper test cannot run because Xvfb is not installed and the wrapper correctly refuses blind execution. These environment limits are retained for comparison with the ordinary hosted checks. + +The exact Basis-transcoder test subsequently passes with `NODE_PATH` pointing to this checkout's existing `packages/core/node_modules`, which lets its isolated temporary project resolve the already installed Three transcoder. No production code, test, dependency or expected result changes. Both resolver and pristine-scaffold failures are thus reproduced environment-setup failures with passing scoped reruns; the aggregate IPC and absent-display limits remain. + +Publication privacy: the historical hidden-control report in the current tree is now minimal source/run/artifact/assertion provenance; its original raw report remains available in the immutable earlier commit and original workflow artifact. All original assertion outcomes and screenshots remain preserved. The current proof adds only minimal native provenance and actual PNG bytes. diff --git a/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md b/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md new file mode 100644 index 0000000000..0a7b69180a --- /dev/null +++ b/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md @@ -0,0 +1,329 @@ +--- +prd_contract: v1 +--- + +# PRD-473 — Rain: the storm kit (TEMPEST, rebuilt on engine abstractions) + +**Status:** DONE (2026-10-01) — all three phases and AC-1..AC-5 verified; one owner taste call (the visual score) recorded under Blocked on +**Complexity:** 7 → HIGH; risk override: none +**Owner:** João (visual taste calls and the final PR screenshots) +**Depends on:** None + +## Context + +`/home/joao/Downloads/tempest.html` (718 lines, single file, no assets) is a complete coastal storm +study: volumetric clouds ray-marched through a 3D noise volume, a procedurally ray-marched shoreline +with conifers, street luminaires, a guardrail and a lit cabin, a wet road and sea with real-time +reflections and rain ripples, 16 000 camera-anchored rain streaks, branching lightning with +distance-delayed synthesized thunder, four quality tiers, a full weather panel and synthesized +rain/wind/thunder audio. Every pixel is generated at runtime; the only imagery in the PR will be +captures of the reference and of the kit. + +This PRD absorbs every abstraction the source contains into the engine's public vocabulary and +delivers the starter kit `rain`, which reproduces the complete study — not a narrowed subset — using +engine abstractions. Templates live under `packages/create-threenative/templates/` and register +themselves through `kit.json` (`packages/create-threenative/src/index.ts:251`). + +### What the source actually contains (read 2026-09-30, complete inventory) + +| Group | Source constructs (all of them) | +| --- | --- | +| A. Simulation | seeded mulberry32 `rng`; `clamp`/`mix`; exponential weather easing (τ≈0.67 s); weather record `{rain, cloud, wind, fog, exposure, wet}`; four presets `drizzle/storm/supercell/clearing`; `sanitizeWeather` clamped validation; sim clock with `dt` clamped to 0.08 s; auto-strike scheduler (`nextStrike = t + 8 + rand·16`, gated on `cloud > 0.35`) | +| B. Camera | free-fly state `{position, yaw, pitch}` + `home`; WASD/arrows planar move with diagonal normalisation; `Q`/`E` altitude; `Shift` sprint 23 m/s vs 6 m/s; position clamps `x −100…220, y 1.65…110, z −350…90`; drag-to-look yaw/pitch with pitch clamp `−0.7…1.25`; cinematic orbit mode cancelled by manual input; FOV 55°, aspect from viewport; `basis(yaw,pitch)` forward/right/up | +| C. Render passes | **sky/cloud** reduced-res pass (0.4–0.75×), 32/48/64/88 steps by tier, blue-noise jitter, sky gradient + sun disc/glow + flash term; cloud density from a multiscale 64³ RGBA noise volume, flattened condensation base 145–815 m; 5-tap light march with ×1.7 step growth; multiple-scattering approximation; strike-position cloud glow. **world** SDF raymarch (148 steps, adaptive epsilon): fbm+ridge heightfield, road curve, coast, island, one bounded procedural conifer per spatial cell, six repeating luminaires (pole/neck/lamp + emissive underside), guardrail with posts, cabin with lit window and antenna; ground/water with puddle mask (fbm × wetness), 3×3 hashed expanding ripple rings, micro-bump and wave motion, road stripes/edge lines with wear, water albedo and roughness, Fresnel-weighted reflection, sky-texture-offset reflection plus a 36-step secondary reflection march with emissive lamp hit; hemisphere ambient + sun + six cone point lights with specular + strike-direction flash lighting; height-dependent aerial perspective with advected fbm rain curtains; custom `gl_FragDepth` write for particle depth. **rain**: 16 000 quads in a 66×30×66 volume wrapped to a 12 m camera cell, velocity `(wind·20, −(14…22), wind·2)`, projected stretched quads, near/far opacity fades, flash-lit. **bolt**: additive ribbons with core+halo from branching geometry. **bloom**: 5×5 threshold bright pass at ¼ res. **post**: 12 animated refractive lens droplets near the screen edges, 5-tap luma-edge AA, bloom add, exposure multiply, ACES, cool-shadow/warm-highlight grade, vignette, gamma 2.2, dither. Targets: sky, world+depth, bloom; HDR when `EXT_color_buffer_float`; resolution scale and particle count per tier; identical GLSL behind a Three.js `RawShaderMaterial` (GLSL3) driver and a raw WebGL2 driver | +| D. Audio | rain hiss loop (noise → highpass 1800 / lowpass 11 000) with gain from rain; wind loop (pink noise, 35–350 band) with gain from wind + slow LFO; thunder: 7 s generated buffer = crack `exp(−22t)` + rumble `low·(1−e^{−4t})·e^{−0.66t}` with 9 Hz tremolo, distance lowpass, distance gain, scheduled at `distance / 343`; master gain + compressor; suspend/resume on pause and hidden; mute; pending-voice tracking and clearing; gesture unlock, no autoplay | +| E. Interface | topbar (brand, status dot, panel toggle, pause, capture, fullscreen, help); panel with 3 presets, 4 sliders (`rain` mm/h, `cloud` %, `wind` km/h, `fog` %) with readouts, auto-lightning switch, cinematic switch, strike button; "Rendering & accessibility" details (disable flashes, lens droplets, exposure, surface wetness, renderer/HDR readout); quality select (Performance/Balanced/High/Ultra); hero title block; sound pill; telemetry footer (precipitation, wind, fps) and compass heading; `aria-live` toast; help dialog with Escape/backdrop close and photosensitivity warning; hide-interface + restore button; staged loading overlay with progress text; keys `L X H P F R Space W A S D Q E Shift`, `blur` clears keys; responsive breakpoints at 900 px / 620 px / 800 px height; `prefers-reduced-motion` | +| F. Automation API | `window.tempest`: `setWeather`, `setPreset`, `triggerLightning`, `setSafe`, `render`, `step(dt)`, `setQuality`, `stop`, `capture`, `frames`/`flash`/`sizes`; URL params `?still`, `?offline`, `?quality` | + +### Reference captures (root lane, 2026-09-30) + +The unchanged `tempest.html` was loaded in Chromium via Playwright with `?still&offline&quality=high` and +captured at three viewports: [reference desktop](../../../verification/visuals/rain/reference-desktop.png) +(1440×900), [reference tablet](../../../verification/visuals/rain/reference-tablet.png) (1024×768) and +[reference mobile](../../../verification/visuals/rain/reference-mobile.png) (390×844). A 2026-10-01 re-capture +of the same file on this machine's RTX 2080 (ANGLE/Vulkan, headed) differs from the committed references by a +mean 0.27% per pixel, so the committed set stands as the reference. The kit's own captures and the comparison +are in AC-5. + +### Capability lookups actually run (2026-09-30, `threenative-engine-mcp` over `packages/core/capabilities.json`) + +Launched from this lane as a stdio MCP server (`node_modules/.bin/tsx packages/engine-mcp/src/index.ts`, +the same entry `threenative-engine-mcp` installs); 1 request-scope search plus 20 mechanic-scope +searches, then `engine_capability_detail` on 20 hits. + +| Query (scope) | Result actually returned | +| --- | --- | +| the whole TEMPEST study (`request`) | `RenderChain`, `readRenderChainReport`, `readRenderChainObservation`, `useUiIntent`, `sendUiIntent`, `connectUiBridge`, `publishUiState`, `UiLayer`, `advanceFixedStep`, `assertCaptureNotBlank`, `InstancedBatch` | +| emit GPU particles for falling rain, spray, impact bursts (`mechanic`) | `GPUParticles3D` | +| wet ground, puddle mask, ripple normals, sky reflections (`mechanic`) | `Heightfield`, `WaterSurface3D`, `RippleField`, `ssr` (three addon), `loadTerrainSplat` | +| post chain with bloom, exposure, tone mapping (`mechanic`) | `RenderChain`, `Daylight`, the generated `worldEnvironment` file entry | +| free-fly camera, WASD, drag look, altitude keys (`mechanic`) | `InputMap`, `captureMouse`, `PointerEvents3D`, `defineGame` | +| weather sliders and presets driving render parameters (`mechanic`) | `UiLayer`, `sendUiIntent`, `publishUiState`, `useUiState` | +| ripple rings from impacts on water (`mechanic`) | `RippleField`, `WaterSurface3D` | +| sky, sun and weather (`mechanic`) | `Atmosphere`, `Daylight` | +| quality tiers scaling resolution and step counts (`mechanic`) | `readRenderChainReport`, `readRenderChainObservation`, `Atmosphere` | +| **ray march a 3D noise volume for volumetric clouds** (`mechanic`) | **no results** | +| **upload a 3D data texture and sample it in a shader** (`mechanic`) | **no results** | +| **generate branching lightning ribbon geometry** (`mechanic`) | **no results** (only `UAssetError`, `getPlatform`, `softCircleDataTexture` by lexical accident) | +| **synthesize rain/wind/thunder audio instead of loading files** (`mechanic`) | **no synthesis capability** — `AudioBus` plays buffers, `@threenative/assets` has file passes | +| **honour a reduced-motion preference / reduce flashing** (`mechanic`) | **no results** | +| capture a screenshot as PNG (`mechanic`) | `CanvasLayer`, `GameCanvas` — no download/capture capability | + +`GPUParticles3D` constraint: "geometry, color, and timing remain supplied by the game". +`RenderChain` constraint: "stage factories own colour, strength, and all other appearance choices", +"authored stages declare exactly one before or after anchor". `Atmosphere`: "deliberately creates no +mesh, material, or scene light". `AudioBus`: "create buses before playing clips and dispose them +with the game". + +Re-run 2026-10-01 against the regenerated `packages/create-threenative/capabilities.json` (`searchCapabilities` +/ `capabilityDetail` from `packages/engine-mcp/src/index.ts`): the three searches that returned nothing now land +on the kit's generated source — "ray march a 3D noise volume for volumetric clouds" → `createNoiseVolume`, +"generate branching lightning ribbon geometry" → `createStormLightning`, "falling rain streaks around the camera" +→ `createStormRain` — and `capabilityDetail` resolves `createWeatherWorld`, `createNoiseVolume`, +`createStormLightning`, `createStormRain` and `createStormAudio` to files that exist under +`templates/rain/src/` (package `template:rain`). + +## Solution + +**Framework (packages/) — what this PRD changed there, and why each belongs there:** +- `packages/core/src/audio.ts` + the native audio graph (`packages/runtime-native/src/audio/*`): a bus + compressor and gain target ramps, so the study's master compressor and `setTargetAtTime` smoothing exist on + every target (the wip slice; 33 core audio tests, native audio graph tests, conformance `94-audio-context`). + Proven in the game: `storm-audio` reports `compressor: true`, `unsupported: []` and three live voices on + the native desktop host. +- `packages/playtest`: a web-only scenario field `reducedMotion: "reduce"` (emulated before navigation), so a + game that honours the preference can prove it — the harness owns scenario plumbing. +- Repo gates: `scripts/visual-gate.ts` asks for soft shadow-map settings only from a light that casts a + shadow (a ray-marched kit has no shadow map); the assets default-config test lets a kit declare per-clip + audio overrides; `scripts/render-chain-capabilities.ts` lists the kit's generated-source entries. + +**The kit owns (generated source, `templates/rain/`):** every appearance and every rule of the study. The +coast, clouds and sea are the source's own ray-march, transpiled from `tools/tempest-{world,clouds}.frag` by +`tools/generate-shaders.mjs` into TSL; the bloom and post grade are transcribed TSL; the 64³ noise volume +is generated byte-for-byte from the source's seed; rain streaks and the bolt are one instanced draw each +with their own vertex stage; the weather maths, presets, flash envelope and `distance / 343` delay are +`src/state.ts`; sound is the source's DSP baked offline into three WAVs played through `AudioBus`; the +interface is React in `src/ui/`. Named colours and lights are uniforms set from `src/render/{palette,sky, +lighting,materials}.ts`; the player's four tiers are `STUDY_TIERS` in `src/render/quality.ts`. + +**Not admitted to the framework** (recorded so a later reader does not re-argue): the noise volume, the +presets (a preset system is outranking rule 5), the flash envelope and bolt generation, the study tiers, +runtime audio synthesis, the lens/grade stage and the source's second render backend. + +### Abstraction map (source → disposition, as built) + +| Source abstraction | Disposition | +| --- | --- | +| seeded RNG, clamp/mix easing, sim clock | `defineGame({ seed: 607 })` → `ctx.random`; easing, clamp and the 0.08 s step in `src/state.ts` | +| free-fly camera, drag look, cinematic orbit, FOV | `InputMap` (`move` vector, `ascend`/`descend`/`boost` buttons, `look` `pointerRelative`) + rig in `src/render/camera.ts`, FOV 55° | +| pause / hide / sound / photosensitivity keys | the game's input map (`pause`, `hideUi`, `sound`, `safe`) through `intentPatch`; F and P stay in the UI realm (browser APIs) | +| weather state, presets, sliders, readouts, telemetry, compass | game state published through the UI bridge; UI reads `useUiState`, writes `useUiIntent` | +| cloud volume + density + light march | `src/render/noise-volume.ts` (`Data3DTexture`) + transpiled ray-march in `clouds-shader.ts`, a `PassNode` at the tier's resolution share | +| terrain, road, coast, island, trees, luminaires, rail, cabin, wet road, puddles, ripples, reflections, aerial perspective | the source's SDF ray-march transpiled into `world-shader.ts`, one screen quad marked `alwaysRender`, depth written for the rain and bolt | +| rain streaks | `src/render/rain.ts`: one instanced draw, stateless closed form; `GPUParticles3D` inspected and not used (it owns position through a compute node; the source rain has no lifetime) | +| lightning bolt, flash, scene light, cloud glow | `src/render/lightning.ts` + `flashAt` in `src/state.ts`; the strike's entry point lights the coast and the clouds in their shaders | +| bloom, AA, exposure, ACES, grade, vignette, lens drops, dither | one authored `RenderChain` stage (`stormPost`) in `postprocessing.ts`; built-in stages off at every engine tier | +| quality tiers | `STUDY_TIERS` (cloud scale/steps, rain budget, reflections); the drawing buffer is the engine's adaptive resolution | +| rain hiss, wind, thunder with distance delay | `src/audio/storm.ts` over `AudioBus` with the compressor; the delay is kit code | +| reduced motion / photosensitivity | UI reads `matchMedia` once and sends `setSafe`; `safe` gates every strike at one door | + +### Repo registration a new kit must make (each one is a gate, not a chore) + +`kit.json`, the six render-layer files (`scripts/visual-gate.ts`), `quality.ts` and its documentation +(`scripts/check-template-quality.ts`), the conventions applicability row, the `rain` rows in the +capability-recall corpus, the CI matrices, a README row, `playtests/survives.playtest.json` and a bounded +`playtests/performance.playtest.json`, and `native-playtests/*.playtest.json`. + +## Acceptance Criteria + +- [x] AC-1 [local]: a project scaffolded from `--template rain` boots into the storm and one panel intent changes the rendered frame, not only the readout. proof: `node packages/playtest/dist/runner/cli.js + /playtests/storm.playtest.json --url http://127.0.0.1:5373 + --server-command "pnpm --dir exec vite --host 127.0.0.1 --port 5373 --strictPort" --browser-recipe webgpu --headed` + — pass 2026-10-01 (final sweep at 47ccaef41): Drizzle + slider + drag on a paused storm change 92.98% of pixels; the same pause with no input changes 0.48% +- [x] AC-2 [local]: a strike raises the flash value, brightens the scene and lands a `thunder` cue in the audio ledger at the source delay, while `safe` and `prefers-reduced-motion` suppress every flash. proof: the same runner on `playtests/lightning.playtest.json` + — pass: safe strike flash 0 and strikes 0; strike status `flashing`; thunder pending at 36 ticks, one `thunder` cue after; delay 0.9968 s for 341.9 m (= distance/343). `flash.playtest.json`: a held strike (flash ≥ 0.5) changes 97.5% of pixels. `reduced-motion.playtest.json` (`reducedMotion: "reduce"`): key, button and 700 ticks of auto-lightning give flash 0 and strikes 0; the same scenario without the emulation fails (strikes 2) +- [x] AC-3 [local]: rain is registered everywhere a template must be registered — CI matrices, capability-recall brief, applicability row, docs, visual score. proof: `pnpm budgets && pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts + scripts/__tests__/primary-docs.spec.ts scripts/__tests__/check-template-conventions.spec.ts` + — `pnpm budgets` exit 0; the three specs 3 files / 136 tests pass. The visual *score* is the owner's taste call and is recorded under Blocked on, not claimed +- [x] AC-4 [local]: every row of the source inventory above (A simulation, B camera, C render passes, D audio, E interface, F automation API) is present in the rendered kit, checked one row at a time rather than by assertion. proof: the per-row table under "AC-4 — the inventory, row by row" below +- [x] AC-5 [local]: the kit is captured at the same three viewports as the reference and the PR embeds both sets side by side, stating the comparison the owner actually sees — including any row where it differs, with nothing inferred. proof: `docs/verification/visuals/rain/reference-*.png` beside `docs/verification/visuals/rain/kit-*.jpg`, and the side-by-sides `docs/verification/PRD-473/compare-*.jpg`; comparison under "AC-5" below + +### AC-4 — the inventory, row by row (paste into the PR body) + +All browser runs: headed `--browser-recipe webgpu`, adapter NVIDIA Turing, zero console/network/runtime +diagnostics. The repo's own template gate, `TN_TEMPLATE_ONLY=rain pnpm test:templates` (packed tarballs, +fresh scaffold, `pnpm test` over the production preview plus the boot smoke), exits 0: "rain: scaffolded +playtests passed". Native: the built `mystral` host on `--target desktop` (Vulkan, RTX 2080, private Xvfb). + +- **A. Simulation** — `src/state.ts` (presets, `sanitizeWeather`, easing τ 0.67 s, `flashAt`, `thunderDelay`), `src/scenes/Boot.ts` (clock clamped to 0.08 s, scheduler `t + 8 + rand·16` gated on cloud > 0.35), seed 607. Observed: `weather.playtest.json` Drizzle target 0.24, rendered rain eases to ≤ 0.32 (65.9% of pixels change); `auto-lightning.playtest.json` first automatic strike at t = 37.017 (28 + 9); `auto-lightning-clear.playtest.json` with cloud 0.09 no strike through t = 44.2; the API probe's `setWeather({ wind: 2 })` clamps to 1; 10 weather unit tests. +- **B. Camera** — `src/render/camera.ts`, input map in `src/game.ts`. Observed: `fly.playtest.json` W+Shift z 18 → −4.9 (23 m/s), E y 2.85 → 5.85, drag heading 355° → 308°; `cinematic.playtest.json` orbit moves x 1.8 → 2.85 and W hands it back (`cinematic` true → false); `survives.playtest.json` ArrowUp moves the player 6.0 m. +- **C. Render passes** — clouds (`clouds.ts`/`clouds-shader.ts`, noise volume byte-exact), world ray-march (`world.ts`/`world-shader.ts`), rain (`rain.ts`), bolt (`lightning.ts`), bloom and post (`postprocessing.ts`). Observed: `tiers.playtest.json` cloud target 864×540 / 691×432 / 576×360 / 1080×675 of 1440×900 at 64/48/32/88 steps, drops 9120/9120/4940/12160 (captures `docs/verification/PRD-473/tier-{performance,high,ultra}-1440x900.jpg`, performance drops the reflection march); `lightning-strike-1440x900.jpg` shows the branched bolt, lit coast and cloud glow; kit vs reference mean pixel difference 0.41% at 1440×900 (AC-5). +- **D. Audio** — `src/audio/storm.ts` over `AudioBus` + compressor; `tools/make-storm-audio.mjs` bakes the three clips. Observed: browser and native `lightning` scenarios — `storm-audio` loaded 3, compressor true, one `thunder` cue only after the strike's delay; native `unsupported: []`, 3 voices; pause/visibility/mute holds covered by 12 `rain-audio.spec.ts` tests. +- **E. Interface** — `src/ui/{GameUi,Hud,Menu,LoadingOverlay}.tsx`, `src/style.css`. Observed: kit captures at 1440×900, 1024×768, 390×844; `ui-help-*`, `ui-hidden-1440x900` (H hides panel and HUD, restore button shown), `ui-rendering-details-1024x768` (panel scrolls clear of the pill), `ui-mobile-panel-390x844`, `loading-curtain-1024x768` (staged curtain); `automation.playtest.json` (390×844: strike, X safe, H hide, P capture, Space pause); `loading.playtest.json` (curtain lifts, progress 1). +- **F. Automation API** — `src/ui/tempest.ts`. Observed (one-off Playwright probe on the production build, real GPU, 0 errors): `render()` resolves on a new frame; `setPreset("supercell")`; `setWeather`; `setQuality("low")` → `performance`, unknown name throws `TN_RAIN_QUALITY_UNKNOWN`; `stop()` holds the clock, `step(2)` advances it exactly 2 s; `triggerLightning` strikes (flash 0.68), `setSafe(true)` turns auto-lightning off and blocks the next strike; `sizes` 1440×900; `capture()` encodes 1440×900 (1.97 MB); `?still&quality=high` freezes the clock and picks `high` (every AC-5 capture). + +### AC-5 — kit beside the reference + +Same viewports, both frozen at t = 28 with `?still&quality=high`: [desktop](../../../verification/PRD-473/compare-desktop-1440x900.jpg), +[tablet](../../../verification/PRD-473/compare-tablet-1024x768.jpg), [mobile](../../../verification/PRD-473/compare-mobile-390x844.jpg); +kit frames alone in `docs/verification/visuals/rain/kit-{desktop,tablet,mobile}.jpg`. + +| Viewport | Mean abs pixel difference | Pixels off by > 24/255 | Luminance q05/q50/q95, kit vs reference | +| --- | --- | --- | --- | +| 1440×900 | 0.41% | 0.30% | 0.190/0.329/0.544 vs 0.190/0.328/0.544 | +| 1024×768 | 0.52% | 0.87% | 0.191/0.311/0.553 vs 0.190/0.312/0.553 | +| 390×844 | 0.77% | 1.63% | 0.231/0.430/0.576 vs 0.231/0.432/0.577 | + +What still differs, all seen in the images rather than inferred: rain streaks fall at a different phase +(the kit freezes one frame after t = 28); the compass reads `355°` where the reference's still page never +ran its readout and shows `N`; the frame-rate readout shows a number instead of `—`; the sound pill sits +lower on short windows and beside the key hints under 800 px of height (the reference draws the panel over +it at 1024×768); on phones the icon buttons are 44 px and the subtitle is hidden under 480 px. Three real +look differences were found by this comparison and fixed: a transposed fbm rotation (terrain ridges, +puddles and cloud shapes drifted; desktop difference 1.16% → 0.41%), a bloom pass that never got its +resolution and bloomed every highlight mirrored about the horizon, and the sky sampled upside down. + +## Sandbox demo — Storm Chaser (2026-10-01) + +A cold, user-like game on the kit, outside the workspace: `sandbox/rain-demo` in the shared sandbox repo +(commit `fbc56a8`, local, not pushed), scaffolded with `--template rain` from tarballs packed off feat/rain +at 74c910a4f (`sandbox/.packages/rain/*-rain-.tgz`). The game on top: log readings at two survey lamps, +then reach the field station within 75 s; a lightning strike within 500 m while you are in the open costs +one of three nerves, and a lamp post is shelter. Rules `src/chase.ts`, HUD `src/ui/ChaseHud.tsx`, light +pillars `src/render/beacons.ts` (the engine's `Billboard3D`), wired through the kit's `strike()` and frame. + +| Scenario | Target | Result | +| --- | --- | --- | +| `playtests/chase-win.playtest.json` | browser, headed WebGPU (NVIDIA) | pass — lamp 1, lamp 2, station at their labelled steps; won with 71.3 s left; player moved 85.1 m; zero diagnostics | +| `playtests/chase-lose.playtest.json` | browser, headed WebGPU (NVIDIA) | pass — three strikes in the open: nerve 3 → 0, lost (`nerve`); "Run it again" restores 3 and `playing` | +| `native-playtests/chase-win.playtest.json` | `--target desktop`, feat/rain's built `mystral` host | pass — same route and outcome; flashes are suppressed there (the web view reports reduced motion), so the round is the clock and the route | + +Friction (`sandbox/rain-demo/FRICTION.md`), fixed at its layer in feat/rain 1b326f19f: capability search +answered "countdown timer that ends the round" with `TracerPool3D` (the `Scheduler` now carries that +situation; recall row `rain.round-time-limit` red → green); every scaffold's `.gitignore` now ignores the +asset pipeline's compiled outputs (only starter had such rules, and missed the bake receipt; a new scaffold +test was red for rain); and the kit's `AGENTS.md` now says where gameplay attaches. Not fixable in a lane: +`pnpm build:desktop` fails at its last step on the unpublished 0.3.4 prebuilt (`prebuilt-lock.json` HTTP +404), so the native run used the engine checkout's host. Captures: +[desktop](../../../verification/PRD-473/sandbox-desktop-1440x900.jpg), +[tablet](../../../verification/PRD-473/sandbox-tablet-1024x768.jpg), +[mobile](../../../verification/PRD-473/sandbox-mobile-390x844.jpg), +[gameplay](../../../verification/PRD-473/sandbox-gameplay-1440x900.jpg), +[native desktop](../../../verification/PRD-473/sandbox-native-desktop-1280x720.jpg). + +## Blocked on + +- The template's human visual score in `docs/verification/visuals/scores.json` is the owner's taste call + (João); rain has none, like sailing, puzzle and runner. Nothing in this PRD's proofs claims one. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Kit registration | `create-threenative --template rain` | new kit, no incumbent | AC-1 | +| Weather control surface | panel / keys → intents → `intentPatch` → `Boot.ts` uniforms | the source's inline DOM handlers | AC-1, AC-4 A | +| Volumetric clouds | `noise-volume.ts` → `clouds-shader.ts` pass → world sky and reflections | generated kit source | Phase 2, AC-5 | +| Storm post chain | `postprocessing.ts` → one authored `RenderChain` stage | the source's BLOOM + POST passes | Phase 2, AC-5 | +| Storm audio | `src/audio/storm.ts` → `AudioBus` (compressor) | the source's Web Audio graph | AC-2, Phase 3 | +| Capture | playtest captures; `window.tempest.capture` web-gated | the source's `canvas.toDataURL` | AC-4 F | + +## Decisions + +- 2026-09-30 (agent, this PRD): the seeded 3D noise volume generator is **generated kit source**, not a + `packages/core` export — app maths a game could write portably, and it decides a look. +- 2026-09-30 (agent): the earlier `## Blocked on` entry claiming `api.github.com` was unreachable was + withdrawn as unproven; the desktop-native run was then run (Phase 3). +- 2026-09-30 (agent, this PRD): runtime audio synthesis is not portable (no `AudioContext` on the native + host), so the same DSP is baked offline into committed WAVs played through `AudioBus`. +- 2026-09-30 (agent): the source's raw-WebGL2 second driver is withdrawn; the engine's renderer already + models WebGPU and WebGL2. +- 2026-09-30 (agent): the source's `canvas.toDataURL` button stays web-gated; every target's still image + comes from the playtest capture path. +- 2026-10-01 (agent, this PRD): the study's look is its own ray-march, not `Heightfield`/`WaterSurface3D`/ + `RippleField`/`Atmosphere`/`Daylight`/`GPUParticles3D`, which the first plan named. Each was inspected: + none draws an SDF coast, its reflections march or a stateless camera-wrapped rain, and rebuilding the + study on them would change its look rather than port it. The earlier abstraction map is replaced above. +- 2026-10-01 (agent, this PRD): the source's fixed per-tier drawing-buffer scale (0.55/0.8/1/1.25) is not + ported; the engine's adaptive resolution (`resolutionScale: "auto"`) measures the frame instead. The + per-tier cloud resolution, steps, rain budget and reflection switch are ported exactly. +- 2026-10-01 (agent, this PRD): keyboard shortcuts live in the game's input map, not a browser keydown + listener — the native host's web view never has keyboard focus (probe: X and Space did nothing natively). + `M` (sound on/mute) is added so sound is reachable from the keyboard on every target. +- 2026-10-01 (agent, this PRD): on the native desktop host the web view reports `prefers-reduced-motion: + reduce`, so the storm boots with flashes suppressed there; `native-playtests/lightning.playtest.json` + asserts that and turns flashes on with `X`. It is a separate file from `playtests/lightning.playtest.json` + because the desktop runner has no click injection and evaluates no visual assertions. + +## Execution Phases + +### Phase 1 — The kit exists and the storm answers the player + +**Status:** DONE (2026-10-01) + +Results (2026-10-01, a project scaffolded from local tarballs, headed `--browser-recipe webgpu`, +adapter NVIDIA Turing, zero console/network/runtime diagnostics in every run): +- `playtests/storm.playtest.json` pauses the simulation, then clicks Drizzle, drags the + Precipitation slider and drag-looks: 92.9% of pixels change, against 0.48% for the same pause with + no input (`playtests/paused.playtest.json`, elapsed unchanged). Preset `drizzle`, target and + rendered rain 0.24 at the Drizzle step, ≥ 0.8 after the slider, heading 355° → 308°. +- `playtests/fly.playtest.json`: W+Shift for 60 ticks moves z 18 → −4.9 (23 m/s sprint), E for 30 + ticks y 2.85 → 5.85, a drag turns the heading; 86.1% of pixels change. The wip camera read + `input.axis()` for key bindings and the raw pointer counter the tick had already spent, so + sprint, Q/E and drag-look had never worked; it now reads `pressed()` and a `pointerRelative` + binding. +- `survives` moves the registered player 6.0 m; `weather`, `automation`, `simulation` pass. +- The sky was sampled upside down because the generator's flip rule never matched the + transpiler's spacing; the rule is fixed and the shader regenerated. +- The render-layer files `palette/sky/lighting/materials.ts` were another kit's leftovers with no + importer; they now decide the storm's haze, sky, sun, fill, flash light, lamp and sea colours + as shader uniforms (boot capture unchanged: mean delta 0.16/255). + +**Files:** `packages/create-threenative/templates/rain/` — `kit.json`, `src/{main.ts,game.ts,state.ts,scenes/Boot.ts}`, +`src/render/{palette,camera,sky,lighting,materials,postprocessing,quality}.ts`, +`src/ui/{App,GameUi,Hud,Menu,LoadingOverlay}.tsx`, `playtests/{storm,paused,fly,cinematic,survives,performance,loading}.playtest.json`; registration +in `.github/workflows/ci.yml`, the capability-recall corpus, the conventions table and `README.md`. + +- [x] Phase 1: the scaffolded rain kit boots into a storm whose panel, presets and fly camera change the rendered frame. proof: AC-1 command above (pass, 92.9% changed vs 0.48% null), `fly.playtest.json` (pass), `pnpm exec tsx scripts/visual-gate.ts --structural-only` (exit 0, rain passes) and + `pnpm exec tsx scripts/check-template-quality.ts` (exit 0, 11 templates agree) + +### Phase 2 — Sky, sea and rain: the look, built from generated source + +**Status:** DONE (2026-10-01) + +Results: `tiers.playtest.json` switches the panel's quality select through all four tiers and reads back +what was drawn — cloud target 864×540 (high, 0.6), 691×432 (balanced, 0.48), 576×360 (performance, 0.4), +1080×675 (ultra, 0.75) of a 1440×900 frame at 64/48/32/88 march steps, drops 9120/9120/4940/12160 +(= round(budget × 0.76)); every tier capture shows coast, clouds, sea and rain, and `performance` drops +the reflection march. The kit-vs-reference comparison (AC-5) found and fixed three look defects +(transposed fbm `mat2`, the mirrored and unsized bloom, the inverted sky). `performance.playtest.json` +at 1920×1080: p95 frame 4.7 ms, ≤ 11 draws, ≤ 36,865 triangles. +**Files:** `templates/rain/src/render/{noise-volume,clouds,clouds-shader,world,world-shader,rain, +postprocessing,bloom-shader,post-shader,quality}.ts` · `tools/generate-shaders.mjs` · +`playtests/tiers.playtest.json` · `scripts/render-chain-capabilities.ts` · the capability-recall corpus and +budget · regenerated manifests · `docs/verification/visuals/rain/kit-*.jpg` + +- [x] Phase 2: the cloud volume, sea, wet road and rain streaks render at every tier inside the declared bounds, from generated source rather than a new engine export. proof: `pnpm exec tsx scripts/check-template-quality.ts` (exit 0); `pnpm build` regenerates the manifests (exit 0, 2026-10-01); `searchCapabilities`/`capabilityDetail` find the five Rain abstractions at existing `template:rain` paths and `pnpm caps:recall` exits 0 with every `rain.*` row recalled (66/73 overall); and + `node packages/playtest/dist/runner/cli.js /playtests/tiers.playtest.json --url http://127.0.0.1:5373 --server-command "pnpm --dir exec vite --host 127.0.0.1 --port 5373 --strictPort" --browser-recipe webgpu --headed` + — pass, captured at all four tiers (`docs/verification/PRD-473/tier-*.jpg`) + +### Phase 3 — Lightning, thunder, and the finished study + +**Status:** DONE (2026-10-01) + +Results: browser `lightning.playtest.json` as in AC-2. The native desktop scout's "interface but no storm" +was the kit's stylesheet painting `body`: the native UI is a web view over the game, so an opaque body hid +it (red-green on the desktop host — background restored, UI over a flat `#0e1921`; removed, the storm under +the UI). The shortcuts X/H/Space never reached the native web view and now live in the game's input map. +`native-playtests/lightning.playtest.json` on the desktop host: a strike at boot is suppressed (this web +view reports reduced motion), `X` turns flashes on, `M` sound on, `L` strikes (status `flashing`, strikes +1), thunder is pending at 36 ticks and one `thunder` cue sounds after it, delay 0.9968 s; the capture +(`docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg`) shows the bolt, the lit coast and the +toast "LIGHTNING · 0.34 km · thunder in 1.0 s". `native-playtests/boot.playtest.json` passes with the +`stormPost` stage at tier high (`docs/verification/PRD-473/native-desktop-boot-1280x720.jpg`). +**Files:** `templates/rain/src/render/lightning.ts` · `src/scenes/Boot.ts` · `src/game.ts` · `src/audio/storm.ts` · +`src/ui/` · `playtests/{lightning,flash,reduced-motion,auto-lightning,auto-lightning-clear}.playtest.json` · +`native-playtests/{boot,lightning}.playtest.json` · `packages/playtest` (`reducedMotion`) + +- [x] Phase 3: a strike flashes the sky, lights the scene and schedules its thunder, the safety switches suppress it, and the same scenario passes natively. proof: `--target browser` run of + `playtests/lightning.playtest.json` (pass) and `--target desktop` run of `native-playtests/lightning.playtest.json` + (`node packages/playtest/dist/runner/cli.js /native-playtests/lightning.playtest.json --target desktop --executable packages/runtime-native/build/tn-linux/mystral --project --host-arg run --host-arg /.threenative/build/game.js --host-arg --ui --host-arg /.threenative/build/ui`, after `pnpm native:build` exit 0) — pass diff --git a/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md b/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md index ed6ca2ceda..edf04b3bbb 100644 --- a/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md +++ b/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md @@ -1,5 +1,37 @@ # Release readiness — 2026-09-23 +## Status 2026-10-02 + +**The 0.3.4 cohort is published; `alpha:bar` A1 passes (6 of 7, A6 deferred).** `runtime-native-v0.3.4` +is a finalized release (promotions #385, #386, #387 merged). On npm: `core`, `physics`, `playtest`, +`runtime-native`, `ui` 0.3.4; `assets` 0.3.5; `raw-unreal`, `ueformat`, `threenative-blender-mcp` 0.1.4; +`threenative-engine-mcp` 0.2.4; `@threenative/metahuman` 0.1.0 (new); `create-threenative` 0.2.8. +`scripts/verify-registry-install.ts` against the registry passes 22/22 (npm and pnpm, including +`doctor`, `native`, `android`, `mcp`). + +What it took, so the next cut does not repeat it: + +- **The hosted `npm-release` run cannot publish.** Its N-1 upgrade proof asserts frame time and + visuals, which fail on the runner's SwiftShader adapter (run 36969644927). The cohort was published + locally with `pnpm release --skip-gates --yes` on a GPU host, behind a load gate (frame p95 read 34 to + 112 ms against the 33 ms budget while other lanes ran; it passed once load stayed under 8). The gate + was not changed. PRD-196's last box (hosted `clean-room` run) stays open for the same reason. +- **`doctor` demanded one version across all `@threenative` packages**, but the cohort ships `assets` + at 0.3.5 on purpose, so the registry clean-room failed on `npm:doctor`. It now compares the + major.minor series (`d43e0016e`); that needed `create-threenative` 0.2.8, published by hand with + `pnpm --filter create-threenative publish` because `pnpm release` refuses a partial cohort. +- **`publish:check` refused the scaffolder tarball**: `templates/rain/tools/verify-noise-volume.mjs` + imported a `.js` name for a shipped `.ts` file (`6c8858d74`). +- Native consumer proof fixes landed on `develop`: software-adapter declaration on the hosted emulator, + iOS no longer gates `finalize`, and a retry for dropped adb logcat transports. + +The local publish carries no npm provenance (not CI). The Android registry proof needs JDK 17 and +`ANDROID_HOME`; the default JDK 27 on the operator machine fails Gradle. + +**Still open:** a `develop` to `main` promotion carrying the doctor and rain fixes, PRD-064's web +60 fps arm, PRD-366 physical devices, PRD-399, the `fast-uri` highs (no fixed upstream release), and +a way to run the upgrade proof on hosted CI (a GPU lane, or a deliberate software-adapter design). + ## Status 2026-10-01 Inspected on `develop` at `ffe9986f5` (`origin/develop` fetched 2026-10-01). Only the rows in @@ -239,5 +271,5 @@ These fold into [PRD-445](../BLOCKED/requires-release-credentials/PRD-445-public - **New (2026-10-01):** PRD-064's status line still reads 2026-09-25 and does not record PR #361's 2026-09-30 desktop-pair measurement. -**Next action (under two minutes):** run `pnpm alpha:bar --write` to re-green A7; then decide -whether to cut the 0.3.4 cohort, whose `v*` tag push is PRD-196's last box. +**Next action (under two minutes):** promote `develop` to `main` (merge commit) so `main` carries the +doctor and rain fixes; the cohort itself is published (see Status 2026-10-02). diff --git a/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md b/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md index d435ac00f6..7426531ce6 100644 --- a/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md +++ b/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md @@ -426,6 +426,11 @@ census test fails, and `pnpm publish:check` refuses the tree. observed red on 2026-09-26 with the `sdkmanager` line deleted. Not yet executed: no `v*` tag push has run this job since the provisioning landed, so the step is proven locally and the hosted run is still the missing proof. + 2026-10-02: the `v0.3.4` tag push (run 36969644927, `2b6887fe`) failed in `publish` before + `clean-room` ran, because the N-1 upgrade proof asserts frame time and visuals and cannot + pass on the runner's SwiftShader adapter (`TN_PLAYTEST_SOFTWARE_ADAPTER`). The cohort was + published locally instead and the same verifier passed 22/22 there with JDK 17 + (`npm:android` and `pnpm:android` included), so the box stays open until a hosted run reports it. **Wiring:** diff --git a/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md b/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md new file mode 100644 index 0000000000..c7ab754fd6 --- /dev/null +++ b/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md @@ -0,0 +1,98 @@ +--- +prd_contract: v1 +--- + +# PRD-482 — The local agent loop costs only what it catches + +**Status:** PARTIAL — phases 1 and 2 verified; AC-1 audits the 7 days after this lands +**Complexity:** 2 (LOW) +**Owner:** CI tooling +**Depends on:** [PRD-480](../CI/PRD-480-linux-ci-runs-on-the-owner-machine.md) for the push rule's wording + +## Context + +Audit on 2026-10-02 of what an agent pays between starting a task and pushing it: + +- **The pre-push hook is red on clean `develop`, so agents bypass it.** `scripts/ci-fast.sh` took 42 s + and failed 3 of its 4 stages on `01bc7c687`: + - `drift` (33 s of it): `template.spec.ts` typechecks scaffolds against the physics package's built + `dist`, which is stale (`TS2305 VehicleBody3D`). The script's own header promises "nothing that + needs a full workspace build". + - `lint`: `biome check .` has no `vcs.useIgnoreFile`, so it lints git-ignored files such as + `.leanpi/goal.json`. + - `docs`: real broken links from `40919491b`. + + The stages run serially, and 22 of 368 pushes in the past week set `TN_SKIP_PREPUSH=1`. +- **Pushes cancel running boards.** From 2026-09-18 to 10-02, 349 PR runs were cancelled by a newer + push, burning 43.1k runner-min, 37% of all CI time + ([PRD-481](../CI/PRD-481-ci-does-each-piece-of-work-once.md) context). 56 of those pushes changed + only Markdown, and 37 of them were PRD ticks. +- **Context injected on every turn.** `.claude/hooks/ponytail-context.mjs` runs on SessionStart, + every UserPromptSubmit and every SubagentStart (`.claude/settings.json:8,20,31`), about 400 tokens + per user turn. Root `AGENTS.md` adds 1,816 words, and `packages/playtest/AGENTS.md` adds 5,071 words + whenever playtest work loads it. The `gate:*` commands named in root `AGENTS.md:91` were + effectively never run in transcripts since 2026-09-25. + +## Solution + +- Make the hook what its header says: seconds, green on a clean tree, run in parallel. A hook that + is red on `develop` teaches agents to skip it. +- Put the push rule in `AGENTS.md`: while your PR's CI is running, commit locally and push only to fix + that run or once it finishes; a PRD tick rides with the next code push, never alone. +- Inject ponytail once per session and on compaction, not on every prompt. +- Move reference material out of the always-loaded files. + +## Acceptance Criteria + +- [ ] AC-1 [shared]: proof: the PRD-481 audit script over the 7 days after phase 2 lands. Runner-minutes + lost to PR runs cancelled by a newer push drop below 10k a week, from 21.5k. Evidence: pending. + +## Decisions + +- 2026-10-02 (João): cut any step that costs time or tokens without catching problems; speed and + reliability are the only criteria. + +## Execution Phases + +#### Phase 1: The pre-push hook is green and fast + +**Status:** COMPLETE +**Files:** EDIT `scripts/ci-fast.sh` (stages in parallel; scaffold typecheck moves to `ci:local` and CI), +`biome.json` (`vcs.useIgnoreFile`), `docs/PRDs/UI/PRD-native-overlay-utility-styling.md` (broken links). + +- [x] `pnpm ci:fast` passes on a clean `develop` checkout in under 15 s wall. proof: `time pnpm ci:fast`. + Evidence: 2026-10-02 on `af7e25333` plus this branch, all four stages pass, 9.2 s wall (was 42 s and + 3 of 4 red). The biome and link fixes landed directly on `develop` in `af7e25333` to unblock the + backlog push. A never-built checkout still needs one workspace build: three drift specs import + packages' `dist`. +- [x] The scaffold typecheck still runs before merge. proof: the `test` job log of one CI run lists + `template.spec.ts`. Evidence: CI run 37049488719 (2026-10-02), job `test-unit (1/3)` 110991025981: + `✓ packages/create-threenative/__tests__/template.spec.ts (38 tests) 102118ms`. The unit shards run + plain `vitest run`, which this PR does not touch. + +#### Phase 2: Instructions and injections carry only what agents use + +**Status:** COMPLETE +**Files:** EDIT `AGENTS.md` (push rule; `gate:*` lines move to `docs/architecture/` or the scripts' `--help`), +`.claude/settings.json` (ponytail hook on SessionStart and compact only), `packages/playtest/AGENTS.md` +(reference sections move to a linked doc), the `CLAUDE.md` mirrors. + +- [x] `AGENTS.md` states the push rule. proof: `pnpm sync:agents --check` and + `pnpm exec vitest run scripts/__tests__/sync-agent-docs.spec.ts scripts/__tests__/primary-docs.spec.ts`. + Evidence: 2026-10-02, "Push once per run" in the Pull requests list; `pnpm sync:agents` wrote the mirror; + sync-agent-docs, primary-docs and instruction-budget specs 24 passed. +- [x] The ponytail hook fires only on SessionStart and compaction. proof: `jq '.hooks | keys' .claude/settings.json` + lists neither `UserPromptSubmit` nor `SubagentStart` for it. + Evidence: 2026-10-02, `jq '.hooks | keys'` prints `["SessionStart"]`; SessionStart has no matcher, so it + also fires on compaction. +- [x] `packages/playtest/AGENTS.md` is under 2,000 words. proof: `wc -w packages/playtest/AGENTS.md`, and + `pnpm check:docs` passes with every moved section linked. + Evidence: 2026-10-02, 5,256 → 1,113 words. Twelve reference sections (flags and exit codes, the + private-Xvfb frame rate, `assert.tone[]`, the four targets, `perf`, `audio`, startup + observations, `deviceMetrics`, the room and the feet, `assert.causedBy`, `setup`/`aimAt`, tick + semantics and the startup wait) moved verbatim into `packages/playtest/docs/reference.md` and + `docs/determinism.md`, each linked from the section it left; a coverage diff over every line of + the 5,256-word file finds no paragraph missing. `pnpm check:docs` 2,504 links / 1,223 files + clean, `pnpm sync:agents --check` 22 mirrors in sync, sync-agent-docs + primary-docs + + instruction-budget + check-doc-links 36 passed, `packages/playtest/__tests__/doc-drift.spec.ts` + 2 passed, `pnpm lint` exit 0. diff --git a/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md b/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md index 531919c13d..12cda0b5ac 100644 --- a/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md +++ b/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md @@ -69,7 +69,7 @@ Risks: - [ ] AC-2 [local]: world batches and static chunks replay from render bundles. A 200-frame walk re-records bundles only on key mint/retire, and per-frame JS in the render phase for the world is ≤ 1.5 ms at 400 world draws. proof: `world-bundles.spec.ts` (bundle version counters) plus `TN_FRAME_SPANS` on machinefall. - [ ] AC-3 [local]: a foliage asset gets an automatic octahedral impostor as its last LOD level, drawn beyond the chain, casting into the coarse shadow levels. Past the impostor distance, triangles per tree are 2. proof: `world-impostors.spec.ts` plus a visual-baseline capture at the switch distance. - [ ] AC-4 [local]: the cook bakes a per-cell HLOD proxy for hand-placed chunks, and WorldCells draws it beyond the HLOD distance: one draw per material group per cell. proof: `packages/assets/__tests__/hlod.spec.ts` plus the `TN_WORLD_CHUNK_MERGE` / `TN_WORLD_HLOD` markers on machinefall. -- [ ] AC-5 [local]: machinefall `?scene=map-walk` holds CPU frame p95 ≤ 8.3 ms and GPU p95 ≤ 8.3 ms on the RTX 2080 WebGPU adapter, with 0 console errors and no game-side performance option. proof: the walk harness's `TN_FRAME_BUDGET` windows. +- AC-5 (map-walk at 120 fps) moved to [PRD-475](PRD-475-open-world-120-fps-without-visual-loss.md) on 2026-10-01 (João): this PRD keeps the mechanisms, PRD-475 owns the frame target and the no-visual-loss check. - [ ] AC-6 [local]: every mechanism falls back to PRD-458's CPU path on a backend without compute/indirect support, reporting why in `TN_WORLD_GPU_SCENE`. proof: a unit test with a backend lacking `drawIndexedIndirect`. ## Phases @@ -88,7 +88,9 @@ The largest CPU and GPU win, and the prerequisite for bundles. ### Phase 3 — Impostors (AC-3) and HLOD (AC-4) They are independent; the executable plan is below. Impostors are not implemented in the visibility pass. -### Phase 4 — Measure and tune (AC-5) +### Phase 4 — Measure and tune + +Moved to PRD-475 with AC-5 (decision 2026-10-01, João). ## GPU-driven main-pass visibility (bugfix + continuity) diff --git a/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md b/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md new file mode 100644 index 0000000000..19996c730c --- /dev/null +++ b/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md @@ -0,0 +1,147 @@ +# PRD-475 — Machinefall's open world at 120 fps with no visual loss + +**Status:** PARTIAL — merged at prd:25% on João's 2026-10-02 call; the walking p95 boxes (AC-1, AC-2, Phase 2 targets, Phase 3 guard) continue in PRD-478 +**Complexity:** 5 (MEDIUM) — 6–10 engine files (+2), engine and Machinefall release separately (+2), GPU-pass state (+1 risk); risk override: none +**Owner:** João +**Depends on:** PRD-473 (merged at 75% in #375 as `a602467db`) + +## Context + +Machinefall's streamed world (`?scene=map-walk`) is the open-world proof. #375 restored its look: forest shadows on road and floor, and the full forest after a camera jump. Impostors went back to opt-in because their card dropped the forest's shadow. That look, at develop `a602467db`, is the reference this PRD must not lose. The owner said "it should be FAST + look AAA": no frame-time win may cost the look. + +Where it stands, per PRD-473: + +- **GPU:** p50 9–15 ms after the GPU scene went default-on. +- **CPU:** window p95 13.2 ms. +- **What limits the frame:** the GPU and present wait. The main thread is mostly idle; the main pass draws 16–27 M triangles; the wide shadow level spends 11–19 ms of per-draw JS. +- **Timing was not claimed:** under the box's load (~30), two identical walks read render p50 21.4 vs 9.0 ms. +- **Bundles:** measured no gain and are opt-in, because a bundle bakes `visible` and lost gate-hidden trees. + +Develop's recent cuts the game has not picked up yet: + +- PRD-458 world streaming (`4f9638c2e`). +- PRD-473's terminal coarse LOD. `LOD_GENERATOR_VERSION` 2 needs a re-cook. +- PRD-462's spread material checks: frame p50 10.4–13.6 → 3.5–5.9 ms on the colour lane. +- The one-palette skinned draw (`0107bc7d0`). + +Machinefall still pins pre-develop tarballs: + +- main: `pr375-binding-054d0f866554`. +- `feat/prd-458-engine-pin`: `pr375-stabilize-2a776d82d056`. + +No develop-tip tarball exists. + +Instruments, all shipped: + +- `TN_FRAME_BUDGET` (`packages/core/src/frame-budget.ts`) splits the frame into phases (`update|render|overlay|ui|residual|hostGap`). +- The same marker gives per-pass GPU times (`gpuMain|gpuShadow|gpuOther|gpuCompute`) as timestamp-query p50/p95, plus draws and triangles per pass. +- `playtest perf` (`packages/playtest/src/runner/perf.ts`) prints them. +- Under the private Xvfb, fps is suppressed: the present wait lands in update, with a ~50 ms swap floor. CPU is therefore read from the phase partition and GPU from timestamps, never from fps. + +## Solution + +1. **Sync, then measure on develop.** Pack core and assets from develop with `pnpm --filter pack`, and pin Machinefall's `apps/client` to them, including `pnpm.overrides`. Re-cook the world with `pnpm world:post`. Then record the baseline: the frame phases and the per-pass GPU split on the Turing WebGPU adapter. Capture the same-pose reference images at the same time. +2. **Cut what the baseline ranks first, in the engine.** Every fix lives in `packages/core/src/` behind the convention that the engine decides; Machinefall gets no performance option. Candidates, each kept only if it moves its own meter and passes the visual A/B: + - Shadows: redraw a clip level only when its casters or the light moved, instead of every frame. Draw the wide levels from the GPU scene's cull. + - Main pass: triangle cost per screen pixel: LOD gates, and an impostor that casts the forest's shadow. That is the condition under which impostors may come back. + - CPU: per-draw JS on the shadow and main passes. Bundles return only if they keep gate-hidden trees hidden. +3. **Hold it.** The map-walk scenario asserts the budget, so a regression reds the game's own test. + +Quiet-machine rule: compare timings only with load average below 4. Interleave arms (A, B, A, B) over at least 3 runs each. When load stays high, record the run as unverified, never as a result. + +Risks: + +- A cut that reads as "faster" can be a cut that draws less. Every Phase 2 box therefore carries the same-pose A/B, and draws and triangles are compared alongside milliseconds. +- Caching shadow levels can leave stale shadows on moving casters. The A/B includes a pose taken while walking. + +## Acceptance Criteria + +- [ ] AC-1 [local]: Machinefall `?scene=map-walk` CPU frame p95 ≤ 8.3 ms. CPU means the summed phases excluding the present wait, over 3 interleaved runs on the RTX 2080 WebGPU adapter, with 0 console errors and no game-side performance option. proof: `TN_FRAME_BUDGET` windows via `node packages/playtest/dist/runner/cli.js perf` on the map-walk run — Evidence: pending. +- [ ] AC-2 [local]: the same walk's GPU p95 ≤ 8.3 ms, from timestamp-query `gpu` p95 (`gpuMain + gpuShadow + gpuOther + gpuCompute`). proof: the same `TN_FRAME_BUDGET` windows — Evidence: pending. +- [x] AC-3 [local]: no visual loss against develop `a602467db`. Fresh blind raters score the after-captures no lower than the reference on all 4 map-walk and all 4 map-views poses, including the road and forest-floor shadows. proof: `pnpm visuals:ab --before --after --raters 3` — Evidence: engine `d1e6e1796` (core cut12f, machinefall `910d49f`), 3 blind raters through `pnpm visuals:ab`, before = develop walk captures plus settle-gated views (`artifacts/prd-475/ab4-before`), after = `artifacts/prd-475/cut12g/after`: 7 poses equal (v1-v4 4/4, walk-01 4/4, walk-02 3/3, walk-03 4/4), walk-04 a win 3 → 4, no loss; duplicate-pair spread 0 (`artifacts/prd-475/ab5/score.json`). Model raters, not the human session VISUAL-BASELINE.md describes. +- [x] AC-4 [local]: no tree or shadow pops in on approach. A fine capture series along the map-walk highway leg shows no element appearing or swapping LOD within 40 m of the camera. proof: map-walk capture series plus a fresh judge subagent — Evidence: 42 captures 2.3 m apart along the highway (`artifacts/prd-475/cut12f/pop`) against the same series on develop (`pop/base`); a fresh judge found 0 candidate-only pops within 40 m, popping the SAME as develop, and the road forest shadows match frame for frame. Road-band luminance over frames 10-20 is 25.933 % vs develop 25.940 %. + +## Blocked on + +- Presented 120 fps on a real 120 Hz+ display. Xvfb's swap floor cannot show it. Unblocked by João running `?scene=map-walk` on the desktop monitor and reading the fps overlay. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Develop engine in the game | Machinefall `apps/client/package.json` `file:` tarball pins and `pnpm.overrides` | Replaces the `pr375-*` pins | Phase 1 box 1 | +| World frame cost | `?scene=map-walk` → `WorldCells.update` (`packages/core/src/world-cells.ts`) → GPU scene and `VirtualShadowNode` (`packages/core/src/render/virtual-shadow.ts`) | Changed in place; the CPU path stays as the fallback for backends without indirect draws | AC-1, AC-2 | +| Budget regression guard | Machinefall `apps/client/playtests/scenes/map-walk.playtest.json` `assert.performance` | Tightens the current `maxFrameMsP95: 250` | Phase 3 box 1 | + +## Decisions + +- 2026-10-01 (João): a follow-up PRD targets 120 fps with no visual compromise ("FAST + look AAA"). Impostors stay opt-in until they cast the forest's shadow. +- 2026-10-01 (João, via this PRD): PRD-473's AC-5 (map-walk at 120 fps) moves here. PRD-473 keeps its mechanisms: GPU cull, bundles, impostors, HLOD and fallback. +- 2026-10-01: the visual reference is develop `a602467db`, the look restored in #375, and not the older `pr375-binding` build. +- 2026-10-01: a merged super-tile was drawn a whole `blockOrigin` from the tiles it replaced — its geometry is written relative to the block origin and the mesh was left at (0, 0, 0), so every block away from the origin showed as floating slabs over a hole where the ground is; unit tests only ever merged an island at the origin, where the two agree. +- 2026-10-02 (João): stop tuning and "stabilize and merge what you can". This PRD merges with its shipped cuts: idle frame p50 2.6–4.8 ms vs develop 5–7, walking render p50 7.0–8.4 vs 8.3–10.4, and AC-3 holding. Walking p95 stays at 19–26 ms (develop 18–25) and moves to PRD-478 (frame architecture: per-draw refresh only on changed inputs, streaming in a worker, GPU-driven shadow casters). The boxes stay open here rather than being ticked on unmet targets. +- 2026-10-02: the bridge-pose van shading is a capture race, not a shipped regression. The van band reads 30.89 / 31.05 / 31.47 across repeated captures of the same build, and develop `a602467db` itself read 31.0472 in `artifacts/prd-475/bisect-van2`. Our builds land on 31.47 more often (cut12i 3/3, cut12k 2/3, cut12m 1/3); the cause is open in PRD-478 alongside the shadow-level cadence. + +## Execution Phases + +#### Phase 1: Sync Machinefall with develop and record the baseline +**Files:** Machinefall `apps/client/package.json`, `pnpm-lock.yaml`, and the re-cooked `public/world/*`; `docs/verification/runtime-perf-state.md` (perf findings update in place). + +- [x] Machinefall runs develop's engine: the core and assets tarballs packed from develop `a602467db` or later are pinned, the world is re-cooked, and map-walk passes. proof: `pnpm test:scenes` (map-walk) in Machinefall, 0 console errors. — machinefall `feat/prd-475-develop-engine` `2ba8f21`. Core 0.3.4 and assets 0.3.5 tarballs from `a602467db`; installed dist tree hash equals the engine build. map-walk ran 5 times on nvidia/turing with 0 console errors; 4 green, 1 red on `state.failures` 1, the unlogged cell failure in Phase 2. map-views is green with 4 distinct poses. +- [x] The baseline is recorded in `docs/verification/runtime-perf-state.md`. It covers the CPU phases p50/p95 and the `gpuMain/gpuShadow/gpuOther/gpuCompute` p50/p95, with draws and triangles per pass, over 3 quiet-machine map-walk runs. The same-pose reference captures (4 walk, 4 views) are saved as the AC-3 `--before` directory. proof: `node packages/playtest/dist/runner/cli.js perf` on the runs. — 3 runs at load 3.4–3.8: frame p50 12.3 / p95 26.3 ms, CPU render 12.1 / 26.1 ms, GPU 7.4 / 11.3 ms (main 5.6, shadow 1.1), draws main 318 and shadow 200. References are in `artifacts/prd-475/reference/` (gitignored): 4 walk and 4 views. + +**Status:** DONE. The CPU render phase leads: render p50 is 1.6× GPU p50, and update/overlay/ui read 0. Phase 2 therefore starts with CPU attribution. + +**Verification:** the scenario run plus the perf report. The ranking of costs decides the order of Phase 2. + +#### Phase 2: Cut the frame where the baseline says, in the engine +**Status:** IN PROGRESS +**Files:** `packages/core/src/render/virtual-shadow.ts`, `packages/core/src/world-cells.ts`, `packages/core/src/world-gpu-scene.ts`, plus their specs. These are expected, not fixed: the baseline names the real ones. + +Budget split, to be confirmed against the baseline: shadows 2.5 ms GPU, main 5 ms GPU, CPU render phase 4 ms. + +- [ ] Shadow passes: map-walk `gpuShadow` p95 ≤ 2.5 ms. The shadow pass's share of CPU render time halves against the baseline, with a red-green spec on the mechanism and AC-3's A/B at the four walk poses. proof: `pnpm exec vitest run packages/core/__tests__/` plus `playtest perf`. +- [ ] Main pass: map-walk `gpuMain` p95 ≤ 5 ms, with a red-green spec on the mechanism and the same A/B. proof: the mechanism's spec plus `playtest perf`. +- [x] A failed world load names itself: `WorldCells` prints the asset or cell and the error behind a `TN_WORLD_CELL_FAILURE` marker instead of only counting it (`world-cells.ts` `#startAssetLoad`, `#startChunkLoad`, `#attachChunks`). proof: a red-green spec in `packages/core/__tests__/` with a rejecting loader. — `packages/core/__tests__/world-cells.spec.ts::WorldCells > names a refused world load in a TN_WORLD_CELL_FAILURE marker`, red without the marker, green with it; core 2184/2184. +- [ ] CPU render phase p95 ≤ 4 ms on map-walk, with a red-green spec on the mechanism and the same A/B. proof: the mechanism's spec plus `playtest perf`. + +**Verification:** each cut is measured A/B against the build before it, interleaved and on a quiet machine. A cut that fails its meter, or the visual A/B, is reverted and recorded under Decisions. + +Cut 1 (terrain super-tiles, PRD-473 plan): landed in ec9a739ad, b9442ec5a, bc998f510, 8349962ce, 0421ed329; Machinefall A/B pending. +Cut 1 fix: Machinefall correctness pair (same build, merge off/on) found the ring shrinking 289 → 248 because block bytes were charged to tile admission; blocks are now reported (`blockBytes`), not charged — 68a3691d2. Terrain draws 289 → 33–42, main draws p50 289–343 → 158–258. +Cut 1 fix 2: a block kept drawing the ground of a tile that had left it — a second copy at the level the tile left, for as many frames as the one-per-frame rebuild queue took to reach it — and counted that tile twice, so the `tiles` stat drifted off 289; a departure now dissolves its block for the frames before the rebuild brings it back — f8a990abe; per-frame walk invariant `world-terrain-merge-walk.spec.ts` (red at frame 18, green after; draws stay under the unmerged ring's 81). +Cut 2 (memoised shadow-caster table): landed in d47a24dfc; Machinefall A/B pending. +Cut 1 fix 3: a merged block's mesh sat at the world origin while its vertices were block-local, so every block but (0,0) drew displaced ("floating slabs", white ground) — `890578e81`, two specs comparing every block vertex through `matrixWorld` with its tiles'. +Cuts 1+2 in Machinefall (`machinefall@8107d51`, core `prd475-cut12c`), clean machine (load < 3, GPU < 10%), 4+4 interleaved against develop: frame p50 9.55 → 7.20 ms, frame p95 20.55 → 17.22 ms, render p95 20.45 → 17.10 ms, GPU p50 6.95 → 7.12 ms, GPU p95 10.93 → 10.20 ms, main draws 335.5 → 246.5; 8/8 map-walk pass, 0 console errors. Fresh blind same-pose judge, 8 poses against `artifacts/prd-475/reference/`: SAME overall (6 same, 1 better, views-v2 noted for vehicle variants and slightly thinner left forest). +Cut 3 (per-frame resource version bumps): measured, not fixed — there are none to stop. A throwaway probe in the game's bundled `three` counted `Textures.updateTexture` past its version guard at **0 calls** and `_copyCompressedBufferToTexture` at **0** over a 8400-frame map-walk (`machinefall@8107d51`), so nothing is re-uploaded per draw; `Source.needsUpdate` and `BufferAttribute.needsUpdate` (which `StorageBufferAttribute` inherits, so the GPU scene's `source`/`keys`/`locals`/`args` are counted) peak at 0.5 and 0.6 per 1000 frames, all from real writes: `mergeParts` cloning a geometry during a streamed chunk merge, and GLTFLoader/KTX2Loader loading a newly resident asset. `NodeMaterialObserver.needsRefresh` ran 0.33 times per 1000 frames, so the per-draw node-state refresh already takes its settled fast path. The `attrib2` profile agrees: `_copyCompressedBufferToTexture` is 156.6 ms of 27.6 s total, **0.57%** inclusive, not the 26% / 2.95 ms per frame the stack summary suggested — its samples are ancestors of a per-draw path that is already short-circuiting, and the 34.1% `getNodeBuilderState` figure is the per-draw `needsRefresh` check itself. No engine change, no commit, no Machinefall A/B. +Cut 4 (per-level shadow caster culling): measured, not built — three already culls per level, so a batch-bounds cut would be a second cull of the same volumes. R1: a caster batch is **per `(asset,level,part) × world-grid square`** (`#casterFor`, `world-cells.ts:5537`, key `${key}@${cluster}`), not per cell and not key-wide; the wide half (`#wideFor`, `:5553`, key `@*`) is deliberately ring-wide and is only drawn by a level whose window covers the ring. Each carries **its own** world bounds — `#rebound` unions the per-block AABBs read from the written instance bytes (`:1148`, `#boxOf` `:1167`), published as `mesh.boundingSphere` — and `frustumCulled = true` (`:1079`, restored `:1737`). R2: three decides, in `_projectObject` (`Renderer.js:3132`), `frustum.intersectsObject(object)` against the level's own `shadow.camera` — the very ortho window the cut proposes to test by hand; a level's camera is `±extent` X/Y with the depth `#deriveDepth` brackets conservatively toward the light (`virtual-shadow.ts:1509`). R3: measured with a throwaway probe through that exact path — 17 cluster batches on a line at 24 m spacing, level 0 (48 m window) kept **1**, level 1 (192 m) kept **5**, and a tall caster beyond the box *toward the light* was still kept (`near=1`, `far=41.4`, intersects `true`), so the existing depth derivation already refuses the false negative the cut's own rule warns about. Two brief premises were wrong and are corrected here: `frustumCulled = false` at `world-cells.ts:5952`/`:5956` is `#dressGpu`, reached only for `role === "main"` (`#adoptGpu` `:5843`), and main meshes keep `castShadow = false` on layer 0 (`#dressMesh` `:5744`) — so **no caster batch is GPU-dressed** and the main camera's dispatch (`world-gpu-scene.ts:4132`, ortho early-return above it) never sets a caster's count. The 257 draws a level submits are batches three's own per-level test already kept. No engine change, no commit, no Machinefall A/B. +Visual fixes found on the way (all red-green): GPU-scene activation dropped placements sliced before the renderer arrived (`d91709b44`, Sol); cached shadow levels never re-rendered when casters left, joined or stopped casting, which was pre-existing on develop (`0c9b264ff`, `07f617495`); cut 2's probe loop kept two callback `return`s, so the first skipped coarse caster ended the probe and the 96 m/320 m levels lost the forest's road shadows (`d1e6e1796`, Sol). Machinefall's map-views drains on streaming settle, not on the in-flight count (`machinefall@13976d8`): develop also lost the v2 forest when the screenshot landed mid-load. +Cut 5 (chunk shadow consolidation, 7376879e1, f015f6cbe, b3d746a24): retained opaque instanced casters go from 5 layer-0 draws to 0 plus 2 side proxies with identical per-level triangle inputs, unchanged main draws and eviction cleanup; AutoLOD remains excluded. In Machinefall (core cut12h) putting merged proxies on the wide half too (7376879e1) drew merged slabs into coarse levels, and the bridge deck self-shadowed as wide acne stripes (map-views v3, walk-04); an ablation build of 7376879e1 alone reproduces it, develop does not. b3d746a24 keeps merged proxies cluster-only as on develop; retained proxies keep both halves (they stand in for layer-0 casters every level drew). cut12i (machinefall `c5baf56`): blind 3-rater judge 8/8 poses equal to develop (`artifacts/prd-475/ab7`), pop series SAME (0 pops each, road band 25.929 % vs 25.940 %), coarse-level layer-0 casters 148 → 65 at 320 m in cut12h. Timing A/B pending. +Cut 6 (repeated chunk parts instanced, 78280560c, reverted bf1e22e5b): in its unit fixture, 18 → 4 main and layer-0 candidates with identical inputs; in Machinefall (core cut12j) main draws went 225 → 171, but the bridge-deck acne returned. Cut 6 takes meshes that carry extra vertex channels (tangents, colours) out of the material merge so it can instance them, and the deck then cast through a retained proxy into coarse wide levels. Without that skip, 9 of its 10 specs lose their candidates. Kept out until merge-skipped parts cast like merged ones. +Timing (clean-ab3, develop vs cut12i, 4+4 interleaved, quiet gate): idle at the final pose frame p50 2.3 vs 5.5-6 ms, but walking CPU render p95 per window 40-70 vs 17-34 ms in all 4 pairs; cut12f vs cut12i (2 pairs) puts it on cut 5: 23-24 vs 34-37 ms. +Cut 5 index fix (e6ce09623): retained proxies keep one index geometry per level camera and rewrite it only when that level's selection changes (red: 12 index-version bumps per 3-level sequence, green 0). cut12k (machinefall `eabf0a7`) vs develop, 4 pairs: walking render p95 19-26 vs 18-24 ms, walking p50 7.0-8.4 vs 8.3-10.4, idle frame p50 2.6-4.8 vs 5-7, main draws 208-297 vs 297-327; blind 3-rater judge 8/8 equal (`artifacts/prd-475/ab8`); pop road band 25.940 % = develop. Gates: core 2219 passed, typecheck 0, lint 0 errors. +Walking tail attribution (unminified trace, cut12k): 390 of 1491 rAFs over 12 ms (p95 21.6 ms). Inside them: three's per-draw loop ~50 %, WorldCells update ~28 % (terrain follow 11 %, admit 9.7 %), VirtualShadow level renders ~15 %, node builds ~9 %. Level 0 (24 m) re-renders while moving and submits 270-690 draws (cluster 206-283 + layer0 93-309): the walking plateau. Next lever: GPU-driven shadow casters / level-0 cost. +Shadow invalidation on caster change: both sides were broken — a level held its map across a caster leaving or joining the world and across `castShadow`/`visible` flipping, which is the ghost shadow the map-views camera jump leaves behind, because cut 2's `childadded`/`childremoved` listener only marked the caster table stale and nothing asked the affected level to redraw; `#onTreeChanged` now also measures the changed child and `invalidateRegion`s the levels covering it, and a per-frame `#pollCasters` reads `visible`/`castShadow` off the memoised table (one flag pair per caster, no walk) for the changes that fire no event. + +GPU source continuity fix (2026-10-02, source/unit lane; complexity 3, LOW): a queued build could consume CPU placement slices before GPU activation, then publish without those slices' source records. `WorldCells.update` now restarts pending builds through `#seedGpuSources` before dressing the keys, returning unpublished CPU claims and retaining outgoing batches. Red: `world-gpu-scene.spec.ts` "seeds source records for a build sliced before the renderer arrived" received 164 placements against the CPU's 262. Green: four activation timings (1, 3, 5, 8 CPU units), with shadows and one fresh mesh per update, match the CPU's source matrices before and after eviction/readmission and its main-pass instance total; a multi-part partial-eviction test also keeps every placement drawable. Self-review: one activation reset, unchanged CPU fallback, existing admission queue and synchronous buffer growth reused. Gates: `pnpm exec vitest run packages/core` 2208 passed, 2 skipped; `pnpm typecheck` exit 0 after building missing local package artifacts; `pnpm lint` 0 errors, 992 warnings, exit 0; `pnpm quality` 181 findings, exit 0. The separate browser matrix owns visual proof; Phase 2's performance and visual boxes stay open. + +Cut 2 coarse-probe fix (2026-10-02, source/unit lane): converting the caster traversal callback to a loop left two `return`s behind, so an alpha caster or a mesh without bounds ended the whole probe before later casters, layer selection and depth collection. Both now `continue`. `virtual-shadow-caster-draws.spec.ts` adds nested streamed casters, cluster/wide choices, moving cameras and caster transforms, and a missing-bounds case: all 3 red with `expected 0 to be 1`, all green after the two-line fix. A temporary comparison against `d47a24dfc^` matched all 12 coarse-level probe/depth/layer/candidate-draw observations after the fix; the specs also keep the selected trunk inside the 96/320 m shadow frustums. Gates: core 2211 passed, 2 skipped; typecheck exit 0; lint 0 errors, 992 warnings, exit 0; quality 181 findings, exit 0. Self-review: callback skips now skip one mesh; no API or scheduling change. Machinefall shadow recovery and performance remain unverified in this lane; the browser owner must run the same-pose A/B, so Phase 2 and AC-3 stay open. + +Active-blend scan fix: `world-tiles-cost.spec.ts` red: expected `Map.values` not called, got 1. The active-tile Set removes a 25-tile walk from every blend-count read and makes transition advancement visit active tiles only; settled rendered positions stay identical. `pnpm exec vitest run packages/core/__tests__/world-tiles-cost.spec.ts packages/core/__tests__/world-terrain-merge-walk.spec.ts --maxWorkers=1`: 10 passed, 2 benchmark skips, exit 0. + +Neighbor-LOD scan fix: `world-tiles-cost.spec.ts` red: 75 resident-map lookups, expected 25. Target changes already coordinate the resident ring; the extra neighbor pass is now required only for newly admitted tiles. Green: 25 lookups (50 saved/follow), identical settled positions; terrain cost and merge-walk specs 11 passed, 2 benchmark skips, exit 0. + +Frame-work follow-up (2026-10-02): the shared measured frame budget (c13e35a5e: streaming after the draw, deferred in shadow frames, forced within 3) was reverted in c823efa91. Machinefall cut12l (core `7deb743a39daa13e`) clean-ab5 pairs 1-3: walking render p95 26-35 ms vs develop 21.5-24.7, p50 no better; blind ab9 7/8 equal, 1 LOSS (walk-04 bridge: cliff shadow missing on the vans) that a recapture did not repeat (van-band luminance 30.8937 = develop); pop road band 25.940 = develop, pop judge: no late objects. Deferring stacked the backlog rather than spreading it. The two exact scan cuts above stay. + +Render-loop node-key fix (2026-10-02, source/unit lane): reuse `three/tsl`'s stock constant shadow-colour node across VirtualShadowNode's per-level materials. Red: 9 prewarmed casters held 2 node states; green: 1, gate cache misses 34 → 21 (compiler stubbed), with unchanged source index ranges, materials and instance counts per pass. All 39 settled submissions still refresh inside three; 0 render-object creations/geometry swaps and 0 late builds for prewarmed keys. Gates: core 2246 passed, 2 skipped; typecheck exit 0; lint 0 errors, 1012 warnings, exit 0; quality 183 findings, exit 0. Browser timing and same-pose GPU output remain unverified; Phase 2 stays open. + +Shared shadow colour (2026-10-02): `1299c6a91` shares three's constant shadow colorNode across the virtual levels, so identical level shaders reuse one node-builder state (`world-cells-shadow-prewarm.spec.ts` red: 2 states for 9 casters, expected 1; loading-gate cache misses 34 → 21). cut12n blind ab11: 7 equal, 1 win (develop's bridge-deck capture missed trees), 0 losses; pop band 25.940 = develop. + +#### Phase 3: Hold 120 fps with the look intact +**Status:** NOT STARTED +**Files:** Machinefall `apps/client/playtests/scenes/map-walk.playtest.json`; this PRD. + +- [ ] map-walk's own scenario reds when CPU or GPU p95 exceeds 8.3 ms. It uses the performance assertion on the render and GPU series, not fps, which Xvfb suppresses. proof: `pnpm test:scenes` green on the candidate and red on the Phase 1 baseline build. +- AC-1 to AC-4 above close this phase. + +**Verification:** AC-1 to AC-4 evidence, then `pnpm typecheck && pnpm lint && pnpm test`, and the Machinefall pin moved to the final tarball. diff --git a/docs/architecture/CHARTER.md b/docs/architecture/CHARTER.md index 0687a1de76..08c87a1942 100644 --- a/docs/architecture/CHARTER.md +++ b/docs/architecture/CHARTER.md @@ -270,7 +270,11 @@ geometry, placement, colour and material all come from the game); **`clipPoseErr `clipTrackBindings`, `clipBoneCoverage`, `boneContact`** (instruments that drive a clip, read the pose and report degrees, names and metres — they select no clip and move nothing); and **`Heightfield`** (one row-major buffer, interpolation, normals, and the ordering physics consumes; -every height comes from the game's own sampler); **`FlightModel`** (lift, drag, thrust, stall, +every height comes from the game's own sampler); **`SnowField`** (indentation, displaced bank, +compaction and disturbance channels over one canonical `Heightfield`, load-dependent penetration +and bounded sparse recovery; the game supplies the terrain, the snow depth, the contact footprint, +the load and every response coefficient, and no renderer or GPU encoding is involved); +**`FlightModel`** (lift, drag, thrust, stall, control moments and the carrier-deck run for a fixed-wing aircraft; every mass, wing area, power, inertia, wind and damage multiplier comes from the game, which the model moves but never dresses); and **automatic discrete LOD** (error-driven level selection for a model the asset cook baked a diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index b4db25cf5c..809cd31c92 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -24,7 +24,7 @@ pnpm dev Open the local URL that Vite prints. The default template is `starter`, a game with a React HUD. This guide uses `minimal`, which draws its HUD in the scene. Other templates include -`platformer`, `action-rpg`, `defense`, `racing`, `sailing` and `shooter`. +`platformer`, `action-rpg`, `defense`, `racing`, `sailing`, `rain`, `shooter` and `snow`. If you use npm, pass template flags after `--`: diff --git a/docs/verification/PRD-289-conventions-2026-08-31.md b/docs/verification/PRD-289-conventions-2026-08-31.md index 9da7fba1af..d41c57ad69 100644 --- a/docs/verification/PRD-289-conventions-2026-08-31.md +++ b/docs/verification/PRD-289-conventions-2026-08-31.md @@ -19,10 +19,12 @@ table, reads each template's `AGENTS.md`, and checks the TypeScript AST for the | platformer | N/A — the fox run places every body through its own physics character and keeps no generated conventions module | N/A — every model is authored in metres already and no generated conventions module rescales it | N/A — the template has no held object or character hand | N/A — procedural rig motion has no AnimationClip asset | | puzzle | N/A — the vault keeps its own sandbox look and places crates and the warden through physics, with no generated conventions module | N/A — every model is authored in metres already and no generated conventions module rescales it | N/A — the claw carries crates through physics velocity, not a bone attachment | N/A — no skinned or animated asset is loaded | | racing | N/A — vehicle suspension and snap-to-ground own floor contact | `src/conventions.ts:5` | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | +| rain | N/A — the camera flies; no walking actor needs floor contact | N/A — procedural geometry is authored in metres; no imported model needs scaling | N/A — there is no held object or character hand | N/A — no skinned or animated asset is loaded | | rts | N/A — every unit is placed by the simulation at its own ground height, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | | runner | `src/conventions.ts:19` | `src/conventions.ts:18` | N/A — the runner carries nothing and has no character hand | N/A — the jump is an authored arc, not an AnimationClip | | sailing | N/A — the ship is waterborne and uses buoyancy, not floor grounding | `src/conventions.ts:9` | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | | shooter | N/A — the soldier's floor contact is measured against the town's own collider boxes, and a corpse is settled onto the deck it fell on | `src/entities/Enemy.ts:863` | `src/entities/Enemy.ts:869` | `src/entities/Rifle.ts:121` | +| snow | N/A — the procedural explorer plants each boot on the canonical snow surface (`src/entities/Explorer.ts`), and no authored model is grounded | N/A — every body is built from primitives in metres; no authored model is loaded | N/A — the template has no held object | N/A — the gait is procedural IK, not an AnimationClip | | starter | `src/conventions.ts:15` | `src/conventions.ts:14` | N/A — the template has no held object or character hand | N/A — the native proof asset is static and has no AnimationClip | | tower-defense | N/A — every tower, walker and pad is placed by the simulation at ground level, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | diff --git a/docs/verification/PRD-469/ac1-ac2-contact-shapes.png b/docs/verification/PRD-469/ac1-ac2-contact-shapes.png new file mode 100644 index 0000000000..d443fab274 Binary files /dev/null and b/docs/verification/PRD-469/ac1-ac2-contact-shapes.png differ diff --git a/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg b/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg new file mode 100644 index 0000000000..ead251af1f Binary files /dev/null and b/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg differ diff --git a/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg b/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg new file mode 100644 index 0000000000..86af41df08 Binary files /dev/null and b/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg differ diff --git a/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg b/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg new file mode 100644 index 0000000000..6745bb02ee Binary files /dev/null and b/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg differ diff --git a/docs/verification/PRD-469/reference-icefield-1024x768.jpg b/docs/verification/PRD-469/reference-icefield-1024x768.jpg new file mode 100644 index 0000000000..c27e0d6a8c Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-1024x768.jpg differ diff --git a/docs/verification/PRD-469/reference-icefield-1440x900.jpg b/docs/verification/PRD-469/reference-icefield-1440x900.jpg new file mode 100644 index 0000000000..4988f856aa Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-1440x900.jpg differ diff --git a/docs/verification/PRD-469/reference-icefield-390x844.jpg b/docs/verification/PRD-469/reference-icefield-390x844.jpg new file mode 100644 index 0000000000..642f0a353a Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-390x844.jpg differ diff --git a/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg b/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg new file mode 100644 index 0000000000..7cb0bc0cd1 Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg differ diff --git a/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg b/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg new file mode 100644 index 0000000000..21fb49d220 Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg differ diff --git a/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg b/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg new file mode 100644 index 0000000000..5cf323d2d8 Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg differ diff --git a/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg b/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg new file mode 100644 index 0000000000..1c724b2a5b Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg differ diff --git a/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg new file mode 100644 index 0000000000..4ac83dc56a Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg differ diff --git a/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg b/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg new file mode 100644 index 0000000000..78c6793c54 Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg differ diff --git a/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg new file mode 100644 index 0000000000..718a3b2b4c Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg differ diff --git a/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg new file mode 100644 index 0000000000..6aa17cfb77 Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg differ diff --git a/docs/verification/PRD-473/compare-desktop-1440x900.jpg b/docs/verification/PRD-473/compare-desktop-1440x900.jpg new file mode 100644 index 0000000000..3a93499f91 Binary files /dev/null and b/docs/verification/PRD-473/compare-desktop-1440x900.jpg differ diff --git a/docs/verification/PRD-473/compare-mobile-390x844.jpg b/docs/verification/PRD-473/compare-mobile-390x844.jpg new file mode 100644 index 0000000000..ad6ef24626 Binary files /dev/null and b/docs/verification/PRD-473/compare-mobile-390x844.jpg differ diff --git a/docs/verification/PRD-473/compare-tablet-1024x768.jpg b/docs/verification/PRD-473/compare-tablet-1024x768.jpg new file mode 100644 index 0000000000..e636b54d42 Binary files /dev/null and b/docs/verification/PRD-473/compare-tablet-1024x768.jpg differ diff --git a/docs/verification/PRD-473/lightning-strike-1440x900.jpg b/docs/verification/PRD-473/lightning-strike-1440x900.jpg new file mode 100644 index 0000000000..e5e31b7082 Binary files /dev/null and b/docs/verification/PRD-473/lightning-strike-1440x900.jpg differ diff --git a/docs/verification/PRD-473/loading-curtain-1024x768.jpg b/docs/verification/PRD-473/loading-curtain-1024x768.jpg new file mode 100644 index 0000000000..75688ae512 Binary files /dev/null and b/docs/verification/PRD-473/loading-curtain-1024x768.jpg differ diff --git a/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg b/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg new file mode 100644 index 0000000000..f394fce9d9 Binary files /dev/null and b/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg differ diff --git a/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg b/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg new file mode 100644 index 0000000000..3f5e35aca2 Binary files /dev/null and b/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg differ diff --git a/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg b/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg new file mode 100644 index 0000000000..4646cd954e Binary files /dev/null and b/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg differ diff --git a/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg b/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg new file mode 100644 index 0000000000..95b9cad34f Binary files /dev/null and b/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg differ diff --git a/docs/verification/PRD-473/sandbox-mobile-390x844.jpg b/docs/verification/PRD-473/sandbox-mobile-390x844.jpg new file mode 100644 index 0000000000..fc8b14db97 Binary files /dev/null and b/docs/verification/PRD-473/sandbox-mobile-390x844.jpg differ diff --git a/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg b/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg new file mode 100644 index 0000000000..b22a11953b Binary files /dev/null and b/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg differ diff --git a/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg b/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg new file mode 100644 index 0000000000..4b172e20e0 Binary files /dev/null and b/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg differ diff --git a/docs/verification/PRD-473/tier-high-1440x900.jpg b/docs/verification/PRD-473/tier-high-1440x900.jpg new file mode 100644 index 0000000000..fb55bd0fbd Binary files /dev/null and b/docs/verification/PRD-473/tier-high-1440x900.jpg differ diff --git a/docs/verification/PRD-473/tier-performance-1440x900.jpg b/docs/verification/PRD-473/tier-performance-1440x900.jpg new file mode 100644 index 0000000000..66526bc30a Binary files /dev/null and b/docs/verification/PRD-473/tier-performance-1440x900.jpg differ diff --git a/docs/verification/PRD-473/tier-ultra-1440x900.jpg b/docs/verification/PRD-473/tier-ultra-1440x900.jpg new file mode 100644 index 0000000000..d0c97d8a00 Binary files /dev/null and b/docs/verification/PRD-473/tier-ultra-1440x900.jpg differ diff --git a/docs/verification/PRD-473/ui-help-1440x900.jpg b/docs/verification/PRD-473/ui-help-1440x900.jpg new file mode 100644 index 0000000000..6e14d0ef37 Binary files /dev/null and b/docs/verification/PRD-473/ui-help-1440x900.jpg differ diff --git a/docs/verification/PRD-473/ui-hidden-1440x900.jpg b/docs/verification/PRD-473/ui-hidden-1440x900.jpg new file mode 100644 index 0000000000..0a2563881b Binary files /dev/null and b/docs/verification/PRD-473/ui-hidden-1440x900.jpg differ diff --git a/docs/verification/PRD-473/ui-mobile-help-390x844.jpg b/docs/verification/PRD-473/ui-mobile-help-390x844.jpg new file mode 100644 index 0000000000..c361c5e7b0 Binary files /dev/null and b/docs/verification/PRD-473/ui-mobile-help-390x844.jpg differ diff --git a/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg b/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg new file mode 100644 index 0000000000..f8018b3001 Binary files /dev/null and b/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg differ diff --git a/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg b/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg new file mode 100644 index 0000000000..54dbfd6cec Binary files /dev/null and b/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg differ diff --git a/docs/verification/alpha-bar.md b/docs/verification/alpha-bar.md index 885fc6b6e1..8594813061 100644 --- a/docs/verification/alpha-bar.md +++ b/docs/verification/alpha-bar.md @@ -4,7 +4,7 @@ | # | Alpha requirement | State | What the bar read, and what it said | |---|---|---|---| -| A1 | A stranger can install it from the public registry | **red** | `npm view versions --json` — 1 of 12 publishable package(s) are absent from the registry: @threenative/metahuman. Unpublished workspace version(s): @threenative/assets 0.3.5, @threenative/core 0.3.4, @threenative/physics 0.3.4, @threenative/playtest 0.3.4, @threenative/raw-unreal 0.1.4, @threenative/runtime-native 0.3.4, @threenative/ueformat 0.1.4, @threenative/ui 0.3.4, create-threenative 0.2.7, threenative-blender-mcp 0.1.4, threenative-engine-mcp 0.2.4. | +| A1 | A stranger can install it from the public registry | **green** | `npm view versions --json` — All 12 publishable package(s) are on the registry at their workspace versions. | | A2 | The golden path completes from published artifacts | **green** | `an alpha-bar evidence block for A2 in docs/verification/` — npx create-threenative@0.2.2 scaffolds, npm install resolves every package from registry.npmjs.org with zero file:/link: specifiers, and npm run build succeeds. (docs/verification/registry-install-2026-08-16.md, produced by: npx --yes create-threenative@0.2.2 my-game --template starter && npm install && npm run build, in a directory with no workspace above it) | | A3 | Verification cannot report green while asserting nothing | **green** | `an alpha-bar evidence block for A3 in docs/verification/` — The shipped runner exited 1 for an empty assertion set and reported TN_PLAYTEST_SCENARIO_NO_ASSERTIONS with the message "Scenario 'alpha-a3-empty' completed without evaluating any assertions."; the true-positive component.cube.quaternion.0 control passed with exit 0 on the same runner, project and browser recipe. (docs/verification/alpha-a3-2026-08-29.md, produced by: node packages/playtest/dist/runner/cli.js packages/playtest/__tests__/fixtures/alpha-a3-empty.playtest.json --project examples/native-smoke --url http://127.0.0.1:5187 --server-command "pnpm exec vite --host 127.0.0.1 --port 5187 --strictPort" --browser-recipe webgpu --allow-software) | | A4 | The value claim rests on one measured paired round | **green** | `docs/verification/round-*.md` — Round 9 (2026-08-15) records a measured paired round: platformer/framework 1/2 scenarios, 9/10 rows, platformer/vanilla 0/2 scenarios, 7/10 rows. | diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index 91055cb8a5..369ddacfd0 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -8,7 +8,7 @@ targets could not be built and are named below. | Subsystem | Instrumented lines | Covered | Line coverage | | --- | ---: | ---: | ---: | | `src/async/` | 73 | 60 | 82.19% | -| `src/audio/` | 1223 | 1047 | 85.61% | +| `src/audio/` | 1471 | 1301 | 88.44% | | `src/canvas/` | 1334 | 1110 | 83.21% | | `src/cli/` | 1682 | 1238 | 73.60% | | `src/fs/` | 235 | 189 | 80.43% | @@ -23,10 +23,10 @@ targets could not be built and are named below. | `src/vfs/` | 239 | 195 | 81.59% | | `src/webgpu/` | 9661 | 7380 | 76.39% | | `src/webtransport/` | 1391 | 1078 | 77.50% | -| `src/workers/` | 615 | 524 | 85.20% | -| **TOTAL** | **24108** | **18917** | **78.47%** | +| `src/workers/` | 615 | 527 | 85.69% | +| **TOTAL** | **24356** | **19174** | **78.72%** | -Source digest: `sha256:26482043df2162d041ffeec0c7448661fde58a299a62285a6728d703b75e6aed` +Source digest: `sha256:b309d234707d2ecd8ad21f4c86933adb2b1b9d43ed966e2e276f89c33fa4dbad` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/docs/verification/native-runtime-census-2026-08-16.md b/docs/verification/native-runtime-census-2026-08-16.md index 63f5922ca3..4e3c66431d 100644 --- a/docs/verification/native-runtime-census-2026-08-16.md +++ b/docs/verification/native-runtime-census-2026-08-16.md @@ -15,16 +15,16 @@ considered, and a KEEP/DELETE verdict. | Counted area | Lines | Owner | Live proof or caller | Alternative considered | Verdict | | --- | ---: | --- | --- | --- | --- | -| `src/` | 57,242 | PRD-045, PRD-047, PRD-048, PRD-050, PRD-053, PRD-116, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `src/physics/native_bindings.cpp:586`; desktop V8 and native runtime commands | Move host shims into each game or delete the native host | **KEEP** — this is the owned native host and its physics boundary. | -| `conformance/` | 9,409 | PRD-053, PRD-054, PRD-055, PRD-076 | `conformance/run-conformance.mjs`; root `pnpm parity` | Replace cross-target registry/proofs with untested per-game scripts | **KEEP** — shared executable conformance evidence. | -| `tests/` | 57,463 | PRD-045, PRD-046, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-055, PRD-076, PRD-116, PRD-127, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `tests/physics_actuation_bindings_test.cpp:141`; runtime-native Vitest and native tests | Delete fail-closed tests to reduce the trigger | **KEEP** — removal would conceal regressions. | +| `src/` | 57,757 | PRD-045, PRD-047, PRD-048, PRD-050, PRD-053, PRD-116, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `src/physics/native_bindings.cpp:586`; desktop V8 and native runtime commands | Move host shims into each game or delete the native host | **KEEP** — this is the owned native host and its physics boundary. | +| `conformance/` | 9,477 | PRD-053, PRD-054, PRD-055, PRD-076 | `conformance/run-conformance.mjs`; root `pnpm parity` | Replace cross-target registry/proofs with untested per-game scripts | **KEEP** — shared executable conformance evidence. | +| `tests/` | 57,859 | PRD-045, PRD-046, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-055, PRD-076, PRD-116, PRD-127, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `tests/physics_actuation_bindings_test.cpp:141`; runtime-native Vitest and native tests | Delete fail-closed tests to reduce the trigger | **KEEP** — removal would conceal regressions. | | `__tests__/` | 936 | PRD-228 | `__tests__/host-gap-gpu-drain.spec.ts`; the runtime-native Vitest suite via `vitest.config.ts` | Fold it into `tests/`, where every other spec in this package lives | **KEEP** — it is one source-reading spec proving the GPU-drain profile stays diagnostic-only and never ships, and it is picked up by the same suite either way. Its one-file directory is an accident of where it landed, not a design; moving it into `tests/` is a tidy-up and not this row's argument. | -| `scripts/` | 30,903 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-116, PRD-127, PRD-153, PRD-155, PRD-292 | `scripts/verify-desktop-physics.mjs:206-228`; build, platform, and device-condition verifiers | Make every game own packaging, device, and verifier orchestration | **KEEP** — no smaller shared alternative preserves the proof. | +| `scripts/` | 30,902 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-116, PRD-127, PRD-153, PRD-155, PRD-292 | `scripts/verify-desktop-physics.mjs:206-228`; build, platform, and device-condition verifiers | Make every game own packaging, device, and verifier orchestration | **KEEP** — no smaller shared alternative preserves the proof. | | `scenarios/` | 109 | PRD-217 | `scenarios/starter-ui-overlay-desktop.playtest.json`; run by the hosted `native-platforms` desktop-core job through `scripts/verify-starter-ui-overlay.mjs` | Keep the starter-HUD input assertion inside the desktop-core job script instead of a standalone scenario file | **KEEP** — the scenario is the portable playtest that proves the starter HUD receives real input on the packed desktop runtime; the hosted desktop-core job is its caller, so the area is one owned proof rather than reusable game content. | -| `include/` | 5,935 | PRD-046, PRD-047, PRD-053, PRD-116, PRD-143, PRD-153, PRD-292 | `include/threenative/physics_native.h:131`; C ABI consumed at `src/physics/native_bindings.cpp:586` | Add per-game native headers or remove the native C boundary | **KEEP** — the coarse host ABI is the shared boundary. | +| `include/` | 6,091 | PRD-046, PRD-047, PRD-053, PRD-116, PRD-143, PRD-153, PRD-292 | `include/threenative/physics_native.h:131`; C ABI consumed at `src/physics/native_bindings.cpp:586` | Add per-game native headers or remove the native C boundary | **KEEP** — the coarse host ABI is the shared boundary. | | `android/` | 3,187 | PRD-045, PRD-048, PRD-050, PRD-053, PRD-054, PRD-153 | Android host sources and `scripts/verify-android-physics-parity.mjs` | Require each game to rebuild Android lifecycle and transport | **KEEP** — required Android packaging and execution plumbing. | -| `native/` | 8,161 | PRD-046, PRD-049, PRD-116, PRD-143, PRD-292 | `native/physics/src/lib.rs:463`; `native/physics/tests/actuation.rs:324` | Use the web WASM/Rapier backend on native or move physics into games | **KEEP** — this is the native Rust implementation behind the shared API. | -| Root `CMakeLists.txt` | 2,758 | PRD-047, PRD-048, PRD-050 | `pnpm native:build`; explicit binding target build | Make every game own native dependency discovery and linking | **KEEP** — opt-in host build configuration. | +| `native/` | 8,580 | PRD-046, PRD-049, PRD-116, PRD-143, PRD-292 | `native/physics/src/lib.rs:463`; `native/physics/tests/actuation.rs:324` | Use the web WASM/Rapier backend on native or move physics into games | **KEEP** — this is the native Rust implementation behind the shared API. | +| Root `CMakeLists.txt` | 2,759 | PRD-047, PRD-048, PRD-050 | `pnpm native:build`; explicit binding target build | Make every game own native dependency discovery and linking | **KEEP** — opt-in host build configuration. | | `cmake/` | 328 | PRD-047, PRD-048, PRD-050 | Root CMake configuration includes these platform modules | Duplicate compiler/platform rules in each game | **KEEP** — shared build modules. | | `CMakePresets.json` | 141 | PRD-047, PRD-048, PRD-050 | `tn-linux` preset used by the native build and binding target | Remove declared presets and use undocumented local flags | **KEEP** — reproducible host presets. | | `ios/` | 598 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-153 | `scripts/verify-ios-simulator.mjs` and iOS host sources | Make each game own iOS packaging and simulator lifecycle | **KEEP** — shared iOS packaging boundary; this lane made no iOS execution claim. | @@ -35,7 +35,7 @@ considered, and a KEEP/DELETE verdict. | `js-engine-versions.json` | 38 | PRD-222 | `tests/js-engine-version-skew.test.mjs`; `scripts/download-deps.mjs` consumes the pinned engine versions | Leave engine/version compatibility as prose or duplicate pins across download scripts | **KEEP** — one fail-closed manifest prevents desktop, Android, and iOS engine pins from drifting silently. | | `build-matrix.json` | 231 | PRD-235 | `scripts/check-build-matrix.ts` from `scripts/check-budgets.ts:467` via `pnpm budgets`; proven by `scripts/__tests__/check-build-matrix.spec.ts` | Leave "which build directory does this test executable live in" as prose in `AGENTS.md` and tribal knowledge | **KEEP** — the prose answer was already wrong once: a contract test that only links in `build/tn-linux` is invisible to anyone reading `CMakeLists.txt`. This is the machine-readable contract, and the gate cross-checks every preset, cache variable, and test executable it names against CMake, so either side drifting is a named failure. | | `native-deps.lock.json` | 1,050 | PRD-059 | `scripts/download-deps.mjs` reads it before any acquisition; `tests/dependency-provenance.test.mjs` proves lock coverage | Leave dependency URLs/versions/hashes as inline mutable code in `download-deps.mjs` | **KEEP** — the reviewed immutable payload matrix is what makes reconstruction mean the same bytes; inline authority already served one 404. | -| **Total** | **178,811** | | `pnpm budgets` current measurement | | **No area rejected.** | +| **Total** | **180,365** | | `pnpm budgets` current measurement | | **No area rejected.** | **Regenerated 2026-09-02 for PRD-292 recovery:** the native physics ABI, its contract test, and the continuous-collision measurement harness changed the counted `src/`, `include/`, `tests/`, diff --git a/docs/verification/prd-364-draft-audit-2026-09-06.md b/docs/verification/prd-364-draft-audit-2026-09-06.md index 1377ae840f..93f646bd2e 100644 --- a/docs/verification/prd-364-draft-audit-2026-09-06.md +++ b/docs/verification/prd-364-draft-audit-2026-09-06.md @@ -1,6 +1,6 @@ # PRD-364 drafting audit — 2026-09-06 -Scope: planning only at checkout `713ca111`. The [PRD](../PRDs/CI/PRD-364-remove-development-gate-bloat.md) records inspected callers and read-only GitHub run observations. No runtime or workflow implementation changed. +Scope: planning only at checkout `713ca111`. The [PRD](../PRDs/done/PRD-364-remove-development-gate-bloat.md) records inspected callers and read-only GitHub run observations. No runtime or workflow implementation changed. ## Executed commands diff --git a/docs/verification/prd341/provenance.json b/docs/verification/prd341/provenance.json new file mode 100644 index 0000000000..a971ade44d --- /dev/null +++ b/docs/verification/prd341/provenance.json @@ -0,0 +1,144 @@ +{ + "sourceSha": "0ea7e29a0ff18717d33b8bce3300ec0568fea842", + "workflowRun": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892", + "artifactId": 11218670853, + "artifactDigest": "sha256:8c8b12e370e8f8013804b7701570d284f2ed618b23b692b873e5ec6240110a89", + "qualification": "rendered-pixel correctness only; software adapters allowed, no native or hardware-performance claim", + "dedicatedGatePassed": true, + "fullRequiredCi": "pending", + "frames": [ + { + "variant": "underexposed", + "file": "underexposed.png", + "sha256": "173b18dadda32b33922afacf2bb9596dab752e8273914755204f86617c4b6657", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 720, + "width": 1280 + } + }, + "pass": false, + "tone": [ + { + "code": "TN_TONE", + "label": "tone-0.png", + "atStep": "rendered", + "mean": 89.55505316840278, + "p1": 6, + "p50": 95, + "p99": 136, + "clipFraction": 0, + "blackFraction": 0.00390625 + }, + { + "code": "TN_TONE", + "label": "after.png", + "mean": 89.55505316840278, + "p1": 6, + "p50": 95, + "p99": 136, + "clipFraction": 0, + "blackFraction": 0.00390625 + } + ], + "diagnostics": [ + { + "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED", + "message": "Tone 'rendered' mean measured 89.55505316840278; required {\"min\":140,\"max\":210}.", + "severity": "error", + "observedRuntimePath": "observations.json/tone" + }, + { + "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED", + "message": "Tone 'rendered' p99 measured 136; required {\"min\":230}.", + "severity": "error", + "observedRuntimePath": "observations.json/tone" + }, + { + "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED", + "message": "Tone 'after.png' mean measured 89.55505316840278; required {\"min\":140,\"max\":210}.", + "severity": "error", + "observedRuntimePath": "observations.json/tone" + }, + { + "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED", + "message": "Tone 'after.png' p99 measured 136; required {\"min\":230}.", + "severity": "error", + "observedRuntimePath": "observations.json/tone" + } + ] + }, + { + "variant": "restored", + "file": "restored.png", + "sha256": "ad77af341b9ab04b61060e3eb8e1904f93b7d9273b6b2bc763e0e6269f0e4dcf", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 720, + "width": 1280 + } + }, + "pass": true, + "tone": [ + { + "code": "TN_TONE", + "label": "tone-0.png", + "atStep": "rendered", + "mean": 170.49870985243055, + "p1": 22, + "p50": 180, + "p99": 254, + "clipFraction": 0.0078125, + "blackFraction": 0.00390625 + }, + { + "code": "TN_TONE", + "label": "after.png", + "mean": 170.49870985243055, + "p1": 22, + "p50": 180, + "p99": 254, + "clipFraction": 0.0078125, + "blackFraction": 0.00390625 + } + ], + "diagnostics": [] + } + ] +} diff --git a/docs/verification/prd341/restored.png b/docs/verification/prd341/restored.png new file mode 100644 index 0000000000..6d62534715 Binary files /dev/null and b/docs/verification/prd341/restored.png differ diff --git a/docs/verification/prd341/underexposed.png b/docs/verification/prd341/underexposed.png new file mode 100644 index 0000000000..d2bf903f30 Binary files /dev/null and b/docs/verification/prd341/underexposed.png differ diff --git a/docs/verification/runtime-perf-state.md b/docs/verification/runtime-perf-state.md index adf8804546..51c2fc7a4e 100644 --- a/docs/verification/runtime-perf-state.md +++ b/docs/verification/runtime-perf-state.md @@ -10,6 +10,26 @@ git history (`git log --diff-filter=D --name-only -- docs/verification/` names t `git show ^:docs/verification/`). §8 indexes what each one concluded. A claim whose detail is not in this file exists only in git — quote it with the commit. +## Machinefall open world on develop: the CPU render phase is the frame — 2026-10-01 + +PRD-475 Phase 1 baseline (`docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md`). Machinefall `?scene=map-walk` on develop `a602467db` (core 0.3.4, assets 0.3.5, world re-cooked), browser WebGPU on the RTX 2080 (nvidia/turing), private Xvfb, 3 runs started at load 3.4–3.8. Steady `TN_FRAME_BUDGET` windows; medians across runs. + +| Meter | p50 | p95 | +| --- | --- | --- | +| frame | 12.3 ms | 26.3 ms | +| render phase (CPU) | 12.1 ms | 26.1 ms | +| hostGap | 0.6 ms | 61.9 ms | +| GPU (timestamp-query) | 7.4 ms | 11.3 ms | +| gpuMain / gpuShadow | 5.6 / 1.1 ms | — | +| draws main / shadow | 318 / 200 | 414 / 270 | + +- The CPU render phase is the frame: render p50 is 1.6× the GPU p50, and update, overlay and ui read 0. The 120 fps budget (8.3 ms p95) is missed by 3.1× on CPU and 1.4× on GPU. +- Window fps median 45 (51, 42, 45). One window per run is the post-walk idle at 83–119 fps, which is why a whole-run average reads near 120. +- The triangle census reads 264 M main and 23 M shadow triangles for about 318 draws, against 9.8 M `mainGpuTriangles`. It is not trusted until it is checked. `gpuShadow` read 0 in one run. +- One earlier run of five counted 1 failed world load (`WorldCells` `failures`), not reproduced in four reruns. The count records no cell and no error. +- **After PRD-475 cuts 1+2** (terrain super-tiles, shadow caster table; `890578e81`), clean machine (load < 3, GPU < 10%), 4+4 interleaved runs against develop: frame p50 9.55 → 7.20 ms, frame p95 20.55 → 17.22 ms, GPU p50 6.95 → 7.12, GPU p95 10.93 → 10.20, main draws 335.5 → 246.5. The develop arm's own spread (frame p50 6.4–12.0 ms) overlaps, so the ratio is directional; the branch's distribution is tighter. Still 2.1× over the 8.3 ms p95 on CPU and 1.2× on GPU. +- **PRD-475 as merged** (2026-10-02, cut12k–n, walk/idle split of `TN_FRAME_BUDGET` windows, quiet gate, 3–4 interleaved pairs against develop `a602467db`): idle frame p50 2.6–4.8 vs 5–7 ms; walking render p50 7.0–8.4 vs 8.3–10.4; walking render p95 19–26 vs 18–25 (unchanged, the 8.3 ms target is not met); main draws 208–297 vs 297–327. A long walking frame (unminified trace, 390 of 1491 rAFs over 12 ms, mean 20 ms) spends ~7.5 ms in three's per-object refresh, ~5.5 ms in WorldCells streaming and ~3 ms in a shadow-level re-render; that tail is PRD-478's. A shared measured frame budget (streaming deferred in shadow frames) made walking p95 worse (26–35 ms) and was reverted. + ## ThreeNative vs Godot 4.7.1 scoreboard, and three projection fixes — 2026-09-28 PRD-449 (`docs/PRDs/done/PRD-449-cross-engine-benchmarks-and-html-report.md`). One cube scene, native desktop, RTX 2080, 1280×720 uncapped, 40 warm-up + 120 measured frames, and 3 alternating runs per engine (`pnpm bench:scoreboard`). A row is a win only when the median gap beats the run-to-run spread. The dashboard is `pnpm bench:engines:monitor` (`artifacts/engine-load-test/prd-449/progress.html`). diff --git a/docs/verification/visuals/rain/kit-desktop.jpg b/docs/verification/visuals/rain/kit-desktop.jpg new file mode 100644 index 0000000000..55be161ba5 Binary files /dev/null and b/docs/verification/visuals/rain/kit-desktop.jpg differ diff --git a/docs/verification/visuals/rain/kit-mobile.jpg b/docs/verification/visuals/rain/kit-mobile.jpg new file mode 100644 index 0000000000..e3c84d9f72 Binary files /dev/null and b/docs/verification/visuals/rain/kit-mobile.jpg differ diff --git a/docs/verification/visuals/rain/kit-tablet.jpg b/docs/verification/visuals/rain/kit-tablet.jpg new file mode 100644 index 0000000000..3f6e84e90b Binary files /dev/null and b/docs/verification/visuals/rain/kit-tablet.jpg differ diff --git a/docs/verification/visuals/rain/reference-desktop.png b/docs/verification/visuals/rain/reference-desktop.png new file mode 100644 index 0000000000..ed3bb7cf38 Binary files /dev/null and b/docs/verification/visuals/rain/reference-desktop.png differ diff --git a/docs/verification/visuals/rain/reference-mobile.png b/docs/verification/visuals/rain/reference-mobile.png new file mode 100644 index 0000000000..77ceea8736 Binary files /dev/null and b/docs/verification/visuals/rain/reference-mobile.png differ diff --git a/docs/verification/visuals/rain/reference-tablet.png b/docs/verification/visuals/rain/reference-tablet.png new file mode 100644 index 0000000000..b6e76a107b Binary files /dev/null and b/docs/verification/visuals/rain/reference-tablet.png differ diff --git a/docs/verification/vq11-decals-36989297734/motion.png b/docs/verification/vq11-decals-36989297734/motion.png new file mode 100644 index 0000000000..d2af756406 Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/motion.png differ diff --git a/docs/verification/vq11-decals-36989297734/static.png b/docs/verification/vq11-decals-36989297734/static.png new file mode 100644 index 0000000000..c15d6d481e Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/static.png differ diff --git a/docs/verification/vq11-decals-36989297734/summary.json b/docs/verification/vq11-decals-36989297734/summary.json new file mode 100644 index 0000000000..82172ab59a --- /dev/null +++ b/docs/verification/vq11-decals-36989297734/summary.json @@ -0,0 +1,186 @@ +{ + "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324", + "correctnessOnly": true, + "results": [ + { + "name": "static", + "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png", + "diagnostics": [] + }, + { + "name": "motion", + "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0.8042230500706451, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png", + "diagnostics": [] + }, + { + "name": "teardown", + "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324", + "qualified": true, + "measurement": { + "generation": 1, + "active": 128, + "capacity": 256, + "created": 321, + "released": 193, + "geometryBytes": 136140, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": true + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png", + "diagnostics": [] + }, + { + "name": "restart", + "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324", + "qualified": true, + "measurement": { + "generation": 2, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png", + "diagnostics": [] + } + ] +} diff --git a/docs/verification/vq11-decals-36989297734/teardown.png b/docs/verification/vq11-decals-36989297734/teardown.png new file mode 100644 index 0000000000..f10178395e Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/teardown.png differ diff --git a/docs/verification/vq11-decals-36991562324/hidden-decals.png b/docs/verification/vq11-decals-36991562324/hidden-decals.png new file mode 100644 index 0000000000..6c43259ac7 Binary files /dev/null and b/docs/verification/vq11-decals-36991562324/hidden-decals.png differ diff --git a/docs/verification/vq11-decals-36991562324/hidden-report.json b/docs/verification/vq11-decals-36991562324/hidden-report.json new file mode 100644 index 0000000000..5bebf69176 --- /dev/null +++ b/docs/verification/vq11-decals-36991562324/hidden-report.json @@ -0,0 +1,65 @@ +{ + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324", + "artifactId": 11218994493, + "artifactSha256": "a84190c40b3cdd6679ff9fea858dd6cfe2373646f090192ee531411fbaba1070", + "kind": "historical hidden-material control provenance", + "pass": false, + "expectedFailureIds": ["visual.1.region.darkPixels", "visual.2.region.darkPixels"], + "assertions": [ + { + "id": "visual.0.region", + "pass": true + }, + { + "id": "visual.1.region", + "pass": true + }, + { + "id": "visual.1.region.darkPixels", + "pass": false + }, + { + "id": "visual.1.region.maxDarkPixels", + "pass": true + }, + { + "id": "visual.2.region", + "pass": true + }, + { + "id": "visual.2.region.darkPixels", + "pass": false + }, + { + "id": "visual.2.region.maxDarkPixels", + "pass": true + }, + { + "id": "component.decals.active.value", + "pass": true + }, + { + "id": "component.decals.created.value", + "pass": true + }, + { + "id": "component.decals.geometryBytes.value", + "pass": true + }, + { + "id": "component.decals.renderTriangles.value", + "pass": true + }, + { + "id": "component.decals.generation.value", + "pass": true + }, + { + "id": "diagnostics", + "pass": true + } + ], + "originalReportUrl": "https://github.com/ThreeNativeHQ/threenative/blob/e1566472ee439650c74109a92e7fabae1770f71a/docs/verification/vq11-decals-36991562324/hidden-report.json", + "note": "Minimal public provenance. Full original report remains in the workflow artifact and the immutable historical commit; no environment or diagnostic payload is republished." +} diff --git a/docs/verification/vq11-decals-36991562324/summary.json b/docs/verification/vq11-decals-36991562324/summary.json new file mode 100644 index 0000000000..036da3a007 --- /dev/null +++ b/docs/verification/vq11-decals-36991562324/summary.json @@ -0,0 +1,428 @@ +{ + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "correctnessOnly": true, + "results": [ + { + "name": "static", + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "motion", + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0.8042230500706451, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.04210648148148148, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5506944444444445, + "nonblankPixelRatio": 0.9999729938271605 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.051812865497076026, + "nonblankPixelRatio": 0.9997660818713451 + } + ] + } + }, + { + "name": "teardown", + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "qualified": true, + "measurement": { + "generation": 1, + "active": 128, + "capacity": 256, + "created": 321, + "released": 193, + "geometryBytes": 136140, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": true + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.07619598765432099, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.6017264660493827, + "nonblankPixelRatio": 0.9999787808641976 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + } + ] + } + }, + { + "name": "restart", + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "qualified": true, + "measurement": { + "generation": 2, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "hidden-decals", + "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6", + "qualified": false, + "negativeControlPassed": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 259794, + "maxDrawCalls": 4, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + }, + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + } + ], + "visual": { + "changedPixelRatio": 0, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + } + ] +} diff --git a/docs/verification/vq11-decals-37003284983/atlas.png b/docs/verification/vq11-decals-37003284983/atlas.png new file mode 100644 index 0000000000..e8bb3a7169 Binary files /dev/null and b/docs/verification/vq11-decals-37003284983/atlas.png differ diff --git a/docs/verification/vq11-decals-37003284983/fading.png b/docs/verification/vq11-decals-37003284983/fading.png new file mode 100644 index 0000000000..58991affee Binary files /dev/null and b/docs/verification/vq11-decals-37003284983/fading.png differ diff --git a/docs/verification/vq11-decals-37003284983/summary.json b/docs/verification/vq11-decals-37003284983/summary.json new file mode 100644 index 0000000000..0b91e2cedd --- /dev/null +++ b/docs/verification/vq11-decals-37003284983/summary.json @@ -0,0 +1,1071 @@ +{ + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "correctnessOnly": true, + "results": [ + { + "name": "static", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "motion", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0.8042230500706451, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.04210648148148148, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5506944444444445, + "nonblankPixelRatio": 0.9999729938271605 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.051812865497076026, + "nonblankPixelRatio": 0.9997660818713451 + } + ] + } + }, + { + "name": "teardown", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 128, + "capacity": 256, + "created": 321, + "released": 193, + "geometryBytes": 136140, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": true, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.07619598765432099, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.6017264660493827, + "nonblankPixelRatio": 0.9999787808641976 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + } + ] + } + }, + { + "name": "restart", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 2, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + }, + { + "generation": 2, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + } + ], + "residencySamples": 2, + "geometryGrowth": 0, + "textureGrowth": 0, + "memoryGrowth": 0 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "atlas", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/atlas/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5419116512345679, + "nonblankPixelRatio": 0.9999787808641976 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.1, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.12, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.4545029239766082, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "fading", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0.49999999999999994, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/fading/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.0277758487654321, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 100, + "width": 150, + "x": 280, + "y": 195, + "maxDarkPixelRatio": 0.3, + "maxLuminance": 0.65, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.06553333333333333, + "nonblankPixelRatio": 1 + }, + { + "height": 80, + "width": 85, + "x": 555, + "y": 195, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.6, + "minDarkPixelRatio": 0.1, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.43441176470588233, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "expired", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": true, + "measurement": { + "generation": 1, + "active": 0, + "capacity": 256, + "created": 321, + "released": 321, + "geometryBytes": 0, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/expired/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.029544753086419753, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "lifecycle", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": false, + "measurement": { + "generation": 6, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + }, + { + "generation": 2, + "geometries": 258, + "textures": 4, + "bytes": 6522318 + }, + { + "generation": 3, + "geometries": 258, + "textures": 4, + "bytes": 6522318 + }, + { + "generation": 4, + "geometries": 258, + "textures": 4, + "bytes": 6522318 + }, + { + "generation": 5, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 6, + "geometries": 258, + "textures": 4, + "bytes": 6522318 + } + ], + "residencySamples": 6, + "geometryGrowth": 1, + "textureGrowth": 0, + "memoryGrowth": 576 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/lifecycle/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_COMPONENT_ASSERTION_FAILED", + "message": "Component 'geometryGrowth' on entity 'decals' did not satisfy the assertion.", + "observedRuntimePath": "observations.json/components/decals/geometryGrowth", + "severity": "error", + "suggestion": "Expected {\"component\":\"geometryGrowth\",\"entity\":\"decals\",\"allowTrivial\":\"GPU resource counts and bytes must not grow across the five warmed scene instances.\",\"lte\":0}, observed before=undefined after=1. Check the owning script and runtime component synchronization." + }, + { + "code": "TN_PLAYTEST_COMPONENT_ASSERTION_FAILED", + "message": "Component 'memoryGrowth' on entity 'decals' did not satisfy the assertion.", + "observedRuntimePath": "observations.json/components/decals/memoryGrowth", + "severity": "error", + "suggestion": "Expected {\"component\":\"memoryGrowth\",\"entity\":\"decals\",\"allowTrivial\":\"GPU resource counts and bytes must not grow across the five warmed scene instances.\",\"lte\":0}, observed before=undefined after=576. Check the owning script and runtime component synchronization." + } + ], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "hidden-decals", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": false, + "negativeControlPassed": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 259794, + "maxDrawCalls": 4, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + }, + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + } + ], + "visual": { + "changedPixelRatio": 0, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "hidden-fading", + "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c", + "qualified": false, + "negativeControlPassed": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0.49999999999999994, + "residency": [ + { + "generation": 1, + "geometries": 1, + "textures": 2, + "bytes": 6223938 + } + ], + "residencySamples": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-fading/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (280, 195) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + }, + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (555, 195) contained 0 dark pixels, below required ratio 0.1.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + } + ], + "visual": { + "changedPixelRatio": 0.029544753086419753, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 100, + "width": 150, + "x": 280, + "y": 195, + "maxDarkPixelRatio": 0.3, + "maxLuminance": 0.65, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 80, + "width": 85, + "x": 555, + "y": 195, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.6, + "minDarkPixelRatio": 0.1, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + } + ] +} diff --git a/docs/verification/vq11-decals-37005420971/summary.json b/docs/verification/vq11-decals-37005420971/summary.json new file mode 100644 index 0000000000..eb896571ce --- /dev/null +++ b/docs/verification/vq11-decals-37005420971/summary.json @@ -0,0 +1,1006 @@ +{ + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "correctnessOnly": true, + "results": [ + { + "name": "static", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "motion", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0.8042230500706451, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.04210648148148148, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5506944444444445, + "nonblankPixelRatio": 0.9999729938271605 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.051812865497076026, + "nonblankPixelRatio": 0.9997660818713451 + } + ] + } + }, + { + "name": "teardown", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 128, + "capacity": 256, + "created": 321, + "released": 193, + "geometryBytes": 136140, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": true, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.07619598765432099, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.6017264660493827, + "nonblankPixelRatio": 0.9999787808641976 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + } + ] + } + }, + { + "name": "restart", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 2, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "atlas", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275028, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/atlas/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5419116512345679, + "nonblankPixelRatio": 0.9999787808641976 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.1, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.12, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.4545029239766082, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "fading", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0.49999999999999994, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/fading/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.0277758487654321, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 100, + "width": 150, + "x": 280, + "y": 195, + "maxDarkPixelRatio": 0.3, + "maxLuminance": 0.65, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.06553333333333333, + "nonblankPixelRatio": 1 + }, + { + "height": 80, + "width": 85, + "x": 555, + "y": 195, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.6, + "minDarkPixelRatio": 0.1, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.43441176470588233, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "expired", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 1, + "active": 0, + "capacity": 256, + "created": 321, + "released": 321, + "geometryBytes": 0, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/expired/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.029544753086419753, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "lifecycle", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": true, + "measurement": { + "generation": 6, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [ + { + "generation": 1, + "renderCalls": 56, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 2, + "renderCalls": 64, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 3, + "renderCalls": 72, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 4, + "renderCalls": 80, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 5, + "renderCalls": 88, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 6, + "renderCalls": 96, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + } + ], + "residencySamples": 6, + "stableResidencyFrames": 3, + "geometryGrowth": 0, + "textureGrowth": 0, + "memoryGrowth": 0 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/lifecycle/after.png", + "diagnostics": [], + "visual": { + "changedPixelRatio": 0.00017939814814814815, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5420428240740741, + "nonblankPixelRatio": 0.9999710648148148 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05341176470588235, + "nonblankPixelRatio": 0.9994823529411765 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0.05660818713450293, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "hidden-decals", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": false, + "negativeControlPassed": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 259794, + "maxDrawCalls": 4, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 0 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + }, + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + } + ], + "visual": { + "changedPixelRatio": 0, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.2, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + }, + { + "name": "hidden-fading", + "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee", + "qualified": false, + "negativeControlPassed": true, + "measurement": { + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": true, + "averageOpacity": 0.49999999999999994, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 1 + }, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 540, + "width": 960 + } + }, + "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-fading/after.png", + "diagnostics": [ + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (280, 195) contained 0 dark pixels, below required ratio 0.01.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + }, + { + "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED", + "message": "Screenshot region at (555, 195) contained 0 dark pixels, below required ratio 0.1.", + "severity": "error", + "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region." + } + ], + "visual": { + "changedPixelRatio": 0.029544753086419753, + "comparisonSource": "before-after", + "nonblankRegions": [ + { + "height": 540, + "width": 960, + "x": 0, + "y": 0, + "minNonblankPixelRatio": 0.15, + "darkPixelRatio": 0.5417457561728395, + "nonblankPixelRatio": 1 + }, + { + "height": 125, + "width": 170, + "x": 270, + "y": 185, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 90, + "width": 95, + "x": 550, + "y": 190, + "maxDarkPixelRatio": 0.001, + "maxLuminance": 0.35, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 100, + "width": 150, + "x": 280, + "y": 195, + "maxDarkPixelRatio": 0.3, + "maxLuminance": 0.65, + "minDarkPixelRatio": 0.01, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + }, + { + "height": 80, + "width": 85, + "x": 555, + "y": 195, + "maxDarkPixelRatio": 0.65, + "maxLuminance": 0.6, + "minDarkPixelRatio": 0.1, + "minNonblankPixelRatio": 0.9, + "darkPixelRatio": 0, + "nonblankPixelRatio": 1 + } + ] + } + } + ] +} diff --git a/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json b/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json new file mode 100644 index 0000000000..647538ed3b --- /dev/null +++ b/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json @@ -0,0 +1,54 @@ +{ + "sourceSha": "563ab23e9448a993013cb0d58c75ab6ab83917c9", + "artifactDirectory": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/lifecycle", + "assertionResults": [ + { + "details": { + "consoleErrors": 0, + "networkErrors": 0, + "policy": { + "networkErrorsOptOutReason": "The run target has no network observer; its network observation is hardwired empty, so the default network policy is waived rather than evaluated against nothing.", + "noConsoleErrors": true, + "noNetworkErrors": false, + "noRuntimeDiagnostics": true + }, + "reason": "not-evaluated", + "runtimeDiagnostics": 0 + }, + "id": "diagnostics", + "pass": false + } + ], + "debugColliders": false, + "diagnostics": [ + { + "code": "TN_PLAYTEST_OBSERVATION_UNAVAILABLE", + "fix": { + "instruction": "Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path." + }, + "message": "Resource wait for 'state.residencyGeneration' timed out after 30150 ms; predicate {\"gte\":1,\"id\":\"state\",\"path\":\"residencyGeneration\"}, last observation 0.", + "path": "state", + "severity": "error", + "suggestion": "Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path." + } + ], + "diagnosticsPolicy": { + "networkErrorsOptOutReason": "The run target has no network observer; its network observation is hardwired empty, so the default network policy is waived rather than evaluated against nothing.", + "noConsoleErrors": true, + "noNetworkErrors": false, + "noRuntimeDiagnostics": true + }, + "distance": 0, + "entity": "", + "expectMoved": false, + "frames": 0, + "input": "", + "movementThreshold": 0, + "pass": false, + "runtime": "native", + "scenario": "vq11-lifecycle", + "target": "desktop", + "trivialityOptOutCount": 0, + "trivialityOptOuts": [], + "url": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native/decals" +} diff --git a/docs/verification/vq11-decals-native-37010879478/motion.png b/docs/verification/vq11-decals-native-37010879478/motion.png new file mode 100644 index 0000000000..3848ef3886 Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/motion.png differ diff --git a/docs/verification/vq11-decals-native-37010879478/static-console.json b/docs/verification/vq11-decals-native-37010879478/static-console.json new file mode 100644 index 0000000000..9590b4daaf --- /dev/null +++ b/docs/verification/vq11-decals-native-37010879478/static-console.json @@ -0,0 +1,1234 @@ +[ + { + "text": "TN_COLD_START:{\"segment\":\"process\",\"atMs\":0.000}", + "type": "log" + }, + { + "text": "=== ThreeNative Native Runtime ===", + "type": "log" + }, + { + "text": "Version: 0.3.4", + "type": "log" + }, + { + "text": "Script: .threenative/game.js", + "type": "log" + }, + { + "text": "Window: 960x540", + "type": "log" + }, + { + "text": "[Mystral] Initializing runtime...", + "type": "log" + }, + { + "text": "[Mystral] Window: 960x540", + "type": "log" + }, + { + "text": "[Mystral] Presentation cap: 60 fps", + "type": "log" + }, + { + "text": "[Window] Creating window: ThreeNative (960x540)", + "type": "log" + }, + { + "text": "[Window] SDL initialized", + "type": "log" + }, + { + "text": "[Window] Running in hidden mode (MYSTRAL_HEADLESS=1)", + "type": "log" + }, + { + "text": "[Window] Brand icon unavailable in embedded bundle; using compositor default", + "type": "log" + }, + { + "text": "[Window] Actual window size: 960x540", + "type": "log" + }, + { + "text": "[Window] Window created successfully", + "type": "log" + }, + { + "text": "[Mystral] Lifecycle watch installed; backgroundMode=pause", + "type": "log" + }, + { + "text": "[Mystral] Actual window size: 960x540", + "type": "log" + }, + { + "text": "[WebGPU] Initializing...", + "type": "log" + }, + { + "text": "[WebGPU] Instance created", + "type": "log" + }, + { + "text": "[Mystral] Using X11 display: 0xaae1a72e9610 window: 4194350", + "type": "log" + }, + { + "text": "[WebGPU] Creating surface for platform type 4 with display pointer", + "type": "log" + }, + { + "text": "[WebGPU] Creating X11 surface with display: 0xaae1a72e9610 window: 0x40002e", + "type": "log" + }, + { + "text": "[WebGPU] Surface created", + "type": "log" + }, + { + "text": "[WebGPU] Adapter acquired successfully", + "type": "log" + }, + { + "text": "[WebGPU] Adapter: llvmpipe (LLVM 20.1.2, 128 bits)", + "type": "log" + }, + { + "text": "[WebGPU] Vendor: llvmpipe", + "type": "log" + }, + { + "text": "[WebGPU] Backend: Vulkan", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe indirect-first-instance: yes", + "type": "log" + }, + { + "text": "[WebGPU] Requesting IndirectFirstInstance feature (supported)", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe texture-compression-bc: yes", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe texture-compression-etc2: no", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe texture-compression-astc: no", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe timestamp-query: yes", + "type": "log" + }, + { + "text": "[WebGPU] adapter feature probe rg11b10ufloat-renderable: yes", + "type": "log" + }, + { + "text": "[WebGPU] Device acquired successfully", + "type": "log" + }, + { + "text": "[WebGPU] TN_WEBGPU_FEATURES:{\"timestamp-query\":true,\"texture-compression-bc\":true,\"texture-compression-etc2\":false,\"texture-compression-astc\":false,\"indirect-first-instance\":true,\"rg11b10ufloat-renderable\":true}", + "type": "log" + }, + { + "text": "[WebGPU] Queue acquired", + "type": "log" + }, + { + "text": "[WebGPU] configureSurface called: 960x540", + "type": "log" + }, + { + "text": "[WebGPU] surface_=0xaae1a7351e20, device_=0xaae1a74719d0, adapter_=0xaae1a74553c0", + "type": "log" + }, + { + "text": "[WebGPU] Getting surface capabilities...", + "type": "log" + }, + { + "text": "[WebGPU] Got capabilities: formatCount=2", + "type": "log" + }, + { + "text": "[WebGPU] Available surface formats:", + "type": "log" + }, + { + "text": " [0] = 24", + "type": "log" + }, + { + "text": " [1] = 23", + "type": "log" + }, + { + "text": "[WebGPU] Using surface format: 23", + "type": "log" + }, + { + "text": "[WebGPU] Surface configured: 960x540", + "type": "log" + }, + { + "text": "[WebGPU] Present mode: fifo (vsync=true)", + "type": "log" + }, + { + "text": "[JS] Creating QuickJS engine (fallback)", + "type": "log" + }, + { + "text": "[QuickJS] Creating engine...", + "type": "log" + }, + { + "text": "[QuickJS] Engine created successfully", + "type": "log" + }, + { + "text": "[Mystral] Using JS engine: QuickJS", + "type": "log" + }, + { + "text": "[Mystral] Fetch API initialized (file://, http://, https://)", + "type": "log" + }, + { + "text": "[Mystral] Web Streams API initialized (ReadableStream/WritableStream/TransformStream)", + "type": "log" + }, + { + "text": "[WorkerRegistry] Initialized", + "type": "log" + }, + { + "text": "[Mystral] URL and native Worker facade initialized", + "type": "log" + }, + { + "text": "[DOM] Canvas element created with addEventListener, style, etc.", + "type": "log" + }, + { + "text": "[log] [Mystral] WebP format support: NO", + "type": "log" + }, + { + "text": "[Mystral] DOM event system initialized", + "type": "log" + }, + { + "text": "[Mystral] localStorage initialized: /home/runner/.local/share/mystral/storage/bounded-decals.json", + "type": "log" + }, + { + "text": "[Audio] Web Audio API bindings initialized", + "type": "log" + }, + { + "text": "TN_PIPELINE_CAPTURE:{\"version\":1,\"build\":{\"identity\":\"mystral-native@0.3.4\"},\"adapter\":{\"identity\":\"native:vulkan/llvmpipe/llvmpipe (LLVM 20.1.2, 128 bits)/Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)\",\"thermal\":\"unavailable\"},\"clock\":{\"source\":\"steady\",\"originMs\":0}}", + "type": "log" + }, + { + "text": "TN_PIPELINE_CACHE:{\"version\":1,\"phase\":\"device\",\"mode\":\"unsupported\",\"featureGranted\":false,\"renderAttached\":0,\"computeAttached\":0,\"emptyBytes\":0,\"serializedBytes\":0,\"load\":\"unavailable\",\"loadedBytes\":0,\"loadMs\":0,\"store\":\"not-attempted\",\"storedBytes\":0,\"storeMs\":0,\"snapshotMs\":0,\"reason\":\"this build's WebGPU backend declares no pipeline cache API\"}", + "type": "log" + }, + { + "text": "TN_SURFACE_FORMAT:{\"native\":\"bgra8unorm\",\"render\":\"bgra8unorm\",\"bridge\":false,\"present\":\"fifo\"}", + "type": "log" + }, + { + "text": "[MystralRT] No hardware RT available, using stub backend", + "type": "log" + }, + { + "text": "[MystralRT] Bindings initialized (backend: none)", + "type": "log" + }, + { + "text": "[EventLoop] libuv 1.51.0 initialized", + "type": "log" + }, + { + "text": "[AsyncHttp] Initialized with curl_multi + libuv", + "type": "log" + }, + { + "text": "[AsyncFile] Initialized with libuv thread pool", + "type": "log" + }, + { + "text": "[FileWatcher] Initialized with libuv fs_event", + "type": "log" + }, + { + "text": "[Mystral] Runtime initialized", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"runtime_created\",\"atMs\":580.324}", + "type": "log" + }, + { + "text": "[Mystral] Evaluating script: threenative-playtest-mailbox.js (246 bytes)", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"compile_begin\",\"atMs\":580.344}", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"compile_complete\",\"atMs\":580.397}", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"execute_begin\",\"atMs\":580.407}", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"execute_complete\",\"atMs\":580.412}", + "type": "log" + }, + { + "text": "[Mystral] Desktop playtest mailbox configured", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"game_eval_begin\",\"atMs\":580.415}", + "type": "log" + }, + { + "text": "[Mystral] Loading script: .threenative/game.js", + "type": "log" + }, + { + "text": "[Fetch] Read 212 bytes from bundle: assets.manifest.json", + "type": "log" + }, + { + "text": "[Mystral] Starting main loop...", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cf01b5890b1b6101\",\"bytes\":28,\"requestId\":\"ready\",\"requestMethod\":\"\",\"requestOrder\":-1,\"pump\":{\"observed\":true,\"pumpCount\":1,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":853.834,\"maxGapMs\":0.000,\"maxGapAtMs\":-1.000,\"trailingGapMs\":0.988,\"atMs\":854.823,\"longGaps\":[],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "[Fetch] Read 4712 bytes from bundle: receiver.glb", + "type": "log" + }, + { + "text": "[log] TN_NATIVE_SMOKE_READY:webgpu", + "type": "log" + }, + { + "text": "[log] TN_NATIVE_SMOKE_FIRST_FRAME", + "type": "log" + }, + { + "text": "[log] TN_RENDER_PROJECTION:{\"projecting\":false,\"reasonCode\":\"belowMeshFloor\",\"reason\":\"fewer than 200 batchable meshes; the mirror would cost more than it saves\",\"sourceRenderables\":0,\"resultDrawCandidates\":0,\"batches\":0,\"instancedBatches\":0,\"materialBatches\":0,\"skinnedBatches\":0,\"projectedObjects\":0,\"exactObjects\":0,\"exact\":{},\"materialChecks\":\"spread\",\"materialChecksPerFrame\":512,\"materialCheckStaleFrames\":0}", + "type": "log" + }, + { + "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":1,\"pipelineIdentity\":\"native-render-1\",\"programIdentity\":\"00fc0a8ad03630cc/458120a84b226496\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":0.00027999999999999998,\"settledMs\":6.401694,\"queueMs\":0,\"serviceMs\":6.4014139999999999,\"wallMs\":6.4014139999999999,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"00fc0a8ad03630cc\",\"bytes\":1299},\"fragment\":{\"hash\":\"458120a84b226496\",\"bytes\":1595},\"cache\":\"unsupported\",\"label\":\"renderPipeline_outputColorTransform_18\"}", + "type": "log" + }, + { + "text": "[warn] THREE.WebGPURenderer: Timestamp tracking is disabled.", + "type": "warning" + }, + { + "text": "TN_SLOW_FRAME_OPS:{\"replayMs\":421.24257799999998,\"ops\":18,\"top\":[{\"op\":\"submit\",\"n\":2,\"ms\":421.13237700000002},{\"op\":\"render.end\",\"n\":2,\"ms\":0.075402999999999998},{\"op\":\"beginRenderPass\",\"n\":2,\"ms\":0.016678999999999999},{\"op\":\"createCommandEncoder\",\"n\":2,\"ms\":0.008175},{\"op\":\"finish\",\"n\":2,\"ms\":0.0021359999999999999},{\"op\":\"render.setBindGroup\",\"n\":2,\"ms\":0.001872}]}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":1}", + "type": "log" + }, + { + "text": "TN_PIPELINE_FIRST_PRESENT:{\"version\":1,\"boundaryMs\":540.37911299999996}", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"first_frame\",\"atMs\":1411.164}", + "type": "log" + }, + { + "text": "TN_PUMP_SILENCE:{\"observed\":true,\"pumpCount\":1,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":853.834,\"maxGapMs\":0.000,\"maxGapAtMs\":-1.000,\"trailingGapMs\":557.333,\"longGaps\":[],\"droppedLongGaps\":0}", + "type": "log" + }, + { + "text": "TN_STALL_SEGMENTS:{\"toFirstFrameMs\":1411.178,\"frameBeganAtMs\":857.006,\"gapMs\":554.172,\"segments\":{\"pipelineCompile\":{\"ms\":6.468,\"calls\":1},\"shaderCompile\":{\"ms\":0.445,\"calls\":2},\"textureUpload\":{\"ms\":0.183,\"calls\":4},\"bufferUpload\":{\"ms\":0.183,\"calls\":3},\"queueSubmit\":{\"ms\":0.000,\"calls\":0}},\"attributedMs\":7.279,\"residualMs\":546.893,\"attributedShare\":0.0131}", + "type": "log" + }, + { + "text": "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":1,\"requested\":1,\"emitted\":1,\"outstanding\":0}", + "type": "log" + }, + { + "text": "TN_SLOW_END_FRAME:{\"totalMs\":533.67494299999998,\"drainMs\":0.029454000000000001,\"replayMs\":421.29363799999999,\"uiMs\":0.00078399999999999997,\"presentMs\":112.30627699999999,\"pollMs\":0.043206000000000001,\"gpuDrainMs\":0,\"otherMs\":0.0015839999999999999}", + "type": "log" + }, + { + "text": "TN_SLOW_PHASE:{\"phase\":\"endDawnFrame\",\"ms\":533.68100000000004,\"atMs\":1411.2539899999999}", + "type": "log" + }, + { + "text": "TN_SLOW_PHASE:{\"phase\":\"pollEvents\",\"ms\":557.68200000000002,\"atMs\":1411.518525}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"d51d0ecd79920230\",\"bytes\":596,\"requestId\":\"1\",\"requestMethod\":\"describe\",\"requestOrder\":1,\"pump\":{\"observed\":true,\"pumpCount\":2,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":1411.531,\"maxGapMs\":557.697,\"maxGapAtMs\":853.834,\"trailingGapMs\":1122.184,\"atMs\":2533.715,\"longGaps\":[557.697],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":320,\"released\":64,\"geometryBytes\":259896,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":false,\"renderTriangles\":128,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":320,\"released\":64,\"geometryBytes\":259896,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":false,\"renderTriangles\":128,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":2,\"pipelineIdentity\":\"native-render-2\",\"programIdentity\":\"2eb07d0bdc0da0c6/e792f11cd4d28ee0\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1827.715976,\"settledMs\":1831.635959,\"queueMs\":0,\"serviceMs\":3.919983000000002,\"wallMs\":3.919983000000002,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"2eb07d0bdc0da0c6\",\"bytes\":1953},\"fragment\":{\"hash\":\"e792f11cd4d28ee0\",\"bytes\":14203},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshStandardMaterial_19\"}", + "type": "log" + }, + { + "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":3,\"pipelineIdentity\":\"native-render-3\",\"programIdentity\":\"2eb07d0bdc0da0c6/e792f11cd4d28ee0\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1869.3319839999999,\"settledMs\":1872.7200909999999,\"queueMs\":0,\"serviceMs\":3.3881069999999909,\"wallMs\":3.3881069999999909,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"2eb07d0bdc0da0c6\",\"bytes\":1953},\"fragment\":{\"hash\":\"e792f11cd4d28ee0\",\"bytes\":14203},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshStandardMaterial_21\"}", + "type": "log" + }, + { + "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":4,\"pipelineIdentity\":\"native-render-4\",\"programIdentity\":\"effe698ea684daf6/957687f9bd1af7aa\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1884.9268770000001,\"settledMs\":1886.2407189999999,\"queueMs\":0,\"serviceMs\":1.313841999999795,\"wallMs\":1.313841999999795,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"effe698ea684daf6\",\"bytes\":1482},\"fragment\":{\"hash\":\"957687f9bd1af7aa\",\"bytes\":3294},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshBasicMaterial_89\"}", + "type": "log" + }, + { + "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":5,\"pipelineIdentity\":\"native-render-5\",\"programIdentity\":\"effe698ea684daf6/957687f9bd1af7aa\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1894.9154160000001,\"settledMs\":1896.334564,\"queueMs\":0,\"serviceMs\":1.4191479999999501,\"wallMs\":1.4191479999999501,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"effe698ea684daf6\",\"bytes\":1482},\"fragment\":{\"hash\":\"957687f9bd1af7aa\",\"bytes\":3294},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshBasicMaterial_89\"}", + "type": "log" + }, + { + "text": "TN_SLOW_PHASE:{\"phase\":\"animationFrames\",\"ms\":1754.6659999999999,\"atMs\":3166.772508}", + "type": "log" + }, + { + "text": "TN_SLOW_FRAME_OPS:{\"replayMs\":577.880135,\"ops\":3889,\"top\":[{\"op\":\"submit\",\"n\":2,\"ms\":574.93500800000004},{\"op\":\"render.end\",\"n\":2,\"ms\":1.5463789999999999},{\"op\":\"render.setBindGroup\",\"n\":519,\"ms\":0.363147},{\"op\":\"writeBuffer\",\"n\":1552,\"ms\":0.26949899999999999},{\"op\":\"writeTexture\",\"n\":2,\"ms\":0.23605699999999999},{\"op\":\"render.setVertexBuffer\",\"n\":517,\"ms\":0.16612399999999999}]}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":2}", + "type": "log" + }, + { + "text": "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":2,\"requested\":5,\"emitted\":5,\"outstanding\":0}", + "type": "log" + }, + { + "text": "TN_SLOW_END_FRAME:{\"totalMs\":583.17345999999998,\"drainMs\":0.084194000000000005,\"replayMs\":577.93781100000001,\"uiMs\":0.00084800000000000001,\"presentMs\":5.1016579999999996,\"pollMs\":0.047364999999999997,\"gpuDrainMs\":0,\"otherMs\":0.0015839999999999999}", + "type": "log" + }, + { + "text": "TN_SLOW_PHASE:{\"phase\":\"endDawnFrame\",\"ms\":583.18899999999996,\"atMs\":3749.9956529999999}", + "type": "log" + }, + { + "text": "TN_SLOW_PHASE:{\"phase\":\"pollEvents\",\"ms\":2343.7020000000002,\"atMs\":3755.2437260000002}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"19bbb4273bc7b242\",\"bytes\":168,\"requestId\":\"2\",\"requestMethod\":\"ready\",\"requestOrder\":2,\"pump\":{\"observed\":true,\"pumpCount\":3,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3755.271,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.611,\"atMs\":3759.882,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "[log] TN_FRAME_HITCH:{\"gapMs\":2343.24,\"uptimeMs\":3823.79,\"wallClock\":1790946973685}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":3}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"78b95576888a52b7\",\"bytes\":72,\"requestId\":\"3\",\"requestMethod\":\"advance\",\"requestOrder\":3,\"pump\":{\"observed\":true,\"pumpCount\":4,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3858.159,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":21.716,\"atMs\":3879.875,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":4}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9c7a1dd6c70ad0e0\",\"bytes\":168,\"requestId\":\"4\",\"requestMethod\":\"ready\",\"requestOrder\":4,\"pump\":{\"observed\":true,\"pumpCount\":5,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3965.014,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.618,\"atMs\":3966.632,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":5}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"af6e700c4e3f1fca\",\"bytes\":71,\"requestId\":\"5\",\"requestMethod\":\"advance\",\"requestOrder\":5,\"pump\":{\"observed\":true,\"pumpCount\":6,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4051.185,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.065,\"atMs\":4055.251,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":6}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"adcce64027efbb36\",\"bytes\":168,\"requestId\":\"6\",\"requestMethod\":\"ready\",\"requestOrder\":6,\"pump\":{\"observed\":true,\"pumpCount\":7,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4140.260,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.689,\"atMs\":4141.950,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":7}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8f2dad1d0d1a33ab\",\"bytes\":71,\"requestId\":\"7\",\"requestMethod\":\"advance\",\"requestOrder\":7,\"pump\":{\"observed\":true,\"pumpCount\":8,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4238.374,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.149,\"atMs\":4241.524,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":8}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"07870d7be2b04b74\",\"bytes\":168,\"requestId\":\"8\",\"requestMethod\":\"ready\",\"requestOrder\":8,\"pump\":{\"observed\":true,\"pumpCount\":9,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4327.281,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.556,\"atMs\":4328.837,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":9}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"89debabef68245d0\",\"bytes\":71,\"requestId\":\"9\",\"requestMethod\":\"advance\",\"requestOrder\":9,\"pump\":{\"observed\":true,\"pumpCount\":10,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4414.522,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.995,\"atMs\":4418.518,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":10}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"2aad096ed3467ddf\",\"bytes\":169,\"requestId\":\"10\",\"requestMethod\":\"ready\",\"requestOrder\":10,\"pump\":{\"observed\":true,\"pumpCount\":11,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4504.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.653,\"atMs\":4505.915,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":11}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cdf6d448b2ecb5ae\",\"bytes\":72,\"requestId\":\"11\",\"requestMethod\":\"advance\",\"requestOrder\":11,\"pump\":{\"observed\":true,\"pumpCount\":12,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4591.308,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.871,\"atMs\":4594.180,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":12}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"58699643da11af6d\",\"bytes\":169,\"requestId\":\"12\",\"requestMethod\":\"ready\",\"requestOrder\":12,\"pump\":{\"observed\":true,\"pumpCount\":13,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4680.403,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.745,\"atMs\":4682.148,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":13}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"dd22e9348bd1a591\",\"bytes\":72,\"requestId\":\"13\",\"requestMethod\":\"advance\",\"requestOrder\":13,\"pump\":{\"observed\":true,\"pumpCount\":14,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4769.194,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.191,\"atMs\":4773.384,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":14}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"7ce7cd2a47a1920b\",\"bytes\":169,\"requestId\":\"14\",\"requestMethod\":\"ready\",\"requestOrder\":14,\"pump\":{\"observed\":true,\"pumpCount\":15,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4859.995,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.748,\"atMs\":4861.744,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":15}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c7dbbaecf9618974\",\"bytes\":72,\"requestId\":\"15\",\"requestMethod\":\"advance\",\"requestOrder\":15,\"pump\":{\"observed\":true,\"pumpCount\":16,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4949.326,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.267,\"atMs\":4952.594,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":16}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9655fa46e0d42b79\",\"bytes\":169,\"requestId\":\"16\",\"requestMethod\":\"ready\",\"requestOrder\":16,\"pump\":{\"observed\":true,\"pumpCount\":17,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5038.885,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.641,\"atMs\":5040.526,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":17}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cad80a66f9a59b2b\",\"bytes\":72,\"requestId\":\"17\",\"requestMethod\":\"advance\",\"requestOrder\":17,\"pump\":{\"observed\":true,\"pumpCount\":18,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5125.853,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.031,\"atMs\":5129.885,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":18}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"b4268cb69df16ee7\",\"bytes\":169,\"requestId\":\"18\",\"requestMethod\":\"ready\",\"requestOrder\":18,\"pump\":{\"observed\":true,\"pumpCount\":19,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5217.293,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.795,\"atMs\":5219.087,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":19}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1dd915307902036a\",\"bytes\":72,\"requestId\":\"19\",\"requestMethod\":\"advance\",\"requestOrder\":19,\"pump\":{\"observed\":true,\"pumpCount\":20,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5306.854,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.911,\"atMs\":5309.765,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":20}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"216b29bcb8e05b1c\",\"bytes\":169,\"requestId\":\"20\",\"requestMethod\":\"ready\",\"requestOrder\":20,\"pump\":{\"observed\":true,\"pumpCount\":21,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5395.859,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.656,\"atMs\":5397.516,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":21}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"279fed16396dcd86\",\"bytes\":72,\"requestId\":\"21\",\"requestMethod\":\"advance\",\"requestOrder\":21,\"pump\":{\"observed\":true,\"pumpCount\":22,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5484.510,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.170,\"atMs\":5488.680,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":22}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ce289c8a103aa272\",\"bytes\":169,\"requestId\":\"22\",\"requestMethod\":\"ready\",\"requestOrder\":22,\"pump\":{\"observed\":true,\"pumpCount\":23,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5573.880,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.578,\"atMs\":5575.458,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":23}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"72275fb20f5bcf70\",\"bytes\":72,\"requestId\":\"23\",\"requestMethod\":\"advance\",\"requestOrder\":23,\"pump\":{\"observed\":true,\"pumpCount\":24,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5664.399,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.315,\"atMs\":5667.714,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":24}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"93e3cef21e462b50\",\"bytes\":169,\"requestId\":\"24\",\"requestMethod\":\"ready\",\"requestOrder\":24,\"pump\":{\"observed\":true,\"pumpCount\":25,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5754.409,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.773,\"atMs\":5756.182,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":25}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"279ca0192310c4c7\",\"bytes\":72,\"requestId\":\"25\",\"requestMethod\":\"advance\",\"requestOrder\":25,\"pump\":{\"observed\":true,\"pumpCount\":26,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5842.468,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.228,\"atMs\":5846.696,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":26}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8d4bb9970b4ae526\",\"bytes\":169,\"requestId\":\"26\",\"requestMethod\":\"ready\",\"requestOrder\":26,\"pump\":{\"observed\":true,\"pumpCount\":27,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5932.559,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.921,\"atMs\":5934.481,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":27}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"198f6d5cdea59b3a\",\"bytes\":72,\"requestId\":\"27\",\"requestMethod\":\"advance\",\"requestOrder\":27,\"pump\":{\"observed\":true,\"pumpCount\":28,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6021.134,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.081,\"atMs\":6025.214,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":28}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f25763a78bc64424\",\"bytes\":169,\"requestId\":\"28\",\"requestMethod\":\"ready\",\"requestOrder\":28,\"pump\":{\"observed\":true,\"pumpCount\":29,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6110.901,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.614,\"atMs\":6112.515,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":29}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c83dac86f371d28d\",\"bytes\":72,\"requestId\":\"29\",\"requestMethod\":\"advance\",\"requestOrder\":29,\"pump\":{\"observed\":true,\"pumpCount\":30,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6199.084,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.100,\"atMs\":6203.184,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":30}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"47b9e49bdf111499\",\"bytes\":169,\"requestId\":\"30\",\"requestMethod\":\"ready\",\"requestOrder\":30,\"pump\":{\"observed\":true,\"pumpCount\":31,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6291.960,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.703,\"atMs\":6293.663,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":31}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"823675271fc907f5\",\"bytes\":72,\"requestId\":\"31\",\"requestMethod\":\"advance\",\"requestOrder\":31,\"pump\":{\"observed\":true,\"pumpCount\":32,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6379.675,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.192,\"atMs\":6382.866,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":32}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"31c93cedd3583d2b\",\"bytes\":169,\"requestId\":\"32\",\"requestMethod\":\"ready\",\"requestOrder\":32,\"pump\":{\"observed\":true,\"pumpCount\":33,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6466.923,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":6468.543,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":33}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"640a7c0fe169f9f6\",\"bytes\":72,\"requestId\":\"33\",\"requestMethod\":\"advance\",\"requestOrder\":33,\"pump\":{\"observed\":true,\"pumpCount\":34,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6553.725,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.086,\"atMs\":6557.811,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":34}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"dd3f5c7ad916b98d\",\"bytes\":169,\"requestId\":\"34\",\"requestMethod\":\"ready\",\"requestOrder\":34,\"pump\":{\"observed\":true,\"pumpCount\":35,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6642.950,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.688,\"atMs\":6644.638,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":35}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"93e707672ccc448f\",\"bytes\":72,\"requestId\":\"35\",\"requestMethod\":\"advance\",\"requestOrder\":35,\"pump\":{\"observed\":true,\"pumpCount\":36,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6729.996,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.838,\"atMs\":6732.834,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":36}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"0b65d747bac6607f\",\"bytes\":169,\"requestId\":\"36\",\"requestMethod\":\"ready\",\"requestOrder\":36,\"pump\":{\"observed\":true,\"pumpCount\":37,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6817.951,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.770,\"atMs\":6819.720,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":37}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f8f056c81673203c\",\"bytes\":72,\"requestId\":\"37\",\"requestMethod\":\"advance\",\"requestOrder\":37,\"pump\":{\"observed\":true,\"pumpCount\":38,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6904.066,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.081,\"atMs\":6908.147,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":38}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ef2e395a3bdfb4a1\",\"bytes\":169,\"requestId\":\"38\",\"requestMethod\":\"ready\",\"requestOrder\":38,\"pump\":{\"observed\":true,\"pumpCount\":39,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6993.401,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.537,\"atMs\":6994.937,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":39}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"e526ad18bf498959\",\"bytes\":72,\"requestId\":\"39\",\"requestMethod\":\"advance\",\"requestOrder\":39,\"pump\":{\"observed\":true,\"pumpCount\":40,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7079.625,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.197,\"atMs\":7082.822,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":40}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"81f6430656d4a536\",\"bytes\":169,\"requestId\":\"40\",\"requestMethod\":\"ready\",\"requestOrder\":40,\"pump\":{\"observed\":true,\"pumpCount\":41,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7165.846,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.456,\"atMs\":7167.301,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":41}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"117b49d130b50c15\",\"bytes\":72,\"requestId\":\"41\",\"requestMethod\":\"advance\",\"requestOrder\":41,\"pump\":{\"observed\":true,\"pumpCount\":42,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7251.308,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.776,\"atMs\":7255.084,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":42}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"70a37a9cf5efbae0\",\"bytes\":169,\"requestId\":\"42\",\"requestMethod\":\"ready\",\"requestOrder\":42,\"pump\":{\"observed\":true,\"pumpCount\":43,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7348.435,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.657,\"atMs\":7350.092,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":43}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"e31f767984275c39\",\"bytes\":72,\"requestId\":\"43\",\"requestMethod\":\"advance\",\"requestOrder\":43,\"pump\":{\"observed\":true,\"pumpCount\":44,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7433.785,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.883,\"atMs\":7438.669,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":44}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ede510ed6aac9c42\",\"bytes\":169,\"requestId\":\"44\",\"requestMethod\":\"ready\",\"requestOrder\":44,\"pump\":{\"observed\":true,\"pumpCount\":45,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7522.708,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.542,\"atMs\":7524.250,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":45}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"477caedf27ed1792\",\"bytes\":72,\"requestId\":\"45\",\"requestMethod\":\"advance\",\"requestOrder\":45,\"pump\":{\"observed\":true,\"pumpCount\":46,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7607.341,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.009,\"atMs\":7611.349,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":46}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"347d2fcdb8d947ac\",\"bytes\":169,\"requestId\":\"46\",\"requestMethod\":\"ready\",\"requestOrder\":46,\"pump\":{\"observed\":true,\"pumpCount\":47,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7695.968,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.564,\"atMs\":7697.533,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":47}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c458ea380df770a3\",\"bytes\":72,\"requestId\":\"47\",\"requestMethod\":\"advance\",\"requestOrder\":47,\"pump\":{\"observed\":true,\"pumpCount\":48,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7793.298,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.162,\"atMs\":7796.460,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":48}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"3e262161873c549e\",\"bytes\":169,\"requestId\":\"48\",\"requestMethod\":\"ready\",\"requestOrder\":48,\"pump\":{\"observed\":true,\"pumpCount\":49,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7881.718,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.601,\"atMs\":7883.319,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":49}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"fdb55682e517f658\",\"bytes\":72,\"requestId\":\"49\",\"requestMethod\":\"advance\",\"requestOrder\":49,\"pump\":{\"observed\":true,\"pumpCount\":50,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7968.599,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.097,\"atMs\":7972.695,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":50}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1d9b12d3ab425c3b\",\"bytes\":169,\"requestId\":\"50\",\"requestMethod\":\"ready\",\"requestOrder\":50,\"pump\":{\"observed\":true,\"pumpCount\":51,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8058.345,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.634,\"atMs\":8059.980,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":51}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"7782b2b6cb71c8dc\",\"bytes\":72,\"requestId\":\"51\",\"requestMethod\":\"advance\",\"requestOrder\":51,\"pump\":{\"observed\":true,\"pumpCount\":52,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8150.988,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.655,\"atMs\":8155.643,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":52}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"b6a103b889890fa9\",\"bytes\":169,\"requestId\":\"52\",\"requestMethod\":\"ready\",\"requestOrder\":52,\"pump\":{\"observed\":true,\"pumpCount\":53,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8247.187,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.347,\"atMs\":8249.533,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":53}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9d71a81816380853\",\"bytes\":72,\"requestId\":\"53\",\"requestMethod\":\"advance\",\"requestOrder\":53,\"pump\":{\"observed\":true,\"pumpCount\":54,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8342.500,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.664,\"atMs\":8347.164,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":54}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6f98cda10b366c4f\",\"bytes\":169,\"requestId\":\"54\",\"requestMethod\":\"ready\",\"requestOrder\":54,\"pump\":{\"observed\":true,\"pumpCount\":55,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8438.992,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.736,\"atMs\":8440.728,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":55}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"337ebeaa86ffbade\",\"bytes\":72,\"requestId\":\"55\",\"requestMethod\":\"advance\",\"requestOrder\":55,\"pump\":{\"observed\":true,\"pumpCount\":56,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8526.854,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.155,\"atMs\":8530.009,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":56}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"16cf2f9ca6fd47dd\",\"bytes\":169,\"requestId\":\"56\",\"requestMethod\":\"ready\",\"requestOrder\":56,\"pump\":{\"observed\":true,\"pumpCount\":57,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8613.976,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.586,\"atMs\":8615.562,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":57}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"da4470c0057f3901\",\"bytes\":72,\"requestId\":\"57\",\"requestMethod\":\"advance\",\"requestOrder\":57,\"pump\":{\"observed\":true,\"pumpCount\":58,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8699.938,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.049,\"atMs\":8703.987,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":58}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"271183c751d68ce3\",\"bytes\":169,\"requestId\":\"58\",\"requestMethod\":\"ready\",\"requestOrder\":58,\"pump\":{\"observed\":true,\"pumpCount\":59,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8788.066,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.644,\"atMs\":8789.710,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":59}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"de33de02ae45f990\",\"bytes\":72,\"requestId\":\"59\",\"requestMethod\":\"advance\",\"requestOrder\":59,\"pump\":{\"observed\":true,\"pumpCount\":60,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8874.535,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.077,\"atMs\":8878.612,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":60}", + "type": "log" + }, + { + "text": "TN_PRESENTS_TICK:{\"frames\":60,\"presents\":60,\"textureMB\":29,\"textures\":6,\"bufferMB\":0,\"capHz\":60}", + "type": "log" + }, + { + "text": "TN_GPU_TEXTURES:{\"totalMB\":29,\"count\":6,\"buckets\":[{\"k\":\"960x540 rgba16float msaa4\",\"n\":1,\"mb\":15},{\"k\":\"960x540 depth24plus msaa4\",\"n\":1,\"mb\":7},{\"k\":\"960x540 rgba16float\",\"n\":1,\"mb\":3},{\"k\":\"960x540 depth24plus\",\"n\":1,\"mb\":1},{\"k\":\"64x64 rgba8unorm\",\"n\":1,\"mb\":0},{\"k\":\"16x16 rg16float\",\"n\":1,\"mb\":0}],\"bucketsTotal\":6}", + "type": "log" + }, + { + "text": "TN_GPU_BUFFERS:{\"totalMB\":0,\"count\":1296,\"buckets\":[{\"k\":\"vertex|copysrc|copydst\",\"n\":517,\"mb\":0},{\"k\":\"uniform|copydst\",\"n\":519,\"mb\":0},{\"k\":\"index|copysrc|copydst\",\"n\":258,\"mb\":0},{\"k\":\"copydst|mapread\",\"n\":1,\"mb\":0},{\"k\":\"copysrc\",\"n\":1,\"mb\":0}]}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"18b2ef1e76e5bce8\",\"bytes\":169,\"requestId\":\"60\",\"requestMethod\":\"ready\",\"requestOrder\":60,\"pump\":{\"observed\":true,\"pumpCount\":61,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8965.798,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.626,\"atMs\":8967.424,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":61}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"09d5f00b59a5a2e0\",\"bytes\":72,\"requestId\":\"61\",\"requestMethod\":\"advance\",\"requestOrder\":61,\"pump\":{\"observed\":true,\"pumpCount\":62,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9052.530,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.171,\"atMs\":9056.701,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":62}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c2911562e037c03e\",\"bytes\":169,\"requestId\":\"62\",\"requestMethod\":\"ready\",\"requestOrder\":62,\"pump\":{\"observed\":true,\"pumpCount\":63,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9143.789,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.601,\"atMs\":9145.390,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":63}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"0e85742f46a37379\",\"bytes\":73,\"requestId\":\"63\",\"requestMethod\":\"advance\",\"requestOrder\":63,\"pump\":{\"observed\":true,\"pumpCount\":64,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9233.151,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.170,\"atMs\":9236.321,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":64}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9baadaa99db7dd94\",\"bytes\":169,\"requestId\":\"64\",\"requestMethod\":\"ready\",\"requestOrder\":64,\"pump\":{\"observed\":true,\"pumpCount\":65,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9322.870,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.543,\"atMs\":9324.414,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":65}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9966ef2d4e6708b2\",\"bytes\":73,\"requestId\":\"65\",\"requestMethod\":\"advance\",\"requestOrder\":65,\"pump\":{\"observed\":true,\"pumpCount\":66,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9409.915,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.106,\"atMs\":9414.020,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":66}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"69d6de25bf7a182a\",\"bytes\":169,\"requestId\":\"66\",\"requestMethod\":\"ready\",\"requestOrder\":66,\"pump\":{\"observed\":true,\"pumpCount\":67,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9500.117,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.627,\"atMs\":9501.744,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":67}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f6598f5c20cca33b\",\"bytes\":73,\"requestId\":\"67\",\"requestMethod\":\"advance\",\"requestOrder\":67,\"pump\":{\"observed\":true,\"pumpCount\":68,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9590.236,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.862,\"atMs\":9593.097,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":68}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"bf9d77b1364a0e80\",\"bytes\":169,\"requestId\":\"68\",\"requestMethod\":\"ready\",\"requestOrder\":68,\"pump\":{\"observed\":true,\"pumpCount\":69,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9679.332,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.679,\"atMs\":9681.011,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":69}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"af051c6ddb763330\",\"bytes\":73,\"requestId\":\"69\",\"requestMethod\":\"advance\",\"requestOrder\":69,\"pump\":{\"observed\":true,\"pumpCount\":70,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9765.724,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.146,\"atMs\":9769.869,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":70}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"3cdb38250d95b1e5\",\"bytes\":169,\"requestId\":\"70\",\"requestMethod\":\"ready\",\"requestOrder\":70,\"pump\":{\"observed\":true,\"pumpCount\":71,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9854.890,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.591,\"atMs\":9856.481,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":71}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c7c209b160e92ec6\",\"bytes\":73,\"requestId\":\"71\",\"requestMethod\":\"advance\",\"requestOrder\":71,\"pump\":{\"observed\":true,\"pumpCount\":72,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9941.189,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.153,\"atMs\":9944.342,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":72}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"520e454b3c904f77\",\"bytes\":169,\"requestId\":\"72\",\"requestMethod\":\"ready\",\"requestOrder\":72,\"pump\":{\"observed\":true,\"pumpCount\":73,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10028.046,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":10029.666,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":73}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"52b64084ebb918ed\",\"bytes\":73,\"requestId\":\"73\",\"requestMethod\":\"advance\",\"requestOrder\":73,\"pump\":{\"observed\":true,\"pumpCount\":74,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10114.864,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.924,\"atMs\":10118.787,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":74}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6380c227074b8691\",\"bytes\":169,\"requestId\":\"74\",\"requestMethod\":\"ready\",\"requestOrder\":74,\"pump\":{\"observed\":true,\"pumpCount\":75,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10201.862,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.617,\"atMs\":10203.479,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":75}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"be3dc3260f36fe3c\",\"bytes\":73,\"requestId\":\"75\",\"requestMethod\":\"advance\",\"requestOrder\":75,\"pump\":{\"observed\":true,\"pumpCount\":76,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10288.000,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.102,\"atMs\":10292.102,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":76}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1f677084102d9b83\",\"bytes\":169,\"requestId\":\"76\",\"requestMethod\":\"ready\",\"requestOrder\":76,\"pump\":{\"observed\":true,\"pumpCount\":77,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10378.999,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.538,\"atMs\":10380.537,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":77}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c4d75c1cfeb82787\",\"bytes\":73,\"requestId\":\"77\",\"requestMethod\":\"advance\",\"requestOrder\":77,\"pump\":{\"observed\":true,\"pumpCount\":78,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10464.486,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.966,\"atMs\":10468.452,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":78}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"94350233bec333fd\",\"bytes\":169,\"requestId\":\"78\",\"requestMethod\":\"ready\",\"requestOrder\":78,\"pump\":{\"observed\":true,\"pumpCount\":79,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10551.853,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.557,\"atMs\":10553.410,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":79}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"d07ed5024424a452\",\"bytes\":73,\"requestId\":\"79\",\"requestMethod\":\"advance\",\"requestOrder\":79,\"pump\":{\"observed\":true,\"pumpCount\":80,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10636.856,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.068,\"atMs\":10639.924,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":80}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"89741c4f3d5d2262\",\"bytes\":169,\"requestId\":\"80\",\"requestMethod\":\"ready\",\"requestOrder\":80,\"pump\":{\"observed\":true,\"pumpCount\":81,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10724.017,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.691,\"atMs\":10725.708,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":81}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"301571c6a38e0434\",\"bytes\":73,\"requestId\":\"81\",\"requestMethod\":\"advance\",\"requestOrder\":81,\"pump\":{\"observed\":true,\"pumpCount\":82,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10809.833,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.308,\"atMs\":10814.141,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":82}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"90fedf35eedd2fcc\",\"bytes\":169,\"requestId\":\"82\",\"requestMethod\":\"ready\",\"requestOrder\":82,\"pump\":{\"observed\":true,\"pumpCount\":83,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10906.953,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.531,\"atMs\":10908.483,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":83}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"036f4c51138a9c58\",\"bytes\":73,\"requestId\":\"83\",\"requestMethod\":\"advance\",\"requestOrder\":83,\"pump\":{\"observed\":true,\"pumpCount\":84,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10991.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.801,\"atMs\":10995.064,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":84}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"14a16ced8cf51356\",\"bytes\":169,\"requestId\":\"84\",\"requestMethod\":\"ready\",\"requestOrder\":84,\"pump\":{\"observed\":true,\"pumpCount\":85,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11077.669,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.637,\"atMs\":11079.306,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":85}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"a3ee970b2a64450f\",\"bytes\":73,\"requestId\":\"85\",\"requestMethod\":\"advance\",\"requestOrder\":85,\"pump\":{\"observed\":true,\"pumpCount\":86,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11162.818,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.012,\"atMs\":11166.830,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":86}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"64b8e924b0b5ab80\",\"bytes\":169,\"requestId\":\"86\",\"requestMethod\":\"ready\",\"requestOrder\":86,\"pump\":{\"observed\":true,\"pumpCount\":87,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11253.412,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":11255.032,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":87}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"a323689e1d42692a\",\"bytes\":73,\"requestId\":\"87\",\"requestMethod\":\"advance\",\"requestOrder\":87,\"pump\":{\"observed\":true,\"pumpCount\":88,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11352.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.304,\"atMs\":11355.567,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":88}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"795a5d9268e0ad67\",\"bytes\":249,\"requestId\":\"88\",\"requestMethod\":\"ready\",\"requestOrder\":88,\"pump\":{\"observed\":true,\"pumpCount\":89,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11439.123,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.671,\"atMs\":11440.794,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":89}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9224ca862071dfa0\",\"bytes\":6165,\"requestId\":\"89\",\"requestMethod\":\"sample\",\"requestOrder\":89,\"pump\":{\"observed\":true,\"pumpCount\":90,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11525.048,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":23.764,\"atMs\":11548.812,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":90}", + "type": "log" + }, + { + "text": "TN_COLD_START:{\"segment\":\"first_playable\",\"atMs\":11633.833}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6878ee373e5f987e\",\"bytes\":74,\"requestId\":\"90\",\"requestMethod\":\"advance\",\"requestOrder\":90,\"pump\":{\"observed\":true,\"pumpCount\":91,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11633.919,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":132.181,\"atMs\":11766.100,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":91}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8bd550e42e078f4d\",\"bytes\":6165,\"requestId\":\"91\",\"requestMethod\":\"sample\",\"requestOrder\":91,\"pump\":{\"observed\":true,\"pumpCount\":92,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11856.224,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.205,\"atMs\":11870.429,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":92}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8e69a825d07469a4\",\"bytes\":6165,\"requestId\":\"92\",\"requestMethod\":\"sample\",\"requestOrder\":92,\"pump\":{\"observed\":true,\"pumpCount\":93,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11961.372,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.562,\"atMs\":11975.935,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":93}", + "type": "log" + }, + { + "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9534902c1c41adeb\",\"bytes\":6165,\"requestId\":\"93\",\"requestMethod\":\"sample\",\"requestOrder\":93,\"pump\":{\"observed\":true,\"pumpCount\":94,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":12065.898,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.339,\"atMs\":12080.237,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":94}", + "type": "log" + }, + { + "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":95}", + "type": "log" + }, + { + "text": "[Screenshot] First 16 bytes (BGRA raw): 47 35 23 255 47 35 23 255 47 35 23 255 47 35 23 255 ", + "type": "log" + }, + { + "text": "[Screenshot] Middle bytes (BGRA raw): 47 35 23 255 47 35 23 255 47 35 23 255 47 35 23 255 ", + "type": "log" + }, + { + "text": "[Screenshot] Saved: /home/runner/work/threenative/threenative/artifacts/vq11-decals-native/static/after.png (960x540)", + "type": "log" + } +] diff --git a/docs/verification/vq11-decals-native-37010879478/static.png b/docs/verification/vq11-decals-native-37010879478/static.png new file mode 100644 index 0000000000..da3ebd877a Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/static.png differ diff --git a/docs/verification/vq11-decals-native-37010879478/summary.json b/docs/verification/vq11-decals-native-37010879478/summary.json new file mode 100644 index 0000000000..6a6291e8dc --- /dev/null +++ b/docs/verification/vq11-decals-native-37010879478/summary.json @@ -0,0 +1,544 @@ +{ + "sourceSha": "563ab23e9448a993013cb0d58c75ab6ab83917c9", + "pass": false, + "qualification": "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.", + "results": [ + { + "name": "static", + "pass": true, + "negativeControl": false, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 540, + "width": 960 + } + }, + "state": { + "residencyGeneration": 0, + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 0, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 0 + }, + "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8", + "pixels": { + "assertions": [ + { + "id": "visual.0.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.15, + "observed": 0.9999691358024692 + } + }, + { + "id": "visual.1.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9995294117647059 + } + }, + { + "id": "visual.1.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.1.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.2.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9997660818713451 + } + }, + { + "id": "visual.2.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05719298245614035 + } + }, + { + "id": "visual.2.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05719298245614035 + } + }, + { + "details": { + "consoleErrors": 0, + "networkErrors": 0, + "policy": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true + }, + "runtimeDiagnostics": 0 + }, + "id": "diagnostics", + "pass": true + } + ], + "diagnostics": [] + }, + "diagnostics": [] + }, + { + "name": "motion", + "pass": true, + "negativeControl": false, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 540, + "width": 960 + } + }, + "state": { + "residencyGeneration": 0, + "generation": 1, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 0, + "motionError": 0, + "receiverMotion": 0.7549778411745116, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 0 + }, + "screenshotSha256": "a80813b0c2ebdff2c817664a8780b59396bbec4844bacfba5ac25998fe001c99", + "pixels": { + "assertions": [ + { + "id": "visual.0.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.15, + "observed": 0.9999729938271605 + } + }, + { + "id": "visual.1.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9995294117647059 + } + }, + { + "id": "visual.1.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.1.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.2.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9996491228070176 + } + }, + { + "id": "visual.2.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05309941520467836 + } + }, + { + "id": "visual.2.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05309941520467836 + } + }, + { + "details": { + "consoleErrors": 0, + "networkErrors": 0, + "policy": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true + }, + "runtimeDiagnostics": 0 + }, + "id": "diagnostics", + "pass": true + } + ], + "diagnostics": [] + }, + "diagnostics": [] + }, + { + "name": "teardown", + "pass": true, + "negativeControl": false, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 540, + "width": 960 + } + }, + "state": { + "residencyGeneration": 0, + "generation": 1, + "active": 128, + "capacity": 256, + "created": 321, + "released": 193, + "geometryBytes": 136188, + "maxDrawCalls": 0, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": true, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 0 + }, + "screenshotSha256": "2a85f1cb78de0bd06f79534704b44170397c6a299d9b5709dd76c560c08f6880", + "pixels": { + "assertions": [ + { + "id": "visual.0.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.15, + "observed": 0.9999807098765432 + } + }, + { + "id": "visual.1.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9995294117647059 + } + }, + { + "id": "visual.1.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.1.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "details": { + "consoleErrors": 0, + "networkErrors": 0, + "policy": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true + }, + "runtimeDiagnostics": 0 + }, + "id": "diagnostics", + "pass": true + } + ], + "diagnostics": [] + }, + "diagnostics": [] + }, + { + "name": "restart", + "pass": true, + "negativeControl": false, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 540, + "width": 960 + } + }, + "state": { + "residencyGeneration": 0, + "generation": 2, + "active": 256, + "capacity": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 0, + "motionError": 0, + "receiverMotion": 0, + "lodHit": true, + "renderTriangles": 2, + "receiverRemoved": false, + "atlasFade": false, + "averageOpacity": 1, + "residency": [], + "residencySamples": 0, + "stableResidencyFrames": 0 + }, + "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8", + "pixels": { + "assertions": [ + { + "id": "visual.0.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.15, + "observed": 0.9999691358024692 + } + }, + { + "id": "visual.1.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9995294117647059 + } + }, + { + "id": "visual.1.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.1.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05331764705882353 + } + }, + { + "id": "visual.2.region", + "pass": true, + "details": { + "after": true, + "expected": { + "equals": true + }, + "minimum": 0.9, + "observed": 0.9997660818713451 + } + }, + { + "id": "visual.2.region.darkPixels", + "pass": true, + "details": { + "maximumLuminance": 0.35, + "minimumDarkPixelRatio": 0.01, + "observedDarkPixelRatio": 0.05719298245614035 + } + }, + { + "id": "visual.2.region.maxDarkPixels", + "pass": true, + "details": { + "maximumDarkPixelRatio": 0.2, + "observedDarkPixelRatio": 0.05719298245614035 + } + }, + { + "details": { + "consoleErrors": 0, + "networkErrors": 0, + "policy": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true + }, + "runtimeDiagnostics": 0 + }, + "id": "diagnostics", + "pass": true + } + ], + "diagnostics": [] + }, + "diagnostics": [] + } + ], + "identity": { + "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "capabilities": { + "engine": "quickjs", + "version": "TN_COLD_START:{\"segment\":\"process\",\"atMs\":0.000}\nMystral Native Runtime v0.3.4\nNative WebGPU JS runtime - wgpu-native + quickjs build" + }, + "assetHashes": { + "receiver.glb": "7ed6179e17f75f3f60576f72968140773c7f65cb1b8d760240576ac4092c05c3", + "assets.manifest.json": "3b833a9fc96c4f3ef939840f2154f3e40aa95d4beb6cda965833b54f43cde1dc" + }, + "executables": { + "native.ts": { + "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native/decals", + "sha256": "623b916c245470157308c7203472cffaa7a8860b88093697e16305577af95ca7", + "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native.js", + "bundleSha256": "69b662ca548cd31bd6848acdfd086befd22a612bbb396caf9e8c9e5da27d33ee" + }, + "native-atlas.ts": { + "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-atlas/decals", + "sha256": "681760e85faa4cc1e0d18b67f1f05bb0b6cf65d4910d8a79a9ec9d96630426aa", + "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-atlas.js", + "bundleSha256": "ebe516e667d7d9da27f08cd2964a4a8a520cc849b2251261f781f026de47a27b" + }, + "native-hidden.ts": { + "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden/decals", + "sha256": "165d1ce5cf07db055c92947644f840efaca58e4b75bf4e8e24fc4967f61eed28", + "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden.js", + "bundleSha256": "43f5d19cf13531736384038fa9e372f90c45f7d2558335fa717070412acac08c" + }, + "native-hidden-fading.ts": { + "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden-fading/decals", + "sha256": "6bcf02f0fd25ccc8ef2cebfc2210db64ad88b7328f8107f6ea685bd36bd97527", + "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden-fading.js", + "bundleSha256": "b451d8a9c4982b9e22c7f82c40d02d879037dc78f8dadecb459c60d82022f7c5" + } + } + }, + "error": "AssertionError [ERR_ASSERTION]: [{\"code\":\"TN_PLAYTEST_OBSERVATION_UNAVAILABLE\",\"fix\":{\"instruction\":\"Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path.\"},\"message\":\"Resource wait for 'state.residencyGeneration' timed out after 30150 ms; predicate {\\\"gte\\\":1,\\\"id\\\":\\\"state\\\",\\\"path\\\":\\\"residencyGeneration\\\"}, last observation 0.\",\"path\":\"state\",\"severity\":\"error\",\"suggestion\":\"Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path.\"}]\n\nfalse !== true\n\n at assertNativeDecalCapture (/home/runner/work/threenative/threenative/packages/create-threenative/__tests__/fixtures/bounded-decals/native-proof.ts:73:10)\n at (/home/runner/work/threenative/threenative/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts:167:7)" +} diff --git a/docs/verification/vq11-decals-native-37010879478/teardown.png b/docs/verification/vq11-decals-native-37010879478/teardown.png new file mode 100644 index 0000000000..1823155528 Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/teardown.png differ diff --git a/docs/verification/vq11-decals-native-37015640669/atlas.png b/docs/verification/vq11-decals-native-37015640669/atlas.png new file mode 100644 index 0000000000..82992fcef5 Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/atlas.png differ diff --git a/docs/verification/vq11-decals-native-37015640669/expired.png b/docs/verification/vq11-decals-native-37015640669/expired.png new file mode 100644 index 0000000000..5351a90ffb Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/expired.png differ diff --git a/docs/verification/vq11-decals-native-37015640669/fading.png b/docs/verification/vq11-decals-native-37015640669/fading.png new file mode 100644 index 0000000000..a4d602f580 Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/fading.png differ diff --git a/docs/verification/vq11-decals-native-37015640669/provenance.json b/docs/verification/vq11-decals-native-37015640669/provenance.json new file mode 100644 index 0000000000..795d8cb902 --- /dev/null +++ b/docs/verification/vq11-decals-native-37015640669/provenance.json @@ -0,0 +1,245 @@ +{ + "sourceSha": "8f85fff19913c955a44b7b85d2cc117b972bd19c", + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669", + "artifactId": 11231490134, + "artifactSha256": "07925a63d4091f2a53d06ef7b178619d775ef9185890949afb4645c83e9d519f", + "qualification": "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.", + "pass": true, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 540, + "width": 960 + } + }, + "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "runtimeVersion": "Mystral Native Runtime v0.3.4; wgpu-native + QuickJS", + "assetHashes": { + "receiver.glb": "7ed6179e17f75f3f60576f72968140773c7f65cb1b8d760240576ac4092c05c3", + "assets.manifest.json": "3b833a9fc96c4f3ef939840f2154f3e40aa95d4beb6cda965833b54f43cde1dc" + }, + "builds": { + "native.ts": { + "executableSha256": "6d53ae54426ee3226a06373978fa1ea081c02d1667ceafa191bf70a1657a7fa7", + "bundleSha256": "391f98f6391b915798aa10053b71a03a652d1b07a72062d29448309435ac051a" + }, + "native-atlas.ts": { + "executableSha256": "a8bda56b2a103f0ae6f1330c5f35f56dfb2c7dc0d4b2ce672b2424f5eff05b26", + "bundleSha256": "683bc8d337886e7f9ca803c09d0ea89393e5914d17c7c58cfe2536fbe2db49d5" + }, + "native-hidden.ts": { + "executableSha256": "a404951c4b9f9e73d44589b307253383590b185dcc33aae1cad4bdc83fcfc4d5", + "bundleSha256": "54e4a82de7e6920b74d87beb66c995dd40bbf3012efcaf06989ec14a04e26abe" + }, + "native-hidden-fading.ts": { + "executableSha256": "511bfb911a6909a09043151fdfc65ea124b1ced21559b80c27c93144c8d6c7cd", + "bundleSha256": "5f538d5bdde31205935bf18c22214b641748e98a8636a7ba73e37ea06ee2d329" + } + }, + "results": [ + { + "name": "static", + "pass": true, + "negativeControl": false, + "png": "../vq11-decals-native-37010879478/static.png", + "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [] + }, + { + "name": "motion", + "pass": true, + "negativeControl": false, + "png": "../vq11-decals-native-37010879478/motion.png", + "screenshotSha256": "a80813b0c2ebdff2c817664a8780b59396bbec4844bacfba5ac25998fe001c99", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [] + }, + { + "name": "teardown", + "pass": true, + "negativeControl": false, + "png": "../vq11-decals-native-37010879478/teardown.png", + "screenshotSha256": "2a85f1cb78de0bd06f79534704b44170397c6a299d9b5709dd76c560c08f6880", + "active": 128, + "created": 321, + "released": 193, + "geometryBytes": 136188, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [] + }, + { + "name": "restart", + "pass": true, + "negativeControl": false, + "png": "../vq11-decals-native-37010879478/static.png", + "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [] + }, + { + "name": "lifecycle", + "pass": true, + "negativeControl": false, + "png": "../vq11-decals-native-37010879478/static.png", + "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [], + "residency": [ + { + "generation": 1, + "renderCalls": 18, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 2, + "renderCalls": 230, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 3, + "renderCalls": 258, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 4, + "renderCalls": 286, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 5, + "renderCalls": 314, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + }, + { + "generation": 6, + "renderCalls": 342, + "stableFrames": 3, + "geometries": 259, + "textures": 4, + "bytes": 6522894 + } + ], + "growth": { + "geometryGrowth": 0, + "textureGrowth": 0, + "memoryGrowth": 0 + } + }, + { + "name": "atlas", + "pass": true, + "negativeControl": false, + "png": "atlas.png", + "screenshotSha256": "4ecb9a664104a750608b1326bf18bbed367f03d0ed88c021b19a94f9d91fcd0f", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 1, + "failedPixelAssertions": [] + }, + { + "name": "fading", + "pass": true, + "negativeControl": false, + "png": "fading.png", + "screenshotSha256": "4740d99ae7b3ba976a3ea9a49c218021443a7e870c9dbc4da72bc5479f55a554", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 0.4749999999999973, + "failedPixelAssertions": [] + }, + { + "name": "expired", + "pass": true, + "negativeControl": false, + "png": "expired.png", + "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e", + "active": 0, + "created": 321, + "released": 321, + "geometryBytes": 0, + "maxDrawCalls": 516, + "averageOpacity": 0, + "failedPixelAssertions": [] + }, + { + "name": "hidden-decals", + "pass": true, + "negativeControl": true, + "png": "expired.png", + "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 259794, + "maxDrawCalls": 4, + "averageOpacity": 1, + "failedPixelAssertions": ["visual.1.region.darkPixels", "visual.2.region.darkPixels"] + }, + { + "name": "hidden-fading", + "pass": true, + "negativeControl": true, + "png": "expired.png", + "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e", + "active": 256, + "created": 321, + "released": 65, + "geometryBytes": 275076, + "maxDrawCalls": 516, + "averageOpacity": 0.4749999999999973, + "failedPixelAssertions": ["visual.3.region.darkPixels", "visual.4.region.darkPixels"] + } + ] +} diff --git a/examples/abyss-framework/dist.build-report.json b/examples/abyss-framework/dist.build-report.json index 74a4aee87b..34c3adacd7 100644 --- a/examples/abyss-framework/dist.build-report.json +++ b/examples/abyss-framework/dist.build-report.json @@ -2,10 +2,10 @@ "artifact": { "kind": "directory", "name": "dist", - "sha256": "279c9028a54c8fa7ef092c5ef7e3a3b43ab38e9600cc6bcd0e2c1243691701a7" + "sha256": "00610e60c69c4bb14aab51c193fd747d3ba2f87d5583aef8e5261f6e67815dfe" }, "measured": { - "artifactBytes": 12563552, + "artifactBytes": 12577621, "packagedAssetBytes": 786109 }, "manifestSha256": "e96b9804e18d941afd1774ff628452ab4db9c199a7230ee00cba306cf58ad036", diff --git a/examples/abyss-framework/package.json b/examples/abyss-framework/package.json index deafce5432..e37fe7a7e9 100644 --- a/examples/abyss-framework/package.json +++ b/examples/abyss-framework/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@tailwindcss/vite": "catalog:", "@threenative/assets": "workspace:*", + "@threenative/playtest": "workspace:*", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/three": "0.185.3", diff --git a/examples/abyss-framework/playtests/tone.playtest.json b/examples/abyss-framework/playtests/tone.playtest.json new file mode 100644 index 0000000000..bb0c1710dc --- /dev/null +++ b/examples/abyss-framework/playtests/tone.playtest.json @@ -0,0 +1,22 @@ +{ + "name": "tone-exposure", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1280, "height": 720 }, + "warmupFrames": 2, + "artifacts": { "screenshots": "after" }, + "steps": [{ "kind": "wait", "label": "rendered", "waitTicks": 2 }], + "assert": { + "tone": [ + { + "atStep": "rendered", + "mean": { "min": 140, "max": 210 }, + "p99": { "min": 230 }, + "clipFraction": { "max": 0.02 }, + "blackFraction": { "max": 0.05 } + }, + { "mean": { "min": 140, "max": 210 }, "p99": { "min": 230 } } + ], + "diagnostics": { "noConsoleErrors": true, "noNetworkErrors": true } + } +} diff --git a/examples/abyss-framework/src/render/toneCalibration.ts b/examples/abyss-framework/src/render/toneCalibration.ts new file mode 100644 index 0000000000..927f697b74 --- /dev/null +++ b/examples/abyss-framework/src/render/toneCalibration.ts @@ -0,0 +1,37 @@ +import { + AmbientLight, + BoxGeometry, + DirectionalLight, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + OrthographicCamera, + PlaneGeometry, + Scene, + SphereGeometry, +} from "three"; + +/** Neutral ramp and lit 3D subjects make the exposure mutation inspectable in a real render. */ +export function createToneCalibration(): { camera: OrthographicCamera; scene: Scene } { + const scene = new Scene(); + const camera = new OrthographicCamera(-2, 2, 1.125, -1.125, 0.1, 20); + camera.position.z = 5; + const panel = new PlaneGeometry(4 / 256, 2.25); + for (let value = 0; value < 256; value += 1) { + const material = new MeshBasicMaterial(); + material.color.setRGB(value / 255, value / 255, value / 255); + const strip = new Mesh(panel, material); + strip.position.x = -2 + (value + 0.5) * (4 / 256); + scene.add(strip); + } + const material = new MeshStandardMaterial({ color: 0x999999, roughness: 0.35 }); + const sphere = new Mesh(new SphereGeometry(0.32, 32, 24), material); + sphere.position.set(-0.65, 0, 0.4); + const cube = new Mesh(new BoxGeometry(0.48, 0.48, 0.48), material); + cube.position.set(0.65, 0, 0.4); + cube.rotation.set(0.3, 0.5, 0); + const key = new DirectionalLight(0xffffff, 3); + key.position.set(-2, 3, 4); + scene.add(sphere, cube, new AmbientLight(0xffffff, 0.4), key); + return { camera, scene }; +} diff --git a/examples/abyss-framework/src/tone-main.ts b/examples/abyss-framework/src/tone-main.ts new file mode 100644 index 0000000000..d5ead5a4c4 --- /dev/null +++ b/examples/abyss-framework/src/tone-main.ts @@ -0,0 +1,27 @@ +import { installThreePlaytestBridge } from "@threenative/playtest/three"; +import { LinearToneMapping, WebGPURenderer } from "three/webgpu"; +import { createToneCalibration } from "./render/toneCalibration.js"; + +const { camera, scene } = createToneCalibration(); +const renderer = new WebGPURenderer({ antialias: false }); +renderer.setPixelRatio(1); +renderer.setSize(1280, 720); +renderer.toneMapping = LinearToneMapping; +renderer.toneMappingExposure = new URLSearchParams(location.search).has("underexposed") ? 0.25 : 1; +document.body.appendChild(renderer.domElement); +await renderer.init(); +await renderer.renderAsync(scene, camera); +let tick = 0; +installThreePlaytestBridge({ + camera, + diagnostics: () => [], + fixedStep: async (ticks) => { + tick += ticks; + await renderer.renderAsync(scene, camera); + return ticks; + }, + renderer, + resources: { read: () => ({ exposure: renderer.toneMappingExposure }) }, + scene, + tick: () => tick, +}); diff --git a/examples/abyss-framework/tone.html b/examples/abyss-framework/tone.html new file mode 100644 index 0000000000..8c7a72ec21 --- /dev/null +++ b/examples/abyss-framework/tone.html @@ -0,0 +1,5 @@ + + + Tone exposure calibration + + diff --git a/examples/abyss-framework/tone.vite.config.ts b/examples/abyss-framework/tone.vite.config.ts new file mode 100644 index 0000000000..63fb18f205 --- /dev/null +++ b/examples/abyss-framework/tone.vite.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +// A deterministic fixture with no cooked assets. Use the freshly built public playtest bridge. +export default defineConfig({ + publicDir: false, + build: { + emptyOutDir: true, + outDir: "../../artifacts/tone-fixture", + rollupOptions: { input: fileURLToPath(new URL("tone.html", import.meta.url)) }, + }, +}); diff --git a/examples/prd476-fluid-particles/index.html b/examples/prd476-fluid-particles/index.html new file mode 100644 index 0000000000..6201860c1d --- /dev/null +++ b/examples/prd476-fluid-particles/index.html @@ -0,0 +1,17 @@ + + + + + + + PRD-476 fluid particles + + + + + + diff --git a/examples/prd476-fluid-particles/package.json b/examples/prd476-fluid-particles/package.json new file mode 100644 index 0000000000..10801707cf --- /dev/null +++ b/examples/prd476-fluid-particles/package.json @@ -0,0 +1,23 @@ +{ + "name": "prd476-fluid-particles", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:desktop": "node ../../packages/runtime-native/scripts/bundle.mjs --project . --entry src/game.ts --target desktop --output dist/prd476-fluid-particles-native.js", + "build": "vite build", + "dev": "vite", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@threenative/core": "workspace:*", + "@threenative/physics": "workspace:*", + "@threenative/playtest": "workspace:*", + "three": "catalog:" + }, + "devDependencies": { + "@types/three": "0.185.3", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json new file mode 100644 index 0000000000..343791f5ba --- /dev/null +++ b/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json @@ -0,0 +1,49 @@ +{ + "name": "prd476-fluid-particles-coupling", + "schemaVersion": 1, + "subject": "a dense sphere sinks, a light box floats on particle water, and the solver reads their transforms every step", + "target": "web", + "viewport": { "width": 1280, "height": 720 }, + "warmupFrames": 20, + "artifacts": { "screenshots": true }, + "steps": [ + { + "kind": "wait", + "label": "falling", + "waitTicks": 60, + "release": true, + "screenshot": "falling" + }, + { "kind": "wait", "label": "splash", "waitTicks": 40, "release": true, "screenshot": "splash" }, + { "kind": "wait", "label": "settling", "waitTicks": 300, "release": true }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 300, + "release": true, + "screenshot": "settled" + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { "id": "GameState", "path": "steps", "changed": true }, + { "id": "GameState", "path": "count", "gte": 2000 }, + { "id": "GameState", "path": "sphereY", "lte": 0.45 }, + { "id": "GameState", "path": "sphereSpeed", "lte": 0.2 }, + { "id": "GameState", "path": "boxSurfaceGap", "lte": 0.2 }, + { "id": "GameState", "path": "surfaceDisturbance", "gte": 0.05 } + ], + "visual": [ + { + "region": { "x": 0, "y": 0, "width": 1280, "height": 720, "minNonblankPixelRatio": 0.01 }, + "frameDiff": { "minChangedPixelRatio": 0.002 } + } + ] + } +} diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json new file mode 100644 index 0000000000..8ea929271c --- /dev/null +++ b/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json @@ -0,0 +1,141 @@ +{ + "name": "prd476-fluid-particles-native-dam-break", + "schemaVersion": 1, + "subject": "a released dam-break column runs across the tank and settles on the GPU", + "target": "desktop", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 20, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "gated", + "waitTicks": 30, + "release": true, + "screenshot": "gated" + }, + { + "kind": "wait", + "label": "running", + "waitTicks": 90, + "release": true, + "screenshot": "running" + }, + { + "kind": "wait", + "label": "settle-0", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-1", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-2", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-3", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-4", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-5", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-6", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-7", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-8", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-9", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-10", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settle-11", + "waitTicks": 50, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 50, + "release": true, + "screenshot": "settled" + } + ], + "assert": { + "resources": [ + { + "id": "GameState", + "path": "steps", + "changed": true + }, + { + "id": "GameState", + "path": "peakFrontX", + "gte": 1.0 + }, + { + "id": "GameState", + "path": "maxSpeed", + "lte": 1.0 + }, + { + "id": "GameState", + "path": "meanCompression", + "lte": 0.05, + "allowTrivial": "Native stats readbacks have landed before the first observation, so this held invariant is satisfied from the start; peakFrontX and maxSpeed prove the water moved and settled." + }, + { + "id": "GameState", + "path": "inBounds", + "equals": 1, + "allowTrivial": "Native stats readbacks have landed before the first observation, so this held invariant is satisfied from the start; peakFrontX and maxSpeed prove the water moved and settled." + } + ] + } +} diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json new file mode 100644 index 0000000000..2530189376 --- /dev/null +++ b/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json @@ -0,0 +1,97 @@ +{ + "name": "prd476-fluid-particles-dam-break", + "schemaVersion": 1, + "subject": "a released dam-break column runs across the tank and settles on the GPU", + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 20, + "artifacts": { + "screenshots": true + }, + "steps": [ + { + "kind": "wait", + "label": "gated", + "waitTicks": 30, + "release": true, + "screenshot": "gated" + }, + { + "kind": "wait", + "label": "running", + "waitTicks": 90, + "release": true, + "screenshot": "running" + }, + { + "kind": "wait", + "label": "settling", + "waitTicks": 300, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 400, + "release": true, + "screenshot": "settled" + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "GameState", + "path": "steps", + "changed": true + }, + { + "id": "GameState", + "path": "count", + "gte": 1600 + }, + { + "id": "GameState", + "path": "peakFrontX", + "gte": 1.0 + }, + { + "id": "GameState", + "path": "meanCompression", + "lte": 0.05 + }, + { + "id": "GameState", + "path": "maxSpeed", + "lte": 1.0 + }, + { + "id": "GameState", + "path": "inBounds", + "equals": 1 + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + }, + "frameDiff": { + "minChangedPixelRatio": 0.002 + } + } + ] + } +} diff --git a/examples/prd476-fluid-particles/src/conformance.js b/examples/prd476-fluid-particles/src/conformance.js new file mode 100644 index 0000000000..e38257857a --- /dev/null +++ b/examples/prd476-fluid-particles/src/conformance.js @@ -0,0 +1,112 @@ +import { vec3 } from "three/tsl"; +import * as three from "three/webgpu"; +import { FluidParticles3D } from "../../../packages/core/src/fluid-particles.ts"; +import { warmUpScene } from "../../../packages/core/src/warmup.ts"; +import { + assertCondition, + startVisualScene, +} from "../../../packages/runtime-native/conformance/scenes/shared/scene-support.js"; + +const STEPS = 90; +const BOUNDS = { min: [-1.1, 0.0968, -0.55], max: [1.1, 2, 0.55] }; + +function isNativeConformanceRuntime() { + return String(globalThis.__TN_ASSET_BASE__ ?? "").endsWith(":0/"); +} + +async function waitForSubmittedWork(renderer) { + await renderer.backend?.device?.queue?.onSubmittedWorkDone?.(); +} + +/** A small block of water drops, splashes and settles; the same passes run on web and desktop. */ +export async function startScene(canvas, dimensions) { + return startVisualScene( + canvas, + dimensions, + "fluid-particles", + async ({ camera, renderer, scene }) => { + const water = new FluidParticles3D({ + bounds: BOUNDS, + capacity: 600, + readbackEvery: 1, + spacing: 0.22, + }); + const computeRenderer = { + kind: "webgpu", + compute: (node) => renderer.compute(node), + readback: (attribute) => renderer.getArrayBufferAsync(attribute), + }; + water.attachRenderer(computeRenderer); + const warmup = await warmUpScene(renderer, scene, camera, { + computeNodes: water.warmupNodes, + }); + assertCondition( + warmup.computeCompiled === water.warmupNodes.length, + "fluid particles warm-up must compile every ordered solver pass", + ); + + const placed = water.fill([-0.9, 0.9, -0.44], [-0.1, 1.8, 0.44]); + assertCondition(placed >= 100, `fluid particles must place a block, placed ${placed}`); + for (let index = 0; index < STEPS; index += 1) water.process(computeRenderer); + await waitForSubmittedWork(renderer); + assertCondition(water.steps === STEPS, "fluid particles must advance every fixed step"); + + let stats = null; + if (!isNativeConformanceRuntime()) { + // The readback lands asynchronously; give it a few frames before judging the solver. + for (let attempt = 0; attempt < 40 && water.stats === undefined; attempt += 1) { + water.process(computeRenderer); + await waitForSubmittedWork(renderer); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + stats = water.stats ?? null; + assertCondition(stats !== null, "fluid particles stats readback must land on web"); + assertCondition( + stats.count === placed, + `live particles ${stats.count} must equal ${placed}`, + ); + assertCondition( + stats.meanCompression < 0.05, + `compression ${stats.meanCompression} must stay low`, + ); + for (let axis = 0; axis < 3; axis += 1) { + assertCondition( + stats.min[axis] >= BOUNDS.min[axis] - 0.01 && + stats.max[axis] <= BOUNDS.max[axis] + 0.01, + "fluid particles must stay inside their bounds", + ); + } + console.log(`FLUID_PARTICLES_STATS:${JSON.stringify(stats)}`); + } else { + console.log( + "FLUID_PARTICLES_READBACK:skipped; native readback is verified by parity capture", + ); + } + + const material = new three.SpriteNodeMaterial({ depthWrite: true }); + const particle = water.positions.toAttribute(); + material.positionNode = particle.xyz; + material.scaleNode = particle.w.mul(0.17); + material.colorNode = vec3(0.25, 0.75, 1); + const points = new three.Sprite(material); + points.count = water.capacity; + points.frustumCulled = false; + scene.add(points); + camera.position.set(0, 1.2, 4.2); + camera.lookAt(0, 0.8, 0); + + // The solver keeps stepping per frame so a capture sees the same settled state on both hosts. + return { + detail: { + fixedSteps: water.steps, + particles: placed, + meanCompression: stats === null ? null : Number(stats.meanCompression.toFixed(6)), + warmupNodes: water.warmupNodes.length, + }, + mesh: points, + water, + }; + }, + { background: 0x05070e }, + ); +} diff --git a/examples/prd476-fluid-particles/src/coupling.ts b/examples/prd476-fluid-particles/src/coupling.ts new file mode 100644 index 0000000000..ea051ebc0b --- /dev/null +++ b/examples/prd476-fluid-particles/src/coupling.ts @@ -0,0 +1,148 @@ +import { FluidParticles3D, type ICtx, Scene, type SceneFrame } from "@threenative/core"; +import { + Buoyancy3D, + CollisionShape3D, + type IPhysicsContext, + RigidBody3D, +} from "@threenative/physics"; +import { BoxGeometry, Mesh, MeshBasicMaterial, SphereGeometry } from "three"; +import { createPointsView } from "./render/points.js"; +import { type IFluidParticlesState, INITIAL_STATE } from "./state.js"; + +/** The bodies fall ~6.6 m (about 70 steps) onto the pool; its rest level is read at step 55. */ +const LEVEL_STEP = 55; +const WATER_DENSITY = 1000; + +interface IBodySpec { + readonly name: "sphere" | "boxLight" | "boxSmall"; + readonly density: number; + readonly start: readonly [number, number, number]; + readonly half: readonly [number, number, number]; +} + +const SPHERE_RADIUS = 0.3; +const BODIES: readonly IBodySpec[] = [ + { + name: "sphere", + density: 1900, + start: [-1, 8, 0], + half: [SPHERE_RADIUS, SPHERE_RADIUS, SPHERE_RADIUS], + }, + { name: "boxLight", density: 550, start: [1.2, 8.5, 0], half: [0.4, 0.4, 0.4] }, + { name: "boxSmall", density: 300, start: [1.2, 9.5, -0.8], half: [0.25, 0.25, 0.25] }, +]; + +export class CouplingScene extends Scene { + static override readonly initialState: IFluidParticlesState = INITIAL_STATE; + + override enter( + ctx: ICtx, + ): SceneFrame { + const water = new FluidParticles3D({ capacity: 4000, readbackEvery: 1 }); + ctx.add(water); + ctx.add(createPointsView(water, ctx.scene, ctx.camera)); + water.fill([-2.8, 0.1, -1.5], [2.8, 1.3, 1.5]); + + // The tank floor the bodies can rest on; its top is the water's floor. + const floor = new RigidBody3D({ + collisionLayer: 1, + physics: ctx.physics, + position: { x: 0, y: -1, z: 0 }, + shape: CollisionShape3D.box(8, 2, 5), + type: "fixed", + }); + + const bodies = BODIES.map((spec) => { + const isSphere = spec.name === "sphere"; + const mesh = new Mesh( + isSphere + ? new SphereGeometry(spec.half[0], 20, 14) + : new BoxGeometry(spec.half[0] * 2, spec.half[1] * 2, spec.half[2] * 2), + new MeshBasicMaterial({ color: isSphere ? 0xf2a65a : 0xe8e0c8 }), + ); + mesh.position.set(...spec.start); + ctx.add(mesh); + const volume = isSphere + ? (4 / 3) * Math.PI * spec.half[0] ** 3 + : 8 * spec.half[0] * spec.half[1] * spec.half[2]; + const body = new RigidBody3D({ + collisionLayer: 1, + mass: spec.density * volume, + object: mesh, + physics: ctx.physics, + shape: isSphere + ? CollisionShape3D.sphere(spec.half[0]) + : CollisionShape3D.box(spec.half[0] * 2, spec.half[1] * 2, spec.half[2] * 2), + }); + const [hx, hy, hz] = spec.half; + new Buoyancy3D({ + body, + density: WATER_DENSITY, + drag: isSphere ? 800 : 2500, + gravity: 9.81, + // One point at the centre: heave only. Points off-centre would feed surface noise into + // torque, and Rapier angular damping is not on the body options. + hullPoints: [{ position: [0, 0, 0] }], + pointSpacing: hy * 2, + surface: water, + volume, + }); + return { body, mesh, spec }; + }); + const sphere = bodies[0] as (typeof bodies)[number]; + const box = bodies[1] as (typeof bodies)[number]; + + // Each fixed step the solver learns where the bodies are, so its particles flow around them. + ctx.afterPhysics(() => { + water.setColliders( + bodies.map(({ mesh, spec }) => + spec.name === "sphere" + ? { + kind: "sphere", + center: mesh.position.toArray() as [number, number, number], + radius: spec.half[0], + } + : { + kind: "box", + center: mesh.position.toArray() as [number, number, number], + halfExtents: spec.half, + rotation: mesh.quaternion.toArray() as [number, number, number, number], + }, + ), + ); + }); + + let level0 = 0; + let disturbance = 0; + return (frame) => { + void floor; // the fixed floor body must outlive the scene + const stats = water.stats; + if (water.steps === LEVEL_STEP) level0 = water.heightAt(sphere.spec.start[0], 0); + if (water.steps > LEVEL_STEP) + disturbance = Math.max( + disturbance, + Math.abs(water.heightAt(sphere.spec.start[0], 0) - level0), + ); + const surfaceAtBox = water.heightAt(box.mesh.position.x, box.mesh.position.z); + const landed = stats !== undefined; + frame.state.set({ + count: stats?.count ?? 0, + steps: water.steps, + meanCompression: stats?.meanCompression ?? 1, + maxSpeed: stats?.maxSpeed ?? 99, + staleFrames: stats?.staleFrames ?? 0, + sphereY: sphere.mesh.position.y, + sphereSpeed: Math.hypot( + sphere.body.linearVelocity.x, + sphere.body.linearVelocity.y, + sphere.body.linearVelocity.z, + ), + surfaceRest: level0, + boxY: box.mesh.position.y, + boxSurfaceGap: landed ? Math.abs(box.mesh.position.y - surfaceAtBox) : 99, + surfaceDisturbance: disturbance, + surface: surfaceAtBox, + }); + }; + } +} diff --git a/examples/prd476-fluid-particles/src/game.ts b/examples/prd476-fluid-particles/src/game.ts new file mode 100644 index 0000000000..c450550257 --- /dev/null +++ b/examples/prd476-fluid-particles/src/game.ts @@ -0,0 +1,117 @@ +import { FluidParticles3D, type ICtx, Scene, type SceneFrame, defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { type IPhysicsContext, rapier } from "@threenative/physics"; +import { CouplingScene } from "./coupling.js"; +import { createPointsView } from "./render/points.js"; +import { type IFluidParticlesState, INITIAL_STATE } from "./state.js"; + +/** The dam gate comes out after this many fixed steps (0.8 s, as in the lab). */ +const GATE_STEPS = 48; +const GATE = { kind: "box", center: [-0.9, 2.4, 0], halfExtents: [0.05, 2.4, 1.6] } as const; + +class DamBreakScene extends Scene { + static override readonly initialState: IFluidParticlesState = INITIAL_STATE; + + override enter( + ctx: ICtx, + ): SceneFrame { + const query = new URLSearchParams(globalThis.location?.search ?? ""); + const knob = (name: string): number | undefined => + query.has(name) ? Number(query.get(name)) : undefined; + const water = new FluidParticles3D({ + capacity: 6000, + viscosity: knob("visc"), + cohesion: knob("coh"), + iterations: knob("iter"), + vorticity: knob("vort"), + }); + ctx.add(water); + ctx.add(createPointsView(water, ctx.scene, ctx.camera)); + // ?stress=1 fills the whole tank to capacity (6,000 particles) and keeps no gate. + const stress = new URLSearchParams(globalThis.location?.search ?? "").has("stress"); + if (stress) water.fill([-2.8, 0.1, -1.5], [2.8, 3.6, 1.5]); + else { + water.setColliders([GATE]); + water.fill([-2.8, 0.1, -1.5], [-1.0, 2.8, 1.5]); + } + + if (new URLSearchParams(globalThis.location?.search ?? "").has("bench")) { + // Back-to-back solver steps timed to GPU completion: the cost of one fixed step, with no + // presentation in the number. + const device = ( + ctx.renderer.raw as { + backend?: { device?: { queue: { onSubmittedWorkDone(): Promise } } }; + } + ).backend?.device; + (globalThis as Record).__fluidBench = async (steps: number) => { + await device?.queue.onSubmittedWorkDone(); + const start = performance.now(); + for (let step = 0; step < steps; step += 1) water.process(ctx.renderer); + await device?.queue.onSubmittedWorkDone(); + return (performance.now() - start) / steps; + }; + // GPU-side cost per step from timestamp queries (browser `allow_unsafe_apis` dawn feature); + // the pool holds 2,048 queries at two per pass, so keep `steps` under ~40. + const raw = ctx.renderer.raw as { + backend?: { trackTimestamp?: boolean }; + info?: { compute?: { timestamp?: number } }; + resolveTimestampsAsync?: (type: string) => Promise; + }; + (globalThis as Record).__fluidGpuMs = async (steps: number) => { + if (raw.backend === undefined || raw.resolveTimestampsAsync === undefined) return -1; + raw.backend.trackTimestamp = true; + for (let step = 0; step < steps; step += 1) water.process(ctx.renderer); + await device?.queue.onSubmittedWorkDone(); + await raw.resolveTimestampsAsync("compute"); + raw.backend.trackTimestamp = false; + return (raw.info?.compute?.timestamp ?? -1) / steps; + }; + } + (globalThis as Record).__fluidProbe = () => ({ + steps: water.steps, + stats: water.stats, + }); + let peakFrontX = 0; + return (frame) => { + if (water.steps === GATE_STEPS) water.setColliders([]); + const stats = water.stats; + const { min, max } = water.bounds; + const slack = 0.01; + peakFrontX = Math.max(peakFrontX, stats?.max[0] ?? 0); + frame.state.set({ + count: stats?.count ?? 0, + steps: water.steps, + meanCompression: stats?.meanCompression ?? 1, + maxSpeed: stats?.maxSpeed ?? 99, + frontX: stats?.max[0] ?? 0, + peakFrontX, + minY: stats?.min[1] ?? 0, + maxY: stats?.max[1] ?? 0, + inBounds: + stats !== undefined && + [0, 1, 2].every( + (axis) => + (stats.min[axis] as number) >= (min[axis] as number) - slack && + (stats.max[axis] as number) <= (max[axis] as number) + slack, + ) + ? 1 + : 0, + released: water.steps > GATE_STEPS ? 1 : 0, + staleFrames: stats?.staleFrames ?? 0, + }); + }; + } +} + +const game = defineGame({ + step: 1 / 60, + plugins: [rapier({ gravity: { x: 0, y: -9.81, z: 0 } }), playtest()], + render: { preferWebGPU: true }, + scenes: { coupling: CouplingScene, dam: DamBreakScene }, + start: + new URLSearchParams(globalThis.location?.search ?? "").get("scene") === "coupling" + ? "coupling" + : "dam", +}); + +export default game; diff --git a/examples/prd476-fluid-particles/src/main.ts b/examples/prd476-fluid-particles/src/main.ts new file mode 100644 index 0000000000..3d75795336 --- /dev/null +++ b/examples/prd476-fluid-particles/src/main.ts @@ -0,0 +1,3 @@ +import game from "./game.js"; + +void game.start(); diff --git a/examples/prd476-fluid-particles/src/render/points.ts b/examples/prd476-fluid-particles/src/render/points.ts new file mode 100644 index 0000000000..f9d5a711a4 --- /dev/null +++ b/examples/prd476-fluid-particles/src/render/points.ts @@ -0,0 +1,45 @@ +import type { FluidParticles3D } from "@threenative/core"; +import { + BoxGeometry, + type Camera, + Color, + EdgesGeometry, + Group, + LineBasicMaterial, + LineSegments, + type Scene, +} from "three"; +import { vec3 } from "three/tsl"; +import { Sprite, SpriteNodeMaterial } from "three/webgpu"; +import { createWaterVolume } from "./water-volume.js"; + +/** The raymarched water by default; `?points` swaps in the debug look, one flat dot per particle. */ +export function createPointsView(water: FluidParticles3D, scene: Scene, camera: Camera): Group { + scene.background = new Color(0x0e2238); + camera.position.set(0, 3.0, 7.6); + camera.lookAt(0, 1.3, 0); + + const view = new Group(); + const material = new SpriteNodeMaterial({ transparent: false, depthWrite: true }); + const particle = water.positions.toAttribute(); + const speed = water.velocities.toAttribute(); + material.positionNode = particle.xyz; + material.scaleNode = particle.w.mul(water.spacing * 0.9); + material.colorNode = vec3(0.25, 0.75, 1).add(speed.w.mul(0.6)); + const points = new Sprite(material); + points.count = water.capacity; + points.frustumCulled = false; + const debug = new URLSearchParams(globalThis.location?.search ?? "").has("points"); + if (debug) view.add(points); + else view.add(createWaterVolume(water)); + + const { min, max } = water.bounds; + const size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]] as const; + const tank = new LineSegments( + new EdgesGeometry(new BoxGeometry(...size)), + new LineBasicMaterial({ color: 0x6f95b8 }), + ); + tank.position.set(min[0] + size[0] / 2, min[1] + size[1] / 2, min[2] + size[2] / 2); + view.add(tank); + return view; +} diff --git a/examples/prd476-fluid-particles/src/render/water-volume.ts b/examples/prd476-fluid-particles/src/render/water-volume.ts new file mode 100644 index 0000000000..ac894540d1 --- /dev/null +++ b/examples/prd476-fluid-particles/src/render/water-volume.ts @@ -0,0 +1,193 @@ +import type { FluidParticles3D } from "@threenative/core"; +import { BackSide, BoxGeometry, Mesh } from "three"; +import * as tsl from "three/tsl"; +import { MeshBasicNodeMaterial } from "three/webgpu"; + +// The look is this file's: a raymarch of the solver's density volume with a refracted checker +// floor, Beer-Lambert absorption and Schlick Fresnel. Swap any of it without touching packages/. +// quality-allow: TSL nodes are typed per swizzle by three 0.185; this file is arithmetic over them. +// biome-ignore lint/suspicious/noExplicitAny: see above. +type N = any; +const { + Break, + Fn, + If, + Loop, + cameraPosition, + clamp, + dot, + exp, + float, + floor, + hash, + max, + min, + mix, + select, + normalize, + pow, + positionWorld, + reflect, + refract, + texture3D, + vec3, + vec4, + // biome-ignore lint/suspicious/noExplicitAny: see above. +} = tsl as unknown as Record; + +export interface IWaterVolumeOptions { + /** Ray steps through the tank. 72 resolves 0.11 m voxels across a 7 m diagonal. */ + readonly steps?: number; + readonly deep?: readonly [number, number, number]; + readonly shallow?: readonly [number, number, number]; + /** Absorption per metre of water, per channel. */ + readonly absorption?: readonly [number, number, number]; +} + +const DENSITY_THRESHOLD = 0.5; + +export function createWaterVolume( + water: FluidParticles3D, + options: IWaterVolumeOptions = {}, +): Mesh { + const steps = options.steps ?? 72; + const deep = vec3(...(options.deep ?? [0.0, 0.2, 0.45])); + const shallow = vec3(...(options.shallow ?? [0.15, 0.72, 0.95])); + const absorption = vec3(...(options.absorption ?? [0.7, 0.22, 0.1])); + const { min: lo, max: hi } = water.bounds; + const bmin = vec3(...lo); + const bmax = vec3(...hi); + const size = vec3(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); + const voxel = water.voxelSize; + // The density texture spans whole voxels, a little past the bounds on the long axes. + const extent = vec3(...water.volumeSize.map((count) => count * voxel)); + const volume = water.density; + + // Sample inside the walls: the field fades over the last spacing next to a wall, which would + // read as a free surface pressed against the glass. Clamping keeps the water full to the wall. + const pad = water.spacing * 1.2; + const inner = (p: N): N => + vec3( + clamp(p.x, bmin.x.add(pad), bmax.x.sub(pad)), + max(p.y, bmin.y.add(pad)), + clamp(p.z, bmin.z.add(pad), bmax.z.sub(pad)), + ); + const density = (p: N): N => texture3D(volume, inner(p).sub(bmin).div(extent).clamp(0, 1)).x; + const gradient = (p: N): N => { + const e = voxel * 1.5; + return vec3( + density(p.add(vec3(e, 0, 0))).sub(density(p.sub(vec3(e, 0, 0)))), + density(p.add(vec3(0, e, 0))).sub(density(p.sub(vec3(0, e, 0)))), + density(p.add(vec3(0, 0, e))).sub(density(p.sub(vec3(0, 0, e)))), + ); + }; + const sky = (d: N): N => + mix(vec3(0.7, 0.85, 1.0), vec3(0.12, 0.3, 0.6), clamp(d.y.mul(1.4), 0, 1)); + const floorColor = (p: N): N => { + const cell = floor(p.x.mul(2)) + .add(floor(p.z.mul(2))) + .mod(2); + return mix(vec3(0.12, 0.2, 0.3), vec3(0.28, 0.42, 0.55), cell); + }; + + const march = Fn(() => { + const origin = cameraPosition; + const direction = normalize(positionWorld.sub(cameraPosition)); + const inverse = vec3(1).div(direction); + const t1 = bmin.sub(origin).mul(inverse); + const t2 = bmax.sub(origin).mul(inverse); + const near = max(max(min(t1.x, t2.x), min(t1.y, t2.y)), max(min(t1.z, t2.z), 0)); + const far = min(min(max(t1.x, t2.x), max(t1.y, t2.y)), max(t1.z, t2.z)); + const stride = far.sub(near).div(steps); + const t = near + .add(stride.mul(hash(positionWorld.x.mul(131).add(positionWorld.y.mul(197))))) + .toVar(); + const hit = float(0).toVar(); + const hitPoint = vec3(0).toVar(); + const previous = float(0).toVar(); + // A ray that starts under water (looking through the glass) travels until it leaves the water; + // one that starts in air travels until it meets the surface. + const entry = origin.add(direction.mul(near)); + const inside = density(entry).greaterThan(DENSITY_THRESHOLD); + previous.assign(density(origin.add(direction.mul(t)))); + Loop(steps, () => { + const p = origin.add(direction.mul(t)); + const d = density(p); + const crossed = select( + inside, + d.lessThan(DENSITY_THRESHOLD), + d.greaterThan(DENSITY_THRESHOLD), + ); + If(crossed.and(hit.lessThan(0.5)), () => { + const k = clamp( + float(DENSITY_THRESHOLD) + .sub(previous) + .div(max(d.sub(previous).abs(), 1e-4).mul(select(inside, float(-1), float(1)))), + 0, + 1, + ); + hitPoint.assign(origin.add(direction.mul(t.sub(stride.mul(float(1).sub(k)))))); + hit.assign(1); + Break(); + }); + previous.assign(d); + t.addAssign(stride); + }); + + // Through the glass: Beer-Lambert along the path in water, then whatever the ray reaches. + const farPoint = origin.add(direction.mul(far)); + const leave = select(hit.greaterThan(0.5), hitPoint, farPoint); + const path = clamp(leave.sub(entry).length().mul(2.2), 0, 6); + const trans = exp(absorption.mul(path).negate()); + const sideBody = mix(deep, shallow, clamp(float(1.1).sub(path.mul(0.25)), 0, 1)); + const underside = mix(sky(vec3(direction.x, direction.y.negate(), direction.z)), shallow, 0.35); + const onFloor = leave.y.lessThan(lo[1] + 0.05); + const sideEnd = select( + hit.greaterThan(0.5), + underside, + select(onFloor, floorColor(leave).mul(0.8), mix(deep, shallow, 0.45)), + ); + const sideColor = sideEnd.mul(trans).add(sideBody.mul(vec3(1).sub(trans))); + + // From the air: gradient normal, refracted floor, Fresnel, sun glint. + const normal = normalize( + gradient(hitPoint) + .negate() + .add(vec3(0, 1e-3, 0)), + ); + const facing = clamp(dot(normal, direction.negate()), 0, 1); + const fresnel = float(0.02).add(float(0.98).mul(pow(float(1).sub(facing), 5))); + const bent = refract(direction, normal, float(1 / 1.33)); + const toFloor = max(hitPoint.y.sub(lo[1]), 0.02).div(max(bent.y.negate(), 0.05)); + const floorPoint = hitPoint.add(bent.mul(toFloor)); + const through = clamp(toFloor, 0, 6); + const transmittance = exp(absorption.mul(through).negate()); + const body = mix(deep, shallow, clamp(through.mul(-0.4).add(1.2), 0, 1)); + const refracted = floorColor(floorPoint) + .mul(transmittance) + .add(body.mul(vec3(1).sub(transmittance))); + const mirror = sky(reflect(direction, normal)); + const sun = pow( + clamp(dot(reflect(direction, normal), normalize(vec3(0.4, 0.8, 0.3))), 0, 1), + 90, + ).mul(1.2); + const airColor = mix(refracted, mirror, fresnel).add(sun); + const color = select(inside, sideColor, airColor); + return vec4(color, select(inside, float(1), hit)); + }); + + const material = new MeshBasicNodeMaterial({ + side: BackSide, + transparent: true, + depthWrite: false, + }); + material.toneMapped = false; + const shaded = march(); + material.colorNode = shaded.xyz; + material.opacityNode = shaded.w; + const mesh = new Mesh(new BoxGeometry(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), material); + mesh.position.set((lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2); + mesh.frustumCulled = false; + mesh.renderOrder = 2; + return mesh; +} diff --git a/examples/prd476-fluid-particles/src/state.ts b/examples/prd476-fluid-particles/src/state.ts new file mode 100644 index 0000000000..79bb9cd3e5 --- /dev/null +++ b/examples/prd476-fluid-particles/src/state.ts @@ -0,0 +1,45 @@ +export interface IFluidParticlesState extends Record { + count: number; + steps: number; + meanCompression: number; + maxSpeed: number; + frontX: number; + peakFrontX: number; + minY: number; + maxY: number; + inBounds: number; + released: number; + staleFrames: number; + /** Coupling scene: 99 until the body exists, then metres. */ + sphereY: number; + sphereSpeed: number; + boxY: number; + /** |box centre - heightAt(box)|, 99 until the surface has landed. */ + boxSurfaceGap: number; + /** Largest |surface - resting level| seen at the drop point after the sphere is released. */ + surfaceDisturbance: number; + surface: number; + /** Surface height at the drop point before the sphere arrives. */ + surfaceRest: number; +} + +export const INITIAL_STATE: IFluidParticlesState = { + count: 0, + steps: 0, + meanCompression: 1, + maxSpeed: 99, + frontX: 0, + peakFrontX: 0, + minY: 0, + maxY: 0, + inBounds: 0, + released: 0, + staleFrames: 0, + sphereY: 99, + sphereSpeed: 99, + boxY: 99, + boxSurfaceGap: 99, + surfaceDisturbance: 0, + surface: 0, + surfaceRest: 0, +}; diff --git a/examples/prd476-fluid-particles/tsconfig.json b/examples/prd476-fluid-particles/tsconfig.json new file mode 100644 index 0000000000..ea5c89279a --- /dev/null +++ b/examples/prd476-fluid-particles/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["vite/client"] + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 516f952332..a1770f17f3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "check:flight-cost": "tsx scripts/check-flight-cost.ts --max-mean-ms 0.35", "ci:fast": "bash scripts/ci-fast.sh", "ci:local": "bash scripts/ci-local.sh", + "ci:runners": "bash scripts/ci-runners.sh", + "ci:runners:install": "bash scripts/ci-runners.sh install", "coverage": "vitest run --coverage", "gate:doctor": "tsx scripts/gate-cli.ts doctor", "gate:resume": "tsx scripts/gate-cli.ts resume", @@ -75,7 +77,8 @@ "test:ci": "TN_SUITE_PREBUILT=1 bash scripts/run-test-suite.sh", "test:playtest": "pnpm --filter @threenative/playtest build && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtest/moves.json --scenario examples/abyss-framework/playtest/camera.json --scenario examples/abyss-framework/playtests/movement-axis.playtest.json --scenario examples/abyss-framework/playtests/zoom-input.playtest.json --url http://127.0.0.1:5180 --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/navigation.playtest.json --url 'http://127.0.0.1:5180/?navigation' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/streaming.playtest.json --url 'http://127.0.0.1:5180/?streaming' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed", "test:playtest:ci": "test -f packages/playtest/dist/runner/cli.js || { echo 'TN_PREBUILT_PLAYWRIGHT_RUNNER_MISSING: packages/playtest/dist/runner/cli.js' >&2; exit 1; }; sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtest/moves.json --scenario examples/abyss-framework/playtest/camera.json --scenario examples/abyss-framework/playtests/movement-axis.playtest.json --scenario examples/abyss-framework/playtests/zoom-input.playtest.json --url http://127.0.0.1:5180 --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed --allow-software && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/navigation.playtest.json --url 'http://127.0.0.1:5180/?navigation' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed --allow-software", - "test:templates": "sh scripts/xvfb.sh tsx scripts/verify-template-playtests.ts", + "test:templates": "sh scripts/xvfb.sh tsx scripts/verify-template-playtests.ts && pnpm test:tone", + "test:tone": "sh scripts/xvfb.sh tsx scripts/verify-tone-exposure.ts", "test:vfx-gallery": "pnpm --filter @threenative/playtest build && pnpm --filter @threenative/core build && pnpm --filter vfx-gallery build && tsx scripts/vfx-gallery-visual.ts", "typecheck": "tsc --noEmit -p tsconfig.json && pnpm -r --if-present run typecheck", "verify:golden-path": "sh scripts/xvfb.sh tsx scripts/verify-golden-path.ts", diff --git a/packages/assets/__tests__/audio-fixtures.ts b/packages/assets/__tests__/audio-fixtures.ts index 6dde64b379..299d5342c7 100644 --- a/packages/assets/__tests__/audio-fixtures.ts +++ b/packages/assets/__tests__/audio-fixtures.ts @@ -63,11 +63,13 @@ function writeSample( bits: number, float: boolean, ): void { - const clamped = Math.max(-1, Math.min(1, value)); + // Float32 WAV is not clamped: values above 1.0 are the headroom a Float32 source can hold, and a + // fixture that silently clipped them would make that unrepresentable in any test. if (float) { - buffer.writeFloatLE(clamped, at); + buffer.writeFloatLE(value, at); return; } + const clamped = Math.max(-1, Math.min(1, value)); if (bits === 8) { buffer.writeUInt8(Math.round(clamped * 127) + 128, at); return; diff --git a/packages/assets/__tests__/audio-pass.spec.ts b/packages/assets/__tests__/audio-pass.spec.ts index 23c68297a5..619dff52ce 100644 --- a/packages/assets/__tests__/audio-pass.spec.ts +++ b/packages/assets/__tests__/audio-pass.spec.ts @@ -573,6 +573,60 @@ describe("the audio pass pass-through", () => { expect(compiled.outputBytes.equals(bed)).toBe(true); }); + it("should ship a float WAV whose samples exceed 1.0 unchanged, because that is headroom", async () => { + // A Float32 WAV is allowed above 1.0 and a storm bed uses it: rain's own wind clip peaks at + // 1.053, and the headroom above full scale is deliberate mix, not a defect to normalise away. + // Clamping it into [-1, 1] here would make that unrepresentable in a test at all. + const above = wavClip({ float: true, frames: RATE, sample: () => 1.053 }); + + const compiled = await compileAudio("threenative-audio-float-headroom-", "wind.wav", above, { + audio: { overrides: [{ glob: "wind.wav", conditioning: "none" }] }, + }); + + expect(compiled.outputBytes.equals(above)).toBe(true); + expect(compiled.entry.audio).toMatchObject({ container: "RIFF/WAVE", reencoded: false }); + expect((compiled.entry.audio as Record).peakBefore).toBeCloseTo(1.053, 6); + expect((compiled.entry.audio as Record).peakAfter).toBeCloseTo(1.053, 6); + }); + + it("should show what the default pass does to that headroom, so the declaration is not free", async () => { + // A sine rather than a constant, because a constant is pure DC and the pass removes DC before + // it looks at the peak at all. + const above = wavClip({ + float: true, + frames: RATE, + sample: (frame) => 1.053 * Math.sin((2 * Math.PI * 220 * frame) / RATE), + }); + + const cooked = await compileAudio("threenative-audio-float-drift-", "wind.wav", above); + + const audio = cooked.entry.audio as Record; + // The ceiling attenuates, and the Vorbis round trip lands a little above it, so the peak the + // player hears is neither the authored one nor its absence. That is the drift the declaration + // exists to prevent, and the tolerance here is the codec's overshoot, not a fudge. + expect(Number(audio.peakAfter)).toBeLessThan(1.053); + expect(Number(audio.peakAfter)).toBeCloseTo(10 ** (-1 / 20), 1); + }); + + it("should assert a declared float loop's seam on the bytes it ships", async () => { + const loop = wavClip({ + float: true, + frames: RATE * 2, + sample: (frame) => 1.053 * Math.sin((2 * Math.PI * 80 * frame) / RATE), + }); + + const compiled = await compileAudio("threenative-audio-float-seam-", "bed.wav", loop, { + audio: { + overrides: [{ conditioning: "none", glob: "bed.wav", loop: { crossFadeMs: 0 } }], + }, + }); + + expect(Number((compiled.entry.audio as Record).seamRatio)).toBeLessThanOrEqual( + 1.5, + ); + expect(compiled.outputBytes.equals(loop)).toBe(true); + }); + it("should ship an already-conditioned Ogg source untouched instead of re-encoding it", async () => { // Sixteen of wildwood's nineteen clips are already mono, already under the ceiling and carry // no DC: re-encoding them cost about 4% more bytes and a generation of lossy Vorbis to diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 659c64933c..298d40e98d 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -52,6 +52,18 @@ six-second walk on a 289-tile ring paid ~270 ms for them: `TN_TERRAIN_VALIDATE=1 `?tnTerrainValidate=1` or `validate: true` runs them, and `maxSeamGap`, `maxVisualSeamGap` and `maxLodPop` report `undefined` while they are off, never an unmeasured `0`. +`SnowField` is the subpath's numerical snow record: indentation, displaced bank, compaction and +disturbance channels over one canonical `Heightfield`, plus load-dependent penetration and bounded +sparse recovery. It writes `terrain + depth + bank - indent` back through +`Heightfield.updateHeights`, so queries, rendered geometry and collider export keep reading one +surface. The game supplies the terrain, the snow depth, the contact footprint, the load in newtons +and the supported area; the field owns no boot, tread, material, particle or camera. `depth`, +`hardness`, `yieldFraction`, `maxBank` and `responseTime` are its only response coefficients. +`heightAt` and `normalAt` keep `Heightfield`'s out-of-region error contract; `sample` returns zeros +outside the resident region so a broad sweep needs no bounds check. It is a heightfield +approximation, never granular snow, avalanche simulation, melting or a calibrated material law, +and it does not encode to a texture — GPU encoding belongs in `src/render/`. + `picking.ts` is the one place a third-party dependency other than `three` and `zustand` reaches core. It is contained deliberately: `ScenePicker` builds a hierarchy on first use, patches no `three` prototype, and a game that never calls `ctx.raycast` never builds one. diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index b6d9c49368..ee7a0076a4 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -54,6 +54,18 @@ six-second walk on a 289-tile ring paid ~270 ms for them: `TN_TERRAIN_VALIDATE=1 `?tnTerrainValidate=1` or `validate: true` runs them, and `maxSeamGap`, `maxVisualSeamGap` and `maxLodPop` report `undefined` while they are off, never an unmeasured `0`. +`SnowField` is the subpath's numerical snow record: indentation, displaced bank, compaction and +disturbance channels over one canonical `Heightfield`, plus load-dependent penetration and bounded +sparse recovery. It writes `terrain + depth + bank - indent` back through +`Heightfield.updateHeights`, so queries, rendered geometry and collider export keep reading one +surface. The game supplies the terrain, the snow depth, the contact footprint, the load in newtons +and the supported area; the field owns no boot, tread, material, particle or camera. `depth`, +`hardness`, `yieldFraction`, `maxBank` and `responseTime` are its only response coefficients. +`heightAt` and `normalAt` keep `Heightfield`'s out-of-region error contract; `sample` returns zeros +outside the resident region so a broad sweep needs no bounds check. It is a heightfield +approximation, never granular snow, avalanche simulation, melting or a calibrated material law, +and it does not encode to a texture — GPU encoding belongs in `src/render/`. + `picking.ts` is the one place a third-party dependency other than `three` and `zustand` reaches core. It is contained deliberately: `ScenePicker` builds a hierarchy on first use, patches no `three` prototype, and a game that never calls `ctx.raycast` never builds one. diff --git a/packages/core/__tests__/audio.spec.ts b/packages/core/__tests__/audio.spec.ts index 32e4594ef3..b27867b317 100644 --- a/packages/core/__tests__/audio.spec.ts +++ b/packages/core/__tests__/audio.spec.ts @@ -1,6 +1,11 @@ import { AudioContext, Object3D, PerspectiveCamera, Vector3 } from "three"; import { describe, expect, it } from "vitest"; -import { AudioBus, audioRuntimeSnapshot, resetAudioCueLedger } from "../src/audio.js"; +import { + AudioBus, + type IAudioCompressorOptions, + audioRuntimeSnapshot, + resetAudioCueLedger, +} from "../src/audio.js"; interface IFakeAudioParam { value: number; @@ -43,15 +48,42 @@ interface IFakeSource { } /** - * @param browser Give the context the parts only a browser has — a biquad filter factory and - * cancellable params. The default is the native host's narrower surface. + * Every edge the fake graph was asked to make, in order. + * + * Routing is the whole question a compressor and a per-voice low-pass turn on — which node feeds + * which — and a fake whose `connect` returns `undefined` can only prove a node was built. These + * two record the edge, so a test can read the chain the bus actually wired. + */ +let graphEdges: Array<{ from: unknown; to: unknown }> = []; + +/** Every gain node the fake handed out, in order: the listener's first, then one per pooled voice. */ +let madeGains: unknown[] = []; + +function connect(this: unknown, to: unknown): void { + graphEdges.push({ from: this, to }); +} + +function disconnect(this: unknown): void { + graphEdges.push({ from: this, to: null }); +} + +/** Did `from` feed `to` at any point? */ +function wired(from: unknown, to: unknown): boolean { + return graphEdges.some((edge) => edge.from === from && edge.to === to); +} + +/** + * @param browser Give the context the parts only a browser has — cancellable params. The default + * is the native host's narrower surface, which now carries the filter and the compressor. */ function audioContext(browser = false): globalThis.AudioContext { + graphEdges = []; + madeGains = []; const context = { createBufferSource: () => ({ - connect: () => undefined, + connect, detune: parameter(0, browser), - disconnect: () => undefined, + disconnect, loop: false, loopEnd: 0, loopStart: 0, @@ -63,15 +95,33 @@ function audioContext(browser = false): globalThis.AudioContext { }, stop: () => undefined, }), - createGain: () => ({ - connect: () => undefined, - disconnect: () => undefined, - gain: parameter(1, browser), + createGain: () => { + const node = { connect, disconnect, gain: parameter(1, browser) }; + madeGains.push(node); + return node; + }, + createDynamicsCompressor: () => ({ + attack: parameter(0.003), + connect, + disconnect, + knee: parameter(30), + ratio: parameter(12), + release: parameter(0.25), + threshold: parameter(-24), + }), + // The native host binds this once the low-pass gap closed, so the "narrow surface" fake + // carries it. Its params are inert like every other native param: they take a write and + // report it back, which is all the portable contract reads. + createBiquadFilter: () => ({ + connect, + disconnect, + frequency: parameter(350), + type: "lowpass" as const, }), createPanner: () => ({ - connect: () => undefined, + connect, distanceModel: "inverse" as const, - disconnect: () => undefined, + disconnect, maxDistance: 10_000, panningModel: "HRTF" as const, refDistance: 1, @@ -83,8 +133,8 @@ function audioContext(browser = false): globalThis.AudioContext { ...(browser ? { createBiquadFilter: () => ({ - connect: () => undefined, - disconnect: () => undefined, + connect, + disconnect, frequency: parameter(20_000, true), type: "lowpass" as const, }), @@ -588,16 +638,120 @@ describe("AudioBus", () => { try { bus.play(buffer, { detune: 40, lowpassHz: 900 }); - // Both are accepted and both do nothing on the native host. Reporting is the difference - // between a mix that is quietly flat everywhere and one a build can be failed on. - expect(bus.unsupported).toEqual(["detune", "lowpassHz"]); - expect(audioRuntimeSnapshot().unsupported).toEqual(["detune", "lowpassHz"]); + // `lowpassHz` is honoured on the native host now, because it binds a biquad. `detune` is + // still an inert stand-in there, and reporting it is the difference between a mix that is + // quietly flat everywhere and one a build can be failed on. + expect(bus.unsupported).toEqual(["detune"]); + expect(audioRuntimeSnapshot().unsupported).toEqual(["detune"]); } finally { bus.dispose(); } }); - it("should report nothing unsupported on a runtime that honours the shaping", async () => { + it("sums every voice into a game-configured compressor ahead of the bus volume", async () => { + const context = audioContext(); + const made: Array> = []; + const original = context.createDynamicsCompressor as unknown as () => unknown; + context.createDynamicsCompressor = () => { + const node = original.call(context) as Record; + made.push(node); + return node as unknown as DynamicsCompressorNode; + }; + const bus = new AudioBus({ + camera: new PerspectiveCamera(), + compressor: { attack: 0.004, knee: 6, ratio: 5, release: 0.18, threshold: -15 }, + gestureTarget: new EventTarget(), + }); + + try { + await bus.unlock(); + // The mix the source ran: a compressor holding three summed voices down. Every number is + // the game's, so the bus must not invent one. + expect(made).toHaveLength(1); + const node = made[0]; + const values = Object.fromEntries( + Object.entries(node ?? {}).map(([name, p]) => [name, p.value]), + ); + expect(values).toEqual({ attack: 0.004, knee: 6, ratio: 5, release: 0.18, threshold: -15 }); + // The compressor is reachable so a mixer can read `reduction` off it. + expect(bus.compressor).toBe(node); + + // The routing, which is the part that was wrong: voices reach the compressor, the + // compressor reaches the bus volume gain. Three's own `listener.filter` slot would have put + // it on the far side of that gain, compressing a level the volume had already moved. + const masterGain = bus.listener.gain; + expect(madeGains[0]).toBe(masterGain); + expect(wired(node, masterGain)).toBe(true); + bus.play(buffer); + expect(madeGains[1]).not.toBe(masterGain); + expect(wired(madeGains[1], node)).toBe(true); + expect(bus.listener.getFilter()).toBeNull(); + + bus.dispose(); + // And it leaves with the bus, so a disposed bus is not still holding voices. + expect(graphEdges).toContainEqual({ from: node, to: null }); + } finally { + bus.dispose(); + } + }); + + it("should fail closed on a compressor option it cannot honour", () => { + audioContext(); + const camera = new PerspectiveCamera(); + const at = (over: Partial) => () => + new AudioBus({ + camera, + compressor: { attack: 0.01, knee: 6, ratio: 5, release: 0.2, threshold: -15, ...over }, + }); + + expect(at({ attack: Number.NaN })).toThrow(/compressor\.attack/u); + expect(at({ attack: 1.5 })).toThrow(/compressor\.attack/u); + expect(at({ release: 1.5 })).toThrow(/compressor\.release/u); + expect(at({ release: Number.POSITIVE_INFINITY })).toThrow(/compressor\.release/u); + expect(at({ knee: -1 })).toThrow(/compressor\.knee/u); + expect(at({ knee: 41 })).toThrow(/compressor\.knee/u); + expect(at({ ratio: 0.5 })).toThrow(/compressor\.ratio/u); + expect(at({ ratio: 21 })).toThrow(/compressor\.ratio/u); + expect(at({ threshold: 12 })).toThrow(/compressor\.threshold/u); + expect(at({ threshold: -140 })).toThrow(/compressor\.threshold/u); + // A half-spelled compressor is not a compressor with defaults; it is a mistake. + expect( + () => + new AudioBus({ + camera, + compressor: { + knee: 6, + ratio: 5, + release: 0.2, + threshold: -15, + } as unknown as IAudioCompressorOptions, + }), + ).toThrow(/compressor\.attack/u); + + // Zero is inside the platform's range for attack and release — an instant response, not a + // mistake — so refusing it would be this engine being stricter than the Web Audio it mirrors. + for (const instant of [0, 1]) { + const bus = new AudioBus({ + camera, + compressor: { attack: instant, knee: 0, ratio: 20, release: instant, threshold: -100 }, + }); + expect(bus.compressor).toBeDefined(); + bus.dispose(); + } + + // A runtime with no dynamics node is named, not silently left uncompressed. + const bare = audioContext(); + Reflect.deleteProperty(bare, "createDynamicsCompressor"); + const bus = new AudioBus({ + camera, + compressor: { attack: 0.01, knee: 6, ratio: 5, release: 0.2, threshold: -15 }, + }); + expect(bus.unsupported).toEqual(["compressor"]); + expect(bus.compressor).toBeUndefined(); + bus.dispose(); + }); + + it("reports nothing unsupported for a bus that honours the shaping", async () => { audioContext(true); const bus = new AudioBus({ camera: new PerspectiveCamera() }); await bus.unlock(); diff --git a/packages/core/__tests__/fixtures/snow-field-source.json b/packages/core/__tests__/fixtures/snow-field-source.json new file mode 100644 index 0000000000..e7b822e343 --- /dev/null +++ b/packages/core/__tests__/fixtures/snow-field-source.json @@ -0,0 +1,3401 @@ +{ + "channels": ["indent", "bank", "compaction", "disturbance"], + "points": [ + [-0.45, -0.45], + [-0.4, -0.45], + [-0.35, -0.45], + [-0.3, -0.45], + [-0.25, -0.45], + [-0.2, -0.45], + [-0.15, -0.45], + [-0.1, -0.45], + [-0.05, -0.45], + [0, -0.45], + [0.05, -0.45], + [0.1, -0.45], + [0.15, -0.45], + [0.2, -0.45], + [0.25, -0.45], + [0.3, -0.45], + [0.35, -0.45], + [0.4, -0.45], + [0.45, -0.45], + [0.5, -0.45], + [0.55, -0.45], + [0.6, -0.45], + [0.65, -0.45], + [0.7, -0.45], + [0.75, -0.45], + [0.8, -0.45], + [0.85, -0.45], + [0.9, -0.45], + [0.95, -0.45], + [-0.45, -0.4], + [-0.4, -0.4], + [-0.35, -0.4], + [-0.3, -0.4], + [-0.25, -0.4], + [-0.2, -0.4], + [-0.15, -0.4], + [-0.1, -0.4], + [-0.05, -0.4], + [0, -0.4], + [0.05, -0.4], + [0.1, -0.4], + [0.15, -0.4], + [0.2, -0.4], + [0.25, -0.4], + [0.3, -0.4], + [0.35, -0.4], + [0.4, -0.4], + [0.45, -0.4], + [0.5, -0.4], + [0.55, -0.4], + [0.6, -0.4], + [0.65, -0.4], + [0.7, -0.4], + [0.75, -0.4], + [0.8, -0.4], + [0.85, -0.4], + [0.9, -0.4], + [0.95, -0.4], + [-0.45, -0.35], + [-0.4, -0.35], + [-0.35, -0.35], + [-0.3, -0.35], + [-0.25, -0.35], + [-0.2, -0.35], + [-0.15, -0.35], + [-0.1, -0.35], + [-0.05, -0.35], + [0, -0.35], + [0.05, -0.35], + [0.1, -0.35], + [0.15, -0.35], + [0.2, -0.35], + [0.25, -0.35], + [0.3, -0.35], + [0.35, -0.35], + [0.4, -0.35], + [0.45, -0.35], + [0.5, -0.35], + [0.55, -0.35], + [0.6, -0.35], + [0.65, -0.35], + [0.7, -0.35], + [0.75, -0.35], + [0.8, -0.35], + [0.85, -0.35], + [0.9, -0.35], + [0.95, -0.35], + [-0.45, -0.3], + [-0.4, -0.3], + [-0.35, -0.3], + [-0.3, -0.3], + [-0.25, -0.3], + [-0.2, -0.3], + [-0.15, -0.3], + [-0.1, -0.3], + [-0.05, -0.3], + [0, -0.3], + [0.05, -0.3], + [0.1, -0.3], + [0.15, -0.3], + [0.2, -0.3], + [0.25, -0.3], + [0.3, -0.3], + [0.35, -0.3], + [0.4, -0.3], + [0.45, -0.3], + [0.5, -0.3], + [0.55, -0.3], + [0.6, -0.3], + [0.65, -0.3], + [0.7, -0.3], + [0.75, -0.3], + [0.8, -0.3], + [0.85, -0.3], + [0.9, -0.3], + [0.95, -0.3], + [-0.45, -0.25], + [-0.4, -0.25], + [-0.35, -0.25], + [-0.3, -0.25], + [-0.25, -0.25], + [-0.2, -0.25], + [-0.15, -0.25], + [-0.1, -0.25], + [-0.05, -0.25], + [0, -0.25], + [0.05, -0.25], + [0.1, -0.25], + [0.15, -0.25], + [0.2, -0.25], + [0.25, -0.25], + [0.3, -0.25], + [0.35, -0.25], + [0.4, -0.25], + [0.45, -0.25], + [0.5, -0.25], + [0.55, -0.25], + [0.6, -0.25], + [0.65, -0.25], + [0.7, -0.25], + [0.75, -0.25], + [0.8, -0.25], + [0.85, -0.25], + [0.9, -0.25], + [0.95, -0.25], + [-0.45, -0.2], + [-0.4, -0.2], + [-0.35, -0.2], + [-0.3, -0.2], + [-0.25, -0.2], + [-0.2, -0.2], + [-0.15, -0.2], + [-0.1, -0.2], + [-0.05, -0.2], + [0, -0.2], + [0.05, -0.2], + [0.1, -0.2], + [0.15, -0.2], + [0.2, -0.2], + [0.25, -0.2], + [0.3, -0.2], + [0.35, -0.2], + [0.4, -0.2], + [0.45, -0.2], + [0.5, -0.2], + [0.55, -0.2], + [0.6, -0.2], + [0.65, -0.2], + [0.7, -0.2], + [0.75, -0.2], + [0.8, -0.2], + [0.85, -0.2], + [0.9, -0.2], + [0.95, -0.2], + [-0.45, -0.15], + [-0.4, -0.15], + [-0.35, -0.15], + [-0.3, -0.15], + [-0.25, -0.15], + [-0.2, -0.15], + [-0.15, -0.15], + [-0.1, -0.15], + [-0.05, -0.15], + [0, -0.15], + [0.05, -0.15], + [0.1, -0.15], + [0.15, -0.15], + [0.2, -0.15], + [0.25, -0.15], + [0.3, -0.15], + [0.35, -0.15], + [0.4, -0.15], + [0.45, -0.15], + [0.5, -0.15], + [0.55, -0.15], + [0.6, -0.15], + [0.65, -0.15], + [0.7, -0.15], + [0.75, -0.15], + [0.8, -0.15], + [0.85, -0.15], + [0.9, -0.15], + [0.95, -0.15], + [-0.45, -0.1], + [-0.4, -0.1], + [-0.35, -0.1], + [-0.3, -0.1], + [-0.25, -0.1], + [-0.2, -0.1], + [-0.15, -0.1], + [-0.1, -0.1], + [-0.05, -0.1], + [0, -0.1], + [0.05, -0.1], + [0.1, -0.1], + [0.15, -0.1], + [0.2, -0.1], + [0.25, -0.1], + [0.3, -0.1], + [0.35, -0.1], + [0.4, -0.1], + [0.45, -0.1], + [0.5, -0.1], + [0.55, -0.1], + [0.6, -0.1], + [0.65, -0.1], + [0.7, -0.1], + [0.75, -0.1], + [0.8, -0.1], + [0.85, -0.1], + [0.9, -0.1], + [0.95, -0.1], + [-0.45, -0.05], + [-0.4, -0.05], + [-0.35, -0.05], + [-0.3, -0.05], + [-0.25, -0.05], + [-0.2, -0.05], + [-0.15, -0.05], + [-0.1, -0.05], + [-0.05, -0.05], + [0, -0.05], + [0.05, -0.05], + [0.1, -0.05], + [0.15, -0.05], + [0.2, -0.05], + [0.25, -0.05], + [0.3, -0.05], + [0.35, -0.05], + [0.4, -0.05], + [0.45, -0.05], + [0.5, -0.05], + [0.55, -0.05], + [0.6, -0.05], + [0.65, -0.05], + [0.7, -0.05], + [0.75, -0.05], + [0.8, -0.05], + [0.85, -0.05], + [0.9, -0.05], + [0.95, -0.05], + [-0.45, 0], + [-0.4, 0], + [-0.35, 0], + [-0.3, 0], + [-0.25, 0], + [-0.2, 0], + [-0.15, 0], + [-0.1, 0], + [-0.05, 0], + [0, 0], + [0.05, 0], + [0.1, 0], + [0.15, 0], + [0.2, 0], + [0.25, 0], + [0.3, 0], + [0.35, 0], + [0.4, 0], + [0.45, 0], + [0.5, 0], + [0.55, 0], + [0.6, 0], + [0.65, 0], + [0.7, 0], + [0.75, 0], + [0.8, 0], + [0.85, 0], + [0.9, 0], + [0.95, 0], + [-0.45, 0.05], + [-0.4, 0.05], + [-0.35, 0.05], + [-0.3, 0.05], + [-0.25, 0.05], + [-0.2, 0.05], + [-0.15, 0.05], + [-0.1, 0.05], + [-0.05, 0.05], + [0, 0.05], + [0.05, 0.05], + [0.1, 0.05], + [0.15, 0.05], + [0.2, 0.05], + [0.25, 0.05], + [0.3, 0.05], + [0.35, 0.05], + [0.4, 0.05], + [0.45, 0.05], + [0.5, 0.05], + [0.55, 0.05], + [0.6, 0.05], + [0.65, 0.05], + [0.7, 0.05], + [0.75, 0.05], + [0.8, 0.05], + [0.85, 0.05], + [0.9, 0.05], + [0.95, 0.05], + [-0.45, 0.1], + [-0.4, 0.1], + [-0.35, 0.1], + [-0.3, 0.1], + [-0.25, 0.1], + [-0.2, 0.1], + [-0.15, 0.1], + [-0.1, 0.1], + [-0.05, 0.1], + [0, 0.1], + [0.05, 0.1], + [0.1, 0.1], + [0.15, 0.1], + [0.2, 0.1], + [0.25, 0.1], + [0.3, 0.1], + [0.35, 0.1], + [0.4, 0.1], + [0.45, 0.1], + [0.5, 0.1], + [0.55, 0.1], + [0.6, 0.1], + [0.65, 0.1], + [0.7, 0.1], + [0.75, 0.1], + [0.8, 0.1], + [0.85, 0.1], + [0.9, 0.1], + [0.95, 0.1], + [-0.45, 0.15], + [-0.4, 0.15], + [-0.35, 0.15], + [-0.3, 0.15], + [-0.25, 0.15], + [-0.2, 0.15], + [-0.15, 0.15], + [-0.1, 0.15], + [-0.05, 0.15], + [0, 0.15], + [0.05, 0.15], + [0.1, 0.15], + [0.15, 0.15], + [0.2, 0.15], + [0.25, 0.15], + [0.3, 0.15], + [0.35, 0.15], + [0.4, 0.15], + [0.45, 0.15], + [0.5, 0.15], + [0.55, 0.15], + [0.6, 0.15], + [0.65, 0.15], + [0.7, 0.15], + [0.75, 0.15], + [0.8, 0.15], + [0.85, 0.15], + [0.9, 0.15], + [0.95, 0.15], + [-0.45, 0.2], + [-0.4, 0.2], + [-0.35, 0.2], + [-0.3, 0.2], + [-0.25, 0.2], + [-0.2, 0.2], + [-0.15, 0.2], + [-0.1, 0.2], + [-0.05, 0.2], + [0, 0.2], + [0.05, 0.2], + [0.1, 0.2], + [0.15, 0.2], + [0.2, 0.2], + [0.25, 0.2], + [0.3, 0.2], + [0.35, 0.2], + [0.4, 0.2], + [0.45, 0.2], + [0.5, 0.2], + [0.55, 0.2], + [0.6, 0.2], + [0.65, 0.2], + [0.7, 0.2], + [0.75, 0.2], + [0.8, 0.2], + [0.85, 0.2], + [0.9, 0.2], + [0.95, 0.2], + [-0.45, 0.25], + [-0.4, 0.25], + [-0.35, 0.25], + [-0.3, 0.25], + [-0.25, 0.25], + [-0.2, 0.25], + [-0.15, 0.25], + [-0.1, 0.25], + [-0.05, 0.25], + [0, 0.25], + [0.05, 0.25], + [0.1, 0.25], + [0.15, 0.25], + [0.2, 0.25], + [0.25, 0.25], + [0.3, 0.25], + [0.35, 0.25], + [0.4, 0.25], + [0.45, 0.25], + [0.5, 0.25], + [0.55, 0.25], + [0.6, 0.25], + [0.65, 0.25], + [0.7, 0.25], + [0.75, 0.25], + [0.8, 0.25], + [0.85, 0.25], + [0.9, 0.25], + [0.95, 0.25], + [-0.45, 0.3], + [-0.4, 0.3], + [-0.35, 0.3], + [-0.3, 0.3], + [-0.25, 0.3], + [-0.2, 0.3], + [-0.15, 0.3], + [-0.1, 0.3], + [-0.05, 0.3], + [0, 0.3], + [0.05, 0.3], + [0.1, 0.3], + [0.15, 0.3], + [0.2, 0.3], + [0.25, 0.3], + [0.3, 0.3], + [0.35, 0.3], + [0.4, 0.3], + [0.45, 0.3], + [0.5, 0.3], + [0.55, 0.3], + [0.6, 0.3], + [0.65, 0.3], + [0.7, 0.3], + [0.75, 0.3], + [0.8, 0.3], + [0.85, 0.3], + [0.9, 0.3], + [0.95, 0.3], + [-0.45, 0.35], + [-0.4, 0.35], + [-0.35, 0.35], + [-0.3, 0.35], + [-0.25, 0.35], + [-0.2, 0.35], + [-0.15, 0.35], + [-0.1, 0.35], + [-0.05, 0.35], + [0, 0.35], + [0.05, 0.35], + [0.1, 0.35], + [0.15, 0.35], + [0.2, 0.35], + [0.25, 0.35], + [0.3, 0.35], + [0.35, 0.35], + [0.4, 0.35], + [0.45, 0.35], + [0.5, 0.35], + [0.55, 0.35], + [0.6, 0.35], + [0.65, 0.35], + [0.7, 0.35], + [0.75, 0.35], + [0.8, 0.35], + [0.85, 0.35], + [0.9, 0.35], + [0.95, 0.35], + [-0.45, 0.4], + [-0.4, 0.4], + [-0.35, 0.4], + [-0.3, 0.4], + [-0.25, 0.4], + [-0.2, 0.4], + [-0.15, 0.4], + [-0.1, 0.4], + [-0.05, 0.4], + [0, 0.4], + [0.05, 0.4], + [0.1, 0.4], + [0.15, 0.4], + [0.2, 0.4], + [0.25, 0.4], + [0.3, 0.4], + [0.35, 0.4], + [0.4, 0.4], + [0.45, 0.4], + [0.5, 0.4], + [0.55, 0.4], + [0.6, 0.4], + [0.65, 0.4], + [0.7, 0.4], + [0.75, 0.4], + [0.8, 0.4], + [0.85, 0.4], + [0.9, 0.4], + [0.95, 0.4], + [-0.45, 0.45], + [-0.4, 0.45], + [-0.35, 0.45], + [-0.3, 0.45], + [-0.25, 0.45], + [-0.2, 0.45], + [-0.15, 0.45], + [-0.1, 0.45], + [-0.05, 0.45], + [0, 0.45], + [0.05, 0.45], + [0.1, 0.45], + [0.15, 0.45], + [0.2, 0.45], + [0.25, 0.45], + [0.3, 0.45], + [0.35, 0.45], + [0.4, 0.45], + [0.45, 0.45], + [0.5, 0.45], + [0.55, 0.45], + [0.6, 0.45], + [0.65, 0.45], + [0.7, 0.45], + [0.75, 0.45], + [0.8, 0.45], + [0.85, 0.45], + [0.9, 0.45], + [0.95, 0.45], + [3.25, -2.5], + [3.3, -2.5], + [3.35, -2.5], + [3.4, -2.5], + [3.45, -2.5], + [3.5, -2.5], + [3.55, -2.5], + [3.6, -2.5], + [3.65, -2.5], + [3.7, -2.5], + [3.75, -2.5], + [3.25, -2.45], + [3.3, -2.45], + [3.35, -2.45], + [3.4, -2.45], + [3.45, -2.45], + [3.5, -2.45], + [3.55, -2.45], + [3.6, -2.45], + [3.65, -2.45], + [3.7, -2.45], + [3.75, -2.45], + [3.25, -2.4], + [3.3, -2.4], + [3.35, -2.4], + [3.4, -2.4], + [3.45, -2.4], + [3.5, -2.4], + [3.55, -2.4], + [3.6, -2.4], + [3.65, -2.4], + [3.7, -2.4], + [3.75, -2.4], + [3.25, -2.35], + [3.3, -2.35], + [3.35, -2.35], + [3.4, -2.35], + [3.45, -2.35], + [3.5, -2.35], + [3.55, -2.35], + [3.6, -2.35], + [3.65, -2.35], + [3.7, -2.35], + [3.75, -2.35], + [3.25, -2.3], + [3.3, -2.3], + [3.35, -2.3], + [3.4, -2.3], + [3.45, -2.3], + [3.5, -2.3], + [3.55, -2.3], + [3.6, -2.3], + [3.65, -2.3], + [3.7, -2.3], + [3.75, -2.3], + [3.25, -2.25], + [3.3, -2.25], + [3.35, -2.25], + [3.4, -2.25], + [3.45, -2.25], + [3.5, -2.25], + [3.55, -2.25], + [3.6, -2.25], + [3.65, -2.25], + [3.7, -2.25], + [3.75, -2.25], + [3.25, -2.2], + [3.3, -2.2], + [3.35, -2.2], + [3.4, -2.2], + [3.45, -2.2], + [3.5, -2.2], + [3.55, -2.2], + [3.6, -2.2], + [3.65, -2.2], + [3.7, -2.2], + [3.75, -2.2], + [3.25, -2.15], + [3.3, -2.15], + [3.35, -2.15], + [3.4, -2.15], + [3.45, -2.15], + [3.5, -2.15], + [3.55, -2.15], + [3.6, -2.15], + [3.65, -2.15], + [3.7, -2.15], + [3.75, -2.15], + [3.25, -2.1], + [3.3, -2.1], + [3.35, -2.1], + [3.4, -2.1], + [3.45, -2.1], + [3.5, -2.1], + [3.55, -2.1], + [3.6, -2.1], + [3.65, -2.1], + [3.7, -2.1], + [3.75, -2.1], + [3.25, -2.05], + [3.3, -2.05], + [3.35, -2.05], + [3.4, -2.05], + [3.45, -2.05], + [3.5, -2.05], + [3.55, -2.05], + [3.6, -2.05], + [3.65, -2.05], + [3.7, -2.05], + [3.75, -2.05], + [3.25, -2], + [3.3, -2], + [3.35, -2], + [3.4, -2], + [3.45, -2], + [3.5, -2], + [3.55, -2], + [3.6, -2], + [3.65, -2], + [3.7, -2], + [3.75, -2] + ], + "scenario": { + "depth": 0.28, + "depthOverride": 0.12, + "recovery": { "deposition": 0.0012, "dt": 0.016666666666666666, "steps": 120, "wind": 0.4 }, + "resolution": 256, + "size": 8, + "stamps": [ + { "angle": 0, "hardness": 0.3, "mass": 80, "x": 0, "z": 0 }, + { "angle": 0.9, "hardness": 0.55, "mass": 80, "x": 0.62, "z": 0.18 }, + { "angle": -0.4, "hardness": 0.1, "mass": 80, "x": 3.5, "z": -2.25 } + ] + }, + "source": "ICEFIELD.html lines 85-193, sha256 85b170815f8f149a9bea757f80d4362cb5e0cc6b2e33dbf5ab1efab9a4dd02f7", + "afterStamps": { + "activeCells": 995, + "samples": [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000035703, 0, 0.000599934], + [0, 0.000405119, 0, 0.005633255], + [0, 0.000538718, 0, 0.007871535], + [0, 0.000532961, 0, 0.007871606], + [0, 0.000617861, 0, 0.007871535], + [0, 0.000408953, 0, 0.005633255], + [0, 0.000030732, 0, 0.000599934], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000071264, 0, 0.000915865], + [0, 0.003760299, 0, 0.064208327], + [0, 0.020084126, 0, 0.277716852], + [0, 0.027941626, 0, 0.330799313], + [0, 0.023029346, 0, 0.330800657], + [0, 0.017840283, 0, 0.330799313], + [0, 0.018343353, 0, 0.277716852], + [0, 0.005290187, 0, 0.064208327], + [0, 0.000053733, 0, 0.000915865], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002147998, 0, 0.031283871], + [0, 0.028038135, 0, 0.371994022], + [0.018600822, 0.01217859, 0.154569686, 0.397090864], + [0.032534779, 0.009958278, 0.272997692, 0.515769904], + [0.02905577, 0.012080435, 0.273001228, 0.515773411], + [0.032534779, 0.012366598, 0.272997692, 0.515769904], + [0.018600822, 0.01341942, 0.154569686, 0.397090864], + [0, 0.02415247, 0, 0.371994022], + [0, 0.00236099, 0, 0.031283871], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 4.19e-7, 0, 0.000008391], + [0, 0.004935801, 0, 0.066831582], + [0, 0.022543855, 0, 0.354962819], + [0.073344391, 0, 0.610203097, 0.773387963], + [0.094187243, 0, 0.788999975, 1], + [0.093049863, 0, 0.788999975, 1], + [0.094187243, 0, 0.788999975, 1], + [0.073344391, 0, 0.610203097, 0.773387963], + [0, 0.026266987, 0, 0.354962819], + [0, 0.00444189, 0, 0.066831582], + [0, 4.24e-7, 0, 0.000008391], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00000548, 0, 0.000077666], + [0, 0.004849419, 0, 0.072292401], + [0, 0.025343678, 0, 0.347090297], + [0.075971119, 0, 0.628883886, 0.797064507], + [0.093807134, 0, 0.788999975, 1], + [0.093649067, 0, 0.788999975, 1], + [0.093807134, 0, 0.788999975, 1], + [0.075971119, 0, 0.628883886, 0.797064507], + [0, 0.022726852, 0, 0.347090297], + [0, 0.005240806, 0, 0.072292401], + [0, 0.000005742, 0, 0.000077666], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00003654, 0, 0.000830616], + [0, 0.000248805, 0, 0.004815521], + [0, 0.000104484, 0, 0.002264198], + [0, 0.00000566, 0, 0.000099314], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000008365, 0, 0.00011807], + [0, 0.005982716, 0, 0.082747657], + [0.000035566, 0.021774311, 0.000286691, 0.332525472], + [0.078746187, 0, 0.660099006, 0.836627383], + [0.096790859, 0, 0.788999975, 1], + [0.092486952, 0, 0.788999975, 1], + [0.096790859, 0, 0.788999975, 1], + [0.078746187, 0, 0.660099006, 0.836627383], + [0.000035566, 0.023933547, 0.000286691, 0.332525472], + [0, 0.005617323, 0, 0.082747657], + [0, 0.000008024, 0, 0.00011807], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000651755, 0, 0.011932793], + [0, 0.006758632, 0, 0.163115198], + [0, 0.014978947, 0, 0.294104705], + [0, 0.015060322, 0, 0.243539342], + [0, 0.002255629, 0, 0.052214956], + [0, 0.000043012, 0, 0.001154393], + [0, 5.38e-7, 0, 0.00000879], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000013406, 0, 0.000193931], + [0, 0.006539432, 0, 0.097366123], + [0.000602873, 0.022802183, 0.005148844, 0.313333687], + [0.086322183, 0, 0.696212279, 0.882398331], + [0.099103782, 0, 0.788999975, 1], + [0.096387144, 0, 0.788999975, 1], + [0.099103782, 0, 0.788999975, 1], + [0.086322183, 0, 0.696212279, 0.882398331], + [0.000602873, 0.020714984, 0.005148844, 0.313333687], + [0, 0.006993052, 0, 0.097366123], + [0, 0.000014269, 0, 0.000193931], + [0, 0, 0, 0], + [0, 0.000053955, 0, 0.001475737], + [0, 0.006786654, 0, 0.141621431], + [0, 0.022032062, 0, 0.410183427], + [0.018058237, 0.002265099, 0.223765778, 0.309117222], + [0.001356602, 0.014360191, 0.019168744, 0.266678796], + [0, 0.020369235, 0, 0.391515791], + [0, 0.003266921, 0, 0.082213857], + [0, 0.000158067, 0, 0.00271444], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000022185, 0, 0.000324396], + [0, 0.008157867, 0, 0.11486867], + [0.002559091, 0.019751675, 0.020001691, 0.292512006], + [0.093622403, 0, 0.728926271, 0.923860939], + [0.096671458, 0, 0.788999975, 1], + [0.09918569, 0, 0.788999975, 1], + [0.096671458, 0, 0.788999975, 1], + [0.093622403, 0, 0.728926271, 0.923860939], + [0.002559091, 0.020629148, 0.020001691, 0.292512006], + [0, 0.00792807, 0, 0.11486867], + [0, 0.000021302, 0, 0.000324396], + [0, 0.000041845, 0, 0.000893688], + [0, 0.004940509, 0, 0.095857396], + [0.000499557, 0.019988515, 0.006534515, 0.390358752], + [0.025258226, 0.002078524, 0.33800573, 0.446861105], + [0.065777926, 0, 0.82078192, 0.96961835], + [0.057536448, 0, 0.71064973, 0.839515344], + [0.019853104, 0.005875143, 0.242976991, 0.420023752], + [0, 0.020655479, 0, 0.382731869], + [0, 0.010947881, 0, 0.205876769], + [0, 0.001114326, 0, 0.025450763], + [0, 0.000007543, 0, 0.000153819], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000033294, 0, 0.000500484], + [0, 0.009088855, 0, 0.132496313], + [0.005260505, 0.018783491, 0.040131336, 0.267692207], + [0.095599199, 0, 0.753679813, 0.955234252], + [0.102382902, 0, 0.788999975, 1], + [0.101353987, 0, 0.788999975, 1], + [0.102382902, 0, 0.788999975, 1], + [0.095599199, 0, 0.753679813, 0.955234252], + [0.005260505, 0.017917798, 0.040131336, 0.267692207], + [0, 0.00933763, 0, 0.132496313], + [0, 0.000036419, 0, 0.000520953], + [0, 0.001165021, 0, 0.023917343], + [0, 0.022368297, 0, 0.379195845], + [0.012074262, 0.006962687, 0.166679427, 0.380370243], + [0.060980928, 0, 0.799995713, 0.945062877], + [0.06488937, 0, 0.846499979, 1], + [0.068942852, 0, 0.846499979, 1], + [0.068719378, 0, 0.839506875, 0.991738786], + [0.034499525, 0.001569376, 0.41155218, 0.517713506], + [0.00275499, 0.023191517, 0.03223732, 0.409280722], + [0, 0.013691512, 0, 0.321384386], + [0, 0.001607219, 0, 0.042214908], + [0, 0.000144131, 0, 0.002380918], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00004619, 0, 0.000678265], + [0, 0.010293001, 0, 0.146216106], + [0.007709117, 0.01021141, 0.05825951, 0.212426338], + [0.097571084, 0, 0.767541419, 0.972802814], + [0.104874093, 0, 0.788999975, 1], + [0.1025517, 0, 0.788999975, 1], + [0.104874093, 0, 0.788999975, 1], + [0.097571084, 0, 0.767541419, 0.972802814], + [0.007709117, 0.00854759, 0.05825951, 0.212426338], + [0, 0.010213657, 0, 0.146216106], + [0, 0.000069129, 0, 0.001092548], + [0, 0.00456715, 0, 0.100219958], + [0.002491793, 0.012976398, 0.030876572, 0.269290947], + [0.057111011, 0, 0.709835881, 0.838553905], + [0.064705619, 0, 0.846499901, 0.999999907], + [0.066894214, 0, 0.846499979, 1], + [0.067324807, 0, 0.846499979, 1], + [0.06960851, 0, 0.846499979, 1], + [0.065131286, 0, 0.83339493, 0.984518547], + [0.055204078, 0, 0.636894802, 0.752386091], + [0.013057399, 0.010088329, 0.148646526, 0.381492593], + [0, 0.020854034, 0, 0.417889166], + [0, 0.006870216, 0, 0.137181566], + [0, 0.000239647, 0, 0.004187725], + [0, 0.000001373, 0, 0.00003433], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000050626, 0, 0.000764218], + [0, 0.010591394, 0, 0.152062941], + [0.00868918, 0.008606454, 0.066347217, 0.223452099], + [0.10326156, 0, 0.772383702, 0.978940072], + [0.103643746, 0, 0.788999975, 1], + [0.09924269, 0, 0.788999975, 1], + [0.103643746, 0, 0.788999975, 1], + [0.10326156, 0, 0.772383702, 0.978940072], + [0.00868918, 0.01035817, 0.066347217, 0.223452099], + [0, 0.010644467, 0, 0.152062941], + [0, 0.000068487, 0, 0.001017918], + [0, 0.00342837, 0, 0.074207094], + [0.000177135, 0.017077518, 0.002194929, 0.358274797], + [0.03715465, 0.003366579, 0.467958393, 0.606630614], + [0.066632571, 0, 0.825030097, 0.97463686], + [0.068841251, 0, 0.846499979, 1], + [0.061297624, 0, 0.846499979, 1], + [0.070080035, 0, 0.846499979, 1], + [0.072754938, 0, 0.846499979, 1], + [0.068456591, 0, 0.846499979, 1], + [0.066468228, 0, 0.75755761, 0.89492926], + [0.022099846, 0, 0.2466818, 0.29141383], + [0.001866689, 0.019948359, 0.020817651, 0.402441943], + [0, 0.007849952, 0, 0.196557266], + [0, 0.000725833, 0, 0.012251181], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00005303, 0, 0.000766015], + [0, 0.010680219, 0, 0.152171837], + [0.008804639, 0.01050638, 0.066449991, 0.223593271], + [0.101890195, 0, 0.772356784, 0.978905927], + [0.102230046, 0, 0.788999975, 1], + [0.103576601, 0, 0.788999975, 1], + [0.102230046, 0, 0.788999975, 1], + [0.101890195, 0, 0.772356784, 0.978905927], + [0.008804639, 0.008299192, 0.066449991, 0.223593271], + [0, 0.01061468, 0, 0.152171837], + [0, 0.000050253, 0, 0.00077326], + [0, 0.000367486, 0, 0.0073999], + [0, 0.010890123, 0, 0.206619384], + [0.001106367, 0.02018717, 0.013653037, 0.436316418], + [0.025764218, 0.002226467, 0.332110482, 0.436751056], + [0.068348223, 0, 0.826017181, 0.975802946], + [0.071249769, 0, 0.846499979, 1], + [0.068951926, 0, 0.846499979, 1], + [0.071089581, 0, 0.846499979, 1], + [0.074506367, 0, 0.846499979, 1], + [0.074192417, 0, 0.846499979, 1], + [0.074111421, 0, 0.82654161, 0.976422481], + [0.038902547, 0.001851731, 0.448219156, 0.558642154], + [0, 0.017799625, 0, 0.39290174], + [0, 0.004928018, 0, 0.08979881], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000011162, 0, 0.000211075], + [0, 0.005561418, 0, 0.079497537], + [0.000006165, 0.026290367, 0.000046106, 0.378171491], + [0.030208363, 0.013177246, 0.22592375, 0.46892793], + [0.036503113, 0.011226523, 0.273001228, 0.515773411], + [0.03583558, 0.011512619, 0.273001228, 0.515773411], + [0.036503113, 0.01363486, 0.273001228, 0.515773411], + [0.030208363, 0.01415576, 0.22592375, 0.46892793], + [0.000006165, 0.023568469, 0.000046106, 0.378171491], + [0, 0.005766474, 0, 0.079497537], + [0, 0.000011823, 0, 0.000211075], + [0, 0.000001335, 0, 0.000036782], + [0, 0.000572796, 0, 0.009042708], + [0, 0.007986929, 0, 0.161738191], + [0, 0.016241769, 0, 0.394635052], + [0.023514748, 0.007002985, 0.285072464, 0.446200308], + [0.066975986, 0, 0.783824296, 0.925959013], + [0.070897078, 0, 0.846499979, 1], + [0.073716707, 0, 0.846499979, 1], + [0.072200318, 0, 0.846499979, 1], + [0.072895552, 0, 0.846499979, 1], + [0.075904453, 0, 0.846499979, 1], + [0.056464015, 0, 0.64369987, 0.760425146], + [0.000095008, 0.019933505, 0.001085153, 0.348476932], + [0, 0.005015872, 0, 0.112331775], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000291016, 0, 0.003772369], + [0, 0.006764536, 0, 0.118109665], + [0, 0.023183641, 0, 0.312032468], + [0, 0.028132758, 0, 0.330800657], + [0, 0.022943812, 0, 0.330800657], + [0, 0.018031461, 0, 0.330800657], + [0, 0.0204048, 0, 0.312032468], + [0, 0.009432704, 0, 0.118109665], + [0, 0.000239176, 0, 0.003772369], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000123222, 0, 0.002520441], + [0, 0.004493114, 0, 0.093118775], + [0, 0.020376897, 0, 0.414455348], + [0.010520237, 0.001365878, 0.122924989, 0.1742868], + [0.065406071, 0.000223592, 0.744524296, 0.882354346], + [0.07504564, 0, 0.846499979, 1], + [0.069865699, 0, 0.846499979, 1], + [0.074207917, 0, 0.846499979, 1], + [0.069724427, 0, 0.83036444, 0.98093852], + [0.015496485, 0.00981962, 0.172838331, 0.402189924], + [0, 0.021244745, 0, 0.396235973], + [0, 0.00141391, 0, 0.031778858], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 9.84e-7, 0, 0.000019599], + [0, 0.000127837, 0, 0.00147572], + [0, 0.00044437, 0, 0.00696401], + [0, 0.000476095, 0, 0.007871606], + [0, 0.000560999, 0, 0.007871606], + [0, 0.000555237, 0, 0.007871606], + [0, 0.000461505, 0, 0.00696401], + [0, 0.000116251, 0, 0.00147572], + [0, 0.000001107, 0, 0.000019599], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 4.63e-7, 0, 0.000012542], + [0, 0.000116676, 0, 0.001900417], + [0, 0.005137175, 0, 0.093780712], + [0.000056742, 0.015629876, 0.000642975, 0.376683306], + [0.011840388, 0.012822097, 0.133405636, 0.388048293], + [0.054946292, 0, 0.637672225, 0.753304461], + [0.075806813, 0, 0.845443398, 0.99875182], + [0.068674978, 0, 0.809560891, 0.956362556], + [0.022633138, 0, 0.265010098, 0.313065666], + [0.00028137, 0.019457041, 0.003241381, 0.374175416], + [0, 0.002959685, 0, 0.074305293], + [0, 0.000076313, 0, 0.001344509], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000047179, 0, 0.00083043], + [0, 0.001773941, 0, 0.040416774], + [0, 0.014832955, 0, 0.3424016], + [0.004156747, 0.017914788, 0.05017412, 0.359260085], + [0.048499561, 0.000773256, 0.544355761, 0.661574199], + [0.039140421, 0.002494278, 0.436500854, 0.5547936], + [0, 0.018421567, 0, 0.442374103], + [0, 0.00771897, 0, 0.144594245], + [0, 0.000046378, 0, 0.000994351], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000018636, 0, 0.000361616], + [0, 0.001515939, 0, 0.029370503], + [0, 0.011626355, 0, 0.235313224], + [0.001401362, 0.020209466, 0.015628235, 0.437557867], + [0.001120853, 0.021758119, 0.012499946, 0.434193347], + [0, 0.007472837, 0, 0.184268172], + [0, 0.000566082, 0, 0.009231487], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000217131, 0, 0.001871454], + [0, 0.018326641, 0, 0.148355855], + [0.008456822, 0.02592693, 0.04377579, 0.351931363], + [0.088784135, 0.004501063, 0.459577513, 0.672590443], + [0.042537104, 0.009621285, 0.221653477, 0.380012912], + [0.000600289, 0.03736433, 0.003185588, 0.333195133], + [0, 0.036454737, 0, 0.422655811], + [0, 0.027057849, 0, 0.216470031], + [0, 0.000689632, 0, 0.00903928], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002893137, 0, 0.028102585], + [0, 0.041061901, 0, 0.392523764], + [0.050686975, 0.014914172, 0.262008481, 0.467586203], + [0.143627465, 0, 0.742999971, 1], + [0.142407369, 0, 0.740285891, 0.996347125], + [0.109928552, 0, 0.592343776, 0.797232571], + [0.060809336, 0.013276995, 0.315807434, 0.49707071], + [0.009939433, 0.032140643, 0.05144994, 0.40350916], + [0, 0.015489773, 0, 0.158863941], + [0, 0, 0, 0], + [0, 0.000024972, 0, 0.000302562], + [0, 0.014754982, 0, 0.130412795], + [0, 0.031228702, 0, 0.389381215], + [0.104772918, 0, 0.540796671, 0.727855547], + [0.144856119, 0, 0.742999971, 1], + [0.141403835, 0, 0.742999971, 1], + [0.143724479, 0, 0.742999971, 1], + [0.143549259, 0, 0.742999971, 1], + [0.043088783, 0.015511357, 0.223042443, 0.480191575], + [0, 0.038664038, 0, 0.290329458], + [0, 0.000002197, 0, 0.000020832], + [0, 0.001278782, 0, 0.015540274], + [0, 0.035939443, 0, 0.319325382], + [0.037195697, 0.014872137, 0.196174443, 0.357541061], + [0.14418113, 0, 0.724200047, 0.974697275], + [0.146535546, 0, 0.742999971, 1], + [0.14431986, 0, 0.742999971, 1], + [0.14520586, 0, 0.742999971, 1], + [0.125065214, 0, 0.643388439, 0.865933335], + [0.006552442, 0.029913471, 0.033884798, 0.343025277], + [0, 0.011226771, 0, 0.117120145], + [0, 0.000098847, 0, 0.000896796], + [0, 0.014032069, 0, 0.124107006], + [0.005511944, 0.036371214, 0.027498872, 0.413881733], + [0.121255986, 0.001750178, 0.59043352, 0.808168819], + [0.1473838, 0, 0.742999971, 1], + [0.149302127, 0, 0.742999971, 1], + [0.147704458, 0, 0.742999971, 1], + [0.146828637, 0, 0.740467879, 0.996592069], + [0.063928879, 0, 0.327443729, 0.440704882], + [0, 0.041468909, 0, 0.368757183], + [0, 0.001262307, 0, 0.014576263], + [0, 0.001060498, 0, 0.011665029], + [0, 0.042081902, 0, 0.345431656], + [0.036415535, 0.019599894, 0.173942556, 0.396824163], + [0.143421085, 0, 0.719438776, 0.968289104], + [0.153917738, 0, 0.742999971, 1], + [0.14807751, 0, 0.742999971, 1], + [0.146972875, 0, 0.742999971, 1], + [0.132741388, 0, 0.689382405, 0.927836379], + [0.01846471, 0.018284399, 0.09600393, 0.349321746], + [0, 0.026675882, 0, 0.26758718], + [0, 0.000117749, 0, 0.001052298], + [0, 0.00771062, 0, 0.074776992], + [0.000464894, 0.035206671, 0.002166531, 0.370844266], + [0.102697328, 0, 0.485532206, 0.653475385], + [0.157739671, 0, 0.742999971, 1], + [0.150854593, 0, 0.742999971, 1], + [0.150699029, 0, 0.742999971, 1], + [0.152688194, 0, 0.742999971, 1], + [0.117266373, 0.002631629, 0.569889141, 0.781878547], + [0.00504039, 0.046400808, 0.024570831, 0.44567775], + [0, 0.012331886, 0, 0.110073033], + [0, 0, 0, 0], + [0, 0.028344113, 0, 0.251321786], + [0.026950294, 0.020163395, 0.125730982, 0.324874938], + [0.158010038, 0, 0.736402423, 0.991120395], + [0.152442818, 0, 0.742999971, 1], + [0.156933082, 0, 0.742999971, 1], + [0.158005321, 0, 0.742999971, 1], + [0.157295524, 0, 0.742999971, 1], + [0.066585924, 0.007109917, 0.316112729, 0.517996875], + [0.000037514, 0.039796557, 0.000181915, 0.369994612], + [0, 0.002456926, 0, 0.01986481], + [0, 0, 0, 0], + [2.8e-8, 0.040608135, 1.28e-7, 0.396294931], + [0.07534295, 0.010681124, 0.351012575, 0.571318232], + [0.158859889, 0, 0.742999971, 1], + [0.159326811, 0, 0.742999971, 1], + [0.154939645, 0, 0.742999971, 1], + [0.159248398, 0, 0.742999971, 1], + [0.143915247, 0, 0.672429985, 0.905020179], + [0.011225421, 0.02668756, 0.052749752, 0.301716547], + [0, 0.01860207, 0, 0.181474939], + [0, 0.000191195, 0, 0.002166625], + [0, 0, 0, 0], + [0, 0.028505516, 0, 0.260573703], + [0.008449908, 0.017081003, 0.039370917, 0.249393425], + [0.074850136, 0.005061574, 0.34870517, 0.524867657], + [0.132621458, 0.00058138, 0.644805654, 0.871750379], + [0.154416851, 0, 0.722839531, 0.972866158], + [0.159284478, 0, 0.742074509, 0.998754425], + [0.087556635, 0.002200472, 0.407939087, 0.563487501], + [0, 0.038836572, 0, 0.406291852], + [0, 0.00676271, 0, 0.05520572], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00299193, 0, 0.028818236], + [0, 0.021175486, 0, 0.200856033], + [0.000337417, 0.040255055, 0.00157193, 0.371186538], + [0.010671383, 0.033280569, 0.050846861, 0.426134376], + [0.043170632, 0.016082665, 0.201119886, 0.390063484], + [0.101313463, 0, 0.472012867, 0.63527976], + [0.025997051, 0.024828062, 0.12117359, 0.383334762], + [0, 0.029243723, 0, 0.310752146], + [0, 0.00098219, 0, 0.0096936], + [0, 0, 0, 0], + [0, 0, 0, 0] + ] + }, + "afterRecovery": { + "activeCells": 875, + "samples": [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000404908, 0, 0.00528973], + [0, 0.00053844, 0, 0.007391517], + [0, 0.000532685, 0, 0.007391583], + [0, 0.00061754, 0, 0.007391517], + [0, 0.00040874, 0, 0.00528973], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000071227, 0, 0.000860014], + [0, 0.003751841, 0, 0.060190363], + [0, 0.020073636, 0, 0.260781281], + [0, 0.027927058, 0, 0.310626724], + [0, 0.023017457, 0, 0.310627993], + [0, 0.017831044, 0, 0.310626724], + [0, 0.018333839, 0, 0.260781281], + [0, 0.005281863, 0, 0.060190363], + [0, 0.000053706, 0, 0.000860014], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002055101, 0, 0.028405512], + [0, 0.02802358, 0, 0.349309271], + [0.017325834, 0.01217227, 0.145219298, 0.372875731], + [0.03125971, 0.009953108, 0.256483265, 0.4843176], + [0.027780701, 0.012074159, 0.256486585, 0.484320886], + [0.03125971, 0.012360132, 0.256483265, 0.4843176], + [0.017325834, 0.013412446, 0.145219298, 0.372875731], + [0, 0.024139944, 0, 0.349309271], + [0, 0.002273829, 0, 0.028405512], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.004933227, 0, 0.062756097], + [0, 0.022532088, 0, 0.333316709], + [0.070944542, 0, 0.573290147, 0.726225565], + [0.09178756, 0, 0.741271138, 0.939018428], + [0.09065018, 0, 0.741271138, 0.939018428], + [0.09178756, 0, 0.741271138, 0.939018428], + [0.070944542, 0, 0.573290147, 0.726225565], + [0, 0.026253328, 0, 0.333316709], + [0, 0.004439593, 0, 0.062756097], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.0048469, 0, 0.067883919], + [0, 0.025330515, 0, 0.325924232], + [0.073571436, 0, 0.590840941, 0.748458413], + [0.091407451, 0, 0.741271138, 0.939018428], + [0.091249384, 0, 0.741271138, 0.939018428], + [0.091407451, 0, 0.741271138, 0.939018428], + [0.073571436, 0, 0.590840941, 0.748458413], + [0, 0.022715108, 0, 0.325924232], + [0, 0.005238095, 0, 0.067883919], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.005979585, 0, 0.077701583], + [0, 0.021763019, 0.000269348, 0.312247616], + [0.076346504, 0, 0.620167705, 0.785608599], + [0.094391176, 0, 0.741271138, 0.939018428], + [0.090087269, 0, 0.741271138, 0.939018428], + [0.094391176, 0, 0.741271138, 0.939018428], + [0.076346504, 0, 0.620167705, 0.785608599], + [0, 0.023921144, 0.000269348, 0.312247616], + [0, 0.005614399, 0, 0.077701583], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000582922, 0, 0.009878053], + [0, 0.006755097, 0, 0.153168185], + [0, 0.014971171, 0, 0.276169809], + [0, 0.015052421, 0, 0.228687975], + [0, 0.002216224, 0, 0.048371355], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.006536034, 0, 0.091428613], + [0, 0.022790417, 0.004837376, 0.294226196], + [0.0839225, 0, 0.654096262, 0.828588402], + [0.096704099, 0, 0.741271138, 0.939018428], + [0.093987461, 0, 0.741271138, 0.939018428], + [0.096704099, 0, 0.741271138, 0.939018428], + [0.0839225, 0, 0.654096262, 0.828588402], + [0, 0.020704232, 0.004837376, 0.294226196], + [0, 0.006989422, 0, 0.091428613], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.006763469, 0, 0.132609277], + [0, 0.022020589, 0, 0.385169833], + [0.015883265, 0.002263921, 0.210229512, 0.290266824], + [0, 0.014352666, 0.018009176, 0.250416371], + [0, 0.020358683, 0, 0.367640583], + [0, 0.00325231, 0, 0.076875142], + [0, 0.000141054, 0, 0.002110758], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.008153652, 0, 0.107863818], + [0.000834092, 0.019741465, 0.018791725, 0.274674213], + [0.09122272, 0, 0.684831524, 0.867522525], + [0.094271775, 0, 0.741271138, 0.939018428], + [0.096786007, 0, 0.741271138, 0.939018428], + [0.094271775, 0, 0.741271138, 0.939018428], + [0.09122272, 0, 0.684831524, 0.867522525], + [0.000834092, 0.0206184, 0.018791725, 0.274674213], + [0, 0.00792394, 0, 0.107863818], + [0, 0, 0, 0], + [0, 0.000029337, 0, 0.000641707], + [0, 0.00492799, 0, 0.089849209], + [0.000199558, 0.019978149, 0.006139223, 0.366554121], + [0.023045664, 0.002077441, 0.317558822, 0.419610926], + [0.063378117, 0, 0.771130202, 0.910489347], + [0.055136536, 0, 0.6676603, 0.788320444], + [0.018203106, 0.00587207, 0.228278592, 0.394410189], + [0, 0.020644739, 0, 0.359392368], + [0, 0.010942227, 0, 0.193322119], + [0, 0.001073109, 0, 0.023230945], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.009084117, 0, 0.124416517], + [0.003535492, 0.018773723, 0.037703677, 0.251367978], + [0.093199516, 0, 0.70808753, 0.896982701], + [0.099983219, 0, 0.741271138, 0.939018428], + [0.098954304, 0, 0.741271138, 0.939018428], + [0.099983219, 0, 0.741271138, 0.939018428], + [0.093199516, 0, 0.70808753, 0.896982701], + [0.003535492, 0.017908475, 0.037703677, 0.251367978], + [0, 0.009332753, 0, 0.124416517], + [0, 0, 0, 0], + [0, 0.001164415, 0, 0.022458824], + [0, 0.022356775, 0, 0.356071984], + [0.011077554, 0.006959041, 0.156596466, 0.357174651], + [0.058581153, 0, 0.751601424, 0.887431425], + [0.06248951, 0, 0.795292497, 0.939018428], + [0.066543168, 0, 0.795292497, 0.939018428], + [0.06631968, 0, 0.788722478, 0.931261156], + [0.032099455, 0.001568557, 0.386656162, 0.486142614], + [0.002122175, 0.023179458, 0.03028719, 0.384322232], + [0, 0.013684395, 0, 0.301785909], + [0, 0.00160639, 0, 0.039640593], + [0, 0.000112165, 0, 0.00167618], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.010287621, 0, 0.137299651], + [0.005984103, 0.010206074, 0.054735241, 0.199472283], + [0.095171401, 0, 0.721110597, 0.913479814], + [0.10247441, 0, 0.741271138, 0.939018428], + [0.100152017, 0, 0.741271138, 0.939018428], + [0.10247441, 0, 0.741271138, 0.939018428], + [0.095171401, 0, 0.721110597, 0.913479814], + [0.005984103, 0.008543154, 0.054735241, 0.199472283], + [0, 0.010208376, 0, 0.137299651], + [0, 0, 0, 0], + [0, 0.004564787, 0, 0.094108402], + [0.001408976, 0.012969657, 0.029008759, 0.252869227], + [0.054711195, 0, 0.666895798, 0.78741766], + [0.062305676, 0, 0.79529243, 0.939018347], + [0.064494531, 0, 0.795292497, 0.939018428], + [0.06492512, 0, 0.795292497, 0.939018428], + [0.067208775, 0, 0.795292497, 0.939018428], + [0.062731332, 0, 0.78298023, 0.924481066], + [0.052804284, 0, 0.598367109, 0.706504497], + [0.011943196, 0.010083063, 0.139654472, 0.358228647], + [0, 0.020843277, 0, 0.392405691], + [0, 0.006844884, 0, 0.12825052], + [0, 0.000238346, 0, 0.00390472], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.0105859, 0, 0.142789924], + [0.006964167, 0.008601965, 0.062333688, 0.209825675], + [0.100861877, 0, 0.725659929, 0.919242889], + [0.101244063, 0, 0.741271138, 0.939018428], + [0.096843007, 0, 0.741271138, 0.939018428], + [0.101244063, 0, 0.741271138, 0.939018428], + [0.100861877, 0, 0.725659929, 0.919242889], + [0.006964167, 0.01035276, 0.062333688, 0.209825675], + [0, 0.010638935, 0, 0.142789924], + [0, 0, 0, 0], + [0, 0.003426592, 0, 0.069681837], + [0, 0.017068614, 0.002062151, 0.336426702], + [0.035130972, 0.003364834, 0.439650156, 0.569637372], + [0.064232869, 0, 0.775121443, 0.915201954], + [0.066441568, 0, 0.795292497, 0.939018428], + [0.05889763, 0, 0.795292497, 0.939018428], + [0.067680352, 0, 0.795292497, 0.939018428], + [0.070355255, 0, 0.795292497, 0.939018428], + [0.066056901, 0, 0.795292497, 0.939018428], + [0.064068414, 0, 0.711730515, 0.840355091], + [0.019699796, 0, 0.231759348, 0.273642964], + [0.001592472, 0.019938051, 0.01955833, 0.377900507], + [0, 0.007845895, 0, 0.184570881], + [0, 0.000673599, 0, 0.010238976], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.010674645, 0, 0.142892181], + [0.007079626, 0.01050088, 0.06243023, 0.209958248], + [0.099490512, 0, 0.725634777, 0.919210886], + [0.099830363, 0, 0.741271138, 0.939018428], + [0.101176918, 0, 0.741271138, 0.939018428], + [0.099830363, 0, 0.741271138, 0.939018428], + [0.099490512, 0, 0.725634777, 0.919210886], + [0.007079626, 0.008294874, 0.06243023, 0.209958248], + [0, 0.010609131, 0, 0.142892181], + [0, 0, 0, 0], + [0, 0.000303066, 0, 0.005969269], + [0, 0.010884481, 0, 0.194019417], + [0.000973627, 0.020176616, 0.012827125, 0.409709233], + [0.023692443, 0.002225306, 0.312020143, 0.410117377], + [0.06594854, 0, 0.77604882, 0.916297038], + [0.068850073, 0, 0.795292497, 0.939018428], + [0.066552096, 0, 0.795292497, 0.939018428], + [0.068689898, 0, 0.795292497, 0.939018428], + [0.072106684, 0, 0.795292497, 0.939018428], + [0.071792734, 0, 0.795292497, 0.939018428], + [0.071711682, 0, 0.776541524, 0.916878708], + [0.036680834, 0.001850768, 0.42110509, 0.524575325], + [0, 0.017790354, 0, 0.368942005], + [0, 0.00490558, 0, 0.083821033], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.005558514, 0, 0.074649648], + [0, 0.026276675, 0.000043317, 0.35511008], + [0.028933368, 0.013170377, 0.212256939, 0.440332], + [0.035228282, 0.01122073, 0.256486585, 0.484320886], + [0.034560749, 0.011506595, 0.256486585, 0.484320886], + [0.035228282, 0.013627772, 0.256486585, 0.484320886], + [0.028933368, 0.01414838, 0.212256939, 0.440332], + [0, 0.023556222, 0.000043317, 0.35511008], + [0, 0.005763477, 0, 0.074649648], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000556127, 0, 0.008151438], + [0, 0.007982791, 0, 0.151875174], + [0, 0.016233329, 0, 0.370569515], + [0.021747554, 0.006999331, 0.267827603, 0.41899043], + [0.0645761, 0, 0.736408297, 0.869492604], + [0.068497376, 0, 0.795292497, 0.939018428], + [0.071317024, 0, 0.795292497, 0.939018428], + [0.069800635, 0, 0.795292497, 0.939018428], + [0.070495869, 0, 0.795292497, 0.939018428], + [0.07350477, 0, 0.795292497, 0.939018428], + [0.054064129, 0, 0.604760553, 0.714053373], + [0, 0.019923182, 0.001019509, 0.327226306], + [0, 0.00501326, 0, 0.105481633], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000250679, 0, 0.002932722], + [0, 0.006760589, 0, 0.110899644], + [0, 0.023171628, 0, 0.293004304], + [0, 0.028118215, 0, 0.310627993], + [0, 0.022931815, 0, 0.310627993], + [0, 0.018022057, 0, 0.310627993], + [0, 0.020394266, 0, 0.293004304], + [0, 0.009427347, 0, 0.110899644], + [0, 0.00019093, 0, 0.002932722], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00011277, 0, 0.002188138], + [0, 0.004490715, 0, 0.087438913], + [0, 0.02036625, 0, 0.389181323], + [0.008265529, 0.001365165, 0.115488896, 0.163658518], + [0.063006206, 0.000223477, 0.69948572, 0.828546993], + [0.072645957, 0, 0.795292497, 0.939018428], + [0.067466016, 0, 0.795292497, 0.939018428], + [0.071808234, 0, 0.795292497, 0.939018428], + [0.067324716, 0, 0.780133061, 0.921119347], + [0.014531214, 0.009814492, 0.162382767, 0.37766383], + [0, 0.02123376, 0, 0.372072967], + [0, 0.001184306, 0, 0.02627428], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000125558, 0, 0.00134432], + [0, 0.000444138, 0, 0.006539334], + [0, 0.000475849, 0, 0.007391583], + [0, 0.000560708, 0, 0.007391583], + [0, 0.000554948, 0, 0.007391583], + [0, 0.000461264, 0, 0.006539334], + [0, 0.000113702, 0, 0.00134432], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000116326, 0, 0.001777167], + [0, 0.005078354, 0, 0.086606888], + [0.00003096, 0.015621715, 0.000604079, 0.353712656], + [0.010443923, 0.01281544, 0.12533551, 0.364384587], + [0.05254638, 0, 0.599097476, 0.707366942], + [0.07340713, 0, 0.794299854, 0.937846392], + [0.066275224, 0, 0.760588005, 0.898042135], + [0.020233082, 0, 0.248978884, 0.293974436], + [0, 0.019446879, 0.003045299, 0.351357704], + [0, 0.002953102, 0, 0.069700005], + [0, 0.000067463, 0, 0.001117041], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.001755082, 0, 0.037643848], + [0, 0.014825208, 0, 0.321521512], + [0.003763013, 0.017905505, 0.047138934, 0.33735191], + [0.046212264, 0.000772858, 0.511426138, 0.621230563], + [0.036984338, 0.002492984, 0.410095682, 0.520961472], + [0, 0.018412011, 0, 0.415397435], + [0, 0.007693368, 0, 0.135237088], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000006829, 0, 0.000131956], + [0, 0.001508088, 0, 0.027400056], + [0, 0.011620294, 0, 0.220963484], + [0.001096672, 0.02019888, 0.014682837, 0.410874873], + [0.000898195, 0.021746755, 0.011743788, 0.407715641], + [0, 0.00746894, 0, 0.173031235], + [0, 0.000552309, 0, 0.008331299], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000212587, 0, 0.001710458], + [0, 0.018315123, 0, 0.139288099], + [0.007542795, 0.025913409, 0.041127677, 0.330470075], + [0.086788587, 0.004498714, 0.431776321, 0.631574988], + [0.040587146, 0.009616295, 0.208244972, 0.356839185], + [0, 0.037344933, 0.002992881, 0.312876502], + [0, 0.036435717, 0, 0.396881714], + [0, 0.027043741, 0, 0.203269336], + [0, 0.00068365, 0, 0.008447941], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002859387, 0, 0.026163478], + [0, 0.041040596, 0, 0.368587034], + [0.049044151, 0.014906417, 0.246158732, 0.439072153], + [0.141227782, 0, 0.698053718, 0.939018428], + [0.140007686, 0, 0.695503837, 0.935588372], + [0.107528803, 0, 0.556511147, 0.74861616], + [0.058409444, 0.013270052, 0.296703318, 0.46675856], + [0.009390965, 0.032123929, 0.048337597, 0.378902633], + [0, 0.01547736, 0, 0.149132623], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.014747351, 0, 0.122460041], + [0, 0.031212441, 0, 0.365636248], + [0.102373235, 0, 0.508082294, 0.683469823], + [0.142456436, 0, 0.698053718, 0.939018428], + [0.139004152, 0, 0.698053718, 0.939018428], + [0.141324796, 0, 0.698053718, 0.939018428], + [0.141149576, 0, 0.698053718, 0.939018428], + [0.041738961, 0.015503338, 0.209549977, 0.450908855], + [0, 0.038644092, 0, 0.272624819], + [0, 0, 0, 0], + [0, 0.001270793, 0, 0.014527404], + [0, 0.035920812, 0, 0.299852478], + [0.035095733, 0.014864399, 0.184307144, 0.335737657], + [0.141781447, 0, 0.680391014, 0.915258805], + [0.144135863, 0, 0.698053718, 0.939018428], + [0.141920177, 0, 0.698053718, 0.939018428], + [0.142806177, 0, 0.698053718, 0.939018428], + [0.122665531, 0, 0.604467967, 0.813127342], + [0.005846408, 0.029897862, 0.031835004, 0.322107137], + [0, 0.011218686, 0, 0.109959619], + [0, 0.000092746, 0, 0.00079736], + [0, 0.014024716, 0, 0.11653876], + [0.004836933, 0.03635221, 0.025835382, 0.38864264], + [0.118856258, 0.00174927, 0.554716497, 0.758885595], + [0.144984117, 0, 0.698053718, 0.939018428], + [0.146902444, 0, 0.698053718, 0.939018428], + [0.145304775, 0, 0.698053718, 0.939018428], + [0.144428954, 0, 0.695674805, 0.935818324], + [0.061528837, 0, 0.307635729, 0.413830085], + [0, 0.041447277, 0, 0.346269903], + [0, 0.001261654, 0, 0.013687383], + [0, 0.00086125, 0, 0.008827311], + [0, 0.042060186, 0, 0.32436677], + [0.03439293, 0.019589737, 0.163420256, 0.372625306], + [0.14102139, 0, 0.675917807, 0.909241337], + [0.151518055, 0, 0.698053718, 0.939018428], + [0.145677827, 0, 0.698053718, 0.939018428], + [0.144573192, 0, 0.698053718, 0.939018428], + [0.130341705, 0, 0.647679633, 0.871255522], + [0.017646788, 0.018274835, 0.090196368, 0.328019762], + [0, 0.026661949, 0, 0.251269326], + [0, 0.000085007, 0, 0.000660522], + [0, 0.007706615, 0, 0.070216982], + [0, 0.03518835, 0.002035472, 0.348229682], + [0.100297444, 0, 0.456160886, 0.61362547], + [0.155339988, 0, 0.698053718, 0.939018428], + [0.14845491, 0, 0.698053718, 0.939018428], + [0.148299346, 0, 0.698053718, 0.939018428], + [0.150288511, 0, 0.698053718, 0.939018428], + [0.114866589, 0.002630267, 0.535414895, 0.73419837], + [0.004665376, 0.046376571, 0.023084472, 0.41849968], + [0, 0.012280871, 0, 0.103045675], + [0, 0, 0, 0], + [0, 0.028329352, 0, 0.235995871], + [0.025313029, 0.020152807, 0.11812512, 0.305063553], + [0.155610355, 0, 0.691855245, 0.930680487], + [0.150043135, 0, 0.698053718, 0.939018428], + [0.154533399, 0, 0.698053718, 0.939018428], + [0.155605638, 0, 0.698053718, 0.939018428], + [0.154895841, 0, 0.698053718, 0.939018428], + [0.064767412, 0.007106231, 0.296990139, 0.486408743], + [0.000025796, 0.039775735, 0.00017091, 0.347431812], + [0, 0.002442872, 0, 0.01851953], + [0, 0, 0, 0], + [0, 0.040587082, 1.2e-7, 0.372128258], + [0.073693076, 0.010675597, 0.329778828, 0.536478516], + [0.156460206, 0, 0.698053718, 0.939018428], + [0.156927128, 0, 0.698053718, 0.939018428], + [0.152539962, 0, 0.698053718, 0.939018428], + [0.156848715, 0, 0.698053718, 0.939018428], + [0.141515564, 0, 0.631752707, 0.849830739], + [0.010056818, 0.026673723, 0.049558769, 0.283317459], + [0, 0.018592432, 0, 0.170408344], + [0, 0.000143263, 0, 0.001696362], + [0, 0, 0, 0], + [0, 0.028490577, 0, 0.244683543], + [0.00705771, 0.017072083, 0.036989255, 0.234185069], + [0.072719935, 0.005058938, 0.327610852, 0.492860605], + [0.130221707, 0.000581079, 0.605799381, 0.818589968], + [0.152017168, 0, 0.679112838, 0.913539261], + [0.156884795, 0, 0.697184238, 0.937848809], + [0.08515667, 0.002199326, 0.383261678, 0.529125162], + [0, 0.038816351, 0, 0.381515636], + [0, 0.006720648, 0, 0.051454974], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002990371, 0, 0.027060861], + [0, 0.021164471, 0, 0.188607563], + [0.000168667, 0.040234116, 0.001476839, 0.348551028], + [0.010071355, 0.033263156, 0.047770992, 0.400148133], + [0.041718203, 0.016074294, 0.188953539, 0.366276847], + [0.09891376, 0, 0.443459423, 0.596539483], + [0.024778292, 0.024815227, 0.113843437, 0.359958373], + [0, 0.029228436, 0, 0.291802069], + [0, 0.00089496, 0, 0.008292256], + [0, 0, 0, 0], + [0, 0, 0, 0] + ] + }, + "afterDepth": { + "activeCells": 875, + "samples": [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000404908, 0, 0.00528973], + [0, 0.00053844, 0, 0.007391517], + [0, 0.000532685, 0, 0.007391583], + [0, 0.00061754, 0, 0.007391517], + [0, 0.00040874, 0, 0.00528973], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000071227, 0, 0.000860014], + [0, 0.003751841, 0, 0.060190363], + [0, 0.020073636, 0, 0.260781281], + [0, 0.027927058, 0, 0.310626724], + [0, 0.023017457, 0, 0.310627993], + [0, 0.017831044, 0, 0.310626724], + [0, 0.018333839, 0, 0.260781281], + [0, 0.005281863, 0, 0.060190363], + [0, 0.000053706, 0, 0.000860014], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002055101, 0, 0.028405512], + [0, 0.02802358, 0, 0.349309271], + [0.017325834, 0.01217227, 0.145219298, 0.372875731], + [0.03125971, 0.009953108, 0.256483265, 0.4843176], + [0.027780701, 0.012074159, 0.256486585, 0.484320886], + [0.03125971, 0.012360132, 0.256483265, 0.4843176], + [0.017325834, 0.013412446, 0.145219298, 0.372875731], + [0, 0.024139944, 0, 0.349309271], + [0, 0.002273829, 0, 0.028405512], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.004933227, 0, 0.062756097], + [0, 0.022532088, 0, 0.333316709], + [0.070944542, 0, 0.573290147, 0.726225565], + [0.09178756, 0, 0.741271138, 0.939018428], + [0.09065018, 0, 0.741271138, 0.939018428], + [0.09178756, 0, 0.741271138, 0.939018428], + [0.070944542, 0, 0.573290147, 0.726225565], + [0, 0.026253328, 0, 0.333316709], + [0, 0.004439593, 0, 0.062756097], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.0048469, 0, 0.067883919], + [0, 0.025330515, 0, 0.325924232], + [0.073571436, 0, 0.590840941, 0.748458413], + [0.091407451, 0, 0.741271138, 0.939018428], + [0.091249384, 0, 0.741271138, 0.939018428], + [0.091407451, 0, 0.741271138, 0.939018428], + [0.073571436, 0, 0.590840941, 0.748458413], + [0, 0.022715108, 0, 0.325924232], + [0, 0.005238095, 0, 0.067883919], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.005979585, 0, 0.077701583], + [0, 0.021763019, 0.000269348, 0.312247616], + [0.076346504, 0, 0.620167705, 0.785608599], + [0.094391176, 0, 0.741271138, 0.939018428], + [0.090087269, 0, 0.741271138, 0.939018428], + [0.094391176, 0, 0.741271138, 0.939018428], + [0.076346504, 0, 0.620167705, 0.785608599], + [0, 0.023921144, 0.000269348, 0.312247616], + [0, 0.005614399, 0, 0.077701583], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000582922, 0, 0.009878053], + [0, 0.006755097, 0, 0.153168185], + [0, 0.014971171, 0, 0.276169809], + [0, 0.015052421, 0, 0.228687975], + [0, 0.002216224, 0, 0.048371355], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.006536034, 0, 0.091428613], + [0, 0.022790417, 0.004837376, 0.294226196], + [0.0839225, 0, 0.654096262, 0.828588402], + [0.096704099, 0, 0.741271138, 0.939018428], + [0.093987461, 0, 0.741271138, 0.939018428], + [0.096704099, 0, 0.741271138, 0.939018428], + [0.0839225, 0, 0.654096262, 0.828588402], + [0, 0.020704232, 0.004837376, 0.294226196], + [0, 0.006989422, 0, 0.091428613], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.006763469, 0, 0.132609277], + [0, 0.022020589, 0, 0.385169833], + [0.015883265, 0.002263921, 0.210229512, 0.290266824], + [0, 0.014352666, 0.018009176, 0.250416371], + [0, 0.020358683, 0, 0.367640583], + [0, 0.00325231, 0, 0.076875142], + [0, 0.000141054, 0, 0.002110758], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.008153652, 0, 0.107863818], + [0.000834092, 0.019741465, 0.018791725, 0.274674213], + [0.09122272, 0, 0.684831524, 0.867522525], + [0.094271775, 0, 0.741271138, 0.939018428], + [0.096786007, 0, 0.741271138, 0.939018428], + [0.094271775, 0, 0.741271138, 0.939018428], + [0.09122272, 0, 0.684831524, 0.867522525], + [0.000834092, 0.0206184, 0.018791725, 0.274674213], + [0, 0.00792394, 0, 0.107863818], + [0, 0, 0, 0], + [0, 0.000029337, 0, 0.000641707], + [0, 0.00492799, 0, 0.089849209], + [0.000199558, 0.019978149, 0.006139223, 0.366554121], + [0.023045664, 0.002077441, 0.317558822, 0.419610926], + [0.063378117, 0, 0.771130202, 0.910489347], + [0.055136536, 0, 0.6676603, 0.788320444], + [0.018203106, 0.00587207, 0.228278592, 0.394410189], + [0, 0.020644739, 0, 0.359392368], + [0, 0.010942227, 0, 0.193322119], + [0, 0.001073109, 0, 0.023230945], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.009084117, 0, 0.124416517], + [0.003535492, 0.018773723, 0.037703677, 0.251367978], + [0.093199516, 0, 0.70808753, 0.896982701], + [0.099983219, 0, 0.741271138, 0.939018428], + [0.098954304, 0, 0.741271138, 0.939018428], + [0.099983219, 0, 0.741271138, 0.939018428], + [0.093199516, 0, 0.70808753, 0.896982701], + [0.003535492, 0.017908475, 0.037703677, 0.251367978], + [0, 0.009332753, 0, 0.124416517], + [0, 0, 0, 0], + [0, 0.001164415, 0, 0.022458824], + [0, 0.022356775, 0, 0.356071984], + [0.011077554, 0.006959041, 0.156596466, 0.357174651], + [0.058581153, 0, 0.751601424, 0.887431425], + [0.06248951, 0, 0.795292497, 0.939018428], + [0.066543168, 0, 0.795292497, 0.939018428], + [0.06631968, 0, 0.788722478, 0.931261156], + [0.032099455, 0.001568557, 0.386656162, 0.486142614], + [0.002122175, 0.023179458, 0.03028719, 0.384322232], + [0, 0.013684395, 0, 0.301785909], + [0, 0.00160639, 0, 0.039640593], + [0, 0.000112165, 0, 0.00167618], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.010287621, 0, 0.137299651], + [0.005984103, 0.010206074, 0.054735241, 0.199472283], + [0.095171401, 0, 0.721110597, 0.913479814], + [0.10247441, 0, 0.741271138, 0.939018428], + [0.100152017, 0, 0.741271138, 0.939018428], + [0.10247441, 0, 0.741271138, 0.939018428], + [0.095171401, 0, 0.721110597, 0.913479814], + [0.005984103, 0.008543154, 0.054735241, 0.199472283], + [0, 0.010208376, 0, 0.137299651], + [0, 0, 0, 0], + [0, 0.004564787, 0, 0.094108402], + [0.001408976, 0.012969657, 0.029008759, 0.252869227], + [0.054711195, 0, 0.666895798, 0.78741766], + [0.062305676, 0, 0.79529243, 0.939018347], + [0.064494531, 0, 0.795292497, 0.939018428], + [0.06492512, 0, 0.795292497, 0.939018428], + [0.067208775, 0, 0.795292497, 0.939018428], + [0.062731332, 0, 0.78298023, 0.924481066], + [0.052804284, 0, 0.598367109, 0.706504497], + [0.011943196, 0.010083063, 0.139654472, 0.358228647], + [0, 0.020843277, 0, 0.392405691], + [0, 0.006844884, 0, 0.12825052], + [0, 0.000238346, 0, 0.00390472], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.0105859, 0, 0.142789924], + [0.006964167, 0.008601965, 0.062333688, 0.209825675], + [0.100861877, 0, 0.725659929, 0.919242889], + [0.101244063, 0, 0.741271138, 0.939018428], + [0.096843007, 0, 0.741271138, 0.939018428], + [0.101244063, 0, 0.741271138, 0.939018428], + [0.100861877, 0, 0.725659929, 0.919242889], + [0.006964167, 0.01035276, 0.062333688, 0.209825675], + [0, 0.010638935, 0, 0.142789924], + [0, 0, 0, 0], + [0, 0.003426592, 0, 0.069681837], + [0, 0.017068614, 0.002062151, 0.336426702], + [0.035130972, 0.003364834, 0.439650156, 0.569637372], + [0.064232869, 0, 0.775121443, 0.915201954], + [0.066441568, 0, 0.795292497, 0.939018428], + [0.05889763, 0, 0.795292497, 0.939018428], + [0.067680352, 0, 0.795292497, 0.939018428], + [0.070355255, 0, 0.795292497, 0.939018428], + [0.066056901, 0, 0.795292497, 0.939018428], + [0.064068414, 0, 0.711730515, 0.840355091], + [0.019699796, 0, 0.231759348, 0.273642964], + [0.001592472, 0.019938051, 0.01955833, 0.377900507], + [0, 0.007845895, 0, 0.184570881], + [0, 0.000673599, 0, 0.010238976], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.010674645, 0, 0.142892181], + [0.007079626, 0.01050088, 0.06243023, 0.209958248], + [0.099490512, 0, 0.725634777, 0.919210886], + [0.099830363, 0, 0.741271138, 0.939018428], + [0.101176918, 0, 0.741271138, 0.939018428], + [0.099830363, 0, 0.741271138, 0.939018428], + [0.099490512, 0, 0.725634777, 0.919210886], + [0.007079626, 0.008294874, 0.06243023, 0.209958248], + [0, 0.010609131, 0, 0.142892181], + [0, 0, 0, 0], + [0, 0.000303066, 0, 0.005969269], + [0, 0.010884481, 0, 0.194019417], + [0.000973627, 0.020176616, 0.012827125, 0.409709233], + [0.023692443, 0.002225306, 0.312020143, 0.410117377], + [0.06594854, 0, 0.77604882, 0.916297038], + [0.068850073, 0, 0.795292497, 0.939018428], + [0.066552096, 0, 0.795292497, 0.939018428], + [0.068689898, 0, 0.795292497, 0.939018428], + [0.072106684, 0, 0.795292497, 0.939018428], + [0.071792734, 0, 0.795292497, 0.939018428], + [0.071711682, 0, 0.776541524, 0.916878708], + [0.036680834, 0.001850768, 0.42110509, 0.524575325], + [0, 0.017790354, 0, 0.368942005], + [0, 0.00490558, 0, 0.083821033], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.005558514, 0, 0.074649648], + [0, 0.026276675, 0.000043317, 0.35511008], + [0.028933368, 0.013170377, 0.212256939, 0.440332], + [0.035228282, 0.01122073, 0.256486585, 0.484320886], + [0.034560749, 0.011506595, 0.256486585, 0.484320886], + [0.035228282, 0.013627772, 0.256486585, 0.484320886], + [0.028933368, 0.01414838, 0.212256939, 0.440332], + [0, 0.023556222, 0.000043317, 0.35511008], + [0, 0.005763477, 0, 0.074649648], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000556127, 0, 0.008151438], + [0, 0.007982791, 0, 0.151875174], + [0, 0.016233329, 0, 0.370569515], + [0.021747554, 0.006999331, 0.267827603, 0.41899043], + [0.0645761, 0, 0.736408297, 0.869492604], + [0.068497376, 0, 0.795292497, 0.939018428], + [0.071317024, 0, 0.795292497, 0.939018428], + [0.069800635, 0, 0.795292497, 0.939018428], + [0.070495869, 0, 0.795292497, 0.939018428], + [0.07350477, 0, 0.795292497, 0.939018428], + [0.054064129, 0, 0.604760553, 0.714053373], + [0, 0.019923182, 0.001019509, 0.327226306], + [0, 0.00501326, 0, 0.105481633], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000250679, 0, 0.002932722], + [0, 0.006760589, 0, 0.110899644], + [0, 0.023171628, 0, 0.293004304], + [0, 0.028118215, 0, 0.310627993], + [0, 0.022931815, 0, 0.310627993], + [0, 0.018022057, 0, 0.310627993], + [0, 0.020394266, 0, 0.293004304], + [0, 0.009427347, 0, 0.110899644], + [0, 0.00019093, 0, 0.002932722], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.00011277, 0, 0.002188138], + [0, 0.004490715, 0, 0.087438913], + [0, 0.02036625, 0, 0.389181323], + [0.008265529, 0.001365165, 0.115488896, 0.163658518], + [0.063006206, 0.000223477, 0.69948572, 0.828546993], + [0.072645957, 0, 0.795292497, 0.939018428], + [0.067466016, 0, 0.795292497, 0.939018428], + [0.071808234, 0, 0.795292497, 0.939018428], + [0.067324716, 0, 0.780133061, 0.921119347], + [0.014531214, 0.009814492, 0.162382767, 0.37766383], + [0, 0.02123376, 0, 0.372072967], + [0, 0.001184306, 0, 0.02627428], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000125558, 0, 0.00134432], + [0, 0.000444138, 0, 0.006539334], + [0, 0.000475849, 0, 0.007391583], + [0, 0.000560708, 0, 0.007391583], + [0, 0.000554948, 0, 0.007391583], + [0, 0.000461264, 0, 0.006539334], + [0, 0.000113702, 0, 0.00134432], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000116326, 0, 0.001777167], + [0, 0.005078354, 0, 0.086606888], + [0.00003096, 0.015621715, 0.000604079, 0.353712656], + [0.010443923, 0.01281544, 0.12533551, 0.364384587], + [0.05254638, 0, 0.599097476, 0.707366942], + [0.07340713, 0, 0.794299854, 0.937846392], + [0.066275224, 0, 0.760588005, 0.898042135], + [0.020233082, 0, 0.248978884, 0.293974436], + [0, 0.019446879, 0.003045299, 0.351357704], + [0, 0.002953102, 0, 0.069700005], + [0, 0.000067463, 0, 0.001117041], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.001755082, 0, 0.037643848], + [0, 0.014825208, 0, 0.321521512], + [0.003763013, 0.017905505, 0.047138934, 0.33735191], + [0.046212264, 0.000772858, 0.511426138, 0.621230563], + [0.036984338, 0.002492984, 0.410095682, 0.520961472], + [0, 0.018412011, 0, 0.415397435], + [0, 0.007693368, 0, 0.135237088], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000006829, 0, 0.000131956], + [0, 0.001508088, 0, 0.027400056], + [0, 0.011620294, 0, 0.220963484], + [0.001096672, 0.02019888, 0.014682837, 0.410874873], + [0.000898195, 0.021746755, 0.011743788, 0.407715641], + [0, 0.00746894, 0, 0.173031235], + [0, 0.000552309, 0, 0.008331299], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.000212587, 0, 0.001710458], + [0, 0.018315123, 0, 0.139288099], + [0.007542795, 0.025913409, 0.041127677, 0.330470075], + [0.084831813, 0.004498714, 0.431776321, 0.631574988], + [0.040587146, 0.009616295, 0.208244972, 0.356839185], + [0, 0.037344933, 0.002992881, 0.312876502], + [0, 0.036435717, 0, 0.396881714], + [0, 0.027043741, 0, 0.203269336], + [0, 0.00068365, 0, 0.008447941], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002859387, 0, 0.026163478], + [0, 0.041040596, 0, 0.368587034], + [0.044463089, 0.014906417, 0.246158732, 0.439072153], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.695503837, 0.935588372], + [0.093841107, 0, 0.556511147, 0.74861616], + [0.050353775, 0.013270052, 0.296703318, 0.46675856], + [0.009390965, 0.032123929, 0.048337597, 0.378902633], + [0, 0.01547736, 0, 0.149132623], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.014747351, 0, 0.122460041], + [0, 0.031212441, 0, 0.365636248], + [0.084835982, 0, 0.508082294, 0.683469823], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.041738961, 0.015503338, 0.209549977, 0.450908855], + [0, 0.038644092, 0, 0.272624819], + [0, 0, 0, 0], + [0, 0.001270793, 0, 0.014527404], + [0, 0.035920812, 0, 0.299852478], + [0.035095733, 0.014864399, 0.184307144, 0.335737657], + [0.105599999, 0, 0.680391014, 0.915258805], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105576996, 0, 0.604467967, 0.813127342], + [0.005846408, 0.029897862, 0.031835004, 0.322107137], + [0, 0.011218686, 0, 0.109959619], + [0, 0.000092746, 0, 0.00079736], + [0, 0.014024716, 0, 0.11653876], + [0.004836933, 0.03635221, 0.025835382, 0.38864264], + [0.094549747, 0.00174927, 0.554716497, 0.758885595], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.695674805, 0.935818324], + [0.055833854, 0, 0.307635729, 0.413830085], + [0, 0.041447277, 0, 0.346269903], + [0, 0.001261654, 0, 0.013687383], + [0, 0.00086125, 0, 0.008827311], + [0, 0.042060186, 0, 0.32436677], + [0.02934486, 0.019589737, 0.163420256, 0.372625306], + [0.10434895, 0, 0.675917807, 0.909241337], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.102252451, 0, 0.647679633, 0.871255522], + [0.016632766, 0.018274835, 0.090196368, 0.328019762], + [0, 0.026661949, 0, 0.251269326], + [0, 0.000085007, 0, 0.000660522], + [0, 0.007706615, 0, 0.070216982], + [0, 0.03518835, 0.002035472, 0.348229682], + [0.082770507, 0, 0.456160886, 0.61362547], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.086664601, 0.002630267, 0.535414895, 0.73419837], + [0.004665376, 0.046376571, 0.023084472, 0.41849968], + [0, 0.012280871, 0, 0.103045675], + [0, 0, 0, 0], + [0, 0.028329352, 0, 0.235995871], + [0.025313029, 0.020152807, 0.11812512, 0.305063553], + [0.105599999, 0, 0.691855245, 0.930680487], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.064181481, 0.007106231, 0.296990139, 0.486408743], + [0.000025796, 0.039775735, 0.00017091, 0.347431812], + [0, 0.002442872, 0, 0.01851953], + [0, 0, 0, 0], + [0, 0.040587082, 1.2e-7, 0.372128258], + [0.070834622, 0.010675597, 0.329778828, 0.536478516], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.698053718, 0.939018428], + [0.105599999, 0, 0.631752707, 0.849830739], + [0.010056818, 0.026673723, 0.049558769, 0.283317459], + [0, 0.018592432, 0, 0.170408344], + [0, 0.000143263, 0, 0.001696362], + [0, 0, 0, 0], + [0, 0.028490577, 0, 0.244683543], + [0.00705771, 0.017072083, 0.036989255, 0.234185069], + [0.06720078, 0.005058938, 0.327610852, 0.492860605], + [0.095394596, 0.000581079, 0.605799381, 0.818589968], + [0.105599999, 0, 0.679112838, 0.913539261], + [0.105599999, 0, 0.697184238, 0.937848809], + [0.068555961, 0.002199326, 0.383261678, 0.529125162], + [0, 0.038816351, 0, 0.381515636], + [0, 0.006720648, 0, 0.051454974], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0.002990371, 0, 0.027060861], + [0, 0.021164471, 0, 0.188607563], + [0.000168667, 0.040234116, 0.001476839, 0.348551028], + [0.010071355, 0.033263156, 0.047770992, 0.400148133], + [0.035770619, 0.016074294, 0.188953539, 0.366276847], + [0.086364852, 0, 0.443459423, 0.596539483], + [0.022110107, 0.024815227, 0.113843437, 0.359958373], + [0, 0.029228436, 0, 0.291802069], + [0, 0.00089496, 0, 0.008292256], + [0, 0, 0, 0], + [0, 0, 0, 0] + ] + }, + "afterReset": { + "activeCells": 0, + "samples": [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0] + ] + }, + "penetrations": [0.11721948, 0.084338303, 0.177206854], + "steps": 3, + "lastSink": 0.177206854 +} diff --git a/packages/core/__tests__/fluid-particles.spec.ts b/packages/core/__tests__/fluid-particles.spec.ts new file mode 100644 index 0000000000..b1b44a8794 --- /dev/null +++ b/packages/core/__tests__/fluid-particles.spec.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, vi } from "vitest"; +import { FluidParticles3D } from "../src/fluid-particles.js"; +import type { IRendererLike, RendererKind } from "../src/renderer.js"; + +function renderer(names: string[], kind: RendererKind = "webgpu"): IRendererLike { + const canvas = new EventTarget() as HTMLCanvasElement; + return { + compileAsync: async () => undefined, + compute: (node) => names.push((node as { name: string }).name), + dispose: () => undefined, + domElement: canvas, + info: {}, + kind, + raw: {}, + readback: async () => new ArrayBuffer(0), + render: () => undefined, + renderOverlay: () => undefined, + setOutputNode: () => undefined, + setSize: () => undefined, + gpuFrameMs: () => undefined, + resolveGpuFrame: () => undefined, + setResolutionScale: () => undefined, + surface: () => ({ + atFloor: false, + drawingBufferHeight: 1, + drawingBufferWidth: 1, + resolutionScale: 1, + sampleCount: 1, + scaleSource: "pinned" as const, + }), + }; +} + +describe("FluidParticles3D", () => { + it("fails closed for every invalid option", () => { + expect(() => new FluidParticles3D({ capacity: 0 })).toThrow("FluidParticles3D.capacity"); + expect(() => new FluidParticles3D({ capacity: 70000 })).toThrow("FluidParticles3D.capacity"); + expect(() => new FluidParticles3D({ capacity: 10.5 })).toThrow("FluidParticles3D.capacity"); + expect(() => new FluidParticles3D({ capacity: 8, spacing: 0 })).toThrow( + "FluidParticles3D.spacing", + ); + expect(() => new FluidParticles3D({ capacity: 8, iterations: 9 })).toThrow( + "FluidParticles3D.iterations", + ); + expect(() => new FluidParticles3D({ capacity: 8, iterations: -1 })).toThrow( + "FluidParticles3D.iterations", + ); + expect(() => new FluidParticles3D({ capacity: 8, viscosity: -1 })).toThrow( + "FluidParticles3D.viscosity", + ); + expect( + () => + new FluidParticles3D({ + capacity: 8, + bounds: { min: [-1, 0, -1], max: [1, 0.1, 1] }, + }), + ).toThrow("FluidParticles3D.bounds must be at least two spacings wide"); + expect(() => new FluidParticles3D({ capacity: 8, maxColliders: 0 })).toThrow( + "FluidParticles3D.maxColliders", + ); + }); + + it("defaults to a fixed cadence, fourteen warmup kernels and no landed readback", () => { + const water = new FluidParticles3D({ capacity: 100 }); + expect(water.processCadence).toBe("fixed"); + expect(water.warmupNodes).toHaveLength(14); + expect(water.used).toBe(0); + expect(water.stats).toBeUndefined(); + expect(water.sample(0, 0).height).toBe(water.bounds.min[1]); + }); + + it("refuses a renderer without WebGPU compute, and a second renderer", () => { + const water = new FluidParticles3D({ capacity: 8 }); + expect(() => water.attachRenderer(renderer([], "webgl2"))).toThrow("WebGPU renderer"); + + const gpu = renderer([]); + water.attachRenderer(gpu); + water.attachRenderer(gpu); + expect(() => water.attachRenderer(renderer([]))).toThrow( + "FluidParticles3D is already attached to a renderer.", + ); + }); + + it("fills at most capacity and reports the exact lattice count", () => { + const full = new FluidParticles3D({ capacity: 50, spacing: 0.22 }); + expect(full.fill([0, 0, 0], [2, 2, 2])).toBe(50); + expect(full.fill([0, 0, 0], [2, 2, 2])).toBe(0); + expect(full.used).toBe(50); + + const small = new FluidParticles3D({ capacity: 50, spacing: 0.22 }); + expect(small.fill([0, 0, 0], [0.44, 0.44, 0.44])).toBe(27); + }); + + it("emits into a ring buffer and throttles the queue per step", () => { + const ring = new FluidParticles3D({ capacity: 4 }); + expect(ring.emit([0, 0, 0])).toBe(true); + expect(ring.used).toBe(1); + for (let spawn = 0; spawn < 5; spawn += 1) expect(ring.emit([0, 0, 0])).toBe(true); + expect(ring.used).toBe(4); + + const queued = new FluidParticles3D({ capacity: 8, maxSpawns: 2 }); + expect(queued.emit([0, 0, 0])).toBe(true); + expect(queued.emit([0, 0, 0])).toBe(true); + expect(queued.emit([0, 0, 0])).toBe(false); + queued.process(renderer([])); + expect(queued.emit([0, 0, 0])).toBe(true); + }); + + it("dispatches nothing while empty, then the documented pass order", () => { + const names: string[] = []; + const water = new FluidParticles3D({ capacity: 8, iterations: 2 }); + const gpu = renderer(names); + water.attachRenderer(gpu); + + water.process(); + expect(names).toEqual([]); + expect(water.steps).toBe(1); + + water.fill([0, 0, 0], [0.44, 0.44, 0.44]); + water.process(); + expect(names).toEqual([ + "fluidParticles.inject", + "fluidParticles.predict", + "fluidParticles.grid.clear", + "fluidParticles.grid.build", + "fluidParticles.lambda", + "fluidParticles.delta", + "fluidParticles.apply", + "fluidParticles.grid.clear", + "fluidParticles.grid.build", + "fluidParticles.lambda", + "fluidParticles.delta", + "fluidParticles.apply", + "fluidParticles.grid.clear", + "fluidParticles.grid.build", + "fluidParticles.velocity", + "fluidParticles.stats.clear", + "fluidParticles.smooth", + "fluidParticles.confine", + "fluidParticles.stats.finalize", + "fluidParticles.volume", + "fluidParticles.columns", + ]); + expect(water.steps).toBe(2); + }); + + it("queues four drains and stirs, and validates colliders", () => { + const water = new FluidParticles3D({ capacity: 8 }); + for (let queue = 0; queue < 4; queue += 1) expect(water.drain([0, 0, 0], [1, 1, 1])).toBe(true); + expect(water.drain([0, 0, 0], [1, 1, 1])).toBe(false); + for (let queue = 0; queue < 4; queue += 1) expect(water.stir([0, 0, 0], 1, 0.5)).toBe(true); + expect(water.stir([0, 0, 0], 1, 0.5)).toBe(false); + + const spheres = Array.from({ length: 9 }, () => ({ + kind: "sphere" as const, + center: [0, 0, 0] as const, + radius: 1, + })); + expect(() => water.setColliders(spheres)).toThrow( + "FluidParticles3D.setColliders accepts at most 8.", + ); + expect(() => + water.setColliders([{ kind: "box", center: [0, 0.5, 0], halfExtents: [0.5, 0, 0.5] }]), + ).toThrow("FluidParticles3D.collider.halfExtents must be positive."); + expect(() => + water.setColliders([ + { + kind: "box", + center: [0, 0.5, 0], + halfExtents: [0.5, 0.5, 0.5], + rotation: [0, 0, 0, 0], + }, + ]), + ).toThrow("FluidParticles3D collider rotation must be non-zero."); + expect(() => + water.setColliders([ + { kind: "sphere", center: [0, 0.5, 0], radius: 0.4 }, + { + kind: "box", + center: [0, 0.5, 0], + halfExtents: [0.5, 0.5, 0.5], + rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2], + }, + ]), + ).not.toThrow(); + }); + + it("releases on detach and refuses every mutation and dispatch afterwards", () => { + const names: string[] = []; + const water = new FluidParticles3D({ capacity: 8 }); + water.attachRenderer(renderer(names)); + water.fill([0, 0, 0], [0.44, 0.44, 0.44]); + + water.detach(); + expect(water.released).toBe(true); + expect(() => water.fill([0, 0, 0], [1, 1, 1])).toThrow( + "FluidParticles3D cannot fill after release.", + ); + expect(() => water.emit([0, 0, 0])).toThrow("FluidParticles3D cannot emit after release."); + expect(() => water.drain([0, 0, 0], [1, 1, 1])).toThrow( + "FluidParticles3D cannot drain after release.", + ); + expect(() => water.stir([0, 0, 0], 1, 0.5)).toThrow( + "FluidParticles3D cannot stir after release.", + ); + expect(() => water.setColliders([])).toThrow( + "FluidParticles3D cannot setColliders after release.", + ); + + water.process(); + expect(names).toEqual([]); + expect(water.steps).toBe(0); + }); + + it("defaults to water-like damping, not a jelly", () => { + const water = new FluidParticles3D({ capacity: 8 }); + expect(water.viscosity).toBeLessThanOrEqual(0.02); + expect(water.cohesion).toBeLessThanOrEqual(0.05); + expect(water.vorticity).toBeGreaterThanOrEqual(0.01); + }); + + it("round-trips the live solver knobs and rejects negative ones", () => { + const water = new FluidParticles3D({ capacity: 8 }); + water.viscosity = 0.5; + water.cohesion = 0; + water.vorticity = 0.02; + water.gravity = 0; + expect(water.viscosity).toBe(0.5); + expect(water.cohesion).toBe(0); + expect(water.vorticity).toBe(0.02); + expect(water.gravity).toBe(0); + + expect(() => { + water.viscosity = -1; + }).toThrow("FluidParticles3D.viscosity"); + expect(() => { + water.cohesion = -1; + }).toThrow("FluidParticles3D.cohesion"); + expect(() => { + water.vorticity = -1; + }).toThrow("FluidParticles3D.vorticity"); + expect(() => { + water.gravity = -1; + }).toThrow("FluidParticles3D.gravity"); + expect(water.viscosity).toBe(0.5); + }); + + it("frees every storage buffer on detach", () => { + const water = new FluidParticles3D({ capacity: 8 }); + water.attachRenderer(renderer([])); + const disposed = vi.spyOn(water.positions.value, "dispose"); + const velocities = vi.spyOn(water.velocities.value, "dispose"); + water.detach(); + expect(disposed).toHaveBeenCalledOnce(); + expect(velocities).toHaveBeenCalledOnce(); + }); + + it("keeps the previous colliders when a later one is invalid, and rejects a NaN rotation", () => { + const water = new FluidParticles3D({ capacity: 8 }); + water.setColliders([{ kind: "sphere", center: [0, 1, 0], radius: 0.3 }]); + expect(() => + water.setColliders([ + { kind: "sphere", center: [1, 1, 0], radius: 0.3 }, + { kind: "box", center: [0, 0, 0], halfExtents: [1, 1, 1], rotation: [Number.NaN, 0, 0, 1] }, + ]), + ).toThrow("FluidParticles3D.collider.rotation"); + expect(water.stats).toBeUndefined(); + expect(water.staleFrames).toBe(0); + }); +}); diff --git a/packages/core/__tests__/snow-field.spec.ts b/packages/core/__tests__/snow-field.spec.ts new file mode 100644 index 0000000000..5f217f94e0 --- /dev/null +++ b/packages/core/__tests__/snow-field.spec.ts @@ -0,0 +1,596 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { Vector3 } from "three"; +import { describe, expect, it } from "vitest"; +import { + type ISnowFootprint, + type ISnowFootprintSample, + SnowField, + snowDiscFootprint, +} from "../src/snow-field.js"; +import { Heightfield } from "../src/world.js"; + +/** + * The fixture was produced by running the original ICEFIELD study's own snow code — the same + * functions copied verbatim from lines 85-193 of the supplied document — through one deterministic + * scenario. Comparing against it is how the extraction proves it reproduced the source's + * footprint state and deposition instead of re-inventing them. + */ +interface ISourceSnapshot { + readonly activeCells: number; + readonly samples: readonly (readonly number[])[]; +} + +interface ISourceFixture { + readonly afterDepth: ISourceSnapshot; + readonly afterRecovery: ISourceSnapshot; + readonly afterReset: ISourceSnapshot; + readonly afterStamps: ISourceSnapshot; + readonly penetrations: readonly number[]; + readonly points: readonly (readonly [number, number])[]; + readonly scenario: { + readonly depth: number; + readonly depthOverride: number; + readonly recovery: { + readonly deposition: number; + readonly dt: number; + readonly steps: number; + readonly wind: number; + }; + readonly resolution: number; + readonly size: number; + readonly stamps: readonly { + readonly angle: number; + readonly hardness: number; + readonly mass: number; + readonly x: number; + readonly z: number; + }[]; + }; + readonly source: string; + readonly steps: number; + readonly lastSink: number; +} + +const fixture = JSON.parse( + readFileSync(path.join(import.meta.dirname, "fixtures", "snow-field-source.json"), "utf8"), +) as ISourceFixture; + +const CHANNELS = ["indent", "bank", "compaction", "disturbance"] as const; +/** The study's boot covered 0.074 m² and pushed 80 kg times gravity through it. */ +const SOURCE_BOOT_AREA = 0.074; +const SOURCE_BOOT_MASS = 80; +const GRAVITY = 9.81; + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function smoothstep(edge0: number, edge1: number, value: number): number { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +/** The study's terrain function, kept here so the fixture can rebuild the same ground. */ +function sourceTerrain(x: number, z: number): number { + const radius = Math.hypot(x, z); + const hills = + smoothstep(14, 42, radius) * (0.8 + 0.85 * Math.sin(x * 0.085 + 1) * Math.cos(z * 0.075)); + return ( + 0.2 * Math.sin(x * 0.16) * Math.cos(z * 0.12) + + 0.09 * Math.sin(x * 0.39 + z * 0.21) + + 0.07 * Math.cos(z * 0.53 - x * 0.24) + + hills + ); +} + +const ZERO_SAMPLE: ISnowFootprintSample = { + bank: 0, + coverage: 0, + disturbance: 0, + relief: 0, + shape: 0, +}; + +/** + * The study's rounded boot sole with its chevrons, groove and raised rim, expressed through the + * generalized footprint contract. Boot design belongs to a game; this copy exists only so the + * source fixture can be reproduced exactly. + */ +function sourceBootFootprint(): ISnowFootprint { + return { + extent: 0.48, + sample: (x, z) => { + const width = 0.13 + smoothstep(-0.22, 0.15, z) * 0.013; + const qx = Math.abs(x) - (width - 0.052); + const qz = Math.abs(z) - (0.255 - 0.052); + const sdf = + Math.hypot(Math.max(qx, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qz), 0) - 0.052; + if (sdf > 0.105) return ZERO_SAMPLE; + const tread = Math.max(0, Math.cos(z * 83 + Math.abs(x) * 30)) ** 8; + const groove = (1 - smoothstep(0.009, 0.023, Math.abs(x))) * 0.004; + const rim = sdf > 0 ? Math.exp(-(((sdf - 0.027) / 0.027) ** 2)) : 0; + return { + bank: rim * (0.78 + 0.22 * Math.sin(x * 94 + z * 63)), + coverage: 1 - smoothstep(-0.038, 0.008, sdf), + disturbance: rim * 0.5, + relief: 0.012 * tread + groove, + shape: 0.9 + 0.1 * smoothstep(-0.2, 0.15, z), + }; + }, + }; +} + +function fixtureTerrain(): Heightfield { + return Heightfield.fromSampler({ + columns: fixture.scenario.resolution, + depth: fixture.scenario.size, + origin: { x: 0, z: 0 }, + rows: fixture.scenario.resolution, + sampleHeight: sourceTerrain, + width: fixture.scenario.size, + }); +} + +function snapshot(snow: SnowField): ISourceSnapshot { + const samples = fixture.points.map(([x, z]) => { + const value = snow.sample(x, z); + return [value.indent, value.bank, value.compaction, value.disturbance]; + }); + return { activeCells: snow.activeCells, samples }; +} + +/** Worst per-channel difference over every fixture point, plus an exact active-cell comparison. */ +function worstDifference(actual: ISourceSnapshot, expected: ISourceSnapshot): number { + expect(actual.activeCells, "active cells").toBe(expected.activeCells); + let worst = 0; + for (let index = 0; index < expected.samples.length; index += 1) { + const actualSample = actual.samples[index] as readonly number[]; + const expectedSample = expected.samples[index] as readonly number[]; + for (let channel = 0; channel < 4; channel += 1) { + const difference = Math.abs( + (actualSample[channel] as number) - (expectedSample[channel] as number), + ); + if (difference > worst) worst = difference; + if (difference > 1e-9) + throw new Error( + `${CHANNELS[channel]} at fixture point ${String(index)} (${String( + (fixture.points[index] as readonly number[])[0], + )}, ${String((fixture.points[index] as readonly number[])[1])}) differs by ${String( + difference, + )}.`, + ); + } + } + return worst; +} + +function replaySourceScenario(): SnowField { + const snow = new SnowField({ field: fixtureTerrain(), depth: fixture.scenario.depth }); + for (const stamp of fixture.scenario.stamps) { + snow.stamp({ + area: SOURCE_BOOT_AREA, + footprint: sourceBootFootprint(), + hardness: stamp.hardness, + load: stamp.mass * GRAVITY, + rotation: stamp.angle, + x: stamp.x, + z: stamp.z, + }); + } + return snow; +} + +/** An axis-aligned box contact: a crate corner, a plank, a platform edge. */ +function boxFootprint(halfWidth: number, halfLength: number): ISnowFootprint { + const edge = 0.02; + return { + extent: Math.hypot(halfWidth, halfLength) + edge, + sample: (x, z) => { + const inside = Math.max(Math.abs(x) - halfWidth, Math.abs(z) - halfLength); + if (inside > edge) return ZERO_SAMPLE; + const coverage = 1 - smoothstep(-edge, edge, inside); + return { + bank: inside > 0 ? 1 - inside / edge : 0, + coverage, + disturbance: coverage, + relief: 0, + shape: 1, + }; + }, + }; +} + +describe("SnowField source regression", () => { + it("reproduces the source study's footprint state through the public field", () => { + const snow = replaySourceScenario(); + expect(snow.steps).toBe(fixture.steps); + expect(worstDifference(snapshot(snow), fixture.afterStamps)).toBeLessThan(1e-9); + }); + + it("reaches the source's penetration for the same mass, area and hardness", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: fixture.scenario.depth }); + const measured = fixture.scenario.stamps.map((stamp) => + snow.stamp({ + area: SOURCE_BOOT_AREA, + footprint: sourceBootFootprint(), + hardness: stamp.hardness, + load: stamp.mass * GRAVITY, + rotation: stamp.angle, + x: stamp.x, + z: stamp.z, + }), + ); + for (let index = 0; index < measured.length; index += 1) + expect(measured[index]).toBeCloseTo(fixture.penetrations[index] as number, 9); + expect(snow.steps).toBe(fixture.steps); + expect(snow.lastSink).toBeCloseTo(fixture.lastSink, 9); + }); + + it("recovers, retunes depth and resets exactly as the source did", () => { + const snow = replaySourceScenario(); + const { deposition, dt, steps, wind } = fixture.scenario.recovery; + for (let tick = 0; tick < steps; tick += 1) snow.recover(dt, deposition, wind); + expect(worstDifference(snapshot(snow), fixture.afterRecovery)).toBeLessThan(1e-9); + + snow.setDepth(fixture.scenario.depthOverride); + expect(worstDifference(snapshot(snow), fixture.afterDepth)).toBeLessThan(1e-9); + + snow.reset(); + expect(worstDifference(snapshot(snow), fixture.afterReset)).toBeLessThan(1e-9); + expect(snow.steps).toBe(0); + expect(snow.lastSink).toBe(0); + expect(snow.activeCells).toBe(0); + }); +}); + +describe("SnowField canonical surface", () => { + it("writes terrain plus depth plus bank minus indentation into the shared heightfield", () => { + const field = fixtureTerrain(); + const pristine = fixtureTerrain(); + const snow = new SnowField({ field, depth: 0.28 }); + snow.stamp({ + area: 0.05, + footprint: sourceBootFootprint(), + load: 900, + x: 0.4, + z: -0.2, + }); + for (let index = 0; index < 200; index += 1) { + const x = -0.6 + index * 0.008; + const sample = snow.sample(x, 0.05); + expect(snow.heightAt(x, 0.05)).toBeCloseTo( + pristine.heightAt(x, 0.05) + 0.28 + sample.bank - sample.indent, + 5, + ); + } + }); + + it("keeps rendered vertices and collider samples within a millimetre of canonical heights", () => { + const field = fixtureTerrain(); + const snow = new SnowField({ field, depth: 0.28 }); + const geometry = field.toGeometry(); + const centre = (field.rows >> 1) * field.columns + (field.columns >> 1); + const before = geometry.getAttribute("position").getY(centre); + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.3), load: 2000, x: 0, z: 0 }); + const region = snow.dirtyRegion; + expect(region).toBeDefined(); + field.refreshGeometry(geometry, region); + const positions = geometry.getAttribute("position"); + const normals = geometry.getAttribute("normal"); + const collider = field.toColliderHeights(); + expect(Math.abs(positions.getY(centre) - before)).toBeGreaterThan(0.02); + + let worst = 0; + for (let row = 0; row < field.rows; row += 1) { + const worldZ = field.origin.z - field.depth / 2 + (row * field.depth) / (field.rows - 1); + for (let column = 0; column < field.columns; column += 1) { + const index = row * field.columns + column; + const worldX = + field.origin.x - field.width / 2 + (column * field.width) / (field.columns - 1); + const canonical = snow.heightAt(worldX, worldZ); + worst = Math.max(worst, Math.abs(positions.getY(index) - canonical)); + worst = Math.max( + worst, + Math.abs((collider[column * field.rows + row] as number) - canonical), + ); + worst = Math.max(worst, Math.abs(normals.getY(index) - snow.normalAt(worldX, worldZ).y)); + } + } + expect(worst).toBeLessThan(0.001); + }); + + it("keeps a windowed geometry refresh's bounds around every vertex", () => { + // Flat, so the stamp's floor is below every height the bounds were first measured on. + const field = Heightfield.fromSampler({ + columns: 65, + depth: 4, + origin: { x: 0, z: 0 }, + rows: 65, + sampleHeight: () => 0, + width: 4, + }); + const snow = new SnowField({ field, depth: 0.28 }); + const geometry = field.toGeometry(); + field.refreshGeometry(geometry); + snow.stamp({ area: 0.004, footprint: snowDiscFootprint(0.3), load: 5000, x: 0, z: 0 }); + field.refreshGeometry(geometry, snow.dirtyRegion); + const box = geometry.boundingBox; + const sphere = geometry.boundingSphere; + if (box === null || sphere === null) throw new Error("refreshGeometry left no bounds"); + const position = geometry.getAttribute("position"); + const point = new Vector3(); + for (let index = 0; index < position.count; index += 1) { + point.fromBufferAttribute(position, index); + expect(box.containsPoint(point)).toBe(true); + expect(sphere.containsPoint(point)).toBe(true); + } + }); + + it("tracks a monotonic version and the dirty window of the surface it changed", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + const start = snow.version; + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: -1, z: 2 }); + expect(snow.version).toBe(start + 1); + const region = snow.dirtyRegion; + expect(region?.columns).toBeLessThan(snow.columns); + expect(region?.rows).toBeLessThan(snow.rows); + expect(region?.column).toBeGreaterThan(0); + expect(region?.row).toBeGreaterThan(0); + }); + + it("copies a channel window that matches sample at every cell, and rejects a window outside", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.3), load: 2000, x: 0, z: 0 }); + const region = snow.dirtyRegion; + if (region === undefined) throw new Error("the stamp reported no window"); + const indent = snow.copyChannel("indent", region); + const compaction = snow.copyChannel("compaction", region, new Float32Array(1)); + let worst = 0; + for (let row = 0; row < region.rows; row += 1) { + for (let column = 0; column < region.columns; column += 1) { + const x = snow.minimumX + (region.column + column) * snow.cellWidth; + const z = snow.minimumZ + (region.row + row) * snow.cellDepth; + const sample = snow.sample(x, z); + const index = row * region.columns + column; + worst = Math.max(worst, Math.abs((indent[index] as number) - sample.indent)); + worst = Math.max(worst, Math.abs((compaction[index] as number) - sample.compaction)); + } + } + expect(worst).toBeLessThan(1e-6); + expect(Math.max(...indent)).toBeGreaterThan(0.02); + expect(() => + snow.copyChannel("indent", { column: snow.columns - 2, columns: 4, row: 0, rows: 1 }), + ).toThrow(/inside the field/); + }); + + it("hands a renderer the union of every window written since it last looked", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + snow.takeDirtyRegion(); + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: -1, z: 2 }); + const first = snow.dirtyRegion; + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: 1, z: -2 }); + const second = snow.dirtyRegion; + const union = snow.takeDirtyRegion(); + if (first === undefined || second === undefined || union === undefined) + throw new Error("each stamp must report a window"); + for (const part of [first, second]) { + expect(union.column).toBeLessThanOrEqual(part.column); + expect(union.row).toBeLessThanOrEqual(part.row); + expect(union.column + union.columns).toBeGreaterThanOrEqual(part.column + part.columns); + expect(union.row + union.rows).toBeGreaterThanOrEqual(part.row + part.rows); + } + expect(snow.takeDirtyRegion()).toBeUndefined(); + }); +}); + +describe("SnowField contact profiles", () => { + it("prints a boot and a disc differently, and honours rotation", () => { + const boot = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + const disc = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + const load = 80 * GRAVITY; + boot.stamp({ area: SOURCE_BOOT_AREA, footprint: sourceBootFootprint(), load, x: 0, z: 0 }); + disc.stamp({ + area: SOURCE_BOOT_AREA, + footprint: snowDiscFootprint(0.153), + load, + x: 0, + z: 0, + }); + + // A disc is radially symmetric; the boot is long in z and narrow in x. + expect(disc.sample(0.13, 0).indent).toBeCloseTo(disc.sample(0, 0.13).indent, 5); + expect(boot.sample(0, 0.2).indent).toBeGreaterThan(boot.sample(0.2, 0).indent + 0.01); + expect(boot.sample(0, 0).indent).toBeGreaterThan(0.02); + + const turned = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + turned.stamp({ + area: SOURCE_BOOT_AREA, + footprint: sourceBootFootprint(), + load, + rotation: Math.PI / 2, + x: 0, + z: 0, + }); + expect(turned.sample(0.2, 0).indent).toBeGreaterThan(turned.sample(0, 0.2).indent + 0.01); + expect(turned.sample(0.2, 0).indent).toBeCloseTo(boot.sample(0, 0.2).indent, 5); + }); + + it("responds to pressure rather than to a fixed boot constant", () => { + const depth = 0.3; + const load = 80 * GRAVITY; + const concentrated = new SnowField({ field: fixtureTerrain(), depth }); + const spread = new SnowField({ field: fixtureTerrain(), depth }); + const light = new SnowField({ field: fixtureTerrain(), depth }); + concentrated.stamp({ + area: 0.02, + footprint: snowDiscFootprint(0.08), + load, + x: 0, + z: 0, + }); + spread.stamp({ area: 0.4, footprint: snowDiscFootprint(0.36), load, x: 0, z: 0 }); + light.stamp({ area: 0.02, footprint: snowDiscFootprint(0.08), load: load / 8, x: 0, z: 0 }); + + expect(concentrated.sample(0, 0).indent).toBeGreaterThan(spread.sample(0, 0).indent + 0.05); + expect(concentrated.sample(0, 0).indent).toBeGreaterThan(light.sample(0, 0).indent + 0.02); + expect(spread.activeCells).toBeGreaterThan(concentrated.activeCells); + }); + + it("reaches the same indentation however the contact time is split", () => { + const depth = 0.3; + const load = 600; + const once = new SnowField({ field: fixtureTerrain(), depth }); + const many = new SnowField({ field: fixtureTerrain(), depth }); + once.stamp({ + area: 0.03, + duration: 0.3, + footprint: snowDiscFootprint(0.12), + load, + x: 0, + z: 0, + }); + for (let tick = 0; tick < 18; tick += 1) + many.stamp({ + area: 0.03, + duration: 0.3 / 18, + footprint: snowDiscFootprint(0.12), + load, + x: 0, + z: 0, + }); + expect(many.sample(0, 0).indent).toBeCloseTo(once.sample(0, 0).indent, 6); + expect(many.sample(0.1, 0.05).indent).toBeCloseTo(once.sample(0.1, 0.05).indent, 6); + + // A resting contact that has already yielded does not keep sinking without limit. + many.stamp({ + area: 0.03, + duration: 5, + footprint: snowDiscFootprint(0.12), + load, + x: 0, + z: 0, + }); + expect(many.sample(0, 0).indent).toBeLessThan(depth * 0.88); + }); + + it("presses any bounded shape, not just a boot: a rotated box leaves a rectangular print", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + snow.stamp({ + area: 0.6, + footprint: boxFootprint(0.5, 0.2), + load: 300, + x: 0, + z: 0, + }); + expect(snow.sample(0.45, 0).indent).toBeGreaterThan(0.01); + expect(snow.sample(0, 0.15).indent).toBeGreaterThan(0.01); + expect(snow.sample(0.45, 0.3).indent).toBeLessThan(0.001); + expect(snow.sample(0.7, 0).indent).toBeLessThan(0.001); + }); + + it("stamps a rolling sphere as connected track instead of one repeated hole", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + const footprint = snowDiscFootprint(0.25); + for (let step = 0; step < 12; step += 1) + snow.stamp({ area: 0.2, footprint, load: 10 * GRAVITY, x: -1 + step * 0.15, z: 0.5 }); + for (let step = 0; step < 12; step += 1) + expect(snow.sample(-1 + step * 0.15, 0.5).indent).toBeGreaterThan(0.01); + expect(snow.sample(-1, 1.5).indent).toBeLessThan(0.001); + }); +}); + +describe("SnowField validation", () => { + it("rejects malformed contacts and dimensions before it mutates anything", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + const footprint = snowDiscFootprint(0.2); + const version = snow.version; + const contact = { area: 0.1, footprint, load: 100, x: 0, z: 0 }; + expect(() => snow.stamp({ ...contact, area: 0 })).toThrow(/area/); + expect(() => snow.stamp({ ...contact, area: Number.NaN })).toThrow(/area/); + expect(() => snow.stamp({ ...contact, load: -1 })).toThrow(/load/); + expect(() => snow.stamp({ ...contact, x: Number.POSITIVE_INFINITY })).toThrow(/x/); + expect(() => snow.stamp({ ...contact, duration: -1 })).toThrow(/duration/); + expect(() => + snow.stamp({ ...contact, footprint: { extent: 0, sample: () => ZERO_SAMPLE } }), + ).toThrow(/extent/); + expect(() => + snow.stamp({ + ...contact, + footprint: { extent: 1000, sample: () => ZERO_SAMPLE }, + }), + ).toThrow(/cells/); + expect(() => snow.recover(-1, 0.1, 0.1)).toThrow(/recovery seconds/); + expect(() => snow.recover(1, -1, 0.1)).toThrow(/deposition/); + expect(() => snow.setDepth(-0.1)).toThrow(/depth/); + expect(() => new SnowField({ field: fixtureTerrain(), depth: Number.NaN })).toThrow(/depth/); + expect(snow.version).toBe(version); + }); + + it("keeps Heightfield's error contract for heightAt and normalAt, and returns zeros in sample", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + expect(() => snow.heightAt(99, 0)).toThrow(/outside its resident region/); + expect(() => snow.normalAt(0, -99)).toThrow(/outside its resident region/); + expect(snow.sample(99, 99)).toEqual({ bank: 0, compaction: 0, disturbance: 0, indent: 0 }); + }); + + it("reports a stamp entirely outside the snowfield as a no-op", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + const version = snow.version; + expect( + snow.stamp({ + area: 0.02, + footprint: snowDiscFootprint(0.2), + load: 800, + x: 40, + z: 40, + }), + ).toBe(0); + expect(snow.version).toBe(version); + expect(snow.steps).toBe(0); + }); + + it("clips a straddling contact at the boundary instead of wrapping or throwing", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + const edge = snow.minimumX + 0.1; + expect( + snow.stamp({ + area: 0.02, + footprint: snowDiscFootprint(0.3), + load: 800, + x: edge, + z: 0, + }), + ).toBeGreaterThan(0); + expect(snow.sample(snow.minimumX + 0.01, 0).indent).toBeGreaterThan(0.01); + expect(() => snow.sample(snow.minimumX - 1, 0)).not.toThrow(); + }); + + it("treats zero depth as bare ground with no indentation at all", () => { + const field = fixtureTerrain(); + const pristine = fixtureTerrain(); + const snow = new SnowField({ field, depth: 0 }); + expect( + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.2), load: 900, x: 0, z: 0 }), + ).toBe(0); + expect(snow.sample(0, 0).indent).toBe(0); + expect(snow.heightAt(0.5, 0.5)).toBeCloseTo(pristine.heightAt(0.5, 0.5), 6); + }); + + it("buries indentation when depth shrinks and keeps tracks when it grows", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 }); + snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.25), load: 1200, x: 0, z: 0 }); + const deep = snow.sample(0, 0).indent; + snow.setDepth(0.06); + expect(snow.sample(0, 0).indent).toBeLessThan(deep); + expect(snow.sample(0, 0).indent).toBeLessThanOrEqual(0.06 * 0.88 + 1e-6); + snow.setDepth(0.4); + expect(snow.sample(0, 0).indent).toBeGreaterThan(0); + }); + + it("reports the bytes it retains", () => { + const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 }); + const cells = snow.columns * snow.rows; + expect(snow.memoryBytes).toBe(cells * 5 * Float32Array.BYTES_PER_ELEMENT); + }); +}); diff --git a/packages/core/__tests__/three-shadow-override-cache.spec.ts b/packages/core/__tests__/three-shadow-override-cache.spec.ts index 4727ce5631..6b1b932fdb 100644 --- a/packages/core/__tests__/three-shadow-override-cache.spec.ts +++ b/packages/core/__tests__/three-shadow-override-cache.spec.ts @@ -1,4 +1,4 @@ -import { BoxGeometry, Camera, Mesh, Scene } from "three"; +import { BoxGeometry, type BufferGeometry, Camera, Mesh, Scene } from "three"; // @ts-expect-error Three's private renderer module has no public declaration; this test must exercise it directly. import RenderObjects from "three/src/renderers/common/RenderObjects.js"; import { @@ -36,7 +36,12 @@ interface IShadowRendererStub { readonly _cacheShadowNodes: WeakMap; _currentSourceMaterial: unknown; readonly _getShadowNodes: unknown; - readonly _handleObjectFunction: (object: unknown, material: IDrawnMaterial) => void; + readonly _handleObjectFunction: ( + object: unknown, + material: IDrawnMaterial, + scene: Scene, + camera: Camera, + ) => void; readonly shadowMap: { enabled: boolean; type: number }; } @@ -53,9 +58,9 @@ type RenderObjectCall = ( passId: unknown, ) => void; -function shadowPass(): { +function shadowPass(onDraw?: (mesh: Mesh, camera: Camera) => void): { drawn: IDrawnMaterial[]; - draw: (mesh: Mesh) => void; + draw: (mesh: Mesh, camera?: Camera) => void; override: NodeMaterial; } { const drawn: IDrawnMaterial[] = []; @@ -67,8 +72,10 @@ function shadowPass(): { _cacheShadowNodes: new WeakMap(), _currentSourceMaterial: null, _getShadowNodes: rendererPrototype._getShadowNodes, - _handleObjectFunction: (_object, material) => - drawn.push({ alphaTest: material.alphaTest, side: material.side }), + _handleObjectFunction: (object, material, _scene, camera) => { + drawn.push({ alphaTest: material.alphaTest, side: material.side }); + onDraw?.(object as Mesh, camera); + }, shadowMap: { enabled: true, type: PCFShadowMap }, }; @@ -80,12 +87,12 @@ function shadowPass(): { const camera = new Camera(); return { - draw: (mesh) => + draw: (mesh, drawCamera = camera) => rendererPrototype.renderObject.call( stub, mesh, scene, - camera, + drawCamera, mesh.geometry, mesh.material, null, @@ -111,6 +118,7 @@ type InternalNodeMaterial = NodeMaterial & { }; interface IRenderObjectProbe { + readonly geometry: BufferGeometry; readonly _sourceMaterial: NodeMaterial | null; readonly sourceVersion: number; readonly version: number; @@ -140,7 +148,7 @@ interface IRendererProbe { } function renderObjectsProbe(): { - get: (object: Mesh, source: NodeMaterial) => IRenderObjectProbe; + get: (object: Mesh, source: NodeMaterial, camera?: Camera) => IRenderObjectProbe; makeSource: (alphaTest: number) => MeshStandardNodeMaterial; makeObject: (source: NodeMaterial) => Mesh; override: InternalNodeMaterial; @@ -177,11 +185,11 @@ function renderObjectsProbe(): { const lightsNode = {}; return { - get: (object, source) => { + get: (object, source, drawCamera = camera) => { renderer._currentSourceMaterial = source; // This is the backing-field write performed by the patched Renderer during a shadow pass. override._alphaTest = source.alphaTest; - return manager.get(object, override, scene, camera, lightsNode, renderContext, null); + return manager.get(object, override, scene, drawCamera, lightsNode, renderContext, null); }, makeSource: (alphaTest) => { const source = new MeshStandardNodeMaterial(); @@ -197,6 +205,30 @@ function renderObjectsProbe(): { } describe("three shadow override material cache", () => { + it("fetches geometry swapped in onBeforeRender even when cameras share a render object", () => { + const probe = renderObjectsProbe(); + const source = probe.makeSource(0); + const object = probe.makeObject(source); + const geometry = object.geometry; + const geometries = [geometry, geometry.clone(), geometry.clone()]; + for (const held of geometries) held.setAttribute("position", geometry.getAttribute("position")); + const cameras = [new Camera(), new Camera(), new Camera()]; + object.onBeforeRender = (_renderer, _scene, camera) => { + object.geometry = geometries[cameras.indexOf(camera)] as BufferGeometry; + }; + const fetched: IRenderObjectProbe[] = []; + const { draw } = shadowPass((mesh, camera) => { + const renderObject = probe.get(mesh, source, camera); + expect(renderObject.geometry).toBe(geometries[cameras.indexOf(camera)]); + expect(renderObject.geometry.getIndex()).toBe(mesh.geometry.getIndex()); + fetched.push(renderObject); + }); + for (let frame = 0; frame < 3; frame += 1) for (const camera of cameras) draw(object, camera); + expect(fetched).toHaveLength(9); + expect(new Set(fetched).size).toBe(1); + for (const held of geometries) held.dispose(); + }); + it("keeps the shared override material's version still across mixed alphaTest casters", () => { const { draw, override } = shadowPass(); const foliage = caster(0.5); diff --git a/packages/core/__tests__/virtual-shadow-caster-draws.spec.ts b/packages/core/__tests__/virtual-shadow-caster-draws.spec.ts index 92c0db8af0..911a7db013 100644 --- a/packages/core/__tests__/virtual-shadow-caster-draws.spec.ts +++ b/packages/core/__tests__/virtual-shadow-caster-draws.spec.ts @@ -1,13 +1,18 @@ import { BoxGeometry, DirectionalLight, + Frustum, + Group, + Matrix4, Mesh, MeshBasicMaterial, + type OrthographicCamera, PerspectiveCamera, Scene, + type Sphere, } from "three"; import type { NodeBuilder, NodeFrame } from "three/webgpu"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { VIRTUAL_SHADOW_CASTER_LAYER, VIRTUAL_SHADOW_SMALL_CASTER_LAYER, @@ -62,11 +67,15 @@ function shadowWorld(): { camera: PerspectiveCamera; light: DirectionalLight; sc return { camera, light, scene }; } -function nodeFor(light: DirectionalLight): VirtualShadowNode { +function nodeFor( + light: DirectionalLight, + options: { clipExtents?: readonly number[]; invalidationDelay?: number } = {}, +): VirtualShadowNode { const node = new VirtualShadowNode(light, { clipExtents: [24, 96], mapSize: 1024, marker: false, + ...options, }); node.setup(builder); // The real draw belongs to three's renderer; this test is about what the level reports. @@ -78,8 +87,25 @@ function nodeFor(light: DirectionalLight): VirtualShadowNode { afterEach(() => { clock = 0; + vi.restoreAllMocks(); }); +/** What every level reports: the bill, the size gate and the depth derived off the caster set. */ +function report(node: VirtualShadowNode): unknown[] { + return node.stats.perLevel.map((level, index) => { + const camera = ( + node.levelLights[index] as unknown as { shadow: { camera: OrthographicCamera } } + ).shadow.camera; + return { + draws: level.draws, + drawsBy: level.drawsBy, + far: camera.far, + gateHidden: level.gateHidden, + near: camera.near, + }; + }); +} + describe("a level's reported caster draws", () => { it("reports the chosen cluster half, its small casters, its chunk proxies and layer 0", () => { const { camera, light, scene } = shadowWorld(); @@ -142,3 +168,338 @@ describe("a level's reported caster draws", () => { node.dispose(); }); }); + +/** + * PRD-475 cut 2: the caster set a level reads is memoised, so a world that did not change is not + * walked again on the next render. Three things have to hold for that to be worth anything, and + * each is asserted here through the node's own reported bill and derived depth rather than through + * a counter of its own: the walk happens once and not per render, a change the tree reports rebuilds + * it, and every other change a game can make to a caster — its `castShadow`, where it stands — is + * still read on the render itself. The last is checked against a node that has never seen the + * world, because "the memoised node agrees with a fresh walk" is the only statement worth making + * about a memo. + */ +/** + * What a node that has never seen this world says about it, having walked it in full — the + * statement the memo has to keep making after every change a game can make to a caster. + */ +function reportFromAFreshNode(light: DirectionalLight, camera: PerspectiveCamera): unknown[] { + const fresh = nodeFor(light, { invalidationDelay: 0 }); + fresh.updateBefore(frameFor(camera)); + const reported = report(fresh); + fresh.dispose(); + return reported; +} + +describe("the memoised caster set", () => { + it.each(["cluster", "wide"] as const)( + "finishes the coarse probe after an alpha caster, choosing %s casters", + (half) => { + const { camera, light, scene } = shadowWorld(); + const cells = new Group(); + scene.add(cells); + const cutout = caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "pine-leaves@0,0"); + (cutout.material as MeshBasicMaterial).alphaTest = 0.5; + cells.add(cutout); + const node = nodeFor(light, { clipExtents: [24, 96, 320], invalidationDelay: 0 }); + node.updateBefore(frameFor(camera)); + + // Stream a populated subtree after the first render, with an opaque trunk behind the cutout. + const loaded = new Group(); + const trunk = caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "pine-trunk@0,0"); + const wide = caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "pine-trunk@*"); + trunk.position.set(20, 12, -48); + wide.position.copy(trunk.position); + loaded.add(trunk, wide); + if (half === "cluster") { + const secondWide = caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "oak-trunk@*"); + loaded.add(secondWide); + } + cells.add(loaded); + const receiver = caster(scene, 0, "terrain-block"); + receiver.geometry = new BoxGeometry(512, 1, 512); + receiver.castShadow = false; + receiver.receiveShadow = true; + + for (const distance of [0, 32, 64]) { + camera.position.z = -distance; + loaded.position.y = distance / 8; + scene.updateMatrixWorld(true); + node.invalidateAll(); + for (let level = 0; level < 3; level += 1) { + node.updateBefore(frameFor(camera)); + if (level === 0) continue; + const coarse = node.stats.perLevel[level]; + expect(coarse?.rendered).toBe(1); + expect(coarse?.drawsBy[half]).toBe(1); + expect(coarse?.draws).toBe(1); + const shadowLight = node.levelLights[level] as DirectionalLight; + shadowLight.shadow.updateMatrices(shadowLight); + const shadowCamera = shadowLight.shadow.camera; + const frustum = new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices( + shadowCamera.projectionMatrix, + shadowCamera.matrixWorldInverse, + ), + ); + expect(frustum.intersectsObject(half === "cluster" ? trunk : wide)).toBe(true); + expect(shadowCamera.far).toBeLessThan(1000); + } + expect(cutout.castShadow).toBe(true); + } + node.dispose(); + }, + ); + + it("continues the probe after a mesh with no bounding sphere", () => { + const { camera, light, scene } = shadowWorld(); + const missing = caster(scene, 0, "missing-bounds"); + vi.spyOn(missing.geometry, "computeBoundingSphere").mockImplementation(() => undefined); + caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "trunk"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-1"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-2"); + scene.updateMatrixWorld(true); + const node = nodeFor(light); + node.updateBefore(frameFor(camera)); + expect(node.stats.perLevel[0]?.drawsBy.cluster).toBe(1); + expect(node.stats.perLevel[0]?.draws).toBe(1); + node.dispose(); + }); + + /** Four casters: one cluster square against two wide meshes, so the cluster half wins the choice, + * and one on layer 0, which every level draws and no half decides. */ + function threeCasters(): { + camera: PerspectiveCamera; + cluster: Mesh; + light: DirectionalLight; + scene: Scene; + } { + const { camera, light, scene } = shadowWorld(); + const cluster = caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "cluster"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-2"); + caster(scene, 0, "terrain"); + scene.updateMatrixWorld(true); + return { camera, cluster, light, scene }; + } + + it("walks the caster root once for two renders of a world that did not change", () => { + const { camera, light, scene } = threeCasters(); + const node = nodeFor(light, { invalidationDelay: 0 }); + const walk = vi.spyOn(scene, "traverse"); + + node.updateBefore(frameFor(camera)); + expect(node.stats.perLevel[0]?.rendered).toBe(1); + expect(walk).toHaveBeenCalledTimes(1); + + // The same level again, with nothing in the world touched: an ask, not a change. + light.shadow.needsUpdate = true; + node.updateBefore(frameFor(camera)); + expect(node.stats.perLevel[0]?.rendered).toBe(1); + expect(walk).toHaveBeenCalledTimes(1); + node.dispose(); + }); + + it("walks again when a caster joins the tree, and says the same thing as a fresh walk", () => { + const { camera, light, scene } = threeCasters(); + const node = nodeFor(light, { invalidationDelay: 0 }); + const walk = vi.spyOn(scene, "traverse"); + node.updateBefore(frameFor(camera)); + const before = report(node); + expect(walk).toHaveBeenCalledTimes(1); + + caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "second-cluster"); + scene.updateMatrixWorld(true); + light.shadow.needsUpdate = true; + node.updateBefore(frameFor(camera)); + + // The walk the change asked for, and not another one on the render after it. + expect(walk).toHaveBeenCalledTimes(2); + const after = report(node); + expect(after).not.toEqual(before); + expect(after).toEqual(reportFromAFreshNode(light, camera)); + node.dispose(); + }); + + it("sees a caster that stops casting, without the tree changing", () => { + const { camera, cluster, light, scene } = threeCasters(); + const node = nodeFor(light, { invalidationDelay: 0 }); + node.updateBefore(frameFor(camera)); + const before = report(node); + + // No add, no remove, so the caster set is the same set of meshes — and the level no longer + // draws the cluster half, because a mesh that casts nothing is not in either bill. + cluster.castShadow = false; + light.shadow.needsUpdate = true; + node.updateBefore(frameFor(camera)); + const after = report(node); + expect(after).not.toEqual(before); + expect(after).toEqual(reportFromAFreshNode(light, camera)); + node.dispose(); + }); + + it("sees a caster that moved, without the tree changing", () => { + const { camera, cluster, light, scene } = threeCasters(); + const node = nodeFor(light, { invalidationDelay: 0 }); + node.updateBefore(frameFor(camera)); + const before = report(node); + + // Raised 36 m: same mesh, same layers, a longer throw and so a deeper window. The world matrix + // the world sphere was derived from is what the render compares, which is why a memo keyed on + // anything coarser than this would hand the level a stale depth. + cluster.position.set(0, 40, 0); + scene.updateMatrixWorld(true); + light.shadow.needsUpdate = true; + node.updateBefore(frameFor(camera)); + const after = report(node); + expect(after).not.toEqual(before); + expect(after).toEqual(reportFromAFreshNode(light, camera)); + node.dispose(); + }); + + it("drops a caster that leaves the tree", () => { + const { camera, light, scene } = threeCasters(); + const node = nodeFor(light, { invalidationDelay: 0 }); + node.updateBefore(frameFor(camera)); + const before = report(node); + + scene.remove(scene.getObjectByName("cluster") as Mesh); + scene.updateMatrixWorld(true); + light.shadow.needsUpdate = true; + node.updateBefore(frameFor(camera)); + const after = report(node); + expect(after).not.toEqual(before); + expect(after).toEqual(reportFromAFreshNode(light, camera)); + node.dispose(); + }); +}); + +/** + * PRD-475: a level holds its map until something asks it to redraw, and a caster that leaves or joins + * the world is something. These three ask for the ask — no `needsUpdate`, no `invalidateAll`, no + * `invalidateRegion`, nothing but the tree itself changing — because that is exactly what a streamed + * cell eviction and admission do, and a level that keeps its map across one keeps a ghost shadow of + * geometry the world no longer holds. + */ +describe("a level and the casters that changed", () => { + /** + * Two cluster casters inside the finest window against three wide ones, so the level picks the + * cluster half and its bill is those two plus the terrain. A change to the casters is then a + * change in the bill, which is what these three read. + */ + function twoCasters(): { + b: Mesh; + camera: PerspectiveCamera; + light: DirectionalLight; + scene: Scene; + } { + const { camera, light, scene } = shadowWorld(); + caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "A"); + const b = caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "B"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-1"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-2"); + caster(scene, VIRTUAL_SHADOW_WIDE_CASTER_LAYER, "wide-3"); + caster(scene, 0, "terrain"); + scene.updateMatrixWorld(true); + return { b, camera, light, scene }; + } + + /** + * Every level mapped and settled, then one frame that asks for nothing and must be served from + * cache. The finest level's row is returned from the frame it rendered on, since a level that + * keeps its map reports no draws of its own. + */ + function settled( + node: VirtualShadowNode, + camera: PerspectiveCamera, + draws = 3, + ): { draws: number } { + let row = { draws: 0 }; + for (let frame = 0; frame < 3; frame += 1) { + node.updateBefore(frameFor(camera)); + if ((node.stats.perLevel[0]?.rendered ?? 0) === 1) + row = { draws: node.stats.perLevel[0]?.draws ?? 0 }; + } + node.updateBefore(frameFor(camera)); + expect(node.stats).toMatchObject({ rendered: 0, cached: 2 }); + expect(row.draws).toBe(draws); + return row; + } + + it("re-renders and drops a caster that left the tree", () => { + const { b, camera, light, scene } = twoCasters(); + const node = nodeFor(light); + settled(node, camera); + + // What a cell eviction is: the parent drops it, and nothing sets a flag anywhere. + scene.remove(b); + scene.updateMatrixWorld(true); + node.updateBefore(frameFor(camera)); + + expect(node.stats.perLevel[0]).toMatchObject({ rendered: 1, invalidated: 1 }); + expect(node.stats.perLevel[0]?.draws).toBe(2); + node.dispose(); + }); + + it("re-renders and draws a caster that joined the tree", () => { + const { camera, light, scene } = twoCasters(); + const node = nodeFor(light); + settled(node, camera); + + caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "C"); + scene.updateMatrixWorld(true); + node.updateBefore(frameFor(camera)); + + expect(node.stats.perLevel[0]).toMatchObject({ rendered: 1, invalidated: 1 }); + expect(node.stats.perLevel[0]?.draws).toBe(4); + node.dispose(); + }); + + it("re-renders a level a caster only its sphere reaches into", () => { + const { camera, light, scene } = twoCasters(); + const node = nodeFor(light); + + // A 16 m cube — a 13.86 m sphere — stood so its *centre* is outside the finest window and only + // its sphere reaches over the edge, which is what a forest straddling a page boundary is. The + // window is read off the node rather than guessed, so the placement survives a change to the + // clipmap's own grid: `1.05 × radius` past the edge is inside the sphere's reach and outside + // half of it, so a box sized by the radius names no level at all while the sphere names this one. + const edge = caster(scene, VIRTUAL_SHADOW_CASTER_LAYER, "edge"); + edge.geometry = new BoxGeometry(16, 16, 16); + edge.geometry.computeBoundingSphere(); + const radius = (edge.geometry.boundingSphere as Sphere).radius; + const window = node.clipmap.getWindow(0); + const half = node.clipmap.pagesPerAxis / 2; + const centreU = (window.minX + half) * window.pageWorldSize; + const centreV = (window.minY + half) * window.pageWorldSize; + const { x, y, z } = node.clipmap.unproject({ + u: centreU + window.extent + radius * 1.05, + v: centreV, + w: 0, + }); + edge.position.set(x, y, z); + scene.updateMatrixWorld(true); + + settled(node, camera, 4); + + scene.remove(edge); + node.updateBefore(frameFor(camera)); + + expect(node.stats.perLevel[0]).toMatchObject({ rendered: 1, invalidated: 1 }); + expect(node.stats.perLevel[0]?.draws).toBe(3); + node.dispose(); + }); + + it("re-renders a level whose caster stopped casting", () => { + const { b, camera, light, scene } = twoCasters(); + const node = nodeFor(light); + settled(node, camera); + + b.castShadow = false; + node.updateBefore(frameFor(camera)); + + expect(node.stats.perLevel[0]).toMatchObject({ rendered: 1, invalidated: 1 }); + expect(node.stats.perLevel[0]?.draws).toBe(2); + node.dispose(); + }); +}); diff --git a/packages/core/__tests__/world-cells-chunk-merge.spec.ts b/packages/core/__tests__/world-cells-chunk-merge.spec.ts index 4a2b3e3767..c6713d9a3c 100644 --- a/packages/core/__tests__/world-cells-chunk-merge.spec.ts +++ b/packages/core/__tests__/world-cells-chunk-merge.spec.ts @@ -2,11 +2,15 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + BackSide, Box3, BoxGeometry, BufferAttribute, BufferGeometry, + type Camera, + DirectionalLight, FrontSide, + Frustum, Group, InstancedMesh, InterleavedBufferAttribute, @@ -15,9 +19,11 @@ import { Mesh, MeshBasicMaterial, type Object3D, + Scene, Vector3, } from "three"; import { PerspectiveCamera } from "three"; +import type { NodeBuilder, NodeFrame } from "three/webgpu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DISCRETE_LOD_SCHEMA_VERSION, @@ -25,7 +31,11 @@ import { TN_DISCRETE_LOD, lodChainOf, } from "../src/model-lod.js"; -import { VIRTUAL_SHADOW_CASTER_LAYER } from "../src/render/virtual-shadow.js"; +import { + VIRTUAL_SHADOW_CASTER_LAYER, + VIRTUAL_SHADOW_WIDE_CASTER_LAYER, + VirtualShadowNode, +} from "../src/render/virtual-shadow.js"; import type { IRendererLike } from "../src/renderer.js"; import { isStatic } from "../src/static-transform.js"; import { type IWorldPackage, WorldCells } from "../src/world.js"; @@ -169,6 +179,189 @@ function meshesIn(root: Object3D): Mesh[] { return found; } +/** Observe the real level probe's draw inputs at three's shadow-render boundary, without a GPU. */ +function shadowDraws( + world: WorldCells, + half: "cluster" | "wide", + observe?: ( + meshes: Mesh[], + level: number, + depth: { near: number; far: number }, + camera: Camera, + ) => void, +): Mesh[][] { + const scene = new Scene(); + const camera = new PerspectiveCamera(60, 1, 0.1, 900); + camera.position.copy(CHUNK_ORIGIN).add(new Vector3(0, 10, 0)); + const light = new DirectionalLight(); + light.position.copy(CHUNK_ORIGIN).add(new Vector3(60, 200, -40)); + light.target.position.copy(CHUNK_ORIGIN); + light.castShadow = true; + scene.add(world, camera, light, light.target); + for (const layer of [VIRTUAL_SHADOW_CASTER_LAYER, VIRTUAL_SHADOW_WIDE_CASTER_LAYER]) { + const count = layer === VIRTUAL_SHADOW_WIDE_CASTER_LAYER && half === "cluster" ? 3 : 1; + for (let index = 0; index < count; index += 1) { + const batch = new Mesh(new BoxGeometry(8, 8, 8), surface); + batch.position.copy(CHUNK_ORIGIN); + batch.castShadow = true; + batch.layers.set(layer); + scene.add(batch); + } + } + scene.updateMatrixWorld(true); + const node = new VirtualShadowNode(light, { + clipExtents: [24, 96, 320], + invalidationDelay: 0, + marker: false, + mapSize: 1024, + adaptiveRefresh: false, + adaptiveCasterGate: false, + }); + node.setup({ + context: {}, + material: {}, + renderer: { shadowMap: { enabled: true } }, + } as unknown as NodeBuilder); + const draws: Mesh[][] = []; + node.levelNodes.forEach((levelNode, index) => { + (levelNode as unknown as { updateShadow(frame: NodeFrame): void }).updateShadow = () => { + const levelLight = node.levelLights[index] as DirectionalLight; + levelLight.shadow.updateMatrices(levelLight); + const shadowCamera = levelLight.shadow.camera; + expect(shadowCamera.layers.isEnabled(VIRTUAL_SHADOW_CASTER_LAYER)).toBe(half === "cluster"); + expect(shadowCamera.layers.isEnabled(VIRTUAL_SHADOW_WIDE_CASTER_LAYER)).toBe(half === "wide"); + const frustum = new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices( + shadowCamera.projectionMatrix, + shadowCamera.matrixWorldInverse, + ), + shadowCamera.coordinateSystem, + shadowCamera.reversedDepth, + ); + const visible: Mesh[] = []; + world.traverseVisible((object) => { + if (object instanceof Mesh) visible.push(object); + }); + draws[index] = visible.filter( + (mesh) => + mesh.visible && + mesh.castShadow && + shadowCamera.layers.test(mesh.layers) && + (!mesh.frustumCulled || frustum.intersectsObject(mesh)), + ); + for (const mesh of draws[index] as Mesh[]) + mesh.onBeforeRender( + {} as never, + scene, + shadowCamera, + mesh.geometry, + mesh.material as Material, + null as never, + ); + observe?.( + draws[index] as Mesh[], + index, + { near: shadowCamera.near, far: shadowCamera.far }, + shadowCamera, + ); + for (const mesh of draws[index] as Mesh[]) + mesh.onAfterRender( + {} as never, + scene, + shadowCamera, + mesh.geometry, + mesh.material as Material, + null as never, + ); + }; + }); + for (const mover of node.moverNodes) + (mover as unknown as { updateShadow(frame: NodeFrame): void }).updateShadow = () => undefined; + for (let time = 1; time <= 3; time += 1) + node.updateBefore({ camera, renderer: {}, time } as unknown as NodeFrame); + expect(draws).toHaveLength(3); + node.dispose(); + world.removeFromParent(); + return draws; +} + +/** The ordered world-space vertices of every drawn triangle, including its authored side. */ +function trianglesOf(meshes: readonly Mesh[]): string[] { + const triangles: string[] = []; + const instance = new Matrix4(); + const world = new Matrix4(); + const vertex = new Vector3(); + for (const mesh of meshes) { + const geometry = mesh.geometry; + const position = geometry.getAttribute("position"); + const indices = geometry.getIndex(); + const end = Math.min( + indices?.count ?? position.count, + geometry.drawRange.start + geometry.drawRange.count, + ); + for (let copy = 0; copy < (mesh instanceof InstancedMesh ? mesh.count : 1); copy += 1) { + if (mesh instanceof InstancedMesh) mesh.getMatrixAt(copy, instance); + else instance.identity(); + world.multiplyMatrices(mesh.matrixWorld, instance); + for (let at = geometry.drawRange.start; at + 2 < end; at += 3) { + const triangle: number[] = [(mesh.material as Material).side]; + for (let corner = 0; corner < 3; corner += 1) { + vertex + .fromBufferAttribute(position, indices?.getX(at + corner) ?? at + corner) + .applyMatrix4(world); + triangle.push(vertex.x, vertex.y, vertex.z); + } + triangles.push(JSON.stringify(triangle)); + } + } + } + return triangles.sort(); +} + +function preservedChunk(): { group: Group; parts: InstancedMesh[] } { + const group = new Group(); + group.position.copy(CHUNK_ORIGIN); + const nested = new Group(); + nested.position.set(2, 4, -2); + nested.scale.set(2, 1, 2); + group.add(nested); + const parts: InstancedMesh[] = []; + for (let part = 0; part < 5; part += 1) { + const size = part === 4 ? 1 / 32 : 2; + const box = new BoxGeometry(size, size, size); + const geometry = part % 2 === 0 ? box : box.toNonIndexed(); + if (part === 0) { + const position = geometry.getAttribute("position"); + geometry.setAttribute( + "position", + new BufferAttribute( + Int16Array.from(position.array, (value) => value * 32767), + 3, + true, + ), + ); + } + if (part === 1) geometry.setDrawRange(3, 15); + const mesh = new InstancedMesh( + geometry, + new MeshBasicMaterial({ side: part % 2 === 0 ? FrontSide : BackSide }), + part === 4 ? 1 : 3, + ); + mesh.position.set(part * 2, 0, 0); + if (part === 3) mesh.position.x = 150; + for (let copy = 0; copy < mesh.count; copy += 1) + mesh.setMatrixAt( + copy, + copy === 2 + ? new Matrix4().set(0, 0, 1, 0, 0, 1, 0, 4, -1, 0, 0, -4, 0, 0, 0, 1) + : new Matrix4().makeTranslation(0, copy * 2, copy * -2), + ); + nested.add(mesh); + parts.push(mesh); + } + return { group, parts }; +} + function markers(): string[] { return (console.info as unknown as { mock: { calls: unknown[][] } }).mock.calls .map((call) => String(call[0])) @@ -228,7 +421,7 @@ async function attached( ...options, admissionBudgetMs: Number.POSITIVE_INFINITY, budgets, - follow: { position: { ...FOLLOW } }, + follow: options.follow ?? { position: { ...FOLLOW } }, loadModel: async () => model, prefetchSeconds: 0, ring: 0, @@ -484,6 +677,232 @@ describe("a hand-placed chunk merged by material", () => { world.dispose(); }); + // A merged slab cast into a wide (coarse) level's large texels self-shadows as acne stripes: + // Machinefall's bridge deck, PRD-475. The wide half keeps develop's look and drops it. + it.each([ + ["cluster", true], + ["wide", false], + ] as const)( + "a level selecting %s casters draws the merged chunk proxy: %s", + async (half, drawn) => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + const { group } = shadowChunkModel(); + const { chunk, world } = await attached(group, { shadows: { cast: true, receive: true } }); + const proxy = chunk.getObjectByName("world-chunk-shadow") as Mesh; + expect(proxy).toBeDefined(); + const draws = shadowDraws(world, half); + for (const level of draws) expect(level.includes(proxy)).toBe(drawn); + world.dispose(); + }, + ); + + it.each(["cluster", "wide"] as const)( + "consolidates retained opaque parts with exact %s level inputs and unchanged main draws", + async (half) => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + const { group, parts } = preservedChunk(); + group.updateMatrixWorld(true); + const mainBefore = trianglesOf(parts); + const original = parts.map((mesh) => ({ + geometry: mesh.geometry, + material: mesh.material, + parent: mesh.parent, + matrix: mesh.matrix.clone(), + })); + const follow = { position: { ...FOLLOW } }; + const { chunk, world } = await attached(group, { + follow, + chunkMergeMaxTriangles: 1, + shadows: { cast: true, receive: true }, + }); + const proxies = meshesIn(chunk).filter((mesh) => !mesh.layers.isEnabled(0)); + expect(parts.filter((mesh) => mesh.castShadow && mesh.layers.isEnabled(0))).toHaveLength(0); + expect(proxies).toHaveLength(2); + expect(meshesIn(chunk).filter((mesh) => mesh.layers.isEnabled(0))).toEqual(parts); + expect(trianglesOf(parts)).toEqual(mainBefore); + for (let index = 0; index < parts.length; index += 1) { + const mesh = parts[index] as InstancedMesh; + expect(mesh.geometry).toBe(original[index]?.geometry); + expect(mesh.material).toBe(original[index]?.material); + expect(mesh.parent).toBe(original[index]?.parent); + expect(mesh.matrix.equals(original[index]?.matrix as Matrix4)).toBe(true); + } + const after: string[][] = []; + shadowDraws(world, half, (drawn, level) => { + after[level] = trianglesOf(drawn); + }); + // Re-enable the exact retained sources and disable only their proxies: the pre-consolidation draw path. + for (const mesh of parts) mesh.castShadow = true; + for (const proxy of proxies) proxy.castShadow = false; + const before: string[][] = []; + shadowDraws(world, half, (drawn, level) => { + before[level] = trianglesOf(drawn); + }); + expect(before[0]?.length).toBeGreaterThan(0); + expect(before[0]?.length).toBeGreaterThan(before[1]?.length ?? 0); + expect(after).toEqual(before); + expect(before[2]?.length).toBeGreaterThan(before[1]?.length ?? 0); + for (const mesh of parts) mesh.castShadow = false; + for (const proxy of proxies) proxy.castShadow = true; + // A hidden source hierarchy must not reappear through a proxy parented outside it. + const nested = parts[0]?.parent as Group; + nested.visible = false; + const hiddenAfter: string[][] = []; + shadowDraws(world, half, (drawn, level) => { + hiddenAfter[level] = trianglesOf(drawn); + }); + for (const mesh of parts) mesh.castShadow = true; + for (const proxy of proxies) proxy.castShadow = false; + const hiddenBefore: string[][] = []; + shadowDraws(world, half, (drawn, level) => { + hiddenBefore[level] = trianglesOf(drawn); + }); + expect(hiddenAfter).toEqual(hiddenBefore); + expect(hiddenAfter).toEqual([[], [], []]); + nested.visible = true; + for (const mesh of parts) mesh.castShadow = false; + for (const proxy of proxies) proxy.castShadow = true; + expect(trianglesOf(parts)).toEqual(mainBefore); + const disposed = proxies.map((mesh) => vi.spyOn(mesh.geometry, "dispose")); + follow.position.x += 4 * CELL_SIZE; + for (let pass = 0; pass < 40 && world.stats().residentKeys.length > 0; pass += 1) { + world.update(); + await flush(); + } + expect(world.stats().residentKeys).toEqual([]); + expect(world.getObjectByName("world-chunk")).toBeUndefined(); + for (const spy of disposed) expect(spy).toHaveBeenCalled(); + world.dispose(); + }, + ); + + it.each(["cluster", "wide"] as const)( + "does no retained proxy index writes or uploads after warming each %s level", + async (half) => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + const { group } = preservedChunk(); + const follow = { position: { ...FOLLOW } }; + const { chunk, world } = await attached(group, { + follow, + chunkMergeMaxTriangles: 1, + shadows: { cast: true, receive: true }, + }); + const proxies = meshesIn(chunk).filter((mesh) => !mesh.layers.isEnabled(0)); + const levels: { + camera: Camera; + gates: number[]; + indices: BufferAttribute[]; + triangles: string[]; + }[] = []; + const positions = proxies.map((proxy) => proxy.geometry.getAttribute("position")); + shadowDraws(world, half, (drawn, level, _depth, camera) => { + levels[level] = { + camera, + gates: proxies.map((proxy) => Reflect.get(proxy, "casterMinDiameter") as number), + indices: proxies.map((proxy) => proxy.geometry.getIndex() as BufferAttribute), + triangles: trianglesOf(drawn), + }; + }); + expect(new Set(levels.map((level) => JSON.stringify(level.triangles))).size).toBe(3); + const indices = [...new Set(levels.flatMap((level) => level.indices))]; + const writes = indices.map((index) => vi.spyOn(index.array as Uint32Array, "set")); + const versions = indices.map((index) => index.version); + const geometries = new Set(proxies.map((proxy) => proxy.geometry)); + const scene = new Scene(); + const render = (level: (typeof levels)[number]) => { + proxies.forEach((proxy, part) => { + Reflect.set(proxy, "casterMinDiameter", level.gates[part]); + proxy.onBeforeRender( + {} as never, + scene, + level.camera, + proxy.geometry, + proxy.material as Material, + null as never, + ); + expect(proxy.geometry.getAttribute("position")).toBe(positions[part]); + geometries.add(proxy.geometry); + }); + }; + for (let frame = 0; frame < 6; frame += 1) + for (const level of levels) { + render(level); + proxies.forEach((proxy, part) => + expect(proxy.geometry.getIndex()).toBe(level.indices[part]), + ); + expect(trianglesOf(proxies)).toEqual(level.triangles); + } + expect(indices.map((index, at) => index.version - (versions[at] as number))).toEqual( + indices.map(() => 0), + ); + expect(writes.map((spy) => spy.mock.calls.length)).toEqual(indices.map(() => 0)); + // A changed level must still update immediately, without invalidating either other level. + const changed = levels[0] as (typeof levels)[number]; + changed.gates = proxies.map(() => Number.POSITIVE_INFINITY); + render(changed); + expect(trianglesOf(proxies)).toEqual([]); + for (const level of levels.slice(1)) { + const before = level.indices.map((index) => index.version); + render(level); + expect(trianglesOf(proxies)).toEqual(level.triangles); + expect(level.indices.map((index) => index.version)).toEqual(before); + } + // More than three cameras reuse the bounded geometry pool, which eviction releases whole. + const held = levels[1] as (typeof levels)[number]; + for (let extra = 0; extra < 6; extra += 1) render({ ...held, camera: held.camera.clone() }); + expect(geometries.size).toBeLessThanOrEqual(proxies.length * 3); + const disposed = [...geometries].map((geometry) => vi.spyOn(geometry, "dispose")); + follow.position.x += 4 * CELL_SIZE; + for (let pass = 0; pass < 40 && world.stats().residentKeys.length > 0; pass += 1) { + world.update(); + await flush(); + } + expect(world.stats().residentKeys).toEqual([]); + for (const spy of disposed) expect(spy).toHaveBeenCalledOnce(); + world.dispose(); + }, + ); + + it("leaves AutoLOD, alpha, morph and deforming retained parts casting their own silhouettes", async () => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + const { group, parts } = preservedChunk(); + const chained = await chainedMesh(); + const lod = new InstancedMesh(chained.geometry, chained.material, 1); + const morph = new InstancedMesh(morphed().geometry, surface, 1); + const alpha = new InstancedMesh( + new BoxGeometry(), + new MeshBasicMaterial({ alphaTest: 0.5 }), + 1, + ); + const transparent = new InstancedMesh( + new BoxGeometry(), + new MeshBasicMaterial({ transparent: true }), + 1, + ); + const deformingMaterial = new MeshBasicMaterial(); + Reflect.set(deformingMaterial, "positionNode", {}); + const deforming = new InstancedMesh(new BoxGeometry(), deformingMaterial, 1); + const custom = new InstancedMesh(new BoxGeometry(), surface, 1); + custom.customDepthMaterial = new MeshBasicMaterial(); + const mirrored = new InstancedMesh(new BoxGeometry(), surface, 1); + mirrored.scale.x = -1; + const excluded = [lod, morph, alpha, transparent, deforming, custom, mirrored]; + for (const mesh of excluded) mesh.setMatrixAt(0, new Matrix4()); + group.add(...excluded); + const { chunk, world } = await attached(group, { + chunkMergeMaxTriangles: 1, + shadows: { cast: true, receive: true }, + }); + expect(meshesIn(chunk).filter((mesh) => !mesh.layers.isEnabled(0))).toHaveLength(2); + for (const mesh of excluded) { + expect(mesh.parent).toBe(group); + expect(mesh.castShadow).toBe(true); + } + for (const mesh of parts) expect(mesh.castShadow).toBe(false); + expect(lodChainOf(lod.geometry)).toBeDefined(); + world.dispose(); + }); + it("collapses the chunk's shadow bill into one position-only proxy per side", async () => { vi.spyOn(console, "info").mockImplementation(() => undefined); const { cutout, group, opaque } = shadowChunkModel(); @@ -510,7 +929,7 @@ describe("a hand-placed chunk merged by material", () => { "TN_WORLD_CHUNK_MERGE meshes=16 draws=5 bytes=13440 uploaded=0 instancedExpanded=0 keptInstanced=0 shadowDraws=1", ]); expect(meshesIn(chunk)).toHaveLength(5); - // On the layer the level shadow cameras draw and the main camera never does, and nothing else. + // On the cluster caster half only, and off the main camera's layer. expect(proxy.layers.mask).toBe(1 << VIRTUAL_SHADOW_CASTER_LAYER); expect(proxy.castShadow).toBe(true); expect(proxy.receiveShadow).toBe(false); @@ -550,6 +969,7 @@ describe("a hand-placed chunk merged by material", () => { throw new Error("Expected a plain position attribute on the proxy."); const proxyBox = new Box3().setFromBufferAttribute(proxyPosition); expect(proxyBox.equals(coveredBox)).toBe(true); + expect(trianglesOf([proxy])).toEqual(trianglesOf(covered)); // The proxy belongs to the chunk: evicting the cell releases it with the rest of the subtree. const disposed = vi.spyOn(proxy.geometry, "dispose"); diff --git a/packages/core/__tests__/world-cells-shadow-prewarm.spec.ts b/packages/core/__tests__/world-cells-shadow-prewarm.spec.ts index 9c5660a5aa..cab5920174 100644 --- a/packages/core/__tests__/world-cells-shadow-prewarm.spec.ts +++ b/packages/core/__tests__/world-cells-shadow-prewarm.spec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { BoxGeometry, + type BufferGeometry, DirectionalLight, Frustum, Group, @@ -14,7 +15,14 @@ import { PerspectiveCamera, Scene, } from "three"; -import type { NodeBuilder, NodeFrame } from "three/webgpu"; +// @ts-expect-error Three's private render object has no public declaration. +import RenderObject from "three/src/renderers/common/RenderObject.js"; +// @ts-expect-error Three's private render-object manager has no public declaration. +import RenderObjects from "three/src/renderers/common/RenderObjects.js"; +// @ts-expect-error Three's private node manager has no public declaration. +import NodeManager from "three/src/renderers/common/nodes/NodeManager.js"; +import { getShadowMaterial } from "three/tsl"; +import { type NodeBuilder, type NodeFrame, NodeMaterialObserver } from "three/webgpu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { VIRTUAL_SHADOW_CASTER_LAYER, @@ -57,7 +65,7 @@ const FOLLOW = { function model(): Object3D { const group = new Group(); for (let part = 0; part < 2; part += 1) - group.add(new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())); + group.add(new Mesh(new BoxGeometry(8, 8, 8), new MeshBasicMaterial())); return group; } @@ -126,6 +134,7 @@ interface ILevelShadow { } interface ILevelNode { + readonly light: DirectionalLight; readonly shadow: ILevelShadow; updateShadow(frame: NodeFrame): void; } @@ -143,11 +152,17 @@ interface IShadowTally { * is handed to `renderObject`, which is what calls `onBeforeRender`. The node build that follows is * a GPU build, so the draw is the part this harness can carry and the part that moves the build. */ -function shadowDraws(node: VirtualShadowNode, root: Object3D, tally: IShadowTally): void { +function shadowDraws( + node: VirtualShadowNode, + root: Object3D, + tally: IShadowTally, + observe?: (mesh: InstancedMesh, level: number) => void, +): void { const renderer = {} as unknown as Parameters[0]; - for (const levelNode of [...node.levelNodes, ...node.moverNodes]) { + for (const [index, levelNode] of [...node.levelNodes, ...node.moverNodes].entries()) { const level = levelNode as unknown as ILevelNode; level.updateShadow = (): void => { + level.light.shadow.updateMatrices(level.light); const camera = level.shadow.camera; camera.updateMatrixWorld(true); camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); @@ -175,6 +190,7 @@ function shadowDraws(node: VirtualShadowNode, root: Object3D, tally: IShadowTall mesh.material as MeshBasicMaterial, mesh as unknown as Group, ); + observe?.(mesh, index); }); }; } @@ -199,9 +215,10 @@ describe("a streamed world's caster prewarm", () => { light.castShadow = true; scene.add(light, light.target); const node = new VirtualShadowNode(light, { - clipExtents: [48, 192], + clipExtents: [48, 192, 320], mapSize: 256, marker: false, + minCasterTexels: 0, }); node.setup(builder); @@ -216,14 +233,92 @@ describe("a streamed world's caster prewarm", () => { ring: 0, // The one hook the gate has to reach the levels with: what a game passes to make them redraw // when the world streams records is exactly what makes a caster's prewarm draw happen. - shadows: { cast: true, castLevels: 1, invalidate: () => node.invalidateAll() }, + shadows: { cast: true, castLevels: 2, invalidate: () => node.invalidateAll() }, surface, url: "/world/world.json", }); // In a scene: a world nothing projects is a world nothing draws, and the gate is a promise about // draws rather than about mints. scene.add(world); - shadowDraws(node, scene, tally); + // Real RenderObjects and NodeManager caches; only shader compilation is replaced by a counter. + const render = { + _currentSourceMaterial: null as unknown, + backend: { isWebGPUBackend: true }, + contextNode: { id: 1, version: 0 }, + currentSamples: 1, + getMRT: () => null, + }; + const nodes = new NodeManager(render, render.backend); + const builds: InstancedMesh[] = []; + nodes._createNodeBuilder = (object: { object: InstancedMesh; material: unknown }) => ({ + build: () => builds.push(object.object), + getAttributesArray: () => [], + getBindings: () => [], + updateNodes: [], + updateBeforeNodes: [], + updateAfterNodes: [], + observer: new NodeMaterialObserver({ ...object, context: {} } as never), + }); + const objects = new RenderObjects( + render, + nodes, + { getIndex: (object: { geometry: BufferGeometry }) => object.geometry.index }, + { delete: vi.fn() }, + { deleteForRender: vi.fn() }, + {}, + ); + const creations = vi.spyOn(objects, "createRenderObject"); + const geometryChanges = vi.spyOn(RenderObject.prototype, "setGeometry"); + let refreshes = 0; + let draws = 0; + const context = { id: 1, sampleCount: 1 }; + const lights = { getLights: () => [] }; + const submitted = new Set(); + const coverage = new Map>(); + shadowDraws(node, scene, tally, (mesh, index) => { + const levelLight = ( + [...node.levelNodes, ...node.moverNodes][index] as unknown as { light: DirectionalLight } + ).light; + if (levelLight === undefined) throw new Error("missing submitted shadow light"); + const override = getShadowMaterial(levelLight); + override.side = (mesh.material as MeshBasicMaterial).side === 0 ? 1 : 0; + render._currentSourceMaterial = mesh.material; + const object = objects.get( + mesh, + override, + scene, + levelLight.shadow.camera, + lights, + context, + null, + ); + // The cache change must leave exactly the source's index range, material and instances. + object.drawRange = mesh.geometry.drawRange; + const params = object.getDrawParameters(); + if (mesh.count === 0) expect(params).toBeNull(); + else { + const indexCount = + mesh.geometry.index?.count ?? mesh.geometry.getAttribute("position").count; + const start = Math.max(0, mesh.geometry.drawRange.start); + expect(params).toMatchObject({ + firstVertex: start, + vertexCount: Math.min(indexCount, start + mesh.geometry.drawRange.count) - start, + instanceCount: mesh.count, + }); + } + expect(object.geometry).toBe(mesh.geometry); + expect(object._sourceMaterial).toBe(mesh.material); + expect(object.material).toBe(override); + expect(override.side).toBe((mesh.material as MeshBasicMaterial).side === 0 ? 1 : 0); + nodes.nodeFrame.renderId = clock * 8 + index; + draws += 1; + if (nodes.needsRefresh(object)) refreshes += 1; + const state = object.getNodeBuilderState(); + submitted.add(mesh); + const covered = coverage.get(mesh) ?? new Set(); + covered.add(state); + coverage.set(mesh, covered); + }); /** * The prewarmed *casters*: meshes carrying the owed-a-draw flag that live on one of the two @@ -279,13 +374,52 @@ describe("a streamed world's caster prewarm", () => { for (const name of prewarmed) expect(tally.counts.get(name), `${name} drew in a shadow render`).toBeGreaterThan(0); - // The walk half of the claim, in the unit form this harness can carry: the shadow-context node - // is built by a key's *first* shadow draw, so a key first drawn behind the gate builds nothing - // later however long the walk runs. That is what a real `backend.createNodeBuilder` wrapper - // would count; counting the first draw is the same event without a GPU. + // A first submission must warm the actual node-state key used by later level passes. + // Compilation is counted at NodeManager's build boundary; draw inputs are checked above. for (const name of prewarmed) expect(tally.first.get(name), `${name} first drew behind the gate`).toBeLessThan(settledAt); const firsts = new Map(tally.first); + const warmed = new Set([...submitted].filter((mesh) => prewarmed.has(mesh.name))); + const builtAtGate = builds.length; + const twice = [...warmed].filter((mesh) => (coverage.get(mesh)?.size ?? 0) > 1); + expect( + twice.map((mesh) => [mesh.name, coverage.get(mesh)?.size]), + "identical level shaders reuse one node-builder state per caster", + ).toEqual([]); + + // Force each level to revisit the same objects after the gate, before streaming can replace them. + node.invalidateAll(); + for (let frame = 0; frame < 4; frame += 1) { + clock += 1; + tally.frame = clock; + node.updateBefore({ camera, renderer: {}, time: clock } as unknown as NodeFrame); + } + + // A level's first RenderObject is still necessary; its node state must already be warm. + const staticStart = { + creates: creations.mock.calls.length, + swaps: geometryChanges.mock.calls.length, + draws, + refreshes, + }; + node.invalidateAll(); + for (let frame = 0; frame < 4; frame += 1) { + clock += 1; + tally.frame = clock; + node.updateBefore({ camera, renderer: {}, time: clock } as unknown as NodeFrame); + } + expect(creations.mock.calls.length - staticStart.creates).toBe(0); + expect(geometryChanges.mock.calls.length - staticStart.swaps).toBe(0); + expect(draws - staticStart.draws).toBeGreaterThan(0); + expect(refreshes - staticStart.refreshes).toBe(draws - staticStart.draws); + console.log( + `TN_RENDERLOOP_SPEC staticDraws=${draws - staticStart.draws} refreshes=${refreshes - staticStart.refreshes} creations=0 swaps=0 states=${builtAtGate}`, + ); + const late = builds.slice(builtAtGate).filter((mesh) => prewarmed.has(mesh.name)); + expect( + late.map((mesh) => mesh.name), + "prewarmed objects have no cold shadow-level node states", + ).toEqual([]); for (let frame = 0; frame < 12; frame += 1) { follow.position.x += 0.5 * CELL_SIZE; const walk = cameraAt(follow.position.x, follow.position.z); @@ -297,6 +431,18 @@ describe("a streamed world's caster prewarm", () => { } for (const [name, frame] of firsts) expect(tally.first.get(name), `${name} was not first drawn on the walk`).toBe(frame); + node.invalidateAll(); + for (let frame = 0; frame < 3; frame += 1) { + clock += 1; + node.updateBefore({ camera, renderer: {}, time: clock } as unknown as NodeFrame); + } + expect( + builds + .slice(builtAtGate) + .filter((mesh) => prewarmed.has(mesh.name)) + .map((mesh) => mesh.name), + "no prewarmed key builds during the walk", + ).toEqual([]); world.dispose(); node.dispose(); }); diff --git a/packages/core/__tests__/world-cells.spec.ts b/packages/core/__tests__/world-cells.spec.ts index 0a9d78f1fe..9e7c2583bd 100644 --- a/packages/core/__tests__/world-cells.spec.ts +++ b/packages/core/__tests__/world-cells.spec.ts @@ -1138,6 +1138,52 @@ describe("WorldCells", () => { world.dispose(); }); + it("names a refused world load in a TN_WORLD_CELL_FAILURE marker", async () => { + stubManifestFetch({ + ...manifest, + cells: manifest.cells + .filter((cell) => cell.x === 0 && cell.z === 0) + .map((cell) => ({ + ...cell, + chunks: [], + runs: cell.runs.filter((run) => run.asset === "pine"), + })), + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const follow = followAt(0, 0); + let refused = false; + const load = (url: string): Promise => { + if (url.includes("pine") && !refused) { + refused = true; + return Promise.reject(new Error("pine is offline")); + } + return Promise.resolve(makeModel()); + }; + const world = await loadWorld({ + budgets: largeBudgets, + follow, + loadModel: load, + ring: 1, + surface, + url: "/world/world.json", + }); + + const center = cellCenter(0, 0); + follow.position.x = center.x; + follow.position.z = center.z; + world.update(); + await flush(); + + const lines = warn.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith("TN_WORLD_CELL_FAILURE")); + expect(lines[0]).toContain("kind=asset id=pine"); + expect(lines[0]).toContain("pine.glb"); + expect(lines[0]).toContain("message=pine is offline"); + expect(world.stats().failures).toBe(1); + world.dispose(); + }); + it("rejects a concurrency or refilter cap that could never start a load", async () => { stubFixtureFetch(); for (const option of ["concurrency", "rebuildsPerUpdate", "chunkMergeMaxTriangles"] as const) diff --git a/packages/core/__tests__/world-gpu-scene.spec.ts b/packages/core/__tests__/world-gpu-scene.spec.ts index d32ad56993..73d421e524 100644 --- a/packages/core/__tests__/world-gpu-scene.spec.ts +++ b/packages/core/__tests__/world-gpu-scene.spec.ts @@ -862,6 +862,52 @@ describe("WorldCells GPU-driven main pass", () => { * test is the wiring and the counters, not a draw. */ describe("WorldCells with the GPU-driven main pass", () => { + it("keeps every multi-part placement drawable after partial eviction and readmission", async () => { + stubManifestFetch(true); + const follow = { position: { ...cellCentre(0, 1), y: 0 } }; + const keyed = vi.spyOn(WorldGpuScene.prototype, "key"); + const world = await WorldCells.load({ + admissionBudgetMs: Number.POSITIVE_INFINITY, + budgets, + follow, + gpuScene: true, + loadModel: async () => multiPartModel(), + prefetchSeconds: 0, + ring: 0, + surface, + url: "/world/world.json", + }); + const renderer = gpuRendererStub(); + const camera = playerCamera(); + for (const x of [0, 1, 2]) { + Object.assign(follow.position, cellCentre(x, 1)); + world.update(renderer, camera); + await flushed(world); + } + const scene = keyed.mock.contexts.at(-1) as WorldGpuScene; + expect(world.stats().evictions).toBeGreaterThan(0); + const slot = scene.gates().findIndex((_, index) => scene.slotAsset(index) === "pine"); + const expected = pineIn("1,1", "2,1"); + expect(scene.placements.filter((one) => one.slot === slot)).toHaveLength(expected); + // Put every resident pine in the far level: any LOD can receive every placement this frame. + const result = cullAndSelect({ + camera: { planes: new Float32Array(24), x: 0, y: 0, z: -1000 }, + count: scene.placements.length, + placements: scene.placements, + regionCount: scene.regions.length, + regions: scene.regions, + slots: scene.gates(), + }); + for (const mesh of mainLevel(world, "pine", 1)) { + const region = scene.regionOf(mesh.name) as IRegion; + expect(result.counts[region.argsIndex], mesh.name).toBe(expected); + expect(region.capacity, mesh.name).toBeGreaterThanOrEqual(expected); + expect(mesh.instanceMatrix).toBe(scene.drawn); + expect(mesh.geometry.indirect).toBe(scene.args); + } + world.dispose(); + }); + it("spends no regroup and no refilter, feeds the source buffer, and dresses every main key", async () => { stubManifestFetch(); const follow = { position: { ...cellCentre(0, 1), y: 0 } as { x: number; z: number } }; @@ -2057,6 +2103,92 @@ describe("WorldCells GPU-driven main pass, against the CPU path's own drawn set" }); describe("WorldCells whose GPU scene comes up under a built ring", () => { + it.each([1, 3, 5, 8])( + "seeds source records for a build sliced before the renderer arrived (%i CPU units)", + async (units) => { + stubManifestFetch(); + const follow = { position: { ...cellCentre(1, 1), y: 0 } }; + const keyed = vi.spyOn(WorldGpuScene.prototype, "key"); + const logged = vi.spyOn(console, "info"); + let clock = 0; + const world = await WorldCells.load({ + admissionBudgetMs: 1, + admissionNow: () => { + clock += 10_000; + return clock; + }, + budgets, + follow, + freshMeshesPerUpdate: 1, + gpuScene: true, + loadModel: async () => plainModel(), + prefetchSeconds: 0, + ring: 0, + shadows: { cast: true }, + surface, + url: "/world/world.json", + }); + world.update(); + await flush(); + // Enable during placement slicing or mesh publication, including a pending pine build. + for (let unit = 0; unit < units; unit += 1) world.update(); + expect(world.stats().admission.backlog).toBeGreaterThan(0); + expect(world.stats().admission.deferred).toBeGreaterThan(0); + world.update(gpuRendererStub(), playerCamera()); + await flushed(world); + const mainReport = logged.mock.calls + .map(([line]) => String(line)) + .filter((line) => line.startsWith("TN_WORLD_MAIN_CULL ")) + .at(-1); + + const cpu = await WorldCells.load({ + admissionBudgetMs: Number.POSITIVE_INFINITY, + budgets, + follow, + gpuScene: false, + loadModel: async () => plainModel(), + prefetchSeconds: 0, + ring: 0, + surface, + url: "/world/world.json", + }); + cpu.update(); + await flushed(cpu); + const expected = mainKeys(cpu).reduce((sum, mesh) => sum + mesh.count, 0); + expect(world.stats().gpuScene.instances).toBe(expected); + expect(mainReport).toContain(`instances=${String(expected)} `); + const matrices = mainKeys(cpu) + .flatMap((mesh) => + Array.from({ length: mesh.count }, (_, index) => + mesh.instanceMatrix.array.slice(index * 16, (index + 1) * 16).join(","), + ), + ) + .sort(); + const scene = keyed.mock.contexts.at(-1) as WorldGpuScene; + const assertSources = (): void => { + expect( + scene.placements + .filter((one) => one.slot >= 0) + .map((one) => one.matrix.join(",")) + .sort(), + ).toEqual(matrices); + }; + assertSources(); + Object.assign(follow.position, cellCentre(6, 1)); + world.update(); + await flushed(world); + expect(world.stats().gpuScene.instances).toBe(0); + Object.assign(follow.position, cellCentre(1, 1)); + world.update(); + await flushed(world); + expect(world.stats().evictions).toBeGreaterThan(0); + assertSources(); + expect(world.stats().failures).toBe(0); + cpu.dispose(); + world.dispose(); + }, + ); + /** A world over the same package, the same ring and the same camera, built the way the test asks. */ async function build(framesWithoutARenderer: number): Promise { stubManifestFetch(); diff --git a/packages/core/__tests__/world-heightfield.spec.ts b/packages/core/__tests__/world-heightfield.spec.ts index b80c1d4845..2058c066ad 100644 --- a/packages/core/__tests__/world-heightfield.spec.ts +++ b/packages/core/__tests__/world-heightfield.spec.ts @@ -327,3 +327,59 @@ describe("Heightfield", () => { value.process(); }); }); + +describe("Heightfield change trackers", () => { + const flat = () => + Heightfield.fromSampler({ + columns: 9, + depth: 8, + origin: { x: 0, z: 0 }, + rows: 9, + sampleHeight: () => 0, + width: 8, + }); + + it("hands each tracker the union of the windows written since its own last take", () => { + const field = flat(); + const first = field.trackChanges(); + const second = field.trackChanges(); + expect(first.take()).toBeUndefined(); + field.updateHeights({ + column: 1, + columns: 2, + row: 1, + rows: 1, + heights: new Float32Array([1, 1]), + }); + field.updateHeights({ + column: 5, + columns: 1, + row: 6, + rows: 2, + heights: new Float32Array([2, 2]), + }); + expect(first.take()).toEqual({ column: 1, columns: 5, row: 1, rows: 7 }); + expect(first.take()).toBeUndefined(); + // The second tracker never took, so it still holds both writes. + expect(second.take()).toEqual({ column: 1, columns: 5, row: 1, rows: 7 }); + second.dispose(); + field.updateHeights({ column: 0, columns: 1, row: 0, rows: 1, heights: new Float32Array([3]) }); + expect(second.take()).toBeUndefined(); + expect(first.take()).toEqual({ column: 0, columns: 1, row: 0, rows: 1 }); + }); + + it("reads one collider sample without copying the field", () => { + const field = flat(); + field.updateHeights({ + column: 3, + columns: 1, + row: 5, + rows: 1, + heights: new Float32Array([0.7]), + }); + const copy = field.toColliderHeights(); + expect(field.colliderHeight(5, 3)).toBeCloseTo(0.7, 6); + expect(field.colliderHeight(5, 3)).toBe(copy[3 * field.rows + 5]); + expect(() => field.colliderHeight(9, 0)).toThrow(/outside/); + }); +}); diff --git a/packages/core/__tests__/world-terrain-merge-walk.spec.ts b/packages/core/__tests__/world-terrain-merge-walk.spec.ts new file mode 100644 index 0000000000..4d0467791f --- /dev/null +++ b/packages/core/__tests__/world-terrain-merge-walk.spec.ts @@ -0,0 +1,290 @@ +import { Mesh, MeshBasicMaterial } from "three"; +import { describe, expect, it, vi } from "vitest"; +import { TerrainTiles } from "../src/world-tiles.js"; + +const sampleHeight = (x: number, z: number): number => + Math.sin(x * 0.17) * 2 + Math.cos(z * 0.13) * 1.5 + Math.sin((x + z) * 0.07); + +const TILE_SIZE = 16; +const BLOCK_SIDE = 4; +const BLOCK_PREFIX = "tn-terrain-block:"; +const STEP = 0.75; +const LEG_FRAMES = 220; +const FRAMES = LEG_FRAMES * 2; + +function terrain(mergeTiles: boolean): TerrainTiles { + return new TerrainTiles({ + mergeTiles, + residentByteBudget: 64_000_000, + residentTileBudget: 256, + sampleHeight, + // Thresholds inside the streamed ring, so a walk of one tile's width re-levels whole rows. + lodDistances: [TILE_SIZE * 3, TILE_SIZE * 7], + streamRadius: 4, + surface: new MeshBasicMaterial(), + tileResolution: 33, + tileSize: TILE_SIZE, + } as ConstructorParameters[0]); +} + +/** + * A frame allowance in the shape the game passes: units of streamed work, spent in order. Six leaves + * room for the ring's admissions and still refuses a rebuild now and then, so the walk also covers a + * block that waits longer than the one-per-frame cap. + */ +function frameBudget(): { admit: (work: () => void) => boolean } { + let units = 0; + return { + admit(work) { + units += 1; + if (units > 6) return false; + work(); + return true; + }, + }; +} + +/** Straight through a tile's width of ring crossings, then a right-angle turn: a map walk's churn. */ +function path(frame: number): { x: number; z: number } { + const travelled = frame * STEP; + return frame < LEG_FRAMES + ? { x: travelled, z: 0 } + : { x: LEG_FRAMES * STEP, z: travelled - LEG_FRAMES * STEP }; +} + +function lodOf(blockId: string): number { + return Number(blockId.slice(0, blockId.indexOf(":"))); +} + +function cellOf(x: number, z: number): string { + return cellKeyFor(Math.floor(x / TILE_SIZE + 0.5), Math.floor(z / TILE_SIZE + 0.5)); +} + +function cellKeyFor(tileX: number, tileZ: number): string { + return `${String(tileX)}:${String(tileZ)}`; +} + +function blockIdFor(tileX: number, tileZ: number, lod: number): string { + return `${String(lod)}:${String(Math.floor(tileX / BLOCK_SIDE))},${String( + Math.floor(tileZ / BLOCK_SIDE), + )}`; +} + +/** + * One frame of the merged ring, read the way the main pass reads it. + * + * A block is a concatenation of whole level geometries, all of its members at the block's LOD tier, + * so the buffer is a whole number of equal runs and the first vertex of each run names the tile it + * came from. That is what makes "this tile is hidden because a block draws it" measurable from + * outside, instead of taking the record the class keeps on trust. + */ +interface ITileRead { + readonly blockId: string; + readonly drawn: number; + readonly morphing: boolean; + readonly own: boolean; +} + +function readFrame(tiles: TerrainTiles): { + readonly held: Map>; + readonly perLevel: Map; + readonly tiles: Map; +} { + const perLevel = new Map(); + const cells = new Set(); + const read = new Map(); + for (const key of tiles.residentKeys) { + const tile = tiles.getTile(key); + if (tile === undefined) throw new Error(`Missing resident tile '${key}'.`); + const visible = tile.lod.levels + .map(({ object }, index) => (object.visible ? index : -1)) + .filter((index) => index >= 0); + const level = tile.lod.levels[tile.lodLevel]; + if (level === undefined) + throw new Error(`Tile '${key}' has no level ${String(tile.lodLevel)}.`); + // A settled tile shows exactly its own level; a morphing tile shows the finer of the pair, which + // is how a frame tells the two apart without reading private transition state. + const morphing = visible.length === 1 && visible[0] !== tile.lodLevel; + read.set(key, { + blockId: blockIdFor(tile.tileX, tile.tileZ, tile.lodLevel), + drawn: visible.length, + morphing, + own: visible.length === 1 && visible[0] === tile.lodLevel, + }); + cells.add(cellKeyFor(tile.tileX, tile.tileZ)); + perLevel.set(tile.lodLevel, (level.object as Mesh).geometry.getAttribute("position").count); + } + const held = new Map>(); + for (const child of tiles.children) + if (child instanceof Mesh && child.name.startsWith(BLOCK_PREFIX)) + held.set(child.name.slice(BLOCK_PREFIX.length), readBlock(child, cells, perLevel)); + return { held, perLevel, tiles: read }; +} + +/** Which tile cells one block's buffer holds, named run by run, and `?offset` for any run it cannot. */ +function readBlock( + mesh: Mesh, + cells: ReadonlySet, + perLevel: ReadonlyMap, +): Set { + const id = mesh.name.slice(BLOCK_PREFIX.length); + const separator = id.indexOf(":"); + const cell = id.slice(separator + 1).split(","); + const originX = Number(cell[0]) * BLOCK_SIDE * TILE_SIZE; + const originZ = Number(cell[1]) * BLOCK_SIDE * TILE_SIZE; + const run = perLevel.get(Number(id.slice(0, separator))) ?? 0; + const position = mesh.geometry.getAttribute("position"); + const members = new Set(); + for (let offset = 0; offset + run <= position.count; offset += run) { + const here = cellOf(position.getX(offset) + originX, position.getZ(offset) + originZ); + members.add(cells.has(here) ? here : `?${String(offset)}`); + } + if (position.count % run !== 0) members.add(`?remainder${String(position.count % run)}`); + return members; +} + +/** + * The per-frame walk invariants, each failure naming its frame. + * + * (i) every settled resident tile is drawn exactly once — its own level mesh, or the one block whose + * geometry holds it, never both and never neither; + * (ii) a tile whose block has not been rebuilt for it yet keeps drawing its own mesh, so the rebuild + * queue never opens a hole; + * (iii) residency is the merge-off walk's residency, tile for tile, every frame; + * (iv) the `tiles` stat is the resident tile count. + */ +function frameFailures(frame: number, merged: TerrainTiles, plain: TerrainTiles): string[] { + const { held, tiles } = readFrame(merged); + return [ + ...tileFailures(frame, merged, tiles, held), + ...blockFailures(frame, merged, held), + ...statFailures(frame, merged, plain), + ]; +} + +/** (i) and (ii), tile by tile: a tile draws itself or the block that holds it, never both or neither. */ +function tileFailures( + frame: number, + tiles: TerrainTiles, + read: ReadonlyMap, + held: ReadonlyMap>, +): string[] { + const failures: string[] = []; + for (const [key, tile] of read) { + const resident = tiles.getTile(key); + if (resident === undefined) continue; + const cell = cellKeyFor(resident.tileX, resident.tileZ); + const inBlock = held.get(tile.blockId)?.has(cell) === true; + if (tile.morphing) { + // A tile mid a blend is never merged, so it always draws itself. + if (tile.drawn !== 1) + failures.push( + `frame ${String(frame)}: '${key}' is blending and submits ${String(tile.drawn)} meshes`, + ); + continue; + } + if (tile.own && inBlock) + failures.push(`frame ${String(frame)}: '${key}' draws itself and block '${tile.blockId}'`); + if (!tile.own && !inBlock) + failures.push( + `frame ${String(frame)}: '${key}' draws nothing, waiting on block '${tile.blockId}'`, + ); + } + return failures; +} + +/** (i) from the block's side: nothing in a block a tile does not own, or owns at another LOD tier. */ +function blockFailures( + frame: number, + tiles: TerrainTiles, + held: ReadonlyMap>, +): string[] { + const failures: string[] = []; + for (const [id, members] of held) + for (const member of members) { + const other = tiles.getTile(member); + if (member.startsWith("?")) + failures.push( + `frame ${String(frame)}: block '${id}' holds '${member}', which no tile owns`, + ); + else if (other !== undefined && lodOf(id) !== other.lodLevel) + failures.push( + `frame ${String(frame)}: block '${id}' holds '${member}', which is now level ${String(other.lodLevel)}`, + ); + } + return failures; +} + +/** (iii) and (iv): the merge-off walk's residency, and a `tiles` stat that counts it. */ +function statFailures(frame: number, merged: TerrainTiles, plain: TerrainTiles): string[] { + const failures: string[] = []; + const stat = merged.terrainTiles.tiles; + if (stat !== merged.residentTileCount) + failures.push( + `frame ${String(frame)}: tiles stat ${String(stat)} against ${String(merged.residentTileCount)} resident`, + ); + if (merged.residentKeys.join() !== plain.residentKeys.join()) + failures.push(`frame ${String(frame)}: residency differs from the merge-off walk`); + return failures; +} + +/** How many tiles this frame streamed in and out of the ring, so the walk proves it streamed. */ +function countStreamed( + seen: Set, + tiles: TerrainTiles, +): { admitted: number; evicted: number } { + const keys = tiles.residentKeys; + let admitted = 0; + let evicted = 0; + for (const key of keys) + if (!seen.has(key)) { + seen.add(key); + admitted += 1; + } + for (const key of [...seen]) + if (!keys.includes(key)) { + seen.delete(key); + evicted += 1; + } + return { admitted, evicted }; +} + +describe("TerrainTiles merge under a walk", () => { + it("draws every resident tile exactly once on every frame of a streaming walk", () => { + const marker = vi.spyOn(console, "info").mockImplementation(() => undefined); + const merged = terrain(true); + const plain = terrain(false); + try { + const failures: string[] = []; + const seen = new Set(); + const streamed = { admitted: 0, evicted: 0 }; + let blended = 0; + for (let frame = 0; frame < FRAMES; frame += 1) { + const at = path(frame); + for (const tiles of [merged, plain]) { + tiles.follow(at, frameBudget()); + tiles.process(); + } + const frame_ = countStreamed(seen, merged); + streamed.admitted += frame_.admitted; + streamed.evicted += frame_.evicted; + blended += merged.blendingTiles === 0 ? 0 : 1; + failures.push(...frameFailures(frame, merged, plain)); + } + marker.mockRestore(); + // The walk has to stream, re-level and blend, or the invariants below prove nothing. + expect({ blended, resident: merged.residentTileCount }).toEqual({ + blended: expect.any(Number), + resident: 81, + }); + expect(streamed.evicted).toBeGreaterThan(0); + expect(streamed.admitted).toBeGreaterThan(0); + expect(blended).toBeGreaterThan(0); + expect(failures.slice(0, 8)).toEqual([]); + } finally { + merged.dispose(); + plain.dispose(); + marker.mockRestore(); + } + }); +}); diff --git a/packages/core/__tests__/world-terrain-merge.spec.ts b/packages/core/__tests__/world-terrain-merge.spec.ts index d3c406bea9..e3f7333b89 100644 --- a/packages/core/__tests__/world-terrain-merge.spec.ts +++ b/packages/core/__tests__/world-terrain-merge.spec.ts @@ -1,5 +1,5 @@ -import { type BufferGeometry, Mesh, MeshBasicMaterial } from "three"; -import { describe, expect, it } from "vitest"; +import { type BufferGeometry, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three"; +import { describe, expect, it, vi } from "vitest"; import { TerrainTiles } from "../src/world-tiles.js"; const sampleHeight = (x: number, z: number): number => @@ -27,6 +27,22 @@ function blockMeshes(tiles: TerrainTiles): Mesh[] { ); } +/** Every visible terrain level mesh and block mesh: the tiles the main pass would submit. */ +function submittedMeshes(tiles: TerrainTiles): number { + const levels = new Set(); + for (const key of tiles.residentKeys) { + const tile = tiles.getTile(key); + if (tile === undefined) continue; + for (const level of tile.lod.levels) levels.add(level.object); + } + let submitted = 0; + tiles.traverseVisible((object) => { + if (object instanceof Mesh && (object.name.startsWith(BLOCK_PREFIX) || levels.has(object))) + submitted += 1; + }); + return submitted; +} + function blockKey(mesh: Mesh): { lod: number; blockX: number; blockZ: number } { const key = mesh.name.slice(BLOCK_PREFIX.length); const [lod, cell] = key.split(":"); @@ -74,18 +90,118 @@ function sortedCounts(values: readonly string[]): Map { return counts; } +function normalKeys(geometry: BufferGeometry, offsetX: number, offsetZ: number): string[] { + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + const keys: string[] = []; + for (let vertex = 0; vertex < position.count; vertex += 1) + keys.push( + `${String(normal.getX(vertex))},${String(normal.getY(vertex))},${String( + normal.getZ(vertex), + )}|${String(position.getX(vertex) + offsetX)},${String(position.getY(vertex))},${String( + position.getZ(vertex) + offsetZ, + )}`, + ); + return keys; +} + +/** Every triangle as its three world positions, so a merge can be compared without vertex order. */ +function triangleKeys( + geometry: BufferGeometry, + offsetX: number, + offsetZ: number, +): Map { + const position = geometry.getAttribute("position"); + const index = geometry.getIndex(); + if (index === null) throw new Error("Expected an indexed terrain geometry."); + const keys: string[] = []; + for (let element = 0; element + 2 < index.count; element += 3) { + const corners: string[] = []; + for (let corner = 0; corner < 3; corner += 1) { + const vertex = index.getX(element + corner); + corners.push( + `${String(position.getX(vertex) + offsetX)},${String(position.getY(vertex))},${String( + position.getZ(vertex) + offsetZ, + )}`, + ); + } + keys.push(corners.sort().join("|")); + } + return sortedCounts(keys); +} + +function worldVertices(mesh: Mesh): Vector3[] { + const position = mesh.geometry.getAttribute("position"); + const points: Vector3[] = []; + for (let vertex = 0; vertex < position.count; vertex += 1) + points.push( + new Vector3(position.getX(vertex), position.getY(vertex), position.getZ(vertex)).applyMatrix4( + mesh.matrixWorld, + ), + ); + return points; +} + +function nearestDistance(point: Vector3, points: readonly Vector3[]): number { + let nearest = Number.POSITIVE_INFINITY; + for (const candidate of points) nearest = Math.min(nearest, point.distanceTo(candidate)); + return nearest; +} + +function settled(tiles: TerrainTiles, follow: { x: number; z: number }): void { + for (let frame = 0; frame < 12; frame += 1) tiles.follow(follow); +} + +/** + * A merged block must draw where its tiles draw: for every settled tile the block covers, each of its + * level vertices has a merged vertex at the same world position. Read through `matrixWorld`, so a + * translated terrain root or a block whose own mesh transform is wrong fails here too. The island + * is followed away from the world origin because a block at (0, 0) merges into geometry that already + * carries its own translation and hides a wrong block transform. (PRD-475.) + */ +function expectBlocksOnTheirTiles(tiles: TerrainTiles): void { + tiles.updateMatrixWorld(true); + const blocks = blockMeshes(tiles); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + const merged = worldVertices(block); + const { lod, blockX, blockZ } = blockKey(block); + let members = 0; + for (const key of tiles.residentKeys) { + const tile = tiles.getTile(key); + if (tile === undefined || tile.lodLevel !== lod) continue; + if (Math.floor(tile.tileX / 4) !== blockX || Math.floor(tile.tileZ / 4) !== blockZ) continue; + const level = tile.lod.levels[tile.lodLevel]?.object; + if (!(level instanceof Mesh)) continue; + members += 1; + for (const point of worldVertices(level)) { + const distance = nearestDistance(point, merged); + expect( + distance, + `block '${block.name}' vertex is ${distance.toFixed(3)}m from the tile vertex at (${point.x.toFixed( + 3, + )}, ${point.y.toFixed(3)}, ${point.z.toFixed(3)})`, + ).toBeLessThan(1e-4); + } + } + expect(members).toBeGreaterThan(0); + } +} + describe("TerrainTiles merge", () => { - it("reads mergeTiles from the option, the environment, the query string and the global, off by default", () => { + it("merges by default and reads an opt-out from the environment, the query string and the global", () => { const previousEnv = process.env.TN_TERRAIN_MERGE; const previousSearch = (globalThis as { location?: Location }).location?.search; const previousGlobal = (globalThis as { __tnTerrainMerge?: unknown }).__tnTerrainMerge; try { for (const [env, search, global, expected] of [ - [undefined, "", undefined, false], + [undefined, "", undefined, true], ["1", "", undefined, true], ["0", "", undefined, false], - [undefined, "?tnTerrainMerge=1", undefined, true], + ["false", "", undefined, false], [undefined, "?tnTerrainMerge=0", undefined, false], + [undefined, "?tnTerrainMerge=1", undefined, true], + [undefined, "", 0, false], [undefined, "", 1, true], ] as const) { if (env === undefined) Reflect.deleteProperty(process.env, "TN_TERRAIN_MERGE"); @@ -120,6 +236,126 @@ describe("TerrainTiles merge", () => { } }); + it("reports the tile census beside the lodTransitions stat, counting the meshes it submits", () => { + for (const mergeTiles of [false, true]) { + const tiles = terrain(mergeTiles); + try { + tiles.follow({ x: 16, z: 16 }); + const stat = tiles.debug().terrainTiles as { + blending: number; + blocks: number; + draws: number; + tiles: number; + }; + expect(stat.draws).toBe(submittedMeshes(tiles)); + expect(stat.tiles).toBe(tiles.residentTileCount); + expect(stat.blending).toBe(tiles.blendingTiles); + expect(stat.blocks).toBe(mergeTiles ? 1 : 0); + } finally { + tiles.dispose(); + } + } + }); + + it("draws a block away from the world origin where its tiles are", () => { + const tiles = terrain(true); + try { + settled(tiles, { x: 48, z: 48 }); + expectBlocksOnTheirTiles(tiles); + } finally { + tiles.dispose(); + } + }); + + it("draws a block on its tiles under a translated terrain root", () => { + const tiles = terrain(true); + const root = new Object3D(); + root.position.set(120, 0, -80); + root.add(tiles); + try { + settled(tiles, { x: 48, z: 48 }); + root.updateMatrixWorld(true); + expectBlocksOnTheirTiles(tiles); + } finally { + root.remove(tiles); + tiles.dispose(); + } + }); + + it("reports a merged block's bytes without charging them to the tiles they duplicate", () => { + const walk = (mergeTiles: boolean): { blockBytes: number; tileBytes: number } => { + const tiles = terrain(mergeTiles); + try { + tiles.follow({ x: 16, z: 16 }); + return { + blockBytes: tiles.terrainTiles.blockBytes, + tileBytes: tiles.residentBytes, + }; + } finally { + tiles.dispose(); + } + }; + const merged = walk(true); + const unmerged = walk(false); + // A block holds a copy of level vertices its tiles already hold, so the copy is reported as its + // own stat and never charged to admission: `residentBytes` is the nine tiles either way. + expect(merged.blockBytes).toBeGreaterThan(0); + expect(unmerged.blockBytes).toBe(0); + expect(merged.tileBytes).toBe(unmerged.tileBytes); + }); + + it("admits the same ring with the merge on as with it off, at a budget sized for unmerged terrain", () => { + const walk = [16, 24, 40, 56, 72, 88, 104, 120, 136, 152]; + const tiles = (mergeTiles: boolean, residentByteBudget = 64_000_000): TerrainTiles => + terrain(mergeTiles, { residentByteBudget, streamRadius: 4 }); + /** Resident bytes and the fewest tiles resident at any step: a ring only partly admitted + * mid-walk is what losing terrain detail looks like from the game's side of the seam. */ + const walkRing = (mergeTiles: boolean, residentByteBudget?: number) => { + const ring = tiles(mergeTiles, residentByteBudget); + try { + let minimumTiles = 0; + let peakBytes = 0; + for (const x of walk) { + for (let frame = 0; frame < 6; frame += 1) ring.follow({ x, z: 16 }); + minimumTiles = + minimumTiles === 0 + ? ring.residentTileCount + : Math.min(minimumTiles, ring.residentTileCount); + peakBytes = Math.max(peakBytes, ring.residentBytes); + } + return { minimumTiles, peakBytes }; + } finally { + ring.dispose(); + } + }; + // A budget sized for the unmerged walk must admit that same walk with the merge on: a block is a + // derived copy of tiles that are already resident, so charging it evicts real terrain detail. + const unmerged = walkRing(false); + expect(walkRing(true, unmerged.peakBytes).minimumTiles).toBe(unmerged.minimumTiles); + }); + + it("appends the seam and LOD-pop measurements to the tile marker only when validation is on", () => { + const marker = (validate: boolean): string => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const tiles = terrain(false, { validate }); + try { + tiles.follow({ x: 16, z: 16 }); + tiles.process(); + const line = info.mock.calls.map((call) => String(call[0])); + info.mockRestore(); + const found = line.find((value) => value.startsWith("TN_TERRAIN_TILES")); + if (found === undefined) throw new Error("The tile marker never printed."); + return found; + } finally { + tiles.dispose(); + } + }; + expect(marker(true)).toContain("maxSeamGap="); + expect(marker(true)).toContain("maxLodPop="); + expect(marker(true)).toContain("maxVisualSeamGap="); + expect(marker(false)).not.toContain("maxSeamGap="); + }); + it("leaves the draw structure untouched with the merge off", () => { const tiles = terrain(false); try { @@ -151,17 +387,135 @@ describe("TerrainTiles merge", () => { const originX = blockX * 4 * 16; const originZ = blockZ * 4 * 16; const expected: string[] = []; + const expectedNormals: string[] = []; + const expectedTriangles = new Map(); for (const key of tiles.residentKeys) { const tile = tiles.getTile(key); if (tile === undefined) throw new Error(`Missing resident tile '${key}'.`); - expected.push( - ...levelWorldKeys(tiles, key, 0, tile.tileX * 16 - originX, tile.tileZ * 16 - originZ), - ); + const offsetX = tile.tileX * 16 - originX; + const offsetZ = tile.tileZ * 16 - originZ; + expected.push(...levelWorldKeys(tiles, key, 0, offsetX, offsetZ)); + const level = tile.lod.levels[0]?.object; + if (!(level instanceof Mesh)) throw new Error("Missing level 0."); + expectedNormals.push(...normalKeys(level.geometry, offsetX, offsetZ)); + for (const [triangle, count] of triangleKeys(level.geometry, offsetX, offsetZ)) + expectedTriangles.set(triangle, (expectedTriangles.get(triangle) ?? 0) + count); } const expectedCounts = sortedCounts(expected); const actualCounts = sortedCounts(mergedWorldKeys(block, 0, 0)); expect(actualCounts.size).toBe(expectedCounts.size); expect(actualCounts).toEqual(expectedCounts); + // One visible mesh for the island, and every vertex carrying the normal and the triangles of + // the tile it came from: the merge moved no vertex and dropped no triangle. + expect(submittedMeshes(tiles)).toBe(1); + expect(sortedCounts(normalKeys(block.geometry, 0, 0))).toEqual(sortedCounts(expectedNormals)); + expect(triangleKeys(block.geometry, 0, 0)).toEqual(expectedTriangles); + } finally { + tiles.dispose(); + } + }); + + it("rebuilds one dirty block per frame, so three dirty blocks take three frames", () => { + const tiles = terrain(true); + try { + // Following (0, 0) splits the 3x3 ring across four block cells, so one follow leaves three + // block-forming rebuilds dirty at once. + tiles.follow({ x: 0, z: 0 }); + expect(tiles.terrainTiles.rebuilds).toBeLessThanOrEqual(1); + let frames = 1; + while (tiles.terrainTiles.rebuilds < 3 && frames < 8) { + const before = tiles.terrainTiles.rebuilds; + tiles.follow({ x: 0, z: 0 }); + expect(tiles.terrainTiles.rebuilds - before).toBeLessThanOrEqual(1); + frames += 1; + } + expect(frames).toBeGreaterThanOrEqual(3); + expect(tiles.terrainTiles.blocks).toBe(3); + expect(tiles.terrainTiles.draws).toBe(4); + // Nothing is left dirty, so a settled follow point builds nothing. + const settled = tiles.terrainTiles.rebuilds; + tiles.follow({ x: 0, z: 0 }); + expect(tiles.terrainTiles.rebuilds).toBe(settled); + } finally { + tiles.dispose(); + } + }); + + it("defers a rebuild the frame budget refuses to the next frame that allows it", () => { + const tiles = terrain(true); + try { + tiles.follow({ x: 16, z: 16 }); + expect(tiles.terrainTiles.blocks).toBe(1); + const rebuilds = tiles.terrainTiles.rebuilds; + // The point moves 24 tiles away, so the ring and its blocks are all dirty again, and the frame + // pays for nothing: the rebuild waits instead of being dropped. + tiles.follow({ x: 400, z: 0 }, { admit: () => false }); + expect(tiles.terrainTiles.rebuilds).toBe(rebuilds); + expect(tiles.deferredAdmissions).toBeGreaterThan(0); + // Every frame that allows the work rebuilds one block, so the ring here converges. + let frames = 0; + while (frames < 12 && tiles.terrainTiles.rebuilds === rebuilds) { + tiles.follow( + { x: 400, z: 0 }, + { + admit: (work) => { + work(); + return true; + }, + }, + ); + frames += 1; + } + expect(frames).toBeLessThan(12); + expect(tiles.terrainTiles.rebuilds).toBe(rebuilds + 1); + for (let frame = 0; frame < 12; frame += 1) + tiles.follow( + { x: 400, z: 0 }, + { + admit: (work) => { + work(); + return true; + }, + }, + ); + // The stale block at the old point is gone and nothing is left dirty. + const settled = tiles.terrainTiles.rebuilds; + tiles.follow( + { x: 400, z: 0 }, + { + admit: (work) => { + work(); + return true; + }, + }, + ); + expect(tiles.terrainTiles.rebuilds).toBe(settled); + expect(tiles.terrainTiles.draws).toBe(submittedMeshes(tiles)); + } finally { + tiles.dispose(); + } + }); + + it("re-merges a blending tile's block on a later frame once its blend ends", () => { + const tiles = terrain(true, { lodDistances: [8], lodFactors: [1, 2] }); + try { + tiles.follow({ x: 16, z: 16 }); + tiles.follow({ x: 24.5, z: 16 }); + expect(tiles.blendingTiles).toBeGreaterThan(0); + // The blend is three frames, and the block that will take the tile back is rebuilt on a frame + // of its own, so the tile must not stay an individual mesh for good. + for (let frame = 0; frame < 8 && tiles.blendingTiles > 0; frame += 1) { + tiles.process(); + tiles.follow({ x: 24.5, z: 16 }); + } + expect(tiles.blendingTiles).toBe(0); + const merged = tiles.residentKeys.filter((key) => { + const tile = tiles.getTile(key); + if (tile === undefined) throw new Error(`Missing resident tile '${key}'.`); + return !tile.lod.levels.some(({ object }) => object.visible); + }); + expect(merged.length).toBeGreaterThan(0); + expect(tiles.terrainTiles.draws).toBe(submittedMeshes(tiles)); } finally { tiles.dispose(); } @@ -192,7 +546,9 @@ describe("TerrainTiles merge", () => { it("keeps a lone block member individual rather than copying its geometry", () => { const tiles = terrain(true); try { - tiles.follow({ x: 0, z: 0 }); + // One block per frame, so the ring is followed until every cell has been rebuilt. + for (let frame = 0; frame < 8 && tiles.terrainTiles.rebuilds < 3; frame += 1) + tiles.follow({ x: 0, z: 0 }); // tileX -1..1 x tileZ -1..1 splits into three blocks of two or more tiles and one lone tile. expect(tiles.terrainTiles.blocks).toBe(3); expect(tiles.terrainTiles.tiles).toBe(9); diff --git a/packages/core/__tests__/world-tiles-cost.spec.ts b/packages/core/__tests__/world-tiles-cost.spec.ts index a331cd1510..c24415b6b2 100644 --- a/packages/core/__tests__/world-tiles-cost.spec.ts +++ b/packages/core/__tests__/world-tiles-cost.spec.ts @@ -24,18 +24,33 @@ import { Heightfield } from "../src/world.js"; */ const bench = process.env.TN_BENCH === "1"; +/** The name a merged super-tile carries; an unmerged tile has no name of its own. */ +const BLOCK_PREFIX = "tn-terrain-block:"; + const sampleHeight = (x: number, z: number): number => Math.sin(x * 0.017) * 12 + Math.cos(z * 0.013) * 9 + Math.sin((x + z) * 0.007) * 4; -/** Every resident tile's rendered positions, so "unchanged" is a number and not a claim. */ +/** + * Every position the main pass submits for terrain levels, so "unchanged" is a number and not a + * claim. Keyed by the mesh that carries it: a merged super-tile under its own block name, a tile + * that draws its own level under the tile key. + */ function renderedPositions(tiles: TerrainTiles): Record { const rendered: Record = {}; + const record = (key: string, mesh: Mesh): void => { + rendered[key] = (mesh.geometry.getAttribute("position").array as Float32Array).slice(); + }; + // A named child is a merged super-tile; the unnamed ones are the stitch bridges, which are not + // terrain levels and were never part of this record. + for (const child of tiles.children) + if (child instanceof Mesh && child.name.startsWith(BLOCK_PREFIX)) record(child.name, child); for (const key of tiles.residentKeys) { const tile = tiles.getTile(key); if (tile === undefined) throw new Error(`Missing resident tile '${key}'.`); - const mesh = tile.lod.levels.find(({ object }) => object.visible)?.object; - if (!(mesh instanceof Mesh)) throw new Error(`Missing visible LOD for tile '${key}'.`); - rendered[key] = (mesh.geometry.getAttribute("position").array as Float32Array).slice(); + for (const level of tile.lod.levels) { + const mesh = level.object; + if (mesh instanceof Mesh && mesh.visible) record(key, mesh); + } } return rendered; } @@ -64,12 +79,26 @@ function ring( for (let frame = 0; frame < 4; frame += 1) tiles.process(); tiles.follow({ x: 0, z: 0 }); tiles.process(); + // A third settle: the super-tile blocks rebuild one per frame, so the ring is only settled once + // the last dirty block is built. + let rebuilds = tiles.terrainTiles.rebuilds; + for (let frame = 0; frame < 64; frame += 1) { + tiles.follow({ x: 0, z: 0 }); + tiles.process(); + if (tiles.terrainTiles.rebuilds === rebuilds) break; + rebuilds = tiles.terrainTiles.rebuilds; + } return tiles; } -/** A settled ring's stitch bridge, which is the only child of the tiles group that is a bare mesh. */ +/** + * A settled ring's stitch bridge: the only child of the tiles group that is a bare, unnamed mesh, + * where a merged super-tile is a bare mesh with a block name. + */ function bridgeOf(tiles: TerrainTiles): Mesh { - const bridge = tiles.children.find((child): child is Mesh => child instanceof Mesh); + const bridge = tiles.children.find( + (child): child is Mesh => child instanceof Mesh && !child.name.startsWith(BLOCK_PREFIX), + ); if (bridge === undefined) throw new Error("Expected a mixed-LOD bridge mesh."); return bridge; } @@ -114,6 +143,54 @@ function expectContainsEveryVertex(mesh: Mesh): void { } describe("TerrainTiles settled-frame cost", () => { + it("skips the neighbor LOD scan when follow changed no tile or target", () => { + const tiles = ring(2, 17, 32, 25, 64_000_000); + try { + const before = renderedPositions(tiles); + let lookups = 0; + const original = Map.prototype.get; + const get = vi.spyOn(Map.prototype, "get").mockImplementation(function ( + this: Map, + key: unknown, + ) { + if (this.size === 25) lookups += 1; + return original.call(this, key); + }); + tiles.follow({ x: 0, z: 0 }); + get.mockRestore(); + expect(lookups).toBe(25); + tiles.process(); + expect(renderedPositions(tiles)).toEqual(before); + } finally { + vi.restoreAllMocks(); + tiles.dispose(); + } + }); + + it("reads the active blend count without walking the resident ring", () => { + const tiles = ring(2, 17, 32, 25, 64_000_000); + try { + const before = renderedPositions(tiles); + const values = vi.spyOn(Map.prototype, "values"); + expect(tiles.blendingTiles).toBe(0); + expect(values).not.toHaveBeenCalled(); + values.mockRestore(); + tiles.process(); + expect(renderedPositions(tiles)).toEqual(before); + for (let frame = 1; frame <= 60; frame += 1) { + tiles.follow({ x: frame * 0.3, z: 0 }); + tiles.process(); + } + for (let frame = 0; frame < 4; frame += 1) tiles.process(); + expect(tiles.blendingTiles).toBe(0); + tiles.dispose(); + expect(tiles.blendingTiles).toBe(0); + } finally { + vi.restoreAllMocks(); + tiles.dispose(); + } + }); + // A still camera must not rebuild per-sample seam coverage. `bridgeCoverageAt` revalidated the // whole bridge topology and re-derived three world matrices *for every sample of every pair*, // which is what turned a 25-tile ring into a 289-tile frame cost. diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index b1bb552c38..203e7086cf 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -794,7 +794,8 @@ "aliases": [], "constraints": [ "create buses before playing clips and dispose them with the game", - "refDistance and rolloffFactor tune positional falloff and apply to playAt only" + "refDistance and rolloffFactor tune positional falloff and apply to playAt only", + "compressor takes threshold, knee, ratio, attack and release from the game and takes effect on the bus sum" ], "example": "const effects = new AudioBus({ camera });", "importPath": "@threenative/core", @@ -806,6 +807,7 @@ "play a sound effect with a volume bus", "mute or adjust a category of game audio", "keep a gunshot audible at 20 metres by tuning positional falloff", + "hold a loud mix below clipping with a compressor on the bus", "play cannon, wave, and ship sound effects" ], "summary": "Route effects through a named audio bus.", @@ -1496,6 +1498,33 @@ "supersedes": [], "symbol": "FluidField2D" }, + { + "aliases": [], + "constraints": [ + "add the fluid through `ctx.add` so renderer attachment, fixed-step dispatch, and release are automatic", + "a renderer without WebGPU compute throws a named error at attach; it never draws nothing", + "`emit` recycles the oldest slot once `capacity` slots have been used; `fill` stops at capacity", + "`sample` and `stats` read a throttled GPU copy and report `staleFrames`; they are never live" + ], + "example": "const water = new FluidParticles3D({ capacity: 6000 });\nctx.add(water);\nwater.fill([-2.8, 0.1, -1.5], [-0.6, 3, 1.5]);", + "importPath": "@threenative/core", + "kind": "class", + "overrides": [ + "iterations, viscosity, cohesion, vorticity, gravity and maxSpeed tune the solver without changing its pass order" + ], + "package": "@threenative/core", + "signature": "export class FluidParticles3D extends Group { … }", + "situations": [ + "pour, splash, or dam-break water that fills a container and flows around obstacles", + "simulate water or another liquid as particles in a fluid simulation", + "drop a ball or box into liquid and let it displace and float on the water", + "emit a stream or waterfall of particle fluid and drain it somewhere else", + "sample particle-fluid density or surface height in a game-owned render node" + ], + "summary": "Simulate liquid as GPU particles (pour, splash, dam break, waterfall) and expose positions, a density volume and a surface height while the game owns every look.", + "supersedes": [], + "symbol": "FluidParticles3D" + }, { "aliases": [], "constraints": [ @@ -1662,7 +1691,11 @@ "update many small visual particles", "trail dust, exhaust, or spray behind a moving object", "emit cannon smoke and muzzle flash particles", - "fire a cannonball projectile with cannon smoke particles" + "fire a cannonball projectile with cannon smoke particles", + "splash spray droplets with lifetime and gravity", + "spawn water droplets, mist, or foam bubbles above a fluid surface", + "falling snowflakes, rain or ash around the player that thicken into a windy storm or blizzard", + "kick up a spray of powder snow or dust where a foot or a ball lands" ], "summary": "Dispatch a game-owned particle surface and process function through a pooled system.", "supersedes": [], @@ -2686,7 +2719,8 @@ "situations": [ "delay an enemy patrol transition", "run a callback every simulation tick", - "tween a numeric property with a game-owned curve" + "tween a numeric property with a game-owned curve", + "a countdown timer: end the level when its time limit runs out" ], "summary": "Schedule delayed and repeating callbacks or tween numeric properties with game-owned cleanup.", "supersedes": [], @@ -3042,6 +3076,7 @@ "signature": "export class TracerPool3D { … }", "situations": [ "show where a hitscan round went", + "show each round a weapon fires, one tracer per trigger press", "draw incoming fire without spawning projectiles" ], "summary": "Pool travelling bullet-streak meshes for hitscan shots.", @@ -3658,7 +3693,8 @@ "generate a terrain a player can walk across", "query the same ground height or normal that a player sees and collides with", "ask how high the ground is here", - "build islands and coastlines from terrain" + "build islands and coastlines from terrain", + "keep a collider or other copy of a deforming terrain in step without rescanning the whole field (`trackChanges`)" ], "summary": "One height buffer shared by world queries, rendered geometry, and a physics heightfield. The game supplies every value, so changing the terrain's shape never requires a package edit. `fromSampler` evaluates that game function exactly once at each vertex and retains only the resulting numbers. Queries interpolate those same numbers instead of evaluating the function again.", "supersedes": [], @@ -3719,6 +3755,50 @@ "supersedes": [], "symbol": "loadWorldHeightmap" }, + { + "aliases": [], + "constraints": ["radius is metres; the footprint never grows with load, only deeper"], + "example": "const footprint = snowDiscFootprint(0.25);", + "importPath": "@threenative/core/world", + "kind": "function", + "overrides": ["softness widens the eased edge without changing the contact radius"], + "package": "@threenative/core", + "signature": "export function snowDiscFootprint( radius: number, options: ISnowDiscFootprintOptions = { … }", + "situations": [ + "press a ball, wheel or probe into snow", + "give a sphere a physically sized snow contact instead of a boot shape" + ], + "summary": "A circular contact footprint, sized from a radius the caller measures. The shape a sphere, a ball, a wheel or a probe presses into snow, and the default the physics binding derives from a sphere's contact geometry. Flat inside the radius, eased over the softness band, with a raised rim just outside it.", + "supersedes": [], + "symbol": "snowDiscFootprint" + }, + { + "aliases": [], + "constraints": [ + "the field composes onto a Heightfield; construct the terrain first and let this own the surface", + "zero depth is bare ground: contacts register no indentation at all", + "out-of-region heightAt and normalAt follow Heightfield's error contract; sample returns zeros" + ], + "example": "import { Heightfield } from \"@threenative/core/world\";\nconst terrain = Heightfield.fromSampler({ rows: 129, columns: 129, width: 64, depth: 64, origin: { x: 0, z: 0 }, sampleHeight: (x, z) => Math.sin(x * 0.1) * 0.5 });\nconst snow = new SnowField({ field: terrain, depth: 0.28, hardness: 0.3 });\nsnow.stamp({ x: 0, z: 0, area: 0.074, load: 784, duration: 0.1, footprint: snowDiscFootprint(0.12) });\nsnow.recover(1 / 60, 0.0012, 0.4);", + "importPath": "@threenative/core/world", + "kind": "class", + "overrides": [ + "depth, hardness, yieldFraction, maxBank and responseTime name the response coefficients" + ], + "package": "@threenative/core", + "requires": ["@threenative/core/world Heightfield as the canonical terrain and surface"], + "signature": "export class SnowField { … }", + "situations": [ + "leave footprints, tracks and tyre ruts in snow that persist and fill in over time", + "let a pushed sphere carve a connected track and a dropped one settle into a crater", + "store snow deformation that rendered geometry and collision both read", + "reset a snowfield between rounds or change its depth at runtime", + "show how packed the snow is where people have walked or objects have rested" + ], + "summary": "Persistent snow deformation over one canonical heightfield. Snow keeps four channels per cell — indentation, displaced bank, compaction and disturbance — and writes the combined surface (`terrain + depth + bank - indent`) back into the heightfield it was given. Queries, rendered geometry and collider export therefore all read one surface, and no second terrain representation exists. The field is numeric only: it never chooses a boot, a tread, a particle, a material or a camera. Games supply terrain heights, the snow depth, the contact profile and the response coefficients; this class owns the storage, the load-dependent penetration and the bounded recovery. It is a heightfield approximation — not granular snow, displaced-volume conservation, avalanches, melting or a calibrated material law.", + "supersedes": [], + "symbol": "SnowField" + }, { "aliases": ["stream terrain across chunks"], "constraints": [ @@ -3730,7 +3810,7 @@ "importPath": "@threenative/core/world", "kind": "class", "overrides": [ - "tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, validate, and budgets" + "tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, mergeTiles, validate, and budgets" ], "package": "@threenative/core", "signature": "export class TerrainTiles extends Object3D implements IComputeDriven { … }", @@ -3928,6 +4008,51 @@ "supersedes": [], "symbol": "Area3D" }, + { + "aliases": [], + "constraints": [ + "register `rapier()` before attaching, and call `step` once per fixed step after the physics step", + "the backend must expose persistent solved contacts and in-place shape refresh; one that does not fails at attach", + "verified on browser WebGPU and the native Linux desktop host; Android and iOS share the native seam but have not run it", + "automatic profiles cover sphere, box and capsule; any other shape needs an explicit footprint" + ], + "example": "const snowPhysics = attachSnowPhysics({ physics: ctx.physics, snow, bodies: [ball] });\nafterPhysics(ctx, (dt) => snowPhysics.step(dt));", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [ + "loadScale, supportNormal, colliderTolerance, deposition, wind, collisionLayer and collisionMask name the binding's own behaviour" + ], + "package": "@threenative/physics", + "requires": ["@threenative/core/world SnowField as the surface it deforms"], + "signature": "export function attachSnowPhysics(options: ISnowPhysicsOptions): ISnowPhysicsBinding { … }", + "situations": [ + "leave footprints and tracks where physical bodies actually touch snow", + "let a dropped or pushed sphere carve and settle into deformable snow", + "make a crate, capsule or ball compress the surface it rests on" + ], + "summary": "Drive a `SnowField` from real solved contacts. The binding creates the snow surface collider from the field's own canonical samples and keeps it in step with them, so a body lands on the surface a query would report. Each fixed step it reads the solver's persistent contacts — not collision start/stop events, which carry no point or load — deforms the snow only where a contact is supported and loaded downward, then republishes the surface before the next step. Airborne bodies, side contacts and unrelated colliders never deform anything. A dropped sphere settles on the surface it made; a pushed one rotates and carves a connected track without its transform being copied anywhere. Footprints come from the body's own collision shape and orientation unless the game supplies one. Contact load is `impulse / deltaTime * loadScale`: a solver impulse over a step, which estimates a contact force and is not measured. `observe().loadProvenance` says so.", + "supersedes": [], + "symbol": "attachSnowPhysics" + }, + { + "aliases": [], + "constraints": [ + "halfWidth and halfDepth are metres; rotation comes from the contact, not the footprint" + ], + "example": "const footprint = boxFootprint(0.4, 0.25);", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [], + "package": "@threenative/physics", + "signature": "export function boxFootprint(halfWidth: number, halfDepth: number): ISnowFootprint { … }", + "situations": [ + "let a crate, platform or plank press a rectangular pit into snow", + "imprint a box's own footprint rather than a circle around it" + ], + "summary": "A rectangular contact: the face a box rests on, in the contact's own frame. Coverage is full across the face and fades just outside it; the bank rises beyond that.", + "supersedes": [], + "symbol": "boxFootprint" + }, { "aliases": [], "constraints": [ @@ -3962,6 +4087,23 @@ "supersedes": [], "symbol": "Buoyancy3D" }, + { + "aliases": [], + "constraints": ["radius and halfHeight are metres and never grow with load"], + "example": "const footprint = capsuleFootprint(0.5, 0.2);", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [], + "package": "@threenative/physics", + "signature": "export function capsuleFootprint(halfHeight: number, radius: number): ISnowFootprint { … }", + "situations": [ + "let a fallen capsule, limb or barrel leave a linear imprint in snow", + "give a capsule a shape-appropriate snow contact instead of a sphere's dot" + ], + "summary": "A stadium contact: a rectangle of half-length `halfHeight` and half-width `radius`, capped by two half-discs of `radius`. The shape a capsule lies on, so a knocked-over body sinks along a line instead of at a point. Coverage is full across the body's own width and fades just outside it, so the body's edge never rests on a half-pressed rim; the bank rises beyond that.", + "supersedes": [], + "symbol": "capsuleFootprint" + }, { "aliases": [ "raised platform gap hazard restart", @@ -6626,6 +6768,35 @@ "supersedes": [], "symbol": "useUiState" }, + { + "symbol": "createStormAudio", + "package": "template:rain", + "importPath": "src/audio/storm.ts", + "kind": "function", + "signature": "createStormAudio(ctx: ICtx): IStormAudio", + "summary": "The storm's sound as generated source, mixed through the engine's own `AudioBus`: a rain hiss, a wind bed, and thunder queued strike by strike and delayed and filtered by how far away it was struck.", + "situations": [ + "rain hiss, wind bed, thunder, a storm you can hear", + "delay thunder by the distance it was struck", + "sound that follows pause, mute and tab visibility", + "audio that queues strikes instead of cutting each other off" + ], + "example": "src/audio/storm.ts — register the returned helper, call update(state) after advancing the simulation, and dispose it with the owning scene", + "constraints": [ + "`queueStrike({ at, metres })` queues: a second strike does not overwrite one still crossing the air, and `at` is absolute simulation time, which is what lets a strike survive a freeze.", + "Two holds, kept apart so neither latches the other: `setSilenced` is the game's own pause intent, `setHidden` is the UI realm or engine lifecycle visibility. The bus is released only when both are clear.", + "`update(state)` is the frame's truth and is called once per frame after the simulation advances; `debug()` is what a playtest reads back.", + "The caller owns entity registration, updates and disposal; creating the helper does not register it.", + "Audio begins only after a user gesture — the bus's unlock error is surfaced rather than swallowed.", + "The three clips are baked by `tools/make-storm-audio.mjs` from the study's own maths; this file plays them, it does not synthesise them live.", + "The helper requests the source's compressor settings ahead of master gain: threshold -15 dB, knee 30 dB, ratio 5, attack 0.003 s and release 0.25 s.", + "Each thunder voice is a labelled `thunder` cue, so a playtest's `audio` assertion reads it from the runtime ledger; the rain kit's `playtests/lightning.playtest.json` proves one cue after a strike, sounded only once the strike's `distance / 343` delay had passed.", + "This entry describes the Rain starter scaffold's own generated `src/audio/storm.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, { "symbol": "renderer.matrixWorld", "package": "@threenative/core", @@ -6721,6 +6892,107 @@ "supersedes": [], "aliases": [] }, + { + "symbol": "createStormLightning", + "package": "template:rain", + "importPath": "src/render/lightning.ts", + "kind": "function", + "signature": "createStormLightning(scene: Scene, camera: PerspectiveCamera): IStormLightning", + "summary": "A branching lightning bolt as generated Rain source: `makeBolt(end, random)` walks a 30-segment channel down from the cloud deck with side branches, and one additive camera-facing ribbon draw (gaussian core inside a wide halo) shows it while the caller's flash envelope is above zero. `strike(position, random)` returns where the bolt entered the sky.", + "situations": [ + "generate branching lightning ribbon geometry", + "a lightning strike that lights the scene and the clouds", + "a camera-facing ribbon or beam that stays the same pixel width at any distance", + "a flash envelope that stutters rather than fades once" + ], + "example": "src/render/lightning.ts — the bolt; src/state.ts `flashAt` for the envelope; src/scenes/Boot.ts strike() for the thunder delay", + "constraints": [ + "Pass the game's seeded random so a strike is reproducible; a non-finite position or random value throws rather than drawing NaN geometry.", + "The buffers hold the worst case (30 channel segments plus 24 branches of 9) and are allocated once; a strike rewrites them and opens the draw range, never reallocating.", + "`update(flash)` takes an envelope already gated on photosensitivity mode by the caller; below 0.003 the mesh is hidden. Register it with `alwaysRender`: its quads are projected by its own vertex stage, so its bounds say nothing about where it lands.", + "It depth-tests against the coast's written depth, so a bolt behind the headland is hidden by it.", + "This entry describes the Rain starter scaffold's own generated `src/render/lightning.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createNoiseVolume", + "package": "template:rain", + "importPath": "src/render/noise-volume.ts", + "kind": "function", + "signature": "createNoiseVolume(): INoiseVolume", + "summary": "The 64³ RGBA cloud noise volume as generated Rain source: `{ data: Uint8Array, texture: Data3DTexture }`. Its authored seed, octave weights and source-exact generator define the storm's cloud shapes.", + "situations": [ + "generate a 3D noise volume instead of shipping one as an asset", + "rebuild the storm's clouds procedurally at startup", + "tileable 3D noise that does not crease at the edges", + "four octave scales packed into one texture" + ], + "example": "src/render/noise-volume.ts — edit the seed, the lattices or the channel weights there", + "constraints": [ + "Seed 13291 uses the source's mulberry32 sequence. The generated volume matches all 1,048,576 reference bytes; changing the authored seed or generator changes its appearance.", + "Two distinct failure channels, not one: `TN_NOISE_CHANNEL` is a channel value that does not fit a byte, `TN_NOISE_LENGTH` is a byte count that does not fill the volume. Both throw rather than uploading a volume the shader would read wrong.", + "64³ of RGBA is 1 MiB, and `NOISE_SIZE` is what the loops and the byte count are written against; changing the size changes the memory cost with it.", + "Indices wrap at every face, which is what makes the volume tile, and the fractional part is smoothed by `f²(3 − 2f)` so lattice edges do not show as creases in the cloud.", + "This entry describes the Rain starter scaffold's own generated `src/render/noise-volume.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createStormRain", + "package": "template:rain", + "importPath": "src/render/rain.ts", + "kind": "function", + "signature": "createStormRain(scene: Scene, camera: PerspectiveCamera): IStormRain", + "summary": "Camera-anchored falling rain as generated Rain source: one instanced draw whose vertex stage hashes each drop from its index, wraps it in a 66 × 30 × 66 m cell that follows the camera in 12 m steps, and projects a quad stretched along its own fall — no particle simulation, no texture.", + "situations": [ + "rain streaks around the camera", + "thousands of falling drops in one draw", + "rain that leans with the wind and lights up in a lightning flash" + ], + "example": "src/render/rain.ts — the drop maths; `STUDY_TIERS` in src/render/quality.ts for the per-tier budget", + "constraints": [ + "The drawn count is `round(rainBudget × weather.rain)` for the tier, read back from `geometry.instanceCount` (`instanceCount`), not from the budget.", + "Stateless by design: a drop's position is a function of its index and the clock, so pausing the clock freezes the rain and nothing accumulates. `GPUParticles3D` is the engine's emitter with lifetimes; reach for it when drops must spawn, live and die.", + "Register it with `alwaysRender`: the vertex stage places every drop, so the geometry's own bounds do not.", + "This entry describes the Rain starter scaffold's own generated `src/render/rain.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createWeatherWorld", + "package": "template:rain", + "importPath": "src/render/world.ts", + "kind": "function", + "signature": "createWeatherWorld(scene: Scene, camera: PerspectiveCamera): IWeatherWorld", + "summary": "The raymarched coastal storm as generated source: one screen quad whose TSL shader draws the sky, the sea, the coast, the road, the forest and the lamps, and whose wet ground reflects them, written from the uniforms the game feeds it each frame. The sky it samples is the live cloud pass this call builds, not a stand-in gradient. Returns `{ quad, update(options), dispose() }`.", + "situations": [ + "a procedural storm, sea, coastline or open horizon drawn from maths rather than geometry", + "rain and wind that change how a scene looks without swapping a mesh", + "a lightning flash that lights the coast and the sky at once", + "wet reflections on a ground plane", + "sky that darkens as cloud cover rises" + ], + "example": "src/render/world.ts — edit the shader there, or the weather it is fed from src/state.ts", + "constraints": [ + "`update` takes `{ elapsed, flash, weather, strike, quality }`: absolute seconds, a flash envelope already gated on the photosensitivity switch by the caller, the bolt's entry point in the cloud deck in metres (what the clouds glow around and the coast is lit from), and a quality name of `performance | balanced | high | ultra`.", + "The tier's cloud resolution share, march steps and reflection switch come from `STUDY_TIERS` in src/render/quality.ts; `performance` skips the 36-step reflection march, so a low tier loses reflections, not the coast. `clouds()` reads back the cloud target the renderer really drew and its steps.", + "Its colours and lights are uniforms set once from src/render/palette.ts, sky.ts, lighting.ts and materials.ts; the shader itself is generated from tools/tempest-*.frag by tools/generate-shaders.mjs — edit the .frag, not the generated file.", + "The engine's `Scene` and `PerspectiveCamera` are injected as arguments; the file holds no engine lifetime of its own. `dispose()` removes the quad and disposes the geometry, material and sky texture — the scene does not do it for you.", + "The quad has `frustumCulled = false`, because its two-metre bounds would otherwise be culled the moment the camera looks along the coast.", + "The cloud pass is built first and its texture feeds the world shader (sampled with the render-target flip); `update` also drives the clouds. Rain streaks and the bolt are separate draws: `createStormRain`, `createStormLightning`.", + "This entry describes the Rain starter scaffold's own generated `src/render/world.ts`, not a `@threenative/` or three export — there is no installed package to import it from. Appearance lives in that source with no package import at all; nothing in packages/ decides how this storm looks." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, { "symbol": "WorldEnvironment", "package": "three", diff --git a/packages/core/src/audio.ts b/packages/core/src/audio.ts index 6025483635..85297a200c 100644 --- a/packages/core/src/audio.ts +++ b/packages/core/src/audio.ts @@ -1,7 +1,40 @@ import { AudioListener, Object3D, PositionalAudio, Audio as ThreeAudio, type Vector3 } from "three"; +/** + * Bus-level dynamics compression, every value the game's. + * + * All five are required and none has an engine default: a compressor is a mix decision, and a + * bus that filled in the threshold itself would be deciding how loud the game is. The ranges are + * Web Audio's own — threshold -100 to 0 dBFS, knee 0–40 dB, ratio 1–20, attack and release + * 0–1 s, where 0 is an instant response — and anything outside them throws rather than being + * quietly clamped into a mix the game did not ask for. + */ +export interface IAudioCompressorOptions { + /** Level above which the bus is compressed, in dBFS. Must be finite and between -100 and 0. */ + readonly threshold: number; + /** Width of the soft transition around `threshold`, in dB. Must be finite and 0–40. */ + readonly knee: number; + /** Input:output ratio above the threshold. Must be finite and 1–20. */ + readonly ratio: number; + /** Seconds to reach full compression once a peak arrives. Must be finite and 0–1. */ + readonly attack: number; + /** Seconds to return to unity once the mix falls back. Must be finite and 0–1. */ + readonly release: number; +} + export interface IAudioBusOptions { readonly camera: Object3D; + /** + * A compressor on this bus's master, holding a loud mix below clipping. + * + * Every voice sums into it before the bus's volume gain, so it sees the mix rather than each + * cue alone — which is the whole point: three cues that are each fine and add up to a clipped + * master is a defect no per-voice option can see, and a compressor that sat after the gain + * would be working on a level the bus volume had already moved. Opt in, and every value comes + * from the game. A runtime with no dynamics node reports `compressor` in `unsupported` rather + * than playing on uncompressed and quiet about it. + */ + readonly compressor?: IAudioCompressorOptions; readonly gestureTarget?: EventTarget; readonly listener?: AudioListener; readonly source?: () => EventTarget | undefined; @@ -84,8 +117,8 @@ export interface IAudioRuntimeSnapshot { /** * Cue-shaping options this runtime accepted and could not honour, sorted and de-duplicated. * - * The native host binds neither a biquad filter nor a schedulable `detune`, so `lowpassHz` and - * `detune` are silently dropped there. A mix tuned on the web and shipped to a phone is flat in + * The native host binds a biquad filter and a compressor but no schedulable `detune`, so + * `detune` is silently dropped there. A mix tuned on the web and shipped to a phone is flat in * a way nothing else reports, and a build can be failed on this. */ readonly unsupported: readonly string[]; @@ -205,6 +238,8 @@ export class AudioBus { #freeFlat: PooledVoice[] = []; #freePositional: PooledVoice[] = []; #maxVoices: number; + /** The bus master compressor, ahead of the listener's volume gain. See `#installCompressor`. */ + #compressor: DynamicsCompressorNode | undefined; #unlocked = false; #disposed = false; #paused = false; @@ -219,6 +254,7 @@ export class AudioBus { this.#maxVoices = maxVoices; this.#camera = options.camera; this.listener = options.listener ?? new AudioListener(); + if (options.compressor !== undefined) this.#installCompressor(options.compressor); const source = options.source ?? (() => (typeof window === "undefined" ? undefined : window)); this.#gestureTarget = options.gestureTarget ?? source(); this.setCamera(options.camera); @@ -228,9 +264,23 @@ export class AudioBus { for (const event of ["keydown", "pointerdown", "touchstart"] as const) { this.#gestureTarget?.addEventListener(event, this.#gesture); } + if (this.#unsupported.size > 0) this.#flushUnsupported(); audioState().buses.add(this); } + /** + * Warn about cue shaping this runtime dropped, once per option, before the first frame. + * + * `#dropped` is reached while a cue plays, which is too late to act on and easy to miss in a + * device log. A bus asked for a compressor it cannot have knows at construction, so that case + * is reported here instead of waiting for a mix to prove it. + */ + #flushUnsupported(): void { + for (const option of this.#unsupported) { + console.warn(`AudioBus: this runtime cannot honour "${option}"; the cue sounds without it.`); + } + } + get queued(): number { return this.#queue.length; } @@ -269,6 +319,17 @@ export class AudioBus { return [...this.#unsupported].sort(); } + /** + * The compressor on this bus's master, or `undefined` when the bus has none — either because + * the game did not ask for one or because this runtime has no dynamics node. + * + * Exposed so a game can read `reduction` off it, which is the only observation that answers + * "is the compressor doing anything", and so a playtest can assert on it. + */ + get compressor(): DynamicsCompressorNode | undefined { + return this.#compressor; + } + /** * The whole bus's level, which is what a volume slider and a duck both move. * @@ -342,6 +403,51 @@ export class AudioBus { this.#flushQueue(); } + /** + * Put a compressor where every voice sums, ahead of this bus's volume gain. + * + * Three wires a voice's gain straight to `listener.getInput()` in its own constructor, and the + * listener's input is the bus's volume gain — so the only honest place for a bus compressor is + * the voice side of that junction, which is what `#attachToMaster` splices it into. Its + * alternative, `AudioListener.setFilter`, sits *after* the gain and would compress a mix the + * volume slider had already moved. It also shares the listener's single filter slot, so a game + * with two buses on one listener would have the second compressor overwrite the first. + */ + #installCompressor(options: IAudioCompressorOptions): void { + assertCompressor(options); + const context = this.listener.context as AudioContext & { + createDynamicsCompressor?: () => DynamicsCompressorNode; + }; + if (context.createDynamicsCompressor === undefined) { + this.#dropped("compressor"); + return; + } + const compressor = context.createDynamicsCompressor(); + // Every value is the game's, written before the node hears a sample. + for (const name of ["threshold", "knee", "ratio", "attack", "release"] as const) { + compressor[name].value = options[name]; + } + // The compressor's own output is the bus gain, so it is the one node every voice reaches + // through rather than past. A bus sharing a provided listener still reaches that gain + // directly, so installing a compressor here never silences another bus. + compressor.connect(this.listener.getInput()); + this.#compressor = compressor; + } + + /** + * Route a freshly constructed voice into the bus master, through the compressor when there is one. + * + * The splice is the price of three not offering a way to route a voice anywhere but straight + * into the listener. It is paid once per pooled voice, at claim time, so a bus pays for it at + * peak concurrency and never again. + */ + #attachToMaster(voice: ThreeAudio): ThreeAudio { + if (this.#compressor === undefined) return voice; + voice.gain.disconnect(); + voice.gain.connect(this.#compressor); + return voice; + } + setCamera(camera: Object3D): void { this.#camera = camera; camera.add(this.listener); @@ -452,6 +558,12 @@ export class AudioBus { dispose(): void { if (this.#disposed) return; this.#disposed = true; + // Every voice feeds the compressor, so it has to be lifted out again: leaving it connected + // keeps a node the game may still hold alive, still holding this bus's voices. + if (this.#compressor !== undefined) { + this.#compressor.disconnect(); + this.#compressor = undefined; + } this.stop(); // `stop` retires the live voices; `#retire` short-circuits its free-list push once disposed, // so the only thing left is to unhook the ones already parked there. @@ -633,7 +745,7 @@ export class AudioBus { return reused; } return { - voice: new ThreeAudio(this.listener), + voice: this.#attachToMaster(new ThreeAudio(this.listener)), startedAt: 0, filter: undefined, looping, @@ -654,7 +766,7 @@ export class AudioBus { return reused; } return { - voice: new PositionalAudio(this.listener), + voice: this.#attachToMaster(new PositionalAudio(this.listener)), startedAt: 0, filter: undefined, looping, @@ -720,6 +832,30 @@ export function audioRuntimeSnapshot(): IAudioRuntimeSnapshot { }; } +/** + * The compressor contract, checked before the node is built rather than after it is wired in. + * + * Ranges are Web Audio's own, so a value the platform would reject is refused here with its name + * rather than clamped into something the game did not ask for. + */ +function assertCompressor(options: IAudioCompressorOptions): void { + for (const name of ["threshold", "knee", "ratio", "attack", "release"] as const) { + if (!Number.isFinite(options[name])) { + throw new TypeError(`compressor.${name} must be a finite number.`); + } + } + if (options.threshold < -100 || options.threshold > 0) + throw new RangeError("compressor.threshold must be between -100 and 0 dBFS."); + if (options.knee < 0 || options.knee > 40) + throw new RangeError("compressor.knee must be between 0 and 40 dB."); + if (options.ratio < 1 || options.ratio > 20) + throw new RangeError("compressor.ratio must be between 1 and 20."); + if (options.attack < 0 || options.attack > 1) + throw new RangeError("compressor.attack must be between 0 and 1 second."); + if (options.release < 0 || options.release > 1) + throw new RangeError("compressor.release must be between 0 and 1 second."); +} + /** Every numeric contract on a cue, checked before a voice is claimed rather than after. */ function assertOptions(options: IAudioPlayOptions): void { // A cue that is not a label is a mistake the ledger would swallow: a number, an object, or an diff --git a/packages/core/src/fluid-particles.ts b/packages/core/src/fluid-particles.ts new file mode 100644 index 0000000000..44159cd07d --- /dev/null +++ b/packages/core/src/fluid-particles.ts @@ -0,0 +1,1216 @@ +import { Group, Vector3, Vector4 } from "three"; +import type { Node, UniformNode } from "three/src/nodes/Nodes.js"; +import * as tsl from "three/tsl"; +import { + type ComputeNode, + HalfFloatType, + LinearFilter, + RGBAFormat, + Storage3DTexture, +} from "three/webgpu"; +import { GPUReadback } from "./gpu-readback.js"; +import type { IRendererLike } from "./renderer.js"; + +type Vec3 = readonly [number, number, number]; +type Vec4 = readonly [number, number, number, number]; +/** + * A TSL node as the solver handles it: vectors, scalars and storage elements through swizzles that + * three's types refuse for a storage element or an atomic result. Confined to this module. + */ +// quality-allow: the solver is arithmetic over TSL nodes, which three 0.185 types per-swizzle. +// biome-ignore lint/suspicious/noExplicitAny: three's TSL types refuse the swizzles the kernels read. +type TslNode = Record; +type TslVar = TslNode; +// quality-allow: the whole TSL namespace is read through one loose record; see TslNode. +// biome-ignore lint/suspicious/noExplicitAny: see TslNode. +const tslNodes: Record = tsl; +const { + Fn, + If, + Loop, + Return, + abs, + atomicAdd, + atomicLoad, + atomicMax, + atomicMin, + atomicStore, + clamp, + dot, + exp, + float, + floor, + hash, + instanceIndex, + instancedArray, + int, + ivec3, + length, + max, + min, + select, + storageTexture, + textureStore, + uint, + uniform, + uniformArray, + uvec3, + vec3, + vec4, +} = tslNodes; + +export interface IFluidBounds { + readonly min: Vec3; + readonly max: Vec3; +} + +/** An obstacle the water flows around. Boxes carry an xyzw quaternion; spheres have no orientation. */ +export type IFluidCollider = + | { readonly kind: "sphere"; readonly center: Vec3; readonly radius: number } + | { + readonly kind: "box"; + readonly center: Vec3; + readonly halfExtents: Vec3; + readonly rotation?: Vec4; + }; + +export interface IFluidParticlesOptions { + /** Particle slots. The solver never holds more than this many live particles. */ + readonly capacity: number; + /** Rest spacing in metres. The smoothing radius is twice this. */ + readonly spacing?: number; + /** The container. Particles are projected back inside it every iteration. */ + readonly bounds?: IFluidBounds; + /** Density-constraint iterations per fixed step, 0 to 8. */ + readonly iterations?: number; + readonly viscosity?: number; + readonly cohesion?: number; + readonly vorticity?: number; + readonly gravity?: number; + /** Speed clamp in m/s; it is what stops a particle tunnelling through a thin collider. */ + readonly maxSpeed?: number; + readonly timeStep?: number; + /** Colliders updated per step through `setColliders`. */ + readonly maxColliders?: number; + /** Queued `fill`/`emit` calls consumed per step. Extra calls return `false`. */ + readonly maxSpawns?: number; + /** Voxel edge of the density volume, in metres. Defaults to half the spacing. */ + readonly voxelSize?: number; + /** Fixed steps between GPU-to-CPU copies of stats and column heights. */ + readonly readbackEvery?: number; + /** Extra acceleration (m/s²) added to every particle, from the game's own TSL. */ + readonly force?: (position: Node<"vec3">, velocity: Node<"vec3">, time: Node<"float">) => Node; +} + +export interface IFluidParticlesStats { + readonly count: number; + /** Mean of `max(0, density - 1)` over live particles; 0 is incompressible. */ + readonly meanCompression: number; + readonly maxSpeed: number; + readonly min: Vec3; + readonly max: Vec3; + /** Fixed steps between the GPU state these numbers describe and now. */ + readonly staleFrames: number; +} + +const DEFAULT_SPACING = 0.22; +const DEFAULT_BOUNDS: IFluidBounds = { min: [-2.9, 0.0968, -1.6], max: [2.9, 4.65, 1.6] }; +const CELL_CAPACITY = 32; +const STAT_WORDS = 9; +const READOUT_HEADER = 16; +const MAX_STIRS = 4; +const MAX_DRAINS = 4; +const FIXED_POINT = 1000; +const COMPRESSION_SCALE = 100000; +/** Column height of a column whose water is roofed by a collider; `sample` reads past it. */ +const COVERED = -1e30; +const COVERED_SEARCH = 8; +/** Half-width, in columns, of the box filter `sample` averages. */ +const SURFACE_FILTER = 2; +const MIN_OPEN_COLUMNS = 4; + +function positive(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) + throw new Error(`FluidParticles3D.${name} must be positive.`); +} + +function nonNegative(name: string, value: number): void { + if (!Number.isFinite(value) || value < 0) + throw new Error(`FluidParticles3D.${name} must be non-negative.`); +} + +function integerAtLeast(name: string, value: number, least: number): void { + if (!Number.isInteger(value) || value < least) + throw new Error(`FluidParticles3D.${name} must be an integer of at least ${least}.`); +} + +function finiteVec(name: string, value: readonly number[], length: number): void { + if (value.length !== length || !value.every(Number.isFinite)) + throw new Error(`FluidParticles3D.${name} must be ${length} finite numbers.`); +} + +function nodeVar(node: TslNode): TslVar { + return node.toVar(); +} + +function computeKernel(name: string, count: number, body: () => void): ComputeNode { + const node = Fn(body)().compute(count); + node.setName(name); + return node; +} + +/** Rotate `vector` by the inverse quaternion's rows, one world-to-local basis row per axis. */ +function basisRows(rotation: Vec4): readonly [Vec3, Vec3, Vec3] { + finiteVec("collider.rotation", rotation, 4); + const [x, y, z, w] = rotation; + const length2 = x * x + y * y + z * z + w * w; + if (length2 < 1e-12) throw new Error("FluidParticles3D collider rotation must be non-zero."); + const k = 2 / length2; + const xx = x * x * k; + const yy = y * y * k; + const zz = z * z * k; + const xy = x * y * k; + const xz = x * z * k; + const yz = y * z * k; + const wx = w * x * k; + const wy = w * y * k; + const wz = w * z * k; + // Columns of R; the rows of R^T are the world-to-local axes. + return [ + [1 - yy - zz, xy + wz, xz - wy], + [xy - wz, 1 - xx - zz, yz + wx], + [xz + wy, yz - wx, 1 - xx - yy], + ]; +} + +/** + * Simulate liquid as particles on the GPU and expose the data, never the look. + * + * It is a Position Based Fluids solver (density constraints, XSPH viscosity, cohesion, vorticity + * confinement) whose passes run as compute kernels every fixed step. Nothing is drawn: `positions` + * and `velocities` are storage nodes (`.w` of `positions` is the live flag, `.w` of `velocities` + * is a 0..1 foam hint), `density` is a 3D texture of smoothed particle density a game raymarches + * or thresholds, and `sample(x, z)` is the free-surface height a `Buoyancy3D` consumes. + * Appearance — refraction, absorption, foam, spray — stays in the game's `src/render/` code. + * @situation pour, splash, or dam-break water that fills a container and flows around obstacles + * @situation simulate water or another liquid as particles in a fluid simulation + * @situation drop a ball or box into liquid and let it displace and float on the water + * @situation emit a stream or waterfall of particle fluid and drain it somewhere else + * @situation sample particle-fluid density or surface height in a game-owned render node + * @constraint add the fluid through `ctx.add` so renderer attachment, fixed-step dispatch, and release are automatic + * @constraint a renderer without WebGPU compute throws a named error at attach; it never draws nothing + * @constraint `emit` recycles the oldest slot once `capacity` slots have been used; `fill` stops at capacity + * @constraint `sample` and `stats` read a throttled GPU copy and report `staleFrames`; they are never live + * @override iterations, viscosity, cohesion, vorticity, gravity and maxSpeed tune the solver without changing its pass order + * @example const water = new FluidParticles3D({ capacity: 6000 }); + * ctx.add(water); + * water.fill([-2.8, 0.1, -1.5], [-0.6, 3, 1.5]); + */ +export class FluidParticles3D extends Group { + readonly capacity: number; + readonly spacing: number; + readonly bounds: IFluidBounds; + readonly iterations: number; + readonly maxSpeed: number; + readonly timeStep: number; + readonly maxColliders: number; + readonly maxSpawns: number; + readonly processCadence = "fixed" as const; + readonly warmupNodes: readonly ComputeNode[]; + /** vec4 per slot: xyz position, w = 1 live / 0 free. */ + readonly positions: ReturnType; + /** vec4 per slot: xyz velocity, w = foam 0..1. */ + readonly velocities: ReturnType; + /** Smoothed particle density, ~1 inside water; sample it with `texture3D`. */ + readonly density: Storage3DTexture; + /** Voxel counts of `density`. */ + readonly volumeSize: Vec3; + /** World-space corner of voxel (0,0,0) and the edge length of one voxel. */ + readonly volumeOrigin: Vec3; + readonly voxelSize: number; + + readonly #viscosity: UniformNode<"float", number>; + readonly #cohesion: UniformNode<"float", number>; + readonly #vorticity: UniformNode<"float", number>; + readonly #gravity: UniformNode<"float", number>; + readonly #time: UniformNode<"float", number>; + readonly #slots: UniformNode<"uint", number>; + readonly #spawnCount: UniformNode<"uint", number>; + readonly #spawnA: TslNode; + readonly #spawnB: TslNode; + readonly #spawnC: TslNode; + readonly #drainCount: UniformNode<"uint", number>; + readonly #drainMin: TslNode; + readonly #drainMax: TslNode; + readonly #stirCount: UniformNode<"uint", number>; + readonly #stirA: TslNode; + readonly #stirB: TslNode; + readonly #colliderCount: UniformNode<"uint", number>; + readonly #colliderA: TslNode; + readonly #colliderB: TslNode; + readonly #colliderRows: readonly [TslNode, TslNode, TslNode]; + readonly #readout: ReturnType; + readonly #buffers: readonly ReturnType[]; + readonly #readback: GPUReadback; + readonly #columns: readonly [number, number]; + readonly #inject: ComputeNode; + readonly #predict: ComputeNode; + readonly #gridClear: ComputeNode; + readonly #gridBuild: ComputeNode; + readonly #lambda: ComputeNode; + readonly #delta: ComputeNode; + readonly #apply: ComputeNode; + readonly #velocity: ComputeNode; + readonly #smooth: ComputeNode; + readonly #confine: ComputeNode; + readonly #statsClear: ComputeNode; + readonly #statsFinalize: ComputeNode; + readonly #volume: ComputeNode; + readonly #columnHeights: ComputeNode; + #renderer: IRendererLike | undefined; + #cursor = 0; + #used = 0; + #queuedSpawns = 0; + #queuedDrains = 0; + #queuedStirs = 0; + #colliders = 0; + #steps = 0; + #released = false; + + constructor(options: IFluidParticlesOptions) { + integerAtLeast("capacity", options.capacity, 1); + if (options.capacity > 65536) + throw new Error("FluidParticles3D.capacity must be at most 65536."); + const spacing = options.spacing ?? DEFAULT_SPACING; + const bounds = options.bounds ?? DEFAULT_BOUNDS; + const iterations = options.iterations ?? 3; + const viscosity = options.viscosity ?? 0.008; + const cohesion = options.cohesion ?? 0.03; + const vorticity = options.vorticity ?? 0.015; + const gravity = options.gravity ?? 9.81; + const maxSpeed = options.maxSpeed ?? 18; + const timeStep = options.timeStep ?? 1 / 60; + const maxColliders = options.maxColliders ?? 8; + const maxSpawns = options.maxSpawns ?? 16; + const voxelSize = options.voxelSize ?? spacing / 2; + const readbackEvery = options.readbackEvery ?? 4; + positive("spacing", spacing); + positive("maxSpeed", maxSpeed); + positive("timeStep", timeStep); + positive("voxelSize", voxelSize); + nonNegative("viscosity", viscosity); + nonNegative("cohesion", cohesion); + nonNegative("vorticity", vorticity); + nonNegative("gravity", gravity); + if (!Number.isInteger(iterations) || iterations < 0 || iterations > 8) + throw new Error("FluidParticles3D.iterations must be an integer from 0 to 8."); + integerAtLeast("maxColliders", maxColliders, 1); + integerAtLeast("maxSpawns", maxSpawns, 1); + integerAtLeast("readbackEvery", readbackEvery, 1); + finiteVec("bounds.min", bounds.min, 3); + finiteVec("bounds.max", bounds.max, 3); + for (let axis = 0; axis < 3; axis += 1) + if ((bounds.max[axis] as number) - (bounds.min[axis] as number) < spacing * 2) + throw new Error( + "FluidParticles3D.bounds must be at least two spacings wide on every axis.", + ); + + super(); + this.capacity = options.capacity; + this.spacing = spacing; + this.bounds = bounds; + this.iterations = iterations; + this.maxSpeed = maxSpeed; + this.timeStep = timeStep; + this.maxColliders = maxColliders; + this.maxSpawns = maxSpawns; + this.voxelSize = voxelSize; + + // Kernel constants of the reference solver: h = 2 spacing, unit rest density. + const radius = spacing * 0.44; + const h = spacing * 2; + const h2 = h * h; + const volumeOfParticle = spacing ** 3; + const poly6 = 315 / (64 * Math.PI * h ** 9); + const spiky = -45 / (Math.PI * h ** 6); + const wSelf = poly6 * h ** 6; + const wReference = poly6 * (h2 - (0.3 * h) ** 2) ** 3; + const relaxation = 0.03; + const wallFriction = 0.08; + const min3 = bounds.min; + const max3 = bounds.max; + + const gridOrigin: Vec3 = [min3[0] - h, min3[1] - h, min3[2] - h]; + const gridDims: Vec3 = [ + Math.ceil((max3[0] - min3[0]) / h) + 3, + Math.ceil((max3[1] - min3[1]) / h) + 3, + Math.ceil((max3[2] - min3[2]) / h) + 3, + ]; + const cells = gridDims[0] * gridDims[1] * gridDims[2]; + this.volumeSize = [ + Math.ceil((max3[0] - min3[0]) / voxelSize), + Math.ceil((max3[1] - min3[1]) / voxelSize), + Math.ceil((max3[2] - min3[2]) / voxelSize), + ]; + this.volumeOrigin = min3; + const [vx, vy, vz] = this.volumeSize; + const voxels = vx * vy * vz; + if (voxels > 4_000_000) + throw new Error("FluidParticles3D.voxelSize makes a density volume over 4M voxels."); + this.#columns = [vx, vz]; + + const capacity = this.capacity; + this.positions = instancedArray(capacity, "vec4"); + this.velocities = instancedArray(capacity, "vec4"); + const previous = instancedArray(capacity, "vec4"); + const deltas = instancedArray(capacity, "vec4"); + const omega = instancedArray(capacity, "vec4"); + const lambdas = instancedArray(capacity, "float"); + const densities = instancedArray(capacity, "float"); + const cellCount = instancedArray(cells, "uint").toAtomic(); + const cellItems = instancedArray(cells * CELL_CAPACITY, "uint"); + const stats = instancedArray(STAT_WORDS, "uint").toAtomic(); + const volumeBuffer = instancedArray(voxels, "float"); + this.#readout = instancedArray(READOUT_HEADER + vx * vz, "float"); + this.#buffers = [ + this.positions, + this.velocities, + previous, + deltas, + omega, + lambdas, + densities, + cellCount, + cellItems, + stats, + volumeBuffer, + this.#readout, + ]; + const readout = this.#readout; + const positions = this.positions; + const velocities = this.velocities; + + this.density = new Storage3DTexture(vx, vy, vz); + this.density.format = RGBAFormat; + this.density.type = HalfFloatType; + this.density.minFilter = LinearFilter; + this.density.magFilter = LinearFilter; + this.density.generateMipmaps = false; + this.density.needsUpdate = true; + + this.#viscosity = uniform(viscosity); + this.#cohesion = uniform(cohesion); + this.#vorticity = uniform(vorticity); + this.#gravity = uniform(gravity); + this.#time = uniform(0); + this.#slots = uniform(0, "uint"); + const zeros = (count: number): Vector4[] => Array.from({ length: count }, () => new Vector4()); + this.#spawnCount = uniform(0, "uint"); + this.#spawnA = uniformArray(zeros(maxSpawns), "vec4"); + this.#spawnB = uniformArray(zeros(maxSpawns), "vec4"); + this.#spawnC = uniformArray(zeros(maxSpawns), "vec4"); + this.#drainCount = uniform(0, "uint"); + this.#drainMin = uniformArray(zeros(MAX_DRAINS), "vec4"); + this.#drainMax = uniformArray(zeros(MAX_DRAINS), "vec4"); + this.#stirCount = uniform(0, "uint"); + this.#stirA = uniformArray(zeros(MAX_STIRS), "vec4"); + this.#stirB = uniformArray(zeros(MAX_STIRS), "vec4"); + this.#colliderCount = uniform(0, "uint"); + this.#colliderA = uniformArray(zeros(maxColliders), "vec4"); + this.#colliderB = uniformArray(zeros(maxColliders), "vec4"); + this.#colliderRows = [ + uniformArray(zeros(maxColliders), "vec4"), + uniformArray(zeros(maxColliders), "vec4"), + uniformArray(zeros(maxColliders), "vec4"), + ]; + + const dt = float(timeStep); + const live = (index: TslNode): TslNode => positions.element(index).w.greaterThan(0.5); + const guard = (): void => { + If(instanceIndex.greaterThanEqual(this.#slots), () => Return()); + If(live(instanceIndex).not(), () => Return()); + }; + + const cellOf = (point: TslNode) => + ivec3( + clamp( + floor(point.sub(vec3(...gridOrigin)).div(h)), + vec3(1), + vec3(gridDims[0] - 2, gridDims[1] - 2, gridDims[2] - 2), + ), + ); + const cellId = (cell: TslNode): TslNode => + cell.x + .add(cell.y.mul(gridDims[0])) + .add(cell.z.mul(gridDims[0] * gridDims[1])) + .toUint(); + + /** Visit every particle within the smoothing radius of `point`, `self` excluded. */ + const forNeighbours = ( + point: TslNode, + self: TslNode, + body: (other: TslNode, offset: TslNode, r2: TslNode) => void, + ): void => { + const cell = cellOf(point); + Loop(27, ({ i: k }: { i: TslNode }) => { + const dx = k.mod(3).sub(1); + const dy = k.div(3).mod(3).sub(1); + const dz = k.div(9).sub(1); + const id = cellId(cell.add(ivec3(dx, dy, dz))); + const inCell = min(atomicLoad(cellCount.element(id)), uint(CELL_CAPACITY)).toVar(); + Loop({ start: uint(0), end: inCell, type: "uint" }, ({ i: slot }: { i: TslNode }) => { + const other = cellItems.element(id.mul(CELL_CAPACITY).add(slot)); + If(other.notEqual(self), () => { + const offset = point.sub(positions.element(other).xyz); + const r2 = dot(offset, offset); + If(r2.lessThan(h2), () => body(other, offset, r2)); + }); + }); + }); + }; + const kernel = (r2: TslNode): TslNode => float(h2).sub(r2).pow(3).mul(poly6); + const gradientScale = (r: TslNode): TslNode => + float(h) + .sub(r) + .pow(2) + .mul(volumeOfParticle * spiky) + .div(r); + + const project = (point: TslVar): void => { + point.assign(clamp(point, vec3(...min3), vec3(...max3))); + Loop({ start: uint(0), end: this.#colliderCount, type: "uint" }, ({ i }: { i: TslNode }) => { + const a = this.#colliderA.element(i); + const b = this.#colliderB.element(i); + const offset = point.sub(a.xyz); + If(dot(offset, offset).lessThan(b.w.add(radius).pow(2)), () => { + If(a.w.lessThan(0.5), () => { + const reach = b.x.add(radius); + const distance = length(offset); + If(distance.lessThan(reach), () => { + const direction = select( + distance.lessThan(1e-9), + vec3(0, 1, 0), + offset.div(max(distance, 1e-9)), + ); + point.assign(a.xyz.add(direction.mul(reach))); + }); + }).Else(() => { + const rows = this.#colliderRows.map((row) => row.element(i).xyz); + const local = vec3( + dot(rows[0] as TslNode, offset), + dot(rows[1] as TslNode, offset), + dot(rows[2] as TslNode, offset), + ); + const half = b.xyz.add(radius); + const absolute = abs(local); + If( + absolute.x + .lessThan(half.x) + .and(absolute.y.lessThan(half.y)) + .and(absolute.z.lessThan(half.z)), + () => { + const gap = half.sub(absolute); + const out = nodeVar(local); + If(gap.x.lessThan(gap.y).and(gap.x.lessThan(gap.z)), () => { + out.assign( + vec3(select(local.x.lessThan(0), half.x.negate(), half.x), local.y, local.z), + ); + }) + .ElseIf(gap.y.lessThan(gap.z), () => { + out.assign( + vec3(local.x, select(local.y.lessThan(0), half.y.negate(), half.y), local.z), + ); + }) + .Else(() => { + out.assign( + vec3(local.x, local.y, select(local.z.lessThan(0), half.z.negate(), half.z)), + ); + }); + const world = (rows[0] as TslNode) + .mul((out as TslNode).x) + .add((rows[1] as TslNode).mul((out as TslNode).y)) + .add((rows[2] as TslNode).mul((out as TslNode).z)); + point.assign(a.xyz.add(world)); + }, + ); + }); + }); + }); + // Colliders push outward, possibly past the container: the container wins. + point.assign(clamp(point, vec3(...min3), vec3(...max3))); + }; + + this.#inject = computeKernel("fluidParticles.inject", capacity, () => { + const slot = instanceIndex; + If(slot.greaterThanEqual(uint(capacity)), () => Return()); + const body = positions.element(slot); + const motion = velocities.element(slot); + Loop({ start: uint(0), end: this.#drainCount, type: "uint" }, ({ i }: { i: TslNode }) => { + const low = this.#drainMin.element(i).xyz; + const high = this.#drainMax.element(i).xyz; + const p = body.xyz; + If( + body.w + .greaterThan(0.5) + .and(p.x.greaterThanEqual(low.x)) + .and(p.x.lessThanEqual(high.x)) + .and(p.y.greaterThanEqual(low.y)) + .and(p.y.lessThanEqual(high.y)) + .and(p.z.greaterThanEqual(low.z)) + .and(p.z.lessThanEqual(high.z)), + () => { + body.assign(vec4(p, 0)); + motion.assign(vec4(0)); + }, + ); + }); + Loop({ start: uint(0), end: this.#spawnCount, type: "uint" }, ({ i }: { i: TslNode }) => { + const a = this.#spawnA.element(i); // min.xyz, first slot + const b = this.#spawnB.element(i); // lattice dims xyz, particle count + const c = this.#spawnC.element(i); // velocity.xyz, lattice spacing + const relative = slot.add(uint(capacity)).sub(a.w.toUint()).mod(uint(capacity)); + If(relative.lessThan(b.w.toUint()), () => { + const nx = b.x.toUint(); + const nz = b.z.toUint(); + const ix = relative.mod(nx); + const iz = relative.div(nx).mod(nz); + const iy = relative.div(nx.mul(nz)); + const jitter = select( + b.w.greaterThan(1.5), + vec3( + hash(slot.add(relative)).sub(0.5), + hash(slot.add(relative).add(uint(104729))).sub(0.5), + hash(slot.add(relative).add(uint(7919))).sub(0.5), + ).mul(c.w.mul(0.1)), + vec3(0), + ); + const placed = a.xyz.add(vec3(float(ix), float(iy), float(iz)).mul(c.w)).add(jitter); + body.assign(vec4(placed, 1)); + motion.assign(vec4(c.xyz, 0)); + }); + }); + }); + + this.#predict = computeKernel("fluidParticles.predict", capacity, () => { + guard(); + const i = instanceIndex; + const velocity = nodeVar(velocities.element(i).xyz); + const position = nodeVar(positions.element(i).xyz); + Loop({ start: uint(0), end: this.#stirCount, type: "uint" }, ({ i: s }: { i: TslNode }) => { + const centre = this.#stirA.element(s); + const offset = position.sub(centre.xyz); + const distance = length(offset); + If(distance.lessThan(centre.w), () => { + const t = float(1).sub(distance.div(centre.w)).mul(this.#stirB.element(s).x); + velocity.assign( + velocity.add( + vec3( + offset.x.div(distance.add(0.15)).mul(t), + t.mul(0.65), + offset.z.div(distance.add(0.15)).mul(t), + ), + ), + ); + }); + }); + velocity.assign(velocity.add(vec3(0, this.#gravity.negate().mul(dt), 0))); + if (options.force !== undefined) + velocity.assign( + velocity.add( + ( + options.force(position as never, velocity as never, this.#time as never) as TslNode + ).mul(dt), + ), + ); + velocity.assign(clamp(velocity, vec3(-maxSpeed), vec3(maxSpeed))); + previous.element(i).assign(vec4(position, 1)); + position.assign(position.add(velocity.mul(dt))); + project(position); + positions.element(i).assign(vec4(position, 1)); + velocities.element(i).assign(vec4(velocity, velocities.element(i).w)); + }); + + this.#gridClear = computeKernel("fluidParticles.grid.clear", cells, () => { + If(instanceIndex.greaterThanEqual(uint(cells)), () => Return()); + atomicStore(cellCount.element(instanceIndex), uint(0)); + }); + this.#gridBuild = computeKernel("fluidParticles.grid.build", capacity, () => { + guard(); + const id = cellId(cellOf(positions.element(instanceIndex).xyz)); + const slot = atomicAdd(cellCount.element(id), uint(1)).toVar(); + If(slot.lessThan(uint(CELL_CAPACITY)), () => { + cellItems.element(id.mul(CELL_CAPACITY).add(slot)).assign(instanceIndex); + }); + }); + + this.#lambda = computeKernel("fluidParticles.lambda", capacity, () => { + guard(); + const i = instanceIndex; + const p = positions.element(i).xyz; + const rho = nodeVar(float(volumeOfParticle * wSelf)); + const gradient = nodeVar(vec3(0)); + const sum = nodeVar(float(0)); + forNeighbours(p, i, (_other, offset, r2) => { + rho.addAssign(kernel(r2).mul(volumeOfParticle)); + const r = r2.sqrt(); + If(r.greaterThan(1e-6), () => { + const a = offset.mul(gradientScale(r)); + gradient.addAssign(a); + sum.addAssign(dot(a, a)); + }); + }); + densities.element(i).assign(rho); + const constraint = max(rho.sub(1), 0); + lambdas + .element(i) + .assign(constraint.negate().div(sum.add(dot(gradient, gradient)).add(relaxation))); + }); + + this.#delta = computeKernel("fluidParticles.delta", capacity, () => { + guard(); + const i = instanceIndex; + const p = positions.element(i).xyz; + const own = lambdas.element(i); + const total = nodeVar(vec3(0)); + forNeighbours(p, i, (other, offset, r2) => { + const r = r2.sqrt(); + If(r.greaterThan(1e-6), () => { + const ratio = kernel(r2).div(wReference); + const tensile = ratio.pow(4).mul(-0.001); + const strength = own.add(lambdas.element(other)).add(tensile); + total.addAssign(offset.mul(strength.mul(gradientScale(r)))); + }); + }); + const limit = spacing * 0.24; + const magnitude = length(total); + const scale = select(magnitude.greaterThan(limit), float(limit).div(magnitude), float(1)); + deltas.element(i).assign(vec4(total.mul(scale), 0)); + }); + + this.#apply = computeKernel("fluidParticles.apply", capacity, () => { + guard(); + const i = instanceIndex; + const p = nodeVar(positions.element(i).xyz.add(deltas.element(i).xyz)); + project(p); + positions.element(i).assign(vec4(p, 1)); + }); + + this.#velocity = computeKernel("fluidParticles.velocity", capacity, () => { + guard(); + const i = instanceIndex; + const v = positions.element(i).xyz.sub(previous.element(i).xyz).div(dt); + velocities.element(i).assign(vec4(v, velocities.element(i).w)); + }); + + this.#smooth = computeKernel("fluidParticles.smooth", capacity, () => { + guard(); + const i = instanceIndex; + const p = positions.element(i).xyz; + const v = velocities.element(i).xyz; + const change = nodeVar(vec3(0)); + const curl = nodeVar(vec3(0)); + const neighbours = nodeVar(float(0)); + forNeighbours(p, i, (other, offset, r2) => { + const w = kernel(r2).mul(volumeOfParticle); + const dv = velocities.element(other).xyz.sub(v); + change.addAssign( + dv.mul(this.#viscosity.mul(w)).sub(offset.mul(this.#cohesion.mul(w).mul(dt).mul(8))), + ); + neighbours.addAssign(1); + const r = r2.sqrt(); + If(r.greaterThan(1e-6), () => { + const s = gradientScale(r); + curl.addAssign( + vec3( + dv.y.mul(offset.z).sub(dv.z.mul(offset.y)), + dv.z.mul(offset.x).sub(dv.x.mul(offset.z)), + dv.x.mul(offset.y).sub(dv.y.mul(offset.x)), + ).mul(s), + ); + }); + }); + deltas.element(i).assign(vec4(change, 0)); + omega.element(i).assign(vec4(curl, neighbours)); + }); + + this.#confine = computeKernel("fluidParticles.confine", capacity, () => { + guard(); + const i = instanceIndex; + const p = positions.element(i).xyz; + const own = length(omega.element(i).xyz); + const eta = nodeVar(vec3(0)); + forNeighbours(p, i, (other, offset, r2) => { + const r = r2.sqrt(); + If(r.greaterThan(1e-6), () => { + const diff = length(omega.element(other).xyz).sub(own); + eta.addAssign(offset.mul(gradientScale(r).mul(diff))); + }); + }); + const w = omega.element(i).xyz; + const scale = this.#vorticity.mul(dt).div(length(eta).add(1e-5)); + const force = vec3( + eta.y.mul(w.z).sub(eta.z.mul(w.y)), + eta.z.mul(w.x).sub(eta.x.mul(w.z)), + eta.x.mul(w.y).sub(eta.y.mul(w.x)), + ).mul(scale); + const limited = clamp(force, vec3(-0.15), vec3(0.15)); + const v = nodeVar( + velocities + .element(i) + .xyz.add(deltas.element(i).xyz) + .add(select(this.#vorticity.greaterThan(0), limited, vec3(0))), + ); + const position = positions.element(i).xyz; + If(position.y.lessThanEqual(min3[1] + 0.002), () => { + v.assign(vec3(v.x.mul(1 - wallFriction), v.y, v.z.mul(1 - wallFriction))); + }); + const speed = length(v); + const source = clamp(speed.sub(1.5).mul(0.14), 0, 1).mul( + select(omega.element(i).w.lessThan(22), float(1), float(0.2)), + ); + const foam = max(velocities.element(i).w.mul(exp(dt.mul(-1.3))), source); + velocities.element(i).assign(vec4(v, foam)); + atomicAdd(stats.element(0), uint(1)); + atomicAdd(stats.element(1), uint(max(densities.element(i).sub(1), 0).mul(COMPRESSION_SCALE))); + atomicMax(stats.element(2), uint(speed.mul(FIXED_POINT))); + for (let axis = 0; axis < 3; axis += 1) { + const offset = uint( + max( + [position.x, position.y, position.z][axis]?.sub(min3[axis] as number).add(1) as TslNode, + 0, + ).mul(FIXED_POINT), + ); + atomicMin(stats.element(3 + axis), offset); + atomicMax(stats.element(6 + axis), offset); + } + }); + + this.#statsClear = computeKernel("fluidParticles.stats.clear", STAT_WORDS, () => { + If(instanceIndex.greaterThanEqual(uint(STAT_WORDS)), () => Return()); + const word = instanceIndex; + atomicStore( + stats.element(word), + select(word.greaterThanEqual(3).and(word.lessThan(6)), uint(0xffffffff), uint(0)), + ); + }); + this.#statsFinalize = computeKernel("fluidParticles.stats.finalize", STAT_WORDS, () => { + If(instanceIndex.greaterThanEqual(uint(STAT_WORDS)), () => Return()); + const word = instanceIndex; + const count = float(atomicLoad(stats.element(0))); + const raw = float(atomicLoad(stats.element(word))); + const axis = word.sub(3).mod(3); + const base = select( + axis.equal(0), + float(min3[0]), + select(axis.equal(1), float(min3[1]), float(min3[2])), + ); + const spatial = select(count.lessThan(0.5), float(0), raw.div(FIXED_POINT).sub(1).add(base)); + const value = select( + word.equal(0), + raw, + select( + word.equal(1), + raw.div(COMPRESSION_SCALE).div(max(count, 1)), + select(word.equal(2), raw.div(FIXED_POINT), spatial), + ), + ); + readout.element(word).assign(value); + }); + + this.#volume = computeKernel("fluidParticles.volume", voxels, () => { + If(instanceIndex.greaterThanEqual(uint(voxels)), () => Return()); + const ix = instanceIndex.mod(uint(vx)); + const iy = instanceIndex.div(uint(vx)).mod(uint(vy)); + const iz = instanceIndex.div(uint(vx * vy)); + const centre = vec3(float(ix), float(iy), float(iz)) + .add(0.5) + .mul(voxelSize) + .add(vec3(...min3)); + const total = nodeVar(float(0)); + forNeighbours(centre, uint(0xffffffff), (_other, _offset, r2) => { + total.addAssign(kernel(r2).mul(volumeOfParticle)); + }); + volumeBuffer.element(instanceIndex).assign(total); + textureStore( + storageTexture(this.density).toWriteOnly(), + uvec3(ix, iy, iz), + vec4(total, 0, 0, 1), + ).toWriteOnly(); + }); + + /** Is `point` inside any collider? Used to tell a column roofed by a body from open water. */ + const insideCollider = (point: TslNode): TslNode => { + const inside = nodeVar(float(0)); + Loop({ start: uint(0), end: this.#colliderCount, type: "uint" }, ({ i }: { i: TslNode }) => { + const a = this.#colliderA.element(i); + const b = this.#colliderB.element(i); + const offset = point.sub(a.xyz); + If(a.w.lessThan(0.5), () => { + If(length(offset).lessThan(b.x), () => { + inside.assign(float(1)); + }); + }).Else(() => { + const rows = this.#colliderRows.map((row) => row.element(i).xyz); + const local = abs( + vec3( + dot(rows[0] as TslNode, offset), + dot(rows[1] as TslNode, offset), + dot(rows[2] as TslNode, offset), + ), + ); + If(local.x.lessThan(b.x).and(local.y.lessThan(b.y)).and(local.z.lessThan(b.z)), () => { + inside.assign(float(1)); + }); + }); + }); + return inside; + }; + + this.#columnHeights = computeKernel("fluidParticles.columns", vx * vz, () => { + If(instanceIndex.greaterThanEqual(uint(vx * vz)), () => Return()); + const ix = instanceIndex.mod(uint(vx)); + const iz = instanceIndex.div(uint(vx)); + const top = nodeVar(float(min3[1])); + const topLayer = nodeVar(int(-1)); + Loop({ start: int(0), end: int(vy), type: "int" }, ({ i: layer }: { i: TslNode }) => { + const voxel = ix.add(uint(layer).mul(uint(vx))).add(iz.mul(uint(vx * vy))); + If(volumeBuffer.element(voxel).greaterThan(0.5), () => { + top.assign(float(layer).add(1).mul(voxelSize).add(min3[1])); + topLayer.assign(layer); + }); + }); + // A body on the column displaces its water: what is left under it is the hull's underside + // (or the bare floor), not the free surface. Flag such a column so `sample` reads its + // neighbours. `topLayer + 1` is the first voxel above the water, or the floor when dry. + const covered = nodeVar(float(0)); + for (const above of [0, 1, 2]) { + const centre = vec3( + float(ix).add(0.5).mul(voxelSize).add(min3[0]), + float(topLayer.add(1 + above)) + .add(0.5) + .mul(voxelSize) + .add(min3[1]), + float(iz).add(0.5).mul(voxelSize).add(min3[2]), + ); + If(insideCollider(centre).greaterThan(0.5), () => { + covered.assign(float(1)); + }); + } + readout + .element(uint(READOUT_HEADER).add(instanceIndex)) + .assign(select(covered.greaterThan(0.5), float(COVERED), top)); + }); + + this.#readback = new GPUReadback({ attribute: readout.value, everyFrames: readbackEvery }); + this.warmupNodes = [ + this.#inject, + this.#predict, + this.#gridClear, + this.#gridBuild, + this.#lambda, + this.#delta, + this.#apply, + this.#velocity, + this.#smooth, + this.#confine, + this.#statsClear, + this.#statsFinalize, + this.#volume, + this.#columnHeights, + ]; + this.addEventListener("removed", this.#onRemoved); + } + + get released(): boolean { + return this.#released; + } + + /** Slots handed out so far; never above `capacity`. */ + get used(): number { + return this.#used; + } + + get steps(): number { + return this.#steps; + } + + get viscosity(): number { + return this.#viscosity.value; + } + + set viscosity(value: number) { + nonNegative("viscosity", value); + this.#viscosity.value = value; + } + + get cohesion(): number { + return this.#cohesion.value; + } + + set cohesion(value: number) { + nonNegative("cohesion", value); + this.#cohesion.value = value; + } + + get vorticity(): number { + return this.#vorticity.value; + } + + set vorticity(value: number) { + nonNegative("vorticity", value); + this.#vorticity.value = value; + } + + get gravity(): number { + return this.#gravity.value; + } + + set gravity(value: number) { + nonNegative("gravity", value); + this.#gravity.value = value; + } + + attachRenderer(renderer: IRendererLike): void { + if (this.#released) throw new Error("FluidParticles3D cannot be attached after release."); + if (this.#renderer === renderer) return; + if (renderer.kind !== "webgpu") + throw new Error( + "FluidParticles3D needs a WebGPU renderer: the solver is compute-only and would draw nothing on this backend.", + ); + if (this.#renderer !== undefined) + throw new Error("FluidParticles3D is already attached to a renderer."); + this.#renderer = renderer; + } + + /** + * Place a lattice of particles in `[min, max]`, one per `spacing`, and return how many fit. + * Particles never wrap: a full solver places none. + */ + fill(min: Vec3, max: Vec3, velocity: Vec3 = [0, 0, 0]): number { + this.#assertLive("fill"); + finiteVec("fill.min", min, 3); + finiteVec("fill.max", max, 3); + finiteVec("fill.velocity", velocity, 3); + const dims = [0, 1, 2].map( + (axis) => + Math.floor(((max[axis] as number) - (min[axis] as number)) / this.spacing + 1e-6) + 1, + ) as [number, number, number]; + if (dims.some((dim) => dim < 1)) throw new Error("FluidParticles3D.fill needs max >= min."); + const wanted = dims[0] * dims[1] * dims[2]; + const room = this.capacity - this.#cursor; + const placed = Math.min(wanted, room); + if (placed <= 0 || this.#queuedSpawns >= this.maxSpawns) return 0; + this.#queueSpawn(min, dims, placed, velocity, this.spacing); + this.#cursor += placed; + this.#used = Math.min(this.#cursor, this.capacity); + return placed; + } + + /** Spawn one particle. Once every slot has been used the oldest slot is recycled. */ + emit(position: Vec3, velocity: Vec3 = [0, 0, 0]): boolean { + this.#assertLive("emit"); + finiteVec("emit.position", position, 3); + finiteVec("emit.velocity", velocity, 3); + if (this.#queuedSpawns >= this.maxSpawns) return false; + const slot = this.#cursor % this.capacity; + this.#queueSpawn(position, [1, 1, 1], 1, velocity, 1, slot); + this.#cursor += 1; + this.#used = Math.min(this.#cursor, this.capacity); + return true; + } + + /** Free every live particle inside the box on the next step. */ + drain(min: Vec3, max: Vec3): boolean { + this.#assertLive("drain"); + finiteVec("drain.min", min, 3); + finiteVec("drain.max", max, 3); + if (this.#queuedDrains >= MAX_DRAINS) return false; + (this.#drainMin.array[this.#queuedDrains] as Vector4).set(min[0], min[1], min[2], 0); + (this.#drainMax.array[this.#queuedDrains] as Vector4).set(max[0], max[1], max[2], 0); + this.#queuedDrains += 1; + return true; + } + + /** Push particles within `radius` of `point` outward and up, strongest at the centre. */ + stir(point: Vec3, strength: number, radius: number): boolean { + this.#assertLive("stir"); + finiteVec("stir.point", point, 3); + positive("stir.radius", radius); + if (!Number.isFinite(strength)) + throw new Error("FluidParticles3D.stir.strength must be finite."); + if (this.#queuedStirs >= MAX_STIRS) return false; + (this.#stirA.array[this.#queuedStirs] as Vector4).set(point[0], point[1], point[2], radius); + (this.#stirB.array[this.#queuedStirs] as Vector4).set(strength, 0, 0, 0); + this.#queuedStirs += 1; + return true; + } + + /** Replace the colliders. Call it every fixed step with the bodies' current transforms. */ + setColliders(colliders: readonly IFluidCollider[]): void { + this.#assertLive("setColliders"); + if (colliders.length > this.maxColliders) + throw new Error(`FluidParticles3D.setColliders accepts at most ${this.maxColliders}.`); + // Validate every collider before writing any, so a throw leaves the previous set intact. + const rows = colliders.map((collider) => { + finiteVec("collider.center", collider.center, 3); + if (collider.kind === "sphere") { + positive("collider.radius", collider.radius); + return undefined; + } + finiteVec("collider.halfExtents", collider.halfExtents, 3); + if (collider.halfExtents.some((half) => half <= 0)) + throw new Error("FluidParticles3D.collider.halfExtents must be positive."); + return basisRows(collider.rotation ?? [0, 0, 0, 1]); + }); + colliders.forEach((collider, index) => { + const a = this.#colliderA.array[index] as Vector4; + const b = this.#colliderB.array[index] as Vector4; + if (collider.kind === "sphere") { + a.set(...collider.center, 0); + b.set(collider.radius, 0, 0, collider.radius); + return; + } + a.set(...collider.center, 1); + b.set(...collider.halfExtents, new Vector3(...collider.halfExtents).length()); + (rows[index] as readonly Vec3[]).forEach((row, axis) => + (this.#colliderRows[axis]?.array[index] as Vector4).set(row[0], row[1], row[2], 0), + ); + }); + this.#colliders = colliders.length; + } + + /** + * Free-surface height near `(x, z)`, box-filtered over open columns of the newest landed GPU + * copy; `bounds.min.y` where no water is near or nothing has landed yet. Shaped for `Buoyancy3D`'s `surface`. + */ + sample(x: number, z: number, _time = 0): { readonly height: number } { + const data = this.#readback.data; + const floor = this.bounds.min[1]; + if (data === undefined) return { height: floor }; + const [columns, rows] = this.#columns; + const cx = Math.round( + clampNumber((x - this.bounds.min[0]) / this.voxelSize - 0.5, 0, columns - 1), + ); + const cz = Math.round( + clampNumber((z - this.bounds.min[2]) / this.voxelSize - 0.5, 0, rows - 1), + ); + // A box-filter over the open columns near the point: splash noise averages out, and a column + // roofed by a body (flagged COVERED) is read through the open water around it, widening until + // enough of it is found. + for (let radius = SURFACE_FILTER; radius <= COVERED_SEARCH; radius += 1) { + let sum = 0; + let found = 0; + for (let pz = Math.max(0, cz - radius); pz <= Math.min(rows - 1, cz + radius); pz += 1) + for (let px = Math.max(0, cx - radius); px <= Math.min(columns - 1, cx + radius); px += 1) { + const value = data[READOUT_HEADER + px + pz * columns] ?? COVERED; + if (value > COVERED / 2) { + sum += value; + found += 1; + } + } + if (found >= MIN_OPEN_COLUMNS) return { height: sum / found }; + } + return { height: floor }; + } + + /** Surface height at `(x, z)`; see {@link sample}. */ + heightAt(x: number, z: number): number { + return this.sample(x, z).height; + } + + /** + * Fixed steps between the GPU state the newest copy holds and now; grows until the first copy + * lands. `sample` and `heightAt` read the same copy, so this is their age too. + */ + get staleFrames(): number { + return this.#readback.staleFrames; + } + + /** Counts and extremes from the newest landed GPU copy, or `undefined` before one lands. */ + get stats(): IFluidParticlesStats | undefined { + const sample = this.#readback.sample; + if (sample === undefined) return undefined; + const d = sample.data; + return { + count: d[0] ?? 0, + meanCompression: d[1] ?? 0, + maxSpeed: d[2] ?? 0, + min: [d[3] ?? 0, d[4] ?? 0, d[5] ?? 0], + max: [d[6] ?? 0, d[7] ?? 0, d[8] ?? 0], + staleFrames: sample.staleFrames, + }; + } + + process(renderer = this.#renderer): void { + if (this.#released) return; + if (renderer === undefined) throw new Error("FluidParticles3D is not attached to a renderer."); + this.#time.value += this.timeStep; + this.#slots.value = this.#used; + this.#spawnCount.value = this.#queuedSpawns; + this.#drainCount.value = this.#queuedDrains; + this.#stirCount.value = this.#queuedStirs; + this.#colliderCount.value = this.#colliders; + if (this.#queuedSpawns > 0 || this.#queuedDrains > 0) renderer.compute(this.#inject); + this.#queuedSpawns = 0; + this.#queuedDrains = 0; + if (this.#used > 0) { + renderer.compute(this.#predict); + for (let iteration = 0; iteration < this.iterations; iteration += 1) { + renderer.compute(this.#gridClear); + renderer.compute(this.#gridBuild); + renderer.compute(this.#lambda); + renderer.compute(this.#delta); + renderer.compute(this.#apply); + } + renderer.compute(this.#gridClear); + renderer.compute(this.#gridBuild); + renderer.compute(this.#velocity); + renderer.compute(this.#statsClear); + renderer.compute(this.#smooth); + renderer.compute(this.#confine); + renderer.compute(this.#statsFinalize); + renderer.compute(this.#volume); + renderer.compute(this.#columnHeights); + } + this.#queuedStirs = 0; + this.#steps += 1; + this.#readback.request(renderer); + } + + detach(): void { + if (this.#released) return; + this.#renderer = undefined; + for (const node of this.warmupNodes) node.dispose(); + this.density.dispose(); + for (const buffer of this.#buffers) buffer.value.dispose(); + this.#readback.dispose(); + this.#released = true; + } + + #queueSpawn( + first: Vec3, + dims: Vec3, + count: number, + velocity: Vec3, + spacing: number, + slot = this.#cursor % this.capacity, + ): void { + const index = this.#queuedSpawns; + (this.#spawnA.array[index] as Vector4).set(first[0], first[1], first[2], slot); + (this.#spawnB.array[index] as Vector4).set(dims[0], dims[1], dims[2], count); + (this.#spawnC.array[index] as Vector4).set(velocity[0], velocity[1], velocity[2], spacing); + this.#queuedSpawns += 1; + } + + #assertLive(name: string): void { + if (this.#released) throw new Error(`FluidParticles3D cannot ${name} after release.`); + } + + #onRemoved = (): void => { + if (this.#renderer !== undefined) this.detach(); + }; +} + +function clampNumber(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 735656ed10..06ee9c646f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,15 +96,21 @@ export { onLaunchFailure } from "./launch-diagnostics.js"; export { resetAudioCueLedger } from "./audio.js"; export type { ILaunchFailure, LaunchFailureKind } from "./launch-diagnostics.js"; export type { IAssetLoader, IAssetLoaderOptions, ITextureOptions } from "./assets.js"; -export type { IAudioBusOptions, IAudioPlayOptions } from "./audio.js"; +export type { + IAudioBusOptions, + IAudioCompressorOptions, + IAudioPlayOptions, +} from "./audio.js"; /** * Route effects through a named audio bus. * @situation play a sound effect with a volume bus * @situation mute or adjust a category of game audio * @situation keep a gunshot audible at 20 metres by tuning positional falloff + * @situation hold a loud mix below clipping with a compressor on the bus * @situation play cannon, wave, and ship sound effects * @constraint create buses before playing clips and dispose them with the game * @constraint refDistance and rolloffFactor tune positional falloff and apply to playAt only + * @constraint compressor takes threshold, knee, ratio, attack and release from the game and takes effect on the bus sum * @supersedes new Audio( * @example const effects = new AudioBus({ camera }); */ @@ -919,6 +925,10 @@ export type { IGPUReadbackOptions, IGPUReadbackSample } from "./gpu-readback.js" * @situation trail dust, exhaust, or spray behind a moving object * @situation emit cannon smoke and muzzle flash particles * @situation fire a cannonball projectile with cannon smoke particles + * @situation splash spray droplets with lifetime and gravity + * @situation spawn water droplets, mist, or foam bubbles above a fluid surface + * @situation falling snowflakes, rain or ash around the player that thicken into a windy storm or blizzard + * @situation kick up a spray of powder snow or dust where a foot or a ball lands * @constraint geometry, color, and timing remain supplied by the game * @example const particles = new GPUParticles3D(particleOptions); */ @@ -940,6 +950,29 @@ export { GPUParticles3D } from "./particles.js"; */ export { FluidField2D } from "./fluid-field.js"; export type { IFluidFieldOptions, IFluidFieldSampler, IFluidFieldVector2 } from "./fluid-field.js"; +/** + * Simulate liquid as GPU particles (pour, splash, dam break, waterfall) and expose positions, a density volume and a surface height while the game owns every look. + * @situation pour, splash, or dam-break water that fills a container and flows around obstacles + * @situation simulate water or another liquid as particles in a fluid simulation + * @situation drop a ball or box into liquid and let it displace and float on the water + * @situation emit a stream or waterfall of particle fluid and drain it somewhere else + * @situation sample particle-fluid density or surface height in a game-owned render node + * @constraint add the fluid through `ctx.add` so renderer attachment, fixed-step dispatch, and release are automatic + * @constraint a renderer without WebGPU compute throws a named error at attach; it never draws nothing + * @constraint `emit` recycles the oldest slot once `capacity` slots have been used; `fill` stops at capacity + * @constraint `sample` and `stats` read a throttled GPU copy and report `staleFrames`; they are never live + * @override iterations, viscosity, cohesion, vorticity, gravity and maxSpeed tune the solver without changing its pass order + * @example const water = new FluidParticles3D({ capacity: 6000 }); + * ctx.add(water); + * water.fill([-2.8, 0.1, -1.5], [-0.6, 3, 1.5]); + */ +export { FluidParticles3D } from "./fluid-particles.js"; +export type { + IFluidBounds, + IFluidCollider, + IFluidParticlesOptions, + IFluidParticlesStats, +} from "./fluid-particles.js"; /** * Propagate a disturbance across a patch of water surface and let it fade. * @situation make a splash or explosion ripple outward across water @@ -1015,6 +1048,7 @@ export { softCircleDataTexture } from "./textures.js"; /** * Pool travelling bullet-streak meshes for hitscan shots. * @situation show where a hitscan round went + * @situation show each round a weapon fires, one tracer per trigger press * @situation draw incoming fire without spawning projectiles * @constraint the surface comes from the game; pooling, travel, and fading belong to the engine * @constraint update once per frame and dispose with the owning scene @@ -1167,6 +1201,7 @@ export type { * @situation delay an enemy patrol transition * @situation run a callback every simulation tick * @situation tween a numeric property with a game-owned curve + * @situation a countdown timer: end the level when its time limit runs out * @alias tower defense game * @alias spawn waves * @constraint dispose returned handles when the owning scene exits diff --git a/packages/core/src/render/virtual-shadow.ts b/packages/core/src/render/virtual-shadow.ts index 000ccf3505..23b9b26f6f 100644 --- a/packages/core/src/render/virtual-shadow.ts +++ b/packages/core/src/render/virtual-shadow.ts @@ -16,6 +16,7 @@ import { abs, and, float, + getShadowMaterial, max, min, positionWorld, @@ -176,7 +177,7 @@ export interface IVirtualShadowOptions { /** * Draw every level past the finest one with less geometry than the main pass would, default true. * - * Two defaults, both Unreal's and both applied in one place — the traverse a level render already + * Two defaults, both Unreal's and both applied in one place — the pass a level render already * makes over the casters in its window, where each of the two is a change to the object three * draws from and is put back the moment that render is over: * @@ -417,6 +418,39 @@ const POOL_STRIDE = 7; const MAX_MASS_WINDOW_WIDTHS = 1; const POOL_CASTERS = 1 << 0; +/** + * A mesh in the caster table, with the two channels the probe reads off the object rather than off + * the geometry: the bounds three's own cull would use, which `Mesh` does not declare, and the + * `casterPrewarmOwed` flag `WorldCells` writes (see `SharedBatch.awaitPrewarmDraw`). + */ +interface ICasterMesh extends Mesh { + boundingBox?: Box3 | null; + boundingSphere?: Sphere | null; + casterPrewarmOwed?: boolean; + chunkShadowProxy?: boolean; + casterMinDiameter?: number; + computeBoundingBox(): void; + computeBoundingSphere(): void; +} + +/** + * One caster's memoised world values, and the inputs they were derived from. + * + * The key is the world matrix plus the local sphere and the local box's two Y values — everything + * `Sphere.applyMatrix4` and `Box3.applyMatrix4` read, and nothing else. Twenty-two doubles compare + * against the pair of matrix applies they replace, and a key that matches cannot have a different + * answer, so this is a memo on the inputs rather than a guess about whether the mesh moved. A + * caster under a frozen subtree, a streamed cell nobody touched and a walking stag are therefore + * the same case: whichever of them changed its world matrix is recomputed, and the rest are not. + * + * A fresh slot is all zeros, which no world matrix can be — its last element is 1 — so the first + * render of a caster always misses and always computes. + */ +const CASTER_KEY_STRIDE = 22; +/** World centre and radius, then the box's world height range: what the pool row is written from. */ +const CASTER_WORLD_STRIDE = 6; +const CASTER_STRIDE = CASTER_KEY_STRIDE + CASTER_WORLD_STRIDE; + /** A placeholder light per level: the stock shadow node reads position and target from it. */ class LevelLight extends Object3D { readonly target = new Object3D(); @@ -529,6 +563,12 @@ const _direction = new Vector3(); const _center = new Vector3(); const _sphere = new Sphere(); const _box = new Box3(); +const _size = new Vector3(); + +/** What a caster is doing as far as a shadow level is concerned: 1 visible, 2 casting. */ +function casterFlag(mesh: { castShadow?: boolean; visible?: boolean }): number { + return (mesh.visible === true ? 1 : 0) | (mesh.castShadow === true ? 2 : 0); +} /** The per-level entry of a scalar-or-array option, the last entry standing in for the rest. */ function at(values: readonly number[], level: number): number { @@ -685,12 +725,51 @@ export class VirtualShadowNode extends ShadowBaseNode { /** 1 when each level derives its own light-space depth, 0 when `lightDistance`/`depthRange` pin it. */ #autoDepth = true; /** - * Every shadow-relevant world sphere one level render collected, five numbers each. The depth - * derivation reads it in two passes after the one traverse that filled it, and it is reused + * Every shadow-relevant world sphere one level render collected, seven numbers each. The depth + * derivation reads it in two passes after the one pass that filled it, and it is reused * between renders, so a level render allocates nothing for it. */ #pool = new Float64Array(POOL_STRIDE * 256); #poolCount = 0; + /** + * The meshes under the light's root, in the order the tree walks them. Rebuilt only when the tree + * itself changed (see `#ensureCasters`); `castShadow`, the layers and `visible` are re-read on + * every render, because a game flips those without touching the tree. + */ + #casterTable: ICasterMesh[] = []; + /** Every object the table was built over, so its tree listeners can come off on the next build. */ + #casterObjects: Object3D[] = []; + /** The root `#casterTable` was walked from; a re-parented light changes it. */ + #casterRoot: Object3D | null = null; + /** Set by `childadded` / `childremoved`, which three dispatches on the object that changed. */ + #casterStale = false; + /** The memo described by `CASTER_KEY_STRIDE`: per caster, the key it was computed from and the answer. */ + #casterMemo = new Float64Array(CASTER_STRIDE * 128); + /** + * What `#pollCasters` last read off each table entry: 1 for `visible`, 2 for `castShadow`. Sized and + * filled by `#ensureCasters`, so the first poll after a build reports what the build already saw. + */ + #casterFlags = new Uint8Array(0); + /** + * `childadded` and `childremoved` on every object the table was built over, which is how the table + * learns the tree changed without walking it to find out. Shared by every object so the build + * allocates one listener rather than one per mesh. + * + * The change is also an ask, not only a rebuild: a level that keeps its map across a caster + * arriving or leaving keeps the shadow of geometry the world no longer holds, which is the ghost a + * camera jump leaves behind when the cells under it were evicted. Marking the table stale only + * decides what the *next* render reads, and a level that never renders is the whole problem. So the + * child is measured and the levels covering it are asked, on the same event. + */ + #onTreeChanged = (event: { child?: Object3D; type?: string }): void => { + this.#casterStale = true; + const child = event.child; + if (child === undefined) return; + // A removed caster's world matrix is the one it last drew with — the one the stale maps hold — + // so it is read as it stands. An added one has not been composed into its parent yet. + if (event.type !== "childremoved") child.updateWorldMatrix(true, false); + this.#askAboutCaster(child); + }; /** Casters the current level's size gate hid, restored the moment that render is over. */ #hidden: Object3D[] = []; /** Alpha casters `#probe` took out of the coarse levels' shadow pass, with `castShadow` to restore. */ @@ -964,8 +1043,8 @@ export class VirtualShadowNode extends ShadowBaseNode { /** * The topmost object above the light: the scene it is lit in, whatever the game nested it under. - * Walked once per level render, so a `Daylight` group between the sun and the world is not the - * boundary that hides every tree from the span below. + * Walked per level render, so a `Daylight` group between the sun and the world is not the boundary + * that hides every tree from the span below. */ #root(): Object3D { let root = this.light as Object3D; @@ -974,12 +1053,147 @@ export class VirtualShadowNode extends ShadowBaseNode { } /** - * One traverse for four automatic fixes: the world bounding sphere of every shadow-relevant mesh - * goes into the pool, every caster too small for this level's texel grid is hidden until the - * level's render is over, every level past the finest one draws less of what it can (see - * `shadowLodBias`), and the level's caster granularity is chosen from the two bills it could pay. - * Mirrors the sphere three's own cull reads, so the gate drops exactly the volumes that cull would - * have kept and the depth below measures the same boxes it will draw. + * The caster set this render reads, rebuilt only when the tree it came from changed. + * + * `childadded` and `childremoved` are three's own events, dispatched on the parent that gained or + * lost the object, so this is told when the tree changed rather than guessing from a version or a + * child count — a streamed cell admitted and an evicted one in the same frame cancel out in any + * checksum, and the shadow a level then derives is a shadow cast by geometry it never saw. A + * re-parented light changes the root, which is compared outright. Everything a game can change + * about a caster *without* touching the tree — `visible`, `castShadow`, the layers, its material, + * and where it stands — is re-read on the render itself rather than held here. + * + * The listeners come off the previous walk before the new one starts, so an object that has left + * the tree cannot keep marking this stale for whatever it is parented to now. + */ + #ensureCasters(): void { + const root = this.#root(); + if (this.#casterRoot === root && this.#casterStale === false) return; + this.#casterRoot = root; + this.#casterStale = false; + for (const object of this.#casterObjects) { + object.removeEventListener("childadded", this.#onTreeChanged); + object.removeEventListener("childremoved", this.#onTreeChanged); + } + this.#casterObjects.length = 0; + this.#casterTable.length = 0; + root.traverse((object) => { + object.addEventListener("childadded", this.#onTreeChanged); + object.addEventListener("childremoved", this.#onTreeChanged); + this.#casterObjects.push(object); + if ((object as { isMesh?: boolean }).isMesh === true) + this.#casterTable.push(object as ICasterMesh); + }); + const wanted = this.#casterTable.length * CASTER_STRIDE; + if (wanted > this.#casterMemo.length) { + let size = this.#casterMemo.length; + while (size < wanted) size *= 2; + const grown = new Float64Array(size); + grown.set(this.#casterMemo); + this.#casterMemo = grown; + } + // Filled from what the build just read, so the first `#pollCasters` after it reports only what + // changed since — a caster that arrived asked already, on the event that added it. + this.#casterFlags = new Uint8Array(this.#casterTable.length); + for (let entry = 0; entry < this.#casterTable.length; entry += 1) { + const mesh = this.#casterTable[entry]; + if (mesh === undefined) continue; + this.#casterFlags[entry] = casterFlag(mesh); + } + } + + /** + * The world box a changed caster occupies, in the shape `invalidateRegion` takes. Its own bounds + * where it publishes any — a world cluster's sphere is its whole grid square, which is the only + * thing that measures where an evicted cell's instances stood — and its geometry's otherwise. A + * caster that measures as nothing (a bare group) has no region to name, and asks every level. + */ + #casterBounds(object: Object3D): IBoundsLike | undefined { + const mesh = object as ICasterMesh; + const own = mesh.isMesh === true ? mesh.boundingSphere : undefined; + const sphere = own ?? (mesh as Partial).geometry?.boundingSphere; + if (sphere === null || sphere === undefined) { + // A group, or a mesh whose geometry nobody measured: the long way, once per change. + _box.setFromObject(object, false); + if (_box.isEmpty()) return undefined; + } else { + _sphere.copy(sphere).applyMatrix4(mesh.matrixWorld); + // The sphere's *diameter* is the box, not its radius: a half-radius box stops at the sphere's + // own centre, so a caster straddling a level edge drew nothing of it into the level whose + // window the far half of the sphere reaches, and that level kept the shadow of geometry the + // world had just dropped. + _box.setFromCenterAndSize(_sphere.center, _size.setScalar(_sphere.radius * 2)); + } + return { + max: { x: _box.max.x, y: _box.max.y, z: _box.max.z }, + min: { x: _box.min.x, y: _box.min.y, z: _box.min.z }, + }; + } + + /** Ask the levels covering one changed caster to redraw. */ + #askAboutCaster(object: Object3D): void { + if (this.#levels.length === 0) return; + // Nothing that casts: the level lights this node parents into the scene are `Object3D`s with a + // shadow slot and no geometry, and asking for all three levels redraws on their arrival. A group + // is asked — its children arrive on their own events, so a group added empty and filled after is + // still asked about each mesh that lands in it. + if ((object as { isMesh?: boolean }).isMesh !== true && object.children.length === 0) return; + const bounds = this.#casterBounds(object); + if (bounds === undefined) this.invalidateAll(); + else this.invalidateRegion(bounds); + } + + /** + * What a game can change about a caster without touching the tree: its `visible` and its + * `castShadow`. A level that keeps its map across either keeps the shadow of geometry that casts + * nothing any more, which is the same ghost an eviction leaves and just as wrong. Both are read + * here, per frame, off the memoised table — one flag pair per caster, no matrix and no bounds — + * and a change asks for the levels covering that caster. The table is what cut 2 made cheap; this + * reads it, it does not walk the world. + * + * It runs before `#probe`'s own writes (`#restoreHidden` and the mover exclusion both set these + * flags and put them back inside the same call), so what it compares is what the game last said. + */ + #pollCasters(): void { + const table = this.#casterTable; + const flags = this.#casterFlags; + if (flags.length !== table.length) return; + for (let entry = 0; entry < table.length; entry += 1) { + const mesh = table[entry]; + if (mesh === undefined) continue; + const flag = casterFlag(mesh); + if (flags[entry] === flag) continue; + flags[entry] = flag; + this.#askAboutCaster(mesh); + } + } + + /** Drops the table and its listeners, which is what a disposed node owes the scene it read. */ + #dropCasters(): void { + for (const object of this.#casterObjects) { + object.removeEventListener("childadded", this.#onTreeChanged); + object.removeEventListener("childremoved", this.#onTreeChanged); + } + this.#casterObjects.length = 0; + this.#casterTable.length = 0; + this.#casterFlags = new Uint8Array(0); + this.#casterRoot = null; + this.#casterStale = false; + } + + /** + * One pass over the caster set for four automatic fixes: the world bounding sphere of every + * shadow-relevant mesh goes into the pool, every caster too small for this level's texel grid is + * hidden until the level's render is over, every level past the finest one draws less of what it + * can (see `shadowLodBias`), and the level's caster granularity is chosen from the two bills it + * could pay. Mirrors the sphere three's own cull reads, so the gate drops exactly the volumes that + * cull would have kept and the depth below measures the same boxes it will draw. + * + * It is a pass over the memoised caster set rather than a walk of the world: the set is rebuilt + * only when the tree changed (see `#ensureCasters`) and each caster's world sphere is derived + * again only when the numbers it is derived from changed (see `CASTER_KEY_STRIDE`). On the + * reference game's map-walk that walk was 3.9% of the CPU render phase, spent re-deriving a caster + * set that a walking camera does not change. * * Both halves of every key are on the caster-only layers, so the bill is counted off the meshes * themselves rather than configured: the clusters whose square the level's window holds, against @@ -1009,40 +1223,40 @@ export class VirtualShadowNode extends ShadowBaseNode { this.#hidden.length = 0; this.#alphaHidden.length = 0; this.#coarsened.length = 0; - // Past the finest level, every level draws less of what it can. Set on the world here, in the - // traverse that is already walking this window's casters, and put back by `#restoreHidden` when + // Past the finest level, every level draws less of what it can. Set on the world here, on the + // pass that is already reading this window's casters, and put back by `#restoreHidden` when // the render is over — so the mover maps and the main pass see every mesh as it was authored. const biased = index >= 1 && this.options.shadowLodBias; let clusterDraws = 0; let wideDraws = 0; - // The level's bill by kind, tallied on the same walk and summed by the chosen halves once the + // The level's bill by kind, tallied on the same pass and summed by the chosen halves once the // choice is known. Numbers, not objects: a frame allocates nothing for this. let nCluster = 0; let nWide = 0; let nSmall = 0; let nChunk = 0; + let nChunkBoth = 0; let nLayer0 = 0; // A caster still owed its prewarm draw (see `SharedBatch.awaitPrewarmDraw`): its shadow-context // node is built by the next shadow render that draws it, so the level that draws it here is - // what moves the build off the walk. Both layers are therefore rendered while one is owed, since + // what moves the build off the render. Both layers are therefore rendered while one is owed, since // the choice below would leave the other half's casters unbuilt until the level's own window // move. The flag is on the mesh rather than a module-level signal because the world chunk does // not import this one — the same channel as the `casterInstanceScale` read below. let prewarming = false; - this.#root().traverse((object) => { - if (object.visible !== true) return; - if ((object as { isMesh?: boolean }).isMesh !== true) return; - const mesh = object as Mesh & { - boundingBox?: Box3 | null; - boundingSphere?: Sphere | null; - casterPrewarmOwed?: boolean; - computeBoundingBox(): void; - computeBoundingSphere(): void; - }; + this.#ensureCasters(); + const table = this.#casterTable; + const memo = this.#casterMemo; + for (let entry = 0; entry < table.length; entry += 1) { + const mesh = table[entry]; + if (mesh === undefined) continue; + if (mesh.visible !== true) continue; + // A retained-part proxy applies this gate to each original source at the draw boundary. + if (mesh.chunkShadowProxy === true) mesh.casterMinDiameter = gate; // Read before the gates below: the level is going to render both caster layers either way, and // a caster too small for this level's texels is still one the prewarm owes a draw. if (mesh.casterPrewarmOwed === true) prewarming = true; - // What a coarse level submits less of, in the same walk: an alpha caster casts nothing — its + // What a coarse level submits less of, in the same pass: an alpha caster casts nothing — its // cutout is its own texture and the level's texel is wider than the card — and a chained mesh // is submitted with the chain's coarsest geometry, which is metres per texel where LOD0 is // needles. Both are the renderer's own business to submit nothing for and the coarse geometry @@ -1060,7 +1274,7 @@ export class VirtualShadowNode extends ShadowBaseNode { if (isAlphaCaster(mesh.material)) { mesh.castShadow = false; this.#alphaHidden.push(mesh); - return; + continue; } const chain = lodChainOf(mesh.geometry); const coarsest = chain?.levels[chain.levels.length - 1]; @@ -1082,7 +1296,7 @@ export class VirtualShadowNode extends ShadowBaseNode { } else { sphere = own; } - if (sphere === null || sphere === undefined) return; + if (sphere === null || sphere === undefined) continue; // The box too, and for the same reason: a sphere has to cover a 128 m tile's diagonal, so its // height range is the tile's diagonal rather than the tile's relief, and the window's ground // is then read as 180 m of cliff. @@ -1097,12 +1311,65 @@ export class VirtualShadowNode extends ShadowBaseNode { } else { box = ownBox; } - _sphere.copy(sphere).applyMatrix4(mesh.matrixWorld); - if (box === null || box === undefined) { - _box.min.set(_sphere.center.x, _sphere.center.y - _sphere.radius, _sphere.center.z); - _box.max.set(_sphere.center.x, _sphere.center.y + _sphere.radius, _sphere.center.z); + // A mesh with no box of its own stands in its world sphere, which the world matrix and the + // sphere already determine, so its two placeholders are never compared against anything. + const boxYLow = box === null || box === undefined ? 0 : box.min.y; + const boxYHigh = box === null || box === undefined ? 0 : box.max.y; + // Two matrix applies per caster per render is what this memo replaces, and what it is keyed + // on is what those two read: the world matrix, the local sphere, and the local box's height + // range. A key that matches cannot have a different answer, so a mesh under a frozen subtree + // and a stag that walked all skip and compute respectively — with nothing in the memo deciding + // which is which. Only the box's Y is kept: `#probe` reads no other component of it, and the + // two world spheres it does read are put back whole. + const memoAt = entry * CASTER_STRIDE; + const elements = mesh.matrixWorld.elements; + let memoised = true; + for (let e = 0; e < 16; e += 1) { + if (memo[memoAt + e] !== elements[e]) { + memoised = false; + break; + } + } + if ( + memoised && + (memo[memoAt + 16] !== sphere.center.x || + memo[memoAt + 17] !== sphere.center.y || + memo[memoAt + 18] !== sphere.center.z || + memo[memoAt + 19] !== sphere.radius || + memo[memoAt + 20] !== boxYLow || + memo[memoAt + 21] !== boxYHigh) + ) + memoised = false; + if (memoised) { + _sphere.center.set( + memo[memoAt + 22] as number, + memo[memoAt + 23] as number, + memo[memoAt + 24] as number, + ); + _sphere.radius = memo[memoAt + 25] as number; + _box.min.y = memo[memoAt + 26] as number; + _box.max.y = memo[memoAt + 27] as number; } else { - _box.copy(box).applyMatrix4(mesh.matrixWorld); + _sphere.copy(sphere).applyMatrix4(mesh.matrixWorld); + if (box === null || box === undefined) { + _box.min.set(_sphere.center.x, _sphere.center.y - _sphere.radius, _sphere.center.z); + _box.max.set(_sphere.center.x, _sphere.center.y + _sphere.radius, _sphere.center.z); + } else { + _box.copy(box).applyMatrix4(mesh.matrixWorld); + } + for (let e = 0; e < 16; e += 1) memo[memoAt + e] = elements[e] as number; + memo[memoAt + 16] = sphere.center.x; + memo[memoAt + 17] = sphere.center.y; + memo[memoAt + 18] = sphere.center.z; + memo[memoAt + 19] = sphere.radius; + memo[memoAt + 20] = boxYLow; + memo[memoAt + 21] = boxYHigh; + memo[memoAt + 22] = _sphere.center.x; + memo[memoAt + 23] = _sphere.center.y; + memo[memoAt + 24] = _sphere.center.z; + memo[memoAt + 25] = _sphere.radius; + memo[memoAt + 26] = _box.min.y; + memo[memoAt + 27] = _box.max.y; } const at = this.#poolCount * POOL_STRIDE; if (at + POOL_STRIDE > this.#pool.length) { @@ -1133,35 +1400,42 @@ export class VirtualShadowNode extends ShadowBaseNode { // while the replay still drew it this frame. See WorldCells `bundled`. if ( mesh.castShadow && + mesh.chunkShadowProxy !== true && mesh.userData.tnBundled !== true && instanceDiameter(mesh, _sphere.radius) < gate ) { - object.visible = false; - this.#hidden.push(object); + mesh.visible = false; + this.#hidden.push(mesh); gateHidden += 1; - return; + continue; } // What this level would submit from each half, counted as it goes: a hidden caster submits // nothing, so it is not in either bill. A key-wide mesh spans the whole ring, so the level // keeps it whether or not the window reaches it — the cull, not the choice, decides that one. - if ((mesh.layers.mask & clusterLayer) !== 0) { + // A retained chunk proxy belongs to both halves and costs the same whichever one is chosen. + const bothHalves = + (mesh.layers.mask & (clusterLayer | wideLayer)) === (clusterLayer | wideLayer); + if (!bothHalves && (mesh.layers.mask & clusterLayer) !== 0) { if (Math.hypot(_sphere.center.x - centre.x, _sphere.center.z - centre.z) <= window) clusterDraws += 1; - } else if ((mesh.layers.mask & wideLayer) !== 0) { + } else if (!bothHalves && (mesh.layers.mask & wideLayer) !== 0) { wideDraws += 1; } // The same bill by kind, for what the level's chosen layers end up submitting. Only a caster - // counts, and the merged per-chunk proxies — named `-shadow`, on the cluster layer — + // counts, and the per-chunk proxies — named `-shadow`, merged ones on the cluster half, + // retained ones on both — // are bucketed apart from the batches they stand in for so they are not counted twice. The // small layer and layer 0's own casters take no part in the cluster/wide choice above. if (mesh.castShadow === true) { - if (mesh.name.endsWith("-shadow")) nChunk += 1; - else if ((mesh.layers.mask & smallLayer) !== 0) nSmall += 1; + if (mesh.name.endsWith("-shadow")) { + if (bothHalves) nChunkBoth += 1; + else nChunk += 1; + } else if ((mesh.layers.mask & smallLayer) !== 0) nSmall += 1; else if ((mesh.layers.mask & clusterLayer) !== 0) nCluster += 1; else if ((mesh.layers.mask & wideLayer) !== 0) nWide += 1; else if ((mesh.layers.mask & 1) !== 0) nLayer0 += 1; } - }); + } // One of the two caster layers, never both, and the cheaper bill: clusters when the squares the // window covers are fewer than the keys waiting on the wide layer, one mesh per key when they are // not. A fraction of the ring's radius was the rule before, and a 192 m window over a 640 m ring @@ -1169,7 +1443,7 @@ export class VirtualShadowNode extends ShadowBaseNode { // because the terrain and everything else in the world casts from it, and a world with no caster // batch at all counts zero of both and takes the wide layer for nothing. A level rendering while // a caster is still owed its prewarm draw takes both, which is the only way the half it did not - // pick gets its node built before the walk. + // pick gets its node built before the pass. const clustered = clusterDraws < wideDraws; level.shadow.camera.layers.set(0); level.shadow.camera.layers.enable( @@ -1184,13 +1458,13 @@ export class VirtualShadowNode extends ShadowBaseNode { if (index === 0 || prewarming) level.shadow.camera.layers.enable(VIRTUAL_SHADOW_SMALL_CASTER_LAYER); // The bill the chosen layers will actually submit: a half that was not picked submits none of - // its meshes, and the proxies on the cluster layer go with that half. + // its meshes. Merged chunk proxies go with the cluster half; retained ones submit either way. const chosenCluster = clustered || prewarming; const chosenWide = !clustered || prewarming; const chosenSmall = index === 0 || prewarming; const by = stat.drawsBy; by.cluster = chosenCluster ? nCluster : 0; - by.chunkProxy = chosenCluster ? nChunk : 0; + by.chunkProxy = nChunkBoth + (chosenCluster ? nChunk : 0); by.wide = chosenWide ? nWide : 0; by.small = chosenSmall ? nSmall : 0; by.layer0 = nLayer0; @@ -1350,7 +1624,7 @@ export class VirtualShadowNode extends ShadowBaseNode { * * The u/v box is what bounds the window sideways, so the depth is the only free axis, and every * object that overlaps that box has already been found: the pool is the whole candidate set and - * this pass over it costs no second traverse. The height comes from each box rather than each + * this pass over it costs no second walk of the world. The height comes from each box rather than each * sphere, because a sphere has to cover a 128 m tile's diagonal and would report 180 m of cliff * where the tile has 15 m of it. */ @@ -1445,6 +1719,7 @@ export class VirtualShadowNode extends ShadowBaseNode { if (this.#initialised) return; this.#initialised = true; const source = this.light as DirectionalLight; + let shadowColor: ReturnType["colorNode"] = null; this.options.clipExtents.forEach((extent, index) => { const levelShadow = source.shadow.clone(); syncShadowSettings(source.shadow, levelShadow); @@ -1477,6 +1752,13 @@ export class VirtualShadowNode extends ShadowBaseNode { moverShadow.camera.layers.set(VIRTUAL_SHADOW_MOVER_LAYER); const light = new LevelLight(levelShadow); light.name = `VirtualShadowLevel${String(index)}`; + // quality-allow: the stock shadow node reads only position, target and shadow off its light + const node = shadow(light as unknown as DirectionalLight, levelShadow); + // Stock shadow colours are identical, but their node identities split the shader cache. + // Share only that constant; each level keeps its material, camera and render bindings. + const material = getShadowMaterial(node.light); + shadowColor ??= material.colorNode; + material.colorNode = shadowColor; this.#levels.push({ appliedStep: at(this.options.refreshStep, index), costMs: 0, @@ -1507,8 +1789,7 @@ export class VirtualShadowNode extends ShadowBaseNode { // One placeholder light serves both maps: the stock node reads only its placement. // quality-allow: the stock shadow node reads only position, target and shadow off its light moverNode: shadow(light as unknown as DirectionalLight, moverShadow), - // quality-allow: the stock shadow node reads only position, target and shadow off its light - node: shadow(light as unknown as DirectionalLight, levelShadow), + node, shadow: levelShadow, }); }); @@ -1657,6 +1938,10 @@ export class VirtualShadowNode extends ShadowBaseNode { this.#centerV.value = this.clipmap.centerLight.v; this.#basisU.value.set(this.clipmap.basisU.x, this.clipmap.basisU.y, this.clipmap.basisU.z); this.#basisV.value.set(this.clipmap.basisV.x, this.clipmap.basisV.y, this.clipmap.basisV.z); + // A caster that stopped casting, or stopped being visible, changed what the levels hold without + // changing the tree, so nothing else on the frame would say so. Asked here, before this frame's + // own writes to those flags, which is what `#probe` and the mover exclusion below both do. + this.#pollCasters(); // Movers leave the cached maps and are drawn into the mover maps below. A child attached // after `trackCaster` — a loaded mesh under a placeholder group — picks up the layer here. @@ -1788,7 +2073,7 @@ export class VirtualShadowNode extends ShadowBaseNode { } const stat = { deferred: due && !grant ? 1 : 0, - // Filled by `#probe` on the frame's granted render, from the meshes it walked; a level + // Filled by `#probe` on the frame's granted render, from the meshes it read; a level // that keeps its map submits no caster draws of its own, so it reports none. draws: 0, drawsBy: { chunkProxy: 0, cluster: 0, layer0: 0, small: 0, wide: 0 }, @@ -1820,7 +2105,7 @@ export class VirtualShadowNode extends ShadowBaseNode { level.light.target.position.set(centre.x, centre.y, centre.z); level.light.target.updateMatrixWorld(true); if (grant) { - // The two automatic fixes, off one traverse of the world this window can see: the depth + // The two automatic fixes, off one pass over the world this window can see: the depth // the level needs to cover what can actually shadow it, and the casters too small for its // texels. Both are undone the moment the render is over — by `#restoreHidden`, which the // mover maps and the main pass both need back. @@ -1900,6 +2185,7 @@ export class VirtualShadowNode extends ShadowBaseNode { override dispose(): void { this.#regions.length = 0; + this.#dropCasters(); for (const level of this.#levels) { level.light.removeFromParent(); level.light.target.removeFromParent(); diff --git a/packages/core/src/snow-field.ts b/packages/core/src/snow-field.ts new file mode 100644 index 0000000000..2622abe651 --- /dev/null +++ b/packages/core/src/snow-field.ts @@ -0,0 +1,744 @@ +import type { Vector3 } from "three"; +import { unionBounds } from "./world-region.js"; +import type { Heightfield, IHeightfieldRegionBounds } from "./world.js"; + +/** + * What one contact footprint wants at a local offset, sampled in the contact's own frame. + * + * A footprint describes a shape, never a load: the same sole sinks deeper under a heavier + * contact. Every field is 0..1 except `relief`, which is metres. + */ +export interface ISnowFootprintSample { + /** 0..1 fraction of the footprint touching snow here; erases displaced bank and adds compaction. */ + readonly coverage: number; + /** 0..1 relative depth at this offset, so a toe can press deeper than a heel. */ + readonly shape: number; + /** Metres of tread relief subtracted from the impression at this offset. */ + readonly relief: number; + /** 0..1 raised snow pushed out beside the contact at this offset. */ + readonly bank: number; + /** 0..1 surface disturbance at this offset, for render shading and game queries. */ + readonly disturbance: number; +} + +/** A game-authored contact shape: a boot sole, a tyre, a hull, a sphere. */ +export interface ISnowFootprint { + /** Half-width of the square sampled around the contact centre, in metres. */ + readonly extent: number; + /** Local contact-space sample; +z runs forward along the contact's orientation. */ + readonly sample: (x: number, z: number) => ISnowFootprintSample; +} + +/** One contact between a supported object and the snow surface. */ +export interface ISnowContact { + readonly x: number; + readonly z: number; + /** Game-authored imprint shape. */ + readonly footprint: ISnowFootprint; + /** Supported contact area in square metres. Pressure is load over area, never a fixed constant. */ + readonly area: number; + /** Normal load in newtons. */ + readonly load: number; + /** Orientation about +y in radians. Default 0. */ + readonly rotation?: number; + /** 0..1 snow hardness. Defaults to the field's hardness. */ + readonly hardness?: number; + /** Seconds the contact is applied. Defaults to the field's response time. */ + readonly duration?: number; +} + +/** The four snow channels at one world position. */ +export interface ISnowFieldSample { + readonly indent: number; + readonly bank: number; + readonly compaction: number; + readonly disturbance: number; +} + +export interface ISnowFieldOptions { + /** The canonical terrain. Snow composes onto it; there is no second terrain representation. */ + readonly field: Heightfield; + /** Snow depth in metres. Zero is bare ground. Default 0.28. */ + readonly depth?: number; + /** 0..1 snow hardness used by contacts that do not override it. Default 0.3. */ + readonly hardness?: number; + /** Fraction of its target impression a contact reaches in one response time. Default 0.9. */ + readonly yieldFraction?: number; + /** Metres of raised bank allowed beside a contact. Default 0.085. */ + readonly maxBank?: number; + /** Seconds a fully-loaded contact takes to reach its full impression. Default 0.1. */ + readonly responseTime?: number; +} + +export interface ISnowDiscFootprintOptions { + /** 0..1 width of the falloff at the disc's edge, as a fraction of the radius. Default 0.25. */ + readonly softness?: number; +} + +/** Resistance of snow to a contact at hardness 0, in pascals. Approximate, not a calibrated law. */ +const PRESSURE_RESISTANCE_BASE = 18_000; +/** Extra resistance a fully hard contact meets at hardness 1, in pascals. */ +const PRESSURE_RESISTANCE_HARDNESS = 140_000; +/** Fraction of the snow depth a contact reaches before it is fully loaded. */ +const PRESSURE_DEPTH_FLOOR = 0.16; +/** Deepest fraction of the snow depth any single contact can reach. */ +const MAX_PENETRATION_FRACTION = 0.82; +/** Deepest fraction of the snow depth indentation may reach before a contact stops compacting. */ +const MAX_INDENT_FRACTION = 0.88; +/** Fraction of a contact's penetration that becomes raised bank height. */ +const BANK_LOAD_SCALE = 0.38; +/** Below these channel values a recovered cell is fully buried and leaves the active set. */ +const RECOVERED_INDENT = 0.0004; +const RECOVERED_COMPACTION = 0.008; +/** Wind decay per metre per second of wind, matched to the source study's feel. */ +const WIND_EROSION = 0.00065; +/** Deposition burial rate per metre of fill. */ +const DEPOSITION_BURIAL = 26; +/** Hard ceiling on cells one contact may span, so a malformed extent fails instead of allocating. */ +const MAX_CONTACT_CELLS = 1 << 20; + +const EMPTY_SAMPLE: ISnowFootprintSample = Object.freeze({ + bank: 0, + coverage: 0, + disturbance: 0, + relief: 0, + shape: 0, +}); + +/** The single-cell window a grid index covers. */ +function cellBounds(index: number, columns: number): IHeightfieldRegionBounds { + const row = Math.floor(index / columns); + return { column: index - row * columns, columns: 1, row, rows: 1 }; +} + +/** The smallest window covering both inputs. */ + +function finite(value: number, name: string): number { + if (!Number.isFinite(value)) throw new Error(`SnowField ${name} must be finite.`); + return value; +} + +function nonNegative(value: number, name: string): number { + finite(value, name); + if (value < 0) throw new Error(`SnowField ${name} must not be negative.`); + return value; +} + +function positive(value: number, name: string): number { + nonNegative(value, name); + if (value === 0) throw new Error(`SnowField ${name} must be greater than zero.`); + return value; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function lerp(from: number, to: number, amount: number): number { + return from + (to - from) * amount; +} + +function smoothstep(edge0: number, edge1: number, value: number): number { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function unit(sample: ISnowFootprintSample, name: string): ISnowFootprintSample { + const coverage = finite(sample.coverage, `${name}.coverage`); + const shape = finite(sample.shape, `${name}.shape`); + const relief = finite(sample.relief, `${name}.relief`); + const bank = finite(sample.bank, `${name}.bank`); + const disturbance = finite(sample.disturbance, `${name}.disturbance`); + if (relief < 0) throw new Error(`SnowField ${name}.relief must not be negative.`); + return { + bank: clamp(bank, 0, 1), + coverage: clamp(coverage, 0, 1), + disturbance: clamp(disturbance, 0, 1), + relief, + shape: clamp(shape, 0, 1), + }; +} + +/** + * A circular contact footprint, sized from a radius the caller measures. + * + * The shape a sphere, a ball, a wheel or a probe presses into snow, and the default the physics + * binding derives from a sphere's contact geometry. Flat inside the radius, eased over the + * softness band, with a raised rim just outside it. + * @situation press a ball, wheel or probe into snow + * @situation give a sphere a physically sized snow contact instead of a boot shape + * @constraint radius is metres; the footprint never grows with load, only deeper + * @override softness widens the eased edge without changing the contact radius + * @example const footprint = snowDiscFootprint(0.25); + */ +export function snowDiscFootprint( + radius: number, + options: ISnowDiscFootprintOptions = {}, +): ISnowFootprint { + positive(radius, "disc radius"); + const softness = clamp(finite(options.softness ?? 0.25, "disc softness"), 0.01, 1); + const inner = 1 - softness; + const outer = 1 + softness; + return { + extent: radius * outer, + sample: (x, z) => { + const distance = Math.hypot(x, z) / radius; + if (distance >= outer) return EMPTY_SAMPLE; + const coverage = distance <= inner ? 1 : 1 - smoothstep(inner, 1, distance); + const bank = distance > 1 ? Math.exp(-(((distance - 1) / (softness * 0.75)) ** 2)) : 0; + return { + bank, + coverage, + disturbance: Math.max(coverage, bank * 0.5), + relief: 0, + shape: 1, + }; + }, + }; +} + +/** + * Persistent snow deformation over one canonical heightfield. + * + * Snow keeps four channels per cell — indentation, displaced bank, compaction and disturbance — + * and writes the combined surface (`terrain + depth + bank - indent`) back into the heightfield + * it was given. Queries, rendered geometry and collider export therefore all read one surface, + * and no second terrain representation exists. + * + * The field is numeric only: it never chooses a boot, a tread, a particle, a material or a + * camera. Games supply terrain heights, the snow depth, the contact profile and the response + * coefficients; this class owns the storage, the load-dependent penetration and the bounded + * recovery. It is a heightfield approximation — not granular snow, displaced-volume + * conservation, avalanches, melting or a calibrated material law. + * @situation leave footprints, tracks and tyre ruts in snow that persist and fill in over time + * @situation let a pushed sphere carve a connected track and a dropped one settle into a crater + * @situation store snow deformation that rendered geometry and collision both read + * @situation reset a snowfield between rounds or change its depth at runtime + * @situation show how packed the snow is where people have walked or objects have rested + * @constraint the field composes onto a Heightfield; construct the terrain first and let this own the surface + * @constraint zero depth is bare ground: contacts register no indentation at all + * @constraint out-of-region heightAt and normalAt follow Heightfield's error contract; sample returns zeros + * @override depth, hardness, yieldFraction, maxBank and responseTime name the response coefficients + * @requires @threenative/core/world Heightfield as the canonical terrain and surface + * @example import { Heightfield } from "@threenative/core/world"; + * const terrain = Heightfield.fromSampler({ rows: 129, columns: 129, width: 64, depth: 64, origin: { x: 0, z: 0 }, sampleHeight: (x, z) => Math.sin(x * 0.1) * 0.5 }); + * const snow = new SnowField({ field: terrain, depth: 0.28, hardness: 0.3 }); + * snow.stamp({ x: 0, z: 0, area: 0.074, load: 784, duration: 0.1, footprint: snowDiscFootprint(0.12) }); + * snow.recover(1 / 60, 0.0012, 0.4); + */ +export class SnowField { + /** Canonical terrain and the surface snow composes onto. */ + readonly field: Heightfield; + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly cellDepth: number; + /** World x of column 0. */ + readonly minimumX: number; + /** World z of row 0. */ + readonly minimumZ: number; + /** 0..1 snow hardness used by contacts that do not override it. */ + hardness: number; + /** Fraction of its target impression a contact reaches in one response time. */ + readonly yieldFraction: number; + /** Metres of raised bank allowed beside a contact. */ + readonly maxBank: number; + /** Seconds a fully-loaded contact takes to reach its full impression. */ + readonly responseTime: number; + #depth: number; + readonly #base: Float32Array; + readonly #indent: Float32Array; + readonly #bank: Float32Array; + readonly #compaction: Float32Array; + readonly #disturbance: Float32Array; + readonly #active = new Set(); + #steps = 0; + #lastSink = 0; + #version = 0; + #dirty: IHeightfieldRegionBounds | undefined; + #untaken: IHeightfieldRegionBounds | undefined; + #scratch = new Float32Array(0); + + constructor(options: ISnowFieldOptions) { + const field = options.field; + if (typeof field?.updateHeights !== "function") + throw new Error("SnowField requires a Heightfield as its canonical terrain."); + this.field = field; + this.columns = field.columns; + this.rows = field.rows; + this.cellWidth = field.width / (field.columns - 1); + this.cellDepth = field.depth / (field.rows - 1); + this.minimumX = field.origin.x - field.width / 2; + this.minimumZ = field.origin.z - field.depth / 2; + this.#depth = nonNegative(options.depth ?? 0.28, "depth"); + this.hardness = clamp(finite(options.hardness ?? 0.3, "hardness"), 0, 1); + this.yieldFraction = clamp(finite(options.yieldFraction ?? 0.9, "yieldFraction"), 0, 1); + this.maxBank = nonNegative(options.maxBank ?? 0.085, "maxBank"); + this.responseTime = positive(options.responseTime ?? 0.1, "responseTime"); + this.#base = field.heights; + const cells = this.columns * this.rows; + this.#indent = new Float32Array(cells); + this.#bank = new Float32Array(cells); + this.#compaction = new Float32Array(cells); + this.#disturbance = new Float32Array(cells); + // The untouched surface already stands `depth` above the terrain, before any contact. + this.#flush({ column: 0, columns: this.columns, row: 0, rows: this.rows }); + } + + /** Snow depth in metres. Zero is bare ground. */ + get depth(): number { + return this.#depth; + } + + /** Contacts applied since construction or the last reset. */ + get steps(): number { + return this.#steps; + } + + /** Penetration the most recent contact reached, in metres. */ + get lastSink(): number { + return this.#lastSink; + } + + /** Cells still carrying deformation. Recovery drops a cell once it is fully buried. */ + get activeCells(): number { + return this.#active.size; + } + + /** Monotonic version, incremented once per batch that changed the canonical surface. */ + get version(): number { + return this.#version; + } + + /** Window of the canonical field the last change wrote, or undefined before the first change. */ + get dirtyRegion(): IHeightfieldRegionBounds | undefined { + return this.#dirty === undefined ? undefined : { ...this.#dirty }; + } + + /** + * Union of every window written since the previous call, then forgotten. + * + * One fixed step can write several windows — each contact, then recovery — and `dirtyRegion` + * keeps only the last, so a renderer that refreshes geometry once per drawn frame takes this + * instead. It has one owner: two consumers taking it would each miss the other's windows. + */ + takeDirtyRegion(): IHeightfieldRegionBounds | undefined { + const region = this.#untaken; + this.#untaken = undefined; + return region; + } + + /** Bytes retained by the four snow channels and the base terrain copy. */ + get memoryBytes(): number { + return (4 * this.#indent.length + this.#base.length) * Float32Array.BYTES_PER_ELEMENT; + } + + /** + * The four snow channels at a world position, bilinearly interpolated. + * + * Returns zeros outside the field rather than throwing, so a caller sweeping a broad area does + * not have to bounds-check first. `heightAt` keeps the strict contract. + */ + sample(x: number, z: number): ISnowFieldSample { + finite(x, "query x"); + finite(z, "query z"); + const column = (x - this.minimumX) / this.cellWidth; + const row = (z - this.minimumZ) / this.cellDepth; + if (column < 0 || row < 0 || column > this.columns - 1 || row > this.rows - 1) + return { bank: 0, compaction: 0, disturbance: 0, indent: 0 }; + const column0 = Math.floor(column); + const row0 = Math.floor(row); + const column1 = Math.min(this.columns - 1, column0 + 1); + const row1 = Math.min(this.rows - 1, row0 + 1); + const columnMix = column - column0; + const rowMix = row - row0; + const channel = (values: Float32Array): number => + lerp( + lerp( + values[row0 * this.columns + column0] as number, + values[row0 * this.columns + column1] as number, + columnMix, + ), + lerp( + values[row1 * this.columns + column0] as number, + values[row1 * this.columns + column1] as number, + columnMix, + ), + rowMix, + ); + return { + bank: channel(this.#bank), + compaction: channel(this.#compaction), + disturbance: channel(this.#disturbance), + indent: channel(this.#indent), + }; + } + + /** + * One channel over a window of cells, row-major, written into `target` (allocated when absent + * or too small) and returned. The bulk read a renderer uses to encode a refreshed window: a + * `sample` per vertex costs a bilinear lookup and an object each, and a refill window is + * thousands of vertices. A window outside the field throws. + */ + copyChannel( + channel: keyof ISnowFieldSample, + bounds: IHeightfieldRegionBounds, + target?: Float32Array, + ): Float32Array { + const values = + channel === "indent" + ? this.#indent + : channel === "bank" + ? this.#bank + : channel === "compaction" + ? this.#compaction + : channel === "disturbance" + ? this.#disturbance + : undefined; + if (values === undefined) throw new Error(`SnowField has no channel '${String(channel)}'.`); + const { column, columns, row, rows } = bounds; + if ( + !Number.isInteger(column) || + !Number.isInteger(row) || + !Number.isInteger(columns) || + !Number.isInteger(rows) || + column < 0 || + row < 0 || + columns < 1 || + rows < 1 || + column + columns > this.columns || + row + rows > this.rows + ) + throw new Error("SnowField.copyChannel window must lie inside the field."); + const output = + target !== undefined && target.length >= columns * rows + ? target + : new Float32Array(columns * rows); + for (let index = 0; index < rows; index += 1) { + const start = (row + index) * this.columns + column; + output.set(values.subarray(start, start + columns), index * columns); + } + return output; + } + + /** Snow surface height at a world position, from the same samples geometry and collision read. */ + heightAt(x: number, z: number): number { + return this.field.heightAt(x, z); + } + + /** Surface normal at a world position. */ + normalAt(x: number, z: number, target?: Vector3): Vector3 { + return target === undefined ? this.field.normalAt(x, z) : this.field.normalAt(x, z, target); + } + + /** + * How far towards its target impression a contact has travelled after `seconds`. + * + * The remaining distance shrinks geometrically, so the same total contact time reaches the same + * depth however it is split across ticks. That is what keeps deformation independent of + * presentation rate without touching the fixed physics timestep. + */ + #fraction(seconds: number): number { + if (seconds <= 0) return 0; + if (this.yieldFraction >= 1) return 1; + return 1 - (1 - this.yieldFraction) ** (seconds / this.responseTime); + } + + /** + * Press one contact into the snow and return the penetration it reached, in metres. + * + * Pressure is `load / area`, so the same profile responds to how much weight it carries and how + * widely that weight is spread. `duration` scales how far the contact gets towards its target + * impression, which is what makes a run of shorter contacts agree with one long one. A contact + * entirely outside the field returns 0 and leaves the version unchanged. + */ + stamp(contact: ISnowContact): number { + const resolved = this.#requireContact(contact); + if (this.#depth <= 0) return 0; + const penetration = this.#penetration(resolved.hardness, contact.load, contact.area); + if (penetration <= 0) return 0; + const fraction = this.#fraction(contact.duration ?? this.responseTime); + if (fraction <= 0) return 0; + if (!this.#overlaps(resolved)) return 0; + const window = this.#contactWindow(resolved.x, resolved.z, resolved.extent); + if (!this.#pressWindow(window, resolved, penetration, fraction)) return 0; + this.#steps += 1; + this.#lastSink = penetration; + this.#flush({ + column: window.firstColumn, + columns: window.lastColumn - window.firstColumn + 1, + row: window.firstRow, + rows: window.lastRow - window.firstRow + 1, + }); + return penetration; + } + + /** Depth a contact reaches: pressure against snow resistance, bounded by the snow depth. */ + #penetration(hardness: number, load: number, area: number): number { + const resistance = PRESSURE_RESISTANCE_BASE + hardness ** 1.5 * PRESSURE_RESISTANCE_HARDNESS; + return clamp( + this.#depth * (PRESSURE_DEPTH_FLOOR + load / area / resistance), + 0, + this.#depth * MAX_PENETRATION_FRACTION, + ); + } + + /** Whether a contact's bounding square reaches any part of the field. */ + #overlaps(contact: { readonly extent: number; readonly x: number; readonly z: number }): boolean { + if ( + contact.x + contact.extent < this.minimumX || + contact.x - contact.extent > this.minimumX + this.field.width + ) + return false; + return !( + contact.z + contact.extent < this.minimumZ || + contact.z - contact.extent > this.minimumZ + this.field.depth + ); + } + + /** Sample the footprint across its window, returning whether any cell changed. */ + #pressWindow( + window: { + readonly firstColumn: number; + readonly firstRow: number; + readonly lastColumn: number; + readonly lastRow: number; + }, + contact: { + readonly cosine: number; + readonly footprint: ISnowFootprint; + readonly hardness: number; + readonly sine: number; + readonly x: number; + readonly z: number; + }, + penetration: number, + fraction: number, + ): boolean { + let touched = false; + for (let row = window.firstRow; row <= window.lastRow; row += 1) { + const localZ = this.minimumZ + row * this.cellDepth - contact.z; + for (let column = window.firstColumn; column <= window.lastColumn; column += 1) { + const localX = this.minimumX + column * this.cellWidth - contact.x; + const sample = unit( + contact.footprint.sample( + localX * contact.cosine - localZ * contact.sine, + localX * contact.sine + localZ * contact.cosine, + ), + "footprint sample", + ); + if (sample.coverage <= 0 && sample.bank <= 0) continue; + this.#applySample( + row * this.columns + column, + sample, + penetration, + fraction, + contact.hardness, + ); + touched = true; + } + } + return touched; + } + + /** Reject a malformed contact before any part of the surface changes. */ + #requireContact(contact: ISnowContact): { + readonly cosine: number; + readonly extent: number; + readonly footprint: ISnowFootprint; + readonly hardness: number; + readonly sine: number; + readonly x: number; + readonly z: number; + } { + const x = finite(contact.x, "contact x"); + const z = finite(contact.z, "contact z"); + positive(contact.area, "contact area"); + nonNegative(contact.load, "contact load"); + const rotation = finite(contact.rotation ?? 0, "contact rotation"); + nonNegative(contact.duration ?? this.responseTime, "contact duration"); + const footprint = contact.footprint; + if (typeof footprint?.sample !== "function") + throw new Error("SnowField contact requires a footprint with a sample function."); + return { + cosine: Math.cos(rotation), + extent: positive(footprint.extent, "footprint extent"), + footprint, + hardness: clamp(finite(contact.hardness ?? this.hardness, "contact hardness"), 0, 1), + sine: Math.sin(rotation), + x, + z, + }; + } + + /** + * The grid window a contact covers, rejected when its footprint spans more cells than allowed. + * + * The span is measured before clipping to the grid: a footprint larger than the field is + * malformed rather than merely wasteful, and must fail instead of allocating a window for it. + */ + #contactWindow( + x: number, + z: number, + extent: number, + ): { + readonly firstColumn: number; + readonly firstRow: number; + readonly lastColumn: number; + readonly lastRow: number; + } { + const lowColumn = Math.floor((x - extent - this.minimumX) / this.cellWidth); + const highColumn = Math.ceil((x + extent - this.minimumX) / this.cellWidth); + const lowRow = Math.floor((z - extent - this.minimumZ) / this.cellDepth); + const highRow = Math.ceil((z + extent - this.minimumZ) / this.cellDepth); + const spanned = (highColumn - lowColumn + 1) * (highRow - lowRow + 1); + if (spanned > MAX_CONTACT_CELLS) + throw new Error( + `SnowField contact footprint spans ${String(spanned)} cells, above the ${String(MAX_CONTACT_CELLS)}-cell limit.`, + ); + return { + firstColumn: clamp(lowColumn, 0, this.columns - 1), + firstRow: clamp(lowRow, 0, this.rows - 1), + lastColumn: clamp(highColumn, 0, this.columns - 1), + lastRow: clamp(highRow, 0, this.rows - 1), + }; + } + + /** + * Apply one footprint sample to one cell. + * + * Contact first erases displaced bank and records compaction and disturbance; the rim then + * raises bank beside the contact. That order is the source study's and is what keeps its + * regression fixture reproducible. + */ + #applySample( + index: number, + sample: ISnowFootprintSample, + penetration: number, + fraction: number, + hardness: number, + ): void { + if (sample.coverage > 0) { + const target = Math.max(0, penetration * sample.shape - sample.relief) * sample.coverage; + const oldIndent = this.#indent[index] as number; + this.#indent[index] = Math.min( + this.#depth * MAX_INDENT_FRACTION, + Math.max(oldIndent, lerp(oldIndent, target, fraction)), + ); + this.#bank[index] = (this.#bank[index] as number) * (1 - sample.coverage); + this.#compaction[index] = Math.min( + 1, + Math.max(this.#compaction[index] as number, sample.coverage * (0.72 + 0.23 * hardness)), + ); + this.#disturbance[index] = Math.max(this.#disturbance[index] as number, sample.coverage); + } + if (sample.bank > 0) { + this.#bank[index] = Math.min( + this.maxBank, + Math.max(this.#bank[index] as number, penetration * BANK_LOAD_SCALE * sample.bank), + ); + this.#disturbance[index] = Math.max(this.#disturbance[index] as number, sample.disturbance); + } + this.#active.add(index); + } + + /** + * Let snowfall fill depressions and wind soften banks over `seconds`. + * + * Deposition and wind are rates, so this is presentation-rate independent: a second of + * simulation recovers the same amount however many ticks it was split into. It accelerates + * deposition only — never gravity, movement, falling flakes or the physics timestep. + */ + recover(seconds: number, deposition: number, wind: number): void { + const dt = nonNegative(seconds, "recovery seconds"); + if (dt === 0) return; + const depositionRate = nonNegative(deposition, "recovery deposition"); + const windRate = nonNegative(wind, "recovery wind"); + if ((depositionRate === 0 && windRate === 0) || this.#active.size === 0) return; + const raise = depositionRate * dt; + const erosion = Math.exp(-windRate * dt * WIND_EROSION); + const burial = Math.exp(-raise * DEPOSITION_BURIAL); + let dirty: IHeightfieldRegionBounds | undefined; + for (const index of this.#active) { + this.#recoverCell(index, raise, erosion, burial); + dirty = unionBounds(dirty, cellBounds(index, this.columns)); + } + if (dirty === undefined) return; + this.#flush(dirty); + } + + /** + * Apply one tick of deposition and wind to one cell, releasing it once it is fully buried. + */ + #recoverCell(index: number, raise: number, erosion: number, burial: number): void { + const indent = Math.max(0, (this.#indent[index] as number) - raise); + const bank = (this.#bank[index] as number) * erosion; + const compaction = (this.#compaction[index] as number) * burial; + const disturbance = (this.#disturbance[index] as number) * burial * erosion; + this.#indent[index] = indent; + this.#bank[index] = bank; + this.#compaction[index] = compaction; + this.#disturbance[index] = disturbance; + if ( + indent >= RECOVERED_INDENT || + bank >= RECOVERED_INDENT || + compaction >= RECOVERED_COMPACTION + ) + return; + this.#indent[index] = 0; + this.#bank[index] = 0; + this.#compaction[index] = 0; + this.#disturbance[index] = 0; + this.#active.delete(index); + } + + /** + * Change the snow depth in metres and push the new surface through the canonical field. + * + * Existing indentation is capped to the new depth rather than scaled, so deepening snow does + * not erase tracks and shallowing it buries them. + */ + setDepth(depth: number): void { + const next = nonNegative(depth, "depth"); + if (next === this.#depth) return; + this.#depth = next; + const limit = next * MAX_INDENT_FRACTION; + for (const index of this.#active) + this.#indent[index] = Math.min(this.#indent[index] as number, limit); + this.#flush({ column: 0, columns: this.columns, row: 0, rows: this.rows }); + } + + /** Clear every track, bank and compaction record and return the surface to bare snow. */ + reset(): void { + this.#indent.fill(0); + this.#bank.fill(0); + this.#compaction.fill(0); + this.#disturbance.fill(0); + this.#active.clear(); + this.#steps = 0; + this.#lastSink = 0; + this.#flush({ column: 0, columns: this.columns, row: 0, rows: this.rows }); + } + + /** Combine one window of channels with the base terrain and hand it to the canonical field. */ + #flush(bounds: IHeightfieldRegionBounds): void { + const samples = bounds.columns * bounds.rows; + if (this.#scratch.length < samples) this.#scratch = new Float32Array(samples); + const heights = this.#scratch.subarray(0, samples); + for (let row = 0; row < bounds.rows; row += 1) { + for (let column = 0; column < bounds.columns; column += 1) { + const source = (bounds.row + row) * this.columns + (bounds.column + column); + heights[row * bounds.columns + column] = + (this.#base[source] as number) + + this.#depth + + (this.#bank[source] as number) - + (this.#indent[source] as number); + } + } + // The scratch buffer is written straight through: updateHeights copies before it returns. + this.field.updateHeights({ ...bounds, heights }); + this.#dirty = { ...bounds }; + this.#untaken = unionBounds(this.#untaken, bounds); + this.#version += 1; + } +} diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index d93ad1f633..df16cc7575 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -8,7 +8,7 @@ import { Group, InstancedBufferAttribute, InstancedMesh, - type Material, + Material, Matrix4, Mesh, Object3D, @@ -24,6 +24,7 @@ import { markEngineRenderHook } from "./engine-render-hook.js"; import { InstancedBatch } from "./instanced-batch.js"; import { mergeByMaterial } from "./merge-parts.js"; import { type ILodChain, biasedLodDistance, lodChainOf, setLodBias } from "./model-lod.js"; +import { displacesVertices } from "./projection-plan.js"; import { cutoutSurface } from "./render/foliage-alpha.js"; import { materialKey } from "./render/material-key.js"; import { @@ -2375,10 +2376,10 @@ interface IChunkMerge { readonly keptInstanced: number; /** Teardowns of the consumed shapes that threw, for the caller to count. */ readonly failed: number; - /** Shadow-only proxies the chunk carries, one per `side`; 0 when the chunk casts nothing. */ + /** Shadow-only proxies, per side for merged and retained parts; 0 when nothing casts. */ readonly shadowDraws: number; /** - * The merged opaque meshes a proxy covers. They keep drawing the main pass and keep receiving, and + * The opaque meshes a proxy covers. They keep drawing the main pass and keep receiving, and * they stop casting: the depth pass reads positions, not materials, so it reads the proxy instead. */ readonly shadowCovered: readonly Mesh[]; @@ -2415,23 +2416,25 @@ const worldOwned = new WeakSet(); * grouped by `side` — the one thing a depth material does read — collapses that bill to one draw per * side. * - * The proxy lives alone on `VIRTUAL_SHADOW_CASTER_LAYER`, the layer the level shadow cameras draw and - * the main camera never does, and is counted by the same `#probe` window test as the scatter caster + * A merged proxy lives on the cluster caster half only, a layer the level shadow cameras draw and + * the main camera never does, so a level that picks the wide half drops it (develop's look: a + * merged slab casting into a coarse level's large texels self-shadows as acne stripes). A retained + * proxy stands in for layer-0 casters, which every level drew, so it sits on both halves. Either way it is counted by the same `#probe` window test as the scatter caster * clusters, so a level whose window does not reach the chunk drops it for free. Nothing here decides * how anything looks: the covered meshes keep the game's own materials and keep drawing the main pass * with them, and the proxy carries the world's material for its `side` by reference — the group it * was grouped *by* is the material's own `side`, so the depth pass reads exactly what the covered * mesh's own depth material would have read, and this file still constructs no material. * - * Alpha-tested and transparent meshes are left out and keep casting themselves: a cutout's depth is - * its own texture, and a transparent one is not a shadow at all. So are the kept instanced meshes, - * which a merge left alone. + * Alpha-tested and transparent meshes keep casting themselves. Retained static instances use + * separate per-side proxies, so their per-part gates do not change the existing merged proxies. */ function buildChunkShadowProxies( chunk: Object3D, merged: readonly Mesh[], label: string, proxyMaterials: Map, + selectParts = false, ): { readonly covered: Mesh[]; readonly proxies: Mesh[] } { const sides = new Map(); for (const mesh of merged) { @@ -2446,19 +2449,23 @@ function buildChunkShadowProxies( const covered: Mesh[] = []; const proxies: Mesh[] = []; for (const [side, group] of sides) { - const held = proxyMaterials.get(side); + // Three's sides occupy 0..2; retained sources have their own compatible material pool. + const key = side + (selectParts ? 3 : 0); + const held = proxyMaterials.get(key); const material = held ?? group.material; if (held === undefined) { - proxyMaterials.set(side, material); + proxyMaterials.set(key, material); worldOwned.add(material); } - const proxy = new Mesh(shadowProxyGeometry(group.meshes, label), material); + const proxy = new Mesh(shadowProxyGeometry(group.meshes, label, chunk), material); + if (selectParts) selectChunkShadowParts(proxy, group.meshes); proxy.name = `${CHUNK_NAME}-shadow`; proxy.layers.set(VIRTUAL_SHADOW_CASTER_LAYER); + if (selectParts) proxy.layers.enable(VIRTUAL_SHADOW_WIDE_CASTER_LAYER); proxy.castShadow = true; proxy.receiveShadow = false; - markStatic(proxy); chunk.add(proxy); + markStatic(proxy); proxies.push(proxy); for (const mesh of group.meshes) { mesh.castShadow = false; @@ -2475,51 +2482,150 @@ function buildChunkShadowProxies( * and interleaved (`KHR_mesh_quantization`) and the raw array behind them is not what three would * have drawn. One pass at load time; the result is a depth buffer's whole input. */ -function shadowProxyGeometry(meshes: readonly Mesh[], label: string): BufferGeometry { +function shadowProxyGeometry( + meshes: readonly Mesh[], + label: string, + chunk: Object3D, +): BufferGeometry { + chunk.updateMatrixWorld(true); + const toRoot = chunk.matrixWorld.clone().invert(); + const place = new Matrix4(); + const instance = new Matrix4(); + const matrix = new Matrix4(); + const vertex = new Vector3(); let vertices = 0; let drawn = 0; for (const mesh of meshes) { const position = mesh.geometry.getAttribute("position"); if (position === undefined) throw new Error(`Chunk shadow proxy (${label}): a merged mesh carries no position.`); - vertices += position.count; - drawn += mesh.geometry.getIndex()?.count ?? position.count; + const copies = mesh instanceof InstancedMesh ? mesh.count : 1; + const total = mesh.geometry.getIndex()?.count ?? position.count; + const start = Math.min(total, Math.max(0, mesh.geometry.drawRange.start)); + vertices += position.count * copies; + drawn += Math.max(0, Math.min(total, start + mesh.geometry.drawRange.count) - start) * copies; } const positions = new Float32Array(vertices * 3); const indices = new Uint32Array(drawn); let vertexAt = 0; let indexAt = 0; + const geometry = new BufferGeometry(); for (const mesh of meshes) { const position = mesh.geometry.getAttribute("position"); if (position === undefined) throw new Error(`Chunk shadow proxy (${label}): a merged mesh carries no position.`); - for (let index = 0; index < position.count; index += 1) { - const at = (vertexAt + index) * 3; - positions[at] = position.getX(index); - positions[at + 1] = position.getY(index); - positions[at + 2] = position.getZ(index); - } const source = mesh.geometry.getIndex(); - if (source === null || source === undefined) { + const total = source?.count ?? position.count; + const start = Math.min(total, Math.max(0, mesh.geometry.drawRange.start)); + const end = Math.min(total, start + mesh.geometry.drawRange.count); + const groupAt = indexAt; + place.multiplyMatrices(toRoot, mesh.matrixWorld); + for (let copy = 0; copy < (mesh instanceof InstancedMesh ? mesh.count : 1); copy += 1) { + if (mesh instanceof InstancedMesh) mesh.getMatrixAt(copy, instance); + else instance.identity(); + matrix.multiplyMatrices(place, instance); for (let index = 0; index < position.count; index += 1) { - indices[indexAt + index] = vertexAt + index; + vertex.fromBufferAttribute(position, index).applyMatrix4(matrix); + vertex.toArray(positions, (vertexAt + index) * 3); } - indexAt += position.count; - } else { - for (let index = 0; index < source.count; index += 1) { - indices[indexAt + index] = source.getX(index) + vertexAt; - } - indexAt += source.count; + for (let index = start; index < end; index += 1) + indices[indexAt++] = (source?.getX(index) ?? index) + vertexAt; + vertexAt += position.count; } - vertexAt += position.count; + geometry.addGroup(groupAt, indexAt - groupAt); } - const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(positions, 3)); geometry.setIndex(new BufferAttribute(indices, 1)); geometry.computeBoundingSphere(); return geometry; } +/** Keep retained sources' visibility, frustum and texel decisions on each level's single draw. */ +function selectChunkShadowParts(proxy: Mesh, sources: readonly Mesh[]): void { + const caster = proxy as Mesh & { chunkShadowProxy?: boolean; casterMinDiameter?: number }; + caster.chunkShadowProxy = true; + // The source frustums below decide the exact inputs; the union must not veto that decision. + proxy.frustumCulled = false; + const geometry = proxy.geometry; + const complete = ((geometry.getIndex() as BufferAttribute).array as Uint32Array).slice(); + const levels = new Map(); + const disposeLevels = (event: { target: unknown }) => { + const geometries = new Set([geometry]); + for (const level of levels.values()) geometries.add(level.geometry); + for (const held of geometries) held.removeEventListener("dispose", disposeLevels); + for (const held of geometries) if (held !== event.target) held.dispose(); + levels.clear(); + }; + geometry.addEventListener("dispose", disposeLevels); + const frustum = new Frustum(); + const projection = new Matrix4(); + const sphere = new Sphere(); + proxy.onBeforeRender = function (_renderer, _scene, camera) { + let level = levels.get(camera); + if (level === undefined) { + if (levels.size === 3) { + // ponytail: three resident camera buffers; extra cameras reuse the oldest slot. + const oldest = levels.keys().next().value as Camera; + level = levels.get(oldest) as { geometry: BufferGeometry; selected: Uint8Array }; + levels.delete(oldest); + level.selected.fill(255); + } else { + const perLevel = levels.size === 0 ? geometry : new BufferGeometry(); + if (perLevel !== geometry) { + perLevel.setAttribute("position", geometry.getAttribute("position")); + perLevel.setIndex(new BufferAttribute(new Uint32Array(complete.length), 1)); + perLevel.boundingSphere = geometry.boundingSphere; + perLevel.addEventListener("dispose", disposeLevels); + } + level = { geometry: perLevel, selected: new Uint8Array(sources.length).fill(255) }; + } + levels.set(camera, level); + } + // Three fetches the render object after this hook and detects geometry identity changes, + // even when different cameras share a render object. Exact projection stand-ins use `this`. + this.geometry = level.geometry; + const index = level.geometry.getIndex() as BufferAttribute; + const selected = level.selected; + frustum.setFromProjectionMatrix( + projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + camera.coordinateSystem, + camera.reversedDepth, + ); + let changed = false; + let count = 0; + for (let part = 0; part < sources.length; part += 1) { + const source = sources[part] as InstancedMesh; + if (source.boundingSphere === null) source.computeBoundingSphere(); + sphere.copy(source.boundingSphere as Sphere).applyMatrix4(source.matrixWorld); + let visible = true; + for (let node: Object3D | null = source; node !== null; node = node.parent) + if (!node.visible) visible = false; + const draws = + visible && + sphere.radius * 2 >= (caster.casterMinDiameter ?? 0) && + (!source.frustumCulled || frustum.intersectsObject(source)); + const next = draws ? 1 : 0; + if (selected[part] !== next) changed = true; + selected[part] = next; + if (draws) count += geometry.groups[part]?.count ?? 0; + } + if (changed) { + let at = 0; + for (let part = 0; part < sources.length; part += 1) { + const range = geometry.groups[part]; + if (selected[part] !== 1 || range === undefined) continue; + (index.array as Uint32Array).set( + complete.subarray(range.start, range.start + range.count), + at, + ); + at += range.count; + } + index.needsUpdate = true; + } + level.geometry.setDrawRange(0, count); + }; +} + /** * One chunk's static meshes merged by material, in place, before the chunk is added. * @@ -2621,6 +2727,43 @@ function mergeChunk( proxyMaterials, ) : undefined; + // AutoLOD and morphs remain their own draws. Only unmodified static instance silhouettes qualify. + const toRoot = chunk.matrixWorld.clone().invert(); + const retained = castShadow + ? buildChunkShadowProxies( + chunk, + [...kept].filter( + (mesh) => + mesh instanceof InstancedMesh && + mesh.count > 0 && + mesh.layers.isEnabled(0) && + lodChainOf(mesh.geometry) === undefined && + Object.keys(mesh.geometry.morphAttributes).length === 0 && + mesh.morphTexture === null && + mesh.customDepthMaterial === undefined && + mesh.customDistanceMaterial === undefined && + mesh.onBeforeRender === Object3D.prototype.onBeforeRender && + !Array.isArray(mesh.material) && + !displacesVertices(mesh.material) && + mesh.material.visible && + mesh.material.allowOverride && + mesh.material.onBeforeCompile === Material.prototype.onBeforeCompile && + Reflect.get(mesh.material, "isShaderMaterial") !== true && + mesh.material.shadowSide === null && + (mesh.material.clippingPlanes?.length ?? 0) === 0 && + Reflect.get(mesh.material, "vertexNode") == null && + Reflect.get(mesh.material, "depthNode") == null && + Reflect.get(mesh.material, "castShadowNode") == null && + Reflect.get(mesh.material, "wireframe") !== true && + Reflect.get(mesh.geometry, "indirect") == null && + Reflect.get(mesh, "casterInstanceScale") == null && + new Matrix4().multiplyMatrices(toRoot, mesh.matrixWorld).determinant() >= 0, + ), + `world chunk ${chunk.name} retained`, + proxyMaterials, + true, + ) + : undefined; return { bytes: mergedBytes(result, kept), draws: meshesOf(chunk), @@ -2628,8 +2771,8 @@ function mergeChunk( failed, keptInstanced, meshes: before, - shadowCovered: shadow?.covered ?? [], - shadowDraws: shadow?.proxies.length ?? 0, + shadowCovered: [...(shadow?.covered ?? []), ...(retained?.covered ?? [])], + shadowDraws: (shadow?.proxies.length ?? 0) + (retained?.proxies.length ?? 0), }; } @@ -3453,8 +3596,8 @@ export class WorldCells extends Group implements IComputeDriven { /** The triangle cap a chunk's instanced meshes are expanded under; see the load option. */ readonly #chunkMergeMaxTriangles: number; /** - * The one surface each `side` of a chunk shadow proxy draws with, for the whole world. See - * `worldOwned`: sharing it is what holds a world to one shadow pipeline per side. + * Per-side surfaces shared world-wide, separately for merged and retained proxies. See + * `worldOwned`: a chunk teardown must not release another chunk's shadow surface. */ readonly #proxyMaterials = new Map(); /** @@ -3945,8 +4088,8 @@ export class WorldCells extends Group implements IComputeDriven { // resident ring puts them in, through the same build every first-seen asset takes. if (this.#gpuScene.on && this.#gpuSeeded === false) { this.#gpuSeeded = true; - this.#seedGpuKeys(); this.#seedGpuSources(); + this.#seedGpuKeys(); // After the keys, because that is the only point at which the marker can say how many of the // world's main meshes are dressed: printed at enable it would read the ring as it was a frame // earlier, which is the state the run that found this was in. @@ -6046,6 +6189,15 @@ export class WorldCells extends Group implements IComputeDriven { * not a frame of holes. */ #seedGpuSources(): void { + // A pending build may already have consumed CPU-only slices. Restart it before any swap can + // publish a partial source list; its outgoing batches keep drawing until the new build lands. + const pending = this.#jobs; + this.#jobs = []; + for (const job of pending) { + this.#queued.delete(job.run); + for (const entry of job.fresh) this.#clearSegment(entry); + this.#queueBuild(job.asset, job.cell, job.run, true, job.force); + } for (const cell of this.#resident.values()) for (const batch of cell.batches) { const asset = this.#assets.get(batch.asset); @@ -6366,8 +6518,9 @@ export class WorldCells extends Group implements IComputeDriven { loads.push( this.#limiter .load(() => this.#model(path), wanted) - .catch(() => { + .catch((error: unknown) => { this.#failures += 1; + this.#reportCellFailure("asset", asset.id, [path], error); return undefined; }), ); @@ -7482,6 +7635,25 @@ export class WorldCells extends Group implements IComputeDriven { ); } + /** + * `TN_WORLD_CELL_FAILURE`, one line per refused world load: which stage refused (`asset`, `chunk` + * or `attach`), the asset id or cell key it refused for, the paths it was reading, and the error + * behind it. A counted failure alone says a world lost something; this says which world load and + * why, which is the only thing a playtest's `state.failures` can be attributed from. A warning + * rather than an error, because a refused load is a world that draws the rest, not a crash. + */ + #reportCellFailure( + kind: "asset" | "chunk" | "attach", + id: string, + paths: readonly string[], + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `TN_WORLD_CELL_FAILURE kind=${kind} id=${id} paths=${paths.join(",")} message=${message}`, + ); + } + /** * `TN_WORLD_LOD_CHAIN`, one line per asset that was widened: how many levels it is now drawn at, * where each takes over in metres to one decimal, and what each submits in triangles. Level 0 @@ -7669,8 +7841,9 @@ export class WorldCells extends Group implements IComputeDriven { models.filter((model): model is Object3D => model !== undefined), ); }, - () => { + (error: unknown) => { this.#failures += 1; + this.#reportCellFailure("chunk", cell.key, paths, error); }, ); } @@ -7814,8 +7987,14 @@ export class WorldCells extends Group implements IComputeDriven { if (proxies > 0) this.#shadowRecordsMoved(new Box3().setFromObject(object, false)); attached += 1; } - } catch { + } catch (error) { this.#failures += 1; + this.#reportCellFailure( + "attach", + cell.key, + Array.from(cell.cell.chunks ?? [], (chunk) => resolveRelative(this.#logicalBase, chunk)), + error, + ); for (let i = attached; i < models.length; i += 1) this.#failures += this.#disposeLoaded(models[i] as Object3D); } diff --git a/packages/core/src/world-region.ts b/packages/core/src/world-region.ts new file mode 100644 index 0000000000..0f95ed81b3 --- /dev/null +++ b/packages/core/src/world-region.ts @@ -0,0 +1,17 @@ +import type { IHeightfieldRegionBounds } from "./world.js"; + +/** The smallest window covering both; `current` may be absent. Internal to the world modules. */ +export function unionBounds( + current: IHeightfieldRegionBounds | undefined, + next: IHeightfieldRegionBounds, +): IHeightfieldRegionBounds { + if (current === undefined) return next; + const column = Math.min(current.column, next.column); + const row = Math.min(current.row, next.row); + return { + column, + columns: Math.max(current.column + current.columns, next.column + next.columns) - column, + row, + rows: Math.max(current.row + current.rows, next.row + next.rows) - row, + }; +} diff --git a/packages/core/src/world-tiles.ts b/packages/core/src/world-tiles.ts index bdb7287f1d..df3417f4e4 100644 --- a/packages/core/src/world-tiles.ts +++ b/packages/core/src/world-tiles.ts @@ -121,10 +121,14 @@ export interface IWorldTilesOptions { */ readonly validate?: boolean; /** - * Merge settled same-LOD terrain tiles into super-tiles: one mesh per K×K block, rebuilt from the - * resident tiles through the admission budget. It changes how many draws the main pass submits, - * never how the ground looks — a merged vertex lands on the world position its tile's vertex did. - * Off by default; `mergeTiles: true`, `?tnTerrainMerge=1` or `TN_TERRAIN_MERGE=1` turns it on. + * Merge settled same-LOD terrain tiles into super-tiles: one mesh per K×K block, one block rebuilt + * per frame. It changes how many draws the main pass submits, never how the ground looks — a merged + * vertex lands on the world position its tile's vertex did, on the same game-owned `surface`. + * + * On by default, like `validate`'s measurements are off by default only because they cost a + * measurement a frame. `mergeTiles: false`, `?tnTerrainMerge=0` or `TN_TERRAIN_MERGE=0` turns it + * off for a run. A block's bytes are reported as `terrainTiles.blockBytes` and are never charged to + * `residentByteBudget`, so the merge changes draws and no tile the budget would admit without it. */ readonly mergeTiles?: boolean; /** Explicit game-owned measurement region used by the topology evaluator. */ @@ -187,7 +191,11 @@ interface IMergedBlock { readonly lod: number; readonly geometry: BufferGeometry; readonly mesh: Mesh; - /** The tile keys whose current level geometry this block holds. */ + /** This block's own geometry bytes, reported through `terrainTiles.blockBytes`. */ + bytes: number; + /** The tile keys whose current level geometry this block holds, kept equal to what its geometry + * actually holds: a record the geometry has moved past double-draws the tiles that left and hides + * the ones that joined. */ members: Set; } @@ -209,29 +217,29 @@ const LOD_TRANSITION_FRAMES = 3; /** Tiles per side in a merged super-tile block. Small, because a block is culled as one object. */ const TERRAIN_MERGE_BLOCK = 4; const TERRAIN_MERGE_FLAG = "TN_TERRAIN_MERGE"; -const TERRAIN_MERGE_MARKER_MS = 5_000; +const TERRAIN_TILE_MARKER_MS = 5_000; const BRIDGE_COORDINATE_EPSILON = 1e-4; /** - * Whether `TN_TERRAIN_MERGE` asks for terrain tile merging on this launch. + * Whether this launch asked for terrain tile merging to be turned off. * - * Off by default: the merge exists to cut the main pass's terrain draws on a wide streamed ring, and - * a run that never asked for it must submit exactly the meshes it always did. Read the three ways - * every other launch switch is: a native launch sets the environment variable, a browser asks with - * the query string, and a test or harness sets the global. Only `1` and `true` count, so an old URL - * that never mentioned the switch stays off. + * The merge is on by default, so this only has to hear the three ways every other launch switch is + * read: a native launch sets the environment variable, a browser asks with the query string, and a + * test or harness sets the global. Only `0` and `false` count, so a URL that never mentioned the + * switch — or one that still carries the `=1` that used to ask for the merge — stays on. */ -function terrainMergeRequested(): boolean { +function terrainMergeOptedOut(): boolean { const host = globalThis as { process?: { env?: Record } } & Record< string, unknown >; const fromEnv = host.process?.env?.[TERRAIN_MERGE_FLAG]; - if (fromEnv === "1" || fromEnv === "true") return true; + if (fromEnv === "0" || fromEnv === "false") return true; const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnTerrainMerge=(?:1|true)(?:&|$)/u.test(query)) return true; + if (typeof query === "string" && /[?&]tnTerrainMerge=(?:0|false)(?:&|$)/u.test(query)) + return true; return ( - host.__tnTerrainMerge === true || host.__tnTerrainMerge === 1 || host.__tnTerrainMerge === "1" + host.__tnTerrainMerge === false || host.__tnTerrainMerge === 0 || host.__tnTerrainMerge === "0" ); } @@ -1803,7 +1811,7 @@ function setManualLodLevel(lod: LOD, level: number): void { * @constraint sampleHeight and surface are required game choices; no landform or surface preset is installed * @constraint residentTileBudget and residentByteBudget are hard caps; a tile that cannot fit throws * @constraint seam gap, LOD pop and the rendered-vertex finiteness scan are measurements that are off by default; TN_TERRAIN_VALIDATE=1, ?tnTerrainValidate=1 or validate: true runs them, and maxSeamGap, maxVisualSeamGap and maxLodPop report undefined while they are off - * @override tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, validate, and budgets + * @override tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, mergeTiles, validate, and budgets * @example const tiles = new TerrainTiles({ sampleHeight, surface: gameSurface(), tileSize: 256, tileResolution: 129, residentTileBudget: 25, residentByteBudget: 32_000_000 }); */ export class TerrainTiles extends Object3D implements IComputeDriven { @@ -1833,13 +1841,16 @@ export class TerrainTiles extends Object3D implements IComputeDriven { #peakBytes = 0; #peakTiles = 0; #lodTransitions = 0; + readonly #blending = new Set(); /** Merged super-tiles by block key, and the blocks a LOD or residency change left to rebuild. */ readonly #blocks = new Map(); readonly #dirtyBlocks = new Set(); /** Tile key -> block key currently hiding it, so a settled member is not drawn twice. */ readonly #mergedMembers = new Map(); #blockRebuilds = 0; - #mergeToldAt = Number.NEGATIVE_INFINITY; + /** Merged super-tile bytes, reported beside the tiles they duplicate and never charged to them. */ + #blockBytes = 0; + #tilesToldAt = Number.NEGATIVE_INFINITY; readonly #mergeTiles: boolean; // The last `follow` ran out of admission budget before it wanted everything, so the next one // has to run even if the follow point has not moved. Cleared at the top of `follow`. @@ -1903,7 +1914,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { if (options.surface === undefined || options.surface === null) throw new Error("TerrainTiles surface is required and must be game-owned."); this.#validate = options.validate ?? terrainValidationRequested(); - this.#mergeTiles = options.mergeTiles ?? terrainMergeRequested(); + this.#mergeTiles = options.mergeTiles ?? !terrainMergeOptedOut(); this.#sampleHeight = options.sampleHeight; if (typeof options.sampleHeight !== "function") throw new Error("TerrainTiles sampleHeight is required."); @@ -1953,6 +1964,19 @@ export class TerrainTiles extends Object3D implements IComputeDriven { return this.#resident.size; } + /** + * Every retained terrain byte, against `residentByteBudget`: the resident tiles with their levels + * and edge samples, the stitch bridges, and the retained topology. + * + * A merged super-tile is deliberately absent. A block duplicates the level vertices of the tiles it + * covers, and those tiles stay resident because a tile that leaves the block draws its own mesh + * again — so charging the copy made a derived artefact evict real terrain detail out of the same + * budget the tiles were admitted against, and a budget sized for unmerged terrain held fewer tiles + * with the merge on. The copy is reported as `terrainTiles.blockBytes` instead. (PRD-475.) + * + * ponytail: terrain geometry costs up to ~2x while merged; free the per-tile GPU buffers behind a + * block (keep the height field for queries) if that memory ever matters. + */ get residentBytes(): number { // Read four times a frame by the residency admission and the peak record, so it accumulates // instead of materialising the resident set. @@ -1992,11 +2016,17 @@ export class TerrainTiles extends Object3D implements IComputeDriven { * What the main pass submits for terrain levels right now: `tiles` surfaces, held as `blocks` * merged super-tiles plus one mesh each for the tiles that are not merged (a lone block member or * a tile mid LOD morph). `draws` is `blocks` plus those individual meshes, so with the merge off - * it is exactly the number of visible level meshes — the census the merge exists to cut. `rebuilds` - * counts the block geometries built over this residency owner's life. + * it is exactly the number of visible level meshes — the census the merge exists to cut. `blending` + * counts the resident tiles mid LOD morph, which are always among the individual meshes. A block + * whose tile just left stops being drawn until its rebuild lands, and those tiles are counted as the + * individual meshes they went back to — see `#releaseStaleBlock`. + * `rebuilds` counts the block geometries built over this residency owner's life. `blockBytes` is + * what the held blocks cost, reported and not charged to `residentByteBudget` — see `residentBytes`. */ get terrainTiles(): { + readonly blockBytes: number; readonly blocks: number; + readonly blending: number; readonly draws: number; readonly rebuilds: number; readonly tiles: number; @@ -2008,7 +2038,9 @@ export class TerrainTiles extends Object3D implements IComputeDriven { for (const block of this.#blocks.values()) merged += block.members.size; const blocks = this.#blocks.size; return { + blockBytes: this.#blockBytes, blocks, + blending: this.blendingTiles, draws: individual + blocks, rebuilds: this.#blockRebuilds, tiles: individual + merged, @@ -2021,9 +2053,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { * `process` while its follow point is standing still would freeze every blend on frame one. */ get blendingTiles(): number { - let blending = 0; - for (const tile of this.#resident.values()) if (tile.lodTransition !== undefined) blending += 1; - return blending; + return this.#blending.size; } /** @@ -2173,7 +2203,8 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#recordPeaks(); this.#updateColliders(centerX, centerZ, budget); this.#applyLodTargets(targets); - this.#coordinateNeighborLods(hadFocus); + // Retargeting already coordinates the resident ring; only newly admitted tiles can owe it. + if (built > 0) this.#coordinateNeighborLods(hadFocus); this.#seamPass(); // After every LOD target is settled, so a block is built from the levels this pass left behind. this.#rebuildDirtyBlocks(budget); @@ -2214,7 +2245,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#seamPass(); if (lodFrame !== undefined) this.#recordLodPopAfterReconciliation(lodFrame); this.#recordPeaks(); - this.#reportMergeMarker(); + this.#reportTileMarker(); } debug(): Record { @@ -2270,6 +2301,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { residentTileBudget: this.residentTileBudget, topologyBytes: this.#topologyBytes, lodTransitions: this.#lodTransitions, + terrainTiles: this.terrainTiles, maxLodPop: this.maxLodPop, stitchedEdges: this.#stitchedEdges, skirtVertexCount: [...this.#resident.values()].reduce( @@ -2518,8 +2550,9 @@ export class TerrainTiles extends Object3D implements IComputeDriven { const next = tile.levels[selectable]; if (previous === undefined || next === undefined) throw new Error("TerrainTiles LOD transition references a missing level."); - // The tile leaves its old level's block and joins the new level's; both memberships changed. - this.#markBlockDirty(previousLevel, tile.tileX, tile.tileZ); + // The tile leaves its old level's block and joins the new level's: the old one is now holding + // ground this tile is no longer at, and the new one is waiting to be built. + this.#releaseStaleBlock(previousLevel, tile.tileX, tile.tileZ); tile.lodLevel = selectable; this.#markBlockDirty(selectable, tile.tileX, tile.tileZ); this.#ringEpoch += 1; @@ -2530,6 +2563,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { return; } this.#lodTransitions += 1; + this.#blending.add(tile); tile.lodTransition = { elapsedFrames: 0, from: previousLevel, @@ -2599,7 +2633,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { } #advanceLodTransitions(): void { - for (const tile of this.#resident.values()) { + for (const tile of this.#blending) { const transition = tile.lodTransition; if (transition === undefined) continue; const from = tile.levels[transition.from]; @@ -2624,6 +2658,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { ); this.#restoreLodTransition(tile, transition); tile.lodTransition = undefined; + this.#blending.delete(tile); setManualLodLevel(tile.lod, transition.to); this.#setLodVisibility(tile); // The end of a blend is the one moment a tile rejoins its settled block. @@ -2733,6 +2768,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#maxLodTransitionFrames = Math.max(this.#maxLodTransitionFrames, transition.elapsedFrames); this.#restoreLodTransition(tile, transition); tile.lodTransition = undefined; + this.#blending.delete(tile); setManualLodLevel(tile.lod, transition.to); this.#setLodVisibility(tile); } @@ -2901,34 +2937,51 @@ export class TerrainTiles extends Object3D implements IComputeDriven { } /** - * Rebuild at most one dirty block, charging it to the frame's admission budget like any other - * streamed work. A rebuild concatenates a K×K block of settled tile levels, so it is bounded and - * never one long task; a refused rebuild stays dirty and the next `follow` runs it. With no budget - * every dirty block rebuilds, which is what a test or a one-shot harness wants. + * Let go of a block one of its tiles has left. + * + * A departure is the one ring change a rebuild cannot hide: the block's geometry still holds the + * tile that just left, so it would draw that ground a second time — at the level the tile has left + * — until this frame's one rebuild could replace it, and one rebuild a frame does not arrive before + * the next departure. So the block stops being drawn the frame the departure happens, every tile it + * still holds goes back to its own mesh, and the rebuild queue brings the block back whole. It is + * dirty either way: the block has to be rebuilt whatever this does to it. (PRD-475.) + */ + #releaseStaleBlock(lod: number, tileX: number, tileZ: number): void { + if (!this.#mergeTiles) return; + const blockKey = blockKeyFor(lod, tileX, tileZ); + this.#markBlockDirty(lod, tileX, tileZ); + if (this.#blocks.has(blockKey)) this.#dissolveBlock(blockKey); + } + + /** + * Rebuild one dirty block, charging it to the frame's admission budget like any other streamed + * work. One, per frame, whatever the budget still holds: a rebuild concatenates a K×K block of + * settled tile levels, and a follow that paid for every block its own streaming churn left dirty + * is the frame-time spike this cap exists to prevent — the plan's `TN_FRAME_SPANS` check watches + * for exactly that. A refused rebuild stays dirty and the next `follow` runs it, so N dirty blocks + * take N frames and a still follow point converges. */ #rebuildDirtyBlocks(budget: IAdmissionBudget | undefined): void { - if (!this.#mergeTiles || this.#dirtyBlocks.size === 0) return; - while (this.#dirtyBlocks.size > 0) { - let key: string | undefined; - for (const candidate of this.#dirtyBlocks) - if (key === undefined || candidate < key) key = candidate; - if (key === undefined) return; - const dirty = key; - const run = (): void => { - this.#dirtyBlocks.delete(dirty); - this.#rebuildBlock(dirty); - }; - // With no budget every dirty block rebuilds; with one, a refusal stops the loop and leaves the - // rest dirty for a later frame, so no single `follow` pays for more than the frame's allowance. - if (budget === undefined) { - run(); - continue; - } - if (!budget.admit(run)) { - this.#deferredAdmissions = 1; - return; - } + if (!this.#mergeTiles) return; + const dirty = this.#nextDirtyBlock(); + if (dirty === undefined) return; + const run = (): void => { + this.#dirtyBlocks.delete(dirty); + this.#rebuildBlock(dirty); + }; + if (budget === undefined) { + run(); + return; } + if (!budget.admit(run)) this.#deferredAdmissions = 1; + } + + /** The lowest block key still waiting, so the one rebuild a frame spends is the same every time. */ + #nextDirtyBlock(): string | undefined { + let first: string | undefined; + for (const candidate of this.#dirtyBlocks) + if (first === undefined || candidate < first) first = candidate; + return first; } /** @@ -2955,20 +3008,31 @@ export class TerrainTiles extends Object3D implements IComputeDriven { z: blockZ * TERRAIN_MERGE_BLOCK * this.tileSize, }; const geometry = mergeLevelGeometry(parts, blockOrigin); + const bytes = geometryBytes(geometry); + this.#blockBytes += bytes - (existing?.bytes ?? 0); if (existing === undefined) { const mesh = new Mesh(geometry, this.#surface); mesh.frustumCulled = true; mesh.name = `tn-terrain-block:${blockKey}`; + // The merged geometry is written relative to the block origin, so the mesh carries it. Without + // this the block drew a whole `blockOrigin` away from the tiles it replaced: floating slabs and + // a hole where the ground is, at any block that is not at (0, 0). (PRD-475.) + mesh.position.set(blockOrigin.x, 0, blockOrigin.z); mesh.receiveShadow = this.#receiveShadow; - this.#blocks.set(blockKey, { geometry, key: blockKey, lod, members, mesh }); + this.#blocks.set(blockKey, { bytes, geometry, key: blockKey, lod, members, mesh }); this.add(mesh); } else { + const left = existing.members; existing.geometry.dispose(); + existing.bytes = bytes; existing.mesh.geometry = geometry; - this.#blocks.set(blockKey, { geometry, key: blockKey, lod, members, mesh: existing.mesh }); + // The record follows the geometry: the loop below restores the tiles this rebuild dropped, and + // `terrainTiles` counts what the blocks hold, so a stale record here is a tile drawn twice or + // not at all (PRD-475). + existing.members = members; + this.#blocks.set(blockKey, existing); + for (const key of left) if (!members.has(key)) this.#showTile(key, blockKey); } - if (existing !== undefined) - for (const key of existing.members) if (!members.has(key)) this.#showTile(key, blockKey); for (const key of members) { this.#mergedMembers.set(key, blockKey); const tile = this.#resident.get(key); @@ -3007,6 +3071,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { if (block === undefined) return; this.#blocks.delete(blockKey); this.remove(block.mesh); + this.#blockBytes -= block.bytes; block.geometry.dispose(); for (const key of block.members) this.#showTile(key, blockKey); } @@ -3016,22 +3081,30 @@ export class TerrainTiles extends Object3D implements IComputeDriven { return typeof host.performance?.now === "function" ? host.performance.now() : Date.now(); } - /** `TN_TERRAIN_MERGE`, every `TERRAIN_MERGE_MARKER_MS` while the merge is on. */ - #reportMergeMarker(): void { - if (!this.#mergeTiles) return; + /** + * `TN_TERRAIN_TILES`, every `TERRAIN_TILE_MARKER_MS`, with the merge on or off: the same census + * before and after is the only way to read what the merge cut. A run that asked for validation also + * gets the seam and LOD-pop maxima it is paying to measure, so a browser console run prints them + * instead of only an in-process accessor ever reading them. + */ + #reportTileMarker(): void { const now = this.#now(); - if (now - this.#mergeToldAt < TERRAIN_MERGE_MARKER_MS) return; - this.#mergeToldAt = now; + if (now - this.#tilesToldAt < TERRAIN_TILE_MARKER_MS) return; + this.#tilesToldAt = now; const stats = this.terrainTiles; + const measurements = this.#validate + ? ` maxSeamGap=${String(this.maxSeamGap)} maxLodPop=${String(this.maxLodPop)} ` + + `maxVisualSeamGap=${String(this.maxVisualSeamGap)}` + : ""; console.info( - `TN_TERRAIN_MERGE tiles=${String(stats.tiles)} blocks=${String(stats.blocks)} ` + - `draws=${String(stats.draws)} rebuilds=${String(stats.rebuilds)}`, + `TN_TERRAIN_TILES tiles=${String(stats.tiles)} blocks=${String(stats.blocks)} draws=${String(stats.draws)} blending=${String(stats.blending)} rebuilds=${String(stats.rebuilds)} blockBytes=${String(stats.blockBytes)}${measurements}`, ); } #evict(tile: IResidentTile): void { + this.#blending.delete(tile); if (this.#mergeTiles) { - this.#markTileDirty(tile); + this.#releaseStaleBlock(tile.lodLevel, tile.tileX, tile.tileZ); this.#mergedMembers.delete(tile.key); } if (this.#resident.get(tile.key) === tile) this.#resident.delete(tile.key); diff --git a/packages/core/src/world.ts b/packages/core/src/world.ts index bba8923d9f..5099fcc368 100644 --- a/packages/core/src/world.ts +++ b/packages/core/src/world.ts @@ -1,4 +1,4 @@ -import { BufferAttribute, BufferGeometry, Group, Vector3 } from "three"; +import { BufferAttribute, BufferGeometry, Group, Sphere, Vector3 } from "three"; import type { IComputeDriven } from "./compute-driven.js"; import { GPUReadback, type IGPUReadbackSample } from "./gpu-readback.js"; import type { IRendererLike } from "./renderer.js"; @@ -8,6 +8,7 @@ import { createWorldGpuPasses, simulateWorldPassesCpu, } from "./world-passes.js"; +import { unionBounds } from "./world-region.js"; export interface IHeightfieldOrigin { readonly x: number; @@ -24,6 +25,32 @@ export interface IHeightfieldOptions { readonly worldPasses?: IHeightfieldWorldPassOptions; } +/** A rectangular window of a heightfield's canonical samples, in grid indices. */ +/** Hands out the union of every window `updateHeights` wrote since the last `take`. */ +export interface IHeightfieldChangeTracker { + /** The changed window since the previous take, or undefined when nothing changed. */ + take(): IHeightfieldRegionBounds | undefined; + /** Stop tracking. Idempotent. */ + dispose(): void; +} + +export interface IHeightfieldRegionBounds { + /** First column (x index), inclusive. */ + readonly column: number; + /** Number of columns covered. */ + readonly columns: number; + /** First row (z index), inclusive. */ + readonly row: number; + /** Number of rows covered. */ + readonly rows: number; +} + +/** A rectangular window plus the replacement samples written over it. */ +export interface IHeightfieldRegion extends IHeightfieldRegionBounds { + /** Row-major samples, `columns * rows` long and read with the region's own stride. */ + readonly heights: Float32Array; +} + export interface IHeightfieldWorldPassOptions { /** Maximum TSL compute dispatches submitted by one rendered frame. */ readonly dispatchBudget: number; @@ -60,6 +87,38 @@ function count(value: number, name: string): number { return value; } +/** Validates one axis of an update window against the field's own sample count. */ +function regionAxis( + start: number, + size: number, + total: number, + names: readonly [string, string], +): { readonly count: number; readonly start: number } { + if (!Number.isInteger(start) || start < 0) + throw new Error(`Heightfield region ${names[0]} must be an integer of at least 0.`); + if (!Number.isInteger(size) || size < 1) + throw new Error(`Heightfield region ${names[1]} must be an integer of at least 1.`); + if (start + size > total) + throw new Error( + `Heightfield region ${names[0]} ${String(start)} + ${names[1]} ${String(size)} exceeds the field's ${String(total)} samples.`, + ); + return { count: size, start }; +} + +function regionColumns( + region: IHeightfieldRegionBounds, + total: number, +): { readonly count: number; readonly start: number } { + return regionAxis(region.column, region.columns, total, ["column", "columns"]); +} + +function regionRows( + region: IHeightfieldRegionBounds, + total: number, +): { readonly count: number; readonly start: number } { + return regionAxis(region.row, region.rows, total, ["row", "rows"]); +} + function shouldBuildGpuPasses(options: IHeightfieldWorldPassOptions | undefined): boolean { return options?.gpu === true; } @@ -86,6 +145,7 @@ function validateWorldPassBudget(options: IHeightfieldWorldPassOptions): void { * @situation query the same ground height or normal that a player sees and collides with * @situation ask how high the ground is here * @situation build islands and coastlines from terrain + * @situation keep a collider or other copy of a deforming terrain in step without rescanning the whole field (`trackChanges`) * @constraint sampleHeight owns the terrain shape and stays in game source; the framework stores and interpolates its output * @constraint rows and columns are vertex counts; geometry is row-major z-then-x and collider export transposes once into Rapier's column-major matrix order * @override rows, columns, width, depth, origin, and sampleHeight are explicit on every field @@ -102,6 +162,7 @@ export class Heightfield extends Group implements IComputeDriven { readonly #cellDepth: number; readonly #cellWidth: number; readonly #colliderHeights: Float32Array; + readonly #trackers = new Set<{ region: IHeightfieldRegionBounds | undefined }>(); readonly #heights: Float32Array; readonly #minimumX: number; readonly #minimumZ: number; @@ -114,6 +175,7 @@ export class Heightfield extends Group implements IComputeDriven { #renderer: IRendererLike | undefined; #gpuCompletionObserved = false; #released = false; + #version = 0; constructor(options: IHeightfieldOptions & IStoredHeightfieldChannels) { super(); @@ -362,6 +424,154 @@ export class Heightfield extends Group implements IComputeDriven { return target.set(-slopeX, 1, -slopeZ).normalize(); } + /** + * Monotonic sample version, incremented by every `updateHeights` call. + * + * A renderer or collider that caches derived data compares this against its own last-seen value + * instead of diffing the whole grid each frame. + */ + get version(): number { + return this.#version; + } + + /** + * Overwrite one rectangular window of the canonical samples. + * + * Height is the one buffer queries, rendered geometry and collider export share, so a second + * terrain representation is never needed: a simulation writes its surface here and every + * consumer already reads it. The whole window is validated before any sample changes, so a + * malformed call leaves the field untouched rather than half-written. + * @situation deform terrain, snow or water in place and have queries and collision follow + * @constraint the region must lie inside the field; an out-of-bounds window throws and writes nothing + * @constraint heights is `columns * rows` long, row-major, and every sample must be finite + */ + updateHeights(region: IHeightfieldRegion): void { + if (this.#released) throw new Error("Heightfield cannot be updated after release."); + const column = regionColumns(region, this.columns); + const row = regionRows(region, this.rows); + const expected = column.count * row.count; + if (region.heights.length !== expected) + throw new Error( + `Heightfield region expected ${expected} heights, received ${region.heights.length}.`, + ); + for (const height of region.heights) finite(height, "region sample"); + for (let index = 0; index < row.count; index += 1) { + for (let offset = 0; offset < column.count; offset += 1) { + const target = row.start + index; + const source = column.start + offset; + this.#heights[target * this.columns + source] = region.heights[ + index * column.count + offset + ] as number; + this.#colliderHeights[source * this.rows + target] = this.#height( + target * this.columns + source, + ); + } + } + const written = { + column: column.start, + columns: column.count, + row: row.start, + rows: row.count, + }; + for (const tracker of this.#trackers) tracker.region = unionBounds(tracker.region, written); + this.#version += 1; + } + + /** + * Follow which samples change, independently of every other follower. + * + * A consumer that mirrors the surface — a collider, a GPU copy, a cache — needs the window that + * changed since *it* last looked; comparing the whole field instead costs every sample on every + * change. Each tracker keeps its own union of the windows `updateHeights` wrote, so two + * consumers never take each other's changes. + * + * ```ts + * const changes = field.trackChanges(); + * const changed = changes.take(); // undefined when nothing was written + * ``` + */ + trackChanges(): IHeightfieldChangeTracker { + const state: { region: IHeightfieldRegionBounds | undefined } = { region: undefined }; + this.#trackers.add(state); + return { + take: () => { + const region = state.region; + state.region = undefined; + return region; + }, + dispose: () => { + this.#trackers.delete(state); + }, + }; + } + + /** The collider sample at `row`, `column`: what `toColliderHeights()` holds there, without a copy. */ + colliderHeight(row: number, column: number): number { + if ( + !Number.isInteger(row) || + !Number.isInteger(column) || + row < 0 || + column < 0 || + row >= this.rows || + column >= this.columns + ) + throw new Error(`Heightfield sample ${row},${column} is outside the field.`); + return this.#colliderHeights[column * this.rows + row] as number; + } + + /** + * Rewrite an existing `toGeometry()` result from the current canonical samples. + * + * Positions carry over unchanged because only the surface moves; y and normals are refreshed + * from the same sampler queries use, which is what keeps a rendered vertex and a height query + * from disagreeing. Pass a region to touch only the window a simulation just changed. + * @situation redraw a terrain, snow or water mesh after its samples changed + * @constraint geometry must come from `toGeometry()` with the same rows and columns + */ + refreshGeometry(geometry: BufferGeometry, bounds?: IHeightfieldRegionBounds): void { + const position = geometry.getAttribute("position"); + const normalAttribute = geometry.getAttribute("normal"); + if (position.count !== this.rows * this.columns) + throw new Error( + `Heightfield geometry has ${position.count} vertices, expected ${this.rows * this.columns}.`, + ); + const column = + bounds === undefined + ? { count: this.columns, start: 0 } + : regionColumns(bounds, this.columns); + const row = + bounds === undefined ? { count: this.rows, start: 0 } : regionRows(bounds, this.rows); + let lowest = Number.POSITIVE_INFINITY; + let highest = Number.NEGATIVE_INFINITY; + for (let index = 0; index < row.count; index += 1) { + const target = row.start + index; + for (let offset = 0; offset < column.count; offset += 1) { + const source = column.start + offset; + const vertex = target * this.columns + source; + const height = this.#height(vertex); + position.setY(vertex, height); + lowest = Math.min(lowest, height); + highest = Math.max(highest, height); + if (normalAttribute === undefined) continue; + this.#gridNormal(source, target, normalAttribute, vertex); + } + } + position.needsUpdate = true; + if (normalAttribute !== undefined) normalAttribute.needsUpdate = true; + // Only heights move, so a windowed refresh grows the existing bounds by the window's own + // range instead of walking every vertex again: on a 321-sample field the full recompute cost + // two milliseconds for a footprint-sized window. Bounds may stay conservative after a refill. + const box = geometry.boundingBox; + if (bounds === undefined || box === null) { + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return; + } + box.min.y = Math.min(box.min.y, lowest); + box.max.y = Math.max(box.max.y, highest); + geometry.boundingSphere = box.getBoundingSphere(geometry.boundingSphere ?? new Sphere()); + } + /** The same values transposed once into Rapier's column-major height-matrix order. */ toColliderHeights(): Float32Array { return this.#colliderHeights.slice(); @@ -463,6 +673,31 @@ export class Heightfield extends Group implements IComputeDriven { return maximum; } + /** + * `normalAt` at a sample, read straight from the grid: the same central differences, one-sided + * at the border, without five validated bilinear queries per vertex. On a 401-sample field a + * windowed refresh spent most of its time in those queries. + */ + #gridNormal( + column: number, + row: number, + target: { setXYZ(index: number, x: number, y: number, z: number): unknown }, + vertex: number, + ): void { + const left = Math.max(0, column - 1); + const right = Math.min(this.columns - 1, column + 1); + const near = Math.max(0, row - 1); + const far = Math.min(this.rows - 1, row + 1); + const slopeX = + (this.#height(row * this.columns + right) - this.#height(row * this.columns + left)) / + ((right - left) * this.#cellWidth); + const slopeZ = + (this.#height(far * this.columns + column) - this.#height(near * this.columns + column)) / + ((far - near) * this.#cellDepth); + const length = Math.hypot(slopeX, 1, slopeZ); + target.setXYZ(vertex, -slopeX / length, 1 / length, -slopeZ / length); + } + #height(index: number): number { const height = this.#heights[index]; if (height === undefined) throw new Error(`Heightfield internal sample ${index} is missing.`); @@ -501,6 +736,15 @@ export type { WorldPackageErrorCode, } from "./world-package.js"; +export { SnowField, snowDiscFootprint } from "./snow-field.js"; +export type { + ISnowContact, + ISnowFieldOptions, + ISnowFieldSample, + ISnowFootprint, + ISnowFootprintSample, +} from "./snow-field.js"; + export { heightSamplerFromHeightmap, loadWorldHeightmap } from "./world-heightmap.js"; export { loadTerrainSplat } from "./world-terrain-splat.js"; export type { diff --git a/packages/create-threenative/AGENTS.md b/packages/create-threenative/AGENTS.md index 39f58410f7..47e93ca85f 100644 --- a/packages/create-threenative/AGENTS.md +++ b/packages/create-threenative/AGENTS.md @@ -30,6 +30,7 @@ output look worse than vanilla. - `minimal` — no React, no UI folder. Core + physics: a test arena, a photographed sky and a rigged mannequin, with no HUD. - `starter` — adds React 19, Tailwind 4, `src/ui/`. The default. +- `rain` — a procedural coastal storm with a free-fly camera, weather controls and a React interface; no physics dependency. - `platformer` — adds `PathFollow3D` steering with avoidance. Nothing in `templates/` imports `@threenative/physics/navigation`: its Recast WASM is web/desktop only, and every kit is expected to run on all four targets, so each kit's `AGENTS.md` steers an authoring agent to the diff --git a/packages/create-threenative/CLAUDE.md b/packages/create-threenative/CLAUDE.md index 74bcc831a3..84948a6d78 100644 --- a/packages/create-threenative/CLAUDE.md +++ b/packages/create-threenative/CLAUDE.md @@ -32,6 +32,7 @@ output look worse than vanilla. - `minimal` — no React, no UI folder. Core + physics: a test arena, a photographed sky and a rigged mannequin, with no HUD. - `starter` — adds React 19, Tailwind 4, `src/ui/`. The default. +- `rain` — a procedural coastal storm with a free-fly camera, weather controls and a React interface; no physics dependency. - `platformer` — adds `PathFollow3D` steering with avoidance. Nothing in `templates/` imports `@threenative/physics/navigation`: its Recast WASM is web/desktop only, and every kit is expected to run on all four targets, so each kit's `AGENTS.md` steers an authoring agent to the diff --git a/packages/create-threenative/README.md b/packages/create-threenative/README.md index cff6de0648..d646586f75 100644 --- a/packages/create-threenative/README.md +++ b/packages/create-threenative/README.md @@ -7,7 +7,7 @@ pnpm create threenative my-game ``` The default template is `starter`. Choose `minimal`, `platformer`, `action-rpg`, `tower-defense`, -`racing`, `sailing`, or `shooter` for a different starting point: +`racing`, `sailing`, `rain`, `shooter`, or `snow` for a different starting point: ```sh pnpm create threenative my-game --template minimal @@ -17,7 +17,9 @@ pnpm create threenative my-game --template action-rpg pnpm create threenative my-game --template tower-defense pnpm create threenative my-game --template racing pnpm create threenative my-game --template sailing +pnpm create threenative my-game --template rain pnpm create threenative my-game --template shooter +pnpm create threenative my-game --template snow ``` Prerequisites are Node.js 20.19.0 or newer and pnpm 10 or newer. npm is also supported for the diff --git a/packages/create-threenative/__tests__/doctor.spec.ts b/packages/create-threenative/__tests__/doctor.spec.ts index 6f14582f3f..d7a1309d2f 100644 --- a/packages/create-threenative/__tests__/doctor.spec.ts +++ b/packages/create-threenative/__tests__/doctor.spec.ts @@ -234,6 +234,18 @@ describe("threenative doctor", () => { expect(check(report, "dependencies").fix).toMatch(/install/i); }); + it("accepts a patch difference inside one 0.x series", () => { + const report = diagnoseProject( + snapshot({ + installedVersions: new Map([ + ["@threenative/core", "0.3.4"], + ["@threenative/physics", "0.3.5"], + ]), + }), + ); + expect(check(report, "versions").status).toBe("ok"); + }); + it("fails when installed @threenative packages disagree on version, naming both", () => { const report = diagnoseProject( snapshot({ diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/atlas.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/atlas.playtest.json new file mode 100644 index 0000000000..c48f47c7ea --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/atlas.playtest.json @@ -0,0 +1,107 @@ +{ + "name": "vq11-atlas", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 90 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + }, + { + "entity": "decals", + "component": "atlasFade", + "equals": true, + "allowTrivial": "The same atlas is sampled through two distinct authored UV rectangles." + }, + { + "entity": "decals", + "component": "averageOpacity", + "equals": 1, + "allowTrivial": "Observe actual cloned material opacity and exact expiry, not a timer counter." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.1 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.12, + "maxDarkPixelRatio": 0.65 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/expired.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/expired.playtest.json new file mode 100644 index 0000000000..2d4ac86c00 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/expired.playtest.json @@ -0,0 +1,123 @@ +{ + "name": "vq11-expired", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 90 + }, + { + "kind": "input", + "label": "start-fade", + "press": "KeyF", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "expired", + "waitFrames": 180 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 0, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "equals": 0 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + }, + { + "entity": "decals", + "component": "atlasFade", + "equals": true, + "allowTrivial": "The same atlas is sampled through two distinct authored UV rectangles." + }, + { + "entity": "decals", + "component": "averageOpacity", + "equals": 0, + "allowTrivial": "Observe actual cloned material opacity and exact expiry, not a timer counter." + }, + { + "entity": "decals", + "component": "released", + "equals": 321, + "allowTrivial": "Every projected buffer is released by eviction or authored expiry." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "maxDarkPixelRatio": 0.001 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "maxDarkPixelRatio": 0.001 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/fading.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/fading.playtest.json new file mode 100644 index 0000000000..b1c68a617a --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/fading.playtest.json @@ -0,0 +1,142 @@ +{ + "name": "vq11-fading", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 90 + }, + { + "kind": "input", + "label": "start-fade", + "press": "KeyF", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "fading", + "waitFrames": 60 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + }, + { + "entity": "decals", + "component": "atlasFade", + "equals": true, + "allowTrivial": "The same atlas is sampled through two distinct authored UV rectangles." + }, + { + "entity": "decals", + "component": "averageOpacity", + "gte": 0.4, + "lte": 0.6, + "allowTrivial": "Observe actual cloned material opacity and exact expiry, not a timer counter." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "maxDarkPixelRatio": 0.001 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "maxDarkPixelRatio": 0.001 + } + }, + { + "region": { + "x": 280, + "y": 195, + "width": 150, + "height": 100, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.65, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.3 + } + }, + { + "region": { + "x": 555, + "y": 195, + "width": 85, + "height": 80, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.6, + "minDarkPixelRatio": 0.1, + "maxDarkPixelRatio": 0.65 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/game.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/game.ts new file mode 100644 index 0000000000..a93d66e366 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/game.ts @@ -0,0 +1,344 @@ +import { + AmbientLight, + BoxGeometry, + type BufferGeometry, + Color, + DataTexture, + DirectionalLight, + Mesh, + MeshStandardMaterial, + type Object3D, + type PerspectiveCamera, + RGBAFormat, + UnsignedByteType, + Vector3, +} from "three"; +import type { WebGPURenderer } from "three/webgpu"; +import { type ICtx, Scene, baseGeometryOf, defineGame } from "../../../../core/dist/index.js"; +import { playtest } from "../../../../core/dist/playtest.js"; +import { DecalField, bulletHoleTexture } from "../../../templates/shooter/src/render/decals.js"; + +type ResidencySample = { renderCalls: number; geometries: number; textures: number; bytes: number }; + +/** The framework owns the loop: cumulative render calls, not info.frame or fixed ticks, advance. */ +export function observeResidency( + previous: ResidencySample & { stableFrames: number }, + current: ResidencySample, + ready: boolean, +): void { + if (!Object.values(current).every((value) => Number.isFinite(value) && value >= 0)) + throw new Error("Decal fixture renderer residency is unavailable."); + if (!ready) { + Object.assign(previous, current, { stableFrames: 0 }); + return; + } + if (current.renderCalls === previous.renderCalls) return; + const stable = + current.geometries === previous.geometries && + current.textures === previous.textures && + current.bytes === previous.bytes; + Object.assign(previous, current, { stableFrames: stable ? previous.stableFrames + 1 : 1 }); +} + +/** Portable, opt-in runtime proof. Neither the shooter entry nor its authored look is changed. */ +export function createDecalFixture(hideDecals = false, atlasFade = false, hideDuringFade = false) { + let generation = 0; + const residency: (ResidencySample & { generation: number; stableFrames: number })[] = []; + class DecalRoom extends Scene { + #source: Object3D | undefined; + #dispose = () => {}; + #observeRenderedFrame = () => {}; + + override async load(ctx: ICtx): Promise { + const model = await ctx.assets.model<{ scene: Object3D }>("receiver.glb"); + this.#source = model.scene; + } + + override enter(ctx: ICtx) { + generation += 1; + ctx.state.set({ residencyGeneration: 0 }); + const source = this.#source?.getObjectByProperty("isMesh", true); + if (!(source instanceof Mesh)) throw new Error("Decal fixture receiver did not load."); + const camera = ctx.camera as PerspectiveCamera; + camera.position.set(0, 4.5, 12); + camera.lookAt(0, 2.3, 0); + ctx.scene.background = new Color(0x17232f); + const materials = [0xc7b493, 0x84a9ad, 0x36485a].map( + (color) => new MeshStandardMaterial({ color, roughness: 0.9 }), + ); + const left = source.clone(); + const right = source.clone(); + left.material = materials[0] as MeshStandardMaterial; + right.material = materials[1] as MeshStandardMaterial; + left.position.set(-2.5, 2.4, 0); + right.position.set(2.5, 2.4, 0); + ctx.add(left); + ctx.add(right); + const floorGeometry = new BoxGeometry(15, 0.15, 10); + const floor = new Mesh(floorGeometry, materials[2]); + floor.position.y = -0.1; + ctx.add(floor); + const sun = new DirectionalLight(0xffefd2, 3); + sun.position.set(2, 7, 8); + ctx.add(sun); + ctx.add(new AmbientLight(0xdbeaff, 1.5)); + let map = bulletHoleTexture(64) as DataTexture; + if (atlasFade) { + // A two-cell authored atlas: impact on the left, a solid environment mark on the right. + // Their different pixel coverage makes selecting the wrong atlas region observable. + const data = new Uint8Array(128 * 64 * 4); + const impact = map.image.data; + if (impact === null) throw new Error("Decal fixture impact pixels are unavailable."); + for (let y = 0; y < 64; y += 1) { + for (let x = 0; x < 64; x += 1) { + const target = (y * 128 + x) * 4; + data.set(impact.subarray((y * 64 + x) * 4, (y * 64 + x + 1) * 4), target); + data.set([20, 20, 20, x >= 3 && x < 61 && y >= 3 && y < 61 ? 255 : 0], target + 256); + } + } + map.dispose(); + map = new DataTexture(data, 128, 64, RGBAFormat, UnsignedByteType); + map.needsUpdate = true; + } + const field = new DecalField(ctx.scene, { + countPerVariant: 256, + map, + offset: 0.008, + size: 0.19, + tints: { impact: 0xffffff }, + }); + field.settle(); + let created = 0; + let released = 0; + const stamp = (receiver: Mesh, x: number, y: number): void => { + receiver.updateWorldMatrix(true, false); + const origin = receiver.localToWorld(new Vector3(x, y, 2)); + const direction = new Vector3(0, 0, -1).transformDirection(receiver.matrixWorld); + const hit = ctx.raycast({ + origin, + direction, + targets: receiver, + exclude: receiver.children, + }); + if (hit?.object !== receiver || hit.face === null || hit.face === undefined) { + throw new Error("Decal fixture lost its authored picking surface."); + } + const normal = hit.face.normal.clone().transformDirection(receiver.matrixWorld); + if ( + !field.project(receiver, baseGeometryOf(receiver), hit.point, normal, "impact", { + depth: 0.1, + ...(atlasFade + ? { + uvRect: receiver === left ? ([0, 0, 0.5, 1] as const) : ([0.5, 0, 1, 1] as const), + fade: { duration: 2, opacity: (progress: number) => 1 - progress }, + } + : {}), + }) + ) { + throw new Error("Decal fixture projection unexpectedly missed."); + } + const slot = receiver.children.at(-1); + if (!(slot instanceof Mesh)) throw new Error("Decal fixture has no projected slot."); + // Negative control: preserve real picking, projection, allocations and observations, + // but suppress the material's actual draw. The pixel gate must reject this scene. + slot.material.visible = !hideDecals; + created += 1; + slot.geometry.addEventListener("dispose", () => { + released += 1; + }); + }; + for (let hit = 0; hit < 320; hit += 1) { + const index = Math.floor(hit / 2); + stamp( + hit % 2 === 0 ? left : right, + -1.6 + (index % 16) * 0.213, + -1.45 + Math.floor(index / 16) * 0.32, + ); + } + let moving = false; + let fading = false; + let elapsed = 0; + let frames = 0; + let motionError = 0; + let lodHit = false; + let maxDrawCalls = 0; + let residencySampled = false; + const residencyObservation = { + renderCalls: (ctx.renderer.raw as WebGPURenderer).info.render.calls, + stableFrames: 0, + geometries: -1, + textures: -1, + bytes: -1, + }; + let rendered: (ResidencySample & { drawCalls: number; ready: boolean }) | undefined; + // This fixture has no depth-coupled output: Scene.render runs after its world draw. + // Capture before Three's Animation callback can reset per-frame draw counters again. + this.#observeRenderedFrame = () => { + const info = (ctx.renderer.raw as WebGPURenderer).info; + if (info.render.calls === residencyObservation.renderCalls) return; + rendered = { + renderCalls: info.render.calls, + drawCalls: info.render.drawCalls, + geometries: info.memory.geometries, + textures: info.memory.textures, + bytes: info.memory.total, + ready: + lodHit && left.geometry.index?.count === 6 && info.render.drawCalls >= field.capacity, + }; + }; + const base = baseGeometryOf(left); + if (base.index?.count !== 384) + throw new Error("Decal fixture LOD0 must contain 128 triangles."); + const observation = () => { + const baseline = residency[1]; + let geometryBytes = 0; + let active = 0; + let opacitySum = 0; + for (const receiver of [left, right]) { + for (const child of receiver.children) { + if (!(child instanceof Mesh)) continue; + active += 1; + opacitySum += child.material.opacity; + const geometry: BufferGeometry = child.geometry; + for (const attribute of Object.values(geometry.attributes)) { + geometryBytes += attribute.array.byteLength; + } + geometryBytes += geometry.index?.array.byteLength ?? 0; + } + } + return { + generation, + active, + capacity: field.capacity, + created, + released, + geometryBytes, + maxDrawCalls, + motionError, + receiverMotion: Math.abs(right.rotation.y) + Math.abs(right.position.y - 2.4), + lodHit, + renderTriangles: (left.geometry.index?.count ?? 0) / 3, + receiverRemoved: right.parent === null, + atlasFade, + averageOpacity: active === 0 ? 0 : opacitySum / active, + residency, + residencySamples: residency.length, + stableResidencyFrames: residencyObservation.stableFrames, + // Compare after the first reset, so one-time renderer startup caches are warmed. + ...(baseline === undefined + ? {} + : { + geometryGrowth: Math.max( + 0, + ...residency.slice(1).map((sample) => sample.geometries - baseline.geometries), + ), + textureGrowth: Math.max( + 0, + ...residency.slice(1).map((sample) => sample.textures - baseline.textures), + ), + memoryGrowth: Math.max( + 0, + ...residency.slice(1).map((sample) => sample.bytes - baseline.bytes), + ), + }), + }; + }; + ctx.entities.add("decals", { debug: observation }); + this.#dispose = () => { + field.dispose(); + map.dispose(); + floorGeometry.dispose(); + for (const material of materials) material.dispose(); + }; + return (frame: ICtx, dt: number) => { + // Consume only on the next update. Native replays/submits the previous RAF stream + // before its next timer/mailbox/update iteration; fixed ticks alone cannot advance this. + if (rendered !== undefined) { + const { drawCalls, ready, ...sample } = rendered; + maxDrawCalls = Math.max(maxDrawCalls, drawCalls); + observeResidency(residencyObservation, sample, ready); + rendered = undefined; + } + if (frame.input.justPressed("motion")) moving = !moving; + if (frame.input.justPressed("fade")) { + fading = true; + // Suppress rendering only after the control has shown its real marks. The opacity + // curve and owned geometry continue normally, so only positive fade pixels can reject it. + if (hideDuringFade) + for (const receiver of [left, right]) { + for (const child of receiver.children) + if (child instanceof Mesh) child.material.visible = false; + } + } + if (frame.input.justPressed("remove")) right.removeFromParent(); + if (frame.input.justPressed("reset")) { + void frame.goto("room"); + return; + } + if (moving) { + elapsed += dt; + right.rotation.y = Math.sin(elapsed) * 0.5; + right.position.y = 2.4 + Math.sin(elapsed * 1.5) * 0.35; + } + field.update(fading ? dt : 0); + right.updateWorldMatrix(true, true); + const mark = right.children[0]; + if (mark instanceof Mesh) { + const local = new Vector3().fromBufferAttribute( + mark.geometry.getAttribute("position"), + 0, + ); + motionError = Math.max( + motionError, + mark.localToWorld(local.clone()).distanceTo(right.localToWorld(local)), + ); + } + if (!lodHit && left.geometry !== base) { + stamp(left, 1.7, 1.7); + lodHit = true; + } + frames += 1; + if (!residencySampled && residencyObservation.stableFrames >= 3) { + residency.push({ + generation, + ...residencyObservation, + }); + // Retain the warm baseline and the latest samples even during manual repeated resets. + if (residency.length > 8) residency.splice(2, 1); + residencySampled = true; + ctx.state.set({ residencyGeneration: generation }); + console.info(`TN_DECAL_FIXTURE:${JSON.stringify(observation())}`); + } + // Native exposes state resources, not the browser's component assertion family. + ctx.state.set({ ...observation(), residencyGeneration: residencySampled ? generation : 0 }); + if (frames % 30 === 0) console.info(`TN_DECAL_FIXTURE:${JSON.stringify(observation())}`); + }; + } + + override render(): void { + this.#observeRenderedFrame(); + } + + override exit(): void { + this.#observeRenderedFrame = () => {}; + this.#dispose(); + this.#dispose = () => {}; + } + } + return defineGame({ + camera: { far: 100, fov: 48, near: 0.1, projection: "perspective" }, + display: { maxFps: 60 }, + initialState: { residencyGeneration: 0 }, + input: { + motion: { keys: ["KeyM"] }, + remove: { keys: ["KeyX"] }, + reset: { keys: ["KeyR"] }, + fade: { keys: ["KeyF"] }, + }, + plugins: [playtest({ holdUntilAttached: true })], + renderer: { preferWebGPU: true }, + scenes: { room: DecalRoom }, + seed: 11, + start: "room", + }); +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/index.html b/packages/create-threenative/__tests__/fixtures/bounded-decals/index.html new file mode 100644 index 0000000000..1d6085dc63 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/index.html @@ -0,0 +1,2 @@ + +VQ11 bounded receiver decals diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/lifecycle.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/lifecycle.playtest.json new file mode 100644 index 0000000000..e2c75327e8 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/lifecycle.playtest.json @@ -0,0 +1,215 @@ +{ + "name": "vq11-lifecycle", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "label": "rendered-residency-1", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 1 + }, + "timeoutMs": 30000, + "release": true + }, + { + "kind": "input", + "label": "reset-1", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "label": "rendered-residency-2", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 2 + }, + "timeoutMs": 30000, + "release": true + }, + { + "kind": "input", + "label": "reset-2", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "label": "rendered-residency-3", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true + }, + { + "kind": "input", + "label": "reset-3", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "label": "rendered-residency-4", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 4 + }, + "timeoutMs": 30000, + "release": true + }, + { + "kind": "input", + "label": "reset-4", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "label": "rendered-residency-5", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 5 + }, + "timeoutMs": 30000, + "release": true + }, + { + "kind": "input", + "label": "reset-5", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "label": "rendered-residency-6", + "waitForResource": { + "id": "state", + "path": "residencyGeneration", + "gte": 6 + }, + "timeoutMs": 30000, + "release": true + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 6, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + }, + { + "entity": "decals", + "component": "residencySamples", + "equals": 6, + "allowTrivial": "One memory sample per scene after three stable completed world-render intervals and real LOD/cap readiness." + }, + { + "entity": "decals", + "component": "geometryGrowth", + "lte": 0, + "allowTrivial": "GPU resource counts and bytes must not grow across the five warmed scene instances." + }, + { + "entity": "decals", + "component": "textureGrowth", + "lte": 0, + "allowTrivial": "GPU resource counts and bytes must not grow across the five warmed scene instances." + }, + { + "entity": "decals", + "component": "memoryGrowth", + "lte": 0, + "allowTrivial": "GPU resource counts and bytes must not grow across the five warmed scene instances." + }, + { + "entity": "decals", + "component": "stableResidencyFrames", + "gte": 3, + "allowTrivial": "Fixed simulation ticks cannot substitute for completed render intervals." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/main.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/main.ts new file mode 100644 index 0000000000..42e132d891 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/main.ts @@ -0,0 +1,17 @@ +import { createDecalFixture } from "./game.js"; +const params = new URLSearchParams(location.search); +const game = createDecalFixture( + params.get("hideDecals") === "1", + params.get("atlasFade") === "1", + params.get("hideFading") === "1", +); +void game + .start() + .then(() => { + const canvas = game.ctx?.renderer.domElement; + if (canvas === undefined) throw new Error("Decal fixture has no canvas."); + document.body.append(canvas); + }) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/motion.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/motion.playtest.json new file mode 100644 index 0000000000..969878b789 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/motion.playtest.json @@ -0,0 +1,119 @@ +{ + "name": "vq11-motion", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 40 + }, + { + "kind": "input", + "label": "motion", + "press": "KeyM", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitFrames": 90 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "motionError", + "lte": 1e-6, + "allowTrivial": "Measures mark vertices against receiver-local coordinates during actual rigid motion." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + }, + { + "entity": "decals", + "component": "receiverMotion", + "allowTrivial": "The actual receiver transform must change, so a dropped motion input cannot pass.", + "gte": 0.1 + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/native-atlas.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-atlas.ts new file mode 100644 index 0000000000..dfba335989 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-atlas.ts @@ -0,0 +1,2 @@ +import { createDecalFixture } from "./game.js"; +export default createDecalFixture(false, true); diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden-fading.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden-fading.ts new file mode 100644 index 0000000000..5fefa02601 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden-fading.ts @@ -0,0 +1,2 @@ +import { createDecalFixture } from "./game.js"; +export default createDecalFixture(false, true, true); diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden.ts new file mode 100644 index 0000000000..9a38758b0c --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-hidden.ts @@ -0,0 +1,2 @@ +import { createDecalFixture } from "./game.js"; +export default createDecalFixture(true); diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/native-proof.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-proof.ts new file mode 100644 index 0000000000..4fdb7079fa --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/native-proof.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { PNG } from "pngjs"; +import { + type IPlaytestScenario, + evaluateRichPlaytestAssertions, +} from "../../../../playtest/dist/index.js"; +import type { IStandalonePlaytestReport } from "../../../../playtest/dist/runner/index.js"; +import { screenshotObservations } from "../../../../playtest/src/runner/steps.js"; + +export const nativeDecalCases = [ + ...["static", "motion", "teardown", "restart", "lifecycle"].map((name) => ({ + name, + scenario: name, + entry: "native.ts", + expectedPixelFailures: [] as string[], + })), + ...["atlas", "fading", "expired"].map((name) => ({ + name, + scenario: name, + entry: "native-atlas.ts", + expectedPixelFailures: [] as string[], + })), + { + name: "hidden-decals", + scenario: "static", + entry: "native-hidden.ts", + expectedPixelFailures: ["visual.1.region.darkPixels", "visual.2.region.darkPixels"], + }, + { + name: "hidden-fading", + scenario: "fading", + entry: "native-hidden-fading.ts", + expectedPixelFailures: ["visual.3.region.darkPixels", "visual.4.region.darkPixels"], + }, +]; + +/** Native supports resources/startup; pixel predicates remain external and unchanged. */ +export function nativeDecalScenario(scenario: IPlaytestScenario): IPlaytestScenario { + assert.ok(scenario.assert?.components?.length, "Native decals require state predicates."); + assert.ok( + scenario.assert.components.every(({ entity }) => entity === "decals"), + "Unmapped native entity.", + ); + assert.ok( + Object.keys(scenario.assert).every((key) => + ["components", "visual", "diagnostics"].includes(key), + ), + "Unmapped native assertion family.", + ); + const { sourcePath: _sourcePath, ...portable } = scenario; + return { + ...portable, + target: "desktop", + artifacts: { screenshots: "after", console: true }, + assert: { + resources: scenario.assert.components.map(({ entity: _entity, component, ...predicate }) => ({ + id: "state", + path: component, + ...predicate, + })), + startup: { maxEnteredMs: 120_000, maxReadyMs: 120_000 }, + }, + }; +} + +export function assertNativeDecalCapture( + report: Pick< + IStandalonePlaytestReport, + "pass" | "runtime" | "target" | "diagnostics" | "capture" | "startup" | "assertionResults" + >, + nativeConsole: unknown, +): void { + assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); + assert.equal(report.runtime, "native"); + assert.equal(report.target, "desktop"); + assert.equal(report.startup?.phase, "ready"); + assert.equal(report.startup.compileSettled, true); + assert.ok(report.assertionResults?.length, "Native assertions are missing."); + assert.ok( + report.assertionResults.every(({ pass }) => pass), + "Native assertion failed.", + ); + const capture = report.capture; + assert.ok(capture, "Native capture provenance is missing."); + assert.equal(capture.target, "desktop"); + assert.equal(capture.captureMethod, "device.screenshot"); + assert.equal(capture.rendererKind, "webgpu"); + assert.deepEqual(capture.viewport, { width: 960, height: 540 }); + assert.ok( + ["vendor", "architecture", "device", "description"].some((key) => { + const value = capture.adapter[key]; + return ( + typeof value === "string" && + value.trim() !== "" && + !/^(unknown|unavailable|0|0x0)$/iu.test(value) + ); + }), + "Native adapter identity is missing.", + ); + for (const diagnostic of report.diagnostics) { + assert.notEqual(diagnostic.severity, "error", diagnostic.code); + assert.doesNotMatch(diagnostic.code, /DEVICE_LOST|BRIDGE_MISSING/u); + } + assert.ok(Array.isArray(nativeConsole) && nativeConsole.length > 0, "Native console is missing."); + for (const entry of nativeConsole) { + assert.ok( + entry && typeof entry.text === "string" && typeof entry.type === "string", + "Malformed native console.", + ); + assert.notEqual(entry.type, "error", entry.text); + assert.doesNotMatch( + entry.text, + /\[FATAL\]|\[WebGPU\].*(?:Device error|Device lost|Failed)|validation error|device(?:[ _-]| was )?lost|(?:Type|Reference|Range|Syntax)Error|TN_(?:NATIVE_START_FAILED|ASSETS_UNRESOLVED|NATIVE_KTX2_UNSUPPORTED|NATIVE_MESH_COMPRESSION_UNSUPPORTED)/iu, + ); + } + assert.ok( + nativeConsole.some((entry) => entry.text.includes("TN_NATIVE_SMOKE_FIRST_FRAME")), + "Native first frame is missing.", + ); +} + +/** Pure PNG measurement using the maintained evaluator, not a native capability claim. */ +export function evaluateDecalPixels(png: Buffer, authored: IPlaytestScenario) { + const image = PNG.sync.read(png); + assert.equal(image.width, authored.viewport.width, "Actual screenshot width"); + assert.equal(image.height, authored.viewport.height, "Actual screenshot height"); + assert.ok(authored.assert?.visual?.length, "Visible decal predicates are missing."); + const scenario = { ...authored, assert: { visual: authored.assert.visual } }; + return evaluateRichPlaytestAssertions({ + scenario, + report: { + diagnostics: [], + distance: 0, + entity: "", + expectMoved: false, + frames: 1, + trivialityOptOuts: [], + observations: { + console: [], + hud: {}, + network: [], + resources: {}, + visual: screenshotObservations(undefined, png, scenario), + }, + }, + }); +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/native.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/native.ts new file mode 100644 index 0000000000..4369be64a0 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/native.ts @@ -0,0 +1,2 @@ +import { createDecalFixture } from "./game.js"; +export default createDecalFixture(); diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/package.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/package.json new file mode 100644 index 0000000000..d221be7a14 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/package.json @@ -0,0 +1,6 @@ +{ + "name": "vq11-bounded-decals-fixture", + "private": true, + "type": "module", + "threenative": { "nativeEntry": "native.ts" } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/public/assets.manifest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/public/assets.manifest.json new file mode 100644 index 0000000000..c86e13268b --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/public/assets.manifest.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "entries": { + "receiver.glb": { + "output": "receiver.glb", + "lod": { + "runtime": { + "maxPixelError": 1, + "hysteresis": 0.15 + } + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/public/receiver.glb b/packages/create-threenative/__tests__/fixtures/bounded-decals/public/receiver.glb new file mode 100644 index 0000000000..7dcc89a384 Binary files /dev/null and b/packages/create-threenative/__tests__/fixtures/bounded-decals/public/receiver.glb differ diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/restart.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/restart.playtest.json new file mode 100644 index 0000000000..4b79543e1c --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/restart.playtest.json @@ -0,0 +1,107 @@ +{ + "name": "vq11-restart", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 40 + }, + { + "kind": "input", + "label": "restart", + "press": "KeyR", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitFrames": 90 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 2, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/static.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/static.playtest.json new file mode 100644 index 0000000000..b070b1904c --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/static.playtest.json @@ -0,0 +1,95 @@ +{ + "name": "vq11-static", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 90 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 256, + "allowTrivial": "Real projected marks survive more than 256 impacts and eviction." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + }, + { + "region": { + "x": 550, + "y": 190, + "width": 95, + "height": 90, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/teardown.playtest.json b/packages/create-threenative/__tests__/fixtures/bounded-decals/teardown.playtest.json new file mode 100644 index 0000000000..ba4e255b27 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/teardown.playtest.json @@ -0,0 +1,101 @@ +{ + "name": "vq11-teardown", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 960, + "height": 540 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "lod-and-pool", + "waitFrames": 40 + }, + { + "kind": "input", + "label": "teardown", + "press": "KeyX", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitFrames": 90 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "decals", + "component": "active", + "equals": 128, + "allowTrivial": "Removing one receiver releases its remaining 128 marks while the other retains its half." + }, + { + "entity": "decals", + "component": "created", + "gte": 321, + "allowTrivial": "320 initial marks plus a real post-LOD raycast hit." + }, + { + "entity": "decals", + "component": "geometryBytes", + "allowTrivial": "Measures actual projected GPU-upload buffer bytes, not an empty field.", + "gte": 1 + }, + { + "entity": "decals", + "component": "renderTriangles", + "equals": 2, + "allowTrivial": "The loaded 128-triangle authored receiver actually selects its two-triangle LOD." + }, + { + "entity": "decals", + "component": "released", + "gte": 193, + "allowTrivial": "65 cap evictions plus 128 receiver-owned projections." + }, + { + "entity": "decals", + "component": "generation", + "equals": 1, + "allowTrivial": "The restart input must construct a new scene; unchanged initial state is not evidence." + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 960, + "height": 540, + "minNonblankPixelRatio": 0.15 + } + }, + { + "region": { + "x": 270, + "y": 185, + "width": 170, + "height": 125, + "minNonblankPixelRatio": 0.9, + "maxLuminance": 0.35, + "minDarkPixelRatio": 0.01, + "maxDarkPixelRatio": 0.2 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts new file mode 100644 index 0000000000..59fef526fb --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPlaytestScenario } from "../../../../playtest/dist/index.js"; +import { runDesktopPlaytest } from "../../../../playtest/dist/runner/index.js"; +import { + assertNativeDecalCapture, + evaluateDecalPixels, + nativeDecalCases, + nativeDecalScenario, +} from "./native-proof.js"; + +const fixture = dirname(fileURLToPath(import.meta.url)); +const root = resolve(fixture, "../../../../.."); +const artifacts = join(root, "artifacts/vq11-decals-native"); +const bundleOnly = process.argv.includes("--bundle-only"); +const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", +}).trim(); +const hash = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); +const fileHash = async (path: string) => hash(await readFile(path)); +const write = async (path: string, value: unknown) => + writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +const run = (script: string, args: string[]) => + execFileSync(process.execPath, [script, ...args], { cwd: root, stdio: "inherit" }); +await mkdir(artifacts, { recursive: true }); +if (!bundleOnly) + assert.equal( + execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim(), + "", + "Native capture requires a clean committed source tree.", + ); +const summary: { + sourceSha: string; + pass: boolean; + qualification: string; + results: unknown[]; + identity?: unknown; + error?: string; +} = { + sourceSha, + pass: false, + qualification: + "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.", + results: [], +}; +await write(join(artifacts, "attempt.json"), { + sourceSha, + runId: process.env.GITHUB_RUN_ID, + bundleOnly, +}); +try { + const bundles = new Map(); + for (const entry of new Set(nativeDecalCases.map((item) => item.entry))) { + const bundle = join(artifacts, "variants", entry.replace(".ts", ".js")); + run("packages/runtime-native/scripts/bundle.mjs", [ + "--project", + fixture, + "--entry", + entry, + "--target", + "desktop", + "--native-backend", + "--output", + bundle, + ]); + bundles.set(entry, bundle); + } + if (bundleOnly) { + console.info("VQ11 native bundles built; no native execution or screenshot claim."); + } else { + const runtime = process.env.THREENATIVE_RUNTIME_BINARY; + assert.ok(runtime, "THREENATIVE_RUNTIME_BINARY must name this job's actual native build."); + assert.equal(process.platform, "linux"); + assert.equal(process.arch, "arm64"); + const version = execFileSync(runtime, ["--version"], { encoding: "utf8", timeout: 10_000 }); + assert.match(version, /\+ quickjs build\b/iu, "Expected the actual ARM QuickJS host."); + const capabilities = { engine: "quickjs", version: version.trim() }; + const runtimeBytes = await readFile(runtime); + const assets = ["receiver.glb", "assets.manifest.json"]; + const assetHashes = Object.fromEntries( + await Promise.all( + assets.map(async (name) => [name, await fileHash(join(fixture, "public", name))]), + ), + ); + const config = join(artifacts, "packaging-config.json"); + await write(config, { + app: { id: "com.threenative.vq11", name: "VQ11 bounded decals", version: "1.0.0", build: 1 }, + display: { fullscreen: false, maxFps: 60 }, + window: { width: 960, height: 540, maximized: false, resizable: false }, + renderer: { preferWebGPU: true, resolutionScale: 1 }, + ui: { renderer: "native" }, + }); + const executables = new Map< + string, + { path: string; sha256: string; bundle: string; bundleSha256: string } + >(); + for (const [entry, bundle] of bundles) { + const executable = join(artifacts, "variants", entry.replace(".ts", ""), "decals"); + run("packages/runtime-native/scripts/package-desktop.mjs", [ + "--bundle", + bundle, + "--assets", + join(fixture, "public"), + "--runtime", + runtime, + "--config", + config, + "--mode", + "debug", + "--output", + executable, + ]); + const packaged = await readFile(executable); + assert.equal( + hash(packaged.subarray(0, runtimeBytes.length)), + hash(runtimeBytes), + "Package must carry the exact selected runtime prefix.", + ); + executables.set(entry, { + path: executable, + sha256: hash(packaged), + bundle, + bundleSha256: await fileHash(bundle), + }); + } + summary.identity = { + runtimeSha256: hash(runtimeBytes), + capabilities, + assetHashes, + executables: Object.fromEntries(executables), + }; + await write(join(artifacts, "summary.json"), summary); + for (const item of nativeDecalCases) { + const authored = await loadPlaytestScenario(fixture, `${item.scenario}.playtest.json`); + const directory = join(artifacts, item.name); + await mkdir(directory, { recursive: true }); + const scenarioPath = join(directory, "scenario.playtest.json"); + await write(scenarioPath, nativeDecalScenario(authored)); + await loadPlaytestScenario(root, scenarioPath); + const executable = executables.get(item.entry); + assert.ok(executable, "Native variant was not packaged."); + const report = await runDesktopPlaytest({ + target: "desktop", + projectPath: fixture, + scenarioPath, + artifactDirectory: directory, + desktop: { + executable: executable.path, + hostArgs: ["--windowed", "--width", "960", "--height", "540"], + }, + allowSoftwareAdapter: true, + headless: false, + timeoutMs: 180_000, + trace: false, + url: "", + }); + await write(join(directory, "report.json"), { sourceSha, ...report }); + // A crashed host can omit console.json; preserve/report its real diagnostics first. + const nativeConsole: unknown = await readFile(join(directory, "console.json"), "utf8") + .then(JSON.parse) + .catch(() => undefined); + assertNativeDecalCapture(report, nativeConsole); + const png = await readFile(join(directory, "after.png")); + const pixels = evaluateDecalPixels(png, authored); + const failed = pixels.assertions + .filter(({ pass }) => !pass) + .map(({ id }) => id) + .sort(); + const pass = JSON.stringify(failed) === JSON.stringify(item.expectedPixelFailures); + summary.results.push({ + name: item.name, + pass, + negativeControl: item.expectedPixelFailures.length > 0, + capture: report.capture, + state: report.observations?.resources?.state?.after, + screenshotSha256: hash(png), + pixels, + diagnostics: report.diagnostics, + }); + await write(join(artifacts, "summary.json"), summary); + assert.ok(pass, `Native ${item.name} pixel predicates failed: ${JSON.stringify(failed)}`); + assert.equal( + await fileHash(executable.path), + executable.sha256, + "Packaged executable changed during capture.", + ); + } + assert.equal(await fileHash(runtime), hash(runtimeBytes), "Runtime changed during capture."); + for (const [name, expected] of Object.entries(assetHashes)) + assert.equal( + await fileHash(join(fixture, "public", name)), + expected, + "Receiver assets changed.", + ); + for (const entry of executables.values()) { + assert.equal(await fileHash(entry.path), entry.sha256); + assert.equal(await fileHash(entry.bundle), entry.bundleSha256); + } + summary.pass = true; + } +} catch (error) { + summary.error = error instanceof Error ? (error.stack ?? error.message) : String(error); + throw error; +} finally { + await write(join(artifacts, "summary.json"), summary); +} diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts new file mode 100644 index 0000000000..7b8d5d4140 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts @@ -0,0 +1,145 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; +import { + WEBGPU_BROWSER_ARGS, + runStandalonePlaytest, +} from "../../../../playtest/dist/runner/index.js"; + +const fixture = dirname(fileURLToPath(import.meta.url)); +const root = resolve(fixture, "../../../../.."); +const artifacts = join(root, "artifacts/vq11-decals"); +const site = join(artifacts, "site"); +await mkdir(artifacts, { recursive: true }); +await build({ configFile: false, root: fixture, build: { outDir: site, emptyOutDir: true } }); +if (!process.argv.includes("--build-only")) { + const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + const vite = join( + dirname(fileURLToPath(import.meta.resolve("vite/package.json"))), + "bin/vite.js", + ); + const results = []; + for (const name of [ + "static", + "motion", + "teardown", + "restart", + "atlas", + "fading", + "expired", + "lifecycle", + "hidden-decals", + "hidden-fading", + ]) { + const hiddenFading = name === "hidden-fading"; + const hidden = name === "hidden-decals" || hiddenFading; + const atlasFade = ["atlas", "fading", "expired", "hidden-fading"].includes(name); + const expired = name === "expired"; + const directory = join(artifacts, name); + const report = await runStandalonePlaytest({ + artifactDirectory: directory, + projectPath: fixture, + scenarioPath: join( + fixture, + `${hiddenFading ? "fading" : hidden ? "static" : name}.playtest.json`, + ), + url: `http://127.0.0.1:4173/${hiddenFading ? "?atlasFade=1&hideFading=1" : hidden ? "?hideDecals=1" : atlasFade ? "?atlasFade=1" : ""}`, + port: 0, + server: { + command: `${JSON.stringify(process.execPath)} ${JSON.stringify(vite)} preview --host 127.0.0.1 --port $PORT --strictPort --outDir ${JSON.stringify(site)}`, + cwd: fixture, + }, + timeoutMs: 180_000, + headless: false, + trace: false, + target: "browser", + browserArgs: WEBGPU_BROWSER_ARGS, + allowSoftwareAdapter: true, + captureArtifactScreenshots: true, + }); + // Keep real partial frames/report even when qualification fails. Never turn lost-device + // frames into passing proof just because the software-adapter runner downgraded a warning. + await writeFile( + join(directory, "report.json"), + `${JSON.stringify({ sourceSha, ...report }, null, 2)}\n`, + ); + const last = report.observations?.console + .filter((entry) => entry.text.startsWith("TN_DECAL_FIXTURE:")) + .at(-1); + const measurement = + last === undefined ? undefined : JSON.parse(last.text.slice("TN_DECAL_FIXTURE:".length)); + const screenshot = join(directory, "after.png"); + const screenshotPresent = await stat(screenshot) + .then((file) => file.size > 0) + .catch(() => false); + const disallowedDiagnostics = report.diagnostics.filter( + (diagnostic) => + diagnostic.severity === "error" || diagnostic.code === "TN_PLAYTEST_SOFTWARE_DEVICE_LOST", + ); + const observed = + report.capture?.rendererKind === "webgpu" && + Object.values(report.capture.adapter).some((value) => value.length > 0) && + disallowedDiagnostics.length === 0 && + screenshotPresent && + measurement !== undefined && + measurement.active === (expired ? 0 : name === "teardown" ? 128 : 256) && + measurement.capacity === 256 && + measurement.created >= 321 && + measurement.lodHit === true && + measurement.renderTriangles === 2 && + Number.isFinite(measurement.geometryBytes) && + (expired ? measurement.geometryBytes === 0 : measurement.geometryBytes > 0) && + Number.isFinite(measurement.maxDrawCalls) && + measurement.maxDrawCalls > 0 && + Number.isFinite(measurement.motionError) && + measurement.motionError <= 1e-6; + const failedAssertions = report.assertionResults?.filter((assertion) => !assertion.pass); + const negativeControlPassed = + hidden && + !report.pass && + report.capture?.rendererKind === "webgpu" && + Object.values(report.capture.adapter).some((value) => value.length > 0) && + screenshotPresent && + // Require both receiver interiors to lose their mark pixels while every counter, + // LOD, runtime and whole-frame assertion still passes. A crash is not a control. + JSON.stringify(failedAssertions?.map(({ id }) => id).sort()) === + JSON.stringify( + hiddenFading + ? ["visual.3.region.darkPixels", "visual.4.region.darkPixels"] + : ["visual.1.region.darkPixels", "visual.2.region.darkPixels"], + ) && + report.diagnostics.length === 2 && + report.diagnostics.every(({ code }) => code === "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED"); + results.push({ + name, + sourceSha, + qualified: !hidden && report.pass && observed, + ...(hidden ? { negativeControlPassed } : {}), + measurement, + capture: report.capture, + screenshot: screenshotPresent ? screenshot : null, + diagnostics: report.diagnostics, + visual: report.observations?.visual, + }); + await writeFile( + join(artifacts, "summary.json"), + `${JSON.stringify({ sourceSha, correctnessOnly: true, results }, null, 2)}\n`, + ); + } + if ( + results.some((result) => + result.name.startsWith("hidden-") ? !result.negativeControlPassed : !result.qualified, + ) + ) + throw new Error( + "VQ11 runtime qualification failed; retained frames are diagnostic only. Inspect artifacts/vq11-decals/summary.json.", + ); + console.info( + `VQ11_DECAL_PROOF ${JSON.stringify({ sourceSha, captures: results.length, hiddenDecalsRejected: true, hiddenFadingRejected: true, correctnessOnly: true })}`, + ); +} diff --git a/packages/create-threenative/__tests__/rain-audio.spec.ts b/packages/create-threenative/__tests__/rain-audio.spec.ts new file mode 100644 index 0000000000..727ab28e45 --- /dev/null +++ b/packages/create-threenative/__tests__/rain-audio.spec.ts @@ -0,0 +1,327 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audioPass } from "@threenative/assets"; +import { AudioContext, Object3D } from "three"; +import { describe, expect, it } from "vitest"; +import { + type IStormAudio, + STORM_AUDIO_ENTITY, + createStormAudio, +} from "../templates/rain/src/audio/storm.js"; +import type { GameState } from "../templates/rain/src/state.js"; +import config from "../templates/rain/threenative.config.js"; + +/** + * The storm's sound, as generated source, against the engine's real `AudioBus`. + * + * This imports `src/audio/storm.ts` itself rather than restating its maths: the helper is the + * deliverable, and a copy of it would keep passing after the file it was copied from broke. The + * fake `AudioContext` is the narrow surface `packages/core/__tests__/audio.spec.ts` builds — the + * one the native host binds — so a wiring that needs a browser-only node fails here. + * + * The second half cooks the template's own clips through the shared audio pass with the config's + * declarations, because the authored amplitudes are what the sound is: a peak above 1.0 and + * Float32 samples are choices this storm made, and a pipeline that quietly normalised or + * transcoded them would be as wrong as a mistuned constant. + */ + +interface IFakeParam { + value: number; + cancelScheduledValues(): void; + setValueAtTime(value: number): void; + linearRampToValueAtTime(value: number): void; + setTargetAtTime(value: number, at: number, tau: number): void; +} + +/** Every `setTargetAtTime` a param was asked for, as `[value, timeConstant]`. */ +let ramps: Array<[number, number]> = []; + +function param(value: number, scheduling = false): IFakeParam { + const fake: IFakeParam = { + value, + cancelScheduledValues() {}, + setValueAtTime(next) { + this.value = next; + }, + linearRampToValueAtTime(next) { + this.value = next; + }, + setTargetAtTime(next, _at, tau) { + ramps.push([next, tau]); + this.value = next; + }, + }; + // The native host binds no cancellable params; a browser has them. The default here is native, + // which is the surface a claim about the game has to survive. + if (scheduling) fake.cancelScheduledValues = () => undefined; + return fake; +} + +const TEMPLATE = path.resolve(fileURLToPath(import.meta.url), "../../templates/rain"); + +function fakeContext(): globalThis.AudioContext { + const context = { + createBufferSource: () => ({ + connect: () => undefined, + detune: param(0), + disconnect: () => undefined, + loop: false, + loopEnd: 0, + loopStart: 0, + onended: null as (() => void) | null, + playbackRate: param(1), + start: () => undefined, + stop: () => undefined, + }), + createDynamicsCompressor: () => ({ + attack: param(0.003), + connect: () => undefined, + disconnect: () => undefined, + knee: param(30), + ratio: param(12), + release: param(0.25), + threshold: param(-24), + }), + createGain: () => ({ connect: () => undefined, disconnect: () => undefined, gain: param(1) }), + createBiquadFilter: () => ({ + connect: () => undefined, + disconnect: () => undefined, + frequency: param(350), + type: "lowpass" as const, + }), + createPanner: () => ({ connect: () => undefined, disconnect: () => undefined }), + currentTime: 0, + destination: {}, + resume: async () => undefined, + } as unknown as globalThis.AudioContext; + AudioContext.setContext(context); + return context; +} + +const clip = { duration: 1 } as AudioBuffer; + +function state(over: Partial = {}): GameState { + return { + audioEnabled: true, + elapsed: 0, + muted: false, + safe: false, + weather: { cloud: 1, exposure: 1, fog: 1, rain: 1, wet: 1, wind: 1 }, + ...over, + } as GameState; +} + +/** Clips resolve a microtask after the first frame, and `unlock` is async. */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +async function armed(over: Partial = {}): Promise { + fakeContext(); + ramps = []; + const storm = createStormAudio({ + assets: { audio: async () => clip }, + camera: new Object3D(), + } as unknown as Parameters[0]); + await settle(); + storm.update(state(over)); + await settle(); + return storm; +} + +const debug = (storm: IStormAudio): Record => + storm.debug() as Record; + +describe("the storm audio entity", () => { + it("should start the two beds on the enable edge, through a compressor", async () => { + const storm = await armed(); + const reading = debug(storm); + + expect(reading.enabled).toBe(true); + expect(reading.voices).toBe(2); + // The source's `DynamicsCompressor`, asked for by name and not stood in for with gains. + expect(reading.compressor).toBe(true); + expect(reading.unsupported).toEqual([]); + expect(reading.loadError).toBe("none"); + expect(reading.unlockError).toBe("none"); + storm.dispose(); + }); + + it("should register under the name the game reaches it by", () => { + expect(STORM_AUDIO_ENTITY).toBe("storm-audio"); + }); + + it("should hold on pause and on visibility independently, releasing only when both clear", async () => { + const storm = await armed(); + + storm.setHidden(true); + expect(debug(storm).paused).toBe(true); + expect(debug(storm).pausedVoices).toBe(2); + + storm.setHidden(false); + expect(debug(storm).paused).toBe(false); + + // A tab that comes back must not resume a paused storm. + storm.setHidden(true); + storm.setSilenced(true); + storm.setHidden(false); + expect(debug(storm).paused).toBe(true); + + storm.setSilenced(false); + expect(debug(storm).paused).toBe(false); + storm.dispose(); + }); + + it("should take the beds down when audio is turned off, and bring them back when it returns", async () => { + const storm = await armed(); + + storm.update(state({ audioEnabled: false })); + expect(debug(storm).voices).toBe(0); + expect(debug(storm).queued).toBe(0); + + storm.update(state({ audioEnabled: true })); + await settle(); + expect(debug(storm).voices).toBe(2); + storm.dispose(); + }); + + it("should queue each strike for its own delay and fire it exactly once", async () => { + const storm = await armed(); + const delay = (metres: number): number => metres / 343; + + storm.queueStrike({ at: 10 + delay(3430), metres: 3430 }); + storm.queueStrike({ at: 10 + delay(100), metres: 100 }); + + storm.update(state({ elapsed: 10 })); + expect(debug(storm).pendingThunder).toBe(2); + expect(debug(storm).thunderVoices).toBe(0); + + // The nearer strike lands first: queueing is not last-write-wins. + storm.update(state({ elapsed: 10 + delay(100) })); + expect(debug(storm).pendingThunder).toBe(1); + expect(debug(storm).thunderVoices).toBe(1); + + storm.update(state({ elapsed: 10 + delay(100) })); + expect(debug(storm).thunderVoices).toBe(1); + + storm.update(state({ elapsed: 10 + delay(3430) })); + expect(debug(storm).pendingThunder).toBe(0); + expect(debug(storm).thunderVoices).toBe(2); + storm.dispose(); + }); + + it("should suppress thunder while lightning is gated, without leaving it queued", async () => { + const storm = await armed(); + storm.queueStrike({ at: 5, metres: 400 }); + + storm.update(state({ elapsed: 6, safe: true })); + expect(debug(storm).pendingThunder).toBe(0); + expect(debug(storm).thunderVoices).toBe(0); + + // Safe is a hold on the one strike, not a switch: lifting it lets the next one through. + storm.queueStrike({ at: 7, metres: 400 }); + storm.update(state({ elapsed: 8, safe: false })); + expect(debug(storm).thunderVoices).toBe(1); + storm.dispose(); + }); + + it("should ramp the bed gains with the source's own time constants", async () => { + const storm = await armed(); + ramps = []; + + storm.update( + state({ weather: { cloud: 1, exposure: 1, fog: 1, rain: 0.5, wet: 1, wind: 0.5 } }), + ); + expect([...new Set(ramps.map(([, tau]) => tau))].sort()).toEqual([0.25, 0.4]); + storm.dispose(); + }); + + it("should refuse a strike with no finite time or distance", async () => { + const storm = await armed(); + + expect(() => storm.queueStrike({ at: Number.NaN, metres: 1 })).toThrow(RangeError); + expect(() => storm.queueStrike({ at: 1, metres: -1 })).toThrow(RangeError); + storm.dispose(); + }); + + it("should report a missing clip rather than failing silently", async () => { + fakeContext(); + const storm = createStormAudio({ + assets: { audio: async () => Promise.reject(new Error("no such clip")) }, + camera: new Object3D(), + } as unknown as Parameters[0]); + await settle(); + storm.update(state()); + + expect(String(debug(storm).loadError)).toContain("no such clip"); + // Sound is not the storm: the weather still runs and the beds come up empty rather than throw. + expect(debug(storm).enabled).toBe(true); + storm.dispose(); + }); + + it("should silence every voice when the game disposes it", async () => { + const storm = await armed(); + storm.dispose(); + + expect(debug(storm).voices).toBe(0); + }); +}); + +interface IAuthoredWav { + readonly channels: number; + readonly frames: number; + readonly peak: number; + readonly sampleRate: number; +} + +/** A Float32 WAV as authored, read by hand so the check needs no encoder to agree with itself. */ +function authoredWav(bytes: Buffer): IAuthoredWav { + expect(bytes.subarray(0, 4).toString("ascii")).toBe("RIFF"); + expect(bytes.readUInt16LE(20)).toBe(3); + const channels = bytes.readUInt16LE(22); + const frames = (bytes.length - 44) / (channels * 4); + let peak = 0; + for (let frame = 0; frame < frames; frame += 1) + for (let channel = 0; channel < channels; channel += 1) { + const value = Math.abs(bytes.readFloatLE(44 + (frame * channels + channel) * 4)); + if (value > peak) peak = value; + } + return { channels, frames, peak, sampleRate: bytes.readUInt32LE(24) }; +} + +describe("the storm clips through the shared audio pass", () => { + const overrides = config.assets?.audio === "none" ? [] : (config.assets?.audio?.overrides ?? []); + + it("should declare every clip unconditioned, so the authored bytes ship", () => { + expect(overrides.map((override) => override.glob).sort()).toEqual([ + "rain-loop.wav", + "thunder.wav", + "wind-loop.wav", + ]); + for (const override of overrides) expect(override.conditioning).toBe("none"); + // The two beds are declared loops, so their seam is still measured and still asserted. + expect(overrides.filter((override) => override.loop === true)).toHaveLength(2); + }); + + it("should ship each clip byte for byte, Float32 samples and peak intact", async () => { + const pass = audioPass({ overrides }); + + for (const name of ["rain-loop.wav", "thunder.wav", "wind-loop.wav"]) { + const input = await readFile(path.join(TEMPLATE, "assets", name)); + const authored = authoredWav(input); + const output = await pass.apply(input, name); + + expect(Buffer.isBuffer(output)).toBe(false); + const cooked = output as { buffer: Buffer; entry?: { audio?: Record } }; + expect(cooked.buffer.equals(input)).toBe(true); + expect(cooked.entry?.audio).toMatchObject({ + conditioned: false, + container: "RIFF/WAVE", + reencoded: false, + }); + // Measured on the shipped bytes, so this is the amplitude the player gets rather than the + // one the source file was written with. + const shipped = authoredWav(cooked.buffer); + expect(shipped).toEqual(authored); + } + }); +}); diff --git a/packages/create-threenative/__tests__/rain-automation.spec.ts b/packages/create-threenative/__tests__/rain-automation.spec.ts new file mode 100644 index 0000000000..7cc72aa487 --- /dev/null +++ b/packages/create-threenative/__tests__/rain-automation.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + QUALITY_ALIASES, + automationRequest, + qualityTier, +} from "../templates/rain/src/ui/automation.js"; + +describe("rain automation quality vocabulary", () => { + it("accepts the source study's four names and the engine's four", () => { + expect(Object.keys(QUALITY_ALIASES).sort()).toEqual([ + "balanced", + "cinematic", + "high", + "low", + "performance", + "ultra", + ]); + expect(qualityTier("low")).toBe("performance"); + expect(qualityTier("cinematic")).toBe("ultra"); + expect(qualityTier("balanced")).toBe("balanced"); + expect(qualityTier("high")).toBe("high"); + }); + + it("refuses a name in neither vocabulary, and an inherited one", () => { + expect(() => qualityTier("ultra")).not.toThrow(); + expect(() => qualityTier("cinematic_")).toThrow(/TN_RAIN_QUALITY_UNKNOWN/); + expect(() => qualityTier("toString")).toThrow(/TN_RAIN_QUALITY_UNKNOWN/); + expect(() => qualityTier(3)).toThrow(/TN_RAIN_QUALITY_UNKNOWN/); + }); +}); + +describe("rain automation query", () => { + it("reads still and the tier alias together", () => { + expect(automationRequest("?still&quality=high")).toEqual({ quality: "high", still: true }); + expect(automationRequest("?quality=low")).toEqual({ quality: "performance", still: false }); + expect(automationRequest("")).toEqual({ still: false }); + }); + + it("names an unreadable tier instead of opening the page on a guessed one", () => { + expect(automationRequest("?quality=cinematiques")).toEqual({ + rejectedQuality: "cinematiques", + still: false, + }); + expect(automationRequest("?still&quality=lwo")).toEqual({ + rejectedQuality: "lwo", + still: true, + }); + }); +}); diff --git a/packages/create-threenative/__tests__/rain-lightning.spec.ts b/packages/create-threenative/__tests__/rain-lightning.spec.ts new file mode 100644 index 0000000000..9902c36bfc --- /dev/null +++ b/packages/create-threenative/__tests__/rain-lightning.spec.ts @@ -0,0 +1,128 @@ +import { PerspectiveCamera, Scene, Vector3 } from "three"; +import { expect, it } from "vitest"; +import { createStormLightning, makeBolt } from "../templates/rain/src/render/lightning.js"; + +/** The study's own `rng`, so a seeded bolt is the same bolt twice. */ +function seeded(seed: number): () => number { + let a = seed | 0; + return () => { + a += 0x6d2b79f5; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const finite = (v: Vector3): boolean => + Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); + +it("builds a branched bolt that starts in the sky and lands on the requested point", () => { + const end = new Vector3(12, 0, -300); + const bolt = makeBolt(end, seeded(4711)); + expect(bolt.origin.y).toBeGreaterThanOrEqual(145); + expect(bolt.origin.y).toBeLessThanOrEqual(245); + expect(bolt.end).toEqual(end); + // The channel's 30 segments are all present, every endpoint is a real finite metre-scale point, + // and no segment is a zero-length stub that would collapse to a dot on the near plane. + expect(bolt.segments.length).toBeGreaterThanOrEqual(30); + const channels = bolt.segments.filter((s) => s.strength === 1); + expect(channels.length).toBe(30); + expect(channels[29]?.b).toEqual(end); + for (const segment of bolt.segments) { + expect(finite(segment.a)).toBe(true); + expect(finite(segment.b)).toBe(true); + expect(segment.a.distanceTo(segment.b)).toBeGreaterThan(0); + expect(segment.strength).toBeGreaterThan(0); + expect(segment.strength).toBeLessThanOrEqual(1); + } + // Branches are off the channel, weaker than it, and falling away from it. + const branches = bolt.segments.filter((s) => s.strength < 1); + expect(branches.length).toBeGreaterThan(0); + for (const branch of branches) expect(branch.b.y).toBeLessThan(branch.a.y); + // Two seeds, two different bolts: the shape is the caller's random, not a fixed scribble. + expect(makeBolt(end, seeded(1)).segments.map((s) => s.b.z)).not.toEqual( + makeBolt(end, seeded(2)).segments.map((s) => s.b.z), + ); +}); + +it("drives a real mesh from one strike, rejects non-finite input, and releases everything", () => { + const scene = new Scene(); + const camera = new PerspectiveCamera(55, 16 / 9, 0.1, 400); + const lightning = createStormLightning(scene, camera); + const { mesh } = lightning; + const geometry = mesh.geometry as unknown as { + drawRange: { count: number }; + getAttribute(name: string): { array: Float32Array; version: number }; + dispose(): void; + }; + const material = mesh.material as unknown as { dispose(): void }; + let disposed = 0; + const realDispose = geometry.dispose.bind(geometry); + geometry.dispose = () => { + disposed++; + realDispose(); + }; + + expect(scene.children).toContain(mesh); + expect(geometry.drawRange.count).toBe(0); + // Nothing has been struck, so a bright flash with no bolt must not issue a draw. + lightning.update(1.5); + expect(mesh.visible).toBe(false); + + const origin = lightning.strike(new Vector3(12, 0, -300), seeded(4711)); + expect(finite(origin)).toBe(true); + const segments = geometry.drawRange.count / 6; + expect(segments).toBeGreaterThanOrEqual(30); + // The flash alone decides visibility, so a struck bolt on a dark frame is still hidden. + expect(mesh.visible).toBe(false); + lightning.update(1.5); + expect(mesh.visible).toBe(true); + + // The buffers the draw range covers hold that bolt's own endpoints, and every one is finite. + const starts = geometry.getAttribute("aStart").array; + const ends = geometry.getAttribute("aEnd").array; + const strengths = geometry.getAttribute("aStrength").array; + // Each attribute's version advanced, so the rewrite is queued for upload rather than lost. + expect(geometry.getAttribute("aStart").version).toBeGreaterThan(0); + expect(geometry.getAttribute("aEnd").version).toBeGreaterThan(0); + expect(geometry.getAttribute("aStrength").version).toBeGreaterThan(0); + for (let i = 0; i < geometry.drawRange.count; i++) { + const at = i * 3; + expect( + Number.isFinite(starts[at]) && + Number.isFinite(starts[at + 1]) && + Number.isFinite(starts[at + 2]), + ).toBe(true); + expect( + Number.isFinite(ends[at]) && Number.isFinite(ends[at + 1]) && Number.isFinite(ends[at + 2]), + ).toBe(true); + expect(Number.isFinite(strengths[i] as number)).toBe(true); + expect(strengths[i]).toBeGreaterThan(0); + } + // The last channel segment really does land on the point the caller asked for. Branches come after + // their channel segment, so the channel's own last vertex is the last one at full strength. + let lastChannel = -1; + for (let i = 0; i < geometry.drawRange.count; i++) { + if (strengths[i] === 1) lastChannel = i; + } + expect(Array.from(ends.subarray(lastChannel * 3, lastChannel * 3 + 3))).toEqual([12, 0, -300]); + + // The flash is the caller's, already photosafe: a dark frame hides the bolt, a bright one shows it. + lightning.update(0); + expect(mesh.visible).toBe(false); + lightning.update(2.4); + expect(mesh.visible).toBe(true); + expect(() => lightning.update(Number.NaN)).toThrow(/finite/); + + expect(() => lightning.strike(new Vector3(Number.NaN, 0, -300), seeded(1))).toThrow(/finite/); + expect(() => lightning.strike({ x: 0, y: 0, z: 0 } as Vector3, seeded(1))).toThrow(/finite/); + expect(() => + lightning.strike(new Vector3(0, 0, -300), undefined as unknown as () => number), + ).toThrow(/random/); + expect(() => lightning.strike(new Vector3(0, 0, -300), () => Number.NaN)).toThrow(/finite/); + + lightning.dispose(); + expect(scene.children).not.toContain(mesh); + expect(disposed).toBe(1); +}); diff --git a/packages/create-threenative/__tests__/rain-weather.spec.ts b/packages/create-threenative/__tests__/rain-weather.spec.ts new file mode 100644 index 0000000000..0686e8719e --- /dev/null +++ b/packages/create-threenative/__tests__/rain-weather.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + PRESETS, + type Weather, + easeWeather, + flashAt, + intentPatch, + preset, + thunderDelay, +} from "../templates/rain/src/state.js"; + +const storm = (): Weather => preset("storm"); + +describe("rain weather intents", () => { + it("routes every UI toggle to the state field it names", () => { + const fields = [ + ["setAudioEnabled", "audioEnabled"], + ["setAutoLightning", "autoLightning"], + ["setCinematic", "cinematic"], + ["setDroplets", "droplets"], + ["setFrozen", "frozen"], + ["setMuted", "muted"], + ] as const; + for (const [intent, field] of fields) { + expect(intentPatch(intent, true, storm())).toEqual({ [field]: true }); + expect(intentPatch(intent, false, storm())).toEqual({ [field]: false }); + } + }); + + it("takes automatic lightning down with photosensitivity mode, at the one door", () => { + expect(intentPatch("setSafe", true, storm())).toEqual({ autoLightning: false, safe: true }); + expect(intentPatch("setSafe", false, storm())).toEqual({ safe: false }); + }); + + it("rejects a toggle that is not a boolean", () => { + expect(() => intentPatch("setSafe", "true", storm())).toThrow(); + for (const name of ["toString", "__proto__", "constructor"]) { + expect(() => intentPatch(name, true, storm())).toThrow(); + } + }); + + it("accepts only own preset names, not inherited keys", () => { + expect(intentPatch("setPreset", "drizzle", storm())).toEqual({ + preset: "drizzle", + target: { ...PRESETS.drizzle }, + }); + for (const name of ["toString", "__proto__", "constructor", "STORM", ""]) { + expect(() => intentPatch("setPreset", name, storm())).toThrow(); + } + }); + + it("clamps valid weather channels onto the current target", () => { + const target = intentPatch("setWeather", { rain: 4, exposure: -1, cloud: 0.5 }, storm()).target; + expect(target?.rain).toBe(1); + expect(target?.exposure).toBe(0.3); + expect(target?.cloud).toBe(0.5); + expect(target?.wind).toBe(PRESETS.storm.wind); + }); + + it("rejects a malformed weather payload whole, leaving nothing half-applied", () => { + const base = storm(); + for (const payload of [ + { rain: null }, + { rain: true }, + { rain: "0.5" }, + { rain: Number.NaN }, + { rain: Number.POSITIVE_INFINITY }, + { rain: 0.5, cloud: undefined }, + { rainfall: 0.5 }, + ]) { + expect(() => intentPatch("setWeather", payload, base)).toThrow(); + } + // The base the caller passed is untouched, so a rejected drag moves nothing. + expect(base).toEqual(PRESETS.storm); + }); + + it("publishes the requests the scene consumes", () => { + expect(intentPatch("strike", undefined, storm())).toEqual({ strikeRequested: true }); + expect(intentPatch("resetCamera", undefined, storm())).toEqual({ + cameraReset: true, + cinematic: false, + }); + expect(intentPatch("pause", undefined, storm())).toEqual({ paused: true }); + expect(intentPatch("resume", undefined, storm())).toEqual({ paused: false }); + expect(intentPatch("step", 0.5, storm())).toEqual({ stepRequest: 0.5 }); + expect(intentPatch("step", 600, storm())).toEqual({ stepRequest: 60 }); + expect(intentPatch("step", -1, storm())).toEqual({ stepRequest: 0 }); + expect(() => intentPatch("step", "0.5", storm())).toThrow(); + expect(() => intentPatch("step", Number.NaN, storm())).toThrow(); + expect(() => intentPatch("setWeather", "storm", storm())).toThrow(); + expect(() => intentPatch("teleport", true, storm())).toThrow(); + }); +}); + +describe("rain weather maths", () => { + it("eases towards the target without overshooting it", () => { + const from = preset("drizzle"); + const to = preset("supercell"); + const eased = easeWeather(from, to, 1 / 60); + expect(eased.rain).toBeGreaterThan(from.rain); + expect(eased.rain).toBeLessThan(to.rain); + }); + + it("flashes hard right after a strike and not a second later", () => { + expect(flashAt(0.1)).toBeGreaterThan(0.5); + expect(flashAt(0.21)).toBeGreaterThan(flashAt(0.6)); + expect(flashAt(2)).toBe(0); + expect(flashAt(-1)).toBe(0); + }); + + it("delays thunder by the distance the strike crossed", () => { + expect(thunderDelay(343)).toBeCloseTo(1, 10); + expect(thunderDelay(-5)).toBe(0); + expect(thunderDelay(Number.NaN)).toBe(0); + }); +}); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 70dd27ecb1..a35bdfbe72 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -85,6 +85,7 @@ const BUG_REPORT_SKILL_PATHS = [ // capability reference derived from it, and every scaffold embeds both, so exporting one public // symbol moves every template's bytes. Six of the seven moved for that reason alone; platformer // also carries its chasers' route change. +// Recomputed 2026-10-02 for create-threenative 0.2.8: every template pins the scaffolder version. // Recomputed 2026-08-31 from the values CI measured, not from a local run. // // These were updated three times in a row and were wrong all three times, because they were @@ -180,17 +181,25 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed again 2026-10-01 after merging the quality/post chain into this branch and landing // the rts sim's order, queue and event-record fixes: `rts` alone moves, and it is the only one of // the eleven that carries `src/sim/`. Measured through createProject on the merged tree. - "action-rpg": "e477cb8fe35157325e234e07bcbb3e7b09a69bb43c94ff2f591f7ee02bc30c48", - minimal: "62875da8b839633c518a8f52eb66ebb74a421c271f21bf6a0cdce54b83642082", - platformer: "b09a698711561333be22eefa98e9d0b454124bda9a9dcb6a72fad3fd156e5ed5", - puzzle: "759634702a6da8c8f70203aeaa5ac6d7163fd275920a084fcbfc10477562edc3", - racing: "384c151639f267d1fa530fd3f0b71f7cd5a2249b5eb4817598d35050cedbe3c6", - rts: "d2c9eaa70d55b4f99943448444e43e107bbaadea1a4162474a35ffb494a94a62", - runner: "d52219261507b0949aeb96f0d865adf81f9dddae6a8ee99f050e21e3ec3c6977", - sailing: "718fd70cef653ec6ccdf99ea1dc8a0510479974e19d95acdca60ec1411c0fe10", - shooter: "0369a3a146585111d7d2355b887ae7a22b5278e17a84d0324949f3c8ad8c1933", - starter: "53d57f84f19160bec1e15147366e0f9bc8fd06d03f11bf050d23fda3a236e239", - "tower-defense": "b87e3a3626c0ef744cf24cf106b1e85a51c010856f2f6acc60f6bf7c65f27246", + // Recomputed 2026-10-01 on the merge of develop (PRD-470/471/472/474) into the rain + snow + // branch (PRD-469, PRD-473): the merged tree carries both sides' engine and manifest bytes, so + // all thirteen trees, rain and snow included, were re-measured through createProject. + // Recomputed 2026-10-02 rebasing the FabCLI manual-login fallback onto develop: the shared + // threenative-assets skill ships in every scaffold, so all thirteen trees move. + "action-rpg": "0b06ad9bbcddcb702497985eaaa855c5bfbc66d43809e720f2f9b445172b6dd3", + minimal: "531c452baae7bbeb08a511f8cc06c2d94b5886b1de70c31de5968462584c82bd", + platformer: "616d63fb1b0fe888c39a4dbbae651ecd3e85b61d5fd396bc0f77fde122bef585", + puzzle: "f0a302958d7607eb8438ddedea79241bf87f04620d28fe04d92dc8c5bd887742", + racing: "39c2eb35911939484c4be5c35b8b1fb1be2ccaa8fdb4c2269a7e7943f61ca93f", + rain: "80a2c0297f451b096950bcdb7a7710776e063c4819c6b1b543f0bc320edda41c", + rts: "df5e7ef2ff5dedb7c234239141afbaae2d32f8d6f9e58be89e0731b47f857572", + runner: "cbeb5578241eab87a38307dd27f4ede42bf4058774d6dea4cf848fde0fd50bb2", + sailing: "adf775fd15287af4cc00b675264e157e0555b027e3bd32ad9a5e8aebbcd1f64d", + // VQ11 after merge of develop 416ffd7: measured through createProject, only shooter changed. + shooter: "0b9dadf239d441709f214da3035792741f791fdf5349e44c063f619b048a3484", + snow: "f3d4878829cf14ebee6355e720c223171529d550e8cafc9cd6328285a8000d3b", + starter: "fae9ee908d5c9370fdf95c1314f6845be5fcb9fd64114af21082331b21e53579", + "tower-defense": "68ce595b3857e5ca3e94c7b4dc09f29cb0e16459b6642c235f391b7312da365e", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts b/packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts new file mode 100644 index 0000000000..a607a9ec55 --- /dev/null +++ b/packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts @@ -0,0 +1,264 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { Mesh, PerspectiveCamera, Scene, Vector2, Vector3 } from "three"; +import Info from "three/src/renderers/common/Info.js"; +import { afterEach, expect, it, vi } from "vitest"; +import * as fixtureCore from "../../core/dist/index.js"; +import { createAssetLoader } from "../../core/src/assets.js"; +import { baseGeometryOf, updateModelLods } from "../../core/src/model-lod.js"; +import { ScenePicker } from "../../core/src/picking.js"; +import { evaluateRichPlaytestAssertions } from "../../playtest/src/assertion-evaluators.js"; +import { screenshotObservations } from "../../playtest/src/runner/steps.js"; +import { loadPlaytestScenario } from "../../playtest/src/scenario.js"; +import { DecalField, bulletHoleTexture } from "../templates/shooter/src/render/decals.js"; +import { createDecalFixture, observeResidency } from "./fixtures/bounded-decals/game.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("requires stable completed render intervals rather than simulated ticks for residency", () => { + const previous = { renderCalls: 10, stableFrames: 0, geometries: -1, textures: -1, bytes: -1 }; + const current = { renderCalls: 10, geometries: 258, textures: 4, bytes: 6500000 }; + for (let tick = 0; tick < 90; tick += 1) observeResidency(previous, current, true); + expect(previous.stableFrames).toBe(0); + for (const calls of [14, 18, 22]) { + current.renderCalls = calls; + observeResidency(previous, current, true); + } + expect(previous.stableFrames).toBe(3); // One observation per interval, not four internal passes. + current.renderCalls = 26; + current.geometries += 1; + current.bytes += 576; + observeResidency(previous, current, true); + expect(previous.stableFrames).toBe(1); + observeResidency(previous, current, false); + expect(previous.stableFrames).toBe(0); + expect(() => observeResidency(previous, { ...current, bytes: Number.NaN }, true)).toThrow( + /residency/i, + ); +}); + +it.each([0, 255, 256])( + "observes the real fixture render before Three resets counters, requiring 256 draws (%i)", + async (draws) => { + const expectedGeneration = draws >= 256 ? 1 : 0; + let config: Parameters[0] | undefined; + vi.spyOn(fixtureCore, "defineGame").mockImplementation((value) => { + config = value; + return {} as never; + }); + createDecalFixture(); + const Room = config?.scenes.room; + if (Room === undefined) throw new Error("Missing actual fixture scene"); + const room = new Room(); + const glb = await readFile( + new URL("./fixtures/bounded-decals/public/receiver.glb", import.meta.url), + ); + const manifest = await readFile( + new URL("./fixtures/bounded-decals/public/assets.manifest.json", import.meta.url), + "utf8", + ); + vi.stubGlobal("fetch", async (url: string) => + url.endsWith("assets.manifest.json") + ? new Response(manifest, { headers: { "content-type": "application/json" } }) + : new Response(glb, { headers: { "content-type": "model/gltf-binary" } }), + ); + const scene = new Scene(); + const camera = new PerspectiveCamera(48, 16 / 9, 0.1, 100); + const info = new Info(); + Object.assign(info.memory, { geometries: 259, textures: 4, total: 6500000 }); + const state: Record = {}; + const picker = new ScenePicker({ + camera, + pointer: () => new Vector2(), + scene, + viewport: {} as never, + }); + const ctx = { + assets: fixtureCore.createAssetLoader({ basePath: "https://fixture.invalid" }), + camera, + scene, + add: (object: Mesh) => scene.add(object), + entities: { add: () => {} }, + input: { justPressed: () => false }, + raycast: picker.raycast.bind(picker), + renderer: { raw: { info } }, + state: { set: (value: Record) => Object.assign(state, value) }, + } as unknown as fixtureCore.ICtx; + vi.spyOn(console, "info").mockImplementation(() => {}); + await room.load(ctx); + const update = room.enter(ctx); + if (typeof update !== "function") throw new Error("Missing actual fixture update"); + try { + camera.updateMatrixWorld(true); + fixtureCore.updateModelLods(scene, camera, 540); + update(ctx, 0); // The actual post-LOD hit must exist before residency can qualify. + expect(state.lodHit).toBe(true); + const receiver = scene.children.find((object) => object instanceof Mesh); + if (!(receiver instanceof Mesh)) throw new Error("Missing actual receiver"); + info.render.calls = 1; + for (let draw = 0; draw < 256; draw += 1) info.update(receiver, 6, 1); + for (let tick = 0; tick < 10; tick += 1) update(ctx, 0); + expect(state.stableResidencyFrames).toBe(0); // Live counters alone are not a world snapshot. + for (let interval = 1; interval <= 3; interval += 1) { + info.reset(); + info.render.calls += 1; + for (let draw = 0; draw < draws; draw += 1) info.update(receiver, 6, 1); + room.render(ctx); // The framework invokes this only after this ordinary world render. + expect(state.residencyGeneration).toBe(0); // Do not publish before the next update. + info.reset(); // The native RAF ordering can erase per-frame draws before that update. + expect(info.render.drawCalls).toBe(0); + expect(info.render.calls).toBe(interval + 1); + for (let tick = 0; tick < 10; tick += 1) update(ctx, 0); + expect(state.stableResidencyFrames).toBe(expectedGeneration * interval); + room.render(ctx); // A repeated hook without another render call cannot add an interval. + update(ctx, 0); + expect(state.stableResidencyFrames).toBe(expectedGeneration * interval); + } + expect(state.maxDrawCalls).toBe(draws); + expect(state.residencyGeneration).toBe(expectedGeneration); + expect(state.residencySamples).toBe(expectedGeneration); + } finally { + room.exit(ctx); + picker.dispose(); + } + }, +); + +async function evaluateCapturedPixels( + scenarioName: string, + captureName: string, + run = "36989297734", +) { + const fixture = fileURLToPath(new URL("./fixtures/bounded-decals/", import.meta.url)); + const loaded = await loadPlaytestScenario(fixture, `${scenarioName}.playtest.json`); + const scenario = { ...loaded, assert: { visual: loaded.assert?.visual } }; + const png = await readFile( + new URL(`../../../docs/verification/vq11-decals-${run}/${captureName}.png`, import.meta.url), + ); + return evaluateRichPlaytestAssertions({ + scenario, + report: { + diagnostics: [], + distance: 0, + entity: "", + expectMoved: false, + frames: 1, + trivialityOptOuts: [], + observations: { + console: [], + hud: {}, + network: [], + resources: {}, + visual: screenshotObservations(undefined, png, scenario), + }, + }, + }).assertions; +} + +it("rejects fully hidden decals as intermediate fade evidence despite nonblank receivers", async () => { + const assertions = await evaluateCapturedPixels("fading", "hidden-decals", "36991562324"); + expect(assertions.filter(({ pass }) => !pass).map(({ id }) => id)).toEqual([ + "visual.3.region.darkPixels", + "visual.4.region.darkPixels", + ]); +}); + +it("rejects a real screenshot missing the right receiver despite a nonblank lit scene", async () => { + const assertions = await evaluateCapturedPixels("static", "teardown"); + expect(assertions.find(({ id }) => id === "visual.0.region")?.pass).toBe(true); + expect(assertions.filter(({ pass }) => !pass).map(({ id }) => id)).toEqual([ + "visual.2.region.maxDarkPixels", + ]); +}); + +it.each(["static", "motion", "teardown", "restart"])( + "recognizes visible marks inside the %s receiver regions of actual hosted captures", + async (name) => { + const assertions = await evaluateCapturedPixels(name, name === "restart" ? "static" : name); + expect(assertions.filter(({ id }) => id.endsWith(".region.darkPixels"))).toHaveLength( + name === "teardown" ? 1 : 2, + ); + expect(assertions.every(({ pass }) => pass)).toBe(true); + }, +); + +it("projects real framework hits onto the fixture's authored surface after actual LOD selection", async () => { + const glb = await readFile( + new URL("./fixtures/bounded-decals/public/receiver.glb", import.meta.url), + ); + const manifest = await readFile( + new URL("./fixtures/bounded-decals/public/assets.manifest.json", import.meta.url), + "utf8", + ); + vi.stubGlobal("fetch", async (url: string) => + url.endsWith("assets.manifest.json") + ? new Response(manifest, { headers: { "content-type": "application/json" } }) + : new Response(glb, { headers: { "content-type": "model/gltf-binary" } }), + ); + const loader = createAssetLoader({ basePath: "https://fixture.invalid" }); + const loaded = await loader.model<{ scene: Scene }>("receiver.glb"); + const receiver = loaded.scene.getObjectByProperty("isMesh", true); + if (!(receiver instanceof Mesh)) throw new Error("Fixture mesh missing"); + const base = baseGeometryOf(receiver); + expect(base.index?.count).toBe(384); + const root = new Scene(); + root.add(receiver); + const camera = new PerspectiveCamera(48, 16 / 9, 0.1, 100); + camera.position.set(0, 0, 12); + camera.updateMatrixWorld(true); + root.updateMatrixWorld(true); + expect(updateModelLods(root, camera, 540)).toBe(2); + expect(receiver.geometry).not.toBe(base); + expect(baseGeometryOf(receiver)).toBe(base); + const picker = new ScenePicker({ + camera, + pointer: () => new Vector2(), + scene: root, + viewport: {} as never, + }); + const hit = picker.raycast({ + origin: new Vector3(1, 1, 2), + direction: new Vector3(0, 0, -1), + targets: receiver, + }); + expect(hit?.object).toBe(receiver); + expect(hit?.point.toArray()).toEqual([1, 1, 0]); + const map = bulletHoleTexture(8); + const field = new DecalField(root, { + countPerVariant: 1, + size: 0.2, + offset: 0.01, + map, + tints: { impact: 0xffffff }, + }); + if (hit?.face === null || hit?.face === undefined) throw new Error("Fixture hit normal missing"); + expect( + field.project(receiver, baseGeometryOf(receiver), hit.point, hit.face.normal, "impact", { + depth: 0.1, + }), + ).toBe(true); + const mark = receiver.children[0]; + if (!(mark instanceof Mesh)) throw new Error("Fixture mark missing"); + const projected = mark.geometry; + camera.position.z = 0.3; + camera.updateMatrixWorld(true); + expect(updateModelLods(root, camera, 540)).toBe(128); + expect(receiver.geometry).toBe(base); + expect(mark.geometry).toBe(projected); + expect( + picker + .raycast({ + origin: new Vector3(1, 1, 2), + direction: new Vector3(0, 0, -1), + targets: receiver, + exclude: receiver.children, + }) + ?.point.toArray(), + ).toEqual([1, 1, 0]); + field.dispose(); + map.dispose(); + picker.dispose(); +}); diff --git a/packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts b/packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts new file mode 100644 index 0000000000..f5a0f1000e --- /dev/null +++ b/packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts @@ -0,0 +1,116 @@ +import { + Group, + Material, + Mesh, + type MeshBasicMaterial, + PlaneGeometry, + Texture, + Vector3, +} from "three"; +import { afterEach, expect, it, vi } from "vitest"; +import { Registry } from "../../core/src/entities.js"; +import { DecalField } from "../templates/shooter/src/render/decals.js"; + +afterEach(() => vi.restoreAllMocks()); + +it("releases every owned slot material while retaining the borrowed map", () => { + const parent = new Group(); + const unrelated = new Group(); + parent.add(unrelated); + const map = new Texture(); + const mapDisposed = vi.fn(); + map.addEventListener("dispose", mapDisposed); + const materialDisposed = vi.spyOn(Material.prototype, "dispose"); + const field = new DecalField(parent, { + countPerVariant: 3, + map, + size: 0.13, + tints: { steel: 0xb6bcc4, wood: 0x8a6a44 }, + }); + const slots = parent.children.filter( + (child): child is Mesh => child instanceof Mesh, + ); + expect(slots).toHaveLength(6); + expect(new Set(slots.map((slot) => slot.material)).size).toBe(6); + const releases = slots.map((slot) => { + expect(slot.material.map).toBe(map); + const released = vi.fn(); + slot.material.addEventListener("dispose", released); + return released; + }); + const geometryDisposed = vi.fn(); + slots[0]?.geometry.addEventListener("dispose", geometryDisposed); + + field.place(new Vector3(1, 2, 3), new Vector3(0, 1, 0), "steel"); + field.dispose(); + + for (const released of releases) expect(released).toHaveBeenCalledTimes(1); + // Six cloned slot materials and the two family source materials are owned by the field. + expect(materialDisposed).toHaveBeenCalledTimes(8); + expect(geometryDisposed).toHaveBeenCalledTimes(1); + expect(parent.children).toEqual([unrelated]); + expect(mapDisposed).not.toHaveBeenCalled(); + map.dispose(); + expect(mapDisposed).toHaveBeenCalledTimes(1); +}); + +it.each([0, 3])("tears down only once with %i slots per variant", (countPerVariant) => { + const parent = new Group(); + const map = new Texture(); + const materialDisposed = vi.spyOn(Material.prototype, "dispose"); + const geometryDisposed = vi.spyOn(PlaneGeometry.prototype, "dispose"); + const field = new DecalField(parent, { + countPerVariant, + map, + size: 0.13, + tints: { steel: 0xb6bcc4 }, + }); + + field.dispose(); + field.dispose(); + + expect(materialDisposed).toHaveBeenCalledTimes(countPerVariant + 1); + expect(geometryDisposed).toHaveBeenCalledTimes(1); + expect(parent.children).toHaveLength(0); + map.dispose(); +}); + +it("releases resources after repeated pool reuse, clear and registry teardown", () => { + const map = new Texture(); + const mapDisposed = vi.fn(); + map.addEventListener("dispose", mapDisposed); + const materialDisposed = vi.spyOn(Material.prototype, "dispose"); + const geometryDisposed = vi.spyOn(PlaneGeometry.prototype, "dispose"); + const point = new Vector3(1, 2, 3); + const normal = new Vector3(0, 1, 0); + + for (let cycle = 0; cycle < 20; cycle += 1) { + const parent = new Group(); + const registry = new Registry(); + const field = new DecalField(parent, { + countPerVariant: 4, + map, + size: 0.13, + tints: { steel: 0xb6bcc4, wood: 0x8a6a44 }, + }); + const slots = [...parent.children]; + registry.add("decals", field); + field.settle(); + for (let hit = 0; hit < 100; hit += 1) { + field.place(point, normal, hit % 2 === 0 ? "steel" : "wood"); + } + field.clear(); + expect(parent.children).toEqual(slots); + expect(parent.children).toHaveLength(field.capacity); + expect(materialDisposed).toHaveBeenCalledTimes(cycle * 10); + expect(geometryDisposed).toHaveBeenCalledTimes(cycle); + field.place(point, normal, "wood"); + registry.clear(); + field.dispose(); + expect(parent.children).toHaveLength(0); + expect(materialDisposed).toHaveBeenCalledTimes((cycle + 1) * 10); + expect(geometryDisposed).toHaveBeenCalledTimes(cycle + 1); + expect(mapDisposed).not.toHaveBeenCalled(); + } + map.dispose(); +}); diff --git a/packages/create-threenative/__tests__/shooter-decals-native.spec.ts b/packages/create-threenative/__tests__/shooter-decals-native.spec.ts new file mode 100644 index 0000000000..5839c16546 --- /dev/null +++ b/packages/create-threenative/__tests__/shooter-decals-native.spec.ts @@ -0,0 +1,147 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { loadPlaytestScenario, requiredPlaytestCapabilities } from "../../playtest/dist/index.js"; +import { + ANDROID_TRANSPORT_CAPABILITIES, + type IBridgeTransport, + connectPlaytestBridgeTransport, +} from "../../playtest/dist/runner/index.js"; +import { PLAYTEST_PROTOCOL_LIMITS } from "../../playtest/src/protocol.js"; +import { validatePlaytestScenario } from "../../playtest/src/scenario.js"; +import { + assertNativeDecalCapture, + evaluateDecalPixels, + nativeDecalCases, + nativeDecalScenario, +} from "./fixtures/bounded-decals/native-proof.js"; + +const fixture = fileURLToPath(new URL("./fixtures/bounded-decals/", import.meta.url)); +const consoleEvidence = [{ type: "log", text: "TN_NATIVE_SMOKE_FIRST_FRAME" }]; +function report(): Parameters[0] { + return { + pass: true, + runtime: "native", + target: "desktop", + diagnostics: [], + startup: { phase: "ready", progress: 1, compileSettled: true, rule: "compile-settled" }, + assertionResults: [{ id: "resources.0", pass: true }], + capture: { + target: "desktop", + rendererKind: "webgpu", + captureMethod: "device.screenshot", + browserArgs: [], + adapter: { description: "llvmpipe" }, + viewport: { width: 960, height: 540 }, + }, + }; +} + +it("requires genuine native provenance, readiness, nonempty assertions and clean host console", () => { + expect(() => assertNativeDecalCapture(report(), consoleEvidence)).not.toThrow(); + for (const mutation of [ + { pass: false }, + { runtime: "web" }, + { target: "browser" }, + { capture: undefined }, + { startup: undefined }, + { startup: { phase: "ready", progress: 1, compileSettled: false, rule: "compile-settled" } }, + { assertionResults: [] }, + { assertionResults: [{ id: "resources.0", pass: false }] }, + { diagnostics: [{ severity: "warning", code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" }] }, + { diagnostics: [{ severity: "error", code: "unrelated" }] }, + ]) + expect(() => + assertNativeDecalCapture( + { ...report(), ...mutation } as Parameters[0], + consoleEvidence, + ), + ).toThrow(); + for (const bad of [ + [], + undefined, + [{ type: "error", text: "unrelated" }], + [{ type: "log", text: "[WebGPU] Device error (Validation): invalid binding" }], + [{ type: "log", text: "TypeError: unavailable API" }], + [{ type: "log", text: "no frame" }], + ]) + expect(() => assertNativeDecalCapture(report(), bad)).toThrow(); + const native = report(); + if (native.capture === undefined) throw new Error("Missing fixture capture"); + native.capture.adapter = { features: "some-feature", description: "unknown" }; + expect(() => assertNativeDecalCapture(native, consoleEvidence)).toThrow(); + native.capture.adapter = { description: "llvmpipe" }; + native.capture.captureMethod = "page.screenshot"; + expect(() => assertNativeDecalCapture(native, consoleEvidence)).toThrow(); + native.capture.captureMethod = "device.screenshot"; + native.capture.viewport.width = 800; + expect(() => assertNativeDecalCapture(native, consoleEvidence)).toThrow(); +}); + +it("preserves all authored predicates and passes actual minimal-native mailbox preflight", async () => { + // Actual shared transport capabilities plus the minimal QuickJS describe shape from PR396. + // This proves protocol admission, not execution on a native renderer. + const transport: IBridgeTransport = { + capabilities: ANDROID_TRANSPORT_CAPABILITIES, + waitForBridge: async () => true, + close: async () => {}, + async call(method: string): Promise { + if (method === "describe") + return { + capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + limits: PLAYTEST_PROTOCOL_LIMITS, + name: "@threenative/playtest/three", + protocolVersion: 1, + } as T; + if (method === "ready") return { ready: true } as T; + throw new Error(`Unexpected preflight call ${method}`); + }, + }; + for (const { scenario: name } of nativeDecalCases) { + const authored = await loadPlaytestScenario(fixture, `${name}.playtest.json`); + const native = nativeDecalScenario(authored); + expect(native.assert?.resources).toEqual( + authored.assert?.components?.map(({ entity: _entity, component, ...predicate }) => ({ + id: "state", + path: component, + ...predicate, + })), + ); + expect(native.steps).toEqual(authored.steps); + expect(native.assert?.visual).toBeUndefined(); + expect(requiredPlaytestCapabilities(native, "desktop")).not.toContain("runtime.diagnostics"); + expect(validatePlaytestScenario(native, name).target).toBe("desktop"); + await expect( + connectPlaytestBridgeTransport(transport, native, 1000, "desktop"), + ).resolves.toBeDefined(); + } +}); + +it("uses unchanged visual predicates on actual capture bytes and rejects hidden partial fade", async () => { + const fading = await loadPlaytestScenario(fixture, "fading.playtest.json"); + const actual = await readFile( + new URL("../../../docs/verification/vq11-decals-37003284983/fading.png", import.meta.url), + ); + const hidden = await readFile( + new URL( + "../../../docs/verification/vq11-decals-36991562324/hidden-decals.png", + import.meta.url, + ), + ); + expect(evaluateDecalPixels(actual, fading).assertions.every(({ pass }) => pass)).toBe(true); + expect( + evaluateDecalPixels(hidden, fading) + .assertions.filter(({ pass }) => !pass) + .map(({ id }) => id), + ).toEqual(["visual.3.region.darkPixels", "visual.4.region.darkPixels"]); + expect(() => + evaluateDecalPixels(actual, { ...fading, viewport: { width: 800, height: 540 } }), + ).toThrow(); + expect(() => nativeDecalScenario({ ...fading, assert: {} })).toThrow(); + expect(() => + nativeDecalScenario({ + ...fading, + assert: { ...fading.assert, fps: { min: 60 } }, + } as typeof fading), + ).toThrow(); +}); diff --git a/packages/create-threenative/__tests__/shooter-decals-projection.spec.ts b/packages/create-threenative/__tests__/shooter-decals-projection.spec.ts new file mode 100644 index 0000000000..5a33e4de08 --- /dev/null +++ b/packages/create-threenative/__tests__/shooter-decals-projection.spec.ts @@ -0,0 +1,496 @@ +import { + BatchedMesh, + BoxGeometry, + type BufferGeometry, + Float32BufferAttribute, + Group, + InstancedMesh, + Mesh, + MeshBasicMaterial, + PlaneGeometry, + SkinnedMesh, + Texture, + Vector3, +} from "three"; +import { DecalGeometry } from "three/addons/geometries/DecalGeometry.js"; +import { describe, expect, it, vi } from "vitest"; +import { Registry } from "../../core/src/entities.js"; +import { baseGeometryOf } from "../../core/src/model-lod.js"; +import { DecalField } from "../templates/shooter/src/render/decals.js"; + +function fixture(count = 2) { + const root = new Group(); + const map = new Texture(); + const receiver = new Mesh( + new PlaneGeometry(8, 8), + new MeshBasicMaterial(), + ); + root.add(receiver); + const field = new DecalField(root, { + countPerVariant: count, + map, + offset: 0.01, + size: 1, + tints: { stone: 0xffffff }, + }); + const slots = root.children.filter( + (child): child is Mesh => + child instanceof Mesh && child !== receiver, + ); + return { root, map, receiver, field, slots }; +} + +const normal = new Vector3(0, 0, 1); + +describe("game-owned receiver decal projection", () => { + it("releases fading projected buffers exactly once across 20 registry lifecycles", () => { + let created = 0; + let disposed = 0; + for (let cycle = 0; cycle < 20; cycle += 1) { + const { receiver, field, slots, map } = fixture(4); + const registry = new Registry(); + registry.add("decals", field); + registry.add("map", map); + const borrowedDisposed = vi.fn(); + receiver.geometry.addEventListener("dispose", borrowedDisposed); + for (let shot = 0; shot < 12; shot += 1) { + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + fade: { duration: 1, opacity: (p) => 1 - p }, + }); + created += 1; + slots[shot % 4]?.geometry.addEventListener("dispose", () => { + disposed += 1; + }); + field.update(0.1); + } + field.update(0.5); + receiver.removeFromParent(); + field.update(1); + registry.clear(); + registry.clear(); + expect(receiver.children).toHaveLength(0); + expect(borrowedDisposed).not.toHaveBeenCalled(); + expect(disposed).toBe(created); + } + expect(created).toBe(240); + }); + it("maps only owned projected UVs into an authored atlas rectangle", () => { + const a = fixture(1); + const b = fixture(1); + const borrowedUvs = Array.from(a.receiver.geometry.getAttribute("uv").array); + a.field.project(a.receiver, a.receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + uvRect: [0.5, 0.25, 1, 0.75], + }); + b.field.project(b.receiver, b.receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + }); + const atlas = a.slots[0]?.geometry.getAttribute("uv"); + const full = b.slots[0]?.geometry.getAttribute("uv"); + if (atlas === undefined || full === undefined) throw new Error("Missing atlas UVs"); + for (let i = 0; i < full.count; i += 1) { + expect(atlas.getX(i)).toBeCloseTo(0.5 + full.getX(i) * 0.5, 6); + expect(atlas.getY(i)).toBeCloseTo(0.25 + full.getY(i) * 0.5, 6); + } + expect(Array.from(a.receiver.geometry.getAttribute("uv").array)).toEqual(borrowedUvs); + expect(a.slots[0]?.material.map).toBe(a.map); + expect(a.map.offset.toArray()).toEqual([0, 0]); + expect(a.map.repeat.toArray()).toEqual([1, 1]); + a.field.dispose(); + b.field.dispose(); + }); + + it("uses the authored opacity curve and expires owned geometry once without retiring the slot", () => { + const { root, receiver, field, slots, map } = fixture(1); + const slot = slots[0]; + if (slot === undefined) throw new Error("Missing slot"); + const plane = slot.geometry; + const materialDisposed = vi.fn(); + const mapDisposed = vi.fn(); + slot.material.addEventListener("dispose", materialDisposed); + map.addEventListener("dispose", mapDisposed); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + fade: { duration: 2, opacity: (progress: number) => (1 - progress) ** 2 }, + }); + const geometryDisposed = vi.fn(); + slot.geometry.addEventListener("dispose", geometryDisposed); + field.update(0.5); + expect(slot.material.opacity).toBe(0.5625); + field.update(0.5); + expect(slot.material.opacity).toBe(0.25); + field.update(1); + field.update(10); + expect(geometryDisposed).toHaveBeenCalledTimes(1); + expect(receiver.children).toHaveLength(0); + expect(slot.parent).toBe(root); + expect(slot.geometry).toBe(plane); + expect(slot.visible).toBe(false); + expect(slot.material.opacity).toBe(1); + expect(materialDisposed).not.toHaveBeenCalled(); + expect(mapDisposed).not.toHaveBeenCalled(); + field.dispose(); + field.dispose(); + expect(geometryDisposed).toHaveBeenCalledTimes(1); + expect(materialDisposed).toHaveBeenCalledTimes(1); + expect(mapDisposed).not.toHaveBeenCalled(); + }); + + it("resets fade on projected or legacy reuse and keeps unconfigured marks persistent", () => { + const { receiver, field, slots } = fixture(1); + const fading = { depth: 0.2, fade: { duration: 2, opacity: (p: number) => 1 - p } }; + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", fading); + field.update(1); + expect(slots[0]?.material.opacity).toBe(0.5); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + field.update(1000); + expect(receiver.children).toHaveLength(1); + expect(slots[0]?.material.opacity).toBe(1); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", fading); + field.update(1); + field.place(new Vector3(), normal, "stone"); + field.update(1000); + expect(slots[0]?.material.opacity).toBe(1); + expect(slots[0]?.visible).toBe(true); + field.dispose(); + }); + + it.each([ + { uvRect: [-0.1, 0, 1, 1] }, + { uvRect: [0.5, 0, 0.5, 1] }, + { uvRect: [0, 0, 1.1, 1] }, + { uvRect: [0, 0, 1, Number.NaN] }, + { fade: { duration: 0, opacity: () => 1 } }, + { fade: { duration: Number.POSITIVE_INFINITY, opacity: () => 1 } }, + { fade: { duration: 1, opacity: () => Number.NaN } }, + { fade: { duration: 1, opacity: () => 1.1 } }, + ])("refuses malformed authored controls without evicting a live mark: %j", (controls) => { + const { receiver, field, slots } = fixture(1); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const geometry = slots[0]?.geometry; + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + ...controls, + } as Parameters[5]), + ).toThrow(/decal/i); + expect(slots[0]?.geometry).toBe(geometry); + expect(field.placed).toBe(1); + field.dispose(); + }); + + it("rejects invalid time or curve values without advancing a live fade", () => { + const { receiver, field, slots } = fixture(1); + let invalid = false; + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + fade: { duration: 2, opacity: (p: number) => (invalid ? Number.NaN : 1 - p) }, + }); + for (const dt of [-1, Number.NaN, Number.POSITIVE_INFINITY]) + expect(() => field.update(dt)).toThrow(/decal/i); + field.update(0.5); + expect(slots[0]?.material.opacity).toBe(0.75); + invalid = true; + expect(() => field.update(0.5)).toThrow(/decal/i); + expect(slots[0]?.material.opacity).toBe(0.75); + invalid = false; + field.update(0.5); + expect(slots[0]?.material.opacity).toBe(0.5); + field.dispose(); + }); + + it("refuses projection bias beyond the projector half-depth before recycling", () => { + const { receiver, field, slots } = fixture(1); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const geometry = slots[0]?.geometry; + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.01 }), + ).toThrow(/bias/i); + expect(slots[0]?.geometry).toBe(geometry); + field.dispose(); + }); + it("uses upstream projection on only the selected receiver with authored UVs and bias", () => { + const { root, receiver, field, slots } = fixture(); + const other = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + other.position.z = 0.02; + root.add(other); + expect( + field.project(receiver, baseGeometryOf(receiver), new Vector3(), normal, "stone", { + depth: 0.2, + }), + ).toBe(true); + const slot = slots[0]; + expect(slot?.parent).toBe(receiver); + expect(slot?.geometry).toBeInstanceOf(DecalGeometry); + expect(other.children).toHaveLength(0); + const positions = slot?.geometry.getAttribute("position"); + const uvs = slot?.geometry.getAttribute("uv"); + expect(positions?.count).toBeGreaterThan(0); + for (let index = 0; index < (positions?.count ?? 0); index += 1) { + expect(positions?.getZ(index)).toBeCloseTo(0.01, 6); + expect(uvs?.getX(index)).toBeGreaterThanOrEqual(-1e-6); + expect(uvs?.getX(index)).toBeLessThanOrEqual(1 + 1e-6); + expect(uvs?.getY(index)).toBeGreaterThanOrEqual(-1e-6); + expect(uvs?.getY(index)).toBeLessThanOrEqual(1 + 1e-6); + } + field.dispose(); + }); + + it("retains local geometry through rigid receiver and ancestor motion", () => { + const { root, receiver, field, slots } = fixture(); + const parent = new Group(); + root.add(parent); + parent.add(receiver); + receiver.position.set(2, 1, 3); + receiver.rotation.y = 0.7; + receiver.scale.setScalar(2); + receiver.updateWorldMatrix(true, false); + const point = receiver.localToWorld(new Vector3()); + const worldNormal = normal.clone().transformDirection(receiver.matrixWorld); + field.project(receiver, receiver.geometry, point, worldNormal, "stone", { depth: 0.2 }); + const slot = slots[0]; + if (slot === undefined) throw new Error("Missing slot"); + const local = new Vector3().fromBufferAttribute(slot.geometry.getAttribute("position"), 0); + parent.position.set(-3, 4, 1); + parent.rotation.z = 0.4; + receiver.position.x += 1; + receiver.rotation.y += 0.5; + root.updateMatrixWorld(true); + const expected = receiver.localToWorld(local.clone()); + const actual = slot.localToWorld(local.clone()); + expect(actual.distanceTo(expected)).toBeLessThan(1e-6); + expect( + new Vector3().fromBufferAttribute(slot.geometry.getAttribute("position"), 0).equals(local), + ).toBe(true); + field.dispose(); + }); + + it("does not project the back face of a thin receiver", () => { + const { receiver, field, slots } = fixture(); + receiver.geometry = new BoxGeometry(2, 2, 0.01); + field.project(receiver, receiver.geometry, new Vector3(0, 0, 0.005), normal, "stone", { + depth: 0.2, + }); + const geometry = slots[0]?.geometry; + const positions = geometry?.getAttribute("position"); + const indices = geometry?.getIndex(); + expect(indices?.count).toBeGreaterThan(0); + for (let index = 0; index < (indices?.count ?? 0); index += 1) { + expect(positions?.getZ(indices?.getX(index) ?? -1)).toBeCloseTo(0.015, 6); + } + field.dispose(); + }); + + it("bounds 1,024 impacts to 256 slots and disposes evicted projected geometry", () => { + const { receiver, field, slots, map } = fixture(256); + const borrowed = receiver.geometry; + const borrowedDisposed = vi.fn(); + borrowed.addEventListener("dispose", borrowedDisposed); + const mapDisposed = vi.fn(); + map.addEventListener("dispose", mapDisposed); + let disposed = 0; + for (let hit = 0; hit < 1024; hit += 1) { + expect( + field.project(receiver, borrowed, new Vector3(), normal, "stone", { depth: 0.2 }), + ).toBe(true); + slots[hit % 256]?.geometry.addEventListener("dispose", () => { + disposed += 1; + }); + expect(receiver.children).toHaveLength(Math.min(hit + 1, 256)); + } + expect(disposed).toBe(768); + expect(field.capacity).toBe(256); + expect(field.placed).toBe(1024); + field.dispose(); + field.dispose(); + expect(disposed).toBe(1024); + expect(receiver.children).toHaveLength(0); + expect(borrowedDisposed).not.toHaveBeenCalled(); + expect(mapDisposed).not.toHaveBeenCalled(); + }); + + it("releases a removed receiver immediately and a detached ancestor on update", () => { + const { root, receiver, field, slots } = fixture(); + const parent = new Group(); + root.add(parent); + parent.add(receiver); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const firstDisposed = vi.fn(); + slots[0]?.geometry.addEventListener("dispose", firstDisposed); + receiver.removeFromParent(); + expect(firstDisposed).toHaveBeenCalledTimes(1); + expect(receiver.children).toHaveLength(0); + parent.add(receiver); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const secondDisposed = vi.fn(); + slots[1]?.geometry.addEventListener("dispose", secondDisposed); + parent.removeFromParent(); + field.update(); + field.update(); + expect(secondDisposed).toHaveBeenCalledTimes(1); + expect(receiver.children).toHaveLength(0); + field.dispose(); + }); + + it("restores the legacy plane on reuse and clears only owned projected geometry", () => { + const { root, receiver, field, slots } = fixture(1); + const plane = slots[0]?.geometry; + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const projectedDisposed = vi.fn(); + slots[0]?.geometry.addEventListener("dispose", projectedDisposed); + field.place(new Vector3(1, 2, 3), normal, "stone", 2); + expect(projectedDisposed).toHaveBeenCalledTimes(1); + expect(slots[0]?.parent).toBe(root); + expect(slots[0]?.geometry).toBe(plane); + expect(slots[0]?.position.toArray()).toEqual([1, 2, 3.01]); + expect(slots[0]?.scale.x).toBe(2); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const clearedDisposed = vi.fn(); + slots[0]?.geometry.addEventListener("dispose", clearedDisposed); + field.clear(); + field.clear(); + expect(clearedDisposed).toHaveBeenCalledTimes(1); + expect(slots[0]?.geometry).toBe(plane); + expect(receiver.children).toHaveLength(0); + field.dispose(); + }); + + it("uses a supplied base geometry across render LOD swaps without mutating or owning it", () => { + const { receiver, field, slots } = fixture(); + const base = baseGeometryOf(receiver); + const coarse = new PlaneGeometry(0.1, 0.1); + receiver.geometry = coarse; + field.project(receiver, base, new Vector3(2, 0, 0), normal, "stone", { depth: 0.2 }); + expect(slots[0]?.geometry.getAttribute("position").count).toBeGreaterThan(0); + expect(receiver.geometry).toBe(coarse); + const localPositions = Array.from(slots[0]?.geometry.getAttribute("position").array ?? []); + coarse.dispose(); + receiver.geometry = base; + expect(Array.from(slots[0]?.geometry.getAttribute("position").array ?? [])).toEqual( + localPositions, + ); + field.dispose(); + }); + + it.each([ + ["skinned", () => new SkinnedMesh(new PlaneGeometry(8, 8), new MeshBasicMaterial())], + ["instanced", () => new InstancedMesh(new PlaneGeometry(8, 8), new MeshBasicMaterial(), 1)], + ["batched", () => new BatchedMesh(2, 20, 30, new MeshBasicMaterial())], + [ + "transparent", + () => new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial({ transparent: true })), + ], + ["cutout", () => new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial({ alphaTest: 0.5 }))], + [ + "morphed", + () => { + const mesh = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + mesh.geometry.morphAttributes.position = [ + new Float32BufferAttribute(new Float32Array(12), 3), + ]; + return mesh; + }, + ], + [ + "nonuniform", + () => { + const mesh = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + mesh.scale.set(1, 2, 1); + return mesh; + }, + ], + [ + "mirrored", + () => { + const mesh = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + mesh.scale.x = -1; + return mesh; + }, + ], + [ + "singular", + () => { + const mesh = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + mesh.scale.x = 0; + return mesh; + }, + ], + ])("refuses %s receivers before recycling a live slot", (_name, makeReceiver) => { + const { root, receiver, field, slots } = fixture(1); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const existing = slots[0]?.geometry; + const unsupported = makeReceiver(); + root.add(unsupported); + expect(() => + field.project(unsupported, unsupported.geometry, new Vector3(), normal, "stone", { + depth: 0.2, + }), + ).toThrow(/unsupported receiver/i); + expect(slots[0]?.geometry).toBe(existing); + expect(field.placed).toBe(1); + field.dispose(); + }); + + it("does not consume a slot on a miss, after disposal, or with an invalid projector", () => { + const { receiver, field, slots } = fixture(1); + const original = slots[0]?.geometry; + expect( + field.project(receiver, receiver.geometry, new Vector3(20, 0, 0), normal, "stone", { + depth: 0.2, + }), + ).toBe(false); + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0 }), + ).toThrow(/projector/i); + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), new Vector3(), "stone", { + depth: 0.2, + }), + ).toThrow(/projector/i); + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), new Vector3(1e308, 0, 0), "stone", { + depth: 0.2, + }), + ).toThrow(/projector/i); + expect(field.placed).toBe(0); + expect(slots[0]?.geometry).toBe(original); + field.dispose(); + expect( + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }), + ).toBe(false); + }); + + it("refuses nonuniform ancestor scale and shear", () => { + const { root, receiver, field } = fixture(); + const parent = new Group(); + root.add(parent); + parent.add(receiver); + parent.scale.set(1, 2, 1); + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }), + ).toThrow(/unsupported receiver/i); + parent.scale.setScalar(1); + receiver.matrixAutoUpdate = false; + receiver.matrix.makeShear(0.5, 0, 0, 0, 0, 0); + expect(() => + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }), + ).toThrow(/unsupported receiver/i); + field.dispose(); + }); + + it("explicit receiver cleanup does not retire another receiver's marks", () => { + const { root, receiver, field, slots } = fixture(); + const other = new Mesh(new PlaneGeometry(8, 8), new MeshBasicMaterial()); + root.add(other); + field.project(receiver, receiver.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + field.project(other, other.geometry, new Vector3(), normal, "stone", { depth: 0.2 }); + const retained = slots[1]?.geometry; + field.removeReceiver(receiver); + field.removeReceiver(receiver); + expect(receiver.children).toHaveLength(0); + expect(other.children).toHaveLength(1); + expect(slots[1]?.geometry).toBe(retained); + field.dispose(); + }); +}); diff --git a/packages/create-threenative/__tests__/template-assets-compile-1.spec.ts b/packages/create-threenative/__tests__/template-assets-compile-1.spec.ts new file mode 100644 index 0000000000..77b53fe820 --- /dev/null +++ b/packages/create-threenative/__tests__/template-assets-compile-1.spec.ts @@ -0,0 +1,37 @@ +/** + * No shipped template may configure a compile pass off — first half of the sweep. + * + * The cases and the reason they exist live in `template-assets-compile-cases.ts`; this file is the + * half of the template list `vitest --shard` sends here, and the other half's twin is + * `template-assets-compile-2.spec.ts`. + */ + +import { describe, expect, it } from "vitest"; +import { + compilesUnderTheDefaultConfig, + compilesWithCompressionOnByDefault, + firstHalf, + reachesTheUncookedBudgetWithAnEligibleSourceProbe, + templates, +} from "./template-assets-compile-cases.js"; + +describe("shipped templates", () => { + it.each(firstHalf)( + "%s reaches the uncooked budget with an eligible source probe", + reachesTheUncookedBudgetWithAnEligibleSourceProbe, + ); + + it("should name at least one template, or this gate is measuring nothing", () => { + expect(templates.length).toBeGreaterThan(0); + }); + + it.each(firstHalf)( + "%s should compile with compression on by default", + compilesWithCompressionOnByDefault, + ); + + it.each(firstHalf)( + "%s assets should compile under the default config", + compilesUnderTheDefaultConfig, + ); +}); diff --git a/packages/create-threenative/__tests__/template-assets-compile-2.spec.ts b/packages/create-threenative/__tests__/template-assets-compile-2.spec.ts new file mode 100644 index 0000000000..1294f4b2c8 --- /dev/null +++ b/packages/create-threenative/__tests__/template-assets-compile-2.spec.ts @@ -0,0 +1,33 @@ +/** + * No shipped template may configure a compile pass off — second half of the sweep. + * + * The cases and the reason they exist live in `template-assets-compile-cases.ts`; this file is the + * half of the template list `vitest --shard` sends here, and the other half's twin is + * `template-assets-compile-1.spec.ts`, which carries the suite's one whole-suite assertion. This + * file's half fails closed in that module rather than by repeating it. + */ + +import { describe, it } from "vitest"; +import { + compilesUnderTheDefaultConfig, + compilesWithCompressionOnByDefault, + reachesTheUncookedBudgetWithAnEligibleSourceProbe, + secondHalf, +} from "./template-assets-compile-cases.js"; + +describe("shipped templates", () => { + it.each(secondHalf)( + "%s reaches the uncooked budget with an eligible source probe", + reachesTheUncookedBudgetWithAnEligibleSourceProbe, + ); + + it.each(secondHalf)( + "%s should compile with compression on by default", + compilesWithCompressionOnByDefault, + ); + + it.each(secondHalf)( + "%s assets should compile under the default config", + compilesUnderTheDefaultConfig, + ); +}); diff --git a/packages/create-threenative/__tests__/template-assets-compile-cases.ts b/packages/create-threenative/__tests__/template-assets-compile-cases.ts new file mode 100644 index 0000000000..50f7102c88 --- /dev/null +++ b/packages/create-threenative/__tests__/template-assets-compile-cases.ts @@ -0,0 +1,102 @@ +/** + * No shipped template may configure a compile pass off. + * + * `templates/starter` and `templates/sailing` both carried `assets: { models: "none", textures: + * "none" }`, written for two proof files that grew slightly under compression — the PNG is 150 + * bytes and compresses to 542. Scaffolded games inherit that object verbatim and no game revisits + * it: one shipped 2,003 MB of manifest output containing 53 PNG, 35 JPG and not one `.ktx2`, + * where the pipeline emits 10.5 MB for every 38 MB of real game texture. A 392-byte regression on + * a proof asset bought a 27.5 MB one on every real one. + * + * `/AGENTS.md`: *if the engine can measure the right value at the point of use, it decides.* The + * compile step's default is compression on; the template's job is to leave it alone. This gate + * holds it there, and proves the default actually compiles what the templates ship. + * + * The sweep lives in one module and two spec files because `vitest --shard` divides a list of + * files, never a file: the thirteen templates in one file measured 317s of test time on CI run + * 37071464562, and no shard count divided that. `template-assets-compile-1.spec.ts` and + * `template-assets-compile-2.spec.ts` run the same cases over the two halves of the list, so the + * heaviest single unit file is one half of the sweep. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import path from "node:path"; +import { compileAssets } from "@threenative/assets"; +import { expect } from "vitest"; +import { rgbaPng } from "../../../test-support/png.js"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; +import { TEMPLATE_ROOT, allTemplates } from "../../../test-support/templates.js"; +import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; +import { loadConfig } from "../src/config.js"; +import { createProject } from "../src/index.js"; + +export const templates = allTemplates(); + +const midpoint = Math.ceil(templates.length / 2); +export const firstHalf = templates.slice(0, midpoint); +export const secondHalf = templates.slice(midpoint); +// A half that empties leaves its spec file asserting one cheap case over nothing, which reads like +// a covered sweep. The count is the only thing that can break it, so it fails here instead. +if (firstHalf.length === 0 || secondHalf.length === 0) { + throw new Error( + `TN_TEMPLATE_ASSETS_HALF_EMPTY: ${templates.length} templates cannot split in two.`, + ); +} + +export async function reachesTheUncookedBudgetWithAnEligibleSourceProbe(template: string) { + const root = await makeTempDir(`threenative-template-budget-${template}-`); + const { target } = await createProject({ install: false, target: template, template }, root); + const config = await loadConfig(target); + await mkdir(join(target, "assets"), { recursive: true }); + await writeFile(join(target, "assets/budget-probe.png"), rgbaPng({ width: 16, height: 16 })); + // Empty and fully cooked templates should pass any uncooked ceiling. The planted raw + // source makes the negative control meaningful for every scaffold, including empty kits. + const options = { + cwd: target, + transcoder: basisTranscoderPaths(), + config: { ...config.assets, textures: "none" as const }, + }; + await expect(compileAssets(options)).resolves.toBeDefined(); + await expect( + compileAssets({ ...options, config: { ...options.config, budget: 1 } }), + ).rejects.toThrow("TN_ASSETS_BUDGET_EXCEEDED"); +} + +export async function compilesWithCompressionOnByDefault(template: string) { + const source = await readFile(join(TEMPLATE_ROOT, template, "threenative.config.ts"), "utf8"); + + // Narrow on purpose: an override that *configures* a pass is welcome, and only the "none" + // shorthand — the one that ships bytes as authored forever — is refused here. + expect(source).not.toMatch(/\bmodels:\s*"none"/u); + expect(source).not.toMatch(/\btextures:\s*"none"/u); +} + +export async function compilesUnderTheDefaultConfig(template: string) { + const root = await makeTempDir(`threenative-template-assets-${template}-`); + const { target } = await createProject({ install: false, target: template, template }, root); + const config = await loadConfig(target); + // Defaults everywhere, except per-clip audio declarations: those configure the audio pass + // (rain ships its baked float storm clips unconditioned, with their loops declared) rather + // than skipping it, and the compile below runs through them. + expect(Object.keys(config.assets ?? {}).filter((key) => key !== "audio")).toEqual([]); + // Exercise the actual scaffold's config seam, including kits with no source assets. + const result = await compileAssets({ + cwd: target, + config: config.assets, + transcoder: basisTranscoderPaths(), + }); + if (result.written === 0) { + await expect( + readFile(path.join(target, "public", "assets.manifest.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + return; + } + + const manifest = JSON.parse( + await readFile(path.join(target, "public", "assets.manifest.json"), "utf8"), + ) as { entries: Record }; + expect(Object.keys(manifest.entries).length).toBeGreaterThan(0); +} diff --git a/packages/create-threenative/__tests__/template-assets-compile.spec.ts b/packages/create-threenative/__tests__/template-assets-compile.spec.ts deleted file mode 100644 index 2e42b942a9..0000000000 --- a/packages/create-threenative/__tests__/template-assets-compile.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * No shipped template may configure a compile pass off. - * - * `templates/starter` and `templates/sailing` both carried `assets: { models: "none", textures: - * "none" }`, written for two proof files that grew slightly under compression — the PNG is 150 - * bytes and compresses to 542. Scaffolded games inherit that object verbatim and no game revisits - * it: one shipped 2,003 MB of manifest output containing 53 PNG, 35 JPG and not one `.ktx2`, - * where the pipeline emits 10.5 MB for every 38 MB of real game texture. A 392-byte regression on - * a proof asset bought a 27.5 MB one on every real one. - * - * `/AGENTS.md`: *if the engine can measure the right value at the point of use, it decides.* The - * compile step's default is compression on; the template's job is to leave it alone. This gate - * holds it there, and proves the default actually compiles what the templates ship. - * - */ - -import { readdirSync, statSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { compileAssets } from "@threenative/assets"; -import { describe, expect, it } from "vitest"; -import { rgbaPng } from "../../../test-support/png.js"; -import { makeTempDir } from "../../../test-support/temp-dir.js"; -import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; -import { loadConfig } from "../src/config.js"; -import { createProject } from "../src/index.js"; - -const templatesRoot = fileURLToPath(new URL("../templates", import.meta.url)); -const templates = readdirSync(templatesRoot).filter((entry) => - statSync(join(templatesRoot, entry)).isDirectory(), -); - -describe("shipped templates", () => { - it.each(templates)( - "%s reaches the uncooked budget with an eligible source probe", - async (template) => { - const root = await makeTempDir(`threenative-template-budget-${template}-`); - const { target } = await createProject({ install: false, target: template, template }, root); - const config = await loadConfig(target); - await mkdir(join(target, "assets"), { recursive: true }); - await writeFile(join(target, "assets/budget-probe.png"), rgbaPng({ width: 16, height: 16 })); - // Empty and fully cooked templates should pass any uncooked ceiling. The planted raw - // source makes the negative control meaningful for every scaffold, including empty kits. - const options = { - cwd: target, - transcoder: basisTranscoderPaths(), - config: { ...config.assets, textures: "none" as const }, - }; - await expect(compileAssets(options)).resolves.toBeDefined(); - await expect( - compileAssets({ ...options, config: { ...options.config, budget: 1 } }), - ).rejects.toThrow("TN_ASSETS_BUDGET_EXCEEDED"); - }, - ); - - it("should name at least one template, or this gate is measuring nothing", () => { - expect(templates.length).toBeGreaterThan(0); - }); - - it.each(templates)("%s should compile with compression on by default", async (template) => { - const source = await readFile(join(templatesRoot, template, "threenative.config.ts"), "utf8"); - - // Narrow on purpose: an override that *configures* a pass is welcome, and only the "none" - // shorthand — the one that ships bytes as authored forever — is refused here. - expect(source).not.toMatch(/\bmodels:\s*"none"/u); - expect(source).not.toMatch(/\btextures:\s*"none"/u); - }); - - it.each(templates)("%s assets should compile under the default config", async (template) => { - const root = await makeTempDir(`threenative-template-assets-${template}-`); - const { target } = await createProject({ install: false, target: template, template }, root); - const config = await loadConfig(target); - expect(config.assets).toBeUndefined(); - // Exercise the actual scaffold's config seam, including kits with no source assets. - const result = await compileAssets({ - cwd: target, - config: config.assets, - transcoder: basisTranscoderPaths(), - }); - if (result.written === 0) { - await expect( - readFile(path.join(target, "public", "assets.manifest.json")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - return; - } - - const manifest = JSON.parse( - await readFile(path.join(target, "public", "assets.manifest.json"), "utf8"), - ) as { entries: Record }; - expect(Object.keys(manifest.entries).length).toBeGreaterThan(0); - }); -}); diff --git a/packages/create-threenative/__tests__/template-quality.spec.ts b/packages/create-threenative/__tests__/template-quality.spec.ts index c5aaabb58e..de7da290e7 100644 --- a/packages/create-threenative/__tests__/template-quality.spec.ts +++ b/packages/create-threenative/__tests__/template-quality.spec.ts @@ -143,7 +143,12 @@ describe("template quality tiers", () => { const renderer = { kind: "webgpu", raw: {}, - createRenderChain: () => ({ applied: { dropped: [], stages: [] }, dispose() {} }), + // A working chain runs what it was asked for; a kit that fails closed when its own + // stage is missing (rain) must see it applied, as it would on a real renderer. + createRenderChain: (options?: { request?: { stages?: readonly string[] } }) => ({ + applied: { dropped: [], stages: [...(options?.request?.stages ?? [])] }, + dispose() {}, + }), }; const reported = (environment: Parameters[3]): string => { info.mockClear(); diff --git a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts index 32111fb354..dc9922414f 100644 --- a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts @@ -7,12 +7,14 @@ import { MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, + PlaneGeometry, Scene, Texture, Vector2, Vector3, } from "three"; import { describe, expect, it, vi } from "vitest"; +import { Registry } from "../../core/src/entities.js"; import { createRandom } from "../../core/src/random.js"; import { rapier } from "../../physics/src/index.js"; import type { IPhysicsContext } from "../../physics/src/plugin.js"; @@ -276,6 +278,65 @@ function runSceneFrames(frame: unknown, context: unknown, beforeMeasure?: () => } describe("generated template ordinary-frame runtime cost", () => { + it("releases the shooter-owned decal texture after its borrowers on repeated scene teardown", async () => { + const { Play } = await import("../templates/shooter/src/scenes/Play.js"); + const { ENEMY_CLIPS } = await import("../templates/shooter/src/entities/Enemy.js"); + const maps = new Set(); + for (let cycle = 0; cycle < 3; cycle += 1) { + const physics = await physicsFixture(); + const entities = new Registry(); + const context = { ...sceneContext(physics.physics, Play.initialState), entities }; + const sky = new Texture(); + const skyDisposed = vi.fn(); + sky.addEventListener("dispose", skyDisposed); + const play = new Play(); + try { + await play.load({ + ...context, + assets: { + ...context.assets, + model: async (): Promise => templatedRig(ENEMY_CLIPS) as T, + texture: async () => sky, + }, + } as never); + play.enter(context as never); + const slots = context.scene.children.filter( + (child): child is Mesh => + child instanceof Mesh && + child.geometry instanceof PlaneGeometry && + child.renderOrder === 23, + ); + expect(slots).toHaveLength(224); + const map = slots[0]?.material.map; + if (map === undefined || map === null) throw new Error("Shooter decal texture missing."); + expect(new Set(slots.map((slot) => slot.material.map))).toEqual(new Set([map])); + expect(maps.has(map)).toBe(false); + maps.add(map); + let releasedMaterials = 0; + for (const slot of slots) { + slot.material.addEventListener("dispose", () => { + releasedMaterials += 1; + }); + } + const mapDisposed = vi.fn(() => expect(releasedMaterials).toBe(slots.length)); + map.addEventListener("dispose", mapDisposed); + + play.exit(); + entities.clear(); + expect(mapDisposed).toHaveBeenCalledTimes(1); + play.exit(); + entities.clear(); + expect(mapDisposed).toHaveBeenCalledTimes(1); + expect(skyDisposed).not.toHaveBeenCalled(); + } finally { + play.exit(); + entities.clear(); + physics.dispose(); + sky.dispose(); + } + } + }); + it("executes 600 steady frames per template without fresh vector work", async () => { const minimal = await import("../templates/minimal/src/render/camera.js"); const starter = await import("../templates/starter/src/entities/Player.js"); diff --git a/packages/create-threenative/agent-docs/references/assertion-reference.md b/packages/create-threenative/agent-docs/references/assertion-reference.md index f4856c4c27..65dd55bac1 100644 --- a/packages/create-threenative/agent-docs/references/assertion-reference.md +++ b/packages/create-threenative/agent-docs/references/assertion-reference.md @@ -856,3 +856,20 @@ Proves the render chain reports its quality tier, authored stage ids/order, grap } } ``` + + +## Captured tone + +Use `tone` to gate exposure from decoded frame pixels. Each row takes an optional `atStep` +(named step; omitted means final frame) and at least one metric bound. `mean`, `p1`, `p50`, `p99` +use display luminance 0..255; `clipFraction` and `blackFraction` use 0..1. Bounds take inclusive +min/max numbers. Empty, invalid and contradictory bounds throw at load; a missing capture fails. + +```json +{ "tone": [{ "atStep": "landed", "mean": { "min": 60, "max": 140 }, "p99": { "min": 150 }, "clipFraction": { "max": 0.005 } }] } +``` + +The host uses one rounded 256-bin Rec.709 luminance histogram; fully transparent pixels are +excluded. The CLI command `threenative-playtest tone shot.png other.png` reports the same six +metrics plus an unweighted frame-average row without starting a browser. Tone is an exposure +gate, not an aesthetic verdict. Retain and inspect the actual runtime screenshots. diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index f7073d6b64..c3a7bddb85 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -3,7 +3,7 @@ # Capability reference -Every public class and function export represented by this manifest across `@threenative/assets`, `@threenative/core`, `@threenative/metahuman`, `@threenative/physics`, `@threenative/playtest`, `@threenative/raw-unreal`, `@threenative/ueformat`, `@threenative/ui`, `template:starter`, and `three`, +Every public class and function export represented by this manifest across `@threenative/assets`, `@threenative/core`, `@threenative/metahuman`, `@threenative/physics`, `@threenative/playtest`, `@threenative/raw-unreal`, `@threenative/ueformat`, `@threenative/ui`, `template:rain`, `template:starter`, and `three`, generated from the doc tags the engine itself compiles, so this page cannot disagree with the code. Look here before writing a replacement; ask `engine_search_capabilities` when an MCP server is available. @@ -618,8 +618,8 @@ model.step(1 / 60, { turn: -1, pitch: 0.4 }); export class AudioBus { … } ``` -- **Use when:** play a sound effect with a volume bus · mute or adjust a category of game audio · keep a gunshot audible at 20 metres by tuning positional falloff · play cannon, wave, and ship sound effects -- **Constraints:** create buses before playing clips and dispose them with the game · refDistance and rolloffFactor tune positional falloff and apply to playAt only +- **Use when:** play a sound effect with a volume bus · mute or adjust a category of game audio · keep a gunshot audible at 20 metres by tuning positional falloff · hold a loud mix below clipping with a compressor on the bus · play cannon, wave, and ship sound effects +- **Constraints:** create buses before playing clips and dispose them with the game · refDistance and rolloffFactor tune positional falloff and apply to playAt only · compressor takes threshold, knee, ratio, attack and release from the game and takes effect on the bus sum - **Supersedes (writing this fails `pnpm budgets`):** new Audio( ```ts @@ -1170,6 +1170,24 @@ ctx.add(field); field.splat({ x: 0.5, y: 0.5 }, { x: 0.2, y: 0 }, 1); ``` +### `FluidParticles3D` + +`class` — Simulate liquid as GPU particles (pour, splash, dam break, waterfall) and expose positions, a density volume and a surface height while the game owns every look. + +```ts +export class FluidParticles3D extends Group { … } +``` + +- **Use when:** pour, splash, or dam-break water that fills a container and flows around obstacles · simulate water or another liquid as particles in a fluid simulation · drop a ball or box into liquid and let it displace and float on the water · emit a stream or waterfall of particle fluid and drain it somewhere else · sample particle-fluid density or surface height in a game-owned render node +- **Constraints:** add the fluid through `ctx.add` so renderer attachment, fixed-step dispatch, and release are automatic · a renderer without WebGPU compute throws a named error at attach; it never draws nothing · `emit` recycles the oldest slot once `capacity` slots have been used; `fill` stops at capacity · `sample` and `stats` read a throttled GPU copy and report `staleFrames`; they are never live +- **Overrides:** iterations, viscosity, cohesion, vorticity, gravity and maxSpeed tune the solver without changing its pass order + +```ts +const water = new FluidParticles3D({ capacity: 6000 }); +ctx.add(water); +water.fill([-2.8, 0.1, -1.5], [-0.6, 3, 1.5]); +``` + ### `formatSceneWarning` `function` — Warn the agent that built the scene before a human plays it: a frame whose GPU is idle while its JS render phase is longer than the display's own period is a scene-shape problem, and the engine already has the shape. On by default, printed at most once per reported window as `TN_SCENE_WARNING`, and silent on a scene that is honestly GPU-bound. @@ -1284,7 +1302,7 @@ if (isMobile()) showTouchControls(); export class GPUParticles3D extends Sprite implements IComputeDriven { … } ``` -- **Use when:** emit sparks, smoke, or other transient effects · update many small visual particles · trail dust, exhaust, or spray behind a moving object · emit cannon smoke and muzzle flash particles · fire a cannonball projectile with cannon smoke particles +- **Use when:** emit sparks, smoke, or other transient effects · update many small visual particles · trail dust, exhaust, or spray behind a moving object · emit cannon smoke and muzzle flash particles · fire a cannonball projectile with cannon smoke particles · splash spray droplets with lifetime and gravity · spawn water droplets, mist, or foam bubbles above a fluid surface · falling snowflakes, rain or ash around the player that thicken into a windy storm or blizzard · kick up a spray of powder snow or dust where a foot or a ball lands - **Constraints:** geometry, color, and timing remain supplied by the game ```ts @@ -2093,7 +2111,7 @@ defineGame({ display: { maxFps: 60 }, scenes: { Play } }); export class Scheduler { … } ``` -- **Use when:** delay an enemy patrol transition · run a callback every simulation tick · tween a numeric property with a game-owned curve +- **Use when:** delay an enemy patrol transition · run a callback every simulation tick · tween a numeric property with a game-owned curve · a countdown timer: end the level when its time limit runs out - **Constraints:** dispose returned handles when the owning scene exits · ease receives progress in the range 0 to 1 and its return value is the interpolation factor ```ts @@ -2358,7 +2376,7 @@ invalidateStatic(drawbridge); export class TracerPool3D { … } ``` -- **Use when:** show where a hitscan round went · draw incoming fire without spawning projectiles +- **Use when:** show where a hitscan round went · show each round a weapon fires, one tracer per trigger press · draw incoming fire without spawning projectiles - **Constraints:** the surface comes from the game; pooling, travel, and fading belong to the engine · update once per frame and dispose with the owning scene ```ts @@ -2874,7 +2892,7 @@ const capabilities = getWorldCapabilities({ limits: adapter.limits, cpuFallbackI export class Heightfield extends Group implements IComputeDriven { … } ``` -- **Use when:** build terrain geometry and collision from one game-authored height function · generate a terrain a player can walk across · query the same ground height or normal that a player sees and collides with · ask how high the ground is here · build islands and coastlines from terrain +- **Use when:** build terrain geometry and collision from one game-authored height function · generate a terrain a player can walk across · query the same ground height or normal that a player sees and collides with · ask how high the ground is here · build islands and coastlines from terrain · keep a collider or other copy of a deforming terrain in step without rescanning the whole field (`trackChanges`) - **Constraints:** sampleHeight owns the terrain shape and stays in game source; the framework stores and interpolates its output · rows and columns are vertex counts; geometry is row-major z-then-x and collider export transposes once into Rapier's column-major matrix order - **Overrides:** rows, columns, width, depth, origin, and sampleHeight are explicit on every field @@ -2929,6 +2947,43 @@ export async function loadWorldHeightmap(url: string): Promise { const data = await loadWorldHeightmap("/world/terrain/heightmap.u16"); ``` +### `snowDiscFootprint` + +`function` — A circular contact footprint, sized from a radius the caller measures. The shape a sphere, a ball, a wheel or a probe presses into snow, and the default the physics binding derives from a sphere's contact geometry. Flat inside the radius, eased over the softness band, with a raised rim just outside it. + +```ts +export function snowDiscFootprint( radius: number, options: ISnowDiscFootprintOptions = { … } +``` + +- **Use when:** press a ball, wheel or probe into snow · give a sphere a physically sized snow contact instead of a boot shape +- **Constraints:** radius is metres; the footprint never grows with load, only deeper +- **Overrides:** softness widens the eased edge without changing the contact radius + +```ts +const footprint = snowDiscFootprint(0.25); +``` + +### `SnowField` + +`class` — Persistent snow deformation over one canonical heightfield. Snow keeps four channels per cell — indentation, displaced bank, compaction and disturbance — and writes the combined surface (`terrain + depth + bank - indent`) back into the heightfield it was given. Queries, rendered geometry and collider export therefore all read one surface, and no second terrain representation exists. The field is numeric only: it never chooses a boot, a tread, a particle, a material or a camera. Games supply terrain heights, the snow depth, the contact profile and the response coefficients; this class owns the storage, the load-dependent penetration and the bounded recovery. It is a heightfield approximation — not granular snow, displaced-volume conservation, avalanches, melting or a calibrated material law. + +```ts +export class SnowField { … } +``` + +- **Use when:** leave footprints, tracks and tyre ruts in snow that persist and fill in over time · let a pushed sphere carve a connected track and a dropped one settle into a crater · store snow deformation that rendered geometry and collision both read · reset a snowfield between rounds or change its depth at runtime · show how packed the snow is where people have walked or objects have rested +- **Constraints:** the field composes onto a Heightfield; construct the terrain first and let this own the surface · zero depth is bare ground: contacts register no indentation at all · out-of-region heightAt and normalAt follow Heightfield's error contract; sample returns zeros +- **Requires:** @threenative/core/world Heightfield as the canonical terrain and surface +- **Overrides:** depth, hardness, yieldFraction, maxBank and responseTime name the response coefficients + +```ts +import { Heightfield } from "@threenative/core/world"; +const terrain = Heightfield.fromSampler({ rows: 129, columns: 129, width: 64, depth: 64, origin: { x: 0, z: 0 }, sampleHeight: (x, z) => Math.sin(x * 0.1) * 0.5 }); +const snow = new SnowField({ field: terrain, depth: 0.28, hardness: 0.3 }); +snow.stamp({ x: 0, z: 0, area: 0.074, load: 784, duration: 0.1, footprint: snowDiscFootprint(0.12) }); +snow.recover(1 / 60, 0.0012, 0.4); +``` + ### `TerrainTiles` `class` — Stream a bounded square of game-authored heightfields and keep their render and physics units together. The class composes ordinary THREE.LOD objects and leaves frustum/projection culling to the renderer's existing scene path. @@ -2939,7 +2994,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { … } - **Use when:** stream terrain without cracks · keep generated terrain resident around a moving player · put a generated terrain tile into a game-owned physics world - **Constraints:** sampleHeight and surface are required game choices; no landform or surface preset is installed · residentTileBudget and residentByteBudget are hard caps; a tile that cannot fit throws · seam gap, LOD pop and the rendered-vertex finiteness scan are measurements that are off by default; TN_TERRAIN_VALIDATE=1, ?tnTerrainValidate=1 or validate: true runs them, and maxSeamGap, maxVisualSeamGap and maxLodPop report undefined while they are off -- **Overrides:** tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, validate, and budgets +- **Overrides:** tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, mergeTiles, validate, and budgets ```ts const tiles = new TerrainTiles({ sampleHeight, surface: gameSurface(), tileSize: 256, tileResolution: 129, residentTileBudget: 25, residentByteBudget: 32_000_000 }); @@ -3100,6 +3155,39 @@ export class Area3D { … } const goal = new Area3D({ physics: ctx.physics, shape: CollisionShape3D.sphere(1.2), position: { x: 0, y: 0.5, z: -8 } }); ``` +### `attachSnowPhysics` + +`function` — Drive a `SnowField` from real solved contacts. The binding creates the snow surface collider from the field's own canonical samples and keeps it in step with them, so a body lands on the surface a query would report. Each fixed step it reads the solver's persistent contacts — not collision start/stop events, which carry no point or load — deforms the snow only where a contact is supported and loaded downward, then republishes the surface before the next step. Airborne bodies, side contacts and unrelated colliders never deform anything. A dropped sphere settles on the surface it made; a pushed one rotates and carves a connected track without its transform being copied anywhere. Footprints come from the body's own collision shape and orientation unless the game supplies one. Contact load is `impulse / deltaTime * loadScale`: a solver impulse over a step, which estimates a contact force and is not measured. `observe().loadProvenance` says so. + +```ts +export function attachSnowPhysics(options: ISnowPhysicsOptions): ISnowPhysicsBinding { … } +``` + +- **Use when:** leave footprints and tracks where physical bodies actually touch snow · let a dropped or pushed sphere carve and settle into deformable snow · make a crate, capsule or ball compress the surface it rests on +- **Constraints:** register `rapier()` before attaching, and call `step` once per fixed step after the physics step · the backend must expose persistent solved contacts and in-place shape refresh; one that does not fails at attach · verified on browser WebGPU and the native Linux desktop host; Android and iOS share the native seam but have not run it · automatic profiles cover sphere, box and capsule; any other shape needs an explicit footprint +- **Requires:** @threenative/core/world SnowField as the surface it deforms +- **Overrides:** loadScale, supportNormal, colliderTolerance, deposition, wind, collisionLayer and collisionMask name the binding's own behaviour + +```ts +const snowPhysics = attachSnowPhysics({ physics: ctx.physics, snow, bodies: [ball] }); +afterPhysics(ctx, (dt) => snowPhysics.step(dt)); +``` + +### `boxFootprint` + +`function` — A rectangular contact: the face a box rests on, in the contact's own frame. Coverage is full across the face and fades just outside it; the bank rises beyond that. + +```ts +export function boxFootprint(halfWidth: number, halfDepth: number): ISnowFootprint { … } +``` + +- **Use when:** let a crate, platform or plank press a rectangular pit into snow · imprint a box's own footprint rather than a circle around it +- **Constraints:** halfWidth and halfDepth are metres; rotation comes from the contact, not the footprint + +```ts +const footprint = boxFootprint(0.4, 0.25); +``` + ### `buildStaticColliders` `function` — Build fixed trimesh bodies from the meshes a game authored in a scene root. @@ -3131,6 +3219,21 @@ export class Buoyancy3D { … } new Buoyancy3D({ body, surface: field, hullPoints, density: 1_000, drag: 4 }); ``` +### `capsuleFootprint` + +`function` — A stadium contact: a rectangle of half-length `halfHeight` and half-width `radius`, capped by two half-discs of `radius`. The shape a capsule lies on, so a knocked-over body sinks along a line instead of at a point. Coverage is full across the body's own width and fades just outside it, so the body's edge never rests on a half-pressed rim; the bank rises beyond that. + +```ts +export function capsuleFootprint(halfHeight: number, radius: number): ISnowFootprint { … } +``` + +- **Use when:** let a fallen capsule, limb or barrel leave a linear imprint in snow · give a capsule a shape-appropriate snow contact instead of a sphere's dot +- **Constraints:** radius and halfHeight are metres and never grow with load + +```ts +const footprint = capsuleFootprint(0.5, 0.2); +``` + ### `CharacterBody3D` `class` — Move a character body with collision-aware sliding. @@ -5448,6 +5551,23 @@ export function useUiState(): TState | undefined; const score = useUiState((state) => state.score); ``` +## `src/audio/storm.ts` + +### `createStormAudio` + +`function` — The storm's sound as generated source, mixed through the engine's own `AudioBus`: a rain hiss, a wind bed, and thunder queued strike by strike and delayed and filtered by how far away it was struck. + +```ts +createStormAudio(ctx: ICtx): IStormAudio +``` + +- **Use when:** rain hiss, wind bed, thunder, a storm you can hear · delay thunder by the distance it was struck · sound that follows pause, mute and tab visibility · audio that queues strikes instead of cutting each other off +- **Constraints:** `queueStrike({ at, metres })` queues: a second strike does not overwrite one still crossing the air, and `at` is absolute simulation time, which is what lets a strike survive a freeze. · Two holds, kept apart so neither latches the other: `setSilenced` is the game's own pause intent, `setHidden` is the UI realm or engine lifecycle visibility. The bus is released only when both are clear. · `update(state)` is the frame's truth and is called once per frame after the simulation advances; `debug()` is what a playtest reads back. · The caller owns entity registration, updates and disposal; creating the helper does not register it. · Audio begins only after a user gesture — the bus's unlock error is surfaced rather than swallowed. · The three clips are baked by `tools/make-storm-audio.mjs` from the study's own maths; this file plays them, it does not synthesise them live. · The helper requests the source's compressor settings ahead of master gain: threshold -15 dB, knee 30 dB, ratio 5, attack 0.003 s and release 0.25 s. · Each thunder voice is a labelled `thunder` cue, so a playtest's `audio` assertion reads it from the runtime ledger; the rain kit's `playtests/lightning.playtest.json` proves one cue after a strike, sounded only once the strike's `distance / 343` delay had passed. · This entry describes the Rain starter scaffold's own generated `src/audio/storm.ts`, not a `@threenative/` or three export — there is no installed package to import it from. + +```ts +src/audio/storm.ts — register the returned helper, call update(state) after advancing the simulation, and dispose it with the owning scene +``` + ## `src/game.ts` ### `renderer.matrixWorld` @@ -5498,6 +5618,74 @@ renderer.projection?: boolean | { materialChecks?: 'spread' | 'everyFrame' } renderer: { projection: false } // in threenative.config.ts ``` +## `src/render/lightning.ts` + +### `createStormLightning` + +`function` — A branching lightning bolt as generated Rain source: `makeBolt(end, random)` walks a 30-segment channel down from the cloud deck with side branches, and one additive camera-facing ribbon draw (gaussian core inside a wide halo) shows it while the caller's flash envelope is above zero. `strike(position, random)` returns where the bolt entered the sky. + +```ts +createStormLightning(scene: Scene, camera: PerspectiveCamera): IStormLightning +``` + +- **Use when:** generate branching lightning ribbon geometry · a lightning strike that lights the scene and the clouds · a camera-facing ribbon or beam that stays the same pixel width at any distance · a flash envelope that stutters rather than fades once +- **Constraints:** Pass the game's seeded random so a strike is reproducible; a non-finite position or random value throws rather than drawing NaN geometry. · The buffers hold the worst case (30 channel segments plus 24 branches of 9) and are allocated once; a strike rewrites them and opens the draw range, never reallocating. · `update(flash)` takes an envelope already gated on photosensitivity mode by the caller; below 0.003 the mesh is hidden. Register it with `alwaysRender`: its quads are projected by its own vertex stage, so its bounds say nothing about where it lands. · It depth-tests against the coast's written depth, so a bolt behind the headland is hidden by it. · This entry describes the Rain starter scaffold's own generated `src/render/lightning.ts`, not a `@threenative/` or three export — there is no installed package to import it from. + +```ts +src/render/lightning.ts — the bolt; src/state.ts `flashAt` for the envelope; src/scenes/Boot.ts strike() for the thunder delay +``` + +## `src/render/noise-volume.ts` + +### `createNoiseVolume` + +`function` — The 64³ RGBA cloud noise volume as generated Rain source: `{ data: Uint8Array, texture: Data3DTexture }`. Its authored seed, octave weights and source-exact generator define the storm's cloud shapes. + +```ts +createNoiseVolume(): INoiseVolume +``` + +- **Use when:** generate a 3D noise volume instead of shipping one as an asset · rebuild the storm's clouds procedurally at startup · tileable 3D noise that does not crease at the edges · four octave scales packed into one texture +- **Constraints:** Seed 13291 uses the source's mulberry32 sequence. The generated volume matches all 1,048,576 reference bytes; changing the authored seed or generator changes its appearance. · Two distinct failure channels, not one: `TN_NOISE_CHANNEL` is a channel value that does not fit a byte, `TN_NOISE_LENGTH` is a byte count that does not fill the volume. Both throw rather than uploading a volume the shader would read wrong. · 64³ of RGBA is 1 MiB, and `NOISE_SIZE` is what the loops and the byte count are written against; changing the size changes the memory cost with it. · Indices wrap at every face, which is what makes the volume tile, and the fractional part is smoothed by `f²(3 − 2f)` so lattice edges do not show as creases in the cloud. · This entry describes the Rain starter scaffold's own generated `src/render/noise-volume.ts`, not a `@threenative/` or three export — there is no installed package to import it from. + +```ts +src/render/noise-volume.ts — edit the seed, the lattices or the channel weights there +``` + +## `src/render/rain.ts` + +### `createStormRain` + +`function` — Camera-anchored falling rain as generated Rain source: one instanced draw whose vertex stage hashes each drop from its index, wraps it in a 66 × 30 × 66 m cell that follows the camera in 12 m steps, and projects a quad stretched along its own fall — no particle simulation, no texture. + +```ts +createStormRain(scene: Scene, camera: PerspectiveCamera): IStormRain +``` + +- **Use when:** rain streaks around the camera · thousands of falling drops in one draw · rain that leans with the wind and lights up in a lightning flash +- **Constraints:** The drawn count is `round(rainBudget × weather.rain)` for the tier, read back from `geometry.instanceCount` (`instanceCount`), not from the budget. · Stateless by design: a drop's position is a function of its index and the clock, so pausing the clock freezes the rain and nothing accumulates. `GPUParticles3D` is the engine's emitter with lifetimes; reach for it when drops must spawn, live and die. · Register it with `alwaysRender`: the vertex stage places every drop, so the geometry's own bounds do not. · This entry describes the Rain starter scaffold's own generated `src/render/rain.ts`, not a `@threenative/` or three export — there is no installed package to import it from. + +```ts +src/render/rain.ts — the drop maths; `STUDY_TIERS` in src/render/quality.ts for the per-tier budget +``` + +## `src/render/world.ts` + +### `createWeatherWorld` + +`function` — The raymarched coastal storm as generated source: one screen quad whose TSL shader draws the sky, the sea, the coast, the road, the forest and the lamps, and whose wet ground reflects them, written from the uniforms the game feeds it each frame. The sky it samples is the live cloud pass this call builds, not a stand-in gradient. Returns `{ quad, update(options), dispose() }`. + +```ts +createWeatherWorld(scene: Scene, camera: PerspectiveCamera): IWeatherWorld +``` + +- **Use when:** a procedural storm, sea, coastline or open horizon drawn from maths rather than geometry · rain and wind that change how a scene looks without swapping a mesh · a lightning flash that lights the coast and the sky at once · wet reflections on a ground plane · sky that darkens as cloud cover rises +- **Constraints:** `update` takes `{ elapsed, flash, weather, strike, quality }`: absolute seconds, a flash envelope already gated on the photosensitivity switch by the caller, the bolt's entry point in the cloud deck in metres (what the clouds glow around and the coast is lit from), and a quality name of `performance | balanced | high | ultra`. · The tier's cloud resolution share, march steps and reflection switch come from `STUDY_TIERS` in src/render/quality.ts; `performance` skips the 36-step reflection march, so a low tier loses reflections, not the coast. `clouds()` reads back the cloud target the renderer really drew and its steps. · Its colours and lights are uniforms set once from src/render/palette.ts, sky.ts, lighting.ts and materials.ts; the shader itself is generated from tools/tempest-*.frag by tools/generate-shaders.mjs — edit the .frag, not the generated file. · The engine's `Scene` and `PerspectiveCamera` are injected as arguments; the file holds no engine lifetime of its own. `dispose()` removes the quad and disposes the geometry, material and sky texture — the scene does not do it for you. · The quad has `frustumCulled = false`, because its two-metre bounds would otherwise be culled the moment the camera looks along the coast. · The cloud pass is built first and its texture feeds the world shader (sampled with the render-target flip); `update` also drives the clouds. Rain streaks and the bolt are separate draws: `createStormRain`, `createStormLightning`. · This entry describes the Rain starter scaffold's own generated `src/render/world.ts`, not a `@threenative/` or three export — there is no installed package to import it from. Appearance lives in that source with no package import at all; nothing in packages/ decides how this storm looks. + +```ts +src/render/world.ts — edit the shader there, or the weather it is fed from src/state.ts +``` + ## `src/render/worldEnvironment.ts` ### `WorldEnvironment` diff --git a/packages/create-threenative/agent-docs/references/finding-assets.md b/packages/create-threenative/agent-docs/references/finding-assets.md index 4e68782be5..aefade4aee 100644 --- a/packages/create-threenative/agent-docs/references/finding-assets.md +++ b/packages/create-threenative/agent-docs/references/finding-assets.md @@ -162,6 +162,21 @@ What the import will and will not do: copy, print, or persist cookies or tokens; interact with the login page instead. Searching and `fab_get_asset` stay anonymous, so the licence check never depends on the authenticated path it is guarding. +- **If the WebView login fails, use the manual paste flow.** On Wayland the WebView dies with Gdk + "Error 71 (Protocol error)", and `GDK_BACKEND=x11` still may not let the user sign in. + `fabcli auth login --manual` refuses a non-TTY stdin, so run it under a pty fed by a FIFO: + `mkfifo f; (sleep 1200 > f &); (script -qfec "fabcli auth login --manual" /dev/null < f > log &)`. + The user opens Epic's redirect endpoint + (`https://www.epicgames.com/id/api/redirect?clientId=&responseType=code`; + `strings "$(command -v fabcli)" | grep epicgames.com/id/login` prints the full login URL) in + their own signed-in browser. It returns JSON with an `authorizationCode`; the user pastes that + value to the agent (or, when asked, the agent reads it from the page through browser control), + and the agent writes it with `printf '%s\n' "$CODE" > f` without echoing it. The pty echoes the + code into `log`, so keep that file private and delete it once `fabcli auth status` reports + `authenticated: true`. The separate Fab web session can still read `session_present: false`; + `fab_import_asset` resolves ownership through the library and works regardless. Never run + `pkill -f ` in the same shell command that names the pattern: it matches its own + command line and kills the shell. - **Two external tools install themselves on first use** (FabCLI and UE Viewer). Set `THREENATIVE_TOOLCHAIN_AUTOINSTALL=0` to require you install them instead. Linux and Windows only. - **`packages` is not optional in practice.** A marketplace pack converts to many gigabytes of diff --git a/packages/create-threenative/agent-files/.agents/skills/threenative-assets/SKILL.md b/packages/create-threenative/agent-files/.agents/skills/threenative-assets/SKILL.md index af4f2288c7..46bcf4831b 100644 --- a/packages/create-threenative/agent-files/.agents/skills/threenative-assets/SKILL.md +++ b/packages/create-threenative/agent-files/.agents/skills/threenative-assets/SKILL.md @@ -24,7 +24,8 @@ need Unreal Editor export and cannot be recovered by retrying the converter. If FabCLI needs a session, proactively run `fabcli auth login`. Use Claude browser, or Codex's `chrome:control-chrome`, to complete the login with the user's active browser session, then retry -the MCP call. Never read, copy, print, or persist browser cookies or tokens. The MCP checks +the MCP call. If the WebView login fails (it crashes on Wayland), use the `--manual` pty flow in +the reference below. Never read, copy, print, or persist browser cookies or tokens. The MCP checks entitlement and accepts only Fab Standard or CC-BY; it never claims or buys. Full arguments: `node_modules/create-threenative/agent-docs/references/finding-assets.md`. diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index b1bb552c38..203e7086cf 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -794,7 +794,8 @@ "aliases": [], "constraints": [ "create buses before playing clips and dispose them with the game", - "refDistance and rolloffFactor tune positional falloff and apply to playAt only" + "refDistance and rolloffFactor tune positional falloff and apply to playAt only", + "compressor takes threshold, knee, ratio, attack and release from the game and takes effect on the bus sum" ], "example": "const effects = new AudioBus({ camera });", "importPath": "@threenative/core", @@ -806,6 +807,7 @@ "play a sound effect with a volume bus", "mute or adjust a category of game audio", "keep a gunshot audible at 20 metres by tuning positional falloff", + "hold a loud mix below clipping with a compressor on the bus", "play cannon, wave, and ship sound effects" ], "summary": "Route effects through a named audio bus.", @@ -1496,6 +1498,33 @@ "supersedes": [], "symbol": "FluidField2D" }, + { + "aliases": [], + "constraints": [ + "add the fluid through `ctx.add` so renderer attachment, fixed-step dispatch, and release are automatic", + "a renderer without WebGPU compute throws a named error at attach; it never draws nothing", + "`emit` recycles the oldest slot once `capacity` slots have been used; `fill` stops at capacity", + "`sample` and `stats` read a throttled GPU copy and report `staleFrames`; they are never live" + ], + "example": "const water = new FluidParticles3D({ capacity: 6000 });\nctx.add(water);\nwater.fill([-2.8, 0.1, -1.5], [-0.6, 3, 1.5]);", + "importPath": "@threenative/core", + "kind": "class", + "overrides": [ + "iterations, viscosity, cohesion, vorticity, gravity and maxSpeed tune the solver without changing its pass order" + ], + "package": "@threenative/core", + "signature": "export class FluidParticles3D extends Group { … }", + "situations": [ + "pour, splash, or dam-break water that fills a container and flows around obstacles", + "simulate water or another liquid as particles in a fluid simulation", + "drop a ball or box into liquid and let it displace and float on the water", + "emit a stream or waterfall of particle fluid and drain it somewhere else", + "sample particle-fluid density or surface height in a game-owned render node" + ], + "summary": "Simulate liquid as GPU particles (pour, splash, dam break, waterfall) and expose positions, a density volume and a surface height while the game owns every look.", + "supersedes": [], + "symbol": "FluidParticles3D" + }, { "aliases": [], "constraints": [ @@ -1662,7 +1691,11 @@ "update many small visual particles", "trail dust, exhaust, or spray behind a moving object", "emit cannon smoke and muzzle flash particles", - "fire a cannonball projectile with cannon smoke particles" + "fire a cannonball projectile with cannon smoke particles", + "splash spray droplets with lifetime and gravity", + "spawn water droplets, mist, or foam bubbles above a fluid surface", + "falling snowflakes, rain or ash around the player that thicken into a windy storm or blizzard", + "kick up a spray of powder snow or dust where a foot or a ball lands" ], "summary": "Dispatch a game-owned particle surface and process function through a pooled system.", "supersedes": [], @@ -2686,7 +2719,8 @@ "situations": [ "delay an enemy patrol transition", "run a callback every simulation tick", - "tween a numeric property with a game-owned curve" + "tween a numeric property with a game-owned curve", + "a countdown timer: end the level when its time limit runs out" ], "summary": "Schedule delayed and repeating callbacks or tween numeric properties with game-owned cleanup.", "supersedes": [], @@ -3042,6 +3076,7 @@ "signature": "export class TracerPool3D { … }", "situations": [ "show where a hitscan round went", + "show each round a weapon fires, one tracer per trigger press", "draw incoming fire without spawning projectiles" ], "summary": "Pool travelling bullet-streak meshes for hitscan shots.", @@ -3658,7 +3693,8 @@ "generate a terrain a player can walk across", "query the same ground height or normal that a player sees and collides with", "ask how high the ground is here", - "build islands and coastlines from terrain" + "build islands and coastlines from terrain", + "keep a collider or other copy of a deforming terrain in step without rescanning the whole field (`trackChanges`)" ], "summary": "One height buffer shared by world queries, rendered geometry, and a physics heightfield. The game supplies every value, so changing the terrain's shape never requires a package edit. `fromSampler` evaluates that game function exactly once at each vertex and retains only the resulting numbers. Queries interpolate those same numbers instead of evaluating the function again.", "supersedes": [], @@ -3719,6 +3755,50 @@ "supersedes": [], "symbol": "loadWorldHeightmap" }, + { + "aliases": [], + "constraints": ["radius is metres; the footprint never grows with load, only deeper"], + "example": "const footprint = snowDiscFootprint(0.25);", + "importPath": "@threenative/core/world", + "kind": "function", + "overrides": ["softness widens the eased edge without changing the contact radius"], + "package": "@threenative/core", + "signature": "export function snowDiscFootprint( radius: number, options: ISnowDiscFootprintOptions = { … }", + "situations": [ + "press a ball, wheel or probe into snow", + "give a sphere a physically sized snow contact instead of a boot shape" + ], + "summary": "A circular contact footprint, sized from a radius the caller measures. The shape a sphere, a ball, a wheel or a probe presses into snow, and the default the physics binding derives from a sphere's contact geometry. Flat inside the radius, eased over the softness band, with a raised rim just outside it.", + "supersedes": [], + "symbol": "snowDiscFootprint" + }, + { + "aliases": [], + "constraints": [ + "the field composes onto a Heightfield; construct the terrain first and let this own the surface", + "zero depth is bare ground: contacts register no indentation at all", + "out-of-region heightAt and normalAt follow Heightfield's error contract; sample returns zeros" + ], + "example": "import { Heightfield } from \"@threenative/core/world\";\nconst terrain = Heightfield.fromSampler({ rows: 129, columns: 129, width: 64, depth: 64, origin: { x: 0, z: 0 }, sampleHeight: (x, z) => Math.sin(x * 0.1) * 0.5 });\nconst snow = new SnowField({ field: terrain, depth: 0.28, hardness: 0.3 });\nsnow.stamp({ x: 0, z: 0, area: 0.074, load: 784, duration: 0.1, footprint: snowDiscFootprint(0.12) });\nsnow.recover(1 / 60, 0.0012, 0.4);", + "importPath": "@threenative/core/world", + "kind": "class", + "overrides": [ + "depth, hardness, yieldFraction, maxBank and responseTime name the response coefficients" + ], + "package": "@threenative/core", + "requires": ["@threenative/core/world Heightfield as the canonical terrain and surface"], + "signature": "export class SnowField { … }", + "situations": [ + "leave footprints, tracks and tyre ruts in snow that persist and fill in over time", + "let a pushed sphere carve a connected track and a dropped one settle into a crater", + "store snow deformation that rendered geometry and collision both read", + "reset a snowfield between rounds or change its depth at runtime", + "show how packed the snow is where people have walked or objects have rested" + ], + "summary": "Persistent snow deformation over one canonical heightfield. Snow keeps four channels per cell — indentation, displaced bank, compaction and disturbance — and writes the combined surface (`terrain + depth + bank - indent`) back into the heightfield it was given. Queries, rendered geometry and collider export therefore all read one surface, and no second terrain representation exists. The field is numeric only: it never chooses a boot, a tread, a particle, a material or a camera. Games supply terrain heights, the snow depth, the contact profile and the response coefficients; this class owns the storage, the load-dependent penetration and the bounded recovery. It is a heightfield approximation — not granular snow, displaced-volume conservation, avalanches, melting or a calibrated material law.", + "supersedes": [], + "symbol": "SnowField" + }, { "aliases": ["stream terrain across chunks"], "constraints": [ @@ -3730,7 +3810,7 @@ "importPath": "@threenative/core/world", "kind": "class", "overrides": [ - "tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, validate, and budgets" + "tileSize, tileResolution, lodFactors, lodDistances, skirtDepth, streamRadius, colliderRadius, mergeTiles, validate, and budgets" ], "package": "@threenative/core", "signature": "export class TerrainTiles extends Object3D implements IComputeDriven { … }", @@ -3928,6 +4008,51 @@ "supersedes": [], "symbol": "Area3D" }, + { + "aliases": [], + "constraints": [ + "register `rapier()` before attaching, and call `step` once per fixed step after the physics step", + "the backend must expose persistent solved contacts and in-place shape refresh; one that does not fails at attach", + "verified on browser WebGPU and the native Linux desktop host; Android and iOS share the native seam but have not run it", + "automatic profiles cover sphere, box and capsule; any other shape needs an explicit footprint" + ], + "example": "const snowPhysics = attachSnowPhysics({ physics: ctx.physics, snow, bodies: [ball] });\nafterPhysics(ctx, (dt) => snowPhysics.step(dt));", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [ + "loadScale, supportNormal, colliderTolerance, deposition, wind, collisionLayer and collisionMask name the binding's own behaviour" + ], + "package": "@threenative/physics", + "requires": ["@threenative/core/world SnowField as the surface it deforms"], + "signature": "export function attachSnowPhysics(options: ISnowPhysicsOptions): ISnowPhysicsBinding { … }", + "situations": [ + "leave footprints and tracks where physical bodies actually touch snow", + "let a dropped or pushed sphere carve and settle into deformable snow", + "make a crate, capsule or ball compress the surface it rests on" + ], + "summary": "Drive a `SnowField` from real solved contacts. The binding creates the snow surface collider from the field's own canonical samples and keeps it in step with them, so a body lands on the surface a query would report. Each fixed step it reads the solver's persistent contacts — not collision start/stop events, which carry no point or load — deforms the snow only where a contact is supported and loaded downward, then republishes the surface before the next step. Airborne bodies, side contacts and unrelated colliders never deform anything. A dropped sphere settles on the surface it made; a pushed one rotates and carves a connected track without its transform being copied anywhere. Footprints come from the body's own collision shape and orientation unless the game supplies one. Contact load is `impulse / deltaTime * loadScale`: a solver impulse over a step, which estimates a contact force and is not measured. `observe().loadProvenance` says so.", + "supersedes": [], + "symbol": "attachSnowPhysics" + }, + { + "aliases": [], + "constraints": [ + "halfWidth and halfDepth are metres; rotation comes from the contact, not the footprint" + ], + "example": "const footprint = boxFootprint(0.4, 0.25);", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [], + "package": "@threenative/physics", + "signature": "export function boxFootprint(halfWidth: number, halfDepth: number): ISnowFootprint { … }", + "situations": [ + "let a crate, platform or plank press a rectangular pit into snow", + "imprint a box's own footprint rather than a circle around it" + ], + "summary": "A rectangular contact: the face a box rests on, in the contact's own frame. Coverage is full across the face and fades just outside it; the bank rises beyond that.", + "supersedes": [], + "symbol": "boxFootprint" + }, { "aliases": [], "constraints": [ @@ -3962,6 +4087,23 @@ "supersedes": [], "symbol": "Buoyancy3D" }, + { + "aliases": [], + "constraints": ["radius and halfHeight are metres and never grow with load"], + "example": "const footprint = capsuleFootprint(0.5, 0.2);", + "importPath": "@threenative/physics", + "kind": "function", + "overrides": [], + "package": "@threenative/physics", + "signature": "export function capsuleFootprint(halfHeight: number, radius: number): ISnowFootprint { … }", + "situations": [ + "let a fallen capsule, limb or barrel leave a linear imprint in snow", + "give a capsule a shape-appropriate snow contact instead of a sphere's dot" + ], + "summary": "A stadium contact: a rectangle of half-length `halfHeight` and half-width `radius`, capped by two half-discs of `radius`. The shape a capsule lies on, so a knocked-over body sinks along a line instead of at a point. Coverage is full across the body's own width and fades just outside it, so the body's edge never rests on a half-pressed rim; the bank rises beyond that.", + "supersedes": [], + "symbol": "capsuleFootprint" + }, { "aliases": [ "raised platform gap hazard restart", @@ -6626,6 +6768,35 @@ "supersedes": [], "symbol": "useUiState" }, + { + "symbol": "createStormAudio", + "package": "template:rain", + "importPath": "src/audio/storm.ts", + "kind": "function", + "signature": "createStormAudio(ctx: ICtx): IStormAudio", + "summary": "The storm's sound as generated source, mixed through the engine's own `AudioBus`: a rain hiss, a wind bed, and thunder queued strike by strike and delayed and filtered by how far away it was struck.", + "situations": [ + "rain hiss, wind bed, thunder, a storm you can hear", + "delay thunder by the distance it was struck", + "sound that follows pause, mute and tab visibility", + "audio that queues strikes instead of cutting each other off" + ], + "example": "src/audio/storm.ts — register the returned helper, call update(state) after advancing the simulation, and dispose it with the owning scene", + "constraints": [ + "`queueStrike({ at, metres })` queues: a second strike does not overwrite one still crossing the air, and `at` is absolute simulation time, which is what lets a strike survive a freeze.", + "Two holds, kept apart so neither latches the other: `setSilenced` is the game's own pause intent, `setHidden` is the UI realm or engine lifecycle visibility. The bus is released only when both are clear.", + "`update(state)` is the frame's truth and is called once per frame after the simulation advances; `debug()` is what a playtest reads back.", + "The caller owns entity registration, updates and disposal; creating the helper does not register it.", + "Audio begins only after a user gesture — the bus's unlock error is surfaced rather than swallowed.", + "The three clips are baked by `tools/make-storm-audio.mjs` from the study's own maths; this file plays them, it does not synthesise them live.", + "The helper requests the source's compressor settings ahead of master gain: threshold -15 dB, knee 30 dB, ratio 5, attack 0.003 s and release 0.25 s.", + "Each thunder voice is a labelled `thunder` cue, so a playtest's `audio` assertion reads it from the runtime ledger; the rain kit's `playtests/lightning.playtest.json` proves one cue after a strike, sounded only once the strike's `distance / 343` delay had passed.", + "This entry describes the Rain starter scaffold's own generated `src/audio/storm.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, { "symbol": "renderer.matrixWorld", "package": "@threenative/core", @@ -6721,6 +6892,107 @@ "supersedes": [], "aliases": [] }, + { + "symbol": "createStormLightning", + "package": "template:rain", + "importPath": "src/render/lightning.ts", + "kind": "function", + "signature": "createStormLightning(scene: Scene, camera: PerspectiveCamera): IStormLightning", + "summary": "A branching lightning bolt as generated Rain source: `makeBolt(end, random)` walks a 30-segment channel down from the cloud deck with side branches, and one additive camera-facing ribbon draw (gaussian core inside a wide halo) shows it while the caller's flash envelope is above zero. `strike(position, random)` returns where the bolt entered the sky.", + "situations": [ + "generate branching lightning ribbon geometry", + "a lightning strike that lights the scene and the clouds", + "a camera-facing ribbon or beam that stays the same pixel width at any distance", + "a flash envelope that stutters rather than fades once" + ], + "example": "src/render/lightning.ts — the bolt; src/state.ts `flashAt` for the envelope; src/scenes/Boot.ts strike() for the thunder delay", + "constraints": [ + "Pass the game's seeded random so a strike is reproducible; a non-finite position or random value throws rather than drawing NaN geometry.", + "The buffers hold the worst case (30 channel segments plus 24 branches of 9) and are allocated once; a strike rewrites them and opens the draw range, never reallocating.", + "`update(flash)` takes an envelope already gated on photosensitivity mode by the caller; below 0.003 the mesh is hidden. Register it with `alwaysRender`: its quads are projected by its own vertex stage, so its bounds say nothing about where it lands.", + "It depth-tests against the coast's written depth, so a bolt behind the headland is hidden by it.", + "This entry describes the Rain starter scaffold's own generated `src/render/lightning.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createNoiseVolume", + "package": "template:rain", + "importPath": "src/render/noise-volume.ts", + "kind": "function", + "signature": "createNoiseVolume(): INoiseVolume", + "summary": "The 64³ RGBA cloud noise volume as generated Rain source: `{ data: Uint8Array, texture: Data3DTexture }`. Its authored seed, octave weights and source-exact generator define the storm's cloud shapes.", + "situations": [ + "generate a 3D noise volume instead of shipping one as an asset", + "rebuild the storm's clouds procedurally at startup", + "tileable 3D noise that does not crease at the edges", + "four octave scales packed into one texture" + ], + "example": "src/render/noise-volume.ts — edit the seed, the lattices or the channel weights there", + "constraints": [ + "Seed 13291 uses the source's mulberry32 sequence. The generated volume matches all 1,048,576 reference bytes; changing the authored seed or generator changes its appearance.", + "Two distinct failure channels, not one: `TN_NOISE_CHANNEL` is a channel value that does not fit a byte, `TN_NOISE_LENGTH` is a byte count that does not fill the volume. Both throw rather than uploading a volume the shader would read wrong.", + "64³ of RGBA is 1 MiB, and `NOISE_SIZE` is what the loops and the byte count are written against; changing the size changes the memory cost with it.", + "Indices wrap at every face, which is what makes the volume tile, and the fractional part is smoothed by `f²(3 − 2f)` so lattice edges do not show as creases in the cloud.", + "This entry describes the Rain starter scaffold's own generated `src/render/noise-volume.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createStormRain", + "package": "template:rain", + "importPath": "src/render/rain.ts", + "kind": "function", + "signature": "createStormRain(scene: Scene, camera: PerspectiveCamera): IStormRain", + "summary": "Camera-anchored falling rain as generated Rain source: one instanced draw whose vertex stage hashes each drop from its index, wraps it in a 66 × 30 × 66 m cell that follows the camera in 12 m steps, and projects a quad stretched along its own fall — no particle simulation, no texture.", + "situations": [ + "rain streaks around the camera", + "thousands of falling drops in one draw", + "rain that leans with the wind and lights up in a lightning flash" + ], + "example": "src/render/rain.ts — the drop maths; `STUDY_TIERS` in src/render/quality.ts for the per-tier budget", + "constraints": [ + "The drawn count is `round(rainBudget × weather.rain)` for the tier, read back from `geometry.instanceCount` (`instanceCount`), not from the budget.", + "Stateless by design: a drop's position is a function of its index and the clock, so pausing the clock freezes the rain and nothing accumulates. `GPUParticles3D` is the engine's emitter with lifetimes; reach for it when drops must spawn, live and die.", + "Register it with `alwaysRender`: the vertex stage places every drop, so the geometry's own bounds do not.", + "This entry describes the Rain starter scaffold's own generated `src/render/rain.ts`, not a `@threenative/` or three export — there is no installed package to import it from." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, + { + "symbol": "createWeatherWorld", + "package": "template:rain", + "importPath": "src/render/world.ts", + "kind": "function", + "signature": "createWeatherWorld(scene: Scene, camera: PerspectiveCamera): IWeatherWorld", + "summary": "The raymarched coastal storm as generated source: one screen quad whose TSL shader draws the sky, the sea, the coast, the road, the forest and the lamps, and whose wet ground reflects them, written from the uniforms the game feeds it each frame. The sky it samples is the live cloud pass this call builds, not a stand-in gradient. Returns `{ quad, update(options), dispose() }`.", + "situations": [ + "a procedural storm, sea, coastline or open horizon drawn from maths rather than geometry", + "rain and wind that change how a scene looks without swapping a mesh", + "a lightning flash that lights the coast and the sky at once", + "wet reflections on a ground plane", + "sky that darkens as cloud cover rises" + ], + "example": "src/render/world.ts — edit the shader there, or the weather it is fed from src/state.ts", + "constraints": [ + "`update` takes `{ elapsed, flash, weather, strike, quality }`: absolute seconds, a flash envelope already gated on the photosensitivity switch by the caller, the bolt's entry point in the cloud deck in metres (what the clouds glow around and the coast is lit from), and a quality name of `performance | balanced | high | ultra`.", + "The tier's cloud resolution share, march steps and reflection switch come from `STUDY_TIERS` in src/render/quality.ts; `performance` skips the 36-step reflection march, so a low tier loses reflections, not the coast. `clouds()` reads back the cloud target the renderer really drew and its steps.", + "Its colours and lights are uniforms set once from src/render/palette.ts, sky.ts, lighting.ts and materials.ts; the shader itself is generated from tools/tempest-*.frag by tools/generate-shaders.mjs — edit the .frag, not the generated file.", + "The engine's `Scene` and `PerspectiveCamera` are injected as arguments; the file holds no engine lifetime of its own. `dispose()` removes the quad and disposes the geometry, material and sky texture — the scene does not do it for you.", + "The quad has `frustumCulled = false`, because its two-metre bounds would otherwise be culled the moment the camera looks along the coast.", + "The cloud pass is built first and its texture feeds the world shader (sampled with the render-target flip); `update` also drives the clouds. Rain streaks and the bolt are separate draws: `createStormRain`, `createStormLightning`.", + "This entry describes the Rain starter scaffold's own generated `src/render/world.ts`, not a `@threenative/` or three export — there is no installed package to import it from. Appearance lives in that source with no package import at all; nothing in packages/ decides how this storm looks." + ], + "overrides": [], + "supersedes": [], + "aliases": [] + }, { "symbol": "WorldEnvironment", "package": "three", diff --git a/packages/create-threenative/package.json b/packages/create-threenative/package.json index 3e268c5673..40d92e4e71 100644 --- a/packages/create-threenative/package.json +++ b/packages/create-threenative/package.json @@ -1,6 +1,6 @@ { "name": "create-threenative", - "version": "0.2.7", + "version": "0.2.8", "license": "MIT", "description": "Scaffold a readable ThreeNative game project.", "keywords": [ diff --git a/packages/create-threenative/src/doctor.ts b/packages/create-threenative/src/doctor.ts index 49e4c6a274..eab09269e3 100644 --- a/packages/create-threenative/src/doctor.ts +++ b/packages/create-threenative/src/doctor.ts @@ -1252,6 +1252,9 @@ function dependencyChecks(snapshot: IProjectSnapshot): IDoctorCheck[] { const declared = declaredDependencies(snapshot.packageJson); const missing = declared.filter((name) => !snapshot.installedVersions.has(name)); const installed = [...snapshot.installedVersions].sort(([a], [b]) => a.localeCompare(b)); + // The cohort versions per package, so a patch may differ (assets 0.3.5 beside core 0.3.4); on + // 0.x the minor is the compatibility boundary, which is what the peer ranges name. + const series = new Set(installed.map(([, version]) => version.split(".").slice(0, 2).join("."))); const versions = new Set(installed.map(([, version]) => version)); return [ missing.length > 0 @@ -1269,7 +1272,7 @@ function dependencyChecks(snapshot: IProjectSnapshot): IDoctorCheck[] { name: "dependencies", status: "ok", }, - versions.size > 1 + series.size > 1 ? { detail: `@threenative packages disagree on version: ${installed .map(([name, version]) => `${name}@${version}`) @@ -1280,7 +1283,11 @@ function dependencyChecks(snapshot: IProjectSnapshot): IDoctorCheck[] { } : { detail: - versions.size === 0 ? "nothing installed to compare" : `all at ${[...versions][0]}`, + versions.size === 0 + ? "nothing installed to compare" + : versions.size === 1 + ? `all at ${[...versions][0]}` + : `all on the ${[...series][0]} series (${installed.map(([name, version]) => `${name}@${version}`).join(", ")})`, name: "versions", status: "ok", }, diff --git a/packages/create-threenative/src/index.ts b/packages/create-threenative/src/index.ts index f0d19f84d6..cadd2cf4e7 100644 --- a/packages/create-threenative/src/index.ts +++ b/packages/create-threenative/src/index.ts @@ -400,6 +400,11 @@ const AUTHORING_GITIGNORE_RULES = [ ".env", ".env.*", "!.env.example", + // The asset pipeline's compiled outputs: any project with an assets/ folder gets them in public/ + // on its first dev run or build. Hand-owned public files (favicon, icons) stay committed. + "public/assets.manifest.json", + "public/bake.receipt.json", + "public/*.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].*", ] as const; /** Backticked paths and Markdown links share one prefix so both readers resolve identically. */ const REFERENCE_TOKEN_PATTERN = new RegExp( diff --git a/packages/create-threenative/templates/action-rpg/package.json b/packages/create-threenative/templates/action-rpg/package.json index db075c379b..3d7ed0e93a 100644 --- a/packages/create-threenative/templates/action-rpg/package.json +++ b/packages/create-threenative/templates/action-rpg/package.json @@ -32,7 +32,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/minimal/package.json b/packages/create-threenative/templates/minimal/package.json index 7b5f775401..327740be67 100644 --- a/packages/create-threenative/templates/minimal/package.json +++ b/packages/create-threenative/templates/minimal/package.json @@ -24,7 +24,7 @@ "@threenative/assets": "0.3.5", "@threenative/playtest": "0.3.4", "@types/three": "0.185.3", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "typescript": "5.9.3", diff --git a/packages/create-threenative/templates/platformer/package.json b/packages/create-threenative/templates/platformer/package.json index 6b55adc84a..58773eb5e8 100644 --- a/packages/create-threenative/templates/platformer/package.json +++ b/packages/create-threenative/templates/platformer/package.json @@ -31,7 +31,7 @@ "@threenative/playtest": "0.3.4", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/puzzle/package.json b/packages/create-threenative/templates/puzzle/package.json index e76c78f105..53870872fa 100644 --- a/packages/create-threenative/templates/puzzle/package.json +++ b/packages/create-threenative/templates/puzzle/package.json @@ -31,7 +31,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/racing/package.json b/packages/create-threenative/templates/racing/package.json index df224a2e8b..046f2df284 100644 --- a/packages/create-threenative/templates/racing/package.json +++ b/packages/create-threenative/templates/racing/package.json @@ -32,7 +32,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/rain/.codex/config.toml b/packages/create-threenative/templates/rain/.codex/config.toml new file mode 100644 index 0000000000..1906a5c3c9 --- /dev/null +++ b/packages/create-threenative/templates/rain/.codex/config.toml @@ -0,0 +1,19 @@ +[mcp_servers.threenative-assets] +command = "node" +args = ["./node_modules/@threenative/core/mcp/assets.mjs"] + +[mcp_servers.threenative-assets.env] +ASSET_DOWNLOAD_DIR = "./public/assets" +AUDIO_DOWNLOAD_DIR = "./public/audio" + +[mcp_servers.threenative-sculpt] +command = "node" +args = ["./node_modules/@threenative/core/mcp/sculpt.mjs"] + +[mcp_servers.threenative-engine] +command = "node" +args = ["./node_modules/@threenative/core/mcp/engine.mjs"] + +[mcp_servers.threenative-blender] +command = "node" +args = ["./node_modules/@threenative/core/mcp/blender.mjs"] diff --git a/packages/create-threenative/templates/rain/.cursor/mcp.json b/packages/create-threenative/templates/rain/.cursor/mcp.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/rain/.cursor/mcp.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/rain/.gemini/settings.json b/packages/create-threenative/templates/rain/.gemini/settings.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/rain/.gemini/settings.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/rain/.mcp.json b/packages/create-threenative/templates/rain/.mcp.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/rain/.mcp.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/rain/.vscode/mcp.json b/packages/create-threenative/templates/rain/.vscode/mcp.json new file mode 100644 index 0000000000..bb2c8d72ad --- /dev/null +++ b/packages/create-threenative/templates/rain/.vscode/mcp.json @@ -0,0 +1,36 @@ +{ + "servers": { + "threenative-assets": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/rain/.zed/settings.json b/packages/create-threenative/templates/rain/.zed/settings.json new file mode 100644 index 0000000000..0c58b95100 --- /dev/null +++ b/packages/create-threenative/templates/rain/.zed/settings.json @@ -0,0 +1,36 @@ +{ + "context_servers": { + "threenative-assets": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/rain/AGENTS.md b/packages/create-threenative/templates/rain/AGENTS.md new file mode 100644 index 0000000000..e828dc6cb3 --- /dev/null +++ b/packages/create-threenative/templates/rain/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md — __PROJECT_NAME__ rain + +Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. + +## Ownership + +ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, audio lifetimes, and the state bridge. This repository owns the coastal storm: weather presets and easing, the fly camera, the shaders, lightning, sound design and the +interface; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. + +## Start every change + +1. **Critical planning gate:** invoke `threenative-capabilities` before `prd-creator`. Search + `engine_search_capabilities` for the full request and each concrete mechanic, inspect relevant + matches with `engine_capability_detail`, and record a capability or no-match for the plan. Apply the `ponytail` ladder before writing code; never hand-write what the capability search already installs. +2. Then invoke `prd-creator`. Draft the plan around those capabilities and binding constraints, + direct the user to review it, and wait for explicit approval plus an instruction to implement it. +3. Treat returned constraints as binding. This kit needs no navmesh and imports none, so it runs + on every target; adding `@threenative/physics/navigation` would make it web and desktop only. +4. If a build, import, device, or blank frame fails, run `npx threenative doctor` and + `npx @threenative/playtest doctor`; missing observations are not zero. + +## When the framework blocks you, write plain Three.js + +When an `@threenative/*` API is broken, missing, or does not do what you need, replace only that +piece with portable Three.js/plain code. Keep the loop, scenes, input, registry, and playtest +bridge; avoid DOM globals, dynamic `import()`, and raw physics handles. **Report what blocked you** +(API, expectation, result, replacement); never stall the game. + +## Workflow skills + +- `.agents/skills/prd-creator/SKILL.md` / `.claude/skills/prd-creator/SKILL.md` — game plan and approval gate. +- `.agents/skills/threenative-capabilities/SKILL.md` / `.claude/skills/threenative-capabilities/SKILL.md` — capability search. +- `.agents/skills/threenative-playtest/SKILL.md` / `.claude/skills/threenative-playtest/SKILL.md` — diagnosis and proof. +- `.agents/skills/threenative-assets/SKILL.md` / `.claude/skills/threenative-assets/SKILL.md` — assets and sculpting. +- `.agents/skills/threenative-visuals/SKILL.md` / `.claude/skills/threenative-visuals/SKILL.md` — captures and look. +- `.agents/skills/threenative-performance/SKILL.md` / `.claude/skills/threenative-performance/SKILL.md` — measured budgets. +- `.agents/skills/threenative-ui/SKILL.md` / `.claude/skills/threenative-ui/SKILL.md` — native-safe UI. +- `.agents/skills/threenative-context/SKILL.md` / `.claude/skills/threenative-context/SKILL.md` — portable ctx APIs. +- Confirmed framework bugs: use `file-engine-bug` in `.agents/skills/` or `.claude/skills/` after a minimal repro. A game-side `patches/` fix is temporary: once it is settled, it moves into the engine in one PR. Lazy-first: `.agents/skills/ponytail/SKILL.md` / `.claude/skills/ponytail/SKILL.md` — smallest correct change, and its reuse rung is the capability search above. + +## Commands and map + +```sh +pnpm dev +pnpm typecheck +pnpm build:web +pnpm build:desktop +pnpm test +``` + +`src/state.ts` owns weather validation, the four presets, easing (τ 0.67 s), the flash envelope and +the `distance / 343` thunder delay; `src/scenes/Boot.ts` runs the simulation clock, auto-strikes +(gated on cloud cover above 0.35) and publishes everything the UI reads. The coast, clouds, rain +and bloom/grade are shaders generated from `tools/tempest-*.frag` by `node tools/generate-shaders.mjs`; +edit the `.frag`, never a generated `*-shader.ts`. Their named colours and lights live in +`src/render/palette.ts`, `sky.ts`, `lighting.ts` and `materials.ts`, the bolt in `lightning.ts`. +The coast is one ray-marched screen quad, so it is marked with `alwaysRender`; so are the rain and +the bolt, whose quads are projected by their own vertex stage. Sound is three clips baked by +`tools/make-storm-audio.mjs` and played through `AudioBus` in `src/audio/storm.ts`; audio starts on a +gesture, and pause, mute and tab visibility each hold it. `safe` gates every strike at one door, +and `prefers-reduced-motion` turns it on at boot. Keep `playtests/survives.playtest.json` as smoke +proof, and `storm`, `lightning`, `reduced-motion` and `tiers` honest when you change what they assert. +To build a game on the storm, keep its rules in a plain module and wire them in `src/scenes/Boot.ts`: `strike()` is the one door every strike passes (its distance and thunder delay are in scope), the frame function runs per-step rules after the camera moves, and the round's state is a `GameState` field the UI reads; `Billboard3D` turns world markers to the camera. + +## Portable authoring contracts + +Leave `assets` absent: the cook selects target-decodable passes, with `models.sharedImages: true` deduplicating images. `sharedImages: false` embeds duplicate copies; `models.compact` (default `{ flatten: true, join: true, instance: true }`) flattens the scene graph, merges primitives by material and batches a mesh shared by several nodes as `EXT_mesh_gpu_instancing` — all lossless, keeping any node matching `protectedPattern` (or named in `protectedNames`), an animation target, or a skin joint individually addressable; `compact: false` ships the scene graph as authored. `models: "none"` / `textures: "none"` / `audio: "none"` skip those passes and report uncooked bytes. Android/iOS currently skip compression and model dedupe. `assets.exclude` defaults to `[]`; source-relative globs (for example `["unused/**"]`) omit matching files and report saved bytes. `assets.budget` accepts `{ uncooked?: number | "none", total?: number | "none" }`, default `{ uncooked: 64_000_000, total: "none" }`: only bytes left uncooked where cooking was possible count toward `uncooked`. A number sets that ceiling; `"none"` disables both gates. Either disabled gate still reports bytes. Automatic texture cooking retains unaligned source images unchanged and reports `block-size`; those bytes still count toward the uncooked budget. An explicit compression codec override must satisfy four-pixel block alignment; `codec: "none"` opts out. Cooking never silently resizes an image to fix alignment. + +Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. +Scenes use `load`, `enter`, `update`, `exit`, `render`. One world unit is one metre; the camera starts at `(1.8, 2.85, 18)` and `src/render/camera.ts` owns its clamps and speeds; manual input cancels the cinematic orbit. There is no walking actor, weapon or agent here to ground, bind or navigate. +`input.vector("move").y` is +up and means forward; use one explicit `-move.y` conversion. +Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game +state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` +is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's +state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. + +`src/render/quality.ts` owns `low`, `medium`, `high` (the engine chain: no built-in stage at any tier); `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` reports +the source. The player's four tiers are `STUDY_TIERS` in the same file. The bridge flushes about 100 ms; a control flushes it at once. + +When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a +retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's +own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is +caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing +(the `.undefined` failure that plays the bind pose instead of the animation), +`clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous +clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is +supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches 256 per presented frame so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. +## Budget real time for the look + +Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. + +Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it beside the frame rate; `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. + +Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. +## Optional multiplayer transport +For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. diff --git a/packages/create-threenative/templates/rain/CLAUDE.md b/packages/create-threenative/templates/rain/CLAUDE.md new file mode 100644 index 0000000000..069496843a --- /dev/null +++ b/packages/create-threenative/templates/rain/CLAUDE.md @@ -0,0 +1,98 @@ + + +# AGENTS.md — __PROJECT_NAME__ rain + +Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. + +## Ownership + +ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, audio lifetimes, and the state bridge. This repository owns the coastal storm: weather presets and easing, the fly camera, the shaders, lightning, sound design and the +interface; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. + +## Start every change + +1. **Critical planning gate:** invoke `threenative-capabilities` before `prd-creator`. Search + `engine_search_capabilities` for the full request and each concrete mechanic, inspect relevant + matches with `engine_capability_detail`, and record a capability or no-match for the plan. Apply the `ponytail` ladder before writing code; never hand-write what the capability search already installs. +2. Then invoke `prd-creator`. Draft the plan around those capabilities and binding constraints, + direct the user to review it, and wait for explicit approval plus an instruction to implement it. +3. Treat returned constraints as binding. This kit needs no navmesh and imports none, so it runs + on every target; adding `@threenative/physics/navigation` would make it web and desktop only. +4. If a build, import, device, or blank frame fails, run `npx threenative doctor` and + `npx @threenative/playtest doctor`; missing observations are not zero. + +## When the framework blocks you, write plain Three.js + +When an `@threenative/*` API is broken, missing, or does not do what you need, replace only that +piece with portable Three.js/plain code. Keep the loop, scenes, input, registry, and playtest +bridge; avoid DOM globals, dynamic `import()`, and raw physics handles. **Report what blocked you** +(API, expectation, result, replacement); never stall the game. + +## Workflow skills + +- `.agents/skills/prd-creator/SKILL.md` / `.claude/skills/prd-creator/SKILL.md` — game plan and approval gate. +- `.agents/skills/threenative-capabilities/SKILL.md` / `.claude/skills/threenative-capabilities/SKILL.md` — capability search. +- `.agents/skills/threenative-playtest/SKILL.md` / `.claude/skills/threenative-playtest/SKILL.md` — diagnosis and proof. +- `.agents/skills/threenative-assets/SKILL.md` / `.claude/skills/threenative-assets/SKILL.md` — assets and sculpting. +- `.agents/skills/threenative-visuals/SKILL.md` / `.claude/skills/threenative-visuals/SKILL.md` — captures and look. +- `.agents/skills/threenative-performance/SKILL.md` / `.claude/skills/threenative-performance/SKILL.md` — measured budgets. +- `.agents/skills/threenative-ui/SKILL.md` / `.claude/skills/threenative-ui/SKILL.md` — native-safe UI. +- `.agents/skills/threenative-context/SKILL.md` / `.claude/skills/threenative-context/SKILL.md` — portable ctx APIs. +- Confirmed framework bugs: use `file-engine-bug` in `.agents/skills/` or `.claude/skills/` after a minimal repro. A game-side `patches/` fix is temporary: once it is settled, it moves into the engine in one PR. Lazy-first: `.agents/skills/ponytail/SKILL.md` / `.claude/skills/ponytail/SKILL.md` — smallest correct change, and its reuse rung is the capability search above. + +## Commands and map + +```sh +pnpm dev +pnpm typecheck +pnpm build:web +pnpm build:desktop +pnpm test +``` + +`src/state.ts` owns weather validation, the four presets, easing (τ 0.67 s), the flash envelope and +the `distance / 343` thunder delay; `src/scenes/Boot.ts` runs the simulation clock, auto-strikes +(gated on cloud cover above 0.35) and publishes everything the UI reads. The coast, clouds, rain +and bloom/grade are shaders generated from `tools/tempest-*.frag` by `node tools/generate-shaders.mjs`; +edit the `.frag`, never a generated `*-shader.ts`. Their named colours and lights live in +`src/render/palette.ts`, `sky.ts`, `lighting.ts` and `materials.ts`, the bolt in `lightning.ts`. +The coast is one ray-marched screen quad, so it is marked with `alwaysRender`; so are the rain and +the bolt, whose quads are projected by their own vertex stage. Sound is three clips baked by +`tools/make-storm-audio.mjs` and played through `AudioBus` in `src/audio/storm.ts`; audio starts on a +gesture, and pause, mute and tab visibility each hold it. `safe` gates every strike at one door, +and `prefers-reduced-motion` turns it on at boot. Keep `playtests/survives.playtest.json` as smoke +proof, and `storm`, `lightning`, `reduced-motion` and `tiers` honest when you change what they assert. +To build a game on the storm, keep its rules in a plain module and wire them in `src/scenes/Boot.ts`: `strike()` is the one door every strike passes (its distance and thunder delay are in scope), the frame function runs per-step rules after the camera moves, and the round's state is a `GameState` field the UI reads; `Billboard3D` turns world markers to the camera. + +## Portable authoring contracts + +Leave `assets` absent: the cook selects target-decodable passes, with `models.sharedImages: true` deduplicating images. `sharedImages: false` embeds duplicate copies; `models.compact` (default `{ flatten: true, join: true, instance: true }`) flattens the scene graph, merges primitives by material and batches a mesh shared by several nodes as `EXT_mesh_gpu_instancing` — all lossless, keeping any node matching `protectedPattern` (or named in `protectedNames`), an animation target, or a skin joint individually addressable; `compact: false` ships the scene graph as authored. `models: "none"` / `textures: "none"` / `audio: "none"` skip those passes and report uncooked bytes. Android/iOS currently skip compression and model dedupe. `assets.exclude` defaults to `[]`; source-relative globs (for example `["unused/**"]`) omit matching files and report saved bytes. `assets.budget` accepts `{ uncooked?: number | "none", total?: number | "none" }`, default `{ uncooked: 64_000_000, total: "none" }`: only bytes left uncooked where cooking was possible count toward `uncooked`. A number sets that ceiling; `"none"` disables both gates. Either disabled gate still reports bytes. Automatic texture cooking retains unaligned source images unchanged and reports `block-size`; those bytes still count toward the uncooked budget. An explicit compression codec override must satisfy four-pixel block alignment; `codec: "none"` opts out. Cooking never silently resizes an image to fix alignment. + +Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. +Scenes use `load`, `enter`, `update`, `exit`, `render`. One world unit is one metre; the camera starts at `(1.8, 2.85, 18)` and `src/render/camera.ts` owns its clamps and speeds; manual input cancels the cinematic orbit. There is no walking actor, weapon or agent here to ground, bind or navigate. +`input.vector("move").y` is +up and means forward; use one explicit `-move.y` conversion. +Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game +state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` +is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's +state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. + +`src/render/quality.ts` owns `low`, `medium`, `high` (the engine chain: no built-in stage at any tier); `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` reports +the source. The player's four tiers are `STUDY_TIERS` in the same file. The bridge flushes about 100 ms; a control flushes it at once. + +When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a +retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's +own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is +caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing +(the `.undefined` failure that plays the bind pose instead of the animation), +`clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous +clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is +supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches 256 per presented frame so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. +## Budget real time for the look + +Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. + +Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it beside the frame rate; `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. + +Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. +## Optional multiplayer transport +For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. diff --git a/packages/create-threenative/templates/rain/assets/rain-loop.wav b/packages/create-threenative/templates/rain/assets/rain-loop.wav new file mode 100644 index 0000000000..a5cbd10613 Binary files /dev/null and b/packages/create-threenative/templates/rain/assets/rain-loop.wav differ diff --git a/packages/create-threenative/templates/rain/assets/thunder.wav b/packages/create-threenative/templates/rain/assets/thunder.wav new file mode 100644 index 0000000000..93688e0827 Binary files /dev/null and b/packages/create-threenative/templates/rain/assets/thunder.wav differ diff --git a/packages/create-threenative/templates/rain/assets/wind-loop.wav b/packages/create-threenative/templates/rain/assets/wind-loop.wav new file mode 100644 index 0000000000..1063a6540b Binary files /dev/null and b/packages/create-threenative/templates/rain/assets/wind-loop.wav differ diff --git a/packages/create-threenative/templates/rain/index.html b/packages/create-threenative/templates/rain/index.html new file mode 100644 index 0000000000..3977f434c5 --- /dev/null +++ b/packages/create-threenative/templates/rain/index.html @@ -0,0 +1,18 @@ + + + + + + + + + __PROJECT_NAME__ + + +
+ + + \ No newline at end of file diff --git a/packages/create-threenative/templates/rain/kit.json b/packages/create-threenative/templates/rain/kit.json new file mode 100644 index 0000000000..1f10f74716 --- /dev/null +++ b/packages/create-threenative/templates/rain/kit.json @@ -0,0 +1,7 @@ +{ + "name": "rain", + "title": "Rain", + "blurb": "A real-time coastal weather study: shape rain, cloud, wind, fog and wetness, and watch a coastline answer.", + "genre": "rain", + "kit": true +} diff --git a/packages/create-threenative/templates/rain/native-playtests/boot.playtest.json b/packages/create-threenative/templates/rain/native-playtests/boot.playtest.json new file mode 100644 index 0000000000..3ffc34dede --- /dev/null +++ b/packages/create-threenative/templates/rain/native-playtests/boot.playtest.json @@ -0,0 +1,13 @@ +{ + "name": "rain-native-boot", + "schemaVersion": 1, + "target": "desktop", + "viewport": { "width": 1280, "height": 720 }, + "warmupFrames": 10, + "artifacts": { "screenshots": "after" }, + "steps": [{ "kind": "wait", "waitTicks": 120, "release": true }], + "assert": { + "resources": [{ "id": "state", "path": "elapsed", "changed": true }], + "renderChain": { "tier": "high", "stages": { "includes": ["stormPost"] } } + } +} diff --git a/packages/create-threenative/templates/rain/native-playtests/lightning.playtest.json b/packages/create-threenative/templates/rain/native-playtests/lightning.playtest.json new file mode 100644 index 0000000000..1b2404b4f7 --- /dev/null +++ b/packages/create-threenative/templates/rain/native-playtests/lightning.playtest.json @@ -0,0 +1,173 @@ +{ + "name": "rain-native-strike-flashes-and-thunder-follows", + "schemaVersion": 1, + "target": "desktop", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 30, + "artifacts": { + "screenshots": "after" + }, + "steps": [ + { + "kind": "input", + "press": "KeyL", + "label": "suppressed", + "waitTicks": 6, + "release": true + }, + { + "kind": "input", + "press": "KeyX", + "label": "flashes-on", + "waitTicks": 4, + "release": true + }, + { + "kind": "input", + "press": "KeyM", + "label": "sound", + "waitTicks": 10, + "release": true + }, + { + "kind": "input", + "press": "KeyL", + "label": "strike", + "waitTicks": 6, + "screenshot": "native-strike", + "release": true + }, + { + "kind": "wait", + "label": "in-the-air", + "waitTicks": 30, + "release": true + }, + { + "kind": "wait", + "label": "heard", + "waitTicks": 150, + "release": true + } + ], + "assert": { + "resources": [ + { + "id": "state", + "path": "safe", + "allowTrivial": "this desktop web view reports prefers-reduced-motion: reduce, so the storm boots with flashes suppressed; the scenario proves a strike is suppressed until the player turns flashes back on", + "atSteps": [ + { + "equals": true, + "label": "suppressed" + }, + { + "equals": false, + "label": "flashes-on" + } + ] + }, + { + "id": "state", + "path": "audioEnabled", + "atSteps": [ + { + "equals": true, + "label": "sound" + } + ], + "equals": true + }, + { + "id": "state", + "path": "strikes", + "atSteps": [ + { + "equals": 0, + "label": "suppressed" + }, + { + "equals": 1, + "label": "strike" + }, + { + "equals": 1, + "label": "heard" + } + ], + "equals": 1 + }, + { + "id": "state", + "path": "status", + "atSteps": [ + { + "equals": "flashing", + "label": "strike" + } + ] + }, + { + "id": "state", + "path": "lastStrike.delay", + "gte": 0.7, + "lte": 1.6 + }, + { + "id": "state", + "path": "pendingThunderAt", + "atSteps": [ + { + "equals": -1, + "label": "heard" + } + ] + } + ], + "components": [ + { + "entity": "storm-audio", + "component": "pendingThunder", + "atSteps": [ + { + "equals": 1, + "label": "strike" + }, + { + "equals": 1, + "label": "in-the-air" + }, + { + "equals": 0, + "label": "heard" + } + ] + }, + { + "entity": "storm-audio", + "component": "thunderVoices", + "atSteps": [ + { + "equals": 0, + "label": "in-the-air" + }, + { + "equals": 1, + "label": "heard" + } + ], + "equals": 1 + } + ], + "audio": [ + { + "cue": "thunder", + "minPlays": 1, + "maxPlays": 1 + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/opencode.json b/packages/create-threenative/templates/rain/opencode.json new file mode 100644 index 0000000000..c8226dccec --- /dev/null +++ b/packages/create-threenative/templates/rain/opencode.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "threenative-assets": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "enabled": true, + "environment": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ], + "enabled": true + }, + "threenative-engine": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/engine.mjs" + ], + "enabled": true + }, + "threenative-blender": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/blender.mjs" + ], + "enabled": true + } + } +} diff --git a/packages/create-threenative/templates/rain/package.json b/packages/create-threenative/templates/rain/package.json new file mode 100644 index 0000000000..f796ec7e5d --- /dev/null +++ b/packages/create-threenative/templates/rain/package.json @@ -0,0 +1,56 @@ +{ + "name": "__PROJECT_NAME__", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "threenative build", + "build:web": "threenative build --target web", + "build:desktop": "threenative build --target desktop", + "build:android": "threenative build --target android", + "build:ios": "threenative build --target ios", + "pretest": "playwright install chromium", + "test": "pnpm build:web && threenative-playtest --scenario \"playtests/*.playtest.json\" --browser-recipe webgpu --headed --server-command \"pnpm exec vite preview --host 127.0.0.1 --port $PORT --strictPort\"", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@threenative/core": "0.3.4", + "@threenative/ui": "0.3.4", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-reconciler": "0.33.0", + "three": "0.185.1" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@threenative/assets": "0.3.5", + "@threenative/playtest": "0.3.4", + "@types/react": "19.2.0", + "@types/react-dom": "19.2.0", + "@types/three": "0.185.3", + "@vitejs/plugin-react": "6.0.5", + "create-threenative": "0.2.8", + "esbuild": "0.27.0", + "playwright": "1.62.1", + "tailwindcss": "4.3.3", + "typescript": "5.9.3", + "vite": "8.2.0", + "@gltf-transform/cli": "4.4.2" + }, + "optionalDependencies": { + "@threenative/runtime-native": "0.3.4" + }, + "overrides": { + "sharp": ">=0.35.4" + }, + "pnpm": { + "overrides": { + "sharp": ">=0.35.4" + }, + "onlyBuiltDependencies": ["@threenative/core", "@threenative/runtime-native"], + "patchedDependencies": { + "three@0.185.1": "patches/three@0.185.1.patch" + } + } +} diff --git a/packages/create-threenative/templates/rain/playtests/auto-lightning-clear.playtest.json b/packages/create-threenative/templates/rain/playtests/auto-lightning-clear.playtest.json new file mode 100644 index 0000000000..537b839c71 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/auto-lightning-clear.playtest.json @@ -0,0 +1,66 @@ +{ + "name": "rain-auto-lightning-waits-for-cloud-cover", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "steps": [ + { + "kind": "click", + "at": { + "element": { + "id": "cloud" + } + }, + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "Home", + "label": "thin-cloud", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "past-due", + "waitTicks": 900, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "target.cloud", + "lte": 0.2 + }, + { + "id": "state", + "path": "weather.cloud", + "lte": 0.3 + }, + { + "id": "state", + "path": "elapsed", + "gte": 43 + }, + { + "id": "state", + "path": "strikes", + "equals": 0, + "allowTrivial": "the invariant under test: with cloud cover under 0.35 the scheduler never strikes, though its first strike (t = 37) has long passed" + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/auto-lightning.playtest.json b/packages/create-threenative/templates/rain/playtests/auto-lightning.playtest.json new file mode 100644 index 0000000000..9f741119a6 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/auto-lightning.playtest.json @@ -0,0 +1,26 @@ +{ + "name": "rain-auto-lightning-strikes-under-cloud", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1440, "height": 900 }, + "warmupFrames": 30, + "steps": [{ "kind": "wait", "label": "storm", "waitTicks": 720, "release": true }], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "autoLightning", + "equals": true, + "allowTrivial": "automatic lightning is on by default; this scenario proves it fires" + }, + { "id": "state", "path": "strikes", "changed": true, "gte": 1 }, + { "id": "state", "path": "lastStrike.at", "gte": 37 } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/automation.playtest.json b/packages/create-threenative/templates/rain/playtests/automation.playtest.json new file mode 100644 index 0000000000..a9b6c13c05 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/automation.playtest.json @@ -0,0 +1,133 @@ +{ + "name": "rain-browser-controls-and-capture", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 390, + "height": 844 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "both" + }, + "steps": [ + { + "kind": "input", + "press": "KeyL", + "label": "strike", + "release": true, + "waitTicks": 2 + }, + { + "kind": "input", + "press": "KeyX", + "label": "safe", + "release": true, + "waitTicks": 20 + }, + { + "kind": "input", + "press": "KeyH", + "label": "hidden", + "release": true, + "waitTicks": 20 + }, + { + "kind": "input", + "press": "KeyP", + "label": "capture", + "release": true, + "waitTicks": 40 + }, + { + "kind": "input", + "press": "Space", + "label": "paused", + "release": true, + "waitTicks": 20 + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "status", + "atSteps": [ + { + "equals": "flashing", + "label": "strike" + }, + { + "equals": "steady", + "label": "safe" + }, + { + "equals": "paused", + "label": "paused" + } + ], + "equals": "paused" + }, + { + "id": "state", + "path": "safe", + "atSteps": [ + { + "equals": false, + "label": "strike" + }, + { + "equals": true, + "label": "safe" + } + ], + "equals": true + }, + { + "id": "state", + "path": "uiHidden", + "atSteps": [ + { + "equals": true, + "label": "hidden" + } + ], + "equals": true + }, + { + "id": "state", + "path": "quality", + "equals": "performance", + "allowTrivial": "a 390-px window picks the performance tier on the first snapshot, before any observation" + }, + { + "id": "state", + "path": "frame", + "changed": true + }, + { + "id": "state", + "path": "dropCount", + "equals": 4940, + "allowTrivial": "the performance tier draws round(6500 x 0.76 storm rain) = 4940 drops from boot" + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 390, + "height": 844, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/cinematic.playtest.json b/packages/create-threenative/templates/rain/playtests/cinematic.playtest.json new file mode 100644 index 0000000000..bec03e9e7a --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/cinematic.playtest.json @@ -0,0 +1,59 @@ +{ + "name": "rain-cinematic-orbit-yields-to-the-player", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "steps": [ + { + "kind": "click", + "at": { + "element": { + "id": "cinematic" + } + }, + "label": "orbit", + "waitTicks": 120, + "release": true + }, + { + "kind": "input", + "press": "KeyW", + "label": "taken-back", + "holdTicks": 20, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "cinematic", + "atSteps": [ + { + "equals": true, + "label": "orbit" + }, + { + "equals": false, + "label": "taken-back" + } + ] + }, + { + "id": "state", + "path": "position.x", + "changed": true + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/flash.playtest.json b/packages/create-threenative/templates/rain/playtests/flash.playtest.json new file mode 100644 index 0000000000..e2245e28f3 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/flash.playtest.json @@ -0,0 +1,27 @@ +{ + "name": "rain-held-strike-lights-the-scene", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1440, "height": 900 }, + "warmupFrames": 30, + "artifacts": { "screenshots": "before-after" }, + "steps": [ + { "kind": "input", "press": "Space", "label": "paused", "waitTicks": 2, "release": true }, + { "kind": "input", "press": "KeyL", "label": "strike", "waitTicks": 10, "release": true } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { "id": "state", "path": "paused", "equals": true }, + { "id": "state", "path": "strikes", "equals": 1 }, + { "id": "state", "path": "flash", "gte": 0.5 }, + { "id": "state", "path": "elapsed", "changed": false } + ], + "visual": [{ "frameDiff": { "minChangedPixelRatio": 0.3 } }] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/fly.playtest.json b/packages/create-threenative/templates/rain/playtests/fly.playtest.json new file mode 100644 index 0000000000..511a8a203c --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/fly.playtest.json @@ -0,0 +1,54 @@ +{ + "name": "rain-fly-camera-moves-and-looks", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1440, "height": 900 }, + "warmupFrames": 30, + "artifacts": { "screenshots": "before-after" }, + "steps": [ + { + "kind": "input", + "press": ["KeyW", "ShiftLeft"], + "holdTicks": 60, + "label": "sprint", + "release": true + }, + { "kind": "input", "press": "KeyE", "holdTicks": 30, "label": "climb", "release": true }, + { + "kind": "input", + "pointerPosition": { "x": 0.45, "y": 0.5, "buttons": 1 }, + "waitTicks": 2, + "release": false + }, + { + "kind": "input", + "pointerPosition": { "x": 0.3, "y": 0.42, "buttons": 1 }, + "waitTicks": 4, + "release": false + }, + { + "kind": "input", + "pointerPosition": { "x": 0.3, "y": 0.42, "buttons": 0 }, + "label": "look", + "waitTicks": 4, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { "id": "state", "path": "position.z", "lte": 0 }, + { "id": "state", "path": "position.y", "gte": 4.5 }, + { "id": "state", "path": "heading", "changed": true } + ], + "visual": [ + { "frameDiff": { "minChangedPixelRatio": 0.3 } }, + { "region": { "x": 0, "y": 0, "width": 1440, "height": 900, "minNonblankPixelRatio": 0.01 } } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/lightning.playtest.json b/packages/create-threenative/templates/rain/playtests/lightning.playtest.json new file mode 100644 index 0000000000..eabe2b9594 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/lightning.playtest.json @@ -0,0 +1,220 @@ +{ + "name": "rain-strike-flashes-and-thunder-follows", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "click", + "at": { + "element": { + "id": "sound" + } + }, + "label": "sound", + "waitTicks": 10, + "release": true + }, + { + "kind": "input", + "press": "KeyX", + "label": "safe", + "waitTicks": 4, + "release": true + }, + { + "kind": "input", + "press": "KeyL", + "label": "safe-strike", + "waitTicks": 6, + "release": true + }, + { + "kind": "input", + "press": "KeyX", + "label": "unsafe", + "waitTicks": 4, + "release": true + }, + { + "kind": "input", + "press": "KeyL", + "label": "strike", + "waitTicks": 6, + "screenshot": "strike", + "release": true + }, + { + "kind": "wait", + "label": "in-the-air", + "waitTicks": 30, + "release": true + }, + { + "kind": "wait", + "label": "heard", + "waitTicks": 150, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "audioEnabled", + "atSteps": [ + { + "equals": true, + "label": "sound" + } + ], + "equals": true + }, + { + "id": "state", + "path": "safe", + "atSteps": [ + { + "equals": true, + "label": "safe-strike" + }, + { + "equals": false, + "label": "strike" + } + ] + }, + { + "id": "state", + "path": "autoLightning", + "atSteps": [ + { + "equals": false, + "label": "safe" + } + ], + "equals": false + }, + { + "id": "state", + "path": "flash", + "atSteps": [ + { + "equals": 0, + "label": "safe-strike" + } + ] + }, + { + "id": "state", + "path": "strikes", + "atSteps": [ + { + "equals": 0, + "label": "safe-strike" + }, + { + "equals": 1, + "label": "strike" + }, + { + "equals": 1, + "label": "heard" + } + ], + "equals": 1 + }, + { + "id": "state", + "path": "status", + "atSteps": [ + { + "equals": "flashing", + "label": "strike" + } + ] + }, + { + "id": "state", + "path": "lastStrike.delay", + "gte": 0.7, + "lte": 1.6 + }, + { + "id": "state", + "path": "lastStrike.metres", + "gte": 250, + "lte": 520 + }, + { + "id": "state", + "path": "pendingThunderAt", + "atSteps": [ + { + "equals": -1, + "label": "heard" + } + ] + } + ], + "components": [ + { + "entity": "storm-audio", + "atSteps": [ + { + "equals": 0, + "label": "safe-strike" + }, + { + "equals": 1, + "label": "strike" + }, + { + "equals": 1, + "label": "in-the-air" + }, + { + "equals": 0, + "label": "heard" + } + ], + "component": "pendingThunder" + }, + { + "entity": "storm-audio", + "atSteps": [ + { + "equals": 0, + "label": "in-the-air" + }, + { + "equals": 1, + "label": "heard" + } + ], + "equals": 1, + "component": "thunderVoices" + } + ], + "audio": [ + { + "cue": "thunder", + "minPlays": 1, + "maxPlays": 1 + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/loading.playtest.json b/packages/create-threenative/templates/rain/playtests/loading.playtest.json new file mode 100644 index 0000000000..7f35ee9405 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/loading.playtest.json @@ -0,0 +1,57 @@ +{ + "name": "rain-loading-curtain-lifts-on-the-storm", + "schemaVersion": 1, + "target": "web", + "awaitStartup": false, + "viewport": { + "width": 1024, + "height": 768 + }, + "warmupFrames": 0, + "artifacts": { + "screenshots": "after" + }, + "steps": [ + { + "kind": "wait", + "waitTicks": 1, + "release": true + }, + { + "waitForResource": { + "id": "state", + "path": "loading.ready", + "equals": true + }, + "timeoutMs": 60000, + "release": true + }, + { + "kind": "wait", + "waitTicks": 90, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "loading.ready", + "equals": true, + "allowTrivial": "a warm pipeline cache can finish the launch before the first observation; the step capture records the curtain whenever it is still up" + }, + { + "id": "state", + "path": "loading.progress", + "equals": 1, + "allowTrivial": "as above: progress reaches 1 when the engine reports the storm ready" + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/paused.playtest.json b/packages/create-threenative/templates/rain/playtests/paused.playtest.json new file mode 100644 index 0000000000..d469d9c538 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/paused.playtest.json @@ -0,0 +1,59 @@ +{ + "name": "rain-paused-storm-holds-its-frame", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "input", + "press": "Space", + "label": "paused", + "waitTicks": 2, + "release": true + }, + { + "kind": "wait", + "waitTicks": 40, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "paused", + "equals": true + }, + { + "id": "state", + "path": "elapsed", + "changed": false + }, + { + "id": "state", + "path": "frame", + "changed": true + } + ], + "visual": [ + { + "frameDiff": { + "maxChangedPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/performance.playtest.json b/packages/create-threenative/templates/rain/playtests/performance.playtest.json new file mode 100644 index 0000000000..061229666a --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/performance.playtest.json @@ -0,0 +1,22 @@ +{ + "acceptanceId": "rain-production-performance", + "artifacts": { "screenshots": "after" }, + "assert": { + "performance": { + "maxDrawCalls": 16, + "maxFrameMsP95": 33, + "maxTriangles": 40000, + "minFps": 30 + }, + "renderChain": { "tier": "high", "stages": { "includes": ["stormPost"] } } + }, + "name": "rain-production-performance", + "schemaVersion": 1, + "steps": [ + { "kind": "input", "press": "KeyW", "holdTicks": 60, "release": true }, + { "kind": "wait", "waitTicks": 600, "release": true } + ], + "target": "web", + "viewport": { "height": 1080, "width": 1920 }, + "warmupFrames": 60 +} diff --git a/packages/create-threenative/templates/rain/playtests/reduced-motion.playtest.json b/packages/create-threenative/templates/rain/playtests/reduced-motion.playtest.json new file mode 100644 index 0000000000..0e5ec36513 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/reduced-motion.playtest.json @@ -0,0 +1,87 @@ +{ + "name": "rain-reduced-motion-suppresses-every-flash", + "schemaVersion": 1, + "target": "web", + "reducedMotion": "reduce", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "steps": [ + { + "kind": "input", + "press": "KeyL", + "label": "strike", + "waitTicks": 6, + "release": true + }, + { + "kind": "click", + "at": { + "element": { + "id": "strike" + } + }, + "label": "button", + "waitTicks": 6, + "release": true + }, + { + "kind": "wait", + "label": "auto", + "waitTicks": 700, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "safe", + "equals": true, + "allowTrivial": "prefers-reduced-motion turns photosensitivity mode on before the first observation; this scenario proves it stays on through a key strike, the strike button and 700 ticks" + }, + { + "id": "state", + "path": "autoLightning", + "equals": false, + "allowTrivial": "reduced motion takes automatic lightning down at boot; 700 ticks (past the 9 s first auto strike) prove it never comes back" + }, + { + "id": "state", + "path": "cinematic", + "equals": false, + "allowTrivial": "reduced motion keeps the cinematic orbit off from boot" + }, + { + "id": "state", + "path": "flash", + "atSteps": [ + { + "equals": 0, + "label": "strike" + }, + { + "equals": 0, + "label": "button" + } + ], + "equals": 0, + "allowTrivial": "the invariant under test: no strike path produces a flash while reduced motion holds" + }, + { + "id": "state", + "path": "strikes", + "equals": 0, + "allowTrivial": "the invariant under test: neither the key, the button nor the auto scheduler strikes under reduced motion" + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/simulation.playtest.json b/packages/create-threenative/templates/rain/playtests/simulation.playtest.json new file mode 100644 index 0000000000..640aec502e --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/simulation.playtest.json @@ -0,0 +1,20 @@ +{ + "name": "rain-simulation-advances", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1280, "height": 720 }, + "warmupFrames": 10, + "steps": [{ "kind": "wait", "waitTicks": 120, "release": true }], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { "id": "state", "path": "elapsed", "changed": true }, + { "id": "state", "path": "frame", "changed": true } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/storm.playtest.json b/packages/create-threenative/templates/rain/playtests/storm.playtest.json new file mode 100644 index 0000000000..a36bf0dffe --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/storm.playtest.json @@ -0,0 +1,150 @@ +{ + "name": "rain-storm-panel-and-camera-change-the-frame", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "input", + "press": "Space", + "label": "paused", + "waitTicks": 2, + "release": true + }, + { + "kind": "click", + "at": { + "element": { + "id": "preset-drizzle" + } + }, + "label": "drizzle", + "waitTicks": 10, + "release": true + }, + { + "kind": "click", + "at": { + "element": { + "id": "rain" + } + }, + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "End", + "label": "slider", + "waitTicks": 10, + "release": true + }, + { + "kind": "input", + "pointerPosition": { + "x": 0.45, + "y": 0.5, + "buttons": 1 + }, + "waitTicks": 2, + "release": false + }, + { + "kind": "input", + "pointerPosition": { + "x": 0.3, + "y": 0.42, + "buttons": 1 + }, + "waitTicks": 4, + "release": false + }, + { + "kind": "input", + "pointerPosition": { + "x": 0.3, + "y": 0.42, + "buttons": 0 + }, + "label": "look", + "waitTicks": 4, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "paused", + "equals": true + }, + { + "id": "state", + "path": "preset", + "atSteps": [ + { + "equals": "drizzle", + "label": "drizzle" + } + ], + "equals": "drizzle" + }, + { + "id": "state", + "path": "target.rain", + "atSteps": [ + { + "equals": 0.24, + "label": "drizzle" + } + ], + "gte": 0.8 + }, + { + "id": "state", + "path": "weather.rain", + "atSteps": [ + { + "equals": 0.24, + "label": "drizzle" + } + ], + "gte": 0.8 + }, + { + "id": "state", + "path": "heading", + "changed": true + } + ], + "visual": [ + { + "frameDiff": { + "minChangedPixelRatio": 0.2 + } + }, + { + "region": { + "x": 0, + "y": 0, + "width": 1440, + "height": 900, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/survives.playtest.json b/packages/create-threenative/templates/rain/playtests/survives.playtest.json new file mode 100644 index 0000000000..4f78ee02fc --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/survives.playtest.json @@ -0,0 +1,58 @@ +{ + "name": "rain-survives-real-frame-boot", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "artifacts": { + "screenshots": "after" + }, + "steps": [ + { + "kind": "input", + "press": "ArrowUp", + "holdTicks": 60, + "label": "forward", + "release": true + }, + { + "kind": "wait", + "waitTicks": 60, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "elapsed", + "changed": true + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + } + } + ], + "movement": { + "entity": "player", + "minDistance": 3 + } + }, + "subject": "player" +} diff --git a/packages/create-threenative/templates/rain/playtests/tiers.playtest.json b/packages/create-threenative/templates/rain/playtests/tiers.playtest.json new file mode 100644 index 0000000000..4936e5c97e --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/tiers.playtest.json @@ -0,0 +1,179 @@ +{ + "name": "rain-four-tiers-render-the-storm", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1440, + "height": 900 + }, + "warmupFrames": 30, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "wait", + "label": "high", + "waitTicks": 30, + "screenshot": "tier-high", + "release": true + }, + { + "kind": "click", + "at": { + "element": { + "id": "quality" + } + }, + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "Escape", + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "ArrowUp", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "balanced", + "waitTicks": 20, + "screenshot": "tier-balanced", + "release": true + }, + { + "kind": "input", + "press": "ArrowUp", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "performance", + "waitTicks": 20, + "screenshot": "tier-low", + "release": true + }, + { + "kind": "input", + "press": "ArrowDown", + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "ArrowDown", + "waitTicks": 2, + "release": true + }, + { + "kind": "input", + "press": "ArrowDown", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "ultra", + "waitTicks": 20, + "screenshot": "tier-ultra", + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "quality", + "atSteps": [ + { + "equals": "high", + "label": "high" + }, + { + "equals": "balanced", + "label": "balanced" + }, + { + "equals": "performance", + "label": "performance" + }, + { + "equals": "ultra", + "label": "ultra" + } + ], + "equals": "ultra" + }, + { + "id": "state", + "path": "cloudPass.steps", + "atSteps": [ + { + "equals": 64, + "label": "high" + }, + { + "equals": 48, + "label": "balanced" + }, + { + "equals": 32, + "label": "performance" + }, + { + "equals": 88, + "label": "ultra" + } + ], + "equals": 88 + }, + { + "id": "state", + "path": "dropCount", + "atSteps": [ + { + "equals": 9120, + "label": "high" + }, + { + "equals": 9120, + "label": "balanced" + }, + { + "equals": 4940, + "label": "performance" + }, + { + "equals": 12160, + "label": "ultra" + } + ], + "equals": 12160 + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1440, + "height": 900, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/playtests/weather.playtest.json b/packages/create-threenative/templates/rain/playtests/weather.playtest.json new file mode 100644 index 0000000000..7e8c9e3796 --- /dev/null +++ b/packages/create-threenative/templates/rain/playtests/weather.playtest.json @@ -0,0 +1,77 @@ +{ + "name": "rain-drizzle-control-changes-the-rendered-weather", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "both" + }, + "steps": [ + { + "kind": "click", + "at": { + "element": { + "id": "preset-drizzle" + } + }, + "release": true + }, + { + "kind": "wait", + "waitTicks": 180, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "preset", + "equals": "drizzle" + }, + { + "id": "state", + "path": "target.rain", + "equals": 0.24 + }, + { + "id": "state", + "path": "weather.rain", + "changed": true, + "lte": 0.32 + }, + { + "id": "state", + "path": "dropCount", + "changed": true, + "lte": 4000 + } + ], + "visual": [ + { + "frameDiff": { + "minChangedPixelRatio": 0.1 + } + }, + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/rain/public/favicon.svg b/packages/create-threenative/templates/rain/public/favicon.svg new file mode 100644 index 0000000000..31f9ad4225 --- /dev/null +++ b/packages/create-threenative/templates/rain/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/create-threenative/templates/rain/src/audio/storm.ts b/packages/create-threenative/templates/rain/src/audio/storm.ts new file mode 100644 index 0000000000..e69f5386ba --- /dev/null +++ b/packages/create-threenative/templates/rain/src/audio/storm.ts @@ -0,0 +1,314 @@ +// Generated for you. The storm's sound, as the reference study mixed it. +// +// The study synthesised all of this live: a Web Audio graph of noise buffers and biquads, built on +// the first click. This file plays three clips that `tools/make-storm-audio.mjs` baked from the same +// maths, through the engine's own `AudioBus`. Everything that used to be a node is a constant below, +// next to the line of the study it came from; nothing is re-tuned, and nothing here is a framework +// concept. +// +// What is the source's, exactly: +// rain hiss noise → highpass 1800 / lowpass 11000, gain = weather.rain * .20 over .25 s +// wind bed pink noise → bandpass 35..350, gain = .02 + weather.wind * .18 * LFO over .4 s +// thunder crack + rumble + 9 Hz tremolo, lowpass and gain set by how far it was struck +// +// One seam the source had and this file now asks the engine for: the `DynamicsCompressor` it ran +// in front of the master is declared by name below, on the engine's own `AudioBus`. A second, +// native-only seam is named at `thunderDistance`. What has been exercised is the maths and this +// wiring against the engine's real `AudioBus`, plus a headed WebGPU playtest of the registered +// entity; no native target has run the file. + +import { AudioBus, type ICtx } from "@threenative/core"; +import type { GameState } from "../state.js"; + +/** The name this is registered under, so a pause intent can reach it without a captured closure. */ +export const STORM_AUDIO_ENTITY = "storm-audio"; + +/** Baked by `tools/make-storm-audio.mjs`, logical paths into `assets/`. */ +const CLIPS = { + rain: "rain-loop.wav", + thunder: "thunder.wav", + wind: "wind-loop.wav", +} as const; + +/** + * `this.master.gain.setTargetAtTime(.42, this.ctx.currentTime, .12)`. + * + * The source ran a `DynamicsCompressor` in front of this, to hold the three voices down when they + * summed; that response to a loud mix is `MASTER_COMPRESSOR` below, asked for on the bus rather + * than rebuilt out of gains. What this gain cannot do on its own is soften anything: a mix past + * `MASTER_GAIN` clips, and the compressor ahead of it is what keeps that from happening. + */ +const MASTER_GAIN = 0.42; +const MASTER_FADE = 0.12; +/** `mute()`'s `setTargetAtTime(0, currentTime, .06)`. */ +const MUTE_FADE = 0.06; + +/** + * The source's compressor, value by value: `new DynamicsCompressorNode(ctx, { + * threshold: -15, knee: 30, ratio: 5, attack: .003, release: .25 })`. + * + * `AudioBus` takes all five as one option and installs the node where every voice sums, ahead of + * the master gain — the same place the source had it. A runtime with no dynamics node reports + * `compressor` in `bus.unsupported` rather than playing on uncompressed and quiet about it. + */ +const MASTER_COMPRESSOR = { + attack: 0.003, + knee: 30, + ratio: 5, + release: 0.25, + threshold: -15, +} as const; + +/** `weather.rain * .20` — the source overwrote the loop node's own .09 with this every frame. */ +const RAIN_GAIN = 0.2; +/** `.02 + weather.wind * .18 * (.78 + .22 * Math.sin(time * .37))`, term by term. */ +const WIND_BASE = 0.02; +const WIND_GAIN = 0.18; +const WIND_LFO_RATE = 0.37; +const WIND_LFO_FLOOR = 0.78; +const WIND_LFO_DEPTH = 0.22; +/** The two time constants the source smoothed its own gain nodes with. */ +const RAIN_TAU = 0.25; +const WIND_TAU = 0.4; + +/** + * `thunder()`'s distance law: `clamp(4500 - distance * 5, 280, 4500)` and + * `clamp(1.05 - distance * .0009, .18, .95)`. The low-pass goes to the engine as `lowpassHz`, which + * is a real `BiquadFilterNode` on the web. The native host binds no `createBiquadFilter`, so it + * drops the option and the bus names it in `bus.unsupported` — on a phone a near strike is as + * bright as a far one. That is the one unsupported cue option observed in the installed native + * audio bindings; `detune` is the other, and this file does not use it. + */ +function thunderDistance(metres: number): { readonly lowpassHz: number; readonly volume: number } { + return { + lowpassHz: Math.max(280, Math.min(4500, 4500 - metres * 5)), + volume: Math.max(0.18, Math.min(0.95, 1.05 - metres * 0.0009)), + }; +} + +/** A strike, handed over by the scene at the moment it is struck and not before. */ +export interface IStormStrike { + /** Absolute simulation time the thunder is due, which is what makes it survive a freeze. */ + readonly at: number; + /** How far away it was struck, for the distance law above. */ + readonly metres: number; +} + +export interface IStormAudio { + /** Queues one strike. Strikes queue: a second one does not overwrite one still crossing the air. */ + queueStrike(strike: IStormStrike): void; + /** The frame's truth. Call it once per frame, after the simulation has advanced. */ + update(state: GameState): void; + /** + * An explicit hold, from the pause intent. Separate from `setHidden` because the two clear + * independently: the game's own pause outlives the tab it was pressed in. + */ + setSilenced(silenced: boolean): void; + /** + * A visibility hold, from the UI realm or the engine's lifecycle — the one place that knows + * whether the frame is still on screen. Neither this nor `setSilenced` is a substitute for the + * other, and the bus is released only when both are clear. + */ + setHidden(hidden: boolean): void; + dispose(): void; + /** What a playtest reads back. */ + debug(): Record; +} + +type LoopVoice = ReturnType; + +export function createStormAudio(ctx: ICtx): IStormAudio { + const bus = new AudioBus({ camera: ctx.camera, compressor: MASTER_COMPRESSOR }); + const clips: Partial> = {}; + let loops: { readonly rain: LoopVoice; readonly wind: LoopVoice } | undefined; + /** Struck but not yet crossed the air, oldest first. */ + let pending: IStormStrike[] = []; + /** Struck and sounding, in play order. */ + const thunder: LoopVoice[] = []; + let wanted = false; + let muted = false; + let silenced = false; + let hidden = false; + /** What the bus is actually being told, so neither hold can latch the other. */ + let held = false; + let master = -1; + let loadError: string | undefined; + let unlockError: string | undefined; + + /** + * `AudioBus.pause` holds each voice where it stands and `resume` gives it back from there, which + * is the honest reading of a paused simulation. The study suspended the whole `AudioContext` + * instead; the bus will not, because that would silence every other bus on it. + * + * The two holds are kept apart so neither can latch the other: a tab that goes visible again + * releases the sound, and a pause that was pressed still holds it afterwards. + */ + function applyHold(): void { + const hold = silenced || hidden; + if (hold === held) return; + held = hold; + if (hold) bus.pause(); + else bus.resume(); + } + + /** Everything that is sounding but is not a bed, stopped and forgotten. */ + function clearThunder(): void { + pending = []; + for (const voice of thunder) bus.stopVoice(voice); + thunder.length = 0; + } + + /** The source's own smoothing: `gain.setTargetAtTime(target, t, .25)` for rain, `.4` for wind. */ + function ramp(voice: LoopVoice, target: number, tau: number): void { + const param = voice.gain.gain; + if (typeof param.setTargetAtTime === "function") + param.setTargetAtTime(target, bus.listener.context.currentTime, tau); + else voice.setVolume(target); + } + + /** Fires every strike the simulation clock has carried past its own delay, once each. */ + function fireDue(now: number): void { + // A voice that ran out is back in the bus's pool; a stale handle here is only a wrong count. + for (let index = thunder.length - 1; index >= 0; index -= 1) + if (thunder[index]?.isPlaying !== true) thunder.splice(index, 1); + // A strike queued before its clip landed waits in the queue rather than throwing. + if (pending.length === 0 || clips.thunder === undefined) return; + const due = pending.filter((strike) => now >= strike.at); + if (due.length === 0) return; + pending = pending.filter((strike) => now < strike.at); + for (const strike of due) + thunder.push( + bus.play(clips.thunder as AudioBuffer, { + cue: "thunder", + ...thunderDistance(strike.metres), + }), + ); + } + + /** Puts the two beds down. Their gains are written every frame from the eased weather. */ + function startLoops(): void { + loops = { + rain: bus.play(clips.rain as AudioBuffer, { cue: "rain-loop", loop: true, volume: 0 }), + wind: bus.play(clips.wind as AudioBuffer, { cue: "wind-loop", loop: true, volume: 0 }), + }; + } + + /** + * The enable edge is the gesture, and the beds wait for it — and for the clips, which arrive + * after the first frame. Turning sound off has to take the beds down, not just forget that they + * were asked for: `AudioBus.stop` is the one call that ends every voice, and the strikes in the + * air go with them rather than piling up on the way back. + */ + function arm(state: GameState): void { + if (state.audioEnabled !== wanted) { + wanted = state.audioEnabled; + if (wanted) { + // `AudioBus` also unlocks on the first key or pointer event; doing it here as well is what + // lets the automation API raise sound with no gesture at all. A refused unlock is + // reported, never swallowed: a game whose sound silently never starts is worse. + void bus.unlock().catch((error: unknown) => { + unlockError = error instanceof Error ? error.message : String(error); + console.warn(`TN_RAIN_AUDIO_UNLOCK_FAILED ${unlockError}`); + }); + } else { + bus.stop(); + loops = undefined; + clearThunder(); + } + } + if (wanted && loops === undefined && clips.rain !== undefined && clips.wind !== undefined) + startLoops(); + } + + /** The master level and the two bed gains, from the frame's weather. */ + function mix(state: GameState): void { + muted = state.muted; + const level = muted ? 0 : MASTER_GAIN; + if (level !== master) { + master = level; + bus.setVolume(level, muted ? MUTE_FADE : MASTER_FADE); + } + if (loops === undefined) return; + ramp(loops.rain, state.weather.rain * RAIN_GAIN, RAIN_TAU); + ramp( + loops.wind, + WIND_BASE + + state.weather.wind * + WIND_GAIN * + (WIND_LFO_FLOOR + WIND_LFO_DEPTH * Math.sin(state.elapsed * WIND_LFO_RATE)), + WIND_TAU, + ); + } + + for (const key of Object.keys(CLIPS) as (keyof typeof CLIPS)[]) { + void ctx.assets + .audio(CLIPS[key]) + .then((buffer) => { + clips[key] = buffer; + }) + .catch((error: unknown) => { + // Sound is not the storm. A missing clip is reported once and leaves the rest audible. + loadError = `${key}: ${error instanceof Error ? error.message : String(error)}`; + console.warn(`TN_RAIN_AUDIO_MISSING ${loadError}`); + }); + } + + return { + queueStrike(strike) { + if (!Number.isFinite(strike.at) || !Number.isFinite(strike.metres) || strike.metres < 0) + throw new RangeError( + "a strike needs a finite simulation time and a non-negative distance.", + ); + pending.push(strike); + }, + + update(state) { + arm(state); + mix(state); + if (loops === undefined) return; + // Photosensitivity mode is the study's `setSafe`, which also called `clearThunder`. + if (state.safe) clearThunder(); + else fireDue(state.elapsed); + }, + + setSilenced(next) { + silenced = next; + applyHold(); + }, + + setHidden(next) { + hidden = next; + applyHold(); + }, + + dispose() { + pending = []; + thunder.length = 0; + loops = undefined; + bus.dispose(); + }, + + debug() { + return { + enabled: wanted, + muted, + silenced, + hidden, + held: held, + // The engine's own readings, not a mirror of what this file last asked for. + master: bus.volume, + paused: bus.paused, + compressor: bus.compressor !== undefined, + loaded: Object.keys(clips).length, + loadError: loadError ?? "none", + unlockError: unlockError ?? "none", + pendingThunder: pending.length, + thunderVoices: thunder.length, + voices: bus.voices, + queued: bus.queued, + pausedVoices: bus.pausedVoices, + unsupported: bus.unsupported, + }; + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/game.ts b/packages/create-threenative/templates/rain/src/game.ts new file mode 100644 index 0000000000..4e060e4db6 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/game.ts @@ -0,0 +1,114 @@ +import { type IGame, defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { UI_READY_INTENT } from "@threenative/core/ui-layer"; +import config from "../threenative.config.js"; +import { type IStormAudio, STORM_AUDIO_ENTITY } from "./audio/storm.js"; +import { Coast } from "./scenes/Boot.js"; +import { + type GameState, + type PresetName, + type QualityName, + VISIBILITY_INTENT, + type WeatherKey, + intentPatch, +} from "./state.js"; + +const game = defineGame({ + input: { + ascend: { keys: ["KeyE"] }, + boost: { keys: ["ShiftLeft", "ShiftRight"] }, + descend: { keys: ["KeyQ"] }, + // The drag's own motion, sampled at the tick. No click capture: the study looks by dragging, + // and a locked pointer would take the cursor away from the panel beside it. + look: { captureOnClick: false, pointerRelative: true }, + move: { + down: ["ArrowDown", "KeyS"], + left: ["ArrowLeft", "KeyA"], + right: ["ArrowRight", "KeyD"], + up: ["ArrowUp", "KeyW"], + }, + // The study's own shortcuts live here, in the game's input map, rather than in a browser + // keydown listener: on a native host the interface is a web view that never has keyboard + // focus, so a shortcut only the UI realm hears is a shortcut native players do not have. + // F (fullscreen) and P (save image) stay in the UI realm because both are browser APIs. + hideUi: { keys: ["KeyH"] }, + pause: { keys: ["Space"] }, + resetCamera: { keys: ["KeyR"] }, + safe: { keys: ["KeyX"] }, + sound: { keys: ["KeyM"] }, + strike: { keys: ["KeyL"] }, + }, + plugins: [playtest()], + display: config.display, + render: config.renderer, + scenes: { coast: Coast }, + seed: 607, + start: "coast", +}); + +export default game; + +/** + * The intents the UI sends, and the payload each one carries. The UI slice owns nothing else: + * every control here is a named intent and a validated payload, and nothing else crosses. + */ +export type RainIntent = + | { readonly name: "closeHelp" } + | { readonly name: "help" } + | { readonly name: "hideUi" } + | { readonly name: "pause" } + | { readonly name: "resetCamera"; readonly payload?: never } + | { readonly name: "resume" } + | { readonly name: "setAudioEnabled"; readonly payload: boolean } + | { readonly name: "setAutoLightning"; readonly payload: boolean } + | { readonly name: "setCinematic"; readonly payload: boolean } + | { readonly name: "setDroplets"; readonly payload: boolean } + | { readonly name: "setFrozen"; readonly payload: boolean } + | { readonly name: "setMuted"; readonly payload: boolean } + | { readonly name: "setPreset"; readonly payload: PresetName } + | { readonly name: "setQuality"; readonly payload: QualityName } + | { readonly name: "setSafe"; readonly payload: boolean } + | { readonly name: "setWeather"; readonly payload: Partial> } + | { readonly name: "showUi" } + | { readonly name: "step"; readonly payload: number } + | { readonly name: "strike"; readonly payload?: never } + | { readonly name: typeof VISIBILITY_INTENT; readonly payload: boolean }; + +game.ui.onIntent((intent, payload) => { + // The framework's own "my tree has rendered" signal, and the one control that is not a player's: + // it arrives on the same door but carries no weather, so it is answered before the validator + // ever sees it. `uiReady` mirrors `game.ui.connected` rather than being set optimistically, so a + // UI that never came up leaves the loading study up instead of hiding it over nothing. + if (intent === UI_READY_INTENT) { + game.state.set({ uiReady: game.ui.connected }); + game.state.flush(); + return; + } + // The two audio holds, answered at the door: a hidden tab stops the loop, so nothing in `Boot.ts` + // runs to notice it, and the pause hold has to land on the same edge as the press. Neither is a + // weather intent and neither needs a state field of its own. + const storm = (): IStormAudio | undefined => + game.ctx?.entities.get(STORM_AUDIO_ENTITY); + if (intent === "pause" || intent === "resume") storm()?.setSilenced(intent === "pause"); + if (intent === VISIBILITY_INTENT) { + if (typeof payload !== "boolean") { + console.warn(`TN_RAIN_INTENT ${intent}: expected a boolean payload`); + return; + } + storm()?.setHidden(payload); + return; + } + let patch: Partial; + try { + patch = intentPatch(intent, payload, game.state.getState().target); + } catch (why) { + // Reported and dropped rather than thrown: one malformed control must not take the gesture + // flow down with it, and the rejection is visible in the console where the next agent looks. + console.warn(`TN_RAIN_INTENT ${intent}: ${(why as Error).message}`); + return; + } + game.state.set(patch); + // The frame loop flushes too, but a control must feel instant: a slider that waits for the next + // frame reads as a dropped drag. + game.state.flush(); +}); diff --git a/packages/create-threenative/templates/rain/src/main.ts b/packages/create-threenative/templates/rain/src/main.ts new file mode 100644 index 0000000000..6d49a882a9 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/main.ts @@ -0,0 +1,15 @@ +import { acceptHotUpdate } from "@threenative/core/hot"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import game from "./game.js"; +import { App } from "./ui/App.js"; +import "./style.css"; + +import.meta.hot?.accept(); +acceptHotUpdate(game, import.meta.hot); +const root = document.getElementById("root"); +if (root === null) throw new Error("Missing #root element."); +const appRoot = root as typeof root & { __threenativeRoot?: ReturnType }; +const reactRoot = appRoot.__threenativeRoot ?? createRoot(appRoot); +appRoot.__threenativeRoot = reactRoot; +reactRoot.render(createElement(App, { game })); diff --git a/packages/create-threenative/templates/rain/src/render/bloom-shader.ts b/packages/create-threenative/templates/rain/src/render/bloom-shader.ts new file mode 100644 index 0000000000..a8490d631a --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/bloom-shader.ts @@ -0,0 +1,65 @@ +// Transcribed once by Three.js's own GLSL to TSL transpiler from tools/tempest-bloom.frag, and maintained +// here by hand since: `tools/generate-shaders.mjs` regenerates only the coast and cloud shaders. +// Keep the .frag in step when you change this file. +// +// The demo's exact bloom: a 5x5 gaussian at twice the input texel size, masked by a soft-knee +// luminance threshold and normalised by the weight it actually accumulated — not by 25, so the +// frame's corners, where the kernel hangs off the target, cannot dim. Rendered at quarter +// resolution: see src/render/postprocessing.ts. +import { DataTexture, Vector2, Vector3, Vector4 } from "three"; +import { + Fn, + Loop, + exp, + float, + frameGroup, + int, + max, + smoothstep, + texture, + uniform, + vec2, + vec3, + vec4, +} from "three/tsl"; +import type { Node, TextureNode } from "three/webgpu"; + +// Uniforms the shader reads. Values are assigned by src/render/world.ts and src/render/clouds.ts, +// which own the camera basis and the weather; the generated module only holds the nodes. +const uScenePlaceholder = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); +uScenePlaceholder.needsUpdate = true; +export let uScene: TextureNode = texture(uScenePlaceholder).setGroup(frameGroup); +export function setScene(value: TextureNode): void { + uScene = value; +} +export const uRes = uniform(new Vector2(), "vec2").setGroup(frameGroup); + +// Three.js Transpiler r185 + +export const tempestBloom = /*@__PURE__*/ Fn(([vUv]: [Node<"vec2">]) => { + const c = vec3(0).toVar(); + const w = float(0).toVar(); + + Loop( + { start: int(-2), end: int(2), condition: "<=" }, + { start: int(-2), end: int(2), condition: "<=" }, + ({ i, j }) => { + const wt = exp( + float(j.mul(j).add(i.mul(i))) + .negate() + .mul(0.32), + ).toVar(); + // `vUv` is the bloom quad's own geometry uv, bottom-up; the scene it reads is a render target + // whose first row is the top of the frame. Unflipped, every highlight blooms mirrored about + // the horizon — the lamp's glow hung in the sky above it. + const s = uScene + .sample(vec2(vUv.x, vUv.y.oneMinus()).add(vec2(float(j), float(i)).div(uRes).mul(2))) + .rgb.toVar(); + const l = max(s.r, max(s.g, s.b)).toVar(); + c.addAssign(s.mul(smoothstep(0.7, 1.6, l)).mul(wt)); + w.addAssign(wt); + }, + ); + + return vec4(c.div(w), 1); +}); diff --git a/packages/create-threenative/templates/rain/src/render/camera.ts b/packages/create-threenative/templates/rain/src/render/camera.ts new file mode 100644 index 0000000000..d1f8283ed8 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/camera.ts @@ -0,0 +1,160 @@ +// Generated for you. Framing is a game-owned decision. +// +// The rig, not the scene: everything that decides where the camera stands and what it looks at +// lives here, so the scene is left wiring lifecycle and publishing state. +import { Euler, MathUtils, type PerspectiveCamera, Vector3 } from "three"; + +/** The view the game opens on, and the one `resetCamera` returns to. */ +export interface IHomeView { + readonly pitch: number; + readonly position: Vector3; + readonly yaw: number; +} + +export const HOME: IHomeView = { + pitch: 0.21, + position: new Vector3(1.8, 2.85, 18), + yaw: -0.085, +}; + +const PITCH_RANGE: readonly [number, number] = [-0.7, 1.25]; +const WALK_SPEED = 6; +const RUN_SPEED = 23; +const LOOK_SENSITIVITY = 0.0023; + +/** How far the free camera may roam: the coast runs along -z, the sea is at +x. */ +const BOUNDS = { + maxX: 220, + maxY: 110, + maxZ: 90, + minX: -100, + minY: 1.65, + minZ: -350, +} as const; + +/** + * What the rig needs from the engine's input. Structural on purpose: `src/render/` is ordinary + * Three.js and must not import a package. + */ +export interface ICameraInput { + pressed(name: string): boolean; + raw: { + readonly pointer: { + readonly buttons: number; + readonly down: boolean; + }; + }; + /** + * `move` is the key vector; `look` is the drag's motion since the last tick, in pixels — the + * engine samples it at the tick, so the raw pointer's own counter is already spent. + */ + vector(name: string): { x: number; y: number }; +} + +export interface ICameraRig { + /** Camera-space forward, flat on the ground: what WASD walks along. */ + readonly forward: Vector3; + readonly heading: number; + readonly position: Vector3; + readonly right: Vector3; + readonly up: Vector3; + /** True while a key or a drag is asking for the camera, which cancels the cinematic orbit. */ + readonly manual: boolean; + reset(): void; + update(dt: number, elapsed: number, input: ICameraInput, cinematic: boolean): void; +} + +export function setupCamera(camera: PerspectiveCamera): void { + camera.fov = 55; + // Match the source projection/depth coefficients: 1.00012*d - 0.300018. + camera.near = 0.15; + camera.far = 2500; + camera.position.copy(HOME.position); + camera.updateProjectionMatrix(); +} + +const LEFT_MOUSE = 1; + +export function createCameraRig(camera: PerspectiveCamera): ICameraRig { + const position = HOME.position.clone(); + const rotation = new Euler(0, 0, 0, "YXZ"); + const forward = new Vector3(); + const right = new Vector3(); + const up = new Vector3(); + const worldUp = new Vector3(0, 1, 0); + let yaw = HOME.yaw; + let pitch = HOME.pitch; + let manual = false; + + const orient = (): void => { + // The rig stores yaw as the compass heading it publishes, so a reset and a drag agree on + // sign instead of each carrying their own. + rotation.set(pitch, -yaw, 0, "YXZ"); + camera.quaternion.setFromEuler(rotation); + forward.set(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch)); + right.crossVectors(forward, worldUp).normalize(); + up.crossVectors(right, forward); + }; + + const reset = (): void => { + position.copy(HOME.position); + yaw = HOME.yaw; + pitch = HOME.pitch; + orient(); + }; + + reset(); + + return { + forward, + get heading() { + return Math.round(((yaw * 180) / Math.PI + 360) % 360); + }, + get manual() { + return manual; + }, + position, + reset, + right, + up, + update(dt, elapsed, input, cinematic) { + const move = input.vector("move"); + const altitude = Number(input.pressed("ascend")) - Number(input.pressed("descend")); + const boost = input.pressed("boost"); + const dragging = input.raw.pointer.down && (input.raw.pointer.buttons & LEFT_MOUSE) !== 0; + manual = move.x !== 0 || move.y !== 0 || altitude !== 0 || boost || dragging; + if (cinematic && !manual) { + // A slow orbit of the opening view: the coast stays in frame while the weather plays out. + position.set( + HOME.position.x + Math.sin(elapsed * 0.08) * 1.6, + HOME.position.y + Math.sin(elapsed * 0.06) * 0.16, + HOME.position.z + Math.cos(elapsed * 0.07) * 1.8, + ); + yaw = HOME.yaw + Math.sin(elapsed * 0.045) * 0.12; + pitch = HOME.pitch + Math.sin(elapsed * 0.06) * 0.027; + orient(); + camera.position.copy(position); + return; + } + if (dragging) { + const look = input.vector("look"); + yaw += look.x * LOOK_SENSITIVITY; + pitch = MathUtils.clamp(pitch - look.y * LOOK_SENSITIVITY, PITCH_RANGE[0], PITCH_RANGE[1]); + } + // Yaw only, so walking never climbs the hill the camera is standing on. + const flatForward = new Vector3(Math.sin(yaw), 0, -Math.cos(yaw)); + const flatRight = new Vector3(Math.cos(yaw), 0, Math.sin(yaw)); + const speed = dt * (boost ? RUN_SPEED : WALK_SPEED); + position.addScaledVector(flatForward, speed * move.y); + position.addScaledVector(flatRight, speed * move.x); + position.y += speed * altitude; + position.set( + MathUtils.clamp(position.x, BOUNDS.minX, BOUNDS.maxX), + MathUtils.clamp(position.y, BOUNDS.minY, BOUNDS.maxY), + MathUtils.clamp(position.z, BOUNDS.minZ, BOUNDS.maxZ), + ); + orient(); + camera.position.copy(position); + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/render/clouds-shader.ts b/packages/create-threenative/templates/rain/src/render/clouds-shader.ts new file mode 100644 index 0000000000..295c4c9856 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/clouds-shader.ts @@ -0,0 +1,242 @@ +// GENERATED FILE — do not edit it. Every line below is the output of Three.js's own GLSL to +// TSL transpiler, run over tools/tempest-clouds.frag. That .frag file +// is the source of truth; change it and the generator, never this file. +// +// node tools/generate-shaders.mjs +// +// The shader is the storm ceiling: a raymarched volume between 145 and 815 metres, lit by a +// five-tap march toward the sun and filled from a procedural 64³ noise volume. It is one full-screen +// pass into a render target the coast samples — see src/render/clouds.ts. +import { Data3DTexture, Vector2, Vector3, Vector4 } from "three"; +import { + Break, + Fn, + If, + Loop, + abs, + add, + clamp, + depth, + dot, + exp, + float, + floor, + fract, + frameGroup, + int, + length, + mat2, + max, + min, + mix, + mul, + normalize, + pow, + screenCoordinate, + smoothstep, + sub, + texture3D, + uniform, + vec2, + vec3, + vec4, +} from "three/tsl"; +import type { Node, TextureNode } from "three/webgpu"; + +// Uniforms the shader reads. Values are assigned by src/render/world.ts and src/render/clouds.ts, +// which own the camera basis and the weather; the generated module only holds the nodes. +export const uTime = uniform(0, "float").setGroup(frameGroup); +export const uCloud = uniform(0, "float").setGroup(frameGroup); +export const uWind = uniform(0, "float").setGroup(frameGroup); +export const uFlash = uniform(0, "float").setGroup(frameGroup); +export const uAspect = uniform(0, "float").setGroup(frameGroup); +export const uTan = uniform(0, "float").setGroup(frameGroup); +export const uCam = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uForward = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uRight = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uUp = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uStrike = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uSunDir = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uSkyLow = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uSkyHigh = uniform(new Vector3(), "vec3").setGroup(frameGroup); +const uNoisePlaceholder = new Data3DTexture(new Uint8Array([0, 0, 0, 255]), 1, 1, 1); +uNoisePlaceholder.needsUpdate = true; +export let uNoise: TextureNode = texture3D(uNoisePlaceholder).setGroup(frameGroup); +export function setNoise(value: TextureNode): void { + uNoise = value; +} +export const uSteps = uniform(0, "int").setGroup(frameGroup); + +// Three.js Transpiler r185 + +// Polyfills + +const gl_FragCoord = vec3(screenCoordinate.x, screenCoordinate.y.oneMinus(), depth); + +// The look, owned by src/render/{sky,lighting}.ts and set from there every run. + +export const hash12 = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const p3 = fract(vec3(p.xyx).mul(0.1031)).toVar(); + p3.addAssign(dot(p3, p3.yzx.add(33.33))); + + return fract(p3.x.add(p3.y).mul(p3.z)); +}); + +export const hash13 = /*@__PURE__*/ Fn(([p_immutable]: [Node<"vec3">]) => { + const p = p_immutable.toVar(); + p.assign(fract(p.mul(0.1031))); + p.addAssign(dot(p, p.zyx.add(31.32))); + + return fract(p.x.add(p.y).mul(p.z)); +}); + +export const noise2 = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const i = floor(p).toVar(); + const f = fract(p).toVar(); + f.assign(f.mul(f).mul(sub(3, mul(2, f)))); + + return mix( + mix(hash12(i), hash12(i.add(vec2(1, 0))), f.x), + mix(hash12(i.add(vec2(0, 1))), hash12(i.add(1)), f.x), + f.y, + ); +}); + +export const fbm2 = /*@__PURE__*/ Fn(([p_immutable]: [Node<"vec2">]) => { + const p = p_immutable.toVar(); + const v = mul(0.5, noise2(p)).toVar(); + p.assign(mat2(0.8, 0.6, -0.6, 0.8).mul(p).mul(2.03).add(19.1)); + v.addAssign(mul(0.25, noise2(p))); + p.assign(p.mul(2.02).add(9.3)); + v.addAssign(mul(0.125, noise2(p))); + + return v.add(mul(0.0625, noise2(p.mul(2.01).add(17)))); +}); + +export const ray = /*@__PURE__*/ Fn(([uv]: [Node<"vec2">]) => { + const q = uv.mul(2).sub(1).toVar(); + + return normalize( + uForward.add(uRight.mul(q.x).mul(uAspect).mul(uTan)).add(uUp.mul(q.y).mul(uTan)), + ); +}); + +export const project = /*@__PURE__*/ Fn(([p]: [Node<"vec3">]) => { + const v = p.sub(uCam).toVar(); + const d = dot(v, uForward).toVar(); + + return vec4( + dot(v, uRight).div(uAspect.mul(uTan)), + dot(v, uUp).div(uTan), + mul(1.00012, d).sub(0.300018), + d, + ); +}); + +export const density = /*@__PURE__*/ Fn(([p]: [Node<"vec3">]) => { + // One value, one return, for the reason given in the coast's `baseColor`: the transpiler turns a + // GLSL early return into a JavaScript return that only leaves the `If` callback the node graph is + // being built inside, so the branch would never be assigned. The guard is kept as a guard, so a + // clear sky still costs one comparison and no noise fetches. + + const res = float(0).toVar(); + + If(uCloud.greaterThanEqual(0.005), () => { + const drift = vec3(uTime.mul(add(2, uWind.mul(13))), 0, uTime.mul(1.2)).toVar(); + const q = p.add(drift).mul(0.00105).toVar(); + q.y.mulAssign(1.4); + const n = uNoise.sample(q).toVar(); + const broad = n.r + .mul(0.76) + .add(uNoise.sample(q.mul(2.07).add(vec3(0.1, 0.3, 0.7))).r.mul(0.24)) + .toVar(); + + // Flattened condensation base with turbulent billows above it. + + const h = p.y.sub(145).div(670).toVar(); + const base = smoothstep(0, 0.09, h) + .mul(sub(1, smoothstep(0.68, 1, h))) + .toVar(); + const shape = broad.add(uCloud.mul(0.3)).sub(0.72).toVar(); + const edge = uNoise.sample(q.mul(3.81)).b.sub(0.5).mul(0.12).toVar(); + res.assign(clamp(shape.sub(edge).mul(5.1), 0, 1).mul(base)); + }); + + return res; +}); + +export const lightAt = /*@__PURE__*/ Fn(([p_immutable, l]: [Node<"vec3">, Node<"vec3">]) => { + const p = p_immutable.toVar(); + const sum = float(0).toVar(); + const stepLen = float(28).toVar(); + + Loop({ start: int(0), end: 5 }, ({ i }) => { + p.addAssign(l.mul(stepLen)); + sum.addAssign(density(p).mul(stepLen)); + stepLen.mulAssign(1.7); + }); + + return exp(sum.negate().mul(0.015)); +}); + +export const tempestClouds = /*@__PURE__*/ Fn(([vUv]: [Node<"vec2">]) => { + const rd = ray(vUv).toVar(); + rd.y.assign(abs(rd.y)); + rd.assign(normalize(rd)); + const sunDir = uSunDir.toVar(); + const mu = dot(rd, sunDir).toVar(); + const sky = mix(uSkyLow, uSkyHigh, pow(clamp(rd.y, 0, 1), 0.62)).toVar(); + sky.addAssign(vec3(0.31, 0.27, 0.205).mul(pow(max(mu, 0), 14))); + sky.addAssign(vec3(0.7, 0.57, 0.37).mul(pow(max(mu, 0), 140))); + sky.addAssign(uFlash.mul(vec3(0.14, 0.22, 0.36))); + const t0 = max(0, sub(145, uCam.y).div(max(rd.y, 0.008))).toVar(); + const t1 = min(10500, sub(815, uCam.y).div(max(rd.y, 0.008))).toVar(); + const col = vec4(sky, 1).toVar(); + + If(t0.lessThan(t1), () => { + const dt = t1.sub(t0).div(float(uSteps)).toVar(); + const jitter = hash12(gl_FragCoord.xy).mul(0.75).add(0.12).toVar(); + const light = vec3(0).toVar(); + const trans = float(1).toVar(); + const phase = add(0.45, mul(0.65, pow(max(mu, 0), 5))).toVar(); + + Loop({ start: int(0), end: 96 }, ({ i }) => { + If(i.greaterThanEqual(uSteps).or(trans.lessThan(0.008)), () => { + Break(); + }); + + const t = t0.add(float(i).add(jitter).mul(dt)).toVar(); + const p = uCam.add(rd.mul(t)).toVar(); + const d = density(p).toVar(); + + If(d.greaterThan(0.002), () => { + const sun = lightAt(p, sunDir).toVar(); + const height = clamp(p.y.sub(145).div(670), 0, 1).toVar(); + const ambient = mix(vec3(0.035, 0.061, 0.079), vec3(0.19, 0.25, 0.28), height).toVar(); + + // A small multiple-scattering/powder approximation prevents crushed cloud cores. + + const c = ambient + .mul(add(0.7, mul(0.45, sub(1, exp(d.negate().mul(3)))))) + .add(vec3(0.97, 0.94, 0.85).mul(sun).mul(phase).mul(0.7)) + .toVar(); + const glow = exp( + length(p.sub(uStrike).mul(vec3(1, 0.5, 1))) + .negate() + .mul(0.0022), + ).toVar(); + c.addAssign(vec3(0.85, 1.18, 1.9).mul(uFlash).mul(glow).mul(1.2)); + const absorb = sub(1, exp(d.negate().mul(dt).mul(0.02))).toVar(); + light.addAssign(trans.mul(absorb).mul(c)); + trans.mulAssign(sub(1, absorb)); + }); + }); + + const marched = light.add(trans.mul(sky)).toVar(); + const atmospheric = sub(1, exp(t0.negate().mul(0.000075))).toVar(); + marched.assign(mix(marched, sky, atmospheric.mul(0.66))); + col.assign(vec4(marched, sub(1, trans))); + }); + + return col; +}); diff --git a/packages/create-threenative/templates/rain/src/render/clouds.ts b/packages/create-threenative/templates/rain/src/render/clouds.ts new file mode 100644 index 0000000000..8f9b0d416f --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/clouds.ts @@ -0,0 +1,142 @@ +// Generated for you. Ordinary Three.js, and the whole storm ceiling lives in this file. +// +// The clouds are not part of the coast's raymarch: the source rendered them into a screen-sized +// target first and handed that target to the world shader as its sky and its wet reflections, which +// is what makes the reflections carry the same cloud shapes the sky does. This is that pass. The +// shader itself is `clouds-shader.ts`, transpiled from `tools/tempest-clouds.frag`; the maths of the +// volume is there and the only decision here is where the march happens. +// +// The pass is a `PassNode` over a scene holding one screen quad, so the engine's own renderer owns +// the render target, its size and its per-frame update — there is no second render loop here, and +// nothing about the target is copied out by hand. +import { + DoubleSide, + Mesh, + OrthographicCamera, + type PerspectiveCamera, + PlaneGeometry, + Scene, + Vector3, +} from "three"; +import { positionGeometry, texture3D, uv, vec4 } from "three/tsl"; +import { MeshBasicNodeMaterial, PassNode } from "three/webgpu"; +import type { QualityName, Weather } from "../state.js"; +import { + setNoise, + tempestClouds, + uAspect, + uCam, + uCloud, + uFlash, + uForward, + uRight, + uSteps, + uStrike, + uTan, + uTime, + uUp, + uWind, +} from "./clouds-shader.js"; +import { createNoiseVolume } from "./noise-volume.js"; +import { studyTier } from "./quality.js"; +import { setSky } from "./world-shader.js"; + +export interface ICloudPassOptions { + readonly camera: PerspectiveCamera; +} + +export interface ICloudPass { + /** The node the world shader samples for its sky and its reflections. */ + readonly sky: ReturnType; + /** The cloud target as the renderer last sized it, and the march steps it ran: read, not asked. */ + measured(): { readonly height: number; readonly steps: number; readonly width: number }; + dispose(): void; + update(options: { + readonly elapsed: number; + readonly flash: number; + readonly quality: QualityName; + readonly strike: Vector3; + readonly weather: Weather; + }): void; +} + +export function createCloudPass({ camera }: ICloudPassOptions): ICloudPass { + const volume = createNoiseVolume(); + setNoise(texture3D(volume.texture)); + + // The source's cloud quad filled the frame from clip-space corners, so the camera on this pass is + // a formality: the vertex stage writes the corners and nothing reads the projection. + const cloudScene = new Scene(); + const cloudCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1); + const geometry = new PlaneGeometry(2, 2); + const material = new MeshBasicNodeMaterial(); + material.side = DoubleSide; + material.vertexNode = vec4(positionGeometry.xy, 0, 1); + material.fragmentNode = tempestClouds(uv()); + const quad = new Mesh(geometry, material); + // Same as the coast quad: its own bounds are two metres wide at the origin, and the projection it + // is drawn with has no use for them. + quad.frustumCulled = false; + cloudScene.add(quad); + + const pass = new PassNode(PassNode.COLOR, cloudScene, cloudCamera); + // The world's shader has to reach the pass through the node graph, not through a copied texture: + // a pass that is not in the graph the renderer walks is never asked for a frame. + const sky = pass.getTextureNode(); + setSky(sky); + + const forward = new Vector3(); + const right = new Vector3(); + const up = new Vector3(); + let steps = 0; + let scale = 0; + + return { + sky, + measured: () => ({ + height: pass.renderTarget.height, + steps: uSteps.value, + width: pass.renderTarget.width, + }), + dispose(): void { + pass.dispose(); + geometry.dispose(); + material.dispose(); + volume.texture.dispose(); + }, + update({ elapsed, flash, quality, strike, weather }): void { + // The same basis the world marches from, and the same field of view, so the clouds and the + // coast agree on where the horizon is. + camera.getWorldDirection(forward); + right.set(1, 0, 0).applyQuaternion(camera.quaternion); + up.set(0, 1, 0).applyQuaternion(camera.quaternion); + uCam.value.copy(camera.position); + uForward.value.copy(forward); + uRight.value.copy(right); + uUp.value.copy(up); + uAspect.value = camera.aspect; + uTan.value = Math.tan((camera.fov * Math.PI) / 360); + + uTime.value = elapsed; + uCloud.value = weather.cloud; + uWind.value = weather.wind; + uFlash.value = flash; + uStrike.value.copy(strike); + + const tier = studyTier(quality); + const next = tier.cloudSteps; + if (next !== steps) { + steps = next; + uSteps.value = next; + } + + // The pass renders at this share of the frame's resolution: the cloud march is smooth and + // low-frequency next to the coast it backs, so a lower tier costs it little and buys a lot. + const nextScale = tier.cloudScale; + if (nextScale !== scale) { + scale = nextScale; + pass.setResolutionScale(nextScale); + } + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/render/lighting.ts b/packages/create-threenative/templates/rain/src/render/lighting.ts new file mode 100644 index 0000000000..7803dec22d --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/lighting.ts @@ -0,0 +1,39 @@ +// Generated for you. This is ordinary Three.js; tune the storm's light here. +// +// The coast is ray-marched, so nothing here is added to the scene: these are Three's own light +// objects used as the place the storm's lighting is decided, and `setupLighting` hands their +// direction and colour × intensity to the two shaders. No shadow map is involved — the march +// computes its own occlusion — so there is nothing to tune for one. +import { Color, DirectionalLight, HemisphereLight, LinearSRGBColorSpace } from "three"; +import * as clouds from "./clouds-shader.js"; +import * as world from "./world-shader.js"; + +/** The study's colours are linear RGB; these keep them exact instead of converting from sRGB. */ +const linear = (r: number, g: number, b: number): Color => + new Color().setRGB(r, g, b, LinearSRGBColorSpace); + +export interface IStormLighting { + /** The low storm sun behind the deck: its direction lights the clouds and the coast. */ + readonly key: DirectionalLight; + /** The lightning's own light. Its intensity is scaled by the flash every frame in the shader. */ + readonly rim: DirectionalLight; + /** The overcast sky light; the shader weights it `0.45 + 0.55 · n.y`, like a hemisphere. */ + readonly fill: HemisphereLight; +} + +export function setupLighting(): IStormLighting { + const key = new DirectionalLight(linear(0.56, 0.61, 0.67), 0.35); + key.position.set(-0.62, 0.34, -0.71).normalize(); + const rim = new DirectionalLight(linear(0.8 / 1.4, 1.04 / 1.4, 1), 1.4); + const fill = new HemisphereLight(linear(0.64, 0.86, 1.03), linear(0.29, 0.39, 0.46), 0.65); + + world.uSunDir.value.copy(key.position); + clouds.uSunDir.value.copy(key.position); + const sun = key.color.clone().multiplyScalar(key.intensity); + world.uSunColor.value.set(sun.r, sun.g, sun.b); + const flash = rim.color.clone().multiplyScalar(rim.intensity); + world.uFlashLight.value.set(flash.r, flash.g, flash.b); + const sky = fill.color.clone().multiplyScalar(fill.intensity); + world.uFill.value.set(sky.r, sky.g, sky.b); + return { fill, key, rim }; +} diff --git a/packages/create-threenative/templates/rain/src/render/lightning.ts b/packages/create-threenative/templates/rain/src/render/lightning.ts new file mode 100644 index 0000000000..f971a717eb --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/lightning.ts @@ -0,0 +1,360 @@ +// Generated for you. Ordinary Three.js, and one lightning bolt lives in this file. +// +// The reference study drew a strike as CPU geometry: a seeded generator walks a 30 segment channel +// from a random point in the sky down to a ground point, throwing off short side branches as it +// goes, and each segment becomes a quad carrying its own two endpoints. The vertex stage projects +// those two endpoints by hand and widens the quad across the segment in screen space, so the ribbon +// is always camera facing and always the same number of pixels wide however far away it is; the +// fragment stage is one gaussian core inside one wide halo. Nothing is simulated — a strike is +// geometry that already exists, and it is only visible while the caller's flash envelope is above +// zero. +// +// The engine owns the frame loop, the scene and the renderer, so this file owns the bolt geometry, +// its uniforms and its own lifetime — and draws nothing itself. No render loop, no raw GL, no +// engine package import, so the same file runs wherever the game runs. +// Conventions: one world unit is one metre, so a strike lands at a real point on the coast. +import { + AdditiveBlending, + BufferAttribute, + BufferGeometry, + DoubleSide, + DynamicDrawUsage, + Mesh, + type PerspectiveCamera, + type Scene, + Vector3, +} from "three"; +import { + Fn, + abs, + attribute, + cameraFar, + cameraNear, + clamp, + dot, + exp, + float, + frameGroup, + max, + mix, + normalize, + positionGeometry, + screenSize, + select, + uniform, + varying, + vec2, + vec3, + vec4, +} from "three/tsl"; +import { MeshBasicNodeMaterial, type Node } from "three/webgpu"; + +/** + * Room for one bolt's worst case: the study's 30 channel segments, plus every branch it can throw. + * A branch starts at one of the interior channel points and is at most 9 segments long, so this is + * the true ceiling rather than a guess. The buffers are allocated once and reused, so a strike never + * reallocates a geometry and never orphans a GPU buffer. + */ +const MAX_SEGMENTS = 30 + 24 * 9; +const VERTICES_PER_SEGMENT = 6; + +/** The two-triangle corner pattern the study's `boltGeometry` wrote, as three-component positions. */ +const SEGMENT_CORNERS = new Float32Array([-1, 0, 0, 1, 0, 0, -1, 1, 0, -1, 1, 0, 1, 0, 0, 1, 1, 0]); +const CORNERS = new Float32Array(MAX_SEGMENTS * SEGMENT_CORNERS.length); +for (let at = 0; at < CORNERS.length; at += SEGMENT_CORNERS.length) { + CORNERS.set(SEGMENT_CORNERS, at); +} + +/** One segment of ribbon: its own two world endpoints and how bright it burns. */ +export interface IBoltSegment { + readonly a: Vector3; + readonly b: Vector3; + /** 1 down the main channel, decaying towards nothing at the tip of a branch. */ + readonly strength: number; +} + +/** One bolt, as the study's `makeBolt` returned it. */ +export interface IBolt { + readonly segments: IBoltSegment[]; + /** Where the bolt entered the sky, in metres — the caller's point for scene glow and thunder. */ + readonly origin: Vector3; + readonly end: Vector3; +} + +/** The study's `mix`: `a + (b - a) * t`. */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** + * The study's own `makeBolt`, constant for constant. `end` is the point on the ground the bolt + * strikes; `random` is the caller's, so the same source replayed gives the same bolt. + * + * The channel starts in the sky between 145 and 245 metres up, jittered around `end`, and walks down + * in 30 steps whose lateral jitter shrinks as it falls (`1 - f`), so a bolt reads as a strike + * tightening on its target rather than a random walk. Branches come off the middle of the channel + * only (`i > 3 && i < n - 3`), run to one side, and stop at the ground. + */ +export function makeBolt(end: Vector3, random: () => number): IBolt { + const segments: IBoltSegment[] = []; + const y = 145 + random() * 100; + let a = new Vector3(end.x + random() * 42 - 21, y, end.z + random() * 18 - 9); + const origin = a.clone(); + const n = 30; + + for (let i = 1; i <= n; i++) { + const f = i / n; + const b = new Vector3( + lerp(origin.x, end.x, f) + (random() - 0.5) * 13 * (1 - f), + lerp(y, end.y, f), + lerp(origin.z, end.z, f) + (random() - 0.5) * 8 * (1 - f), + ); + segments.push({ a: a.clone(), b: b.clone(), strength: 1 }); + + if (i > 3 && i < n - 3 && random() < 0.39) { + const c = a.clone(); + const dir = random() < 0.5 ? -1 : 1; + const length = 4 + Math.floor(random() * 6); + for (let j = 0; j < length && segments.length < MAX_SEGMENTS; j++) { + const d = new Vector3( + c.x + dir * (3 + random() * 10), + c.y - 3 - random() * 9, + c.z + (random() - 0.5) * 8, + ); + if (d.y < 2) break; + segments.push({ a: c.clone(), b: d.clone(), strength: 0.45 * (1 - j / 12) }); + c.copy(d); + } + } + a = b; + } + return { segments, origin, end: end.clone() }; +} + +export interface IStormLightning { + /** The real mesh, for the engine's culling pass and for a scene walk that counts what is drawn. */ + readonly mesh: Mesh; + /** + * Builds one bolt down to `position` and hands back where it entered the sky, in metres, for the + * scene glow and the thunder delay. `random` is the caller's, so a strike is reproducible. + */ + strike(position: Vector3, random: () => number): Vector3; + /** Current flash envelope. The caller has already gated it on the photosensitivity switch. */ + update(flash: number): void; + dispose(): void; +} + +/** + * The bolt pass: one draw of six-vertex quads, added to `scene`. + * + * The engine's `GPUParticles3D` cannot carry this: the bolt is not a particle system, + * it is CPU geometry with per-segment endpoints handed straight to the vertex stage. This is the + * smallest thing that holds the study's maths — one non-indexed `BufferGeometry` whose attribute + * buffers are allocated once, and a draw range that stays empty until a strike asks for it. + */ +export function createStormLightning(scene: Scene, camera: PerspectiveCamera): IStormLightning { + // The camera block the study passed to the bolt pass, and to the coast, from the same basis. + const uFlash = uniform(0, "float").setGroup(frameGroup); + const uCam = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uForward = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uRight = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uUp = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uAspect = uniform(0, "float").setGroup(frameGroup); + const uTan = uniform(0, "float").setGroup(frameGroup); + + /** + * The study's `proj`. Its x and y are the ordinary perspective divide for this camera's field of + * view, and its z becomes the depth the coast already wrote from the same near and far planes — so + * a bolt behind the headland is hidden by it, which is the one comparison that pass existed for. + */ + function proj(p: Node<"vec3">) { + const v = p.sub(uCam); + const d = dot(v, uForward); + const depth = clamp( + cameraNear + .add(cameraFar) + .sub(cameraNear.mul(cameraFar).mul(2).div(d)) + .div(cameraFar.sub(cameraNear)) + .add(1) + .mul(0.5), + 0, + 1, + ); + return vec4(dot(v, uRight).div(uAspect.mul(uTan)), dot(v, uUp).div(uTan), depth.mul(d), d); + } + + // --- the vertex stage, transcribed from the study's BOLT_VERTEX -------------------------- + + const startNode = attribute<"vec3">("aStart", "vec3"); + const endNode = attribute<"vec3">("aEnd", "vec3"); + const strengthNode = attribute<"float">("aStrength", "float"); + + // `Fn` because the stage assigns into its own clip-space vector twice; outside a function body + // TSL has no stack to emit those assignments into, and drops the ribbon's width. + const ribbon = Fn(() => { + const a = proj(startNode).toVar(); + const b = proj(endNode).toVar(); + // The screen direction from one endpoint to the other, so the quad's width follows the bolt + // instead of the world axes. The epsilon is the study's own, for when the two endpoints land on + // top of each other and there is no direction at all. + const dir = normalize( + b.xy + .div(max(b.w, float(0.1))) + .sub(a.xy.div(max(a.w, float(0.1)))) + .mul(screenSize) + .add(0.00001), + ).toVar(); + const side = vec2(dir.y.negate(), dir.x); + const clip = mix(a, b, positionGeometry.y).toVar(); + // Width in pixels, a little wider for a strong segment: the study's `(7. + 5. * aStrength)`. The + // offset carries the vertex's own `w`, because the quad is authored in clip space. + const across = side + .mul(positionGeometry.x) + .mul(float(7).add(strengthNode.mul(5))) + .div(screenSize) + .mul(clip.w) + .mul(2); + clip.addAssign(vec4(across.x, across.y, float(0), float(0))); + // An endpoint behind the camera has no direction to stretch along; the study threw the quad off + // screen rather than letting it collapse to a dot on the near plane. + clip.assign(select(a.w.lessThan(0.2).or(b.w.lessThan(0.2)), vec4(2, 2, 2, 1), clip)); + return clip; + })(); + + const geometry = new BufferGeometry(); + geometry.setAttribute("position", dynamic(new BufferAttribute(CORNERS, 3))); + geometry.setAttribute( + "aStart", + dynamic(new BufferAttribute(new Float32Array(CORNERS.length), 3)), + ); + geometry.setAttribute("aEnd", dynamic(new BufferAttribute(new Float32Array(CORNERS.length), 3))); + geometry.setAttribute( + "aStrength", + dynamic(new BufferAttribute(new Float32Array(CORNERS.length / 3), 1)), + ); + // No bolt has been struck yet, so there is nothing to draw. + geometry.setDrawRange(0, 0); + + const material = new MeshBasicNodeMaterial(); + // The ribbons are wound by the projection, not by the vertex order, so either face can end up + // front facing; the study's pass drew both. + material.side = DoubleSide; + material.transparent = true; + material.depthWrite = false; + material.blending = AdditiveBlending; + material.vertexNode = ribbon; + + const vAcross = varying(positionGeometry.x, "vAcross"); + const vStrength = varying(strengthNode, "vStrength") as Node<"float">; + // One gaussian core inside one wide halo, the study's `core * 15. + halo * 2.`, added on top of + // whatever the coast already drew — which is what makes a bolt read as light, not as paint. + const core = exp(vAcross.mul(vAcross).negate().mul(750)).toVar(); + const halo = exp(abs(vAcross).negate().mul(7)).mul(0.42).toVar(); + material.fragmentNode = vec4( + vec3(0.55, 0.78, 1.35) + .mul(core.mul(15).add(halo.mul(2))) + .mul(vStrength.mul(uFlash)), + 1, + ); + + const mesh = new Mesh(geometry, material); + // The buffers hold every possible bolt's worth of quads parked at the origin, so the geometry's + // own bounds say nothing about where a strike actually is. + mesh.frustumCulled = false; + mesh.renderOrder = 1; + scene.add(mesh); + + const forward = new Vector3(); + const right = new Vector3(); + const up = new Vector3(); + + return { + mesh, + strike(position, random): Vector3 { + if (!isFiniteVector(position)) { + throw new Error( + `strike position must be a finite point in metres, got ${describe(position)}.`, + ); + } + if (typeof random !== "function") { + throw new Error(`strike needs the caller's random source, got ${typeof random}.`); + } + // The study's `rng` always returns 0..1; a source that does not is a bug in the caller, and it + // would otherwise turn into a bolt of NaN geometry that renders as nothing. + const bolt = makeBolt(position, () => { + const value = random(); + if (!Number.isFinite(value)) { + throw new Error("strike random source returned a non-finite number."); + } + return value; + }); + write(bolt); + return bolt.origin; + }, + update(flash: number): void { + if (!Number.isFinite(flash)) { + throw new Error(`flash must be a finite envelope value, got ${flash}.`); + } + // The same basis and field of view the coast is drawn with, so a bolt and the headland behind + // it agree on where the horizon is. + camera.getWorldDirection(forward); + right.set(1, 0, 0).applyQuaternion(camera.quaternion); + up.set(0, 1, 0).applyQuaternion(camera.quaternion); + uCam.value.copy(camera.position); + uForward.value.copy(forward); + uRight.value.copy(right); + uUp.value.copy(up); + uAspect.value = camera.aspect; + uTan.value = Math.tan((camera.fov * Math.PI) / 360); + uFlash.value = flash; + // Below a hair of flash there is nothing to see, and the study did not issue the draw. + mesh.visible = flash > 0.003 && geometry.drawRange.count > 0; + }, + dispose(): void { + scene.remove(mesh); + geometry.dispose(); + material.dispose(); + }, + }; + + /** Copies a bolt into the attribute buffers and opens the draw range to it. */ + function write(bolt: IBolt): void { + const starts = geometry.getAttribute("aStart") as BufferAttribute; + const ends = geometry.getAttribute("aEnd") as BufferAttribute; + const strengths = geometry.getAttribute("aStrength") as BufferAttribute; + for (let s = 0; s < bolt.segments.length; s++) { + const segment = bolt.segments[s]; + if (segment === undefined) continue; + for (let v = 0; v < VERTICES_PER_SEGMENT; v++) { + const at = (s * VERTICES_PER_SEGMENT + v) * 3; + starts.array[at] = segment.a.x; + starts.array[at + 1] = segment.a.y; + starts.array[at + 2] = segment.a.z; + ends.array[at] = segment.b.x; + ends.array[at + 1] = segment.b.y; + ends.array[at + 2] = segment.b.z; + strengths.array[s * VERTICES_PER_SEGMENT + v] = segment.strength; + } + } + starts.needsUpdate = true; + ends.needsUpdate = true; + strengths.needsUpdate = true; + geometry.setDrawRange(0, bolt.segments.length * VERTICES_PER_SEGMENT); + } +} + +/** The attribute buffers are rewritten on every strike, so they are uploaded, not orphaned. */ +function dynamic(attributeBuffer: BufferAttribute): BufferAttribute { + attributeBuffer.setUsage(DynamicDrawUsage); + return attributeBuffer; +} + +function isFiniteVector(v: Vector3): boolean { + return ( + v instanceof Vector3 && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z) + ); +} + +function describe(v: Vector3): string { + return isFiniteVector(v) ? `${v.x}, ${v.y}, ${v.z}` : String(v); +} diff --git a/packages/create-threenative/templates/rain/src/render/loading.ts b/packages/create-threenative/templates/rain/src/render/loading.ts new file mode 100644 index 0000000000..193fa0719e --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/loading.ts @@ -0,0 +1,327 @@ +import { + type Camera, + CanvasTexture, + ClampToEdgeWrapping, + Mesh, + MeshBasicMaterial, + type OrthographicCamera, + PlaneGeometry, + type Scene, + type Texture, +} from "three"; +import { palette } from "./palette.js"; + +/* BEGIN THREENATIVE LOADING APPEARANCE */ +/** The storm's own night behind the bar; all behavior is authored here. */ +export const loading = { + backgroundColor: palette.night, + backgroundImage: undefined as string | undefined, + enabled: true, + fillImage: undefined as string | undefined, + logoImage: undefined as string | undefined, + progressColor: palette.accent, + showStatus: false, + trackColor: palette.zenith, + // The study's one-pixel line through the middle of the screen; `src/ui/LoadingOverlay.tsx` + // writes the title and the stage above it and the photosensitivity note below it. + bar: { anchorX: 0.5, anchorY: 0.5, height: 1, maxWidth: 290, minWidth: 1, width: 0.64 }, +} as const; +/* END THREENATIVE LOADING APPEARANCE */ + +interface ILoadingHost { + readonly assets?: { texture(path: string): Promise }; + readonly camera: Camera; + readonly canvasLayer: { + readonly camera: OrthographicCamera; + readonly scene: Scene; + opaque: boolean; + }; + readonly renderer: { compileAsync(scene: Scene, camera: Camera): Promise }; + readonly scene: Scene; + readonly startup: { + readonly progress: number; + readonly warmup?: { readonly status: "complete" | "incomplete" | "unavailable" }; + whenReady(): Promise; + }; + readonly viewport?: { + readonly safeArea: { height: number; width: number; x: number; y: number }; + }; +} + +interface ILoadingController { + update(): void; + finish(): void; +} + +function noOp(layer: ILoadingHost["canvasLayer"]): ILoadingController { + layer.opaque = false; + return { finish: () => undefined, update: () => undefined }; +} + +function meshFor( + layer: ILoadingHost["canvasLayer"], + material: MeshBasicMaterial, + renderOrder: number, +): Mesh { + const mesh = new Mesh(new PlaneGeometry(1, 1), material); + mesh.frustumCulled = false; + mesh.renderOrder = renderOrder; + layer.scene.add(mesh); + return mesh; +} + +function imageAspect(texture: Texture | null | undefined): number { + const image = texture?.image as { height?: number; width?: number } | undefined; + const width = image?.width ?? 1; + const height = image?.height ?? 1; + return width > 0 && height > 0 ? width / height : 1; +} + +function configureTexture(texture: Texture): void { + texture.wrapS = ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + texture.needsUpdate = true; +} + +function setFillUv(geometry: PlaneGeometry, progress: number, base: readonly number[]): void { + const uv = geometry.getAttribute("uv"); + for (let index = 0; index < uv.count; index += 1) uv.setX(index, (base[index] ?? 0) * progress); + uv.needsUpdate = true; +} + +function coverUv( + geometry: PlaneGeometry, + texture: Texture, + width: number, + height: number, + baseU: readonly number[], + baseV: readonly number[], +): void { + const imageRatio = imageAspect(texture); + const boxRatio = width / Math.max(1, height); + const visibleU = imageRatio > boxRatio ? boxRatio / imageRatio : 1; + const visibleV = imageRatio < boxRatio ? imageRatio / boxRatio : 1; + const offsetU = (1 - visibleU) / 2; + const offsetV = (1 - visibleV) / 2; + const uv = geometry.getAttribute("uv"); + for (let index = 0; index < uv.count; index += 1) { + uv.setX(index, offsetU + (baseU[index] ?? 0) * visibleU); + uv.setY(index, offsetV + (baseV[index] ?? 0) * visibleV); + } + uv.needsUpdate = true; +} + +function statusMesh( + layer: ILoadingHost["canvasLayer"], +): + | { mesh: Mesh; texture: Texture; update(value: number): void } + | undefined { + if (!loading.showStatus || typeof document === "undefined") return undefined; + const canvas = document.createElement("canvas"); + canvas.width = 180; + canvas.height = 48; + const context = canvas.getContext("2d"); + if (context === null) return undefined; + const texture = new CanvasTexture(canvas); + const mesh = meshFor( + layer, + new MeshBasicMaterial({ depthTest: false, depthWrite: false, map: texture, transparent: true }), + 4, + ); + const update = (value: number): void => { + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#ffffff"; + context.font = "700 20px monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(`${Math.round(value * 100)}%`, canvas.width / 2, canvas.height / 2); + texture.needsUpdate = true; + }; + update(0); + return { mesh, texture, update }; +} + +export function createLoadingScreen(host: ILoadingHost): ILoadingController { + const layer = host.canvasLayer; + if (!loading.enabled) return noOp(layer); + const camera = layer.camera; + const backdrop = meshFor( + layer, + new MeshBasicMaterial({ color: loading.backgroundColor, depthTest: false, depthWrite: false }), + 0, + ); + const track = meshFor( + layer, + new MeshBasicMaterial({ color: loading.trackColor, depthTest: false, depthWrite: false }), + 1, + ); + const fill = meshFor( + layer, + new MeshBasicMaterial({ color: loading.progressColor, depthTest: false, depthWrite: false }), + 2, + ); + const fillBaseU = Array.from({ length: fill.geometry.getAttribute("uv").count }, (_, index) => + fill.geometry.getAttribute("uv").getX(index), + ); + const fillBaseV = Array.from({ length: fill.geometry.getAttribute("uv").count }, (_, index) => + fill.geometry.getAttribute("uv").getY(index), + ); + const logo = + loading.logoImage === undefined + ? undefined + : meshFor( + layer, + new MeshBasicMaterial({ + color: 0xffffff, + depthTest: false, + depthWrite: false, + transparent: true, + }), + 3, + ); + const status = statusMesh(layer); + const parts = [ + backdrop, + track, + fill, + ...(logo === undefined ? [] : [logo]), + ...(status === undefined ? [] : [status.mesh]), + ]; + const ownedTextures = new Set(status === undefined ? [] : [status.texture]); + let width = 1; + let height = 1; + let barWidth = 1; + let barHeight: number = loading.bar.height; + let barX = 0; + let barY = 0; + let progress = 0; + let done = false; + let backdropTexture: Texture | undefined; + + const layout = (): void => { + width = Math.max(1, camera.right - camera.left); + height = Math.max(1, camera.top - camera.bottom); + const safe = host.viewport?.safeArea ?? { height, width, x: 0, y: 0 }; + const safeWidth = Math.max(1, Math.min(width, safe.width)); + const safeHeight = Math.max(1, Math.min(height, safe.height)); + const safeX = Math.max(0, Math.min(width - safeWidth, safe.x)); + const safeY = Math.max(0, Math.min(height - safeHeight, safe.y)); + barWidth = Math.max(2, Math.min(loading.bar.maxWidth, safeWidth * loading.bar.width)); + barHeight = Math.max(2, Math.min(loading.bar.height, safeHeight * 0.05)); + barX = safeX + safeWidth * loading.bar.anchorX; + barY = safeY + safeHeight * loading.bar.anchorY; + const worldX = (screenX: number): number => camera.left + screenX; + const worldY = (screenY: number): number => camera.top - screenY; + const visibleWidth = Math.max(loading.bar.minWidth, barWidth * progress); + backdrop.scale.set(width, height, 1); + backdrop.position.set(worldX(width / 2), worldY(height / 2), 0); + track.scale.set(barWidth, barHeight, 1); + track.position.set(worldX(barX), worldY(barY), 0); + fill.scale.set(visibleWidth, barHeight, 1); + fill.position.set(worldX(barX - barWidth / 2 + visibleWidth / 2), worldY(barY), 0); + if (logo?.visible) { + const logoWidth = Math.min(safeWidth * 0.42, 280); + logo.scale.set( + logoWidth, + Math.min(logoWidth / imageAspect(logo.material.map), safeHeight * 0.22), + 1, + ); + logo.position.set(worldX(barX), worldY(Math.max(safeY, barY - safeHeight * 0.2)), 0); + } + if (status !== undefined) { + status.mesh.scale.set(Math.min(180, safeWidth * 0.32), 48, 1); + status.mesh.position.set(worldX(barX), worldY(Math.min(height, barY + barHeight * 2.5)), 0); + } + if (backdropTexture !== undefined) + coverUv(backdrop.geometry, backdropTexture, width, height, fillBaseU, fillBaseV); + }; + + const updateProgress = (value: number): void => { + const bounded = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; + // A missing or incomplete backend observation cannot prove that all first-use work is done. + // Keep the bar visibly indeterminate until readiness removes the cover; never turn unknown + // coverage into a false 100% claim. + const observationUnknown = + host.startup.warmup !== undefined && host.startup.warmup.status !== "complete"; + progress = observationUnknown ? Math.min(0.9, bounded) : bounded; + setFillUv(fill.geometry, progress, fillBaseU); + status?.update(progress); + layout(); + }; + + const attachTexture = (mesh: Mesh, texture: Texture): void => { + if (done) return; + configureTexture(texture); + mesh.material.map = texture; + mesh.material.color.set(0xffffff); + mesh.material.needsUpdate = true; + ownedTextures.add(texture); + if (mesh === backdrop) backdropTexture = texture; + layout(); + updateProgress(progress); + }; + + const load = (source: string | undefined, mesh: Mesh): void => { + if (source === undefined || host.assets === undefined) return; + void host.assets + .texture(source) + .then((texture) => attachTexture(mesh, texture)) + .catch(() => undefined); + }; + load(loading.backgroundImage, backdrop); + load(loading.fillImage, fill); + if (logo !== undefined && loading.logoImage !== undefined && host.assets !== undefined) { + void host.assets + .texture(loading.logoImage) + .then((texture) => { + if (done) return; + configureTexture(texture); + logo.material.map = texture; + logo.material.needsUpdate = true; + logo.visible = true; + ownedTextures.add(texture); + layout(); + }) + .catch(() => undefined); + } + + layer.opaque = true; + layout(); + updateProgress(0); + + const finish = (): void => { + if (done) return; + done = true; + for (const mesh of parts) { + mesh.removeFromParent(); + mesh.geometry.dispose(); + mesh.material.dispose(); + } + for (const texture of ownedTextures) texture.dispose(); + layer.opaque = false; + }; + + void (async () => { + await host.startup.whenReady(); + if (done) return; + // Prewarm the world's shaders, but do not wait for them here. `whenReady()` already means the + // world is safe to show, and compiling a lit, post-processed scene takes far longer than the + // launch window: hold the screen for it and a playtest — which advances a fixed step as fast + // as the machine allows — runs an entire scenario behind the launch screen, so every capture + // photographs the loading bar instead of the game. The prewarm still happens; it no longer + // decides when the player sees the world. + try { + void host.renderer.compileAsync(host.scene, host.camera).catch(() => undefined); + } catch { + // A renderer without compileAsync is not a reason to keep the world hidden. + } + finish(); + })(); + + return { + finish, + update(): void { + if (!done) updateProgress(host.startup.progress); + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/render/materials.ts b/packages/create-threenative/templates/rain/src/render/materials.ts new file mode 100644 index 0000000000..e97e692a38 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/materials.ts @@ -0,0 +1,19 @@ +// Generated for you. This file owns the coast's surface decisions that are worth a name. +// +// The coast is one ray-marched shader, so its materials are colours the march reads rather than +// Three material objects. The ones a storm is recognised by are here; the rest of the albedos +// (moss, conifers, asphalt, paint) sit in `tools/tempest-world.frag` beside the shapes they colour. +import { Color } from "three"; +import { palette } from "./palette.js"; +import * as world from "./world-shader.js"; + +/** How bright the lamp heads burn: the study's own HDR colour, far above 1 so they bloom. */ +const LAMP_GLOW = { b: 0.72, g: 2.75, r: 6 } as const; + +export function setupMaterials(): void { + const lamp = new Color(palette.lamp); + world.uLampLight.value.set(lamp.r, lamp.g, lamp.b); + world.uLampGlow.value.set(LAMP_GLOW.r, LAMP_GLOW.g, LAMP_GLOW.b); + const water = new Color(palette.water); + world.uWater.value.set(water.r, water.g, water.b); +} diff --git a/packages/create-threenative/templates/rain/src/render/noise-volume.ts b/packages/create-threenative/templates/rain/src/render/noise-volume.ts new file mode 100644 index 0000000000..decd04a291 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/noise-volume.ts @@ -0,0 +1,159 @@ +// Generated for you. Ordinary Three.js, and this is the storm's shape: the 64³ RGBA volume the +// cloud shader samples three times per step. Appearance lives here and in `tools/tempest-clouds.frag` +// — change the seed or the octave weights and every cloud in the storm changes with them. +import { + Data3DTexture, + LinearFilter, + NoColorSpace, + RGBAFormat, + RepeatWrapping, + UnsignedByteType, +} from "three"; + +/** Voxels per side. 64³ of RGBA is 1 MiB, and the source used exactly this. */ +export const NOISE_SIZE = 64; + +/** + * The source's generator seed, as `const r=rng(13291)` inside `noiseVolume`. Written here rather + * than taken from a caller because the sequence is this volume's identity: a different generator + * makes a different storm, and the engine's seeded RNG is an LCG, not this mulberry32. + */ +export const SEED = 13291; + +/** `function rng(seed=1)` verbatim from the source, so a given seed draws the same stream it did. */ +function mulberry32(seed: number): () => number { + let a = seed | 0; + return () => { + a += 0x6d2b79f5; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export interface INoiseVolume { + /** RGBA voxels, x fastest then y then z. One byte each. */ + readonly data: Uint8Array; + readonly texture: Data3DTexture; +} + +interface ILattice { + readonly n: number; + readonly values: Float32Array; +} + +/** + * The source's four octave lattices — 4³, 8³, 16³, 32³ — each one random float per voxel, all drawn + * from the same stream in this order. The order is the contract: it is what makes a given seed + * reproduce a given storm, so it is written out rather than mapped over a list. + */ +function fillLattices(random: () => number): [ILattice, ILattice, ILattice, ILattice] { + const lattice = (n: number): ILattice => { + const values = new Float32Array(n * n * n); + for (let index = 0; index < values.length; index += 1) values[index] = random(); + return { n, values }; + }; + return [lattice(4), lattice(8), lattice(16), lattice(32)]; +} + +/** + * Trilinear value noise on one lattice, in `[0, 1)³`. Indices wrap at every face, which is what + * makes the volume tile, and the fractional part is smoothed by `f²(3 − 2f)` so the lattice edges + * do not show as creases in the cloud. + */ +function sampleLattice(lattice: ILattice, x: number, y: number, z: number): number { + const { n, values } = lattice; + const sx = x * n; + const sy = y * n; + const sz = z * n; + const ix = Math.floor(sx); + const iy = Math.floor(sy); + const iz = Math.floor(sz); + // The index is in range by construction — every component is folded into `[0, n)` above — so the + // cast is about the type checker, not about a value that might be missing. + const at = (a: number, b: number, c: number): number => + values[((c % n) * n + (b % n)) * n + (a % n)] as number; + const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + const blend = (fx: number, fy: number, fz: number): number => { + const smooth = (f: number): number => f * f * (3 - 2 * f); + const tx = smooth(fx); + const ty = smooth(fy); + const tz = smooth(fz); + return lerp( + lerp( + lerp(at(ix, iy, iz), at(ix + 1, iy, iz), tx), + lerp(at(ix, iy + 1, iz), at(ix + 1, iy + 1, iz), tx), + ty, + ), + lerp( + lerp(at(ix, iy, iz + 1), at(ix + 1, iy, iz + 1), tx), + lerp(at(ix, iy + 1, iz + 1), at(ix + 1, iy + 1, iz + 1), tx), + ty, + ), + tz, + ); + }; + return blend(sx - ix, sy - iy, sz - iz); +} + +/** + * Builds the volume the cloud shader samples. The four channels carry four different octave + * combinations, which is why the shader reads `broad` from red, its billow detail from blue and the + * rest as it goes: one texture, four scales, and the shader picks which mix it wants. + * + * Fails closed. The channel weights are the shader's contract — red is the broad shape the coverage + * threshold is measured against — so a volume of the wrong length is a bug to stop on rather than a + * texture to upload and watch read as transparent. + */ +export function createNoiseVolume(): INoiseVolume { + const random = mulberry32(SEED); + const [coarseLattice, mediumLattice, fineLattice, finestLattice] = fillLattices(random); + const data = new Uint8Array(NOISE_SIZE * NOISE_SIZE * NOISE_SIZE * 4); + const byte = (value: number): number => { + const scaled = Math.round(255 * value); + if (!(scaled >= 0 && scaled <= 255)) { + throw new Error(`TN_NOISE_CHANNEL: ${value} does not fit a byte`); + } + return scaled; + }; + let cursor = 0; + for (let z = 0; z < NOISE_SIZE; z += 1) { + for (let y = 0; y < NOISE_SIZE; y += 1) { + for (let x = 0; x < NOISE_SIZE; x += 1) { + const px = x / NOISE_SIZE; + const py = y / NOISE_SIZE; + const pz = z / NOISE_SIZE; + const coarse = sampleLattice(coarseLattice, px, py, pz); + const medium = sampleLattice(mediumLattice, px, py, pz); + const fine = sampleLattice(fineLattice, px, py, pz); + const finest = sampleLattice(finestLattice, px, py, pz); + data[cursor] = byte(coarse * 0.74 + medium * 0.19 + fine * 0.07); + data[cursor + 1] = byte(medium); + data[cursor + 2] = byte(fine * 0.75 + finest * 0.25); + data[cursor + 3] = byte(finest); + cursor += 4; + } + } + } + if (cursor !== data.length) { + throw new Error(`TN_NOISE_LENGTH: wrote ${cursor} bytes into a ${data.length}-byte volume`); + } + + const texture = new Data3DTexture(data, NOISE_SIZE, NOISE_SIZE, NOISE_SIZE); + texture.format = RGBAFormat; + texture.type = UnsignedByteType; + texture.minFilter = LinearFilter; + texture.magFilter = LinearFilter; + // Every face repeats: the shader drifts through the volume forever, and a clamped edge would put + // a hard plane across the sky where the drift ran out of volume. + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.wrapR = RepeatWrapping; + texture.unpackAlignment = 1; + // The shader mixes the sampled values into its own lighting rather than reading them as colour, so + // there is no transfer function to undo. + texture.colorSpace = NoColorSpace; + texture.needsUpdate = true; + return { data, texture }; +} diff --git a/packages/create-threenative/templates/rain/src/render/palette.ts b/packages/create-threenative/templates/rain/src/render/palette.ts new file mode 100644 index 0000000000..8202c4d83a --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/palette.ts @@ -0,0 +1,18 @@ +// Generated for you. These six roles are the storm's editable colour vocabulary. +// +// Hex is sRGB, as a colour picker shows it; `sky.ts` and `materials.ts` hand the shaders the linear +// value Three converts each to, which is the reference study's own number to within 0.002. +export const palette = { + /** Haze at the horizon: the fog the coast fades into, and the lower half of the haze. */ + horizon: 0x6c808b, + /** Haze looking up, where it thins towards the deck. */ + zenith: 0x4d5f73, + /** Sodium street lamps: the light they cast on the wet road. */ + lamp: 0xffba65, + /** Deep sea water, before the sky is reflected in it. */ + water: 0x1e3237, + /** The night behind the loading screen. */ + night: 0x101c24, + /** The one accent: the interface mint, also the loading bar. */ + accent: 0xb6d9cc, +} as const; diff --git a/packages/create-threenative/templates/rain/src/render/post-shader.ts b/packages/create-threenative/templates/rain/src/render/post-shader.ts new file mode 100644 index 0000000000..75a33023d1 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/post-shader.ts @@ -0,0 +1,178 @@ +// Transcribed once by Three.js's own GLSL to TSL transpiler from tools/tempest-post.frag, and maintained +// here by hand since: `tools/generate-shaders.mjs` regenerates only the coast and cloud shaders. +// Keep the .frag in step when you change this file. +// +// The demo's filmic half in one pass: twelve animated refractive beads near the lens edges, a +// five-tap luma-edge antialias over the warped coordinate, the bloom added, the weather exposure, +// the ACES fit, the cool-shadow/warm-highlight grade, the vignette, gamma 1/2.2 and the dither. +// Nothing here is a framework default; every constant is the demo's. +import { DataTexture, Vector2, Vector3, Vector4 } from "three"; +import { + Continue, + Fn, + If, + Loop, + abs, + add, + clamp, + depth, + div, + dot, + exp, + float, + floor, + fract, + frameGroup, + int, + length, + max, + min, + mul, + pow, + screenCoordinate, + select, + sin, + smoothstep, + sub, + texture, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl"; +import type { Node, TextureNode } from "three/webgpu"; + +// Uniforms the shader reads. Values are assigned by src/render/world.ts and src/render/clouds.ts, +// which own the camera basis and the weather; the generated module only holds the nodes. +const uScenePlaceholder = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); +uScenePlaceholder.needsUpdate = true; +export let uScene: TextureNode = texture(uScenePlaceholder).setGroup(frameGroup); +export function setScene(value: TextureNode): void { + uScene = value; +} +const uBloomPlaceholder = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); +uBloomPlaceholder.needsUpdate = true; +export let uBloom: TextureNode = texture(uBloomPlaceholder).setGroup(frameGroup); +export function setBloom(value: TextureNode): void { + uBloom = value; +} +export const uRes = uniform(new Vector2(), "vec2").setGroup(frameGroup); +export const uTime = uniform(0, "float").setGroup(frameGroup); +export const uExposure = uniform(0, "float").setGroup(frameGroup); +export const uRain = uniform(0, "float").setGroup(frameGroup); +export const uLens = uniform(0, "float").setGroup(frameGroup); + +// Three.js Transpiler r185 + +// Polyfills + +const gl_FragCoord = vec3(screenCoordinate.x, screenCoordinate.y.oneMinus(), depth); + +export const hash = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + return fract(sin(dot(p, vec2(127.1, 311.7))).mul(43758.5453123)); +}); + +export const aces = /*@__PURE__*/ Fn(([x]: [Node<"vec3">]) => { + return clamp(x.mul(mul(2.51, x).add(0.03)).div(x.mul(mul(2.43, x).add(0.59)).add(0.14)), 0, 1); +}); + +export const antialias = /*@__PURE__*/ Fn(([uv]: [Node<"vec2">]) => { + const px = div(1, uRes).toVar(); + const m = uScene.sample(uv).rgb.toVar(); + const nw = uScene.sample(uv.add(vec2(-1, -1).mul(px))).rgb.toVar(); + const ne = uScene.sample(uv.add(vec2(1, -1).mul(px))).rgb.toVar(); + const sw = uScene.sample(uv.add(vec2(-1, 1).mul(px))).rgb.toVar(); + const se = uScene.sample(uv.add(vec2(1, 1).mul(px))).rgb.toVar(); + const lum = vec3(0.299, 0.587, 0.114).toVar(); + const l0 = dot(m, lum).toVar(); + const l1 = dot(nw, lum).toVar(); + const l2 = dot(ne, lum).toVar(); + const l3 = dot(sw, lum).toVar(); + const l4 = dot(se, lum).toVar(); + const dir = vec2(l1.add(l2).sub(l3.add(l4)).negate(), l1.add(l3).sub(l2.add(l4))).toVar(); + const reduce = max(l1.add(l2).add(l3).add(l4).mul(0.03125), 0.0078125).toVar(); + const rcp = div(1, min(abs(dir.x), abs(dir.y)).add(reduce)).toVar(); + dir.assign(clamp(dir.mul(rcp), vec2(-6), vec2(6)).mul(px)); + const a = mul( + 0.5, + uScene.sample(uv.add(dir.mul(-0.166667))).rgb.add(uScene.sample(uv.add(dir.mul(0.166667))).rgb), + ).toVar(); + const b = a + .mul(0.5) + .add( + mul( + 0.25, + uScene.sample(uv.sub(dir.mul(0.5))).rgb.add(uScene.sample(uv.add(dir.mul(0.5))).rgb), + ), + ) + .toVar(); + const lb = dot(b, lum).toVar(); + const lo = min(l0, min(min(l1, l2), min(l3, l4))).toVar(); + const hi = max(l0, max(max(l1, l2), max(l3, l4))).toVar(); + + return select(lb.lessThan(lo).or(lb.greaterThan(hi)), a, b); +}); + +export const tempestPost = /*@__PURE__*/ Fn(([vUv]: [Node<"vec2">]) => { + const uv = vUv.toVar(); + const warp = vec2(0).toVar(); + const dropHighlight = float(0).toVar(); + + // Sparse refractive beads near the lens edges, not a full-screen blur. + + If(uLens.greaterThan(0.5), () => { + Loop({ start: int(0), end: 12 }, ({ i }) => { + const f = float(i).toVar(); + const p = vec2(hash(vec2(f, 3)), hash(vec2(f, 7))).toVar(); + p.y.assign(fract(p.y.sub(uTime.mul(add(0.008, hash(vec2(f, 11)).mul(0.01)))))); + + If(p.x.greaterThan(0.18).and(p.x.lessThan(0.82)), () => { + Continue(); + }); + + const d = uv + .sub(p) + .mul(vec2(uRes.x.div(uRes.y), 1)) + .toVar(); + const rad = add(0.008, hash(vec2(f, 9)).mul(0.006)).toVar(); + const dist = length(d.mul(vec2(1, 0.75))).toVar(); + const mask = sub(1, smoothstep(rad.mul(0.7), rad, dist)).toVar(); + warp.addAssign(d.mul(mask).mul(uRain).mul(0.5)); + dropHighlight.addAssign( + pow(max(0, sub(1, abs(dist.sub(rad.mul(0.8))).div(rad.mul(0.1)))), 4) + .mul(0.017) + .mul(uRain), + ); + }); + }); + + const col = antialias(clamp(uv.add(warp), 0, 1)).toVar(); + const bloom = vec3(0).toVar(); + const wt = float(0).toVar(); + + Loop({ start: int(-3), end: int(3), condition: "<=" }, ({ i }) => { + const w = exp(float(i.mul(i)).negate().mul(0.26)).toVar(); + bloom.addAssign(uBloom.sample(uv.add(vec2(0, float(i).mul(3)).div(uRes))).rgb.mul(w)); + wt.addAssign(w); + }); + + col.addAssign(bloom.div(wt).mul(0.22)); + col.mulAssign(uExposure); + col.assign(aces(col)); + + // Cool shadow / warm practical-light grade, retaining neutral highlights. + + col.assign(pow(max(col, vec3(0)), vec3(1.015, 1, 0.975))); + const vign = sub(1, mul(0.28, pow(length(uv.sub(0.5).mul(vec2(1.05, 1))), 1.65))).toVar(); + col.mulAssign(vign); + col.assign(pow(col, vec3(1 / 2.2))); + col.addAssign( + hash(gl_FragCoord.xy.add(floor(uTime.mul(24)))) + .sub(0.5) + .mul(0.005) + .add(dropHighlight), + ); + + return vec4(clamp(col, 0, 1), 1); +}); diff --git a/packages/create-threenative/templates/rain/src/render/postprocessing.ts b/packages/create-threenative/templates/rain/src/render/postprocessing.ts new file mode 100644 index 0000000000..e2b0875a8c --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/postprocessing.ts @@ -0,0 +1,226 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. +// +// This is the storm's filmic half — the demo's own `BLOOM` and `POST` passes, ported rather than +// approximated. `bloom-shader.ts` and `post-shader.ts` hold the maths, transpiled from +// `tools/tempest-bloom.frag` and `tools/tempest-post.frag`; this file owns only where those two +// runs and what feeds them. +// +// Every engine stage that would double a job the demo's own pass already does exactly is off: +// `quality.ts` decides those, and the built-in bloom is a mip pyramid rather than the demo's 5x5 +// quarter-resolution threshold kernel. The engine's tone curve and sRGB encode are applied by the +// render pipeline *after* the graph, so the demo's ACES fit, its grade and its `pow(1/2.2)` would +// otherwise be applied twice — hence `NoToneMapping` and the working colour space below, which is +// what the source's own Three.js driver sets for the same reason. +// +// To change how the storm looks, edit the two `.frag` sources and run the generator. To change how +// much of it there is, edit `quality.ts`. +import { + type Camera, + DoubleSide, + LinearSRGBColorSpace, + Mesh, + NoToneMapping, + OrthographicCamera, + PlaneGeometry, + Scene, +} from "three"; +import { convertToTexture, positionGeometry, uv, vec4 } from "three/tsl"; +import { MeshBasicNodeMaterial, PassNode } from "three/webgpu"; +import { setScene as setBloomScene, tempestBloom, uRes as uBloomRes } from "./bloom-shader.js"; +import { + setBloom, + setScene, + tempestPost, + uExposure, + uLens, + uRain, + uRes, + uTime, +} from "./post-shader.js"; +import { type QualityTier, qualityPreset, resolveQualityTier } from "./quality.js"; +import type { ChainStage, OutputRenderer } from "./worldEnvironment.js"; +import { WorldEnvironment } from "./worldEnvironment.js"; + +/** + * The bloom target's resolution, as a fraction of the frame: the demo's own + * `round(width / 4)`, and the reason that pass is five taps rather than a mip pyramid. + */ +const BLOOM_SCALE = 0.25; + +/** What one frame's post pass needs from the game. Every value is the demo's, unconverted. */ +export interface IStormPostFrame { + /** Seconds since the storm began: the bead drift and the dither's frame index. */ + readonly elapsed: number; + /** Weather exposure, 0…n. The demo applies it after the bloom add, and so does this. */ + readonly exposure: number; + /** Weather rain, 0…1. Bead refraction and highlight scale with it. */ + readonly rain: number; + /** The demo's `lens` toggle: the refractive beads on or off. */ + readonly droplets: boolean; +} + +/** + * The two numbers `uRes` needs, as the renderer reports them. Read through the renderer's own + * `surface()` rather than the canvas, so a resolution scale or a device pixel ratio is already + * accounted for. Required rather than probed: `ctx.renderer` declares it, so a renderer without it + * is a type error here instead of a frame that is wrong. + */ +interface IRendererSurface { + surface(): { readonly drawingBufferWidth: number; readonly drawingBufferHeight: number }; +} + +/** What a scene keeps. `update` is per frame; `dispose` releases everything built here. */ +export interface IStormPost { + /** The engine chain's own report: every stage as applied or refused, with a reason. */ + readonly applied: { + readonly dropped: readonly { name: string; reason: string }[]; + readonly stages: readonly string[]; + dispose?: () => void; + }; + update(frame: IStormPostFrame): void; + dispose(): void; +} + +/** + * Installs the storm's post chain and returns its handle. + * + * `renderer`, `scene` and `camera` are the three the scene passes to everything else in + * `src/render/`. Nothing here reads the DOM, runs a second render loop, or owns a render target: + * the engine's render chain owns the pipeline and the scene pass, and the bloom threshold pass is + * a `PassNode` over a private one-quad scene, so its quarter-resolution target is sized and + * updated by the renderer that draws the frame. + */ +export function setupPost( + renderer: OutputRenderer & IRendererSurface, + scene: Scene, + camera: Camera, + environment: { + /** The renderer named a software adapter; this game's answer is its `low` tier. */ + software?: boolean; + /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ + tier?: QualityTier; + mobile?: boolean; + } = {}, +): IStormPost { + const tier = resolveQualityTier({ + mobile: environment.mobile, + software: environment.software, + tier: environment.tier, + }); + const source = environment.tier === undefined ? "platform" : "override"; + console.info( + `TN_QUALITY_TIER ${tier} mobile=${environment.mobile === true} software=${ + environment.software === true + } source=${source}`, + ); + + // The private bloom scene: one screen quad and a camera whose projection nothing reads, which is + // what the demo's full-screen triangle was. The frame's own scene is never added here — the + // final image is an output node, not a quad in the root scene, so nothing captures itself. + const bloomScene = new Scene(); + const bloomCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1); + const bloomGeometry = new PlaneGeometry(2, 2); + const bloomMaterial = new MeshBasicNodeMaterial(); + bloomMaterial.side = DoubleSide; + bloomMaterial.vertexNode = vec4(positionGeometry.xy, 0, 1); + const bloomQuad = new Mesh(bloomGeometry, bloomMaterial); + bloomQuad.frustumCulled = false; + bloomScene.add(bloomQuad); + const bloomPass = new PassNode(PassNode.COLOR, bloomScene, bloomCamera); + bloomPass.setResolutionScale(BLOOM_SCALE); + + let applied: IStormPost["applied"] = { dropped: [], stages: [] }; + let disposed = false; + const world = new WorldEnvironment({ + ...qualityPreset(tier), + authoredStageNames: ["stormPost"], + authoredStages: () => [stormStage()], + }); + applied = world.apply(renderer, scene, camera); + if (!applied.stages.includes("stormPost")) { + throw new Error( + `setupPost: the engine chain did not run stormPost (${applied.dropped + .map((entry) => `${entry.name}: ${entry.reason}`) + .join("; ")}). The storm's look would silently be the engine's default.`, + ); + } + + // The demo grades and gamma-encodes inside `POST`. The pipeline would tone map and encode again, + // so both are switched off here — the source's own driver does the same two lines. + const raw = renderer.raw as { toneMapping: number; outputColorSpace: string } | undefined; + if (raw === undefined) + throw new Error("setupPost: the renderer has no raw renderer to un-tone-map."); + raw.toneMapping = NoToneMapping; + raw.outputColorSpace = LinearSRGBColorSpace; + + return { + get applied(): IStormPost["applied"] { + return applied; + }, + update({ elapsed, exposure, rain, droplets }): void { + if (disposed) throw new Error("setupPost: update() called after dispose()."); + // The scene pass's own texture size, read from the renderer: the kernel's offsets and the + // lens aspect are texel quantities, so a number that is not the input target's is a wrong + // picture rather than a slower one. The requested CSS size is not that number. + const { drawingBufferWidth: width, drawingBufferHeight: height } = renderer.surface(); + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + (width as number) < 1 || + (height as number) < 1 + ) { + throw new Error( + `setupPost: the frame is ${String(width)}x${String(height)}; the post pass needs the input target's real size.`, + ); + } + if (![elapsed, exposure, rain].every((value) => Number.isFinite(value))) { + throw new Error("setupPost: elapsed, exposure and rain must all be finite numbers."); + } + uRes.value.set(width, height); + // The bright pass reads the full-resolution scene at two of its texels per tap, as the study + // did, so it needs the same size; left at zero its kernel divides by zero. + uBloomRes.value.set(width, height); + uTime.value = elapsed; + uExposure.value = exposure; + uRain.value = rain; + uLens.value = droplets ? 1 : 0; + }, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + bloomPass.dispose(); + bloomGeometry.dispose(); + bloomMaterial.dispose(); + }, + }; + + /** + * The one authored stage: the demo's bloom and `POST`, in that order, as the last thing in the + * chain. It reads the chain's input — the scene pass's colour, coast, cloud, rain and lightning + * already composited — and returns the finished frame. + */ + function stormStage(): ChainStage { + return { + name: "stormPost", + // Anchored after the last built-in stage: this is the frame the player sees, so nothing may + // run after it. `before`/`after` is how the chain orders an authored stage at all. + after: "gradualBackground", + build: (input: unknown) => { + // The chain carries its own colour as `unknown`; this is the one place that becomes a node. + const colour = input as Parameters[0]; + // Materialised as a node in this graph, never copied out: a copied texture has no updater + // behind it and freezes on its first frame. The same node feeds the threshold pass and the + // final pass, so the bloom is computed from exactly the image the output grades. + const sceneTexture = convertToTexture(colour); + // Read inside each generated `Fn` body, so both samplers are bound before the fragment + // nodes are called — that call is the moment the graph is built. + setBloomScene(sceneTexture); + setScene(sceneTexture); + setBloom(bloomPass.getTextureNode()); + bloomMaterial.fragmentNode = tempestBloom(uv()); + return tempestPost(uv()); + }, + }; + } +} diff --git a/packages/create-threenative/templates/rain/src/render/quality.ts b/packages/create-threenative/templates/rain/src/render/quality.ts new file mode 100644 index 0000000000..48cecf51a5 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/quality.ts @@ -0,0 +1,135 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. Delete or rewrite +// it freely — the tiers below are a starting point, not a framework look. +// +// This is the one place this game decides how expensive it looks. Two tables live here: +// +// - `low` / `medium` / `high` are the engine render chain's tiers, which `postprocessing.ts` reads +// through `qualityPreset(resolveQualityTier(...))`. This study turns **every built-in stage off at +// every tier**: its bloom, ACES grade, vignette, lens beads and dither are the source study's own +// single post pass, installed as one authored stage, so a built-in bloom or tone map would do the +// same job a second time. What differs is the rung the chain reports and runs at +// (`renderChainTier`), so a stage you add later with a `minimumTier` drops out on the cheap tiers. +// - `STUDY_TIERS` is the player's "Render quality" select — the reference study's own four tiers, +// `performance` / `balanced` / `high` / `ultra`, number for number: the cloud pass's resolution +// share and march steps, the rain budget, and whether wet surfaces run the 36-step reflection +// march. The drawing buffer itself is the engine's adaptive resolution (`resolutionScale: "auto"` +// in `threenative.config.ts`), which measures the frame instead of trusting a fixed table. +import type { QualityName } from "../state.js"; +import type { IWorldEnvironmentOptions } from "./worldEnvironment.js"; + +/** + * The engine chain's three tier names. `low` is what a phone gets and `high` what a desktop gets; + * for this study all three are the same empty chain — see the note at the top of this file. + */ +export type QualityTier = "low" | "medium" | "high"; + +const QUALITY_TIERS: readonly QualityTier[] = ["low", "medium", "high"]; + +/** + * Narrows an arbitrary string — a URL parameter, a saved setting — to a tier name. Not exported: + * `resolveQualityTier` is the one door in, so an unknown name cannot be waved past the throw. + */ +function isQualityTier(value: string): value is QualityTier { + return (QUALITY_TIERS as readonly string[]).includes(value); +} + +/** + * Picks the tier: an explicit `tier` always wins, otherwise the platform decides. + * + * Fails closed. An unrecognised tier name throws with the value it was handed rather than + * quietly rendering the default, because a silent fallback here looks exactly like a tier that + * turned out to have no effect. + */ +export function resolveQualityTier( + request: { readonly mobile?: boolean; readonly software?: boolean; readonly tier?: string } = {}, +): QualityTier { + const requested = request.tier; + if (requested !== undefined) { + if (!isQualityTier(requested)) { + throw new Error( + `Unknown quality tier ${JSON.stringify(requested)} — expected one of ${QUALITY_TIERS.join(", ")}.`, + ); + } + return requested; + } + // A named software adapter — SwiftShader, llvmpipe, a basic-render driver — is the machine this + // game's desktop look cannot run on: a single `high` frame on one can outlast the device it is + // drawing on, and no adaptation that reacts to frame times gets to run first. `software` is the + // fact the renderer read from `adapter.info`, not a guess from a driver string, and an explicit + // `tier` above still wins over it. + if (request.software === true) return "low"; + return request.mobile === true ? "low" : "high"; +} + +/** + * What a desktop gets from the engine chain: no built-in stage, so there is no cost to record beside + * one; the storm's own post pass is costed in `STUDY_TIERS` below. + */ +const high: IWorldEnvironmentOptions = { + bloomEnabled: false, + denoiseEnabled: false, + // The post pass applies the weather's exposure itself, after its own bloom add. + exposure: 1, + renderChainTier: "high", + ssgiEnabled: false, + ssrEnabled: false, + tonemapMode: "aces", +}; + +/** The rung in between: the same empty chain, run and reported at `medium`. */ +const medium: IWorldEnvironmentOptions = { ...high, renderChainTier: "medium" }; + +/** What a phone gets: the same empty chain at `low`; the `performance` study tier is the cheap look. */ +const low: IWorldEnvironmentOptions = { ...high, renderChainTier: "low" }; + +const QUALITY_PRESETS: Record = { high, low, medium }; + +/** The stages and strengths a tier turns on. Throws on a name that is not a tier. */ +export function qualityPreset(tier: string): IWorldEnvironmentOptions { + const preset = QUALITY_PRESETS[tier as QualityTier]; + if (preset === undefined) { + throw new Error( + `Unknown quality tier ${JSON.stringify(tier)} — expected one of ${QUALITY_TIERS.join(", ")}.`, + ); + } + return preset; +} + +/** One rung of the player's quality select. Every number is the reference study's own. */ +export interface IStudyTier { + /** The cloud pass renders at this share of the frame's resolution (the study's `q[1]`). */ + readonly cloudScale: number; + /** Ray-march steps through the cloud volume (the study's `q[2]`). */ + readonly cloudSteps: number; + /** Rain drops at full precipitation; the drawn count is this times the eased `rain`. */ + readonly rainBudget: number; + /** Whether wet surfaces run the 36-step secondary reflection march. */ + readonly reflections: boolean; +} + +/** + * The four tiers the panel offers, cheapest first. The costs are **unmeasured** per stage on this + * scene: `playtests/tiers.playtest.json` reads back what each tier actually draws, and + * `TN_FRAME_BUDGET` is the frame-time reading to take after changing a rung. + */ +export const STUDY_TIERS: Readonly> = { + // The phone rung: the cheapest clouds, a thinner rain and no reflection march (unmeasured). + performance: { cloudScale: 0.4, cloudSteps: 32, rainBudget: 6_500, reflections: false }, + // Reflections back on; clouds a little finer (unmeasured). + balanced: { cloudScale: 0.48, cloudSteps: 48, rainBudget: 12_000, reflections: true }, + // The desktop default the panel opens on (unmeasured). + high: { cloudScale: 0.6, cloudSteps: 64, rainBudget: 12_000, reflections: true }, + // The finest clouds and the full 16,000 drops (unmeasured). + ultra: { cloudScale: 0.75, cloudSteps: 88, rainBudget: 16_000, reflections: true }, +}; + +/** A tier by name. Throws on a name the select does not offer, rather than drawing a default. */ +export function studyTier(name: string): IStudyTier { + const tier = STUDY_TIERS[name as QualityName]; + if (tier === undefined || !Object.hasOwn(STUDY_TIERS, name)) { + throw new Error( + `Unknown study tier ${JSON.stringify(name)} — expected performance, balanced, high or ultra.`, + ); + } + return tier; +} diff --git a/packages/create-threenative/templates/rain/src/render/rain.ts b/packages/create-threenative/templates/rain/src/render/rain.ts new file mode 100644 index 0000000000..18a64ac62e --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/rain.ts @@ -0,0 +1,299 @@ +// Generated for you. Ordinary Three.js, and the falling rain lives in this file. +// +// The reference study drew every drop in one vertex stage: six vertices per drop, the drop's own +// seed hashed from its index, its position wrapped inside a 66 x 30 x 66 metre cell that follows the +// camera in 12 metre steps, and its two endpoints projected by hand so each quad comes out stretched +// along the drop's own fall. That maths is transcribed below, constant for constant, and it is the +// rain: no sprite sheet, no texture, no particle simulation on the CPU. +// +// The engine owns the frame loop, the scene and the renderer, so this file owns three things — the +// uniforms, the geometry, and its own lifetime — and draws nothing itself. No render loop, no raw +// GL, no engine package import, so the same file runs wherever the game runs. +import { + DoubleSide, + Float32BufferAttribute, + InstancedBufferGeometry, + Mesh, + type PerspectiveCamera, + type Scene, + Vector3, +} from "three"; +import { + abs, + cameraFar, + cameraNear, + clamp, + dot, + float, + floor, + fract, + frameGroup, + instanceIndex, + max, + mix, + mod, + normalize, + positionGeometry, + pow, + screenSize, + select, + sin, + smoothstep, + uniform, + varying, + vec2, + vec3, + vec4, +} from "three/tsl"; +import { MeshBasicNodeMaterial } from "three/webgpu"; +import type { Node } from "three/webgpu"; +import type { QualityName, Weather } from "../state.js"; +import { studyTier } from "./quality.js"; + +/** + * The six corners of one drop's quad, exactly as the study's `rainGeometry` wrote them: `x` runs + * across the streak from -1 to 1 and `y` runs along it from the drop's head to its tail. Stored as + * three-component positions because Three's own bounds pass reads a `position` attribute, and the + * quad is projected by the shader rather than by a camera matrix, so `z` is never read. + */ +const DROP_CORNERS = new Float32Array([-1, 0, 0, 1, 0, 0, -1, 1, 0, -1, 1, 0, 1, 0, 0, 1, 1, 0]); + +/** The cell wraps at this many metres on each horizontal axis, and this many in height. */ +const CELL_XZ = 66; +const CELL_Y = 30; +/** The cell follows the camera in steps of this many metres. */ +const CELL_STEP = 12; +/** The most drops any tier may ask for. */ +const MAX_DROPS = 16_000; + +/** The study's own hash: one sine, one fract, and every drop's fate in it. */ +function rnd(n: Node<"float">) { + return fract(sin(n.mul(127.1)).mul(43758.5453123)); +} + +/** + * How many drops this budget and this weather actually draw, rounded to a whole drop. + * + * The number returned is what `geometry.instanceCount` is set to, so what the renderer issues and + * what this reports are the same figure — there is no second count anywhere that could disagree with + * the draw. + */ +export function rainInstanceCount(budget: number, rain: number): number { + if (!Number.isInteger(budget) || budget <= 0 || budget > MAX_DROPS) { + throw new Error( + `rain budget must be a whole number of drops in 1..${MAX_DROPS}, got ${budget}.`, + ); + } + if (!Number.isFinite(rain) || rain < 0 || rain > 1) { + throw new Error(`weather.rain must be 0..1, got ${rain}.`); + } + return Math.round(budget * rain); +} + +export interface IRainOptions { + /** Absolute seconds, so the fall does not depend on how the frame was scheduled. */ + readonly elapsed: number; + /** Current flash envelope. The caller has already gated it on the photosensitivity switch. */ + readonly flash: number; + readonly weather: Weather; + /** Which tier's drop budget to draw. An unknown name throws rather than falling back. */ + readonly quality: QualityName; +} + +export interface IStormRain { + /** The real mesh, for the engine's culling pass and for a scene walk that counts what is drawn. */ + readonly mesh: Mesh; + /** The drops the renderer is being asked to draw right now: `geometry.instanceCount`. */ + readonly instanceCount: number; + update(options: IRainOptions): void; + dispose(): void; +} + +/** + * The rain pass: one instanced draw of six-vertex quads, added to `scene`. + * + * The engine's `GPUParticles3D` was the thing to reach for and it cannot carry this + * shader: it extends `Sprite`, it overwrites the material's `positionNode` with a storage buffer, and + * it derives every particle's position from a compute node — there is nowhere to hand it an authored + * vertex stage that hashes its own drop index, wraps its own cell and projects its own two endpoints + * by hand. This fills that gap with the smallest thing that holds the maths: an + * `InstancedBufferGeometry`, so the draw count is the geometry's own `instanceCount` and + * `instanceIndex` is the study's `floor(gl_VertexID / 6)`. + */ +export function createStormRain(scene: Scene, camera: PerspectiveCamera): IStormRain { + // The uniform block the study passed to every pass. Assigned each frame below, from the same + // camera basis and the same weather the coast is drawn with. + const uTime = uniform(0, "float").setGroup(frameGroup); + const uRain = uniform(0, "float").setGroup(frameGroup); + const uWind = uniform(0, "float").setGroup(frameGroup); + const uFlash = uniform(0, "float").setGroup(frameGroup); + const uAspect = uniform(0, "float").setGroup(frameGroup); + const uTan = uniform(0, "float").setGroup(frameGroup); + const uCam = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uForward = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uRight = uniform(new Vector3(), "vec3").setGroup(frameGroup); + const uUp = uniform(new Vector3(), "vec3").setGroup(frameGroup); + + /** + * The study's `proj`. Its x and y are the ordinary perspective divide for this camera's field of + * view, and its z becomes the depth the coast's own quad already wrote with these same near and far + * planes — so a drop behind the headland is hidden by it, which is the one comparison that pass + * existed for. + */ + function proj(p: Node<"vec3">) { + const v = p.sub(uCam); + const d = dot(v, uForward); + const depth = clamp( + cameraNear + .add(cameraFar) + .sub(cameraNear.mul(cameraFar).mul(2).div(d)) + .div(cameraFar.sub(cameraNear)) + .add(1) + .mul(0.5), + 0, + 1, + ); + return vec4(dot(v, uRight).div(uAspect.mul(uTan)), dot(v, uUp).div(uTan), depth.mul(d), d); + } + + // --- the vertex stage, transcribed from the study's RAIN_VERTEX -------------------------- + + // The study read each drop's own index out of `gl_VertexID / 6`. Instanced, that division is the + // instance itself: one instance is one drop's six vertices. Written as one composition rather + // than a run of `assign`s on a `toVar` variable: `assign` only exists inside a generated `Fn` + // body, and the whole stage is a single expression anyway, so plain composition is both the + // legal spelling and the smaller one. + const id = float(instanceIndex); + const seed = rnd(id.add(1)); + const seedPos = vec3( + rnd(id.mul(3.1).add(2)) + .mul(CELL_XZ) + .sub(CELL_XZ / 2), + rnd(id.mul(7.3).add(5)).mul(CELL_Y), + rnd(id.mul(5.7).add(8)) + .mul(CELL_XZ) + .sub(CELL_XZ / 2), + ); + const anchor = vec3( + floor(uCam.x.div(CELL_STEP)).mul(CELL_STEP), + float(0), + floor(uCam.z.div(CELL_STEP)).mul(CELL_STEP), + ); + const speed = float(14).add(seed.mul(8)); + const tm = uTime.add(seed.mul(10)); + + // The fall. Each axis wraps inside the cell, so a drop reappears at the far edge instead of + // running out; the cell then jumps with the camera, which is what keeps the volume around the + // viewer rather than around the origin. + const falling = vec3( + mod(seedPos.x.add(tm.mul(uWind).mul(20)).add(CELL_XZ / 2), float(CELL_XZ)).sub(CELL_XZ / 2), + mod(seedPos.y.sub(tm.mul(speed)), float(CELL_Y)).sub(0.45), + mod(seedPos.z.add(tm.mul(uWind).mul(2)).add(CELL_XZ / 2), float(CELL_XZ)).sub(CELL_XZ / 2), + ).add(anchor); + // The cell sits at the viewer's feet: however high the camera flies, the drops start five metres + // below it and fall through the same 30 metres of air. + const p = vec3(falling.x, falling.y.add(max(uCam.y.sub(5), float(0))), falling.z); + + // The streak is the drop's own velocity over a thirteenth of a second, so the quad comes out + // stretched along the fall instead of a square hung at the drop. + const velocity = vec3(uWind.mul(20), speed.negate(), uWind.mul(2)); + const end = p.sub(velocity.mul(float(0.013).add(seed.mul(0.011)))); + const a = proj(p); + const b = proj(end); + + const corner = positionGeometry.xy; + const dir = normalize( + b.xy + .div(max(b.w, float(0.1))) + .sub(a.xy.div(max(a.w, float(0.1)))) + .mul(screenSize) + .add(0.0001), + ); + const side = vec2(dir.y.negate(), dir.x); + // The width is in pixels and the quad is in clip space, so the offset carries the vertex's own + // `w`: the study's `... / uRes * c.w * 2`. + const along = mix(a, b, corner.y); + const across = side + .mul(corner.x) + .mul(mix(float(0.42), float(0.85), seed)) + .div(screenSize) + .mul(along.w) + .mul(2); + const stretched = along.add(vec4(across.x, across.y, float(0), float(0))); + // An endpoint behind the camera has no direction to stretch along; the study threw the quad off + // screen rather than letting it collapse to a dot on the near plane. + const clip = select(a.w.lessThan(0.4).or(b.w.lessThan(0.4)), vec4(2, 2, 2, 1), stretched); + + // Near drops fade up and far ones fade out, so the volume has no visible wall. + const opacity = float(0.07) + .add(seed.mul(0.14)) + .mul(uRain) + .mul(smoothstep(0.3, 1.8, a.w)) + .mul(float(1).sub(smoothstep(12, 43, a.w))); + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(DROP_CORNERS, 3)); + geometry.instanceCount = 0; + + const material = new MeshBasicNodeMaterial(); + // The quads are wound by the projection, not by the vertex order, so either face can end up front + // facing; the study's pass drew both. + material.side = DoubleSide; + material.transparent = true; + // Drops are light over a coast that has already written depth: they test it and leave it alone, + // so streaks never occlude each other or the lightning behind them. + material.depthWrite = false; + material.vertexNode = clip; + + const vAcross = varying(corner.x, "vAcross"); + const vAlong = varying(corner.y, "vAlong"); + const vOpacity = varying(opacity, "vOpacity"); + material.fragmentNode = vec4( + vec3(0.41, 0.56, 0.68).add(uFlash.mul(vec3(0.35, 0.48, 0.65))), + pow(max(float(0), float(1).sub(abs(vAcross))), 1.6) + .mul(sin(vAlong.mul(Math.PI))) + .mul(vOpacity), + ); + + const mesh = new Mesh(geometry, material); + // The geometry holds one quad's worth of corners at the origin while the drops are projected by + // the shader, so its own bounds say nothing about where any of them land. + mesh.frustumCulled = false; + scene.add(mesh); + + const forward = new Vector3(); + const right = new Vector3(); + const up = new Vector3(); + + return { + mesh, + get instanceCount(): number { + return geometry.instanceCount; + }, + update({ elapsed, flash, weather, quality }): void { + geometry.instanceCount = rainInstanceCount(studyTier(quality).rainBudget, weather.rain); + + // The same basis and the same field of view the coast is drawn with, so a drop and the + // headland behind it agree on where the horizon is. + camera.getWorldDirection(forward); + right.set(1, 0, 0).applyQuaternion(camera.quaternion); + up.set(0, 1, 0).applyQuaternion(camera.quaternion); + uCam.value.copy(camera.position); + uForward.value.copy(forward); + uRight.value.copy(right); + uUp.value.copy(up); + uAspect.value = camera.aspect; + uTan.value = Math.tan((camera.fov * Math.PI) / 360); + + uTime.value = elapsed; + uRain.value = weather.rain; + uWind.value = weather.wind; + uFlash.value = flash; + }, + dispose(): void { + scene.remove(mesh); + geometry.dispose(); + material.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/render/sky.ts b/packages/create-threenative/templates/rain/src/render/sky.ts new file mode 100644 index 0000000000..424af3681c --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/sky.ts @@ -0,0 +1,28 @@ +// Generated for you. The sky is game-owned source, not an engine preset. +// +// The storm's sky is drawn by the cloud pass (`clouds.ts`) and the haze the coast fades into is +// drawn by the world pass (`world.ts`); this file decides both colours. The deck's own lighting — +// the sun's direction — is `lighting.ts`. +import { Color, type Scene } from "three"; +import * as clouds from "./clouds-shader.js"; +import { palette } from "./palette.js"; +import * as world from "./world-shader.js"; + +/** The clear sky behind the deck, near the horizon and overhead: linear RGB, the study's own. */ +const SKY_LOW = new Color(0.22, 0.3, 0.355); +const SKY_HIGH = new Color(0.055, 0.1, 0.17); + +/** + * Writes the sky and haze colours into both passes. Run once, before the first frame. The world + * quad covers every pixel, so `scene.background` is never seen; it is set to the horizon anyway so + * a frame that lost the quad still reads as this storm's haze rather than as black. + */ +export function setupSky(scene: Scene): void { + const horizon = new Color(palette.horizon); + scene.background = horizon.clone(); + const zenith = new Color(palette.zenith); + world.uHazeLow.value.set(horizon.r, horizon.g, horizon.b); + world.uHazeHigh.value.set(zenith.r, zenith.g, zenith.b); + clouds.uSkyLow.value.set(SKY_LOW.r, SKY_LOW.g, SKY_LOW.b); + clouds.uSkyHigh.value.set(SKY_HIGH.r, SKY_HIGH.g, SKY_HIGH.b); +} diff --git a/packages/create-threenative/templates/rain/src/render/world-shader.ts b/packages/create-threenative/templates/rain/src/render/world-shader.ts new file mode 100644 index 0000000000..3040feb687 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/world-shader.ts @@ -0,0 +1,680 @@ +// GENERATED FILE — do not edit it. Every line below is the output of Three.js's own GLSL to +// TSL transpiler, run over tools/tempest-world.frag. That .frag file +// is the source of truth; change it and the generator, never this file. +// +// node tools/generate-shaders.mjs +// +// The shader is the coast: terrain, road, sea, the rail and cabin on it, wet reflections and the +// fog that closes the distance. It is raymarched, so it is one full-screen quad and one function +// call — see src/render/world.ts for the uniforms and the camera basis it expects. +import { DataTexture, Vector2, Vector3, Vector4 } from "three"; +import { + Break, + Fn, + If, + Loop, + abs, + add, + atan, + clamp, + cos, + div, + dot, + exp, + float, + floor, + fract, + frameGroup, + int, + length, + mat2, + max, + min, + mix, + mul, + normalize, + oneMinus, + pow, + property, + reflect, + select, + sin, + smoothstep, + sub, + texture, + uniform, + vec2, + vec3, + vec4, +} from "three/tsl"; +import type { Node, TextureNode } from "three/webgpu"; + +// Uniforms the shader reads. Values are assigned by src/render/world.ts and src/render/clouds.ts, +// which own the camera basis and the weather; the generated module only holds the nodes. +export const uTime = uniform(0, "float").setGroup(frameGroup); +export const uRain = uniform(0, "float").setGroup(frameGroup); +export const uFog = uniform(0, "float").setGroup(frameGroup); +export const uWet = uniform(0, "float").setGroup(frameGroup); +export const uFlash = uniform(0, "float").setGroup(frameGroup); +export const uAspect = uniform(0, "float").setGroup(frameGroup); +export const uTan = uniform(0, "float").setGroup(frameGroup); +export const uCam = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uForward = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uRight = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uUp = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uStrike = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uHazeLow = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uHazeHigh = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uSunDir = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uSunColor = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uFill = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uFlashLight = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uLampLight = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uLampGlow = uniform(new Vector3(), "vec3").setGroup(frameGroup); +export const uWater = uniform(new Vector3(), "vec3").setGroup(frameGroup); +const uSkyPlaceholder = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); +uSkyPlaceholder.needsUpdate = true; +export let uSky: TextureNode = texture(uSkyPlaceholder).setGroup(frameGroup); +export function setSky(value: TextureNode): void { + uSky = value; +} +export const uReflect = uniform(0, "int").setGroup(frameGroup); + +// Three.js Transpiler r185 + +// The look, owned by src/render/{sky,lighting,materials}.ts and set from there every run. + +export const hash12 = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const p3 = fract(vec3(p.xyx).mul(0.1031)).toVar(); + p3.addAssign(dot(p3, p3.yzx.add(33.33))); + + return fract(p3.x.add(p3.y).mul(p3.z)); +}); + +export const hash13 = /*@__PURE__*/ Fn(([p_immutable]: [Node<"vec3">]) => { + const p = p_immutable.toVar(); + p.assign(fract(p.mul(0.1031))); + p.addAssign(dot(p, p.zyx.add(31.32))); + + return fract(p.x.add(p.y).mul(p.z)); +}); + +export const noise2 = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const i = floor(p).toVar(); + const f = fract(p).toVar(); + f.assign(f.mul(f).mul(sub(3, mul(2, f)))); + + return mix( + mix(hash12(i), hash12(i.add(vec2(1, 0))), f.x), + mix(hash12(i.add(vec2(0, 1))), hash12(i.add(1)), f.x), + f.y, + ); +}); + +export const fbm2 = /*@__PURE__*/ Fn(([p_immutable]: [Node<"vec2">]) => { + const p = p_immutable.toVar(); + const v = mul(0.5, noise2(p)).toVar(); + p.assign(mat2(0.8, 0.6, -0.6, 0.8).mul(p).mul(2.03).add(19.1)); + v.addAssign(mul(0.25, noise2(p))); + p.assign(p.mul(2.02).add(9.3)); + v.addAssign(mul(0.125, noise2(p))); + + return v.add(mul(0.0625, noise2(p.mul(2.01).add(17)))); +}); + +export const ray = /*@__PURE__*/ Fn(([uv]: [Node<"vec2">]) => { + const q = uv.mul(2).sub(1).toVar(); + + return normalize( + uForward.add(uRight.mul(q.x).mul(uAspect).mul(uTan)).add(uUp.mul(q.y).mul(uTan)), + ); +}); + +export const project = /*@__PURE__*/ Fn(([p]: [Node<"vec3">]) => { + const v = p.sub(uCam).toVar(); + const d = dot(v, uForward).toVar(); + + return vec4( + dot(v, uRight).div(uAspect.mul(uTan)), + dot(v, uUp).div(uTan), + mul(1.00012, d).sub(0.300018), + d, + ); +}); + +export const hazeColor = /*@__PURE__*/ Fn(([rd]: [Node<"vec3">]) => { + return mix(uHazeLow, uHazeHigh, smoothstep(0, 0.7, abs(rd.y))).add( + uFlash.mul(vec3(0.11, 0.16, 0.24)), + ); +}); + +export const roadCenter = /*@__PURE__*/ Fn(([z]: [Node<"float">]) => { + return float(-1.6) + .add(mul(2.1, sin(z.mul(0.012)))) + .add(mul(15, sub(1, smoothstep(-420, -150, z)))); +}); + +export const coast = /*@__PURE__*/ Fn(([z]: [Node<"float">]) => { + return roadCenter(z) + .add(9.2) + .add(sin(z.mul(0.023)).mul(1.8)); +}); + +export const terrain = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const n = fbm2(p.mul(0.009).add(vec2(4, 9))).toVar(); + const r = sub(1, abs(noise2(p.mul(0.018)).mul(2).sub(1))).toVar(); + const left = smoothstep(13, 155, p.x.negate()) + .mul(add(28, n.mul(135)).add(r.mul(20))) + .toVar(); + const back = smoothstep(280, 670, p.y.negate()) + .mul(add(46, n.mul(155)).add(r.mul(22))) + .toVar(); + const farRight = smoothstep(190, 430, p.x) + .mul(add(30, n.mul(95))) + .toVar(); + const h = max(max(left, back), farRight).toVar(); + const island = exp( + dot(p.sub(vec2(94, -205)).div(vec2(39, 48)), p.sub(vec2(94, -205)).div(vec2(39, 48))).negate(), + ) + .mul(add(18, n.mul(10))) + .toVar(); + h.assign(max(h, island)); + + If(h.lessThan(1.5), () => { + h.assign(select(p.x.greaterThan(coast(p.y)), -1.2, add(0.1, n.mul(0.2)))); + }); + + If(p.y.greaterThan(-175).and(abs(p.x.sub(roadCenter(p.y))).lessThan(7.3)), () => { + h.assign(0); + }); + + return h; +}); + +export const sdBox = /*@__PURE__*/ Fn(([p, b]: [Node<"vec3">, Node<"vec3">]) => { + const q = abs(p).sub(b).toVar(); + + return length(max(q, 0)).add(min(max(q.x, max(q.y, q.z)), 0)); +}); + +export const capsule = /*@__PURE__*/ Fn( + ([p, a, b, r]: [Node<"vec3">, Node<"vec3">, Node<"vec3">, Node<"float">]) => { + const pa = p.sub(a).toVar(); + const ba = b.sub(a).toVar(); + + return length(pa.sub(ba.mul(clamp(dot(pa, ba).div(dot(ba, ba)), 0, 1)))).sub(r); + }, +); + +export const choose = /*@__PURE__*/ Fn(([a, b]: [Node<"vec2">, Node<"vec2">]) => { + return select(a.x.lessThan(b.x), a, b); +}); + +export const sceneMap = /*@__PURE__*/ Fn(([p]: [Node<"vec3">]) => { + const h = terrain(p.xz).toVar(); + const res = vec2(p.y.sub(select(h.greaterThan(1), h, -3.5)).mul(0.58), 1).toVar(); + + // One bounded procedural tree per spatial cell; crowns fit inside their cells. + + const cell = floor(p.xz.add(vec2(2, 1)).div(10.5)).toVar(); + const seed = hash12(cell.add(17)).toVar(); + const tc = cell + .add(0.5) + .mul(10.5) + .sub(vec2(2, 1)) + .add(vec2(hash12(cell.add(3)), hash12(cell.add(7))).mul(0.9)) + .sub(0.45) + .toVar(); + const th = terrain(tc).toVar(); + const rel = tc.x.sub(roadCenter(tc.y)).toVar(); + const forest = rel + .lessThan(-9) + .or(tc.y.lessThan(-175)) + .and(th.greaterThan(-0.2)) + .and(th.lessThan(115)) + .and(seed.greaterThan(0.15)) + .and(tc.y.lessThan(-12).or(tc.x.lessThan(-45))) + .toVar(); + + If(forest, () => { + const q = p.sub(vec3(tc.x, th, tc.y)).toVar(); + const ht = add(6, seed.mul(13)).toVar(); + const cy = clamp(q.y.div(ht), 0, 1).toVar(); + const angle = atan(q.z, q.x).toVar(); + const tier = add(0.77, mul(0.23, sin(q.y.mul(3.5).add(seed.mul(13))))).toVar(); + const r = sub(1, cy) + .mul(add(2, seed.mul(1.45))) + .mul(tier) + .toVar(); + r.mulAssign( + add(0.88, mul(0.12, sin(angle.mul(9).add(q.y.mul(2.7))))).add( + mul(0.07, sin(angle.mul(19).sub(q.y.mul(6)))), + ), + ); + const needle = noise2(q.xz.mul(6).add(q.y.mul(2))) + .sub(0.5) + .mul(0.18) + .mul(sub(1, cy)) + .toVar(); + const d = max(length(q.xz).sub(r).add(needle), max(sub(0.8, q.y), q.y.sub(ht))) + .mul(0.67) + .toVar(); + res.assign(choose(res, vec2(d, 2))); + res.assign( + choose( + res, + vec2( + max(length(q.xz).sub(mul(0.13, sub(1, cy.mul(0.65)))), max(q.y.negate(), q.y.sub(ht))), + 3, + ), + ), + ); + }); + + // Repeating steel luminaires, including their angled neck and illuminated undersides. + + const k = clamp(floor(p.z.negate().sub(10).div(38).add(0.5)), 0, 5).toVar(); + const z = float(-10).sub(k.mul(38)).toVar(); + const x = roadCenter(z).sub(6.35).toVar(); + const q = p.sub(vec3(x, 0, z)).toVar(); + const pole = capsule(q, vec3(0, 0, 0), vec3(0, 7.65, 0), 0.075).toVar(); + pole.assign(min(pole, capsule(q, vec3(0, 7.6, 0), vec3(1.6, 8.05, 0), 0.065))); + pole.assign(min(pole, capsule(q, vec3(1.6, 8.05, 0), vec3(2.5, 8.05, 0), 0.055))); + pole.assign(min(pole, sdBox(q.sub(vec3(2.43, 8, 0)), vec3(0.4, 0.1, 0.23)))); + res.assign(choose(res, vec2(pole, 4))); + res.assign(choose(res, vec2(sdBox(q.sub(vec3(2.45, 7.885, 0)), vec3(0.32, 0.021, 0.175)), 6))); + + // Guardrail on the water side, stopping before the distant curve. + + If(p.z.lessThan(28).and(p.z.greaterThan(-176)), () => { + const rx = roadCenter(p.z).add(6.3).toVar(); + const rail = sdBox(vec3(p.x.sub(rx), p.y.sub(0.84), 0), vec3(0.065, 0.13, 1000)).toVar(); + const postZ = floor(p.z.div(5).add(0.5)).mul(5).toVar(); + rail.assign( + min( + rail, + sdBox(p.sub(vec3(roadCenter(postZ).add(6.3), 0.45, postZ)), vec3(0.055, 0.45, 0.055)), + ), + ); + res.assign(choose(res, vec2(rail, 4))); + }); + + // Small roadside field station, with a lit window and antenna. + + const b = p.sub(vec3(-17, 1.85, -73)).toVar(); + const cabin = sdBox(b, vec3(3.3, 1.85, 2.65)).toVar(); + res.assign(choose(res, vec2(cabin, 9))); + res.assign(choose(res, vec2(sdBox(p.sub(vec3(-17, 3.85, -73)), vec3(3.6, 0.14, 2.9)), 4))); + res.assign( + choose(res, vec2(sdBox(p.sub(vec3(-17.8, 2.1, -70.325)), vec3(0.84, 0.48, 0.028)), 6)), + ); + res.assign(choose(res, vec2(capsule(p, vec3(-19, 3.9, -74), vec3(-19, 13, -74), 0.045), 4))); + + return res; +}); + +export const trace = /*@__PURE__*/ Fn( + ([ro, rd, limit]: [Node<"vec3">, Node<"vec3">, Node<"float">]) => { + const t = float(0.25).toVar(); + const mat = float(-1).toVar(); + + Loop({ start: int(0), end: 148 }, ({ i }) => { + If(t.greaterThan(limit).or(t.greaterThan(1850)), () => { + Break(); + }); + + const p = ro.add(rd.mul(t)).toVar(); + const d = sceneMap(p).toVar(); + const eps = max(0.007, t.mul(0.00048)).toVar(); + + If(d.x.lessThan(eps), () => { + mat.assign(d.y); + Break(); + }); + + const stepD = max(0.05, d.x.mul(0.87)).toVar(); + + If(p.y.lessThan(34).and(t.lessThan(270)), () => { + stepD.assign(min(stepD, 4.8)); + }); + + t.addAssign(stepD); + }); + + return vec2(t, mat); + }, +); + +export const getNormal = /*@__PURE__*/ Fn(([p, t]: [Node<"vec3">, Node<"float">]) => { + const e = max(0.008, t.mul(0.00032)).toVar(); + const k = vec2(1, -1).mul(0.5773).toVar(); + + return normalize( + k.xyy + .mul(sceneMap(p.add(k.xyy.mul(e))).x) + .add(k.yyx.mul(sceneMap(p.add(k.yyx.mul(e))).x)) + .add(k.yxy.mul(sceneMap(p.add(k.yxy.mul(e))).x)) + .add(k.xxx.mul(sceneMap(p.add(k.xxx.mul(e))).x)), + ); +}); + +export const baseColor = /*@__PURE__*/ Fn( + ([p, mat, n]: [Node<"vec3">, Node<"float">, Node<"vec3">]) => { + const grit = noise2(p.xz.mul(4.3).add(p.y)) + .mul(0.2) + .add(noise2(p.xz.mul(31)).mul(0.09)) + .toVar(); + + // One value, one return. The transpiler turns a GLSL early return into a JavaScript return that + // only leaves the `If` callback the node graph is being built inside, so the branch is never + // assigned and every material would take the colour of the last line. `else if` keeps the + // first-match order the five separate `if`s had, and `res` is seeded with the fall-through value. + + const res = vec3(0.1).toVar(); + + If(mat.lessThan(1.5), () => { + const moss = smoothstep(0.46, 0.66, fbm2(p.xz.mul(0.3).add(p.y.mul(0.4)))) + .mul(smoothstep(0.4, 0.9, n.y)) + .toVar(); + res.assign( + mix(vec3(0.075, 0.091, 0.09), vec3(0.038, 0.068, 0.042), moss).mul(add(0.7, grit)), + ); + }) + .ElseIf(mat.lessThan(2.5), () => { + res.assign( + mix( + vec3(0.017, 0.034, 0.027), + vec3(0.045, 0.071, 0.047), + noise2(p.xz.mul(5).add(p.y)), + ).mul(add(0.8, max(n.y, 0).mul(0.35))), + ); + }) + .ElseIf(mat.lessThan(3.5), () => { + res.assign(vec3(0.06, 0.047, 0.036)); + }) + .ElseIf(mat.lessThan(4.5), () => { + res.assign(vec3(0.12, 0.155, 0.17).mul(add(0.8, grit))); + }) + .ElseIf(mat.greaterThan(8.5), () => { + const seam = add( + 0.75, + mul(0.25, smoothstep(0.01, 0.1, abs(fract(p.x.mul(3)).sub(0.5)))), + ).toVar(); + res.assign(vec3(0.09, 0.13, 0.14).mul(seam)); + }); + + return res; + }, +); + +export const surfaceLight = /*@__PURE__*/ Fn( + ([p, n, v, albedo, rough]: [ + Node<"vec3">, + Node<"vec3">, + Node<"vec3">, + Node<"vec3">, + Node<"float">, + ]) => { + const hemi = add(0.45, mul(0.55, max(n.y, 0))).toVar(); + const c = albedo.mul(uFill.mul(hemi).add(uSunColor.mul(max(dot(n, uSunDir), 0)))).toVar(); + c.addAssign( + albedo + .mul(uFlash) + .mul(add(0.6, max(dot(n, normalize(uStrike.sub(p))), 0).mul(2.5))) + .mul(uFlashLight), + ); + + Loop({ start: int(0), end: 6 }, ({ i }) => { + const z = float(-10).sub(float(i).mul(38)).toVar(); + const lp = vec3(roadCenter(z).sub(3.9), 7.87, z).toVar(); + const l = lp.sub(p).toVar(); + const dist2 = dot(l, l).toVar(); + l.assign(normalize(l)); + const ndl = max(dot(n, l), 0).toVar(); + const cone = smoothstep(0.1, 0.62, l.y.negate()) + .mul(smoothstep(-0.1, 0.1, l.y)) + .toVar(); + const atten = div(48, add(1, dist2)).toVar(); + const warm = uLampLight.toVar(); + c.addAssign(albedo.mul(warm).mul(atten).mul(ndl).mul(2)); + const hv = normalize(l.add(v)).toVar(); + const shin = mix(20, 900, sub(1, rough)).toVar(); + const spec = pow(max(dot(n, hv), 0), shin) + .mul(shin.add(2)) + .mul(0.018) + .toVar(); + c.addAssign( + warm + .mul(spec) + .mul(atten) + .mul(ndl) + .mul(select(rough.greaterThan(0.55), 0.022, 1)), + ); + }); + + return c; + }, +); + +export const rippleSlope = /*@__PURE__*/ Fn(([p]: [Node<"vec2">]) => { + const cell = floor(p.div(2.2)).toVar(); + const sum = vec2(0).toVar(); + + Loop( + { start: int(-1), end: int(1), condition: "<=" }, + { start: int(-1), end: int(1), condition: "<=" }, + ({ i, j }) => { + const c = cell.add(vec2(float(i), float(j))).toVar(); + const h = hash12(c.add(3.9)).toVar(); + const ctr = c + .add(vec2(h, hash12(c.add(19)))) + .mul(2.2) + .toVar(); + const v = p.sub(ctr).toVar(); + const d = length(v).add(0.001).toVar(); + const age = fract(uTime.mul(add(0.6, uRain.mul(0.2))).add(h.mul(7.3))).toVar(); + const ring = d.sub(age.mul(1.7)).toVar(); + const env = exp(abs(ring).negate().mul(20)) + .mul(sub(1, age)) + .mul(smoothstep(0.025, 0.1, age)) + .toVar(); + sum.addAssign( + v + .div(d) + .mul(cos(ring.mul(95))) + .mul(env) + .mul(0.055), + ); + }, + ); + + return sum.mul(uRain); +}); + +export const reflected = /*@__PURE__*/ Fn( + ([p, rd, sky]: [Node<"vec3">, Node<"vec3">, Node<"vec3">]) => { + // Same reason as baseColor: no early return. The `uReflect` guard becomes the condition around the + // march, which is still a skip, and a hit becomes a `res` plus a `Break`, which is what the trace + // already does. A ray that reaches the end of the march leaves `res` as the sky, as it did. + + const res = sky.toVar(); + + If(uReflect.notEqual(0), () => { + const t = float(0.5).toVar(); + + Loop({ start: int(0), end: 36 }, ({ i }) => { + If(t.greaterThan(280), () => { + Break(); + }); + + const q = p.add(rd.mul(t)).toVar(); + const d = sceneMap(q).toVar(); + + If(d.x.lessThan(max(0.03, t.mul(0.0016))), () => { + If(d.y.greaterThan(5.5).and(d.y.lessThan(6.5)), () => { + res.assign(uLampGlow.mul(0.75)); + }).Else(() => { + res.assign( + mix( + baseColor(q, d.y, vec3(0, 1, 0)).mul(0.7), + hazeColor(rd), + sub(1, exp(t.negate().mul(add(0.0015, uFog.mul(0.004))))), + ), + ); + }); + + Break(); + }); + + t.addAssign(max(0.15, d.x.mul(0.9))); + }); + }); + + return res; + }, +); + +export const tempestWorld = /*@__PURE__*/ Fn(([vUv]: [Node<"vec2">]) => { + const worldDepth = float(1).toVar(); + const rd = ray(vUv).toVar(); + const sky = uSky.sample(vec2(vUv.x, vUv.y.oneMinus())).rgb.toVar(); + const planeT = select(rd.y.lessThan(-0.0001), uCam.y.negate().div(rd.y), 2200).toVar(); + const pp = uCam.add(rd.mul(planeT)).toVar(); + const water = pp.x.greaterThan(coast(pp.z)).and(terrain(pp.xz).lessThan(0)).toVar(); + + If(water.and(rd.y.lessThan(-0.0001)), () => { + planeT.assign(float(-0.6).sub(uCam.y).div(rd.y)); + }); + + const hit = trace(uCam, rd, min(planeT, 2200)).toVar(); + const t = hit.x.toVar(); + const mat = hit.y.toVar(); + const p = uCam.add(rd.mul(t)).toVar(); + const n = property("vec3").toVar(); + const color = sky.toVar(); + const ground = mat.lessThan(0).and(planeT.greaterThan(0)).and(planeT.lessThan(1800)).toVar(); + + If(ground, () => { + t.assign(planeT); + p.assign(uCam.add(rd.mul(t))); + const rx = abs(p.x.sub(roadCenter(p.z))).toVar(); + water.assign(p.x.greaterThan(coast(p.z)).and(terrain(p.xz).lessThan(0))); + const road = rx.lessThan(5.35).and(p.z.greaterThan(-190)).toVar(); + const ripple = rippleSlope(p.xz).toVar(); + const coarse = noise2(p.xz.mul(2.6)).toVar(); + const micro = noise2(p.xz.mul(72)).toVar(); + const puddle = smoothstep(0.37, 0.64, fbm2(p.xz.mul(0.25).add(7))) + .mul(uWet) + .toVar(); + + If(water, () => { + puddle.assign(1); + }); + + const wave = vec2( + cos(p.x.mul(1.3).add(p.z.mul(0.8)).add(uTime.mul(1.7))), + sin(p.z.mul(1.7).sub(p.x.mul(0.4)).sub(uTime.mul(1.2))), + ) + .mul(select(water, 0.018, 0.0015)) + .toVar(); + const bump = vec2(noise2(p.xz.mul(35)), noise2(p.xz.mul(35).add(9))) + .sub(0.5) + .mul(mix(0.22, 0.013, puddle)) + .toVar(); + n.assign( + normalize(vec3(bump.x.add(wave.x).add(ripple.x), 1, bump.y.add(wave.y).add(ripple.y))), + ); + const alb = select( + road, + vec3(0.031, 0.037, 0.042).mul(add(0.58, coarse.mul(0.4)).add(micro.mul(0.3))), + vec3(0.075, 0.086, 0.07).mul(add(0.7, coarse.mul(0.45))), + ).toVar(); + const rough = mix(0.7, 0.08, puddle).toVar(); + + If(road, () => { + const center = p.x.sub(roadCenter(p.z)).toVar(); + const stripe = sub(1, smoothstep(0.042, 0.062, abs(abs(center).sub(0.14)))).toVar(); + const edge = sub(1, smoothstep(0.06, 0.085, abs(rx.sub(5.04)))).toVar(); + const wear = smoothstep(0.1, 0.35, noise2(p.xz.mul(26))).toVar(); + alb.assign(mix(alb, vec3(0.5, 0.36, 0.11), stripe.mul(wear).mul(0.85))); + alb.assign(mix(alb, vec3(0.49, 0.54, 0.5), edge.mul(wear).mul(0.6))); + }); + + If(water, () => { + alb.assign(uWater); + rough.assign(0.045); + }); + + color.assign(surfaceLight(p, n, rd.negate(), alb, rough)); + const fres = add(0.025, mul(0.975, pow(sub(1, max(dot(rd.negate(), n), 0)), 5))).toVar(); + const suv = clamp(vUv.add(vec2(n.x, n.z).mul(vec2(0.07, 0.05))), 0.002, 0.998).toVar(); + const refl = reflected( + p.add(n.mul(0.1)), + reflect(rd, n), + uSky.sample(vec2(suv.x, suv.y.oneMinus())).rgb, + ).toVar(); + const reflection = select( + water, + add(0.25, fres.mul(0.7)), + uWet.mul(0.05).add(puddle.mul(0.23)).add(fres.mul(0.5)), + ).toVar(); + color.assign(mix(color, refl, clamp(reflection, 0, 0.94))); + + // Tiny high-frequency impact coronas, strongest against dark wet tarmac. + + color.addAssign( + length(ripple) + .mul(vec3(0.055, 0.077, 0.09)) + .mul(uRain), + ); + mat.assign(select(water, 8, select(road, 7, 1))); + }).ElseIf(mat.greaterThan(0), () => { + n.assign(getNormal(p, t)); + + If(mat.greaterThan(5.5).and(mat.lessThan(6.5)), () => { + color.assign(uLampGlow); + }).Else(() => { + color.assign( + surfaceLight( + p, + n, + rd.negate(), + baseColor(p, mat, n), + select(mat.greaterThan(3.5), 0.28, 0.88), + ), + ); + }); + + const ao = clamp(p.y.sub(terrain(p.xz)).mul(0.09).add(0.5), 0.4, 1).toVar(); + + If(mat.notEqual(6), () => { + color.mulAssign(ao); + }); + }); + + If(mat.greaterThan(0).or(ground), () => { + // Height-dependent aerial perspective with advected rain curtains. + + const curtain = add( + 0.8, + mul(0.4, fbm2(p.xz.mul(0.012).add(vec2(uTime.mul(0.014), 0)))), + ).toVar(); + const fog = sub( + 1, + exp( + t + .negate() + .mul(add(0.0011, uFog.mul(0.0048))) + .mul(curtain) + .mul(exp(max(p.y, 0).negate().mul(0.008))), + ), + ).toVar(); + color.assign(mix(color, hazeColor(rd), clamp(fog, 0, 0.98))); + const d = max(0.15, t.mul(dot(rd, uForward))).toVar(); + worldDepth.assign(clamp(sub(1.00006, div(0.150009, d)), 0, 0.999999)); + }).Else(() => { + worldDepth.assign(1); + }); + + return vec4(max(color, vec3(0)), worldDepth); +}); diff --git a/packages/create-threenative/templates/rain/src/render/world.ts b/packages/create-threenative/templates/rain/src/render/world.ts new file mode 100644 index 0000000000..f51643d237 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/world.ts @@ -0,0 +1,146 @@ +// Generated for you. Ordinary Three.js, and the whole coastal look lives in this file. +// +// The coast is not a set of meshes. It is one raymarched distance field — terrain, the road down +// it, the double yellow line, the sea, the island, the forest, the six lamps, the rail, the cabin +// with its lit window and its antenna, the wet reflections and the rain ripples — evaluated in the +// fragment shader, which is why the whole thing is a single screen-sized quad. Its maths lives in +// `world-shader.ts`, which `tools/generate-shaders.mjs` transpiles from GLSL; this file +// owns the camera basis, the weather uniforms and the sky it reflects, and nothing else. +// +// Conventions: one world unit is one metre, +x is out to sea, -z runs down the coast. +import { + DoubleSide, + Mesh, + type PerspectiveCamera, + PlaneGeometry, + type Scene, + Vector3, +} from "three"; +import { cameraFar, cameraNear, clamp, float, positionGeometry, uv, vec4 } from "three/tsl"; +import { MeshBasicNodeMaterial } from "three/webgpu"; +import type { QualityName, Weather } from "../state.js"; +import { type ICloudPass, createCloudPass } from "./clouds.js"; +import { setupLighting } from "./lighting.js"; +import { setupMaterials } from "./materials.js"; +import { studyTier } from "./quality.js"; +import { setupSky } from "./sky.js"; +import { + tempestWorld, + uAspect, + uCam, + uFlash, + uFog, + uForward, + uRain, + uReflect, + uRight, + uStrike, + uTan, + uTime, + uUp, + uWet, +} from "./world-shader.js"; + +export interface IWeatherWorldOptions { + /** Absolute seconds, so the weather phase does not depend on how the frame was scheduled. */ + readonly elapsed: number; + /** Current flash envelope, already gated on the photosensitivity switch by the caller. */ + readonly flash: number; + readonly weather: Weather; + /** Where the last strike landed, in metres. Only visible while `flash` is above zero. */ + readonly strike: Vector3; + /** The player's tier: `quality.ts` decides whether it runs the 36-step reflection march. */ + readonly quality: QualityName; +} + +export interface IWeatherWorld { + /** The screen quad, for the engine's culling pass: see the engine's `alwaysRender`. */ + readonly quad: Mesh; + /** What the cloud pass really drew last frame: its target size and its march steps. */ + readonly clouds: ICloudPass["measured"]; + dispose(): void; + update(options: IWeatherWorldOptions): void; +} + +/** + * The cloud pass is built first because it is what the world samples: `createCloudPass` binds the + * pass's texture node to the world's `uSky`, and the world shader's own functions read that binding + * when this call below builds their graph. The order of these two lines is the whole dependency. + */ +export function createWeatherWorld(scene: Scene, camera: PerspectiveCamera): IWeatherWorld { + const clouds: ICloudPass = createCloudPass({ camera }); + // The look the two passes read: sky and haze colours, the sun and the flash light, and the lamp + // and sea colours. Set once; nothing here changes per frame. + setupSky(scene); + setupLighting(); + setupMaterials(); + + const world = tempestWorld(uv()); + // The shader's own depth is a curve fitted to the source's camera; the closed form is reversible, + // so the engine's near/far decide the real clip depth and anything drawn after this quad — rain, + // then lightning — is occluded by the coast rather than floating through it. + const viewDistance = float(0.150009).div(float(1.00006).sub(world.w)); + const material = new MeshBasicNodeMaterial(); + // The source driver drew its coast quad with both faces, so the vertex stage is written to flip + // the corner order; a single-face material would cull whichever way that lands. + material.side = DoubleSide; + material.vertexNode = vec4(positionGeometry.xy, 0, 1); + material.fragmentNode = world.xyz; + material.depthNode = clamp( + cameraNear + .add(cameraFar) + .sub(cameraNear.mul(cameraFar).mul(2).div(viewDistance)) + .div(cameraFar.sub(cameraNear)) + .add(1) + .mul(0.5), + 0, + 1, + ); + + const geometry = new PlaneGeometry(2, 2); + const quad = new Mesh(geometry, material); + // The quad's own bounds are two metres wide and sit at the origin, so both frustum tests would + // throw it away the moment the camera looks along the coast. + quad.frustumCulled = false; + scene.add(quad); + + const forward = new Vector3(); + const right = new Vector3(); + const up = new Vector3(); + + return { + quad, + clouds: clouds.measured, + dispose(): void { + scene.remove(quad); + geometry.dispose(); + material.dispose(); + clouds.dispose(); + }, + update({ elapsed, flash, quality, strike, weather }): void { + // The camera basis the shader raymarches from: the same three axes the renderer draws with, + // and the same field of view, so the coast lines up with anything the engine adds later. + camera.getWorldDirection(forward); + right.set(1, 0, 0).applyQuaternion(camera.quaternion); + up.set(0, 1, 0).applyQuaternion(camera.quaternion); + uCam.value.copy(camera.position); + uForward.value.copy(forward); + uRight.value.copy(right); + uUp.value.copy(up); + uAspect.value = camera.aspect; + uTan.value = Math.tan((camera.fov * Math.PI) / 360); + uTime.value = elapsed; + + uRain.value = weather.rain; + uFog.value = weather.fog; + uWet.value = weather.wet; + uFlash.value = flash; + uStrike.value.copy(strike); + uReflect.value = studyTier(quality).reflections ? 1 : 0; + + // The sky the shader draws and reflects is the cloud target, so the same weather that steers + // the rain also lights the ceiling, and a flash reaches both from one value. + clouds.update({ elapsed, flash, quality, strike, weather }); + }, + }; +} diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts new file mode 100644 index 0000000000..f61ec7a8e9 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -0,0 +1,809 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. Delete or +// rewrite it freely — every default in here is a starting point, not a framework look. +// +// `WorldEnvironment` is Godot's name for the node that says which lighting effects a scene +// runs and how strong they are, and the property names below are Godot's `Environment` +// properties where Godot has one (`ssr_enabled`, `glow_enabled`, `tonemap_mode`) and +// Three.js's node names where it does not (`ssgi`, `gtao`). Nothing here is a new word. +// +// What it owns: building the stage factories and handing them to +// `renderer.createRenderChain()`, which decides the order stages run in, whether each can +// run on this target, and prints `TN_RENDER_CHAIN` naming every stage as applied or +// refused **with a reason** — so a stage that silently no-op'd is never mistaken for one +// you turned off. What it does not own: any colour, and any strength that is not a quality +// tier. Those are arguments, supplied by `postprocessing.ts`, yours to change. + +import { + ACESFilmicToneMapping, + AgXToneMapping, + type Camera, + type DirectionalLight, + NeutralToneMapping, + type PerspectiveCamera, + type Scene, +} from "three"; +import { bloom } from "three/addons/tsl/display/BloomNode.js"; +import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; +import { fxaa } from "three/addons/tsl/display/FXAANode.js"; +import { ao } from "three/addons/tsl/display/GTAONode.js"; +import { godrays } from "three/addons/tsl/display/GodraysNode.js"; +import { smaa } from "three/addons/tsl/display/SMAANode.js"; +import { ssgi } from "three/addons/tsl/display/SSGINode.js"; +import { ssr } from "three/addons/tsl/display/SSRNode.js"; +import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; +import { + color, + convertToTexture, + float, + max, + metalness, + mrt, + normalView, + output, + pass, + roughness, + screenUV, + smoothstep, + vec2, +} from "three/tsl"; +import type { Node } from "three/webgpu"; + +/** Godot's `Viewport.screen_space_aa`: which post-process antialiasing smooths the frame's edges. */ +export type ScreenSpaceAA = "smaa" | "fxaa" | "disabled"; + +/** Godot's `Environment.tonemap_mode`, with the modes Three.js actually ships. */ +export type TonemapMode = "aces" | "agx" | "neutral"; + +/** + * SSGI sample budget. The three tiers are the presets SSGINode's own documentation + * recommends for the temporally-filtered case, not numbers invented here. An unknown + * tier throws rather than quietly becoming the default. + */ +export type SsgiQuality = "low" | "medium" | "high"; + +const SSGI_QUALITY: Record = { + low: { sliceCount: 1, stepCount: 12 }, + medium: { sliceCount: 2, stepCount: 8 }, + high: { sliceCount: 3, stepCount: 16 }, +}; + +const TONEMAP: Record = { + aces: ACESFilmicToneMapping, + agx: AgXToneMapping, + neutral: NeutralToneMapping, +}; + +export interface IWorldEnvironmentOptions { + /** Screen-space indirect diffuse light. Godot calls this `ssil_enabled`. */ + readonly ssgiEnabled?: boolean; + readonly ssgiQuality?: SsgiQuality; + /** How much of the gathered indirect light reaches the frame. Godot's `ssil_intensity`. */ + readonly ssgiIntensity?: number; + /** How far, in world units, indirect light is gathered from. Room scale, not contact scale. */ + readonly ssgiRadius?: number; + /** Screen-space reflections. Godot's `ssr_enabled`. */ + readonly ssrEnabled?: boolean; + /** + * How far, in world units, a reflection ray is traced. + * + * `SSRNode` defaults this to **1**, which is one metre. On any scene larger than a + * tabletop that reads as "screen-space reflections are on and do nothing" — the ray + * terminates before it reaches the thing that should be standing in the floor. + */ + readonly ssrMaxDistance?: number; + /** + * Fraction of the display resolution the reflection is traced at. + * + * A reflection in a rough surface carries almost no high-frequency detail, so tracing it + * at half resolution costs a quarter of the rays and is very hard to see in the result. + */ + readonly ssrResolutionScale?: number; + /** Spatial denoise over the SSGI result. Off is noisier and cheaper. */ + readonly denoiseEnabled?: boolean; + /** Raymarched shafts. Needs `godraysLight`, and that light must cast shadows. */ + readonly godraysEnabled?: boolean; + /** How dense the participating medium is. Higher is foggier — and the whole frame brighter. */ + readonly godraysDensity?: number; + /** + * Haze below this value is discarded instead of added to the frame. + * + * Without it the pass is a uniform brightener rather than a shaft renderer: it returns a + * non-zero value for nearly every pixel, and that "something" is tiny in linear space and + * enormous after the tone curve, which lifts shadows hardest. Subtracting a floor keeps + * the air inside a real beam and discards the ambient lift. + */ + readonly godraysFloor?: number; + /** Multiplier applied after the floor, so beams can be bright without the haze returning. */ + readonly godraysIntensity?: number; + /** + * Raymarch steps per pixel. `GodraysNode` defaults to 60. + * + * Every step is a shadow-map sample, so this multiplies directly into the cost of the + * whole pass. The pass jitters its sample positions, so a lower count trades a hard shaft + * edge for a slightly noisier one rather than for banding — which the denoiser then + * largely absorbs. + */ + readonly godraysSteps?: number; + /** + * Ceiling on shaft brightness. + * + * This is also the single strongest control over how bright the *whole* scene looks: the + * pass adds its accumulated illumination to every pixel, not only to the pixels inside a + * visible beam, so raising it raises the shadow floor of the entire frame. + */ + readonly godraysMaxDensity?: number; + /** + * Ground-truth ambient occlusion, at contact scale. Godot's `ssao_enabled`. + * + * Deliberately a *second* occlusion term next to the one SSGI already produces. SSGI's AO + * is gathered over `ssgiRadius` — metres — the right scale for "this hall is enclosed" + * and the wrong one for "this foot is standing on this floor". This pass runs at a radius + * of centimetres and multiplies on top. + */ + readonly gtaoEnabled?: boolean; + /** Occlusion gather radius in world units. Contact scale, not room scale. */ + readonly gtaoRadius?: number; + /** Exponent on the occlusion term: higher is a darker, tighter contact. */ + readonly gtaoScale?: number; + /** Directions sampled per pixel. Cost is linear in this. */ + readonly gtaoSamples?: number; + /** Fraction of display resolution the occlusion is traced at. */ + readonly gtaoResolutionScale?: number; + /** + * The fraction of the frame SSGI actually gathers at. Half by default. + * + * `GTAONode` and `SSRNode` both carry a `resolutionScale` of their own; **`SSGINode` does not**, + * and its `setSize` passes width and height straight into its render target with no factor. So + * screen-space GI — a low-frequency effect every production renderer gathers at half or quarter + * res — was the only stage in this chain running at full resolution, and it cost accordingly. + * + * Measured on a 46,190-instance forest at 1600x900 on an RTX 2080, with the resolution scaler + * left on auto so the engine's own verdict is part of the result: + * + * fps still/walk scale it settled on interior median dead shadow + * full res 38.0 / 47.2 0.44 704x396 0.0299 19.19% + * off 60.0 / 60.0 0.61 976x549 0.0987 4.54% + * half res 59.8 / 60.0 0.72 1152x648 0.0301 18.58% + * + * Half res is better than both: it holds the budget like switching SSGI off, keeps the look + * within noise of full res, and produces the *sharpest* frame of the three — because the budget + * it hands back is spent by the scaler on pixels rather than on a gather nobody can see at full + * rate. Full-res SSGI was not buying quality, it was trading it. + * + * Fixed without touching three: `setSize` is public and the pass drives the node through it with + * the full frame size, so the instance is wrapped to hand it a smaller one. The node's own maths + * reads the `_resolution` uniform that `setSize` writes, so it stays self-consistent, and the + * result is sampled by UV rather than by texel — which is what makes the upsample free and is + * exactly the mechanism `GTAONode` uses internally. + */ + readonly ssgiResolutionScale?: number; + /** + * Contrast-adaptive sharpening (RCAS) over the finished frame. + * + * Everything upstream of it blurs: the GI gather is denoised, the reflection is traced at + * reduced resolution and roughness-blurred. Each is worth its softness on its own and the + * sum is a frame with no micro-detail left. RCAS is contrast-adaptive, so it puts the + * edges back without ringing the soft areas. + */ + readonly sharpenEnabled?: boolean; + /** RCAS sharpness. **0 is maximum sharpening and 2 is none** — it is a radius, not a gain. */ + readonly sharpenStrength?: number; + /** Corner darkening, as a fraction removed at the extreme corner. Zero disables it. */ + readonly vignetteAmount?: number; + /** + * Post-process antialiasing, run last. Godot's `Viewport.screen_space_aa`. Default `"smaa"`. + * + * Not optional in practice: the chain renders `pass(scene, camera)` single-sampled, so the moment + * any stage runs, the renderer's own multisampling no longer reaches the frame and every edge + * stairsteps — the single loudest "this is a prototype" signal in a frame. It is requested only + * when some other stage (or a `baseColour`) installs a chain; with nothing installed the renderer + * draws straight to the canvas with its own MSAA and this would be paying twice. + */ + readonly screenSpaceAA?: ScreenSpaceAA; + /** Godot's `glow_enabled`. */ + readonly bloomEnabled?: boolean; + readonly bloomStrength?: number; + /** How far a bright pixel bleeds, as a fraction of the screen. */ + readonly bloomRadius?: number; + /** Luminance a pixel must exceed before it blooms at all. */ + readonly bloomThreshold?: number; + /** + * Which of this kit's authored stages to run. + * + * Separate from the graph below, and not for tidiness: the chain has to know whether anything + * was requested at all before the scene pass exists — with nothing requested it sets an + * exposure and returns, and no pass is ever built. So the request is a static decision the kit + * makes from its own config, and only the graph needs the pass. + */ + readonly authoredStageNames?: readonly string[]; + /** + * This kit's own stages, composed after the built-ins. + * + * A factory because an authored stage needs the two things only this file can produce: the + * scene pass's depth texture node, which does not exist until the pass is built, and the + * resolved tier. It returns the complete graph — including stages left dormant this frame — so + * that an anchor like `watercolor after kuwahara` keeps naming a stage that exists. + * + * Naming specific stages here instead — an `outlineEnabled`, a `kuwaharaRadius` — is what put + * one kit's look inside the plumbing every kit copies verbatim, which + * `shared-render-sources.spec.ts` exists to refuse. + */ + readonly authoredStages?: (context: IWorldEnvironmentStageContext) => readonly ChainStage[]; + /** Quality tier passed to the measured chain; the quality module owns this value. */ + readonly renderChainTier?: ChainTier; + readonly tonemapMode?: TonemapMode; + /** + * Scene-referred exposure: multiplied into the scene pass **before** the tone curve, so + * the GI gather, reflections and bloom threshold all see the exposed image and the bloom + * threshold means the same thing at any exposure. + * + * `renderer.toneMappingExposure` is live too (measured on three@0.185.1 — see + * `docs/verification/exposure-ab-2026-08-30.md`), and applies at the same point in the + * graph, but only when no output node is installed. When every stage here is off, this + * class falls back to that scalar; when any stage runs, this multiply is the shutter. + */ + readonly exposure?: number; +} + +export interface IWorldEnvironmentTarget { + /** The directional light godrays are raymarched against. Required only by `godraysEnabled`. */ + readonly godraysLight?: DirectionalLight; + /** + * Composes the colour the chain starts from, given the scene pass it renders. + * + * The chain builds `pass(scene, camera)` itself, so a game that composes something onto the + * beauty pass before any stage runs — aerial perspective, read against that pass's own view-Z, + * is the one in these templates — has nowhere else to put it. The node returned here is the + * game's; the chain only decides where it is spliced in, which is first, ahead of exposure and + * every stage. + */ + readonly baseColour?: (scenePass: ReturnType) => Node<"vec4">; +} + +/** + * What travels down the chain: the composed colour, as TSL's proxied vec4 node — the thing + * that carries `.mul`, `.add`, `.rgb` and `.r`. Every stage takes one and returns one, so the + * compiler checks the composition instead of taking a cast's word for it. + */ +type ChainNode = Node<"vec4">; +/** + * The scene pass's depth texture, as the pass itself types it. + * + * Named through `pass()` rather than written out because the addon's return type is the only + * honest spelling of it, and a hand-written alias here would drift the first time three changes it. + */ +export type DepthTextureNode = ReturnType["getTextureNode"]>; +type ChainTier = "high" | "medium" | "low" | "off"; +type ChainContext = { + readonly tier: ChainTier; +}; +/** + * What a kit's `authoredStages` factory is handed. The depth node is the scene pass's, so a + * stage that reads depth gets the same texture the built-in stages read rather than its own. + */ +export interface IWorldEnvironmentStageContext { + readonly depthNode: DepthTextureNode; + readonly tier: ChainTier; +} + +export type ChainStage = { + readonly name: string; + readonly before?: string; + readonly after?: string; + readonly available?: (context: ChainContext) => boolean | string; + readonly dispose?: () => void; + readonly minimumTier?: ChainTier; + /** The chain plumbing is stage-agnostic, so it names the node it carries `unknown`. */ + readonly build: (input: unknown, context: ChainContext) => unknown; +}; + +/** + * The one seam between the chain's `unknown` and this file's node algebra. + * + * What the chain hands a stage is the colour composed so far — the previous stage's return, or + * the exposed scene pass for the first one — and that is always a `ChainNode`. Saying so once, + * here, is what lets every stage below be written and *checked* as vec4-in, vec4-out instead of + * asserting its way through each expression. The wrapper adds no node to the graph. + */ +function stage(definition: { + readonly name: ChainStage["name"]; + readonly dispose?: () => void; + readonly available?: (context: ChainContext) => boolean | string; + readonly build: (input: ChainNode, context: ChainContext) => ChainNode; +}): ChainStage { + return { + ...definition, + build: (input, context) => definition.build(input as ChainNode, context), + }; +} + +/** + * `three/addons` declares each TSL stage as its raw node class, and `DenoiseNode` is the only + * one in this chain whose declaration omits the element API (`.mul`, `.rgb`, `.r`) that the + * proxied node object it actually hands back at runtime carries — every other stage here is a + * `ChainNode` on its own, checked by the compiler. This is an assertion and nothing else: no + * conversion runs, no node is added to the graph, so it cannot move a pixel. + */ +function denoised(node: ReturnType): ChainNode { + return node as unknown as ChainNode; +} +export type OutputRenderer = { + kind: string; + raw: unknown; + /** + * Installs a node graph directly. Only reached when a `baseColour` is composed and every + * stage is off: the chain installs nothing for an empty stage list, and dropping the game's + * own composition on the floor would be the silent no-op this class exists to prevent. + */ + setOutputNode?: (node: unknown) => void; + createRenderChain?: (options: { + input?: unknown; + worldPass?: unknown; + request?: { stages?: readonly string[]; tier?: ChainContext["tier"] }; + stages?: readonly ChainStage[]; + }) => { + applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; + dispose(): void; + }; +}; + +/** + * Builds a Godot-named lighting chain and installs it through the renderer's render chain. + * + * Every stage a game turns on reports itself: the chain prints `TN_RENDER_CHAIN` naming + * each requested stage as applied or refused with a reason that is never blank. A game with + * GI switched off on a WebGL fallback looks exactly like a game that chose not to use it — + * unless it reads that line. + */ +export class WorldEnvironment { + readonly #options: Required; + + constructor(options: IWorldEnvironmentOptions = {}) { + const quality = options.ssgiQuality ?? "medium"; + if (SSGI_QUALITY[quality] === undefined) { + // Fail closed. A typo'd tier that silently became "medium" is a quality setting + // nobody can trust afterwards. + throw new Error( + `Unknown ssgiQuality '${String(quality)}'. Use one of: ${Object.keys(SSGI_QUALITY).join(", ")}.`, + ); + } + const tonemapMode = options.tonemapMode ?? "aces"; + if (TONEMAP[tonemapMode] === undefined) { + throw new Error( + `Unknown tonemapMode '${String(tonemapMode)}'. Use one of: ${Object.keys(TONEMAP).join(", ")}.`, + ); + } + const renderChainTier = options.renderChainTier ?? "high"; + if (!isChainTier(renderChainTier)) { + throw new Error( + `Unknown render chain tier '${String(renderChainTier)}'. Use high, medium, low, or off.`, + ); + } + this.#options = { + // A kit with no stages of its own is the common case, so both default to empty rather + // than to a branch every call site has to remember. + authoredStageNames: options.authoredStageNames ?? [], + authoredStages: options.authoredStages ?? (() => []), + ssgiEnabled: options.ssgiEnabled ?? false, + ssgiQuality: quality, + ssgiIntensity: options.ssgiIntensity ?? 1, + ssgiRadius: options.ssgiRadius ?? 12, + ssrEnabled: options.ssrEnabled ?? false, + ssrMaxDistance: options.ssrMaxDistance ?? 30, + ssrResolutionScale: options.ssrResolutionScale ?? 0.5, + denoiseEnabled: options.denoiseEnabled ?? true, + godraysEnabled: options.godraysEnabled ?? false, + godraysDensity: options.godraysDensity ?? 0.7, + godraysFloor: options.godraysFloor ?? 0, + godraysIntensity: options.godraysIntensity ?? 1, + godraysSteps: options.godraysSteps ?? 60, + godraysMaxDensity: options.godraysMaxDensity ?? 0.5, + gtaoEnabled: options.gtaoEnabled ?? false, + gtaoRadius: options.gtaoRadius ?? 0.25, + gtaoScale: options.gtaoScale ?? 1, + gtaoSamples: options.gtaoSamples ?? 16, + gtaoResolutionScale: options.gtaoResolutionScale ?? 1, + ssgiResolutionScale: options.ssgiResolutionScale ?? 0.5, + sharpenEnabled: options.sharpenEnabled ?? false, + sharpenStrength: options.sharpenStrength ?? 0.2, + vignetteAmount: options.vignetteAmount ?? 0, + screenSpaceAA: options.screenSpaceAA ?? "smaa", + bloomEnabled: options.bloomEnabled ?? true, + bloomStrength: options.bloomStrength ?? 0.7, + bloomRadius: options.bloomRadius ?? 0.5, + bloomThreshold: options.bloomThreshold ?? 0.2, + renderChainTier, + tonemapMode, + exposure: options.exposure ?? 1, + }; + } + + /** + * Installs the chain. Returns what actually ran — the same applied report the chain + * printed — so a scene can assert on it instead of scraping the console. + */ + apply( + renderer: OutputRenderer, + scene: Scene, + camera: Camera, + target: IWorldEnvironmentTarget = {}, + ): { + dropped: readonly { name: string; reason: string }[]; + stages: readonly string[]; + dispose?: () => void; + } { + const options = this.#options; + const raw = renderer.raw as { toneMapping?: number; toneMappingExposure?: number }; + raw.toneMapping = TONEMAP[options.tonemapMode]; + + const requested: string[] = ( + ["ssgi", "ambientOcclusion", "godRays", "ssr", "sharpen", "bloom", "vignette"] as const + ).filter( + (name) => + (name === "ssgi" && options.ssgiEnabled) || + (name === "ambientOcclusion" && options.gtaoEnabled) || + (name === "godRays" && options.godraysEnabled) || + (name === "ssr" && options.ssrEnabled) || + (name === "sharpen" && options.sharpenEnabled) || + (name === "bloom" && options.bloomEnabled) || + (name === "vignette" && options.vignetteAmount > 0), + ); + requested.push(...options.authoredStageNames); + if ( + options.screenSpaceAA !== "disabled" && + (requested.length > 0 || target.baseColour !== undefined) + ) + requested.push("antialias"); + + // With no stage running there is no node graph to install — the renderer's own + // tone-mapping path renders the frame, and the exposure scalar is live there (measured: + // `docs/verification/exposure-ab-2026-08-30.md`). With any stage running, exposure is + // applied as a multiply on the scene pass instead: scene-referred, so every downstream + // stage sees the exposed image and the bloom threshold means the same thing at any + // exposure. Both land at the same point in the graph — before the tone curve. + if (requested.length === 0 && target.baseColour === undefined) { + raw.toneMappingExposure = options.exposure; + this.#reportApplied([], []); + return { dropped: [], stages: [] }; + } + raw.toneMappingExposure = 1; + + const scenePass = pass(scene, camera); + // Normals come from the multi-render-target alongside the colour. SSGI can derive them + // from depth alone, but the derived normal is flat across a triangle, which shows up as + // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and + // roughness ride along so SSR knows which pixels are supposed to reflect. + if (options.ssgiEnabled || options.ssrEnabled || options.gtaoEnabled) { + scenePass.setMRT(mrt({ output, normal: normalView, metalness, roughness })); + } + // Requested lazily, and this is not a micro-optimisation. **Asking a pass for `normal`, + // `metalness` or `roughness` is what creates the extra render target.** A tier that runs none + // of the stages needing them — the mobile look is bloom and the tone curve — must not ask, or + // the pass carries a colour target no fragment shader writes and WebGPU refuses the pipeline: + // *"Color target has no corresponding fragment stage output but writeMask … is not zero. + // While validating targets[1]"*. The frame then comes out **black** while the chain still + // reports every stage as applied, which is the worst shape a failure can take here. + const nodes = new Map>(); + const textureNode = (name: string): ReturnType => { + const cached = nodes.get(name); + if (cached !== undefined) return cached; + const node = scenePass.getTextureNode(name); + nodes.set(name, node); + return node; + }; + const depth = (): ReturnType => textureNode("depth"); + const normal = (): ReturnType => textureNode("normal"); + const metal = (): ReturnType => textureNode("metalness"); + const rough = (): ReturnType => textureNode("roughness"); + // Every stage here is perspective-camera maths — view-space reconstruction from depth, + // and a projection matrix inverse. The scene's camera is the one the pass rendered with. + const view = camera as PerspectiveCamera; + + const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); + const exposed = convertToTexture(base).mul(options.exposure); + const giDenoise = (node: ChainNode): ChainNode => + options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; + + let releaseBloom = (): void => {}; + const stages: ChainStage[] = [ + stage({ + name: "ssgi", + build: (input) => { + const gi = ssgi(input, depth(), normal(), view); + const tier = SSGI_QUALITY[options.ssgiQuality]; + gi.sliceCount.value = tier.sliceCount; + gi.stepCount.value = tier.stepCount; + gi.radius.value = options.ssgiRadius; + // Gather at a fraction of the frame. See `ssgiResolutionScale` above for why this is a + // wrapper rather than a property: the node has no scale of its own, and `setSize` is the + // public seam the pass drives it through. `setSize` is present on the node and absent + // from its type declarations, so the seam is named here rather than cast away at the call + // site — if a future three release declares it, or gives the node a `resolutionScale`, + // this narrows to nothing and the wrapper can go. Guarded at one texel so a zero or a + // negative cannot ask for an empty target: a 0x0 gather returns black and reads as SSGI + // simply not working. + const ssgiScale = options.ssgiResolutionScale; + const sizedGi = gi as unknown as { + setSize?: (width: number, height: number) => void; + }; + const fullGiSize = sizedGi.setSize?.bind(gi); + if (ssgiScale > 0 && ssgiScale < 1 && fullGiSize !== undefined) { + sizedGi.setSize = (width: number, height: number): void => { + fullGiSize( + Math.max(1, Math.round(width * ssgiScale)), + Math.max(1, Math.round(height * ssgiScale)), + ); + }; + } + + // SSGI writes two targets: AO in a single-channel `RedFormat` texture, and the GI + // term in an RGB one. Multiplying the beauty pass by the whole AO vec4 therefore + // zeroes green and blue and the entire scene renders monochrome red — take `.r` + // for the scalar and `.rgb` for the colour. + // + // No albedo buffer here, so the beauty pass stands in for it: occlusion darkens + // what is already lit, and the gathered indirect light is tinted by the surface it + // lands on. That is the standard approximation, and it is why a red wall tints a + // white floor at all. `ssgiIntensity` is the game's dial on that second term — + // gathered light is unbounded, and a small white room at 1.0 blows out. + const occlusion = giDenoise(gi.getAONode()); + const indirect = giDenoise(gi.getGINode()); + return input.mul(occlusion.r).add(input.mul(indirect.rgb).mul(options.ssgiIntensity)); + }, + }), + stage({ + name: "ambientOcclusion", + build: (input) => { + const contact = ao(depth(), normal(), view); + contact.radius.value = options.gtaoRadius; + contact.scale.value = options.gtaoScale; + contact.samples.value = options.gtaoSamples; + contact.resolutionScale = options.gtaoResolutionScale; + // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and + // multiplying by the vec4 would zero green and blue and render the scene red. + // Applied before SSGI by the chain's canonical order; composing it after the GI + // combine instead darkens crevices the gather re-lit — that is a look choice, and + // this file is where you make it. + // + // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved + // sampling otherwise reads as a speckled halo around every contact — around feet on a + // plain floor most of all — which is exactly the "not smooth" a player notices first. + const occlusion = options.denoiseEnabled + ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) + : contact; + return input.mul(occlusion.r); + }, + }), + stage({ + name: "godRays", + available: () => { + // Godrays are raymarched against the light's shadow map: the shaft is the volume + // the shadow map reports as lit. A light that casts no shadow yields a black pass, + // which would read as "godrays are on and do nothing" — so refuse it by name. + const light = target.godraysLight; + if (light === undefined) return "godraysEnabled is true but no godraysLight was passed"; + if (light.castShadow !== true) + return `light '${light.name || "sun"}' does not cast shadows`; + // `castShadow` is a request, not a result: three allocates the shadow map on the first + // render that needs it, and `GodraysNode` reads `shadow.map.depthTexture` while the + // graph is built. Without this check that read throws inside TSL, the chain build + // fails, and *every* stage is lost — the frame comes back ungraded with no SSGI, no + // SSR and no tonemap, which looks like a scene problem rather than a missing map. + // Refusing by name keeps the rest of the chain, which is this stage list's contract. + if (light.shadow?.map == null) + return `light '${light.name || "sun"}' has no shadow map yet: set renderer.shadowMap.enabled on the raw renderer (ctx.renderer.raw), and build the chain after the first frame has rendered`; + return true; + }, + build: (input) => { + const light = target.godraysLight as DirectionalLight; + const shafts = godrays(depth(), view, light); + shafts.density.value = options.godraysDensity; + shafts.maxDensity.value = options.godraysMaxDensity; + shafts.raymarchSteps.value = options.godraysSteps; + // The pass jitters its samples with interleaved noise; at low step counts the + // noise renders as a regular lattice over every hazy pixel. The bilateral + // denoiser built for the GI gather smooths exactly that — high-frequency, + // depth-coherent — and the shaft edges survive because they sit on geometry + // silhouettes the depth term owns. Denoise before the floor: the floor's + // subtraction amplifies relative noise on near-floor values. + let shaft: ChainNode = convertToTexture(shafts); + if (options.denoiseEnabled) shaft = denoised(denoise(shaft, depth(), normal(), view)); + // Floor, then scale, then tint. The floor is what turns this from a whole-frame + // brightener into a shaft renderer; the tint by the light's own colour is what + // stops a warm sun throwing a white beam. + const cleaned = shaft.rgb.sub(options.godraysFloor).max(0).mul(options.godraysIntensity); + return input.add(cleaned.mul(color(light.color))); + }, + }), + stage({ + name: "ssr", + build: (input) => { + // Three defaults that together make SSR silently do nothing on a real scene, all + // fixed here and each worth stating: + // maxDistance defaults to 1 world unit — a ray that dies after a metre + // reflectNonMetals defaults to false, so a polished *stone* floor never reflects + // roughnessNode null means every surface is treated as a perfect mirror + // `normal`, not `normal.xyz`. SSR calls `.sample()` on this node, so it needs the + // whole TextureNode; a swizzle produces a plain vec3 and the pass dies at shader + // build. `@types/three` declares the parameter as `Node<"vec3">`, which is what + // makes the swizzle look correct — the types are wrong here and the runtime is + // right. Both `colorNode` and `normalNode` are `.sample()`d inside the pass, so + // the composed input must be materialised ONCE and that texture reused on both + // sides of the add: passing the unmaterialised node to the pass while adding the + // reflections onto a second copy builds two parallel graphs, and the frame comes + // out as the bare background colour. + const base = convertToTexture(input); + const reflections = ssr(base, depth(), normal() as unknown as Parameters[2], { + camera: view, + metalnessNode: metal().r, + roughnessNode: rough().g, + reflectNonMetals: true, + }); + reflections.maxDistance.value = options.ssrMaxDistance; + reflections.resolutionScale = options.ssrResolutionScale; + return base.add(reflections); + }, + }), + stage({ + name: "sharpen", + build: (input) => { + // Last of the image stages but one, because RCAS is defined on the finished + // picture: it looks at the contrast around a pixel and sharpens in proportion to + // it, so running it before bloom would sharpen edges that bloom then spreads + // back out. The third argument keeps the node from materialising its own render + // target; the chain's graph is fused instead. + return sharpen(convertToTexture(input), options.sharpenStrength, false); + }, + }), + stage({ + name: "bloom", + dispose: () => { + releaseBloom(); + releaseBloom = () => {}; + }, + build: (input) => { + const texture = convertToTexture(input); + const effect = bloom( + texture, + options.bloomStrength, + options.bloomRadius, + options.bloomThreshold, + ); + releaseBloom = () => { + effect.dispose(); + // convertToTexture may return its input. Only the newly allocated RTT is ours. + const scratch = texture as unknown as { + isRTTNode?: boolean; + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + if (texture !== input && scratch.isRTTNode === true) { + scratch.renderTarget.dispose(); + scratch._quadMesh.material.dispose(); + } + }; + return input.add(effect); + }, + }), + stage({ + name: "vignette", + build: (input) => { + // Radius measured from the centre in screen-diagonal units, so the corner is 1 + // and the falloff does not change shape when the window does. It starts at 0.55 — + // well outside the subject in a 16:9 frame — so the vignette is a corner, not a + // spotlight. + const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); + return input.mul(fall.oneMinus()); + }, + }), + { + // Last, after every built-in: antialiasing is defined on the finished picture, and any + // stage after it would put the stairsteps back. + name: "antialias", + after: "gradualBackground", + build: (input) => { + const colour = input as ChainNode; + // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is + // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 + // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) + // for the filter and expands it back afterwards, so edges resolve without the halo. + const peak = max(colour.r, max(colour.g, colour.b)); + const squeezed = colour.div(peak.add(1)); + const filtered = (options.screenSpaceAA === "fxaa" + ? fxaa(squeezed) + : smaa(squeezed)) as unknown as ChainNode; + const back = max(filtered.r, max(filtered.g, filtered.b)); + return filtered.div(float(1).sub(back).max(1e-4)); + }, + }, + ]; + + // The kit's own stages, composed after the built-ins. The factory is called with the two + // things only this file can produce — the scene pass's depth texture and the resolved tier — + // and returns the complete authored graph plus the subset to run. + stages.push(...options.authoredStages({ depthNode: depth(), tier: options.renderChainTier })); + + // A composed base colour with every stage off still has to reach the frame. The chain + // installs nothing for an empty stage list, so this is the one path that goes direct. + if (requested.length === 0) { + if (renderer.kind !== "webgpu") { + this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); + return { dropped: [], stages: [] }; + } + if (renderer.setOutputNode === undefined) + throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); + renderer.setOutputNode(exposed); + this.#reportApplied([], []); + return { dropped: [], stages: [] }; + } + + if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); + const chain = renderer.createRenderChain({ + input: exposed, + request: { stages: requested, tier: options.renderChainTier }, + stages, + worldPass: scenePass, + }); + this.#reportApplied(chain.applied.stages, chain.applied.dropped); + return { + dropped: chain.applied.dropped, + stages: chain.applied.stages, + dispose: () => { + chain.dispose(); + scenePass.dispose(); + }, + }; + } + + /** + * Prints `TN_WORLD_ENVIRONMENT`: every stage this class can run, named as applied or refused + * **with a reason that is never blank**, plus the tone curve and exposure that framed them. + * + * The chain prints its own line for the stages it installed, and it prints nothing at all when + * nothing was requested — which is exactly the case where a reader most needs to know that GI + * is off because this file says so rather than because a node silently no-op'd. This line is + * emitted every run, including the run where every stage is off. + */ + #reportApplied( + applied: readonly string[], + dropped: readonly { name: string; reason: string }[], + ): void { + const options = this.#options; + const off: Record = { + ambientOcclusion: "gtaoEnabled is false", + antialias: 'screenSpaceAA is "disabled", or no other stage installed a chain', + bloom: "bloomEnabled is false", + godRays: "godraysEnabled is false", + sharpen: "sharpenEnabled is false", + ssgi: "ssgiEnabled is false", + ssr: "ssrEnabled is false", + vignette: "vignetteAmount is 0", + kuwahara: "kuwaharaEnabled is false", + outline: "outlineEnabled is false", + watercolor: "watercolorEnabled is false", + }; + const stages = Object.keys(off) + .sort() + .map((name) => { + if (applied.includes(name)) return { applied: true, name }; + const refused = dropped.find((entry) => entry.name === name); + return { applied: false, name, reason: refused?.reason ?? off[name] }; + }); + console.info( + `TN_WORLD_ENVIRONMENT:${JSON.stringify({ + denoise: options.denoiseEnabled, + exposure: options.exposure, + marker: "TN_WORLD_ENVIRONMENT", + screenSpaceAA: options.screenSpaceAA, + ssgiQuality: options.ssgiQuality, + stages, + tonemapMode: options.tonemapMode, + })}`, + ); + } +} + +function isChainTier(value: unknown): value is ChainTier { + return value === "high" || value === "medium" || value === "low" || value === "off"; +} diff --git a/packages/create-threenative/templates/rain/src/scenes/Boot.ts b/packages/create-threenative/templates/rain/src/scenes/Boot.ts new file mode 100644 index 0000000000..706638505b --- /dev/null +++ b/packages/create-threenative/templates/rain/src/scenes/Boot.ts @@ -0,0 +1,334 @@ +// Generated for you. The one scene: a coast you can walk, and an atmosphere you can reshape. +import { type ICtx, Scene, type SceneFrame, alwaysRender, isMobile } from "@threenative/core"; +import { type PerspectiveCamera, Vector3 } from "three"; +import { STORM_AUDIO_ENTITY, createStormAudio } from "../audio/storm.js"; +import { createCameraRig, setupCamera } from "../render/camera.js"; +import { type IStormLightning, createStormLightning } from "../render/lightning.js"; +import { createLoadingScreen } from "../render/loading.js"; +import { type IStormPost, setupPost } from "../render/postprocessing.js"; +import { type IStormRain, createStormRain } from "../render/rain.js"; +import { createWeatherWorld } from "../render/world.js"; +import { + AUTO_STRIKE_CLOUD, + type GameState, + MAX_STEP, + type SimStatus, + WEATHER_KEYS, + type Weather, + easeWeather, + flashAt, + intentPatch, + thunderDelay, +} from "../state.js"; + +export type WeatherCtx = ICtx; + +/** Seconds of weather the clock starts on, so the first frame is not the start of the storm. */ +const START_TIME = 28; +/** The storm, this far in, is not waiting for the first automatic strike. */ +const FIRST_STRIKE_DELAY = 9; +const STRIKE_MIN_GAP = 8; +const STRIKE_SPREAD = 16; +/** Below this gap the atmosphere is where it was aimed, and the status can say so. */ +const SETTLED = 0.004; +/** Where a strike lands, in the game's own coordinates: out to sea, ahead of the coast. */ +const STRIKE_SITE = { maxX: 49, minX: -45, nearZ: -250, spread: 220 } as const; + +const STORM: Weather = { + cloud: 0.76, + exposure: 1.12, + fog: 0.4, + rain: 0.76, + wet: 1, + wind: 0.55, +}; + +export class Coast extends Scene { + // Kept on the instance because `enter` builds them and `exit` is the only hook that runs when the + // scene goes away: a dropped handle is a shader chain and a geometry that never come back. + #rain: IStormRain | undefined; + #post: IStormPost | undefined; + #lightning: IStormLightning | undefined; + #measureClouds: (() => GameState["cloudPass"]) | undefined; + #curtainLifted: (() => boolean) | undefined; + + static override readonly initialState: GameState = { + audioEnabled: false, + autoLightning: true, + cameraReset: false, + cinematic: false, + cloudPass: { height: 0, steps: 0, width: 0 }, + droplets: true, + dropCount: 0, + elapsed: START_TIME, + flash: 0, + fps: 0, + frame: 0, + frozen: false, + heading: 0, + helpOpen: false, + muted: false, + paused: false, + lastStrike: { at: -1, delay: 0, metres: 0 }, + loading: { progress: 0, ready: false }, + pendingThunderAt: -1, + position: { x: 0, y: 0, z: 0 }, + preset: "storm", + quality: "balanced", + safe: false, + status: "steady", + stepRequest: 0, + strikeRequested: false, + strikes: 0, + target: { ...STORM }, + uiHidden: false, + uiReady: false, + weather: { ...STORM }, + }; + + override enter(ctx: WeatherCtx): SceneFrame { + const camera = ctx.camera as PerspectiveCamera; + setupCamera(camera); + const rig = createCameraRig(camera); + // The study's player is the fly camera, so that is the subject a playtest moves and measures. + ctx.entities.add("player", camera); + const loading = createLoadingScreen(ctx); + // The storm's noise volume is the same 64³ every run whatever the player does: it is seeded + // inside the render layer, because the sequence that fills it is the storm's appearance. + const world = createWeatherWorld(ctx.scene, camera); + // The world is one screen-sized quad at the origin, so the camera's own frustum would cull it + // the moment it looked along the coast. Marking it is how a game says "this is always drawn". + alwaysRender(world.quad); + // The rain is one instanced draw whose drops are projected by its own vertex stage, so its + // bounds say nothing about where they land and the same marker keeps it drawn at any heading. + const rain = createStormRain(ctx.scene, camera); + alwaysRender(rain.mesh); + // The bolt is drawn after the coast and depth-tested against it, so a strike behind the + // headland is hidden by it. Its quads are projected by its own vertex stage, like the rain. + const lightning = createStormLightning(ctx.scene, camera); + alwaysRender(lightning.mesh); + // Retained, not called and dropped: `update` is where the post pass learns this frame's time, + // exposure, rain and droplet toggle, and `uRes` is zero until it has run once. + const post = setupPost(ctx.renderer, ctx.scene, camera, { + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); + this.#rain = rain; + this.#post = post; + this.#lightning = lightning; + this.#measureClouds = world.clouds; + // `loading.ts` lifts its cover by making the canvas layer transparent again; that is the frame + // the world is on screen, so it is the frame the interface may appear on. + this.#curtainLifted = () => !ctx.canvasLayer.opaque; + // Registered, so the engine's registry disposes it with the game and `game.ts` can reach it by + // name for the two holds that arrive outside a frame (the pause intent and tab visibility). + const audio = ctx.entities.add(STORM_AUDIO_ENTITY, createStormAudio(ctx)); + + let weather: Weather = { ...STORM }; + let elapsed = START_TIME; + let frame = 0; + let nextStrikeAt = START_TIME + FIRST_STRIKE_DELAY; + let strikeAt = -100; + let pendingThunderAt = -1; + let cinematic = false; + let safe = false; + let strikes = 0; + let lastStrike = { at: -1, delay: 0, metres: 0 }; + // Where the bolt lands, and where it entered the cloud deck. The deck point is what the clouds + // glow around and what the coast is lit from; the ground point is what the thunder crosses. + // Out at sea, so a flash that lands before the first strike still lights open water. + const site = new Vector3(0, 0, -400); + const strikePoint = new Vector3(0, 180, -400); + + /** + * A strike is queued on the same clock as everything else, so the flash, the thunder and the + * distance it crossed cannot disagree with each other. Gated on the photosensitivity switch + * at the one door every path comes through, manual key and automatic alike. + */ + const strike = (): void => { + if (safe) return; + strikeAt = elapsed - 0.025; + site.set( + STRIKE_SITE.minX + ctx.random() * (STRIKE_SITE.maxX - STRIKE_SITE.minX), + 0, + STRIKE_SITE.nearZ - ctx.random() * STRIKE_SITE.spread, + ); + strikePoint.copy(lightning.strike(site, ctx.random)); + const metres = rig.position.distanceTo(site); + const delay = thunderDelay(metres); + pendingThunderAt = elapsed + delay; + nextStrikeAt = elapsed + STRIKE_MIN_GAP + ctx.random() * STRIKE_SPREAD; + strikes += 1; + lastStrike = { at: elapsed, delay, metres }; + // The thunder is queued on the same clock the flash is drawn against, by the same distance + // the delay came from, so what is heard cannot disagree with what was seen. + audio.queueStrike({ at: pendingThunderAt, metres }); + }; + + /** + * The keyboard shortcuts, through the same validated door the panel's controls use, so a key + * and a click cannot disagree about what photosensitivity mode or sound means. + */ + const readKeys = (frameCtx: WeatherCtx): void => { + const input = frameCtx.input; + const ui = frameCtx.state.getState(); + let patch: Partial = {}; + if (input.justPressed("safe")) + patch = { ...patch, ...intentPatch("setSafe", !ui.safe, ui.target) }; + if (input.justPressed("hideUi")) patch.uiHidden = !ui.uiHidden; + if (input.justPressed("sound")) { + const on = ui.audioEnabled && !ui.muted; + patch.audioEnabled = true; + patch.muted = on; + } + if (input.justPressed("pause")) { + patch.paused = !ui.paused; + audio.setSilenced(patch.paused); + } + if (Object.keys(patch).length > 0) frameCtx.state.set(patch); + }; + + /** Read the intent the UI sent between frames, before anything is derived from it. */ + const readIntent = (frameCtx: WeatherCtx): GameState => { + readKeys(frameCtx); + const ui = frameCtx.state.getState(); + // Both resets come through the same door, and each one is a fresh request: the flag is + // consumed and cleared below, never latched, so a second reset still lands. + const reset = ui.cameraReset || frameCtx.input.justPressed("resetCamera"); + if (reset) { + rig.reset(); + cinematic = false; + } else { + // Any real input hands the camera back: the orbit is a mode, not a cage. + cinematic = ui.cinematic && !rig.manual; + } + safe = ui.safe; + if (ui.strikeRequested || frameCtx.input.justPressed("strike")) strike(); + return ui; + }; + + const advance = (ui: GameState, step: number): void => { + // A held simulation still draws, as the study's did: the look follows the panel at once + // instead of easing on a clock that is not running, and nothing else moves. + if (ui.paused || ui.frozen) weather = { ...ui.target }; + if (step <= 0) return; + elapsed += step; + weather = easeWeather(weather, ui.target, step); + // The study's own gate: past its time, the first frame with real cloud cover strikes. + if (ui.autoLightning && elapsed > nextStrikeAt && weather.cloud > AUTO_STRIKE_CLOUD) strike(); + }; + + return (frameCtx, dt) => { + const ui = readIntent(frameCtx); + // Pause holds the simulation the way the study's did — the frame is still drawn, so a drag + // still looks around — and `frozen` is the automation's stopped clock, which only an explicit + // `step` moves on. A step never runs while paused, exactly as the study's `step(dt)`. + const wall = ui.paused || ui.frozen ? 0 : Math.min(dt, MAX_STEP); + const step = wall + (ui.paused ? 0 : ui.stepRequest); + advance(ui, step); + + const flash = safe ? 0 : flashAt(elapsed - strikeAt); + if (pendingThunderAt >= 0 && elapsed >= pendingThunderAt) pendingThunderAt = -1; + + rig.update(wall, elapsed, frameCtx.input, cinematic); + // The rig only learns it is being driven once it has run, so the published flag is settled + // after it: a hand on the camera ends the orbit on the same frame it happened. + if (rig.manual) cinematic = false; + // Both passes read the weather this frame has just eased towards, not the target the UI + // asked for, so a drop and a lens bead are lit by the same air the coast is drawn with. + rain.update({ elapsed, flash, weather, quality: ui.quality }); + lightning.update(flash); + post.update({ + elapsed, + exposure: weather.exposure, + rain: weather.rain, + droplets: ui.droplets, + }); + world.update({ + elapsed, + flash, + quality: ui.quality, + strike: strikePoint, + weather, + }); + loading.update(); + frame += 1; + + // Both request flags are consumed here, so the next request is a new one. + frameCtx.state.set({ + cameraReset: false, + cinematic, + cloudPass: { ...frameCtx.state.getState().cloudPass, steps: world.clouds().steps }, + dropCount: rain.instanceCount, + elapsed, + flash, + fps: frameCtx.fps, + frame, + heading: rig.heading, + lastStrike, + pendingThunderAt, + position: { x: rig.position.x, y: rig.position.y, z: rig.position.z }, + safe, + stepRequest: 0, + strikeRequested: false, + strikes, + status: statusOf(ui.paused, flash, weather, ui.target), + weather, + }); + frameCtx.state.flush(); + // Last, so the mix is written from the frame that just ran rather than the one before it: + // `getState` already carries the patch above. The pause hold itself is an intent in + // `game.ts`, so the sound stops on the press rather than a frame later. + audio.update(frameCtx.state.getState()); + }; + } + + /** + * The launch and the cloud target, published from the render hook rather than the simulation + * frame: a frame is drawn whether or not the clock moved (a paused or tick-driven run draws + * without simulating), the loading curtain must lift on the frame that shows the storm, and the + * cloud target is only resized by the render itself. "Ready" is the canvas loading screen in + * `src/render/loading.ts` having lifted, so the interface and the world appear together. + */ + override render(ctx: WeatherCtx): void { + const progress = ctx.startup.progress; + // The interface's curtain lifts with the engine's own loading screen, on the same frame. + const ready = this.#curtainLifted?.() ?? false; + const current = ctx.state.getState(); + // Read after the frame was drawn, so it is the target the cloud pass really rendered into. + const clouds = this.#measureClouds?.() ?? current.cloudPass; + // Steps are the frame's own (set by the tier this frame simulated); the size is the render's. + const same = + current.loading.ready === ready && + current.loading.progress === progress && + current.cloudPass.width === clouds.width && + current.cloudPass.height === clouds.height; + if (same) return; + ctx.state.set({ + cloudPass: { ...current.cloudPass, height: clouds.height, width: clouds.width }, + loading: { progress, ready }, + }); + ctx.state.flush(); + } + + /** The scene is leaving: both passes were built here, so both are released here. */ + override exit(): void { + this.#rain?.dispose(); + this.#post?.dispose(); + this.#lightning?.dispose(); + this.#rain = undefined; + this.#post = undefined; + this.#lightning = undefined; + } +} + +/** Derived from the frame that just ran, so the label cannot describe a frame that is over. */ +function statusOf(paused: boolean, flash: number, weather: Weather, target: Weather): SimStatus { + if (paused) return "paused"; + if (flash > 0.003) return "flashing"; + return settled(weather, target) ? "steady" : "settling"; +} + +function settled(current: Weather, target: Weather): boolean { + return WEATHER_KEYS.every((key) => Math.abs(current[key] - target[key]) < SETTLED); +} diff --git a/packages/create-threenative/templates/rain/src/state.ts b/packages/create-threenative/templates/rain/src/state.ts new file mode 100644 index 0000000000..de2aafb78c --- /dev/null +++ b/packages/create-threenative/templates/rain/src/state.ts @@ -0,0 +1,257 @@ +/** + * The weather simulation and the shape of everything the UI reads. + * + * This is game-owned maths on purpose: presets, the finite clamp at the API boundary, the + * exponential ease and the flash curve are the game's own rules, not a framework concept, so + * they live here rather than in a package. + */ + +export const WEATHER_KEYS = ["rain", "cloud", "wind", "fog", "exposure", "wet"] as const; + +export type WeatherKey = (typeof WEATHER_KEYS)[number]; + +export type Weather = Record; + +export type PresetName = "drizzle" | "storm" | "supercell" | "clearing"; + +export type QualityName = "performance" | "balanced" | "high" | "ultra"; + +/** What the simulation is doing right now, derived — never a literal the UI can drift from. */ +export type SimStatus = "steady" | "settling" | "flashing" | "paused"; + +export const PRESETS: Record = { + drizzle: { rain: 0.24, cloud: 0.48, wind: 0.2, fog: 0.2, exposure: 1.22, wet: 0.78 }, + storm: { rain: 0.76, cloud: 0.76, wind: 0.55, fog: 0.4, exposure: 1.12, wet: 1 }, + supercell: { rain: 1, cloud: 0.94, wind: 0.91, fog: 0.66, exposure: 1.04, wet: 1 }, + clearing: { rain: 0.04, cloud: 0.32, wind: 0.14, fog: 0.14, exposure: 1.3, wet: 0.94 }, +}; + +/** A frame longer than this is a stall, and integrating it moves the weather in one visible jump. */ +export const MAX_STEP = 0.08; + +/** Seconds for the atmosphere to cover most of the gap to its target. */ +export const EASE_TAU = 0.67; + +/** Auto lightning waits for real cloud cover, so drizzle does not flash overhead. */ +export const AUTO_STRIKE_CLOUD = 0.35; + +/** + * Tab visibility, from the UI realm — the only place that knows whether the frame is still on + * screen, since `document` is a browser API and portable game code does not reach for one. + * + * It is answered at the intent door rather than validated into a state patch: nothing in the + * atmosphere changes when the tab is hidden, and the loop is stopped anyway, so no frame would ever + * read the field. + */ +export const VISIBILITY_INTENT = "setHidden"; + +const SPEED_OF_SOUND = 343; + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +/** A preset by name, falling back to the storm. A copy, so a caller cannot edit the table. */ +export function preset(name: PresetName): Weather { + return { ...(PRESETS[name] ?? PRESETS.storm) }; +} + +/** + * The finite clamp at the API boundary: a key whose value is not a finite number keeps `base`, + * everything else is clamped into its range. Exposure is the one channel that is not 0..1. + */ +export function sanitizeWeather( + input: Partial>, + base?: Weather, +): Weather { + const result = { ...(base ?? preset("storm")) }; + for (const key of WEATHER_KEYS) { + const value = Number(input[key]); + if (input[key] === undefined || !Number.isFinite(value)) continue; + result[key] = key === "exposure" ? clamp(value, 0.3, 2) : clamp(value, 0, 1); + } + return result; +} + +/** Exponential ease towards the target, independent of frame rate because `dt` is real seconds. */ +export function easeWeather(current: Weather, target: Weather, dt: number): Weather { + const k = 1 - Math.exp(-dt / EASE_TAU); + const next = { ...current }; + for (const key of WEATHER_KEYS) next[key] = current[key] + (target[key] - current[key]) * k; + return next; +} + +/** + * Every UI toggle, and the state field each one actually writes. The intent name is the UI's + * vocabulary and the field is the game's, so the mapping lives in one table rather than as six + * spelled-out cases that can drift apart. + */ +export const TOGGLE_FIELDS = { + setAudioEnabled: "audioEnabled", + setAutoLightning: "autoLightning", + setCinematic: "cinematic", + setDroplets: "droplets", + setFrozen: "frozen", + setMuted: "muted", + setSafe: "safe", +} as const satisfies Record; + +const QUALITIES: readonly QualityName[] = ["performance", "balanced", "high", "ultra"]; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A weather payload turned into a clamped target, or a throw. Every channel is checked before any + * is written, so a malformed payload leaves the atmosphere exactly where it was rather than + * half-moved, and only real finite numbers are accepted: a slider that emits `null` or `"0.5"` is + * a bug in the control, not a value to coerce. + */ +function weatherTarget(payload: unknown, base: Weather): Weather { + if (!isRecord(payload)) throw new Error("expected an object of weather channels"); + const patch: Partial> = {}; + for (const [key, value] of Object.entries(payload)) { + if (!(WEATHER_KEYS as readonly string[]).includes(key)) { + throw new Error(`${key} is not a weather channel`); + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number`); + } + patch[key as WeatherKey] = value; + } + return sanitizeWeather(patch, base); +} + +/** + * The one door every UI intent comes through: intent name and payload in, the state patch the + * game applies out. It throws on a malformed payload and it takes the current target as an + * argument, so it is the whole rule set with no engine, no GPU and no module state — which is + * also what the automation API will call. + */ +export function intentPatch( + intent: string, + payload: unknown, + currentTarget: Weather, +): Partial { + const toggle = Object.hasOwn(TOGGLE_FIELDS, intent) + ? TOGGLE_FIELDS[intent as keyof typeof TOGGLE_FIELDS] + : undefined; + if (toggle !== undefined) { + if (typeof payload !== "boolean") throw new Error("expected a boolean payload"); + // Photosensitivity mode also takes automatic lightning down, at the one door every caller + // uses: a reader who asked for no flashes must not get them from the sky on their own. + if (toggle === "safe" && payload) return { autoLightning: false, safe: true }; + return { [toggle]: payload } as Partial; + } + switch (intent) { + case "setPreset": { + // Own keys only: `in` would accept `toString` and hand back the storm's prototype. + if (typeof payload !== "string" || !Object.hasOwn(PRESETS, payload)) { + throw new Error("expected one of drizzle, storm, supercell, clearing"); + } + const name = payload as PresetName; + return { preset: name, target: { ...PRESETS[name] } }; + } + case "setQuality": + if (!QUALITIES.includes(payload as QualityName)) { + throw new Error("expected performance, balanced, high or ultra"); + } + return { quality: payload as QualityName }; + case "setWeather": + return { target: weatherTarget(payload, currentTarget) }; + case "pause": + return { paused: true }; + case "resume": + return { paused: false }; + case "resetCamera": + // The scene owns the rig, so this is a request it acts on and clears. + return { cameraReset: true, cinematic: false }; + case "strike": + // A request, not a value: the scene owns the clock the flash is measured against. + return { strikeRequested: true }; + case "step": + // The automation's `step(dt)`: seconds of simulation for the next frame, clamped as the + // study clamped them. Consumed by that frame, and ignored while paused. + if (typeof payload !== "number" || !Number.isFinite(payload)) { + throw new Error("expected a finite number of seconds"); + } + return { stepRequest: clamp(payload, 0, 60) }; + case "hideUi": + return { uiHidden: true }; + case "showUi": + return { uiHidden: false }; + case "help": + return { helpOpen: true }; + case "closeHelp": + return { helpOpen: false }; + default: + throw new Error(`${intent} is not a rain intent`); + } +} + +/** The four-peak flash envelope: one strike is a stutter, not one fade. */ +export function flashAt(secondsSinceStrike: number): number { + if (secondsSinceStrike < 0 || secondsSinceStrike > 0.82) return 0; + return Math.min( + 2.4, + 1.6 * Math.exp(-secondsSinceStrike * 34) + + 1.2 * Math.exp(-(((secondsSinceStrike - 0.105) / 0.016) ** 2)) + + 1.7 * Math.exp(-(((secondsSinceStrike - 0.21) / 0.025) ** 2)) + + 0.32 * Math.exp(-(((secondsSinceStrike - 0.38) / 0.09) ** 2)), + ); +} + +/** Thunder arrives after the sound crosses the distance it was struck at. */ +export function thunderDelay(metres: number): number { + return Math.max(0, Number.isFinite(metres) ? metres : 0) / SPEED_OF_SOUND; +} + +export type GameState = { + /** Where the atmosphere is now, which is what the scene actually drew. */ + weather: Weather; + /** Where it is heading. The UI writes here; the simulation eases `weather` towards it. */ + target: Weather; + preset: PresetName; + quality: QualityName; + elapsed: number; + frame: number; + flash: number; + safe: boolean; + droplets: boolean; + autoLightning: boolean; + cinematic: boolean; + audioEnabled: boolean; + muted: boolean; + paused: boolean; + uiHidden: boolean; + helpOpen: boolean; + uiReady: boolean; + heading: number; + /** Where the fly camera stands, in metres, so a move is read back rather than assumed. */ + position: { x: number; y: number; z: number }; + fps: number; + /** + * Drops the renderer was actually asked to draw this frame — the rain geometry's own + * `instanceCount`, not the budget it was derived from, so the readout cannot report a number the + * GPU was never handed. + */ + dropCount: number; + /** Absolute simulation time thunder is due, or -1 when nothing is in the air. */ + pendingThunderAt: number; + /** Strikes so far, manual and automatic, that got past the photosensitivity gate. */ + strikes: number; + /** The last strike: simulation time, metres from the camera, and its thunder delay in seconds. */ + lastStrike: { at: number; delay: number; metres: number }; + /** The cloud target the renderer really drew last frame, and the march steps it ran. */ + cloudPass: { width: number; height: number; steps: number }; + /** The engine's launch: how far first-use compilation has got, and whether the frame is ready. */ + loading: { progress: number; ready: boolean }; + /** The automation's stopped clock: frames still draw, only `stepRequest` advances it. */ + frozen: boolean; + /** Seconds the next frame simulates on top of its own, from `step(dt)`. Cleared once used. */ + stepRequest: number; + status: SimStatus; + /** Set by the reset-camera intent, cleared by the frame that acted on it. */ + cameraReset: boolean; + /** Set by the strike intent, cleared by the frame that struck. */ + strikeRequested: boolean; +}; diff --git a/packages/create-threenative/templates/rain/src/style.css b/packages/create-threenative/templates/rain/src/style.css new file mode 100644 index 0000000000..09e798a823 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/style.css @@ -0,0 +1,1160 @@ +/* + * The interface, as the study wears it. + * + * This is the source composition copied rule for rule — the frosted right panel, the topbar, the + * compass, the hero, the telemetry footer, the sound pill and the help dialog — so the look is + * study data rather than a design decision. Class names and the responsive breakpoints (900px, + * 620px, 800px tall) match the source DOM one for one; see `src/ui/` for the JSX that carries it. + */ + +:root { + color-scheme: dark; + --ink: #e4eee9; + --muted: #93a3a5; + --line: rgba(217, 237, 229, 0.15); + --mint: #b6d9cc; + --amber: #d1a46d; + --panel: rgba(12, 22, 26, 0.77); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + color: var(--ink); + font-family: Arial, Helvetica, sans-serif; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select { + font: inherit; +} + +button { + color: inherit; + cursor: pointer; + border: 0; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--mint); + outline-offset: 5px; +} + +#scene { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + display: block; + touch-action: none; + cursor: grab; +} + +#scene:active { + cursor: grabbing; +} + +/* The page frame the engine's canvas and the interface share. */ +.canvas-root { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; + background: #0e1921; +} + +.canvas-fill { + position: absolute; + inset: 0; +} + +/* `.interface` is the pointer-events-none sheet the whole study sits on; only the controls inside + it opt back in, so a drag that misses a button still reaches the camera. */ +.interface { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.35s; +} + +.interface.hidden, +.panel.hidden { + opacity: 0; + visibility: hidden; +} + +.interface button, +.interface input, +.interface select, +.interface details { + pointer-events: auto; +} + +.topbar { + position: absolute; + top: 29px; + left: 36px; + right: 36px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + display: flex; + align-items: center; + gap: 13px; +} + +.mark { + width: 35px; + height: 35px; + border: 1px solid #dbe5df55; + border-radius: 50%; + display: grid; + place-items: center; + position: relative; +} + +.mark::after { + content: ""; + position: absolute; + inset: 5px; + border: 1px solid #dbe5df24; + border-radius: 50%; +} + +.brand-title { + font-size: 11px; + letter-spacing: 2.8px; + font-weight: 600; +} + +.brand-sub { + font-family: monospace; + font-size: 9px; + letter-spacing: 1.7px; + color: #acb9b7; + margin-top: 6px; +} + +.top-actions { + display: flex; + align-items: center; + gap: 9px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + font: 10px monospace; + letter-spacing: 1.5px; + margin-right: 16px; + color: #c7d8d4; +} + +.dot { + display: inline-block; + width: 5px; + height: 5px; + background: #b2d6c6; + border-radius: 100%; + box-shadow: 0 0 9px #92c4b65a; +} + +.icon-btn { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 1px solid var(--line); + background: #0c171b65; + border-radius: 7px; + backdrop-filter: blur(12px); + transition: background 0.2s; +} + +.icon-btn:hover { + background: #344747b3; +} + +.icon-btn svg { + width: 15px; + height: 15px; + stroke: currentColor; + fill: none; + stroke-width: 1.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon-btn.active { + color: var(--mint); + background: #b6d9cc19; + border-color: #b6d9cc66; +} + +.compass { + position: absolute; + left: 50%; + top: 34px; + transform: translateX(-50%); + width: 206px; + text-align: center; + color: #c9d6d460; +} + +.compass .letters { + font: 9px monospace; + letter-spacing: 3px; + display: flex; + justify-content: space-between; +} + +.compass .ticks { + height: 8px; + margin: 9px 0 0; + background: repeating-linear-gradient( + 90deg, + transparent, + transparent 16px, + #b8cfca50 16px, + #b8cfca50 17px + ); + border-bottom: 1px solid #b8cfca30; +} + +.compass::after { + content: "▾"; + position: absolute; + left: 98px; + top: 19px; + color: #d6e9dd; + font-size: 12px; +} + +.panel { + position: absolute; + right: 36px; + top: 106px; + width: 292px; + border: 1px solid var(--line); + border-radius: 12px; + background: linear-gradient(130deg, #1b2c317a, #0b161cbd); + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px #0002; + /* Never taller than the window leaves above the sound pill and key hints: a long panel (the + rendering details open) scrolls inside itself instead of running under them. */ + max-height: calc(100% - 106px - 170px); + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #b6d9cc33 transparent; + pointer-events: auto; +} + +.panel-header { + padding: 23px 22px 20px; + border-bottom: 1px solid var(--line); +} + +.eyebrow { + font-family: monospace; + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--muted); +} + +.panel-title { + font-size: 22px; + font-weight: 400; + letter-spacing: -0.55px; + margin: 11px 0 7px; +} + +.panel-sub { + font-size: 11px; + color: #a4b4b5; + line-height: 1.6; +} + +.weather-glyph { + float: right; + color: var(--mint); + margin-top: 7px; + opacity: 0.9; +} + +.weather-glyph svg { + width: 31px; + height: 31px; + stroke: currentColor; + stroke-width: 1.3; + fill: none; +} + +.presets { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 4px; + padding: 5px; + margin: 17px 17px 4px; + border: 1px solid #b6d9cc17; + border-radius: 7px; + background: #0a12162e; +} + +.presets button { + padding: 9px 1px; + background: transparent; + border-radius: 4px; + font: 9px monospace; + letter-spacing: 0.45px; + color: #9badb0; +} + +.presets button.active { + background: #bdd6cc1f; + color: #e3eee8; + box-shadow: inset 0 0 0 1px #b6d9cc16; +} + +.sliders { + padding: 14px 22px 0; +} + +.control { + margin-bottom: 21px; +} + +.control-line { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + margin-bottom: 9px; +} + +.readout { + font: 10px monospace; + color: var(--mint); +} + +.readout span { + color: #879a9e; + font-size: 9px; +} + +/* The filled part of a slider is one custom property, written by the control itself. */ +input[type="range"] { + display: block; + width: 100%; + height: 3px; + margin: 10px 0 0; + border-radius: 5px; + appearance: none; + -webkit-appearance: none; + background: linear-gradient( + to right, + var(--mint) 0%, + var(--mint) var(--fill, 50%), + #4b5f614d var(--fill, 50%), + #4b5f614d 100% + ); + cursor: pointer; +} + +input[type="range"]::-webkit-slider-thumb { + width: 8px; + height: 8px; + appearance: none; + background: #d6e8df; + border: 0; + border-radius: 50%; + box-shadow: 0 0 0 3px #b6d9cc0c; +} + +input[type="range"]::-moz-range-thumb { + width: 8px; + height: 8px; + border: 0; + background: #d6e8df; + border-radius: 50%; +} + +.divider { + height: 1px; + background: var(--line); + margin: 1px 22px 16px; +} + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 22px; + margin-bottom: 13px; + font-size: 11px; + color: #cad5d3; +} + +.switch { + width: 27px; + height: 15px; + position: relative; + border-radius: 20px; + background: #56646560; + padding: 0; + transition: 0.2s; +} + +.switch::after { + content: ""; + position: absolute; + left: 3px; + top: 3px; + width: 9px; + height: 9px; + border-radius: 50%; + background: #819091; + transition: 0.2s; +} + +.switch[aria-checked="true"] { + background: #a0c9b742; +} + +.switch[aria-checked="true"]::after { + left: 15px; + background: #bedccd; +} + +.strike-button { + display: flex; + justify-content: center; + align-items: center; + gap: 9px; + width: calc(100% - 44px); + margin: 18px 22px 17px; + padding: 12px; + background: #b3d6c914; + border: 1px solid #c2ded82a; + border-radius: 6px; + font-size: 11px; + transition: 0.2s; +} + +.strike-button:hover { + background: #b3d6c929; + border-color: #c2ded854; +} + +.strike-button svg { + width: 12px; + height: 15px; + stroke: var(--mint); + fill: none; + stroke-width: 1.4; +} + +.strike-button kbd { + margin-left: auto; +} + +.strike-button .button-label { + margin-right: auto; +} + +.strike-button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +kbd { + display: inline-block; + padding: 2px 5px; + border: 1px solid #c2ded824; + border-radius: 3px; + color: #b2c3c1; + font: 9px monospace; + background: #10202544; +} + +.render-footer { + padding: 12px 21px; + border-top: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + background: #06111522; +} + +.quality-label { + font: 9px monospace; + color: #8fa4a4; + letter-spacing: 0.5px; +} + +.render-footer select { + appearance: none; + -webkit-appearance: none; + background: transparent + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23c2d6ce' stroke-width='1.4'/%3E%3C/svg%3E") + no-repeat right 4px center; + border: none; + color: #c2d6ce; + font: 10px monospace; + padding: 4px 20px 4px 4px; + outline-offset: 2px; + cursor: pointer; +} + +.render-footer option { + background: #15262b; +} + +.hero { + position: absolute; + left: 38px; + bottom: 114px; + max-width: 560px; + text-shadow: 0 2px 25px #0007; +} + +.hero .eyebrow { + color: #c1d3cf; + display: flex; + align-items: center; + gap: 12px; + font-size: 9px; + letter-spacing: 2.7px; +} + +.hero .eyebrow::before { + content: ""; + display: block; + width: 25px; + height: 1px; + background: #c1d3cf88; +} + +.hero h1 { + font-size: clamp(48px, 7vw, 100px); + font-weight: 400; + letter-spacing: 7px; + line-height: 1.1; + margin: 12px 0 9px; +} + +.hero p { + font-size: 12px; + color: #bfcdcc; + font-weight: 400; + letter-spacing: 0.45px; + margin: 0; +} + +.bottom-bar { + position: absolute; + left: 38px; + right: 36px; + bottom: 30px; + display: flex; + align-items: end; + justify-content: space-between; +} + +.telemetry { + display: flex; + gap: 35px; +} + +.metric-label { + font: 8px monospace; + letter-spacing: 1.8px; + color: #9eafad; + margin-bottom: 10px; +} + +.metric-value { + font: 12px monospace; + color: #d5e2df; +} + +.metric-value small { + font: 9px monospace; + color: #b6c6c5; + margin-left: 3px; +} + +.key-hint { + font: 9px monospace; + color: #aabbbb; + letter-spacing: 0.15px; + text-align: right; + line-height: 2.2; +} + +.engine { + font: 8px monospace; + letter-spacing: 0.5px; + color: #889d9f; + margin-top: 4px; +} + +.toast { + position: absolute; + top: 95px; + left: 38px; + padding: 10px 14px; + border-left: 2px solid var(--mint); + background: #0e1a2088; + backdrop-filter: blur(12px); + font: 10px monospace; + letter-spacing: 0.5px; + opacity: 0; + transform: translateY(-8px); + transition: 0.3s; + max-width: 390px; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +.bottom-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 40px; +} + +.sound-pill { + font: 9px monospace; + letter-spacing: 1px; + color: #d8e5df; + display: flex; + align-items: center; + gap: 9px; + background: #14272b80; + border: 1px solid var(--line); + padding: 10px 14px; + border-radius: 25px; + backdrop-filter: blur(12px); + pointer-events: auto; +} + +.sound-pill:hover { + background: #293e4499; +} + +.sound-wave { + height: 11px; + display: flex; + gap: 2px; + align-items: center; +} + +.sound-wave span { + height: 4px; + width: 2px; + background: var(--mint); +} + +.sound-wave span:nth-child(2) { + height: 9px; +} + +.sound-wave span:nth-child(3) { + height: 6px; +} + +.sound-wave span:nth-child(4) { + height: 11px; +} + +.sound-pill.active .sound-wave span { + animation: wave 0.8s alternate infinite; +} + +.sound-pill.active .sound-wave span:nth-child(2) { + animation-delay: 0.3s; +} + +@keyframes wave { + to { + height: 2px; + } +} + +.loading { + position: absolute; + inset: 0; + z-index: 20; + /* No background of its own: the engine's canvas loading screen (src/render/loading.ts) is the + opaque night behind this text. A window-sized painted layer over the WebGPU canvas has to be + composited every frame — measured at 1920x1080 under Xvfb, 127 ms a frame against 1.5 ms. */ + transition: opacity 0.8s; + /* A curtain, not a control: a press during the fade reaches the storm underneath. */ + pointer-events: none; +} + +.loading.gone { + opacity: 0; +} + +.loading-head, +.loading-note { + position: absolute; + left: 50%; + width: 320px; + transform: translateX(-50%); + text-align: center; +} + +.loading-head { + bottom: calc(50% + 22px); +} + +.loading-title { + font-size: 28px; + letter-spacing: 7px; + font-weight: 400; + margin-bottom: 30px; +} + +.loading-text { + font: 10px monospace; + color: #9cafae; + letter-spacing: 1px; + line-height: 1.7; +} + +.loading-note { + top: calc(50% + 22px); + font-size: 10px; + line-height: 1.8; + color: #7f969a; +} + +.panel-toggle { + display: none; +} + +.restore { + position: absolute; + bottom: 20px; + right: 20px; + display: none; + background: #0b171bb3; + border: 1px solid var(--line); + border-radius: 6px; + color: #d8e5df; + padding: 10px; + font: 10px monospace; + cursor: pointer; +} + +.restore.visible { + display: block; +} + +.modal { + position: fixed; + inset: 0; + z-index: 30; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: #030a10a6; + backdrop-filter: blur(15px); + color: var(--ink); +} + +.modal[open] { + display: grid; + place-items: center; +} + +.modal::backdrop { + background: transparent; +} + +.modal-content { + position: relative; + max-width: 455px; + width: calc(100% - 42px); + background: #17262e; + border: 1px solid var(--line); + padding: 33px; + border-radius: 12px; +} + +.modal-content h2 { + font-weight: 400; + font-size: 25px; + margin: 0 0 18px; +} + +.modal-content p { + font-size: 12px; + line-height: 1.8; + color: #b9cacb; +} + +.modal-content .close { + position: absolute; + top: 17px; + right: 18px; +} + +.help-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 22px; + font: 11px monospace; + color: #b9cacb; + margin: 23px 0; +} + +.warning { + border-left: 2px solid var(--amber); + padding: 9px 12px; + background: #d1a46d09; + font-size: 11px !important; + color: #d8c3a7 !important; +} + +.advanced { + margin: 0 22px 14px; + font: 10px monospace; + color: #9db1b0; +} + +.advanced summary { + cursor: pointer; + font-size: 9px; + letter-spacing: 0.6px; +} + +.advanced .switch-row { + padding: 0; + margin: 15px 0; +} + +.advanced .control { + margin-top: 16px; + margin-bottom: 12px; +} + +.advanced .control-line { + font-family: Arial, sans-serif; +} + +.panel.minimized { + display: none; +} + +@media (max-height: 800px) { + .panel { + top: 91px; + max-height: calc(100% - 91px - 100px); + } + + .panel-header { + padding: 17px 20px 13px; + } + + .sliders { + padding-top: 10px; + } + + .control { + margin-bottom: 17px; + } + + .panel-title { + font-size: 21px; + margin-top: 8px; + } + + .panel-sub { + font-size: 10px; + } + + .presets { + margin-top: 12px; + } + + .strike-button { + margin-top: 11px; + margin-bottom: 12px; + padding: 10px; + } + + .hero { + bottom: 115px; + } + + /* Short windows: the pill sits beside the hints instead of above them, below the panel. */ + .bottom-actions { + flex-direction: row; + align-items: center; + gap: 22px; + } + + .advanced { + margin-bottom: 11px; + } +} + +@media (max-width: 900px) { + .panel { + width: 266px; + right: 22px; + } + + .topbar { + left: 24px; + right: 22px; + } + + .compass { + display: none; + } + + .hero, + .bottom-bar { + left: 24px; + } + + .hero h1 { + font-size: 62px; + letter-spacing: 5px; + } + + .hero p { + max-width: 280px; + line-height: 1.7; + } + + .status { + font-size: 9px; + margin-right: 4px; + } + + .telemetry { + gap: 22px; + } + + .key-hint { + display: none; + } + + .hero { + max-width: 400px; + } + + .panel-title { + font-size: 20px; + } +} + +@media (max-width: 620px) { + .panel-toggle { + display: grid; + } + + .panel { + display: none; + top: 86px; + right: 18px; + max-height: calc(100% - 172px); + overflow-y: auto; + width: 270px; + } + + .panel.mobile-open { + display: block; + } + + .topbar { + top: 21px; + left: 20px; + right: 18px; + } + + .brand-title { + font-size: 9px; + letter-spacing: 1.9px; + } + + .brand-sub { + font-size: 8px; + letter-spacing: 1px; + } + + .status { + display: none; + } + + .top-actions .desktop { + display: none; + } + + .hero { + left: 23px; + bottom: 129px; + } + + .hero h1 { + font-size: 54px; + } + + .hero p { + font-size: 11px; + max-width: 290px; + } + + .hero .eyebrow { + font-size: 8px; + letter-spacing: 1.5px; + } + + .bottom-bar { + left: 24px; + bottom: 32px; + right: 24px; + } + + .telemetry { + gap: 28px; + } + + .metric-label { + font-size: 7px; + } + + .metric-value { + font-size: 11px; + } + + .telemetry > div:nth-child(3) { + display: none; + } + + .bottom-actions { + margin-bottom: 44px; + } + + .sound-pill { + font-size: 8px; + padding: 9px 12px; + min-height: 44px; + } + + /* Touch targets: every control a thumb reaches is at least 44 px. */ + .icon-btn { + width: 44px; + height: 44px; + } + + .toast { + left: 23px; + top: 85px; + max-width: calc(100% - 46px); + font-size: 9px; + } + + .brand .mark { + width: 30px; + height: 30px; + } +} + +/* The narrowest phones: the subtitle would run under the three buttons, so the title stands alone. */ +@media (max-width: 480px) { + .brand-sub { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + +/* Boot failures are a page state, so the generated game owns their presentation. */ +[data-threenative-canvas-error="true"] { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + box-sizing: border-box; + padding: clamp(1.5rem, 8vw, 6rem); + overflow: auto; + background: #fff7ed; + color: #0e1921; + font-family: inherit; + font-size: clamp(1rem, 2vw, 1.5rem); + line-height: 1.5; + text-align: center; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* The debug surface is framework plumbing; its default presentation is yours to change. */ +[data-threenative-debug-overlay="true"] { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 50; + max-width: 24rem; + padding: 0.75rem; + pointer-events: none; + background: rgb(2 6 23 / 90%); + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 30%); + color: #cffafe; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +[data-threenative-debug-overlay="true"] table { + width: 100%; + table-layout: fixed; +} + +[data-threenative-debug-overlay="true"] th, +[data-threenative-debug-overlay="true"] td { + padding-right: 1rem; + text-align: left; +} + +[data-threenative-debug-overlay="true"] td:last-child { + overflow-wrap: anywhere; + word-break: break-all; +} diff --git a/packages/create-threenative/templates/rain/src/ui/App.tsx b/packages/create-threenative/templates/rain/src/ui/App.tsx new file mode 100644 index 0000000000..1c645e24dc --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/App.tsx @@ -0,0 +1,29 @@ +import type { IGame } from "@threenative/core"; +import { DebugOverlay, GameCanvas } from "@threenative/ui"; +import { useEffect } from "react"; +import type { GameState } from "../state.js"; +import { GameUi } from "./GameUi.js"; +import { installTempest } from "./tempest.js"; + +/** + * The study's page: the engine's canvas, and the interface from `./GameUi.js` over it. + * + * No physics context on the generic, because `game.ts` is `defineGame` — a coastal + * weather study moves the air, not a rigid body. The UI is mounted from `src/main.ts`, so the + * canvas and the overlay share one root and one React tree. + * + * The automation facade is installed here rather than in `game.ts` because `window.tempest` and + * the query it reads are the UI realm's; the game module stays portable code. + */ +export function App({ game }: { game: IGame }) { + useEffect(() => { + installTempest(game); + }, [game]); + return ( +
+ + + +
+ ); +} diff --git a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx new file mode 100644 index 0000000000..65549b69da --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx @@ -0,0 +1,187 @@ +import { UiLayer, useUiIntent, useUiState } from "@threenative/ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { type GameState, VISIBILITY_INTENT } from "../state.js"; +import { Hud, captureScene, toggleFullscreen } from "./Hud.js"; +import { LoadingOverlay } from "./LoadingOverlay.js"; +import { Menu } from "./Menu.js"; +import { automationRequest } from "./automation.js"; + +const TOAST_MS = 3600; + +/** Below this width the study asks for the cheap tier, exactly as the source study did. */ +const NARROW_PX = 700; + +export function GameUi() { + return ( + + + + ); +} + +/** + * The interface, and the three things that are behaviour rather than markup. + * + * `useUiState` and `useUiIntent` need the bridge `UiLayer` opened, so they live in this inner + * component rather than in `GameUi` itself — a hook above the provider throws. Everything the page + * does that is not a control lives here: the status toast, the keyboard, and the two one-time + * defaults that have to be applied against a real snapshot rather than guessed at mount. + */ +function RainInterface() { + const state = useUiState(); + const send = useUiIntent(); + const [toast, setToast] = useState(""); + const [toastVisible, setToastVisible] = useState(false); + const [panelOpen, setPanelOpen] = useState(false); + const toastTimer = useRef | undefined>(undefined); + + const say = useCallback((text: string) => { + setToast(text); + setToastVisible(true); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToastVisible(false), TOAST_MS); + }, []); + useEffect(() => () => clearTimeout(toastTimer.current), []); + + /** Photosensitivity mode; the intent itself also takes automatic lightning down. */ + const setSafeMode = useCallback((on: boolean) => send("setSafe", on), [send]); + + /** + * The status line, said on what the game reports rather than on what a control asked for: a + * shortcut from the game's input map and a click on the panel change the same state, so both are + * announced the same way, and a strike reads its distance and delay off the strike itself. + */ + const previous = useRef(undefined); + useEffect(() => { + const before = previous.current; + previous.current = state; + if (state === undefined || before === undefined) return; + const soundOn = state.audioEnabled && !state.muted; + if (state.safe !== before.safe) + say(state.safe ? "LIGHTNING DISABLED · photosensitivity mode" : "Lightning flashes enabled"); + if (state.paused !== before.paused) + say(state.paused ? "SIMULATION PAUSED" : "SIMULATION RESUMED"); + if (soundOn !== (before.audioEnabled && !before.muted)) + say(soundOn ? "Stereo rain, wind and distance-delayed thunder enabled" : "Sound muted"); + if (state.strikes > before.strikes) { + const { delay, metres } = state.lastStrike; + say( + `LIGHTNING · ${(metres / 1000).toFixed(2)} km${soundOn ? ` · thunder in ${delay.toFixed(1)} s` : ""}`, + ); + } + }, [say, state]); + + const toggleAutoLightning = useCallback(() => { + if (state?.safe === true) { + say("Disable photosensitivity mode first to enable lightning."); + return; + } + send("setAutoLightning", state?.autoLightning !== true); + }, [say, send, state?.autoLightning, state?.safe]); + + /** + * The keyboard, and only the keys that are browser APIs. Everything else — `W A S D`, `Q E`, + * `Shift`, `R`, `L`, `X`, `H`, `M` and `Space` — is the game's input map, which a native host + * delivers too; handling them here as well would act twice per press. + */ + useEffect(() => { + if (state === undefined) return; + const actions: Record void> = { + KeyF: () => void toggleFullscreen(say), + KeyP: () => { + void captureScene(say).catch((why: Error) => say(`Scene image failed: ${why.message}`)); + }, + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === "Escape") { + send("closeHelp"); + return; + } + // A focused slider, select or text field owns its own keys. + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + if (event.repeat) return; + const run = actions[event.code]; + if (run === undefined) return; + // Space scrolls the page and the shortcuts must not reach the browser's own bindings. + event.preventDefault(); + run(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [say, send, state]); + + /** + * Tab visibility, held in the UI realm because `document` is a browser API: this is the only + * place that knows whether the frame is still on screen, and a hidden tab stops the engine loop, + * so nothing in a scene frame would ever notice. Separate from the pause intent on purpose — the + * two clear independently, and a tab that comes back must not resume a paused storm. + */ + useEffect(() => { + const report = () => send(VISIBILITY_INTENT, document.hidden); + document.addEventListener("visibilitychange", report); + return () => document.removeEventListener("visibilitychange", report); + }, [send]); + + /** + * Reduced motion, read in the UI realm and applied once. A reader who then flips a switch has + * said something newer than their operating system, so the preference is never re-applied over a + * snapshot — which is also why it needs a ref rather than an effect dependency. + */ + const reducedApplied = useRef(false); + useEffect(() => { + if (state === undefined || reducedApplied.current) return; + reducedApplied.current = true; + if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + send("setSafe", true); + send("setAutoLightning", false); + send("setCinematic", false); + }, [send, state]); + + /** The first snapshot picks a tier from the width; a later choice is the player's and is kept. */ + const qualityChosen = useRef(false); + useEffect(() => { + if (state === undefined || qualityChosen.current) return; + qualityChosen.current = true; + // `?quality=` outranks the width default: an operator asking for `high` on a phone-sized + // window is asking for the tier, not for the guess this would otherwise make. + const asked = automationRequest(window.location.search).quality; + if (asked !== undefined) { + send("setQuality", asked); + return; + } + send("setQuality", window.innerWidth < NARROW_PX ? "performance" : "high"); + }, [send, state]); + + // Nothing yet from the game, so nothing is claimed: the engine's own loading screen is still up. + if (state === undefined) return null; + + // Until the storm is ready, the curtain is all there is, as in the study: no controls over a + // world that is not on screen yet. + if (!state.loading.ready) + return ; + + return ( + <> + + + + + ); +} diff --git a/packages/create-threenative/templates/rain/src/ui/Hud.tsx b/packages/create-threenative/templates/rain/src/ui/Hud.tsx new file mode 100644 index 0000000000..2b2d70bb05 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/Hud.tsx @@ -0,0 +1,327 @@ +import type { GameState } from "../state.js"; + +export interface IHudProps { + /** The game's published state. The mirror, never the live store, so it works on every target. */ + readonly state: GameState; + readonly send: (intent: string, payload?: unknown) => void; + readonly say: (text: string) => void; + /** Whether the weather panel is open on a narrow screen. Pure presentation, so it is local. */ + readonly panelOpen: boolean; + readonly setPanelOpen: (open: boolean) => void; + /** The toast line and whether it is showing. Held by `GameUi`, printed here in the sheet. */ + readonly toast: string; + readonly toastVisible: boolean; +} + +export interface ISceneSizes { + readonly height: number; + readonly width: number; +} + +/** What a capture really produced: the encoded image's own size, or why there was none. */ +export interface ICaptureResult extends ISceneSizes { + readonly blob?: Blob; + readonly bytes: number; + readonly ok: boolean; + readonly reason?: string; +} + +/** The page's own scene canvas. Null where there is no canvas to read, which is the honest answer. */ +export function sceneCanvas(): HTMLCanvasElement | null { + return document.querySelector('[data-threenative-canvas="true"] canvas'); +} + +/** + * Fullscreen, as the browser actually does it, reported as what the document says afterwards. + * + * There is no engine capability for this and no cross-platform promise to make here: this is the + * document's own fullscreen element, so the failure is reported in the toast rather than swallowed + * into a button that looks like it worked, and the returned answer is the element's own state. + */ +export async function toggleFullscreen(say: (text: string) => void): Promise { + try { + if (document.fullscreenElement === null) { + await document.documentElement.requestFullscreen(); + } else { + await document.exitFullscreen(); + } + } catch (why) { + say(`Fullscreen is not available in this viewer: ${(why as Error).message}`); + return false; + } + const on = document.fullscreenElement !== null; + say(on ? "Fullscreen on" : "Fullscreen off"); + return on; +} + +const PNG_MAGIC = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); +const PROBE_PX = 32; + +/** + * Prove the frame is a real image rather than guessing at it. + * + * A byte floor is a guess: it rejected a valid 390x844 phone capture for being small and would + * have accepted a large blank one. What a blank frame actually is, is a PNG that decodes to a + * single flat colour — so the guard decodes the bytes and counts distinct colours. The magic bytes + * are read too, because a renderer that hands the page a buffer which is not a PNG at all must be + * named as that rather than as a dark picture. + */ +async function proveFrame(canvas: HTMLCanvasElement): Promise { + const empty = { bytes: 0, height: 0, ok: false, width: 0 } as const; + const blob = await new Promise((done) => { + canvas.toBlob(done, "image/png"); + }); + if (blob === null || blob.size === 0) return { ...empty, reason: "the canvas encoded no image" }; + const magic = new Uint8Array(await blob.slice(0, PNG_MAGIC.length).arrayBuffer()); + if (magic.length !== PNG_MAGIC.length || !magic.every((byte, at) => byte === PNG_MAGIC.at(at))) { + return { ...empty, reason: "the encoded bytes are not a PNG" }; + } + const bitmap = await createImageBitmap(blob).catch(() => undefined); + if (bitmap === undefined) return { ...empty, reason: "the PNG did not decode" }; + const probe = document.createElement("canvas"); + probe.width = PROBE_PX; + probe.height = PROBE_PX; + const sampled = probe.getContext("2d", { willReadFrequently: true }); + if (sampled === null) { + bitmap.close(); + return { ...empty, reason: "no 2d context to sample the frame with" }; + } + sampled.drawImage(bitmap, 0, 0, PROBE_PX, PROBE_PX); + bitmap.close(); + const pixels = sampled.getImageData(0, 0, PROBE_PX, PROBE_PX).data; + const colours = new Set(); + for (let at = 0; at < pixels.length; at += 4) { + colours.add( + ((pixels.at(at) ?? 0) << 16) | ((pixels.at(at + 1) ?? 0) << 8) | (pixels.at(at + 2) ?? 0), + ); + } + if (colours.size < 2) { + return { ...empty, reason: "the frame decoded to a single flat colour" }; + } + return { blob, bytes: blob.size, height: canvas.height, ok: true, width: canvas.width }; +} + +/** + * Save the frame, through the page's own canvas, and report what was really written. + * + * A WebGPU canvas can hand the page a blank read, so the image is proved before the download is + * offered rather than after: the toast carries the encoded size and a failed read says why. + */ +export async function captureScene(say: (text: string) => void): Promise { + const canvas = sceneCanvas(); + const result = + canvas === null + ? ({ bytes: 0, height: 0, ok: false, reason: "no scene canvas", width: 0 } as const) + : await proveFrame(canvas); + if (!result.ok || result.blob === undefined) { + say(`Scene image unavailable: ${result.reason ?? "unknown"}`); + return result; + } + const url = URL.createObjectURL(result.blob); + const link = document.createElement("a"); + link.download = `rain-${Date.now()}.png`; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + say(`Scene image saved · ${result.width}x${result.height} · ${result.bytes} bytes`); + return result; +} + +/** + * The parts of the interface that are not the weather panel: the topbar and its controls, the + * compass, the hero title, the sound pill, the telemetry footer, the status toast, and the button + * that brings a hidden interface back. + * + * Every readout is the game's published value — the live atmosphere for telemetry, the mirrored + * booleans for the controls — so nothing here can drift from what the simulation is doing. + */ +export function Hud({ send, say, panelOpen, setPanelOpen, state, toast, toastVisible }: IHudProps) { + const soundOn = state.audioEnabled && !state.muted; + return ( + <> +
+
+
+
+ +
+
+
ATMOSPHERICS
+
INTERACTIVE FIELD STUDIES / 001
+
+
+
+
+ + REALTIME ENVIRONMENT +
+ + + + + +
+
+ +
+
+ NW + {state.heading}° + NE +
+
+
+ +
+
A COASTAL WEATHER STUDY
+

TEMPEST

+

Somewhere between stillness and the storm.

+
+ +
+
+
+
PRECIPITATION
+
+ {(state.weather.rain * 48).toFixed(1)} + mm/h +
+
+
+
WIND SPEED
+
+ {(state.weather.wind * 72).toFixed(1)} + km/h +
+
+
+
FRAME RATE
+
+ {state.fps > 0 ? Math.round(state.fps) : "—"} + fps +
+
+
+ {/* The pill and the key hints share one column, so neither can be drawn over the other or + over the panel above them however short the window is. */} +
+ +
+ DRAG LOOK   W A S D MOVE   Q E ALTITUDE +
+ H HIDE INTERFACE   R RESET CAMERA +
+
+
+ + + {toast} + +
+ {/* Outside the sheet, because the sheet is hidden when this is the way back. */} + + + ); +} diff --git a/packages/create-threenative/templates/rain/src/ui/LoadingOverlay.tsx b/packages/create-threenative/templates/rain/src/ui/LoadingOverlay.tsx new file mode 100644 index 0000000000..bb7c1ef5b9 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/LoadingOverlay.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +/** How long the curtain fades once the storm is ready; matches `.loading`'s CSS transition. */ +const FADE_MS = 800; + +/** + * The study's staged loading overlay, over the engine's own loading bar until the frame the storm + * is ready. The stages are read off the engine's real startup progress, so the text never claims a + * stage the launch has not reached. It paints only its text over the engine's own opaque loading + * screen (the same night colour), and once faded it leaves the page entirely. + */ +export function LoadingOverlay({ progress, ready }: { progress: number; ready: boolean }) { + const [removed, setRemoved] = useState(false); + useEffect(() => { + if (!ready) return; + const timer = setTimeout(() => setRemoved(true), FADE_MS); + return () => clearTimeout(timer); + }, [ready]); + if (removed) return null; + + const stage = ready + ? "ENTERING THE STORM" + : progress < 0.34 + ? "BUILDING THE CLOUD VOLUME" + : progress < 0.67 + ? "LIGHTING THE COAST" + : "ENTERING THE STORM"; + return ( +
+ {/* The progress line itself is the engine's canvas loading bar, centred between these two. */} +
+
TEMPEST
+
+ {stage} +
+
+
+ A procedural, real-time weather study. +
+ Contains lightning flashes. Disable them in +
+ Rendering & Accessibility or press X. +
+
+ ); +} diff --git a/packages/create-threenative/templates/rain/src/ui/Menu.tsx b/packages/create-threenative/templates/rain/src/ui/Menu.tsx new file mode 100644 index 0000000000..9368f04b97 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/Menu.tsx @@ -0,0 +1,370 @@ +import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useRef } from "react"; +import type { GameState, WeatherKey } from "../state.js"; + +export interface IMenuProps { + readonly state: GameState; + readonly send: (intent: string, payload?: unknown) => void; + readonly say: (text: string) => void; + /** Photosensitivity mode: safe on also takes automatic lightning down with it, as the study does. */ + readonly setSafeMode: (on: boolean) => void; + readonly toggleAutoLightning: () => void; + readonly panelOpen: boolean; + readonly setPanelOpen: (open: boolean) => void; +} + +interface ISwitchProps { + readonly id: string; + readonly label: string; + readonly checked: boolean; + readonly onToggle: () => void; +} + +interface ISliderControlProps { + readonly channel: WeatherKey; + readonly id: string; + readonly label: string; + /** The slider's own upper bound in hundredths. Exposure reaches 1.8, the rest stop at 1. */ + readonly max?: number; + readonly readout: ReactNode; +} + +const QUALITY_LABELS: readonly { readonly value: GameState["quality"]; readonly label: string }[] = + [ + { value: "performance", label: "Performance" }, + { value: "balanced", label: "Balanced" }, + { value: "high", label: "High" }, + { value: "ultra", label: "Ultra" }, + ]; + +function Switch({ checked, id, label, onToggle }: ISwitchProps) { + return ( +
+ {label} +
+ ); +} + +/** + * The frosted weather panel and the help dialog — every control that writes the atmosphere, plus + * the accessibility switches the study asks a reader to check before the first storm. + */ +export function Menu({ + panelOpen, + say, + send, + setPanelOpen, + setSafeMode, + state, + toggleAutoLightning, +}: IMenuProps) { + const dialogRef = useRef(null); + const helpRef = useRef(null); + // The study drops the lit preset as soon as a slider is dragged, because the target is then no + // longer the preset's. The game keeps naming the last preset, so the UI owns the distinction. + const customTarget = useRef(false); + const target = state.target; + + // A real `` in the top layer: focus is trapped, Escape is the platform's own, and the + // backdrop click lands on the dialog element itself. State still decides whether it is open. + useEffect(() => { + const dialog = dialogRef.current; + if (dialog === null) return; + if (state.helpOpen && !dialog.open) { + dialog.showModal(); + return; + } + if (!state.helpOpen && dialog.open) { + dialog.close(); + helpRef.current?.focus(); + } + }, [state.helpOpen]); + + const weather = (channel: WeatherKey, value: number) => { + customTarget.current = true; + send("setWeather", { [channel]: value }); + }; + + const choosePreset = (name: "drizzle" | "storm" | "supercell") => { + customTarget.current = false; + send("setPreset", name); + say(`${name.toUpperCase()} · transitioning the atmosphere`); + setPanelOpen(false); + }; + + const control = ({ channel, id, label, max = 100, readout }: ISliderControlProps) => ( +
+
+ + {readout} +
+ {/* Mirrored: the value is the target the simulation is easing towards, so the control can + never show a number the game is not actually heading for. `--fill` paints the mint part + of the track. */} + weather(channel, Number(event.target.value) / 100)} + style={ + { "--fill": `${Math.min(100, (target[channel] * 100) / max) * 100}%` } as CSSProperties + } + type="range" + value={Math.round(target[channel] * 100)} + /> +
+ ); + + return ( + <> + + + {/* + * The backdrop click is a mouse convenience. The keyboard way out is Escape, which the + * native dialog reports through `onCancel` above, so this needs no key handler of its own. + */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape is the keyboard way out. */} + { + event.preventDefault(); + send("closeHelp"); + }} + onClick={(event) => { + if (event.target === event.currentTarget) send("closeHelp"); + }} + ref={dialogRef} + > +
+ +
+ EXPLORE THE ATMOSPHERE +
+

A moment in the storm.

+

+ Drag the scene to look around. Move along the road or rise above the coast. Every + surface, cloud and sound is generated in real time. +

+
+ + W A S D Move + + + Q E Altitude + + + SHIFT Move faster + + + R Reset view + + + L Lightning + + + SPACE Pause + + + H Hide interface + + + P Save image + + + F Fullscreen + + + X Disable flashes + + + M Sound on / mute + +
+

+ Photosensitivity: lightning creates bright, repeated flashes. Press X to disable all + flashes. Reduced-motion preferences disable automatic lightning by default. +

+

+ Weather readings are simulation controls, not a live forecast. The landscape is + procedural, with no scanned assets. +

+
+
+ + ); +} diff --git a/packages/create-threenative/templates/rain/src/ui/automation.ts b/packages/create-threenative/templates/rain/src/ui/automation.ts new file mode 100644 index 0000000000..fd5e132ea7 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/automation.ts @@ -0,0 +1,60 @@ +/** + * The automation vocabulary, with no browser in it. + * + * The source study called its render tiers `low`, `balanced`, `high` and `cinematic`; this + * framework calls them `performance`, `balanced`, `high` and `ultra`. Both names are accepted — + * a captured URL or a script written against the source keeps working — and one table is the + * whole mapping. A name in neither list throws: a typo must not silently pick a tier and report + * success. + * + * Query reading takes a search string rather than reaching for `location`, so the rules are the + * same on every target and a test can hold one. + */ + +import type { QualityName } from "../state.js"; + +/** The four engine tiers, plus the four names the source study published for them. */ +export const QUALITY_ALIASES = { + balanced: "balanced", + cinematic: "ultra", + high: "high", + low: "performance", + performance: "performance", + ultra: "ultra", +} as const satisfies Record; + +export type QualityRequest = keyof typeof QUALITY_ALIASES; + +/** The engine tier one requested name means. Throws on a name in neither vocabulary. */ +export function qualityTier(name: unknown): QualityName { + if (typeof name !== "string" || !Object.hasOwn(QUALITY_ALIASES, name)) { + throw new Error( + `TN_RAIN_QUALITY_UNKNOWN ${String(name)}: expected low, balanced, high or cinematic`, + ); + } + return QUALITY_ALIASES[name as QualityRequest]; +} + +export interface IAutomationRequest { + /** `?still` — freeze the simulation instead of running it. Honoured after the first real frame. */ + readonly still: boolean; + /** The tier `?quality=` asked for, in engine names. */ + readonly quality?: QualityName; + /** A `?quality=` this build cannot honour, reported rather than quietly defaulted. */ + readonly rejectedQuality?: string; +} + +/** + * What the query asks for. An unreadable tier is named in `rejectedQuality` instead of thrown: a + * URL is a place somebody typed, and the page still has to open. + */ +export function automationRequest(search: string): IAutomationRequest { + const query = new URLSearchParams(search); + const asked = query.get("quality"); + if (asked === null) return { still: query.has("still") }; + try { + return { still: query.has("still"), quality: qualityTier(asked) }; + } catch { + return { still: query.has("still"), rejectedQuality: asked }; + } +} diff --git a/packages/create-threenative/templates/rain/src/ui/main.tsx b/packages/create-threenative/templates/rain/src/ui/main.tsx new file mode 100644 index 0000000000..1750a0cb31 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from "react-dom/client"; +import { GameUi } from "./GameUi.js"; +import "../style.css"; + +const root = document.getElementById("tn-ui"); +if (root === null) throw new Error("Missing #tn-ui element."); +createRoot(root).render(); diff --git a/packages/create-threenative/templates/rain/src/ui/tempest.ts b/packages/create-threenative/templates/rain/src/ui/tempest.ts new file mode 100644 index 0000000000..143f7c9f14 --- /dev/null +++ b/packages/create-threenative/templates/rain/src/ui/tempest.ts @@ -0,0 +1,149 @@ +/** + * The source study's `window.tempest`, rebuilt on this framework's own doors. + * + * The source was one HTML file with its own render loop, so `render()` drew a frame and `step(dt)` + * advanced the simulation by hand. Here the engine owns the loop and draws every frame anyway, so + * `render()` resolves once the scene has really drawn another one, and `step(dt)` hands the next + * frame `dt` seconds of simulation through the same intent door the buttons use. `stop()` freezes + * the simulation clock rather than the loop, so a stopped study can still be stepped and rendered, + * which is what the source's `stop` + `step` pairing was for. Every value it reports is read back + * from the store the simulation wrote. + * + * It lives in the UI realm because `window`, `document` and the UI end of the state bridge are + * browser APIs. `src/game.ts` stays portable: this sends the same validated intents the buttons do. + */ + +import type { IGame } from "@threenative/core"; +import { connectUiBridge, sendUiIntent, subscribeUiState } from "@threenative/core/ui-layer"; +import type { GameState, PresetName, WeatherKey } from "../state.js"; +import type { ICaptureResult, ISceneSizes } from "./Hud.js"; +import { captureScene, sceneCanvas } from "./Hud.js"; +import { automationRequest, qualityTier } from "./automation.js"; + +/** One automation verb, mapped to the intent the interface already sends. */ +type IntentName = + | "setFrozen" + | "setPreset" + | "setQuality" + | "setSafe" + | "setWeather" + | "step" + | "strike"; + +export interface ITempestApi { + /** Frames the scene has actually simulated and drawn, as it counted them. */ + readonly frames: number; + /** The lightning envelope the last drawn frame carried, or 0 in photosensitivity mode. */ + readonly flash: number; + /** The stage the renderer is really drawing, in device pixels. */ + readonly sizes: ISceneSizes; + /** The live published state, so a script reads what the simulation is actually doing. */ + readonly state: GameState; + readonly setPreset: (name: PresetName) => void; + /** Accepts the source's `low`/`balanced`/`high`/`cinematic` and the engine's four names. */ + readonly setQuality: (name: string) => void; + readonly setSafe: (on: boolean) => void; + readonly setWeather: (values: Partial>) => void; + /** Freeze the simulation clock. Frames still draw; only `step` moves it on. */ + readonly stop: () => void; + /** Advance the simulation by `dt` seconds (clamped to 0..60) and resolve on the frame that drew it. */ + readonly step: (dt: number) => Promise; + /** Resolve with the frame count once the scene has drawn at least one more frame. */ + readonly render: () => Promise; + readonly triggerLightning: () => void; + /** Save the frame. Resolves with the image that was really encoded, or why there was none. */ + readonly capture: () => Promise; +} + +const say = (text: string): void => { + console.info(`TN_TEMPEST ${text}`); +}; + +const canvasSize = (canvas: HTMLCanvasElement): ISceneSizes => ({ + height: canvas.height, + width: canvas.width, +}); + +/** + * Publish the facade and honour the query. + * + * `?still` waits for a real frame before it stops the loop. The source simply never started one, + * which could leave a canvas that had never been presented; here the loop belongs to the engine, + * so the only faithful still is the loop stopped after the first frame it actually drew, with the + * simulation clock where the source started it. + */ +export function installTempest(game: IGame): ITempestApi { + const bridge = connectUiBridge({ end: "ui" }); + const mirror = subscribeUiState(bridge); + const send = (intent: IntentName, payload?: unknown): void => { + sendUiIntent(bridge, intent, payload); + }; + const query = new URLSearchParams(window.location.search); + const request = automationRequest(window.location.search); + if (request.rejectedQuality !== undefined) { + say( + `?quality=${request.rejectedQuality} is not a tier: expected low, balanced, high or cinematic`, + ); + } + if (request.quality !== undefined) send("setQuality", request.quality); + if (query.has("offline")) { + // The source's offline WebGL2 adapter stood in for a Three.js build it could not load from a + // CDN. This build installs Three locally and never reaches for one, so there is no fallback + // to select and nothing here pretends otherwise. + say("?offline selects no adapter: this build loads Three from its own install"); + } + + /** Resolves once the simulation has counted a frame past the one this was called on. */ + const nextFrame = (): Promise => { + const from = game.state.getState().frame; + return new Promise((resolve) => { + const stop = mirror.subscribe(() => { + const frame = mirror.get()?.frame ?? 0; + if (frame <= from) return; + stop(); + resolve(frame); + }); + }); + }; + + const api: ITempestApi = { + get frames() { + return game.state.getState().frame; + }, + get flash() { + return game.state.getState().flash; + }, + get sizes() { + const canvas = sceneCanvas(); + return canvas === null ? { height: 0, width: 0 } : canvasSize(canvas); + }, + get state() { + return game.state.getState(); + }, + setPreset: (name) => send("setPreset", name), + setQuality: (name) => send("setQuality", qualityTier(name)), + setSafe: (on) => send("setSafe", on), + setWeather: (values) => send("setWeather", values), + stop: () => send("setFrozen", true), + step: (dt) => { + const frame = nextFrame(); + send("step", dt); + return frame; + }, + render: nextFrame, + triggerLightning: () => send("strike"), + capture: () => captureScene(say), + }; + if (request.still) { + const stop = mirror.subscribe(() => { + if ((mirror.get()?.frame ?? 0) < 1) return; + stop(); + send("setFrozen", true); + say(`still · loop stopped on frame ${api.frames} · t=${api.state.elapsed.toFixed(2)}s`); + }); + } + (window as unknown as { tempest?: ITempestApi }).tempest = api; + const { width, height } = api.sizes; + say(`ready · ${width}x${height} · ${request.still ? "still" : "running"}`); + return api; +} diff --git a/packages/create-threenative/templates/rain/tailwind.config.ts b/packages/create-threenative/templates/rain/tailwind.config.ts new file mode 100644 index 0000000000..f35b8cb31d --- /dev/null +++ b/packages/create-threenative/templates/rain/tailwind.config.ts @@ -0,0 +1,2 @@ +// Tailwind 4 is CSS-first. Keep this file for familiar project discovery. +export default {}; diff --git a/packages/create-threenative/templates/rain/threenative.config.ts b/packages/create-threenative/templates/rain/threenative.config.ts new file mode 100644 index 0000000000..a2b7b6d7a7 --- /dev/null +++ b/packages/create-threenative/templates/rain/threenative.config.ts @@ -0,0 +1,66 @@ +import type { IThreeNativeConfig } from "@threenative/core"; + +const config: IThreeNativeConfig = { + app: { + id: "com.threenative.__PROJECT_ID__", + name: "__PROJECT_NAME__", + version: "1.0.0", + build: 1, + icon: "public/icon.png", + icons: { web: { favicon: "public/favicon.svg" } }, + }, + display: { + orientation: "landscape", + fullscreen: true, + keepScreenOn: true, + maxFps: 60, + }, + window: { + title: "__PROJECT_NAME__", + width: 1280, + height: 720, + maximized: false, + resizable: true, + }, + bootSplash: { backgroundColor: "#061b2b" }, + nativeEntry: "src/game.ts", + renderer: { + preferWebGPU: true, + resolutionScale: "auto", + // Multisampling resolves triangle edges. A cutout silhouette — foliage, a fence, hair — is + // carved inside the triangle by an alpha test, so it resolves through the coverage mask or + // not at all, which is what this spends the samples above on. It costs no target and no + // extra pass; set it false for a deliberately hard-edged look. `TN_ALPHA_ANTIALIASING` + // reports what it did, and says so when a single-sampled surface leaves it nothing to do. + alphaAntialiasing: true, + }, + ui: { renderer: "web" }, + assets: { + audio: { + // The three storm clips are the authored waveform, not material to be conditioned. Each is + // a Float32 WAV baked by `tools/make-storm-audio.mjs` from the reference study's own maths, + // and the default pass would move them: a peak ceiling on the wind bed (which peaks above + // 1.0 by design) and a Vorbis round trip on all three. `conditioning: "none"` ships the bytes + // as committed — measurement, the receipt, and each loop's seam assertion all still run, so + // this buys preservation, not silence. + overrides: [ + { conditioning: "none", glob: "rain-loop.wav", loop: true }, + { conditioning: "none", glob: "thunder.wav" }, + { conditioning: "none", glob: "wind-loop.wav", loop: true }, + ], + }, + }, + // One asset tree, one compiler, one representation per artifact. Uncomment, then cook: + // threenative build --target android # cooks defaults.android; --profile beats it + // buildProfiles: { + // defaults: { android: "compact" }, + // profiles: { + // compact: { + // assets: { textures: { maxSize: 1024 }, models: { textures: { maxSize: 1024 } } }, + // } }, + // }, + // Contract, byte definitions, the build report: + // node_modules/create-threenative/agent-docs/references/build-profiles.md +}; + +export default config; diff --git a/packages/create-threenative/templates/rain/tools/generate-shaders.mjs b/packages/create-threenative/templates/rain/tools/generate-shaders.mjs new file mode 100644 index 0000000000..9018119314 --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/generate-shaders.mjs @@ -0,0 +1,656 @@ +// Regenerates src/render/*-shader.ts from the tempest-*.frag files. +// +// Two shaders come out of one pipeline: the raymarched coast and the cloud volume it stands under. +// Both are several hundred lines of GLSL from the Tempest single-file demo, so porting either by +// hand is a transcription exercise with no end state. The installed Three.js transpiler +// (`three/examples/jsm/transpiler`) does the port at authoring time and this file checks the +// result. Nothing is compiled or evaluated at runtime: the output is an ordinary `.ts` module that +// Vite and the native packager see like any other source file. +// +// Fails closed. A GLSL construct the upstream decoder does not understand, a uniform the emitted +// code reads but nothing binds, a local this file cannot materialise, a branch dropped at runtime, +// or any warning from the decoder stops the run instead of writing a file that quietly lost one. +// +// node tools/generate-shaders.mjs +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import GLSLDecoder from "three/examples/jsm/transpiler/GLSLDecoder.js"; +import TSLEncoder from "three/examples/jsm/transpiler/TSLEncoder.js"; +import Transpiler from "three/examples/jsm/transpiler/Transpiler.js"; +import ts from "typescript"; + +const here = dirname(fileURLToPath(import.meta.url)); +// The repository this template lives in, four levels up from `tools/`. A scaffolded project has no +// Biome of its own, which is why the formatting step below is optional and says so. +const repoRoot = resolve(here, "../../../../.."); +// Biome refuses paths outside the directory it is run from, so it runs from the repository root, +// which is also where the configuration that formats the generated file lives. The unsafe fixes +// are asked for on purpose and were diffed: for this output they are the import sort and the +// split of multi-name `const` declarators, both of which leave the graph alone. +const biome = resolve(repoRoot, "node_modules/.bin/biome"); + +/** + * A sampler the decoder turns into a texture node, and how each is declared here. Both start as a + * 1-voxel placeholder that the owning module replaces by assigning `.value`, because a sampled node + * needs a texture before the graph is built. + */ +const SAMPLERS = { + sampler2D: { node: "texture", texture: "DataTexture", create: "1, 1" }, + sampler3D: { node: "texture3D", texture: "Data3DTexture", create: "1, 1, 1" }, +}; + +/** Uniform defaults, so nothing reads an unset value on the first frame. */ +const DEFAULTS = { + float: "0", + int: "0", + vec2: "new Vector2()", + vec3: "new Vector3()", + vec4: "new Vector4()", +}; + +/** + * Applied to every emitted module. GLSL's `mat2(a, b, c, d)` is column-major — `(a, b)` is the first + * column — but TSL's `mat2(a, b, c, d)` with constants builds a `Matrix2`, whose constructor takes + * row-major arguments. Passed through unchanged, fbm's octave rotation comes out transposed: a + * rotation the other way, so every octave after the first lands somewhere else and the terrain's + * ridges, the puddles and the cloud shapes all drift from the study's. Swapping the off-diagonal + * pair is the transpose back. + */ +const MAT2_COLUMN_MAJOR = { + what: "a constant mat2 in GLSL's column-major order", + find: /mat2\(\s*([^,()]+),\s*([^,()]+),\s*([^,()]+),\s*([^,()]+)\)/g, + replace: "mat2($1, $3, $2, $4)", +}; + +/** + * The shaders this generates. `rewrites` are edits to the GLSL text; each one is a rewrite with a + * named reason, and a rule that matches nothing stops the run. `entryOpen` turns the stage's + * `main` into the one function the module exports; `entryClose` covers what that function has to + * hand back. + */ +const SHADERS = [ + { + name: "world", + marker: "TN_WORLD_SHADER", + source: "tempest-world.frag", + target: "../src/render/world-shader.ts", + entry: "tempestWorld", + /** The coast raymarches with a depth term, so it seeds one the way a GLSL global would. */ + entryOpen: ["void main(){", "vec4 tempestWorld(vec2 vUv){float worldDepth=1.;"], + entryClose: [ + { what: "gl_FragDepth", find: "gl_FragDepth", replace: "worldDepth" }, + { + what: "the fragment output", + find: "fragColor=vec4(max(color,vec3(0.)),1.);", + replace: "return vec4(max(color,vec3(0.)),worldDepth);", + }, + ], + samplers: ["sampler2D"], + /** The ripple loop is a nested pair, which the decoder names in a way `LoopNode` cannot read. */ + nestedLoop: true, + /** + * Edits to the code the transpiler emitted, rather than to the GLSL it read. This one is the + * coast's alone: the decoder turns `roadCenter`'s `-1.6` into `-(1.6).add(x)`, which asks a + * JavaScript number for a TSL method and throws on the first frame. `float(-1.6)` is the same + * value. The cloud volume has no literal in that position, so it declares none. + */ + outputRewrites: [ + MAT2_COLUMN_MAJOR, + { + what: "a negated literal with a TSL method call on it", + find: /- ?((?:\d+\.\d*|\.\d+|\d+))\.(?=[A-Za-z_$])/g, + replace: "float(-$1).", + }, + { + // The one orientation rule this shader needs, and it is deliberately not a camera- or + // backend-name conditional. `vUv` is the screen quad's geometry uv: bottom-up, `y = 1` at + // the top of the frame. `uSky` is a `PassTextureNode` — a render target written by the + // cloud pass — and a render target's first row is the top of the image it holds, so its + // `y = 0` is the top of the frame. Sampling one with the other hands the coast the sky + // upside down: the deck's bright horizon band lands high in the frame, well above the + // world's own horizon, and the whole ceiling is mirrored. Normalising the coordinate at the + // fetch is the fix, and it is fetch-local: `ray(vUv)` still marches with the bottom-up uv + // the source was written against, so the cloud volume and every density in it are + // untouched. Both reads are rewritten, the direct sky sample and the reflected `suv`, + // because a flip on one and not the other would put the reflections somewhere the sky they + // mirror is not. + what: "the pass-texture sample coordinate", + find: /uSky\.sample\(\s*(\w+)\s*\)/g, + replace: "uSky.sample(vec2($1.x,$1.y.oneMinus()))", + imports: ["oneMinus"], + }, + ], + rewrites: [ + { + // The upstream decoder only reads the first name of a comma-separated uniform list, so every + // uniform after the first in `uniform float a,b,c;` is dropped from the emitted module and + // would be a ReferenceError at runtime. `boundUniforms` below re-adds and checks them all. + what: "the ripple loop braces", + find: "for(int j=-1;j<=1;j++)", + replace: "for(int j=-1;j<=1;j++){", + }, + { + what: "the ripple loop close", + find: " }return sum*uRain;", + replace: " }}return sum*uRain;", + }, + ], + header: `// The shader is the coast: terrain, road, sea, the rail and cabin on it, wet reflections and the +// fog that closes the distance. It is raymarched, so it is one full-screen quad and one function +// call — see src/render/world.ts for the uniforms and the camera basis it expects.`, + }, + { + name: "clouds", + marker: "TN_CLOUDS_SHADER", + source: "tempest-clouds.frag", + target: "../src/render/clouds-shader.ts", + entry: "tempestClouds", + /** The cloud volume has no depth term; it is the colour the coast samples for its sky. */ + entryOpen: ["void main(){", "vec4 tempestClouds(vec2 vUv){"], + entryClose: [], + samplers: ["sampler3D"], + nestedLoop: false, + outputRewrites: [ + MAT2_COLUMN_MAJOR, + { + // The decoder's own `gl_FragCoord` polyfill reaches for `screenCoordinate.z`, which is not a + // member of a `vec2` screen coordinate: the fragment depth is its own node, and that is what + // `gl_FragCoord.z` is. + what: "the depth component of the decoded gl_FragCoord", + find: "screenCoordinate.z", + replace: "depth", + imports: ["depth"], + }, + ], + rewrites: [], + header: `// The shader is the storm ceiling: a raymarched volume between 145 and 815 metres, lit by a +// five-tap march toward the sun and filled from a procedural 64³ noise volume. It is one full-screen +// pass into a render target the coast samples — see src/render/clouds.ts.`, + }, +]; + +/** Applied to every shader before it is parsed, because none of them is valid GLSL on its own. */ +const COMMON_REWRITES = [ + { + // `precision` is a GLSL-only qualifier with no TSL equivalent; TSL picks the precision itself. + what: "precision qualifiers", + find: /precision[^;]*;/g, + replace: "", + }, + { + // The stage's own varyings become the generated function's parameters, not module state. + what: "stage declarations", + find: /\b(?:in|out)\s+\w+\s+\w+\s*;/g, + replace: "", + }, +]; + +// The emitted code is JavaScript, so the structural fixes below are made on its syntax tree rather +// than on its text: the transpiler's own output decides where they apply, and a shape this file +// does not recognise stops the run rather than being rewritten by a pattern that happened to fit. +// TypeScript is the template's own devDependency, so a scaffolded project has it. + +const parse = (text) => + ts.createSourceFile("shader.ts", text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + +/** + * The name a call's callee is written as. Both spellings appear in TSL: `Fn(...)` for the imported + * functions and `node.toVar()` for the methods, and a declaration initialised by a method call has + * to be recognised as already materialised, so a property access counts too. + */ +const callee = (node) => { + const { expression } = node; + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + return null; +}; + +/** `true` for the arrow or function the transpiler wrote as an argument to `Fn(...)`. */ +const isFnBody = (node) => + (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && + ts.isCallExpression(node.parent) && + callee(node.parent) === "Fn"; + +/** + * `Fn(fn, { p: 'vec2', return: 'float' })` is the transpiler's shorthand for a function taking one + * vec2, and as a second argument it is not a layout anything reads. `Fn`'s builder path takes that + * second argument as a shader-node builder to hand the callback, so the emitted functions capture + * every uniform they read into a struct that is never bound: the first compile reports a missing + * uniform member, and the frame group resolves no member at all. Nothing is lost by dropping it, + * because no function is ever emitted as a real shader function — all of them are inlined, and the + * types the layout spelled out are already on the callback parameters as typed tuples. Removed by + * span, from every `Fn` call. + */ +function stripFnLayout(code) { + const file = parse(code); + const spans = []; + const walk = (node) => { + if (ts.isCallExpression(node) && callee(node) === "Fn") { + const [callback, layout] = node.arguments; + if (node.arguments.length === 1) { + if (!callback || !isFnBody(callback)) + throw new Error("TN_TRANSPILER_FN_SHAPE: an Fn call lost its callback"); + } else if ( + node.arguments.length === 2 && + callback && + isFnBody(callback) && + ts.isObjectLiteralExpression(layout) + ) { + spans.push([callback.end, layout.end]); + } else { + throw new Error( + `TN_TRANSPILER_FN_SHAPE: Fn takes ${node.arguments.length} arguments and none is a layout to remove`, + ); + } + } + ts.forEachChild(node, walk); + }; + walk(file); + if (spans.length === 0) + throw new Error("TN_TRANSPILER_NO_FN_LAYOUT: no Fn call carried a layout argument"); + let out = code; + for (const [from, to] of spans.reverse()) out = out.slice(0, from) + out.slice(to); + return out; +} + +/** + * A local GLSL variable is a mutable TSL node, but the transpiler writes it as a plain JavaScript + * `const` holding the expression it was initialised with. Every later `+=` or `.assign` then + * mutates a temporary nothing reads back, so the shader compiles and runs holding the first value + * it ever saw: the march never advances and the scene stays the sky it started on. `toVar()` is + * what names a node — it materialises the value into a shader variable the rest of the function can + * reassign — and it is needed on every initialised local, not only on the ones a parameter was + * assigned to. Applied inside function bodies only, so the module-level declarations, which are + * uniforms and the `Fn` calls themselves, are left as the transpiler wrote them. + */ +function materializeLocals(code) { + const file = parse(code); + const inserts = []; + const destructured = []; + const walk = (node, inBody) => { + const body = inBody || isFnBody(node) || ts.isFunctionDeclaration(node); + if (body && ts.isVariableDeclaration(node) && node.initializer) { + if (ts.isIdentifier(node.name)) { + const { initializer } = node; + if (!(ts.isCallExpression(initializer) && callee(initializer) === "toVar")) { + inserts.push(initializer.end); + } + } else { + destructured.push(node.name.getText(file)); + } + } + ts.forEachChild(node, (child) => walk(child, body)); + }; + walk(file, false); + if (destructured.length > 0) { + throw new Error( + `TN_TRANSPILER_DESTRUCTURED: ${destructured.join(", ")} destructures, so its value cannot be materialised`, + ); + } + if (inserts.length === 0) + throw new Error("TN_TRANSPILER_NO_LOCALS: no local variable was found to materialise"); + let out = code; + for (const at of inserts.reverse()) out = `${out.slice(0, at)}.toVar()${out.slice(at)}`; + return out; +} + +/** + * A `return` inside an `If` or `Loop` callback is not the GLSL early return it was written as. Those + * callbacks run while the node graph is being built, so the `return` only leaves the callback and + * the node it was building is never assigned anywhere: the branch is dropped and the function keeps + * whatever its last line said. That is how the coast's `baseColor` ended up returning a flat + * `vec3(.1)` for every material and its `reflected` returning the sky for every ray. The sources + * normalise a helper to one final return over `else if` and `Break` instead, so a return still + * shaped like an early one is a mistake to stop on rather than to compile. + */ +function assertNoNestedReturns(code) { + const file = parse(code); + const nested = []; + const walk = (node) => { + if (ts.isReturnStatement(node)) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (isFnBody(parent)) break; + if (ts.isCallExpression(parent) && (callee(parent) === "If" || callee(parent) === "Loop")) { + nested.push(node.getText(file).split("\n")[0].trim()); + break; + } + } + } + ts.forEachChild(node, walk); + }; + walk(file); + if (nested.length > 0) { + throw new Error( + `TN_TRANSPILER_NESTED_RETURN: ${nested.length} return(s) inside If/Loop would be dropped at runtime: ${nested.join(" | ")}`, + ); + } +} + +/** Index of the `}` that closes the `{` at `open`, counting braces in the text between them. */ +function closingBrace(text, open) { + let depth = 0; + for (let index = open; index < text.length; index += 1) { + if (text[index] === "{") { + depth += 1; + } else if (text[index] === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + throw new Error("TN_TRANSPILER_UNCLOSED_BRACE"); +} + +const LOOP_BOUNDS = /Loop\(\s*\{[^{}]*?((?:start|end):\s*-?\d+(?:\.\d+)?)/g; +/** + * `Loop({ start: 0, end: 6 }, …)` leaves the counter's type to be inferred from a bare number, and + * nothing is left to infer it from. GLSL wrote `int i`, so the bounds are stated as `int` too. + */ +function typeLoopBounds(body, injected) { + const typed = body.replaceAll(LOOP_BOUNDS, (match, bound) => + match.replace(bound, `${bound.replace(":", ": int(")})`), + ); + if (typed === body) throw new Error("TN_TRANSPILER_LOOP_BOUNDS: no bare loop bound was found"); + // The decoder leaves the import list out of it: it emitted a bare number, so it never reached for + // `int`, and nothing else in a shader with no `int` local would have imported it either. + injected.add("int"); + return typed; +} + +/** + * The decoder names a loop counter through `params.name`, which `LoopNode` reads and the published + * types do not declare — their own source leaves that field commented out with a TODO saying it + * should be typed. The nested pair is therefore written as `Loop`'s two-parameter form, which the + * types do describe and which `LoopNode` builds from the same two `for` loops, naming the counters + * `i` and `j`. The GLSL ran both counters over the same range, so the order cannot matter. + */ +function flattenNestedLoop(body) { + const pattern = /Loop\(\s*(\{[^{}]*name: 'j'[^{}]*\}),\s*\(\s*\{\s*j\s*\}\s*\)\s*=>\s*\{/; + const nested = body.match(pattern); + if (nested === null) + throw new Error("TN_TRANSPILER_NO_NESTED_LOOP: the ripple loop changed shape"); + const outerOpen = body.indexOf("{", nested.index + nested[0].length - 1); + const outerClose = closingBrace(body, outerOpen); + const innerStart = body.indexOf("Loop(", outerOpen); + const innerBounds = closingBrace(body, body.indexOf("{", innerStart)); + // The callback's own parameter list is destructured, so its braces come first; the body opens + // after the arrow, not after the bounds. + const innerOpen = body.indexOf("{", body.indexOf("=>", innerBounds)); + const innerClose = closingBrace(body, innerOpen); + const innerParams = body.slice(body.indexOf("{", innerStart), innerBounds + 1); + const outerParams = nested[1].replace(/\s*name: 'j',?/, ""); + return [ + body.slice(0, nested.index), + `Loop(${outerParams}, ${innerParams}, ({ i, j }) => {`, + body.slice(innerOpen + 1, innerClose), + "});", + body.slice(body.indexOf(";", outerClose) + 1), + ].join(""); +} + +/** + * `Fn(fn, { p: 'vec2', return: 'float' })` states the parameter types as strings, so TypeScript + * infers nothing from them and settles for `Fn`'s first overload — the one whose callback receives + * the node builder — and every parameter then arrives typed as a NodeBuilder. Annotating the + * parameter with the type the layout already names makes that callback incompatible with the + * builder overload, so the argument-taking overload is the one that matches. No suppression, and the + * return type still comes from the body rather than being asserted. + */ +const FN_SIGNATURE = + /(export const \w+ = \/\*@__PURE__\*\/ Fn\(\s*\(\s*)((?:\[[^\]]*\]|\w+))(\s*\)\s*=>\s*\{[\s\S]*?\n\}, \{)([^{}]*?)(\} \);)/g; +function typeCallbackParameters(body) { + let typed = 0; + const out = body.replace(FN_SIGNATURE, (match, head, parameters, middle, layout, tail) => { + const types = [...layout.matchAll(/(\w+)\s*:\s*'(\w+)'/g)] + .filter(([, name]) => name !== "return") + .map(([, , type]) => `Node<"${type}">`); + if (types.length === 0) + throw new Error("TN_TRANSPILER_NO_PARAMETERS: a function declares none"); + if (types.length > 1 && !parameters.startsWith("[")) { + throw new Error( + `TN_TRANSPILER_PARAMETER_COUNT: ${parameters} has no ${types.length} parameters`, + ); + } + typed += 1; + // The transpiler destructures every parameter from an array, so the annotation is a tuple + // whenever the code destructures and the bare node type when it does not. + const annotation = parameters.startsWith("[") ? `[ ${types.join(", ")} ]` : types[0]; + return `${head}${parameters}: ${annotation}${middle}${layout}${tail}`; + }); + if (typed === 0) throw new Error("TN_TRANSPILER_NO_FUNCTIONS: nothing to type"); + return { body: out, typed }; +} + +function rewriteAll(text, rules, prefix) { + let out = text; + for (const rule of rules) { + const before = out; + out = out.replaceAll(rule.find, rule.replace); + if (out === before) throw new Error(`${prefix}_RULE_DEAD: ${rule.what} matched nothing`); + } + return out; +} + +/** The decoder's own import list, plus whatever this file injected, as the double-quoted form. */ +function mergeTslImport(importLine, injected) { + // `frameGroup` leads because every bound uniform below is set on it, and the decoder never emits + // it: it has no reason to, having written the uniforms itself. + const names = [ + "frameGroup", + ...importLine + .match(/\{([^}]*)\}/)[1] + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ]; + for (const name of injected) if (!names.includes(name)) names.push(name); + return `import { ${names.join(", ")} } from "three/tsl";`; +} + +function generate(config) { + const target = resolve(here, config.target); + const raw = readFileSync(resolve(here, config.source), "utf8"); + // The file opens with a provenance note in comment form; the transpiler copies comments through, + // so it is stripped before parsing rather than carried into the generated module. + const provenance = raw.match(/^(?:\/\/[^\n]*\n)+/); + if (provenance === null) + throw new Error(`${config.marker}_NO_PROVENANCE: the source has no header note`); + const normalized = rewriteAll( + raw.slice(provenance[0].length), + [ + ...COMMON_REWRITES, + { what: "the GLSL entry point", find: config.entryOpen[0], replace: config.entryOpen[1] }, + ...config.entryClose, + ...config.rewrites, + ], + config.marker, + ); + + // Uniform types the decoder turns into a TSL node, read before anything is parsed so a name the + // decoder would drop is still known here. + const declared = new Map(); + for (const match of normalized.matchAll(/\buniform\s+(\w+)\s+([^;]+);/g)) { + const [, type, names] = match; + for (const name of names.split(",")) declared.set(name.trim(), type); + } + if (declared.size === 0) + throw new Error(`${config.marker}_NO_UNIFORMS: the source declares none`); + + const warnings = []; + const quiet = (action) => { + for (const level of ["warn", "error"]) { + const original = console[level]; + console[level] = (...args) => warnings.push(`${level}: ${args.join(" ")}`); + try { + return action(); + } finally { + console[level] = original; + } + } + }; + const emitted = quiet(() => + new Transpiler(new GLSLDecoder(), new TSLEncoder()).parse(normalized), + ); + if (warnings.length > 0) { + throw new Error(`${config.marker}_WARNINGS:\n${warnings.join("\n")}`); + } + if (!emitted.includes(`export const ${config.entry}`)) { + throw new Error(`${config.marker}_NO_ENTRY: the entry point was not emitted`); + } + + // The decoder writes every global uniform as its own single-line `const`, and puts the TSL import + // first. Take the import, drop the uniform lines, and re-emit the ones the code actually reads. + const tslImport = emitted.match(/^import \{[^}]*\} from 'three\/tsl';$/m); + if (tslImport === null) throw new Error(`${config.marker}_NO_IMPORT: the TSL import is missing`); + let body = rewriteAll( + emitted + .replace(tslImport[0], "") + .split("\n") + .filter((line) => !/^const u[A-Za-z0-9]+ = (?:uniform|texture\w*)\(/.test(line)) + .join("\n"), + config.outputRewrites, + config.marker, + ); + + const injected = new Set(); + // A rewrite that substitutes a different TSL call brings its own import with it. + for (const rule of config.outputRewrites) + for (const name of rule.imports ?? []) injected.add(name); + body = typeLoopBounds(body, injected); + if (config.nestedLoop) { + body = flattenNestedLoop(body); + if (/name:\s*'[a-z]'/.test(body)) { + throw new Error(`${config.marker}_LOOP_NAME: a named loop counter survived the rewrite`); + } + } + + const read = /[^A-Za-z0-9_$]?([A-Za-z_$][A-Za-z0-9_$]*)[^A-Za-z0-9_$]?/g; + const used = new Set([...body.matchAll(read)].map((match) => match[1])); + + const threeImports = new Set(["Vector2", "Vector3", "Vector4"]); + const bound = []; + const missing = []; + for (const [name, type] of declared) { + if (!used.has(name)) continue; + if (type in DEFAULTS) { + // `frameGroup`, not the default per-object group: a stack node — every `If`, `Loop` and `Fn` + // in these shaders — builds through a sub-builder, and a uniform first reached inside one is + // registered on that sub-builder's group, leaving the shader's uniform struct short of members + // and the WGSL invalid. One frame-wide buffer, read once per frame, is also what these values + // are: each shader is a single quad, and every one of them is set by the same call each frame. + bound.push( + `export const ${name} = uniform(${DEFAULTS[type]}, "${type}").setGroup(frameGroup);`, + ); + } else if (config.samplers.includes(type)) { + const sampler = SAMPLERS[type]; + threeImports.add(sampler.texture); + // The decoder can only emit a placeholder for a sampler, and a sampled node needs a texture + // before the graph is built, so the placeholder is a single voxel. It is also the wrong kind + // of binding for a render target: a pass that is only ever fed to `.value` is no longer in + // the graph the renderer walks, so it never gets a frame to render into. The binding is + // therefore a rebindable `let` and the owning module swaps the whole node through + // `setX(node)` before the entry function is called — the shader's own `Fn` callbacks read + // it when the graph is built, which is the moment a rebind has to have happened. The sampled + // UVs and colour space are the caller's, as in any TSL material. + const setter = `set${name.replace(/^u([A-Z])/, (_, first) => first.toUpperCase())}`; + bound.push( + `const ${name}Placeholder = new ${sampler.texture}(new Uint8Array([0, 0, 0, 255]), ${sampler.create});`, + `${name}Placeholder.needsUpdate = true;`, + `export let ${name}: TextureNode = ${sampler.node}(${name}Placeholder).setGroup(frameGroup);`, + `export function ${setter}(value: TextureNode): void {`, + ` ${name} = value;`, + "}", + ); + } else { + missing.push(`${name} (${type})`); + } + } + if (missing.length > 0) { + throw new Error( + `${config.marker}_UNBOUND: the shader reads ${missing.join(", ")} with no binding`, + ); + } + if (bound.length === 0) throw new Error(`${config.marker}_NO_BOUND_UNIFORMS: nothing is bound`); + + const { body: annotated, typed } = typeCallbackParameters(body); + const stripped = stripFnLayout(annotated); + const materialized = materializeLocals(stripped); + assertNoNestedReturns(materialized); + + const mergedImport = mergeTslImport(tslImport[0], injected); + const header = `// GENERATED FILE — do not edit it. Every line below is the output of Three.js's own GLSL to +// TSL transpiler, run over tools/${config.source}. That .frag file +// is the source of truth; change it and the generator, never this file. +// +// node tools/generate-shaders.mjs +// +${config.header}`; + const output = [ + header, + `import { ${[...threeImports].sort().join(", ")} } from "three";`, + mergedImport, + `import type { Node, TextureNode } from "three/webgpu";`, + "", + "// Uniforms the shader reads. Values are assigned by src/render/world.ts and src/render/clouds.ts,", + "// which own the camera basis and the weather; the generated module only holds the nodes.", + ...bound, + "", + materialized.trim(), + "", + ].join("\n"); + + // Every name the emitted code calls is either a `three/tsl` import, a uniform, or a function the + // transpiler itself emitted. Anything else would be a silent ReferenceError in the template, which + // is the failure this whole check exists to catch. + const imported = new Set( + mergedImport + .match(/\{([^}]*)\}/)[1] + .split(",") + .map((name) => name.trim()), + ); + const called = (text) => + new Set( + [ + ...text.replace(/\/\/[^\n]*/g, "").matchAll(/(? match[1]), + ); + const local = new Set( + [...materialized.matchAll(/(?:const|function)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]), + ); + const unimported = [...called(materialized)].filter( + (name) => !imported.has(name) && !declared.has(name) && !local.has(name), + ); + if (unimported.length > 0) { + throw new Error( + `${config.marker}_UNIMPORTED: ${unimported.join(", ")} is called but never imported`, + ); + } + + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, output); + const formatted = existsSync(biome); + if (formatted) { + execFileSync(biome, ["check", "--fix", "--unsafe", relative(repoRoot, target)], { + cwd: repoRoot, + stdio: "inherit", + }); + } + console.log( + `${config.marker}:${JSON.stringify({ + declared: declared.size, + bound: bound.length, + bytes: output.length, + dropped: [...declared.keys()].filter( + (name) => !bound.some((line) => line.includes(` ${name} = `)), + ), + formatted, + functions: typed, + layoutsStripped: typed, + localsMaterialized: (materialized.match(/\.toVar\(\)/g) ?? []).length, + warnings: 0, + })}`, + ); +} + +for (const config of SHADERS) generate(config); diff --git a/packages/create-threenative/templates/rain/tools/make-storm-audio.mjs b/packages/create-threenative/templates/rain/tools/make-storm-audio.mjs new file mode 100644 index 0000000000..6523b8e0d7 --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/make-storm-audio.mjs @@ -0,0 +1,387 @@ +// Bakes the reference study's `StormAudio` into the committed clips `src/audio/storm.ts` plays. +// +// node tools/make-storm-audio.mjs # write assets/*.wav and self-check them +// node tools/make-storm-audio.mjs --verify # also prove the constants match that source +// +// The bake is deterministic: one seeded generator, no clock, no environment, no arguments. Two +// runs produce byte-identical files, and the self-check below is what makes that a fact rather +// than a hope — it re-runs the whole bake and compares hashes. +// +// `--verify` takes a copy of the reference study. It re-reads the constants out of that file and +// refuses to run if any of them has drifted from the transcription in `storm-dsp.mjs`, so this +// file cannot quietly stop being the source. It is optional only because the reference is not +// vendored into this repository. + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + LOOPS, + LOOP_SECONDS, + STORM_SEED, + THUNDER_SECONDS, + biquad, + filterLoop, + loopBody, + mulberry32, + thunderBody, +} from "./storm-dsp.mjs"; + +/** Committed at 44.1 kHz stereo, float32 — see `encodeWav` for why not 16-bit. */ +const SAMPLE_RATE = 44100; +const CHANNELS = 2; +const BITS = 32; +const BYTES_PER_SAMPLE = BITS / 8; +const BLOCK_ALIGN = CHANNELS * BYTES_PER_SAMPLE; +const WAVE_FORMAT_IEEE_FLOAT = 3; + +const ASSETS = { + rain: { file: "rain-loop.wav", label: "rain hiss" }, + wind: { file: "wind-loop.wav", label: "wind bed" }, + thunder: { file: "thunder.wav", label: "thunder" }, +}; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const assetsDir = path.resolve(here, "..", "assets"); + +function usage() { + console.log("usage: node tools/make-storm-audio.mjs [--verify ]"); + process.exit(2); +} + +/** + * Minimal float32 RIFF/WAVE writer: `fmt ` then `data`, which is what `THREE.AudioLoader` parses + * and what the asset compiler re-conditions on cook. No LIST or timestamp chunk, so the bytes + * depend on the samples and nothing else. + * + * **Float32, not 16-bit PCM.** The source ran these bodies in a `Float32Array` AudioBuffer and + * mixed them through gain nodes afterwards, so a loop is allowed to peak above full scale: wind is + * `pink * 4`, and after its 35..350 Hz band the committed loop still peaks at 1.05, which the + * source's own `.02 + wind * .18 *` node then brought down to at most .2. A 16-bit file cannot hold + * that — 0.012% of its samples were pinned to ±1.0 and the loudest transient of the loop was + * flattened into a clipped plateau. Every consumer of these files reads the format rather than + * guessing it: `@threenative/assets`' decoder takes `format 3` at 32 bits as `readFloatLE`, the + * native host's `SDL_LoadWAV_IO` path takes the `SDL_AUDIO_F32` branch, and `decodeAudioData` + * scales only integer PCM. + */ +function encodeWav(channels) { + const frames = channels[0].length; + const dataBytes = frames * BLOCK_ALIGN; + const buffer = Buffer.alloc(44 + dataBytes); + buffer.write("RIFF", 0, "ascii"); + buffer.writeUInt32LE(36 + dataBytes, 4); + buffer.write("WAVE", 8, "ascii"); + buffer.write("fmt ", 12, "ascii"); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(WAVE_FORMAT_IEEE_FLOAT, 20); + buffer.writeUInt16LE(CHANNELS, 22); + buffer.writeUInt32LE(SAMPLE_RATE, 24); + buffer.writeUInt32LE(SAMPLE_RATE * BLOCK_ALIGN, 28); + buffer.writeUInt16LE(BLOCK_ALIGN, 32); + buffer.writeUInt16LE(BITS, 34); + buffer.write("data", 36, "ascii"); + buffer.writeUInt32LE(dataBytes, 40); + let offset = 44; + for (let i = 0; i < frames; i += 1) { + for (let c = 0; c < CHANNELS; c += 1) { + buffer.writeFloatLE(channels[c][i], offset); + offset += 4; + } + } + return buffer; +} + +/** One seeded draw, so the three clips come out of one stream exactly as the source did. */ +function bake() { + const rng = mulberry32(STORM_SEED); + const rain = loopBody({ band: LOOPS.rain, sampleRate: SAMPLE_RATE, rng }); + const wind = loopBody({ band: LOOPS.wind, sampleRate: SAMPLE_RATE, rng }); + const thunder = thunderBody({ sampleRate: SAMPLE_RATE, rng }); + // The source filtered live; the filter has to live in the file now. Same two biquads, same + // frequencies, same Q. + for (const [band, channels] of [ + [LOOPS.rain, rain], + [LOOPS.wind, wind], + ]) { + const highpass = biquad("highpass", band.highpassHz, SAMPLE_RATE); + const lowpass = biquad("lowpass", band.lowpassHz, SAMPLE_RATE); + for (let c = 0; c < CHANNELS; c += 1) + channels[c] = filterLoop(filterLoop(channels[c], highpass), lowpass); + } + return { + rain: { bytes: encodeWav(rain), samples: rain }, + thunder: { bytes: encodeWav(thunder), samples: thunder }, + wind: { bytes: encodeWav(wind), samples: wind }, + }; +} + +// --- the checks ----------------------------------------------------------------------------- + +function fail(what) { + throw new Error(`TN_STORM_AUDIO_INVALID: ${what}`); +} + +function assert(what, condition) { + if (!condition) fail(what); +} + +/** Peak and RMS of one decoded channel, plus the NaN/infinity count. */ +function measure(samples) { + let peak = 0; + let sum = 0; + let bad = 0; + for (const value of samples) { + if (!Number.isFinite(value)) bad += 1; + else if (Math.abs(value) > peak) peak = Math.abs(value); + sum += value * value; + } + return { bad, peak, rms: Math.sqrt(sum / samples.length) }; +} + +/** + * Normalised autocorrelation at one lag — the check that the baked filters are the source's bands + * and not a name on a white-noise file. + * + * A signal with energy concentrated near `fs / lag` is correlated with itself there; white noise + * is not. Wind is band-limited to 35..350 Hz, so at the 350 Hz period it correlates hard. Rain is + * high-passed at 1800 Hz, so at the same lag it does not correlate at all. + */ +function autocorrelation(samples, lag) { + let mean = 0; + for (const value of samples) mean += value; + mean /= samples.length; + let num = 0; + let den = 0; + for (let i = 0; i + lag < samples.length; i += 1) { + const a = samples[i] - mean; + const b = samples[i + lag] - mean; + num += a * b; + den += a * a; + } + return num / den; +} + +/** + * The wrap step of a loop against the largest ordinary step in its own neighbourhood — the ratio + * `packages/assets/src/passes/audio.ts` fails a build on. `filterLoop` runs the steady-state + * response of a periodic input, so this has to come out clean; if it does not, the bake is not + * the source's filter and the loop clicks once per three seconds. + */ +function seamRatio(samples) { + const wrap = Math.abs(samples[0] - samples[samples.length - 1]); + const steps = []; + for (let i = 1; i < samples.length; i += 1) steps.push(Math.abs(samples[i] - samples[i - 1])); + steps.sort((a, b) => a - b); + const near = steps[Math.floor(steps.length * 0.99)]; + return near <= 0 ? (wrap <= 0 ? 0 : Number.POSITIVE_INFINITY) : wrap / near; +} + +/** + * How much of the generated signal a float32 file is allowed to lose. + * + * Below half a ULP at 1.0, so a container that clamped or requantised the samples cannot pass it + * by rounding: 16-bit would move a sample by up to 1.5e-5, and it fails here by two orders of + * magnitude. It is a round-trip check, which is the strongest statement available about amplitude + * — "no sample was altered" rather than "few samples were altered". + */ +const FLOAT32_TOLERANCE = 1e-7; + +/** + * The header the runtime's loader, the asset compiler and the native decoder all read, asserted + * before the samples. The format tag is the check that matters: it is what a 16-bit re-encode + * would silently change, and what the three decoders above branch on. + */ +function assertWavHeader(buffer) { + assert("RIFF header", buffer.toString("ascii", 0, 4) === "RIFF"); + assert("WAVE header", buffer.toString("ascii", 8, 12) === "WAVE"); + assert("IEEE float format tag", buffer.readUInt16LE(20) === WAVE_FORMAT_IEEE_FLOAT); + assert("channel count", buffer.readUInt16LE(22) === CHANNELS); + assert("sample rate", buffer.readUInt32LE(24) === SAMPLE_RATE); + assert("bit depth", buffer.readUInt16LE(34) === BITS); +} + +/** One clip's own claims: right length, finite, unaltered, audible, and — for a loop — seamless. */ +function checkClip(key, label, channels, source) { + const seconds = channels[0].length / SAMPLE_RATE; + const expected = key === "thunder" ? THUNDER_SECONDS : LOOP_SECONDS; + assert( + `${label}: duration is ${expected}s, got ${seconds.toFixed(4)}s`, + Math.abs(seconds - expected) < 1e-9, + ); + const rows = []; + for (let c = 0; c < CHANNELS; c += 1) { + const m = measure(channels[c]); + assert(`${label} ch${c}: ${m.bad} non-finite samples`, m.bad === 0); + assert( + `${label} ch${c}: not silent (peak ${m.peak.toFixed(4)}, rms ${m.rms.toFixed(5)})`, + m.rms > 0.001 && m.peak > 0.05, + ); + let moved = 0; + for (let i = 0; i < channels[c].length; i += 1) + moved = Math.max(moved, Math.abs(channels[c][i] - source[c][i])); + assert( + `${label} ch${c}: the file moved a sample by ${moved.toExponential(2)}`, + moved <= FLOAT32_TOLERANCE, + ); + rows.push({ key, channel: c, peak: m.peak, rms: m.rms }); + } + // The clip the 16-bit file could not hold. Wind's body is `pink * 4` and peaks above full scale, + // because the source's own gain node is what brought the loop down to at most .2 — so a peak over + // 1.0 in this file is the amplitude surviving, and a peak at or under it is a clip that lost the + // top of its transient to a container. + if (key === "wind") { + const peak = measure(channels[0]).peak; + assert(`${label}: the file holds its ${peak.toFixed(3)} peak, above full scale`, peak > 1); + } + if (key !== "thunder") { + const wrap = seamRatio(channels[0]); + assert( + `${label}: loop seam ${wrap.toFixed(3)}x exceeds 1.5x the neighbourhood step`, + wrap <= 1.5, + ); + rows.push({ key, seam: wrap }); + } + return rows; +} + +/** Reads the written bytes back and checks the claims the runtime will rest on. */ +function selfCheck(baked) { + const decode = (bytes) => { + assertWavHeader(bytes); + const frames = bytes.readUInt32LE(40) / BLOCK_ALIGN; + const channels = []; + for (let c = 0; c < CHANNELS; c += 1) channels.push(new Float64Array(frames)); + for (let i = 0; i < frames; i += 1) + for (let c = 0; c < CHANNELS; c += 1) + channels[c][i] = bytes.readFloatLE(44 + (i * CHANNELS + c) * BYTES_PER_SAMPLE); + return channels; + }; + + const results = []; + const decoded = {}; + for (const [key, { label }] of Object.entries(ASSETS)) { + const channels = decode(baked[key].bytes); + decoded[key] = channels; + results.push(...checkClip(key, label, channels, baked[key].samples)); + } + + // The filters, as heard: wind is correlated over its own band, rain is not correlated below its + // high-pass corner. One lag, taken from a source constant — the period of rain's 1800 Hz corner. + const lag = Math.round(SAMPLE_RATE / LOOPS.rain.highpassHz); + const windAcf = autocorrelation(decoded.wind[0], lag); + const rainAcf = autocorrelation(decoded.rain[0], lag); + assert( + `wind correlates over its 35..350 Hz band at lag ${lag} (${windAcf.toFixed(3)})`, + windAcf > 0.5, + ); + assert( + `rain does not correlate below its ${LOOPS.rain.highpassHz} Hz corner at lag ${lag} (${rainAcf.toFixed(3)})`, + Math.abs(rainAcf) < 0.1, + ); + + // The thunder envelope: a crack in the first tenth of a second and a decay of real dynamic range. + const strike = decoded.thunder[0]; + const crackWindow = Math.floor(0.1 * SAMPLE_RATE); + const tailWindow = Math.floor(0.5 * SAMPLE_RATE); + let crackPeak = 0; + for (let i = 0; i < crackWindow; i += 1) crackPeak = Math.max(crackPeak, Math.abs(strike[i])); + let tail = 0; + for (let i = strike.length - tailWindow; i < strike.length; i += 1) tail += strike[i] * strike[i]; + const tailRms = Math.sqrt(tail / tailWindow); + const decayDb = 20 * Math.log10(crackPeak / Math.max(tailRms, 1e-9)); + assert( + `thunder attack is inside the first 100 ms (peak ${crackPeak.toFixed(3)})`, + crackPeak > 0.2, + ); + assert(`thunder decays ${decayDb.toFixed(1)} dB into its tail`, decayDb > 20); + // The 9 Hz tremolo is the only term that decorrelates the channels, so a near-identical pair + // would mean it was dropped somewhere between the source and here. + let cross = 0; + let energy = 0; + for (let i = 0; i < strike.length; i += 1) { + cross += decoded.thunder[0][i] * decoded.thunder[1][i]; + energy += decoded.thunder[0][i] * decoded.thunder[0][i]; + } + const stereo = cross / energy; + assert( + `thunder channels are decorrelated by the tremolo phase (r=${stereo.toFixed(3)})`, + stereo < 0.2, + ); + + return { results, windAcf, rainAcf, decayDb, stereo, lag }; +} + +/** + * Re-reads the reference study and refuses to bake if the transcription has drifted. Every check + * is a literal that has to appear in the source; a missing file, an unreadable file or a renamed + * constant all throw rather than quietly passing. + */ +function verifySource(file) { + const source = readFileSync(file, "utf8"); + const required = [ + ["STORM_SEED", `rng(${STORM_SEED})`], + ["LOOPS.rain.highpassHz", `makeLoop(${LOOPS.rain.highpassHz},${LOOPS.rain.lowpassHz},.09)`], + ["LOOPS.wind.highpassHz", `makeLoop(${LOOPS.wind.highpassHz},${LOOPS.wind.lowpassHz},.05)`], + ["LOOP_SECONDS", `createBuffer(2,ctx.sampleRate*${LOOP_SECONDS},ctx.sampleRate)`], + ["THUNDER_SECONDS", `duration=${THUNDER_SECONDS}`], + ["pink", "pink=.96*pink+.04*w"], + ["white scale", "d[i]=low<100?pink*4:w*.6"], + ["brown", "low=.985*low+n*.015"], + ["crack", "Math.exp(-t*22)*n*.8"], + ["rumble", "low*7*(1.-Math.exp(-t*4))*Math.exp(-t*.66)"], + ["tremolo", "( .68+.32*Math.sin(t*9+c))"], + ["rain gain", "this.rainGain.gain.setTargetAtTime(weather.rain*.20"], + ["wind gain", ".02+weather.wind*.18*(.78+.22*Math.sin(time*.37))"], + ["master", "this.master.gain.setTargetAtTime(.42,this.ctx.currentTime,.12)"], + ["distance lowpass", "filter.frequency.value=clamp(4500-distance*5,280,4500)"], + ["distance gain", "gain.gain.value=clamp(1.05-distance*.0009,.18,.95)"], + ]; + for (const [name, literal] of required) + if (!source.includes(literal)) fail(`source '${file}' no longer contains ${name} (${literal})`); + return required.length; +} + +// --- run ------------------------------------------------------------------------------------- + +const args = process.argv.slice(2); +if (args.some((a) => a === "--help" || a === "-h")) usage(); +const verifyAt = args.indexOf("--verify"); +if (verifyAt !== -1 && args[verifyAt + 1] === undefined) usage(); +const verified = verifyAt === -1 ? null : verifySource(args[verifyAt + 1]); + +const first = bake(); +const second = bake(); +for (const key of Object.keys(ASSETS)) { + const a = createHash("sha256").update(first[key].bytes).digest("hex"); + const b = createHash("sha256").update(second[key].bytes).digest("hex"); + assert(`${ASSETS[key].label} is not reproducible across two bakes (${a} vs ${b})`, a === b); +} +const check = selfCheck(first); + +for (const [key, { file, label }] of Object.entries(ASSETS)) { + const bytes = first[key].bytes; + writeFileSync(path.join(assetsDir, file), bytes); + const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16); + console.log( + `${file} ${label} ${(bytes.length / 1024).toFixed(0)} KiB float32 sha256:${hash}`, + ); +} +for (const row of check.results) { + if (row.seam !== undefined) + console.log( + ` ${ASSETS[row.key].label}: loop seam ${row.seam.toFixed(3)}x the neighbourhood step`, + ); + else + console.log( + ` ${ASSETS[row.key].label} ch${row.channel}: peak ${row.peak.toFixed(3)} rms ${row.rms.toFixed(4)}`, + ); +} +console.log( + `self-check ok: wind acf@${check.lag} ${check.windAcf.toFixed(3)} (correlated), rain ${check.rainAcf.toFixed(3)} (not), thunder ${check.decayDb.toFixed(1)} dB decay at r=${check.stereo.toFixed(3)}, two bakes byte-identical`, +); +console.log( + verified === null + ? "note: constants unverified against the reference (pass --verify to check)" + : `verified ${verified} source literals against the reference`, +); diff --git a/packages/create-threenative/templates/rain/tools/storm-dsp.mjs b/packages/create-threenative/templates/rain/tools/storm-dsp.mjs new file mode 100644 index 0000000000..1582814439 --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/storm-dsp.mjs @@ -0,0 +1,177 @@ +// The reference study's audio maths, transcribed out of `/home/joao/Downloads/tempest.html` +// (`class StormAudio`, lines 551-583) so `make-storm-audio.mjs` can bake it offline. +// +// Every constant below is the source's, character for character, and every line of the two +// generators is the source's arithmetic rearranged to fill a typed array instead of an +// AudioBuffer. Nothing here is a redesign: the source runs this DSP on the audio thread against +// live Web Audio nodes, and the native host has no AudioContext at all, so the same numbers are +// computed once here and committed as PCM. The runtime constants that the source applied to *nodes* +// (the master gain, the biquads, the compressor, the distance delay) are named in +// `src/audio/storm.ts`, where the engine's own equivalents are applied instead. + +/** `this.rng = rng(544)` — the same mulberry32 the rest of the study seeds with. */ +export const STORM_SEED = 544; + +/** `ctx.createBuffer(2, ctx.sampleRate * 3, ctx.sampleRate)` — both loops are three seconds. */ +export const LOOP_SECONDS = 3; + +/** `const duration = 7` — the thunder buffer. */ +export const THUNDER_SECONDS = 7; + +/** + * `makeLoop(low, high, volume)` as the source calls it: + * `this.rainGain = this.makeLoop(1800, 11000, .09)` and + * `this.windGain = this.makeLoop(35, 350, .05)`. + * + * `volume` is recorded because the source passes it to the gain node and then overwrites that + * node every frame in `update()`; the levels that are ever heard are the `update()` ones in + * `src/audio/storm.ts`, so this generator does not apply them. + */ +export const LOOPS = { + rain: { highpassHz: 1800, lowpassHz: 11000, nodeGain: 0.09 }, + wind: { highpassHz: 35, lowpassHz: 350, nodeGain: 0.05 }, +}; + +/** The pink integrator: `pink = .96 * pink + .04 * w`, and the `low < 100 ? pink * 4 : w * .6` pick. */ +const PINK_POLE = 0.96; +const PINK_FEED = 0.04; +const PINK_SCALE = 4; +/** `w = rng() * 2 - 1`, used at full scale for the loop bodies. */ +const WHITE_SCALE = 0.6; + +/** Thunder's brown integrator: `low = .985 * low + n * .015`, and the `low * 7` it is scaled by. */ +const BROWN_POLE = 0.985; +const BROWN_FEED = 0.015; +const BROWN_SCALE = 7; + +/** `Math.exp(-t * 22) * n * .8` — the crack, and the only part of the buffer that is not brown. */ +const CRACK_DECAY = 22; +const CRACK_LEVEL = 0.8; +/** `low * 7 * (1 - Math.exp(-t * 4)) * Math.exp(-t * .66)` — the rumble's attack and release. */ +const RUMBLE_ATTACK = 4; +const RUMBLE_DECAY = 0.66; +/** `(.68 + .32 * Math.sin(t * 9 + c))` — the tremolo, and the one-radian offset between channels. */ +const TREMOLO_RATE = 9; +const TREMOLO_DEPTH = 0.32; +const TREMOLO_FLOOR = 0.68; + +/** `function rng(seed = 1)` verbatim, so a given seed is the same stream the source drew. */ +export function mulberry32(seed) { + let a = seed | 0; + return () => { + a += 0x6d2b79f5; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * The RBJ biquad `BiquadFilterNode` computes, for the two types the source uses and at Q = 1, + * which is the spec default: `highpass` and `lowpass` share the same denominator. + * + * Returns `[b0, b1, b2, a1, a2]` already divided by a0, so the recursion is + * `y = b0 x + b1 x1 + b2 x2 - a1 y1 - a2 y2`. + */ +export function biquad(type, hz, sampleRate, q = 1) { + const w0 = (2 * Math.PI * hz) / sampleRate; + const cos = Math.cos(w0); + const alpha = Math.sin(w0) / (2 * q); + const b = + type === "highpass" + ? [(1 + cos) / 2, -(1 + cos), (1 + cos) / 2] + : [(1 - cos) / 2, 1 - cos, (1 - cos) / 2]; + const a0 = 1 + alpha; + return [b[0] / a0, b[1] / a0, b[2] / a0, (-2 * cos) / a0, (1 - alpha) / a0]; +} + +/** + * Runs a biquad over `input` as a *periodic* signal and returns exactly one period. + * + * The source filtered a looping buffer with a live node, so the filter state was continuous + * across every wrap forever. Filtering a finite buffer instead leaves the filter's state at the + * end of the file, and the join back to the start is a step — an audible click on a loop that + * repeats forever, which is the defect `packages/assets/src/passes/audio.ts` is built to reject. + * Running the recursion over the input repeated twice and keeping the second period is the + * steady-state response of a periodic input, so the wrap lands exactly where the join is. + */ +export function filterLoop(input, coefficients) { + const [b0, b1, b2, a1, a2] = coefficients; + const n = input.length; + const out = new Float64Array(n); + let x1 = 0; + let x2 = 0; + let y1 = 0; + let y2 = 0; + for (let k = 0; k < n * 2; k += 1) { + const x = input[k % n]; + const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + if (k >= n) out[k - n] = y; + } + return out; +} + +/** + * `makeLoop`'s buffer body, one channel at a time, before the source's filters. + * + * Channel-major, because that is the order the source drew from its one generator: it filled + * channel 0 from `length` draws and then channel 1 from the next `length`. The pink state is + * per channel — `let pink = 0` is inside the channel loop — so the two channels share no state. + */ +export function loopBody({ band, sampleRate, rng }) { + const n = Math.round(LOOP_SECONDS * sampleRate); + const channels = []; + const pink = band.highpassHz < 100; + for (let c = 0; c < 2; c += 1) { + const data = new Float64Array(n); + let state = 0; + for (let i = 0; i < n; i += 1) { + const w = rng() * 2 - 1; + if (!pink) { + data[i] = w * WHITE_SCALE; + continue; + } + state = PINK_POLE * state + PINK_FEED * w; + data[i] = state * PINK_SCALE; + } + channels.push(data); + } + return channels; +} + +/** + * `thunder()`'s buffer body: `a[i] = clamp(crack + rumble, -1, 1)`, stereo, seven seconds. + * + * `let low = 0` resets per channel and the generator is shared across both, so the left channel + * ends its stream exactly where the right channel starts it. The tremolo's `+ c` term is what + * decorrelates the channels here; nothing else in the buffer does. + */ +export function thunderBody({ sampleRate, rng }) { + const n = Math.floor(THUNDER_SECONDS * sampleRate); + const channels = []; + for (let c = 0; c < 2; c += 1) { + const data = new Float64Array(n); + let low = 0; + for (let i = 0; i < n; i += 1) { + const t = i / sampleRate; + const white = rng() * 2 - 1; + low = BROWN_POLE * low + BROWN_FEED * white; + const crack = Math.exp(-t * CRACK_DECAY) * white * CRACK_LEVEL; + const rumble = + low * + BROWN_SCALE * + (1 - Math.exp(-t * RUMBLE_ATTACK)) * + Math.exp(-t * RUMBLE_DECAY) * + (TREMOLO_FLOOR + TREMOLO_DEPTH * Math.sin(t * TREMOLO_RATE + c)); + const mixed = crack + rumble; + data[i] = mixed > 1 ? 1 : mixed < -1 ? -1 : mixed; + } + channels.push(data); + } + return channels; +} diff --git a/packages/create-threenative/templates/rain/tools/tempest-bloom.frag b/packages/create-threenative/templates/rain/tools/tempest-bloom.frag new file mode 100644 index 0000000000..96adf74089 --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/tempest-bloom.frag @@ -0,0 +1,10 @@ +// The source of truth for src/render/bloom-shader.ts, transcribed from the Tempest single-file +// demo's `BLOOM` constant (const BLOOM, just after the lightning bolt fragment shader). +// +// A 5x5 gaussian at twice the input texel size, masked by a soft-knee luminance threshold and +// normalised by the weight it actually accumulated — not by 25, so the corners of the frame, where +// the kernel hangs off the target, cannot dim. Rendered at quarter resolution; see +// src/render/postprocessing.ts. +precision highp float; +in vec2 vUv;out vec4 fragColor;uniform sampler2D uScene;uniform vec2 uRes; +void main(){vec3 c=vec3(0.);float w=0.;for(int y=-2;y<=2;y++)for(int x=-2;x<=2;x++){float wt=exp(-float(x*x+y*y)*.32);vec3 s=texture(uScene,vUv+vec2(float(x),float(y))/uRes*2.).rgb;float l=max(s.r,max(s.g,s.b));c+=s*smoothstep(.70,1.6,l)*wt;w+=wt;}fragColor=vec4(c/w,1.);} diff --git a/packages/create-threenative/templates/rain/tools/tempest-clouds.frag b/packages/create-threenative/templates/rain/tools/tempest-clouds.frag new file mode 100644 index 0000000000..be7af8362d --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/tempest-clouds.frag @@ -0,0 +1,86 @@ +// The GLSL this template's cloud volume shader is generated from, copied out of the Tempest +// single-file demo (its COMMON + CLOUDS fragment shaders) so the generator is reproducible without +// the original file. Edits here change the generated +// templates/rain/src/render/clouds-shader.ts; edit the generator, not its output. +// +// Two things below are rewritten, both to keep the port mechanical rather than hand-written. The +// `density` early return becomes one value assigned under a guard, and `main`'s degenerate-slab +// return becomes the value it starts with — the reason is on `density`. The five-tap sunlight loop +// counts with `i` rather than `j` so the transpiler emits a loop whose counter it already names the +// way `LoopNode` expects; the counter's name is not part of what the loop computes. +precision highp float; +precision highp sampler3D; +uniform float uTime, uRain, uCloud, uWind, uFog, uWet, uFlash, uAspect, uTan; +uniform vec3 uCam, uForward, uRight, uUp, uStrike; +// The look, owned by src/render/{sky,lighting}.ts and set from there every run. +uniform vec3 uSunDir, uSkyLow, uSkyHigh; +uniform vec2 uRes; +in vec2 vUv; +out vec4 fragColor; +float hash12(vec2 p){vec3 p3=fract(vec3(p.xyx)*.1031);p3+=dot(p3,p3.yzx+33.33);return fract((p3.x+p3.y)*p3.z);} +float hash13(vec3 p){p=fract(p*.1031);p+=dot(p,p.zyx+31.32);return fract((p.x+p.y)*p.z);} +float noise2(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash12(i),hash12(i+vec2(1,0)),f.x),mix(hash12(i+vec2(0,1)),hash12(i+1.),f.x),f.y);} +float fbm2(vec2 p){float v=.5*noise2(p);p=mat2(.8,-.6,.6,.8)*p*2.03+19.1;v+=.25*noise2(p);p=p*2.02+9.3;v+=.125*noise2(p);return v+.0625*noise2(p*2.01+17.);} +vec3 ray(vec2 uv){vec2 q=uv*2.-1.;return normalize(uForward+uRight*q.x*uAspect*uTan+uUp*q.y*uTan);} +vec4 project(vec3 p){vec3 v=p-uCam;float d=dot(v,uForward);return vec4(dot(v,uRight)/(uAspect*uTan),dot(v,uUp)/uTan,1.00012*d-.300018,d);} +uniform sampler3D uNoise; +uniform int uSteps; +float density(vec3 p){ + // One value, one return, for the reason given in the coast's `baseColor`: the transpiler turns a + // GLSL early return into a JavaScript return that only leaves the `If` callback the node graph is + // being built inside, so the branch would never be assigned. The guard is kept as a guard, so a + // clear sky still costs one comparison and no noise fetches. + float res=0.; + if(uCloud>=.005){ + vec3 drift=vec3(uTime*(2.+uWind*13.),0.,uTime*1.2); + vec3 q=(p+drift)*.00105; q.y*=1.4; + vec4 n=texture(uNoise,q); + float broad=n.r*.76+texture(uNoise,q*2.07+vec3(.1,.3,.7)).r*.24; + // Flattened condensation base with turbulent billows above it. + float h=(p.y-145.)/670.; + float base=smoothstep(0.,.09,h)*(1.-smoothstep(.68,1.,h)); + float shape=broad+uCloud*.30-.72; + float edge=(texture(uNoise,q*3.81).b-.5)*.12; + res=clamp((shape-edge)*5.1,0.,1.)*base; + } + return res; +} +float lightAt(vec3 p,vec3 l){float sum=0.,stepLen=28.;for(int i=0;i<5;i++){p+=l*stepLen;sum+=density(p)*stepLen;stepLen*=1.7;}return exp(-sum*.015);} +void main(){ + vec3 rd=ray(vUv);rd.y=abs(rd.y);rd=normalize(rd); + vec3 sunDir=uSunDir;float mu=dot(rd,sunDir); + vec3 sky=mix(uSkyLow,uSkyHigh,pow(clamp(rd.y,0.,1.),.62)); + sky+=vec3(.31,.27,.205)*pow(max(mu,0.),14.); + sky+=vec3(.7,.57,.37)*pow(max(mu,0.),140.); + sky+=uFlash*vec3(.14,.22,.36); + float t0=max(0.,(145.-uCam.y)/max(rd.y,.008)); + float t1=min(10500.,(815.-uCam.y)/max(rd.y,.008)); + vec4 col=vec4(sky,1.); + if(t0=uSteps||trans<.008)break; + float t=t0+(float(i)+jitter)*dt;vec3 p=uCam+rd*t; + float d=density(p); + if(d>.002){ + float sun=lightAt(p,sunDir); + float height=clamp((p.y-145.)/670.,0.,1.); + vec3 ambient=mix(vec3(.035,.061,.079),vec3(.19,.25,.28),height); + // A small multiple-scattering/powder approximation prevents crushed cloud cores. + vec3 c=ambient*(.7+.45*(1.-exp(-d*3.)))+vec3(.97,.94,.85)*sun*phase*.70; + float glow=exp(-length((p-uStrike)*vec3(1.,.5,1.))*.0022); + c+=vec3(.85,1.18,1.9)*uFlash*glow*1.2; + float absorb=1.-exp(-d*dt*.020); + light+=trans*absorb*c;trans*=1.-absorb; + } + } + vec3 marched=light+trans*sky; + float atmospheric=1.-exp(-t0*.000075); + marched=mix(marched,sky,atmospheric*.66); + col=vec4(marched,1.-trans); + } + return col; +} diff --git a/packages/create-threenative/templates/rain/tools/tempest-post.frag b/packages/create-threenative/templates/rain/tools/tempest-post.frag new file mode 100644 index 0000000000..bc9615a83e --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/tempest-post.frag @@ -0,0 +1,47 @@ +// The source of truth for src/render/post-shader.ts, transcribed from the Tempest single-file demo's +// `POST` constant (const POST, immediately after `BLOOM`). +// +// The whole filmic half of the demo in one pass: twelve animated refractive beads near the lens +// edges, a five-tap luma-edge antialias over the warped coordinate, the bloom added, weather +// exposure, the ACES fit, the cool-shadow/warm-highlight grade, the vignette, gamma 1/2.2 and the +// dither. Nothing here is a framework default — every constant is the demo's. +precision highp float; +in vec2 vUv;out vec4 fragColor;uniform sampler2D uScene,uBloom;uniform vec2 uRes;uniform float uTime,uExposure,uRain,uLens; +float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);} +vec3 aces(vec3 x){return clamp((x*(2.51*x+.03))/(x*(2.43*x+.59)+.14),0.,1.);} +vec3 antialias(vec2 uv){ + vec2 px=1./uRes;vec3 m=texture(uScene,uv).rgb; + vec3 nw=texture(uScene,uv+vec2(-1.,-1.)*px).rgb,ne=texture(uScene,uv+vec2(1.,-1.)*px).rgb; + vec3 sw=texture(uScene,uv+vec2(-1.,1.)*px).rgb,se=texture(uScene,uv+vec2(1.,1.)*px).rgb; + vec3 lum=vec3(.299,.587,.114);float l0=dot(m,lum),l1=dot(nw,lum),l2=dot(ne,lum),l3=dot(sw,lum),l4=dot(se,lum); + vec2 dir=vec2(-((l1+l2)-(l3+l4)),(l1+l3)-(l2+l4)); + float reduce=max((l1+l2+l3+l4)*.03125,.0078125);float rcp=1./(min(abs(dir.x),abs(dir.y))+reduce); + dir=clamp(dir*rcp,vec2(-6.),vec2(6.))*px; + vec3 a=.5*(texture(uScene,uv+dir*(-.166667)).rgb+texture(uScene,uv+dir*.166667).rgb); + vec3 b=a*.5+.25*(texture(uScene,uv-dir*.5).rgb+texture(uScene,uv+dir*.5).rgb); + float lb=dot(b,lum),lo=min(l0,min(min(l1,l2),min(l3,l4))),hi=max(l0,max(max(l1,l2),max(l3,l4))); + return lbhi?a:b; +} +void main(){ + vec2 uv=vUv;vec2 warp=vec2(0.);float dropHighlight=0.; + // Sparse refractive beads near the lens edges, not a full-screen blur. + if(uLens>.5){for(int i=0;i<12;i++){ + float f=float(i);vec2 p=vec2(hash(vec2(f,3.)),hash(vec2(f,7.))); + p.y=fract(p.y-uTime*(.008+hash(vec2(f,11.))*.01)); + if(p.x>.18&&p.x<.82)continue; + vec2 d=(uv-p)*vec2(uRes.x/uRes.y,1.);float rad=.008+hash(vec2(f,9.))*.006;float dist=length(d*vec2(1.,.75)); + float mask=1.-smoothstep(rad*.7,rad,dist); + warp+=d*mask*uRain*.5;dropHighlight+=pow(max(0.,1.-abs(dist-rad*.8)/(rad*.10)),4.)*.017*uRain; + }} + vec3 col=antialias(clamp(uv+warp,0.,1.)); + vec3 bloom=vec3(0.);float wt=0.;for(int i=-3;i<=3;i++){float w=exp(-float(i*i)*.26);bloom+=texture(uBloom,uv+vec2(0.,float(i)*3.)/uRes).rgb*w;wt+=w;} + col+=bloom/wt*.22; + col*=uExposure; + col=aces(col); + // Cool shadow / warm practical-light grade, retaining neutral highlights. + col=pow(max(col,vec3(0.)),vec3(1.015,1.,.975)); + float vign=1.-.28*pow(length((uv-.5)*vec2(1.05,1.)),1.65);col*=vign; + col=pow(col,vec3(1./2.2)); + col+=(hash(gl_FragCoord.xy+floor(uTime*24.))-.5)*.005+dropHighlight; + fragColor=vec4(clamp(col,0.,1.),1.); +} diff --git a/packages/create-threenative/templates/rain/tools/tempest-world.frag b/packages/create-threenative/templates/rain/tools/tempest-world.frag new file mode 100644 index 0000000000..87b1dd1e65 --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/tempest-world.frag @@ -0,0 +1,210 @@ +// The GLSL this template's coastal world shader is generated from, copied verbatim out of the +// Tempest single-file demo (its COMMON + WORLD fragment shaders) so the generator is +// reproducible without the original file. Edits here change the generated +// templates/rain/src/render/world-shader.ts; edit the generator, not its output. +precision highp float; +precision highp sampler3D; +uniform float uTime, uRain, uCloud, uWind, uFog, uWet, uFlash, uAspect, uTan; +uniform vec3 uCam, uForward, uRight, uUp, uStrike; +// The look, owned by src/render/{sky,lighting,materials}.ts and set from there every run. +uniform vec3 uHazeLow, uHazeHigh, uSunDir, uSunColor, uFill, uFlashLight, uLampLight, uLampGlow, uWater; +uniform vec2 uRes; +in vec2 vUv; +out vec4 fragColor; +float hash12(vec2 p){vec3 p3=fract(vec3(p.xyx)*.1031);p3+=dot(p3,p3.yzx+33.33);return fract((p3.x+p3.y)*p3.z);} +float hash13(vec3 p){p=fract(p*.1031);p+=dot(p,p.zyx+31.32);return fract((p.x+p.y)*p.z);} +float noise2(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash12(i),hash12(i+vec2(1,0)),f.x),mix(hash12(i+vec2(0,1)),hash12(i+1.),f.x),f.y);} +float fbm2(vec2 p){float v=.5*noise2(p);p=mat2(.8,-.6,.6,.8)*p*2.03+19.1;v+=.25*noise2(p);p=p*2.02+9.3;v+=.125*noise2(p);return v+.0625*noise2(p*2.01+17.);} +vec3 ray(vec2 uv){vec2 q=uv*2.-1.;return normalize(uForward+uRight*q.x*uAspect*uTan+uUp*q.y*uTan);} +vec4 project(vec3 p){vec3 v=p-uCam;float d=dot(v,uForward);return vec4(dot(v,uRight)/(uAspect*uTan),dot(v,uUp)/uTan,1.00012*d-.300018,d);} +vec3 hazeColor(vec3 rd){return mix(uHazeLow,uHazeHigh,smoothstep(0.,.7,abs(rd.y)))+uFlash*vec3(.11,.16,.24);} +uniform sampler2D uSky; +uniform int uReflect; +float roadCenter(float z){return -1.6+2.1*sin(z*.012)+15.*(1.-smoothstep(-420.,-150.,z));} +float coast(float z){return roadCenter(z)+9.2+sin(z*.023)*1.8;} +float terrain(vec2 p){ + float n=fbm2(p*.009+vec2(4.,9.)); + float r=1.-abs(noise2(p*.018)*2.-1.); + float left=smoothstep(13.,155.,-p.x)*(28.+n*135.+r*20.); + float back=smoothstep(280.,670.,-p.y)*(46.+n*155.+r*22.); + float farRight=smoothstep(190.,430.,p.x)*(30.+n*95.); + float h=max(max(left,back),farRight); + float island=exp(-dot((p-vec2(94.,-205.))/vec2(39.,48.),(p-vec2(94.,-205.))/vec2(39.,48.)))*(18.+n*10.); + h=max(h,island); + if(h<1.5){h=p.x>coast(p.y)?-1.2:.10+n*.20;} + if(p.y> -175.&&abs(p.x-roadCenter(p.y))<7.3)h=0.; + return h; +} +float sdBox(vec3 p,vec3 b){vec3 q=abs(p)-b;return length(max(q,0.))+min(max(q.x,max(q.y,q.z)),0.);} +float capsule(vec3 p,vec3 a,vec3 b,float r){vec3 pa=p-a,ba=b-a;return length(pa-ba*clamp(dot(pa,ba)/dot(ba,ba),0.,1.))-r;} +vec2 choose(vec2 a,vec2 b){return a.x1.?h:-3.5))*.58,1.); + // One bounded procedural tree per spatial cell; crowns fit inside their cells. + vec2 cell=floor((p.xz+vec2(2.,1.))/10.5); + float seed=hash12(cell+17.); + vec2 tc=(cell+.5)*10.5-vec2(2.,1.)+vec2(hash12(cell+3.),hash12(cell+7.))*.9-.45; + float th=terrain(tc), rel=tc.x-roadCenter(tc.y); + bool forest=(rel< -9.||tc.y< -175.)&&th>-.2&&th<115.&&seed>.15&&(tc.y< -12.||tc.x< -45.); + if(forest){ + vec3 q=p-vec3(tc.x,th,tc.y);float ht=6.+seed*13.; + float cy=clamp(q.y/ht,0.,1.);float angle=atan(q.z,q.x); + float tier=.77+.23*sin(q.y*3.5+seed*13.); + float r=(1.-cy)*(2.+seed*1.45)*tier; + r*=.88+.12*sin(angle*9.+q.y*2.7)+.07*sin(angle*19.-q.y*6.); + float needle=(noise2(q.xz*6.+q.y*2.)-.5)*.18*(1.-cy); + float d=max(length(q.xz)-r+needle,max(.8-q.y,q.y-ht))*.67; + res=choose(res,vec2(d,2.)); + res=choose(res,vec2(max(length(q.xz)-.13*(1.-cy*.65),max(-q.y,q.y-ht)),3.)); + } + // Repeating steel luminaires, including their angled neck and illuminated undersides. + float k=clamp(floor((-p.z-10.)/38.+.5),0.,5.);float z=-10.-k*38.; + float x=roadCenter(z)-6.35;vec3 q=p-vec3(x,0.,z); + float pole=capsule(q,vec3(0.,0.,0.),vec3(0.,7.65,0.),.075); + pole=min(pole,capsule(q,vec3(0.,7.6,0.),vec3(1.6,8.05,0.),.065)); + pole=min(pole,capsule(q,vec3(1.6,8.05,0.),vec3(2.5,8.05,0.),.055)); + pole=min(pole,sdBox(q-vec3(2.43,8.,0.),vec3(.40,.10,.23))); + res=choose(res,vec2(pole,4.)); + res=choose(res,vec2(sdBox(q-vec3(2.45,7.885,0.),vec3(.32,.021,.175)),6.)); + // Guardrail on the water side, stopping before the distant curve. + if(p.z<28.&&p.z> -176.){ + float rx=roadCenter(p.z)+6.3; + float rail=sdBox(vec3(p.x-rx,p.y-.84,0.),vec3(.065,.13,1000.)); + float postZ=floor(p.z/5.+.5)*5.; + rail=min(rail,sdBox(p-vec3(roadCenter(postZ)+6.3,.45,postZ),vec3(.055,.45,.055))); + res=choose(res,vec2(rail,4.)); + } + // Small roadside field station, with a lit window and antenna. + vec3 b=p-vec3(-17.,1.85,-73.); + float cabin=sdBox(b,vec3(3.3,1.85,2.65)); + res=choose(res,vec2(cabin,9.)); + res=choose(res,vec2(sdBox(p-vec3(-17.,3.85,-73.),vec3(3.6,.14,2.9)),4.)); + res=choose(res,vec2(sdBox(p-vec3(-17.8,2.1,-70.325),vec3(.84,.48,.028)),6.)); + res=choose(res,vec2(capsule(p,vec3(-19.,3.9,-74.),vec3(-19.,13.,-74.),.045),4.)); + return res; +} +vec2 trace(vec3 ro,vec3 rd,float limit){ + float t=.25,mat=-1.; + for(int i=0;i<148;i++){ + if(t>limit||t>1850.)break; + vec3 p=ro+rd*t;vec2 d=sceneMap(p); + float eps=max(.007,t*.00048); + if(d.x8.5){float seam=.75+.25*smoothstep(.01,.1,abs(fract(p.x*3.)-.5));res=vec3(.09,.13,.14)*seam;} + return res; +} +vec3 surfaceLight(vec3 p,vec3 n,vec3 v,vec3 albedo,float rough){ + float hemi=.45+.55*max(n.y,0.);vec3 c=albedo*(uFill*hemi+uSunColor*max(dot(n,uSunDir),0.)); + c+=albedo*uFlash*(.6+max(dot(n,normalize(uStrike-p)),0.)*2.5)*uFlashLight; + for(int i=0;i<6;i++){ + float z=-10.-float(i)*38.;vec3 lp=vec3(roadCenter(z)-3.90,7.87,z);vec3 l=lp-p;float dist2=dot(l,l);l=normalize(l); + float ndl=max(dot(n,l),0.);float cone=smoothstep(.1,.62,-l.y)*smoothstep(-.1,.1,l.y); + float atten=48./(1.+dist2);vec3 warm=uLampLight; + c+=albedo*warm*atten*ndl*2.; + vec3 hv=normalize(l+v);float shin=mix(20.,900.,1.-rough);float spec=pow(max(dot(n,hv),0.),shin)*(shin+2.)*.018; + c+=warm*spec*atten*ndl*(rough>.55?.022:1.); + } + return c; +} +vec2 rippleSlope(vec2 p){ + vec2 cell=floor(p/2.2);vec2 sum=vec2(0.); + for(int j=-1;j<=1;j++)for(int i=-1;i<=1;i++){ + vec2 c=cell+vec2(float(i),float(j));float h=hash12(c+3.9); + vec2 ctr=(c+vec2(h,hash12(c+19.)))*2.2;vec2 v=p-ctr;float d=length(v)+.001; + float age=fract(uTime*(.60+uRain*.2)+h*7.3);float ring=d-age*1.7; + float env=exp(-abs(ring)*20.)*(1.-age)*smoothstep(.025,.10,age); + sum+=v/d*cos(ring*95.)*env*.055; + }return sum*uRain; +} +vec3 reflected(vec3 p,vec3 rd,vec3 sky){ + // Same reason as baseColor: no early return. The `uReflect` guard becomes the condition around the + // march, which is still a skip, and a hit becomes a `res` plus a `Break`, which is what the trace + // already does. A ray that reaches the end of the march leaves `res` as the sky, as it did. + vec3 res=sky; + if(uReflect!=0){ + float t=.5; + for(int i=0;i<36;i++){ + if(t>280.)break; + vec3 q=p+rd*t;vec2 d=sceneMap(q); + if(d.x5.5&&d.y<6.5)res=uLampGlow*.75; + else res=mix(baseColor(q,d.y,vec3(0,1,0))*.7,hazeColor(rd),1.-exp(-t*(.0015+uFog*.004))); + break; + }t+=max(.15,d.x*.9); + } + } + return res; +} +void main(){ + vec3 rd=ray(vUv);vec3 sky=texture(uSky,vUv).rgb; + float planeT=rd.y<-.0001?-uCam.y/rd.y:2200.; + vec3 pp=uCam+rd*planeT; + bool water=pp.x>coast(pp.z)&&terrain(pp.xz)<0.; + if(water&&rd.y<-.0001)planeT=(-.60-uCam.y)/rd.y; + vec2 hit=trace(uCam,rd,min(planeT,2200.)); + float t=hit.x,mat=hit.y;vec3 p=uCam+rd*t;vec3 n;vec3 color=sky; + bool ground=mat<0.&&planeT>0.&&planeT<1800.; + if(ground){ + t=planeT;p=uCam+rd*t; + float rx=abs(p.x-roadCenter(p.z)); + water=p.x>coast(p.z)&&terrain(p.xz)<0.; + bool road=rx<5.35&&p.z> -190.; + vec2 ripple=rippleSlope(p.xz); + float coarse=noise2(p.xz*2.6),micro=noise2(p.xz*72.); + float puddle=smoothstep(.37,.64,fbm2(p.xz*.25+7.))*uWet; + if(water)puddle=1.; + vec2 wave=vec2(cos(p.x*1.3+p.z*.8+uTime*1.7),sin(p.z*1.7-p.x*.4-uTime*1.2))*(water?.018:.0015); + vec2 bump=(vec2(noise2(p.xz*35.),noise2(p.xz*35.+9.))-.5)*mix(.22,.013,puddle); + n=normalize(vec3(bump.x+wave.x+ripple.x,1.,bump.y+wave.y+ripple.y)); + vec3 alb=road?vec3(.031,.037,.042)*( .58+coarse*.40+micro*.30):vec3(.075,.086,.070)*( .7+coarse*.45); + float rough=mix(.70,.08,puddle); + if(road){ + float center=p.x-roadCenter(p.z); + float stripe=(1.-smoothstep(.042,.062,abs(abs(center)-.14))); + float edge=(1.-smoothstep(.06,.085,abs(rx-5.04))); + float wear=smoothstep(.1,.35,noise2(p.xz*26.)); + alb=mix(alb,vec3(.50,.36,.11),stripe*wear*.85); + alb=mix(alb,vec3(.49,.54,.50),edge*wear*.60); + } + if(water){alb=uWater;rough=.045;} + color=surfaceLight(p,n,-rd,alb,rough); + float fres=.025+.975*pow(1.-max(dot(-rd,n),0.),5.); + vec2 suv=clamp(vUv+vec2(n.x,n.z)*vec2(.07,.05),.002,.998); + vec3 refl=reflected(p+n*.10,reflect(rd,n),texture(uSky,suv).rgb); + float reflection=water?(.25+fres*.7):(uWet*.05+puddle*.23+fres*.50); + color=mix(color,refl,clamp(reflection,0.,.94)); + // Tiny high-frequency impact coronas, strongest against dark wet tarmac. + color+=length(ripple)*vec3(.055,.077,.09)*uRain; + mat=water?8.:(road?7.:1.); + }else if(mat>0.){ + n=getNormal(p,t); + if(mat>5.5&&mat<6.5)color=uLampGlow; + else color=surfaceLight(p,n,-rd,baseColor(p,mat,n),mat>3.5?.28:.88); + float ao=clamp((p.y-terrain(p.xz))*.09+.50,.4,1.);if(mat!=6.)color*=ao; + } + if(mat>0.||ground){ + // Height-dependent aerial perspective with advected rain curtains. + float curtain=.80+.40*fbm2(p.xz*.012+vec2(uTime*.014,0.)); + float fog=1.-exp(-t*(.0011+uFog*.0048)*curtain*exp(-max(p.y,0.)*.008)); + color=mix(color,hazeColor(rd),clamp(fog,0.,.98)); + float d=max(.15,t*dot(rd,uForward));gl_FragDepth=clamp(1.00006-.150009/d,0.,.999999); + }else gl_FragDepth=1.; + fragColor=vec4(max(color,vec3(0.)),1.); diff --git a/packages/create-threenative/templates/rain/tools/verify-noise-volume.mjs b/packages/create-threenative/templates/rain/tools/verify-noise-volume.mjs new file mode 100644 index 0000000000..6005afcd1f --- /dev/null +++ b/packages/create-threenative/templates/rain/tools/verify-noise-volume.mjs @@ -0,0 +1,61 @@ +// Proves `src/render/noise-volume.ts` is the reference study's volume, byte for byte. +// +// node --import tsx tools/verify-noise-volume.mjs +// +// The generated module is imported and run; `rng` and `noiseVolume` are cut out of the study file +// and called in a `node:vm` context holding nothing but `Math`, `Uint8Array` and `Float32Array`, +// because the comparison is over pure data and neither side may reach a browser, a GPU or the +// framework. Fails closed: a missing file, a function the study no longer has, or one differing byte +// is an error, and the full 1 MiB is compared rather than a sample of it. +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { NOISE_SIZE, createNoiseVolume } from "../src/render/noise-volume.ts"; + +const [source] = process.argv.slice(2); +if (source === undefined) { + console.error("usage: node --import tsx tools/verify-noise-volume.mjs "); + process.exit(2); +} + +/** The text of `function ` from `from`, up to its own closing brace. */ +function functionSource(text, name) { + const start = text.indexOf(`function ${name}`); + if (start === -1) throw new Error(`TN_SOURCE_MISSING: ${name} is not in the study`); + let depth = 0; + let opened = false; + for (let index = start; index < text.length; index += 1) { + if (text[index] === "{") { + depth += 1; + opened = true; + } else if (text[index] === "}" && opened) { + depth -= 1; + if (depth === 0) return text.slice(start, index + 1); + } + } + throw new Error(`TN_SOURCE_UNBALANCED: ${name} never closes`); +} + +const study = readFileSync(source, "utf8"); +const reference = runInNewContext( + `(() => {${study.match(/const mix=[^\n]*/u)[0]} +${functionSource(study, "rng")} +${functionSource(study, "noiseVolume")} +return noiseVolume();})()`, + { Math, Uint8Array, Float32Array }, +); +const expected = reference.data; +const actual = createNoiseVolume().data; + +if (actual.length !== expected.length || expected.length !== NOISE_SIZE ** 3 * 4) { + throw new Error(`TN_NOISE_SIZE: ${expected.length} source bytes, ${actual.length} generated`); +} +for (let index = 0; index < expected.length; index += 1) { + if (actual[index] !== expected[index]) { + throw new Error( + `TN_NOISE_BYTE: index ${index} is ${actual[index]}, source has ${expected[index]}`, + ); + } +} +const digest = createHash("sha256").update(actual).digest("hex"); +console.log(`TN_NOISE_MATCH ${actual.length} bytes sha256=${digest}`); diff --git a/packages/create-threenative/templates/rain/tsconfig.json b/packages/create-threenative/templates/rain/tsconfig.json new file mode 100644 index 0000000000..990e2cb6e1 --- /dev/null +++ b/packages/create-threenative/templates/rain/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "*.ts"] +} diff --git a/packages/create-threenative/templates/rain/vite.config.ts b/packages/create-threenative/templates/rain/vite.config.ts new file mode 100644 index 0000000000..e411e4f4a9 --- /dev/null +++ b/packages/create-threenative/templates/rain/vite.config.ts @@ -0,0 +1,37 @@ +import tailwindcss from "@tailwindcss/vite"; +import { watchAssets } from "@threenative/assets"; +import react from "@vitejs/plugin-react"; +import { createEngineFreshnessPlugin, createWebBrandPlugin } from "create-threenative"; +import { defineConfig } from "vite"; +import type { Plugin } from "vite"; +import config from "./threenative.config.js"; + +function assetsWatchPlugin(): Plugin { + return { + name: "threenative-assets-watch", + apply: "serve", + async configureServer(server) { + const handle = watchAssets({ config: config.assets, cwd: server.config.root }); + server.httpServer?.once("close", () => handle.close()); + // Nothing is served before the first cook settles: until it does, `public/` has no + // `assets.manifest.json` and every asset answers from its source fallback. + await handle.ready; + }, + }; +} + +export default defineConfig({ + plugins: [ + createEngineFreshnessPlugin(), + createWebBrandPlugin(), + react(), + tailwindcss(), + assetsWatchPlugin(), + ], + server: { + watch: { + ignored: ["**/artifacts/**", "**/screenshots/**", "**/playtests/**"], + usePolling: true, + }, + }, +}); diff --git a/packages/create-threenative/templates/rts/package.json b/packages/create-threenative/templates/rts/package.json index 3a73efc0f1..115f40a27c 100644 --- a/packages/create-threenative/templates/rts/package.json +++ b/packages/create-threenative/templates/rts/package.json @@ -31,7 +31,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/runner/package.json b/packages/create-threenative/templates/runner/package.json index e76c78f105..53870872fa 100644 --- a/packages/create-threenative/templates/runner/package.json +++ b/packages/create-threenative/templates/runner/package.json @@ -31,7 +31,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/sailing/package.json b/packages/create-threenative/templates/sailing/package.json index 19e7733d84..cf9dcb7e75 100644 --- a/packages/create-threenative/templates/sailing/package.json +++ b/packages/create-threenative/templates/sailing/package.json @@ -32,7 +32,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/shooter/package.json b/packages/create-threenative/templates/shooter/package.json index 5c34216f09..3134aab85b 100644 --- a/packages/create-threenative/templates/shooter/package.json +++ b/packages/create-threenative/templates/shooter/package.json @@ -32,7 +32,7 @@ "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "playwright": "1.62.1", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "tailwindcss": "4.3.3", "typescript": "5.9.3", diff --git a/packages/create-threenative/templates/shooter/src/render/decals.ts b/packages/create-threenative/templates/shooter/src/render/decals.ts index 66c7c20657..221096547a 100644 --- a/packages/create-threenative/templates/shooter/src/render/decals.ts +++ b/packages/create-threenative/templates/shooter/src/render/decals.ts @@ -1,7 +1,9 @@ import { + type BufferGeometry, DataTexture, DoubleSide, - type Material, + Euler, + Matrix4, Mesh, MeshBasicMaterial, type Object3D, @@ -12,6 +14,7 @@ import { UnsignedByteType, Vector3, } from "three"; +import { DecalGeometry } from "three/addons/geometries/DecalGeometry.js"; /** * Bullet holes, and any other flat mark a game wants to leave stuck to a wall. @@ -125,9 +128,109 @@ const scratchQuaternion = new Quaternion(); const scratchRoll = new Quaternion(); const scratchNormal = new Vector3(); +type DecalMesh = Mesh; +type DecalFade = { duration: number; opacity: (progress: number) => number }; + +function fadeOpacity(fade: DecalFade, progress: number): number { + const opacity = fade.opacity(progress); + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) + throw new Error("Invalid decal opacity: the authored curve must return a value in [0, 1]."); + return opacity; +} + +/** Projection is deliberately limited to static opaque triangles under rigid/uniform transforms. */ +function validateReceiver(receiver: Mesh, geometry: BufferGeometry): void { + const kind = receiver as Mesh & { + isSkinnedMesh?: boolean; + isInstancedMesh?: boolean; + isBatchedMesh?: boolean; + }; + const materials = Array.isArray(receiver.material) ? receiver.material : [receiver.material]; + const unsupportedMaterial = materials.some((material) => { + const custom = material as typeof material & { + isShaderMaterial?: boolean; + displacementMap?: Texture | null; + positionNode?: unknown; + vertexNode?: unknown; + transmission?: number; + }; + return ( + material.transparent || + material.opacity !== 1 || + material.alphaTest > 0 || + material.alphaHash || + custom.isShaderMaterial || + custom.displacementMap != null || + custom.positionNode != null || + custom.vertexNode != null || + (custom.transmission ?? 0) > 0 + ); + }); + if ( + !receiver.isMesh || + kind.isSkinnedMesh || + kind.isInstancedMesh || + kind.isBatchedMesh || + receiver.userData.deforming === true || + unsupportedMaterial || + Object.values(geometry.morphAttributes).some((attributes) => attributes.length > 0) || + Object.values(receiver.geometry.morphAttributes).some((attributes) => attributes.length > 0) + ) { + throw new Error("Unsupported receiver: decals require a static, opaque, non-instanced mesh."); + } + receiver.updateWorldMatrix(true, false); + const matrix = receiver.matrixWorld; + const x = new Vector3().setFromMatrixColumn(matrix, 0); + const y = new Vector3().setFromMatrixColumn(matrix, 1); + const z = new Vector3().setFromMatrixColumn(matrix, 2); + const length = x.lengthSq(); + const tolerance = length * 1e-6; + if ( + !matrix.elements.every(Number.isFinite) || + matrix.determinant() <= 0 || + length === 0 || + Math.abs(y.lengthSq() - length) > tolerance || + Math.abs(z.lengthSq() - length) > tolerance || + Math.abs(x.dot(y)) > tolerance || + Math.abs(x.dot(z)) > tolerance || + Math.abs(y.dot(z)) > tolerance + ) { + throw new Error( + "Unsupported receiver: decals require a rigid or positive uniform-scale transform.", + ); + } + const position = geometry.getAttribute("position"); + const count = geometry.index?.count ?? position?.count ?? 0; + if ( + position === undefined || + position.itemSize !== 3 || + count % 3 !== 0 || + geometry.drawRange.start !== 0 || + geometry.drawRange.count !== Number.POSITIVE_INFINITY + ) { + throw new Error("Unsupported receiver: decals require complete triangle geometry."); + } +} + +/** Keep upstream clipping/UVs, but do not paint a thin receiver's reverse-facing triangles. */ +function frontFaces(geometry: BufferGeometry, normal: Vector3): number[] { + const position = geometry.getAttribute("position"); + const a = new Vector3(); + const b = new Vector3(); + const c = new Vector3(); + const indices: number[] = []; + for (let index = 0; index < position.count; index += 3) { + a.fromBufferAttribute(position, index); + b.fromBufferAttribute(position, index + 1).sub(a); + c.fromBufferAttribute(position, index + 2).sub(a); + if (b.cross(c).dot(normal) > 0) indices.push(index, index + 1, index + 2); + } + return indices; +} + /** One family of marks: its own material, its own slots, its own recycle cursor. */ type Variant = { - readonly slots: Mesh[]; + readonly slots: DecalMesh[]; readonly material: MeshBasicMaterial; cursor: number; }; @@ -135,9 +238,16 @@ type Variant = { export class DecalField { readonly #variants = new Map(); readonly #geometry: PlaneGeometry; + readonly #parent: Object3D; + readonly #size: number; + readonly #projected = new Map< + DecalMesh, + { receiver: Mesh; removed: () => void; elapsed: number; fade?: DecalFade } + >(); readonly #offset: number; #placed = 0; #capacity = 0; + #disposed = false; /** Slots stop being submitted once their pipeline exists. See `settle`. */ #settled = false; @@ -148,6 +258,7 @@ export class DecalField { countPerVariant: number; /** Edge length of one mark in metres, before the per-place scale multiplier. */ size: number; + /** Borrowed: the caller retains ownership and disposes this after its last user. */ map: Texture; /** Tint per variant: a hole in steel keeps a colder rim than one in wood. */ tints: Readonly>; @@ -156,6 +267,8 @@ export class DecalField { renderOrder?: number; }, ) { + this.#parent = parent; + this.#size = options.size; this.#offset = options.offset ?? 0.018; this.#geometry = new PlaneGeometry(options.size, options.size); for (const [name, tint] of Object.entries(options.tints) as [TVariant, number][]) { @@ -168,7 +281,7 @@ export class DecalField { side: DoubleSide, transparent: true, }); - const slots: Mesh[] = []; + const slots: DecalMesh[] = []; for (let index = 0; index < options.countPerVariant; index += 1) { const mesh = new Mesh(this.#geometry, material.clone()); // Present from the first frame at a size nothing can see, so this material's pipeline is @@ -229,6 +342,7 @@ export class DecalField { const mesh = family.slots[family.cursor % family.slots.length]; family.cursor += 1; if (mesh === undefined) return; + this.#releaseProjection(mesh); this.#placed += 1; scratchNormal.copy(normal).normalize(); scratchQuaternion.setFromUnitVectors(FORWARD, scratchNormal); @@ -244,6 +358,179 @@ export class DecalField { mesh.updateMatrixWorld(true); } + /** + * Opt-in projection: pass the existing hit object and its authored/base geometry from the game. + * `point`, `normal`, `depth` and the size multiplier are world-space. Returns false on a miss or + * absent slot. Only this receiver is clipped; nearby objects are never projected onto. The + * upstream corner/overlap distortion remains: keep depth narrow for thin or concave surfaces. + * `uvRect` selects normalized atlas bounds without mutating the borrowed map. Optional fade + * duration is seconds; its authored opacity curve receives normalized progress in [0, 1). + * Call `update(dt)` each frame for expiry and receivers removed through an ancestor subtree. + */ + project( + receiver: Mesh, + geometry: BufferGeometry, + point: Vector3, + normal: Vector3, + variant: TVariant, + options: { + depth: number; + scale?: number; + uvRect?: readonly [number, number, number, number]; + fade?: DecalFade; + }, + ): boolean { + const family = this.#variants.get(variant); + const mesh = family?.slots[family.cursor % family.slots.length]; + if (family === undefined || mesh === undefined) return false; + const size = this.#size * (options.scale ?? 1); + if ( + ![ + point.x, + point.y, + point.z, + normal.x, + normal.y, + normal.z, + size, + options.depth, + this.#offset, + ].every(Number.isFinite) || + size <= 0 || + options.depth <= 0 || + !Number.isFinite(normal.lengthSq()) || + normal.lengthSq() === 0 + ) { + throw new Error( + "Invalid decal projector: finite point/normal and positive size/depth required.", + ); + } + if (this.#offset < 0 || this.#offset > options.depth / 2) + throw new Error("Invalid decal bias: offset must be in [0, projector depth / 2]."); + const rect = options.uvRect; + if ( + rect !== undefined && + (rect.length !== 4 || + !rect.every(Number.isFinite) || + rect[0] < 0 || + rect[1] < 0 || + rect[2] > 1 || + rect[3] > 1 || + rect[0] >= rect[2] || + rect[1] >= rect[3]) + ) + throw new Error("Invalid decal atlas rectangle: ordered UV bounds in [0, 1] required."); + // Snapshot timing so mutating a caller's options cannot invalidate a live mark. + const fade = options.fade === undefined ? undefined : { ...options.fade }; + if ( + fade !== undefined && + (!Number.isFinite(fade.duration) || fade.duration <= 0 || typeof fade.opacity !== "function") + ) + throw new Error("Invalid decal fade: positive finite duration and opacity curve required."); + const opacity = fade === undefined ? 1 : fadeOpacity(fade, 0); + validateReceiver(receiver, geometry); + if (!this.#contains(receiver)) + throw new Error("Unsupported receiver: mesh is outside the field's parent."); + const direction = normal.clone().normalize(); + const orientation = new Quaternion().setFromUnitVectors(FORWARD, direction); + orientation.multiply( + new Quaternion().setFromAxisAngle(FORWARD, ((this.#placed + 1) * 2.399) % (Math.PI * 2)), + ); + // A proxy borrows the authored geometry without ever swapping the receiver's render LOD. + const source = new Mesh(geometry, receiver.material); + source.matrixWorld.copy(receiver.matrixWorld); + const projected = new DecalGeometry( + source, + point, + new Euler().setFromQuaternion(orientation), + new Vector3(size, size, options.depth), + ); + const indices = frontFaces(projected, direction); + if (indices.length === 0) { + projected.dispose(); + return false; + } + projected.setIndex(indices); + if (rect !== undefined) { + const uv = projected.getAttribute("uv"); + for (let i = 0; i < uv.count; i += 1) + uv.setXY( + i, + rect[0] + uv.getX(i) * (rect[2] - rect[0]), + rect[1] + uv.getY(i) * (rect[3] - rect[1]), + ); + } + projected.translate( + direction.x * this.#offset, + direction.y * this.#offset, + direction.z * this.#offset, + ); + projected.applyMatrix4(new Matrix4().copy(receiver.matrixWorld).invert()); + this.#releaseProjection(mesh); + mesh.geometry = projected; + mesh.position.set(0, 0, 0); + mesh.quaternion.identity(); + mesh.scale.setScalar(1); + mesh.visible = true; + mesh.material.opacity = opacity; + receiver.add(mesh); + const removed = (): void => this.#releaseProjection(mesh); + this.#projected.set(mesh, { receiver, removed, elapsed: 0, fade }); + receiver.addEventListener("removed", removed); + mesh.updateWorldMatrix(true, false); + family.cursor += 1; + this.#placed += 1; + return true; + } + + /** Release marks before a receiver is destroyed; its geometry and material remain borrowed. */ + removeReceiver(receiver: Mesh): void { + for (const [mesh, entry] of this.#projected) { + if (entry.receiver === receiver) this.#releaseProjection(mesh); + } + } + + /** Advance authored fades and clean detached ancestors, visiting at most the slot capacity. */ + update(dt = 0): void { + if (!Number.isFinite(dt) || dt < 0) + throw new Error("Invalid decal timestep: finite nonnegative seconds required."); + for (const [mesh, entry] of this.#projected) { + if (!this.#contains(entry.receiver)) { + this.#releaseProjection(mesh); + continue; + } + if (entry.fade === undefined) continue; + const elapsed = Math.min(entry.elapsed + dt, entry.fade.duration); + if (elapsed >= entry.fade.duration) this.#releaseProjection(mesh); + else { + mesh.material.opacity = fadeOpacity(entry.fade, elapsed / entry.fade.duration); + entry.elapsed = elapsed; + } + } + } + + #contains(object: Object3D): boolean { + for (let current: Object3D | null = object; current !== null; current = current.parent) { + if (current === this.#parent) return true; + } + return false; + } + + #releaseProjection(mesh: DecalMesh): void { + const entry = this.#projected.get(mesh); + if (entry === undefined) return; + entry.receiver.removeEventListener("removed", entry.removed); + this.#projected.delete(mesh); + mesh.geometry.dispose(); + mesh.geometry = this.#geometry; + this.#parent.add(mesh); + mesh.position.set(0, 0, 0); + mesh.quaternion.identity(); + mesh.scale.setScalar(0.0001); + mesh.visible = false; + mesh.material.opacity = 1; + } + /** Registered as an entity so a scenario can assert that a round actually left a mark. */ debug(): { capacity: number; placed: number } { return { capacity: this.#capacity, placed: this.#placed }; @@ -253,6 +540,7 @@ export class DecalField { clear(): void { for (const family of this.#variants.values()) { for (const mesh of family.slots) { + this.#releaseProjection(mesh); mesh.scale.setScalar(0.0001); if (this.#settled) mesh.visible = false; } @@ -261,9 +549,15 @@ export class DecalField { } dispose(): void { + if (this.#disposed) return; + this.#disposed = true; for (const family of this.#variants.values()) { - for (const mesh of family.slots) mesh.removeFromParent(); - (family.material as Material).dispose(); + for (const mesh of family.slots) { + this.#releaseProjection(mesh); + mesh.removeFromParent(); + mesh.material.dispose(); + } + family.material.dispose(); } this.#variants.clear(); this.#geometry.dispose(); diff --git a/packages/create-threenative/templates/shooter/src/scenes/Play.ts b/packages/create-threenative/templates/shooter/src/scenes/Play.ts index fba8996fce..52d23df1bf 100644 --- a/packages/create-threenative/templates/shooter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/shooter/src/scenes/Play.ts @@ -392,9 +392,10 @@ export class Play extends Scene { // Bullet holes. They stay put: a mark that fades tells the player their rounds went nowhere. // What colour the crushed rim comes out is per material — steel burns bare and cold, plaster // and stone go pale, wood darkens. See `decals.ts` for why the pool is shaped this way. + const decalTexture = bulletHoleTexture(); const decals = new DecalField(ctx.scene, { countPerVariant: 56, - map: bulletHoleTexture(), + map: decalTexture, size: 0.13, tints: { plaster: 0xf3ead8, @@ -404,7 +405,11 @@ export class Play extends Scene { }, }); ctx.entities.remove("decals"); + ctx.entities.remove("decal-texture"); ctx.entities.add("decals", decals); + // The field borrows its map. Register the scene-owned texture after its borrowers so + // registry teardown releases their materials first, then the texture, exactly once. + ctx.entities.add("decal-texture", decalTexture); // `hit.face.normal` is object-local; transform it into world space before any // spawn math, or rotated meshes send their bursts into the wall. const impactNormal = new Vector3(); diff --git a/packages/create-threenative/templates/snow/.codex/config.toml b/packages/create-threenative/templates/snow/.codex/config.toml new file mode 100644 index 0000000000..1906a5c3c9 --- /dev/null +++ b/packages/create-threenative/templates/snow/.codex/config.toml @@ -0,0 +1,19 @@ +[mcp_servers.threenative-assets] +command = "node" +args = ["./node_modules/@threenative/core/mcp/assets.mjs"] + +[mcp_servers.threenative-assets.env] +ASSET_DOWNLOAD_DIR = "./public/assets" +AUDIO_DOWNLOAD_DIR = "./public/audio" + +[mcp_servers.threenative-sculpt] +command = "node" +args = ["./node_modules/@threenative/core/mcp/sculpt.mjs"] + +[mcp_servers.threenative-engine] +command = "node" +args = ["./node_modules/@threenative/core/mcp/engine.mjs"] + +[mcp_servers.threenative-blender] +command = "node" +args = ["./node_modules/@threenative/core/mcp/blender.mjs"] diff --git a/packages/create-threenative/templates/snow/.cursor/mcp.json b/packages/create-threenative/templates/snow/.cursor/mcp.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/snow/.cursor/mcp.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/snow/.gemini/settings.json b/packages/create-threenative/templates/snow/.gemini/settings.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/snow/.gemini/settings.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/snow/.mcp.json b/packages/create-threenative/templates/snow/.mcp.json new file mode 100644 index 0000000000..9f0aaf5a41 --- /dev/null +++ b/packages/create-threenative/templates/snow/.mcp.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "threenative-assets": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/snow/.vscode/mcp.json b/packages/create-threenative/templates/snow/.vscode/mcp.json new file mode 100644 index 0000000000..bb2c8d72ad --- /dev/null +++ b/packages/create-threenative/templates/snow/.vscode/mcp.json @@ -0,0 +1,36 @@ +{ + "servers": { + "threenative-assets": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "type": "stdio", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/snow/.zed/settings.json b/packages/create-threenative/templates/snow/.zed/settings.json new file mode 100644 index 0000000000..0c58b95100 --- /dev/null +++ b/packages/create-threenative/templates/snow/.zed/settings.json @@ -0,0 +1,36 @@ +{ + "context_servers": { + "threenative-assets": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "env": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ] + }, + "threenative-engine": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/engine.mjs" + ] + }, + "threenative-blender": { + "source": "custom", + "command": "node", + "args": [ + "./node_modules/@threenative/core/mcp/blender.mjs" + ] + } + } +} diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md new file mode 100644 index 0000000000..e2d24c2ab4 --- /dev/null +++ b/packages/create-threenative/templates/snow/AGENTS.md @@ -0,0 +1,88 @@ +# AGENTS.md — __PROJECT_NAME__ snow + +Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. + +## Ownership + +ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, physics bindings, and the state bridge. This repository owns the explorer, the boot print, the glade, the weather, the HUD and the +look; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. + +## Start every change + +1. **Critical planning gate:** invoke `threenative-capabilities` before `prd-creator`. Search + `engine_search_capabilities` for the full request and each concrete mechanic, inspect relevant + matches with `engine_capability_detail`, and record a capability or no-match for the plan. Apply the `ponytail` ladder before writing code; never hand-write what the capability search already installs. +2. Then invoke `prd-creator`. Draft the plan around those capabilities and binding constraints, + direct the user to review it, and wait for explicit approval plus an instruction to implement it. +3. Treat returned constraints as binding. This kit needs no navmesh and imports none, so it runs + on every target; adding `@threenative/physics/navigation` would make it web and desktop only. +4. If a build, import, device, or blank frame fails, run `npx threenative doctor` and + `npx @threenative/playtest doctor`; missing observations are not zero. +For *"a bullet passes through a wall"*, `RigidBody3D` defaults to continuous collision; `continuousCollision` is the named per-body override, and `body.continuousCollision` reports the effective setting on web/native. + +## When the framework blocks you, write plain Three.js + +When an `@threenative/*` API is broken, missing, or does not do what you need, replace only that +piece with portable Three.js/plain code. Keep the loop, scenes, input, registry, and playtest +bridge; avoid DOM globals, dynamic `import()`, and raw physics handles. **Report what blocked you** +(API, expectation, result, replacement); never stall the game. + +## Workflow skills + +- `.agents/skills/prd-creator/SKILL.md` / `.claude/skills/prd-creator/SKILL.md` — game plan and approval gate. +- `.agents/skills/threenative-capabilities/SKILL.md` / `.claude/skills/threenative-capabilities/SKILL.md` — capability search. +- `.agents/skills/threenative-playtest/SKILL.md` / `.claude/skills/threenative-playtest/SKILL.md` — diagnosis and proof. +- `.agents/skills/threenative-assets/SKILL.md` / `.claude/skills/threenative-assets/SKILL.md` — assets and sculpting. +- `.agents/skills/threenative-visuals/SKILL.md` / `.claude/skills/threenative-visuals/SKILL.md` — captures and look. +- `.agents/skills/threenative-performance/SKILL.md` / `.claude/skills/threenative-performance/SKILL.md` — measured budgets. +- `.agents/skills/threenative-ui/SKILL.md` / `.claude/skills/threenative-ui/SKILL.md` — native-safe UI. +- `.agents/skills/threenative-context/SKILL.md` / `.claude/skills/threenative-context/SKILL.md` — portable ctx APIs. +- Confirmed framework bugs: use `file-engine-bug` in `.agents/skills/` or `.claude/skills/` after a minimal repro. A game-side `patches/` fix is temporary: once it is settled, it moves into the engine in one PR. Lazy-first: `.agents/skills/ponytail/SKILL.md` / `.claude/skills/ponytail/SKILL.md` — smallest correct change, and its reuse rung is the capability search above. + +## Commands and map + +```sh +pnpm dev +pnpm build +pnpm test +pnpm test:native +``` + +The snow is one `SnowField` (`@threenative/core/world`) over one `Heightfield` built in `src/terrain.ts`: indentation, bank, compaction and disturbance composed into the canonical surface that queries, the drawn mesh and the collider all read. Never keep a second height grid. +`attachSnowPhysics` (`@threenative/physics`) owns the snow collider: each fixed step it reads the solver's contacts for the ball, crate and log, presses each body's own shape with its solved load, and installs the changed surface before the next step. Call `step` from `afterPhysics`, once; `observe()` reports `colliderError` and `loadProvenance` (load is a solver estimate). A kinematic `CharacterBody3D` makes no contact load, so `src/entities/Explorer.ts` plants `src/render/bootPrint.ts` itself with `snow.stamp`, only while `grounded`. Its capsule stands on a coarse walk surface (`walkSurface` in `src/terrain.ts`), not the 6 cm snow collider: Rapier sweeps a character against every triangle under it, which cost ~0.9 ms a step there. + +Look lives in `src/render/`: `snowSurface.ts` (TSL material, compaction view, redraws `takeDirtyRegion`), `weather.ts` (`GPUParticles3D` snowfall and powder), `explorer.ts`, `scenery.ts`, `props.ts`. Tune response with `depth`/`hardness` and the refill in `src/scenes/Snow.ts`. The React HUD reads `GameState` and sends intents; `src/commands.ts` routes panel verbs into the same functions as the keys. Keep `playtests/survives.playtest.json` as smoke proof and `playtests/snow-physics.playtest.json` honest: it proves drop, settle and rolling track. +On a touch-primary device (`isMobile() && isTouchscreenAvailable()`), `src/render/touch-controls.ts` adds a left movement stick; keyboard is the desktop fallback. + +## Portable authoring contracts + +Leave `assets` absent: the cook selects target-decodable passes, with `models.sharedImages: true` deduplicating images. `sharedImages: false` embeds duplicate copies; `models.compact` (default `{ flatten: true, join: true, instance: true }`) flattens the scene graph, merges primitives by material and batches a mesh shared by several nodes as `EXT_mesh_gpu_instancing` — all lossless, keeping any node matching `protectedPattern` (or named in `protectedNames`), an animation target, or a skin joint individually addressable; `compact: false` ships the scene graph as authored. `models: "none"` / `textures: "none"` / `audio: "none"` skip those passes and report uncooked bytes. Android/iOS currently skip compression and model dedupe. `assets.exclude` defaults to `[]`; source-relative globs (for example `["unused/**"]`) omit matching files and report saved bytes. `assets.budget` accepts `{ uncooked?: number | "none", total?: number | "none" }`, default `{ uncooked: 64_000_000, total: "none" }`: only bytes left uncooked where cooking was possible count toward `uncooked`. A number sets that ceiling; `"none"` disables both gates. Either disabled gate still reports bytes. Automatic texture cooking retains unaligned source images unchanged and reports `block-size`; those bytes still count toward the uncooked budget. An explicit compression codec override must satisfy four-pixel block alignment; `codec: "none"` opts out. Cooking never silently resizes an image to fix alignment. + +Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. +Scenes use `load`, `enter`, `update`, `exit`, `render`; physics nodes are Godot-named and disposable. The fixed step is the snow's clock: deposition, footsteps and contacts never read the frame rate. +`input.vector("move").y` is +up and means forward; `Snow.ts` turns it into a camera-relative direction. +Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game +state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` +is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's +state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. + +`src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` reports +the source. The bridge flushes about 100 ms; keep readouts in state and frame feedback in Three.js. + +When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a +retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's +own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is +caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing +(the `.undefined` failure that plays the bind pose instead of the animation), +`clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous +clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is +supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches 256 per presented frame so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. +## Budget real time for the look + +Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. + +Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it beside the frame rate; `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. + +Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. +## Optional multiplayer transport +For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md new file mode 100644 index 0000000000..d2597b5c4c --- /dev/null +++ b/packages/create-threenative/templates/snow/CLAUDE.md @@ -0,0 +1,90 @@ + + +# AGENTS.md — __PROJECT_NAME__ snow + +Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. + +## Ownership + +ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, physics bindings, and the state bridge. This repository owns the explorer, the boot print, the glade, the weather, the HUD and the +look; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. + +## Start every change + +1. **Critical planning gate:** invoke `threenative-capabilities` before `prd-creator`. Search + `engine_search_capabilities` for the full request and each concrete mechanic, inspect relevant + matches with `engine_capability_detail`, and record a capability or no-match for the plan. Apply the `ponytail` ladder before writing code; never hand-write what the capability search already installs. +2. Then invoke `prd-creator`. Draft the plan around those capabilities and binding constraints, + direct the user to review it, and wait for explicit approval plus an instruction to implement it. +3. Treat returned constraints as binding. This kit needs no navmesh and imports none, so it runs + on every target; adding `@threenative/physics/navigation` would make it web and desktop only. +4. If a build, import, device, or blank frame fails, run `npx threenative doctor` and + `npx @threenative/playtest doctor`; missing observations are not zero. +For *"a bullet passes through a wall"*, `RigidBody3D` defaults to continuous collision; `continuousCollision` is the named per-body override, and `body.continuousCollision` reports the effective setting on web/native. + +## When the framework blocks you, write plain Three.js + +When an `@threenative/*` API is broken, missing, or does not do what you need, replace only that +piece with portable Three.js/plain code. Keep the loop, scenes, input, registry, and playtest +bridge; avoid DOM globals, dynamic `import()`, and raw physics handles. **Report what blocked you** +(API, expectation, result, replacement); never stall the game. + +## Workflow skills + +- `.agents/skills/prd-creator/SKILL.md` / `.claude/skills/prd-creator/SKILL.md` — game plan and approval gate. +- `.agents/skills/threenative-capabilities/SKILL.md` / `.claude/skills/threenative-capabilities/SKILL.md` — capability search. +- `.agents/skills/threenative-playtest/SKILL.md` / `.claude/skills/threenative-playtest/SKILL.md` — diagnosis and proof. +- `.agents/skills/threenative-assets/SKILL.md` / `.claude/skills/threenative-assets/SKILL.md` — assets and sculpting. +- `.agents/skills/threenative-visuals/SKILL.md` / `.claude/skills/threenative-visuals/SKILL.md` — captures and look. +- `.agents/skills/threenative-performance/SKILL.md` / `.claude/skills/threenative-performance/SKILL.md` — measured budgets. +- `.agents/skills/threenative-ui/SKILL.md` / `.claude/skills/threenative-ui/SKILL.md` — native-safe UI. +- `.agents/skills/threenative-context/SKILL.md` / `.claude/skills/threenative-context/SKILL.md` — portable ctx APIs. +- Confirmed framework bugs: use `file-engine-bug` in `.agents/skills/` or `.claude/skills/` after a minimal repro. A game-side `patches/` fix is temporary: once it is settled, it moves into the engine in one PR. Lazy-first: `.agents/skills/ponytail/SKILL.md` / `.claude/skills/ponytail/SKILL.md` — smallest correct change, and its reuse rung is the capability search above. + +## Commands and map + +```sh +pnpm dev +pnpm build +pnpm test +pnpm test:native +``` + +The snow is one `SnowField` (`@threenative/core/world`) over one `Heightfield` built in `src/terrain.ts`: indentation, bank, compaction and disturbance composed into the canonical surface that queries, the drawn mesh and the collider all read. Never keep a second height grid. +`attachSnowPhysics` (`@threenative/physics`) owns the snow collider: each fixed step it reads the solver's contacts for the ball, crate and log, presses each body's own shape with its solved load, and installs the changed surface before the next step. Call `step` from `afterPhysics`, once; `observe()` reports `colliderError` and `loadProvenance` (load is a solver estimate). A kinematic `CharacterBody3D` makes no contact load, so `src/entities/Explorer.ts` plants `src/render/bootPrint.ts` itself with `snow.stamp`, only while `grounded`. Its capsule stands on a coarse walk surface (`walkSurface` in `src/terrain.ts`), not the 6 cm snow collider: Rapier sweeps a character against every triangle under it, which cost ~0.9 ms a step there. + +Look lives in `src/render/`: `snowSurface.ts` (TSL material, compaction view, redraws `takeDirtyRegion`), `weather.ts` (`GPUParticles3D` snowfall and powder), `explorer.ts`, `scenery.ts`, `props.ts`. Tune response with `depth`/`hardness` and the refill in `src/scenes/Snow.ts`. The React HUD reads `GameState` and sends intents; `src/commands.ts` routes panel verbs into the same functions as the keys. Keep `playtests/survives.playtest.json` as smoke proof and `playtests/snow-physics.playtest.json` honest: it proves drop, settle and rolling track. +On a touch-primary device (`isMobile() && isTouchscreenAvailable()`), `src/render/touch-controls.ts` adds a left movement stick; keyboard is the desktop fallback. + +## Portable authoring contracts + +Leave `assets` absent: the cook selects target-decodable passes, with `models.sharedImages: true` deduplicating images. `sharedImages: false` embeds duplicate copies; `models.compact` (default `{ flatten: true, join: true, instance: true }`) flattens the scene graph, merges primitives by material and batches a mesh shared by several nodes as `EXT_mesh_gpu_instancing` — all lossless, keeping any node matching `protectedPattern` (or named in `protectedNames`), an animation target, or a skin joint individually addressable; `compact: false` ships the scene graph as authored. `models: "none"` / `textures: "none"` / `audio: "none"` skip those passes and report uncooked bytes. Android/iOS currently skip compression and model dedupe. `assets.exclude` defaults to `[]`; source-relative globs (for example `["unused/**"]`) omit matching files and report saved bytes. `assets.budget` accepts `{ uncooked?: number | "none", total?: number | "none" }`, default `{ uncooked: 64_000_000, total: "none" }`: only bytes left uncooked where cooking was possible count toward `uncooked`. A number sets that ceiling; `"none"` disables both gates. Either disabled gate still reports bytes. Automatic texture cooking retains unaligned source images unchanged and reports `block-size`; those bytes still count toward the uncooked budget. An explicit compression codec override must satisfy four-pixel block alignment; `codec: "none"` opts out. Cooking never silently resizes an image to fix alignment. + +Relative look capture: a binding with `pointerRelative: true` captures the canvas on click by default; set `captureOnClick: false` and call `ctx.input.captureMouse()` from your own gesture to opt out. Desktop mode precedence is CLI (`--windowed`, `--maximized`, `--fullscreen`) over `display.fullscreen` over `window.maximized`; with both false, `window.width`/`height` size the normal window. +Scenes use `load`, `enter`, `update`, `exit`, `render`; physics nodes are Godot-named and disposable. The fixed step is the snow's clock: deposition, footsteps and contacts never read the frame rate. +`input.vector("move").y` is +up and means forward; `Snow.ts` turns it into a camera-relative direction. +Rigged assets: put a `.glb` in `assets/`, await `ctx.assets.model("hero.glb")` in `Scene.load()`, then drive `AnimationPlayer` beside its entity. `ctx.goto(name)` rebuilds without resetting game +state; from a frame function `goto` and then `return`; `ctx.state.set({ /* copy this game's initial-state shape */ })` +is a partial patch. `game.goto("")` also rebuilds the scene, but it resets the game's +state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. + +`src/render/quality.ts` owns `low`, `medium`, `high`; `isMobile()` chooses `low`, otherwise `high`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` reports +the source. The bridge flushes about 100 ms; keep readouts in state and frame feedback in Three.js. + +When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a +retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's +own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is +caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing +(the `.undefined` failure that plays the bind pose instead of the animation), +`clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous +clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is +supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches 256 per presented frame so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. +## Budget real time for the look + +Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. + +Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it beside the frame rate; `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. + +Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. +## Optional multiplayer transport +For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. diff --git a/packages/create-threenative/templates/snow/README.md b/packages/create-threenative/templates/snow/README.md new file mode 100644 index 0000000000..1d5743786f --- /dev/null +++ b/packages/create-threenative/templates/snow/README.md @@ -0,0 +1,26 @@ +# Snow glade starter + +A small winter glade where the snow remembers. The explorer leaves boot prints that sink with +their weight; a ball dropped into the powder settles into the crater it makes, and pushed, it rolls +and carves a connected track. A crate and a fallen log press their own shapes. Switch on the +blizzard and fresh snow slowly fills every track. + +`SnowField` stores the deformation and `attachSnowPhysics` connects it to real Rapier contacts, so +the ball rests on the same surface you see. Everything that decides how it looks — the snow +material, the boot, the explorer, the forest, the flakes — is ordinary source in `src/render/`. + +Controls: WASD or the arrow keys to walk (Shift to run), drag to orbit and scroll to zoom; `F` drops +the ball and `G` pushes it; `B` blizzard, `C` camera view, `V` compaction view, `P` auto-explore, +`R` clears every footprint, `M` sound. On a touch screen, drag the left stick. + +The crunch and wind clips in `assets/` are generated noise; replace either with a recording. + +## Commands + +```sh +pnpm dev +pnpm build +pnpm typecheck +pnpm test +pnpm test:native +``` diff --git a/packages/create-threenative/templates/snow/assets/crunch.wav b/packages/create-threenative/templates/snow/assets/crunch.wav new file mode 100644 index 0000000000..0f29d18671 Binary files /dev/null and b/packages/create-threenative/templates/snow/assets/crunch.wav differ diff --git a/packages/create-threenative/templates/snow/assets/wind.wav b/packages/create-threenative/templates/snow/assets/wind.wav new file mode 100644 index 0000000000..c68bb9589e Binary files /dev/null and b/packages/create-threenative/templates/snow/assets/wind.wav differ diff --git a/packages/create-threenative/templates/snow/index.html b/packages/create-threenative/templates/snow/index.html new file mode 100644 index 0000000000..f59c7647a1 --- /dev/null +++ b/packages/create-threenative/templates/snow/index.html @@ -0,0 +1,12 @@ + + + + + + __PROJECT_NAME__ + + +
+ + + diff --git a/packages/create-threenative/templates/snow/kit.json b/packages/create-threenative/templates/snow/kit.json new file mode 100644 index 0000000000..d9fd64d272 --- /dev/null +++ b/packages/create-threenative/templates/snow/kit.json @@ -0,0 +1,7 @@ +{ + "name": "snow", + "title": "Snow glade", + "blurb": "Deformable snow that remembers every footstep, a ball that carves its own track, and weather you can turn into a blizzard.", + "genre": "snow", + "kit": true +} diff --git a/packages/create-threenative/templates/snow/native-playtests/snow-physics.playtest.json b/packages/create-threenative/templates/snow/native-playtests/snow-physics.playtest.json new file mode 100644 index 0000000000..dee4fbfe27 --- /dev/null +++ b/packages/create-threenative/templates/snow/native-playtests/snow-physics.playtest.json @@ -0,0 +1,150 @@ +{ + "name": "snow-physics-native", + "schemaVersion": 1, + "target": "desktop", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 40, + "subject": "player", + "steps": [ + { + "kind": "wait", + "label": "boot", + "waitTicks": 20, + "release": true + }, + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "standing", + "waitTicks": 30, + "release": true + }, + { + "kind": "input", + "label": "drop", + "press": "KeyF", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "falling", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 240, + "release": true + }, + { + "kind": "input", + "label": "push", + "press": "KeyG", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "rolled", + "waitTicks": 200, + "release": true + } + ], + "assert": { + "components": [ + { + "entity": "ball", + "component": "airborne", + "atSteps": [ + { + "label": "falling", + "equals": true + }, + { + "label": "settled", + "equals": false + } + ], + "allowTrivial": "The drop must start in the air and end on the snow: a ball that never leaves the surface proves nothing about airborne contacts." + }, + { + "entity": "ball", + "component": "settled", + "atSteps": [ + { + "label": "settled", + "equals": true + } + ], + "allowTrivial": "Settled means the solved sphere's underside sits within 0.02 m of the surface it pressed, and that surface is at least 2 cm below fresh powder." + }, + { + "entity": "ball", + "component": "airborneLoads", + "equals": 0, + "allowTrivial": "No load may reach the snow while the ball is more than 5 cm above it; a contact read off an airborne body is a phantom." + }, + { + "entity": "ball", + "component": "reach", + "gte": 0.5, + "allowTrivial": "The push must carry the solved body half a metre or more through fresh powder; how far depends on the slope it was pushed up, so this is a floor, not a distance." + }, + { + "entity": "ball", + "component": "rollRatio", + "gte": 0.6, + "lte": 1.6, + "allowTrivial": "Turn times radius over the ground covered: a rolling ball reads near 1 (a ball carving its own trench can over-spin as it slows), a sliding or copied transform reads near 0." + }, + { + "entity": "ball", + "component": "trackGaps", + "equals": 0, + "allowTrivial": "Every recorded point of the ball's path must sit in pressed snow: the track is connected, not a row of holes." + }, + { + "entity": "ball", + "component": "trackSamples", + "gte": 5, + "allowTrivial": "The connected-track check needs a path to check: five or more points ten centimetres apart." + }, + { + "entity": "snow", + "component": "colliderError", + "lte": 0.001, + "allowTrivial": "The collider the next step uses is the canonical surface within a millimetre." + }, + { + "entity": "snow", + "component": "renderError", + "lte": 0.001, + "allowTrivial": "The drawn snow is the canonical surface within a millimetre." + }, + { + "entity": "snow", + "component": "loadProvenance", + "equals": "solver-impulse-per-step", + "allowTrivial": "Snow load is a solver estimate and must say so." + }, + { + "entity": "snow", + "component": "version", + "gte": 2, + "changed": true, + "allowTrivial": "The surface must actually change during the run." + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/native-playtests/survives.playtest.json b/packages/create-threenative/templates/snow/native-playtests/survives.playtest.json new file mode 100644 index 0000000000..996b25a9ef --- /dev/null +++ b/packages/create-threenative/templates/snow/native-playtests/survives.playtest.json @@ -0,0 +1,47 @@ +{ + "name": "native-snow-smoke", + "target": "desktop", + "schemaVersion": 1, + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "subject": "player", + "steps": [ + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "input", + "label": "walk", + "press": "ArrowUp", + "holdTicks": 120, + "release": true + }, + { + "kind": "wait", + "label": "observe-movement", + "waitTicks": 10, + "release": true + } + ], + "assert": { + "movement": { + "entity": "player", + "minDistance": 1 + }, + "components": [ + { + "entity": "player", + "component": "steps", + "gte": 2, + "allowTrivial": "The explorer plants boots in the snow on the native host too." + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/opencode.json b/packages/create-threenative/templates/snow/opencode.json new file mode 100644 index 0000000000..c8226dccec --- /dev/null +++ b/packages/create-threenative/templates/snow/opencode.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "threenative-assets": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/assets.mjs" + ], + "enabled": true, + "environment": { + "ASSET_DOWNLOAD_DIR": "./public/assets", + "AUDIO_DOWNLOAD_DIR": "./public/audio" + } + }, + "threenative-sculpt": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/sculpt.mjs" + ], + "enabled": true + }, + "threenative-engine": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/engine.mjs" + ], + "enabled": true + }, + "threenative-blender": { + "type": "local", + "command": [ + "node", + "./node_modules/@threenative/core/mcp/blender.mjs" + ], + "enabled": true + } + } +} diff --git a/packages/create-threenative/templates/snow/package.json b/packages/create-threenative/templates/snow/package.json new file mode 100644 index 0000000000..9a53bc14f6 --- /dev/null +++ b/packages/create-threenative/templates/snow/package.json @@ -0,0 +1,58 @@ +{ + "name": "__PROJECT_NAME__", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "threenative build", + "build:web": "threenative build --target web", + "build:desktop": "threenative build --target desktop", + "build:android": "threenative build --target android", + "build:ios": "threenative build --target ios", + "test:native": "pnpm build:desktop && threenative-playtest --scenario native-playtests/snow-physics.playtest.json --scenario native-playtests/survives.playtest.json --target desktop --executable dist-native/__PROJECT_NAME__", + "pretest": "playwright install chromium", + "test": "pnpm build:web && threenative-playtest --scenario \"playtests/*.playtest.json\" --browser-recipe webgpu --headed --server-command \"pnpm exec vite preview --host 127.0.0.1 --port $PORT --strictPort\"", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@threenative/core": "0.3.4", + "@threenative/physics": "0.3.4", + "@threenative/ui": "0.3.4", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-reconciler": "0.33.0", + "three": "0.185.1" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@threenative/assets": "0.3.5", + "@threenative/playtest": "0.3.4", + "@types/react": "19.2.0", + "@types/react-dom": "19.2.0", + "@types/three": "0.185.3", + "@vitejs/plugin-react": "6.0.5", + "create-threenative": "0.2.8", + "esbuild": "0.27.0", + "playwright": "1.62.1", + "tailwindcss": "4.3.3", + "typescript": "5.9.3", + "vite": "8.2.0", + "@gltf-transform/cli": "4.4.2" + }, + "optionalDependencies": { + "@threenative/runtime-native": "0.3.4" + }, + "overrides": { + "sharp": ">=0.35.4" + }, + "pnpm": { + "overrides": { + "sharp": ">=0.35.4" + }, + "onlyBuiltDependencies": ["@threenative/core", "@threenative/runtime-native"], + "patchedDependencies": { + "three@0.185.1": "patches/three@0.185.1.patch" + } + } +} diff --git a/packages/create-threenative/templates/snow/playtests/footsteps.playtest.json b/packages/create-threenative/templates/snow/playtests/footsteps.playtest.json new file mode 100644 index 0000000000..64d54288b8 --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/footsteps.playtest.json @@ -0,0 +1,95 @@ +{ + "name": "snow-footsteps", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 20, + "subject": "player", + "steps": [ + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "input", + "label": "walk", + "press": "ArrowUp", + "holdTicks": 150, + "release": true + }, + { + "kind": "wait", + "label": "settle", + "waitTicks": 30, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "movement": { + "entity": "player", + "minDistance": 1.2 + }, + "components": [ + { + "entity": "player", + "component": "steps", + "gte": 3, + "allowTrivial": "Only a grounded body plants a boot that presses snow; three prints prove the walk cycle reaches the snow." + }, + { + "entity": "player", + "component": "grounded", + "equals": true, + "allowTrivial": "The explorer ends standing on the snow it walked over." + }, + { + "entity": "snow", + "component": "renderError", + "lte": 0.001, + "allowTrivial": "The drawn snow is the canonical surface within a millimetre after the walk." + }, + { + "entity": "snow", + "component": "colliderError", + "lte": 0.001, + "allowTrivial": "The collider is the canonical surface within a millimetre after the walk." + } + ], + "resources": [ + { + "id": "state", + "path": "lastSink", + "gte": 0.02, + "allowTrivial": "A planted boot in 28 cm of powder sinks at least 2 cm." + }, + { + "id": "state", + "path": "contacts", + "changed": true + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/playtests/performance.playtest.json b/packages/create-threenative/templates/snow/playtests/performance.playtest.json new file mode 100644 index 0000000000..88c7262974 --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/performance.playtest.json @@ -0,0 +1,36 @@ +{ + "acceptanceId": "prd-469-performance-snow", + "artifacts": { + "screenshots": "after" + }, + "assert": { + "performance": { + "maxDrawCalls": 160, + "maxFrameMsP95": 33, + "maxTriangles": 1000000, + "minFps": 30 + } + }, + "name": "production-performance", + "schemaVersion": 1, + "steps": [ + { + "kind": "input", + "press": "ArrowUp", + "holdTicks": 60, + "release": true + }, + { + "kind": "wait", + "waitTicks": 600, + "release": true + } + ], + "subject": "player", + "target": "web", + "viewport": { + "height": 1080, + "width": 1920 + }, + "warmupFrames": 60 +} diff --git a/packages/create-threenative/templates/snow/playtests/snow-physics.playtest.json b/packages/create-threenative/templates/snow/playtests/snow-physics.playtest.json new file mode 100644 index 0000000000..e9ed5dc72b --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/snow-physics.playtest.json @@ -0,0 +1,154 @@ +{ + "name": "snow-physics", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 40, + "subject": "player", + "steps": [ + { + "kind": "wait", + "label": "boot", + "waitTicks": 20, + "release": true + }, + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "standing", + "waitTicks": 30, + "release": true + }, + { + "kind": "input", + "label": "drop", + "press": "KeyF", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "falling", + "waitTicks": 10, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 240, + "release": true + }, + { + "kind": "input", + "label": "push", + "press": "KeyG", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "rolled", + "waitTicks": 200, + "release": true + } + ], + "assert": { + "components": [ + { + "entity": "ball", + "component": "airborne", + "atSteps": [ + { + "label": "falling", + "equals": true + }, + { + "label": "settled", + "equals": false + } + ], + "allowTrivial": "The drop must start in the air and end on the snow: a ball that never leaves the surface proves nothing about airborne contacts." + }, + { + "entity": "ball", + "component": "settled", + "atSteps": [ + { + "label": "settled", + "equals": true + } + ], + "allowTrivial": "Settled means the solved sphere's underside sits within 0.02 m of the surface it pressed, and that surface is at least 2 cm below fresh powder." + }, + { + "entity": "ball", + "component": "airborneLoads", + "equals": 0, + "allowTrivial": "No load may reach the snow while the ball is more than 5 cm above it; a contact read off an airborne body is a phantom." + }, + { + "entity": "ball", + "component": "reach", + "gte": 0.5, + "allowTrivial": "The push must carry the solved body half a metre or more through fresh powder; how far depends on the slope it was pushed up, so this is a floor, not a distance." + }, + { + "entity": "ball", + "component": "rollRatio", + "gte": 0.6, + "lte": 1.6, + "allowTrivial": "Turn times radius over the ground covered: a rolling ball reads near 1 (a ball carving its own trench can over-spin as it slows), a sliding or copied transform reads near 0." + }, + { + "entity": "ball", + "component": "trackGaps", + "equals": 0, + "allowTrivial": "Every recorded point of the ball's path must sit in pressed snow: the track is connected, not a row of holes." + }, + { + "entity": "ball", + "component": "trackSamples", + "gte": 5, + "allowTrivial": "The connected-track check needs a path to check: five or more points ten centimetres apart." + }, + { + "entity": "snow", + "component": "colliderError", + "lte": 0.001, + "allowTrivial": "The collider the next step uses is the canonical surface within a millimetre." + }, + { + "entity": "snow", + "component": "renderError", + "lte": 0.001, + "allowTrivial": "The drawn snow is the canonical surface within a millimetre." + }, + { + "entity": "snow", + "component": "loadProvenance", + "equals": "solver-impulse-per-step", + "allowTrivial": "Snow load is a solver estimate and must say so." + }, + { + "entity": "snow", + "component": "version", + "gte": 2, + "changed": true, + "allowTrivial": "The surface must actually change during the run." + } + ], + "diagnostics": { + "noConsoleErrors": true, + "runtimeReady": true + } + } +} diff --git a/packages/create-threenative/templates/snow/playtests/survives.playtest.json b/packages/create-threenative/templates/snow/playtests/survives.playtest.json new file mode 100644 index 0000000000..2b596c30c5 --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/survives.playtest.json @@ -0,0 +1,56 @@ +{ + "name": "survives", + "target": "web", + "schemaVersion": 1, + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "subject": "player", + "steps": [ + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "input", + "label": "walk-forward", + "press": "ArrowUp", + "holdTicks": 60, + "release": true + }, + { + "kind": "wait", + "label": "settle", + "waitTicks": 10, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "movement": { + "entity": "player", + "minDistance": 0.5 + }, + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/playtests/touch-controls.playtest.json b/packages/create-threenative/templates/snow/playtests/touch-controls.playtest.json new file mode 100644 index 0000000000..481a5c1e36 --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/touch-controls.playtest.json @@ -0,0 +1,71 @@ +{ + "name": "snow-touch-controls", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 390, + "height": 844 + }, + "warmupFrames": 10, + "subject": "player", + "steps": [ + { + "kind": "input", + "label": "movement-anchor", + "pointers": [ + { + "id": 7, + "x": 0.25, + "y": 0.82 + } + ], + "holdTicks": 2, + "release": false + }, + { + "kind": "input", + "label": "walk-forward", + "pointers": [ + { + "id": 7, + "x": 0.25, + "y": 0.72 + } + ], + "holdTicks": 120, + "release": true + }, + { + "kind": "wait", + "label": "settled", + "waitTicks": 10, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "movement": { + "entity": "player", + "minDistance": 0.5 + }, + "resources": [ + { + "id": "state", + "path": "autoExplore", + "equals": false + } + ], + "visibility": [ + { + "entity": "touch-controls", + "present": true, + "allowTrivial": "Touch-primary lanes keep the movement stick visible while the walk proves it is live." + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/playtests/weather.playtest.json b/packages/create-threenative/templates/snow/playtests/weather.playtest.json new file mode 100644 index 0000000000..58f1d09b25 --- /dev/null +++ b/packages/create-threenative/templates/snow/playtests/weather.playtest.json @@ -0,0 +1,119 @@ +{ + "name": "snow-weather", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 20, + "subject": "player", + "steps": [ + { + "kind": "input", + "label": "hands-off", + "press": "KeyP", + "holdTicks": 2, + "release": true + }, + { + "kind": "wait", + "label": "calm", + "waitTicks": 20, + "release": true + }, + { + "kind": "input", + "label": "blizzard", + "press": "KeyB", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "storm", + "waitTicks": 300, + "release": true + }, + { + "kind": "input", + "label": "compaction", + "press": "KeyV", + "holdTicks": 1, + "release": true + }, + { + "kind": "input", + "label": "reset", + "press": "KeyR", + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "label": "fresh", + "waitTicks": 20, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "components": [ + { + "entity": "weather", + "component": "storm", + "gte": 0.6, + "allowTrivial": "Five seconds after B the blizzard has mostly built: the eased storm level passes 0.6." + }, + { + "entity": "weather", + "component": "wind", + "gte": 4, + "allowTrivial": "A blizzard blows at several metres a second, gusts included." + }, + { + "entity": "snow", + "component": "trailPressed", + "atSteps": [ + { + "label": "calm", + "equals": true + }, + { + "label": "fresh", + "equals": false + } + ], + "allowTrivial": "The arrival trail is in the snow at the start and gone after R clears every footprint." + } + ], + "resources": [ + { + "id": "state", + "path": "blizzard", + "equals": true + }, + { + "id": "state", + "path": "compaction", + "equals": true + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.01 + } + } + ] + } +} diff --git a/packages/create-threenative/templates/snow/public/favicon.svg b/packages/create-threenative/templates/snow/public/favicon.svg new file mode 100644 index 0000000000..31f9ad4225 --- /dev/null +++ b/packages/create-threenative/templates/snow/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/create-threenative/templates/snow/src/audio.ts b/packages/create-threenative/templates/snow/src/audio.ts new file mode 100644 index 0000000000..214b148b34 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/audio.ts @@ -0,0 +1,58 @@ +import { AudioBus } from "@threenative/core"; +import type { Audio, Object3D } from "three"; +import type { IFootstep } from "./entities/Explorer.js"; + +/** + * Boot crunch and a wind bed through one `AudioBus`. Both clips in `assets/` are generated + * noise — a bandpass sweep for the crunch, filtered brown noise for the wind — so swap either + * file for a recording without touching this code. Muted means the bus level is zero; the wind + * keeps looping underneath so unmuting never restarts it mid-gust. + */ +export class SnowAudio { + readonly bus: AudioBus; + #crunch: AudioBuffer | undefined; + #wind: Audio | undefined; + #muted = true; + + constructor(camera: Object3D, load: (name: string) => Promise) { + this.bus = new AudioBus({ camera }); + this.bus.setVolume(0); + void load("crunch.wav") + .then((buffer) => { + this.#crunch = buffer; + }) + .catch(() => undefined); + void load("wind.wav") + .then((buffer) => { + this.#wind = this.bus.play(buffer, { cue: "wind", loop: true, volume: 0 }); + }) + .catch(() => undefined); + } + + get muted(): boolean { + return this.#muted; + } + + setMuted(muted: boolean): void { + this.#muted = muted; + this.bus.setVolume(muted ? 0 : 1, 0.2); + } + + footstep(step: IFootstep): void { + if (this.#muted || this.#crunch === undefined || step.penetration <= 0) return; + this.bus.play(this.#crunch, { + cue: "crunch", + detune: (Math.random() - 0.5) * 400, + volume: Math.min(1, 0.42 + step.penetration * 2), + }); + } + + /** The wind bed follows the live wind and swells with the storm. */ + update(wind: number, storm: number): void { + this.#wind?.setVolume(Math.min(1, 0.12 + storm * 0.55 + wind * 0.01)); + } + + dispose(): void { + this.bus.dispose(); + } +} diff --git a/packages/create-threenative/templates/snow/src/commands.ts b/packages/create-threenative/templates/snow/src/commands.ts new file mode 100644 index 0000000000..b56ebdb8f7 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/commands.ts @@ -0,0 +1,8 @@ +/** + * One-shot actions from the HUD. Sliders and toggles are state; these are verbs, so the UI pushes + * them here and the scene drains the queue on its next fixed step — the same functions a key + * press calls, so the keyboard and the panel can never disagree. + */ +export type Command = "reset" | "drop" | "kick" | "view" | "auto" | "blizzard" | "mute"; + +export const commands: Command[] = []; diff --git a/packages/create-threenative/templates/snow/src/entities/Explorer.ts b/packages/create-threenative/templates/snow/src/entities/Explorer.ts new file mode 100644 index 0000000000..2a8507518a --- /dev/null +++ b/packages/create-threenative/templates/snow/src/entities/Explorer.ts @@ -0,0 +1,279 @@ +import type { SnowField } from "@threenative/core/world"; +import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; +import { Object3D } from "three"; +import { BOOT_AREA, bootPrint } from "../render/bootPrint.js"; +import { ExplorerModel, type IFootPose } from "../render/explorer.js"; +import type { SnowMaterials } from "../render/materials.js"; +import { EXPLORER_LAYER, SNOW_LAYER, terrainHeight } from "../terrain.js"; + +/** One planted boot: where, how it was turned, and how far it sank. */ +export interface IFootstep { + readonly x: number; + readonly y: number; + readonly z: number; + readonly angle: number; + readonly penetration: number; + readonly speed: number; + readonly side: number; +} + +interface IFoot extends IFootPose { + side: number; + x: number; + y: number; + z: number; + angle: number; + swing: boolean; + progress: number; + stop: number; + from: { x: number; z: number }; + to: { x: number; z: number }; +} + +/** The explorer's weight, which every planted boot presses into the snow. */ +const MASS = 80; +const GRAVITY = 9.81; +/** Capsule straight half-length and radius: 1.7 m tall, origin at the waist. */ +const HALF_HEIGHT = 0.55; +const RADIUS = 0.3; +const WAIST = HALF_HEIGHT + RADIUS; +/** + * How far each boot lands from the line of travel. Wide enough that the left and right prints + * stay two rows with snow between them rather than merging into one trench. + */ +export const STANCE = 0.2; + +const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value)); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const damp = (a: number, b: number, rate: number, dt: number) => + lerp(a, b, 1 - Math.exp(-rate * Math.max(0, dt))); +const smoothstep = (edge0: number, edge1: number, value: number) => { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +}; + +/** Walking pace through powder: deeper, softer snow is slower; packed snow is faster. */ +function paceThrough(depth: number, compaction: number, running: boolean): number { + const resistance = clamp(depth, 0, 0.6) * (1 - clamp(compaction, 0, 1) * 0.75); + return (running ? 3.25 : 1.65) / (1 + resistance * 3.5); +} + +/** + * The procedural explorer. Movement goes through a `CharacterBody3D`, so trees, rocks and the + * ball stop or get pushed by real collision; each boot is planted by the walk cycle, and only a + * grounded body plants one. A planted boot presses `bootPrint` into the snow with the explorer's + * own weight over the sole's area — the snow decides how deep that goes. + */ +export class Explorer { + readonly body: CharacterBody3D; + readonly model: ExplorerModel; + /** What a playtest or camera tracks: the drawn explorer, not the collision capsule. */ + readonly object: Object3D; + readonly position = { x: 0, y: 0, z: 0 }; + heading = Math.PI; + speed = 0; + steps = 0; + #velocity = { x: 0, z: 0 }; + #cycle = 0.1; + #elapsed = 0; + #last = { x: 0, z: 0 }; + readonly #feet: IFoot[] = []; + readonly #snow: SnowField; + readonly #onStep: (step: IFootstep) => void; + + constructor( + physics: IPhysicsContext, + snow: SnowField, + materials: SnowMaterials, + start: { x: number; z: number }, + onStep: (step: IFootstep) => void, + ) { + this.#snow = snow; + this.#onStep = onStep; + const anchor = new Object3D(); + anchor.position.set(start.x, snow.heightAt(start.x, start.z) + WAIST + 0.05, start.z); + this.body = new CharacterBody3D({ + autostep: { maxHeight: 0.25, minWidth: 0.1 }, + maxSlopeClimbAngle: 0.8, + object: anchor, + physics, + // Stands on the coarse walk surface, never the 6 cm snow collider (see `WALK_SAMPLES`). + collisionLayer: EXPLORER_LAYER, + collisionMask: 0xffff & ~SNOW_LAYER, + pushesDynamicBodies: true, + shape: CollisionShape3D.capsule(HALF_HEIGHT, RADIUS), + snapToGround: 0.3, + }); + this.model = new ExplorerModel(materials); + this.object = this.model.root; + this.position.x = start.x; + this.position.z = start.z; + this.position.y = snow.heightAt(start.x, start.z) - 0.07; + this.#last = { x: start.x, z: start.z }; + for (const side of [-1, 1]) { + const x = start.x + side * STANCE * Math.cos(this.heading); + const z = start.z - side * STANCE * Math.sin(this.heading); + this.#feet.push({ + angle: this.heading, + from: { x, z }, + progress: 0, + side, + stop: 0, + swing: false, + to: { x, z }, + x, + y: snow.heightAt(x, z) + 0.14, + z, + }); + } + this.model.pose(this.position, this.heading, this.#feet, this.#cycle, 0, 0); + } + + /** Steer towards `move` (a world-space direction, length up to 1) for one fixed step. */ + update(dt: number, move: { x: number; z: number }, running: boolean, hardness: number): void { + this.#elapsed += dt; + const snow = this.#snow; + const anchor = this.body.object.position; + // The walk cycle advances by the ground the body actually covered, so a blocked explorer + // treads in place instead of skating. + const travelled = Math.hypot(anchor.x - this.#last.x, anchor.z - this.#last.z); + this.speed = travelled / Math.max(1e-4, dt); + this.#last = { x: anchor.x, z: anchor.z }; + this.position.x = anchor.x; + this.position.z = anchor.z; + + const pace = paceThrough(snow.depth, snow.sample(anchor.x, anchor.z).compaction, running); + const length = Math.hypot(move.x, move.z); + const scale = length > 1 ? 1 / length : 1; + const moving = length > 0.01; + this.#velocity.x = damp(this.#velocity.x, move.x * scale * pace, moving ? 5 : 8, dt); + this.#velocity.z = damp(this.#velocity.z, move.z * scale * pace, moving ? 5 : 8, dt); + if (!moving && Math.hypot(this.#velocity.x, this.#velocity.z) < 0.025) { + this.#velocity.x = 0; + this.#velocity.z = 0; + } + this.body.velocity.x = this.#velocity.x; + this.body.velocity.z = this.#velocity.z; + this.body.moveAndSlide(dt); + + if (this.speed > 0.05) { + const desired = Math.atan2(this.#velocity.x, this.#velocity.z); + const turn = Math.atan2(Math.sin(desired - this.heading), Math.cos(desired - this.heading)); + this.heading += turn * (1 - Math.exp(-7 * dt)); + } + this.#cycle += travelled / (running ? 1.22 : 1.04); + this.#stepFeet(dt, running, hardness); + + const sink = this.#sinkDepth(hardness) * 0.6; + const bob = + this.speed > 0.02 + ? Math.cos(this.#cycle * Math.PI * 4) * 0.016 + : Math.sin(this.#elapsed * 1.8) * 0.003; + this.position.y = damp( + this.position.y, + terrainHeight(anchor.x, anchor.z) + snow.depth - sink + bob, + 14, + dt, + ); + this.model.pose( + this.position, + this.heading, + this.#feet, + this.#cycle, + this.speed, + this.#elapsed, + ); + } + + /** How far an 80 kg boot sinks in this snow, for the body's visual height. */ + #sinkDepth(hardness: number): number { + const pressure = (MASS * GRAVITY) / BOOT_AREA; + const resistance = 18_000 + clamp(hardness, 0, 1) ** 1.5 * 140_000; + return clamp(this.#snow.depth * (0.16 + pressure / resistance), 0, this.#snow.depth * 0.82); + } + + #stepFeet(dt: number, running: boolean, hardness: number): void { + const snow = this.#snow; + const forward = { x: Math.sin(this.heading), z: Math.cos(this.heading) }; + const right = { x: Math.cos(this.heading), z: -Math.sin(this.heading) }; + for (const foot of this.#feet) { + const phase = (this.#cycle + (foot.side > 0 ? 0.5 : 0)) % 1; + if (!foot.swing && phase >= 0.58 && this.speed > 0.08) { + foot.swing = true; + foot.progress = 0; + foot.stop = 0; + foot.from = { x: foot.x, z: foot.z }; + foot.angle = this.heading; + const reach = running ? 0.64 : 0.49; + foot.to = { + x: this.position.x + forward.x * reach + right.x * foot.side * STANCE, + z: this.position.z + forward.z * reach + right.z * foot.side * STANCE, + }; + } + if (foot.swing) { + if (phase < 0.58 && this.speed > 0.08) { + this.#plant(foot, running, hardness); + continue; + } + let progress = clamp((phase - 0.58) / 0.42, 0, 1); + if (this.speed < 0.08) { + foot.stop += dt * 3.2; + progress = Math.max(foot.progress, clamp(foot.progress + foot.stop, 0, 1)); + } + foot.progress = progress; + const eased = smoothstep(0, 1, progress); + foot.x = lerp(foot.from.x, foot.to.x, eased); + foot.z = lerp(foot.from.z, foot.to.z, eased); + const lift = + Math.sin(progress * Math.PI) * (0.15 + snow.depth * 0.3 + (running ? 0.07 : 0)); + foot.y = this.#surface(foot.x, foot.z) + 0.14 + lift; + if (progress >= 0.999) this.#plant(foot, running, hardness); + } else foot.y = this.#surface(foot.x, foot.z) + 0.14; + } + } + + #surface(x: number, z: number): number { + const half = this.#snow.field.width / 2 - 1e-3; + return this.#snow.heightAt(clamp(x, -half, half), clamp(z, -half, half)); + } + + #plant(foot: IFoot, running: boolean, hardness: number): void { + foot.x = foot.to.x; + foot.z = foot.to.z; + foot.swing = false; + foot.stop = 0; + // A boot only presses snow when the body is standing on it: a jump or a fall plants nothing. + const penetration = this.body.grounded + ? this.#snow.stamp({ + area: BOOT_AREA, + footprint: bootPrint, + hardness, + load: MASS * GRAVITY * (running ? 1.2 : 1), + rotation: foot.angle, + x: foot.x, + z: foot.z, + }) + : 0; + if (penetration > 0) this.steps += 1; + foot.y = this.#surface(foot.x, foot.z) + 0.14; + this.#onStep({ + angle: foot.angle, + penetration, + side: foot.side, + speed: this.speed, + x: foot.x, + y: foot.y - 0.12, + z: foot.z, + }); + } + + debug(): Record { + return { + grounded: this.body.grounded, + heading: this.heading, + position: [this.position.x, this.position.y, this.position.z], + speed: this.speed, + steps: this.steps, + }; + } +} diff --git a/packages/create-threenative/templates/snow/src/game.ts b/packages/create-threenative/templates/snow/src/game.ts new file mode 100644 index 0000000000..6aa7208b37 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/game.ts @@ -0,0 +1,69 @@ +import { defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import type { IPhysicsContext } from "@threenative/physics"; +import { rapier } from "@threenative/physics"; +import config from "../threenative.config.js"; +import { type Command, commands } from "./commands.js"; +import { Boot } from "./scenes/Boot.js"; +import { Snow } from "./scenes/Snow.js"; +import type { GameState } from "./state.js"; + +const game = defineGame({ + input: { + move: { + down: ["ArrowDown", "KeyS"], + left: ["ArrowLeft", "KeyA"], + right: ["ArrowRight", "KeyD"], + up: ["ArrowUp", "KeyW"], + }, + run: { keys: ["ShiftLeft", "ShiftRight"] }, + auto: { keys: ["KeyP"] }, + blizzard: { keys: ["KeyB"] }, + compaction: { keys: ["KeyV"] }, + drop: { keys: ["KeyF"] }, + kick: { keys: ["KeyG"] }, + mute: { keys: ["KeyM"] }, + reset: { keys: ["KeyR"] }, + view: { keys: ["KeyC"] }, + zoom: { pinch: true, scroll: true }, + }, + plugins: [rapier({ gravity: { x: 0, y: -9.81, z: 0 } }), playtest()], + display: config.display, + render: config.renderer, + scenes: { boot: Boot, snow: Snow }, + seed: 2_024, + start: "boot", +}); + +export default game; + +/** Settings the panel may change directly. Anything else in a `set` intent is ignored. */ +const SETTINGS = new Set([ + "snowfall", + "depth", + "fallSpeed", + "hardness", + "wind", + "recovery", + "compaction", +]); +const COMMANDS = new Set(["reset", "drop", "kick", "view", "auto", "blizzard", "mute"]); + +game.ui.onIntent((intent, payload) => { + if (intent === "pause") game.pause(); + if (intent === "resume") game.resume(); + if (intent === "set" && typeof payload === "object" && payload !== null) { + const { key, value } = payload as { key?: string; value?: unknown }; + if ( + SETTINGS.has(key as keyof GameState) && + (typeof value === "number" || typeof value === "boolean") + ) + game.state.set({ [key as string]: value } as Partial); + } + if (COMMANDS.has(intent as Command)) commands.push(intent as Command); + game.state.set({ + uiReady: game.ui.connected, + ...(intent === "pause" || intent === "resume" ? { paused: intent === "pause" } : {}), + } as Partial); + game.state.flush(); +}); diff --git a/packages/create-threenative/templates/snow/src/main.ts b/packages/create-threenative/templates/snow/src/main.ts new file mode 100644 index 0000000000..6d49a882a9 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/main.ts @@ -0,0 +1,15 @@ +import { acceptHotUpdate } from "@threenative/core/hot"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import game from "./game.js"; +import { App } from "./ui/App.js"; +import "./style.css"; + +import.meta.hot?.accept(); +acceptHotUpdate(game, import.meta.hot); +const root = document.getElementById("root"); +if (root === null) throw new Error("Missing #root element."); +const appRoot = root as typeof root & { __threenativeRoot?: ReturnType }; +const reactRoot = appRoot.__threenativeRoot ?? createRoot(appRoot); +appRoot.__threenativeRoot = reactRoot; +reactRoot.render(createElement(App, { game })); diff --git a/packages/create-threenative/templates/snow/src/render/bootPrint.ts b/packages/create-threenative/templates/snow/src/render/bootPrint.ts new file mode 100644 index 0000000000..ad6c1adcfe --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/bootPrint.ts @@ -0,0 +1,50 @@ +// Generated for you. The boot is a look decision: this profile is what a footprint looks like. +// Change the sole, the tread or the rim here; `SnowField` only presses whatever shape it is given. + +/** One sample of a contact profile, in the shape `SnowField.stamp` reads. */ +interface IPrintSample { + readonly bank: number; + readonly coverage: number; + readonly disturbance: number; + readonly relief: number; + readonly shape: number; +} + +const NOTHING: IPrintSample = { bank: 0, coverage: 0, disturbance: 0, relief: 0, shape: 0 }; + +function smoothstep(edge0: number, edge1: number, value: number): number { + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +/** The sole's area, m². Load over this is the pressure a planted boot puts on the snow. */ +export const BOOT_AREA = 0.074; + +/** + * A rounded boot sole — a slightly wider toe than heel — with raised chevrons, a centre groove, + * and a rim of displaced snow around it. Local z runs heel to toe. + */ +export const bootPrint: { + readonly extent: number; + readonly sample: (x: number, z: number) => IPrintSample; +} = { + extent: 0.48, + sample: (x, z) => { + const width = 0.13 + smoothstep(-0.22, 0.15, z) * 0.013; + const qx = Math.abs(x) - (width - 0.052); + const qz = Math.abs(z) - (0.255 - 0.052); + const sdf = + Math.hypot(Math.max(qx, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qz), 0) - 0.052; + if (sdf > 0.105) return NOTHING; + const tread = Math.max(0, Math.cos(z * 83 + Math.abs(x) * 30)) ** 8; + const groove = (1 - smoothstep(0.009, 0.023, Math.abs(x))) * 0.004; + const rim = sdf > 0 ? Math.exp(-(((sdf - 0.027) / 0.027) ** 2)) : 0; + return { + bank: rim * (0.78 + 0.22 * Math.sin(x * 94 + z * 63)), + coverage: 1 - smoothstep(-0.038, 0.008, sdf), + disturbance: rim * 0.5, + relief: 0.012 * tread + groove, + shape: 0.9 + 0.1 * smoothstep(-0.2, 0.15, z), + }; + }, +}; diff --git a/packages/create-threenative/templates/snow/src/render/camera.ts b/packages/create-threenative/templates/snow/src/render/camera.ts new file mode 100644 index 0000000000..91eaeebedf --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/camera.ts @@ -0,0 +1,84 @@ +// Generated for you. Framing is a game-owned decision. +import type { PerspectiveCamera } from "three"; +import type { CameraView } from "../state.js"; + +export function setupCamera(camera: PerspectiveCamera): void { + camera.fov = fieldOfView(camera.aspect); + camera.near = 0.075; + camera.far = 500; + camera.updateProjectionMatrix(); +} + +/** + * Vertical field of view for an aspect ratio. A portrait phone keeps the landscape view's + * horizontal reach rather than its vertical angle, or the explorer fills the screen. + */ +export function fieldOfView(aspect: number): number { + if (aspect >= 1) return 43; + const horizontal = 2 * Math.atan(Math.tan((43 * Math.PI) / 360) * 1.25); + const vertical = 2 * Math.atan(Math.tan(horizontal / 2) / aspect); + return Math.min(70, (vertical * 180) / Math.PI); +} + +/** Orbit distance and elevation (radians from straight down) for each view. */ +const VIEWS: Record = { + // Over the shoulder, high enough that the trail behind the explorer reads. + follow: { polar: 1.1, radius: 11.6 }, + // Low and close on the last footprint: the tread, the rim and the compaction. + surface: { polar: 1.22, radius: 3.1 }, + overhead: { polar: 0.23, radius: 19 }, +}; + +/** + * A damped orbit around a target. Drag and the zoom axis move the goal; the camera eases to it, + * and never dips under the snow it is looking at. + */ +export class OrbitFollow { + yaw = 0.68; + #polar = VIEWS.follow.polar; + #radius = VIEWS.follow.radius; + #goalPolar = VIEWS.follow.polar; + #goalRadius = VIEWS.follow.radius; + readonly #target = { x: 0, y: 0.95, z: 0 }; + + setView(view: CameraView): void { + this.#goalRadius = VIEWS[view].radius; + this.#goalPolar = VIEWS[view].polar; + } + + /** Drag by pixels: sideways turns, vertical tilts. */ + drag(dx: number, dy: number): void { + this.yaw -= dx * 0.005; + this.#goalPolar = Math.max(0.12, Math.min(1.43, this.#goalPolar + dy * 0.004)); + } + + zoom(amount: number): void { + this.#goalRadius = Math.max(1.65, Math.min(30, this.#goalRadius * Math.exp(-amount * 0.001))); + } + + update( + camera: PerspectiveCamera, + goal: { readonly x: number; readonly y: number; readonly z: number }, + ground: (x: number, z: number) => number, + dt: number, + ): void { + const ease = 1 - Math.exp(-dt * 4.5); + const settle = 1 - Math.exp(-dt * 5); + this.#target.x += (goal.x - this.#target.x) * ease; + this.#target.y += (goal.y - this.#target.y) * ease; + this.#target.z += (goal.z - this.#target.z) * ease; + this.#radius += (this.#goalRadius - this.#radius) * settle; + this.#polar += (this.#goalPolar - this.#polar) * settle; + const sine = Math.sin(this.#polar); + const x = this.#target.x + Math.sin(this.yaw) * sine * this.#radius; + const z = this.#target.z + Math.cos(this.yaw) * sine * this.#radius; + const y = this.#target.y + Math.cos(this.#polar) * this.#radius; + const fov = fieldOfView(camera.aspect); + if (Math.abs(fov - camera.fov) > 0.01) { + camera.fov = fov; + camera.updateProjectionMatrix(); + } + camera.position.set(x, Math.max(y, ground(x, z) + 0.3), z); + camera.lookAt(this.#target.x, this.#target.y, this.#target.z); + } +} diff --git a/packages/create-threenative/templates/snow/src/render/explorer.ts b/packages/create-threenative/templates/snow/src/render/explorer.ts new file mode 100644 index 0000000000..eaea634327 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/explorer.ts @@ -0,0 +1,321 @@ +// Generated for you. The explorer's look and its procedural pose: every part is a primitive built +// here, so the character can be restyled — or swapped for a rigged model — without touching the +// movement or the footsteps in `src/entities/Explorer.ts`. +import { + BoxGeometry, + type BufferGeometry, + CapsuleGeometry, + CatmullRomCurve3, + CylinderGeometry, + Group, + type Material, + Mesh, + SphereGeometry, + TorusGeometry, + TubeGeometry, + Vector3, +} from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import type { SnowMaterials } from "./materials.js"; + +type Point = { x: number; y: number; z: number }; + +/** Where each boot is, in world space, and whether it is in the air. */ +export interface IFootPose { + readonly side: number; + readonly x: number; + readonly y: number; + readonly z: number; + readonly angle: number; + readonly swing: boolean; + readonly progress: number; +} + +interface ISegment { + readonly mesh: Mesh; + readonly length: number; +} + +interface ILimb { + readonly side: number; + readonly upper: ISegment; + readonly lower: ISegment; + readonly end: Object3DLike; +} + +type Object3DLike = Group | Mesh; + +/** Bake a group's direct child meshes into one mesh per material, keeping child groups. */ +function mergeRigid(group: Group): void { + const byMaterial = new Map(); + for (const child of [...group.children]) { + if (!(child instanceof Mesh) || Array.isArray(child.material)) continue; + child.updateMatrix(); + const geometry = child.geometry.clone().applyMatrix4(child.matrix); + const list = byMaterial.get(child.material) ?? []; + list.push(geometry); + byMaterial.set(child.material, list); + group.remove(child); + } + for (const [material, geometries] of byMaterial) { + const merged = mergeGeometries(geometries); + for (const geometry of geometries) geometry.dispose(); + if (merged === null) throw new Error("ExplorerModel could not merge a rigid part."); + const mesh = new Mesh(merged, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + group.add(mesh); + } +} + +const UP = new Vector3(0, 1, 0); +const scratchA = new Vector3(); +const scratchB = new Vector3(); + +/** Two-bone IK: the knee that keeps both bones at length, bent towards `bend`. */ +function solveKnee(hip: Point, ankle: Point, upper: number, lower: number, bend: Point): Point { + let dx = ankle.x - hip.x; + let dy = ankle.y - hip.y; + let dz = ankle.z - hip.z; + let distance = Math.hypot(dx, dy, dz); + if (distance < 1e-7) { + dx = 0; + dy = -1; + dz = 0; + distance = 1; + } else { + dx /= distance; + dy /= distance; + dz /= distance; + } + const reach = Math.max(Math.abs(upper - lower) + 1e-6, Math.min(upper + lower - 1e-6, distance)); + const along = (upper * upper - lower * lower + reach * reach) / (2 * reach); + const across = Math.sqrt(Math.max(0, upper * upper - along * along)); + const dot = bend.x * dx + bend.y * dy + bend.z * dz; + let bx = bend.x - dx * dot; + let by = bend.y - dy * dot; + let bz = bend.z - dz * dot; + const length = Math.hypot(bx, by, bz) || 1; + bx /= length; + by /= length; + bz /= length; + return { + x: hip.x + dx * along + bx * across, + y: hip.y + dy * along + by * across, + z: hip.z + dz * along + bz * across, + }; +} + +export class ExplorerModel { + readonly root = new Group(); + readonly #torso = new Group(); + readonly #head = new Group(); + readonly #arms: ILimb[] = []; + readonly #legs: ILimb[] = []; + readonly #sphere = new SphereGeometry(1, 20, 14); + + constructor(materials: SnowMaterials) { + const m = materials; + const torso = this.#torso; + const head = this.#head; + this.root.add(torso); + torso.add(head); + const blob = (material: Material, p: Point, s: Point, parent: Group = this.root) => { + const mesh = new Mesh(this.#sphere, material); + mesh.position.set(p.x, p.y, p.z); + mesh.scale.set(s.x, s.y, s.z); + mesh.castShadow = true; + mesh.receiveShadow = true; + parent.add(mesh); + return mesh; + }; + const box = (material: Material, p: Point, s: Point, parent: Group) => { + const mesh = new Mesh(new BoxGeometry(s.x, s.y, s.z), material); + mesh.position.set(p.x, p.y, p.z); + mesh.castShadow = true; + parent.add(mesh); + return mesh; + }; + const tube = (material: Material, points: number[][], radius: number, parent: Group) => { + const curve = new CatmullRomCurve3(points.map(([x, y, z]) => new Vector3(x, y, z))); + const mesh = new Mesh(new TubeGeometry(curve, 16, radius, 6, false), material); + mesh.castShadow = true; + parent.add(mesh); + }; + const v = (x: number, y: number, z: number): Point => ({ x, y, z }); + + // Quilted coat, waist and hips. + blob(m.coat, v(0, 1.29, 0), v(0.292, 0.365, 0.188), torso); + blob(m.coat, v(0, 1.045, 0), v(0.29, 0.13, 0.193), torso); + blob(m.pants, v(0, 0.95, 0), v(0.238, 0.135, 0.145)); + for (let quilt = 0; quilt < 5; quilt += 1) { + const y = 1.07 + quilt * 0.11; + const profile = Math.sqrt(Math.max(0.3, 1 - ((y - 1.29) / 0.41) ** 2)); + const seam = new Mesh(new TorusGeometry(1, 0.011, 4, 36), m.seam); + seam.rotation.x = Math.PI / 2; + seam.scale.set(0.297 * profile, 0.192 * profile, 0.35); + seam.position.y = y; + torso.add(seam); + } + tube( + m.rubber, + [ + [0, 1.02, 0.196], + [0, 1.3, 0.191], + [0, 1.56, 0.143], + ], + 0.009, + torso, + ); + box(m.seam, v(-0.14, 1.4, 0.179), v(0.105, 0.12, 0.019), torso); + blob(m.seam, v(-0.225, 1.1, 0.1), v(0.067, 0.081, 0.055), torso); + blob(m.seam, v(0.225, 1.1, 0.1), v(0.067, 0.081, 0.055), torso); + blob(m.coat, v(0, 1.595, 0), v(0.205, 0.13, 0.17), torso); + + // Fur-lined hood, goggles and a dusting of snow on top. + blob(m.coat, v(0, 1.765, -0.015), v(0.229, 0.249, 0.205), head); + blob(m.skin, v(0, 1.763, 0.16), v(0.143, 0.171, 0.086), head); + blob(m.gaiter, v(0, 1.683, 0.222), v(0.146, 0.096, 0.045), head); + for (let tuft = 0; tuft < 28; tuft += 1) { + const a = (tuft / 28) * Math.PI * 2; + blob( + m.fur, + v(Math.sin(a) * 0.179, 1.766 + Math.cos(a) * 0.213, 0.17 + Math.sin(tuft * 2.3) * 0.008), + v(0.038, 0.043, 0.043), + head, + ); + } + blob(m.rubber, v(0, 1.823, 0.244), v(0.174, 0.077, 0.043), head); + blob(m.lens, v(0, 1.825, 0.274), v(0.154, 0.054, 0.027), head); + blob(m.snowCap, v(-0.015, 1.96, -0.033), v(0.158, 0.036, 0.143), head); + + // Expedition pack, bedroll and straps. + blob(m.gaiter, v(0, 1.31, -0.248), v(0.235, 0.3, 0.137), torso); + blob(m.pants, v(0, 1.49, -0.286), v(0.211, 0.107, 0.124), torso); + blob(m.coat, v(0, 1.3, -0.373), v(0.124, 0.149, 0.037), torso); + for (const side of [-1, 1]) { + tube( + m.gaiter, + [ + [side * 0.15, 1.03, 0.175], + [side * 0.197, 1.4, 0.173], + [side * 0.17, 1.61, -0.04], + [side * 0.14, 1.45, -0.32], + ], + 0.025, + torso, + ); + box(m.metal, v(side * 0.191, 1.34, 0.198), v(0.034, 0.048, 0.012), torso); + blob(m.pants, v(side * 0.222, 1.25, -0.246), v(0.075, 0.135, 0.087), torso); + } + const bedroll = new Mesh(new CylinderGeometry(0.095, 0.095, 0.43, 16), m.pants); + bedroll.rotation.z = Math.PI / 2; + bedroll.position.set(0, 1, -0.255); + bedroll.castShadow = true; + torso.add(bedroll); + blob(m.snowCap, v(0, 1.592, -0.289), v(0.172, 0.025, 0.086), torso); + + const segment = (material: Material, radius: number, length: number): ISegment => { + const mesh = new Mesh( + new CapsuleGeometry(radius, Math.max(0.02, length - radius * 2), 6, 12), + material, + ); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.root.add(mesh); + return { length, mesh }; + }; + for (const side of [-1, 1]) { + const hand = blob(m.gaiter, v(0, 0, 0), v(0.069, 0.09, 0.065)); + this.#arms.push({ + end: hand, + lower: segment(m.coat, 0.084, 0.3), + side, + upper: segment(m.coat, 0.105, 0.32), + }); + const boot = new Group(); + this.root.add(boot); + blob(m.rubber, v(0, -0.09, 0), v(0.145, 0.047, 0.259), boot); + blob(m.boot, v(0, -0.025, 0.048), v(0.138, 0.086, 0.202), boot); + blob(m.boot, v(0, 0.038, -0.09), v(0.118, 0.145, 0.142), boot); + blob(m.rubber, v(0, -0.015, 0.183), v(0.132, 0.064, 0.073), boot); + const cuff = new Mesh(new CylinderGeometry(0.095, 0.117, 0.18, 16), m.gaiter); + cuff.position.set(0, 0.143, -0.066); + cuff.castShadow = true; + boot.add(cuff); + blob(m.snowCap, v(0.02, 0.055, 0.16), v(0.074, 0.015, 0.046), boot); + this.#legs.push({ + end: boot, + lower: segment(m.pants, 0.092, 0.475), + side, + upper: segment(m.pants, 0.113, 0.475), + }); + } + // The torso, hood and boots never bend internally, so each becomes one mesh per material: + // dozens of primitives as separate draws cost more than the whole snowfield. + for (const group of [torso, head, ...this.#legs.map((leg) => leg.end as Group)]) + mergeRigid(group); + } + + #place(segment: ISegment, from: Point, to: Point): void { + scratchA.set(from.x, from.y, from.z); + scratchB.set(to.x - from.x, to.y - from.y, to.z - from.z); + const length = scratchB.length(); + segment.mesh.position.copy(scratchA).addScaledVector(scratchB, 0.5); + segment.mesh.quaternion.setFromUnitVectors(UP, scratchB.normalize()); + segment.mesh.scale.y = length / segment.length; + } + + /** + * Pose the body at `position` facing `heading`, with each boot where the gait put it. + * `cycle` drives the arm swing and torso sway; `speed` scales them. + */ + pose( + position: Point, + heading: number, + feet: readonly IFootPose[], + cycle: number, + speed: number, + elapsed: number, + ): void { + this.root.position.set(position.x, position.y, position.z); + this.root.rotation.y = heading; + const gait = Math.sin(cycle * Math.PI * 2); + const motion = Math.min(1, speed); + this.#torso.rotation.z = gait * 0.022 * motion; + this.#torso.rotation.x = speed * 0.022; + this.#head.rotation.y = Math.sin(elapsed * 0.45) * 0.055 * (1 - motion * 0.6); + const cos = Math.cos(heading); + const sin = Math.sin(heading); + for (const [index, leg] of this.#legs.entries()) { + const foot = feet[index]; + if (foot === undefined) continue; + const dx = foot.x - position.x; + const dz = foot.z - position.z; + const ankle = { x: dx * cos - dz * sin, y: foot.y - position.y, z: dx * sin + dz * cos }; + const hip = { x: leg.side * 0.145, y: 0.935, z: -0.014 }; + const knee = solveKnee(hip, ankle, 0.475, 0.475, { x: leg.side * 0.08, y: 0, z: 1 }); + this.#place(leg.upper, hip, knee); + this.#place(leg.lower, knee, ankle); + leg.end.position.set(ankle.x, ankle.y, ankle.z); + leg.end.rotation.set( + foot.swing ? Math.sin(foot.progress * Math.PI * 2) * 0.2 : 0, + foot.angle - heading, + 0, + ); + } + for (const arm of this.#arms) { + const swing = gait * arm.side * motion; + const shoulder = { x: arm.side * 0.287, y: 1.49, z: 0 }; + const elbow = { x: arm.side * 0.376, y: 1.215, z: swing * 0.13 - 0.035 }; + const hand = { + x: arm.side * 0.397, + y: 0.976 + Math.abs(swing) * 0.027, + z: swing * 0.23 + 0.055, + }; + this.#place(arm.upper, shoulder, elbow); + this.#place(arm.lower, elbow, hand); + arm.end.position.set(hand.x, hand.y - 0.031, hand.z); + } + } +} diff --git a/packages/create-threenative/templates/snow/src/render/lighting.ts b/packages/create-threenative/templates/snow/src/render/lighting.ts new file mode 100644 index 0000000000..06e90c178d --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/lighting.ts @@ -0,0 +1,50 @@ +// Generated for you. This is ordinary Three.js; tune the sun and fill for your snow. +import { DirectionalLight, HemisphereLight, PCFSoftShadowMap, type Scene } from "three"; +import { palette } from "./palette.js"; + +type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; + +export interface ISnowLights { + /** Follow `target` with the shadow frustum and dim everything for a storm. */ + update(target: { readonly x: number; readonly z: number }, storm: number): void; + readonly sun: DirectionalLight; +} + +export function setupLighting(scene: Scene, renderer: ShadowRenderer): ISnowLights { + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = PCFSoftShadowMap; + // Bright sky above, a cool grey bounce from the snow below. + const sky = new HemisphereLight(0xdbeeff, 0x7d8e9c, 2.2); + scene.add(sky); + + // A low warm sun, so footprints throw shadows into their own floors. + const sun = new DirectionalLight(0xffead1, 3.3); + sun.position.set(-22, 30, -20); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.left = -19; + sun.shadow.camera.right = 19; + sun.shadow.camera.top = 19; + sun.shadow.camera.bottom = -19; + sun.shadow.camera.near = 0.1; + sun.shadow.camera.far = 100; + sun.shadow.bias = -0.0002; + sun.shadow.normalBias = 0.028; + scene.add(sun, sun.target); + + // A cool fill from the camera side keeps the shadowed faces of the forest from going black. + const fill = new DirectionalLight(palette.skyLow, 0.45); + fill.position.set(8, 6, 15); + scene.add(fill); + + return { + sun, + update(target, storm) { + sun.position.set(target.x - 22, 30, target.z - 20); + sun.target.position.set(target.x, 0, target.z); + sun.target.updateMatrixWorld(); + sun.intensity = 3.3 - storm * 2.9; + sky.intensity = 2.2 - storm * 0.55; + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/loading.ts b/packages/create-threenative/templates/snow/src/render/loading.ts new file mode 100644 index 0000000000..e84e2fc64f --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/loading.ts @@ -0,0 +1,325 @@ +import { + type Camera, + CanvasTexture, + ClampToEdgeWrapping, + Mesh, + MeshBasicMaterial, + type OrthographicCamera, + PlaneGeometry, + type Scene, + type Texture, +} from "three"; +import { palette } from "./palette.js"; + +/* BEGIN THREENATIVE LOADING APPEARANCE */ +/** Sailing keeps its own sea composition; all behavior is authored here. */ +export const loading = { + backgroundColor: palette.skyLow, + backgroundImage: undefined as string | undefined, + enabled: true, + fillImage: undefined as string | undefined, + logoImage: undefined as string | undefined, + progressColor: palette.accent, + showStatus: false, + trackColor: palette.skyHigh, + bar: { anchorX: 0.5, anchorY: 0.7, height: 9, maxWidth: 560, minWidth: 1, width: 0.64 }, +} as const; +/* END THREENATIVE LOADING APPEARANCE */ + +interface ILoadingHost { + readonly assets?: { texture(path: string): Promise }; + readonly camera: Camera; + readonly canvasLayer: { + readonly camera: OrthographicCamera; + readonly scene: Scene; + opaque: boolean; + }; + readonly renderer: { compileAsync(scene: Scene, camera: Camera): Promise }; + readonly scene: Scene; + readonly startup: { + readonly progress: number; + readonly warmup?: { readonly status: "complete" | "incomplete" | "unavailable" }; + whenReady(): Promise; + }; + readonly viewport?: { + readonly safeArea: { height: number; width: number; x: number; y: number }; + }; +} + +interface ILoadingController { + update(): void; + finish(): void; +} + +function noOp(layer: ILoadingHost["canvasLayer"]): ILoadingController { + layer.opaque = false; + return { finish: () => undefined, update: () => undefined }; +} + +function meshFor( + layer: ILoadingHost["canvasLayer"], + material: MeshBasicMaterial, + renderOrder: number, +): Mesh { + const mesh = new Mesh(new PlaneGeometry(1, 1), material); + mesh.frustumCulled = false; + mesh.renderOrder = renderOrder; + layer.scene.add(mesh); + return mesh; +} + +function imageAspect(texture: Texture | null | undefined): number { + const image = texture?.image as { height?: number; width?: number } | undefined; + const width = image?.width ?? 1; + const height = image?.height ?? 1; + return width > 0 && height > 0 ? width / height : 1; +} + +function configureTexture(texture: Texture): void { + texture.wrapS = ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + texture.needsUpdate = true; +} + +function setFillUv(geometry: PlaneGeometry, progress: number, base: readonly number[]): void { + const uv = geometry.getAttribute("uv"); + for (let index = 0; index < uv.count; index += 1) uv.setX(index, (base[index] ?? 0) * progress); + uv.needsUpdate = true; +} + +function coverUv( + geometry: PlaneGeometry, + texture: Texture, + width: number, + height: number, + baseU: readonly number[], + baseV: readonly number[], +): void { + const imageRatio = imageAspect(texture); + const boxRatio = width / Math.max(1, height); + const visibleU = imageRatio > boxRatio ? boxRatio / imageRatio : 1; + const visibleV = imageRatio < boxRatio ? imageRatio / boxRatio : 1; + const offsetU = (1 - visibleU) / 2; + const offsetV = (1 - visibleV) / 2; + const uv = geometry.getAttribute("uv"); + for (let index = 0; index < uv.count; index += 1) { + uv.setX(index, offsetU + (baseU[index] ?? 0) * visibleU); + uv.setY(index, offsetV + (baseV[index] ?? 0) * visibleV); + } + uv.needsUpdate = true; +} + +function statusMesh( + layer: ILoadingHost["canvasLayer"], +): + | { mesh: Mesh; texture: Texture; update(value: number): void } + | undefined { + if (!loading.showStatus || typeof document === "undefined") return undefined; + const canvas = document.createElement("canvas"); + canvas.width = 180; + canvas.height = 48; + const context = canvas.getContext("2d"); + if (context === null) return undefined; + const texture = new CanvasTexture(canvas); + const mesh = meshFor( + layer, + new MeshBasicMaterial({ depthTest: false, depthWrite: false, map: texture, transparent: true }), + 4, + ); + const update = (value: number): void => { + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#ffffff"; + context.font = "700 20px monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(`${Math.round(value * 100)}%`, canvas.width / 2, canvas.height / 2); + texture.needsUpdate = true; + }; + update(0); + return { mesh, texture, update }; +} + +export function createLoadingScreen(host: ILoadingHost): ILoadingController { + const layer = host.canvasLayer; + if (!loading.enabled) return noOp(layer); + const camera = layer.camera; + const backdrop = meshFor( + layer, + new MeshBasicMaterial({ color: loading.backgroundColor, depthTest: false, depthWrite: false }), + 0, + ); + const track = meshFor( + layer, + new MeshBasicMaterial({ color: loading.trackColor, depthTest: false, depthWrite: false }), + 1, + ); + const fill = meshFor( + layer, + new MeshBasicMaterial({ color: loading.progressColor, depthTest: false, depthWrite: false }), + 2, + ); + const fillBaseU = Array.from({ length: fill.geometry.getAttribute("uv").count }, (_, index) => + fill.geometry.getAttribute("uv").getX(index), + ); + const fillBaseV = Array.from({ length: fill.geometry.getAttribute("uv").count }, (_, index) => + fill.geometry.getAttribute("uv").getY(index), + ); + const logo = + loading.logoImage === undefined + ? undefined + : meshFor( + layer, + new MeshBasicMaterial({ + color: 0xffffff, + depthTest: false, + depthWrite: false, + transparent: true, + }), + 3, + ); + const status = statusMesh(layer); + const parts = [ + backdrop, + track, + fill, + ...(logo === undefined ? [] : [logo]), + ...(status === undefined ? [] : [status.mesh]), + ]; + const ownedTextures = new Set(status === undefined ? [] : [status.texture]); + let width = 1; + let height = 1; + let barWidth = 1; + let barHeight: number = loading.bar.height; + let barX = 0; + let barY = 0; + let progress = 0; + let done = false; + let backdropTexture: Texture | undefined; + + const layout = (): void => { + width = Math.max(1, camera.right - camera.left); + height = Math.max(1, camera.top - camera.bottom); + const safe = host.viewport?.safeArea ?? { height, width, x: 0, y: 0 }; + const safeWidth = Math.max(1, Math.min(width, safe.width)); + const safeHeight = Math.max(1, Math.min(height, safe.height)); + const safeX = Math.max(0, Math.min(width - safeWidth, safe.x)); + const safeY = Math.max(0, Math.min(height - safeHeight, safe.y)); + barWidth = Math.max(2, Math.min(loading.bar.maxWidth, safeWidth * loading.bar.width)); + barHeight = Math.max(2, Math.min(loading.bar.height, safeHeight * 0.05)); + barX = safeX + safeWidth * loading.bar.anchorX; + barY = safeY + safeHeight * loading.bar.anchorY; + const worldX = (screenX: number): number => camera.left + screenX; + const worldY = (screenY: number): number => camera.top - screenY; + const visibleWidth = Math.max(loading.bar.minWidth, barWidth * progress); + backdrop.scale.set(width, height, 1); + backdrop.position.set(worldX(width / 2), worldY(height / 2), 0); + track.scale.set(barWidth, barHeight, 1); + track.position.set(worldX(barX), worldY(barY), 0); + fill.scale.set(visibleWidth, barHeight, 1); + fill.position.set(worldX(barX - barWidth / 2 + visibleWidth / 2), worldY(barY), 0); + if (logo?.visible) { + const logoWidth = Math.min(safeWidth * 0.42, 280); + logo.scale.set( + logoWidth, + Math.min(logoWidth / imageAspect(logo.material.map), safeHeight * 0.22), + 1, + ); + logo.position.set(worldX(barX), worldY(Math.max(safeY, barY - safeHeight * 0.2)), 0); + } + if (status !== undefined) { + status.mesh.scale.set(Math.min(180, safeWidth * 0.32), 48, 1); + status.mesh.position.set(worldX(barX), worldY(Math.min(height, barY + barHeight * 2.5)), 0); + } + if (backdropTexture !== undefined) + coverUv(backdrop.geometry, backdropTexture, width, height, fillBaseU, fillBaseV); + }; + + const updateProgress = (value: number): void => { + const bounded = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; + // A missing or incomplete backend observation cannot prove that all first-use work is done. + // Keep the bar visibly indeterminate until readiness removes the cover; never turn unknown + // coverage into a false 100% claim. + const observationUnknown = + host.startup.warmup !== undefined && host.startup.warmup.status !== "complete"; + progress = observationUnknown ? Math.min(0.9, bounded) : bounded; + setFillUv(fill.geometry, progress, fillBaseU); + status?.update(progress); + layout(); + }; + + const attachTexture = (mesh: Mesh, texture: Texture): void => { + if (done) return; + configureTexture(texture); + mesh.material.map = texture; + mesh.material.color.set(0xffffff); + mesh.material.needsUpdate = true; + ownedTextures.add(texture); + if (mesh === backdrop) backdropTexture = texture; + layout(); + updateProgress(progress); + }; + + const load = (source: string | undefined, mesh: Mesh): void => { + if (source === undefined || host.assets === undefined) return; + void host.assets + .texture(source) + .then((texture) => attachTexture(mesh, texture)) + .catch(() => undefined); + }; + load(loading.backgroundImage, backdrop); + load(loading.fillImage, fill); + if (logo !== undefined && loading.logoImage !== undefined && host.assets !== undefined) { + void host.assets + .texture(loading.logoImage) + .then((texture) => { + if (done) return; + configureTexture(texture); + logo.material.map = texture; + logo.material.needsUpdate = true; + logo.visible = true; + ownedTextures.add(texture); + layout(); + }) + .catch(() => undefined); + } + + layer.opaque = true; + layout(); + updateProgress(0); + + const finish = (): void => { + if (done) return; + done = true; + for (const mesh of parts) { + mesh.removeFromParent(); + mesh.geometry.dispose(); + mesh.material.dispose(); + } + for (const texture of ownedTextures) texture.dispose(); + layer.opaque = false; + }; + + void (async () => { + await host.startup.whenReady(); + if (done) return; + // Prewarm the world's shaders, but do not wait for them here. `whenReady()` already means the + // world is safe to show, and compiling a lit, post-processed scene takes far longer than the + // launch window: hold the screen for it and a playtest — which advances a fixed step as fast + // as the machine allows — runs an entire scenario behind the launch screen, so every capture + // photographs the loading bar instead of the game. The prewarm still happens; it no longer + // decides when the player sees the world. + try { + void host.renderer.compileAsync(host.scene, host.camera).catch(() => undefined); + } catch { + // A renderer without compileAsync is not a reason to keep the world hidden. + } + finish(); + })(); + + return { + finish, + update(): void { + if (!done) updateProgress(host.startup.progress); + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/materials.ts b/packages/create-threenative/templates/snow/src/render/materials.ts new file mode 100644 index 0000000000..0e3ebfe618 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/materials.ts @@ -0,0 +1,36 @@ +// Generated for you. This file owns every surface the snow kit draws apart from the snow itself +// (`snowSurface.ts`): change the forest, the explorer's kit or the test bodies entirely from here. +import { LineBasicMaterial, MeshStandardMaterial } from "three"; +import { palette } from "./palette.js"; + +const matte = (color: number, roughness = 0.92, metalness = 0) => + new MeshStandardMaterial({ color, metalness, roughness }); + +export function createMaterials() { + return { + trunk: matte(0x5c514a, 1), + needles: matte(palette.floor, 0.94), + /** Snow lying on boughs and rock tops: a touch greyer than the field so it reads as separate. */ + bough: matte(0xe2edf3, 0.91), + rock: matte(0x717d86, 0.94), + stem: new LineBasicMaterial({ color: 0x877f70, opacity: 0.7, transparent: true }), + ridge: new MeshStandardMaterial({ roughness: 1, vertexColors: true }), + coat: matte(palette.accent, 0.86), + seam: matte(0x995124, 0.98), + pants: matte(0x35454d, 0.94), + gaiter: matte(0x25373e, 0.9), + boot: matte(0x66523c, 0.91), + rubber: matte(0x25313a, 0.97), + fur: matte(0xdad7c8, 1), + skin: matte(0xbc947c, 0.85), + lens: matte(0x263d47, 0.13, 0.5), + metal: matte(0xb0b8b5, 0.34, 0.6), + snowCap: matte(0xe9f1f5, 0.92), + /** The ball: a slick, dark sphere, so its track reads against white powder. */ + ball: matte(0x2b4352, 0.35, 0.1), + crate: matte(0x8a6a4a, 0.82), + log: matte(0x6e5b48, 0.9), + }; +} + +export type SnowMaterials = ReturnType; diff --git a/packages/create-threenative/templates/snow/src/render/palette.ts b/packages/create-threenative/templates/snow/src/render/palette.ts new file mode 100644 index 0000000000..c080935f02 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/palette.ts @@ -0,0 +1,17 @@ +// Generated for you. These six roles are the snow kit's editable visual vocabulary. +// A bright overcast glade: the snow is the lightest value in the frame, the sky one step darker +// and bluer above it, so the horizon still reads. The explorer's coat is the only warm colour. +export const palette = { + /** Zenith. */ + skyHigh: 0x5a86a4, + /** Horizon haze and the fog colour. */ + skyLow: 0xc3d6e3, + /** Untouched powder. */ + snow: 0xf6f8fc, + /** Pressed and shadowed snow: the blue a footprint's floor takes. */ + shadow: 0x8aa6c2, + /** The explorer's coat — the one saturated colour, so the eye finds the character first. */ + accent: 0xc77337, + /** Conifer needles and the darkest value in the scene. */ + floor: 0x294744, +} as const; diff --git a/packages/create-threenative/templates/snow/src/render/postprocessing.ts b/packages/create-threenative/templates/snow/src/render/postprocessing.ts new file mode 100644 index 0000000000..86e3ab96cb --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/postprocessing.ts @@ -0,0 +1,44 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. +// +// This file wires two things together and decides nothing itself. `quality.ts`, in this folder, +// owns which stages run at which tier and records what each one measured. `WorldEnvironment`, +// also in this folder, builds them and prints `TN_WORLD_ENVIRONMENT` naming every stage as +// applied or refused **with a reason**, so a stage that silently no-op'd is never mistaken for +// one you turned off. +// +// To make the game cheaper or prettier everywhere, edit `quality.ts`. To force one tier for one +// run — a desktop that is dropping frames, a capture you want to compare — pass it: +// `setupPost(renderer, scene, camera, { tier: "low" })`. Overriding does not silence the report: +// `TN_QUALITY_TIER` names the tier that ran either way. +import type { Camera, DirectionalLight, Scene } from "three"; +import { type QualityTier, qualityPreset, resolveQualityTier } from "./quality.js"; +import type { OutputRenderer } from "./worldEnvironment.js"; +import { WorldEnvironment } from "./worldEnvironment.js"; + +export function setupPost( + renderer: OutputRenderer, + scene: Scene, + camera: Camera, + environment: { + godraysLight?: DirectionalLight; + mobile?: boolean; + /** The renderer named a software adapter; this game's answer is its `low` tier. */ + software?: boolean; + /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ + tier?: QualityTier; + } = {}, +): void { + const tier = resolveQualityTier({ + mobile: environment.mobile, + software: environment.software, + tier: environment.tier, + }); + const source = environment.tier === undefined ? "platform" : "override"; + console.info( + `TN_QUALITY_TIER ${tier} mobile=${environment.mobile === true} software=${ + environment.software === true + } source=${source}`, + ); + const world = new WorldEnvironment(qualityPreset(tier)); + world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); +} diff --git a/packages/create-threenative/templates/snow/src/render/props.ts b/packages/create-threenative/templates/snow/src/render/props.ts new file mode 100644 index 0000000000..0489060528 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/props.ts @@ -0,0 +1,42 @@ +// Generated for you. How the test bodies look. Their size comes from their collision shapes in +// `src/scenes/Snow.ts`; change the look here without touching the physics. +import { BoxGeometry, CapsuleGeometry, Group, Mesh, SphereGeometry, TorusGeometry } from "three"; +import type { SnowMaterials } from "./materials.js"; + +function shadowed(mesh: T): T { + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +/** A dark ball with a pale band, so its roll is visible as it carves. */ +export function createBall(materials: SnowMaterials, radius: number): Group { + const ball = new Group(); + ball.add(shadowed(new Mesh(new SphereGeometry(radius, 32, 20), materials.ball))); + ball.add( + shadowed(new Mesh(new TorusGeometry(radius * 0.995, radius * 0.08, 8, 48), materials.bough)), + ); + return ball; +} + +export function createCrate( + materials: SnowMaterials, + size: { readonly x: number; readonly y: number; readonly z: number }, +): Group { + const crate = new Group(); + crate.add(shadowed(new Mesh(new BoxGeometry(size.x, size.y, size.z), materials.crate))); + // A drift of snow on the lid. + const lid = shadowed( + new Mesh(new BoxGeometry(size.x * 0.92, 0.03, size.z * 0.9), materials.bough), + ); + lid.position.y = size.y / 2 + 0.012; + crate.add(lid); + return crate; +} + +/** A felled log: a capsule lying along its spine. */ +export function createLog(materials: SnowMaterials, halfHeight: number, radius: number): Group { + const log = new Group(); + log.add(shadowed(new Mesh(new CapsuleGeometry(radius, halfHeight * 2, 6, 16), materials.log))); + return log; +} diff --git a/packages/create-threenative/templates/snow/src/render/quality.ts b/packages/create-threenative/templates/snow/src/render/quality.ts new file mode 100644 index 0000000000..86149f9415 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/quality.ts @@ -0,0 +1,135 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. Delete or rewrite +// it freely — the tiers below are a starting point, not a framework look. +// +// This is the one place this game decides how expensive it looks. `postprocessing.ts` reads +// `qualityPreset(resolveQualityTier(...))` and nothing else, so "make it run on a phone" is one +// file to edit rather than a hunt through anonymous literals. +// +// **Where the numbers come from.** Every millisecond below is GPU time from the per-stage +// ablation recorded in the engine repository's `docs/verification/runtime-perf-state.md`: Chrome +// on an RTX 2080, 1600x900, static build, `gpuMs` read from three's `timestamp-query`. In that +// scene the whole five-stage chain costs **12.5 ms of a 14.7 ms GPU frame**, and the same frame +// with every stage off costs **2.2 ms**. The per-stage figures oversum — removing SSGI also +// removes the denoise passes the later stages sample — so read each as *what turning this one off +// gave back*, not as a share of a partition. A stage nobody has ablated on its own says +// `unmeasured` rather than guessing, and your scene is not that scene: read `TN_FRAME_BUDGET` +// back after you change a tier. +// +// One cost that is **not** a stage here and outweighs most of them: the prefiltered reflection +// probe on `scene.environment`, measured at **~6.3 ms of an 18-19 ms Pixel 8 frame**. It is set +// in `sky.ts`, not in this file. +// +// **This template runs neither SSGI nor SSR at any tier.** Its frame cost is the snow — a dense +// deforming heightfield mesh and tens of thousands of flakes — not the post chain, and snow is +// already the brightest thing on screen, so the chain is tonemapping alone at every tier and the +// sun glints come from the snow material. +import type { IWorldEnvironmentOptions } from "./worldEnvironment.js"; + +/** + * The three names this game's look comes in. + * + * `low` is what a phone gets and `high` what a desktop gets — those two are this template's + * shipped looks, unchanged. `medium` is the rung in between for a machine that is neither: a + * laptop iGPU, a handheld, a desktop that is dropping frames. Nothing outside this file decides + * what any of them mean. + */ +export type QualityTier = "low" | "medium" | "high"; + +const QUALITY_TIERS: readonly QualityTier[] = ["low", "medium", "high"]; + +/** + * Narrows an arbitrary string — a URL parameter, a saved setting — to a tier name. Not exported: + * `resolveQualityTier` is the one door in, so an unknown name cannot be waved past the throw. + */ +function isQualityTier(value: string): value is QualityTier { + return (QUALITY_TIERS as readonly string[]).includes(value); +} + +/** + * Picks the tier: an explicit `tier` always wins, otherwise the platform decides. + * + * Fails closed. An unrecognised tier name throws with the value it was handed rather than + * quietly rendering the default, because a silent fallback here looks exactly like a tier that + * turned out to have no effect. + */ +export function resolveQualityTier( + request: { readonly mobile?: boolean; readonly software?: boolean; readonly tier?: string } = {}, +): QualityTier { + const requested = request.tier; + if (requested !== undefined) { + if (!isQualityTier(requested)) { + throw new Error( + `Unknown quality tier ${JSON.stringify(requested)} — expected one of ${QUALITY_TIERS.join(", ")}.`, + ); + } + return requested; + } + // A named software adapter — SwiftShader, llvmpipe, a basic-render driver — is the machine this + // game's desktop look cannot run on: a single `high` frame on one can outlast the device it is + // drawing on, and no adaptation that reacts to frame times gets to run first. `software` is the + // fact the renderer read from `adapter.info`, not a guess from a driver string, and an explicit + // `tier` above still wins over it. + if (request.software === true) return "low"; + return request.mobile === true ? "low" : "high"; +} + +/** + * What a desktop gets: this template's shipped desktop look, unchanged. + */ +const high: IWorldEnvironmentOptions = { + // Strength, radius and threshold are a look decision already tuned to this scene's palette. + // Bloom: ~4.6 ms — the second most expensive stage in the chain, and the one nobody expects + // to be. + // Off. Snow is the brightest thing on screen, and at the default 0.2 threshold every lit + // pixel blooms and the glade sinks into a white haze. A 0.92 threshold kept only the glints on + // the web but left the native desktop frame blank behind the HUD (Dawn/Vulkan, 2026-10-01), so + // the glints live in the snow material's emissive term instead. Turn it back on here. + bloomEnabled: false, + // No SSGI runs here, so there is nothing for the denoiser to clean up. Off, explicitly. + denoiseEnabled: false, + exposure: 0.95, + // Off at every tier — see the note at the top of this file. + ssgiEnabled: false, + // Off at every tier — see the note at the top of this file. + ssrEnabled: false, + tonemapMode: "aces", +}; + +/** + * The rung in between. With no screen-space stage and no bloom at any tier, the post chain is the + * same everywhere; the tier only reaches the measured chain as `renderChainTier`. What a phone + * saves is decided in `src/scenes/Snow.ts` (fewer flakes), and the snow mesh resolution in + * `src/terrain.ts` is where the frame actually goes. + */ +const medium: IWorldEnvironmentOptions = { ...high, renderChainTier: "medium" }; + +/** + * What a phone gets: this template's shipped mobile look, unchanged. + */ +const low: IWorldEnvironmentOptions = { + // Strength, radius and threshold are a look decision already tuned to this scene's palette. + // Bloom: ~4.6 ms — the second most expensive stage in the chain, and the one nobody expects + // to be. + // Off, as at the high tier. + bloomEnabled: false, + exposure: 0.95, + // Off at every tier — see the note at the top of this file. + ssgiEnabled: false, + // Off at every tier — see the note at the top of this file. + ssrEnabled: false, + renderChainTier: "low", + tonemapMode: "aces", +}; + +const QUALITY_PRESETS: Record = { high, low, medium }; + +/** The stages and strengths a tier turns on. Throws on a name that is not a tier. */ +export function qualityPreset(tier: string): IWorldEnvironmentOptions { + const preset = QUALITY_PRESETS[tier as QualityTier]; + if (preset === undefined) { + throw new Error( + `Unknown quality tier ${JSON.stringify(tier)} — expected one of ${QUALITY_TIERS.join(", ")}.`, + ); + } + return preset; +} diff --git a/packages/create-threenative/templates/snow/src/render/scenery.ts b/packages/create-threenative/templates/snow/src/render/scenery.ts new file mode 100644 index 0000000000..85be4b9685 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/scenery.ts @@ -0,0 +1,244 @@ +// Generated for you. The forest, rocks, grass stems and the far ridge are this game's look. +// Everything here is ordinary Three.js built from code; nothing is loaded. +import { + BufferGeometry, + Color, + ConeGeometry, + CylinderGeometry, + Float32BufferAttribute, + Group, + InstancedMesh, + LineSegments, + Mesh, + Object3D, + PlaneGeometry, + SphereGeometry, + Vector3, +} from "three"; +import { terrainHeight } from "../terrain.js"; +import type { SnowMaterials } from "./materials.js"; + +/** A round obstacle the explorer walks around: centre and radius on the ground plane. */ +export interface IObstacle { + readonly x: number; + readonly z: number; + readonly radius: number; + readonly height: number; +} + +function seededRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +const smoothstep = (edge0: number, edge1: number, value: number) => { + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +}; + +/** Glade-edge pines by hand, then a ring of forest further out. `[x, z, height]`. */ +function treePlacements(random: () => number): Array<[number, number, number]> { + const trees: Array<[number, number, number]> = [ + [-9, -6, 6.3], + [9, -5, 7.5], + [-11.5, 4, 8.2], + [10.5, 8, 6.4], + [-7, -14, 7.8], + [4, -17, 9], + [-17, -4, 6], + [16, -9, 9], + ]; + for (let index = 0; index < 80; index += 1) { + const angle = random() * Math.PI * 2; + const radius = 20 + random() * 52; + trees.push([Math.sin(angle) * radius, Math.cos(angle) * radius, 4.5 + random() * 6.7]); + } + return trees; +} + +/** Pines within this radius cast shadows; the shadow map only covers the glade anyway. */ +const SHADOW_RADIUS = 26; + +function forest(materials: SnowMaterials, random: () => number, obstacles: IObstacle[]): Group { + const placements = treePlacements(random); + const levels = 15; + const trunkGeometry = new CylinderGeometry(0.07, 0.14, 1, 9); + const branchGeometry = new ConeGeometry(1, 1, 7, 1); + const boughGeometry = new SphereGeometry(1, 6, 4); + // Two batches: the trees round the glade cast shadows, the far forest does not. One batch of + // every bough would put the whole forest — most of a million triangles — into the shadow pass. + const batches = [true, false].map((near) => { + const count = placements.filter(([x, z]) => Math.hypot(x, z) < SHADOW_RADIUS === near).length; + const capacity = count * levels * 6; + return { + boughs: new InstancedMesh(boughGeometry, materials.bough, capacity), + branches: new InstancedMesh(branchGeometry, materials.needles, capacity), + count: 0, + near, + trees: 0, + trunks: new InstancedMesh(trunkGeometry, materials.trunk, count), + }; + }); + const dummy = new Object3D(); + const up = new Vector3(0, 1, 0); + const direction = new Vector3(); + placements.forEach(([x, z, height], tree) => { + const batch = batches[Math.hypot(x, z) < SHADOW_RADIUS ? 0 : 1]; + if (batch === undefined) return; + const root = terrainHeight(x, z); + const scale = height / 7; + dummy.position.set(x, root + height * 0.48, z); + dummy.rotation.set(0, 0, 0); + dummy.scale.set(scale, height, scale); + dummy.updateMatrix(); + batch.trunks.setMatrixAt(batch.trees, dummy.matrix); + batch.trees += 1; + if (Math.hypot(x, z) < 20) obstacles.push({ height, radius: 0.23 * scale, x, z }); + for (let level = 0; level < levels; level += 1) { + const t = level / levels; + const y = root + 0.75 + t * (height - 0.65); + const radius = (1 - t) * height * 0.23 + 0.055; + // A far tree keeps every tier, so its silhouette holds, with fewer and wider boughs per tier. + const around = level > 12 || !batch.near ? 4 : 6; + for (let index = 0; index < around; index += 1) { + const angle = (index / around) * Math.PI * 2 + level * 0.72 + tree * 1.37; + const reach = radius * (0.8 + random() * 0.3) * (batch.near ? 1 : 1.2); + const dx = Math.cos(angle); + const dz = Math.sin(angle); + dummy.position.set(x + dx * reach * 0.53, y - 0.06, z + dz * reach * 0.53); + dummy.quaternion.setFromUnitVectors(up, direction.set(dx, -0.2, dz).normalize()); + dummy.scale.set(reach * 0.27, reach * 1.5, reach * 0.3); + dummy.updateMatrix(); + batch.branches.setMatrixAt(batch.count, dummy.matrix); + dummy.position.set(x + dx * reach * 0.54, y + 0.11 * scale, z + dz * reach * 0.54); + dummy.rotation.set(0, -angle, 0); + dummy.scale.set(reach * 0.78, 0.13 * scale + reach * 0.09, reach * 0.32); + dummy.updateMatrix(); + batch.boughs.setMatrixAt(batch.count, dummy.matrix); + batch.count += 1; + } + } + }); + const group = new Group(); + for (const batch of batches) { + batch.branches.count = batch.count; + batch.boughs.count = batch.count; + for (const mesh of [batch.trunks, batch.branches, batch.boughs]) { + mesh.castShadow = batch.near; + mesh.receiveShadow = true; + mesh.computeBoundingSphere(); + group.add(mesh); + } + } + return group; +} + +const ROCKS: ReadonlyArray<[number, number, number]> = [ + [-5, 7, 1.15], + [-6.7, 7.8, 0.55], + [7, -8, 1.25], + [7.8, -7, 0.45], + [10, 1, 1.4], + [-10, -3, 0.85], + [-3, -10.5, 0.6], + [3, 10, 0.55], +]; + +function rocks(materials: SnowMaterials, obstacles: IObstacle[]): Group { + const group = new Group(); + ROCKS.forEach(([x, z, radius], index) => { + const geometry = new SphereGeometry(1, 15, 11); + const position = geometry.getAttribute("position"); + for (let vertex = 0; vertex < position.count; vertex += 1) { + const px = position.getX(vertex); + const py = position.getY(vertex); + const pz = position.getZ(vertex); + const bump = 1 + 0.085 * Math.sin(px * 13 + py * 8) + 0.065 * Math.sin(pz * 15 + index); + position.setXYZ(vertex, px * bump, py * bump, pz * bump); + } + geometry.computeVertexNormals(); + const y = terrainHeight(x, z) + radius * 0.25; + const rock = new Mesh(geometry, materials.rock); + rock.position.set(x, y, z); + rock.scale.set(radius, radius * 0.7, radius * 0.82); + rock.rotation.y = index * 1.7; + const cap = new Mesh(geometry, materials.bough); + cap.position.set(x, y + radius * 0.39, z); + cap.scale.set(radius * 0.98, radius * 0.36, radius * 0.84); + cap.rotation.y = rock.rotation.y; + for (const mesh of [rock, cap]) { + mesh.castShadow = true; + mesh.receiveShadow = true; + group.add(mesh); + } + obstacles.push({ height: radius * 1.1, radius: radius * 0.9, x, z }); + }); + return group; +} + +/** Bare stems poking through the powder near the rocks show how deep the snow is. */ +function stems(materials: SnowMaterials, random: () => number, depth: number): LineSegments { + const points: number[] = []; + for (let index = 0; index < 140; index += 1) { + const [rx, rz, radius] = ROCKS[index % ROCKS.length] as [number, number, number]; + const angle = random() * Math.PI * 2; + const distance = radius + random() * 0.9; + const x = rx + Math.cos(angle) * distance; + const z = rz + Math.sin(angle) * distance; + const y = terrainHeight(x, z) + depth - 0.03; + const height = 0.14 + random() * 0.36; + points.push(x, y, z, x + (random() - 0.5) * 0.15, y + height, z + (random() - 0.5) * 0.15); + } + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(points, 3)); + return new LineSegments(geometry, materials.stem); +} + +/** A continuous ridgeline behind the forest rather than repeated cone mountains. */ +function ridge(materials: SnowMaterials): Mesh { + const geometry = new PlaneGeometry(240, 80, 240, 90); + geometry.rotateX(-Math.PI / 2); + geometry.translate(0, 0, -91); + const position = geometry.getAttribute("position"); + for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index); + const z = position.getZ(index); + const v = (z + 131) / 80; + const peaks = + 18 + 10 * Math.sin(x * 0.047 + 1) + 7 * Math.sin(x * 0.1 - 1) + 5 * Math.cos(x * 0.19); + const envelope = Math.sin(Math.max(0, Math.min(1, v)) * Math.PI) ** 1.65; + const ridges = + Math.abs(Math.sin(x * 0.31 + z * 0.27)) * 2.2 + Math.sin(x * 0.82 - z * 0.51) * 0.75; + position.setY(index, -1 + envelope * (peaks + ridges)); + } + geometry.computeVertexNormals(); + const normal = geometry.getAttribute("normal"); + const colors: number[] = []; + const snowy = new Color(0xdce6ed); + const rockFace = new Color(0x536571); + const colour = new Color(); + for (let index = 0; index < position.count; index += 1) { + const exposed = smoothstep(0.5, 0.88, 1 - normal.getY(index)) * 0.66; + const variation = 0.9 + 0.1 * Math.sin(position.getX(index) * 1.7 + position.getZ(index)); + colour.copy(snowy).lerp(rockFace, exposed).multiplyScalar(variation); + colors.push(colour.r, colour.g, colour.b); + } + geometry.setAttribute("color", new Float32BufferAttribute(colors, 3)); + return new Mesh(geometry, materials.ridge); +} + +/** Builds the scenery and reports the obstacles in it, so collision follows what is drawn. */ +export function createScenery( + materials: SnowMaterials, + depth: number, +): { readonly root: Group; readonly obstacles: readonly IObstacle[] } { + const random = seededRandom(224); + const obstacles: IObstacle[] = []; + const root = new Group(); + root.add(forest(materials, random, obstacles), rocks(materials, obstacles)); + root.add(stems(materials, random, depth), ridge(materials)); + return { obstacles, root }; +} diff --git a/packages/create-threenative/templates/snow/src/render/sky.ts b/packages/create-threenative/templates/snow/src/render/sky.ts new file mode 100644 index 0000000000..d3f069d332 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/sky.ts @@ -0,0 +1,57 @@ +// Generated for you. The sky is game-owned source, not an engine preset. +import { + BackSide, + BufferAttribute, + Color, + FogExp2, + Mesh, + MeshBasicMaterial, + type Scene, + SphereGeometry, +} from "three"; +import { palette } from "./palette.js"; + +const CLEAR_FOG = new Color(palette.skyLow); +const STORM_FOG = new Color(0xafc3d1); + +/** A graded dome with a warm sun glow, plus the haze; returns how to darken both for a storm. */ +export function setupSky(scene: Scene): (storm: number) => void { + const RADIUS = 450; + const geometry = new SphereGeometry(RADIUS, 40, 24); + const positions = geometry.getAttribute("position"); + const colors = new Float32Array(positions.count * 3); + const top = new Color(palette.skyHigh); + const bottom = new Color(0xd6e2ea); + const sunGlow = new Color(0xffeedd); + const sun = { x: -0.6, y: 0.57, z: -0.56 }; + const current = new Color(); + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) / RADIUS; + const y = Math.max(0, positions.getY(index) / RADIUS); + const z = positions.getZ(index) / RADIUS; + current.copy(bottom).lerp(top, y ** 0.58); + const toward = Math.max(0, x * sun.x + y * sun.y + z * sun.z); + current.lerp(sunGlow, toward ** 20 * 0.35); + colors.set([current.r, current.g, current.b], index * 3); + } + geometry.setAttribute("color", new BufferAttribute(colors, 3)); + const material = new MeshBasicMaterial({ + fog: false, + side: BackSide, + toneMapped: false, + vertexColors: true, + }); + const dome = new Mesh(geometry, material); + dome.frustumCulled = false; + dome.renderOrder = -100; + scene.background = bottom; + scene.add(dome); + const fog = new FogExp2(CLEAR_FOG.getHex(), 0.016); + scene.fog = fog; + return (storm) => { + fog.color.copy(CLEAR_FOG).lerp(STORM_FOG, storm); + // Thick enough to swallow the far forest, thin enough that the explorer stays readable. + fog.density = 0.016 + storm * 0.05; + material.color.setScalar(1 - storm * 0.3); + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/snowSurface.ts b/packages/create-threenative/templates/snow/src/render/snowSurface.ts new file mode 100644 index 0000000000..7918672b0c --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/snowSurface.ts @@ -0,0 +1,259 @@ +// Generated for you. How the snow looks: its colour, the blue in a pressed footprint, the +// compaction view, the glints. `SnowField` stores numbers; everything a screenshot shows is here. +import { BufferAttribute, type BufferGeometry, Mesh, PlaneGeometry } from "three"; +import { + attribute, + color, + dot, + float, + floor, + fwidth, + hash, + max, + mix, + normalView, + normalWorld, + positionViewDirection, + positionWorld, + pow, + smoothstep, + uniform, + vec3, +} from "three/tsl"; +import { MeshStandardNodeMaterial } from "three/webgpu"; +import { terrainHeight } from "../terrain.js"; +import { palette } from "./palette.js"; + +/** Per-vertex snow state the material reads: indentation (m) and compaction (0..1). */ +const STATE = "snowState"; +/** A window up to this many samples is redrawn at once; wider ones are sliced. */ +const URGENT_CELLS = 12_000; +/** A wide window is spread over about this many frames, never fewer than eight rows a frame. */ +const SLICE_FRAMES = 15; +const SLICE_ROWS = 8; + +type Bounds = { + readonly column: number; + readonly columns: number; + readonly row: number; + readonly rows: number; +}; + +/** The parts of `SnowField` (and its `Heightfield`) this renderer reads, by shape. */ +export interface ISnowSource { + readonly depth: number; + readonly field: { + readonly columns: number; + readonly rows: number; + readonly width: number; + readonly depth: number; + readonly origin: { readonly x: number; readonly z: number }; + toGeometry(): BufferGeometry; + refreshGeometry(geometry: BufferGeometry, bounds?: Bounds): void; + toColliderHeights(): Float32Array; + }; + takeDirtyRegion(): Bounds | undefined; + copyChannel( + channel: "indent" | "compaction", + bounds: Bounds, + target?: Float32Array, + ): Float32Array; +} + +function union(current: Bounds | undefined, next: Bounds): Bounds { + if (current === undefined) return { ...next }; + const column = Math.min(current.column, next.column); + const row = Math.min(current.row, next.row); + return { + column, + columns: Math.max(current.column + current.columns, next.column + next.columns) - column, + row, + rows: Math.max(current.row + current.rows, next.row + next.rows) - row, + }; +} + +export interface ISnowSurface { + readonly mesh: Mesh; + readonly outskirts: Mesh; + /** + * Take what the field wrote since the last call. Small windows — a footprint, a rolling ball — + * are drawn on the next frame; a window wider than `URGENT_CELLS` (snowfall refilling every + * track at once) is spread over the following frames a band of rows at a time. + */ + collect(): void; + /** Push collected windows into the mesh; call once per drawn frame. */ + refresh(): void; + /** Rows still waiting to be redrawn from a wide refill. */ + readonly pendingRows: number; + /** + * Largest gap between a rendered vertex and the canonical surface, metres, after drawing every + * window still waiting: what the next frame shows, not a frame caught mid-redraw. + */ + renderError(): number; + setCompactionView(on: boolean): void; + /** 0 clear .. 1 blizzard: dims the glints with the sun. */ + setStorm(storm: number): void; +} + +const floatUniform = () => uniform(0); +type FloatUniform = ReturnType; + +function snowMaterial(compactionView: FloatUniform, sun: FloatUniform) { + const material = new MeshStandardNodeMaterial({ metalness: 0, roughness: 0.86 }); + const state = attribute(STATE, "vec2" as const); + const indent = state.x; + const compaction = state.y; + // The hollow of a print darkens towards a cold blue; fresh powder keeps a faint grain. + const hollow = smoothstep(0.002, 0.11, indent); + const grain = hash(floor(positionWorld.xz.mul(1.8))); + const powder = color(palette.snow).mul(grain.mul(0.035).add(0.965)); + const pressed = mix(powder, color(palette.shadow), hollow.mul(0.55).add(compaction.mul(0.1))); + // Print walls face away from the light: darken by slope so a footprint reads at a glance. + const wall = mix(float(0.8), float(1), smoothstep(0.55, 0.95, normalWorld.y)); + const lit = pressed.mul(float(1).sub(hollow.mul(0.22))).mul(wall); + const debug = mix(vec3(0.22, 0.51, 0.67), vec3(0.95, 0.47, 0.16), compaction); + material.colorNode = mix(lit, debug, compactionView); + material.roughnessNode = mix(float(0.88), float(0.57), compaction); + // Ice crystals: one in a hundred tiny cells glints when it faces the eye. Faded out where a + // pixel covers several cells, so distant snow does not shimmer. + const crystal = hash(floor(positionWorld.xz.mul(120))); + const facing = pow(max(dot(normalView, positionViewDirection.negate()), 0), 12); + const fade = float(1).sub(smoothstep(0.01, 0.055, fwidth(positionWorld.x))); + material.emissiveNode = vec3(0.7, 0.85, 1).mul( + smoothstep(0.99, 1, crystal).mul(facing).mul(fade).mul(sun).mul(0.55), + ); + return material; +} + +export function createSnowSurface(snow: ISnowSource): ISnowSurface { + const field = snow.field; + const compactionView = floatUniform(); + const sun = floatUniform(); + sun.value = 1; + const material = snowMaterial(compactionView, sun); + + const geometry = field.toGeometry(); + const state = new BufferAttribute(new Float32Array(field.rows * field.columns * 2), 2); + geometry.setAttribute(STATE, state); + const mesh = new Mesh(geometry, material); + mesh.position.set(field.origin.x, 0, field.origin.z); + mesh.receiveShadow = true; + mesh.name = "snow"; + + // Beyond the playable field the ground keeps going under fresh, untouched snow. Inside the + // field it ducks under the deformable surface, so the seam at the edge never shows. + const outskirtsGeometry = new PlaneGeometry(340, 340, 110, 110); + outskirtsGeometry.rotateX(-Math.PI / 2); + const ring = outskirtsGeometry.getAttribute("position"); + const half = field.width / 2; + for (let index = 0; index < ring.count; index += 1) { + const x = ring.getX(index); + const z = ring.getZ(index); + const edge = Math.max(Math.abs(x), Math.abs(z)); + const inside = 1 - Math.min(1, Math.max(0, (edge - (half - 1.5)) / 1.4)); + ring.setY(index, terrainHeight(x, z) + snow.depth - 0.72 * inside - 0.015); + } + outskirtsGeometry.computeVertexNormals(); + outskirtsGeometry.setAttribute(STATE, new BufferAttribute(new Float32Array(ring.count * 2), 2)); + const outskirts = new Mesh(outskirtsGeometry, material); + outskirts.receiveShadow = true; + + let indents: Float32Array = new Float32Array(0); + let compactions: Float32Array = new Float32Array(0); + const writeState = (bounds: Bounds): void => { + indents = snow.copyChannel("indent", bounds, indents); + compactions = snow.copyChannel("compaction", bounds, compactions); + const values = state.array as Float32Array; + for (let row = 0; row < bounds.rows; row += 1) { + for (let column = 0; column < bounds.columns; column += 1) { + const cell = row * bounds.columns + column; + const vertex = (bounds.row + row) * field.columns + bounds.column + column; + values[vertex * 2] = indents[cell] as number; + values[vertex * 2 + 1] = compactions[cell] as number; + } + } + state.needsUpdate = true; + }; + + let urgent: Bounds | undefined; + let background: Bounds | undefined; + // `toGeometry` already drew the field as it stands; the field's own first full write is not news. + snow.takeDirtyRegion(); + const redraw = (written: Bounds): void => { + // A normal reads its neighbours, so one sample beyond the written window moves too. + const column = Math.max(0, written.column - 1); + const row = Math.max(0, written.row - 1); + const bounds = { + column, + columns: Math.min(field.columns, written.column + written.columns + 1) - column, + row, + rows: Math.min(field.rows, written.row + written.rows + 1) - row, + }; + field.refreshGeometry(geometry, bounds); + writeState(bounds); + // Upload only the rows that moved: a whole-field upload every frame is megabytes per frame. + const first = bounds.row * field.columns; + const count = bounds.rows * field.columns; + for (const name of ["position", "normal", STATE]) { + const attribute = geometry.getAttribute(name) as BufferAttribute; + attribute.addUpdateRange(first * attribute.itemSize, count * attribute.itemSize); + } + }; + const collect = (): void => { + const written = snow.takeDirtyRegion(); + if (written === undefined) return; + if (written.columns * written.rows <= URGENT_CELLS) urgent = union(urgent, written); + else background = union(background, written); + }; + + return { + collect, + mesh, + outskirts, + get pendingRows() { + return background?.rows ?? 0; + }, + refresh() { + collect(); + if (urgent !== undefined) redraw(urgent); + urgent = undefined; + if (background === undefined) return; + // Proportional to what is waiting, so a refill always finishes before the next one lands. + const rows = Math.min( + background.rows, + Math.max(SLICE_ROWS, Math.ceil(background.rows / SLICE_FRAMES)), + ); + redraw({ ...background, rows }); + background = + rows === background.rows + ? undefined + : { ...background, row: background.row + rows, rows: background.rows - rows }; + }, + renderError() { + collect(); + if (urgent !== undefined) redraw(urgent); + if (background !== undefined) redraw(background); + urgent = undefined; + background = undefined; + const positions = geometry.getAttribute("position"); + const heights = field.toColliderHeights(); + let worst = 0; + for (let row = 0; row < field.rows; row += 1) + for (let column = 0; column < field.columns; column += 1) + worst = Math.max( + worst, + Math.abs( + positions.getY(row * field.columns + column) - + (heights[column * field.rows + row] as number), + ), + ); + return worst; + }, + setCompactionView(on) { + compactionView.value = on ? 1 : 0; + }, + setStorm(storm) { + sun.value = 1 - storm * 0.85; + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/touch-controls.ts b/packages/create-threenative/templates/snow/src/render/touch-controls.ts new file mode 100644 index 0000000000..c82c6fd30f --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/touch-controls.ts @@ -0,0 +1,143 @@ +import { + CircleGeometry, + type ColorRepresentation, + Group, + MathUtils, + Mesh, + MeshBasicMaterial, + type PerspectiveCamera, + RingGeometry, + Vector2, +} from "three"; +import { palette } from "./palette.js"; + +const MOVE_RADIUS = 72; +const EDGE = 36; + +export interface ITouchPointer { + readonly position: Vector2; +} + +export interface ITouchViewport { + readonly height: number; + readonly width: number; +} + +export interface ITouchInput { + readonly move: Vector2; +} + +function overlayMaterial(color: ColorRepresentation, opacity: number): MeshBasicMaterial { + return new MeshBasicMaterial({ + color, + depthTest: false, + depthWrite: false, + opacity, + transparent: true, + }); +} + +function ringMesh(radius: number, material: MeshBasicMaterial): Mesh { + return new Mesh(new RingGeometry(radius - 5, radius, 32), material); +} + +function place(mesh: Mesh, point: Vector2): void { + mesh.position.set(point.x, point.y, 0); +} + +function movementPoint(size: ITouchViewport): Vector2 { + return new Vector2(MOVE_RADIUS + EDGE, size.height - MOVE_RADIUS - EDGE); +} + +export class TouchControls { + readonly root = new Group(); + readonly object = this.root; + #camera: PerspectiveCamera; + #input: { move: Vector2 } = { move: new Vector2() }; + #moveAnchor = new Vector2(); + #resting = new Vector2(); + #hasMoveAnchor = false; + #lastWidth = -1; + #lastHeight = -1; + #moveBase: Mesh; + #moveKnob: Mesh; + #idleMaterial: MeshBasicMaterial; + #activeMaterial: MeshBasicMaterial; + + constructor(camera: PerspectiveCamera) { + this.#camera = camera; + // On white snow a faint ring vanishes: a dark stick ring and an accent knob. + this.#idleMaterial = overlayMaterial(palette.floor, 0.6); + this.#activeMaterial = overlayMaterial(palette.accent, 0.8); + this.#moveBase = ringMesh(MOVE_RADIUS, this.#idleMaterial); + this.#moveKnob = new Mesh(new CircleGeometry(28, 24), this.#activeMaterial); + this.root.add(this.#moveBase, this.#moveKnob); + this.root.renderOrder = 10_001; + camera.add(this.root); + } + + update(pointers: ReadonlyMap, size: ITouchViewport): ITouchInput { + this.#ensureLayout(size); + let movement: ITouchPointer | undefined; + for (const pointer of pointers.values()) { + if (pointer.position.x < size.width * 0.5) { + movement = pointer; + break; + } + } + + if (movement === undefined) { + this.#hasMoveAnchor = false; + this.#input.move.set(0, 0); + } else { + if (!this.#hasMoveAnchor) { + this.#moveAnchor.copy(movement.position); + this.#hasMoveAnchor = true; + } + this.#input.move.set( + MathUtils.clamp((movement.position.x - this.#moveAnchor.x) / MOVE_RADIUS, -1, 1), + MathUtils.clamp((this.#moveAnchor.y - movement.position.y) / MOVE_RADIUS, -1, 1), + ); + } + + const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; + place( + this.#moveKnob, + new Vector2( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, + ), + ); + this.#moveKnob.material = this.#hasMoveAnchor ? this.#activeMaterial : this.#idleMaterial; + this.#layout(size); + return this.#input; + } + + debug(): Record { + return { move: this.#input.move.toArray() }; + } + + dispose(): void { + this.root.removeFromParent(); + for (const child of this.root.children) { + if (child instanceof Mesh) child.geometry.dispose(); + } + this.#idleMaterial.dispose(); + this.#activeMaterial.dispose(); + } + + #ensureLayout(size: ITouchViewport): void { + if (size.width === this.#lastWidth && size.height === this.#lastHeight) return; + this.#lastWidth = size.width; + this.#lastHeight = size.height; + this.#resting.copy(movementPoint(size)); + } + + #layout(size: ITouchViewport): void { + const worldHeight = 2 * Math.tan(MathUtils.degToRad(this.#camera.fov / 2)); + const pixels = worldHeight / Math.max(1, size.height); + this.root.position.set(-(size.width * pixels) / 2, worldHeight / 2, -1); + this.root.scale.set(pixels, -pixels, 1); + place(this.#moveBase, this.#hasMoveAnchor ? this.#moveAnchor : this.#resting); + } +} diff --git a/packages/create-threenative/templates/snow/src/render/weather.ts b/packages/create-threenative/templates/snow/src/render/weather.ts new file mode 100644 index 0000000000..42da6c2e5a --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/weather.ts @@ -0,0 +1,279 @@ +// Generated for you. Snowfall, the blizzard and the powder a boot kicks up. `GPUParticles3D` (the +// scene passes it in) owns +// the buffers, dispatch and lifetime; the flake's shape, colour, size, drift and fall are here. +import { type Sprite, Vector2 } from "three"; +import { + Fn, + atan, + cameraPosition, + cos, + exp, + float, + fract, + hash, + instanceIndex, + mod, + pow, + sin, + smoothstep, + step, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl"; +import { + AdditiveBlending, + type ComputeNode, + type Node, + NormalBlending, + SpriteNodeMaterial, + type StorageBufferNode, +} from "three/webgpu"; + +/** Half the width of the box of air around the explorer that flakes fall through, metres. */ +const SPAN = 21; +const CEILING = 19; +/** The widest a flake may be drawn, in screen pixels — the study's point-size clamp. */ +const MAX_FLAKE_PIXELS = 24; +const BURST_PARTICLES = 96; +const BURST_LIFETIME = 1.05; + +const random = (salt: number) => hash(instanceIndex.add(salt)); + +/** What a particle mechanism takes: a pool size, a sprite material and two compute passes. */ +export interface IParticleOptions { + readonly amount: number; + readonly material: SpriteNodeMaterial; + readonly start: (buffers: { readonly positions: StorageBufferNode<"vec3"> }) => ComputeNode; + readonly process: (buffers: { readonly positions: StorageBufferNode<"vec3"> }) => ComputeNode; +} + +/** The pooled sprite the scene builds from those options — `GPUParticles3D` in this game. */ +export type ParticleSystem = Sprite & { + readonly buffers: { readonly positions: StorageBufferNode<"vec3"> }; +}; + +export interface ISnowWeather { + readonly snowfall: ParticleSystem; + readonly bursts: readonly ParticleSystem[]; + /** Live wind at the snowfield, m/s, gusts included. */ + readonly wind: number; + /** 0 clear .. 1 blizzard, eased. */ + readonly storm: number; + update( + dt: number, + centre: { readonly x: number; readonly z: number }, + /** Screen pixels per metre at one metre from the camera: `height / (2 tan(fov / 2))`. */ + pixelScale: number, + settings: { + readonly blizzard: boolean; + readonly snowfall: number; + readonly fallSpeed: number; + readonly wind: number; + }, + ): void; + /** Kick up powder where a boot landed, deeper prints throwing more. */ + burst(at: { readonly x: number; readonly y: number; readonly z: number }, strength: number): void; +} + +const floatUniform = (value: number) => uniform(value); +type FloatUniform = ReturnType; + +/** + * The flake sprite. `at` is each flake's world position and `pixelScale` the screen's pixels per + * metre at one metre: a flake is never drawn wider than `MAX_FLAKE_PIXELS`, and fades out right + * in front of the lens, so the air between the camera and the forest never turns to haze. + */ +function flakeMaterial( + storm: FloatUniform, + visible: FloatUniform, + at: Node<"vec3">, + pixelScale: FloatUniform, +) { + const material = new SpriteNodeMaterial({ + blending: NormalBlending, + depthWrite: false, + transparent: true, + }); + // A soft disc with six faint arms, squashed sideways as the storm drives it. + const p = uv() + .sub(0.5) + .mul(vec2(float(1).sub(storm.mul(0.32)), 1)); + const r = p.length(); + const disc = exp(r.mul(r).mul(-18)).mul(float(1).sub(smoothstep(0.36, 0.5, r))); + const arms = pow(cos(atan(p.y, p.x).mul(3)).abs(), 12) + .mul(0.2) + .add(0.8); + const shown = step(float(instanceIndex).add(1), visible); + const distance = cameraPosition.distance(at); + const opacity = random(11) + .mul(0.55) + .add(0.28) + .mul(smoothstep(0.35, 1.2, distance)); + material.colorNode = vec4(0.95, 0.977, 1, disc.mul(arms).mul(opacity).mul(shown)); + const size = pow(random(13), 3).mul(0.063).add(0.016); + const widest = distance.mul(MAX_FLAKE_PIXELS).div(pixelScale); + // In a storm each flake streaks along the wind, the study's squashed-disc look made longer. + const drawn = size.mul(storm.mul(0.5).add(1)).min(widest).mul(shown); + material.scaleNode = vec2(drawn.mul(storm.mul(1.6).add(1)), drawn); + return material; +} + +/** + * Every flake's position is a function of how far the air has carried it, not a stored velocity: + * `drift` and `fall` are integrated on the CPU from the live wind and fall speed, so changing a + * slider never teleports the flakes, and the box wraps around the explorer wherever they walk. + */ +function createSnowfall( + amount: number, + particlesFor: (options: IParticleOptions) => ParticleSystem, +) { + const storm = floatUniform(0); + const visible = floatUniform(amount); + const drift = uniform(new Vector2()); + const fall = uniform(0); + const centre = uniform(new Vector2()); + const swirl = uniform(0); + const pixelScale = floatUniform(800); + const place = (positions: StorageBufferNode<"vec3">): ComputeNode => + Fn(() => { + const seed = random(3).mul(100); + const start = vec3(random(5), random(7), random(9)); + const speed = fract(seed).add(0.58); + const height = mod(start.y.mul(CEILING).sub(fall.mul(speed)), CEILING).sub(0.5); + const phase = swirl.add(seed); + // Air near the ground is slower than air aloft, as the study's coupling had it. + const coupling = height.div(7).clamp(0.18, 1); + const wander = vec2( + sin(phase.add(start.z.mul(4))), + cos(phase.mul(0.86).add(start.x.mul(4))), + ).mul(storm.mul(0.7).add(0.16)); + const span = float(SPAN * 2); + const raw = start.xz.mul(span).add(drift.mul(coupling)).add(wander); + const wrapped = mod(raw.sub(centre).add(SPAN), span).sub(SPAN).add(centre); + positions.element(instanceIndex).assign(vec3(wrapped.x, height, wrapped.y)); + })().compute(amount); + const material = new SpriteNodeMaterial(); + const particles = particlesFor({ + amount, + material, + process: ({ positions }) => place(positions), + start: ({ positions }) => place(positions), + }); + // The flake look needs each flake's position, which exists once the particle buffers do. + const flake = flakeMaterial( + storm, + visible, + particles.buffers.positions.toAttribute(), + pixelScale, + ); + flake.positionNode = material.positionNode; + particles.material = flake; + particles.renderOrder = 3; + return { centre, drift, fall, particles, pixelScale, storm, swirl, visible }; +} + +/** One pooled burst: every particle leaves the boot at once and fades by its own lifetime. */ +function createBurst(particlesFor: (options: IParticleOptions) => ParticleSystem) { + const age = uniform(BURST_LIFETIME); + const kick = uniform(1); + const wind = uniform(0); + const material = new SpriteNodeMaterial({ + blending: AdditiveBlending, + depthWrite: false, + transparent: true, + }); + const life = random(17).mul(0.65).add(0.38); + const t = age.min(life); + const fade = float(1).sub(t.div(life)).max(0); + const p = uv().sub(0.5); + const puff = exp(p.dot(p).mul(-16)).mul(float(1).sub(smoothstep(0.34, 0.5, p.length()))); + const alive = step(age, life); + material.colorNode = vec4(0.9, 0.94, 1, puff.mul(pow(fade, 1.5)).mul(0.5).mul(alive)); + material.scaleNode = vec2(random(19).mul(0.048).add(0.025).mul(t.div(life).mul(1.9).add(1))); + const place = (positions: StorageBufferNode<"vec3">): ComputeNode => + Fn(() => { + const angle = random(23).mul(Math.PI * 2); + const outward = random(29).mul(0.58).add(0.22).mul(kick); + const rise = random(31).mul(0.85).add(0.4).mul(kick); + const launch = vec3(cos(angle).mul(0.13), 0.035, sin(angle).mul(0.2)); + const across = vec3(cos(angle).mul(outward).add(wind.mul(0.12)), 0, sin(angle).mul(outward)); + // Air drag slows the spray; gravity brings it back to the snow, where it rests. + const travelled = float(1) + .sub(exp(t.mul(-1.8))) + .div(1.8); + const height = rise.mul(t).sub(t.mul(t).mul(1.25)).max(0); + positions + .element(instanceIndex) + .assign(launch.add(across.mul(travelled)).add(vec3(0, height, 0))); + })().compute(BURST_PARTICLES); + const particles = particlesFor({ + amount: BURST_PARTICLES, + material, + process: ({ positions }) => place(positions), + start: ({ positions }) => place(positions), + }); + particles.renderOrder = 4; + particles.visible = false; + return { age, kick, particles, wind }; +} + +export function createWeather(options: { + readonly flakes: number; + /** Builds a pooled particle system; the scene passes `GPUParticles3D`. */ + readonly particles: (options: IParticleOptions) => ParticleSystem; +}): ISnowWeather { + const fall = createSnowfall(options.flakes, options.particles); + const bursts = Array.from({ length: 8 }, () => createBurst(options.particles)); + let cursor = 0; + let time = 0; + let storm = 0; + let wind = 0.4; + let drifted = { x: 0, z: 0 }; + let fallen = 0; + const damp = (a: number, b: number, rate: number, dt: number) => + a + (b - a) * (1 - Math.exp(-rate * Math.max(0, dt))); + return { + bursts: bursts.map((burst) => burst.particles), + burst(at, strength) { + const burst = bursts[cursor % bursts.length]; + cursor += 1; + if (burst === undefined) return; + burst.particles.position.set(at.x, at.y, at.z); + burst.particles.visible = true; + burst.age.value = 0; + burst.kick.value = 0.8 + Math.min(1, strength * 4); + }, + snowfall: fall.particles, + get storm() { + return storm; + }, + update(dt, centre, pixelScale, settings) { + fall.pixelScale.value = pixelScale; + time += dt; + storm = damp(storm, settings.blizzard ? 1 : 0, 0.95, dt); + const gust = 1 + 0.22 * Math.sin(time * 1.1) + 0.12 * Math.sin(time * 2.73 + 1.6); + const target = (settings.wind * (1 - storm) + (9.5 + settings.wind * 0.5) * storm) * gust; + wind = damp(wind, target, 2.3, dt); + drifted = { x: drifted.x + wind * dt, z: drifted.z + wind * 0.24 * dt }; + fallen += settings.fallSpeed * (0.95 + storm * 0.7) * dt; + fall.drift.value.set(drifted.x, drifted.z); + fall.fall.value = fallen; + fall.swirl.value = time * 0.7; + fall.centre.value.set(centre.x, centre.z); + fall.storm.value = storm; + fall.visible.value = Math.round(options.flakes * settings.snowfall * (0.24 + 0.76 * storm)); + for (const burst of bursts) { + if (!burst.particles.visible) continue; + burst.age.value += dt; + burst.wind.value = wind; + if (burst.age.value > BURST_LIFETIME) burst.particles.visible = false; + } + }, + get wind() { + return wind; + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts new file mode 100644 index 0000000000..f61ec7a8e9 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -0,0 +1,809 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. Delete or +// rewrite it freely — every default in here is a starting point, not a framework look. +// +// `WorldEnvironment` is Godot's name for the node that says which lighting effects a scene +// runs and how strong they are, and the property names below are Godot's `Environment` +// properties where Godot has one (`ssr_enabled`, `glow_enabled`, `tonemap_mode`) and +// Three.js's node names where it does not (`ssgi`, `gtao`). Nothing here is a new word. +// +// What it owns: building the stage factories and handing them to +// `renderer.createRenderChain()`, which decides the order stages run in, whether each can +// run on this target, and prints `TN_RENDER_CHAIN` naming every stage as applied or +// refused **with a reason** — so a stage that silently no-op'd is never mistaken for one +// you turned off. What it does not own: any colour, and any strength that is not a quality +// tier. Those are arguments, supplied by `postprocessing.ts`, yours to change. + +import { + ACESFilmicToneMapping, + AgXToneMapping, + type Camera, + type DirectionalLight, + NeutralToneMapping, + type PerspectiveCamera, + type Scene, +} from "three"; +import { bloom } from "three/addons/tsl/display/BloomNode.js"; +import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; +import { fxaa } from "three/addons/tsl/display/FXAANode.js"; +import { ao } from "three/addons/tsl/display/GTAONode.js"; +import { godrays } from "three/addons/tsl/display/GodraysNode.js"; +import { smaa } from "three/addons/tsl/display/SMAANode.js"; +import { ssgi } from "three/addons/tsl/display/SSGINode.js"; +import { ssr } from "three/addons/tsl/display/SSRNode.js"; +import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; +import { + color, + convertToTexture, + float, + max, + metalness, + mrt, + normalView, + output, + pass, + roughness, + screenUV, + smoothstep, + vec2, +} from "three/tsl"; +import type { Node } from "three/webgpu"; + +/** Godot's `Viewport.screen_space_aa`: which post-process antialiasing smooths the frame's edges. */ +export type ScreenSpaceAA = "smaa" | "fxaa" | "disabled"; + +/** Godot's `Environment.tonemap_mode`, with the modes Three.js actually ships. */ +export type TonemapMode = "aces" | "agx" | "neutral"; + +/** + * SSGI sample budget. The three tiers are the presets SSGINode's own documentation + * recommends for the temporally-filtered case, not numbers invented here. An unknown + * tier throws rather than quietly becoming the default. + */ +export type SsgiQuality = "low" | "medium" | "high"; + +const SSGI_QUALITY: Record = { + low: { sliceCount: 1, stepCount: 12 }, + medium: { sliceCount: 2, stepCount: 8 }, + high: { sliceCount: 3, stepCount: 16 }, +}; + +const TONEMAP: Record = { + aces: ACESFilmicToneMapping, + agx: AgXToneMapping, + neutral: NeutralToneMapping, +}; + +export interface IWorldEnvironmentOptions { + /** Screen-space indirect diffuse light. Godot calls this `ssil_enabled`. */ + readonly ssgiEnabled?: boolean; + readonly ssgiQuality?: SsgiQuality; + /** How much of the gathered indirect light reaches the frame. Godot's `ssil_intensity`. */ + readonly ssgiIntensity?: number; + /** How far, in world units, indirect light is gathered from. Room scale, not contact scale. */ + readonly ssgiRadius?: number; + /** Screen-space reflections. Godot's `ssr_enabled`. */ + readonly ssrEnabled?: boolean; + /** + * How far, in world units, a reflection ray is traced. + * + * `SSRNode` defaults this to **1**, which is one metre. On any scene larger than a + * tabletop that reads as "screen-space reflections are on and do nothing" — the ray + * terminates before it reaches the thing that should be standing in the floor. + */ + readonly ssrMaxDistance?: number; + /** + * Fraction of the display resolution the reflection is traced at. + * + * A reflection in a rough surface carries almost no high-frequency detail, so tracing it + * at half resolution costs a quarter of the rays and is very hard to see in the result. + */ + readonly ssrResolutionScale?: number; + /** Spatial denoise over the SSGI result. Off is noisier and cheaper. */ + readonly denoiseEnabled?: boolean; + /** Raymarched shafts. Needs `godraysLight`, and that light must cast shadows. */ + readonly godraysEnabled?: boolean; + /** How dense the participating medium is. Higher is foggier — and the whole frame brighter. */ + readonly godraysDensity?: number; + /** + * Haze below this value is discarded instead of added to the frame. + * + * Without it the pass is a uniform brightener rather than a shaft renderer: it returns a + * non-zero value for nearly every pixel, and that "something" is tiny in linear space and + * enormous after the tone curve, which lifts shadows hardest. Subtracting a floor keeps + * the air inside a real beam and discards the ambient lift. + */ + readonly godraysFloor?: number; + /** Multiplier applied after the floor, so beams can be bright without the haze returning. */ + readonly godraysIntensity?: number; + /** + * Raymarch steps per pixel. `GodraysNode` defaults to 60. + * + * Every step is a shadow-map sample, so this multiplies directly into the cost of the + * whole pass. The pass jitters its sample positions, so a lower count trades a hard shaft + * edge for a slightly noisier one rather than for banding — which the denoiser then + * largely absorbs. + */ + readonly godraysSteps?: number; + /** + * Ceiling on shaft brightness. + * + * This is also the single strongest control over how bright the *whole* scene looks: the + * pass adds its accumulated illumination to every pixel, not only to the pixels inside a + * visible beam, so raising it raises the shadow floor of the entire frame. + */ + readonly godraysMaxDensity?: number; + /** + * Ground-truth ambient occlusion, at contact scale. Godot's `ssao_enabled`. + * + * Deliberately a *second* occlusion term next to the one SSGI already produces. SSGI's AO + * is gathered over `ssgiRadius` — metres — the right scale for "this hall is enclosed" + * and the wrong one for "this foot is standing on this floor". This pass runs at a radius + * of centimetres and multiplies on top. + */ + readonly gtaoEnabled?: boolean; + /** Occlusion gather radius in world units. Contact scale, not room scale. */ + readonly gtaoRadius?: number; + /** Exponent on the occlusion term: higher is a darker, tighter contact. */ + readonly gtaoScale?: number; + /** Directions sampled per pixel. Cost is linear in this. */ + readonly gtaoSamples?: number; + /** Fraction of display resolution the occlusion is traced at. */ + readonly gtaoResolutionScale?: number; + /** + * The fraction of the frame SSGI actually gathers at. Half by default. + * + * `GTAONode` and `SSRNode` both carry a `resolutionScale` of their own; **`SSGINode` does not**, + * and its `setSize` passes width and height straight into its render target with no factor. So + * screen-space GI — a low-frequency effect every production renderer gathers at half or quarter + * res — was the only stage in this chain running at full resolution, and it cost accordingly. + * + * Measured on a 46,190-instance forest at 1600x900 on an RTX 2080, with the resolution scaler + * left on auto so the engine's own verdict is part of the result: + * + * fps still/walk scale it settled on interior median dead shadow + * full res 38.0 / 47.2 0.44 704x396 0.0299 19.19% + * off 60.0 / 60.0 0.61 976x549 0.0987 4.54% + * half res 59.8 / 60.0 0.72 1152x648 0.0301 18.58% + * + * Half res is better than both: it holds the budget like switching SSGI off, keeps the look + * within noise of full res, and produces the *sharpest* frame of the three — because the budget + * it hands back is spent by the scaler on pixels rather than on a gather nobody can see at full + * rate. Full-res SSGI was not buying quality, it was trading it. + * + * Fixed without touching three: `setSize` is public and the pass drives the node through it with + * the full frame size, so the instance is wrapped to hand it a smaller one. The node's own maths + * reads the `_resolution` uniform that `setSize` writes, so it stays self-consistent, and the + * result is sampled by UV rather than by texel — which is what makes the upsample free and is + * exactly the mechanism `GTAONode` uses internally. + */ + readonly ssgiResolutionScale?: number; + /** + * Contrast-adaptive sharpening (RCAS) over the finished frame. + * + * Everything upstream of it blurs: the GI gather is denoised, the reflection is traced at + * reduced resolution and roughness-blurred. Each is worth its softness on its own and the + * sum is a frame with no micro-detail left. RCAS is contrast-adaptive, so it puts the + * edges back without ringing the soft areas. + */ + readonly sharpenEnabled?: boolean; + /** RCAS sharpness. **0 is maximum sharpening and 2 is none** — it is a radius, not a gain. */ + readonly sharpenStrength?: number; + /** Corner darkening, as a fraction removed at the extreme corner. Zero disables it. */ + readonly vignetteAmount?: number; + /** + * Post-process antialiasing, run last. Godot's `Viewport.screen_space_aa`. Default `"smaa"`. + * + * Not optional in practice: the chain renders `pass(scene, camera)` single-sampled, so the moment + * any stage runs, the renderer's own multisampling no longer reaches the frame and every edge + * stairsteps — the single loudest "this is a prototype" signal in a frame. It is requested only + * when some other stage (or a `baseColour`) installs a chain; with nothing installed the renderer + * draws straight to the canvas with its own MSAA and this would be paying twice. + */ + readonly screenSpaceAA?: ScreenSpaceAA; + /** Godot's `glow_enabled`. */ + readonly bloomEnabled?: boolean; + readonly bloomStrength?: number; + /** How far a bright pixel bleeds, as a fraction of the screen. */ + readonly bloomRadius?: number; + /** Luminance a pixel must exceed before it blooms at all. */ + readonly bloomThreshold?: number; + /** + * Which of this kit's authored stages to run. + * + * Separate from the graph below, and not for tidiness: the chain has to know whether anything + * was requested at all before the scene pass exists — with nothing requested it sets an + * exposure and returns, and no pass is ever built. So the request is a static decision the kit + * makes from its own config, and only the graph needs the pass. + */ + readonly authoredStageNames?: readonly string[]; + /** + * This kit's own stages, composed after the built-ins. + * + * A factory because an authored stage needs the two things only this file can produce: the + * scene pass's depth texture node, which does not exist until the pass is built, and the + * resolved tier. It returns the complete graph — including stages left dormant this frame — so + * that an anchor like `watercolor after kuwahara` keeps naming a stage that exists. + * + * Naming specific stages here instead — an `outlineEnabled`, a `kuwaharaRadius` — is what put + * one kit's look inside the plumbing every kit copies verbatim, which + * `shared-render-sources.spec.ts` exists to refuse. + */ + readonly authoredStages?: (context: IWorldEnvironmentStageContext) => readonly ChainStage[]; + /** Quality tier passed to the measured chain; the quality module owns this value. */ + readonly renderChainTier?: ChainTier; + readonly tonemapMode?: TonemapMode; + /** + * Scene-referred exposure: multiplied into the scene pass **before** the tone curve, so + * the GI gather, reflections and bloom threshold all see the exposed image and the bloom + * threshold means the same thing at any exposure. + * + * `renderer.toneMappingExposure` is live too (measured on three@0.185.1 — see + * `docs/verification/exposure-ab-2026-08-30.md`), and applies at the same point in the + * graph, but only when no output node is installed. When every stage here is off, this + * class falls back to that scalar; when any stage runs, this multiply is the shutter. + */ + readonly exposure?: number; +} + +export interface IWorldEnvironmentTarget { + /** The directional light godrays are raymarched against. Required only by `godraysEnabled`. */ + readonly godraysLight?: DirectionalLight; + /** + * Composes the colour the chain starts from, given the scene pass it renders. + * + * The chain builds `pass(scene, camera)` itself, so a game that composes something onto the + * beauty pass before any stage runs — aerial perspective, read against that pass's own view-Z, + * is the one in these templates — has nowhere else to put it. The node returned here is the + * game's; the chain only decides where it is spliced in, which is first, ahead of exposure and + * every stage. + */ + readonly baseColour?: (scenePass: ReturnType) => Node<"vec4">; +} + +/** + * What travels down the chain: the composed colour, as TSL's proxied vec4 node — the thing + * that carries `.mul`, `.add`, `.rgb` and `.r`. Every stage takes one and returns one, so the + * compiler checks the composition instead of taking a cast's word for it. + */ +type ChainNode = Node<"vec4">; +/** + * The scene pass's depth texture, as the pass itself types it. + * + * Named through `pass()` rather than written out because the addon's return type is the only + * honest spelling of it, and a hand-written alias here would drift the first time three changes it. + */ +export type DepthTextureNode = ReturnType["getTextureNode"]>; +type ChainTier = "high" | "medium" | "low" | "off"; +type ChainContext = { + readonly tier: ChainTier; +}; +/** + * What a kit's `authoredStages` factory is handed. The depth node is the scene pass's, so a + * stage that reads depth gets the same texture the built-in stages read rather than its own. + */ +export interface IWorldEnvironmentStageContext { + readonly depthNode: DepthTextureNode; + readonly tier: ChainTier; +} + +export type ChainStage = { + readonly name: string; + readonly before?: string; + readonly after?: string; + readonly available?: (context: ChainContext) => boolean | string; + readonly dispose?: () => void; + readonly minimumTier?: ChainTier; + /** The chain plumbing is stage-agnostic, so it names the node it carries `unknown`. */ + readonly build: (input: unknown, context: ChainContext) => unknown; +}; + +/** + * The one seam between the chain's `unknown` and this file's node algebra. + * + * What the chain hands a stage is the colour composed so far — the previous stage's return, or + * the exposed scene pass for the first one — and that is always a `ChainNode`. Saying so once, + * here, is what lets every stage below be written and *checked* as vec4-in, vec4-out instead of + * asserting its way through each expression. The wrapper adds no node to the graph. + */ +function stage(definition: { + readonly name: ChainStage["name"]; + readonly dispose?: () => void; + readonly available?: (context: ChainContext) => boolean | string; + readonly build: (input: ChainNode, context: ChainContext) => ChainNode; +}): ChainStage { + return { + ...definition, + build: (input, context) => definition.build(input as ChainNode, context), + }; +} + +/** + * `three/addons` declares each TSL stage as its raw node class, and `DenoiseNode` is the only + * one in this chain whose declaration omits the element API (`.mul`, `.rgb`, `.r`) that the + * proxied node object it actually hands back at runtime carries — every other stage here is a + * `ChainNode` on its own, checked by the compiler. This is an assertion and nothing else: no + * conversion runs, no node is added to the graph, so it cannot move a pixel. + */ +function denoised(node: ReturnType): ChainNode { + return node as unknown as ChainNode; +} +export type OutputRenderer = { + kind: string; + raw: unknown; + /** + * Installs a node graph directly. Only reached when a `baseColour` is composed and every + * stage is off: the chain installs nothing for an empty stage list, and dropping the game's + * own composition on the floor would be the silent no-op this class exists to prevent. + */ + setOutputNode?: (node: unknown) => void; + createRenderChain?: (options: { + input?: unknown; + worldPass?: unknown; + request?: { stages?: readonly string[]; tier?: ChainContext["tier"] }; + stages?: readonly ChainStage[]; + }) => { + applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; + dispose(): void; + }; +}; + +/** + * Builds a Godot-named lighting chain and installs it through the renderer's render chain. + * + * Every stage a game turns on reports itself: the chain prints `TN_RENDER_CHAIN` naming + * each requested stage as applied or refused with a reason that is never blank. A game with + * GI switched off on a WebGL fallback looks exactly like a game that chose not to use it — + * unless it reads that line. + */ +export class WorldEnvironment { + readonly #options: Required; + + constructor(options: IWorldEnvironmentOptions = {}) { + const quality = options.ssgiQuality ?? "medium"; + if (SSGI_QUALITY[quality] === undefined) { + // Fail closed. A typo'd tier that silently became "medium" is a quality setting + // nobody can trust afterwards. + throw new Error( + `Unknown ssgiQuality '${String(quality)}'. Use one of: ${Object.keys(SSGI_QUALITY).join(", ")}.`, + ); + } + const tonemapMode = options.tonemapMode ?? "aces"; + if (TONEMAP[tonemapMode] === undefined) { + throw new Error( + `Unknown tonemapMode '${String(tonemapMode)}'. Use one of: ${Object.keys(TONEMAP).join(", ")}.`, + ); + } + const renderChainTier = options.renderChainTier ?? "high"; + if (!isChainTier(renderChainTier)) { + throw new Error( + `Unknown render chain tier '${String(renderChainTier)}'. Use high, medium, low, or off.`, + ); + } + this.#options = { + // A kit with no stages of its own is the common case, so both default to empty rather + // than to a branch every call site has to remember. + authoredStageNames: options.authoredStageNames ?? [], + authoredStages: options.authoredStages ?? (() => []), + ssgiEnabled: options.ssgiEnabled ?? false, + ssgiQuality: quality, + ssgiIntensity: options.ssgiIntensity ?? 1, + ssgiRadius: options.ssgiRadius ?? 12, + ssrEnabled: options.ssrEnabled ?? false, + ssrMaxDistance: options.ssrMaxDistance ?? 30, + ssrResolutionScale: options.ssrResolutionScale ?? 0.5, + denoiseEnabled: options.denoiseEnabled ?? true, + godraysEnabled: options.godraysEnabled ?? false, + godraysDensity: options.godraysDensity ?? 0.7, + godraysFloor: options.godraysFloor ?? 0, + godraysIntensity: options.godraysIntensity ?? 1, + godraysSteps: options.godraysSteps ?? 60, + godraysMaxDensity: options.godraysMaxDensity ?? 0.5, + gtaoEnabled: options.gtaoEnabled ?? false, + gtaoRadius: options.gtaoRadius ?? 0.25, + gtaoScale: options.gtaoScale ?? 1, + gtaoSamples: options.gtaoSamples ?? 16, + gtaoResolutionScale: options.gtaoResolutionScale ?? 1, + ssgiResolutionScale: options.ssgiResolutionScale ?? 0.5, + sharpenEnabled: options.sharpenEnabled ?? false, + sharpenStrength: options.sharpenStrength ?? 0.2, + vignetteAmount: options.vignetteAmount ?? 0, + screenSpaceAA: options.screenSpaceAA ?? "smaa", + bloomEnabled: options.bloomEnabled ?? true, + bloomStrength: options.bloomStrength ?? 0.7, + bloomRadius: options.bloomRadius ?? 0.5, + bloomThreshold: options.bloomThreshold ?? 0.2, + renderChainTier, + tonemapMode, + exposure: options.exposure ?? 1, + }; + } + + /** + * Installs the chain. Returns what actually ran — the same applied report the chain + * printed — so a scene can assert on it instead of scraping the console. + */ + apply( + renderer: OutputRenderer, + scene: Scene, + camera: Camera, + target: IWorldEnvironmentTarget = {}, + ): { + dropped: readonly { name: string; reason: string }[]; + stages: readonly string[]; + dispose?: () => void; + } { + const options = this.#options; + const raw = renderer.raw as { toneMapping?: number; toneMappingExposure?: number }; + raw.toneMapping = TONEMAP[options.tonemapMode]; + + const requested: string[] = ( + ["ssgi", "ambientOcclusion", "godRays", "ssr", "sharpen", "bloom", "vignette"] as const + ).filter( + (name) => + (name === "ssgi" && options.ssgiEnabled) || + (name === "ambientOcclusion" && options.gtaoEnabled) || + (name === "godRays" && options.godraysEnabled) || + (name === "ssr" && options.ssrEnabled) || + (name === "sharpen" && options.sharpenEnabled) || + (name === "bloom" && options.bloomEnabled) || + (name === "vignette" && options.vignetteAmount > 0), + ); + requested.push(...options.authoredStageNames); + if ( + options.screenSpaceAA !== "disabled" && + (requested.length > 0 || target.baseColour !== undefined) + ) + requested.push("antialias"); + + // With no stage running there is no node graph to install — the renderer's own + // tone-mapping path renders the frame, and the exposure scalar is live there (measured: + // `docs/verification/exposure-ab-2026-08-30.md`). With any stage running, exposure is + // applied as a multiply on the scene pass instead: scene-referred, so every downstream + // stage sees the exposed image and the bloom threshold means the same thing at any + // exposure. Both land at the same point in the graph — before the tone curve. + if (requested.length === 0 && target.baseColour === undefined) { + raw.toneMappingExposure = options.exposure; + this.#reportApplied([], []); + return { dropped: [], stages: [] }; + } + raw.toneMappingExposure = 1; + + const scenePass = pass(scene, camera); + // Normals come from the multi-render-target alongside the colour. SSGI can derive them + // from depth alone, but the derived normal is flat across a triangle, which shows up as + // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and + // roughness ride along so SSR knows which pixels are supposed to reflect. + if (options.ssgiEnabled || options.ssrEnabled || options.gtaoEnabled) { + scenePass.setMRT(mrt({ output, normal: normalView, metalness, roughness })); + } + // Requested lazily, and this is not a micro-optimisation. **Asking a pass for `normal`, + // `metalness` or `roughness` is what creates the extra render target.** A tier that runs none + // of the stages needing them — the mobile look is bloom and the tone curve — must not ask, or + // the pass carries a colour target no fragment shader writes and WebGPU refuses the pipeline: + // *"Color target has no corresponding fragment stage output but writeMask … is not zero. + // While validating targets[1]"*. The frame then comes out **black** while the chain still + // reports every stage as applied, which is the worst shape a failure can take here. + const nodes = new Map>(); + const textureNode = (name: string): ReturnType => { + const cached = nodes.get(name); + if (cached !== undefined) return cached; + const node = scenePass.getTextureNode(name); + nodes.set(name, node); + return node; + }; + const depth = (): ReturnType => textureNode("depth"); + const normal = (): ReturnType => textureNode("normal"); + const metal = (): ReturnType => textureNode("metalness"); + const rough = (): ReturnType => textureNode("roughness"); + // Every stage here is perspective-camera maths — view-space reconstruction from depth, + // and a projection matrix inverse. The scene's camera is the one the pass rendered with. + const view = camera as PerspectiveCamera; + + const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); + const exposed = convertToTexture(base).mul(options.exposure); + const giDenoise = (node: ChainNode): ChainNode => + options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; + + let releaseBloom = (): void => {}; + const stages: ChainStage[] = [ + stage({ + name: "ssgi", + build: (input) => { + const gi = ssgi(input, depth(), normal(), view); + const tier = SSGI_QUALITY[options.ssgiQuality]; + gi.sliceCount.value = tier.sliceCount; + gi.stepCount.value = tier.stepCount; + gi.radius.value = options.ssgiRadius; + // Gather at a fraction of the frame. See `ssgiResolutionScale` above for why this is a + // wrapper rather than a property: the node has no scale of its own, and `setSize` is the + // public seam the pass drives it through. `setSize` is present on the node and absent + // from its type declarations, so the seam is named here rather than cast away at the call + // site — if a future three release declares it, or gives the node a `resolutionScale`, + // this narrows to nothing and the wrapper can go. Guarded at one texel so a zero or a + // negative cannot ask for an empty target: a 0x0 gather returns black and reads as SSGI + // simply not working. + const ssgiScale = options.ssgiResolutionScale; + const sizedGi = gi as unknown as { + setSize?: (width: number, height: number) => void; + }; + const fullGiSize = sizedGi.setSize?.bind(gi); + if (ssgiScale > 0 && ssgiScale < 1 && fullGiSize !== undefined) { + sizedGi.setSize = (width: number, height: number): void => { + fullGiSize( + Math.max(1, Math.round(width * ssgiScale)), + Math.max(1, Math.round(height * ssgiScale)), + ); + }; + } + + // SSGI writes two targets: AO in a single-channel `RedFormat` texture, and the GI + // term in an RGB one. Multiplying the beauty pass by the whole AO vec4 therefore + // zeroes green and blue and the entire scene renders monochrome red — take `.r` + // for the scalar and `.rgb` for the colour. + // + // No albedo buffer here, so the beauty pass stands in for it: occlusion darkens + // what is already lit, and the gathered indirect light is tinted by the surface it + // lands on. That is the standard approximation, and it is why a red wall tints a + // white floor at all. `ssgiIntensity` is the game's dial on that second term — + // gathered light is unbounded, and a small white room at 1.0 blows out. + const occlusion = giDenoise(gi.getAONode()); + const indirect = giDenoise(gi.getGINode()); + return input.mul(occlusion.r).add(input.mul(indirect.rgb).mul(options.ssgiIntensity)); + }, + }), + stage({ + name: "ambientOcclusion", + build: (input) => { + const contact = ao(depth(), normal(), view); + contact.radius.value = options.gtaoRadius; + contact.scale.value = options.gtaoScale; + contact.samples.value = options.gtaoSamples; + contact.resolutionScale = options.gtaoResolutionScale; + // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and + // multiplying by the vec4 would zero green and blue and render the scene red. + // Applied before SSGI by the chain's canonical order; composing it after the GI + // combine instead darkens crevices the gather re-lit — that is a look choice, and + // this file is where you make it. + // + // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved + // sampling otherwise reads as a speckled halo around every contact — around feet on a + // plain floor most of all — which is exactly the "not smooth" a player notices first. + const occlusion = options.denoiseEnabled + ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) + : contact; + return input.mul(occlusion.r); + }, + }), + stage({ + name: "godRays", + available: () => { + // Godrays are raymarched against the light's shadow map: the shaft is the volume + // the shadow map reports as lit. A light that casts no shadow yields a black pass, + // which would read as "godrays are on and do nothing" — so refuse it by name. + const light = target.godraysLight; + if (light === undefined) return "godraysEnabled is true but no godraysLight was passed"; + if (light.castShadow !== true) + return `light '${light.name || "sun"}' does not cast shadows`; + // `castShadow` is a request, not a result: three allocates the shadow map on the first + // render that needs it, and `GodraysNode` reads `shadow.map.depthTexture` while the + // graph is built. Without this check that read throws inside TSL, the chain build + // fails, and *every* stage is lost — the frame comes back ungraded with no SSGI, no + // SSR and no tonemap, which looks like a scene problem rather than a missing map. + // Refusing by name keeps the rest of the chain, which is this stage list's contract. + if (light.shadow?.map == null) + return `light '${light.name || "sun"}' has no shadow map yet: set renderer.shadowMap.enabled on the raw renderer (ctx.renderer.raw), and build the chain after the first frame has rendered`; + return true; + }, + build: (input) => { + const light = target.godraysLight as DirectionalLight; + const shafts = godrays(depth(), view, light); + shafts.density.value = options.godraysDensity; + shafts.maxDensity.value = options.godraysMaxDensity; + shafts.raymarchSteps.value = options.godraysSteps; + // The pass jitters its samples with interleaved noise; at low step counts the + // noise renders as a regular lattice over every hazy pixel. The bilateral + // denoiser built for the GI gather smooths exactly that — high-frequency, + // depth-coherent — and the shaft edges survive because they sit on geometry + // silhouettes the depth term owns. Denoise before the floor: the floor's + // subtraction amplifies relative noise on near-floor values. + let shaft: ChainNode = convertToTexture(shafts); + if (options.denoiseEnabled) shaft = denoised(denoise(shaft, depth(), normal(), view)); + // Floor, then scale, then tint. The floor is what turns this from a whole-frame + // brightener into a shaft renderer; the tint by the light's own colour is what + // stops a warm sun throwing a white beam. + const cleaned = shaft.rgb.sub(options.godraysFloor).max(0).mul(options.godraysIntensity); + return input.add(cleaned.mul(color(light.color))); + }, + }), + stage({ + name: "ssr", + build: (input) => { + // Three defaults that together make SSR silently do nothing on a real scene, all + // fixed here and each worth stating: + // maxDistance defaults to 1 world unit — a ray that dies after a metre + // reflectNonMetals defaults to false, so a polished *stone* floor never reflects + // roughnessNode null means every surface is treated as a perfect mirror + // `normal`, not `normal.xyz`. SSR calls `.sample()` on this node, so it needs the + // whole TextureNode; a swizzle produces a plain vec3 and the pass dies at shader + // build. `@types/three` declares the parameter as `Node<"vec3">`, which is what + // makes the swizzle look correct — the types are wrong here and the runtime is + // right. Both `colorNode` and `normalNode` are `.sample()`d inside the pass, so + // the composed input must be materialised ONCE and that texture reused on both + // sides of the add: passing the unmaterialised node to the pass while adding the + // reflections onto a second copy builds two parallel graphs, and the frame comes + // out as the bare background colour. + const base = convertToTexture(input); + const reflections = ssr(base, depth(), normal() as unknown as Parameters[2], { + camera: view, + metalnessNode: metal().r, + roughnessNode: rough().g, + reflectNonMetals: true, + }); + reflections.maxDistance.value = options.ssrMaxDistance; + reflections.resolutionScale = options.ssrResolutionScale; + return base.add(reflections); + }, + }), + stage({ + name: "sharpen", + build: (input) => { + // Last of the image stages but one, because RCAS is defined on the finished + // picture: it looks at the contrast around a pixel and sharpens in proportion to + // it, so running it before bloom would sharpen edges that bloom then spreads + // back out. The third argument keeps the node from materialising its own render + // target; the chain's graph is fused instead. + return sharpen(convertToTexture(input), options.sharpenStrength, false); + }, + }), + stage({ + name: "bloom", + dispose: () => { + releaseBloom(); + releaseBloom = () => {}; + }, + build: (input) => { + const texture = convertToTexture(input); + const effect = bloom( + texture, + options.bloomStrength, + options.bloomRadius, + options.bloomThreshold, + ); + releaseBloom = () => { + effect.dispose(); + // convertToTexture may return its input. Only the newly allocated RTT is ours. + const scratch = texture as unknown as { + isRTTNode?: boolean; + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + if (texture !== input && scratch.isRTTNode === true) { + scratch.renderTarget.dispose(); + scratch._quadMesh.material.dispose(); + } + }; + return input.add(effect); + }, + }), + stage({ + name: "vignette", + build: (input) => { + // Radius measured from the centre in screen-diagonal units, so the corner is 1 + // and the falloff does not change shape when the window does. It starts at 0.55 — + // well outside the subject in a 16:9 frame — so the vignette is a corner, not a + // spotlight. + const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); + return input.mul(fall.oneMinus()); + }, + }), + { + // Last, after every built-in: antialiasing is defined on the finished picture, and any + // stage after it would put the stairsteps back. + name: "antialias", + after: "gradualBackground", + build: (input) => { + const colour = input as ChainNode; + // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is + // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 + // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) + // for the filter and expands it back afterwards, so edges resolve without the halo. + const peak = max(colour.r, max(colour.g, colour.b)); + const squeezed = colour.div(peak.add(1)); + const filtered = (options.screenSpaceAA === "fxaa" + ? fxaa(squeezed) + : smaa(squeezed)) as unknown as ChainNode; + const back = max(filtered.r, max(filtered.g, filtered.b)); + return filtered.div(float(1).sub(back).max(1e-4)); + }, + }, + ]; + + // The kit's own stages, composed after the built-ins. The factory is called with the two + // things only this file can produce — the scene pass's depth texture and the resolved tier — + // and returns the complete authored graph plus the subset to run. + stages.push(...options.authoredStages({ depthNode: depth(), tier: options.renderChainTier })); + + // A composed base colour with every stage off still has to reach the frame. The chain + // installs nothing for an empty stage list, so this is the one path that goes direct. + if (requested.length === 0) { + if (renderer.kind !== "webgpu") { + this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); + return { dropped: [], stages: [] }; + } + if (renderer.setOutputNode === undefined) + throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); + renderer.setOutputNode(exposed); + this.#reportApplied([], []); + return { dropped: [], stages: [] }; + } + + if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); + const chain = renderer.createRenderChain({ + input: exposed, + request: { stages: requested, tier: options.renderChainTier }, + stages, + worldPass: scenePass, + }); + this.#reportApplied(chain.applied.stages, chain.applied.dropped); + return { + dropped: chain.applied.dropped, + stages: chain.applied.stages, + dispose: () => { + chain.dispose(); + scenePass.dispose(); + }, + }; + } + + /** + * Prints `TN_WORLD_ENVIRONMENT`: every stage this class can run, named as applied or refused + * **with a reason that is never blank**, plus the tone curve and exposure that framed them. + * + * The chain prints its own line for the stages it installed, and it prints nothing at all when + * nothing was requested — which is exactly the case where a reader most needs to know that GI + * is off because this file says so rather than because a node silently no-op'd. This line is + * emitted every run, including the run where every stage is off. + */ + #reportApplied( + applied: readonly string[], + dropped: readonly { name: string; reason: string }[], + ): void { + const options = this.#options; + const off: Record = { + ambientOcclusion: "gtaoEnabled is false", + antialias: 'screenSpaceAA is "disabled", or no other stage installed a chain', + bloom: "bloomEnabled is false", + godRays: "godraysEnabled is false", + sharpen: "sharpenEnabled is false", + ssgi: "ssgiEnabled is false", + ssr: "ssrEnabled is false", + vignette: "vignetteAmount is 0", + kuwahara: "kuwaharaEnabled is false", + outline: "outlineEnabled is false", + watercolor: "watercolorEnabled is false", + }; + const stages = Object.keys(off) + .sort() + .map((name) => { + if (applied.includes(name)) return { applied: true, name }; + const refused = dropped.find((entry) => entry.name === name); + return { applied: false, name, reason: refused?.reason ?? off[name] }; + }); + console.info( + `TN_WORLD_ENVIRONMENT:${JSON.stringify({ + denoise: options.denoiseEnabled, + exposure: options.exposure, + marker: "TN_WORLD_ENVIRONMENT", + screenSpaceAA: options.screenSpaceAA, + ssgiQuality: options.ssgiQuality, + stages, + tonemapMode: options.tonemapMode, + })}`, + ); + } +} + +function isChainTier(value: unknown): value is ChainTier { + return value === "high" || value === "medium" || value === "low" || value === "off"; +} diff --git a/packages/create-threenative/templates/snow/src/scenes/Boot.ts b/packages/create-threenative/templates/snow/src/scenes/Boot.ts new file mode 100644 index 0000000000..81d5d04494 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/scenes/Boot.ts @@ -0,0 +1,12 @@ +import { Scene } from "@threenative/core"; +import type { IPhysicsContext } from "@threenative/physics"; +import type { GameState } from "../state.js"; +import { Snow } from "./Snow.js"; + +export class Boot extends Scene { + static override readonly initialState = Snow.initialState; + + override enter(ctx: Parameters[0]): void { + void ctx.goto("snow"); + } +} diff --git a/packages/create-threenative/templates/snow/src/scenes/Snow.ts b/packages/create-threenative/templates/snow/src/scenes/Snow.ts new file mode 100644 index 0000000000..0f9a10291a --- /dev/null +++ b/packages/create-threenative/templates/snow/src/scenes/Snow.ts @@ -0,0 +1,635 @@ +import { + GPUParticles3D, + type ICtx, + Scene, + type SceneFrame, + isMobile, + isTouchscreenAvailable, +} from "@threenative/core"; +import type { SnowField } from "@threenative/core/world"; +import { + CollisionShape3D, + type IPhysicsContext, + RigidBody3D, + attachSnowPhysics, +} from "@threenative/physics"; +import type { Object3D, PerspectiveCamera } from "three"; +import { SnowAudio } from "../audio.js"; +import { type Command, commands } from "../commands.js"; +import { Explorer, type IFootstep, STANCE } from "../entities/Explorer.js"; +import { BOOT_AREA, bootPrint } from "../render/bootPrint.js"; +import { OrbitFollow, setupCamera } from "../render/camera.js"; +import { setupLighting } from "../render/lighting.js"; +import { createLoadingScreen } from "../render/loading.js"; +import { createMaterials } from "../render/materials.js"; +import { setupPost } from "../render/postprocessing.js"; +import { createBall, createCrate, createLog } from "../render/props.js"; +import { createScenery } from "../render/scenery.js"; +import { setupSky } from "../render/sky.js"; +import { createSnowSurface } from "../render/snowSurface.js"; +import { TouchControls } from "../render/touch-controls.js"; +import { createWeather } from "../render/weather.js"; +import { type CameraView, type GameState, PROBE_COLUMNS, PROBE_ROWS } from "../state.js"; +import { + EXPLORER_LAYER, + FIELD_SIZE, + SNOW_LAYER, + WALK_LAYER, + createSnowfield, + walkSurface, +} from "../terrain.js"; + +export type GameCtx = ICtx; + +const BALL_RADIUS = 0.25; +const BALL_MASS = 10; +/** Newton-seconds a push gives the ball: 2.4 m/s on a 10 kg ball, enough to carve on uphill. */ +const KICK = 24; +const CRATE = { x: 0.5, y: 0.3, z: 0.4 } as const; +const LOG = { halfHeight: 0.35, radius: 0.14 } as const; +/** Where the explorer walks while auto-explore is on. */ +const ROUTE: ReadonlyArray = [ + [0.5, -5], + [-4, -8], + [-7, -3], + [-5, 4], + [0, 7], + [5, 3], + [3, -2], +]; +const VIEWS: readonly CameraView[] = ["follow", "surface", "overhead"]; +/** Seconds between snowfall refills; recovery is rate-based, so this only sets the cadence. */ +const REFILL_INTERVAL = 0.5; + +/** One dynamic body that sits in the snow, with what a playtest needs to read back about it. */ +function placeBody( + ctx: GameCtx, + snow: SnowField, + body: RigidBody3D, + at: { readonly x: number; readonly z: number }, + lift: number, +): void { + const half = FIELD_SIZE / 2 - 1; + const x = Math.max(-half, Math.min(half, at.x)); + const z = Math.max(-half, Math.min(half, at.z)); + const y = snow.heightAt(x, z) + lift; + ctx.physics.simulation.setBodyTransform(body.body.id, { x, y, z }); + body.linearVelocity = { x: 0, y: 0, z: 0 }; +} + +export class Snow extends Scene { + static override readonly initialState: GameState = { + autoExplore: true, + ballGap: 0, + ballLoad: 0, + ballSink: 0, + blizzard: false, + compaction: false, + contacts: 0, + depth: 0.28, + fallSpeed: 0.55, + hardness: 0.22, + lastSink: 0, + muted: true, + paused: false, + probe: [], + recovery: 120, + snowfall: 0.58, + speed: 0, + storm: 0, + toast: "", + toastId: 0, + uiReady: false, + view: "follow", + wind: 0.4, + windNow: 0.4, + }; + + override enter(ctx: GameCtx): SceneFrame { + const initial = ctx.state.getState(); + const mobile = isMobile(); + const stormSky = setupSky(ctx.scene); + const lights = setupLighting( + ctx.scene, + ctx.renderer.raw as Parameters[1], + ); + setupPost(ctx.renderer, ctx.scene, ctx.camera, { + mobile, + software: ctx.renderer.softwareAdapter !== undefined, + }); + const loading = createLoadingScreen(ctx); + const camera = ctx.camera as PerspectiveCamera; + setupCamera(camera); + ctx.add(camera); + const orbit = new OrbitFollow(); + const touchControls = + mobile && isTouchscreenAvailable() + ? ctx.entities.add("touch-controls", new TouchControls(camera)) + : undefined; + + const materials = createMaterials(); + const snow = createSnowfield(initial.depth, initial.hardness); + const startDepth = snow.depth; + const surface = createSnowSurface(snow); + ctx.add(surface.mesh); + ctx.add(surface.outskirts); + const scenery = createScenery(materials, snow.depth); + ctx.add(scenery.root); + + // Trees and rocks stop the explorer and the ball; the field's edge is a fence nobody sees. + for (const obstacle of scenery.obstacles) { + if (Math.abs(obstacle.x) > FIELD_SIZE / 2 + 2 || Math.abs(obstacle.z) > FIELD_SIZE / 2 + 2) + continue; + new RigidBody3D({ + physics: ctx.physics, + position: { x: obstacle.x, y: obstacle.height / 2, z: obstacle.z }, + shape: CollisionShape3D.capsule( + Math.max(0, obstacle.height / 2 - obstacle.radius), + obstacle.radius, + ), + type: "fixed", + }); + } + const half = FIELD_SIZE / 2; + for (const [x, z, width, depth] of [ + [0, -half - 0.5, FIELD_SIZE + 2, 1], + [0, half + 0.5, FIELD_SIZE + 2, 1], + [-half - 0.5, 0, 1, FIELD_SIZE + 2], + [half + 0.5, 0, 1, FIELD_SIZE + 2], + ] as const) + new RigidBody3D({ + physics: ctx.physics, + position: { x, y: 1, z }, + shape: CollisionShape3D.box(width, 6, depth), + type: "fixed", + }); + + // The snow binding owns the surface collider and presses solved contacts into the field. + const snowPhysics = attachSnowPhysics({ + collisionLayer: SNOW_LAYER, + physics: ctx.physics, + snow, + }); + // The explorer walks on a coarse copy of the ground plus fresh snow; only it collides with it. + const walk = (depth: number) => { + const field = walkSurface(depth); + return new RigidBody3D({ + collisionLayer: WALK_LAYER, + collisionMask: EXPLORER_LAYER, + physics: ctx.physics, + position: { x: 0, y: 0, z: 0 }, + shape: CollisionShape3D.heightfield(field.rows, field.columns, field.toColliderHeights(), { + x: FIELD_SIZE, + y: 1, + z: FIELD_SIZE, + }), + type: "fixed", + }); + }; + let walkBody = walk(snow.depth); + + const ballObject = createBall(materials, BALL_RADIUS); + ballObject.position.set(2.2, snow.heightAt(2.2, -2) + BALL_RADIUS + 0.05, -2); + const ball = new RigidBody3D({ + mass: BALL_MASS, + object: ballObject, + physics: ctx.physics, + shape: CollisionShape3D.sphere(BALL_RADIUS), + }); + ctx.add(ballObject); + const crateObject = createCrate(materials, CRATE); + crateObject.position.set(-2.6, snow.heightAt(-2.6, -2.4) + CRATE.y / 2 + 0.05, -2.4); + crateObject.rotation.y = 0.5; + const crate = new RigidBody3D({ + mass: 25, + object: crateObject, + physics: ctx.physics, + shape: CollisionShape3D.box(CRATE.x, CRATE.y, CRATE.z), + }); + ctx.add(crateObject); + const logObject = createLog(materials, LOG.halfHeight, LOG.radius); + logObject.position.set(-1.6, snow.heightAt(-1.6, 2.8) + LOG.radius + 0.05, 2.8); + logObject.rotation.set(Math.PI / 2, 0, 0.6); + const log = new RigidBody3D({ + mass: 20, + object: logObject, + physics: ctx.physics, + shape: CollisionShape3D.capsule(LOG.halfHeight, LOG.radius), + }); + ctx.add(logObject); + for (const body of [ball, crate, log]) snowPhysics.add(body); + + const weather = createWeather({ + flakes: mobile ? 6_000 : 16_000, + particles: (options) => new GPUParticles3D(options), + }); + ctx.add(weather.snowfall); + for (const burst of weather.bursts) ctx.add(burst); + const audio = new SnowAudio(camera, (name) => ctx.assets.audio(name)); + ctx.entities.add("audio", audio.bus); + + const recentSteps: IFootstep[] = []; + let lastStep: IFootstep = { + angle: Math.PI, + penetration: 0, + side: 1, + speed: 0, + x: 0, + y: 0, + z: 2, + }; + const explorer = new Explorer(ctx.physics, snow, materials, { x: 0, z: 0 }, (step) => { + if (step.penetration <= 0) return; + lastStep = step; + recentSteps.push(step); + if (recentSteps.length > 8) recentSteps.shift(); + weather.burst(step, step.penetration); + audio.footstep(step); + }); + ctx.add(explorer.model.root); + // A short arrival trail, so the snow's memory is the first thing on screen. + for (let index = 0; index < 14; index += 1) { + const z = 6.4 - index * 0.46; + const x = 0.35 * Math.sin(z * 0.7) + (index % 2 ? -STANCE : STANCE); + const angle = Math.PI + Math.cos(z * 0.7) * 0.2; + const penetration = snow.stamp({ + area: BOOT_AREA, + footprint: bootPrint, + load: 80 * 9.81, + rotation: angle, + x, + z, + }); + lastStep = { angle, penetration, side: index % 2 ? -1 : 1, speed: 0, x, y: 0, z }; + } + + // ---- what a playtest reads back ---- + const telemetry = { + airborneLoads: 0, + dropped: false, + kickedAt: undefined as { x: number; z: number } | undefined, + path: [] as Array<{ x: number; z: number }>, + turned: 0, + /** Ground the ball's centre covered since the kick, step by step. */ + rolled: 0, + /** Farthest the ball got from where it was kicked. */ + reach: 0, + lastRotation: { w: 1, x: 0, y: 0, z: 0 }, + lastPosition: { x: 0, y: 0, z: 0 }, + lastClearance: 0, + }; + // Read the solver's own pose: the drawn ball is synced from it later in the frame, and one + // step stale is ten centimetres at the speed a dropped ball lands. + const solvedBall = () => + ctx.physics.simulation.readBodyTransform?.(ball.body.id) ?? { + position: ballObject.position, + rotation: ballObject.quaternion, + }; + const ballState = () => { + const p = solvedBall().position; + const inside = Math.abs(p.x) < half && Math.abs(p.z) < half; + const ground = inside ? snow.heightAt(p.x, p.z) : 0; + // Clearance is the closest the sphere's underside comes to the snow anywhere beneath it, + // not just under its centre: a ball rolling over the lip of its crater is touching the rim + // while its centre stands well above the crater floor. + let clearance = Number.POSITIVE_INFINITY; + for (const ring of [0, 0.5, 0.85]) { + for (let step = 0; step < (ring === 0 ? 1 : 8); step += 1) { + const angle = (step / 8) * Math.PI * 2; + const x = clampToField(p.x + Math.cos(angle) * ring * BALL_RADIUS); + const z = clampToField(p.z + Math.sin(angle) * ring * BALL_RADIUS); + const underside = p.y - Math.sqrt(1 - ring * ring) * BALL_RADIUS; + clearance = Math.min(clearance, underside - snow.heightAt(x, z)); + } + } + return { + clearance, + gap: p.y - BALL_RADIUS - ground, + sink: inside ? snow.sample(p.x, p.z).indent : 0, + }; + }; + ctx.entities.add("ball", { + object: ballObject, + debug: () => { + const { clearance, gap, sink } = ballState(); + const kick = telemetry.kickedAt; + const p = ballObject.position; + const travelled = kick === undefined ? 0 : Math.hypot(p.x - kick.x, p.z - kick.z); + // Every recorded point of the path since the kick must sit in pressed snow. + let trackGaps = 0; + for (const point of telemetry.path) + if (snow.sample(point.x, point.z).indent < 0.004) trackGaps += 1; + return { + airborne: clearance > 0.05, + clearance, + airborneLoads: telemetry.airborneLoads, + dropped: telemetry.dropped, + gap, + kicked: kick !== undefined, + load: snowPhysics.loadOf(ball), + position: [p.x, p.y, p.z], + // Turn times radius over the distance actually covered: 1 is rolling without slipping. + reach: telemetry.reach, + rollRatio: + telemetry.rolled > 0.05 ? (telemetry.turned * BALL_RADIUS) / telemetry.rolled : 0, + settled: Math.abs(gap) <= 0.02 && sink > 0.02, + sink, + trackGaps, + trackSamples: telemetry.path.length, + travelled, + }; + }, + }); + const pressedUnder = (object: Object3D) => { + const p = object.position; + return Math.abs(p.x) < half && Math.abs(p.z) < half ? snow.sample(p.x, p.z).indent : 0; + }; + ctx.entities.add("crate", { + object: crateObject, + debug: () => ({ load: snowPhysics.loadOf(crate), sink: pressedUnder(crateObject) }), + }); + ctx.entities.add("log", { + object: logObject, + debug: () => ({ load: snowPhysics.loadOf(log), sink: pressedUnder(logObject) }), + }); + ctx.entities.add("player", explorer); + // The first print of the arrival trail: nothing walks back over it unless steered there. + const trailStart = { x: 0.35 * Math.sin(6.4 * 0.7) + STANCE, z: 6.4 }; + ctx.entities.add("snow", { + debug: () => ({ + trailPressed: snow.sample(trailStart.x, trailStart.z).indent > 0.01, + ...snowPhysics.observe(), + activeCells: snow.activeCells, + depth: snow.depth, + memoryBytes: snow.memoryBytes, + pendingRows: surface.pendingRows, + renderError: surface.renderError(), + steps: snow.steps, + }), + }); + ctx.entities.add("weather", { debug: () => ({ storm: weather.storm, wind: weather.wind }) }); + + // The surface view frames the newest print far enough from the explorer to be seen past them. + const surfaceTarget = () => { + const print = + [...recentSteps] + .reverse() + .find( + (step) => Math.hypot(step.x - explorer.position.x, step.z - explorer.position.z) > 1.2, + ) ?? lastStep; + return { + x: print.x, + y: snow.heightAt(clampToField(print.x), clampToField(print.z)) + 0.12, + z: print.z, + }; + }; + // ---- verbs: one function per action, shared by keys and the HUD ---- + let toastId = 0; + let toast = ""; + const say = (message: string) => { + toast = message; + toastId += 1; + }; + let view = 0; + // The scene owns this; state only mirrors it, so a key and the panel never race the bridge. + let autoExplore = initial.autoExplore; + let blizzard = initial.blizzard; + const act = (command: Command): void => { + const state = ctx.state.getState(); + if (command === "reset") { + snow.reset(); + placeBody(ctx, snow, ball, { x: 2.2, z: -2 }, BALL_RADIUS + 0.02); + placeBody(ctx, snow, crate, { x: -2.6, z: -2.4 }, CRATE.y / 2 + 0.02); + placeBody(ctx, snow, log, { x: -1.6, z: 2.8 }, LOG.radius + 0.02); + telemetry.kickedAt = undefined; + telemetry.path.length = 0; + lastStep = { ...lastStep, penetration: 0 }; + say("A fresh blanket of snow. All footprints cleared."); + } else if (command === "drop") { + const ahead = { + x: explorer.position.x + Math.sin(explorer.heading) * 1.8, + z: explorer.position.z + Math.cos(explorer.heading) * 1.8, + }; + placeBody(ctx, snow, ball, ahead, 1.6); + telemetry.dropped = true; + telemetry.airborneLoads = 0; + telemetry.kickedAt = undefined; + telemetry.path.length = 0; + say("Ball dropped. Watch it settle into the snow."); + } else if (command === "kick") { + const p = ballObject.position; + let dx = p.x - explorer.position.x; + let dz = p.z - explorer.position.z; + const length = Math.hypot(dx, dz) || 1; + dx /= length; + dz /= length; + // A kick lifts as well as pushes: a dropped ball can sit ten centimetres down in its own + // crater, and a purely sideways shove only rolls it up the wall and back in. + ball.applyImpulse({ x: dx * KICK, y: KICK * 0.4, z: dz * KICK }); + telemetry.kickedAt = { x: p.x, z: p.z }; + telemetry.path.length = 0; + telemetry.turned = 0; + telemetry.rolled = 0; + telemetry.reach = 0; + say("Ball pushed. It rolls through the powder and carves its own track."); + } else if (command === "view") { + view = (view + 1) % VIEWS.length; + ctx.state.set({ view: VIEWS[view] }); + if (VIEWS[view] === "surface") { + // Look back at the print from beyond it, so the explorer is never between. + const print = surfaceTarget(); + orbit.yaw = Math.atan2(print.x - explorer.position.x, print.z - explorer.position.z); + } + } else if (command === "auto") { + autoExplore = !autoExplore; + say(autoExplore ? "Auto-explore on." : "You are in control. Hold Shift to run."); + } else if (command === "blizzard") { + blizzard = !blizzard; + say( + blizzard + ? "Blizzard building — wind and visibility are changing." + : "Returning to soft snowfall.", + ); + } else if (command === "mute") { + audio.setMuted(!audio.muted); + ctx.state.set({ muted: audio.muted }); + } + }; + + let refill = 0; + ctx.afterPhysics((dt) => { + // Solve → (this) consume contacts once, deform, refill → collider installed before the next + // step. The renderer collects what changed and draws it before the frame. + surface.collect(); + refill += dt; + if (refill >= REFILL_INTERVAL) { + const state = ctx.state.getState(); + const deposition = ((2 + 18 * weather.storm) * state.snowfall * state.recovery) / 3_600_000; + snow.recover(refill, deposition, weather.wind * weather.storm); + refill = 0; + surface.collect(); + } + snowPhysics.step(dt); + // Ball telemetry: a load while the ball was clearly above the snow for the whole step — at + // its start and at its end — would be a contact nothing touched. A step that lands or + // kicks off legitimately touches at one end of it. + const clearance = ballState().clearance; + if (Math.min(clearance, telemetry.lastClearance) > 0.05 && snowPhysics.loadOf(ball) > 0) + telemetry.airborneLoads += 1; + telemetry.lastClearance = clearance; + const solved = solvedBall(); + const rotation = solved.rotation; + const previous = telemetry.lastRotation; + const dot = Math.min( + 1, + Math.abs( + previous.x * rotation.x + + previous.y * rotation.y + + previous.z * rotation.z + + previous.w * rotation.w, + ), + ); + const p = solved.position; + const moved = telemetry.lastPosition; + if (telemetry.kickedAt !== undefined) { + telemetry.turned += 2 * Math.acos(dot); + telemetry.rolled += Math.hypot(p.x - moved.x, p.z - moved.z); + telemetry.reach = Math.max( + telemetry.reach, + Math.hypot(p.x - telemetry.kickedAt.x, p.z - telemetry.kickedAt.z), + ); + // Only where the ball is on the snow: a hop off the crater lip presses nothing beneath it. + const last = telemetry.path[telemetry.path.length - 1]; + if ( + clearance <= 0.01 && + (last === undefined || Math.hypot(p.x - last.x, p.z - last.z) > 0.1) + ) + telemetry.path.push({ x: p.x, z: p.z }); + } + telemetry.lastRotation = { w: rotation.w, x: rotation.x, y: rotation.y, z: rotation.z }; + telemetry.lastPosition = { x: p.x, y: p.y, z: p.z }; + }); + ctx.beforeRender(() => surface.refresh()); + + let waypoint = 0; + let pointer: { x: number; y: number } | undefined; + return (frameCtx, dt) => { + loading.update(); + const input = frameCtx.input; + for (const command of commands.splice(0)) act(command); + for (const name of ["reset", "drop", "kick", "view", "auto", "blizzard", "mute"] as const) + if (input.justPressed(name)) act(name); + if (input.justPressed("compaction")) + frameCtx.state.set({ compaction: !frameCtx.state.getState().compaction }); + + const state = frameCtx.state.getState(); + if (state.depth !== snow.depth) { + snow.setDepth(state.depth); + walkBody.dispose(); + walkBody = walk(snow.depth); + surface.outskirts.position.y = snow.depth - startDepth; + } + snow.hardness = state.hardness; + surface.setCompactionView(state.compaction); + + // Steering: the stick or the keys take over from auto-explore. + const stick = touchControls?.update(input.raw.pointers, frameCtx.viewport.size).move; + const keys = input.vector("move"); + const sideways = keys.x + (stick?.x ?? 0); + const forward = keys.y + (stick?.y ?? 0); + if (autoExplore && Math.hypot(sideways, forward) > 0.1) { + autoExplore = false; + say( + touchControls === undefined + ? "You are in control. Hold Shift to run." + : "You are in control.", + ); + } + let move: { x: number; z: number }; + if (autoExplore) { + const [tx, tz] = ROUTE[waypoint] ?? [0, 0]; + const dx = tx - explorer.position.x; + const dz = tz - explorer.position.z; + const distance = Math.hypot(dx, dz); + if (distance < 0.85) waypoint = (waypoint + 1) % ROUTE.length; + move = { x: dx / Math.max(0.01, distance), z: dz / Math.max(0.01, distance) }; + } else { + const yaw = orbit.yaw; + move = { + x: Math.cos(yaw) * sideways - Math.sin(yaw) * forward, + z: -Math.sin(yaw) * sideways - Math.cos(yaw) * forward, + }; + } + explorer.update(dt, move, input.pressed("run"), state.hardness); + + weather.update( + dt, + explorer.position, + frameCtx.viewport.size.height / (2 * Math.tan((camera.fov * Math.PI) / 360)), + { ...state, blizzard }, + ); + lights.update(explorer.position, weather.storm); + stormSky(weather.storm); + surface.setStorm(weather.storm); + audio.update(weather.wind, weather.storm); + + // Camera: drag to orbit (desktop), scroll or pinch to zoom. + const raw = input.raw.pointer; + if (touchControls === undefined && raw.down) { + if (pointer !== undefined) + orbit.drag(raw.position.x - pointer.x, raw.position.y - pointer.y); + pointer = { x: raw.position.x, y: raw.position.y }; + } else pointer = undefined; + orbit.zoom(input.axis("zoom") * 16); + orbit.setView(state.view); + const target = + state.view === "surface" + ? surfaceTarget() + : { + x: explorer.position.x, + y: explorer.position.y + (state.view === "overhead" ? 0.25 : 0.8), + z: explorer.position.z, + }; + orbit.update(camera, target, (x, z) => snow.heightAt(clampToField(x), clampToField(z)), dt); + + const { gap, sink } = ballState(); + frameCtx.state.set({ + autoExplore, + blizzard, + ballGap: gap, + ballLoad: snowPhysics.loadOf(ball), + ballSink: sink, + contacts: snow.steps, + lastSink: lastStep.penetration, + muted: audio.muted, + probe: probeAround(snow, lastStep), + speed: explorer.speed, + storm: weather.storm, + toast, + toastId, + windNow: weather.wind, + }); + }; + } +} + +function clampToField(value: number): number { + const half = FIELD_SIZE / 2 - 1e-3; + return Math.max(-half, Math.min(half, value)); +} + +/** The indentation under the last footprint, in the boot's own frame, for the HUD's probe. */ +function probeAround(snow: SnowField, step: IFootstep): number[] { + const values: number[] = []; + const cos = Math.cos(step.angle); + const sin = Math.sin(step.angle); + for (let row = 0; row < PROBE_ROWS; row += 1) { + for (let column = 0; column < PROBE_COLUMNS; column += 1) { + const u = (column / (PROBE_COLUMNS - 1) - 0.5) * 0.56; + const v = (0.5 - row / (PROBE_ROWS - 1)) * 0.75; + const sample = snow.sample(step.x + u * cos + v * sin, step.z - u * sin + v * cos); + values.push(Math.round((sample.indent - sample.bank) * 1000) / 1000); + } + } + return values; +} diff --git a/packages/create-threenative/templates/snow/src/state.ts b/packages/create-threenative/templates/snow/src/state.ts new file mode 100644 index 0000000000..860db38313 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/state.ts @@ -0,0 +1,46 @@ +export type CameraView = "follow" | "surface" | "overhead"; + +export type GameState = { + uiReady: boolean; + paused: boolean; + /** The explorer walks the glade on its own until a movement key or the stick takes over. */ + autoExplore: boolean; + blizzard: boolean; + /** 0 clear .. 1 full blizzard, eased, so the HUD and the weather agree mid-transition. */ + storm: number; + /** Flake density, 0..1. */ + snowfall: number; + /** Powder depth in metres. */ + depth: number; + /** Falling speed of a flake in still air, m/s. */ + fallSpeed: number; + /** 0..1 snow hardness. */ + hardness: number; + /** Base wind in m/s before gusts and the storm. */ + wind: number; + /** The wind actually blowing this frame, m/s. */ + windNow: number; + /** How many times faster than real time fresh snow refills tracks. */ + recovery: number; + compaction: boolean; + muted: boolean; + view: CameraView; + /** Contacts the field has taken since the last reset — footsteps and bodies together. */ + contacts: number; + /** The last footfall's penetration, metres. */ + lastSink: number; + speed: number; + /** Indentation under the last footfall, `PROBE_COLUMNS` x `PROBE_ROWS`, row-major, metres. */ + probe: number[]; + /** How far the ball's underside sits above (positive) or in (negative) the snow, metres. */ + ballGap: number; + /** Indentation under the ball, metres. */ + ballSink: number; + /** Load the ball's solved contacts pressed into the snow last step, newtons. */ + ballLoad: number; + toast: string; + toastId: number; +}; + +export const PROBE_COLUMNS = 15; +export const PROBE_ROWS = 20; diff --git a/packages/create-threenative/templates/snow/src/style.css b/packages/create-threenative/templates/snow/src/style.css new file mode 100644 index 0000000000..81f24cd504 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/style.css @@ -0,0 +1,143 @@ +@import "tailwindcss"; + +@theme { + --font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --color-ink: #0c1c28; + --color-panel: rgb(18 36 47 / 0.8); + --color-text: #eef5f7; + --color-muted: #a7bac4; + --color-line: rgb(210 230 240 / 0.18); + --color-aqua: #b6eadd; + --color-aqua-ink: #163a37; +} + +:root, +body, +#root { + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + color: var(--color-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +#root { + background: #aabfcf; +} + +/* Every control shows where keyboard focus is. */ +:where(button, input, select, summary, [tabindex]):focus-visible { + outline: 2px solid var(--color-aqua); + outline-offset: 3px; +} + +/* A slider whose filled part is the accent: `--fill` is set from the value. */ +.snow-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 24px; + background: transparent; + cursor: pointer; +} +.snow-range::-webkit-slider-runnable-track { + height: 3px; + border-radius: 99px; + background: linear-gradient( + to right, + var(--color-aqua) var(--fill, 50%), + rgb(177 205 218 / 0.18) var(--fill, 50%) + ); +} +.snow-range::-moz-range-track { + height: 3px; + border-radius: 99px; + background: rgb(177 205 218 / 0.18); +} +.snow-range::-moz-range-progress { + height: 3px; + border-radius: 99px; + background: var(--color-aqua); +} +.snow-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + margin-top: -5.5px; + border-radius: 50%; + border: 2px solid #12303a; + background: var(--color-aqua); +} +.snow-range::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid #12303a; + background: var(--color-aqua); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; + } +} + +/* Boot failures are a page state, so the generated game owns their presentation. */ +[data-threenative-canvas-error="true"] { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + box-sizing: border-box; + padding: clamp(1.5rem, 8vw, 6rem); + overflow: auto; + background: #0c1c28; + color: #eef5f7; + font-family: inherit; + font-size: clamp(1rem, 2vw, 1.5rem); + line-height: 1.5; + text-align: center; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* The debug surface is framework plumbing; its default presentation is yours to change. */ +[data-threenative-debug-overlay="true"] { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 50; + max-width: 24rem; + padding: 0.75rem; + pointer-events: none; + background: rgb(2 6 23 / 90%); + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 30%); + color: #cffafe; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +[data-threenative-debug-overlay="true"] table { + width: 100%; + table-layout: fixed; +} + +[data-threenative-debug-overlay="true"] th, +[data-threenative-debug-overlay="true"] td { + padding-right: 1rem; + text-align: left; +} + +[data-threenative-debug-overlay="true"] td:last-child { + overflow-wrap: anywhere; + word-break: break-all; +} diff --git a/packages/create-threenative/templates/snow/src/terrain.ts b/packages/create-threenative/templates/snow/src/terrain.ts new file mode 100644 index 0000000000..e65e3f34bf --- /dev/null +++ b/packages/create-threenative/templates/snow/src/terrain.ts @@ -0,0 +1,64 @@ +import { Heightfield, SnowField } from "@threenative/core/world"; + +/** The playable snowfield is a square this many metres across, centred on the origin. */ +export const FIELD_SIZE = 24; +/** + * Samples along each side. 401 puts one every 6 cm, fine enough for a boot's heel and toe and + * the gap between two prints to read. The same samples are the drawn mesh and the collider, so + * raising it costs triangles and collider rebuilds as well. + */ +export const FIELD_SAMPLES = 401; +/** + * What the explorer's capsule stands on: the ground plus fresh snow, one sample every 24 cm. + * Rapier sweeps a character's capsule against every collider triangle under it, so on the 6 cm + * snow collider each step cost ~0.9 ms (measured); here it is a tenth of that. The body's drawn + * height and its boots already come from the canonical snow, so nothing visible changes. + */ +export const WALK_SAMPLES = 101; +/** Collision layers: the deforming snow, the explorer's walk surface, and the explorer. */ +export const SNOW_LAYER = 0b10; +export const WALK_LAYER = 0b100; +export const EXPLORER_LAYER = 0b1000; + +function smoothstep(edge0: number, edge1: number, value: number): number { + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +/** Rolling ground under the snow: gentle swells in the glade, hills beyond it. */ +export function terrainHeight(x: number, z: number): number { + const radius = Math.hypot(x, z); + const hills = + smoothstep(14, 42, radius) * (0.8 + 0.85 * Math.sin(x * 0.085 + 1) * Math.cos(z * 0.075)); + return ( + 0.2 * Math.sin(x * 0.16) * Math.cos(z * 0.12) + + 0.09 * Math.sin(x * 0.39 + z * 0.21) + + 0.07 * Math.cos(z * 0.53 - x * 0.24) + + hills + ); +} + +/** The ground plus `depth` of fresh snow at `WALK_SAMPLES`, in collider order. */ +export function walkSurface(depth: number): Heightfield { + return Heightfield.fromSampler({ + columns: WALK_SAMPLES, + depth: FIELD_SIZE, + origin: { x: 0, z: 0 }, + rows: WALK_SAMPLES, + sampleHeight: (x, z) => terrainHeight(x, z) + depth, + width: FIELD_SIZE, + }); +} + +/** The canonical terrain and the deformable snow on it. Response coefficients are the study's. */ +export function createSnowfield(depth: number, hardness: number): SnowField { + const field = Heightfield.fromSampler({ + columns: FIELD_SAMPLES, + depth: FIELD_SIZE, + origin: { x: 0, z: 0 }, + rows: FIELD_SAMPLES, + sampleHeight: terrainHeight, + width: FIELD_SIZE, + }); + return new SnowField({ depth, field, hardness }); +} diff --git a/packages/create-threenative/templates/snow/src/ui/App.tsx b/packages/create-threenative/templates/snow/src/ui/App.tsx new file mode 100644 index 0000000000..0e6197c81b --- /dev/null +++ b/packages/create-threenative/templates/snow/src/ui/App.tsx @@ -0,0 +1,15 @@ +import type { IGame } from "@threenative/core"; +import type { IPhysicsContext } from "@threenative/physics"; +import { DebugOverlay, GameCanvas } from "@threenative/ui"; +import type { GameState } from "../state.js"; +import { GameUi } from "./GameUi.js"; + +export function App({ game }: { game: IGame }) { + return ( +
+ + + +
+ ); +} diff --git a/packages/create-threenative/templates/snow/src/ui/Controls.tsx b/packages/create-threenative/templates/snow/src/ui/Controls.tsx new file mode 100644 index 0000000000..3920339dac --- /dev/null +++ b/packages/create-threenative/templates/snow/src/ui/Controls.tsx @@ -0,0 +1,322 @@ +import type { useUiIntent } from "@threenative/ui"; +import { type ReactNode, useEffect, useState } from "react"; +import type { GameState } from "../state.js"; +import { Icon } from "./icons.js"; + +type Send = ReturnType; + +interface IRangeProps { + readonly id: keyof GameState & string; + readonly label: string; + readonly value: number; + readonly min: number; + readonly max: number; + readonly step: number; + readonly format: (value: number) => string; + readonly send: Send; +} + +/** + * A labelled slider. It keeps its own value while dragged — the game's state comes back on a + * ~100 ms bridge, and a slider bound straight to it would jump under the thumb. + */ +function Range({ id, label, value, min, max, step, format, send }: IRangeProps) { + const [local, setLocal] = useState(value); + const [dragging, setDragging] = useState(false); + useEffect(() => { + if (!dragging) setLocal(value); + }, [dragging, value]); + const fill = `${((local - min) / (max - min)) * 100}%`; + return ( +
+ + setDragging(false)} + onChange={(event) => { + const next = Number(event.currentTarget.value); + setLocal(next); + send("set", { key: id, value: next }); + }} + onPointerDown={() => setDragging(true)} + onPointerUp={() => setDragging(false)} + step={step} + style={{ "--fill": fill } as React.CSSProperties} + type="range" + value={local} + /> +
+ ); +} + +function Toggle({ + label, + pressed, + onToggle, +}: { + readonly label: string; + readonly pressed: boolean; + readonly onToggle: () => void; +}) { + return ( +
+ {label} + {/* The 44 px button is the hit area; the switch drawn inside it stays small. */} + +
+ ); +} + +function ActionButton({ + children, + grow = false, + label, + onClick, + primary = false, + pressed, + title, +}: { + readonly children: ReactNode; + /** Take the row's spare width, so a row of buttons spans its panel. */ + readonly grow?: boolean; + readonly label?: string; + readonly onClick: () => void; + readonly primary?: boolean; + readonly pressed?: boolean; + readonly title?: string; +}) { + return ( + + ); +} + +/** Drop the ball in front of the explorer, or push it away. */ +export function BallActions({ send }: { readonly send: Send }) { + return ( +
+ send("drop")} + title="Drop the ball in front of you (F)" + > + + Drop + + send("kick")} + title="Push the ball away from you (G)" + > + + Push + +
+ ); +} + +/** Weather, snow and session controls: the panel on the left, or the sheet on a phone. */ +export function Controls({ state, send }: { readonly state: GameState; readonly send: Send }) { + const percent = (value: number) => `${Math.round(value * 100)}%`; + return ( +
+
+
+ Weather + + B + +
+
+ Weather + {([false, true] as const).map((blizzard) => ( + + ))} +
+ + `${Math.round(value * 100)} cm`} + id="depth" + label="Powder depth" + max={0.55} + min={0.06} + send={send} + step={0.01} + value={state.depth} + /> + `${value.toFixed(2)} m/s`} + id="fallSpeed" + label="Flake fall speed" + max={1.6} + min={0.2} + send={send} + step={0.05} + value={state.fallSpeed} + /> +
+ + Snow physics + + + + `${value.toFixed(1)} m/s`} + id="wind" + label="Base wind" + max={12} + min={0} + send={send} + step={0.1} + value={state.wind} + /> + `${value}×`} + id="recovery" + label="Deposition time scale" + max={1200} + min={0} + send={send} + step={30} + value={state.recovery} + /> + send("set", { key: "compaction", value: !state.compaction })} + pressed={state.compaction} + /> + {state.compaction ? ( +

+ + packed + + + fresh powder + +

+ ) : null} +

+ Fresh snow fills tracks over time. The time scale speeds up deposition, never walking or + falling flakes. +

+
+
+
+ send("auto")} + pressed={state.autoExplore} + primary={state.autoExplore} + > + + + {state.autoExplore ? "Auto-explore on" : "Auto-explore off"} + + + send(state.paused ? "resume" : "pause")} + pressed={state.paused} + title="Pause simulation" + > + + + send("reset")} + title="Clear all footprints (R)" + > + + +
+
+ +
+
+ ); +} diff --git a/packages/create-threenative/templates/snow/src/ui/GameUi.tsx b/packages/create-threenative/templates/snow/src/ui/GameUi.tsx new file mode 100644 index 0000000000..0d27f08729 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/ui/GameUi.tsx @@ -0,0 +1,354 @@ +import { UiLayer, useUiIntent, useUiState } from "@threenative/ui"; +import { useEffect, useState } from "react"; +import { type GameState, PROBE_COLUMNS } from "../state.js"; +import { BallActions, Controls } from "./Controls.js"; +import { Icon, Mark } from "./icons.js"; + +const VIEW_LABELS: Record = { + follow: "Follow view", + overhead: "Overhead view", + surface: "Surface view", +}; + +function IconButton({ + icon, + label, + onClick, + pressed, + className = "", +}: { + readonly icon: Parameters[0]["name"]; + readonly label: string; + readonly onClick: () => void; + readonly pressed?: boolean; + readonly className?: string; +}) { + return ( + + ); +} + +/** The last footprint's floor, drawn as a small depth map: darker blue is deeper. */ +function Probe({ values }: { readonly values: readonly number[] }) { + const rows = Math.max(1, Math.ceil(values.length / PROBE_COLUMNS)); + return ( + + + {/* The grid is coarse; a slight blur reads as a pressed surface rather than pixels. */} + + + + + + {values.map((value, index) => { + const shade = Math.max(0, Math.min(1, value / 0.18)); + const bank = Math.max(0, Math.min(1, -value / 0.045)); + const r = Math.round(214 - shade * 109 + bank * 33); + const g = Math.round(230 - shade * 92 + bank * 21); + const b = Math.round(240 - shade * 64 + bank * 15); + return ( + + ); + })} + + + ); +} + +function Metric({ + label, + value, + unit, +}: { readonly label: string; readonly value: string; readonly unit?: string }) { + return ( +
+
{label}
+
+ {value} + {unit === undefined ? null : ( + {unit} + )} +
+
+ ); +} + +function Help({ onClose }: { readonly onClose: () => void }) { + useEffect(() => { + const close = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", close); + return () => window.removeEventListener("keydown", close); + }, [onClose]); + return ( +
+ +

+ Leave a little trace. +

+

+ Walk the glade, push the ball through the powder, or switch on the blizzard and watch the + tracks fill. +

+ + + {[ + ["Walk / run", "WASD or arrows / Shift"], + ["Orbit / zoom", "Drag / scroll"], + ["Drop / push the ball", "F / G"], + ["Blizzard / camera", "B / C"], + ["Auto-explore / compaction", "P / V"], + ["Clear footprints / sound", "R / M"], + ].map(([action, keys]) => ( + + + + + ))} + +
{action}{keys}
+

+ The snow is a deformable heightfield with approximate compaction and deposition, not a + granular solver. +

+ +
+
+ ); +} + +function Toast({ state }: { readonly state: GameState }) { + const [shown, setShown] = useState(false); + useEffect(() => { + if (state.toastId === 0) return; + setShown(true); + const timer = setTimeout(() => setShown(false), 2800); + return () => clearTimeout(timer); + }, [state.toastId]); + return ( + + {state.toast} + + ); +} + +function Hud() { + const state = useUiState(); + const send = useUiIntent(); + const [menuOpen, setMenuOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + if (state === undefined) return null; + const weatherWords = state.blizzard ? "Incoming gusts · spindrift" : "Light air · dry powder"; + return ( +
+ {/* Vignette and storm veil: atmosphere only, never in the way of a click. */} +
+ {/* A soft scrim under the header and readouts keeps small light text legible over snow. */} +
+
+ +
+
+
+ +
+
+
SNOW GLADE
+
+ DEFORMABLE SNOW STUDY +
+
+
+
+
+ + {state.paused ? "SIMULATION PAUSED" : "LIVE SIMULATION"} +
+ send("mute")} + pressed={!state.muted} + /> + setHelpOpen(true)} + /> + setMenuOpen(!menuOpen)} + pressed={menuOpen} + /> +
+
+ + {/* Left column: title, then the controls. On a phone the controls live behind the menu. */} +
+
+
+ A STUDY IN WINTER +
+

+ Snow, +
{" "} + in motion. +

+

+ Fresh powder. Every step remembered. A ball that carves its own track. +

+
+
+ +

+ Take over at any time with WASD. +

+
+
+ +
+
+ {state.windNow.toFixed(1)} + m/s +
+
WIND AT SNOWFIELD
+
+ {weatherWords} +
+
+ + + +
+
+ THREENATIVE / SNOW STUDY +
+
+ {[ + ["W A S D", "Walk"], + ["SHIFT", "Run"], + ["F / G", "Ball"], + ["B", "Blizzard"], + ["DRAG", "Orbit"], + ].map(([key, action]) => ( + + + {key} + + {action} + + ))} +
+
+ +
+
+ + + {helpOpen ? setHelpOpen(false)} /> : null} +
+ ); +} + +export function GameUi() { + return ( + + + + ); +} diff --git a/packages/create-threenative/templates/snow/src/ui/icons.tsx b/packages/create-threenative/templates/snow/src/ui/icons.tsx new file mode 100644 index 0000000000..3305ad2ab5 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/ui/icons.tsx @@ -0,0 +1,54 @@ +// Stroke icons drawn at 24 px; colour follows the text around them. +const PATHS = { + blizzard: "M3 8h13a3 3 0 1 0-3-3M2 12h17a3 3 0 1 1-3 3M4 17h6", + camera: "M3 8h4l2-3h6l2 3h4v12H3V8zM12 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", + drop: "M12 3v12M7 10l5 5 5-5M5 20h14", + help: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9.5 9a2.5 2.5 0 1 1 4.5 1.5c-1.4 1-2 1-2 3M12 17h.01", + menu: "M4 6h16M4 12h16M4 18h16", + muted: "M11 5L6 9H3v6h3l5 4V5zM16 9l5 6m0-6l-5 6", + pause: "M8 5v14M16 5v14", + play: "M8 5l11 7-11 7V5z", + push: "M4 12h12M12 7l5 5-5 5M20 5v14", + reset: "M3 10a9 9 0 1 1 2 8M3 4v6h6", + snow: "M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9M9 5l3 3 3-3m-6 14l3-3 3 3", + sound: "M11 5L6 9H3v6h3l5 4V5zM15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14", + close: "M6 6l12 12M18 6L6 18", +} as const; + +export type IconName = keyof typeof PATHS; + +export function Icon({ + name, + className = "h-[17px] w-[17px]", +}: { name: IconName; className?: string }) { + return ( + + ); +} + +/** The snowflake mark beside the title. */ +export function Mark() { + return ( + + ); +} diff --git a/packages/create-threenative/templates/snow/src/ui/main.tsx b/packages/create-threenative/templates/snow/src/ui/main.tsx new file mode 100644 index 0000000000..1750a0cb31 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/ui/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from "react-dom/client"; +import { GameUi } from "./GameUi.js"; +import "../style.css"; + +const root = document.getElementById("tn-ui"); +if (root === null) throw new Error("Missing #tn-ui element."); +createRoot(root).render(); diff --git a/packages/create-threenative/templates/snow/tailwind.config.ts b/packages/create-threenative/templates/snow/tailwind.config.ts new file mode 100644 index 0000000000..f35b8cb31d --- /dev/null +++ b/packages/create-threenative/templates/snow/tailwind.config.ts @@ -0,0 +1,2 @@ +// Tailwind 4 is CSS-first. Keep this file for familiar project discovery. +export default {}; diff --git a/packages/create-threenative/templates/snow/threenative.config.ts b/packages/create-threenative/templates/snow/threenative.config.ts new file mode 100644 index 0000000000..dd11d3c54c --- /dev/null +++ b/packages/create-threenative/templates/snow/threenative.config.ts @@ -0,0 +1,51 @@ +import type { IThreeNativeConfig } from "@threenative/core"; + +const config: IThreeNativeConfig = { + app: { + id: "com.threenative.__PROJECT_ID__", + name: "__PROJECT_NAME__", + version: "1.0.0", + build: 1, + icon: "public/icon.png", + icons: { web: { favicon: "public/favicon.svg" } }, + }, + display: { + orientation: "landscape", + fullscreen: true, + keepScreenOn: true, + maxFps: 60, + }, + window: { + title: "__PROJECT_NAME__", + width: 1280, + height: 720, + maximized: false, + resizable: true, + }, + bootSplash: { backgroundColor: "#0c1c28" }, + nativeEntry: "src/game.ts", + renderer: { + preferWebGPU: true, + resolutionScale: "auto", + // Multisampling resolves triangle edges. A cutout silhouette — foliage, a fence, hair — is + // carved inside the triangle by an alpha test, so it resolves through the coverage mask or + // not at all, which is what this spends the samples above on. It costs no target and no + // extra pass; set it false for a deliberately hard-edged look. `TN_ALPHA_ANTIALIASING` + // reports what it did, and says so when a single-sampled surface leaves it nothing to do. + alphaAntialiasing: true, + }, + ui: { renderer: "web" }, + // One asset tree, one compiler, one representation per artifact. Uncomment, then cook: + // threenative build --target android # cooks defaults.android; --profile beats it + // buildProfiles: { + // defaults: { android: "compact" }, + // profiles: { + // compact: { + // assets: { textures: { maxSize: 1024 }, models: { textures: { maxSize: 1024 } } }, + // } }, + // }, + // Contract, byte definitions, the build report: + // node_modules/create-threenative/agent-docs/references/build-profiles.md +}; + +export default config; diff --git a/packages/create-threenative/templates/snow/tsconfig.json b/packages/create-threenative/templates/snow/tsconfig.json new file mode 100644 index 0000000000..990e2cb6e1 --- /dev/null +++ b/packages/create-threenative/templates/snow/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "*.ts"] +} diff --git a/packages/create-threenative/templates/snow/vite.config.ts b/packages/create-threenative/templates/snow/vite.config.ts new file mode 100644 index 0000000000..e411e4f4a9 --- /dev/null +++ b/packages/create-threenative/templates/snow/vite.config.ts @@ -0,0 +1,37 @@ +import tailwindcss from "@tailwindcss/vite"; +import { watchAssets } from "@threenative/assets"; +import react from "@vitejs/plugin-react"; +import { createEngineFreshnessPlugin, createWebBrandPlugin } from "create-threenative"; +import { defineConfig } from "vite"; +import type { Plugin } from "vite"; +import config from "./threenative.config.js"; + +function assetsWatchPlugin(): Plugin { + return { + name: "threenative-assets-watch", + apply: "serve", + async configureServer(server) { + const handle = watchAssets({ config: config.assets, cwd: server.config.root }); + server.httpServer?.once("close", () => handle.close()); + // Nothing is served before the first cook settles: until it does, `public/` has no + // `assets.manifest.json` and every asset answers from its source fallback. + await handle.ready; + }, + }; +} + +export default defineConfig({ + plugins: [ + createEngineFreshnessPlugin(), + createWebBrandPlugin(), + react(), + tailwindcss(), + assetsWatchPlugin(), + ], + server: { + watch: { + ignored: ["**/artifacts/**", "**/screenshots/**", "**/playtests/**"], + usePolling: true, + }, + }, +}); diff --git a/packages/create-threenative/templates/starter/package.json b/packages/create-threenative/templates/starter/package.json index bf1a6bad3d..90e58d1a8d 100644 --- a/packages/create-threenative/templates/starter/package.json +++ b/packages/create-threenative/templates/starter/package.json @@ -33,7 +33,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/create-threenative/templates/tower-defense/package.json b/packages/create-threenative/templates/tower-defense/package.json index e76c78f105..53870872fa 100644 --- a/packages/create-threenative/templates/tower-defense/package.json +++ b/packages/create-threenative/templates/tower-defense/package.json @@ -31,7 +31,7 @@ "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.7", + "create-threenative": "0.2.8", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", diff --git a/packages/physics/__tests__/actuation.spec.ts b/packages/physics/__tests__/actuation.spec.ts index bf03c14bf9..c05d6e90da 100644 --- a/packages/physics/__tests__/actuation.spec.ts +++ b/packages/physics/__tests__/actuation.spec.ts @@ -110,6 +110,27 @@ describe("dynamic body actuation", () => { expect(rigidBodyObject(body).position.x).toBeGreaterThan(0.5); }); + it("applies an off-centre force for one step only: its torque does not linger", async () => { + const { ctx, step } = await world(); + const body = crate(ctx); + body.applyForceAtPoint({ x: 0, y: 0, z: 30 }, { x: 0.5, y: 0.5, z: 0 }); + step(2); + /** Angle the body turns over the next ten frames, in radians. */ + const spin = () => { + const before = rigidBodyObject(body).quaternion.clone(); + step(10); + return before.angleTo(rigidBodyObject(body).quaternion); + }; + const early = spin(); + step(10); + const late = spin(); + // One step of torque leaves a steady spin (the windows may differ by a fixed step); a torque + // the backend kept would spin it up — unfixed, the late window turned 3.3x the early one. + expect(early).toBeGreaterThan(0.01); + expect(late / early).toBeGreaterThan(0.7); + expect(late / early).toBeLessThan(1.3); + }); + // Rapier discards actuation on a sleeping body, which is the same silent no-op class as the // discarded transform write this API exists to replace. it("wakes a settled body rather than discarding the impulse", async () => { diff --git a/packages/physics/__tests__/native-contract.spec.ts b/packages/physics/__tests__/native-contract.spec.ts index 79ddf83c3a..dddea9b1c3 100644 --- a/packages/physics/__tests__/native-contract.spec.ts +++ b/packages/physics/__tests__/native-contract.spec.ts @@ -7,7 +7,11 @@ import { CollisionShape3D } from "../src/CollisionShape3D.js"; import { PhysicsDirectSpaceState3D } from "../src/PhysicsDirectSpaceState3D.js"; import { RigidBody3D } from "../src/RigidBody3D.js"; import * as webEntry from "../src/index.js"; -import { type INativeSimulation, createNativePhysicsSimulation } from "../src/native/host.js"; +import { + type INativeShapeDescriptor, + type INativeSimulation, + createNativePhysicsSimulation, +} from "../src/native/host.js"; import { Area3D as NativeArea3D, CharacterBody3D as NativeCharacterBody3D, @@ -143,17 +147,107 @@ describe("native physics contract", () => { position: { x: 0, y: 0, z: 0 }, rotation: { w: 1, x: 0, y: 0, z: 0 }, sensor: false, - shape: CollisionShape3D.heightfield(2, 2, new Float32Array(4), { - x: 1, - y: 1, - z: 1, - }).descriptor, + shape: { + collisionLayer: 1, + collisionMask: 0xffff, + kind: "convexHull", + sensor: false, + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]), + x: 0, + y: 0, + z: 0, + }, type: "fixed", }), - ).toThrow(/TN_NATIVE_PHYSICS_SHAPE_UNSUPPORTED.*heightfield/); + ).toThrow(/TN_NATIVE_PHYSICS_SHAPE_UNSUPPORTED.*convexHull/); expect(createBody).not.toHaveBeenCalled(); }); + it("hands a heightfield to the native host as its own column-major samples", () => { + const createBody = vi.fn((_options: { shape: INativeShapeDescriptor }) => 3); + const native = createNativePhysicsSimulation( + { createBody } as unknown as INativeSimulation, + "0.30.0", + ); + const heights = new Float32Array([0, 1, 2, 3, 4, 5]); + native.createBody({ + mass: 0, + position: { x: 0, y: 0, z: 0 }, + rotation: { w: 1, x: 0, y: 0, z: 0 }, + sensor: false, + shape: CollisionShape3D.heightfield(2, 3, heights, { x: 4, y: 2, z: 6 }).descriptor, + type: "fixed", + }); + const shape = createBody.mock.calls[0]?.[0].shape; + // The same buffer, grid and extent the web backend passes Rapier, so both build one surface. + expect(shape).toMatchObject({ columns: 3, kind: "heightfield", rows: 2 }); + expect(shape?.heights).toBe(heights); + expect(shape?.scale).toEqual({ x: 4, y: 2, z: 6 }); + expect(() => + native.createBody({ + mass: 0, + position: { x: 0, y: 0, z: 0 }, + rotation: { w: 1, x: 0, y: 0, z: 0 }, + sensor: false, + shape: { + ...CollisionShape3D.heightfield(2, 3, heights, { x: 4, y: 2, z: 6 }).descriptor, + heights: new Float32Array(5), + }, + type: "fixed", + }), + ).toThrow(/TN_NATIVE_PHYSICS_SHAPE_INVALID/); + }); + + it("reads native contacts and refreshes a collider shape, failing closed on an old runtime", () => { + const readContacts = vi.fn(() => 2); + const setColliderShape = vi.fn(); + const createBody = vi.fn(() => 0); + const native = createNativePhysicsSimulation( + { createBody, readContacts, setColliderShape } as unknown as INativeSimulation, + "0.30.0", + ); + const surface = native.createBody({ + mass: 0, + position: { x: 0, y: 0, z: 0 }, + rotation: { w: 1, x: 0, y: 0, z: 0 }, + sensor: false, + shape: CollisionShape3D.heightfield(2, 2, new Float32Array(4), { x: 1, y: 1, z: 1 }) + .descriptor, + type: "fixed", + }); + const ids = new Uint32Array([4]); + const buffer = new Float32Array(16); + expect(native.readContacts?.(surface.collider, ids, buffer)).toBe(2); + expect(readContacts).toHaveBeenCalledWith(0, ids, buffer); + native.setColliderShape?.( + surface.collider, + CollisionShape3D.heightfield(2, 2, new Float32Array(4), { x: 1, y: 1, z: 1 }).descriptor, + ); + expect(setColliderShape.mock.calls[0]?.[1]).toMatchObject({ kind: "heightfield" }); + expect(() => + native.setColliderShape?.(surface.collider, CollisionShape3D.sphere(1).descriptor), + ).toThrow(/TN_NATIVE_PHYSICS_SHAPE_UNSUPPORTED/); + expect(() => native.readContacts?.({ id: 99, raw: undefined }, ids, buffer)).toThrow( + /not a live collider/, + ); + + const old = createNativePhysicsSimulation( + { createBody } as unknown as INativeSimulation, + "0.30.0", + ); + const oldSurface = old.createBody({ + mass: 0, + position: { x: 0, y: 0, z: 0 }, + rotation: { w: 1, x: 0, y: 0, z: 0 }, + sensor: false, + shape: CollisionShape3D.sphere(1).descriptor, + type: "fixed", + }); + expect(() => old.readContacts?.(oldSurface.collider, ids, buffer)).toThrow( + /TN_NATIVE_PHYSICS_CONTACTS_MISSING/, + ); + }); + it("hands a trimesh body's vertices and indices to the native host", () => { const createBody = vi.fn(() => 7); const native = createNativePhysicsSimulation( diff --git a/packages/physics/__tests__/snow-contacts.spec.ts b/packages/physics/__tests__/snow-contacts.spec.ts new file mode 100644 index 0000000000..d667158b18 --- /dev/null +++ b/packages/physics/__tests__/snow-contacts.spec.ts @@ -0,0 +1,456 @@ +import * as RAPIER from "@dimforge/rapier3d-compat"; +import type { ICtx } from "@threenative/core"; +import { Heightfield, SnowField } from "@threenative/core/world"; +import { Object3D } from "three"; +import { afterEach, describe, expect, it } from "vitest"; +import "../src/index.js"; +import { CollisionShape3D } from "../src/CollisionShape3D.js"; +import { RigidBody3D } from "../src/RigidBody3D.js"; +import { type IPhysicsContext, rapier } from "../src/plugin.js"; +import { + type ISnowPhysicsBinding, + attachSnowPhysics, + boxFootprint, + capsuleFootprint, +} from "../src/snow.js"; + +/** + * These runs drive real Rapier. Nothing here mocks the solver: the acceptance is that a solved + * contact, read back from the narrow phase, is what deforms the snow, and that the deformed + * surface is what the next step collides against. + */ +const FIXED_STEP = 1 / 60; +const SNOW_DEPTH = 0.28; +const BALL_RADIUS = 0.25; +const BALL_MASS = 10; +const SETTLE_TOLERANCE = 0.02; + +type PhysicsCtx = ICtx, IPhysicsContext>; + +const plugins: Array> = []; +const disposers: Array<() => void> = []; + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + for (const plugin of plugins.splice(0)) plugin.dispose?.({} as PhysicsCtx); +}); + +async function world(): Promise { + await RAPIER.init(); + const plugin = rapier({ gravity: { x: 0, y: -9.81, z: 0 } }); + const ctx = { physics: undefined } as unknown as PhysicsCtx; + await plugin.setup?.(ctx); + plugins.push(plugin); + return ctx; +} + +function snowField( + options: { depth?: number; originX?: number; slopeX?: number; slopeZ?: number } = {}, +): SnowField { + const field = Heightfield.fromSampler({ + columns: 129, + depth: 6, + origin: { x: options.originX ?? 0, z: 0 }, + rows: 129, + sampleHeight: (x, z) => x * (options.slopeX ?? 0) + z * (options.slopeZ ?? 0), + width: 6, + }); + return new SnowField({ depth: options.depth ?? SNOW_DEPTH, field }); +} + +async function scene(options: Parameters[0] = {}) { + const ctx = await world(); + const snow = snowField(options); + const binding = attachSnowPhysics({ physics: ctx.physics, snow }); + disposers.push(() => binding.dispose()); + return { binding, ctx, snow }; +} + +function ball(physics: IPhysicsContext, x: number, y: number, z: number): RigidBody3D { + const object = new Object3D(); + object.position.set(x, y, z); + return new RigidBody3D({ + mass: BALL_MASS, + object, + physics, + shape: CollisionShape3D.sphere(BALL_RADIUS), + }); +} + +function run( + ctx: PhysicsCtx, + bindings: ISnowPhysicsBinding | readonly ISnowPhysicsBinding[], + steps: number, + onStep?: (index: number) => void, +): void { + const list = Array.isArray(bindings) ? bindings : [bindings]; + for (let index = 0; index < steps; index += 1) { + ctx.physics.simulation.step(FIXED_STEP); + for (const binding of list as readonly ISnowPhysicsBinding[]) binding.step(FIXED_STEP); + onStep?.(index); + } +} + +function solved(ctx: PhysicsCtx, body: RigidBody3D) { + const transform = ctx.physics.simulation.readBodyTransform?.(body.body.id); + if (transform === undefined) throw new Error("the solver reported no transform for the body"); + return transform; +} + +function sleeping(ctx: PhysicsCtx, body: RigidBody3D): boolean { + const buffer = new Float32Array(64); + const count = ctx.physics.simulation.readBodySleepStates(buffer); + for (let index = 0; index < count; index += 1) + if (buffer[index * 2] === body.body.id) return buffer[index * 2 + 1] === 1; + return false; +} + +/** Angle between two unit quaternions, in radians. */ +function turned(a: { x: number; y: number; z: number; w: number }, b: typeof a): number { + const dot = Math.min(1, Math.abs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w)); + return 2 * Math.acos(dot); +} + +describe("sphere on deformable snow under real physics", () => { + it("does not stamp while airborne, then settles on the surface it made", async () => { + const { binding, ctx, snow } = await scene(); + const body = ball(ctx.physics, 0, 1.6, 0); + binding.add(body); + + run(ctx, binding, 15, () => expect(binding.loadOf(body)).toBe(0)); + expect(solved(ctx, body).position.y).toBeGreaterThan(SNOW_DEPTH + BALL_RADIUS + 0.3); + expect(snow.steps).toBe(0); + expect(snow.activeCells).toBe(0); + + let pressed = 0; + run(ctx, binding, 285, () => { + pressed += binding.supported; + }); + const rest = solved(ctx, body).position; + expect(pressed).toBeGreaterThan(0); + expect(snow.sample(0, 0).indent).toBeGreaterThan(0.02); + // The sphere's underside and the surface it made agree within the acceptance tolerance. + expect(Math.abs(rest.y - BALL_RADIUS - snow.heightAt(rest.x, rest.z))).toBeLessThan( + SETTLE_TOLERANCE, + ); + // The load came from the solver and roughly carries the sphere's weight. + run(ctx, binding, 1); + const observed = binding.observe(); + expect(observed.loadProvenance).toBe("solver-impulse-per-step"); + expect(binding.loadOf(body)).toBe(observed.load); + if (observed.supported > 0) { + expect(observed.load).toBeGreaterThan(BALL_MASS * 9.81 * 0.5); + expect(observed.load).toBeLessThan(BALL_MASS * 9.81 * 2); + } + }); + + it("rolls a pushed sphere through a connected circular track", async () => { + const { binding, ctx, snow } = await scene(); + const body = ball(ctx.physics, 0, 0.6, -2); + binding.add(body); + run(ctx, binding, 90); + expect(snow.sample(0, -2).indent).toBeGreaterThan(0.01); + + body.applyImpulse({ x: 0, y: 0, z: 8 }); + let previous = solved(ctx, body).rotation; + let rotation = 0; + run(ctx, binding, 180, () => { + const current = solved(ctx, body).rotation; + rotation += turned(previous, { ...current }); + previous = { ...current }; + }); + const end = solved(ctx, body).position; + const travelled = end.z + 2; + expect(travelled).toBeGreaterThan(1); + expect(Math.abs(end.x)).toBeLessThan(0.05); + // The solver rolled it: the turn it made matches the distance over its radius. + expect(rotation).toBeGreaterThan((travelled / BALL_RADIUS) * 0.8); + expect(rotation).toBeLessThan((travelled / BALL_RADIUS) * 1.2); + + // Every sample along the path is pressed: the track is connected, not a row of holes. + for (let index = 0; index <= 40; index += 1) { + const z = -2 + (index / 40) * travelled; + expect(snow.sample(0, z).indent).toBeGreaterThan(0.005); + } + // And it is circular: pressed under the centre line, untouched beyond the radius. + expect(snow.sample(BALL_RADIUS * 0.5, -1).indent).toBeGreaterThan(0.002); + expect(snow.sample(BALL_RADIUS * 1.6, -1).indent).toBeLessThan(0.0005); + }); + + it("presses with the vertical part of a contact, so a sideways shove does not dig a pit", async () => { + const { binding, ctx, snow } = await scene(); + const body = ball(ctx.physics, 0, 0.6, 0); + binding.add(body); + run(ctx, binding, 120); + const resting = snow.sample(0, 0).indent; + // A hard horizontal shove drives the ball into the wall of its own crater for a step or two. + body.applyImpulse({ x: 0, y: 0, z: 18 }); + let deepest = 0; + run(ctx, binding, 6, () => { + for (let z = -0.3; z <= 0.6; z += 0.05) deepest = Math.max(deepest, snow.sample(0, z).indent); + }); + expect(deepest).toBeLessThan(resting + 0.01); + }); + + it("keeps the collider on the canonical surface with at most one step of lag", async () => { + const { binding, ctx, snow } = await scene(); + const body = ball(ctx.physics, 0.3, 0.8, 0.2); + binding.add(body); + run(ctx, binding, 120, () => { + // Solve, consume contacts once, deform, then the collider matches before the next step. + expect(binding.surfaceVersion).toBe(snow.version); + expect(binding.observe().colliderError).toBeLessThanOrEqual(0.001); + }); + + // A game-side reset lands between steps; the next consumed step installs it. + snow.reset(); + expect(binding.observe().colliderError).toBeGreaterThan(0.001); + run(ctx, binding, 1); + expect(binding.observe().colliderError).toBeLessThanOrEqual(0.001); + + // Triangle interiors of the collider agree with the canonical surface. + run(ctx, binding, 120); + let worst = 0; + for (let index = 0; index < 60; index += 1) { + const x = 0.3 + Math.cos(index) * 0.37 * ((index % 7) / 7); + const z = 0.2 + Math.sin(index * 1.3) * 0.37 * ((index % 5) / 5); + const hit = ctx.physics.simulation.intersectRay({ + collisionMask: 0xffff, + from: { x, y: 3, z }, + to: { x, y: -1, z }, + }); + if (hit === undefined || hit.body.id !== binding.surface.id) continue; + worst = Math.max(worst, Math.abs(3 - hit.distance - snow.heightAt(x, z))); + } + expect(worst).toBeLessThan(0.01); + }); + + it("wakes a resting sphere when the snow beneath it changes depth", async () => { + const { binding, ctx, snow } = await scene({ depth: 0.4 }); + const body = ball(ctx.physics, 0, 0.8, 0); + binding.add(body); + let slept = false; + run(ctx, binding, 900, () => { + slept ||= sleeping(ctx, body); + }); + expect(slept).toBe(true); + snow.setDepth(0.1); + run(ctx, binding, 180); + const rest = solved(ctx, body).position; + expect(Math.abs(rest.y - BALL_RADIUS - snow.heightAt(rest.x, rest.z))).toBeLessThan( + SETTLE_TOLERANCE, + ); + }); + + it("integrates the same under 30, 60 and 120 Hz presentation", async () => { + const indents: number[] = []; + for (const hz of [30, 60, 120]) { + const { binding, ctx, snow } = await scene(); + binding.deposition = 0.002; + binding.wind = 0.4; + const body = ball(ctx.physics, 0, 0.7, 0); + binding.add(body); + // A fixed-step accumulator fed by frames of the presentation rate. + let accumulator = 0; + for (let frame = 0; frame < 3 * hz; frame += 1) { + accumulator += 1 / hz; + while (accumulator >= FIXED_STEP - 1e-9) { + ctx.physics.simulation.step(FIXED_STEP); + binding.step(FIXED_STEP); + accumulator -= FIXED_STEP; + } + } + indents.push(snow.sample(0, 0).indent); + } + expect(indents[0]).toBeGreaterThan(0.01); + expect(Math.max(...indents) - Math.min(...indents)).toBeLessThanOrEqual(0.001); + }); +}); + +describe("box and capsule contacts", () => { + it("presses a box's resting face and a fallen capsule's trough", async () => { + const { binding, ctx, snow } = await scene(); + const boxObject = new Object3D(); + boxObject.position.set(-1.5, 1, 0); + // Turned a quarter around y, so a print aligned to the body proves orientation is used. + boxObject.rotation.y = Math.PI / 2; + const box = new RigidBody3D({ + mass: 60, + object: boxObject, + physics: ctx.physics, + shape: CollisionShape3D.box(1.0, 0.4, 0.5), + }); + const capsuleObject = new Object3D(); + capsuleObject.position.set(1.5, 1, 0); + // Laid on its side so its spine runs along z, as a fallen body's does. + capsuleObject.rotation.x = Math.PI / 2; + const capsule = new RigidBody3D({ + mass: 60, + object: capsuleObject, + physics: ctx.physics, + shape: CollisionShape3D.capsule(0.5, 0.2), + }); + binding.add(box); + binding.add(capsule); + run(ctx, binding, 400); + + // The box's 1.0 m side now runs along z and its 0.5 m side along x. + expect(snow.sample(-1.5, 0).indent).toBeGreaterThan(0.01); + expect(snow.sample(-1.5, 0.4).indent).toBeGreaterThan(0.01); + expect(snow.sample(-1.5 + 0.4, 0).indent).toBeLessThan(0.002); + expect(snow.sample(-1.5, 0.75).indent).toBeLessThan(0.002); + + // The capsule's trough runs along its spine: both ends pressed, the flanks not. + expect(snow.sample(1.5, 0).indent).toBeGreaterThan(0.01); + expect(snow.sample(1.5, -0.5).indent).toBeGreaterThan(0.01); + expect(snow.sample(1.5, 0.5).indent).toBeGreaterThan(0.01); + expect(snow.sample(1.5 + 0.4, 0).indent).toBeLessThan(0.002); + + // Both still rest on the surface they made. + expect(solved(ctx, box).position.y - 0.2 - snow.heightAt(-1.5, 0)).toBeLessThan(0.03); + expect(solved(ctx, capsule).position.y - 0.2 - snow.heightAt(1.5, 0)).toBeLessThan(0.03); + }); + + it("presses an upright capsule as a disc, not a trough", async () => { + const { binding, ctx, snow } = await scene(); + const object = new Object3D(); + object.position.set(0, 1.2, 0); + const capsule = new RigidBody3D({ + mass: 60, + object, + physics: ctx.physics, + shape: CollisionShape3D.capsule(0.5, 0.2), + }); + binding.add(capsule); + run(ctx, binding, 20); + // Within the first contacts it is still upright: a round print, no trough along any axis. + run(ctx, binding, 10); + expect(snow.steps).toBeGreaterThan(0); + expect(snow.sample(0, 0).indent).toBeGreaterThan(0.005); + expect(snow.sample(0, 0.45).indent).toBeLessThan(0.001); + expect(snow.sample(0.45, 0).indent).toBeLessThan(0.001); + }); + + it("orients the surface collider on the field's own axes and scale", async () => { + for (const slope of [ + { slopeX: 0.15, slopeZ: 0 }, + { slopeX: 0, slopeZ: 0.15 }, + ]) { + const { binding, ctx, snow } = await scene(slope); + // Placed up the slope: a transposed or mis-scaled collider would put it at the wrong height. + const object = new Object3D(); + object.position.set(slope.slopeX > 0 ? 1.8 : 0, 2, slope.slopeZ > 0 ? 1.8 : 0); + const high = new RigidBody3D({ + mass: 20, + object, + physics: ctx.physics, + shape: CollisionShape3D.box(0.4, 0.4, 0.4), + }); + binding.add(high); + run(ctx, binding, 240); + const position = solved(ctx, high).position; + // A box resting on a 0.15 slope: centre stands half its height over the surface, tilted. + const expected = snow.heightAt(position.x, position.z) + 0.2 / Math.cos(Math.atan(0.15)); + expect(Math.abs(position.y - expected)).toBeLessThan(0.03); + // It stayed far enough up the slope that a transposed collider would be > 0.09 m off. + expect(slope.slopeX > 0 ? position.x : position.z).toBeGreaterThan(0.6); + } + }); +}); + +describe("snow binding lifecycle", () => { + it("survives recovery and a field reset without a stale collider or fall-through", async () => { + const { binding, ctx, snow } = await scene(); + binding.deposition = 0.002; + binding.wind = 0.5; + const body = ball(ctx.physics, 0, 0.6, 0); + binding.add(body); + run(ctx, binding, 300); + const surface = binding.surface; + expect(snow.activeCells).toBeGreaterThan(0); + + run(ctx, binding, 600); + expect(binding.surface).toBe(surface); + expect(binding.surfaceVersion).toBe(snow.version); + + snow.reset(); + run(ctx, binding, 240); + expect(binding.surface).toBe(surface); + const rest = solved(ctx, body).position; + expect(Math.abs(rest.y - BALL_RADIUS - snow.heightAt(rest.x, rest.z))).toBeLessThan( + SETTLE_TOLERANCE, + ); + }); + + it("routes contacts only to the field a body touches when two share a world", async () => { + const ctx = await world(); + const west = snowField({ originX: -4 }); + const east = snowField({ originX: 4 }); + const westBinding = attachSnowPhysics({ physics: ctx.physics, snow: west }); + const eastBinding = attachSnowPhysics({ physics: ctx.physics, snow: east }); + disposers.push( + () => westBinding.dispose(), + () => eastBinding.dispose(), + ); + const body = ball(ctx.physics, -4, 0.8, 0); + westBinding.add(body); + eastBinding.add(body); + run(ctx, [westBinding, eastBinding], 240); + expect(west.sample(-4, 0).indent).toBeGreaterThan(0.01); + expect(east.steps).toBe(0); + expect(east.activeCells).toBe(0); + expect(eastBinding.contacts).toBe(0); + }); + + it("ignores bodies resting on scenery and stops watching a removed body", async () => { + const { binding, ctx, snow } = await scene(); + new RigidBody3D({ + physics: ctx.physics, + position: { x: 1, y: 0.6, z: 0 }, + shape: CollisionShape3D.box(1, 0.2, 1), + type: "fixed", + }); + const onPlatform = ball(ctx.physics, 1, 1.2, 0); + binding.add(onPlatform); + run(ctx, binding, 120); + expect(snow.steps).toBe(0); + + const onSnow = ball(ctx.physics, -1, 0.6, 0); + binding.add(onSnow); + run(ctx, binding, 60); + const steps = snow.steps; + expect(steps).toBeGreaterThan(0); + binding.remove(onSnow); + run(ctx, binding, 60); + expect(snow.steps).toBe(steps); + }); + + it("refuses a shape it cannot profile and names the ones it can", async () => { + const { binding, ctx } = await scene(); + const mesh = new RigidBody3D({ + mass: 5, + object: new Object3D(), + physics: ctx.physics, + shape: CollisionShape3D.heightfield(3, 3, new Float32Array(9), { x: 1, y: 1, z: 1 }), + }); + expect(() => binding.add(mesh)).toThrow(/supported shapes are sphere, box, capsule/); + expect(() => binding.add(mesh, boxFootprint(0.5, 0.5))).not.toThrow(); + expect(() => binding.add(mesh, capsuleFootprint(0.4, 0.15))).not.toThrow(); + }); + + it("rejects malformed tuning and steps, and disposes idempotently", async () => { + const { binding, ctx, snow } = await scene(); + expect(() => binding.step(0)).toThrow(/delta time/); + expect(() => binding.step(Number.NaN)).toThrow(/delta time/); + expect(() => attachSnowPhysics({ loadScale: Number.NaN, physics: ctx.physics, snow })).toThrow( + /loadScale/, + ); + binding.wind = Number.POSITIVE_INFINITY; + expect(() => binding.step(FIXED_STEP)).toThrow(/wind/); + expect(() => capsuleFootprint(0.4, 0)).toThrow(/radius/); + expect(() => boxFootprint(-1, 1)).toThrow(/halfWidth/); + binding.dispose(); + expect(() => binding.dispose()).not.toThrow(); + expect(() => binding.step(FIXED_STEP)).not.toThrow(); + }); +}); diff --git a/packages/physics/src/index.ts b/packages/physics/src/index.ts index 04dd239cef..749c926c7c 100644 --- a/packages/physics/src/index.ts +++ b/packages/physics/src/index.ts @@ -100,6 +100,13 @@ export type { * @example const cloth = new SoftBody3D(mesh, { ...options, collision: softBodyCollision(wall) }); */ export { softBodyCollision } from "./softbody-collision.js"; +// Capability metadata for the snow binding lives on its declarations in snow.ts. +export { attachSnowPhysics, boxFootprint, capsuleFootprint } from "./snow.js"; +export type { + ISnowPhysicsBinding, + ISnowPhysicsObservation, + ISnowPhysicsOptions, +} from "./snow.js"; export type { IIntersectPointOptions, IIntersectRayOptions, diff --git a/packages/physics/src/native/host.ts b/packages/physics/src/native/host.ts index e158a2792b..38401c5e40 100644 --- a/packages/physics/src/native/host.ts +++ b/packages/physics/src/native/host.ts @@ -35,13 +35,18 @@ import { } from "../simulation.js"; export interface INativeShapeDescriptor { - readonly kind: "box" | "capsule" | "sphere" | "trimesh"; + readonly kind: "box" | "capsule" | "heightfield" | "sphere" | "trimesh"; readonly x: number; readonly y: number; readonly z: number; /** Present for `trimesh` only: a flat xyz vertex buffer and a flat triangle-index buffer. */ readonly vertices?: Float32Array; readonly indices?: Uint32Array; + /** Present for `heightfield` only: column-major samples, the grid and its full extent. */ + readonly heights?: Float32Array; + readonly rows?: number; + readonly columns?: number; + readonly scale?: { readonly x: number; readonly y: number; readonly z: number }; collisionLayer: number; collisionMask: number; sensor: boolean; @@ -147,6 +152,9 @@ export interface INativeSimulation { readVehicleState?(id: number, output: Float32Array): number; resetVehicle?(id: number, position: IPhysicsVector3, yaw: number): void; readAreaIntersections(buffer: Uint32Array): number; + /** Optional so old runtimes fail loudly through the adapter instead of reporting no contact. */ + readContacts?(target: number, candidates: Uint32Array, output: Float32Array): number; + setColliderShape?(id: number, shape: INativeShapeDescriptor): void; intersectRay( query: INativeRayQuery, output?: Float32Array, @@ -256,13 +264,41 @@ function primitiveShape(shape: IPhysicsShapeDescriptor): INativeShapeDescriptor } /** - * A body's shape, including the one concave shape a static level needs. + * A body's shape, including the concave shapes a level needs. * * A doorway, an arch or a floor with a hole cannot be a box, a ball or a capsule, so a trimesh is - * the only shape that keeps the opening the model actually has. Queries stay primitive-only: the - * native query path has no trimesh representation and says so instead of silently missing. + * the only shape that keeps the opening the model actually has. A heightfield crosses as its own + * samples, in the column-major order the web backend hands Rapier, so both runtimes build the same + * surface. Queries stay primitive-only: the native query path has no trimesh representation and + * says so instead of silently missing. */ function nativeBodyShape(shape: IPhysicsShapeDescriptor): INativeShapeDescriptor { + if (shape.kind === "heightfield") { + const { columns, heights, rows, scale } = shape; + if ( + rows === undefined || + columns === undefined || + heights === undefined || + scale === undefined || + heights.length !== rows * columns + ) + throw new Error( + "TN_NATIVE_PHYSICS_SHAPE_INVALID: heightfield requires rows x columns heights", + ); + return { + collisionLayer: shape.collisionLayer, + collisionMask: shape.collisionMask, + columns, + heights, + kind: "heightfield", + rows, + scale, + sensor: shape.sensor, + x: 0, + y: 0, + z: 0, + }; + } if (shape.kind === "trimesh") { if (shape.vertices === undefined || shape.indices === undefined) throw new Error("TN_NATIVE_PHYSICS_SHAPE_INVALID: trimesh requires vertices and indices"); @@ -659,6 +695,38 @@ export function createNativePhysicsSimulation( requirePhysicsSleepStateBuffer(buffer, bodyIds.size); return raw.readBodySleepStates(buffer); }, + readContacts: (target, colliders, buffer) => { + requireLive(); + if (raw.readContacts === undefined) + throw new Error("TN_NATIVE_PHYSICS_CONTACTS_MISSING: runtime ABI is too old"); + if (!(buffer instanceof Float32Array) || !(colliders instanceof Uint32Array)) + throw new Error( + "IPhysicsSimulation.readContacts requires Uint32Array ids and a Float32Array.", + ); + if (!bodyIds.has(target.id)) + throw new Error( + `IPhysicsSimulation contact target ${String(target.id)} is not a live collider.`, + ); + const count = raw.readContacts(target.id, colliders, buffer); + if (!Number.isInteger(count) || count < 0) + throw new Error("TN_NATIVE_PHYSICS_INVALID: contact read returned an invalid count"); + return count; + }, + setColliderShape: (collider, shape) => { + requireLive(); + if (raw.setColliderShape === undefined) + throw new Error("TN_NATIVE_PHYSICS_SHAPE_REFRESH_MISSING: runtime ABI is too old"); + if (!bodyIds.has(collider.id)) + throw new Error( + `IPhysicsSimulation shape target ${String(collider.id)} is not a live collider.`, + ); + if (shape.kind !== "heightfield") + throw new Error( + `TN_NATIVE_PHYSICS_SHAPE_UNSUPPORTED: native setColliderShape takes a heightfield, not ${shape.kind}`, + ); + raw.setColliderShape(collider.id, nativeBodyShape(shape)); + invalidateObservations(); + }, intersectRay: (value) => { requireLive(); const query = requirePhysicsRayQuery(value); diff --git a/packages/physics/src/native/index.ts b/packages/physics/src/native/index.ts index eb5c42d5e7..fbe0d25b32 100644 --- a/packages/physics/src/native/index.ts +++ b/packages/physics/src/native/index.ts @@ -30,6 +30,12 @@ export type { StaticColliderPredicate, } from "../static-colliders.js"; export { softBodyCollision } from "../softbody-collision.js"; +export { attachSnowPhysics, boxFootprint, capsuleFootprint } from "../snow.js"; +export type { + ISnowPhysicsBinding, + ISnowPhysicsObservation, + ISnowPhysicsOptions, +} from "../snow.js"; export type { IIntersectPointOptions, IIntersectRayOptions, diff --git a/packages/physics/src/simulation.ts b/packages/physics/src/simulation.ts index 86284c2377..7d862f702a 100644 --- a/packages/physics/src/simulation.ts +++ b/packages/physics/src/simulation.ts @@ -16,6 +16,16 @@ export const PHYSICS_COLLISION_EVENT_STRIDE = 4; /** One record is logical body id and a sleeping flag encoded as 0 or 1. */ export const PHYSICS_SLEEP_STATE_STRIDE = 2; + +/** + * One solved contact manifold. + * + * `[collider id, world x, y, z, normal x, y, z, impulse]`. The point is the mean of the + * manifold's solver contacts and the normal points from the read target toward the touching + * collider. The impulse is the step's summed normal impulse in newton-seconds, not a measured + * force: divide it by the step to estimate load, and say so. + */ +export const PHYSICS_CONTACT_STRIDE = 8; /** Caps native query buffers while remaining exactly representable by the native ABI. */ export const MAX_PHYSICS_QUERY_RESULTS = 1024; @@ -300,6 +310,30 @@ export interface IPhysicsSimulation { * the same tick, or copy the fields out before the step advances. */ areaIntersections?(id: number): ReadonlySet; + /** + * Persistent solved contacts between one target collider and a set of candidate colliders. + * + * Collision start/stop events say a pair began touching; they carry no point, normal or load, + * so a contact that persists cannot be observed through them. This reads the narrow phase's + * solved manifolds directly, which is what a deformation or support consumer needs. + * + * One `PHYSICS_CONTACT_STRIDE` record per solved manifold, written from index 0. Returns the + * total record count; when that exceeds the buffer's capacity only the first records were + * written, and the caller must grow the buffer and read again before the next step. A sleeping + * body was not solved and reports nothing. Optional: a backend with no narrow-phase access + * omits it, and a caller that needs contacts must fail closed instead of assuming support. + */ + readContacts?( + target: IPhysicsColliderHandle, + colliders: Uint32Array, + buffer: Float32Array, + ): number; + /** + * Replace a collider's shape in place, keeping its handle, owning body, filters and scene + * cleanup. Recreating a collider instead leaves every recorded identity stale. Bodies sleeping + * on the old shape must wake, since the new one may no longer support them. + */ + setColliderShape?(collider: IPhysicsColliderHandle, shape: IPhysicsShapeDescriptor): void; /** * Attach ray-cast wheels to a dynamic chassis. A vehicle lives and dies with its body, so * `removeBody()` releases its controller; there is no separate vehicle removal call. @@ -742,6 +776,7 @@ export function createWebPhysicsShape( shape.columns - 1, shape.heights, shape.scale, + rapier.HeightFieldFlags.FIX_INTERNAL_EDGES, ); } descriptor.setCollisionGroups(interactionGroups(shape.collisionLayer, shape.collisionMask)); @@ -884,6 +919,40 @@ function writeRapierTransformRecord( renderBuffer[offset + 7] = rotation.w; } +/** One `PHYSICS_CONTACT_STRIDE` record summarising a solved manifold. */ +function writeContactRecord( + buffer: Float32Array, + record: number, + colliderId: number, + manifold: rapier.TempContactManifold, + flipped: boolean, +): void { + const solved = manifold.numSolverContacts(); + let impulse = 0; + for (let point = 0; point < manifold.numContacts(); point += 1) + impulse += manifold.contactImpulse(point); + let x = 0; + let y = 0; + let z = 0; + for (let point = 0; point < solved; point += 1) { + const world = manifold.solverContactPoint(point); + x += world.x; + y += world.y; + z += world.z; + } + const normal = manifold.normal(); + const sign = flipped ? -1 : 1; + const offset = record * PHYSICS_CONTACT_STRIDE; + buffer[offset] = colliderId; + buffer[offset + 1] = x / solved; + buffer[offset + 2] = y / solved; + buffer[offset + 3] = z / solved; + buffer[offset + 4] = normal.x * sign; + buffer[offset + 5] = normal.y * sign; + buffer[offset + 6] = normal.z * sign; + buffer[offset + 7] = impulse; +} + /** Web adapter. It is the only implementation that names Rapier's JS objects. */ export function createWebPhysicsSimulation( options: IWebPhysicsSimulationOptions, @@ -1259,9 +1328,13 @@ export function createWebPhysicsSimulation( } } options.world.step(options.eventQueue); - // Rapier retains accumulated forces unless the caller clears them. The public seam is a - // fixed-step force, so clear it after every step to keep web and native actuation aligned. - for (const entry of bodies.values()) entry.body.resetForces(true); + // Rapier retains accumulated forces and torques unless the caller clears them. The public + // seam is a fixed-step force — an off-centre one adds a torque — so clear both after every + // step to keep web and native actuation aligned. + for (const entry of bodies.values()) { + entry.body.resetForces(true); + entry.body.resetTorques(true); + } dirtyBodies.clear(); }, readVisibleTransforms: (renderBuffer) => { @@ -1437,6 +1510,45 @@ export function createWebPhysicsSimulation( } return current; }, + readContacts: (target, colliders, buffer) => { + requireLive(); + if (!(buffer instanceof Float32Array)) + throw new Error("IPhysicsSimulation.readContacts requires a Float32Array buffer."); + if (!(colliders instanceof Uint32Array)) + throw new Error("IPhysicsSimulation.readContacts requires a Uint32Array of collider ids."); + // Seam ids are logical body ids, not Rapier handles: resolve through the registry. + const targetCollider = bodies.get(target.id)?.collider; + if (targetCollider === undefined) + throw new Error( + `IPhysicsSimulation contact target ${String(target.id)} is not a live collider.`, + ); + const capacity = Math.floor(buffer.length / PHYSICS_CONTACT_STRIDE); + let count = 0; + for (let index = 0; index < colliders.length; index += 1) { + const otherId = colliders[index] as number; + if (otherId === target.id) continue; + const entry = bodies.get(otherId); + // A sleeping body was not solved this step, so its stored impulses are not this step's. + if (entry === undefined || entry.body.isSleeping()) continue; + // A heightfield reports one candidate manifold per touched sub-shape and most carry no + // solver contact at all, so only manifolds the solver actually used are kept. + options.world.contactPair(targetCollider, entry.collider, (manifold, flipped) => { + if (manifold.numSolverContacts() === 0) return; + if (count < capacity) writeContactRecord(buffer, count, otherId, manifold, flipped); + count += 1; + }); + } + return count; + }, + setColliderShape: (collider, shape) => { + requireLive(); + const entry = bodies.get(collider.id); + if (entry === undefined) + throw new Error( + `IPhysicsSimulation shape target ${String(collider.id)} is not a live collider.`, + ); + entry.collider.setShape(createWebPhysicsShape(options.rapier, shape).shape); + }, createVehicle: (vehicleOptions) => { requireLive(); const requested = requirePhysicsVehicleCreateOptions(vehicleOptions); diff --git a/packages/physics/src/snow.ts b/packages/physics/src/snow.ts new file mode 100644 index 0000000000..6403709bc2 --- /dev/null +++ b/packages/physics/src/snow.ts @@ -0,0 +1,636 @@ +import { + type IHeightfieldRegionBounds, + type ISnowFootprint, + type ISnowFootprintSample, + type SnowField, + snowDiscFootprint, +} from "@threenative/core/world"; +import type { IPhysicsColliderHandle } from "./handles.js"; +import type { IPhysicsContext, PhysicsBody3D } from "./plugin.js"; +import { + type IPhysicsShapeDescriptor, + PHYSICS_CONTACT_STRIDE, + type PhysicsShapeKind, +} from "./simulation.js"; + +/** The smallest window covering both. */ +function unionBounds( + current: IHeightfieldRegionBounds | undefined, + next: IHeightfieldRegionBounds, +): IHeightfieldRegionBounds { + if (current === undefined) return next; + const column = Math.min(current.column, next.column); + const row = Math.min(current.row, next.row); + return { + column, + columns: Math.max(current.column + current.columns, next.column + next.columns) - column, + row, + rows: Math.max(current.row + current.rows, next.row + next.rows) - row, + }; +} + +const ZERO_SAMPLE: ISnowFootprintSample = { + bank: 0, + coverage: 0, + disturbance: 0, + relief: 0, + shape: 0, +}; + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function smoothstep(edge0: number, edge1: number, value: number): number { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function positive(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) + throw new Error(`attachSnowPhysics ${name} must be a positive finite number.`); + return value; +} + +/** + * A stadium contact: a rectangle of half-length `halfHeight` and half-width `radius`, capped by + * two half-discs of `radius`. The shape a capsule lies on, so a knocked-over body sinks along a + * line instead of at a point. Coverage is full across the body's own width and fades just + * outside it, so the body's edge never rests on a half-pressed rim; the bank rises beyond that. + * @situation let a fallen capsule, limb or barrel leave a linear imprint in snow + * @situation give a capsule a shape-appropriate snow contact instead of a sphere's dot + * @constraint radius and halfHeight are metres and never grow with load + * @example const footprint = capsuleFootprint(0.5, 0.2); + */ +export function capsuleFootprint(halfHeight: number, radius: number): ISnowFootprint { + positive(radius, "capsule radius"); + if (!Number.isFinite(halfHeight) || halfHeight < 0) + throw new Error("attachSnowPhysics capsule halfHeight must be a non-negative finite number."); + const softness = 0.2 * radius; + return { + extent: halfHeight + radius + 2 * softness, + sample: (x, z) => { + // Distance beyond the body: from the spine, a segment from -halfHeight to +halfHeight on z. + const along = clamp(z, -halfHeight, halfHeight); + return edgeSample(Math.hypot(x, z - along) - radius, softness); + }, + }; +} + +/** + * A rectangular contact: the face a box rests on, in the contact's own frame. Coverage is full + * across the face and fades just outside it; the bank rises beyond that. + * @situation let a crate, platform or plank press a rectangular pit into snow + * @situation imprint a box's own footprint rather than a circle around it + * @constraint halfWidth and halfDepth are metres; rotation comes from the contact, not the footprint + * @example const footprint = boxFootprint(0.4, 0.25); + */ +export function boxFootprint(halfWidth: number, halfDepth: number): ISnowFootprint { + positive(halfWidth, "box halfWidth"); + positive(halfDepth, "box halfDepth"); + const edge = 0.15 * Math.min(halfWidth, halfDepth); + return { + extent: Math.hypot(halfWidth + 2 * edge, halfDepth + 2 * edge), + sample: (x, z) => edgeSample(Math.max(Math.abs(x) - halfWidth, Math.abs(z) - halfDepth), edge), + }; +} + +/** + * One sample of a solid body's print by its distance `outside` the body's own outline: fully + * covered inside, fading to nothing over `edge` beyond it, then a bank over the next `edge`. + */ +function edgeSample(outside: number, edge: number): ISnowFootprintSample { + if (outside >= 2 * edge) return ZERO_SAMPLE; + if (outside >= edge) { + const bank = 1 - (outside - edge) / edge; + return { bank, coverage: 0, disturbance: bank * 0.5, relief: 0, shape: 1 }; + } + const coverage = outside <= 0 ? 1 : 1 - smoothstep(0, edge, outside); + return { bank: 0, coverage, disturbance: coverage, relief: 0, shape: 1 }; +} + +/** Shape kinds `attachSnowPhysics` can derive an automatic contact profile for. */ +const SUPPORTED_SHAPES: readonly PhysicsShapeKind[] = ["sphere", "box", "capsule"]; + +interface IQuaternion { + readonly x: number; + readonly y: number; + readonly z: number; + readonly w: number; +} + +/** A local unit axis rotated into world space. */ +function rotateAxis(q: IQuaternion, axis: 0 | 1 | 2): [number, number, number] { + const { w, x, y, z } = q; + if (axis === 0) return [1 - 2 * (y * y + z * z), 2 * (x * y + w * z), 2 * (x * z - w * y)]; + if (axis === 1) return [2 * (x * y - w * z), 1 - 2 * (x * x + z * z), 2 * (y * z + w * x)]; + return [2 * (x * z + w * y), 2 * (y * z - w * x), 1 - 2 * (x * x + y * y)]; +} + +interface IWatchedBody { + readonly body: PhysicsBody3D; + /** Load this body pressed into the snow in the last consumed step, newtons. */ + load: number; + readonly shape: IPhysicsShapeDescriptor | undefined; + readonly custom: ISnowFootprint | undefined; + readonly cache: Map; + impulse: number; +} + +/** + * The footprint a body presses with its current orientation, where it is centred, and its yaw. + * + * The footprint is centred under the body, never on the contact centroid: a rocking box touches + * one corner at a time, and centring its whole face on that corner would smear the print. A + * sphere is a disc whatever its rotation. A box presses whichever face is most nearly facing + * down; a capsule presses a stadium whose length is its spine's horizontal projection, so an + * upright capsule leaves a disc and a fallen one a trough. + */ +function orientedFootprint( + watched: IWatchedBody, + transform: IBodyPose, + cellSize: number, +): IOrientedFootprint { + const { position, rotation } = transform; + if (watched.custom !== undefined) { + const forward = rotateAxis(rotation, 2); + return { + footprint: watched.custom, + rotation: Math.atan2(forward[0], forward[2]), + x: position.x, + z: position.z, + }; + } + const shape = watched.shape; + if (shape === undefined) throw new Error("attachSnowPhysics watched body lost its shape."); + if (shape.kind === "box") return boxPrint(watched.cache, shape, transform); + if (shape.kind === "capsule") return capsulePrint(watched.cache, shape, transform, cellSize); + return { + footprint: cached(watched.cache, 0, () => snowDiscFootprint(shape.x)), + rotation: 0, + x: position.x, + z: position.z, + }; +} + +interface IBodyPose { + readonly position: { readonly x: number; readonly z: number }; + readonly rotation: IQuaternion; +} + +interface IOrientedFootprint { + readonly footprint: ISnowFootprint; + readonly rotation: number; + readonly x: number; + readonly z: number; +} + +function cached( + cache: Map, + key: number, + create: () => ISnowFootprint, +): ISnowFootprint { + let footprint = cache.get(key); + if (footprint === undefined) { + footprint = create(); + cache.set(key, footprint); + } + return footprint; +} + +/** A box presses whichever face is most nearly facing down, centred on that face. */ +function boxPrint( + cache: Map, + shape: IPhysicsShapeDescriptor, + { position, rotation }: IBodyPose, +): IOrientedFootprint { + const axes = [rotateAxis(rotation, 0), rotateAxis(rotation, 1), rotateAxis(rotation, 2)]; + const halves = [shape.x, shape.y, shape.z]; + let down = 0; + for (let axis = 1; axis < 3; axis += 1) + if (Math.abs(axes[axis]?.[1] ?? 0) > Math.abs(axes[down]?.[1] ?? 0)) down = axis; + const across = down === 0 ? 1 : 0; + const along = down === 2 ? 1 : 2; + // The resting face's centre, which a tilted box offsets horizontally from its own centre. + const normal = axes[down] as [number, number, number]; + const toFace = (halves[down] as number) * (normal[1] > 0 ? -1 : 1); + const axis = axes[across] as [number, number, number]; + return { + footprint: cached(cache, down, () => + boxFootprint(halves[across] as number, halves[along] as number), + ), + rotation: Math.atan2(-axis[2], axis[0]), + x: position.x + normal[0] * toFace, + z: position.z + normal[2] * toFace, + }; +} + +/** A capsule presses a stadium as long as its spine's horizontal projection. */ +function capsulePrint( + cache: Map, + shape: IPhysicsShapeDescriptor, + { position, rotation }: IBodyPose, + cellSize: number, +): IOrientedFootprint { + // Rapier's spine runs along the body's local y. + const spine = rotateAxis(rotation, 1); + const reach = shape.x * Math.hypot(spine[0], spine[2]); + // Footprints are cached per half-cell of projected length, so a tumbling capsule reuses them. + const key = Math.round(reach / (cellSize / 2)); + return { + footprint: cached(cache, key, () => capsuleFootprint((key * cellSize) / 2, shape.y)), + rotation: Math.atan2(spine[0], spine[2]), + x: position.x, + z: position.z, + }; +} + +export interface ISnowPhysicsOptions { + /** The physics context whose solved steps drive the deformation. */ + readonly physics: IPhysicsContext; + /** The snow surface these bodies deform. */ + readonly snow: SnowField; + /** Bodies to watch from the start. Register more later with `add`. */ + readonly bodies?: readonly PhysicsBody3D[]; + /** Collision layer of the generated snow surface collider. Default 1. */ + readonly collisionLayer?: number; + /** Collision mask of the generated snow surface collider. Default 0xffff. */ + readonly collisionMask?: number; + /** Multiplier turning a solved contact impulse into load. Default 1. */ + readonly loadScale?: number; + /** Smallest contact-normal height that counts as support. Default 0.35. */ + readonly supportNormal?: number; + /** + * Largest gap, in metres, the live collider may keep from the canonical surface before it is + * rebuilt. Default 0.0005. A resting body's print converges geometrically, so rebuilding on + * every sub-millimetre change would wake it every step and it would never sleep. + */ + readonly colliderTolerance?: number; + /** Snowfall deposition in metres per second, forwarded to `SnowField.recover`. Default 0. */ + readonly deposition?: number; + /** Wind erosion rate, forwarded to `SnowField.recover`. Default 0. */ + readonly wind?: number; +} + +/** What the last consumed step observed, for a registry snapshot or a playtest probe. */ +export interface ISnowPhysicsObservation { + /** Solved snow-contact manifolds read in the most recent step. */ + readonly contacts: number; + /** Bodies whose supported, downward-loaded contacts pressed the snow in that step. */ + readonly supported: number; + /** Summed load those bodies pressed with, in newtons. */ + readonly load: number; + /** How `load` was obtained. It is estimated from solver impulses, never measured. */ + readonly loadProvenance: "solver-impulse-per-step"; + /** Canonical snow surface version. */ + readonly version: number; + /** Canonical version the live collider was last reconciled with. */ + readonly colliderVersion: number; + /** Largest gap between the collider's source samples and the canonical surface, in metres. */ + readonly colliderError: number; +} + +export interface ISnowPhysicsBinding { + /** The generated snow surface collider. Its identity survives every deformation. */ + readonly surface: IPhysicsColliderHandle; + /** Canonical surface version the live collider was last reconciled with. */ + readonly surfaceVersion: number; + /** Solved contact manifolds read in the most recent step. */ + readonly contacts: number; + /** Bodies that pressed the snow in the most recent step. */ + readonly supported: number; + /** Snow surface version at the last step this binding ran. */ + readonly version: number; + /** Live snowfall deposition in metres per second; checked at the next step. */ + deposition: number; + /** Live wind erosion rate; checked at the next step. */ + wind: number; + add(body: PhysicsBody3D, footprint?: ISnowFootprint): void; + remove(body: PhysicsBody3D): void; + /** Load one watched body pressed into the snow in the last step, newtons; 0 when airborne. */ + loadOf(body: PhysicsBody3D): number; + /** + * Consume one solved step: read contacts, deform the snow, let it recover, then publish the + * changed surface to collision. Call it once per fixed step, after the physics step and before + * the next one. + */ + step(deltaTime: number): void; + /** The last step's observation, with the collider measured against the canonical surface. */ + observe(): ISnowPhysicsObservation; + /** Idempotent cleanup: removes the surface body and forgets every watched body. */ + dispose(): void; +} + +/** + * Drive a `SnowField` from real solved contacts. + * + * The binding creates the snow surface collider from the field's own canonical samples and keeps + * it in step with them, so a body lands on the surface a query would report. Each fixed step it + * reads the solver's persistent contacts — not collision start/stop events, which carry no point + * or load — deforms the snow only where a contact is supported and loaded downward, then + * republishes the surface before the next step. Airborne bodies, side contacts and unrelated + * colliders never deform anything. + * + * A dropped sphere settles on the surface it made; a pushed one rotates and carves a connected + * track without its transform being copied anywhere. Footprints come from the body's own + * collision shape and orientation unless the game supplies one. + * + * Contact load is `impulse / deltaTime * loadScale`: a solver impulse over a step, which + * estimates a contact force and is not measured. `observe().loadProvenance` says so. + * @situation leave footprints and tracks where physical bodies actually touch snow + * @situation let a dropped or pushed sphere carve and settle into deformable snow + * @situation make a crate, capsule or ball compress the surface it rests on + * @constraint register `rapier()` before attaching, and call `step` once per fixed step after the physics step + * @constraint the backend must expose persistent solved contacts and in-place shape refresh; one that does not fails at attach + * @constraint verified on browser WebGPU and the native Linux desktop host; Android and iOS share the native seam but have not run it + * @constraint automatic profiles cover sphere, box and capsule; any other shape needs an explicit footprint + * @override loadScale, supportNormal, colliderTolerance, deposition, wind, collisionLayer and collisionMask name the binding's own behaviour + * @requires @threenative/core/world SnowField as the surface it deforms + * @example const snowPhysics = attachSnowPhysics({ physics: ctx.physics, snow, bodies: [ball] }); + * afterPhysics(ctx, (dt) => snowPhysics.step(dt)); + */ +export function attachSnowPhysics(options: ISnowPhysicsOptions): ISnowPhysicsBinding { + const simulation = options.physics.simulation; + if ( + typeof simulation.readContacts !== "function" || + typeof simulation.setColliderShape !== "function" || + typeof simulation.readBodyTransform !== "function" + ) + throw new Error( + "attachSnowPhysics requires a physics backend with persistent solved contacts, in-place shape refresh and body transform reads.", + ); + const readContacts = simulation.readContacts.bind(simulation); + const setColliderShape = simulation.setColliderShape.bind(simulation); + const readBodyTransform = simulation.readBodyTransform.bind(simulation); + const snow = options.snow; + const field = snow.field; + const loadScale = finiteOr(options.loadScale, 1, "loadScale"); + const supportNormal = clamp(finiteOr(options.supportNormal, 0.35, "supportNormal"), 0, 1); + const cellSize = Math.min(snow.cellWidth, snow.cellDepth); + const colliderTolerance = finiteOr(options.colliderTolerance, 0.0005, "colliderTolerance"); + if (colliderTolerance < 0) + throw new Error("attachSnowPhysics colliderTolerance must be non-negative."); + + const watched = new Map(); + let colliderHeights = field.toColliderHeights(); + // The window that may differ from the installed collider: everything written since the last + // rebuild. Comparing only it is what keeps a resting body from costing a whole-field scan per + // step (measured: 1.0 of 1.3 ms per step on a 401-sample field). + const changes = field.trackChanges(); + let unsynced: IHeightfieldRegionBounds | undefined; + let surfaceVersion = snow.version; + let contacts = 0; + let supported = 0; + let load = 0; + let version = snow.version; + let disposed = false; + + function surfaceDescriptor(): IPhysicsShapeDescriptor { + return { + collisionLayer: options.collisionLayer ?? 1, + collisionMask: options.collisionMask ?? 0xffff, + columns: field.columns, + heights: colliderHeights, + kind: "heightfield", + rows: field.rows, + // Rapier's heightfield scale is the full extent, and its own axis order is the transposed + // buffer `toColliderHeights` already produced. + scale: { x: field.width, y: 1, z: field.depth }, + sensor: false, + x: 0, + y: 0, + z: 0, + }; + } + + const registration = simulation.createBody({ + entity: "threenative.snow.surface", + mass: 0, + position: { x: field.origin.x, y: 0, z: field.origin.z }, + rotation: { w: 1, x: 0, y: 0, z: 0 }, + sensor: false, + shape: surfaceDescriptor(), + type: "fixed", + }); + const surface = registration.collider; + const surfaceBody = registration.body; + + let contactBuffer = new Float32Array(PHYSICS_CONTACT_STRIDE * 64); + let colliderScratch = new Uint32Array(8); + + /** Largest gap between the live collider's samples and the given canonical samples. */ + function colliderGap(canonical: Float32Array): number { + let gap = 0; + for (let index = 0; index < canonical.length; index += 1) + gap = Math.max( + gap, + Math.abs((canonical[index] as number) - (colliderHeights[index] as number)), + ); + return gap; + } + + /** Largest gap between the live collider and the canonical surface inside one window. */ + function windowGap(window: IHeightfieldRegionBounds): number { + let gap = 0; + const rows = field.rows; + for (let column = window.column; column < window.column + window.columns; column += 1) { + for (let row = window.row; row < window.row + window.rows; row += 1) { + const installed = colliderHeights[column * rows + row] as number; + gap = Math.max(gap, Math.abs(field.colliderHeight(row, column) - installed)); + } + } + return gap; + } + + function refreshSurface(): void { + const changed = changes.take(); + if (changed !== undefined) unsynced = unionBounds(unsynced, changed); + if (unsynced !== undefined && windowGap(unsynced) > colliderTolerance) { + colliderHeights = field.toColliderHeights(); + setColliderShape(surface, surfaceDescriptor()); + unsynced = undefined; + } + surfaceVersion = snow.version; + } + + function colliderIds(): Uint32Array { + if (colliderScratch.length < watched.size) colliderScratch = new Uint32Array(watched.size * 2); + let index = 0; + for (const id of watched.keys()) { + colliderScratch[index] = id; + index += 1; + } + return colliderScratch.subarray(0, index); + } + + /** Read every solved manifold, growing the buffer rather than dropping any. */ + function readAll(ids: Uint32Array): number { + let found = readContacts(surface, ids, contactBuffer); + if (found * PHYSICS_CONTACT_STRIDE > contactBuffer.length) { + contactBuffer = new Float32Array(found * 2 * PHYSICS_CONTACT_STRIDE); + found = readContacts(surface, ids, contactBuffer); + if (found * PHYSICS_CONTACT_STRIDE > contactBuffer.length) + throw new Error("attachSnowPhysics contact count changed between two reads of one step."); + } + return found; + } + + function add(body: PhysicsBody3D, footprint?: ISnowFootprint): void { + if (disposed) throw new Error("attachSnowPhysics binding is disposed."); + const shape = "shape" in body ? body.shape.descriptor : undefined; + if (footprint === undefined && !SUPPORTED_SHAPES.includes(shape?.kind as PhysicsShapeKind)) + throw new Error( + `attachSnowPhysics cannot derive a contact profile for a '${shape?.kind ?? "unknown"}' shape; supported shapes are ${SUPPORTED_SHAPES.join(", ")}. Pass an explicit footprint instead.`, + ); + watched.set(body.collider.id, { + body, + cache: new Map(), + custom: footprint, + impulse: 0, + load: 0, + shape, + }); + } + + for (const body of options.bodies ?? []) add(body); + + /** Sum each watched body's supported, downward-loaded impulse from the step's manifolds. */ + function gather(): void { + const found = readAll(colliderIds()); + contacts = found; + for (let index = 0; index < found; index += 1) { + const offset = index * PHYSICS_CONTACT_STRIDE; + const entry = watched.get(contactBuffer[offset] as number); + // Only the surface pushing up on a body, with the body pressing back, deforms snow. + if (entry === undefined || (contactBuffer[offset + 5] as number) < supportNormal) continue; + entry.impulse += Math.max(0, contactBuffer[offset + 7] as number); + } + } + + function press(deltaTime: number): void { + gather(); + // One contact per body per step, under the body: the load is its whole supported impulse, + // spread over its footprint's own area. + for (const entry of watched.values()) { + entry.load = 0; + if (entry.impulse <= 0) continue; + const transform = readBodyTransform(entry.body.body.id); + if (transform === undefined) throw new Error("attachSnowPhysics lost a watched body."); + const oriented = orientedFootprint(entry, transform, cellSize); + const bodyLoad = (entry.impulse / deltaTime) * loadScale; + snow.stamp({ + area: footprintArea(oriented.footprint), + duration: deltaTime, + footprint: oriented.footprint, + load: bodyLoad, + rotation: oriented.rotation, + x: oriented.x, + z: oriented.z, + }); + supported += 1; + load += bodyLoad; + entry.load = bodyLoad; + entry.impulse = 0; + } + } + + const binding: ISnowPhysicsBinding = { + add, + deposition: finiteOr(options.deposition, 0, "deposition"), + wind: finiteOr(options.wind, 0, "wind"), + get contacts() { + return contacts; + }, + get supported() { + return supported; + }, + get surface() { + return surface; + }, + get surfaceVersion() { + return surfaceVersion; + }, + get version() { + return version; + }, + observe(): ISnowPhysicsObservation { + return { + colliderError: colliderGap(field.toColliderHeights()), + colliderVersion: surfaceVersion, + contacts, + load, + loadProvenance: "solver-impulse-per-step", + supported, + version: snow.version, + }; + }, + remove(body: PhysicsBody3D): void { + watched.delete(body.collider.id); + }, + loadOf(body: PhysicsBody3D): number { + return watched.get(body.collider.id)?.load ?? 0; + }, + step(deltaTime: number): void { + if (disposed) return; + if (!Number.isFinite(deltaTime) || deltaTime <= 0) + throw new Error("attachSnowPhysics step requires a positive finite delta time."); + contacts = 0; + supported = 0; + load = 0; + if (watched.size > 0) press(deltaTime); + const deposition = Math.max(0, finiteOr(binding.deposition, 0, "deposition")); + const wind = Math.max(0, finiteOr(binding.wind, 0, "wind")); + if (deposition > 0 || wind > 0) snow.recover(deltaTime, deposition, wind); + refreshSurface(); + version = snow.version; + }, + dispose(): void { + if (disposed) return; + disposed = true; + changes.dispose(); + watched.clear(); + simulation.removeBody(surfaceBody.id); + }, + }; + return binding; +} +function finiteOr(value: number | undefined, fallback: number, name: string): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) throw new Error(`attachSnowPhysics ${name} must be finite.`); + return value; +} + +/** + * Smallest area a contact is allowed to claim. + * + * The snow model divides load by area, so a degenerate profile that reports a near-zero area + * would produce an unbounded pressure. The floor is the model's own guard, not a physical claim. + */ +const MIN_CONTACT_AREA = 1e-4; + +const footprintAreas = new WeakMap(); + +/** + * The area a footprint actually covers, measured once per profile and then remembered. + * + * A footprint is a shape, never a load, so its supported area is a property of the shape. It is + * measured with a bounded sweep rather than trusted to a hardcoded constant, and floored so a + * degenerate profile cannot turn a finite load into an unbounded pressure. + */ +function footprintArea(footprint: ISnowFootprint): number { + const cached = footprintAreas.get(footprint); + if (cached !== undefined) return cached; + const steps = 24; + const step = (footprint.extent * 2) / steps; + let area = 0; + for (let row = 0; row < steps; row += 1) { + const z = -footprint.extent + (row + 0.5) * step; + for (let column = 0; column < steps; column += 1) { + const x = -footprint.extent + (column + 0.5) * step; + area += footprint.sample(x, z).coverage * step * step; + } + } + const measured = Math.max(area, MIN_CONTACT_AREA); + footprintAreas.set(footprint, measured); + return measured; +} diff --git a/packages/playtest/AGENTS.md b/packages/playtest/AGENTS.md index c9bac64d3b..0919649f10 100644 --- a/packages/playtest/AGENTS.md +++ b/packages/playtest/AGENTS.md @@ -1,7 +1,10 @@ # AGENTS.md — @threenative/playtest -Read `/AGENTS.md` first. This file covers the operator CLI and what is different about changing -the harness. +Read `/AGENTS.md` first. This file is the operator CLI and what is different about changing the +harness. Flags, exit codes, per-target detail and the assertion vocabulary live in +[docs/reference.md](docs/reference.md); ticks and the startup wait in +[docs/determinism.md](docs/determinism.md). Anything that is reference rather than rule is there, +not here. ## Running a scenario @@ -13,51 +16,32 @@ node packages/playtest/dist/runner/cli.js playtests/smoke.playtest.json \ --browser-recipe webgpu ``` -Exit `0` passed, `1` assertions failed, `2` never reached assertions, `69` when a command's -external decoder is absent so nothing was inspected, and `75` when the capture lock queue timed -out — the last two are explicitly **not** test failures, and `75` prints the holder and queue -depth. `--build-report .build-report.json` adopts the `performanceBudget` a -`threenative build` published beside that artifact: the run re-hashes the artifact under test and -exits `2` when it is not the one the report describes (`TN_PLAYTEST_BUILD_REPORT_STALE`), and -merges the budget per key into the scenario's own `assert.performance`. It needs `--artifact` on -browser and Android, which is the only thing that says which build the run exercises. `--server-command` needs a workspace that has a `dev` script — an example or a scaffolded -project; there is no root `pnpm dev`. `--browser-recipe webgpu` supplies the current Chromium WebGPU -flags including `--enable-features=Vulkan`, without which Chromium silently serves WebGPU from -SwiftShader and reports healthy-looking limits from a CPU rasteriser; `--browser-arg` is the escape -hatch, and a run that does not name its adapter is not evidence. **On Linux the runner provisions a -private Xvfb for every pixel-producing run, whether or not a display exists** (stripping Wayland env -itself), so a scenario never opens windows over whatever the operator is doing — a sweep that -borrows `:0` makes the machine unusable for as long as it runs. Set `TN_PLAYTEST_HOST_DISPLAY=1` to -paint on the session's own display instead; opt in when the run needs the session's real GPU adapter -(heavy TSL post chains have been seen falling back to SwiftShader under Xvfb) or when a human wants -to watch it. Do **not** reach for `--headless` to keep windows off a screen: headless Chromium -cannot capture WebGPU here, so it changes what you measure. The runner takes a capture lock only -when it detects competing runners — or always with `CAPTURE_LOCK=1`; lock state is printed to stderr -either way. `sh scripts/xvfb.sh` remains as an optional compatibility wrapper — never `xvfb-run`, -whose exit status is its own failing cleanup kill rather than the command's. - -**A frame rate from that private Xvfb is wrong, not missing**, so no command may print one as -though it were measured: without vsync the present wait lands inside the update phase (13.3 fps -there against 57.7 on the real display, one build). `trace` never prints one. `perf` prefers the -display's own rate whenever a window carries one — core reads the host's `__tnPresentedCount` and -reports `presents`/`presentedFps` beside the loop's `fps` — and otherwise suppresses the column and -refuses a `--min-fps` bound when the run's display is private (`--executable`) **or the log says for -itself that the frames never reached the display**: the host counts loop frames and presents -separately in `TN_PRESENTS_TICK`, and a loop the presentation cap outran inflates `fps` by exactly -that ratio — midway's native launch log reported 2631 fps beside its own -`{"frames":1740,"presents":133,"capHz":60}`, and passed a 55 fps bound. A window that counted zero -presents carries no rate at all: the column prints `0.00`, the windows are named in words, and a -bound over windows that all lack a rate fails closed. Both refusals take `--allow-virtual-display` -when the operator is deliberately reading phase timings alone. A -`--min-fps` bound that can be satisfied by a number nobody can vouch for is a green with nothing -behind it — the desktop build above reported 20,000 fps that way. - -In a scaffolded project the same CLI is `npx @threenative/playtest`, and `diagnostics`, console, -network, screenshot and trace assertions work against any URL. The framework template installs the -bridge with `playtest()` in `defineGame`; a plain Three.js project uses `installThreePlaytestBridge` -from `@threenative/playtest/three`. Semantic assertions (`movement`, `camera`, `visibility`) against -a project with neither bridge fail `TN_PLAYTEST_BRIDGE_MISSING` — that is the harness being right. -Install the bridge or narrow the scenario; never delete the assertion to get green. +Exit `0` passed, `1` assertions failed, `2` never reached assertions, and `69` / `75` are +explicitly **not** test failures (external decoder absent; capture lock queue timed out). + +- `--browser-recipe webgpu` is not optional on the browser lane. Without its Vulkan flags Chromium + silently serves WebGPU from SwiftShader and reports healthy-looking limits from a CPU rasteriser; + a run that does not name its adapter is not evidence. `--browser-arg` is the escape hatch. +- Never `--headless` to keep windows off a screen: headless Chromium cannot capture WebGPU here, + so it changes what you measure. On Linux the runner provisions its own private Xvfb for every + pixel-producing run; `TN_PLAYTEST_HOST_DISPLAY=1` opts into the session's display when the run + needs the real GPU adapter or a human watching. +- **A frame rate from that private Xvfb is wrong, not missing.** `perf` suppresses the column and + refuses a `--min-fps` bound (exit `1`, `TN_PERF_VIRTUAL_DISPLAY`) unless you pass + `--allow-virtual-display`. A bound satisfied by a number nobody can vouch for is a green with + nothing behind it. +- Wrap a headless run in `sh scripts/xvfb.sh `, never `xvfb-run` — its exit status is its own + failing cleanup kill, not the command's. +- A scaffolded project runs the same CLI as `npx @threenative/playtest`. The framework template + installs the bridge with `playtest()` in `defineGame`; a plain Three.js project uses + `installThreePlaytestBridge` from `@threenative/playtest/three`. +- `diagnostics`, console, network, screenshot and trace assertions work against any URL. Semantic + assertions (`movement`, `camera`, `visibility`) against a project with neither bridge fail + `TN_PLAYTEST_BRIDGE_MISSING` — that is the harness being right. Install the bridge or narrow the + scenario; **never delete the assertion to get green**. + +Ask the machine before spending a run: `doctor --text`, `doctor --url --text` (adds the +scene), `doctor --device --text` (adds device thermals) — see the root `AGENTS.md`. ## The rule that outranks everything else in this package @@ -80,402 +64,57 @@ Concretely, when you touch this package: - New assertion types need a test proving the wrong-typed case fails. `__tests__/` already holds the shape: `vacuous-assertion.spec.ts`, `silent-drop.spec.ts`, `evidence-required.spec.ts`. Add to those rather than starting a new pattern. - -## One scenario, four targets - -`--target browser|android|desktop|ios` runs the same scenario file against a browser, an Android -device or emulator, a native desktop executable, and an iOS simulator or device -(`runner/androidRunner.ts`, `runner/desktopRunner.ts`, `runner/iosRunner.ts`, -`runner/deviceTransport.ts`). Desktop requires `--executable`, and the game bundle reaches the host -through repeatable `--host-arg` (`--host-arg run --host-arg dist/game.js`) — without it the host -launches with no game and the run reports `TN_PLAYTEST_BRIDGE_MISSING` at zero frames. The runner -owns a temporary local -mailbox and passes its root to the native host through `TN_PLAYTEST_MAILBOX_ROOT`. Keep it that way: -an assertion that only -means something on one target is a fork of the harness. - -A device target that cannot be reached fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades -to a browser run. Where a target genuinely lacks an observer — device transport has no CDP -network observer — the assertion **errors and names the working target**, it does not skip. -The negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, -`-wrong-value`) prove the device path still fails closed; run them when you change transport -or observation code. - -## `perf` — read the frame meters without opening a log - -`threenative-playtest perf` parses `TN_FRAME_BUDGET` and `TN_HOST_GAP` out of one source — -`--file `, `--executable ` with repeatable `--host-arg`, or `--logcat ` — and -reports fps, frame/render/hostGap p50/p95 per window plus the host-gap segments. It exists -because every number in the Android-fps hunt was read from these markers by hand; the parser -(`runner/perf.ts`) is that hand-read, encoded. - -Protocol rules are built in, not optional: **window 1 is discarded as startup** (it always -lies), `--require-windows` (default 2) counts *steady* windows and a run with fewer exits **2** -— not enough evidence, never an empty pass — and a marker line whose JSON cannot be parsed -**throws** (`TN_PERF_MARKER_MALFORMED`) rather than silently vanishing. Optional bounds -`--max-frame-p95 ` / `--min-fps ` fail with exit **1** on any steady-window violation; -without bounds the command reports and exits 0. The host's `Present mode:` line is captured -when the host logs one. `--text` renders the human-readable table; default output is JSON. - -Desktop spawn under a headless session rides the same Xvfb rule as any pixel run: -`sh scripts/xvfb.sh threenative-playtest perf --executable … --host-arg run --host-arg game.js …`, -and that Xvfb is exactly the display whose frame rate cannot be trusted — such a run prints no -`fps` column and refuses `--min-fps` (exit 1, `TN_PERF_VIRTUAL_DISPLAY`) unless the operator passes -`--allow-virtual-display`. The phase and host-gap rows, which are what the native lane's baselines -quote, are unaffected. -The command never launches a browser — the browser lane already bounds performance through -`assert.performance` — and it never tunes anything; it is a meter reader. - -## `audio` — look at the sound, because nobody listens in CI - -`threenative-playtest audio --expect ` decodes every clip a game declares and -reports band energy, peak, DC, silence and loop-seam continuity, writing one spectrogram PNG per -clip. No browser, no display, no capture lock: inspecting audio reads files, and taking the capture -queue for it would block the machine's pixel work for nothing. - -It exists because **every check that does not involve listening passes on audio that is wrong**. -The file exists, it is served 200, it decodes, no page error, it is inside the byte budget — and the -clip is a hum where a chime should be. That shipped here: a discovery cue with 80% of its energy in -100-500 Hz and 1% above 2 kHz, on the one sound a player waits to hear. So did fifteen footsteps -with up to 45% of their energy below 100 Hz, which is a thud, not a boot on stone. Both are -unmistakable in a band profile and a spectrogram, and invisible in a size, a duration or a green -`loaded` marker. - -**The game declares the expectations, because the inspector cannot know them.** Nothing here knows -that a forest bed should be broadband and a discovery chime should be bright. The manifest is the -game saying so: - -```json -{ "version": 1, "clips": [ - { "path": "public/audio/forest-bed.ogg", "loop": true, - "bands": { "sub": { "max": 3 }, "high": { "min": 20 }, "air": { "min": 20 } } }, - { "path": "public/audio/landmark-found.ogg", "loop": false, - "bands": { "low": { "max": 25 }, "high": { "min": 25 } } } -] } -``` - -Bands are `sub` (<100 Hz), `low` (100-500), `mid` (500-2k), `high` (2k-8k) and `air` (>8k), -contiguous to Nyquist, reported as percentages of summed magnitude over Hann-windowed frames -covering the whole signal. They are comparable to each other and to what a game declares, not to -another tool's numbers. The frame hop shrinks on a short clip so its spectrogram is wide enough to -read a transient off — a half-second footstep holds twenty non-overlapping frames, and a -twenty-column picture defeats the only reason the picture exists. - -Fails closed, on this package's own rule: an unknown key, a band nobody measures, a bound that can -never hold, a `seamMaxRatio` on a clip whose `loop` is false, a duplicate path, or an empty clip -list all **throw** rather than skip. `loop` is required rather than defaulted, because it is the one -fact that decides whether the seam is checked. `--dir ` additionally fails when any audio file -under it is undeclared — otherwise the gate is only as good as the manifest and a clip added later -is a clip nothing checks. - -**Two measurement rules, both learned by getting them wrong.** - -*Decode at the file's own rate.* A seam measured on a resampled decode measures the resampler: its -FIR window runs off the end of the data at the first and last output sample and is zero-padded, so -the edge samples are the only wrong ones in the file — exactly where a seam test looks. On one real -set that inflated the reported step three to sevenfold and reordered which clip looked worst. The -command never passes `-ar` to ffmpeg. - -*Judge the wrap against the steps beside it.* A click is a step that is anomalous **where it -happens**. A sparse clip is mostly quiet, so a whole-clip percentile flatters its seam; a dense one -is mostly loud, so the same percentile condemns a join nobody could hear. The reference is the -99th-percentile sample step within 50 ms either side, and the default ceiling is 1.5x rather than -1.0x — a flawless wrap that lands on the signal's steepest point *is* the largest step in its -neighbourhood and measures exactly 1.0. - -Exit `0` when every check passed, `1` when one failed, `2` when it could not run (a malformed or -unreadable manifest), and `69` when ffmpeg is absent. That last code is the point: **"I could not -check" and "I checked and it is fine" must never be the same answer**, so CI can treat 69 as a skip -and still treat 1 as a defect. - -`--text` prints `✓`/`!`/`✗` lines with a `fix:` on anything that is not ok; default output is JSON. -The numbers are the gate and the picture is what a person or an agent looks at when the gate fires, -so every spectrogram written is named in both. Verdicts, parsing and analysis live in -`runner/audio.ts` and are unit-tested against synthesised signals in `__tests__/audio.spec.ts`; -`runner/audioRun.ts` only drives ffmpeg. - -## Startup time is an observation - -The runtime stamps its startup milestones on its own clock — `loadStartedMs`, `enteredMs`, -`compileSettledMs`, `readyMs`, in milliseconds since navigation — and publishes them as -`observations.startup.timeline`. Scenarios bound them with `assert.startup`: `maxEnteredMs`, -`maxCompileSettledMs`, `maxReadyMs`. A milestone the run never reached fails closed -(`TN_PLAYTEST_STARTUP_UNOBSERVABLE`); one past its ceiling fails -`TN_PLAYTEST_STARTUP_TOO_SLOW` with the measured value. Before this, startup was a console -anecdote read off `TN_STARTUP_WARMUP` after the fact. - -## Device thermal, power and battery - -Every `--target android` run measures the phone around itself and reports it as -`observations.deviceMetrics` — battery temperature and level, Android thermal status, charging -state, current draw, and Pixel's per-rail ODPM power breakdown. It is sampled before `prepare()` -(the only moment a pre-launch baseline is still readable, since `prepare` clears logcat), every -five seconds during the run, and once after the last bridge sample. - -The point is **comparability**. On 2026-08-24 two cold-launch runs read 44 s to first frame -against a 14.7 s baseline and the difference was blamed on a code change; the device had reached -43.2 °C at thermal status 2 while the baseline ran at 38.2 °C at status 0. A run is flagged -`thermallyConfounded` with named reasons — `hot-start` (≥ 40 °C), `throttled-start`, -`thermal-status-rose`, `charging`, `incomplete` — and a flagged run's numbers are still reported -in full. The verdict withdraws the claim of comparability, never the measurement. - -Scenarios assert on it with `deviceMetrics`: `notThermallyConfounded`, `maxTemperatureRiseC`, -`maxThermalStatus`. On browser, desktop or iOS the assertion fails -`TN_PLAYTEST_UNSUPPORTED_ON_TARGET` and names android — it never skips. A reading the device does -not expose (per-rail power off Pixel hardware, `current_now` where the sysfs node is absent) -reports `{ available: false, reason }`; **nothing here ever reports an unmeasured zero**. - -Ask *before* spending a run: `doctor --device ` reports the phone's battery temperature, -thermal status, charge level and charging state next to the machine checks. It warns — a hot -phone is a run that will not be comparable, not a broken machine — and only fails on a device -that is unreachable or a probe that cannot be parsed. - -```sh -node packages/playtest/dist/runner/cli.js doctor --device --text -``` - -Parsers and verdict live in `runner/deviceMetrics.ts` and are unit-tested against captured device -output in `__tests__/fixtures/device-metrics/`. Add real captures there rather than inventing a -`dumpsys` format. Both the observation lane and `doctor --device` only report; the gate that -*refuses* a hot or charging device before a benchmark is -`packages/runtime-native/scripts/device-preflight.mjs`, and they share the same battery floor so -an operator is never told two different stories. - -## The room, and the feet - -Two things the harness measured and nothing could read. - -`doctor --url` used to end its report with *not observed: lights, materials and textures* and -*not observed: camera framing*. An agent looking at a black or washed-out frame therefore had -nothing between "the bridge answered" and a screenshot, which is the one instrument that cannot -say **why**. The bridge now reports the room as `observations.scene` — lights with their colours -and intensities, materials counted per distinct material by constructor name, fog with its own -near/far or density, the background, the camera's position, forward, fov and clip planes, and the -scene's world extent. Counts and names only: it decides nothing about how anything looks, and a -value the scene does not carry is absent rather than zero. The walk is capped -(`SCENE_WALK_OBJECT_CAP`, `SCENE_LIGHT_CAP`) and a scene past either reports `truncated: true`, -so a floor is never read as a total. - -`doctor --url` reads it back as three lines — `lighting`, `materials`, `camera` — and names the -ways a frame dies while every other number stays healthy: **lit materials with no visible light**, -a **fog far plane in front of the scene it is fogging**, and a **camera far plane that clips it**. -The second is round 9's lost visual column, where a radius-90 sky dome sat behind `Fog(bottom, 18, -80)` and rendered as one flat wash; no gate could see it. - -`AnimationPlayer.stride` has measured the *feet meet the floor* convention since it shipped — what -the clip carries against what the body covered — and it never crossed the bridge either, so a game -that set `strideSync: false` had turned the measurement off as far as any proof was concerned. -It now rides in `gameplay.animation..stride`, and `assert.animation[]` bounds it: - -- `maxFootSlide` — ceiling on `|feet − ground| / ground`. The feet move at the rate the clip is - *actually playing*, which is the measured `rate` only when `synced`; an overridden clip keeps - its authored rate. Reading `rate` unconditionally scored an overridden run at zero slide — - the exact case the bound exists to catch, found by the locomotion scenario, not by a unit test. -- `strideSynced` — require the convention applied (`true`) or deliberately overridden (`false`). - -Both fail closed, and each failure names which kind: `TN_PLAYTEST_STRIDE_UNOBSERVED` when the -producer reported no stride, reported half of one, or the body covered no ground to compare -against; `TN_PLAYTEST_STRIDE_NOT_SYNCED`; `TN_PLAYTEST_FOOT_SLIDE` with both speeds and the -ceiling. A game that does not measure stride has not measured zero slide. - -A scenario bounds the same numbers with `assert.scene`, so the check outlives whoever ran -`doctor` once: - -- `minVisibleLights` — floor on lights the renderer will actually see. An invisible light is no - light. -- `litMaterialsAreLit` — fail when lit materials are mounted and nothing lights them. A scene of - `MeshBasicMaterial` needs no light and is not failed for having none. -- `fogClearsScene` — fail when a linear fog goes opaque in front of the scene's furthest corner. -- `cameraClearsScene` — fail when the camera's far plane cuts the world it is pointed at. - -`assert.scene` bounds the room. It cannot say **where one object is**, and that is the other half -of every screenshot an agent takes: is the crate in the vault or under the floor, is the camera -pointed at it, did the plate's normal map ever load, is the character's mesh visible once every -ancestor's `visible` flag is counted. Those kill a frame while every count above them stays -healthy, and a screenshot shows the damage without naming the cause. - -`sceneNodes` reports the graph node by node, for the nodes a selector picks out — `name`, -`nameContains`, `pathContains`, `type` — and bounds them: - -```json -{ "assert": { "sceneNodes": [ - { "select": { "nameContains": "crate" }, "minCount": 30, "visible": true, "texturesLoaded": true }, - { "select": { "name": "seal" }, "inFrustum": true }, - { "select": { "nameContains": "debug" }, "maxCount": 0 } -] } } -``` - -- `visible` — the node's own flag **and every ancestor's**, which is what the renderer acts on. A - visible mesh under a hidden group draws nothing and reports `visible: true` on itself. -- `inFrustum` — the node's world bounds against the active camera's frustum. A node with no - bounds is never tested and reports no membership, which fails rather than passing unmeasured. -- `texturesLoaded` — every bound map slot carries pixels. A slot bound to a texture whose image - never arrived samples black while the material, the light count and the network all read fine. -- `animated` — clips are mounted on the object. This is not what the game's mixer is *playing*; - that is `gameplay.animation` and `assert.animation`, and the failure message says so. -- `minCount` (default 1) / `maxCount` — `maxCount: 0` is how a scenario asserts a node is absent. -- `minTriangles` — summed across the matched nodes, and reported as a floor when the selector's - limit cut the list. - -Each observation is walked only when a scenario asks for it, because reading geometry, materials -and world bounds per object is real work on a large scene. The walk is capped -(`SCENE_NODE_WALK_CAP`, per-selector `limit`), `matched` always counts every match, and a cut list -reports `truncated: true` — a floor is never read as a total. A selector that filters nothing, an -assertion that selects nodes and bounds none of them, and a `minCount` above its `maxCount` all -throw at load. A run with no node observation fails once as `sceneNodes.observed` -(`TN_PLAYTEST_SCENE_NODES_UNOBSERVED`). Advertised as the `scene.nodes` capability. - -## "Because", not "and then" - -A run could always say *a contact happened* and *the state reads `won`*. Nothing related the two. -`assert.states[].atSteps` orders them at **step** boundaries, which cannot separate a win arriving -with the contact from one arriving 199 ticks later inside the same step — and that is exactly the -shape of a terminal state driven by a timer or a distance check that merely lands near a contact. - -Measured in the field: a sandbox physics puzzle reported `won` on frame one with the player -untouched eight metres away, because its goal volume overlapped the floor slab. Its own HUD agreed -the game was won. Every assertion in the harness was green, because each was individually true. - -Both sides are now tick-stamped by the producer, and `assert.causedBy` relates them: - -```json -{ "assert": { "causedBy": [ - { "cause": { "contact": { "entity": "warden", "with": "seal", "kind": "trigger" } }, - "effect": { "path": "state.status", "becomes": "won" }, - "neverBefore": true, "withinTicks": 4 } -] } } -``` - -- `neverBefore` fails when the effect is ever observed before the first matching cause. That is the - frame-one fake win, at tick granularity. -- `withinTicks` bounds `effectTick - causeTick`. A win long after the contact was not caused by it. -- `cause` takes a `contact` **or** a `transition`, never both — two causes in one row would - silently make the earlier one the cause. So "the door opened because the plate was pressed" is - the same row shape. -- A row setting neither `neverBefore` nor `withinTicks` throws at load: ordering alone is what - `atSteps` already does, and asserting only that both events happened is two assertions wearing - one name. - -Core's `playtest()` plugin drains both logs **per tick** rather than per sample and advertises -`runtime.transitions`. The transition watcher diffs each registered entity's `state` and the -top-level primitive fields of the published game state, recording `{ path, from, to, tick }` — -`states.` or `state.`. A path's first observed value is its starting point, not a -transition; recording it as one would fail every `neverBefore` against the game's own initial -state. The log is bounded by `PLAYTEST_TRANSITION_LOG_LIMIT`. - -Every failure mode is named and closed. A run with no transition log fails once as -`causedBy.observed` (`TN_PLAYTEST_TRANSITIONS_UNOBSERVED`) — a loop that never ticked has observed -nothing, not nothing-changed. A matching contact carrying **no tick** fails -`TN_PLAYTEST_CAUSE_UNSTAMPED` rather than being read as a cause at tick zero, and the message says -the producer drains only at sample time. A cause the run never observed is -`TN_PLAYTEST_CAUSE_NOT_OBSERVED`; an effect that never transitioned is -`TN_PLAYTEST_EFFECT_NOT_OBSERVED`; the frame-one case is `TN_PLAYTEST_EFFECT_PRECEDES_CAUSE` with -both ticks; a late effect is `TN_PLAYTEST_CAUSE_TOO_DISTANT`. **The family never falls back to step -granularity** — that is a different measurement, and answering a tick question with it would be the -confident wrong number this package exists to refuse. - -One related fix in core, from the same build: an area's contact used to be dropped unless the game -passed an explicit `entity` option, so a trigger that demonstrably fired reported no contact at all -and a `contacts` assertion went green over a run whose own state proved the overlap. The far side -now falls back to the scene-registry id the area is registered under. - -A run with no scene observation fails once as `scene.observed` -(`TN_PLAYTEST_SCENE_UNOBSERVED`) rather than failing each bound against nothing, and an -`assert.scene` that sets no bound throws at load. An unmeasurable comparison — no world extent, -no far plane — fails; it never counts as cleared. - -Advertised as the `scene.observe` capability. A capability the runner's registry does not define -is rejected (`TN_PLAYTEST_BRIDGE_CAPABILITY_UNKNOWN`), so a new observation channel is registered -in `src/capabilities.ts` in the same change that starts advertising it. - -## Scenario-controlled spawn & aim - -The scenario `setup` block carries a placement vocabulary so capturing a vantage frame is -one scenario, not a patch-run-revert ceremony: - -- `setup.spawn { x, z }` (+ optional `y`) overrides the SUBJECT player start's position. - An absent `y` preserves the game's own height (eye or ground line); it is never silently - defaulted to zero. Requires `subject`. -- `setup.aim { yaw, pitch }` overrides the SUBJECT player start's aim; both angles are - radians, Three.js convention (forward is -Z at yaw 0, pitch positive up). Requires - `subject`. -- `setup.place[]` entries `{ entity, at: {x,y,z}, facing?: {yaw} | lookAt?: {x,y,z}, frozen?: boolean }` - put named entities at explicit transforms. `frozen` sets `PLAYTEST_FROZEN_MARKER` - (`__threenativeFrozen`) on the entity's userData — data the game reads to suppress - physics motion, never a runner-side teleport loop. - -Presence semantics are explicit and fail closed: an unknown entity id, an entity missing -from the registry at apply time, or a target coincident with the subject is a NAMED error -(`TN_PLAYTEST_SETUP_UNAPPLIED`), never a silent skip. One entity may be placed by only one -of `setup.entities` / `setup.place`. - -Steps can also carry `{ kind: "aimAt", target: { x, z } | { entity }, pitch?, waitTicks?, screenshot?, label? }`. -The runner samples the subject's current position, computes yaw/pitch toward the target, -and applies them through the setup channel as quaternion data — no CDP mouse events and no -OS-focus dependency. An `aimAt` step cannot also deliver input (`press`, pointers) or -ignored holds (`holdTicks`/`holdFrames`); follow it with a `waitTicks` step to hold the pose. - -Every requested override rides into the run report as `setup.requested` next to what -applied (`setup.applied`). A run whose placement cannot apply fails with the reason named — -an overridden spawn must be visible in diagnostics, never green-with-silence. The game keeps -its own spawn constants; scenarios override them for determinism, through this one channel. -The template-teaching copy of this vocabulary ships via the create-threenative shared -fragment when games adopt it; until then this section is the harness contract. +- **"I could not check" and "I checked and it is fine" must never be the same answer.** That is + what `69` and `75` exist for: CI may skip them and still treat `1` as a defect. + +## Where it fails closed, and under what name + +Each of these is a named failure, never a skip. Do not add a branch that softens one. The +vocabulary behind them is in [docs/reference.md](docs/reference.md). + +- **Four targets, one scenario** — `--target browser|android|desktop|ios`. An unreachable device + fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades to a browser run. A target that genuinely + lacks an observer errors and names the working target. Desktop needs `--executable` plus + repeatable `--host-arg`, or the host launches with no game and reports + `TN_PLAYTEST_BRIDGE_MISSING` at zero frames. +- **A missing observation fails once, by name** — `causedBy.observed`, `sceneNodes.observed`, + `scene.observed`, `TN_PLAYTEST_SCENE_NODES_UNOBSERVED`, `TN_PLAYTEST_SCENE_UNOBSERVED`. A + `deviceMetrics` assertion off android is `TN_PLAYTEST_UNSUPPORTED_ON_TARGET` and names android. +- **An unmeasured value is never a zero** — an absent device reading reports + `{ available: false, reason }`; a stride the producer never reported is + `TN_PLAYTEST_STRIDE_UNOBSERVED`, not zero slide; a walk past its cap reports `truncated: true` + so a floor is never read as a total. +- **An assertion that only means something on one target is a fork of the harness.** The + negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, + `-wrong-value`) prove the device path still fails closed — run them when you touch transport or + observation code. +- **Advertise only what you registered.** An unknown capability is + `TN_PLAYTEST_BRIDGE_CAPABILITY_UNKNOWN`, so a new observation channel is registered in + `src/capabilities.ts` in the same change that starts advertising it. ## Determinism -Scenario steps count fixed-step ticks, not milliseconds — use `holdTicks`, `waitTicks`. The -deprecated `holdFrames` and `waitFrames` aliases remain accepted for compatibility and are -treated as ticks when the bridge exposes `runtime.fixedStep`; `warmupFrames` remains a genuine -requestAnimationFrame warmup. Never introduce a wall-clock sleep or a millisecond-based step -into scenario semantics. - -**Ticks are not the clock a launch runs on.** A run advances ticks as fast as the machine allows, -so a whole scenario can complete during a launch that has not finished — and everything the -application gates on startup (compute dispatch, the first world present) then never happens inside -the run. `starter-look` read `flagSteps` 0 before and 0 after because the cloth had never been -dispatched; twelve starter scenarios photographed the loading screen and reported -`TN_CAPTURE_BLANK`. Both depended only on how long boot took. - -So after `warmupFrames` and before the baseline observation, the runner waits for the application -to say its world is safe to observe: a bridge that advertises **`runtime.startup`** reports -`{ phase, progress }` from `ready()` (core's `playtest()` plugin publishes `ctx.startup`), and the -runner holds — pumping frames on browser, ticks on device — until `phase` is `"ready"`. Bounded by -`PLAYTEST_STARTUP_READY_TIMEOUT_MS`; a game that never gets there fails -`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. An application that reports no -startup at all — a plain Three.js page — is never waited on, and advertising the capability without -reporting it is malformed and throws. - -Readiness means two things, and the wait always insists on both. Core reaches `"ready"` after -first-use compilation settles **and** a frame window — five sustained in-budget frames, or a -bounded expiry of `STARTUP_STABLE_WINDOW_MS` (10s) when a CPU rasteriser can never meet them. -Observing at bare compile settlement is not enough: a game that builds its world on `whenReady()` -(the puzzle's crate pile is one) has not run yet, and the run then asserts against a half-built -world. So the software lane waits for `"ready"` like any other — at most 10s more per scenario — -and the declaration (`--allow-software` / `TN_PLAYTEST_ALLOW_SOFTWARE=1`) only **labels** the -result: `startup.rule` is `"compile-settled"`, because a CPU rasteriser reached readiness on the -bounded window rather than on sustained frames, and an operator must not read that pass as a -smoothness measurement. A lane that reaches readiness immediately is labelled the same way. +Scenario steps count fixed-step ticks, not milliseconds — `holdTicks`, `waitTicks`. Never +introduce a wall-clock sleep or a millisecond-based step into scenario semantics. Ticks are also +not the clock a launch runs on, so the runner waits, before the baseline observation, for a +bridge advertising `runtime.startup` to report `phase: "ready"`; bounded by +`PLAYTEST_STARTUP_READY_TIMEOUT_MS`, and a game that never gets there fails +`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. A host that has *exited* is +`TN_PLAYTEST_STARTUP_HOST_EXITED`, not a slow launch. **Never fix a boot race by lengthening a +wait** — padding changes which runs get lucky. Full rule: [docs/determinism.md](docs/determinism.md). -Compile settlement is still required — it is the part that makes a run observe the game rather than -the loading screen — and a game whose phase never leaves `"collapsing"` fails -`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. The label is keyed *only* off -that declaration, never off a timeout or an adapter guess. Every report carries `startup.rule`, -`"sustained-frames"` or `"compile-settled"`, so a software-lane pass is never read as a smoothness -measurement. +## Where things live -A bridge that stops answering is not automatically a slow launch. A host that has **exited** looks -identical from the runner's side — both are operation timeouts — so on the device targets the wait -also asks the driver whether the process is still there, and a dead one fails immediately as -`TN_PLAYTEST_STARTUP_HOST_EXITED` with the console tail that says why. Only a `false` reading ends -the wait: a driver that cannot answer leaves it exactly as it was, because inferring a crash from an -unreadable probe is the implicit fallback everything else here refuses. Without it a crashed runtime -spent the full `PLAYTEST_STARTUP_READY_TIMEOUT_MS` and was then reported as the game's loading gate -being too slow — three minutes of silence blamed on the wrong layer, which is how the hosted -Windows performance collector read on PR #122. +| Concern | File | +| --- | --- | +| `perf` markers → fps, p95, host-gap segments | `runner/perf.ts` | +| audio verdicts, parsing, analysis | `runner/audio.ts` (`runner/audioRun.ts` only drives ffmpeg) | +| device thermal and power parsers | `runner/deviceMetrics.ts` | +| the four target runners | `runner/androidRunner.ts`, `runner/desktopRunner.ts`, `runner/iosRunner.ts`, `runner/deviceTransport.ts` | +| capability registry | `src/capabilities.ts` | -Never fix a boot race by lengthening a wait. Padding changes which runs get lucky; the tick counts -were already identical in the runs that disagreed. +Add real captured device output to `__tests__/fixtures/device-metrics/` rather than inventing a +`dumpsys` format. ## This is salvaged code @@ -491,4 +130,4 @@ from the rest of the repo. Match the file you are editing, not the root conventi Vitest at the root only collects `packages/**/__tests__/**/*.spec.ts`. The co-located `src/**/*.test.ts` files here are **not** run by `pnpm test`. Put anything that must gate CI -in `__tests__/`, or run the co-located ones explicitly and say that you did. +in `__tests__/`, or run the co-located ones explicitly and say that you did. \ No newline at end of file diff --git a/packages/playtest/CLAUDE.md b/packages/playtest/CLAUDE.md index 4b5f59b246..75c412cfa9 100644 --- a/packages/playtest/CLAUDE.md +++ b/packages/playtest/CLAUDE.md @@ -2,8 +2,11 @@ # AGENTS.md — @threenative/playtest -Read `/AGENTS.md` first. This file covers the operator CLI and what is different about changing -the harness. +Read `/AGENTS.md` first. This file is the operator CLI and what is different about changing the +harness. Flags, exit codes, per-target detail and the assertion vocabulary live in +[docs/reference.md](docs/reference.md); ticks and the startup wait in +[docs/determinism.md](docs/determinism.md). Anything that is reference rather than rule is there, +not here. ## Running a scenario @@ -15,51 +18,32 @@ node packages/playtest/dist/runner/cli.js playtests/smoke.playtest.json \ --browser-recipe webgpu ``` -Exit `0` passed, `1` assertions failed, `2` never reached assertions, `69` when a command's -external decoder is absent so nothing was inspected, and `75` when the capture lock queue timed -out — the last two are explicitly **not** test failures, and `75` prints the holder and queue -depth. `--build-report .build-report.json` adopts the `performanceBudget` a -`threenative build` published beside that artifact: the run re-hashes the artifact under test and -exits `2` when it is not the one the report describes (`TN_PLAYTEST_BUILD_REPORT_STALE`), and -merges the budget per key into the scenario's own `assert.performance`. It needs `--artifact` on -browser and Android, which is the only thing that says which build the run exercises. `--server-command` needs a workspace that has a `dev` script — an example or a scaffolded -project; there is no root `pnpm dev`. `--browser-recipe webgpu` supplies the current Chromium WebGPU -flags including `--enable-features=Vulkan`, without which Chromium silently serves WebGPU from -SwiftShader and reports healthy-looking limits from a CPU rasteriser; `--browser-arg` is the escape -hatch, and a run that does not name its adapter is not evidence. **On Linux the runner provisions a -private Xvfb for every pixel-producing run, whether or not a display exists** (stripping Wayland env -itself), so a scenario never opens windows over whatever the operator is doing — a sweep that -borrows `:0` makes the machine unusable for as long as it runs. Set `TN_PLAYTEST_HOST_DISPLAY=1` to -paint on the session's own display instead; opt in when the run needs the session's real GPU adapter -(heavy TSL post chains have been seen falling back to SwiftShader under Xvfb) or when a human wants -to watch it. Do **not** reach for `--headless` to keep windows off a screen: headless Chromium -cannot capture WebGPU here, so it changes what you measure. The runner takes a capture lock only -when it detects competing runners — or always with `CAPTURE_LOCK=1`; lock state is printed to stderr -either way. `sh scripts/xvfb.sh` remains as an optional compatibility wrapper — never `xvfb-run`, -whose exit status is its own failing cleanup kill rather than the command's. - -**A frame rate from that private Xvfb is wrong, not missing**, so no command may print one as -though it were measured: without vsync the present wait lands inside the update phase (13.3 fps -there against 57.7 on the real display, one build). `trace` never prints one. `perf` prefers the -display's own rate whenever a window carries one — core reads the host's `__tnPresentedCount` and -reports `presents`/`presentedFps` beside the loop's `fps` — and otherwise suppresses the column and -refuses a `--min-fps` bound when the run's display is private (`--executable`) **or the log says for -itself that the frames never reached the display**: the host counts loop frames and presents -separately in `TN_PRESENTS_TICK`, and a loop the presentation cap outran inflates `fps` by exactly -that ratio — midway's native launch log reported 2631 fps beside its own -`{"frames":1740,"presents":133,"capHz":60}`, and passed a 55 fps bound. A window that counted zero -presents carries no rate at all: the column prints `0.00`, the windows are named in words, and a -bound over windows that all lack a rate fails closed. Both refusals take `--allow-virtual-display` -when the operator is deliberately reading phase timings alone. A -`--min-fps` bound that can be satisfied by a number nobody can vouch for is a green with nothing -behind it — the desktop build above reported 20,000 fps that way. - -In a scaffolded project the same CLI is `npx @threenative/playtest`, and `diagnostics`, console, -network, screenshot and trace assertions work against any URL. The framework template installs the -bridge with `playtest()` in `defineGame`; a plain Three.js project uses `installThreePlaytestBridge` -from `@threenative/playtest/three`. Semantic assertions (`movement`, `camera`, `visibility`) against -a project with neither bridge fail `TN_PLAYTEST_BRIDGE_MISSING` — that is the harness being right. -Install the bridge or narrow the scenario; never delete the assertion to get green. +Exit `0` passed, `1` assertions failed, `2` never reached assertions, and `69` / `75` are +explicitly **not** test failures (external decoder absent; capture lock queue timed out). + +- `--browser-recipe webgpu` is not optional on the browser lane. Without its Vulkan flags Chromium + silently serves WebGPU from SwiftShader and reports healthy-looking limits from a CPU rasteriser; + a run that does not name its adapter is not evidence. `--browser-arg` is the escape hatch. +- Never `--headless` to keep windows off a screen: headless Chromium cannot capture WebGPU here, + so it changes what you measure. On Linux the runner provisions its own private Xvfb for every + pixel-producing run; `TN_PLAYTEST_HOST_DISPLAY=1` opts into the session's display when the run + needs the real GPU adapter or a human watching. +- **A frame rate from that private Xvfb is wrong, not missing.** `perf` suppresses the column and + refuses a `--min-fps` bound (exit `1`, `TN_PERF_VIRTUAL_DISPLAY`) unless you pass + `--allow-virtual-display`. A bound satisfied by a number nobody can vouch for is a green with + nothing behind it. +- Wrap a headless run in `sh scripts/xvfb.sh `, never `xvfb-run` — its exit status is its own + failing cleanup kill, not the command's. +- A scaffolded project runs the same CLI as `npx @threenative/playtest`. The framework template + installs the bridge with `playtest()` in `defineGame`; a plain Three.js project uses + `installThreePlaytestBridge` from `@threenative/playtest/three`. +- `diagnostics`, console, network, screenshot and trace assertions work against any URL. Semantic + assertions (`movement`, `camera`, `visibility`) against a project with neither bridge fail + `TN_PLAYTEST_BRIDGE_MISSING` — that is the harness being right. Install the bridge or narrow the + scenario; **never delete the assertion to get green**. + +Ask the machine before spending a run: `doctor --text`, `doctor --url --text` (adds the +scene), `doctor --device --text` (adds device thermals) — see the root `AGENTS.md`. ## The rule that outranks everything else in this package @@ -82,402 +66,57 @@ Concretely, when you touch this package: - New assertion types need a test proving the wrong-typed case fails. `__tests__/` already holds the shape: `vacuous-assertion.spec.ts`, `silent-drop.spec.ts`, `evidence-required.spec.ts`. Add to those rather than starting a new pattern. - -## One scenario, four targets - -`--target browser|android|desktop|ios` runs the same scenario file against a browser, an Android -device or emulator, a native desktop executable, and an iOS simulator or device -(`runner/androidRunner.ts`, `runner/desktopRunner.ts`, `runner/iosRunner.ts`, -`runner/deviceTransport.ts`). Desktop requires `--executable`, and the game bundle reaches the host -through repeatable `--host-arg` (`--host-arg run --host-arg dist/game.js`) — without it the host -launches with no game and the run reports `TN_PLAYTEST_BRIDGE_MISSING` at zero frames. The runner -owns a temporary local -mailbox and passes its root to the native host through `TN_PLAYTEST_MAILBOX_ROOT`. Keep it that way: -an assertion that only -means something on one target is a fork of the harness. - -A device target that cannot be reached fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades -to a browser run. Where a target genuinely lacks an observer — device transport has no CDP -network observer — the assertion **errors and names the working target**, it does not skip. -The negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, -`-wrong-value`) prove the device path still fails closed; run them when you change transport -or observation code. - -## `perf` — read the frame meters without opening a log - -`threenative-playtest perf` parses `TN_FRAME_BUDGET` and `TN_HOST_GAP` out of one source — -`--file `, `--executable ` with repeatable `--host-arg`, or `--logcat ` — and -reports fps, frame/render/hostGap p50/p95 per window plus the host-gap segments. It exists -because every number in the Android-fps hunt was read from these markers by hand; the parser -(`runner/perf.ts`) is that hand-read, encoded. - -Protocol rules are built in, not optional: **window 1 is discarded as startup** (it always -lies), `--require-windows` (default 2) counts *steady* windows and a run with fewer exits **2** -— not enough evidence, never an empty pass — and a marker line whose JSON cannot be parsed -**throws** (`TN_PERF_MARKER_MALFORMED`) rather than silently vanishing. Optional bounds -`--max-frame-p95 ` / `--min-fps ` fail with exit **1** on any steady-window violation; -without bounds the command reports and exits 0. The host's `Present mode:` line is captured -when the host logs one. `--text` renders the human-readable table; default output is JSON. - -Desktop spawn under a headless session rides the same Xvfb rule as any pixel run: -`sh scripts/xvfb.sh threenative-playtest perf --executable … --host-arg run --host-arg game.js …`, -and that Xvfb is exactly the display whose frame rate cannot be trusted — such a run prints no -`fps` column and refuses `--min-fps` (exit 1, `TN_PERF_VIRTUAL_DISPLAY`) unless the operator passes -`--allow-virtual-display`. The phase and host-gap rows, which are what the native lane's baselines -quote, are unaffected. -The command never launches a browser — the browser lane already bounds performance through -`assert.performance` — and it never tunes anything; it is a meter reader. - -## `audio` — look at the sound, because nobody listens in CI - -`threenative-playtest audio --expect ` decodes every clip a game declares and -reports band energy, peak, DC, silence and loop-seam continuity, writing one spectrogram PNG per -clip. No browser, no display, no capture lock: inspecting audio reads files, and taking the capture -queue for it would block the machine's pixel work for nothing. - -It exists because **every check that does not involve listening passes on audio that is wrong**. -The file exists, it is served 200, it decodes, no page error, it is inside the byte budget — and the -clip is a hum where a chime should be. That shipped here: a discovery cue with 80% of its energy in -100-500 Hz and 1% above 2 kHz, on the one sound a player waits to hear. So did fifteen footsteps -with up to 45% of their energy below 100 Hz, which is a thud, not a boot on stone. Both are -unmistakable in a band profile and a spectrogram, and invisible in a size, a duration or a green -`loaded` marker. - -**The game declares the expectations, because the inspector cannot know them.** Nothing here knows -that a forest bed should be broadband and a discovery chime should be bright. The manifest is the -game saying so: - -```json -{ "version": 1, "clips": [ - { "path": "public/audio/forest-bed.ogg", "loop": true, - "bands": { "sub": { "max": 3 }, "high": { "min": 20 }, "air": { "min": 20 } } }, - { "path": "public/audio/landmark-found.ogg", "loop": false, - "bands": { "low": { "max": 25 }, "high": { "min": 25 } } } -] } -``` - -Bands are `sub` (<100 Hz), `low` (100-500), `mid` (500-2k), `high` (2k-8k) and `air` (>8k), -contiguous to Nyquist, reported as percentages of summed magnitude over Hann-windowed frames -covering the whole signal. They are comparable to each other and to what a game declares, not to -another tool's numbers. The frame hop shrinks on a short clip so its spectrogram is wide enough to -read a transient off — a half-second footstep holds twenty non-overlapping frames, and a -twenty-column picture defeats the only reason the picture exists. - -Fails closed, on this package's own rule: an unknown key, a band nobody measures, a bound that can -never hold, a `seamMaxRatio` on a clip whose `loop` is false, a duplicate path, or an empty clip -list all **throw** rather than skip. `loop` is required rather than defaulted, because it is the one -fact that decides whether the seam is checked. `--dir ` additionally fails when any audio file -under it is undeclared — otherwise the gate is only as good as the manifest and a clip added later -is a clip nothing checks. - -**Two measurement rules, both learned by getting them wrong.** - -*Decode at the file's own rate.* A seam measured on a resampled decode measures the resampler: its -FIR window runs off the end of the data at the first and last output sample and is zero-padded, so -the edge samples are the only wrong ones in the file — exactly where a seam test looks. On one real -set that inflated the reported step three to sevenfold and reordered which clip looked worst. The -command never passes `-ar` to ffmpeg. - -*Judge the wrap against the steps beside it.* A click is a step that is anomalous **where it -happens**. A sparse clip is mostly quiet, so a whole-clip percentile flatters its seam; a dense one -is mostly loud, so the same percentile condemns a join nobody could hear. The reference is the -99th-percentile sample step within 50 ms either side, and the default ceiling is 1.5x rather than -1.0x — a flawless wrap that lands on the signal's steepest point *is* the largest step in its -neighbourhood and measures exactly 1.0. - -Exit `0` when every check passed, `1` when one failed, `2` when it could not run (a malformed or -unreadable manifest), and `69` when ffmpeg is absent. That last code is the point: **"I could not -check" and "I checked and it is fine" must never be the same answer**, so CI can treat 69 as a skip -and still treat 1 as a defect. - -`--text` prints `✓`/`!`/`✗` lines with a `fix:` on anything that is not ok; default output is JSON. -The numbers are the gate and the picture is what a person or an agent looks at when the gate fires, -so every spectrogram written is named in both. Verdicts, parsing and analysis live in -`runner/audio.ts` and are unit-tested against synthesised signals in `__tests__/audio.spec.ts`; -`runner/audioRun.ts` only drives ffmpeg. - -## Startup time is an observation - -The runtime stamps its startup milestones on its own clock — `loadStartedMs`, `enteredMs`, -`compileSettledMs`, `readyMs`, in milliseconds since navigation — and publishes them as -`observations.startup.timeline`. Scenarios bound them with `assert.startup`: `maxEnteredMs`, -`maxCompileSettledMs`, `maxReadyMs`. A milestone the run never reached fails closed -(`TN_PLAYTEST_STARTUP_UNOBSERVABLE`); one past its ceiling fails -`TN_PLAYTEST_STARTUP_TOO_SLOW` with the measured value. Before this, startup was a console -anecdote read off `TN_STARTUP_WARMUP` after the fact. - -## Device thermal, power and battery - -Every `--target android` run measures the phone around itself and reports it as -`observations.deviceMetrics` — battery temperature and level, Android thermal status, charging -state, current draw, and Pixel's per-rail ODPM power breakdown. It is sampled before `prepare()` -(the only moment a pre-launch baseline is still readable, since `prepare` clears logcat), every -five seconds during the run, and once after the last bridge sample. - -The point is **comparability**. On 2026-08-24 two cold-launch runs read 44 s to first frame -against a 14.7 s baseline and the difference was blamed on a code change; the device had reached -43.2 °C at thermal status 2 while the baseline ran at 38.2 °C at status 0. A run is flagged -`thermallyConfounded` with named reasons — `hot-start` (≥ 40 °C), `throttled-start`, -`thermal-status-rose`, `charging`, `incomplete` — and a flagged run's numbers are still reported -in full. The verdict withdraws the claim of comparability, never the measurement. - -Scenarios assert on it with `deviceMetrics`: `notThermallyConfounded`, `maxTemperatureRiseC`, -`maxThermalStatus`. On browser, desktop or iOS the assertion fails -`TN_PLAYTEST_UNSUPPORTED_ON_TARGET` and names android — it never skips. A reading the device does -not expose (per-rail power off Pixel hardware, `current_now` where the sysfs node is absent) -reports `{ available: false, reason }`; **nothing here ever reports an unmeasured zero**. - -Ask *before* spending a run: `doctor --device ` reports the phone's battery temperature, -thermal status, charge level and charging state next to the machine checks. It warns — a hot -phone is a run that will not be comparable, not a broken machine — and only fails on a device -that is unreachable or a probe that cannot be parsed. - -```sh -node packages/playtest/dist/runner/cli.js doctor --device --text -``` - -Parsers and verdict live in `runner/deviceMetrics.ts` and are unit-tested against captured device -output in `__tests__/fixtures/device-metrics/`. Add real captures there rather than inventing a -`dumpsys` format. Both the observation lane and `doctor --device` only report; the gate that -*refuses* a hot or charging device before a benchmark is -`packages/runtime-native/scripts/device-preflight.mjs`, and they share the same battery floor so -an operator is never told two different stories. - -## The room, and the feet - -Two things the harness measured and nothing could read. - -`doctor --url` used to end its report with *not observed: lights, materials and textures* and -*not observed: camera framing*. An agent looking at a black or washed-out frame therefore had -nothing between "the bridge answered" and a screenshot, which is the one instrument that cannot -say **why**. The bridge now reports the room as `observations.scene` — lights with their colours -and intensities, materials counted per distinct material by constructor name, fog with its own -near/far or density, the background, the camera's position, forward, fov and clip planes, and the -scene's world extent. Counts and names only: it decides nothing about how anything looks, and a -value the scene does not carry is absent rather than zero. The walk is capped -(`SCENE_WALK_OBJECT_CAP`, `SCENE_LIGHT_CAP`) and a scene past either reports `truncated: true`, -so a floor is never read as a total. - -`doctor --url` reads it back as three lines — `lighting`, `materials`, `camera` — and names the -ways a frame dies while every other number stays healthy: **lit materials with no visible light**, -a **fog far plane in front of the scene it is fogging**, and a **camera far plane that clips it**. -The second is round 9's lost visual column, where a radius-90 sky dome sat behind `Fog(bottom, 18, -80)` and rendered as one flat wash; no gate could see it. - -`AnimationPlayer.stride` has measured the *feet meet the floor* convention since it shipped — what -the clip carries against what the body covered — and it never crossed the bridge either, so a game -that set `strideSync: false` had turned the measurement off as far as any proof was concerned. -It now rides in `gameplay.animation..stride`, and `assert.animation[]` bounds it: - -- `maxFootSlide` — ceiling on `|feet − ground| / ground`. The feet move at the rate the clip is - *actually playing*, which is the measured `rate` only when `synced`; an overridden clip keeps - its authored rate. Reading `rate` unconditionally scored an overridden run at zero slide — - the exact case the bound exists to catch, found by the locomotion scenario, not by a unit test. -- `strideSynced` — require the convention applied (`true`) or deliberately overridden (`false`). - -Both fail closed, and each failure names which kind: `TN_PLAYTEST_STRIDE_UNOBSERVED` when the -producer reported no stride, reported half of one, or the body covered no ground to compare -against; `TN_PLAYTEST_STRIDE_NOT_SYNCED`; `TN_PLAYTEST_FOOT_SLIDE` with both speeds and the -ceiling. A game that does not measure stride has not measured zero slide. - -A scenario bounds the same numbers with `assert.scene`, so the check outlives whoever ran -`doctor` once: - -- `minVisibleLights` — floor on lights the renderer will actually see. An invisible light is no - light. -- `litMaterialsAreLit` — fail when lit materials are mounted and nothing lights them. A scene of - `MeshBasicMaterial` needs no light and is not failed for having none. -- `fogClearsScene` — fail when a linear fog goes opaque in front of the scene's furthest corner. -- `cameraClearsScene` — fail when the camera's far plane cuts the world it is pointed at. - -`assert.scene` bounds the room. It cannot say **where one object is**, and that is the other half -of every screenshot an agent takes: is the crate in the vault or under the floor, is the camera -pointed at it, did the plate's normal map ever load, is the character's mesh visible once every -ancestor's `visible` flag is counted. Those kill a frame while every count above them stays -healthy, and a screenshot shows the damage without naming the cause. - -`sceneNodes` reports the graph node by node, for the nodes a selector picks out — `name`, -`nameContains`, `pathContains`, `type` — and bounds them: - -```json -{ "assert": { "sceneNodes": [ - { "select": { "nameContains": "crate" }, "minCount": 30, "visible": true, "texturesLoaded": true }, - { "select": { "name": "seal" }, "inFrustum": true }, - { "select": { "nameContains": "debug" }, "maxCount": 0 } -] } } -``` - -- `visible` — the node's own flag **and every ancestor's**, which is what the renderer acts on. A - visible mesh under a hidden group draws nothing and reports `visible: true` on itself. -- `inFrustum` — the node's world bounds against the active camera's frustum. A node with no - bounds is never tested and reports no membership, which fails rather than passing unmeasured. -- `texturesLoaded` — every bound map slot carries pixels. A slot bound to a texture whose image - never arrived samples black while the material, the light count and the network all read fine. -- `animated` — clips are mounted on the object. This is not what the game's mixer is *playing*; - that is `gameplay.animation` and `assert.animation`, and the failure message says so. -- `minCount` (default 1) / `maxCount` — `maxCount: 0` is how a scenario asserts a node is absent. -- `minTriangles` — summed across the matched nodes, and reported as a floor when the selector's - limit cut the list. - -Each observation is walked only when a scenario asks for it, because reading geometry, materials -and world bounds per object is real work on a large scene. The walk is capped -(`SCENE_NODE_WALK_CAP`, per-selector `limit`), `matched` always counts every match, and a cut list -reports `truncated: true` — a floor is never read as a total. A selector that filters nothing, an -assertion that selects nodes and bounds none of them, and a `minCount` above its `maxCount` all -throw at load. A run with no node observation fails once as `sceneNodes.observed` -(`TN_PLAYTEST_SCENE_NODES_UNOBSERVED`). Advertised as the `scene.nodes` capability. - -## "Because", not "and then" - -A run could always say *a contact happened* and *the state reads `won`*. Nothing related the two. -`assert.states[].atSteps` orders them at **step** boundaries, which cannot separate a win arriving -with the contact from one arriving 199 ticks later inside the same step — and that is exactly the -shape of a terminal state driven by a timer or a distance check that merely lands near a contact. - -Measured in the field: a sandbox physics puzzle reported `won` on frame one with the player -untouched eight metres away, because its goal volume overlapped the floor slab. Its own HUD agreed -the game was won. Every assertion in the harness was green, because each was individually true. - -Both sides are now tick-stamped by the producer, and `assert.causedBy` relates them: - -```json -{ "assert": { "causedBy": [ - { "cause": { "contact": { "entity": "warden", "with": "seal", "kind": "trigger" } }, - "effect": { "path": "state.status", "becomes": "won" }, - "neverBefore": true, "withinTicks": 4 } -] } } -``` - -- `neverBefore` fails when the effect is ever observed before the first matching cause. That is the - frame-one fake win, at tick granularity. -- `withinTicks` bounds `effectTick - causeTick`. A win long after the contact was not caused by it. -- `cause` takes a `contact` **or** a `transition`, never both — two causes in one row would - silently make the earlier one the cause. So "the door opened because the plate was pressed" is - the same row shape. -- A row setting neither `neverBefore` nor `withinTicks` throws at load: ordering alone is what - `atSteps` already does, and asserting only that both events happened is two assertions wearing - one name. - -Core's `playtest()` plugin drains both logs **per tick** rather than per sample and advertises -`runtime.transitions`. The transition watcher diffs each registered entity's `state` and the -top-level primitive fields of the published game state, recording `{ path, from, to, tick }` — -`states.` or `state.`. A path's first observed value is its starting point, not a -transition; recording it as one would fail every `neverBefore` against the game's own initial -state. The log is bounded by `PLAYTEST_TRANSITION_LOG_LIMIT`. - -Every failure mode is named and closed. A run with no transition log fails once as -`causedBy.observed` (`TN_PLAYTEST_TRANSITIONS_UNOBSERVED`) — a loop that never ticked has observed -nothing, not nothing-changed. A matching contact carrying **no tick** fails -`TN_PLAYTEST_CAUSE_UNSTAMPED` rather than being read as a cause at tick zero, and the message says -the producer drains only at sample time. A cause the run never observed is -`TN_PLAYTEST_CAUSE_NOT_OBSERVED`; an effect that never transitioned is -`TN_PLAYTEST_EFFECT_NOT_OBSERVED`; the frame-one case is `TN_PLAYTEST_EFFECT_PRECEDES_CAUSE` with -both ticks; a late effect is `TN_PLAYTEST_CAUSE_TOO_DISTANT`. **The family never falls back to step -granularity** — that is a different measurement, and answering a tick question with it would be the -confident wrong number this package exists to refuse. - -One related fix in core, from the same build: an area's contact used to be dropped unless the game -passed an explicit `entity` option, so a trigger that demonstrably fired reported no contact at all -and a `contacts` assertion went green over a run whose own state proved the overlap. The far side -now falls back to the scene-registry id the area is registered under. - -A run with no scene observation fails once as `scene.observed` -(`TN_PLAYTEST_SCENE_UNOBSERVED`) rather than failing each bound against nothing, and an -`assert.scene` that sets no bound throws at load. An unmeasurable comparison — no world extent, -no far plane — fails; it never counts as cleared. - -Advertised as the `scene.observe` capability. A capability the runner's registry does not define -is rejected (`TN_PLAYTEST_BRIDGE_CAPABILITY_UNKNOWN`), so a new observation channel is registered -in `src/capabilities.ts` in the same change that starts advertising it. - -## Scenario-controlled spawn & aim - -The scenario `setup` block carries a placement vocabulary so capturing a vantage frame is -one scenario, not a patch-run-revert ceremony: - -- `setup.spawn { x, z }` (+ optional `y`) overrides the SUBJECT player start's position. - An absent `y` preserves the game's own height (eye or ground line); it is never silently - defaulted to zero. Requires `subject`. -- `setup.aim { yaw, pitch }` overrides the SUBJECT player start's aim; both angles are - radians, Three.js convention (forward is -Z at yaw 0, pitch positive up). Requires - `subject`. -- `setup.place[]` entries `{ entity, at: {x,y,z}, facing?: {yaw} | lookAt?: {x,y,z}, frozen?: boolean }` - put named entities at explicit transforms. `frozen` sets `PLAYTEST_FROZEN_MARKER` - (`__threenativeFrozen`) on the entity's userData — data the game reads to suppress - physics motion, never a runner-side teleport loop. - -Presence semantics are explicit and fail closed: an unknown entity id, an entity missing -from the registry at apply time, or a target coincident with the subject is a NAMED error -(`TN_PLAYTEST_SETUP_UNAPPLIED`), never a silent skip. One entity may be placed by only one -of `setup.entities` / `setup.place`. - -Steps can also carry `{ kind: "aimAt", target: { x, z } | { entity }, pitch?, waitTicks?, screenshot?, label? }`. -The runner samples the subject's current position, computes yaw/pitch toward the target, -and applies them through the setup channel as quaternion data — no CDP mouse events and no -OS-focus dependency. An `aimAt` step cannot also deliver input (`press`, pointers) or -ignored holds (`holdTicks`/`holdFrames`); follow it with a `waitTicks` step to hold the pose. - -Every requested override rides into the run report as `setup.requested` next to what -applied (`setup.applied`). A run whose placement cannot apply fails with the reason named — -an overridden spawn must be visible in diagnostics, never green-with-silence. The game keeps -its own spawn constants; scenarios override them for determinism, through this one channel. -The template-teaching copy of this vocabulary ships via the create-threenative shared -fragment when games adopt it; until then this section is the harness contract. +- **"I could not check" and "I checked and it is fine" must never be the same answer.** That is + what `69` and `75` exist for: CI may skip them and still treat `1` as a defect. + +## Where it fails closed, and under what name + +Each of these is a named failure, never a skip. Do not add a branch that softens one. The +vocabulary behind them is in [docs/reference.md](docs/reference.md). + +- **Four targets, one scenario** — `--target browser|android|desktop|ios`. An unreachable device + fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades to a browser run. A target that genuinely + lacks an observer errors and names the working target. Desktop needs `--executable` plus + repeatable `--host-arg`, or the host launches with no game and reports + `TN_PLAYTEST_BRIDGE_MISSING` at zero frames. +- **A missing observation fails once, by name** — `causedBy.observed`, `sceneNodes.observed`, + `scene.observed`, `TN_PLAYTEST_SCENE_NODES_UNOBSERVED`, `TN_PLAYTEST_SCENE_UNOBSERVED`. A + `deviceMetrics` assertion off android is `TN_PLAYTEST_UNSUPPORTED_ON_TARGET` and names android. +- **An unmeasured value is never a zero** — an absent device reading reports + `{ available: false, reason }`; a stride the producer never reported is + `TN_PLAYTEST_STRIDE_UNOBSERVED`, not zero slide; a walk past its cap reports `truncated: true` + so a floor is never read as a total. +- **An assertion that only means something on one target is a fork of the harness.** The + negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, + `-wrong-value`) prove the device path still fails closed — run them when you touch transport or + observation code. +- **Advertise only what you registered.** An unknown capability is + `TN_PLAYTEST_BRIDGE_CAPABILITY_UNKNOWN`, so a new observation channel is registered in + `src/capabilities.ts` in the same change that starts advertising it. ## Determinism -Scenario steps count fixed-step ticks, not milliseconds — use `holdTicks`, `waitTicks`. The -deprecated `holdFrames` and `waitFrames` aliases remain accepted for compatibility and are -treated as ticks when the bridge exposes `runtime.fixedStep`; `warmupFrames` remains a genuine -requestAnimationFrame warmup. Never introduce a wall-clock sleep or a millisecond-based step -into scenario semantics. - -**Ticks are not the clock a launch runs on.** A run advances ticks as fast as the machine allows, -so a whole scenario can complete during a launch that has not finished — and everything the -application gates on startup (compute dispatch, the first world present) then never happens inside -the run. `starter-look` read `flagSteps` 0 before and 0 after because the cloth had never been -dispatched; twelve starter scenarios photographed the loading screen and reported -`TN_CAPTURE_BLANK`. Both depended only on how long boot took. - -So after `warmupFrames` and before the baseline observation, the runner waits for the application -to say its world is safe to observe: a bridge that advertises **`runtime.startup`** reports -`{ phase, progress }` from `ready()` (core's `playtest()` plugin publishes `ctx.startup`), and the -runner holds — pumping frames on browser, ticks on device — until `phase` is `"ready"`. Bounded by -`PLAYTEST_STARTUP_READY_TIMEOUT_MS`; a game that never gets there fails -`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. An application that reports no -startup at all — a plain Three.js page — is never waited on, and advertising the capability without -reporting it is malformed and throws. - -Readiness means two things, and the wait always insists on both. Core reaches `"ready"` after -first-use compilation settles **and** a frame window — five sustained in-budget frames, or a -bounded expiry of `STARTUP_STABLE_WINDOW_MS` (10s) when a CPU rasteriser can never meet them. -Observing at bare compile settlement is not enough: a game that builds its world on `whenReady()` -(the puzzle's crate pile is one) has not run yet, and the run then asserts against a half-built -world. So the software lane waits for `"ready"` like any other — at most 10s more per scenario — -and the declaration (`--allow-software` / `TN_PLAYTEST_ALLOW_SOFTWARE=1`) only **labels** the -result: `startup.rule` is `"compile-settled"`, because a CPU rasteriser reached readiness on the -bounded window rather than on sustained frames, and an operator must not read that pass as a -smoothness measurement. A lane that reaches readiness immediately is labelled the same way. +Scenario steps count fixed-step ticks, not milliseconds — `holdTicks`, `waitTicks`. Never +introduce a wall-clock sleep or a millisecond-based step into scenario semantics. Ticks are also +not the clock a launch runs on, so the runner waits, before the baseline observation, for a +bridge advertising `runtime.startup` to report `phase: "ready"`; bounded by +`PLAYTEST_STARTUP_READY_TIMEOUT_MS`, and a game that never gets there fails +`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. A host that has *exited* is +`TN_PLAYTEST_STARTUP_HOST_EXITED`, not a slow launch. **Never fix a boot race by lengthening a +wait** — padding changes which runs get lucky. Full rule: [docs/determinism.md](docs/determinism.md). -Compile settlement is still required — it is the part that makes a run observe the game rather than -the loading screen — and a game whose phase never leaves `"collapsing"` fails -`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. The label is keyed *only* off -that declaration, never off a timeout or an adapter guess. Every report carries `startup.rule`, -`"sustained-frames"` or `"compile-settled"`, so a software-lane pass is never read as a smoothness -measurement. +## Where things live -A bridge that stops answering is not automatically a slow launch. A host that has **exited** looks -identical from the runner's side — both are operation timeouts — so on the device targets the wait -also asks the driver whether the process is still there, and a dead one fails immediately as -`TN_PLAYTEST_STARTUP_HOST_EXITED` with the console tail that says why. Only a `false` reading ends -the wait: a driver that cannot answer leaves it exactly as it was, because inferring a crash from an -unreadable probe is the implicit fallback everything else here refuses. Without it a crashed runtime -spent the full `PLAYTEST_STARTUP_READY_TIMEOUT_MS` and was then reported as the game's loading gate -being too slow — three minutes of silence blamed on the wrong layer, which is how the hosted -Windows performance collector read on PR #122. +| Concern | File | +| --- | --- | +| `perf` markers → fps, p95, host-gap segments | `runner/perf.ts` | +| audio verdicts, parsing, analysis | `runner/audio.ts` (`runner/audioRun.ts` only drives ffmpeg) | +| device thermal and power parsers | `runner/deviceMetrics.ts` | +| the four target runners | `runner/androidRunner.ts`, `runner/desktopRunner.ts`, `runner/iosRunner.ts`, `runner/deviceTransport.ts` | +| capability registry | `src/capabilities.ts` | -Never fix a boot race by lengthening a wait. Padding changes which runs get lucky; the tick counts -were already identical in the runs that disagreed. +Add real captured device output to `__tests__/fixtures/device-metrics/` rather than inventing a +`dumpsys` format. ## This is salvaged code @@ -493,4 +132,4 @@ from the rest of the repo. Match the file you are editing, not the root conventi Vitest at the root only collects `packages/**/__tests__/**/*.spec.ts`. The co-located `src/**/*.test.ts` files here are **not** run by `pnpm test`. Put anything that must gate CI -in `__tests__/`, or run the co-located ones explicitly and say that you did. +in `__tests__/`, or run the co-located ones explicitly and say that you did. \ No newline at end of file diff --git a/packages/playtest/README.md b/packages/playtest/README.md index 801925a707..9d15b39632 100644 --- a/packages/playtest/README.md +++ b/packages/playtest/README.md @@ -55,7 +55,7 @@ npx @threenative/playtest playtests/device-smoke.playtest.json \ ``` For a signed physical build, add `--ios-transport device --device `. -Network, DOM, and visual-metric assertions are unsupported on device targets and fail +Network, DOM, and `assert.visual` assertions are unsupported on device targets and fail `TN_PLAYTEST_UNSUPPORTED_ON_TARGET` with exit code 2. Default CI does not run Android or iOS device scenarios. `.github/workflows/native-platforms.yml` is an explicit opt-in platform lane; an absent run is not a pass. @@ -68,3 +68,39 @@ entity, an absent resource, an empty effect log, or a scenario with no assertions at all is a failure, never a silent pass. Wrong-typed assertion values are rejected when the scenario loads rather than dropped, so a scenario cannot quietly run with fewer checks than its author wrote. + + +## Tone gates and offline inspection + +`npx @threenative/playtest tone shot.png other.png` prints tab-separated rows for mean, p1, p50, +p99, clip%, black%, plus an unweighted frame-average row. No browser is started. Empty input, +unreadable PNGs and images without visible pixels exit 2 without a partial table. + +```json +{ "assert": { "tone": [{ "atStep": "landed", "mean": { "min": 60, "max": 140 }, "p99": { "min": 150 }, "clipFraction": { "max": 0.005 } }] } } +``` + +`atStep` selects a named step; omission selects the final capture. Every metric takes inclusive +min/max bounds. Empty arrays, empty bounds, non-finite/out-of-range numbers, unknown keys and +reversed ranges fail at load. Missing capture evidence fails TN_PLAYTEST_TONE_UNOBSERVED. +Failed bounds name the measured and required values. + +All six metrics use the capture guard's existing PNG decode without downsampling: rounded +Rec.709 display luminance fills 256 bins; mean averages bin values and percentiles use nearest +rank. Clip/black fractions count bins 255/0 and use 0..1 in assertions (percentages in the CLI). +Fully transparent pixels are excluded; other pixels retain stored RGB without compositing. +These numbers measure exposure, not aesthetic quality. + + +Browser, Android, desktop and iOS scenario captures now record TN_TONE under observations.tone, +even without tone assertions. Explicit tone requests force their captures despite disabled +convenience screenshots. A named step's existing screenshot is reused. This host-side support +is exercised with target-driver fixtures; it does not turn unit tests into native GPU proof. + + +Repository runtime proof: `pnpm test:tone` builds the isolated calibration fixture and captures +its real WebGPU output at exposure 0.25 and 1. The same assertions must fail then pass. PNGs, +observations, reports and adapter provenance are retained under `artifacts/tone-exposure`; the +summary names the exact source SHA. It also runs after `pnpm test:templates`. The maintained +`integration-tone` workflow permits software WebGPU for pixel correctness only, not native +execution, hardware performance or aesthetic qualification. diff --git a/packages/playtest/__tests__/android-console.spec.ts b/packages/playtest/__tests__/android-console.spec.ts index 3f7c95a409..c46fceb98a 100644 --- a/packages/playtest/__tests__/android-console.spec.ts +++ b/packages/playtest/__tests__/android-console.spec.ts @@ -25,6 +25,88 @@ test("Android console starts after stopping the previous process and keeps new l expect(await driver.captureConsole()).toEqual([{ text: launchError, type: "error" }]); }); +/** + * One dropped adb transport is not a game failure. + * + * A hosted SwiftShader emulator drops the adb transport mid-dump: `adb logcat -d` prints part of + * the buffer, exits non-zero on the truncated line, and the run failed TN_PLAYTEST_RUNNER_FAILED on + * a game whose assertions had all passed (CI `clean-consumer`, 5th of 6 sequential runs). The same + * class was already fixed twice on the native side — #310 and b12becde6 — by re-reading after + * `wait-for-device`; mirroring that shape here keeps the two harnesses answering a dropped transport + * the same way. + */ +test("Android console capture retries a dropped logcat transport instead of failing the run", async () => { + const calls: Array = []; + let logcatAttempts = 0; + const driver = new AdbAndroidDriver({ + activity: "com.threenative.runtime.MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.example.game", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async (args) => { + calls.push(args); + if (args[0] === "logcat" && args[1] === "-d") { + logcatAttempts += 1; + if (logcatAttempts === 1) throw new Error("adb logcat -d -v brief failed: I/SDL ( 8001): [Mystral] Runtime ini"); + return "I/SDL ( 8001): [Mystral] Runtime initialized\nE/SDL ( 8001): [Mystral] authored script failed\n"; + } + return ""; + }; + + expect(await driver.captureConsole()).toEqual([ + { text: "I/SDL ( 8001): [Mystral] Runtime initialized", type: "log" }, + { text: "E/SDL ( 8001): [Mystral] authored script failed", type: "error" }, + ]); + expect(logcatAttempts).toBe(2); + expect(calls.filter((args) => args[0] === "wait-for-device")).toHaveLength(1); +}); + +test("Android console capture fails with a bounded excerpt once the transport is gone", async () => { + let logcatAttempts = 0; + const driver = new AdbAndroidDriver({ + activity: "com.threenative.runtime.MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.example.game", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async (args) => { + if (args[0] === "logcat" && args[1] === "-d") { + logcatAttempts += 1; + throw new Error(`adb logcat -d -v brief failed: ${"E/SDL ( 8001): noise\n".repeat(4_000)}`); + } + return ""; + }; + + // The failing report embedded an 82 KB logcat in the message; the excerpt keeps the complaint + // (the tail, where the truncation is visible) and drops the rest. + const failure = await driver.captureConsole().then(() => undefined, (error: Error) => error); + expect(failure?.message).toMatch(/^adb logcat -d -v brief failed: /u); + expect(failure?.message.length ?? 0).toBeLessThanOrEqual(2_200); + expect(failure?.message).toMatch(/noise/u); + expect(logcatAttempts).toBe(2); +}); + +test("Android console capture does not retry a device that refuses the command", async () => { + let logcatAttempts = 0; + const driver = new AdbAndroidDriver({ + activity: "com.threenative.runtime.MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.example.game", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async (args) => { + if (args[0] === "logcat" && args[1] === "-d") { + logcatAttempts += 1; + throw new Error("adb logcat -d -v brief failed: KO: unknown command"); + } + return ""; + }; + + await expect(driver.captureConsole()).rejects.toThrow(/unknown command/u); + expect(logcatAttempts).toBe(1); +}); + test("Android console ignores SurfaceSyncGroup framework noise that names MystralActivity", () => { const entries = parseAndroidConsole([ "E/SurfaceSyncGroup( 4270): Failed to receive transaction ready for VRI[MystralActivity]", diff --git a/packages/playtest/__tests__/capture.spec.ts b/packages/playtest/__tests__/capture.spec.ts index 97dcd89bdf..00e23b37ec 100644 --- a/packages/playtest/__tests__/capture.spec.ts +++ b/packages/playtest/__tests__/capture.spec.ts @@ -71,3 +71,14 @@ test("a dark but varied game frame remains accepted below the bright-pixel thres expect(stats.luminanceStdDev).toBeGreaterThanOrEqual(0.02); expect(stats.maxLuminance).toBeLessThanOrEqual(0.5); }); + +test("tone reports all six statistics from a 256-bin display-luminance histogram", () => { + const stats = inspectFrame(image(Array.from({ length: 256 }, (_, value) => [value, value, value, 255]))); + expect(stats.tone).toEqual({ mean: 127.5, p1: 2, p50: 127, p99: 253, clipFraction: 1 / 256, blackFraction: 1 / 256 }); +}); + +test("tone uses Rec.709 luminance and ignores fully transparent pixels", () => { + const stats = inspectFrame(image([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 0]])); + expect(stats.tone).toEqual({ mean: (54 + 182 + 18) / 3, p1: 18, p50: 54, p99: 182, clipFraction: 0, blackFraction: 0 }); + expect(inspectFrame(image([[255, 255, 255, 0]])).tone).toBeUndefined(); +}); diff --git a/packages/playtest/__tests__/click-step.spec.ts b/packages/playtest/__tests__/click-step.spec.ts index 39a3cdd38f..5aa31818c0 100644 --- a/packages/playtest/__tests__/click-step.spec.ts +++ b/packages/playtest/__tests__/click-step.spec.ts @@ -38,6 +38,46 @@ test("click drives a browser pointer down and up at viewport pixels", async () = expect(calls).toEqual([["move", 320, 180], ["down"], ["up"]]); }); +test("click resolves a DOM element target to the centre of its live box, and refuses a missing one", async () => { + const calls: Array<[string, number?, number?]> = []; + const selectors: string[] = []; + const boxes: Record = { + "#cinematic": { height: 15, width: 27, x: 1354, y: 540 }, + "#gone": null, + }; + const page = { + context: () => ({ newCDPSession: async () => ({ send: async () => undefined }) }), + evaluate: async () => undefined, + keyboard: { down: async () => undefined, up: async () => undefined }, + locator: (selector: string) => { + selectors.push(selector); + return { first: () => ({ boundingBox: async () => boxes[selector] ?? null }) }; + }, + mouse: { + down: async () => calls.push(["down"]), + move: async (x: number, y: number) => calls.push(["move", x, y]), + up: async () => calls.push(["up"]), + }, + } as unknown as Page; + const run = (element: { id?: string; selector?: string }) => + runStep( + page, + undefined, + { at: { element }, kind: "click", release: true } as never, + { height: 900, width: 1440 }, + undefined, + [], + { heldKeys: new Set(), pointerButtons: 0, pointers: new Map() }, + undefined, + true, + ); + + await run({ id: "cinematic" }); + expect(calls).toEqual([["move", 1367.5, 547.5], ["down"], ["up"]]); + await expect(run({ id: "gone" })).rejects.toThrow(/'#gone' is not on the page/u); + expect(selectors).toEqual(["#cinematic", "#gone"]); +}); + test("wheel input delivers exactly one browser input sample", async () => { const calls: Array<[number, number]> = []; let deliveredSamples = 0; diff --git a/packages/playtest/__tests__/scenario.spec.ts b/packages/playtest/__tests__/scenario.spec.ts index fb34f60053..3f600e0f60 100644 --- a/packages/playtest/__tests__/scenario.spec.ts +++ b/packages/playtest/__tests__/scenario.spec.ts @@ -586,6 +586,7 @@ test("preflight requirements derive from the registries", () => { * already honours (report.follow set). */ const FAMILY_SCENARIO_ASSERTS = { + tone: [{ mean: { min: 60 } }], aerodynamics: [{ controls: [{ sign: "positive", surface: "elevator" }], entity: "aircraft", minForceSamples: 1 }], animation: [{ advancedFrames: 2, clip: "run", entity: "player" }], audio: [{ cue: "speech:p01", maxPlays: 1, minGapMs: 500, minPlays: 1 }], @@ -626,6 +627,7 @@ const FAMILY_SCENARIO_ASSERTS = { /** Result ids of the all-families scenario when every family's evidence arrived. */ const FAMILY_PASS_IDS = [ + "tone.0.mean", "deviceMetrics.observed", "deviceMetrics.notThermallyConfounded", "deviceMetrics.maxTemperatureRiseC", @@ -703,6 +705,7 @@ function familyReportObservations(fulfilled: boolean) { } const physicsSample = (primitives: unknown[]) => ({ artifact: { primitives }, label: undefined }); return { + tone: [{ code: "TN_TONE", label: "after.png", mean: 100, p1: 0, p50: 100, p99: 200, clipFraction: 0, blackFraction: 0 }], components: { player: { health: { after: 2, before: 3 } } }, console: [], deviceMetrics: { @@ -873,6 +876,7 @@ test("should preserve every assertion family's result contract", async () => { // (visual collapses to its not-evaluated placeholder) and names a diagnostic code. const empty = await evaluate(familyReportObservations(false)); expect(empty.assertions.map(({ id }) => id), "RED observed: assertion family result ordering changed").toEqual([ + "tone.0.observed", "deviceMetrics.observed", "framebufferCoverage", "reachability.0.platform.a.platform.b", @@ -916,6 +920,7 @@ test("should preserve every assertion family's result contract", async () => { "movement.distance", ]); expect(empty.diagnostics.map(({ code }) => code), "RED observed: fail-closed diagnostic codes changed").toEqual([ + "TN_PLAYTEST_TONE_UNOBSERVED", "TN_PLAYTEST_DEVICE_METRICS_UNAVAILABLE", "TN_PLAYTEST_FRAMEBUFFER_WINDOW_NOT_REACHED", "TN_PLAYTEST_REACHABILITY_ASSERTION_FAILED", diff --git a/packages/playtest/__tests__/schema-boundaries.spec.ts b/packages/playtest/__tests__/schema-boundaries.spec.ts index b956abc0cf..1ebe99d5e1 100644 --- a/packages/playtest/__tests__/schema-boundaries.spec.ts +++ b/packages/playtest/__tests__/schema-boundaries.spec.ts @@ -83,6 +83,47 @@ describe("scenario schema boundaries", () => { expect(parsed.target).toBe("web"); }); + it("accepts reduced-motion emulation on the web target and refuses it elsewhere", () => { + const parsed = validatePlaytestScenario({ + ...scenario(undefined), + reducedMotion: "reduce", + target: "web", + viewport: { height: 720, width: 1280 }, + }, "reduced-motion.playtest.json"); + expect(parsed.reducedMotion).toBe("reduce"); + expect(() => validatePlaytestScenario({ + ...scenario(undefined), + reducedMotion: "reduce", + target: "desktop", + viewport: { height: 720, width: 1280 }, + }, "desktop-reduced-motion.playtest.json")).toThrow(/reducedMotion is browser-only/u); + expect(() => validatePlaytestScenario({ + ...scenario(undefined), + reducedMotion: "no-preference", + }, "invalid-reduced-motion.playtest.json")).toThrow(/reducedMotion must be 'reduce'/u); + }); + + it("accepts a click on a DOM element by id or selector, never both or neither", () => { + const click = (at: unknown) => ({ + ...scenario(undefined), + steps: [{ at, kind: "click", release: true, waitTicks: 1 }], + target: "web", + viewport: { height: 720, width: 1280 }, + }); + expect(validatePlaytestScenario(click({ element: { id: "cloud" } }), "id.playtest.json").steps[0]?.at).toEqual({ + element: { id: "cloud" }, + }); + expect( + validatePlaytestScenario(click({ element: { selector: "[role=switch]" } }), "sel.playtest.json").steps[0]?.at, + ).toEqual({ element: { selector: "[role=switch]" } }); + expect(() => validatePlaytestScenario(click({ element: { id: "a", selector: "b" } }), "both.playtest.json")).toThrow( + /exactly one of id or selector/u, + ); + expect(() => validatePlaytestScenario(click({ element: {} }), "none.playtest.json")).toThrow( + /exactly one of id or selector/u, + ); + }); + it("rejects an unknown boot-failure seam", () => { expect(() => validatePlaytestScenario({ ...scenario(undefined), diff --git a/packages/playtest/__tests__/tone-runner.spec.ts b/packages/playtest/__tests__/tone-runner.spec.ts new file mode 100644 index 0000000000..ff792aefc4 --- /dev/null +++ b/packages/playtest/__tests__/tone-runner.spec.ts @@ -0,0 +1,93 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { PNG } from "pngjs"; +import { expect, test } from "vitest"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; +import { inspectFrame } from "../src/capture.js"; +import { PLAYTEST_PROTOCOL_LIMITS, PLAYTEST_PROTOCOL_VERSION } from "../src/protocol.js"; +import { runDevicePlaytest } from "../src/runner/androidRunner.js"; +import { runNeedsPixels } from "../src/runner/captureEnvironment.js"; +import { preflightDisplay } from "../src/runner/runner-support.js"; +import { validatePlaytestScenario } from "../src/scenario.js"; +import type { IDevicePlaytestTransport } from "../src/runner/deviceTransport.js"; + +test("tone qualification uses the built runner for browser callback serialization", async () => { + const verifier = await readFile(new URL("../../../scripts/verify-tone-exposure.ts", import.meta.url), "utf8"); + expect(verifier).toContain('from "../packages/playtest/dist/runner/index.js"'); + expect(verifier).not.toMatch(/from ["'][^"']*\/src\/runner\//); +}); + +test("tone fixture supplies a favicon without an unrelated network request", async () => { + const html = await readFile(new URL("../../../examples/abyss-framework/tone.html", import.meta.url), "utf8"); + expect(html).toMatch(/ undefined, close: async () => undefined, waitForBridge: async () => true, + call: async (method: string, argument?: unknown): Promise => { + if (method === "describe") return { protocolVersion: PLAYTEST_PROTOCOL_VERSION, capabilities: ["runtime.fixedStep", "runtime.diagnostics"], limits: PLAYTEST_PROTOCOL_LIMITS, name: "tone-fixture" } as T; + if (method === "ready") return { ready: true } as T; + if (method === "advance") { tick += argument as number; return undefined as T; } + if (method === "sample") return { clock: { mode: "fixed-step", tick }, diagnostics: [], entities: [], resources: {} } as T; + throw new Error(`Unexpected transport operation ${method}`); + }, + }; + const report = await runDevicePlaytest({ projectPath, scenarioPath: "tone.json", artifactDirectory: join(projectPath, "artifacts"), target, url: "http://127.0.0.1:5173", timeoutMs: 1000, trace: false, headless: true, captureArtifactScreenshots: false }, { + name: target, processName: "tone-fixture", transport, + mailboxPaths: { request: "request", response: "response" }, + driver: { + prepare: async () => undefined, stop: async () => undefined, isAlive: async () => true, captureConsole: async () => [], + screenshot: async (path) => { captured.push(basename(path)); await writeFile(path, earlyAfter && captured.length > 1 ? darkPixels : pixels); }, + }, + }); + return { report, captured, projectPath }; +} + +test.each(["android", "desktop", "ios"] as const)("%s final tone uses the final pixels when an earlier step is named after", async (target) => { + const { report, captured, projectPath } = await run(target, { tone: [{ mean: { min: 100 } }] }, false, true); + expect(captured).toEqual(["after.png", "after.png"]); + const finalTone = inspectFrame(await readFile(join(projectPath, "artifacts/after.png"))).tone; + expect(finalTone?.mean).toBe(32); + expect(report.pass).toBe(false); + expect(report.assertionResults).toContainEqual(expect.objectContaining({ id: "tone.0.mean", pass: false, details: expect.objectContaining({ observed: finalTone?.mean }) })); +}); + +test.each(["android", "desktop", "ios"] as const)("%s captures requested tone despite disabled convenience screenshots", async (target) => { + const { report, captured } = await run(target, { tone: [{ atStep: "landed", mean: { min: 100 } }, { p99: { min: 200 } }] }); + expect(report.pass).toBe(true); + expect(captured).toEqual(["tone-0.png", "after.png"]); + expect(report.observations?.tone).toEqual([ + { code: "TN_TONE", label: "tone-0.png", atStep: "landed", ...inspectFrame(pixels).tone }, + { code: "TN_TONE", label: "after.png", ...inspectFrame(pixels).tone }, + ]); +}); + +test("ordinary captures retain tone even without tone assertions", async () => { + const { report } = await run("desktop", { diagnostics: {} }, "after"); + expect(report.observations?.tone).toEqual([{ code: "TN_TONE", label: "after.png", ...inspectFrame(pixels).tone }]); +}); + +test("browser tone-only assertions require a pixel lane", () => { + const scenario = validatePlaytestScenario({ schemaVersion: 1, name: "tone", steps: [{ waitTicks: 1 }], artifacts: { screenshots: false }, assert: { tone: [{ mean: { min: 1 } }] } }, "tone.json"); + const config = { headless: true, captureArtifactScreenshots: false }; + expect(runNeedsPixels(config as never, scenario)).toBe(true); + expect(preflightDisplay(config as never, scenario, {}, "linux")?.code).toBe("TN_PLAYTEST_HEADLESS_WEBGPU"); +}); diff --git a/packages/playtest/__tests__/tone.spec.ts b/packages/playtest/__tests__/tone.spec.ts new file mode 100644 index 0000000000..0d6e977b51 --- /dev/null +++ b/packages/playtest/__tests__/tone.spec.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { PNG } from "pngjs"; +import { afterEach, expect, test, vi } from "vitest"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; +import { inspectFrame } from "../src/capture.js"; +import { evaluateRichPlaytestAssertions } from "../src/assertion-evaluators.js"; +import { main } from "../src/runner/cli.js"; +import { validatePlaytestScenario } from "../src/scenario.js"; +import type { IPlaytestReport } from "../src/report.js"; + +const metrics = { mean: 127.5, p1: 2, p50: 127, p99: 253, clipFraction: 1 / 256, blackFraction: 1 / 256 }; +const bounds = { mean: { min: 60, max: 140 }, p99: { min: 150 }, p1: { max: 40 }, clipFraction: { max: 0.005 }, blackFraction: { max: 0.25 } }; +function scenario(tone: unknown) { + return validatePlaytestScenario({ name: "tone", schemaVersion: 1, steps: [{ label: "landed", waitTicks: 1 }], assert: { tone } }, "tone.json"); +} +function evaluate(tone: unknown, observed: unknown) { + return evaluateRichPlaytestAssertions({ scenario: scenario(tone), report: { observations: { console: [], hud: {}, network: [], resources: {}, tone: observed } } as unknown as IPlaytestReport }); +} +afterEach(() => { vi.restoreAllMocks(); process.exitCode = undefined; }); + +test("tone bounds load in the assertion dictionary and validate named steps", () => { + expect(scenario([{ atStep: "landed", ...bounds }]).assert?.tone).toEqual([{ atStep: "landed", ...bounds }]); + expect(() => scenario([{ atStep: "typo", mean: { min: 1 } }])).toThrow("names step label 'typo'"); +}); + +test.each([ + [[], "at least one"], [[{}], "at least one metric bound"], [[{ atStep: "landed" }], "at least one metric bound"], + [[{ mean: {} }], "min or max"], [[{ mean: { min: "60" } }], "assert.tone[0].mean.min"], + [[{ mean: { min: Number.NaN } }], "assert.tone[0].mean.min"], [[{ mean: { max: Number.POSITIVE_INFINITY } }], "assert.tone[0].mean.max"], + [[{ mean: { min: -1 } }], "assert.tone[0].mean.min"], [[{ p99: { max: 256 } }], "assert.tone[0].p99.max"], + [[{ clipFraction: { max: 1.1 } }], "assert.tone[0].clipFraction.max"], [[{ blackFraction: { min: -0.1 } }], "assert.tone[0].blackFraction.min"], + [[{ mean: { min: 10, max: 9 } }], "min must not exceed max"], [[{ mean: { maximum: 90 } }], "maximum"], + [[{ mean: null }], "mean"], [[{ atStep: 0, mean: { min: 1 } }], "atStep"], [{ mean: { min: 1 } }, "array"], +])("malformed tone bounds fail at load: %j", (input, message) => { expect(() => scenario(input)).toThrow(message); }); + +test.each([undefined, [], [null], [{ code: "TN_TONE", label: "after.png", ...metrics }]])("missing named capture fails closed: %j", (observed) => { + const result = evaluate([{ atStep: "landed", ...bounds }], observed); + expect(result.assertions).toContainEqual(expect.objectContaining({ id: "tone.0.observed", pass: false })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "TN_PLAYTEST_TONE_UNOBSERVED", message: expect.stringContaining("landed") })); +}); + +test("every inclusive bound is evaluated and failures name measured and required values", () => { + const frame = { code: "TN_TONE", label: "landed.png", atStep: "landed", ...metrics }; + const result = evaluate([{ atStep: "landed", ...bounds }], [frame]); + expect(result.assertions.filter(({ id }) => id.startsWith("tone."))).toHaveLength(5); + expect(result.assertions.every(({ pass }) => pass)).toBe(true); + const dark = evaluate([{ atStep: "landed", ...bounds }], [{ ...frame, mean: 31.875, p99: 63 }]); + expect(dark.assertions.find(({ id }) => id === "tone.0.mean")?.pass).toBe(false); + expect(dark.assertions.find(({ id }) => id === "tone.0.p99")?.pass).toBe(false); + expect(dark.diagnostics.map(({ message }) => message).join(" ")).toMatch(/31\.875.*60/); + expect(dark.diagnostics.map(({ message }) => message).join(" ")).toMatch(/63.*150/); +}); + +test("invalid metrics cannot satisfy even an unrelated bound", () => { + const result = evaluate([{ mean: { min: 0 } }], [{ code: "TN_TONE", label: "after.png", ...metrics, p99: Number.NaN }]); + expect(result.assertions.find(({ id }) => id === "tone.0.observed")?.pass).toBe(false); +}); + +test("CLI and assertions use identical six numbers, with an unweighted frame average", async () => { + const directory = await makeTempDir("tone-cli-"); + const ramp = new PNG({ width: 256, height: 1 }); + for (let i = 0; i < 256; i += 1) ramp.data.set([i, i, i, 255], i * 4); + const black = new PNG({ width: 1, height: 1 }); black.data.set([0, 0, 0, 255]); + const path = join(directory, "ramp.png"); const darkPath = join(directory, "black.png"); + await writeFile(path, PNG.sync.write(ramp)); await writeFile(darkPath, PNG.sync.write(black)); + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((text) => { output.push(String(text)); return true; }); + expect(await main(["tone", path, darkPath])).toBe(0); + const rows = output.join("").trim().split("\n").map((line) => line.split("\t")); + expect(rows[0]).toEqual(["frame", "mean", "p1", "p50", "p99", "clip%", "black%"]); + expect(rows[1]).toEqual([path, "127.50", "2.00", "127.00", "253.00", "0.39", "0.39"]); + expect(rows[3]).toEqual(["average", "63.75", "1.00", "63.50", "126.50", "0.20", "50.20"]); + const tone = inspectFrame(await readFile(path)).tone!; + const result = evaluate([Object.fromEntries(Object.entries(tone).map(([key, value]) => [key, { min: value, max: value }]))], [{ code: "TN_TONE", label: "after.png", ...tone }]); + expect(result.assertions.filter(({ id }) => id.startsWith("tone.")).map(({ details }) => details?.observed)).toEqual(Object.values(tone)); + expect(result.assertions.every(({ pass }) => pass)).toBe(true); +}); + +test.each([[], ["--typo"], ["does-not-exist.png"]])("CLI refuses invalid inputs %j", async (...args) => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); vi.spyOn(process.stderr, "write").mockImplementation(() => true); + expect(await main(["tone", ...args])).toBe(2); +}); + +test("tone-only scenarios stay out of the frame-less template lane", async () => { + const root = await makeTempDir("tone-classifier-"); await mkdir(join(root, "playtests")); + await writeFile(join(root, "playtests/tone.playtest.json"), JSON.stringify({ assert: { tone: [{ mean: { min: 1 } }] }, artifacts: { screenshots: false } })); + await writeFile(join(root, "playtests/state.playtest.json"), JSON.stringify({ assert: { diagnostics: {} }, artifacts: { screenshots: false } })); + const result = spawnSync(process.execPath, ["scripts/non-visual-scenarios.mjs", root], { encoding: "utf8" }); + expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("playtests/state.playtest.json"); expect(result.stderr).toContain("tone.playtest.json"); +}); + + +test("the real-render exposure scenario loads named and final frame bounds", async () => { + const source = JSON.parse(await readFile("examples/abyss-framework/playtests/tone.playtest.json", "utf8")); + const loaded = validatePlaytestScenario(source, "tone.playtest.json"); + expect(loaded.assert?.tone).toHaveLength(2); + expect(loaded.assert?.tone?.[0]?.atStep).toBe("rendered"); + expect(loaded.assert?.tone?.[0]?.mean?.min).toBeGreaterThan(0); +}); diff --git a/packages/playtest/__tests__/vacuous-assertion.spec.ts b/packages/playtest/__tests__/vacuous-assertion.spec.ts index aa852baf2a..986b344e0f 100644 --- a/packages/playtest/__tests__/vacuous-assertion.spec.ts +++ b/packages/playtest/__tests__/vacuous-assertion.spec.ts @@ -180,7 +180,7 @@ test("the six held-value assertion kinds reject boolean and short triviality opt }); test("every registry entry carries rationale and the audit reclassifies the six held-value kinds", () => { - expect(PLAYTEST_ASSERTION_REGISTRY).toHaveLength(29); + expect(PLAYTEST_ASSERTION_REGISTRY).toHaveLength(30); expect(PLAYTEST_ASSERTION_REGISTRY.every(({ trivialityRationale }) => trivialityRationale.trim().length > 0)).toBe(true); expect(PLAYTEST_ASSERTION_REGISTRY.filter(({ triviality }) => triviality === "reject-initial-value").map(({ kind }) => kind)).toEqual([ "components", diff --git a/packages/playtest/docs/determinism.md b/packages/playtest/docs/determinism.md new file mode 100644 index 0000000000..460cd6b173 --- /dev/null +++ b/packages/playtest/docs/determinism.md @@ -0,0 +1,66 @@ +# playtest determinism and startup readiness + +Why scenario timing is ticks rather than milliseconds, and what the runner waits for +before it observes a game. Moved out of +[`@threenative/playtest/AGENTS.md`](../AGENTS.md) verbatim; every paragraph below is the text +AGENTS.md used to carry. + +## Scenario steps count fixed-step ticks, not milliseconds + +Scenario steps count fixed-step ticks, not milliseconds — use `holdTicks`, `waitTicks`. The +deprecated `holdFrames` and `waitFrames` aliases remain accepted for compatibility and are +treated as ticks when the bridge exposes `runtime.fixedStep`; `warmupFrames` remains a genuine +requestAnimationFrame warmup. Never introduce a wall-clock sleep or a millisecond-based step +into scenario semantics. + +`"reducedMotion": "reduce"` (web target only) opens the page with `prefers-reduced-motion: reduce` +emulated before navigation, so a game that suppresses flashes or motion for it can prove so. + +## Ticks are not the clock a launch runs on + +**Ticks are not the clock a launch runs on.** A run advances ticks as fast as the machine allows, +so a whole scenario can complete during a launch that has not finished — and everything the +application gates on startup (compute dispatch, the first world present) then never happens inside +the run. `starter-look` read `flagSteps` 0 before and 0 after because the cloth had never been +dispatched; twelve starter scenarios photographed the loading screen and reported +`TN_CAPTURE_BLANK`. Both depended only on how long boot took. + +So after `warmupFrames` and before the baseline observation, the runner waits for the application +to say its world is safe to observe: a bridge that advertises **`runtime.startup`** reports +`{ phase, progress }` from `ready()` (core's `playtest()` plugin publishes `ctx.startup`), and the +runner holds — pumping frames on browser, ticks on device — until `phase` is `"ready"`. Bounded by +`PLAYTEST_STARTUP_READY_TIMEOUT_MS`; a game that never gets there fails +`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. An application that reports no +startup at all — a plain Three.js page — is never waited on, and advertising the capability without +reporting it is malformed and throws. + +Readiness means two things, and the wait always insists on both. Core reaches `"ready"` after +first-use compilation settles **and** a frame window — five sustained in-budget frames, or a +bounded expiry of `STARTUP_STABLE_WINDOW_MS` (10s) when a CPU rasteriser can never meet them. +Observing at bare compile settlement is not enough: a game that builds its world on `whenReady()` +(the puzzle's crate pile is one) has not run yet, and the run then asserts against a half-built +world. So the software lane waits for `"ready"` like any other — at most 10s more per scenario — +and the declaration (`--allow-software` / `TN_PLAYTEST_ALLOW_SOFTWARE=1`) only **labels** the +result: `startup.rule` is `"compile-settled"`, because a CPU rasteriser reached readiness on the +bounded window rather than on sustained frames, and an operator must not read that pass as a +smoothness measurement. A lane that reaches readiness immediately is labelled the same way. + +Compile settlement is still required — it is the part that makes a run observe the game rather than +the loading screen — and a game whose phase never leaves `"collapsing"` fails +`TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. The label is keyed *only* off +that declaration, never off a timeout or an adapter guess. Every report carries `startup.rule`, +`"sustained-frames"` or `"compile-settled"`, so a software-lane pass is never read as a smoothness +measurement. + +A bridge that stops answering is not automatically a slow launch. A host that has **exited** looks +identical from the runner's side — both are operation timeouts — so on the device targets the wait +also asks the driver whether the process is still there, and a dead one fails immediately as +`TN_PLAYTEST_STARTUP_HOST_EXITED` with the console tail that says why. Only a `false` reading ends +the wait: a driver that cannot answer leaves it exactly as it was, because inferring a crash from an +unreadable probe is the implicit fallback everything else here refuses. Without it a crashed runtime +spent the full `PLAYTEST_STARTUP_READY_TIMEOUT_MS` and was then reported as the game's loading gate +being too slow — three minutes of silence blamed on the wrong layer, which is how the hosted +Windows performance collector read on PR #122. + +Never fix a boot race by lengthening a wait. Padding changes which runs get lucky; the tick counts +were already identical in the runs that disagreed. diff --git a/packages/playtest/docs/reference.md b/packages/playtest/docs/reference.md new file mode 100644 index 0000000000..8ce40d05f3 --- /dev/null +++ b/packages/playtest/docs/reference.md @@ -0,0 +1,403 @@ +# playtest reference + +Flag tables, exit codes, per-target detail and the assertion vocabulary for +[`@threenative/playtest`](../AGENTS.md), moved out of that file verbatim so the +always-loaded rules stay short. Every paragraph below is the text AGENTS.md used to carry. + +## Running a scenario — flags, exit codes and the display + +Exit `0` passed, `1` assertions failed, `2` never reached assertions, `69` when a command's +external decoder is absent so nothing was inspected, and `75` when the capture lock queue timed +out — the last two are explicitly **not** test failures, and `75` prints the holder and queue +depth. `--build-report .build-report.json` adopts the `performanceBudget` a +`threenative build` published beside that artifact: the run re-hashes the artifact under test and +exits `2` when it is not the one the report describes (`TN_PLAYTEST_BUILD_REPORT_STALE`), and +merges the budget per key into the scenario's own `assert.performance`. It needs `--artifact` on +browser and Android, which is the only thing that says which build the run exercises. `--server-command` needs a workspace that has a `dev` script — an example or a scaffolded +project; there is no root `pnpm dev`. `--browser-recipe webgpu` supplies the current Chromium WebGPU +flags including `--enable-features=Vulkan`, without which Chromium silently serves WebGPU from +SwiftShader and reports healthy-looking limits from a CPU rasteriser; `--browser-arg` is the escape +hatch, and a run that does not name its adapter is not evidence. **On Linux the runner provisions a +private Xvfb for every pixel-producing run, whether or not a display exists** (stripping Wayland env +itself), so a scenario never opens windows over whatever the operator is doing — a sweep that +borrows `:0` makes the machine unusable for as long as it runs. Set `TN_PLAYTEST_HOST_DISPLAY=1` to +paint on the session's own display instead; opt in when the run needs the session's real GPU adapter +(heavy TSL post chains have been seen falling back to SwiftShader under Xvfb) or when a human wants +to watch it. Do **not** reach for `--headless` to keep windows off a screen: headless Chromium +cannot capture WebGPU here, so it changes what you measure. The runner takes a capture lock only +when it detects competing runners — or always with `CAPTURE_LOCK=1`; lock state is printed to stderr +either way. `sh scripts/xvfb.sh` remains as an optional compatibility wrapper — never `xvfb-run`, +whose exit status is its own failing cleanup kill rather than the command's. + +## A frame rate from a private Xvfb is wrong, not missing + +**A frame rate from that private Xvfb is wrong, not missing**, so no command may print one as +though it were measured: without vsync the present wait lands inside the update phase (13.3 fps +there against 57.7 on the real display, one build). `trace` never prints one. `perf` prefers the +display's own rate whenever a window carries one — core reads the host's `__tnPresentedCount` and +reports `presents`/`presentedFps` beside the loop's `fps` — and otherwise suppresses the column and +refuses a `--min-fps` bound when the run's display is private (`--executable`) **or the log says for +itself that the frames never reached the display**: the host counts loop frames and presents +separately in `TN_PRESENTS_TICK`, and a loop the presentation cap outran inflates `fps` by exactly +that ratio — midway's native launch log reported 2631 fps beside its own +`{"frames":1740,"presents":133,"capHz":60}`, and passed a 55 fps bound. A window that counted zero +presents carries no rate at all: the column prints `0.00`, the windows are named in words, and a +bound over windows that all lack a rate fails closed. Both refusals take `--allow-virtual-display` +when the operator is deliberately reading phase timings alone. A +`--min-fps` bound that can be satisfied by a number nobody can vouch for is a green with nothing +behind it — the desktop build above reported 20,000 fps that way. + +## Captured tone + +`assert.tone[]` bounds `mean`, `p1`, `p50`, `p99` in display luminance bytes and +`clipFraction`, `blackFraction` in 0..1. Every metric takes inclusive min/max; at least one +bound is required. `atStep` selects a named step, otherwise the final capture is used. +Tone requests survive disabled convenience screenshots and fail on absent captures. The browser +and device paths retain their shared PNG histogram as TN_TONE under observations.tone. The game's +bridge never supplies these host measurements. The offline command +`threenative-playtest tone ` prints identical metrics and a frame-average row. + +## One scenario, four targets + +`--target browser|android|desktop|ios` runs the same scenario file against a browser, an Android +device or emulator, a native desktop executable, and an iOS simulator or device +(`runner/androidRunner.ts`, `runner/desktopRunner.ts`, `runner/iosRunner.ts`, +`runner/deviceTransport.ts`). Desktop requires `--executable`, and the game bundle reaches the host +through repeatable `--host-arg` (`--host-arg run --host-arg dist/game.js`) — without it the host +launches with no game and the run reports `TN_PLAYTEST_BRIDGE_MISSING` at zero frames. The runner +owns a temporary local +mailbox and passes its root to the native host through `TN_PLAYTEST_MAILBOX_ROOT`. Keep it that way: +an assertion that only +means something on one target is a fork of the harness. + +A device target that cannot be reached fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades +to a browser run. Where a target genuinely lacks an observer — device transport has no CDP +network observer — the assertion **errors and names the working target**, it does not skip. +The negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, +`-wrong-value`) prove the device path still fails closed; run them when you change transport +or observation code. + +## `perf` — read the frame meters without opening a log + +`threenative-playtest perf` parses `TN_FRAME_BUDGET` and `TN_HOST_GAP` out of one source — +`--file `, `--executable ` with repeatable `--host-arg`, or `--logcat ` — and +reports fps, frame/render/hostGap p50/p95 per window plus the host-gap segments. It exists +because every number in the Android-fps hunt was read from these markers by hand; the parser +(`runner/perf.ts`) is that hand-read, encoded. + +Protocol rules are built in, not optional: **window 1 is discarded as startup** (it always +lies), `--require-windows` (default 2) counts *steady* windows and a run with fewer exits **2** +— not enough evidence, never an empty pass — and a marker line whose JSON cannot be parsed +**throws** (`TN_PERF_MARKER_MALFORMED`) rather than silently vanishing. Optional bounds +`--max-frame-p95 ` / `--min-fps ` fail with exit **1** on any steady-window violation; +without bounds the command reports and exits 0. The host's `Present mode:` line is captured +when the host logs one. `--text` renders the human-readable table; default output is JSON. + +Desktop spawn under a headless session rides the same Xvfb rule as any pixel run: +`sh scripts/xvfb.sh threenative-playtest perf --executable … --host-arg run --host-arg game.js …`, +and that Xvfb is exactly the display whose frame rate cannot be trusted — such a run prints no +`fps` column and refuses `--min-fps` (exit 1, `TN_PERF_VIRTUAL_DISPLAY`) unless the operator passes +`--allow-virtual-display`. The phase and host-gap rows, which are what the native lane's baselines +quote, are unaffected. +The command never launches a browser — the browser lane already bounds performance through +`assert.performance` — and it never tunes anything; it is a meter reader. + +## `audio` — look at the sound, because nobody listens in CI + +`threenative-playtest audio --expect ` decodes every clip a game declares and +reports band energy, peak, DC, silence and loop-seam continuity, writing one spectrogram PNG per +clip. No browser, no display, no capture lock: inspecting audio reads files, and taking the capture +queue for it would block the machine's pixel work for nothing. + +It exists because **every check that does not involve listening passes on audio that is wrong**. +The file exists, it is served 200, it decodes, no page error, it is inside the byte budget — and the +clip is a hum where a chime should be. That shipped here: a discovery cue with 80% of its energy in +100-500 Hz and 1% above 2 kHz, on the one sound a player waits to hear. So did fifteen footsteps +with up to 45% of their energy below 100 Hz, which is a thud, not a boot on stone. Both are +unmistakable in a band profile and a spectrogram, and invisible in a size, a duration or a green +`loaded` marker. + +**The game declares the expectations, because the inspector cannot know them.** Nothing here knows +that a forest bed should be broadband and a discovery chime should be bright. The manifest is the +game saying so: + +```json +{ "version": 1, "clips": [ + { "path": "public/audio/forest-bed.ogg", "loop": true, + "bands": { "sub": { "max": 3 }, "high": { "min": 20 }, "air": { "min": 20 } } }, + { "path": "public/audio/landmark-found.ogg", "loop": false, + "bands": { "low": { "max": 25 }, "high": { "min": 25 } } } +] } +``` + +Bands are `sub` (<100 Hz), `low` (100-500), `mid` (500-2k), `high` (2k-8k) and `air` (>8k), +contiguous to Nyquist, reported as percentages of summed magnitude over Hann-windowed frames +covering the whole signal. They are comparable to each other and to what a game declares, not to +another tool's numbers. The frame hop shrinks on a short clip so its spectrogram is wide enough to +read a transient off — a half-second footstep holds twenty non-overlapping frames, and a +twenty-column picture defeats the only reason the picture exists. + +Fails closed, on this package's own rule: an unknown key, a band nobody measures, a bound that can +never hold, a `seamMaxRatio` on a clip whose `loop` is false, a duplicate path, or an empty clip +list all **throw** rather than skip. `loop` is required rather than defaulted, because it is the one +fact that decides whether the seam is checked. `--dir ` additionally fails when any audio file +under it is undeclared — otherwise the gate is only as good as the manifest and a clip added later +is a clip nothing checks. + +**Two measurement rules, both learned by getting them wrong.** + +*Decode at the file's own rate.* A seam measured on a resampled decode measures the resampler: its +FIR window runs off the end of the data at the first and last output sample and is zero-padded, so +the edge samples are the only wrong ones in the file — exactly where a seam test looks. On one real +set that inflated the reported step three to sevenfold and reordered which clip looked worst. The +command never passes `-ar` to ffmpeg. + +*Judge the wrap against the steps beside it.* A click is a step that is anomalous **where it +happens**. A sparse clip is mostly quiet, so a whole-clip percentile flatters its seam; a dense one +is mostly loud, so the same percentile condemns a join nobody could hear. The reference is the +99th-percentile sample step within 50 ms either side, and the default ceiling is 1.5x rather than +1.0x — a flawless wrap that lands on the signal's steepest point *is* the largest step in its +neighbourhood and measures exactly 1.0. + +Exit `0` when every check passed, `1` when one failed, `2` when it could not run (a malformed or +unreadable manifest), and `69` when ffmpeg is absent. That last code is the point: **"I could not +check" and "I checked and it is fine" must never be the same answer**, so CI can treat 69 as a skip +and still treat 1 as a defect. + +`--text` prints `✓`/`!`/`✗` lines with a `fix:` on anything that is not ok; default output is JSON. +The numbers are the gate and the picture is what a person or an agent looks at when the gate fires, +so every spectrogram written is named in both. Verdicts, parsing and analysis live in +`runner/audio.ts` and are unit-tested against synthesised signals in `__tests__/audio.spec.ts`; +`runner/audioRun.ts` only drives ffmpeg. + +## Startup time is an observation + +The runtime stamps its startup milestones on its own clock — `loadStartedMs`, `enteredMs`, +`compileSettledMs`, `readyMs`, in milliseconds since navigation — and publishes them as +`observations.startup.timeline`. Scenarios bound them with `assert.startup`: `maxEnteredMs`, +`maxCompileSettledMs`, `maxReadyMs`. A milestone the run never reached fails closed +(`TN_PLAYTEST_STARTUP_UNOBSERVABLE`); one past its ceiling fails +`TN_PLAYTEST_STARTUP_TOO_SLOW` with the measured value. Before this, startup was a console +anecdote read off `TN_STARTUP_WARMUP` after the fact. + +## Device thermal, power and battery + +Every `--target android` run measures the phone around itself and reports it as +`observations.deviceMetrics` — battery temperature and level, Android thermal status, charging +state, current draw, and Pixel's per-rail ODPM power breakdown. It is sampled before `prepare()` +(the only moment a pre-launch baseline is still readable, since `prepare` clears logcat), every +five seconds during the run, and once after the last bridge sample. + +The point is **comparability**. On 2026-08-24 two cold-launch runs read 44 s to first frame +against a 14.7 s baseline and the difference was blamed on a code change; the device had reached +43.2 °C at thermal status 2 while the baseline ran at 38.2 °C at status 0. A run is flagged +`thermallyConfounded` with named reasons — `hot-start` (≥ 40 °C), `throttled-start`, +`thermal-status-rose`, `charging`, `incomplete` — and a flagged run's numbers are still reported +in full. The verdict withdraws the claim of comparability, never the measurement. + +Scenarios assert on it with `deviceMetrics`: `notThermallyConfounded`, `maxTemperatureRiseC`, +`maxThermalStatus`. On browser, desktop or iOS the assertion fails +`TN_PLAYTEST_UNSUPPORTED_ON_TARGET` and names android — it never skips. A reading the device does +not expose (per-rail power off Pixel hardware, `current_now` where the sysfs node is absent) +reports `{ available: false, reason }`; **nothing here ever reports an unmeasured zero**. + +Ask *before* spending a run: `doctor --device ` reports the phone's battery temperature, +thermal status, charge level and charging state next to the machine checks. It warns — a hot +phone is a run that will not be comparable, not a broken machine — and only fails on a device +that is unreachable or a probe that cannot be parsed. + +```sh +node packages/playtest/dist/runner/cli.js doctor --device --text +``` + +Parsers and verdict live in `runner/deviceMetrics.ts` and are unit-tested against captured device +output in `__tests__/fixtures/device-metrics/`. Add real captures there rather than inventing a +`dumpsys` format. Both the observation lane and `doctor --device` only report; the gate that +*refuses* a hot or charging device before a benchmark is +`packages/runtime-native/scripts/device-preflight.mjs`, and they share the same battery floor so +an operator is never told two different stories. + +## The room, and the feet + +Two things the harness measured and nothing could read. + +`doctor --url` used to end its report with *not observed: lights, materials and textures* and +*not observed: camera framing*. An agent looking at a black or washed-out frame therefore had +nothing between "the bridge answered" and a screenshot, which is the one instrument that cannot +say **why**. The bridge now reports the room as `observations.scene` — lights with their colours +and intensities, materials counted per distinct material by constructor name, fog with its own +near/far or density, the background, the camera's position, forward, fov and clip planes, and the +scene's world extent. Counts and names only: it decides nothing about how anything looks, and a +value the scene does not carry is absent rather than zero. The walk is capped +(`SCENE_WALK_OBJECT_CAP`, `SCENE_LIGHT_CAP`) and a scene past either reports `truncated: true`, +so a floor is never read as a total. + +`doctor --url` reads it back as three lines — `lighting`, `materials`, `camera` — and names the +ways a frame dies while every other number stays healthy: **lit materials with no visible light**, +a **fog far plane in front of the scene it is fogging**, and a **camera far plane that clips it**. +The second is round 9's lost visual column, where a radius-90 sky dome sat behind `Fog(bottom, 18, +80)` and rendered as one flat wash; no gate could see it. + +`AnimationPlayer.stride` has measured the *feet meet the floor* convention since it shipped — what +the clip carries against what the body covered — and it never crossed the bridge either, so a game +that set `strideSync: false` had turned the measurement off as far as any proof was concerned. +It now rides in `gameplay.animation..stride`, and `assert.animation[]` bounds it: + +- `maxFootSlide` — ceiling on `|feet − ground| / ground`. The feet move at the rate the clip is + *actually playing*, which is the measured `rate` only when `synced`; an overridden clip keeps + its authored rate. Reading `rate` unconditionally scored an overridden run at zero slide — + the exact case the bound exists to catch, found by the locomotion scenario, not by a unit test. +- `strideSynced` — require the convention applied (`true`) or deliberately overridden (`false`). + +Both fail closed, and each failure names which kind: `TN_PLAYTEST_STRIDE_UNOBSERVED` when the +producer reported no stride, reported half of one, or the body covered no ground to compare +against; `TN_PLAYTEST_STRIDE_NOT_SYNCED`; `TN_PLAYTEST_FOOT_SLIDE` with both speeds and the +ceiling. A game that does not measure stride has not measured zero slide. + +A scenario bounds the same numbers with `assert.scene`, so the check outlives whoever ran +`doctor` once: + +- `minVisibleLights` — floor on lights the renderer will actually see. An invisible light is no + light. +- `litMaterialsAreLit` — fail when lit materials are mounted and nothing lights them. A scene of + `MeshBasicMaterial` needs no light and is not failed for having none. +- `fogClearsScene` — fail when a linear fog goes opaque in front of the scene's furthest corner. +- `cameraClearsScene` — fail when the camera's far plane cuts the world it is pointed at. + +`assert.scene` bounds the room. It cannot say **where one object is**, and that is the other half +of every screenshot an agent takes: is the crate in the vault or under the floor, is the camera +pointed at it, did the plate's normal map ever load, is the character's mesh visible once every +ancestor's `visible` flag is counted. Those kill a frame while every count above them stays +healthy, and a screenshot shows the damage without naming the cause. + +`sceneNodes` reports the graph node by node, for the nodes a selector picks out — `name`, +`nameContains`, `pathContains`, `type` — and bounds them: + +```json +{ "assert": { "sceneNodes": [ + { "select": { "nameContains": "crate" }, "minCount": 30, "visible": true, "texturesLoaded": true }, + { "select": { "name": "seal" }, "inFrustum": true }, + { "select": { "nameContains": "debug" }, "maxCount": 0 } +] } } +``` + +- `visible` — the node's own flag **and every ancestor's**, which is what the renderer acts on. A + visible mesh under a hidden group draws nothing and reports `visible: true` on itself. +- `inFrustum` — the node's world bounds against the active camera's frustum. A node with no + bounds is never tested and reports no membership, which fails rather than passing unmeasured. +- `texturesLoaded` — every bound map slot carries pixels. A slot bound to a texture whose image + never arrived samples black while the material, the light count and the network all read fine. +- `animated` — clips are mounted on the object. This is not what the game's mixer is *playing*; + that is `gameplay.animation` and `assert.animation`, and the failure message says so. +- `minCount` (default 1) / `maxCount` — `maxCount: 0` is how a scenario asserts a node is absent. +- `minTriangles` — summed across the matched nodes, and reported as a floor when the selector's + limit cut the list. + +Each observation is walked only when a scenario asks for it, because reading geometry, materials +and world bounds per object is real work on a large scene. The walk is capped +(`SCENE_NODE_WALK_CAP`, per-selector `limit`), `matched` always counts every match, and a cut list +reports `truncated: true` — a floor is never read as a total. A selector that filters nothing, an +assertion that selects nodes and bounds none of them, and a `minCount` above its `maxCount` all +throw at load. A run with no node observation fails once as `sceneNodes.observed` +(`TN_PLAYTEST_SCENE_NODES_UNOBSERVED`). Advertised as the `scene.nodes` capability. + +## "Because", not "and then" + +A run could always say *a contact happened* and *the state reads `won`*. Nothing related the two. +`assert.states[].atSteps` orders them at **step** boundaries, which cannot separate a win arriving +with the contact from one arriving 199 ticks later inside the same step — and that is exactly the +shape of a terminal state driven by a timer or a distance check that merely lands near a contact. + +Measured in the field: a sandbox physics puzzle reported `won` on frame one with the player +untouched eight metres away, because its goal volume overlapped the floor slab. Its own HUD agreed +the game was won. Every assertion in the harness was green, because each was individually true. + +Both sides are now tick-stamped by the producer, and `assert.causedBy` relates them: + +```json +{ "assert": { "causedBy": [ + { "cause": { "contact": { "entity": "warden", "with": "seal", "kind": "trigger" } }, + "effect": { "path": "state.status", "becomes": "won" }, + "neverBefore": true, "withinTicks": 4 } +] } } +``` + +- `neverBefore` fails when the effect is ever observed before the first matching cause. That is the + frame-one fake win, at tick granularity. +- `withinTicks` bounds `effectTick - causeTick`. A win long after the contact was not caused by it. +- `cause` takes a `contact` **or** a `transition`, never both — two causes in one row would + silently make the earlier one the cause. So "the door opened because the plate was pressed" is + the same row shape. +- A row setting neither `neverBefore` nor `withinTicks` throws at load: ordering alone is what + `atSteps` already does, and asserting only that both events happened is two assertions wearing + one name. + +Core's `playtest()` plugin drains both logs **per tick** rather than per sample and advertises +`runtime.transitions`. The transition watcher diffs each registered entity's `state` and the +top-level primitive fields of the published game state, recording `{ path, from, to, tick }` — +`states.` or `state.`. A path's first observed value is its starting point, not a +transition; recording it as one would fail every `neverBefore` against the game's own initial +state. The log is bounded by `PLAYTEST_TRANSITION_LOG_LIMIT`. + +Every failure mode is named and closed. A run with no transition log fails once as +`causedBy.observed` (`TN_PLAYTEST_TRANSITIONS_UNOBSERVED`) — a loop that never ticked has observed +nothing, not nothing-changed. A matching contact carrying **no tick** fails +`TN_PLAYTEST_CAUSE_UNSTAMPED` rather than being read as a cause at tick zero, and the message says +the producer drains only at sample time. A cause the run never observed is +`TN_PLAYTEST_CAUSE_NOT_OBSERVED`; an effect that never transitioned is +`TN_PLAYTEST_EFFECT_NOT_OBSERVED`; the frame-one case is `TN_PLAYTEST_EFFECT_PRECEDES_CAUSE` with +both ticks; a late effect is `TN_PLAYTEST_CAUSE_TOO_DISTANT`. **The family never falls back to step +granularity** — that is a different measurement, and answering a tick question with it would be the +confident wrong number this package exists to refuse. + +One related fix in core, from the same build: an area's contact used to be dropped unless the game +passed an explicit `entity` option, so a trigger that demonstrably fired reported no contact at all +and a `contacts` assertion went green over a run whose own state proved the overlap. The far side +now falls back to the scene-registry id the area is registered under. + +A run with no scene observation fails once as `scene.observed` +(`TN_PLAYTEST_SCENE_UNOBSERVED`) rather than failing each bound against nothing, and an +`assert.scene` that sets no bound throws at load. An unmeasurable comparison — no world extent, +no far plane — fails; it never counts as cleared. + +Advertised as the `scene.observe` capability. A capability the runner's registry does not define +is rejected (`TN_PLAYTEST_BRIDGE_CAPABILITY_UNKNOWN`), so a new observation channel is registered +in `src/capabilities.ts` in the same change that starts advertising it. + +## Scenario-controlled spawn & aim + +The scenario `setup` block carries a placement vocabulary so capturing a vantage frame is +one scenario, not a patch-run-revert ceremony: + +- `setup.spawn { x, z }` (+ optional `y`) overrides the SUBJECT player start's position. + An absent `y` preserves the game's own height (eye or ground line); it is never silently + defaulted to zero. Requires `subject`. +- `setup.aim { yaw, pitch }` overrides the SUBJECT player start's aim; both angles are + radians, Three.js convention (forward is -Z at yaw 0, pitch positive up). Requires + `subject`. +- `setup.place[]` entries `{ entity, at: {x,y,z}, facing?: {yaw} | lookAt?: {x,y,z}, frozen?: boolean }` + put named entities at explicit transforms. `frozen` sets `PLAYTEST_FROZEN_MARKER` + (`__threenativeFrozen`) on the entity's userData — data the game reads to suppress + physics motion, never a runner-side teleport loop. + +Presence semantics are explicit and fail closed: an unknown entity id, an entity missing +from the registry at apply time, or a target coincident with the subject is a NAMED error +(`TN_PLAYTEST_SETUP_UNAPPLIED`), never a silent skip. One entity may be placed by only one +of `setup.entities` / `setup.place`. + +A `click` step's `at` is viewport pixels `{ x, y }`, a registered entity `{ entity }`, or (browser +only) a DOM element `{ element: { id } | { selector } }` clicked at the centre of its live box. Prefer +the element form for interface controls: pixel positions move with the fonts a machine has +installed, which is how a rain scenario passed locally and missed its switch on CI. +Steps can also carry `{ kind: "aimAt", target: { x, z } | { entity }, pitch?, waitTicks?, screenshot?, label? }`. +The runner samples the subject's current position, computes yaw/pitch toward the target, +and applies them through the setup channel as quaternion data — no CDP mouse events and no +OS-focus dependency. An `aimAt` step cannot also deliver input (`press`, pointers) or +ignored holds (`holdTicks`/`holdFrames`); follow it with a `waitTicks` step to hold the pose. + +Every requested override rides into the run report as `setup.requested` next to what +applied (`setup.applied`). A run whose placement cannot apply fails with the reason named — +an overridden spawn must be visible in diagnostics, never green-with-silence. The game keeps +its own spawn constants; scenarios override them for determinism, through this one channel. +The template-teaching copy of this vocabulary ships via the create-threenative shared +fragment when games adopt it; until then this section is the harness contract. diff --git a/packages/playtest/src/assertion-evaluators.ts b/packages/playtest/src/assertion-evaluators.ts index 2a5acedd56..33afc26a73 100644 --- a/packages/playtest/src/assertion-evaluators.ts +++ b/packages/playtest/src/assertion-evaluators.ts @@ -1,3 +1,4 @@ +import { emitTone } from "./evaluators/tone.js"; // Facade for the evaluator family modules (PRD-182 Phase 2). Import paths are unchanged: // every existing consumer of @threenative/playtest keeps working through this entry. import type { IPlaytestAssertionResult, IPlaytestDiagnostic } from "./assertion-report.js"; @@ -27,6 +28,7 @@ export function evaluateRichPlaytestAssertions(input: { const diagnostics: IPlaytestDiagnostic[] = []; const scenarioAssertions = input.scenario.assert ?? {}; const ctx: IEvaluationContext = { assertions, diagnostics, input, scenarioAssertions }; + emitTone(ctx); emitDeviceMetrics(ctx); emitDisplayFamilies(ctx); emitEvidenceFamilies(ctx); diff --git a/packages/playtest/src/assertion-report.ts b/packages/playtest/src/assertion-report.ts index 6422220dd9..d042b09d18 100644 --- a/packages/playtest/src/assertion-report.ts +++ b/packages/playtest/src/assertion-report.ts @@ -1,3 +1,4 @@ +import type { IPlaytestToneObservation } from "./tone.js"; import type { IPlaytestComponentAssertion, IPlaytestDiagnosticsAssertion, @@ -50,6 +51,7 @@ export interface IPlaytestVisualElementRegionObservation { } export interface IPlaytestObservations { + tone?: IPlaytestToneObservation[]; animation?: unknown; components?: Record>; componentSeries?: Array<{ label: string; snapshots: Record>; tick: number }>; diff --git a/packages/playtest/src/assertion-schema.ts b/packages/playtest/src/assertion-schema.ts index 19fb6954b5..1e209169e7 100644 --- a/packages/playtest/src/assertion-schema.ts +++ b/packages/playtest/src/assertion-schema.ts @@ -1,3 +1,4 @@ +import { TONE_METRICS } from "./tone.js"; import type { IPlaytestScenario, PlaytestTarget } from "./scenario.js"; import type { PlaytestCapability } from "./capabilities.js"; import { resolveDiagnosticsPolicy } from "./assertion-report.js"; @@ -24,6 +25,22 @@ export interface IPlaytestAssertionSchemaEntry { } export const PLAYTEST_ASSERTION_REGISTRY: readonly IPlaytestAssertionSchemaEntry[] = [ + { + description: "Bounds display-referred luminance from a captured PNG frame, using a shared 256-bin histogram.", + example: { tone: [{ atStep: "landed", mean: { min: 60, max: 140 }, p99: { min: 150 } }] }, + fields: [ + { description: "Capture after this named step; omit for the final frame.", name: "atStep", type: "non-empty string" }, + ...TONE_METRICS.map((name) => ({ description: "Inclusive finite bounds; luminance is 0..255 and fractions are 0..1. At least one bound is required.", name, type: "{ min?: number, max?: number }" })), + ], + cardinality: "array", + kind: "tone", + observationPath: "tone", + requiredCapabilities: ["browser.screenshot"], + resultIdPrefix: "tone.", + supportedOn: ["web", "desktop", "bevy"], + triviality: "not-applicable", + trivialityRationale: "The host decodes actual captured pixels; a missing capture fails closed.", + }, { description: "Reports the device's thermal, power and battery state around the run and judges whether the run is comparable with a cool one.", example: { deviceMetrics: { maxTemperatureRiseC: 5, notThermallyConfounded: true } }, diff --git a/packages/playtest/src/capture.ts b/packages/playtest/src/capture.ts index 66a70c8105..b44587d24c 100644 --- a/packages/playtest/src/capture.ts +++ b/packages/playtest/src/capture.ts @@ -1,4 +1,5 @@ import { PNG } from "pngjs"; +import type { IToneMetrics } from "./tone.js"; export const CAPTURE_GUARD_LIMITS = { brightLuminance: 0.05, @@ -11,6 +12,7 @@ export const CAPTURE_GUARD_LIMITS = { } as const; export interface ICaptureFrameStats { + readonly tone?: IToneMetrics; readonly distinctColors: number; readonly brightPixelRatio: number; readonly height: number; @@ -47,6 +49,8 @@ export class CaptureGuardError extends Error { export function inspectFrame(png: Buffer): ICaptureFrameStats { const image = PNG.sync.read(png); const colors = new Set(); + const histogram = new Uint32Array(256); + let toneTotal = 0; let luminanceTotal = 0; let luminanceSquaredTotal = 0; let brightPixels = 0; @@ -61,6 +65,9 @@ export function inspectFrame(png: Buffer): ICaptureFrameStats { colors.add(((red << 24) | (green << 16) | (blue << 8) | alpha) >>> 0); if (alpha === 0) continue; const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; + const bin = Math.round(luminance * 255); + histogram[bin] = (histogram[bin] ?? 0) + 1; + toneTotal += bin; visiblePixels += 1; maxLuminance = Math.max(maxLuminance, luminance); if (luminance > CAPTURE_GUARD_LIMITS.brightLuminance) brightPixels += 1; @@ -70,7 +77,24 @@ export function inspectFrame(png: Buffer): ICaptureFrameStats { const mean = visiblePixels === 0 ? 0 : luminanceTotal / visiblePixels; const variance = visiblePixels === 0 ? 0 : luminanceSquaredTotal / visiblePixels - mean * mean; + const percentile = (fraction: number): number => { + const rank = Math.ceil(visiblePixels * fraction); + let count = 0; + for (let bin = 0; bin < histogram.length; bin += 1) { + count += histogram[bin] ?? 0; + if (count >= rank) return bin; + } + return 255; + }; return { + ...(visiblePixels === 0 ? {} : { tone: { + mean: toneTotal / visiblePixels, + p1: percentile(0.01), + p50: percentile(0.5), + p99: percentile(0.99), + clipFraction: (histogram[255] ?? 0) / visiblePixels, + blackFraction: (histogram[0] ?? 0) / visiblePixels, + } }), distinctColors: colors.size, brightPixelRatio: image.data.length === 0 ? 0 : brightPixels / (image.data.length / 4), height: image.height, diff --git a/packages/playtest/src/evaluators/tone.ts b/packages/playtest/src/evaluators/tone.ts new file mode 100644 index 0000000000..b256711e6a --- /dev/null +++ b/packages/playtest/src/evaluators/tone.ts @@ -0,0 +1,27 @@ +import { TONE_METRICS } from "../tone.js"; +import type { IEvaluationContext } from "./context.js"; + +export function emitTone({ assertions, diagnostics, input, scenarioAssertions }: IEvaluationContext): void { + for (const [index, expected] of (scenarioAssertions.tone ?? []).entries()) { + const label = expected.atStep ?? "after.png"; + const frames = input.report.observations?.tone; + // A step may use the reserved-looking name "after". The terminal capture is appended last. + const frame = Array.isArray(frames) ? frames.slice().reverse().find((entry) => entry !== null && typeof entry === "object" && (expected.atStep === undefined + ? entry.atStep === undefined && entry.label === "after.png" + : entry.atStep === expected.atStep)) : undefined; + if (frame?.code !== "TN_TONE" || !TONE_METRICS.every((key) => typeof frame[key] === "number" + && Number.isFinite(frame[key]) && frame[key] >= 0 && frame[key] <= (key.endsWith("Fraction") ? 1 : 255))) { + assertions.push({ id: `tone.${index}.observed`, pass: false, details: { expected, observed: frame } }); + diagnostics.push({ code: "TN_PLAYTEST_TONE_UNOBSERVED", message: `Tone capture '${label}' is missing or has invalid luminance metrics.`, severity: "error", observedRuntimePath: "observations.json/tone" }); + continue; + } + for (const key of TONE_METRICS) { + const bound = expected[key]; + if (bound === undefined) continue; + const observed = frame[key]; + const pass = (bound.min === undefined || observed >= bound.min) && (bound.max === undefined || observed <= bound.max); + assertions.push({ id: `tone.${index}.${key}`, pass, details: { observed, expected: bound, frame } }); + if (!pass) diagnostics.push({ code: "TN_PLAYTEST_TONE_ASSERTION_FAILED", message: `Tone '${label}' ${key} measured ${observed}; required ${JSON.stringify(bound)}.`, severity: "error", observedRuntimePath: "observations.json/tone" }); + } + } +} diff --git a/packages/playtest/src/index.ts b/packages/playtest/src/index.ts index a9f2126271..7bf5421661 100644 --- a/packages/playtest/src/index.ts +++ b/packages/playtest/src/index.ts @@ -1,3 +1,4 @@ +export type { IToneMetrics, IPlaytestToneObservation } from "./tone.js"; /** * Validate and inspect playtest capability declarations. * @situation check whether a scenario's required capabilities are installed @@ -200,6 +201,7 @@ export type { IPlaytestSignalAssertion, IPlaytestStep, IPlaytestVisualAssertion, + IPlaytestToneAssertion, IPlaytestVisualElementRegion, IPlaytestVisualRegion, IPlaytestVisualRegionBounds, diff --git a/packages/playtest/src/runner/android.ts b/packages/playtest/src/runner/android.ts index 0e235a418e..ff77eefc5c 100644 --- a/packages/playtest/src/runner/android.ts +++ b/packages/playtest/src/runner/android.ts @@ -378,8 +378,30 @@ export class AdbAndroidDriver implements IAndroidDriver { ); } + /** + * The device's own diagnostics, read once and parsed once. + * + * The read is the only step here that can fail for a reason the game did not cause: a hosted + * SwiftShader emulator drops the adb transport mid-dump, and `adb logcat -d` exits non-zero after + * printing part of the buffer — a run whose assertions all passed died on a truncated line (CI + * `clean-consumer`, 5th of 6 sequential runs). The native harnesses already answer that with one + * re-read after `wait-for-device` (#310, b12becde6); this mirrors it, including the named refusal + * exemption: a `KO:` reply is the device rejecting the command, and retrying it would hide that. + */ async captureConsole(): Promise> { - const output = await this.adb(["logcat", "-d", "-v", "brief"]); + const args = ["logcat", "-d", "-v", "brief"]; + let output: string | undefined; + let lastError = ""; + for (let attempt = 1; output === undefined && attempt <= LOGCAT_ATTEMPTS; attempt += 1) { + try { + output = await this.adb(args); + } catch (error) { + lastError = (error as Error).message; + if (isAdbRefusal(lastError) || attempt === LOGCAT_ATTEMPTS) break; + await this.adb(["wait-for-device"]).catch(() => undefined); + } + } + if (output === undefined) throw failedLogcat(args.join(" "), lastError); return parseAndroidConsole(output); } @@ -686,6 +708,33 @@ async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promi }); } +/** + * A refusal is the device answering, not the transport dropping. + * + * `adb` reports a command it will not run as `KO:` on stdout — an unknown subcommand, a locked + * transport, a disabled daemon. Nothing about a retry changes that answer, and retrying it would + * turn a named refusal into a slower version of the same failure. Every other `adb ... failed:` is + * an exit status, which is the one shape a dropped transport produces. + */ +function isAdbRefusal(message: string): boolean { + return /\bKO:/u.test(message); +} + +/** + * The tail of a failed dump, never the whole buffer. + * + * The truncation *is* the evidence — it lands mid-line — so the end is kept. The head is 82 KB of + * Android noise that buries the one line a reader needs, and a CI log viewer truncates it anyway. + */ +function failedLogcat(command: string, message: string): Error { + const prefix = `adb ${command} failed: `; + const detail = message.startsWith(prefix) ? message.slice(prefix.length) : message; + const excerpt = detail.length > LOGCAT_ERROR_LIMIT + ? `…${detail.slice(-LOGCAT_ERROR_LIMIT)}` + : detail; + return new Error(`${prefix}${excerpt}`); +} + export function parseAndroidConsole(output: string): Array<{ text: string; type: string }> { return output .split(/\r?\n/u) @@ -722,6 +771,10 @@ function isPlatformWebViewNoise(text: string): boolean { } const TOUCH_AXIS_MAX = 32767; +/** A dropped adb transport is re-read once; a device that never returns fails the run. */ +const LOGCAT_ATTEMPTS = 2; +/** Bytes of a failed dump kept in the error, so an 82 KB logcat never becomes the report. */ +const LOGCAT_ERROR_LIMIT = 2_000; export function parseAndroidTouchViewport(output: string): IAndroidTouchViewport { const match = /Viewport\s+INTERNAL:\s*displayId=0,[\s\S]*?(?:orientation=(\d+),\s*)?logicalFrame=\[\s*(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\s*\],\s*physicalFrame=\[\s*(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\s*\],\s*deviceSize=\[\s*(\d+),\s*(\d+)\s*\]/u.exec(output); diff --git a/packages/playtest/src/runner/androidRunner.ts b/packages/playtest/src/runner/androidRunner.ts index 726dd1c4cc..dce623df2f 100644 --- a/packages/playtest/src/runner/androidRunner.ts +++ b/packages/playtest/src/runner/androidRunner.ts @@ -1,3 +1,4 @@ +import type { IPlaytestToneObservation } from "../tone.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -287,6 +288,7 @@ async function runDevicePlaytestInternal( // One armed capture per sample; absent means the bridge does no geometry work at all. ...(scenario.assert?.geometry === undefined ? {} : { geometry: scenario.assert.geometry }), } as const; + const tone: IPlaytestToneObservation[] = []; const before = await bridge.sample(sampleRequest); const pathEntity = scenario.assert?.movement?.pathLength === undefined ? undefined @@ -446,11 +448,10 @@ async function runDevicePlaytestInternal( : []; labeledSamples.push({ label: step.label, signals, snapshot }); } - if (step.screenshot !== undefined) { - await captureDeviceScreenshot( - target, - join(config.artifactDirectory, `${safePart(step.screenshot)}.png`), - ); + const wantsStepTone = step.label !== undefined && scenario.assert?.tone?.some(({ atStep }) => atStep === step.label) === true; + if (step.screenshot !== undefined || wantsStepTone) { + const label = step.screenshot === undefined ? `tone-${index}.png` : `${safePart(step.screenshot)}.png`; + await captureDeviceScreenshot(target, join(config.artifactDirectory, label), tone, label, step.label); } if (step.release && pressed !== undefined) { const released = typeof pressed === "string" ? [pressed] : [...pressed]; @@ -510,8 +511,8 @@ async function runDevicePlaytestInternal( appendPosition(pathPositions, after, pathEntity); metrics?.stop(); await metrics?.sampleNow("after").catch(() => undefined); - if (scenario.artifacts?.screenshots !== false) { - await captureDeviceScreenshot(target, join(config.artifactDirectory, "after.png")); + if (scenario.artifacts?.screenshots !== false || scenario.assert?.tone?.some(({ atStep }) => atStep === undefined) === true) { + await captureDeviceScreenshot(target, join(config.artifactDirectory, "after.png"), tone, "after.png"); } if (!(await target.driver.isAlive())) { return failureReport(config, scenario, playtestDiagnostic( @@ -548,6 +549,7 @@ async function runDevicePlaytestInternal( ? undefined : { ...startupOutcome.startup, rule: startupOutcome.rule }, lifecycle?.observation(), + tone, ); // Same artifacts as the browser target: a diagnostic that names console.json must find it // there whichever target produced the run. @@ -621,9 +623,13 @@ async function runDevicePlaytestInternal( async function captureDeviceScreenshot( target: IDevicePlaytestTarget, path: string, + tone: IPlaytestToneObservation[], + label: string, + atStep?: string, ): Promise { await target.driver.screenshot(path); - assertCaptureNotBlank(await readFile(path), path); + const stats = assertCaptureNotBlank(await readFile(path), path); + if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); } /** @@ -735,6 +741,13 @@ async function deviceClickPoint( "Declare at as viewport pixels ({ x, y }) or a registered entity ({ entity }).", )); } + if ("element" in target) { + throw new PlaytestBridgeError(playtestDiagnostic( + "TN_PLAYTEST_UNSUPPORTED_ON_TARGET", + "Click step targets a DOM element, which only the browser target can resolve.", + "Run this click on --target browser, or target viewport pixels or a registered entity.", + )); + } if (!("entity" in target)) return { x: target.x, y: target.y }; if (bridge === undefined) { throw new PlaytestBridgeError(playtestDiagnostic( diff --git a/packages/playtest/src/runner/browserSession.ts b/packages/playtest/src/runner/browserSession.ts index 0a73f98b0b..0fd671ba6e 100644 --- a/packages/playtest/src/runner/browserSession.ts +++ b/packages/playtest/src/runner/browserSession.ts @@ -52,8 +52,9 @@ export function openRunnerPage( const navigationUrl = remoteBrowser?.navigationUrl(config) ?? config.url; // This copy is navigation-only. Reports and operator diagnostics retain the URL supplied on // the command line, while Android Chrome reaches the same server through its reverse tunnel. - return installBootFailure(page, scenario).then(() => - openPageAndConnectBridge(page, { ...config, url: navigationUrl }, scenario)); + return installBootFailure(page, scenario) + .then(() => (scenario.reducedMotion === undefined ? undefined : page.emulateMedia({ reducedMotion: scenario.reducedMotion }))) + .then(() => openPageAndConnectBridge(page, { ...config, url: navigationUrl }, scenario)); } async function installBootFailure(page: Page, scenario: IPlaytestScenario): Promise { diff --git a/packages/playtest/src/runner/captureEnvironment.ts b/packages/playtest/src/runner/captureEnvironment.ts index 873d47d092..1d59cfef03 100644 --- a/packages/playtest/src/runner/captureEnvironment.ts +++ b/packages/playtest/src/runner/captureEnvironment.ts @@ -122,7 +122,7 @@ export function runNeedsPixels( if (config.headless !== true) return true; const takesScreenshot = scenario.artifacts?.screenshots !== false || scenario.steps.some(({ screenshot }) => screenshot !== undefined); - const evaluatesVisual = (scenario.assert?.visual?.length ?? 0) > 0; + const evaluatesVisual = (scenario.assert?.visual?.length ?? 0) > 0 || (scenario.assert?.tone?.length ?? 0) > 0; return takesScreenshot || evaluatesVisual || scenario.assert?.framebufferCoverage !== undefined; } diff --git a/packages/playtest/src/runner/cli.ts b/packages/playtest/src/runner/cli.ts index 4d60f29664..db49e32d02 100644 --- a/packages/playtest/src/runner/cli.ts +++ b/packages/playtest/src/runner/cli.ts @@ -1,3 +1,4 @@ +import { toneCommand } from "./tone.js"; import { existsSync, realpathSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; @@ -290,6 +291,7 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro if (argv[0] === "doctor") return await doctorCommand(argv.slice(1)); if (argv[0] === "perf") return await perfCommand(argv.slice(1)); if (argv[0] === "trace") return await traceCommand(argv.slice(1)); + if (argv[0] === "tone") return await toneCommand(argv.slice(1)); if (argv[0] === "audio") return await audioCommand(argv.slice(1)); if (argv[0] === "init") { const result = await initStandalonePlaytest(process.cwd()); diff --git a/packages/playtest/src/runner/config.ts b/packages/playtest/src/runner/config.ts index 994ed48937..5e0f64294c 100644 --- a/packages/playtest/src/runner/config.ts +++ b/packages/playtest/src/runner/config.ts @@ -189,6 +189,8 @@ export function formatUsage(): string { " no browser, no display, no capture lock", " exits 1 when a check failed, 2 when it could not run, and 69 when", " ffmpeg is absent — \"could not check\" is never a green 0", + " tone print mean, p1, p50, p99, clip%, black% per PNG and an average row", + " no browser or display; empty/unreadable inputs exit 2", " record-to-scenario convert a replay recording into a scenario", " (requires --oracle and --out )", "", diff --git a/packages/playtest/src/runner/observationFields.ts b/packages/playtest/src/runner/observationFields.ts index 2d838bc9fa..6996fb765b 100644 --- a/packages/playtest/src/runner/observationFields.ts +++ b/packages/playtest/src/runner/observationFields.ts @@ -30,4 +30,4 @@ export const STANDALONE_PLAYTEST_OBSERVATION_FIELDS = [ * The bridge is not their producer, so the standalone-runner availability check must not judge * them; the evaluator fails closed and names the target that supplies them instead. */ -export const HOST_PLAYTEST_OBSERVATION_FIELDS = ["deviceLifecycle", "deviceMetrics"] as const; +export const HOST_PLAYTEST_OBSERVATION_FIELDS = ["deviceLifecycle", "deviceMetrics", "tone"] as const; diff --git a/packages/playtest/src/runner/runner-support.ts b/packages/playtest/src/runner/runner-support.ts index 199daf7b7c..11aa1f2298 100644 --- a/packages/playtest/src/runner/runner-support.ts +++ b/packages/playtest/src/runner/runner-support.ts @@ -80,7 +80,7 @@ export function preflightDisplay( ): IPlaytestDiagnostic | undefined { const takesScreenshot = scenario.artifacts?.screenshots !== false || scenario.steps.some(({ screenshot }) => screenshot !== undefined); - const evaluatesVisual = (scenario.assert?.visual?.length ?? 0) > 0; + const evaluatesVisual = (scenario.assert?.visual?.length ?? 0) > 0 || (scenario.assert?.tone?.length ?? 0) > 0; const evaluatesFramebuffer = scenario.assert?.framebufferCoverage !== undefined; if (platform !== "linux" || config.headless !== true || environment.DISPLAY || environment.WAYLAND_DISPLAY || (!takesScreenshot && !evaluatesVisual && !evaluatesFramebuffer)) { return undefined; @@ -196,6 +196,7 @@ export function buildReport( movementBaselineSnapshot: IPlaytestObservationSnapshot | undefined = undefined, startup: IStandalonePlaytestReport["startup"] = undefined, deviceLifecycle: IPlaytestObservations["deviceLifecycle"] = undefined, + tone: IPlaytestObservations["tone"] = undefined, ): IStandalonePlaytestReport { const movementSample = isAnonymousMovementScenario(scenario) ? observedMovementSample(movementSamples) @@ -287,6 +288,7 @@ export function buildReport( // Honest placement reporting: what the scenario asked to override, and what applied. ...(setup === undefined ? {} : { setup }), observations: buildObservations({ + ...(tone === undefined || tone.length === 0 ? {} : { tone }), console: observedConsoleEntries, ...(components === undefined ? {} diff --git a/packages/playtest/src/runner/runner.ts b/packages/playtest/src/runner/runner.ts index 70e53c4e22..c7b65eb74f 100644 --- a/packages/playtest/src/runner/runner.ts +++ b/packages/playtest/src/runner/runner.ts @@ -1,3 +1,4 @@ +import type { IPlaytestToneObservation } from "../tone.js"; import { waitFrames, captureVisualSurface, runStep, sampleVisualElementBounds, screenshotObservations, sampleAfterTransition } from "./steps.js"; import type { StepInputState } from "./steps.js"; import { withPerformanceBudget } from "./buildReport.js"; @@ -479,12 +480,14 @@ async function runStandalonePlaytestInternal( // measured. Collected whenever a movement assertion exists, which is what pays for them. const capturesMovementSamples = capturesAnonymousMovement || scenario.assert?.movement !== undefined; const movementSamples: IMovementSampleInterval[] = []; + const tone: IPlaytestToneObservation[] = []; + const wantsFinalTone = scenario.assert?.tone?.some(({ atStep }) => atStep === undefined) === true; const wantsVisual = (scenario.assert?.visual?.length ?? 0) > 0; const visualAssertions = scenario.assert?.visual ?? []; const hasElementBoundVisualRegion = visualAssertions.some(({ region }) => region !== undefined && "element" in region); const needsCapture = scenario.artifacts?.screenshots !== false || scenario.steps.some((step) => step.screenshot !== undefined) - || wantsVisual; + || wantsVisual || (scenario.assert?.tone?.length ?? 0) > 0; const requiresWebGpuProvenance = browserConfig.browserArgs?.includes("--enable-unsafe-webgpu") === true; const captureProvenance = scenario.bootFailure === undefined && (needsCapture || requiresWebGpuProvenance) ? await readCaptureProvenance(page, browserConfig, scenario) @@ -519,6 +522,7 @@ async function runStandalonePlaytestInternal( label: string, requested: Parameters[0], captureElementBounds = false, + atStep?: string, ): Promise => { // Playwright's 30s default is sized for a machine with a GPU. A CPU rasteriser — SwiftShader // on a CI runner, llvmpipe on a headless box — composites the same frame one to two orders @@ -546,7 +550,8 @@ async function runStandalonePlaytestInternal( } } try { - assertCaptureNotBlank(png, label); + const stats = assertCaptureNotBlank(png, label); + if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); return { ...(elementRegions === undefined ? {} : { elementRegions }), image: png }; } catch (error) { if (!(error instanceof CaptureGuardError)) throw error; @@ -558,7 +563,8 @@ async function runStandalonePlaytestInternal( // The first shot stands; its blank verdict is what gets reported below. } try { - assertCaptureNotBlank(png, label); + const stats = assertCaptureNotBlank(png, label); + if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); return { ...(elementRegions === undefined ? {} : { elementRegions }), image: png }; } catch (error2) { if (!(error2 instanceof CaptureGuardError)) throw error2; @@ -570,12 +576,14 @@ async function runStandalonePlaytestInternal( const captureVisualPage = async ( label: string, artifactPath: string | undefined, + atStep?: string, ): Promise => { if (scenario.bootFailure !== undefined || hasElementBoundVisualRegion) { return capturePage( label, artifactPath === undefined ? {} : { path: artifactPath }, hasElementBoundVisualRegion, + atStep, ); } try { @@ -588,7 +596,8 @@ async function runStandalonePlaytestInternal( }; return undefined; } - assertCaptureNotBlank(png, label); + const stats = assertCaptureNotBlank(png, label); + if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); return { image: png }; } catch (error) { if (!(error instanceof CaptureGuardError)) throw error; @@ -596,7 +605,8 @@ async function runStandalonePlaytestInternal( try { const retry = await captureVisualSurface(activePage, artifactPath); if (retry !== undefined) { - assertCaptureNotBlank(retry, label); + const stats = assertCaptureNotBlank(retry, label); + if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); return { image: retry }; } } catch (error2) { @@ -718,8 +728,11 @@ async function runStandalonePlaytestInternal( : []; labeledSamples.push({ label: step.label, signals, snapshot }); } + const wantsStepTone = step.label !== undefined && scenario.assert?.tone?.some(({ atStep }) => atStep === step.label) === true; if (step.screenshot !== undefined) { - await capturePage(`${safePart(step.screenshot)}.png`, { path: join(activeConfig.artifactDirectory, `${safePart(step.screenshot)}.png`) }); + await capturePage(`${safePart(step.screenshot)}.png`, { path: join(activeConfig.artifactDirectory, `${safePart(step.screenshot)}.png`) }, false, step.label); + } else if (wantsStepTone) { + await captureVisualPage(`tone-${index}.png`, join(activeConfig.artifactDirectory, `tone-${index}.png`), step.label); } } const afterSnapshot = await sampleAfterTransition(page, bridge, sampleRequest); @@ -728,8 +741,8 @@ async function runStandalonePlaytestInternal( if (position !== undefined) pathPositions.push(position); } const afterHud = await sampleHud(page, hudAssertions); - const afterScreenshot = (scenario.artifacts?.screenshots !== false && artifactFrames) || wantsVisual - ? wantsVisual + const afterScreenshot = (scenario.artifacts?.screenshots !== false && artifactFrames) || wantsVisual || wantsFinalTone + ? wantsVisual || wantsFinalTone ? await captureVisualPage( "after.png", scenario.artifacts?.screenshots === false @@ -773,6 +786,8 @@ async function runStandalonePlaytestInternal( startupOutcome === undefined ? undefined : { ...startupOutcome.startup, rule: startupOutcome.rule }, + undefined, + tone, ); await writeObservationArtifacts(activeConfig.artifactDirectory, scenario.artifacts, { console: consoleEntries, diff --git a/packages/playtest/src/runner/steps.ts b/packages/playtest/src/runner/steps.ts index ada228954d..2e297dcef5 100644 --- a/packages/playtest/src/runner/steps.ts +++ b/packages/playtest/src/runner/steps.ts @@ -381,7 +381,9 @@ async function executeClickStep( } const point = "entity" in target ? await entityClickPoint(bridge, target.entity) - : { x: target.x, y: target.y }; + : "element" in target + ? await elementClickPoint(page, target.element) + : { x: target.x, y: target.y }; if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { throw clickError( @@ -400,6 +402,27 @@ async function executeClickStep( await page.mouse.up({ button: "left" }); } +/** The centre of a visible DOM element, read off the live page rather than a layout guess. */ +async function elementClickPoint( + page: Page, + element: { id?: string; selector?: string }, +): Promise<{ x: number; y: number }> { + const selector = element.id === undefined ? (element.selector ?? "") : `#${cssEscapeId(element.id)}`; + const box = await page.locator(selector).first().boundingBox().catch(() => null); + if (box === null || box.width <= 0 || box.height <= 0) { + throw clickError( + `Click target element '${selector}' is not on the page or has no visible box.`, + "Name an element the page renders at this step, by id or CSS selector.", + ); + } + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +/** `CSS.escape` for an id, so an id with punctuation still selects exactly that element. */ +function cssEscapeId(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/gu, (character) => `\\${character}`); +} + async function entityClickPoint( bridge: IPlaytestBridgeClient | undefined, entity: string, diff --git a/packages/playtest/src/runner/tone.ts b/packages/playtest/src/runner/tone.ts new file mode 100644 index 0000000000..5453607f8c --- /dev/null +++ b/packages/playtest/src/runner/tone.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { inspectFrame } from "../capture.js"; +import { TONE_METRICS, type IToneMetrics } from "../tone.js"; +import { PlaytestCliUsageError } from "./config.js"; + +export async function toneCommand(paths: readonly string[]): Promise { + if (paths.length === 0 || paths.some((path) => path.startsWith("-"))) throw new PlaytestCliUsageError("tone requires one or more PNG paths: tone ."); + const rows: Array<{ label: string; metrics: IToneMetrics }> = []; + for (const path of paths) { + const metrics = inspectFrame(await readFile(path)).tone; + if (metrics === undefined) throw new Error(`TN_TONE_UNOBSERVED: '${path}' has no visible pixels.`); + rows.push({ label: path, metrics }); + } + const meanOf = (key: keyof IToneMetrics): number => rows.reduce((sum, { metrics }) => sum + metrics[key], 0) / rows.length; + const average: IToneMetrics = { + mean: meanOf("mean"), + p1: meanOf("p1"), + p50: meanOf("p50"), + p99: meanOf("p99"), + clipFraction: meanOf("clipFraction"), + blackFraction: meanOf("blackFraction"), + }; + rows.push({ label: "average", metrics: average }); + const lines = rows.map(({ label, metrics }) => [label, ...TONE_METRICS.map((key) => (metrics[key] * (key.endsWith("Fraction") ? 100 : 1)).toFixed(2))].join("\t")); + process.stdout.write(["frame\tmean\tp1\tp50\tp99\tclip%\tblack%", ...lines, ""].join("\n")); + return 0; +} diff --git a/packages/playtest/src/scenario/generated-assertion-validators.ts b/packages/playtest/src/scenario/generated-assertion-validators.ts index cf684371bd..74eb370f04 100644 --- a/packages/playtest/src/scenario/generated-assertion-validators.ts +++ b/packages/playtest/src/scenario/generated-assertion-validators.ts @@ -11,6 +11,15 @@ function isRecord(value: unknown): value is Record { } export const GENERATED_ASSERTION_FIELD_VALIDATORS: Readonly>>> = Object.freeze({ + "tone": Object.freeze({ + "atStep": (value: unknown) => typeof value === "string" && value.trim() !== "", + "mean": (value: unknown) => isRecord(value), + "p1": (value: unknown) => isRecord(value), + "p50": (value: unknown) => isRecord(value), + "p99": (value: unknown) => isRecord(value), + "clipFraction": (value: unknown) => isRecord(value), + "blackFraction": (value: unknown) => isRecord(value), + }), "deviceMetrics": Object.freeze({ "maxTemperatureRiseC": (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0, "maxThermalStatus": (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 6, diff --git a/packages/playtest/src/scenario/schema-base.ts b/packages/playtest/src/scenario/schema-base.ts index 99d2556b48..99a21d91e5 100644 --- a/packages/playtest/src/scenario/schema-base.ts +++ b/packages/playtest/src/scenario/schema-base.ts @@ -1,3 +1,4 @@ +import type { IToneMetrics } from "../tone.js"; import type { IPlaytestGeometryCaptureRequest, PlaytestFramePassKind, PlaytestFramePhase } from "../protocol.js"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -93,7 +94,15 @@ export type IPlaytestLifecycleStep = | { operation: "foreground"; rotation?: never } | { operation: "rotate"; rotation: number }; -export type IPlaytestClickTarget = { entity: string } | { x: number; y: number }; +/** + * Where a click lands: viewport pixels, a registered entity's screen bounds, or (browser only) the + * centre of a DOM element by `id` or CSS `selector`. Pixels depend on the page's layout, which + * depends on the fonts a machine has; an element target does not. + */ +export type IPlaytestClickTarget = + | { entity: string } + | { element: IPlaytestVisualRegionTarget } + | { x: number; y: number }; export interface IPlaytestMovementAssertion { axis?: string; @@ -567,7 +576,13 @@ export interface IPlaytestAudioAssertion { minGapMs?: number; } +export interface IPlaytestToneAssertion extends Partial> { + /** Omit to bound the final capture, otherwise capture after this named step. */ + atStep?: string; +} + export interface IPlaytestScenarioAssertions { + tone?: IPlaytestToneAssertion[]; aerodynamics?: IPlaytestAerodynamicsAssertion[]; audio?: IPlaytestAudioAssertion[]; animation?: IPlaytestAnimationAssertion[]; @@ -721,6 +736,11 @@ export interface IPlaytestScenario { inputDelivery?: PlaytestInputDelivery; name: string; parity?: IPlaytestParityConfig; + /** + * Browser-only: open the page with `prefers-reduced-motion: reduce` emulated, so a game that + * honours the preference can prove it. Applied before navigation, so the first read sees it. + */ + reducedMotion?: "reduce"; schemaVersion: 1; setup?: IPlaytestScenarioSetup; sourcePath?: string; diff --git a/packages/playtest/src/scenario/schema-validate.ts b/packages/playtest/src/scenario/schema-validate.ts index 6cfeb66224..f7d42ddd0c 100644 --- a/packages/playtest/src/scenario/schema-validate.ts +++ b/packages/playtest/src/scenario/schema-validate.ts @@ -1,3 +1,5 @@ +import { TONE_METRICS } from "../tone.js"; +import type { IPlaytestToneAssertion } from "./schema-base.js"; import { PLAYTEST_ASSERTION_REGISTRY } from "../assertions.js"; import { invalidScenario, invalidStep, rejectUnknownKeys } from "./errors.js"; import { isRecord, validateViewport, positiveInteger, hasKey, validateOptionalNumberTuple, validateAssertionKeys, validateDeviceMetricsAssertion, validateParityAssertion, validatePerformanceAssertion, validateFramebufferCoverageAssertion, validateRenderChainAssertion, validateStartupAssertion, validateSceneAssertion, validateSceneNodesAssertion, validateCausedByAssertion, validateAnimationAssertion, validateContactAssertion, validatePathAssertion, validateNumberTuple, validateResourcePathAssertion, validateSignalAssertion, validateStateAssertion, validateTagCountAssertion, validateVisibilityAssertion, validateVisualAssertion, requireRecord, optionalNumber, requireString, optionalPositiveNumber, present, optionalTrivialityReason, optionalString, optionalPositiveInteger, optionalTargetArray, optionalBoolean, requireArray, describeValue, optionalNonNegativeNumber } from "./schema-accessors.js"; @@ -13,6 +15,7 @@ export const PLAYTEST_ROOT_KEYS = [ "inputDelivery", "name", "parity", + "reducedMotion", "schemaVersion", "setup", "steps", @@ -50,6 +53,12 @@ export function validatePlaytestScenario(value: unknown, scenarioPath: string, a if (value.bootFailure !== undefined && target !== "web") { throw invalidScenario(scenarioPath, "Scenario bootFailure is browser-only and requires target 'web'."); } + if (value.reducedMotion !== undefined && value.reducedMotion !== "reduce") { + throw invalidScenario(scenarioPath, "Scenario reducedMotion must be 'reduce' when present."); + } + if (value.reducedMotion !== undefined && target !== "web") { + throw invalidScenario(scenarioPath, "Scenario reducedMotion is browser-only and requires target 'web'."); + } const inputDelivery = value.inputDelivery ?? "deterministic"; if (inputDelivery !== "deterministic" && inputDelivery !== "focused-dom") { throw invalidScenario(scenarioPath, "Scenario inputDelivery must be deterministic or focused-dom."); @@ -76,6 +85,7 @@ export function validatePlaytestScenario(value: unknown, scenarioPath: string, a inputDelivery, name, ...(isRecord(value.parity) ? { parity: validateParityConfig(value.parity, scenarioPath) } : {}), + ...(value.reducedMotion === "reduce" ? { reducedMotion: value.reducedMotion } : {}), schemaVersion: 1, ...(isRecord(value.setup) ? { setup: validateSetup(value.setup, scenarioPath, subject) } : {}), ...(absolutePath === undefined ? {} : { sourcePath: absolutePath }), @@ -738,6 +748,18 @@ function validateClickTarget(value: unknown, scenarioPath: string, index: number return value.entity.length === 0 ? undefined : { entity: value.entity }; } if (value.entity !== undefined) return undefined; + if (value.element !== undefined) { + const objectPath = `steps[${index}].at`; + rejectUnknownKeys(value, ["element"], scenarioPath, objectPath); + const element = requireRecord(value.element, scenarioPath, `${objectPath}.element`); + rejectUnknownKeys(element, ["id", "selector"], scenarioPath, `${objectPath}.element`); + const id = optionalString(element, "id", scenarioPath, `${objectPath}.element`); + const selector = optionalString(element, "selector", scenarioPath, `${objectPath}.element`); + if ((id === undefined) === (selector === undefined)) { + throw invalidStep(scenarioPath, `Scenario ${objectPath}.element must name exactly one of id or selector.`); + } + return { element: id === undefined ? { selector: selector as string } : { id } }; + } rejectUnknownKeys(value, ["x", "y"], scenarioPath, `steps[${index}].at`); return typeof value.x === "number" && Number.isFinite(value.x) @@ -830,6 +852,9 @@ export function validateStepLabels( requireLabel(step.label, `assert.components[${index}].atSteps[${stepIndex}].label`); } } + for (const [index, assertion] of (assertions.tone ?? []).entries()) { + requireLabel(assertion.atStep, `assert.tone[${index}].atStep`); + } for (const [index, assertion] of (assertions.signals ?? []).entries()) { requireLabel(assertion.atStep, `assert.signals[${index}].atStep`); } @@ -878,6 +903,9 @@ export function validateAssertions(value: Record, scenarioPath: if (Array.isArray(value.signals) && value.signals.length === 0) { throw invalidScenario(scenarioPath, "Assertion 'assert.signals' must contain at least one signal assertion."); } + if (Array.isArray(value.tone) && value.tone.length === 0) { + throw invalidScenario(scenarioPath, "Assertion 'assert.tone' must contain at least one tone assertion."); + } const movement = isRecord(value.movement) ? value.movement : undefined; const camera = isRecord(value.camera) ? value.camera : undefined; if ( @@ -1073,6 +1101,7 @@ export function validateAssertions(value: Record, scenarioPath: ...(Array.isArray(value.states) ? { states: value.states.map((entry, index) => validateStateAssertion(entry, scenarioPath, `assert.states[${index}]`)) } : {}), ...(Array.isArray(value.tags) ? { tags: value.tags.map((entry, index) => validateTagCountAssertion(entry, scenarioPath, `assert.tags[${index}]`)) } : {}), ...(Array.isArray(value.visibility) ? { visibility: value.visibility.map((entry, index) => validateVisibilityAssertion(entry, scenarioPath, `assert.visibility[${index}]`)) } : {}), + ...(Array.isArray(value.tone) ? { tone: value.tone.map((entry, index) => validateToneAssertion(entry, scenarioPath, `assert.tone[${index}]`)) } : {}), ...(Array.isArray(value.visual) ? { visual: value.visual.map((entry, index) => validateVisualAssertion(entry, scenarioPath, `assert.visual[${index}]`)) } : {}), ...(world === undefined ? {} : { world: validateWorldAssertion(world, scenarioPath) }), }; @@ -1321,3 +1350,29 @@ export function validateGeometryCaptureRequest( ...(record.timeoutMs === undefined ? {} : { timeoutMs: record.timeoutMs as number }), }; } + +function validateToneAssertion(value: unknown, scenarioPath: string, objectPath: string): IPlaytestToneAssertion { + const record = requireRecord(value, scenarioPath, objectPath); + rejectUnknownKeys(record, ["atStep", ...TONE_METRICS], scenarioPath, objectPath); + const result: IPlaytestToneAssertion = {}; + if (record.atStep !== undefined) result.atStep = requireString(record, "atStep", scenarioPath, objectPath); + for (const metric of TONE_METRICS) { + if (record[metric] === undefined) continue; + const path = `${objectPath}.${metric}`; + const bound = requireRecord(record[metric], scenarioPath, path); + rejectUnknownKeys(bound, ["min", "max"], scenarioPath, path); + if (bound.min === undefined && bound.max === undefined) throw invalidScenario(scenarioPath, `${path} requires min or max.`); + const maximum = metric.endsWith("Fraction") ? 1 : 255; + for (const key of ["min", "max"] as const) { + if (bound[key] !== undefined && (typeof bound[key] !== "number" || !Number.isFinite(bound[key]) || bound[key] < 0 || bound[key] > maximum)) { + throw invalidScenario(scenarioPath, `${path}.${key} must be a finite number in [0, ${maximum}].`); + } + } + if (typeof bound.min === "number" && typeof bound.max === "number" && bound.min > bound.max) { + throw invalidScenario(scenarioPath, `${path}: min must not exceed max.`); + } + result[metric] = { ...present("min", bound.min as number | undefined), ...present("max", bound.max as number | undefined) }; + } + if (!TONE_METRICS.some((key) => result[key] !== undefined)) throw invalidScenario(scenarioPath, `${objectPath} requires at least one metric bound.`); + return result; +} diff --git a/packages/playtest/src/tone.ts b/packages/playtest/src/tone.ts new file mode 100644 index 0000000000..2c55570964 --- /dev/null +++ b/packages/playtest/src/tone.ts @@ -0,0 +1,20 @@ +/** Display-referred Rec.709 luminance, quantized to 256 rounded byte bins. */ +export interface IToneMetrics { + readonly mean: number; + readonly p1: number; + readonly p50: number; + readonly p99: number; + /** Fraction in bin 255 (display white). */ + readonly clipFraction: number; + /** Fraction in bin 0 (display black). */ + readonly blackFraction: number; +} + +export const TONE_METRICS = ["mean", "p1", "p50", "p99", "clipFraction", "blackFraction"] as const; + +export interface IPlaytestToneObservation extends IToneMetrics { + readonly code: "TN_TONE"; + readonly label: string; + /** Absent for the convenience before/after captures. */ + readonly atStep?: string; +} diff --git a/packages/runtime-native/CMakeLists.txt b/packages/runtime-native/CMakeLists.txt index 8c87048436..be45c01523 100644 --- a/packages/runtime-native/CMakeLists.txt +++ b/packages/runtime-native/CMakeLists.txt @@ -1424,6 +1424,7 @@ set(TN_RUNTIME_SCRIPT_NAMES audio-context-constructor audio-source-properties audio-gain-param + audio-param audio-panner-properties canvas2d-properties ) diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 157aeb29b0..3dc603cdf9 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -829,6 +829,21 @@ "desktopGate": true, "required": true }, + { + "id": "fluid-particles", + "title": "GPU particle fluid dam drop settles", + "category": "webgpu-tsl", + "status": "implemented", + "scene": "../../examples/prd476-fluid-particles/src/conformance.js", + "browserRecipe": "webgpu", + "requiresHardwareAdapter": true, + "tolerance": { + "pixelMismatchRatio": 0.08, + "perceptualDeltaE": 6.0 + }, + "desktopGate": true, + "required": true + }, { "id": "78-wave-field", "title": "analytic wave field CPU and TSL displacement", diff --git a/packages/runtime-native/conformance/scenes/shared/audio-context.js b/packages/runtime-native/conformance/scenes/shared/audio-context.js index e294f1748d..ae0d3556a8 100644 --- a/packages/runtime-native/conformance/scenes/shared/audio-context.js +++ b/packages/runtime-native/conformance/scenes/shared/audio-context.js @@ -1,5 +1,16 @@ import { assertCondition, startBehaviorScene } from "./scene-support.js"; +/** + * Whether an `AudioParam` read back the value that was written to it. + * + * A Web Audio `AudioParam` stores a `float`, so `0.004` comes back as `0.004000000189…` and an + * exact `===` would fail a browser that is behaving exactly as specified. Both sides go through + * `Math.fround`, so the scene measures the same thing on each lane. + */ +function written(param, expected) { + return Math.fround(param.value) === Math.fround(expected); +} + export function startScene(canvas, dimensions) { return startBehaviorScene(canvas, dimensions, "audio-context", async () => { const AudioContextClass = globalThis.AudioContext ?? globalThis.webkitAudioContext; @@ -9,7 +20,49 @@ export function startScene(canvas, dimensions) { const gain = context.createGain(); assertCondition(typeof gain.connect === "function", "GainNode.connect must exist"); gain.connect(context.destination); + + // The two nodes `AudioBus` reaches for on a target it cannot web-preview: a low-pass for + // `lowpassHz` and a master compressor. Both are plain Web Audio factories, so a runtime that + // binds neither fails here instead of only on device. + assertCondition( + typeof context.createBiquadFilter === "function", + "AudioContext.createBiquadFilter must exist", + ); + const filter = context.createBiquadFilter(); + assertCondition(typeof filter.frequency?.value === "number", "BiquadFilterNode.frequency must exist"); + filter.type = "lowpass"; + filter.frequency.value = 900; + assertCondition(written(filter.frequency, 900), "BiquadFilterNode.frequency must be writable"); + filter.connect(context.destination); + + assertCondition( + typeof context.createDynamicsCompressor === "function", + "AudioContext.createDynamicsCompressor must exist", + ); + const compressor = context.createDynamicsCompressor(); + for (const [name, value] of Object.entries({ + attack: 0.004, + knee: 6, + ratio: 5, + release: 0.18, + threshold: -15, + })) { + assertCondition( + typeof compressor[name]?.value === "number", + `DynamicsCompressorNode.${name} must exist`, + ); + compressor[name].value = value; + assertCondition( + written(compressor[name], value), + `DynamicsCompressorNode.${name} must be writable`, + ); + } + assertCondition( + typeof compressor.reduction === "number", + "DynamicsCompressorNode.reduction must be readable", + ); + compressor.connect(context.destination); if (typeof context.close === "function") await context.close(); - return { gainNode: true }; + return { biquadFilter: true, dynamicsCompressor: true, gainNode: true }; }); } diff --git a/packages/runtime-native/include/mystral/audio/audio_context.h b/packages/runtime-native/include/mystral/audio/audio_context.h index e4f0429a0d..87d4e2ea60 100644 --- a/packages/runtime-native/include/mystral/audio/audio_context.h +++ b/packages/runtime-native/include/mystral/audio/audio_context.h @@ -27,6 +27,8 @@ class AudioBuffer; class AudioBufferSourceNode; class GainNode; class PannerNode; +class BiquadFilterNode; +class DynamicsCompressorNode; class AudioDestinationNode; struct AudioVector3 { @@ -114,12 +116,40 @@ class AudioNode { virtual void disconnect(); virtual void disconnect(AudioNode* destination); + /** + * True for a node that must see the summed mix rather than one voice at a time. + * + * The mixer pulls: it processes each active source through its own chain and adds the + * result, so anything in that chain sees one voice. A compressor is the one node for which + * that is the wrong input — its whole job is holding down cues that are each fine and add up + * to too much — so it declares itself post-mix and becomes a sum boundary: a voice whose chain + * reaches it stops there and contributes to its accumulator, and the node runs once per block + * on everything that arrived, with its own downstream chain after it. + */ + virtual bool postMix() const { return false; } + // For audio processing virtual void process(float* output, size_t numFrames, int numChannels); + /** + * Take one voice's block into this sum boundary instead of processing it. + * + * The block is zeroed on the way in: it has left the forward walk, so whatever the mixer was + * going to add from that buffer is now carried by this node's accumulator. + */ + void accumulate(const float* input, size_t count); + + /** + * Run this boundary once on what accumulated, forward it down its own chain, and add the + * result to `output`. Leaves the accumulator zeroed for the next block. + */ + void flushSum(float* output, size_t numFrames, int numChannels); + protected: AudioContext* context_; std::vector outputs_; + /** Block-sized accumulator for a sum boundary. Written on the audio thread only. */ + std::vector sum_; }; /** @@ -130,6 +160,17 @@ class AudioDestinationNode : public AudioNode { AudioDestinationNode(AudioContext* context); int maxChannelCount() const { return 2; } // Stereo output + + /** Register a node the mixer must run once on the summed output. See `AudioNode::postMix`. */ + void addPostMix(AudioNode* node); + void removePostMix(AudioNode* node); + /** How many post-mix nodes are registered. For a contract proof, not a signal. */ + size_t postMixCount() const; + /** The registered chain, in connect order. Read under `AudioContext::mixMutex`. */ + const std::vector& postMixNodes() const { return postMix_; } + +private: + std::vector postMix_; }; /** @@ -179,6 +220,79 @@ class PannerNode : public AudioNode { std::atomic distanceModel_{DistanceModel::Inverse}; }; +/** + * BiquadFilterNode - the low-pass @threenative/core's AudioBus builds for `lowpassHz`. + * + * Only `lowpass` is implemented, and `setType` refuses the other Web Audio types rather than + * accepting a type it does not honour: a cue that asked for a high-pass and got an unfiltered + * signal is quieter about being wrong than one that is told. The coefficients are RBJ, computed + * once per block from `frequency` and `Q`. + */ +class BiquadFilterNode : public AudioNode { +public: + explicit BiquadFilterNode(AudioContext* context); + + bool setType(const std::string& value); + std::string type() const { return "lowpass"; } + + AudioParam& frequency() { return frequency_; } + const AudioParam& frequency() const { return frequency_; } + AudioParam& q() { return q_; } + const AudioParam& q() const { return q_; } + + void process(float* output, size_t numFrames, int numChannels) override; + +private: + AudioParam frequency_{350.0f}; + AudioParam q_{1.0f}; + /** Per-channel biquad delay line: x1, x2, y1, y2. Two channels is the whole output. */ + std::vector> state_; +}; + +/** + * DynamicsCompressorNode - bus-level dynamics for `AudioBus`'s `compressor` option. + * + * A standard feed-forward compressor: an envelope follower over the peak of the block, a soft + * knee around `threshold`, and the resulting gain reduction smoothed by `attack` and `release`. + * It runs on the bus sum, which is what makes it a bus compressor rather than a per-voice one. + */ +class DynamicsCompressorNode : public AudioNode { +public: + explicit DynamicsCompressorNode(AudioContext* context); + + AudioParam& threshold() { return threshold_; } + const AudioParam& threshold() const { return threshold_; } + AudioParam& knee() { return knee_; } + const AudioParam& knee() const { return knee_; } + AudioParam& ratio() { return ratio_; } + const AudioParam& ratio() const { return ratio_; } + AudioParam& attack() { return attack_; } + const AudioParam& attack() const { return attack_; } + AudioParam& release() { return release_; } + const AudioParam& release() const { return release_; } + + bool postMix() const override { return true; } + + /** Current gain reduction in dB, negative or zero. Read back by a mix check. */ + float reduction() const { return reduction_.load(std::memory_order_relaxed); } + + void process(float* output, size_t numFrames, int numChannels) override; + +private: + AudioParam threshold_{-24.0f}; + AudioParam knee_{30.0f}; + AudioParam ratio_{12.0f}; + AudioParam attack_{0.003f}; + AudioParam release_{0.25f}; + /** + * The smoothed reduction in dB, carried between blocks. + * + * It has to be: the mixer hands the node one block at a time, and an envelope restarted at + * unity every block pumps audibly on a 3 ms attack — once every ~11 ms at 44.1 kHz. + */ + std::atomic reduction_{0.0f}; +}; + /** * AudioBufferSourceNode - plays an AudioBuffer */ @@ -247,6 +361,8 @@ class AudioContext { std::unique_ptr createBufferSource(); std::unique_ptr createGain(); std::unique_ptr createPanner(); + std::unique_ptr createBiquadFilter(); + std::unique_ptr createDynamicsCompressor(); void setListenerPosition(float x, float y, float z); void setListenerOrientation(float forwardX, float forwardY, float forwardZ, @@ -278,6 +394,23 @@ class AudioContext { void unregisterSource(AudioBufferSourceNode* source); void detachSources(); + /** + * Mix one block: every playing voice through its chain, the post-mix sums, then the clamp. + * + * The body of `audioCallback`, public because the mixer is the thing a routing claim is about + * and a test has to be able to render a block through it to prove one. + */ + void renderBlock(float* output, int numFrames); + + /** + * Guards the post-mix chain against the audio thread. + * + * The destination registers a post-mix node from `connect`, which is a JavaScript thread, and + * runs the chain from `audioCallback`, which is SDL's. The vector they share is not atomic, + * so both take this. + */ + std::mutex& mixMutex() { return mixMutex_; } + private: void audioCallback(float* output, int numFrames); static void sdlAudioCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount); @@ -295,6 +428,7 @@ class AudioContext { std::unique_ptr destination_; std::vector activeSources_; std::mutex sourcesMutex_; + std::mutex mixMutex_; std::array sourceBuffer_{}; std::array callbackBuffer_{}; diff --git a/packages/runtime-native/include/threenative/physics_native.h b/packages/runtime-native/include/threenative/physics_native.h index 50d3e45c5f..a461b451c1 100644 --- a/packages/runtime-native/include/threenative/physics_native.h +++ b/packages/runtime-native/include/threenative/physics_native.h @@ -178,6 +178,20 @@ bool tn_physics_add_trimesh_body(TnPhysicsSimulation *simulation, const TnPhysicsBodyOptions *options, const float *vertices, uint32_t vertex_floats, const uint32_t *indices, uint32_t index_count); +/* + * A fixed heightfield body, and the in-place refresh of one: `heights` holds `rows * columns` + * samples in column-major order spanning `scale`, centred on the body, copied during the call. + * The refresh keeps the id, parent body, collision groups and events. + */ +bool tn_physics_add_heightfield_body(TnPhysicsSimulation *simulation, + const TnPhysicsBodyOptions *options, + const float *heights, uint32_t count, uint32_t rows, + uint32_t columns, float scale_x, float scale_y, + float scale_z); +bool tn_physics_set_heightfield_shape(TnPhysicsSimulation *simulation, uint32_t id, + const float *heights, uint32_t count, uint32_t rows, + uint32_t columns, float scale_x, float scale_y, + float scale_z); int32_t tn_physics_create_joint(TnPhysicsSimulation *simulation, const TnPhysicsJointOptions *options); bool tn_physics_remove_joint(TnPhysicsSimulation *simulation, uint32_t id); @@ -226,6 +240,14 @@ int32_t tn_physics_read_visible_transforms( int32_t tn_physics_read_body_sleep_states( const TnPhysicsSimulation *simulation, float *output, size_t output_float_capacity); +/* Writes eight floats per solved manifold between body `target` and each awake candidate body: + candidate id, world contact point x/y/z, world normal x/y/z (target toward candidate) and the + step's summed normal impulse. Returns the total count, which may exceed what `output` holds + (only that many are written), or -1 for an unknown target or a malformed buffer. */ +int32_t tn_physics_read_contacts(const TnPhysicsSimulation *simulation, + uint32_t target, const uint32_t *candidates, + size_t candidate_count, float *output, + size_t output_float_capacity); /* Writes six floats per character: id, grounded, ground body id (-1 when absent), and the world-space ground normal x/y/z. */ int32_t tn_physics_read_character_states( diff --git a/packages/runtime-native/native/physics/src/lib.rs b/packages/runtime-native/native/physics/src/lib.rs index fb3e7ed25a..7577f3005d 100644 --- a/packages/runtime-native/native/physics/src/lib.rs +++ b/packages/runtime-native/native/physics/src/lib.rs @@ -2,7 +2,7 @@ use rapier3d::control::{ CharacterAutostep, CharacterLength, DynamicRayCastVehicleController, KinematicCharacterController, WheelTuning, }; -use rapier3d::na::{Point3, Quaternion, UnitQuaternion}; +use rapier3d::na::{DMatrix, Point3, Quaternion, UnitQuaternion}; use rapier3d::prelude::*; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::ptr; @@ -12,6 +12,8 @@ const TRANSFORM_WIDTH: usize = 8; const SLEEP_STATE_WIDTH: usize = 2; const EVENT_WIDTH: usize = 4; const CHARACTER_STATE_WIDTH: usize = 6; +/// `[collider id, world x, y, z, normal x, y, z, impulse]`, the shared `PHYSICS_CONTACT_STRIDE`. +const CONTACT_WIDTH: usize = 8; /// One float of signed speed, then `PHYSICS_VEHICLE_WHEEL_STRIDE` floats per wheel: contact, /// suspension length, rotation. const VEHICLE_SPEED_FLOATS: usize = 1; @@ -265,6 +267,69 @@ impl EventHandler for CollisionEventCollector { } } +/// A heightfield over `rows` x `columns` samples in the column-major order the web backend hands +/// Rapier (`DMatrix::from_vec`), spanning `scale` and centred on its body, with internal-edge +/// correction so a resting body is not kicked by ghost edges. `None` for malformed input. +fn heightfield_shape( + heights: *const f32, + count: u32, + rows: u32, + columns: u32, + scale: [f32; 3], +) -> Option { + if heights.is_null() || rows < 2 || columns < 2 || count as u64 != rows as u64 * columns as u64 { + return None; + } + if !scale.iter().all(|value| value.is_finite() && *value > 0.0) { + return None; + } + let samples = unsafe { std::slice::from_raw_parts(heights, count as usize) }; + if !samples.iter().all(|value| value.is_finite()) { + return None; + } + Some(SharedShape::heightfield_with_flags( + DMatrix::from_vec(rows as usize, columns as usize, samples.to_vec()), + vector![scale[0], scale[1], scale[2]], + HeightFieldFlags::FIX_INTERNAL_EDGES, + )) +} + +/// Validate and copy a flat xyz vertex buffer and a flat triangle-index buffer from C. +fn copy_trimesh( + vertices: *const f32, + vertex_floats: u32, + indices: *const u32, + index_count: u32, +) -> Option<(Vec>, Vec<[u32; 3]>)> { + if vertices.is_null() || indices.is_null() { + return None; + } + let vertex_count = vertex_floats as usize; + let triangle_vertices = index_count as usize; + if vertex_count == 0 || vertex_count % 3 != 0 || triangle_vertices == 0 || triangle_vertices % 3 != 0 + { + return None; + } + let floats = unsafe { std::slice::from_raw_parts(vertices, vertex_count) }; + if !floats.iter().all(|value| value.is_finite()) { + return None; + } + let raw_indices = unsafe { std::slice::from_raw_parts(indices, triangle_vertices) }; + let point_count = vertex_count / 3; + if raw_indices.iter().any(|index| *index as usize >= point_count) { + return None; + } + let points = floats + .chunks_exact(3) + .map(|vertex| Point3::new(vertex[0], vertex[1], vertex[2])) + .collect(); + let triangles = raw_indices + .chunks_exact(3) + .map(|triangle| [triangle[0], triangle[1], triangle[2]]) + .collect(); + Some((points, triangles)) +} + pub struct Simulation { gravity: Vector, pipeline: PhysicsPipeline, @@ -344,6 +409,68 @@ impl Simulation { self.insert_body(options, collider) } + /// Replace one body's collider shape in place: same handle, parent, groups and events. + /// Bodies sleeping on it wake, since the new surface may no longer hold them up. + fn set_collider_shape(&mut self, id: u32, shape: SharedShape) -> bool { + let Some(entry) = self.entries.get_mut(&id) else { + return false; + }; + let Some(collider) = self.colliders.get_mut(entry.collider) else { + return false; + }; + // Rapier wakes the bodies touching a collider whose shape changed; the test holds it to that. + collider.set_shape(shape.clone()); + entry.shape = shape; + self.query_dirty = true; + true + } + + /// One record per solved manifold between `target` and each awake candidate. Returns the total + /// count; records past `output`'s capacity are counted but not written, so a caller can grow + /// its buffer and read again instead of losing a contact. + fn read_contacts(&self, target: u32, candidates: &[u32], output: &mut [f32]) -> Option { + let target_collider = self.entries.get(&target)?.collider; + let capacity = output.len() / CONTACT_WIDTH; + let mut count = 0; + for &id in candidates { + if id == target { + continue; + } + let Some(entry) = self.entries.get(&id) else { + continue; + }; + // A sleeping body was not solved this step, so its stored impulses are not this step's. + if self.bodies.get(entry.body).is_none_or(|body| body.is_sleeping()) { + continue; + } + let Some(pair) = self.narrow_phase.contact_pair(target_collider, entry.collider) else { + continue; + }; + let sign = if pair.collider1 == target_collider { 1.0 } else { -1.0 }; + for manifold in &pair.manifolds { + let solved = manifold.data.solver_contacts.len(); + if solved == 0 { + continue; + } + if count < capacity { + let impulse: Real = manifold.points.iter().map(|point| point.data.impulse).sum(); + let mut centre = Vector::zeros(); + for contact in &manifold.data.solver_contacts { + centre += contact.point.coords; + } + centre /= solved as Real; + let normal = manifold.data.normal * sign; + output[count * CONTACT_WIDTH..(count + 1) * CONTACT_WIDTH].copy_from_slice(&[ + id as f32, centre.x, centre.y, centre.z, normal.x, normal.y, normal.z, + impulse, + ]); + } + count += 1; + } + } + Some(count) + } + fn insert_body(&mut self, options: TnPhysicsBodyOptions, collider: ColliderBuilder) -> bool { let finite = [ options.position_x, @@ -1052,10 +1179,12 @@ impl Simulation { &(), &self.collision_events, ); - // Rapier retains accumulated forces unless the caller clears them. The JavaScript seam - // promises a force for one fixed step, so keep the native backend identical to web. + // Rapier retains accumulated forces and torques unless the caller clears them. The + // JavaScript seam promises a force for one fixed step (an off-centre one adds a torque), + // so keep the native backend identical to web. for entry in self.entries.values() { self.bodies[entry.body].reset_forces(true); + self.bodies[entry.body].reset_torques(true); } // Rapier delivered the started/stopped transitions while the step ran; translate // collider handles back to body ids exactly the way the web path does @@ -1517,36 +1646,95 @@ pub extern "C" fn tn_physics_add_trimesh_body( let (Some(simulation), false) = (unsafe { simulation.as_mut() }, options.is_null()) else { return false; }; - if vertices.is_null() || indices.is_null() { + let Some((points, triangles)) = copy_trimesh(vertices, vertex_floats, indices, index_count) + else { return false; - } - let vertex_count = vertex_floats as usize; - let triangle_vertices = index_count as usize; - if vertex_count == 0 || vertex_count % 3 != 0 || triangle_vertices == 0 || triangle_vertices % 3 != 0 - { + }; + simulation.add_trimesh_body(unsafe { ptr::read(options) }, points, triangles) +} + +/// Add a fixed body whose collider is a heightfield. `heights` holds `rows * columns` samples in +/// column-major order; it is copied during the call. +#[unsafe(no_mangle)] +pub extern "C" fn tn_physics_add_heightfield_body( + simulation: *mut Simulation, + options: *const TnPhysicsBodyOptions, + heights: *const f32, + count: u32, + rows: u32, + columns: u32, + scale_x: f32, + scale_y: f32, + scale_z: f32, +) -> bool { + let (Some(simulation), false) = (unsafe { simulation.as_mut() }, options.is_null()) else { return false; - } - let floats = unsafe { std::slice::from_raw_parts(vertices, vertex_count) }; - if !floats.iter().all(|value| value.is_finite()) { + }; + let Some(shape) = heightfield_shape(heights, count, rows, columns, [scale_x, scale_y, scale_z]) + else { return false; - } - let raw_indices = unsafe { std::slice::from_raw_parts(indices, triangle_vertices) }; - let point_count = vertex_count / 3; - if raw_indices - .iter() - .any(|index| *index as usize >= point_count) - { + }; + simulation.insert_body(unsafe { ptr::read(options) }, ColliderBuilder::new(shape)) +} + +/// Replace a body's collider with a new heightfield in place, keeping its id, parent body, +/// collision groups and events. Rebuilding a heightfield copies its samples and nothing else, so +/// a deforming surface can be refreshed every fixed step. +#[unsafe(no_mangle)] +pub extern "C" fn tn_physics_set_heightfield_shape( + simulation: *mut Simulation, + id: u32, + heights: *const f32, + count: u32, + rows: u32, + columns: u32, + scale_x: f32, + scale_y: f32, + scale_z: f32, +) -> bool { + let Some(simulation) = (unsafe { simulation.as_mut() }) else { + return false; + }; + let Some(shape) = heightfield_shape(heights, count, rows, columns, [scale_x, scale_y, scale_z]) + else { return false; + }; + simulation.set_collider_shape(id, shape) +} + +/// Persistent solved contacts between one body's collider and a list of candidate bodies, one +/// `CONTACT_WIDTH` record per solved manifold. Returns the total count, which may exceed what +/// `output` holds (only that many are written), or -1 for an unknown target or a bad buffer. +#[unsafe(no_mangle)] +pub extern "C" fn tn_physics_read_contacts( + simulation: *const Simulation, + target: u32, + candidates: *const u32, + candidate_count: usize, + output: *mut f32, + output_float_capacity: usize, +) -> i32 { + let Some(simulation) = (unsafe { simulation.as_ref() }) else { + return -1; + }; + if (candidate_count > 0 && candidates.is_null()) || (output_float_capacity > 0 && output.is_null()) + { + return -1; + } + let candidates = if candidate_count == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(candidates, candidate_count) } + }; + let output = if output_float_capacity == 0 { + &mut [][..] + } else { + unsafe { std::slice::from_raw_parts_mut(output, output_float_capacity) } + }; + match simulation.read_contacts(target, candidates, output) { + Some(count) => i32::try_from(count).unwrap_or(-1), + None => -1, } - let points = floats - .chunks_exact(3) - .map(|vertex| Point3::new(vertex[0], vertex[1], vertex[2])) - .collect(); - let triangles = raw_indices - .chunks_exact(3) - .map(|triangle| [triangle[0], triangle[1], triangle[2]]) - .collect(); - simulation.add_trimesh_body(unsafe { ptr::read(options) }, points, triangles) } #[unsafe(no_mangle)] diff --git a/packages/runtime-native/native/physics/tests/actuation.rs b/packages/runtime-native/native/physics/tests/actuation.rs index ebec58296f..4bbeef4da2 100644 --- a/packages/runtime-native/native/physics/tests/actuation.rs +++ b/packages/runtime-native/native/physics/tests/actuation.rs @@ -185,6 +185,46 @@ fn force_at_point_produces_angular_motion() { tn_physics_destroy(simulation); } +/// Angle between the rotations of two transform records (id, position xyz, quaternion xyzw). +fn turned(a: &[f32], b: &[f32]) -> f32 { + let dot = (a[4] * b[4] + a[5] * b[5] + a[6] * b[6] + a[7] * b[7]) + .abs() + .min(1.0); + 2.0 * dot.acos() +} + +#[test] +fn force_at_point_torque_lasts_one_step() { + let simulation = world(0.0); + assert!(!simulation.is_null()); + assert!(tn_physics_add_body( + simulation, + &body(7, 0, 0, 0.0, 0.0, 0.5, 0.5, 0.5, 1.0), + )); + assert_eq!( + tn_physics_apply_body_force_at_point(simulation, 7, 0.0, 20.0, 0.0, 1.0, 0.0, 0.0), + ACTUATION_OK + ); + let mut previous = vec![0.0; TRANSFORM_WIDTH]; + let mut current = vec![0.0; TRANSFORM_WIDTH]; + let mut spins = Vec::new(); + for _ in 0..12 { + previous.copy_from_slice(¤t); + assert!(tn_physics_step(simulation, 1.0 / 60.0, std::ptr::null(), 0)); + tn_physics_read_visible_transforms(simulation, current.as_mut_ptr(), current.len()); + spins.push(turned(&previous, ¤t)); + } + // One step of torque leaves a constant spin; a torque the backend kept would spin it up. + let early = spins[2]; + let late = spins[11]; + assert!(early > 1e-4, "off-centre force did not spin the body: {early}"); + assert!( + (late - early).abs() < early * 0.05, + "torque lingered past its step: spin went {early} -> {late}" + ); + tn_physics_destroy(simulation); +} + #[test] fn linear_velocity_round_trips_independently() { let simulation = world(0.0); diff --git a/packages/runtime-native/native/physics/tests/snow_contacts.rs b/packages/runtime-native/native/physics/tests/snow_contacts.rs new file mode 100644 index 0000000000..f8ceb3638e --- /dev/null +++ b/packages/runtime-native/native/physics/tests/snow_contacts.rs @@ -0,0 +1,190 @@ +use threenative_native_physics::{ + Simulation, TnPhysicsBodyOptions, TnPhysicsWorldOptions, tn_physics_add_body, + tn_physics_add_heightfield_body, tn_physics_create, tn_physics_destroy, + tn_physics_read_body_sleep_states, tn_physics_read_contacts, + tn_physics_read_visible_transforms, tn_physics_set_heightfield_shape, tn_physics_step, +}; + +const CONTACT_WIDTH: usize = 8; +const TRANSFORM_WIDTH: usize = 8; +const SAMPLES: u32 = 33; +const SIZE: f32 = 6.0; +const STEP: f32 = 1.0 / 60.0; +const SURFACE: u32 = 0; +const BALL: u32 = 1; + +fn options(id: u32, body_type: u32, shape_type: u32, y: f32, radius: f32) -> TnPhysicsBodyOptions { + TnPhysicsBodyOptions { + id, + body_type, + shape_type, + position_x: 0.0, + position_y: y, + position_z: 0.0, + rotation_x: 0.0, + rotation_y: 0.0, + rotation_z: 0.0, + rotation_w: 1.0, + shape_x: radius, + shape_y: 0.0, + shape_z: 0.0, + mass: if body_type == 0 { 10.0 } else { 0.0 }, + collision_layer: 1, + collision_mask: u16::MAX.into(), + sensor: false, + continuous_collision: true, + } +} + +/// A flat heightfield at `height`, `SAMPLES` x `SAMPLES`, in the web backend's column-major order. +fn surface(height: f32) -> Vec { + vec![height; (SAMPLES * SAMPLES) as usize] +} + +fn set_surface(simulation: *mut Simulation, id: u32, heights: &[f32], rows: u32) -> bool { + tn_physics_set_heightfield_shape( + simulation, + id, + heights.as_ptr(), + heights.len() as u32, + rows, + SAMPLES, + SIZE, + 1.0, + SIZE, + ) +} + +fn ball_height(simulation: *mut Simulation) -> f32 { + let mut transforms = [0.0f32; TRANSFORM_WIDTH * 4]; + let count = + tn_physics_read_visible_transforms(simulation, transforms.as_mut_ptr(), transforms.len()); + (0..count as usize) + .find(|index| transforms[index * TRANSFORM_WIDTH] as u32 == BALL) + .map(|index| transforms[index * TRANSFORM_WIDTH + 2]) + .expect("the ball reports a transform") +} + +fn ball_sleeping(simulation: *mut Simulation) -> bool { + let mut states = [0.0f32; 8]; + let count = tn_physics_read_body_sleep_states(simulation, states.as_mut_ptr(), states.len()); + (0..count as usize) + .any(|index| states[index * 2] as u32 == BALL && states[index * 2 + 1] == 1.0) +} + +fn read(simulation: *mut Simulation, output: &mut [f32]) -> i32 { + let candidates = [BALL]; + tn_physics_read_contacts( + simulation, + SURFACE, + candidates.as_ptr(), + candidates.len(), + output.as_mut_ptr(), + output.len(), + ) +} + +fn step(simulation: *mut Simulation, steps: usize) { + for _ in 0..steps { + assert!(tn_physics_step(simulation, STEP, std::ptr::null(), 0)); + } +} + +#[test] +fn reads_solved_support_and_refreshes_the_surface_in_place() { + let simulation = tn_physics_create(&TnPhysicsWorldOptions { + gravity_x: 0.0, + gravity_y: -9.81, + gravity_z: 0.0, + }); + let heights = surface(0.28); + assert!(tn_physics_add_heightfield_body( + simulation, + &options(SURFACE, 1, 0, 0.0, 0.0), + heights.as_ptr(), + heights.len() as u32, + SAMPLES, + SAMPLES, + SIZE, + 1.0, + SIZE, + )); + assert!(tn_physics_add_body( + simulation, + &options(BALL, 0, 1, 1.0, 0.25) + )); + + // Airborne: no solved contact exists yet. + step(simulation, 10); + let mut output = [0.0f32; CONTACT_WIDTH * 16]; + assert_eq!(read(simulation, &mut output), 0); + + step(simulation, 60); + let found = read(simulation, &mut output); + assert!(found > 0, "a resting ball has solved support"); + let mut impulse = 0.0; + for record in output.chunks_exact(CONTACT_WIDTH).take(found as usize) { + assert_eq!(record[0] as u32, BALL); + assert!( + record[5] > 0.9, + "the surface pushes up on the ball: {record:?}" + ); + assert!( + (record[2] - 0.28).abs() < 0.02, + "contact lies on the surface: {record:?}" + ); + impulse += record[7]; + } + // The summed impulse over a step carries roughly the ball's weight. + let load = impulse / STEP; + assert!(load > 98.1 * 0.5 && load < 98.1 * 2.0, "load {load}"); + assert!((ball_height(simulation) - 0.53).abs() < 0.01); + + // A buffer too small is told the count instead of losing contacts silently. + assert_eq!(read(simulation, &mut []), found); + // An unknown target fails closed. + let candidates = [BALL]; + assert_eq!( + tn_physics_read_contacts( + simulation, + 99, + candidates.as_ptr(), + 1, + output.as_mut_ptr(), + output.len() + ), + -1 + ); + + // At rest the ball sleeps and reports nothing: its stored impulses are not this step's. + let mut slept = false; + for _ in 0..600 { + step(simulation, 1); + if ball_sleeping(simulation) { + slept = true; + break; + } + } + assert!(slept, "a ball resting on flat surface falls asleep"); + assert_eq!(read(simulation, &mut output), 0); + + // Lower the surface in place: same body, and the sleeping ball wakes and follows it down. + let lowered = surface(0.1); + assert!(set_surface(simulation, SURFACE, &lowered, SAMPLES)); + step(simulation, 120); + assert!( + (ball_height(simulation) - 0.35).abs() < 0.01, + "ball {}", + ball_height(simulation) + ); + assert!(read(simulation, &mut output) > 0 || ball_sleeping(simulation)); + + // Malformed refreshes are refused rather than half-applied. + assert!(!set_surface(simulation, SURFACE, &lowered, SAMPLES + 1)); + assert!(!set_surface(simulation, 42, &lowered, SAMPLES)); + let mut broken = lowered.clone(); + broken[5] = f32::NAN; + assert!(!set_surface(simulation, SURFACE, &broken, SAMPLES)); + assert!((ball_height(simulation) - 0.35).abs() < 0.01); + tn_physics_destroy(simulation); +} diff --git a/packages/runtime-native/scripts/measure-native-coverage.mjs b/packages/runtime-native/scripts/measure-native-coverage.mjs index 11240a77dd..1316788600 100644 --- a/packages/runtime-native/scripts/measure-native-coverage.mjs +++ b/packages/runtime-native/scripts/measure-native-coverage.mjs @@ -377,7 +377,6 @@ export function requireInvocationProfiles(expectedPrefixes, profileNames) { function coverageExports({ buildDirectory, compiledProducts, executedTargets, profileNames }) { const commonArguments = [ "--format=lcov", - "--fatal-warnings", "-ignore-filename-regex=(third_party|\\.runtime|/usr/|/opt/|sdl3-build)", ]; const reports = []; diff --git a/packages/runtime-native/src/audio/audio_bindings.cpp b/packages/runtime-native/src/audio/audio_bindings.cpp index 28e38a36df..176320176d 100644 --- a/packages/runtime-native/src/audio/audio_bindings.cpp +++ b/packages/runtime-native/src/audio/audio_bindings.cpp @@ -23,6 +23,8 @@ static std::unordered_map> g_sourc static std::unordered_map g_sourceHandles; static std::unordered_map> g_gainNodes; static std::unordered_map> g_pannerNodes; +static std::unordered_map> g_biquadNodes; +static std::unordered_map> g_compressorNodes; static js::Engine* g_jsEngine = nullptr; @@ -101,6 +103,73 @@ static js::JSValueHandle createPassiveAudioParamJS(js::Engine* engine, float ini return param; } +/** + * One Web Audio AudioParam over a native one. + * + * It carries the native setters and the native default; `audio-param.js` turns that into the `value` + * accessor a Web Audio param has, because the engine has no accessor support. Every write lands in + * C++, which is the difference between a filter that filters and one that remembers a number. + */ +static js::JSValueHandle audioParamJS(js::Engine* engine, AudioParam* param) { + auto obj = engine->newObject(); + engine->setProperty(obj, "_default", engine->newNumber(param->value())); + engine->setProperty(obj, "_setValue", + engine->newFunction("_setValue", [param](void*, const std::vector& args) { + if (!args.empty()) param->setValue(static_cast(g_jsEngine->toNumber(args[0]))); + return g_jsEngine->newUndefined(); + }) + ); + engine->setProperty(obj, "_setValueAtTime", + engine->newFunction("_setValueAtTime", [param](void*, const std::vector& args) { + if (args.size() >= 2) { + param->setValueAtTime(static_cast(g_jsEngine->toNumber(args[0])), + g_jsEngine->toNumber(args[1])); + } + return g_jsEngine->newUndefined(); + }) + ); + engine->setProperty(obj, "_linearRampToValueAtTime", + engine->newFunction("_linearRampToValueAtTime", [param](void*, const std::vector& args) { + if (args.size() >= 2) { + param->linearRampToValueAtTime(static_cast(g_jsEngine->toNumber(args[0])), + g_jsEngine->toNumber(args[1])); + } + return g_jsEngine->newUndefined(); + }) + ); + engine->setProperty(obj, "_setTargetAtTime", + engine->newFunction("_setTargetAtTime", [param](void*, const std::vector& args) { + if (args.size() >= 3) { + param->setTargetAtTime(static_cast(g_jsEngine->toNumber(args[0])), + g_jsEngine->toNumber(args[1]), g_jsEngine->toNumber(args[2])); + } + return g_jsEngine->newUndefined(); + }) + ); + return obj; +} + +/** + * Hand the raw params, and optionally the nodes that need a property of their own, to + * `audio-param.js`. The global is cleared so one node's bundle cannot leak into the next. + */ +static void wrapAudioParams(js::Engine& engine, const std::vector& params, + js::JSValueHandle type = {}, js::JSValueHandle reduction = {}) { + auto bundle = engine.newObject(); + auto array = engine.newArray(params.size()); + for (size_t i = 0; i < params.size(); i++) { + engine.setPropertyIndex(array, static_cast(i), params[i]); + } + engine.setProperty(bundle, "params", array); + // An absent handle is a null pointer, and the engine dereferences whatever it is given — so + // the optional entries are only written when the caller has one. + if (type.ptr) engine.setProperty(bundle, "type", type); + if (reduction.ptr) engine.setProperty(bundle, "reduction", reduction); + engine.setGlobalProperty("__tnAudioParamBundle", bundle); + evalAudioScript(engine, "audio-param", "audio-param.js"); + engine.setGlobalProperty("__tnAudioParamBundle", engine.newUndefined()); +} + // Track the current AudioContext being operated on (set via closure capture) // This is a workaround for not having 'this' binding in callbacks @@ -392,6 +461,61 @@ js::JSValueHandle createPannerNodeJS(js::Engine* engine, PannerNode* nodePtr, return jsNode; } +/** + * Create the bounded BiquadFilterNode surface, which is what `AudioBus`'s `lowpassHz` builds. + * + * The low-pass is the only implemented type, and `audio-param.js` makes the setter refuse the other + * five rather than accept a name and pass the signal through unfiltered. + */ +js::JSValueHandle createBiquadFilterJS(js::Engine* engine, BiquadFilterNode* nodePtr, + js::JSValueHandle contextJS) { + auto jsNode = engine->newObject(); + installAudioNodeBindings(engine, jsNode, nodePtr); + engine->setProperty(jsNode, "context", contextJS); + engine->setProperty(jsNode, "_setType", + engine->newFunction("_setType", [nodePtr](void*, const std::vector& args) { + if (args.empty()) return g_jsEngine->newBoolean(false); + return g_jsEngine->newBoolean(nodePtr->setType(g_jsEngine->toString(args[0]))); + }) + ); + auto frequency = audioParamJS(engine, &nodePtr->frequency()); + auto q = audioParamJS(engine, &nodePtr->q()); + wrapAudioParams(*engine, {frequency, q}, jsNode); + engine->setProperty(jsNode, "frequency", frequency); + engine->setProperty(jsNode, "Q", q); + return jsNode; +} + +/** + * Create the DynamicsCompressorNode surface, which is what `AudioBus`'s `compressor` builds. + * + * `connect(destination)` is what puts it post-mix; see `AudioNode::postMix`. `reduction` is the + * readout that answers "is the compressor doing anything". + */ +js::JSValueHandle createDynamicsCompressorJS(js::Engine* engine, DynamicsCompressorNode* nodePtr, + js::JSValueHandle contextJS) { + auto jsNode = engine->newObject(); + installAudioNodeBindings(engine, jsNode, nodePtr); + engine->setProperty(jsNode, "context", contextJS); + engine->setProperty(jsNode, "_getReduction", + engine->newFunction("_getReduction", [nodePtr](void*, const std::vector&) { + return g_jsEngine->newNumber(nodePtr->reduction()); + }) + ); + auto threshold = audioParamJS(engine, &nodePtr->threshold()); + auto knee = audioParamJS(engine, &nodePtr->knee()); + auto ratio = audioParamJS(engine, &nodePtr->ratio()); + auto attack = audioParamJS(engine, &nodePtr->attack()); + auto release = audioParamJS(engine, &nodePtr->release()); + wrapAudioParams(*engine, {threshold, knee, ratio, attack, release}, {}, jsNode); + engine->setProperty(jsNode, "threshold", threshold); + engine->setProperty(jsNode, "knee", knee); + engine->setProperty(jsNode, "ratio", ratio); + engine->setProperty(jsNode, "attack", attack); + engine->setProperty(jsNode, "release", release); + return jsNode; +} + /** * Create AudioContext JS object */ @@ -506,6 +630,28 @@ js::JSValueHandle createAudioContextJS(js::Engine* engine, AudioContext* ctxPtr) }) ); + // createBiquadFilter() - the low-pass `AudioBus` builds for `lowpassHz` + engine->setProperty(jsCtx, "createBiquadFilter", + engine->newFunction("createBiquadFilter", [ctxPtr](void*, const std::vector&) { + auto node = ctxPtr->createBiquadFilter(); + auto* nodePtr = node.get(); + auto jsNode = createBiquadFilterJS(g_jsEngine, nodePtr, g_jsEngine->newUndefined()); + g_biquadNodes[nodePtr] = std::move(node); + return jsNode; + }) + ); + + // createDynamicsCompressor() - the bus master compressor + engine->setProperty(jsCtx, "createDynamicsCompressor", + engine->newFunction("createDynamicsCompressor", [ctxPtr](void*, const std::vector&) { + auto node = ctxPtr->createDynamicsCompressor(); + auto* nodePtr = node.get(); + auto jsNode = createDynamicsCompressorJS(g_jsEngine, nodePtr, g_jsEngine->newUndefined()); + g_compressorNodes[nodePtr] = std::move(node); + return jsNode; + }) + ); + // decodeAudioData(arrayBuffer, onSuccess?, onError?) -> Promise // // The return value has to be a Promise, and twice it was not. First it returned the decoded @@ -695,6 +841,8 @@ void cleanupAudioBindings() { g_sourceNodes.clear(); g_gainNodes.clear(); g_pannerNodes.clear(); + g_biquadNodes.clear(); + g_compressorNodes.clear(); g_audioBuffers.clear(); g_jsEngine = nullptr; } diff --git a/packages/runtime-native/src/audio/audio_context.cpp b/packages/runtime-native/src/audio/audio_context.cpp index 0dad3d8e7e..6d9d4b9678 100644 --- a/packages/runtime-native/src/audio/audio_context.cpp +++ b/packages/runtime-native/src/audio/audio_context.cpp @@ -152,6 +152,13 @@ AudioNode::AudioNode(AudioContext* context) void AudioNode::connect(AudioNode* destination) { if (!destination) return; + // A post-mix node is a sum boundary wherever it is wired, not only when its destination is + // the context destination: `AudioBus` routes voices through the compressor into the listener's + // master gain, so the compressor's own downstream is that gain and never the destination. It + // registers with the destination either way so the mixer knows to run it once per block. + if (postMix() && context_ != nullptr) { + static_cast(context_->destination())->addPostMix(this); + } if (std::find(outputs_.begin(), outputs_.end(), destination) == outputs_.end()) { outputs_.push_back(destination); } @@ -159,15 +166,49 @@ void AudioNode::connect(AudioNode* destination) { void AudioNode::disconnect() { outputs_.clear(); + if (postMix() && context_ != nullptr) { + static_cast(context_->destination())->removePostMix(this); + std::fill(sum_.begin(), sum_.end(), 0.0f); + } } void AudioNode::disconnect(AudioNode* destination) { outputs_.erase(std::remove(outputs_.begin(), outputs_.end(), destination), outputs_.end()); + // The sum registration outlives one erase while another output is still connected, otherwise + // this bus goes silent for every remaining downstream node, not just the one disconnected. + if (outputs_.empty() && postMix() && context_ != nullptr) { + static_cast(context_->destination())->removePostMix(this); + std::fill(sum_.begin(), sum_.end(), 0.0f); + } +} + +void AudioNode::accumulate(const float* input, size_t count) { + if (sum_.size() < count) sum_.assign(count, 0.0f); + for (size_t i = 0; i < count; i++) sum_[i] += input[i]; +} + +void AudioNode::flushSum(float* output, size_t numFrames, int numChannels) { + const size_t count = numFrames * static_cast(numChannels); + if (sum_.size() != count) sum_.assign(count, 0.0f); + // The block's contribution to the mix. It runs its own chain first — for a compressor that is + // the listener's master gain, so the volume the game set is applied after the reduction, which + // is the order `AudioBus` wires them in. + process(sum_.data(), numFrames, numChannels); + for (size_t i = 0; i < count; i++) output[i] += sum_[i]; + std::fill(sum_.begin(), sum_.end(), 0.0f); } void AudioNode::process(float* output, size_t numFrames, int numChannels) { for (auto* destination : outputs_) { - if (destination) destination->process(output, numFrames, numChannels); + if (!destination) continue; + if (destination->postMix()) { + // A sum boundary: hand it the block and stop, so the same voice is not also mixed + // straight to the destination. What it produces comes back through `flushSum`. + destination->accumulate(output, numFrames * static_cast(numChannels)); + std::fill_n(output, numFrames * static_cast(numChannels), 0.0f); + continue; + } + destination->process(output, numFrames, numChannels); } } @@ -178,6 +219,23 @@ void AudioNode::process(float* output, size_t numFrames, int numChannels) { AudioDestinationNode::AudioDestinationNode(AudioContext* context) : AudioNode(context) {} +void AudioDestinationNode::addPostMix(AudioNode* node) { + std::lock_guard lock(context_->mixMutex()); + if (std::find(postMix_.begin(), postMix_.end(), node) == postMix_.end()) { + postMix_.push_back(node); + } +} + +void AudioDestinationNode::removePostMix(AudioNode* node) { + std::lock_guard lock(context_->mixMutex()); + postMix_.erase(std::remove(postMix_.begin(), postMix_.end(), node), postMix_.end()); +} + +size_t AudioDestinationNode::postMixCount() const { + std::lock_guard lock(context_->mixMutex()); + return postMix_.size(); +} + // ============================================================================ // GainNode // ============================================================================ @@ -288,6 +346,128 @@ void PannerNode::process(float* output, size_t numFrames, int numChannels) { AudioNode::process(output, numFrames, numChannels); } +// ============================================================================ +// BiquadFilterNode +// ============================================================================ + +BiquadFilterNode::BiquadFilterNode(AudioContext* context) + : AudioNode(context) {} + +bool BiquadFilterNode::setType(const std::string& value) { + // `lowpass` is the only type `AudioBus` ever asks for. Accepting a name and then not + // filtering that way is the silent failure this whole node exists to remove, so the other + // five Web Audio types are refused and the caller finds out at the call. + return value == "lowpass"; +} + +void BiquadFilterNode::process(float* output, size_t numFrames, int numChannels) { + if (numChannels <= 0) return; + const double sampleRate = std::max(context_->sampleRate(), 1.0f); + const double startTime = context_->currentTime(); + + // RBJ low-pass, with the corner clamped below Nyquist: an unfiltered corner would divide by + // zero in the coefficient and emit a full-scale oscillation. Coefficients are computed once + // per block from the block's opening values — the same hoist `GainNode` does, for the same + // reason. A retune mid-block is picked up on the next one, which is under 23 ms at 44.1 kHz. + const float hz = frequency_.valueAtTime(startTime); + const float q = std::max(q_.valueAtTime(startTime), 0.0001f); + const double nyquist = sampleRate * 0.5; + const double f0 = std::clamp(static_cast(hz), 1.0, std::max(nyquist - 1.0, 1.0)); + const double w0 = 2.0 * 3.14159265358979323846 * f0 / sampleRate; + const double alpha = std::sin(w0) / (2.0 * static_cast(q)); + const double a0 = 1.0 + alpha; + const double b0 = ((1.0 - std::cos(w0)) / 2.0) / a0; + const double b1 = (1.0 - std::cos(w0)) / a0; + const double b2 = b0; // A low-pass is symmetric: b0 == b2. + const double a1 = (-2.0 * std::cos(w0)) / a0; + const double a2 = (1.0 - alpha) / a0; + + // The delay line is per channel and lives for the life of the node, so a voice that keeps its + // filter keeps its filter memory instead of restarting from silence on every cue. + if (state_.size() < static_cast(numChannels)) state_.resize(numChannels); + for (size_t frame = 0; frame < numFrames; frame++) { + for (int channel = 0; channel < numChannels; channel++) { + std::array& s = state_[static_cast(channel)]; + const float x1 = s[0]; + const float x2 = s[1]; + const float y1 = s[2]; + const float y2 = s[3]; + const float x = output[frame * numChannels + channel]; + const float y = static_cast(b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2); + s[0] = x; + s[1] = x1; + s[2] = y; + s[3] = y1; + output[frame * numChannels + channel] = y; + } + } + AudioNode::process(output, numFrames, numChannels); +} + +// ============================================================================ +// DynamicsCompressorNode +// ============================================================================ + +DynamicsCompressorNode::DynamicsCompressorNode(AudioContext* context) + : AudioNode(context) {} + +void DynamicsCompressorNode::process(float* output, size_t numFrames, int numChannels) { + if (numFrames == 0 || numChannels <= 0) return; + const double sampleRate = std::max(context_->sampleRate(), 1.0f); + const double startTime = context_->currentTime(); + + const float threshold = threshold_.valueAtTime(startTime); + const float knee = std::max(knee_.valueAtTime(startTime), 0.0f); + const float ratio = std::max(ratio_.valueAtTime(startTime), 1.0f); + const float attack = std::max(attack_.valueAtTime(startTime), 0.0f); + const float release = std::max(release_.valueAtTime(startTime), 0.0f); + + // One-pole smoothing coefficients for the envelope, from the attack/release time constants. + // `1 - exp(-1/tc)` rather than `1/tc` so a very short constant still converges inside a block. + const auto smoothing = [sampleRate](float seconds) -> float { + if (seconds <= 0.0f) return 1.0f; + return static_cast(1.0 - std::exp(-1.0 / (static_cast(seconds) * sampleRate))); + }; + const float attackCoefficient = smoothing(attack); + const float releaseCoefficient = smoothing(release); + + float reductionDb = reduction_.load(std::memory_order_relaxed); + for (size_t frame = 0; frame < numFrames; frame++) { + // Detector over the loudest channel of the frame: peak, not RMS, so one thunder crack is + // what pulls the gain down rather than the average of a quiet bed and a loud transient. + float peak = 0.0f; + for (int channel = 0; channel < numChannels; channel++) { + peak = std::max(peak, std::abs(output[frame * numChannels + channel])); + } + const float peakDb = peak > 1e-6f ? 20.0f * std::log10(peak) : -100.0f; + const float overshoot = peakDb - threshold; + // Standard dB-domain soft knee, measured from the threshold: nothing below `-knee/2`, a + // quadratic ease across the knee, and the straight ratio line above `+knee/2`. A reduction + // is dB removed, so it is negative or zero everywhere — the previous form subtracted half + // the knee from the overshoot unconditionally and *added* gain for a signal just over the + // threshold, which is an amplifier wearing a compressor's parameters. + const float kneeSlope = 1.0f / ratio - 1.0f; + float targetDb = 0.0f; + if (overshoot > knee * 0.5f) { + targetDb = kneeSlope * overshoot; + } else if (knee > 0.0f && overshoot > -knee * 0.5f) { + const float x = overshoot + knee * 0.5f; + targetDb = kneeSlope * x * x / (2.0f * knee); + } + const float coefficient = targetDb < reductionDb ? attackCoefficient : releaseCoefficient; + reductionDb += (targetDb - reductionDb) * coefficient; + // One gain for the frame, taken from the smoothed dB rather than per sample: a bus + // compressor is a slow envelope, and a per-sample exponential here would be both slower to + // converge and a `pow` on the audio thread for a curve that is already settled. + const float gain = std::pow(10.0f, reductionDb / 20.0f); + for (int channel = 0; channel < numChannels; channel++) { + output[frame * numChannels + channel] *= gain; + } + } + reduction_.store(reductionDb, std::memory_order_relaxed); + AudioNode::process(output, numFrames, numChannels); +} + // ============================================================================ // AudioBufferSourceNode // ============================================================================ @@ -515,6 +695,14 @@ std::unique_ptr AudioContext::createPanner() { return std::make_unique(this); } +std::unique_ptr AudioContext::createBiquadFilter() { + return std::make_unique(this); +} + +std::unique_ptr AudioContext::createDynamicsCompressor() { + return std::make_unique(this); +} + void AudioContext::setListenerPosition(float x, float y, float z) { listenerX_.store(x, std::memory_order_relaxed); listenerY_.store(y, std::memory_order_relaxed); @@ -645,11 +833,12 @@ void AudioContext::detachSources() { activeSources_.clear(); } -void AudioContext::audioCallback(float* output, int numFrames) { +void AudioContext::renderBlock(float* output, int numFrames) { // Clear output buffer std::memset(output, 0, numFrames * 2 * sizeof(float)); - // Mix all active sources + // Mix all active sources. A chain that reaches a sum boundary hands its block to that node and + // comes back zeroed, so this loop adds nothing for a voice the compressor owns. { std::lock_guard lock(sourcesMutex_); const size_t sampleCount = static_cast(numFrames) * 2; @@ -667,6 +856,17 @@ void AudioContext::audioCallback(float* output, int numFrames) { ); } + // Sum boundaries — the bus compressor — run once on everything their own voices contributed, + // and only then contribute to the mix, so an unrelated bus that never routed through them is + // untouched. Under the mix lock because `connect` is a JavaScript thread registering into the + // same vector. + { + std::lock_guard lock(mixMutex_); + for (AudioNode* node : destination_->postMixNodes()) { + node->flushSum(output, static_cast(numFrames), 2); + } + } + // Clamp output to [-1, 1] for (int i = 0; i < numFrames * 2; i++) { output[i] = std::clamp(output[i], -1.0f, 1.0f); @@ -675,6 +875,10 @@ void AudioContext::audioCallback(float* output, int numFrames) { sampleCount_.fetch_add(static_cast(numFrames), std::memory_order_release); } +void AudioContext::audioCallback(float* output, int numFrames) { + renderBlock(output, numFrames); +} + void AudioContext::sdlAudioCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount) { // Safety check: validate userdata pointer first if (!userdata || !stream) { diff --git a/packages/runtime-native/src/physics/native_bindings.cpp b/packages/runtime-native/src/physics/native_bindings.cpp index 27dae65a1b..02a5de9dc8 100644 --- a/packages/runtime-native/src/physics/native_bindings.cpp +++ b/packages/runtime-native/src/physics/native_bindings.cpp @@ -173,8 +173,72 @@ struct TrimeshBuffers { uint32_t index_count = 0; }; +// A heightfield's samples (column-major, as the web backend hands Rapier), grid and extent. +struct HeightfieldBuffers { + const float *heights = nullptr; + uint32_t count = 0; + uint32_t rows = 0; + uint32_t columns = 0; + float scale_x = 0.0f; + float scale_y = 0.0f; + float scale_z = 0.0f; +}; + +bool readGridCount(js::Engine *engine, js::JSValueHandle object, const char *name, + uint32_t &output) { + float value = 0.0f; + if (!readFiniteNumber(engine, object, name, value) || value < 2.0f || + value > 65536.0f || std::floor(value) != value) + return false; + output = static_cast(value); + return true; +} + +// The samples' data pointer stays valid for the synchronous call that parses it, like a trimesh's. +bool parseHeightfield(js::Engine *engine, js::JSValueHandle shape, + HeightfieldBuffers &heightfield) { + const auto heights = engine->getProperty(shape, "heights"); + if (!isTypedArray(engine, heights, "Float32Array") || + !readGridCount(engine, shape, "rows", heightfield.rows) || + !readGridCount(engine, shape, "columns", heightfield.columns) || + !readVector(engine, shape, "scale", heightfield.scale_x, heightfield.scale_y, + heightfield.scale_z)) + return false; + size_t bytes = 0; + void *data = engine->getArrayBufferData(heights, &bytes); + if (data == nullptr || bytes % sizeof(float) != 0) + return false; + heightfield.heights = static_cast(data); + heightfield.count = static_cast(bytes / sizeof(float)); + return true; +} + +// The mesh's data pointers stay valid for the synchronous call that parses them: the typed +// arrays are reachable from the object the caller passed in, so nothing is collected mid-call. +bool parseTrimeshBuffers(js::Engine *engine, js::JSValueHandle shape, + TrimeshBuffers &trimesh) { + const auto vertices = engine->getProperty(shape, "vertices"); + if (!isTypedArray(engine, vertices, "Float32Array")) + return false; + const auto indices = engine->getProperty(shape, "indices"); + if (!isTypedArray(engine, indices, "Uint32Array")) + return false; + size_t vertexBytes = 0; + size_t indexBytes = 0; + void *vertexData = engine->getArrayBufferData(vertices, &vertexBytes); + void *indexData = engine->getArrayBufferData(indices, &indexBytes); + if (vertexData == nullptr || indexData == nullptr) + return false; + trimesh.vertices = static_cast(vertexData); + trimesh.vertex_floats = static_cast(vertexBytes / sizeof(float)); + trimesh.indices = static_cast(indexData); + trimesh.index_count = static_cast(indexBytes / sizeof(uint32_t)); + return true; +} + bool parseBodyOptions(js::Engine *engine, js::JSValueHandle value, uint32_t id, - TnPhysicsBodyOptions &options, TrimeshBuffers &trimesh) { + TnPhysicsBodyOptions &options, TrimeshBuffers &trimesh, + HeightfieldBuffers &heightfield) { if (!engine->isObject(value)) return false; options = {id, 0, 0, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, @@ -208,25 +272,14 @@ bool parseBodyOptions(js::Engine *engine, js::JSValueHandle value, uint32_t id, else if (shapeName == "capsule") options.shape_type = 2; else if (shapeName == "trimesh") { - // The mesh's data pointers stay valid for this synchronous call: the typed arrays are - // reachable from the options object the caller passed in, so nothing is collected mid-call. options.shape_type = 3; - const auto vertices = engine->getProperty(shape, "vertices"); - if (!isTypedArray(engine, vertices, "Float32Array")) - return false; - const auto indices = engine->getProperty(shape, "indices"); - if (!isTypedArray(engine, indices, "Uint32Array")) + if (!parseTrimeshBuffers(engine, shape, trimesh)) return false; - size_t vertexBytes = 0; - size_t indexBytes = 0; - void *vertexData = engine->getArrayBufferData(vertices, &vertexBytes); - void *indexData = engine->getArrayBufferData(indices, &indexBytes); - if (vertexData == nullptr || indexData == nullptr) + } else if (shapeName == "heightfield") { + // Shape type 4 never crosses the C ABI: it selects tn_physics_add_heightfield_body. + options.shape_type = 4; + if (!parseHeightfield(engine, shape, heightfield)) return false; - trimesh.vertices = static_cast(vertexData); - trimesh.vertex_floats = static_cast(vertexBytes / sizeof(float)); - trimesh.indices = static_cast(indexData); - trimesh.index_count = static_cast(indexBytes / sizeof(uint32_t)); } else return false; @@ -579,7 +632,9 @@ js::JSValueHandle makeSimulationObject( return fail(engine, "createBody requires options and an available id"); TnPhysicsBodyOptions options{}; TrimeshBuffers trimesh{}; - if (!parseBodyOptions(engine, args[0], owner->nextId, options, trimesh)) + HeightfieldBuffers heightfield{}; + if (!parseBodyOptions(engine, args[0], owner->nextId, options, trimesh, + heightfield)) return fail(engine, "physics body options are invalid"); const bool added = options.shape_type == 3 @@ -587,6 +642,11 @@ js::JSValueHandle makeSimulationObject( owner->simulation, &options, trimesh.vertices, trimesh.vertex_floats, trimesh.indices, trimesh.index_count) + : options.shape_type == 4 + ? tn_physics_add_heightfield_body( + owner->simulation, &options, heightfield.heights, + heightfield.count, heightfield.rows, heightfield.columns, + heightfield.scale_x, heightfield.scale_y, heightfield.scale_z) : tn_physics_add_body(owner->simulation, &options); if (!added) return fail(engine, "physics body options are invalid"); @@ -1005,6 +1065,62 @@ js::JSValueHandle makeSimulationObject( return fail(engine, "sleep state buffer is too small"); return engine->newNumber(count); })); + engine->setProperty( + simulation, "readContacts", + engine->newFunction( + "readContacts", + [engine, owner](void *, + const std::vector &args) { + if (owner->simulation == nullptr) + return fail(engine, "physics simulation is disposed"); + uint32_t target = 0; + if (args.size() < 3 || !parseBodyIdArgument(engine, args, target) || + !isTypedArray(engine, args[1], "Uint32Array") || + !isTypedArray(engine, args[2], "Float32Array")) + return fail(engine, + "readContacts requires a body id, a Uint32Array and a Float32Array"); + size_t candidateBytes = 0; + size_t outputBytes = 0; + auto *candidates = static_cast( + engine->getArrayBufferData(args[1], &candidateBytes)); + auto *output = static_cast( + engine->getArrayBufferData(args[2], &outputBytes)); + if (candidateBytes % sizeof(uint32_t) != 0 || outputBytes % sizeof(float) != 0) + return fail(engine, "contact buffer is malformed"); + const int32_t count = tn_physics_read_contacts( + owner->simulation, target, candidates, + candidateBytes / sizeof(uint32_t), output, + outputBytes / sizeof(float)); + if (count < 0) + return fail(engine, "readContacts target is not a live body"); + return engine->newNumber(count); + })); + engine->setProperty( + simulation, "setColliderShape", + engine->newFunction( + "setColliderShape", + [engine, owner](void *, + const std::vector &args) { + if (owner->simulation == nullptr) + return fail(engine, "physics simulation is disposed"); + uint32_t id = 0; + if (args.size() < 2 || !parseBodyIdArgument(engine, args, id) || + !engine->isObject(args[1])) + return fail(engine, "setColliderShape requires a body id and a shape"); + const auto kind = engine->getProperty(args[1], "kind"); + if (!engine->isString(kind)) + return fail(engine, "setColliderShape requires a shape kind"); + if (engine->toString(kind) != "heightfield") + return fail(engine, "setColliderShape supports heightfield shapes"); + HeightfieldBuffers heightfield{}; + if (!parseHeightfield(engine, args[1], heightfield) || + !tn_physics_set_heightfield_shape( + owner->simulation, id, heightfield.heights, heightfield.count, + heightfield.rows, heightfield.columns, heightfield.scale_x, + heightfield.scale_y, heightfield.scale_z)) + return fail(engine, "setColliderShape shape was rejected"); + return engine->newUndefined(); + })); engine->setProperty( simulation, "readAreaIntersections", engine->newFunction( diff --git a/packages/runtime-native/src/runtime-scripts/audio-param.js b/packages/runtime-native/src/runtime-scripts/audio-param.js new file mode 100644 index 0000000000..daace3e44d --- /dev/null +++ b/packages/runtime-native/src/runtime-scripts/audio-param.js @@ -0,0 +1,47 @@ +// Turns native `_setValue`-bearing objects into Web Audio AudioParams, and gives the nodes that +// carry them their Web Audio property names. The engine has no accessor support, so `value` has to +// be defined here rather than in the binding. +// +// `bundle.params` are the raw param objects, already carrying the native setters. `bundle.type` is +// a biquad whose filter type must be settable and refused rather than accepted and ignored, and +// `bundle.reduction` is a compressor whose `reduction` is a native readout. +((bundle) => { + for (const param of bundle.params) { + let value = Number(param._default); + Object.defineProperty(param, 'value', { + get: () => value, + set: (next) => { value = Number(next); param._setValue(value); }, + configurable: true, + }); + param.setValueAtTime = (next, time) => { + value = Number(next); param._setValueAtTime(value, time); return param; + }; + param.linearRampToValueAtTime = (next, time) => { + value = Number(next); param._linearRampToValueAtTime(value, time); return param; + }; + param.setTargetAtTime = (next, time, constant) => { + value = Number(next); param._setTargetAtTime(value, time, constant); return param; + }; + } + + if (bundle.type) { + const node = bundle.type; + Object.defineProperty(node, 'type', { + // Only `lowpass` is implemented. Accepting the other five names and passing the signal + // through unfiltered is the silent failure this node exists to remove. + get: () => 'lowpass', + set: (next) => { + if (!node._setType(next)) { + throw new RangeError(`BiquadFilterNode.type: only "lowpass" is implemented on this runtime, not ${next}.`); + } + }, + configurable: true, + }); + } + + if (bundle.reduction) { + const node = bundle.reduction; + const read = node._getReduction; + Object.defineProperty(node, 'reduction', { get: () => read(), configurable: true }); + } +})(globalThis.__tnAudioParamBundle); \ No newline at end of file diff --git a/packages/runtime-native/tests/audio_graph_test.cpp b/packages/runtime-native/tests/audio_graph_test.cpp index ede5ea0ac6..1636ef4826 100644 --- a/packages/runtime-native/tests/audio_graph_test.cpp +++ b/packages/runtime-native/tests/audio_graph_test.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #if defined(_WIN32) #include @@ -27,12 +29,29 @@ bool closeTo(float actual, float expected, float tolerance = 0.0001f) { return std::abs(actual - expected) <= tolerance; } -std::shared_ptr constantBuffer(AudioContext& context) { - auto buffer = context.createBuffer(1, 4, 44100.0f); +std::shared_ptr constantBuffer(AudioContext& context, size_t length = 4) { + auto buffer = context.createBuffer(1, length, 44100.0f); std::fill_n(buffer->getChannelData(0), buffer->length(), 1.0f); return buffer; } +/** A whole second of a sine at `hz`, so a filter's energy loss is measurable rather than transient. */ +std::shared_ptr toneBuffer(AudioContext& context, float hz) { + constexpr size_t kFrames = 44100; + auto buffer = context.createBuffer(1, kFrames, 44100.0f); + for (size_t i = 0; i < kFrames; i++) { + const double t = static_cast(i) / 44100.0; + buffer->getChannelData(0)[i] = static_cast(0.5 * std::sin(2.0 * 3.14159265358979 * hz * t)); + } + return buffer; +} + +float rms(const float* samples, size_t count) { + double sum = 0.0; + for (size_t i = 0; i < count; i++) sum += static_cast(samples[i]) * samples[i]; + return static_cast(std::sqrt(sum / static_cast(count))); +} + } // namespace int main() { @@ -145,6 +164,25 @@ int main() { return 1; } + // The exponential target a fade uses, proved on the block it renders: the first frame is still + // silent and the last is the closed form, so a setter that only stored a value would fail. + auto targetGain = context.createGain(); + targetGain->gain().setValueAtTime(0.0f, 0.0); + targetGain->gain().setTargetAtTime(1.0f, context.currentTime(), 0.25); + auto targetSource = context.createBufferSource(); + targetSource->setBuffer(constantBuffer(context)); + targetSource->connect(targetGain.get()); + targetGain->connect(context.destination()); + targetSource->start(); + float targeted[8] = {}; + targetSource->process(targeted, 4, 2); + const float targetEnd = 1.0f - std::exp(-3.0f / 44100.0f / 0.25f); + if (std::abs(targeted[0]) > 0.0001f || !closeTo(targeted[7], targetEnd)) { + std::cerr << "target ramp failed: first " << targeted[0] << " last " << targeted[7] + << " (want 0 and " << targetEnd << ")\n"; + return 1; + } + auto rightSource = context.createBufferSource(); auto panner = context.createPanner(); rightSource->setBuffer(constantBuffer(context)); @@ -219,7 +257,313 @@ int main() { return 1; } + // `lowpassHz` on the native host: a real biquad, not an inert param. A 4 kHz tone through a + // 200 Hz low-pass loses most of its energy; the same tone unfiltered does not. Anything that + // accepted the write and filtered nothing fails here. + auto filterSource = context.createBufferSource(); + filterSource->setBuffer(toneBuffer(context, 4000.0f)); + auto lowpass = context.createBiquadFilter(); + if (!lowpass->setType("lowpass") || lowpass->type() != "lowpass") { + std::cerr << "a biquad refused the only type it implements\n"; + return 1; + } + if (lowpass->setType("highpass")) { + std::cerr << "a biquad accepted a type it does not filter\n"; + return 1; + } + lowpass->frequency().setValue(200.0f); + filterSource->connect(lowpass.get()); + lowpass->connect(context.destination()); + filterSource->start(); + float filtered[1024] = {}; + filterSource->process(filtered, 512, 2); + const float filteredEnergy = rms(filtered, 1024); + float unfiltered[1024] = {}; + auto wideSource = context.createBufferSource(); + wideSource->setBuffer(toneBuffer(context, 4000.0f)); + wideSource->connect(context.destination()); + wideSource->start(); + wideSource->process(unfiltered, 512, 2); + const float wideEnergy = rms(unfiltered, 1024); + // The transient's first samples dominate a 200 Hz sine's ramp-up either way, so the + // comparison is against a generous fraction rather than an exact figure. + if (!(filteredEnergy < wideEnergy * 0.25f)) { + std::cerr << "a 200 Hz low-pass did not attenuate a 4 kHz tone: " << filteredEnergy + << " against " << wideEnergy << '\n'; + return 1; + } + // Retuning must change the answer, or the frequency would be a number the DSP ignores. + lowpass->frequency().setValue(20000.0f); + float openFiltered[1024] = {}; + filterSource->connect(lowpass.get()); + filterSource->process(openFiltered, 512, 2); + if (!(rms(openFiltered, 1024) > filteredEnergy)) { + std::cerr << "opening the low-pass did not restore energy\n"; + return 1; + } + + // The bus compressor: post-mix, so it holds down a sum rather than one voice. Two voices at + // -6 dBFS each are quiet individually and clip when summed; post-mix they come out under. + auto compressor = context.createDynamicsCompressor(); + if (!compressor->postMix()) { + std::cerr << "a compressor must declare itself post-mix\n"; + return 1; + } + compressor->threshold().setValue(-20.0f); + compressor->knee().setValue(0.0f); + compressor->ratio().setValue(4.0f); + compressor->attack().setValue(0.0f); + compressor->release().setValue(0.0f); + compressor->connect(context.destination()); + if (context.destination()->postMixCount() != 1) { + std::cerr << "connecting a compressor did not register it for post-mix processing\n"; + return 1; + } + float loud[512] = {}; + std::fill_n(loud, 512, 0.5f); + // A block through the node directly, then through the mixer's own post-mix pass, must agree: + // the sum is the compressor's only legal input and the mixer is what feeds it. + float direct[512] = {}; + std::copy_n(loud, 512, direct); + compressor->process(direct, 256, 2); + if (compressor->reduction() >= 0.0f) { + std::cerr << "a 0.5 signal did not pull a -20 dB threshold down: " << compressor->reduction() + << '\n'; + return 1; + } + float quiet[512] = {}; + std::fill_n(quiet, 512, 0.01f); + float unquiet[512] = {}; + std::copy_n(quiet, 512, unquiet); + compressor->process(unquiet, 256, 2); + if (std::abs(compressor->reduction()) > 0.001f) { + std::cerr << "a signal below the threshold was still reduced: " << compressor->reduction() + << '\n'; + return 1; + } + // A DC-ish input passes untouched, which is what makes the reduction above a real measurement + // rather than a constant offset. + std::fill_n(direct, 512, 0.001f); + compressor->process(direct, 256, 2); + if (!closeTo(direct[0], 0.001f, 0.0005f)) { + std::cerr << "a below-threshold signal was not passed through: " << direct[0] << '\n'; + return 1; + } + + // Disconnecting lifts it out of the mix, so a disposed compressor stops processing. + compressor->disconnect(context.destination()); + if (context.destination()->postMixCount() != 0) { + std::cerr << "disconnecting a compressor left it in the post-mix chain\n"; + return 1; + } + compressor->connect(context.destination()); + if (context.destination()->postMixCount() != 1) { + std::cerr << "reconnecting a compressor did not restore it\n"; + return 1; + } + + // The real `AudioBus` topology, driven through the mixer: two voices into ONE compressor, then + // the listener's master gain, then the destination — plus a second bus that reaches the same + // master gain without a compressor. `compressor.connect(destination)` above proved a node the + // mixer would run; it said nothing about the graph the game actually builds, where the + // compressor's own downstream is a gain and never the destination. + float compressed = 0.0f; + float bypass = 0.0f; + { + AudioContext bus; + auto busCompressor = bus.createDynamicsCompressor(); + busCompressor->threshold().setValue(-20.0f); + busCompressor->knee().setValue(0.0f); + busCompressor->ratio().setValue(4.0f); + busCompressor->attack().setValue(0.0f); + busCompressor->release().setValue(0.0f); + auto master = bus.createGain(); + master->gain().setValue(0.42f); + busCompressor->connect(master.get()); + master->connect(bus.destination()); + if (bus.destination()->postMixCount() != 1) { + std::cerr << "a compressor wired into a master gain was not registered as a sum boundary\n"; + return 1; + } + + // Two voices, each -6 dBFS. On their own neither crosses the -20 dB threshold, so a + // per-source compressor leaves them alone; summed they reach 0 dBFS and must come down. + const size_t kFrames = 256; + constexpr size_t kLongEnough = 1u << 16; + std::vector> voices; + std::vector> voiceGains; + for (int voice = 0; voice < 2; voice++) { + auto source = bus.createBufferSource(); + source->setBuffer(constantBuffer(bus, kLongEnough)); + auto gain = bus.createGain(); + gain->gain().setValue(0.5f); + source->connect(gain.get()); + gain->connect(busCompressor.get()); + source->start(); + voices.push_back(std::move(source)); + voiceGains.push_back(std::move(gain)); + } + // The bypass bus: the same master gain, no compressor, so it must be bit-identical whatever + // the compressor is doing to its own bus. + auto bypassSource = bus.createBufferSource(); + bypassSource->setBuffer(constantBuffer(bus, kLongEnough)); + auto bypassGain = bus.createGain(); + bypassGain->gain().setValue(0.25f); + bypassSource->connect(bypassGain.get()); + bypassGain->connect(master.get()); + bypassSource->start(); + + std::vector mixed(kFrames * 2, 0.0f); + bus.renderBlock(mixed.data(), static_cast(kFrames)); + compressed = mixed[kFrames - 2]; + // The bypass bus measured on its own, with the compressor's voices out of the way. + for (auto& voice : voices) voice->stop(bus.currentTime()); + std::vector bypassOnly(kFrames * 2, 0.0f); + bus.renderBlock(bypassOnly.data(), static_cast(kFrames)); + bypass = bypassOnly[kFrames - 2]; + + // Summed, the two voices reach 0 dBFS — 20 dB over a -20 dB threshold at 4:1, so 15 dB of + // reduction — and only then the master gain's 0.42, which is the order the graph wires them + // in. Gain first would give 0.1416 for the bus against 0.0747 here; per-source compression + // would have left each 0.5 voice alone at 0.21. The bypass bus rides the same master gain. + const float expectedCompressed = std::pow(10.0f, -15.0f / 20.0f) * 0.42f; + const float expectedMixed = expectedCompressed + 0.25f * 0.42f; + if (!(compressed < 2.0f * 0.5f * 0.42f + 0.25f * 0.42f)) { + std::cerr << "two voices summed through a compressor were not held down at all: " + << compressed << '\n'; + return 1; + } + if (std::abs(compressed - expectedMixed) > 0.001f) { + std::cerr << "the compressor bus did not reduce the sum by 15 dB and then apply the " + "master gain: " + << compressed << " against " << expectedMixed << '\n'; + return 1; + } + if (std::abs(bypass - 0.25f * 0.42f) > 0.0001f) { + std::cerr << "a bus bypassing the compressor was altered by it: " << bypass << " against " + << 0.25f * 0.42f << '\n'; + return 1; + } + + // Disconnecting the compressor must take the two voices out of the mix entirely and leave + // the bypass bus exactly where it was. + busCompressor->disconnect(master.get()); + for (auto& voice : voices) voice->start(); + bypassSource->start(); + std::vector disconnected(kFrames * 2, 0.0f); + bus.renderBlock(disconnected.data(), static_cast(kFrames)); + if (std::abs(disconnected[kFrames - 2] - 0.25f * 0.42f) > 0.0001f) { + std::cerr << "disconnecting the compressor did not silence its own bus: " + << disconnected[kFrames - 2] << '\n'; + return 1; + } + } + + // The soft knee. Web Audio's `knee` spans `[-knee/2, +knee/2]` around the threshold: no + // reduction below it, a quadratic ease across it, and the straight ratio line above. The defect + // this catches is the direction of the reduction — a compressor must never add gain, and the + // previous form produced +11 dB for a signal 1 dB over a 30 dB knee. + { + AudioContext kneeContext; + const float threshold = -20.0f; + const float knee = 30.0f; + const float ratio = 5.0f; + auto probe = kneeContext.createDynamicsCompressor(); + probe->threshold().setValue(threshold); + probe->knee().setValue(knee); + probe->ratio().setValue(ratio); + probe->attack().setValue(0.0f); + probe->release().setValue(0.0f); + // A fresh node per level: the reduction is carried between blocks by design, and this sweep + // is about the curve, not about an envelope settling. + // Levels chosen to straddle every branch of the curve: below the knee, at its lower edge, + // inside it, just over the threshold (where the old form amplified hardest — 1 dB over a + // 30 dB knee gave +11.2 dB of *gain*), at its upper edge, and far above it. + const std::vector levels = {-60.0f, -40.0f, -35.5f, -35.0f, -30.0f, -20.0f, -19.0f, + -10.0f, -5.0f, -4.9f, 0.0f, 3.0f}; + float previousReduction = 0.0f; + float previousLevel = 1e9f; + for (const float levelDb : levels) { + auto node = kneeContext.createDynamicsCompressor(); + node->threshold().setValue(threshold); + node->knee().setValue(knee); + node->ratio().setValue(ratio); + node->attack().setValue(0.0f); + node->release().setValue(0.0f); + const float level = std::pow(10.0f, levelDb / 20.0f); + std::vector block(64 * 2, level); + node->process(block.data(), 64, 2); + const float reduction = node->reduction(); + if (reduction > 0.0001f) { + std::cerr << "a " << levelDb << " dB signal through a -20 dB threshold was boosted " + << reduction << " dB\n"; + return 1; + } + if (reduction > previousReduction + 0.0001f) { + std::cerr << "the knee curve is not monotonic: " << previousLevel << " dB gave " + << previousReduction << " dB and " << levelDb << " dB gave " << reduction + << " dB\n"; + return 1; + } + // Nothing below `-knee/2` of the threshold is touched at all. + if (levelDb < threshold - knee * 0.5f && std::abs(reduction) > 0.0001f) { + std::cerr << "a " << levelDb << " dB signal was reduced " << reduction + << " dB below the knee\n"; + return 1; + } + // Past the top of the knee it is the straight ratio line: `ratio:1` identity below. + const float overshoot = levelDb - threshold; + if (overshoot > knee * 0.5f) { + const float expected = (1.0f / ratio - 1.0f) * overshoot; + if (std::abs(reduction - expected) > 0.01f) { + std::cerr << "above the knee the curve is not the ratio line: " << levelDb + << " dB gave " << reduction << " dB against " << expected << " dB\n"; + return 1; + } + } + // Continuity at the knee's lower edge, where the quadratic starts from zero. + if (levelDb == -35.0f && std::abs(reduction) > 0.001f) { + std::cerr << "the knee did not start from unity at its lower edge: " << reduction + << " dB\n"; + return 1; + } + previousReduction = reduction; + previousLevel = levelDb; + } + + // `ratio: 1` is the identity: a compressor asked not to compress must not touch the signal. + auto unity = kneeContext.createDynamicsCompressor(); + unity->threshold().setValue(threshold); + unity->knee().setValue(knee); + unity->ratio().setValue(1.0f); + unity->attack().setValue(0.0f); + unity->release().setValue(0.0f); + std::vector unityBlock(64 * 2, 0.9f); + unity->process(unityBlock.data(), 64, 2); + if (std::abs(unity->reduction()) > 0.0001f || !closeTo(unityBlock[0], 0.9f, 0.0005f)) { + std::cerr << "ratio 1 was not an identity: reduction " << unity->reduction() << " dB, out " + << unityBlock[0] << '\n'; + return 1; + } + // Silence is untouched, which is what makes the checks above measurements. + auto silent = kneeContext.createDynamicsCompressor(); + silent->threshold().setValue(threshold); + silent->knee().setValue(knee); + silent->ratio().setValue(ratio); + silent->attack().setValue(0.0f); + silent->release().setValue(0.0f); + std::vector silence(64 * 2, 0.0f); + silent->process(silence.data(), 64, 2); + if (std::abs(silent->reduction()) > 0.0001f) { + std::cerr << "silence was reduced " << silent->reduction() << " dB\n"; + return 1; + } + } + std::cout << "audio graph ok: ramp-mid=0.5 gain=0.5 right=0.1 flipped-left=0.1 ended=1" - << " absolute-start=1 scheduled-ahead=0\n"; + << " absolute-start=1 scheduled-ahead=0" + << " lowpass=attenuated postmix=1 reduction=" << compressor->reduction() + << " bus-sum=" << compressed << " bypass=" << bypass + << " target-ramp-first=" << targeted[0] << " target-ramp-last=" << targeted[7] << '\n'; return 0; } diff --git a/packages/runtime-native/tests/canvas_audio_comprehensive_test.cpp b/packages/runtime-native/tests/canvas_audio_comprehensive_test.cpp index d8e5425120..1f597f0856 100644 --- a/packages/runtime-native/tests/canvas_audio_comprehensive_test.cpp +++ b/packages/runtime-native/tests/canvas_audio_comprehensive_test.cpp @@ -294,6 +294,46 @@ constexpr const char* kAudioScript = R"JS((() => { audioCtx.suspend(); audioCtx.resume(); + // BiquadFilterNode - the low-pass `AudioBus` builds for `lowpassHz`. A write that never reached + // C++ would still read back here, so the round trip is checked against the native default. + const biquad = audioCtx.createBiquadFilter(); + if (biquad.frequency.value !== 350) throw new Error("biquad frequency default is not the Web Audio 350"); + biquad.type = "lowpass"; + if (biquad.type !== "lowpass") throw new Error("biquad type did not read back"); + biquad.frequency.value = 900; + if (biquad.frequency.value !== 900) throw new Error("biquad frequency did not read back"); + biquad.Q.value = 2.5; + let refusedType = false; + try { + biquad.type = "highpass"; + } catch { + refusedType = true; + } + if (!refusedType) throw new Error("a biquad accepted a filter type it does not implement"); + biquad.connect(dest); + biquad.disconnect(); + + // DynamicsCompressorNode - the bus master compressor. Every value must reach C++ before the node + // sees a sample, and `reduction` is the readout that says it is doing anything. + const compressor = audioCtx.createDynamicsCompressor(); + const webDefaults = { threshold: -24, knee: 30, ratio: 12, attack: 0.003, release: 0.25 }; + for (const [name, value] of Object.entries(webDefaults)) { + if (Math.abs(compressor[name].value - value) > 1e-6) { + throw new Error(`compressor ${name} default is not the Web Audio ${value}`); + } + } + compressor.threshold.value = -15; + compressor.knee.value = 6; + compressor.ratio.value = 5; + compressor.attack.value = 0.004; + compressor.release.value = 0.18; + compressor.threshold.setValueAtTime(-20, curTime); + compressor.knee.linearRampToValueAtTime(12, curTime + 0.5); + compressor.ratio.setTargetAtTime(4, curTime, 0.1); + if (compressor.reduction !== 0) throw new Error("a fresh compressor must report no reduction"); + compressor.connect(dest); + compressor.disconnect(); + // AudioContext close audioCtx.close(); diff --git a/packages/runtime-native/tests/runtime-next-contract.test.mjs b/packages/runtime-native/tests/runtime-next-contract.test.mjs index ad19cc5df1..e574dd1cc4 100644 --- a/packages/runtime-native/tests/runtime-next-contract.test.mjs +++ b/packages/runtime-native/tests/runtime-next-contract.test.mjs @@ -55,6 +55,7 @@ const RUNTIME_SCRIPT_HASHES = { 'audio-context-constructor.js': 'c3436f70b2597d2d953f780a3388c24b7e60fa3697796973d5002d0c378de227', 'audio-source-properties.js': 'e631cdd093d660c0ada6f9cf23e0627a2bd1f16d22d8c003c52d7f86419d29ef', 'audio-gain-param.js': 'd12e77670eaafe552e90d9fcc78a95d51f872922880bb95b8d51e1bad23b9723', + 'audio-param.js': '375719b8424e57c24aec55ccf012d7e7048c39c72af4dfa25c5ee96bc722e683', 'audio-panner-properties.js': '347b79924b271915fce4259f5cd1ca48ce334d59d76b18730014bd1670cf1cea', 'canvas2d-properties.js': 'e9bd1ff7562fca7cb16ec1c57ffb05aee9dbead65e1d38cebb9d2d710af5bdf7', }; @@ -67,6 +68,7 @@ const RUNTIME_SCRIPT_LOADERS = { 'audio-context-constructor.js': 'evalAudioScript', 'audio-source-properties.js': 'evalAudioScript', 'audio-gain-param.js': 'evalAudioScript', + 'audio-param.js': 'evalAudioScript', 'audio-panner-properties.js': 'evalAudioScript', 'canvas2d-properties.js': 'evalCanvasScript', }; diff --git a/packages/runtime-native/tests/starter-desktop.test.mjs b/packages/runtime-native/tests/starter-desktop.test.mjs index f1be41f965..215bdf1bcc 100644 --- a/packages/runtime-native/tests/starter-desktop.test.mjs +++ b/packages/runtime-native/tests/starter-desktop.test.mjs @@ -387,8 +387,18 @@ test('native workflow proves the Linux release container on x64 and arm64', () = assert.ok(starter); // The arm64 leg is the point of the matrix: a free hosted ARM runner, the same release steps, // and the same scope selection as the x64 leg because it is the same job. - assert.match(starter, /- platform: linux-x64\n\s+runner: ubuntu-24\.04/u); - assert.match(starter, /- platform: linux-arm64\n\s+runner: ubuntu-24\.04-arm/u); + // The rows are shaped by the scope job — a job's `if` cannot read `matrix` — and an ordinary + // pull request's reduced matrix (PRD-380 phase 2) keeps x64 alone. linux-x64 routes through the + // TN_RUNNER switch (PRD-480) and falls back to the same hosted image; arm64 stays hosted. + const rows = workflow.match(/ {2}scope:\n([\s\S]*?)\n {2}web-reference:/u)?.[1]; + assert.ok(rows); + assert.match( + rows, + /\{ platform: "linux-x64", runner: "\$\{\{ \(github\.event\.pull_request\.head\.repo\.fork \|\| !vars\.TN_RUNNER\) && 'ubuntu-24\.04' \|\| vars\.TN_RUNNER \}\}" \}/u, + ); + assert.match(rows, /\{ platform: "linux-arm64", runner: "ubuntu-24\.04-arm" \}/u); + assert.match(rows, /rows\.slice\(0, 1\)/u); + assert.match(starter, /matrix: \$\{\{ fromJSON\(needs\.scope\.outputs\.starter_rows\) \}\}/u); assert.match(starter, /runs-on: \$\{\{ matrix\.runner \}\}/u); // The runner has no GPU, so the software Vulkan ICD and its X display are provisioned. assert.match(starter, /mesa-vulkan-drivers/u); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b725bc52f1..9e5fa54c07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: '@threenative/assets': specifier: workspace:* version: link:../../packages/assets + '@threenative/playtest': + specifier: workspace:* + version: link:../../packages/playtest '@types/react': specifier: 'catalog:' version: 19.2.0 @@ -549,6 +552,31 @@ importers: specifier: 'catalog:' version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/prd476-fluid-particles: + dependencies: + '@threenative/core': + specifier: workspace:* + version: link:../../packages/core + '@threenative/physics': + specifier: workspace:* + version: link:../../packages/physics + '@threenative/playtest': + specifier: workspace:* + version: link:../../packages/playtest + three: + specifier: 'catalog:' + version: 0.185.1(patch_hash=dcbc51317e57b2a422fa4335d86722cd7789d322f215e5a8b00cd96e66c0f05b) + devDependencies: + '@types/three': + specifier: 0.185.3 + version: 0.185.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/quarry: dependencies: '@threenative/core': diff --git a/scripts/__tests__/ci-efficiency.spec.ts b/scripts/__tests__/ci-efficiency.spec.ts index 6754c73ace..ce0faf5e0f 100644 --- a/scripts/__tests__/ci-efficiency.spec.ts +++ b/scripts/__tests__/ci-efficiency.spec.ts @@ -118,17 +118,25 @@ describe("CI efficiency without lost evidence", () => { }, ); - it.each(["benchmark", "budgets", "performance-contracts"])( - "%s starts after scope rather than waiting for artifacts it never downloads", + // These two used to sit at `needs: scope` and restore the workspace key, which meant they started + // beside the only job that saves it, missed every time and compiled the workspace themselves — + // 71-95s each. PRD-481 gives them the producer's upload instead, so the edge is real work. + it.each(["benchmark", "budgets"])( + "%s downloads this run's workspace rather than compiling its own", (name) => { - expect(declaredNeeds(job(name))).toEqual(["scope"]); + expect(declaredNeeds(job(name))).toEqual(["scope", "build-artifacts"]); + expect(job(name)).toContain("uses: ./.github/actions/workspace-dist"); + expect(job(name)).toContain("shared-artifact: workspace-packages"); expect(ancestors(name).has("native-platforms")).toBe(false); - if (name !== "performance-contracts") { - expect(job(name)).toContain("uses: ./.github/actions/workspace-dist"); - } }, ); + it("performance-contracts starts after scope rather than waiting for artifacts it never downloads", () => { + expect(declaredNeeds(job("performance-contracts"))).toEqual(["scope"]); + expect(ancestors("performance-contracts").has("native-platforms")).toBe(false); + expect(job("performance-contracts")).not.toContain("uses: ./.github/actions/workspace-dist"); + }); + it("reports the new producer as well as every pre-existing job", () => { const graph = ciJobGraph(source); expect([...declaredNeeds(job("run-summary"))].sort()).toEqual( @@ -160,6 +168,197 @@ describe("CI efficiency without lost evidence", () => { ); }); +describe("PRD-380 an ordinary pull request owes only the Linux native rows", () => { + /** This repository's own candidate, so a plan built without a diff still validates. */ + const candidate = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf8", + }).stdout.trim(); + + /** Classify without a merge-base diff: the plan still records the native tier each target owes. */ + function classify(args: string[], format: "json" | "github" = "json"): string { + const result = spawnSync( + process.execPath, + [ + path.join(repo, "scripts/ci-change-scope.mjs"), + ...args, + "--candidate-sha", + candidate, + "--format", + format, + ], + { cwd: repo, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + } + + const nativeJobs = new Map( + jobSections(readFileSync(path.join(repo, ".github/workflows/native-platforms.yml"), "utf8")), + ); + /** The executable half of a job: a comment explaining the tier is not a tier. */ + const executable = new Map( + [...nativeJobs].map(([name, section]) => [ + name, + section + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"), + ]), + ); + + it("gives an ordinary pull request the reduced matrix and everything else the full one", () => { + const ordinary = JSON.parse( + classify(["--event-name", "pull_request", "--target", "develop"]), + ) as Record; + // The tier travels with the plan, so ci-required records it and a reuse can compare it. + expect(ordinary).toMatchObject({ selection: "full", native: true, nativeTier: "reduced" }); + expect( + (ordinary.jobs as Record)["native-platforms"]?.required, + ).toBe(true); + expect(classify(["--event-name", "pull_request", "--target", "develop"], "github")).toContain( + "native_tier=reduced\n", + ); + // A merge group on develop is the queue testing the same tree, so it owes the same tier; ci.yml + // passes the queue's base branch for exactly that reason. + expect( + JSON.parse(classify(["--event-name", "merge_group", "--target", "develop"])), + ).toMatchObject({ nativeTier: "reduced" }); + // main pushes, the nightly, an explicit audit and a pull request into main keep every row. + const full: readonly (readonly [string, string])[] = [ + ["push", ""], + ["schedule", ""], + ["workflow_dispatch", ""], + ["merge_group", "main"], + ["pull_request", "main"], + ]; + for (const [event, target] of full) { + expect( + JSON.parse(classify(["--event-name", event, "--target", target])), + `${event} -> ${target}`, + ).toMatchObject({ nativeTier: "full" }); + } + // A run that owes no native lane owes no rows at all — ci-needs.spec.ts proves that case + // against a real narrowed diff. + }); + + it("reads the tier from the planner instead of deciding it per job", () => { + const gated = [...executable] + .filter(([name, section]) => name !== "scope" && section.includes("native_tier")) + .map(([name]) => name) + .sort(); + // Only the macOS, Windows and iOS legs are not Linux rows. + expect(gated).toEqual(["desktop", "ios-simulator"]); + expect(executable.get("desktop")).toContain("needs.scope.outputs.native_tier == 'full'"); + expect(executable.get("ios-simulator")).toContain("needs.scope.outputs.native_tier == 'full'"); + // A job's `if` cannot read `matrix`, so the arm64 row drops through the matrix itself, shaped + // from the same tier: linux-x64 survives a reduced run and both rows survive anything else. The + // x64 row keeps the routing expression every movable Linux job uses (PRD-480). + const scope = executable.get("scope") ?? ""; + expect(scope).toContain("native_tier: ${{ steps.classify.outputs.native_tier }}"); + expect(scope).toContain("starter_rows: ${{ steps.rows.outputs.starter_rows }}"); + expect(scope).toContain( + '{ platform: "linux-x64", runner: "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && \'ubuntu-24.04\' || vars.TN_RUNNER }}"', + ); + expect(scope).toContain('{ platform: "linux-arm64", runner: "ubuntu-24.04-arm" }'); + expect(scope).toContain("rows.slice(0, 1)"); + expect(executable.get("starter-linux")).toContain( + "matrix: ${{ fromJSON(needs.scope.outputs.starter_rows) }}", + ); + expect(job("native-platforms")).toContain("selection_plan: ${{ needs.scope.outputs.plan }}"); + // Every other job is Linux-hosted, so it runs on the reduced tier without asking. + for (const name of [ + "web-reference", + "android-v8-source", + "android-emulator-parity", + "desktop-parity", + "starter-linux", + ]) { + expect(executable.get(name), name).not.toContain("native_tier"); + } + // No lane exempts the full matrix by label: the tier is the planner's answer, and a label is + // not one of its inputs. `release-proof` is the one label exemption PRD-380 phase 1 removed. + const lanes = [...nativeJobs.values()] + .map((section) => + section + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"), + ) + .join("\n"); + expect(lanes).not.toContain("pull_request.labels"); + expect(lanes).not.toContain("native-release-proof"); + }); + + // Run 37100382876 (2026-10-03, `8d7c5741f`) shaped `starter_rows` as a bare array of row objects + // and `starter-linux` never started — no job, no error, and the reusable workflow's aggregate + // still concluded failure, which `ci-required` then reported as a red native lane with every leg + // green. The same two rows on develop's static `include:` produced both jobs, so the emitter has + // to write the shape GitHub expands: a keyed object, `{"include":[…]}`. Evaluating the step for + // each tier is what pins that, rather than asserting the source says so. + it("shapes starter rows GitHub can expand, for every tier the lane is called with", () => { + const scope = nativeJobs.get("scope") ?? ""; + const script = scope.match(/node -e '([\s\S]*?)' >> "\$GITHUB_OUTPUT"/u)?.[1]; + if (script === undefined) throw new Error("scope has no starter_rows step to evaluate"); + const concrete = script.replace(/\$\{\{[\s\S]*?\}\}/gu, "tn-local"); + + const rows = (tier: string): Record[] => { + const run = spawnSync(process.execPath, ["-e", concrete], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, TN_NATIVE_TIER: tier }, + timeout: 5_000, + }); + expect(run.status, run.stderr).toBe(0); + const line = run.stdout.trim(); + expect(line.startsWith("starter_rows="), line).toBe(true); + const matrix = JSON.parse(line.slice("starter_rows=".length)) as Record; + // Never an empty matrix: GitHub expands `include` alone, and `[]` builds no job at all. A run + // that owes no native lane never reaches this job — ci.yml gates the whole call on + // `jobs['native-platforms'].required` — so every tier this job can see owes x64 at minimum. + const include = matrix.include as Record[] | undefined; + expect(Array.isArray(include), JSON.stringify(matrix)).toBe(true); + expect(include?.length ?? 0).toBeGreaterThan(0); + for (const row of include ?? []) + expect(Object.keys(row).sort()).toEqual(["platform", "runner"]); + return include ?? []; + }; + + // The routing expression is the x64 runner's value; the substitution above is only so the step + // parses as JavaScript outside Actions. + expect(scope).toContain( + '{ platform: "linux-x64", runner: "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && \'ubuntu-24.04\' || vars.TN_RUNNER }}"', + ); + // Reduced: x64 alone, on the runner the expression resolves. Full: both rows, arm64 hosted. + expect(rows("reduced")).toEqual([{ platform: "linux-x64", runner: "tn-local" }]); + expect(rows("full")).toEqual([ + { platform: "linux-x64", runner: "tn-local" }, + { platform: "linux-arm64", runner: "ubuntu-24.04-arm" }, + ]); + // An unreadable tier keeps every row rather than dropping the matrix. + expect(rows("")).toEqual(rows("full")); + // The job reads the shaped object, and the x64 leg lands where the expression points. + expect(executable.get("starter-linux")).toContain( + "matrix: ${{ fromJSON(needs.scope.outputs.starter_rows) }}", + ); + expect(executable.get("starter-linux")).toContain("runs-on: ${{ matrix.runner }}"); + // A leg the tier drops is skipped by `if:`, and a skipped leg is neither a failure nor a + // cascade: the two jobs that need `desktop` read `cancelled()`/`always()`, which is why the + // run above kept them green while `desktop` was skipped. + expect(executable.get("desktop")).toContain("needs.scope.outputs.native_tier == 'full'"); + for (const [name, status] of [ + ["performance-coverage", "cancelled()"], + ["networking-matrix", "always()"], + ] as const) { + const section = executable.get(name) ?? ""; + expect(section, name).toContain( + "needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator]", + ); + expect(section, name).toContain(status); + } + }); +}); + describe("PRD-373 fail-closed required verdict", () => { function fullPlan(): Record { const result = spawnSync( @@ -367,7 +566,9 @@ describe("PRD-373 fail-closed required verdict", () => { describe("PRD-373 fixed full candidates and current package products", () => { it("pins every worker checkout to the captured candidate, including reusable native jobs", () => { expect(job("scope")).toContain("vars.TN_DEVELOP_CI_ENABLED == 'true' && 'develop'"); - expect(source).toContain("github.event_name == 'pull_request' && 'latest' || github.run_id"); + expect(source).toContain( + "github.event_name == 'pull_request' && !github.event.pull_request.draft && 'latest' || github.run_id", + ); for (const relative of [".github/workflows/ci.yml", ".github/workflows/native-platforms.yml"]) { const workflow = readFileSync(path.join(repo, relative), "utf8"); for (const [name, section] of jobSections(workflow)) { diff --git a/scripts/__tests__/ci-fast.spec.ts b/scripts/__tests__/ci-fast.spec.ts index d56c6a01d1..35cd203f3f 100644 --- a/scripts/__tests__/ci-fast.spec.ts +++ b/scripts/__tests__/ci-fast.spec.ts @@ -55,11 +55,12 @@ describe("ci-fast bounded hook", () => { it("should run bounded checks when the pre-push hook executes", async () => { const result = await runHook(); expect(result.status, result.output).toBe(0); - expect(result.trace).toEqual([ - "lint", + // Stages run in parallel, so the trace order is not fixed. + expect([...result.trace].sort()).toEqual([ "check:docs", - "sync:agents --check", expect.stringContaining("exec vitest run"), + "lint", + "sync:agents --check", ]); expect(result.trace.join("\n")).not.toMatch(/typecheck|budgets/u); }); @@ -67,7 +68,7 @@ describe("ci-fast bounded hook", () => { it("should fail the hook when lint fails", async () => { const result = await runHook("lint"); expect(result.status, result.output).not.toBe(0); - expect(result.trace[0]).toBe("lint"); + expect(result.trace).toContain("lint"); }); it("should not invoke build, typecheck or budgets when fast checks run", async () => { diff --git a/scripts/__tests__/ci-needs.spec.ts b/scripts/__tests__/ci-needs.spec.ts index 844b046488..e32c583123 100644 --- a/scripts/__tests__/ci-needs.spec.ts +++ b/scripts/__tests__/ci-needs.spec.ts @@ -1,7 +1,9 @@ import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; import { formatJobTimings, formatRunSummary, summaryRows } from "../ci-run-summary.js"; import { ciJobGraph, ciNeedsFindings, declaredNeeds, jobSections } from "../ci-workflow.js"; @@ -327,3 +329,544 @@ describe("PRD-373 measured queue and execution time", () => { expect(summary).not.toContain("0s"); }); }); + +/** + * PRD-481 tree reuse. The key is the whole repository tree, so a tree that changed by one file + * cannot reuse — but a tree is not a validation. The source run also has to prove what this run + * proves: the same or a stronger target tier, the same or a stronger native matrix, every job and + * matrix leg this run requires, and the same runner class. + */ +const REUSED_RUN_ID = 4242; +const SELF_RUN_ID = 1; +const HOSTED = ["ubuntu-latest"]; +const LOCAL = ["self-hosted", "tn-linux"]; + +interface IReuseFixture { + root: string; + /** The commit a previous successful CI run tested. */ + source: string; + /** A later commit carrying the identical tree: the candidate under test. */ + candidate: string; + /** One more source file, so the tree differs by exactly one file. */ + moved: string; + base: string; +} + +/** + * `A` seeds the history, `B` adds a runtime file, `C` is an empty commit on top of it (the same tree + * at a new commit, which is what a re-push or a promotion produces) and `D` adds one more file. + * HEAD is parked back on `C`, so the verdict job's own candidate assertion has something to check. + * `native: true` puts the change under `packages/runtime-native/`, which is what makes the run owe + * the native lane at all — and therefore the tier PRD-380 halves on an ordinary pull request. + */ +function reuseFixture({ native = false }: { native?: boolean } = {}): IReuseFixture { + const root = makeTempDirSync("threenative-tree-reuse-"); + const git = (...args: string[]) => + spawnSync("git", args, { + cwd: root, + encoding: "utf8", + env: { + PATH: process.env.PATH, + GIT_AUTHOR_NAME: "tree reuse fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "tree reuse fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }, + }); + const commit = (contents: Record, message: string) => { + for (const [relative, body] of Object.entries(contents)) { + mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }); + writeFileSync(path.join(root, relative), body); + } + expect(git("add", "-A").status).toBe(0); + expect(git("commit", "--quiet", "-m", message).status).toBe(0); + return git("rev-parse", "HEAD").stdout.trim(); + }; + expect(git("init", "--quiet", "--initial-branch", "develop").status).toBe(0); + const base = commit({ "seed.txt": "seed\n" }, "base"); + const source = commit( + native + ? { + "src/runtime.ts": "export {};\n", + "packages/runtime-native/src/host.cpp": "// host\n", + } + : { "src/runtime.ts": "export {};\n" }, + "runtime", + ); + expect(git("commit", "--quiet", "--allow-empty", "-m", "same tree").status).toBe(0); + const candidate = git("rev-parse", "HEAD").stdout.trim(); + const moved = commit({ "src/other.ts": "export {};\n" }, "one more file"); + expect(git("checkout", "--quiet", "--detach", candidate).status).toBe(0); + return { root, source, candidate, moved, base }; +} + +/** Every check this repository's `ci.yml` declares, matrix legs included. */ +function boardLegs(): string[] { + return [ + "typecheck", + "lint", + "test", + "test-unit (1/3)", + "test-unit (2/3)", + "test-unit (3/3)", + "test-native", + "test-browser", + "test-playtest", + "golden-path-template (starter)", + "template-nonvisual (rain, 1/1)", + "golden-path", + "benchmark", + "build", + "build-artifacts", + "performance-contracts", + "budgets", + "supply-chain", + "native-platforms", + ]; +} + +interface IStubApi { + /** The pull request branch the source run proved. `develop` by default. */ + base?: string; + /** The source run's own event. */ + event?: string; + /** The source run's own `ci-required` conclusion. */ + verdict?: string; + /** Board legs the source run's job list never ran at all. */ + missing?: string[]; + /** + * The source run's `native-platforms` verdict. `skipped` is how a run that owed no native lane + * records itself, and it proves `none` on the native axis rather than `reduced` or `full`. + */ + nativeConclusion?: string; + /** The runner class the source run's board jobs used. */ + sourceLabels?: string[]; + /** The runner class this run routes to. */ + selfLabels?: string[]; + /** A board leg's runner labels, where they differ from the flat class both lists default to. */ + sourceJobLabels?: Record; + /** This run's board legs are queued on these labels, which a queued job already reports. */ + selfJobLabels?: Record; + /** Every read refuses. */ + fail?: boolean; +} + +interface IStubJob { + name: string; + conclusion: string; + labels: string[]; + runner_name: string | null; +} + +function stubJob(name: string, conclusion: string, labels: string[]): IStubJob { + return { + name, + conclusion, + labels, + // A skipped job never gets a runner, which is exactly what the API reports for one. + runner_name: + labels.length === 0 + ? null + : labels.includes("self-hosted") + ? "tn-local-01" + : "GitHub Actions 1", + }; +} + +/** + * The Actions API, stubbed on PATH exactly as `gh` would answer each read: the source run's record, + * its jobs, the pull requests its commit belongs to (which is where its base branch comes from, since + * this repository's run records report no `base_ref`) and this run's own job list. + */ +function fakeActionsApi(fixture: IReuseFixture, api: IStubApi = {}): void { + const bin = path.join(fixture.root, "bin"); + mkdirSync(bin, { recursive: true }); + const sourceLabels = api.sourceLabels ?? HOSTED; + const selfLabels = api.selfLabels ?? HOSTED; + const page = (jobs: IStubJob[]) => ({ total_count: jobs.length, jobs }); + writeFileSync( + path.join(fixture.root, "record.json"), + JSON.stringify({ + id: REUSED_RUN_ID, + event: api.event ?? "pull_request", + // Every run record in this repository reports a null base_ref, so the base is read from the + // commit's pull requests instead. + base_ref: null, + head_sha: fixture.source, + status: "completed", + conclusion: "success", + }), + ); + writeFileSync( + path.join(fixture.root, "jobs.json"), + JSON.stringify( + page([ + stubJob("Change scope", "success", sourceLabels), + ...boardLegs() + .filter((name) => !(api.missing ?? []).includes(name)) + .map((name) => + stubJob( + name, + name === "native-platforms" ? (api.nativeConclusion ?? "success") : "success", + api.sourceJobLabels?.[name] ?? sourceLabels, + ), + ), + stubJob("ci-required", api.verdict ?? "success", sourceLabels), + stubJob("run-summary", "success", sourceLabels), + ]), + ), + ); + writeFileSync( + path.join(fixture.root, "self-jobs.json"), + JSON.stringify( + page([ + stubJob("Change scope", "success", selfLabels), + // A reuse run carries every board leg, skipped by its own `if:`; a named routing override + // states the labels such a leg is queued on, which the API reports before it has a runner. + ...boardLegs().map((name) => { + const labels = api.selfJobLabels?.[name] ?? []; + return stubJob(name, labels.length === 0 ? "skipped" : "queued", labels); + }), + stubJob("ci-required", "in_progress", selfLabels), + stubJob("run-summary", "skipped", []), + ]), + ), + ); + writeFileSync( + path.join(fixture.root, "pulls.json"), + JSON.stringify([{ number: 7, base: { ref: api.base ?? "develop" }, head: { ref: "branch" } }]), + ); + const script = `#!/bin/sh +if [ "$TN_FIXTURE_API_FAIL" = true ]; then echo "gh: HTTP 403: Resource not accessible" >&2; exit 1; fi +case "$*" in + */pulls) cat "$TN_FIXTURE/pulls.json" ;; + *"/runs/$TN_FIXTURE_SELF/jobs"*) cat "$TN_FIXTURE/self-jobs.json" ;; + */jobs*) cat "$TN_FIXTURE/jobs.json" ;; + *workflows*) cat "$TN_FIXTURE/runs.json" ;; + *) cat "$TN_FIXTURE/record.json" ;; +esac +`; + writeFileSync(path.join(bin, "gh"), script); + chmodSync(path.join(bin, "gh"), 0o755); +} + +function listRuns(root: string, runs: { id: number; head_sha: string; conclusion: string }[]) { + writeFileSync(path.join(root, "runs.json"), JSON.stringify({ workflow_runs: runs })); +} + +/** One successful CI run of the tree under test, which is what every reuse case here starts from. */ +function listSourceRun(fixture: IReuseFixture) { + listRuns(fixture.root, [{ id: REUSED_RUN_ID, head_sha: fixture.source, conclusion: "success" }]); +} + +function classifyCandidate( + fixture: IReuseFixture, + head: string, + options: { + event?: string; + target?: string; + apiFail?: boolean; + dayOfWeek?: number; + } = {}, +) { + const result = spawnSync( + process.execPath, + [ + path.join(repo, "scripts/ci-change-scope.mjs"), + "--root", + fixture.root, + "--base", + fixture.base, + "--head", + head, + "--candidate-sha", + head, + "--target", + options.target ?? "develop", + "--event-name", + options.event ?? "pull_request", + // The nightly split is decided from the clock, so the fixture states the day rather than + // waiting for one. + ...(options.dayOfWeek === undefined ? [] : ["--day-of-week", String(options.dayOfWeek)]), + "--format", + "json", + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${path.join(fixture.root, "bin")}:${process.env.PATH}`, + TN_FIXTURE: fixture.root, + TN_FIXTURE_SELF: String(SELF_RUN_ID), + TN_FIXTURE_API_FAIL: options.apiFail === true ? "true" : "false", + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "three-native/fixture", + GITHUB_RUN_ID: String(SELF_RUN_ID), + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Record; +} + +function verifyReusedPlan(fixture: IReuseFixture, plan: Record) { + const jobs = plan.jobs as Record; + const needs = { + scope: { result: "success", outputs: { plan: JSON.stringify(plan) } }, + ...Object.fromEntries(Object.entries(jobs).map(([name]) => [name, { result: "skipped" }])), + }; + return spawnSync(process.execPath, [path.join(repo, "scripts/ci-required.mjs")], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${path.join(fixture.root, "bin")}:${process.env.PATH}`, + TN_CI_NEEDS: JSON.stringify(needs), + // The merge queue targets develop and reports no base ref. A pull-request event would also + // demand the fixture's exact base/head merge parents, which a one-parent fixture cannot make. + TN_CI_EVENT: "merge_group", + TN_CI_BASE_REF: "", + TN_FIXTURE: fixture.root, + TN_FIXTURE_SELF: String(SELF_RUN_ID), + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "three-native/fixture", + GITHUB_RUN_ID: String(SELF_RUN_ID), + }, + }); +} + +describe("PRD-481 a tree is tested once", () => { + it("reuses a successful run that tested this exact tree, citing its run id", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ + selection: "reused", + reusedRunId: REUSED_RUN_ID, + candidateSha: fixture.candidate, + }); + const jobs = plan.jobs as Record; + expect(Object.values(jobs).some((job) => job.required)).toBe(false); + // The diff touched no native path, so this run owes no rows and its source has to prove none. + expect(plan.nativeTier).toBe("none"); + }); + + it("runs the full board for a tree that changed by one file", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.moved); + expect(plan).toMatchObject({ selection: "full" }); + // The reason has to say the tree was looked up and missed. A full run that silently never + // consulted the API and a full run that consulted it and found nothing read the same. + expect(plan.reason).toContain("tree reuse unavailable"); + }); + + it("runs the full board when the Actions API cannot be read", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate, { apiFail: true }); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("HTTP 403"); + }); + + it("keeps workflow_dispatch an explicit full audit even on a tree hit", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate, { event: "workflow_dispatch" }); + expect(plan).toMatchObject({ selection: "full" }); + // Never even asked: the API answers here, and asking would have found this exact tree. + expect(plan.reason).toContain("workflow_dispatch"); + expect(plan.reason).not.toContain("tree reuse"); + }); + + it("never cites an audit dispatch as the proof a reuse rests on", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture, { event: "workflow_dispatch" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("workflow_dispatch"); + }); + + it("keeps Sunday's nightly full and lets Monday's reuse", () => { + // The nightly's subject is the runner image and the network, neither of which an unchanged + // tree can vouch for. Six days of the week may reuse; Sunday may not. A scheduled run reports no + // base ref, so `--target ""` is what the workflow actually passes it. + const fixture = reuseFixture(); + fakeActionsApi(fixture, { event: "schedule" }); + listSourceRun(fixture); + expect( + classifyCandidate(fixture, fixture.candidate, { + event: "schedule", + target: "", + dayOfWeek: 7, + }), + ).toMatchObject({ selection: "full" }); + expect( + classifyCandidate(fixture, fixture.candidate, { + event: "schedule", + target: "", + dayOfWeek: 1, + }), + ).toMatchObject({ selection: "reused", reusedRunId: REUSED_RUN_ID }); + }); + + it("passes a reused verdict only when the source run passed its own", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + const passed = verifyReusedPlan(fixture, plan); + expect(passed.status, passed.stdout + passed.stderr).toBe(0); + fakeActionsApi(fixture, { verdict: "failure" }); + const failed = verifyReusedPlan(fixture, plan); + expect(failed.status).toBe(1); + expect(failed.stderr).toContain("CI_REQUIRED_SOURCE_NOT_SUCCESS"); + }); +}); + +describe("PRD-481 a reused verdict has to cover this run's validation profile", () => { + it("runs the full board when a develop pull request's pass is cited for a promotion", () => { + // The same tree, a stronger requirement. PRD-380 gives an ordinary pull request a reduced + // native matrix, so its pass is not a promotion's proof. + const fixture = reuseFixture(); + fakeActionsApi(fixture, { base: "develop" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate, { target: "main" }); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("tree reuse unavailable"); + expect(plan.reason).toContain("target develop"); + }); + + it("runs the full board when the source never ran a matrix leg this run requires", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture, { missing: ["template-nonvisual (rain, 1/1)"] }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("template-nonvisual (rain, 1/1)"); + }); + + it("runs the full board when the source ran on a different runner class", () => { + // A git tree does not carry the runner image, so a pass on the owner's machine is not a pass on + // a hosted runner. + const fixture = reuseFixture(); + fakeActionsApi(fixture, { selfLabels: LOCAL }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("this run routes to tn-local"); + }); + + it("never lets a `ci`-selection run stand in for a full requirement", () => { + // A `ci` run concludes the gates that read CI configuration and skips the rest, so it is the + // weakest successful run this workflow can produce. It still tests an exact tree, which is why + // reuse has to refuse it on the jobs it skipped rather than on the tree. + const fixture = reuseFixture(); + fakeActionsApi(fixture); + listSourceRun(fixture); + // Same tree, same target, same runner class — the source concluded only the `ci` lane, so its + // job list carries none of the legs a full requirement demands. + const kept = new Set([ + "typecheck", + "lint", + "budgets", + "test-unit", + "build-artifacts", + "supply-chain", + ]); + fakeActionsApi(fixture, { + missing: boardLegs().filter((name) => !kept.has(name)), + }); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("tree reuse unavailable"); + expect(plan.reason).toMatch(/the source run never ran \S+/u); + }); + + it("reuses a promotion pass for an ordinary develop pull request", () => { + // Stronger covers weaker: the promotion proved the full board on this exact tree. + const fixture = reuseFixture(); + fakeActionsApi(fixture, { base: "main" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "reused", reusedRunId: REUSED_RUN_ID }); + expect(plan.reason).toContain(`CI run ${String(REUSED_RUN_ID)}`); + }); + + it("runs the full board when a reduced native pass is cited for a run that owes the full matrix", () => { + // The same tree and the same target strength, one tier apart: PRD-380 phase 2 gives an ordinary + // pull request only the Linux rows, and a push still owes every one of them. + const fixture = reuseFixture({ native: true }); + fakeActionsApi(fixture, { base: "develop" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate, { event: "push", target: "" }); + expect(plan).toMatchObject({ selection: "full", nativeTier: "full" }); + expect(plan.reason).toContain("proved native reduced, this run requires full"); + }); + + it("runs the full board when the source skipped the lane this pull request owes", () => { + const fixture = reuseFixture({ native: true }); + // A develop source that skipped native-platforms proves nothing about native code. + fakeActionsApi(fixture, { base: "develop", nativeConclusion: "skipped" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full", nativeTier: "reduced" }); + expect(plan.reason).toContain("proved native none, this run requires reduced"); + }); + + it("records the reduced tier in the verdict, so the gate cannot accept a weaker source", () => { + const fixture = reuseFixture({ native: true }); + fakeActionsApi(fixture, { base: "main" }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "reused", nativeTier: "reduced" }); + // The source's lane disappears after the scope job made its verdict: `none` no longer covers + // the Linux rows this run owes, and ci-required re-reads the run instead of trusting the plan. + fakeActionsApi(fixture, { base: "main", nativeConclusion: "skipped" }); + const refused = verifyReusedPlan(fixture, plan); + expect(refused.status).toBe(1); + expect(refused.stderr).toContain("proved native none, this run requires reduced"); + }); +}); + +/** Every board leg routed locally, which is what the ordinary joins run on. */ +function localRouting(): Record { + return Object.fromEntries(boardLegs().map((name) => [name, LOCAL])); +} + +/** `supply-chain` is always hosted; the small joins are always local. A board is mixed by design. */ +function mixedRouting(): Record { + return Object.fromEntries( + boardLegs().map((name) => [name, name === "supply-chain" ? HOSTED : LOCAL]), + ); +} + +describe("PRD-481 the runner class is compared per job, because every board is mixed", () => { + it("reuses a run that passed the same mix of runner classes", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture, { sourceJobLabels: mixedRouting(), selfJobLabels: mixedRouting() }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "reused", reusedRunId: REUSED_RUN_ID }); + }); + + it("runs the full board when one job of the mix ran on another runner class", () => { + const fixture = reuseFixture(); + fakeActionsApi(fixture, { + sourceLabels: LOCAL, + sourceJobLabels: { test: HOSTED }, + selfJobLabels: localRouting(), + }); + listSourceRun(fixture); + const plan = classifyCandidate(fixture, fixture.candidate); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain( + "the source run's test ran on hosted while this run routes to tn-local", + ); + }); +}); diff --git a/scripts/__tests__/ci-structure.spec.ts b/scripts/__tests__/ci-structure.spec.ts index adfd98ad2a..a77e61da7c 100644 --- a/scripts/__tests__/ci-structure.spec.ts +++ b/scripts/__tests__/ci-structure.spec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { makeTempDir } from "../../test-support/temp-dir.js"; import { allTemplates } from "../../test-support/templates.js"; +import { declaredNeeds } from "../ci-workflow.js"; import { parsePerformanceLaneManifest } from "../engine-load-test/report.js"; import { collectorCoverage, @@ -413,11 +414,95 @@ it("approved baseline binds provenance while allowing a fresh candidate", () => }); const workflows = [ ".github/workflows/ci.yml", + ".github/workflows/ci-janitor.yml", ".github/workflows/native-platforms.yml", ".github/workflows/native-release.yml", ".github/workflows/npm-release.yml", ] as const; +/** + * Every workflow file on disk, read off the directory rather than off a hand-kept list of "the ones + * that matter". A workflow file that lands without being named here is a set of triggers, runners + * and required checks nobody in this repository has read, and the audit that found + * `feat/prd-368-persistent-pipeline-cache` still holding a 31-minute job could only see it by + * reading the files. Naming the file here is the review. + */ +const reviewedWorkflows = [ + ".github/workflows/build-quiche-owned.yml", + ".github/workflows/ci.yml", + ".github/workflows/ci-janitor.yml", + ".github/workflows/integration.yml", + ".github/workflows/native-platforms.yml", + ".github/workflows/native-release.yml", + ".github/workflows/npm-release.yml", + ".github/workflows/performance-regression.yml", + ".github/workflows/pipeline-cache.yml", + ".github/workflows/release-candidate.yml", + ".github/workflows/site-docs.yml", +] as const; + +/** + * Triggers that test one commit more than once, or that outlive the pull request they were added for. + * + * A `push` with no `branches:` filter fires for the PR's own head commit as well as for the + * `pull_request` event carrying it, so `integration-csg` ran its proof twice for every commit it had + * a pull request for. Narrowing `push` to `main` is not that defect: it fires for the merge, which is + * a different commit, and both `ci.yml` and `pipeline-cache` rely on it to prove the promotion. What + * is never legitimate is a *feature* branch in a `push` filter — `integration-decals` carried + * `fix/vq11-decal-material-lifetime` for a pull request that merged, and `pipeline-cache` listed the + * branch PRD-368 landed on three weeks earlier, each spending a long native lane on pushes nobody + * reads. + */ +function duplicateCommitTriggers(source: string): readonly string[] { + const triggers = commandText(triggerSection(source)); + const findings: string[] = []; + const push = /^ {2}push:\n(?: {4}.*\n)*/mu.exec(triggers)?.[0] ?? ""; + if (/^ {2}pull_request:/mu.test(triggers) && push !== "" && !/\n {4}branches:/u.test(push)) { + findings.push("push + pull_request: one commit runs this workflow twice"); + } + const inline = /branches: \[([^\]]*)\]/mu.exec(push)?.[1] ?? ""; + const block = /\n {4}branches:\n((?: {6}- .*\n)*)/mu.exec(push)?.[1] ?? ""; + for (const branch of `${inline}\n${block}` + .split("\n") + .map((line) => + line + .replace(/^ {6}- /u, "") + .trim() + .replace(/^["']|["']$/gu, ""), + ) + .filter((name) => name !== "")) { + if (!/^(?:main|develop)$/u.test(branch)) { + findings.push(`push narrowed to ${branch}: the lane outlives its pull request`); + } + } + return findings; +} + +/** Every `paths:` entry in a workflow's triggers, in file order. */ +function triggerPaths(source: string): readonly string[] { + const triggers = commandText(triggerSection(source)); + const entries: string[] = []; + let reading = false; + for (const line of triggers.split("\n")) { + if (/^ {4}paths:/u.test(line)) { + reading = true; + continue; + } + if (!reading) continue; + if (!/^ {6}- /u.test(line)) { + reading = false; + continue; + } + entries.push( + line + .replace(/^ {6}- /u, "") + .trim() + .replace(/^["']|["']$/gu, ""), + ); + } + return entries; +} + function jobSections(source: string): readonly [string, string][] { const jobsIndex = source.indexOf("\njobs:\n"); if (jobsIndex < 0) throw new Error("CI workflow did not include a jobs mapping."); @@ -462,16 +547,23 @@ function triggerSection(source: string): string { } function kvmProvisioning(source: string): readonly string[] { - return source - .split("\n") - .map((line) => line.trim()) - .filter( - (line) => - line.includes('KERNEL=="kvm"') || - line === "| sudo tee /etc/udev/rules.d/99-kvm4all.rules" || - line === "sudo udevadm control --reload-rules" || - line === "sudo udevadm trigger --name-match=kvm", - ); + return ( + source + .split("\n") + .map((line) => line.trim()) + // A trailing `|| true` is not part of the provisioning. native-platforms.yml's copy carries one + // because it is the lane PRD-480 routes onto the `tn-local` container, where no udev runs and + // /sys is read-only, so the commands cannot succeed there — while native-release.yml stays + // hosted and keeps them strict. The two lanes must still provision KVM the same way. + .map((line) => line.replace(/ \|\| true$/u, "")) + .filter( + (line) => + line.includes('KERNEL=="kvm"') || + line === "| sudo tee /etc/udev/rules.d/99-kvm4all.rules" || + line === "sudo udevadm control --reload-rules" || + line === "sudo udevadm trigger --name-match=kvm", + ) + ); } function commandText(section: string): string { @@ -519,6 +611,9 @@ interface IScopeFixture { function isolatedGitEnvironment(): NodeJS.ProcessEnv { const environment = { ...process.env }; for (const variable of [ + // A fixture repo is never a CI run: inherited from the CI job, this turned on the live tree-reuse + // lookup and the plan grew an Actions API error. + "GITHUB_ACTIONS", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", @@ -667,6 +762,15 @@ describe("CI pipeline structure", () => { expect(triggers).not.toMatch(/\n\s{2}(?:push|pull_request|schedule):/u); }); + it("never lets a draft pull request run cancel a ready one", async () => { + const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); + const group = /^concurrency:\n {2}group: (.*)$/mu.exec(ci)?.[1] ?? ""; + // A push then `gh pr ready` fires a synchronize run whose payload still says draft. In the + // shared `latest` group with cancel-in-progress, that skipped run cancelled the real one + // (#403, run 37075825121). Only a non-draft pull request run joins the shared group. + expect(group).toContain("!github.event.pull_request.draft && 'latest'"); + }); + it("does not require skipped performance lanes on a prose-only run", async () => { const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); const summary = requiredJob(ci, "run-summary"); @@ -776,7 +880,7 @@ describe("CI pipeline structure", () => { } }); - it("classifies workflow-only changes as full CI with no prose skip", async () => { + it("classifies a workflow-only change as the `ci` selection, not prose and not full", async () => { const fixture = await scopeFixture(); try { const workflowHead = await commitScopeChange( @@ -786,12 +890,18 @@ describe("CI pipeline structure", () => { "workflow-only change", ); - expect(classifyScope(fixture.root, fixture.base, workflowHead)).toMatchObject({ + const plan = classifyScope(fixture.root, fixture.base, workflowHead, ["--target", "develop"]); + expect(plan).toMatchObject({ files: [".github/workflows/ci.yml"], - reason: '".github/workflows/ci.yml" is a non-Markdown path', - scope: "full", - selection: "full", + selection: "ci", + nativeTier: "none", }); + expect( + Object.entries(plan.jobs as Record) + .filter(([, job]) => job.required) + .map(([name]) => name) + .sort(), + ).toEqual(["budgets", "build-artifacts", "lint", "supply-chain", "test-unit", "typecheck"]); } finally { await removeFixture(fixture.root); } @@ -835,8 +945,12 @@ describe("CI pipeline structure", () => { expect(source, `${relative} cancels its own run`).not.toContain( "actions/runs/$GITHUB_RUN_ID/cancel", ); - // Nothing left here calls the Actions API, so nothing may ask to write to it. - expect(source, `${relative} needs no actions: write`).not.toContain("actions: write"); + // Nothing calls the Actions API to cancel its own run, so nothing may ask to write to it. + // `ci-janitor.yml` is the single named exception: it cancels *other* runs, on a closed PR, + // never from a failure step, and never its own run id — all four pinned in its own case. + if (relative !== ".github/workflows/ci-janitor.yml") { + expect(source, `${relative} needs no actions: write`).not.toContain("actions: write"); + } } // Fail-fast that keeps its evidence: a matrix reports every leg, and an expensive job is @@ -915,6 +1029,135 @@ describe("CI pipeline structure", () => { } }); + // PRD-480. Every Linux job that can move picks its runner from one expression, so the pool is + // changed by setting or deleting a repository variable and nothing else. A job left at a bare + // `ubuntu-latest` still runs, which is exactly why this has to be a spec: the pool comes up and + // the queue does not shrink, and nothing in a run says which half of the board ignored it. + const routing = + "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}"; + const lightRouting = + "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}"; + // The hosted jobs that stay hosted, per workflow. `supply-chain` runs gitleaks through + // `docker run`; a self-hosted container would need the host Docker socket mounted to do that, + // which would hand every job root on the owner's machine. `publish-android-v8` is the release + // publish itself — `permissions: contents: write` and a `gh release upload` — and PRD-480 keeps + // release writes on GitHub's own machines. + const hosted = new Map>([ + // golden-path-template runs hosted: its dev server twice failed to answer on a local slot (run 37089715252). + [".github/workflows/ci.yml", new Set(["golden-path-template", "supply-chain"])], + [ + ".github/workflows/native-platforms.yml", + // publish-android-v8 writes the release; android-emulator-parity is CPU-bound SwiftShader that + // overran its 45-minute budget on a pinned 4-thread slot (run 37082733117). + new Set(["android-emulator-parity", "publish-android-v8"]), + ], + ]); + // PRD-480's light lane: the jobs whose whole work is a script or a summary — no workspace build, + // no `pnpm install` and no test suite. `scope` classifies the diff, `build` and `golden-path` + // assert an upstream verdict, `ci-required` is the merge verdict and `run-summary` writes the + // report; in native-platforms.yml `networking-matrix` is the one join that needs no toolchain at + // all. They join on `tn-local-light` (1 CPU, 2 GB) so a ten-second verdict does not queue behind + // five twenty-minute builds. Both directions are load-bearing and both are asserted below: a + // heavy job here would build the workspace in one core, and a light job off the list would queue + // behind the heavy pool — which is the wait the lane exists to remove. + const light = new Set([ + "build", + "ci-required", + "golden-path", + "networking-matrix", + "paths", + "run-summary", + "scope", + ]); + + // A matrix job routes per row: `runs-on: ${{ matrix.runner }}` is only a pointer, and what decides + // where a leg lands is the `runner:` value inside `strategy.matrix.include`. Both native + // matrices carry hosted rows next to movable ones (`linux-x64` moves, `linux-arm64` cannot), so + // the row is named `job/row` here and that is the key the hosted allow-list would use. + function matrixRunnerRows(job: string, section: string): readonly (readonly [string, string])[] { + const rows: [string, string][] = []; + for (const row of section.split(/^ {10}- /mu).slice(1)) { + const runner = row.match(/^ {12}runner: (.+)$/mu)?.[1]; + if (runner !== undefined) { + const platform = row.match(/^\s*platform: (.+)$/mu)?.[1] ?? runner; + rows.push([`${job}/${platform}`, runner]); + } + } + return rows; + } + + // `starter-linux` has no `include` rows at all: a job's `if` cannot read `matrix`, so the arm64 leg + // an ordinary pull request does not owe is dropped by shaping the matrix in `scope` instead. The + // rows therefore still name their runners, one job earlier. Following the pointer is what stops a + // moved row from escaping the switch by moving — the values are asserted in the same place. + function shapedRunnerRows( + job: string, + section: string, + source: string, + ): readonly (readonly [string, string])[] { + if (!/^\s+matrix: \$\{\{ fromJSON\(needs\.scope\.outputs\./mu.test(section)) return []; + const scope = source.match(/^ {2}scope:\n([\s\S]*?)(?=^ {2}\S)/mu)?.[1] ?? ""; + const rows: [string, string][] = []; + for (const row of scope.matchAll(/\{ platform: "([^"]+)", runner: ("[^"]+") \}/gu)) { + rows.push([`${job}/${row[1] ?? ""}`, (row[2] ?? "").slice(1, -1)]); + } + return rows; + } + + it("routes every movable Linux job through exactly one TN_RUNNER switch", async () => { + const directory = path.join(repo, ".github/workflows"); + const routed = [ + ".github/workflows/ci.yml", + ".github/workflows/native-platforms.yml", + ...(await readdir(directory)) + .filter( + (entry) => + entry === "integration.yml" || + (entry.startsWith("integration-") && entry.endsWith(".yml")), + ) + .map((entry) => `.github/workflows/${entry}`), + ].sort(); + expect(routed.length).toBeGreaterThan(2); + for (const relative of routed) { + const keepHosted = hosted.get(relative) ?? new Set(); + const source = await readFile(path.join(repo, relative), "utf8"); + for (const [job, section] of jobSections(source)) { + const runsOn = section.match(/^\s+runs-on:.*$/mu)?.[0].trim(); + if (runsOn === undefined) continue; + const rows = [...matrixRunnerRows(job, section), ...shapedRunnerRows(job, section, source)]; + const targets: readonly (readonly [string, string])[] = + rows.length > 0 ? rows : [[job, runsOn.replace(/^runs-on: /u, "")]]; + for (const [name, runner] of targets) { + const target = `${relative} ${name}`; + // macOS, Windows and arm64 have no tn-local counterpart, so the expression must never + // appear on one of them. + if (/macos|windows|arm/u.test(runner)) { + expect(runner, `${target} is not Linux`).not.toContain(routing); + expect(runner, `${target} is not Linux`).not.toContain(lightRouting); + continue; + } + const onHeavy = runner === routing; + const onLight = runner === lightRouting; + if (keepHosted.has(name)) { + expect( + onHeavy || onLight, + `${target} is on the hosted allow-list but selects ${runner}`, + ).toBe(false); + continue; + } + expect( + onHeavy || onLight, + `${target} runs on \`${runner}\`: use the TN_RUNNER routing expression, or name it on the hosted allow-list`, + ).toBe(true); + expect( + light.has(name), + `${target} is ${onLight ? "on" : "off"} the light lane: only ${[...light].join(", ")} may select tn-local-light`, + ).toBe(onLight); + } + } + } + }); + // A draft PR spends no runner: every job needs `scope`, so skipping it and the always() gate // skips the board, and `ready_for_review` starts it once the draft is marked ready. it("runs nothing on draft pull requests until they are marked ready", async () => { @@ -926,6 +1169,184 @@ describe("CI pipeline structure", () => { expect(ci).toContain("if: ${{ always() && !github.event.pull_request.draft }}"); }); + // PRD-481 adds a fourth selection and a fourth trigger. Every gate's condition has to name what it + // admits rather than what it excludes: `!= 'prose'` reads a reused tree as "not prose" and runs a + // second full lint on a tree CI already passed. + it("runs the merge group and admits no gate on a reused tree", async () => { + const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); + expect(triggerSection(ci)).toContain("merge_group:\n types: [checks_requested]"); + for (const [name, section] of jobSections(ci)) { + // ci-required is the verdict, not a gate: it runs on `always()` and reads the plan instead. + // scope is the decision that produces the selection. + if (name === "ci-required" || name === "scope") continue; + expect(section, `${name} does not name the selection it admits`).toContain( + "selection == 'full'", + ); + expect(section, `${name} would run on a reused tree`).not.toContain("selection != 'prose'"); + } + // A merge group carries no pull_request object, so `!github.event.pull_request.draft` is true + // for it. That has to stay true, or the queue waits forever on a check nobody will report. + expect(requiredJob(ci, "scope")).toContain("if: ${{ !github.event.pull_request.draft }}"); + expect(requiredJob(ci, "ci-required")).toContain( + "if: ${{ always() && !github.event.pull_request.draft }}", + ); + // Both jobs that consult the Actions API, and only with a read-only token. + for (const name of ["scope", "ci-required"]) { + expect(requiredJob(ci, name)).toContain("actions: read"); + expect(requiredJob(ci, name)).toContain("GH_TOKEN: ${{ github.token }}"); + } + }); + + // The audit behind PRD-481 cost 2.4k runner-minutes on one workflow that was still listed against + // a branch whose lane landed three weeks earlier, and ran a second time for every commit that + // also opened a pull request. Neither shape is visible from the workflow file alone, which is why + // they are stated as rules here rather than left to whoever reads the next one. + it("names every workflow file so none lands unreviewed", async () => { + const onDisk = (await readdir(path.join(repo, ".github/workflows"))) + .filter((name) => /\.ya?ml$/u.test(name)) + .map((name) => `.github/workflows/${name}`) + .sort(); + expect(onDisk).toEqual([...reviewedWorkflows].sort()); + // The owner's rule: no workflow file per feature. A new lane is a job in an existing workflow + // with its own paths gate, not a new set of triggers, permissions and required checks. The + // allow-list above already rejects an unnamed file; this names the shape it rejects. + expect( + onDisk.filter((name) => /integration-[^/]+\.ya?ml$/u.test(name)), + "a per-feature integration workflow is back; add its proof as a job in integration.yml", + ).toEqual([]); + }); + + it("keeps every integration lane in one workflow, each with its own paths gate", async () => { + const integrations = reviewedWorkflows.filter((name) => name.includes("/integration")); + expect( + integrations, + "the integration lanes are one workflow, not one file per feature", + ).toEqual([".github/workflows/integration.yml"]); + const source = await readFile(path.join(repo, ".github/workflows/integration.yml"), "utf8"); + expect(duplicateCommitTriggers(source)).toEqual([]); + // Skipping drafts only saves a runner if the event that ends the draft is one this workflow + // listens for. Without `ready_for_review` the guard turns the lanes off rather than cheap. + expect(triggerSection(source)).toContain( + "types: [opened, synchronize, reopened, ready_for_review]", + ); + const sections = jobSections(source); + // A job behind another job of the same workflow is skipped when that one is, so the guard belongs + // on the entry points only. + for (const [job, section] of sections) { + if (/^ {4}needs:/mu.test(section)) continue; + expect(section, `${job} runs on a draft`).toContain( + "if: ${{ !github.event.pull_request.draft }}", + ); + } + // One gate, one output per lane, and every lane job reads its own. A lane folded in without its + // filter would run on every pull request; a filter with no job behind it would silently stop. + const lanes = [...source.matchAll(/^ {10}lane ([a-z][a-z-]*) '/gmu)].map((match) => match[1]); + expect(lanes.length, "a lane lost its trigger filter").toBeGreaterThanOrEqual(5); + for (const lane of lanes) { + const gated = sections.filter(([, section]) => + section.includes(`needs.paths.outputs.${lane} == 'true'`), + ); + expect(gated.length, `no job runs the ${lane} gate`).toBeGreaterThanOrEqual(1); + for (const [job, section] of gated) { + expect(section, `${job} runs the ${lane} gate on a draft`).toContain( + "!github.event.pull_request.draft", + ); + } + expect(source, `the ${lane} gate is not exposed as a job output`).toContain( + `${lane}: \${{ steps.filter.outputs.${lane} }}`, + ); + } + for (const [job, section] of sections) { + if (job === "paths" || /^ {4}needs:/mu.test(section)) continue; + expect(section, `${job} is not behind a per-lane gate`).toContain("needs: paths"); + } + }); + + it("rejects a push trigger that would fire beside pull_request, or for one branch", () => { + expect( + duplicateCommitTriggers( + [ + "name: fixture", + "on:", + " pull_request:", + " paths:", + " - 'examples/a/**'", + " push:", + " paths:", + " - 'examples/a/**'", + "", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + "", + ].join("\n"), + ), + ).toEqual(["push + pull_request: one commit runs this workflow twice"]); + expect( + duplicateCommitTriggers( + [ + "name: fixture", + "on:", + " pull_request:", + " paths:", + " - 'examples/a/**'", + " push:", + " branches: ['fix/vq11-decal-material-lifetime']", + "", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + "", + ].join("\n"), + ), + ).toEqual([ + "push narrowed to fix/vq11-decal-material-lifetime: the lane outlives its pull request", + ]); + // `push` alone is how a workflow follows its own branch after a merge, and how ci.yml hears + // about main. The rule is the pair, not the trigger. + expect( + duplicateCommitTriggers( + [ + "name: fixture", + "on:", + " push:", + " branches: [main]", + "", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + "", + ].join("\n"), + ), + ).toEqual([]); + }); + + it("keeps the pipeline-cache proof's triggers on what that proof reads", async () => { + const relative = ".github/workflows/pipeline-cache.yml"; + const source = await readFile(path.join(repo, relative), "utf8"); + const commands = commandText(source); + + // Every path it fires on has to be a path its own steps open, or the lane runs a 45-minute + // native proof for a commit that cannot have moved it. The workflow file itself is the one + // exemption: GitHub reads it to decide, and every lane lists itself. + const entries = triggerPaths(source); + expect( + entries.length, + "the pipeline-cache lane stopped narrowing its triggers", + ).toBeGreaterThan(0); + for (const entry of entries) { + if (entry === relative) continue; + expect(commands, `${relative} fires on ${entry}, which its steps never read`).toContain( + entry.replace(/\/?\*\*$/u, "/"), + ); + } + + // `push` on a protected branch only, and never beside `pull_request`. The branch it listed + // until now — `feat/prd-368-persistent-pipeline-cache` — kept compiling an android-arm64 ABI + // job for 41 runs and 2.4k runner-minutes after the lane it was opened for had landed. + expect(duplicateCommitTriggers(source), relative).toEqual([]); + }); + it("preserves main qualification while enabling develop PRs and serializes release lanes", async () => { const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); const npm = await readFile(path.join(repo, ".github/workflows/npm-release.yml"), "utf8"); @@ -933,13 +1354,10 @@ describe("CI pipeline structure", () => { expect(ci).toMatch(/push:\n\s+branches:\n\s+- main/u); expect(ci).toMatch(/pull_request:\n\s+branches:\n\s+- main/u); expect(ci).toContain("group: ci-${{ github.event_name }}-${{ github.ref }}"); - // Main evidence arrives via CI completion, never via the push itself: every - // `workflow_run` run shares `github.ref` (the default branch), so the group also - // keys on the triggering CI head SHA - a new completion for a newer main SHA gets - // its own group instead of queueing behind a superseded evidence run. - expect(native).toContain( - "group: native-release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha }}-${{ github.ref }}", - ); + // Every release proof shares one group now, whatever triggered it: a per-ref or per-SHA key + // is what let a promotion PR, a manual dispatch and a main CI completion hold the pool at + // once. `native-release-proof.spec.ts` owns the setting; this is the structural backstop. + expect(native).toContain("&& 'native-release-proof' ||"); expect(native).toContain("cancel-in-progress: false"); const triggers = triggerSection(native); expect(triggers).toContain("workflow_run:"); @@ -958,6 +1376,108 @@ describe("CI pipeline structure", () => { expect(npm).toContain('gh release view "runtime-native-v${native_version}"'); }); + it("holds every release proof in one cross-branch concurrency group", async () => { + const native = await readFile(path.join(repo, ".github/workflows/native-release.yml"), "utf8"); + const concurrency = triggerSection(native).split("\nconcurrency:\n")[1] ?? ""; + expect(concurrency, "native-release declares no concurrency block").not.toBe(""); + // Exactly one, and carrying nothing event-derived. A `${{ github.ref }}` or + // `${{ github.event.workflow_run.head_sha }}` suffix is a second proof running beside the + // first, which is exactly the starvation this group exists to end (PRD-380: 7.0k + // runner-minutes of PR proof against a pool of about three). + const groups = [...concurrency.matchAll(/^\x20{2}group: (.*)$/gmu)].map((match) => match[1]); + // Proof-eligible runs share `native-release-proof`; every other run (an ordinary PR push that + // `gates` refuses) gets a group of its own so it can never displace a pending proof. + expect(groups).toEqual([ + "${{ (github.event_name != 'pull_request' || github.event.pull_request.base.ref == 'main' || contains(github.event.pull_request.labels.*.name, 'release-proof')) && 'native-release-proof' || format('native-release-skip-{0}', github.run_id) }}", + ]); + // `false`, not a conditional: a superseded run still lets its queued consumer finish. + expect(concurrency).toContain("cancel-in-progress: false"); + }); + + it("reruns only a failed CI run's failed jobs, once, and only for known flakes", async () => { + const janitor = await readFile(path.join(repo, ".github/workflows/ci-janitor.yml"), "utf8"); + expect(janitor).toMatch(/workflow_run:\n\s+workflows: \[CI\]\n\s+types: \[completed\]/u); + // Once: a second failure of the same run is a real signal, never retried again. + expect(janitor).toContain("github.event.workflow_run.run_attempt == 1"); + expect(janitor).toContain("github.event.workflow_run.conclusion == 'failure'"); + // Only the failed jobs, and only when every primary failure matches a flake signature. + expect(janitor).toContain('gh run rerun "$RUN_ID" --failed'); + expect(janitor).toMatch(/FLAKE: TN_PLAYTEST_SOFTWARE_DEVICE_LOST\|/u); + expect(janitor).toContain("not a known flake; leaving the run red"); + }); + + it("cancels a closed pull request's runs without injecting its head ref into a shell", async () => { + const janitor = await readFile(path.join(repo, ".github/workflows/ci-janitor.yml"), "utf8"); + // Merged or closed, nothing the head ref is still doing can change the outcome, and those + // runs went on holding runners for another 5.0k runner-minutes over 2026-09-18 to 10-02. + expect(triggerSection(janitor)).toMatch(/pull_request:\n\x20{4}types: \[closed\]/u); + // Cancelling a run is an Actions write; a token that cannot do it reports success while + // cancelling nothing. + expect(janitor).toMatch(/permissions:\n\x20{2}actions: write/u); + const cancel = jobSections(janitor).find(([name]) => name === "cancel")?.[1] ?? ""; + expect(cancel, "ci-janitor declares no cancel job").not.toBe(""); + // The head ref is attacker-controlled branch text: interpolated straight into `run:` it is a + // command injection on a job holding `actions: write`. So is the base ref the merge-group + // prefix is built from, and the number inside it. + expect(cancel).toMatch( + /env:\n(?:\x20{10}[A-Z_]+: .*\n)*\x20{10}HEAD_REF: \$\{\{ github\.event\.pull_request\.head\.ref \}\}/u, + ); + expect(cancel).toMatch( + /\x20{10}BASE_REF: \$\{\{ github\.event\.pull_request\.base\.ref \}\}\n\x20{10}PR_NUMBER: \$\{\{ github\.event\.pull_request\.number \}\}/u, + ); + for (const line of cancel.split("\n")) { + if (/^\x20*#/u.test(line) || !/(?:head[._]ref|base\.ref|\.number)/u.test(line)) continue; + expect( + line, + "a github.* context is interpolated into a shell instead of passed as env", + ).not.toMatch(/run:/u); + } + // A fork's runs belong to another repository; its token cannot cancel them, and trying would + // fail a red check on every fork PR close. + expect(cancel).toMatch( + /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u, + ); + // The promotion PR's head is `develop`: closing it must not cancel develop's own CI runs. + expect(cancel).toMatch( + /head\.ref != 'develop' && github\.event\.pull_request\.head\.ref != 'main'/u, + ); + expect(cancel).toContain('gh run list --repo "$GITHUB_REPOSITORY" --branch "$HEAD_REF"'); + expect(cancel).toContain("--json databaseId,status"); + // A merge-group run reports its branch as `gh-readonly-queue//pr--`. GitHub + // drops the pull request from the queue on close but never cancels the run it already started, + // so #404's run 37093098790 held nine self-hosted jobs after #404 had merged: the head ref's + // cancel never reaches it. The prefix is built from the event's own base ref and number, so the + // sweep reaches that pull request's group and no other. + expect(cancel).toContain('group_prefix="gh-readonly-queue/${BASE_REF}/pr-${PR_NUMBER}-"'); + expect(cancel).toContain("startswith($prefix)"); + expect(cancel).toContain( + 'gh run list --repo "$GITHUB_REPOSITORY" --json databaseId,status,headBranch --limit 100', + ); + // The run-sweeping predecessor this replaced reached across every branch in the repository; + // every cancel here is scoped to the event's own repository and the closed PR's own ref. + expect(cancel).toContain( + 'gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/force-cancel"', + ); + // Never a failure handler: cancelling from the gate that went red is what erased which + // gate went red, on 2026-09-02. + expect(cancel, "the janitor must not be a failure handler").not.toContain("failure()"); + // Only the runs still holding a runner, and never the janitor's own. + for (const status of ["queued", "in_progress"]) { + expect(cancel).toContain(`.status == "${status}"`); + } + expect(cancel).toMatch(/SELF_RUN_ID/gu); + expect(cancel).toMatch(/actions\/runs\/\$run_id\/force-cancel/u); + // A run that finished between the list and the cancel is the expected race, not a failure: + // the cancel is a guarded command, so its non-zero exit is reported rather than propagated. + expect(cancel).toContain( + 'if gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/force-cancel"', + ); + // And the lookup itself fails closed, so a broken call cannot report success having + // cancelled nothing. + expect(cancel).toContain("set -euo pipefail"); + expect(cancel).toMatch(/runs="\$\(gh run list/u); + }); + // A `gh` call infers its repository from a git checkout. A job that never checks out has // none, so `gh` dies with "failed to determine base repo" — and a gate that dies is a gate // that never asked its question. `native-release.yml`'s CI gate shipped that way and could @@ -1146,7 +1666,10 @@ describe("CI pipeline structure", () => { // Runs on every event since 2026-09-01 (owner call): the PR skip reported nothing on the // branch where the regression was written, and the merge that shipped it reported too late. expect(job).not.toContain("github.event_name == 'push'"); - expect(job).not.toContain("pull_request"); + // An `if:` gate naming the event, not any mention of it: the TN_RUNNER routing expression + // (PRD-480) reads `github.event.pull_request.head.repo.fork` in every routed job and gates + // nothing on it. What this forbids is a gate that skips pull requests. + expect(job).not.toMatch(/^\s+if:.*pull_request/mu); expect(job).toContain('TN_PLAYTEST_ALLOW_SOFTWARE: "1"'); expect(job).toContain("non-visual-scenarios.mjs"); expect(job).toContain("threenative-playtest"); @@ -1212,17 +1735,31 @@ describe("CI pipeline structure", () => { expect(job).toContain("non-visual-scenarios.mjs"); }); - it("keeps the measured puzzle lane split into two shards", async () => { + // The counts are measurements, not preferences: each one below is the shard count that puts that + // template's slowest slice inside the run's six-minute leg budget, from the per-scenario + // timestamps in run 37049488719 (puzzle, rain, shooter) and run 37071464562 (sailing, whose two + // slices measured 168s and 467s — one scenario over budget, which is what the third slice is for). + // Raising one without a new measurement is how a lane goes back to eating the run. + it.each([ + ["puzzle", 2], + ["sailing", 3], + ["rain", 5], + ["shooter", 6], + ])("keeps the measured %s lane split into %i shards", async (template, count) => { const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); const job = requiredJob(ci, "template-nonvisual"); - const puzzleShards = [ - ...job.matchAll(/^\s+-\s*\{\s*template:\s*puzzle\s*,\s*shard:\s*"(\d+)\/(\d+)"/gmu), + const shards = [ + ...job.matchAll( + new RegExp( + `^\\s+-\\s*\\{\\s*template:\\s*${template}\\s*,\\s*shard:\\s*"(\\d+)\\/(\\d+)"`, + "gmu", + ), + ), ].map((match) => ({ index: Number(match[1]), count: Number(match[2]) })); - expect(puzzleShards, "puzzle's measured scenario lane must stay split").toEqual([ - { index: 1, count: 2 }, - { index: 2, count: 2 }, - ]); + expect(shards, `${template}'s measured scenario lane must stay split`).toEqual( + Array.from({ length: count }, (_, offset) => ({ index: offset + 1, count })), + ); }); // A shard count above the template's scenario count is a leg that can only ever select nothing. @@ -1359,9 +1896,12 @@ describe("CI pipeline structure", () => { const supplyChain = requiredJob(ci, "supply-chain"); // Markdown-only PRs skip the scan entirely (owner call 2026-09-12): the nightly develop run // and promotions still scan the full git history, so an inert prose PR spends no runner here. + // PRD-481 added the reused tree to the exempt set and the `ci` selection to the admitted one, + // so the condition names every selection it admits instead of the one it refuses. expect(supplyChain).toContain("needs: scope"); - expect(supplyChain).toContain("if: needs.scope.outputs.selection != 'prose'"); - expect(supplyChain).not.toContain("needs.scope.outputs.selection == 'full'"); + expect(supplyChain).toContain( + "if: needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci'", + ); expect(supplyChain).toContain("if: github.event_name != 'pull_request'"); expect(supplyChain).toContain("uses: actions/dependency-review-action@v4"); // ...but the dependency diff itself stays pull_request-only: it needs a base ref and a head @@ -1805,6 +2345,58 @@ describe("CI pipeline structure", () => { ); }); + // Within one run the workspace dist compiled about nine times, at 71-95s each. Every consumer + // declared `needs: scope` only, so all eight started beside the one job that saves the key, all + // eight looked for an entry nobody had published yet, and all eight compiled it themselves. The + // action grows a mode that takes this run's upload instead; a job that keeps the cache path next + // to a producer it is ordered behind is the arrangement that put the nine builds back. + it("compiles the workspace dist once and hands the upload to every consumer", async () => { + const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); + const producer = requiredJob(ci, "build-artifacts"); + expect(producer).toContain("uses: ./.github/actions/workspace-dist"); + expect(producer, "the producer no longer publishes what consumers download").toContain( + "name: workspace-packages", + ); + expect(producer).toMatch(/^\s+packages\/\*\/dist$/mu); + + const shared = "shared-artifact: workspace-packages"; + for (const [name, section] of jobSections(ci)) { + if (name === "build-artifacts") continue; + // The action is what compiles; this job is about which of them compile it themselves. + if (!section.includes("uses: ./.github/actions/workspace-dist")) continue; + // `lint` keeps the cache path on purpose: its dist lane runs on the `instructions` selection, + // where `build-artifacts` is skipped and there is no upload to take. Everything that runs on + // `full` downloads instead. + if (name === "lint") continue; + expect(section, `${name} restores a key nobody saved before it`).toContain( + "needs: [scope, build-artifacts]", + ); + expect(section, `${name} compiles the workspace instead of downloading it`).toContain(shared); + } + + const action = await readFile( + path.join(repo, ".github/actions/workspace-dist/action.yml"), + "utf8", + ); + // Fail closed on the download exactly as on the restore: a partial upload must not be imported + // from, and the producer's step order — build, validate, publish — is what keeps it complete. + expect(action).toContain("Take the compiled workspace from this run's producer"); + expect(action).toContain("uses: actions/download-artifact@v4"); + expect(action).toContain("TN_WORKSPACE_DIST_INCOMPLETE"); + for (const step of [ + "Restore the compiled workspace", + "Build missing workspace bundles", + "Pack current workspace files", + ]) { + const entry = action + .split(/(?=^ {4}- name:)/mu) + .find((block) => block.startsWith(` - name: ${step}\n`)); + expect(entry, `the shared download does not gate ${step}`).toContain( + "inputs.shared-artifact == ''", + ); + } + }); + // Six jobs need `packages/*/dist` and each compiled it from scratch — measured at 49-65s per // job, six times a run. tsup keeps no incremental state, so the output is what gets cached, and // one shared action owns the key so the six cannot drift apart into six different answers about @@ -2902,7 +3494,6 @@ describe("PRD-373 selective feature verification", () => { "packages/physics/src/index.ts", "examples/native-smoke/src/index.ts", "tsconfig.base.json", - ".github/workflows/ci.yml", "templates/topdown/CLAUDE.md", ])( "retains all consumers for %s while exempting native on a clean develop diff", @@ -3127,3 +3718,223 @@ describe("PRD-373 selective feature verification", () => { } }); }); + +/** + * A pull request whose whole diff is CI configuration. The rules that shape it: + * + * - qualifying paths are the workflows, the `ci-*` scripts, their own specs and the runner + * images, plus anything already classified prose or instructions. Everything else keeps + * `full`: `.github/actions/**` is consumed by every board job including the native matrix, + * and a package, a template or a lockfile is the whole point of the board. + * - the merge queue runs the full board before anything lands, so a queue entry, a push, the + * nightly, an explicit audit and a promotion into main are never narrowed — they are the + * places where a tree is qualified rather than a change reviewed. + * - what stays required is what reads the configuration: the classifier itself, lint, typecheck, + * the unit shards (every `ci-*.spec.ts` is one of them), the budgets that hold the invariant, + * the secrets scan, and `build-artifacts` because three of those jobs consume its upload. + */ +const CI_KEPT_JOBS = [ + "budgets", + "build-artifacts", + "lint", + "supply-chain", + "test-unit", + "typecheck", +]; + +const CI_SKIPPED_JOBS = [ + "benchmark", + "build", + "golden-path", + "golden-path-template", + "native-platforms", + "performance-contracts", + "template-nonvisual", + "test", + "test-browser", + "test-native", + "test-playtest", +]; + +describe("a CI-configuration-only pull request", () => { + it.each([ + ".github/workflows/ci.yml", + ".github/workflows/integration.yml", + "scripts/ci-change-scope.mjs", + "scripts/ci-local.sh", + "scripts/ci-workflow.ts", + "scripts/__tests__/ci-needs.spec.ts", + "tools/ci-runners/entrypoint.sh", + "tools/ci-runners/scripts/balance.mjs", + ])("selects `ci` for %s and requires exactly the jobs that read it", async (relative) => { + const fixture = await scopeFixture(); + try { + const head = await commitScopeChange(fixture, relative, "# changed\n", "ci configuration"); + const plan = classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]); + expect(plan).toMatchObject({ selection: "ci", nativeTier: "none" }); + expect( + Object.entries(plan.jobs as Record) + .filter(([, job]) => job.required) + .map(([name]) => name) + .sort(), + ).toEqual(CI_KEPT_JOBS); + } finally { + await removeFixture(fixture.root); + } + }); + + it("keeps one package file in the same diff at full", async () => { + const fixture = await scopeFixture(); + try { + await commitScopeChange(fixture, ".github/workflows/ci.yml", "name: CI\njobs: {}\n", "ci"); + const head = await commitScopeChange( + fixture, + "packages/core/src/change.ts", + "export const changed = true;\n", + "one package file", + ); + const plan = classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]); + expect(plan).toMatchObject({ selection: "full" }); + expect(plan.reason).toContain("packages/core/src/change.ts"); + const jobs = plan.jobs as Record; + expect(Object.values(jobs).filter((job) => job.required).length).toBeGreaterThan(6); + // Every gate the `ci` selection skipped comes back. `native-platforms` is the one a clean + // develop diff legitimately waives, and this diff does not touch a native path. + for (const name of CI_SKIPPED_JOBS.filter((job) => job !== "native-platforms")) { + expect(jobs[name]?.required, name).toBe(true); + } + expect(jobs["native-platforms"]?.required).toBe(false); + } finally { + await removeFixture(fixture.root); + } + }); + + it.each([ + ".github/actions/pnpm/action.yml", + "pnpm-lock.yaml", + "scripts/lib/not-ci.ts", + "scripts/__tests__/something-else.spec.ts", + "packages/create-threenative/templates/starter/src/game.ts", + ])("keeps %s at full, because no CI-config rule covers it", async (relative) => { + const fixture = await scopeFixture(); + try { + const head = await commitScopeChange(fixture, relative, "# changed\n", "not ci config"); + expect( + classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]).selection, + ).toBe("full"); + } finally { + await removeFixture(fixture.root); + } + }); + + // `.github/workflows/*.yml` qualifies, and `native-platforms.yml` is the one that must not: it is + // the matrix's own definition, so narrowing it would exempt the very lane that has to prove it. + it("keeps the native matrix's own workflow at full", async () => { + const fixture = await scopeFixture(); + try { + const head = await commitScopeChange( + fixture, + ".github/workflows/native-platforms.yml", + "name: native\njobs: {}\n", + "native matrix", + ); + expect( + classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]), + ).toMatchObject({ selection: "full", native: true, nativeTier: "reduced" }); + } finally { + await removeFixture(fixture.root); + } + }); + + it("folds a doc or an AGENTS.md edit into the same `ci` selection", async () => { + const fixture = await scopeFixture(); + try { + await commitScopeChange(fixture, ".github/workflows/ci.yml", "name: CI\njobs: {}\n", "ci"); + await commitScopeChange(fixture, "AGENTS.md", "# agents\n", "instructions"); + const head = await commitScopeChange(fixture, "docs/PRDs/inert.md", "# prose\n", "docs"); + expect( + classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]).selection, + ).toBe("ci"); + } finally { + await removeFixture(fixture.root); + } + }); + + it.each([ + [["--event-name", "merge_group", "--target", "develop"]], + [["--event-name", "push", "--target", "develop"]], + [["--event-name", "schedule", "--target", "develop"]], + [["--event-name", "workflow_dispatch", "--target", "develop"]], + [["--target", "main"]], + ])("never narrows %j", async (extra) => { + const fixture = await scopeFixture(); + try { + const head = await commitScopeChange( + fixture, + ".github/workflows/ci.yml", + "name: CI\njobs: {}\n", + "ci configuration", + ); + expect( + classifyScope(fixture.root, fixture.base, head, ["--target", "develop", ...extra]) + .selection, + ).toBe("full"); + } finally { + await removeFixture(fixture.root); + } + }); + + it("runs the kept jobs on `ci` and leaves every other gate on `full` alone", async () => { + const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); + const sections = new Map(jobSections(ci)); + // `scope` produces the selection and `ci-required` reads the plan on `always()`; neither is + // gated by one, so neither can name it. + for (const name of [...CI_KEPT_JOBS, "run-summary"]) { + expect(sections.get(name), name).toContain("needs.scope.outputs.selection == 'ci'"); + } + for (const name of CI_SKIPPED_JOBS) { + expect(sections.get(name), name).not.toContain("needs.scope.outputs.selection == 'ci'"); + expect(sections.get(name), name).toContain("needs.scope.outputs.selection == 'full'"); + } + // `build-artifacts` produces the upload three kept jobs consume; skipping it would leave them + // ordered behind a job that never ran. + for (const name of ["typecheck", "test-unit", "budgets"]) { + expect(declaredNeeds(sections.get(name) ?? ""), name).toContain("build-artifacts"); + } + }); + + it("validates a `ci` plan and rejects one whose kept jobs were flipped", async () => { + const fixture = await scopeFixture(); + try { + const head = await commitScopeChange( + fixture, + ".github/workflows/ci.yml", + "name: CI\njobs: {}\n", + "ci configuration", + ); + const plan = classifyScope(fixture.root, fixture.base, head, ["--target", "develop"]); + const validate = (value: unknown) => + spawnSync( + process.execPath, + [ + path.join(repo, "scripts/ci-change-scope.mjs"), + "--validate-plan", + JSON.stringify(value), + ], + { encoding: "utf8", env: isolatedGitEnvironment() }, + ); + expect(validate(plan).status).toBe(0); + const forged = JSON.parse(JSON.stringify(plan)) as { + jobs: Record; + }; + const unit = forged.jobs["test-unit"]; + expect(unit).toBeDefined(); + (unit as { required: boolean }).required = false; + const refused = validate(forged); + expect(refused.status).toBe(2); + expect(refused.stderr).toContain("CI_SCOPE_INVALID_PLAN"); + } finally { + await removeFixture(fixture.root); + } + }); +}); diff --git a/scripts/__tests__/native-release-proof.spec.ts b/scripts/__tests__/native-release-proof.spec.ts index 7ba662a987..239639b592 100644 --- a/scripts/__tests__/native-release-proof.spec.ts +++ b/scripts/__tests__/native-release-proof.spec.ts @@ -45,13 +45,16 @@ function allowed( refType: string, results: Record = {}, triggerEvent = "push", + pullRequest: { base?: string; labels?: readonly string[] } = {}, ): boolean { const body = job(name).split("\n steps:")[0] ?? ""; const expression = body.match(/\n\x20{4}if: (?:>-\n)?([\s\S]*?)(?=\n\x20{4}[a-z]|$)/u)?.[1]; assert.ok(expression, `missing explicit job condition for ${name}`); const condition = expression .replace(/\$\{\{|\}\}/gu, "") - .replace(/needs\.([\w-]+)/gu, 'needs["$1"]'); + .replace(/needs\.([\w-]+)/gu, 'needs["$1"]') + // `labels.*.name` is GitHub's array filter; the fake event carries the names directly. + .replace(/labels\.\*\.name/gu, "labelNames"); const needs = Object.fromEntries( [ "validate-tag", @@ -73,12 +76,19 @@ function allowed( "cancelled", "always", "startsWith", + "contains", `return Boolean(${condition});`, ); return evaluate( { event_name: event, - event: { workflow_run: { event: triggerEvent } }, + event: { + workflow_run: { event: triggerEvent }, + pull_request: { + base: { ref: pullRequest.base ?? "develop" }, + labelNames: pullRequest.labels ?? [], + }, + }, ref_type: refType, ref: refType === "tag" ? "refs/tags/runtime-native-v0.3.1" : "refs/heads/main", sha, @@ -87,6 +97,7 @@ function allowed( () => false, () => true, (text: string, prefix: string) => text.startsWith(prefix), + (values: unknown, needle: unknown) => Array.isArray(values) && values.includes(needle), ); } @@ -185,8 +196,19 @@ for (const name of [ "clean-consumer-windows", ]) { test(`${name} runs proof despite intentionally skipped publishing dependencies`, () => { + // A promotion PR: PRD-373 retired the `promotion/` ref, so develop -> main is the + // only promotion shape the repository creates. assert.equal( - allowed(name, "pull_request", "branch", { "validate-tag": "skipped", publish: "skipped" }), + allowed( + name, + "pull_request", + "branch", + { "validate-tag": "skipped", publish: "skipped" }, + "push", + { + base: "main", + }, + ), true, ); assert.equal( @@ -199,6 +221,76 @@ for (const name of [ }); } +// The runner pool holds about three runs. `native-release.yml` measured 7.0k runner-minutes +// across 2026-09-18 to 10-02, and almost all of it was proof a pull request push did not need: +// every PR touching one of four paths started a macOS/Windows/Linux/Android board. An ordinary +// push must now start nothing at all, while the four routes that do need the proof keep it. +test("the release proof runs only for a promotion PR, the release-proof label, dispatch or a release tag", () => { + const proof = { "validate-tag": "skipped", publish: "skipped" } as const; + const downstream = { "validate-tag": "skipped", gates: "skipped" } as const; + for (const name of [ + "gates", + "build", + "build-android", + "clean-consumer", + "clean-consumer-windows", + ]) { + assert.equal( + allowed(name, "pull_request", "branch", downstream, "push", { base: "develop" }), + false, + `${name} still runs on an ordinary PR push to develop`, + ); + } + // develop -> main, the promotion PRD-373 defines. + assert.equal(allowed("gates", "pull_request", "branch", proof, "push", { base: "main" }), true); + // The label is the owner's manual route for any other PR. + assert.equal( + allowed("gates", "pull_request", "branch", proof, "push", { + base: "develop", + labels: ["release-proof"], + }), + true, + ); + assert.equal( + allowed("gates", "pull_request", "branch", proof, "push", { base: "develop", labels: ["bug"] }), + false, + "an unrelated label must not buy a release proof", + ); + // Manual dispatch, the main CI completion and a `runtime-native-v*` tag keep their proof. The + // tag route carries its own `validate-tag` gate, so it is asserted with that gate green. + assert.equal(allowed("gates", "workflow_dispatch", "branch", proof), true); + assert.equal(allowed("gates", "workflow_run", "branch", proof), true); + assert.equal(allowed("gates", "push", "tag", { publish: "skipped" }), true); + assert.equal(allowed("gates", "push", "tag", proof), false); +}); + +test("a pull request reaches the proof through an event that can carry its label", () => { + const triggers = workflow.split("\npermissions:")[0] ?? ""; + const pullRequest = + triggers.split("\n pull_request:")[1]?.split("\n workflow_dispatch:")[0] ?? ""; + assert.ok(pullRequest.length > 0, "the workflow declares no pull_request trigger"); + // `labeled` is what makes the label route reachable at all, and the open/reopen events are + // what make a promotion PR reach it. `paths` is gone: a path filter would contradict both. + assert.match(pullRequest, /types: \[[^\]]*labeled/u); + for (const event of ["opened", "synchronize", "reopened"]) { + assert.match(pullRequest, new RegExp(event, "u")); + } + assert.doesNotMatch( + pullRequest, + /\n\x20{4}paths:/u, + "a paths filter re-runs the proof on ordinary pushes to the files it names", + ); +}); + +test("the npm v* release lane still runs its proof", () => { + // npm-release.yml does not call the native proof; it is the other half of "a release still + // spends runners on itself", so narrowing the native trigger must not narrow this one. + const npm = readFileSync(join(root, ".github/workflows/npm-release.yml"), "utf8"); + const triggers = npm.split("\nconcurrency:")[0] ?? ""; + assert.match(triggers, /push:\n\x20{4}tags:\n\x20{6}- "v\*"/u); + assert.match(triggers, /\n\x20{2}workflow_dispatch:/u); +}); + test("native builds and consumers refuse failed dependencies rather than treating always as success", () => { for (const name of ["build", "build-android"]) { assert.equal( @@ -633,27 +725,24 @@ test("the packed consumer job outlasts its measured comparable", () => { ); }); -test("a proof run is not cancelled by the next push to its own branch", () => { +test("at most one release proof holds runners at a time, across branches", () => { const concurrency = workflow.split("\nconcurrency:\n")[1]?.split("\njobs:")[0] ?? ""; assert.ok(concurrency.length > 0, "the workflow declares no concurrency block"); - // Measured over this workflow's entire history: 18 runs, 13 cancelled, 4 failed, zero successes. - // Every cancellation was a pull_request run killed by the next push to the branch. A consumer job - // sitting behind a ~20 minute build matrix cannot survive to completion on an actively-pushed - // branch no matter what its own timeout is, and its evidence is candidate-keyed, so a superseded - // run's output is still valid for the SHA it was produced from. - assert.doesNotMatch( - concurrency, - /cancel-in-progress:\s*\$\{\{[^}]*ref_type[^}]*\}\}/u, - "cancelling every non-tag run is what produced 13 cancellations in 18 runs", - ); - assert.match(concurrency, /cancel-in-progress:\s*false/u); - // A manual proof on main and an automatic one must not evict each other. - assert.match(concurrency, /group:[^\n]*github\.event_name/u); - // Every `workflow_run` run shares `github.ref` (the default branch), so without the - // triggering CI head SHA in the group they all serialize behind each other while the - // Actions queue drains. A new completion for a newer main SHA gets its own group. - assert.match(concurrency, /group:[^\n]*github\.event\.workflow_run\.head_sha/u); - assert.match(concurrency, /group:[^\n]*github\.ref/u); + // Comments are prose about the setting, not the setting: reading them back as configuration + // is how a `github.ref` mentioned in an explanation would fail this for the wrong reason. + const settings = concurrency + .split("\n") + .filter((line) => !/^\x20*#/u.test(line)) + .join("\n"); + // The group used to carry the event name, the triggering CI head SHA and the ref, so a + // promotion PR, a manual dispatch, a tag and every main CI completion each held a runner at + // the same time - and a two-minute join waited behind an hour of proof (PRD-380). One group, + // no cancel: a superseded run still lets its queued consumer finish. + assert.match( + settings, + /^\x20{2}group: \$\{\{ \(.*\) && 'native-release-proof' \|\| format\('native-release-skip-\{0\}', github\.run_id\) \}\}$/mu, + ); + assert.match(settings, /^\x20{2}cancel-in-progress: false$/mu); }); test.each(["schedule", "workflow_dispatch", "pull_request"])( diff --git a/scripts/__tests__/tone-capture-proof.spec.ts b/scripts/__tests__/tone-capture-proof.spec.ts new file mode 100644 index 0000000000..e53c6d2084 --- /dev/null +++ b/scripts/__tests__/tone-capture-proof.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "vitest"; +import type { IPlaytestCaptureProvenance } from "../../packages/playtest/src/index.js"; +import { buildReport } from "../../packages/playtest/src/runner/runner-support.js"; +import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js"; +import { assertToneCaptureDiagnostics } from "../tone-capture-proof.js"; + +function report(variant: "underexposed" | "restored", error?: string) { + const scenario = validatePlaytestScenario( + { + schemaVersion: 1, + name: "tone-proof", + steps: [{ waitTicks: 1 }], + assert: { diagnostics: { noConsoleErrors: true }, tone: [{ mean: { min: 100 } }] }, + }, + "tone.json", + ); + const args: Parameters = [ + { + allowSoftwareAdapter: true, + artifactDirectory: "/tmp/tone-proof", + headless: false, + url: "http://127.0.0.1:5173", + } as Parameters[0], + scenario, + undefined, + undefined, + error === undefined ? [] : [{ type: "error", source: "browser-console", text: error }], + [], + ]; + args[12] = { + adapter: { architecture: "swiftshader", vendor: "google" }, + browserArgs: [], + captureMethod: "page.screenshot", + rendererKind: "webgpu", + target: "web", + viewport: { width: 1280, height: 720 }, + } as IPlaytestCaptureProvenance; + args[20] = [ + { + code: "TN_TONE", + label: "after.png", + mean: variant === "restored" ? 127.5 : 32, + p1: 2, + p50: 127, + p99: 253, + clipFraction: 0, + blackFraction: 0, + }, + ]; + return buildReport(...args); +} + +test.each(["underexposed", "restored"] as const)( + "%s cannot qualify pixels after a downgraded software-device loss", + (variant) => { + const result = report(variant, "WebGPU Device Lost: Instance dropped in popErrorScope"); + expect(result.diagnostics.some(({ code }) => code === "TN_PLAYTEST_SOFTWARE_DEVICE_LOST")).toBe( + true, + ); + expect(result.pass).toBe(variant === "restored"); + expect(() => assertToneCaptureDiagnostics(result.diagnostics, variant)).toThrow( + "TN_PLAYTEST_SOFTWARE_DEVICE_LOST", + ); + }, +); + +test("an expected tone failure cannot hide an unrelated renderer error", () => { + const result = report("underexposed", "TypeError: broken renderer"); + expect(result.pass).toBe(false); + expect(() => assertToneCaptureDiagnostics(result.diagnostics, "underexposed")).toThrow( + "TN_PLAYTEST_CONSOLE_ERROR", + ); +}); + +test.each(["underexposed", "restored"] as const)( + "%s accepts only its clean expected diagnostics", + (variant) => { + const result = report(variant); + expect(result.pass).toBe(variant === "restored"); + expect(() => assertToneCaptureDiagnostics(result.diagnostics, variant)).not.toThrow(); + }, +); + +test("the restored arm cannot accept a tone assertion error", () => { + expect(() => + assertToneCaptureDiagnostics(report("underexposed").diagnostics, "restored"), + ).toThrow("TN_PLAYTEST_TONE_ASSERTION_FAILED"); +}); diff --git a/scripts/__tests__/visual-gate.spec.ts b/scripts/__tests__/visual-gate.spec.ts index f87f0ab7e2..d17ecada6d 100644 --- a/scripts/__tests__/visual-gate.spec.ts +++ b/scripts/__tests__/visual-gate.spec.ts @@ -52,6 +52,39 @@ describe("visual gate", () => { } }); + it("asks for soft shadow-map settings only from a light that casts a shadow", async () => { + const root = await makeTempDir("threenative-visual-shadow-"); + const kit = path.join(root, "shadow-kit"); + try { + await cp(path.resolve("packages/create-threenative/templates/platformer"), kit, { + recursive: true, + }); + const manifest = JSON.parse(await readFile(path.join(kit, "kit.json"), "utf8")) as { + name: string; + }; + await writeFile( + path.join(kit, "kit.json"), + `${JSON.stringify({ ...manifest, name: "shadow-kit" })}\n`, + ); + const lightingPath = path.join(kit, "src/render/lighting.ts"); + const lighting = await readFile(lightingPath, "utf8"); + expect(lighting).toMatch(/castShadow\s*=\s*true/u); + const stripped = lighting.replaceAll("PCFSoftShadowMap", "PCFShadowMap"); + await writeFile(lightingPath, stripped); + const casting = inspectAllTemplates(root).find(({ template }) => template === "shadow-kit"); + expect(casting?.errors).toContain("shadow-kit: lighting.ts is missing PCFSoftShadowMap"); + + await writeFile( + lightingPath, + stripped.replaceAll(/castShadow\s*=\s*true/gu, "castShadow = false"), + ); + const unlit = inspectAllTemplates(root).find(({ template }) => template === "shadow-kit"); + expect(unlit?.errors).not.toContain("shadow-kit: lighting.ts is missing PCFSoftShadowMap"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it("discovers an unregistered broken template and reports its missing render file", async () => { const root = await makeTempDir("threenative-visual-discovery-"); const broken = path.join(root, "unregistered-broken"); diff --git a/scripts/__tests__/workspace-packages.spec.ts b/scripts/__tests__/workspace-packages.spec.ts index 7fa0473257..8fdcbb5cef 100644 --- a/scripts/__tests__/workspace-packages.spec.ts +++ b/scripts/__tests__/workspace-packages.spec.ts @@ -168,6 +168,47 @@ describe("workspace package derivation", () => { ]); }); + it("reads two targeted package builds in unrelated jobs as two builds, not a list", async () => { + // One workflow file now carries every integration lane, and two lanes each build their own + // package. That is two commands; the drift this gate exists for is one command naming a set. + const root = await fixtureRoot(); + await mkdir(path.join(root, ".github/workflows"), { recursive: true }); + await writeFile( + path.join(root, ".github/workflows/integration.yml"), + [ + "jobs:", + " decals:", + " steps:", + " - run: pnpm --filter @threenative/runtime-native native:build", + " tone:", + " steps:", + " - run: pnpm --filter @threenative/playtest build", + ].join("\n"), + ); + + expect(findLiteralPackageEnumerationViolations(root)).toEqual([]); + }); + + it("still reads one run block naming two packages as a hand-listed set", async () => { + const root = await fixtureRoot(); + await mkdir(path.join(root, ".github/workflows"), { recursive: true }); + await writeFile( + path.join(root, ".github/workflows/integration.yml"), + [ + "jobs:", + " tone:", + " steps:", + " - run: |", + " pnpm --filter @threenative/playtest build", + " pnpm --filter @threenative/physics build", + ].join("\n"), + ); + + expect(findLiteralPackageEnumerationViolations(root)).toEqual([ + ".github/workflows/integration.yml", + ]); + }); + it("detects chained literal package commands on one workflow line", async () => { const root = await fixtureRoot(); await mkdir(path.join(root, ".github/workflows"), { recursive: true }); diff --git a/scripts/api-surface.json b/scripts/api-surface.json index b5741e7428..a1b8b5f725 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -66,6 +66,8 @@ "@threenative/core/world#heightSamplerFromHeightmap": "export function heightSamplerFromHeightmap( terrain: IWorldTerrain, extent: IWorldExtent, data: Uint16Array, ): (x: number, z: number) => number { … }", "@threenative/core/world#loadTerrainSplat": "export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise { … }", "@threenative/core/world#loadWorldHeightmap": "export async function loadWorldHeightmap(url: string): Promise { … }", + "@threenative/core/world#snowDiscFootprint": "export function snowDiscFootprint( radius: number, options: ISnowDiscFootprintOptions = { … }", + "@threenative/core/world#SnowField": "export class SnowField { … }", "@threenative/core/world#TerrainTiles": "export class TerrainTiles extends Object3D implements IComputeDriven { … }", "@threenative/core/world#terrainValidationRequested": "export function terrainValidationRequested(): boolean { … }", "@threenative/core/world#validateWorldPackage": "export function validateWorldPackage( manifest: unknown, options: IWorldPackageValidationOptions, ): { … }", @@ -117,6 +119,7 @@ "@threenative/core#exposeDebug": "export function exposeDebug(name: string, value: unknown): void { … }", "@threenative/core#FlightModel": "export class FlightModel { … }", "@threenative/core#FluidField2D": "export class FluidField2D extends Group { … }", + "@threenative/core#FluidParticles3D": "export class FluidParticles3D extends Group { … }", "@threenative/core#formatSceneWarning": "export function formatSceneWarning(warning: ISceneWarning): string { … }", "@threenative/core#formatSpansWindow": "export function formatSpansWindow(window: ISpanWindow): string { … }", "@threenative/core#formatValidationReport": "export function formatValidationReport(report: IValidationReport): string { … }", @@ -229,8 +232,11 @@ "@threenative/physics/navigation#NavigationRegion3D": "export class NavigationRegion3D { … }", "@threenative/physics/navigation#recast": "export function recast(): NavigationPlugin { … }", "@threenative/physics#Area3D": "export class Area3D { … }", + "@threenative/physics#attachSnowPhysics": "export function attachSnowPhysics(options: ISnowPhysicsOptions): ISnowPhysicsBinding { … }", + "@threenative/physics#boxFootprint": "export function boxFootprint(halfWidth: number, halfDepth: number): ISnowFootprint { … }", "@threenative/physics#buildStaticColliders": "export function buildStaticColliders( context: IStaticColliderContext, root: Object3D, filter?: StaticColliderFilter, ): readonly RigidBody3D[] { … }", "@threenative/physics#Buoyancy3D": "export class Buoyancy3D { … }", + "@threenative/physics#capsuleFootprint": "export function capsuleFootprint(halfHeight: number, radius: number): ISnowFootprint { … }", "@threenative/physics#CharacterBody3D": "export class CharacterBody3D { … }", "@threenative/physics#CollisionShape3D": "export class CollisionShape3D { … }", "@threenative/physics#interactionGroups": "export function interactionGroups(layer: number, mask: number): number { … }", diff --git a/scripts/capability-recall.ts b/scripts/capability-recall.ts index 44f2f01bd9..424a62081d 100644 --- a/scripts/capability-recall.ts +++ b/scripts/capability-recall.ts @@ -122,8 +122,10 @@ const TEMPLATE_NAMES = [ "minimal", "platformer", "racing", + "rain", "sailing", "shooter", + "snow", "starter", "tower-defense", ] as const; diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index d85b9d30f0..7affd6347f 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -26,7 +26,27 @@ export const NATIVE_PATHS = [ function isNativePath(file) { return NATIVE_PATHS.some((pattern) => pattern.test(file)); } -const SELECTIONS = new Set(["full", "prose", "instructions"]); +const SELECTIONS = new Set(["full", "prose", "instructions", "reused", "ci"]); +// CI configuration: the workflows, the scripts that decide and report on the board, their own specs +// and the runner images. A diff made only of these cannot change a line of engine, template or +// package, so the jobs with no consumer for a workflow file are the only thing it can break — and +// the `ci` selection is exactly those. Deliberately narrow: `.github/actions/**` is excluded because +// the native matrix consumes it, and `pnpm-lock.yaml`/`pnpm-workspace.yaml` because they change what +// every job installs. Both keep `full`. +const CI_CONFIG_PATHS = [ + /^\.github\/workflows\/[^/]+\.yml$/u, + /^scripts\/ci-[^/]+\.(?:mjs|sh|ts)$/u, + /^scripts\/__tests__\/ci-[^/]+\.spec\.ts$/u, + /^tools\/ci-runners\//u, +]; +// PRD-481. Reuse replaces a full board only, and only for a tree a successful CI run already +// tested. `workflow_dispatch` is the explicit audit a person asked for, so it stays full; the +// Sunday nightly is always full too, because its subject is the runner image and the network +// rather than the tree. +const REUSE_EVENTS = new Set(["pull_request", "push", "merge_group", "schedule"]); +// Enough recent runs to cover the promotion, the merge queue and a re-push. The cap bounds the +// git work per run; a miss past it is a miss, which runs the board. +const REUSE_CANDIDATES = 30; // No package/template exemption yet: core, playtest, scaffolding, physics, fixtures, toolchains // and dependencies have native consumers. Narrow those only with an explicit dependency proof. const FULL_JOBS = [ @@ -46,38 +66,79 @@ const FULL_JOBS = [ "performance-contracts", "native-platforms", ]; +// Every check the plan can require, in the order the plan declares it. A reused run has to have +// concluded every leg of the set its own profile requires; the reporting jobs are not evidence. +const BOARD_JOBS = [...FULL_JOBS, "lint", "supply-chain"]; +// What a `ci` selection still owes: the gates that read CI configuration. `test-unit` is here +// because the unit shards are where every `ci-*.spec.ts` runs, and `build-artifacts` because +// typecheck, test-unit and budgets are ordered behind its upload — dropping it would leave three +// required jobs waiting on a producer that never ran. +const CI_JOBS = new Set(["typecheck", "test-unit", "budgets", "build-artifacts"]); +// Strength, not identity: a stronger source profile may serve a weaker requirement, never the reverse. +const TARGET_RANKS = { main: 2, develop: 1, other: 0 }; +const NATIVE_RANKS = { full: 2, reduced: 1, none: 0 }; -export function selectionPlan(selection, reason, files = [], candidateSha = "", native = false) { +export function selectionPlan( + selection, + reason, + files = [], + candidateSha = "", + native = false, + reusedRunId = 0, + target = "", +) { const full = selection === "full"; + const reused = selection === "reused"; + const ciLane = selection === "ci"; const checks = { - docs: true, + docs: !reused, instructions: full || selection === "instructions", workspace: full, native: full, templates: full, }; + // A reused plan skips everything, so its exemption is the run that did the work, not a rule + // about what the change touched. + const exemption = (proseReason) => + reused + ? `Reused: CI run ${String(reusedRunId)} already passed this identical whole-repo tree` + : proseReason; const jobs = Object.fromEntries( - FULL_JOBS.map((name) => [ - name, - { - required: full, - reason: full - ? `Full dependency closure: ${reason}` - : `Exempt: ${selection} changes do not modify runtime, package, template or shared build inputs`, - }, - ]), + FULL_JOBS.map((name) => { + // `ci` narrows the board to the gates that read the configuration; it never widens one, so a + // job outside CI_JOBS is exempt exactly as it is for prose. + const required = full || (ciLane && CI_JOBS.has(name)); + return [ + name, + { + required, + reason: full + ? `Full dependency closure: ${reason}` + : required + ? `CI configuration only: ${reason}` + : exemption( + `Exempt: ${selection} changes do not modify runtime, package, template or shared build inputs`, + ), + }, + ]; + }), ); const proseOnly = selection === "prose"; + const exemptLane = proseOnly || reused; jobs.lint = { - required: !proseOnly, - reason: proseOnly - ? "Exempt: a Markdown-only change runs no gate; docs are re-validated on the develop nightly and at promotion" + required: !exemptLane || ciLane, + reason: exemptLane + ? exemption( + "Exempt: a Markdown-only change runs no gate; docs are re-validated on the develop nightly and at promotion", + ) : "Documentation, formatting and selected instruction contracts", }; jobs["supply-chain"] = { - required: !proseOnly, - reason: proseOnly - ? "Exempt: a Markdown-only change runs no gate; secrets and dependency review are re-validated on the develop nightly and at promotion" + required: !exemptLane || ciLane, + reason: exemptLane + ? exemption( + "Exempt: a Markdown-only change runs no gate; secrets and dependency review are re-validated on the develop nightly and at promotion", + ) : "Changed prose can still contain credentials; dependency review remains applicable", }; // The native matrix blocks the merge in exactly three cases: a full selection that touches a @@ -89,8 +150,17 @@ export function selectionPlan(selection, reason, files = [], candidateSha = "", required: nativeRequired, reason: nativeRequired ? "Native evidence blocks the merge: a full selection that touches native code, targets main, or cannot prove from a clean pull request that it avoids native code" - : "Exempt: a clean develop pull request whose diff provably touches no native path; the matrix is skipped rather than awaited or run", + : exemption( + "Exempt: a clean develop pull request whose diff provably touches no native path; the matrix is skipped rather than awaited or run", + ), }; + // PRD-380 phase 2: how much of the matrix this run owes. Decided here, from whether the change + // reached native code plus the branch it targets, and read from the plan by both the reusable + // workflow's job gates and the reuse profile — so a run that owes the Linux rows and a run that + // owes all of them can never disagree about which one this is. A reused plan keeps the `native` + // flag of the full plan it stands in for, because what it owes did not change when it stopped + // running the work itself. + const nativeTier = validationProfile({ baseRef: target, nativeRequired: native }).native; return { version: 1, files, @@ -98,7 +168,10 @@ export function selectionPlan(selection, reason, files = [], candidateSha = "", scope: selection, selection, candidateSha, + reusedRunId, native, + target, + nativeTier, checks, jobs, }; @@ -116,11 +189,18 @@ export function validatePlan(value) { !Array.isArray(value.files) || value.files.some((file) => typeof file !== "string" || !file || file.includes("\0")) || typeof value.native !== "boolean" || + typeof value.target !== "string" || + !["full", "reduced", "none"].includes(value.nativeTier) || + !Number.isInteger(value.reusedRunId) || + value.reusedRunId < 0 || + // A reused plan without a source run, or a source run without a reused selection, is the one + // thing that turns this into a skip with nothing behind it. + (value.selection === "reused") !== value.reusedRunId > 0 || typeof value.candidateSha !== "string" || !/^[0-9a-f]{40}$/u.test(value.candidateSha) ) { throw new Error( - "CI_SCOPE_INVALID_PLAN: missing or malformed selection, paths, native requirement, reason or candidate SHA", + "CI_SCOPE_INVALID_PLAN: missing or malformed selection, paths, native requirement, reason, reused run or candidate SHA", ); } const expected = selectionPlan( @@ -129,8 +209,10 @@ export function validatePlan(value) { value.files, value.candidateSha, value.native, + value.reusedRunId, + value.target, ); - for (const field of ["scope", "checks", "jobs"]) { + for (const field of ["scope", "checks", "jobs", "nativeTier"]) { if (JSON.stringify(value[field]) !== JSON.stringify(expected[field])) { throw new Error(`CI_SCOPE_INVALID_PLAN: ${field} does not match the selected check families`); } @@ -157,6 +239,7 @@ function parseArgs(argv) { ["--format", "format"], ["--validate-plan", "plan"], ["--candidate-sha", "candidateSha"], + ["--day-of-week", "dayOfWeek"], ]); const options = { root: process.cwd(), format: "text" }; for (let index = 0; index < argv.length; index += 1) { @@ -180,6 +263,309 @@ function git(root, args) { return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 1024 * 1024 * 16 }); } +/** The tree a commit resolves to, or null when this repository cannot see that commit at all. */ +function treeOf(root, sha) { + const result = git(root, ["rev-parse", "--verify", "--quiet", `${sha}^{tree}`]); + const tree = result.stdout.trim(); + return result.status === 0 && /^[0-9a-f]{40}$/u.test(tree) ? tree : null; +} + +function ghApi(pathname) { + const repository = process.env.GITHUB_REPOSITORY; + if (repository === undefined || !/^[^/\s]+\/[^/\s]+$/u.test(repository)) { + return { error: "GITHUB_REPOSITORY is missing or malformed" }; + } + // `gh` rather than fetch: it is already on every runner, it reads GH_TOKEN from the job's own + // `github.token`, and one stubbed binary is one thing to assert against. + const result = spawnSync("gh", ["api", `repos/${repository}/${pathname}`], { + encoding: "utf8", + maxBuffer: 1024 * 1024 * 16, + }); + if (result.error !== undefined || result.status !== 0) { + const detail = (result.stderr ?? "").trim().split("\n")[0] ?? ""; + return { error: `the Actions API was unreadable${detail === "" ? "" : `: ${detail}`}` }; + } + try { + return { value: JSON.parse(result.stdout) }; + } catch { + return { error: "the Actions API answered with malformed JSON" }; + } +} + +/** + * The successful CI run that tested this exact tree, newest first. + * + * The key is a whole repository tree read out of git, never a name, label or artifact a job could + * have written: the tree of every recent successful CI run is compared with the candidate's, and a + * run whose commit this clone cannot see is fetched rather than assumed. Anything unreadable — + * no token, no repository, a failed or malformed listing, a commit that will not resolve — is a + * miss, and a miss runs the board. + */ +export function findReusableRun(root, candidateSha) { + const candidateTree = treeOf(root, candidateSha); + if (candidateTree === null) return { error: "the candidate's tree could not be resolved" }; + const listed = ghApi("actions/workflows/ci.yml/runs?status=success&per_page=100"); + if ("error" in listed) return listed; + const runs = listed.value?.workflow_runs; + if (!Array.isArray(runs)) return { error: "the successful-run listing could not be interpreted" }; + let examined = 0; + for (const run of runs) { + const id = run?.id; + const sha = run?.head_sha; + if ( + !Number.isInteger(id) || + id <= 0 || + run?.conclusion !== "success" || + !/^[0-9a-f]{40}$/u.test(sha ?? "") + ) { + return { error: "a listed CI run could not be interpreted" }; + } + if (String(id) === process.env.GITHUB_RUN_ID) continue; + examined += 1; + if (examined > REUSE_CANDIDATES) break; + if (treeOf(root, sha) === candidateTree) return { runId: id, tree: candidateTree }; + // A promotion, a re-push or a queue entry names a commit this full-history clone already has, + // so the fetch below is the exception rather than the rule. + git(root, ["fetch", "--quiet", "--no-tags", "origin", sha]); + if (treeOf(root, sha) === candidateTree) return { runId: id, tree: candidateTree }; + } + return { + error: `none of the ${String(examined)} recent successful CI runs tested this exact tree`, + }; +} + +/** + * The only part of a reuse that git cannot settle. An identical tree is not the same validation: + * PRD-380 gives an ordinary pull request a reduced native matrix and this planner already lets a + * clean develop pull request skip the lane, so the source run has to prove what this run proves — + * the same or a stronger target tier and native tier, every job and matrix leg this run requires, + * and the same runner class for every job. Nothing in a tree carries a repository variable or a + * runner image, so all of it comes back from the Actions API, and every gap is a miss that runs the + * board. + */ +export function sourceVerdict({ runId, current }) { + const record = ghApi(`actions/runs/${runId}`); + if ("error" in record) return { error: record.error, code: "CI_REQUIRED_SOURCE_UNKNOWN" }; + const run = record.value; + if (run?.status !== "completed" || run?.conclusion !== "success") { + return { + error: `CI run ${runId} did not complete successfully`, + code: "CI_REQUIRED_SOURCE_UNKNOWN", + }; + } + // A dispatch is the explicit audit a person asked for, so it is never the evidence a run leans on. + if (!REUSE_EVENTS.has(run.event)) { + return { + error: `CI run ${runId} is a ${String(run.event)} run, which is never a reuse source`, + code: "CI_REQUIRED_SOURCE_COVERAGE", + }; + } + const listed = runJobs(runId); + if ("error" in listed) return { error: listed.error, code: "CI_REQUIRED_SOURCE_UNKNOWN" }; + const jobs = listed.jobs; + const verdict = jobs.find((job) => job.name === "ci-required"); + if (verdict === undefined) { + return { + error: `CI run ${runId} reports no ci-required job`, + code: "CI_REQUIRED_SOURCE_UNKNOWN", + }; + } + if (verdict.conclusion !== "success") { + return { + error: `CI run ${runId} reported ci-required as ${String(verdict.conclusion)}`, + code: "CI_REQUIRED_SOURCE_NOT_SUCCESS", + }; + } + // The native tier is the lane's own evidence, not its presence: a develop pull request that waived + // the lane leaves a skipped `native-platforms` behind, and that proves nothing about native code. + const source = { + profile: validationProfile({ + eventName: run.event, + baseRef: sourceBase(run), + nativeRequired: jobs.some( + (job) => boardName(job.name) === "native-platforms" && job.conclusion === "success", + ), + }), + jobs, + }; + const miss = coverageMiss(current, source); + if (miss !== "") return { error: miss, code: "CI_REQUIRED_SOURCE_COVERAGE" }; + return { succeeded: true, conclusion: verdict.conclusion, profile: source.profile }; +} + +/** + * What this run demands of a source: its profile, the runner class each of its jobs routes to, and + * the board jobs and matrix legs its own job graph carries. `exempt` names the board jobs the plan + * waived — the native lane on a clean develop pull request — so the requirement set is the plan's, + * not the job graph's, because a waived job is still listed, as a skip. + */ +export function currentRun({ eventName, baseRef = "", exempt = [] }) { + const listed = runJobs(process.env.GITHUB_RUN_ID ?? ""); + if ("error" in listed) return listed; + const jobs = listed.jobs; + // Per job, not per run: `supply-chain` is always hosted, the small joins always run on + // `tn-local-light` and the platform legs are hosted, so a board is mixed on purpose and one class + // for the whole run is a routing this check cannot model — which was PRD-480. Labels are known + // before a job starts, so each job's own class is read from its own record and a job that has not + // been assigned a runner yet is left out. + const runnerClasses = new Map(); + for (const job of jobs) { + const value = runnerClass(job); + if (value !== "unknown") runnerClasses.set(job.name, value); + } + return { + profile: validationProfile({ + eventName, + baseRef, + nativeRequired: !exempt.includes("native-platforms"), + }), + runnerClasses, + required: jobs + .map((job) => job.name) + .filter((name) => boardName(name) !== null && !exempt.includes(boardName(name))), + }; +} + +/** Every way a source run can fail to cover what this run requires. An empty string means it can. */ +export function coverageMiss(current, source) { + const weaker = profileMiss(current.profile, source.profile); + if (weaker !== "") return weaker; + for (const name of current.required) { + const ran = source.jobs.find((job) => job.name === name); + if (ran === undefined) return `the source run never ran ${name}`; + if (ran.conclusion !== "success") { + return `the source run's ${name} concluded ${String(ran.conclusion)}`; + } + const on = runnerClass(ran); + // This run's class for that job, or the class most of its known jobs report when the job's own is + // unassigned — a reuse run skips every leg it replaces, and a skipped leg reports no runner. That + // stand-in is the only routing this run can prove for such a job, so a leg that disagrees with it + // is a miss, and one that agrees is judged by the same fail-closed rule. + const routes = current.runnerClasses.get(name) ?? dominantClass(current.runnerClasses); + if (on === "unknown" || on !== routes) { + return `the source run's ${name} ran on ${on} while this run routes to ${routes}`; + } + } + return ""; +} + +/** The class most of a run's known jobs report, `unknown` when none does. A tie keeps the first seen. */ +function dominantClass(classes) { + const counts = new Map(); + for (const value of classes.values()) counts.set(value, (counts.get(value) ?? 0) + 1); + let best = "unknown"; + for (const [value, count] of counts) if (count > (counts.get(best) ?? 0)) best = value; + return best; +} + +/** + * The validation profile a run has to prove. `main` is a promotion or a main push, `develop` an + * ordinary pull request or the merge queue on develop, `other` any run whose target the API does not + * report — which ranks lowest, because an unproven target never stands in for a proven one. The + * native tier follows the target: an ordinary pull request owes the reduced matrix PRD-380 gives it, + * everything else the full one, and a run owing no lane owes none. + */ +export function validationProfile({ eventName, baseRef = "", nativeRequired = false }) { + const target = + baseRef === "main" + ? "main" + : baseRef === "develop" || eventName === "merge_group" + ? "develop" + : "other"; + return { target, native: nativeRequired ? (target === "develop" ? "reduced" : "full") : "none" }; +} + +/** Equal or stronger, axis by axis. Weaker on either axis is a miss. */ +function profileMiss(current, source) { + for (const [axis, ranks] of [ + ["target", TARGET_RANKS], + ["native", NATIVE_RANKS], + ]) { + if ((ranks[source[axis]] ?? -1) < (ranks[current[axis]] ?? -1)) { + return `the source run proved ${axis} ${String(source[axis])}, this run requires ${String(current[axis])}`; + } + } + return ""; +} + +/** + * Hosted or self-hosted, from the labels and runner name the jobs API reports. Anything else is + * `unknown`, and an unknown class never matches: no tree proves which image ran. + */ +export function runnerClass(job) { + const labels = Array.isArray(job?.labels) ? job.labels.map(String) : []; + if (labels.includes("self-hosted")) { + return labels.some((label) => label.startsWith("tn-")) ? "tn-local" : "self-hosted"; + } + const hosted = [...labels, String(job?.runner_name ?? "")].some( + (label) => /^(?:ubuntu|macos|windows)-/u.test(label) || label.startsWith("GitHub Actions"), + ); + return hosted ? "hosted" : "unknown"; +} + +/** The board job a check belongs to: a matrix leg and a reusable-workflow prefix included. */ +function boardName(name) { + return ( + BOARD_JOBS.find( + (board) => name === board || name.startsWith(`${board} `) || name.startsWith(`${board}/`), + ) ?? null + ); +} + +/** One read of a run's job list. Unreadable, malformed or truncated is a miss. */ +function runJobs(runId) { + if (!/^\d+$/u.test(runId ?? "")) return { error: "the run id is missing or malformed" }; + const listed = ghApi(`actions/runs/${runId}/jobs?per_page=100`); + if ("error" in listed) return listed; + const jobs = listed.value?.jobs; + if ( + !Array.isArray(jobs) || + jobs.some((job) => typeof job?.name !== "string" || job.name === "") + ) { + return { error: `CI run ${runId}'s job listing could not be interpreted` }; + } + // A truncated page would silently shrink the requirement set, so `total_count` has to agree with it. + if ((listed.value?.total_count ?? 0) > jobs.length) { + return { error: `CI run ${runId} reports more jobs than this read returns` }; + } + return { jobs }; +} + +/** + * The branch a source run had to pass. `base_ref` is null on every run record in this repository, + * so a pull request's base comes from the pull requests its commit belongs to. Nothing readable is + * no base, which ranks as `other` and therefore can never stand in for a promotion. + */ +function sourceBase(run) { + if (typeof run?.base_ref === "string" && run.base_ref !== "") return run.base_ref; + if (run?.event !== "pull_request" || !/^[0-9a-f]{40}$/u.test(run.head_sha ?? "")) return ""; + const listed = ghApi(`commits/${run.head_sha}/pulls`); + if ("error" in listed || !Array.isArray(listed.value)) return ""; + const bases = new Set( + listed.value + .map((pull) => pull?.base?.ref) + .filter((base) => typeof base === "string" && base !== ""), + ); + return bases.size === 1 ? [...bases][0] : ""; +} + +function reuseEligible(options) { + // Never on a developer machine: `pnpm ci:local` runs the work it selected, and a reuse verdict + // there would be a claim about a run that is not happening. + if (process.env.GITHUB_ACTIONS !== "true" || options.full) return false; + if (options.eventName === undefined || !REUSE_EVENTS.has(options.eventName)) return false; + // Sunday's nightly exists to catch drift in the runner image and in network dependencies, which + // an unchanged tree cannot hide. + if (options.eventName === "schedule") return dayOfWeek(options) !== 7; + return true; +} + +/** ISO weekday, 1 = Monday. `--day-of-week` states it so the split above is testable at any hour. */ +function dayOfWeek(options) { + if (options.dayOfWeek !== undefined) return Number(options.dayOfWeek); + return new Date().getUTCDay() || 7; +} + function parseNameStatus(output) { if (!output.endsWith("\0")) return { error: "unterminated name-status records" }; const fields = output.slice(0, -1).split("\0"); @@ -209,6 +595,7 @@ function pathFamily(file, selective) { } if (EXCLUDED_MARKDOWN.some((pattern) => pattern.test(file))) return "a Markdown file consumed by an executable fixture, parser or gate"; + if (CI_CONFIG_PATHS.some((pattern) => pattern.test(file))) return "ci"; // Any Markdown the executable fixtures do not consume is inert: a .md-only PR runs no CI job. // Doc links, evidence budgets and secret scans are re-validated on the develop nightly run and // at promotion. AGENTS.md/CLAUDE.md are instruction consumers and are handled above. @@ -255,9 +642,19 @@ export function classify(options) { options.candidateSha ?? git(options.root, ["rev-parse", "HEAD"]).stdout.trim(); // A full selection reached without a resolved pull-request diff cannot prove the change avoids // native code, so it is native-blocking by default. Only the clean-diff path below may clear it. + const target = options.target ?? ""; const full = (reason, files = [], native = true) => - selectionPlan("full", reason, files, candidateSha, native); + reuseOrKeep( + selectionPlan("full", reason, files, candidateSha, native, 0, target), + options, + candidateSha, + ); if (options.full) return full("explicit full verification requested"); + // Every event except a pull request stays full, and so does every target but develop. A merge + // group is the queue testing the exact tree a merge would produce, so it runs the whole board + // before anything lands — the narrowings below are for reviewing a change, never for qualifying a + // tree, which is why the queue, a push, the nightly, an explicit audit and a promotion into main + // are all excluded here rather than narrowed further downstream. if (options.eventName !== undefined && options.eventName !== "pull_request") return full(`event ${JSON.stringify(options.eventName)} requires complete verification`); if (options.target !== undefined && options.target !== "develop") @@ -273,19 +670,82 @@ export function classify(options) { } const parsed = changedPaths(options); if ("error" in parsed) return full(parsed.error); - // Computed over the whole diff: a native file sorted after a non-native one must still block. - const touchesNative = parsed.paths.some(isNativePath); + // `native-platforms.yml` is the one workflow the CI-configuration rule would otherwise narrow, and + // it is the matrix's own definition: exempting it would waive the very lane that has to prove the + // change. Any native path therefore keeps `full`, not merely `native: true`. Computed over the + // whole diff — a native file sorted after a non-native one must still block. + const native = parsed.paths.find(isNativePath); + if (native !== undefined) return full(`${JSON.stringify(native)} is a native path`, parsed.paths); + const narrowed = diffSelection(parsed.paths, options.target === "develop"); + if (narrowed.blocked !== undefined) + return full(`${JSON.stringify(narrowed.blocked)} is ${narrowed.family}`, parsed.paths, false); + const reason = `all ${String(parsed.paths.length)} changed path(s) match explicit ${[...narrowed.families].sort().join(" + ")} dependency rules`; + return selectionPlan(narrowed.selection, reason, parsed.paths, candidateSha, false, 0, target); +} + +/** + * The selection a complete diff earns, or the first path that keeps the whole board. Every path has + * to be in an accepted family: one `packages/` file sorted after a workflow still decides the board. + * Prose is already covered by lint's doc lane, so prose plus instructions is `instructions`; `ci` + * outranks both — it is the narrower set by a wide margin, so a diff mixing a workflow with a doc + * must not drop typecheck or the unit shards to gain lint alone. + */ +function diffSelection(paths, selective) { const families = new Set(); - for (const file of parsed.paths) { - const family = pathFamily(file, options.target === "develop"); - if (!["prose", "instructions"].includes(family)) - return full(`${JSON.stringify(file)} is ${family}`, parsed.paths, touchesNative); + for (const file of paths) { + const family = pathFamily(file, selective); + if (!["prose", "instructions", "ci"].includes(family)) return { blocked: file, family }; families.add(family); } - // Prose is already covered by lint's doc lane, so prose plus instructions is `instructions`. - const selection = families.has("instructions") ? "instructions" : "prose"; - const reason = `all ${String(parsed.paths.length)} changed path(s) match explicit ${[...families].sort().join(" + ")} dependency rules`; - return selectionPlan(selection, reason, parsed.paths, candidateSha, false); + return { + families, + selection: families.has("ci") ? "ci" : families.has("instructions") ? "instructions" : "prose", + }; +} + +/** + * PRD-481: the one place a full board may be replaced. A narrowed plan is already cheaper than a + * reused one and does not consult the API at all. + */ +function reuseOrKeep(plan, options, candidateSha) { + if (plan.selection !== "full" || !reuseEligible(options)) return plan; + const found = findReusableRun(options.root, candidateSha); + // Fail closed means run the work. The reason carries why, so a run that ran the board for no + // stated reason cannot be confused with one that never looked. + const unavailable = (error) => + selectionPlan( + "full", + `${plan.reason}; tree reuse unavailable: ${error}`, + plan.files, + candidateSha, + plan.native, + 0, + plan.target, + ); + if (!("runId" in found)) return unavailable(found.error); + const current = currentRun({ + eventName: options.eventName, + baseRef: options.target ?? "", + // The plan states exactly which checks this run owes, so a source cannot be credited with + // standing in for a lane this run itself waived. + exempt: Object.entries(plan.jobs) + .filter(([, job]) => !job.required) + .map(([name]) => name), + }); + if ("error" in current) return unavailable(current.error); + const verdict = sourceVerdict({ runId: found.runId, current }); + if (!("succeeded" in verdict)) return unavailable(verdict.error); + return selectionPlan( + "reused", + `whole-repo tree ${found.tree} already passed in CI run ${String(found.runId)}, which proves a ${verdict.profile.target}/${verdict.profile.native} profile on this run's per-job runner classes`, + plan.files, + candidateSha, + // What this run owes did not change when it stopped running the work itself, so the tier + // travels with the verdict: a reused develop pull request still owes the Linux rows. + plan.native, + found.runId, + plan.target, + ); } function output(result, format) { @@ -293,11 +753,13 @@ function output(result, format) { if (format === "github") { for (const key of ["scope", "selection", "reason"]) console.log(`${key}=${result[key]}`); console.log(`candidate_sha=${result.candidateSha}`); + console.log(`reused_run_id=${result.reusedRunId}`); + console.log(`native_tier=${result.nativeTier}`); console.log(`plan=${JSON.stringify(result)}`); return; } console.log( - `CI change scope: ${result.scope}\nCandidate: ${result.candidateSha}\nReason: ${result.reason}`, + `CI change scope: ${result.scope}\nCandidate: ${result.candidateSha}\nSource run: ${result.reusedRunId || "none"}\nReason: ${result.reason}`, ); for (const [name, job] of Object.entries(result.jobs)) console.log(`${job.required ? "Required" : "Exempt"}: ${name} — ${job.reason}`); diff --git a/scripts/ci-fast.sh b/scripts/ci-fast.sh index d60d7baa8a..d5b59054e7 100755 --- a/scripts/ci-fast.sh +++ b/scripts/ci-fast.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# The pre-push tier: bounded drift checks, and nothing that needs a full workspace build, GPU or a -# clock. Full local verification remains `pnpm ci:local`. +# The pre-push tier: bounded drift checks, and nothing that needs a fresh workspace build, GPU or a +# clock (three drift specs import packages' dist, so a never-built checkout needs one workspace build first). +# Full local verification remains `pnpm ci:local`. # # Chosen by what actually broke `main` in one session: a spec pinning a template's old contract, # a scaffold hash not recomputed with the bytes it hashes, relative links left dangling by a folder @@ -8,7 +9,9 @@ # those reproduces here in seconds and cost a 15-minute push-and-wait cycle to discover instead. # # Deliberately NOT here: whole-workspace typecheck and budgets, `test`, `test-browser`, -# `test-playtest`, `golden-path`, `visuals`. They are minutes to tens of minutes, and a hook people +# `test-playtest`, `golden-path`, `visuals`, and `template.spec.ts`, whose scaffold typecheck reads the +# packages' built `dist` and so goes red on any checkout whose build is stale (it runs in CI's `test` +# job and `ci:local`). They are minutes to tens of minutes, and a hook people # skip is worse than no hook — it manufactures confidence without providing it. They live in # `pnpm ci:local`, and this bounded hook never proves runtime correctness. set -u @@ -22,23 +25,37 @@ if ! node scripts/ci-change-scope.mjs --event pull_request --target "$target" \ --base "${TN_CI_BASE:-origin/$target}" --head "${TN_CI_HEAD:-HEAD}" --local; then exit 2; fi echo 'ci:fast is bounded drift verification only; use ci:local --affected or --full for selected local checks. Native platforms are not executed here.' +# A fresh worktree has no `dist`, and three drift specs import packages through it: say so plainly +# instead of failing with three "Failed to resolve entry" stacks. +for pkg in assets physics create-threenative; do + [ -d "packages/$pkg/dist" ] || { + echo "ci:fast: packages/$pkg/dist is missing; this checkout needs one workspace build before it can push." >&2 + exit 2 + } +done + declare -a names=() cmds=() add() { names+=("$1"); cmds+=("$2"); } add lint 'pnpm lint' add docs 'pnpm check:docs' add agents 'pnpm sync:agents --check' -add drift 'pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/template.spec.ts packages/create-threenative/__tests__/playtest.spec.ts packages/create-threenative/__tests__/platformer.spec.ts scripts/__tests__/package-list-drift.spec.ts scripts/__tests__/api-surface.spec.ts scripts/__tests__/xvfb.spec.ts scripts/__tests__/ci-structure.spec.ts' +add drift 'pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/playtest.spec.ts packages/create-threenative/__tests__/platformer.spec.ts scripts/__tests__/package-list-drift.spec.ts scripts/__tests__/api-surface.spec.ts scripts/__tests__/xvfb.spec.ts scripts/__tests__/ci-structure.spec.ts' +# Stages share nothing, so they run at once and the hook costs its slowest stage. status=0 +pids=() for index in "${!names[@]}"; do name="${names[$index]}" - printf '%-12s ' "$name" - start=$(date +%s) - if eval "${cmds[$index]}" >"$log_root/$name.log" 2>&1; then - printf 'pass %3ss\n' "$(( $(date +%s) - start ))" - else - printf 'FAIL %3ss %s\n' "$(( $(date +%s) - start ))" "$log_root/$name.log" - status=1 - fi + ( + start=$(date +%s) + if eval "${cmds[$index]}" >"$log_root/$name.log" 2>&1; then + printf '%-12s pass %3ss\n' "$name" "$(( $(date +%s) - start ))" + else + printf '%-12s FAIL %3ss %s\n' "$name" "$(( $(date +%s) - start ))" "$log_root/$name.log" + exit 1 + fi + ) & + pids+=("$!") done +for pid in "${pids[@]}"; do wait "$pid" || status=1; done exit "$status" diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index cb1ba8c019..f7de829d26 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -45,7 +45,7 @@ fi if ! node scripts/ci-change-scope.mjs "${scope_args[@]}" --format json > "$log_root/selection.json"; then exit 2; fi if ! node scripts/ci-change-scope.mjs --validate-plan "$(cat "$log_root/selection.json")"; then exit 2; fi selection="$(node --input-type=module -e 'import {readFileSync} from "node:fs"; console.log(JSON.parse(readFileSync(process.argv[1], "utf8")).selection)' "$log_root/selection.json")" || exit 2 -case "$selection" in full|prose|instructions) ;; *) echo 'TN_CI_LOCAL_INVALID_SELECTION' >&2; exit 2 ;; esac +case "$selection" in full|prose|instructions|ci) ;; *) echo 'TN_CI_LOCAL_INVALID_SELECTION' >&2; exit 2 ;; esac declare -a names=() cmds=() add() { names+=("$1"); cmds+=("$2"); } diff --git a/scripts/ci-required.mjs b/scripts/ci-required.mjs index 17882dc6ad..d600d2a05b 100644 --- a/scripts/ci-required.mjs +++ b/scripts/ci-required.mjs @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { appendFileSync } from "node:fs"; -import { validatePlan } from "./ci-change-scope.mjs"; +import { currentRun, sourceVerdict, validatePlan } from "./ci-change-scope.mjs"; try { const needs = JSON.parse(process.env.TN_CI_NEEDS ?? "null"); @@ -40,14 +40,41 @@ try { ); } } + // PRD-481. The scope job proved the source run tested this exact tree and covered this run's + // profile; what only the API can settle is whether that run's own verdict went green and whether + // it really did conclude every leg, and a reuse this job cannot confirm is not a pass. + let source = { succeeded: true }; + if (plan.reusedRunId > 0) { + const target = process.env.TN_CI_BASE_REF ?? ""; + // A reuse plan states nothing about what was required — every job in it reads exempt — so the + // gate re-checks what policy owes each target, and the plan's tier is that answer: a pull + // request that owes the Linux rows (PRD-380 phase 2) owes them even when the work was reused, so + // a source that skipped the lane cannot stand in for it. + const current = currentRun({ + eventName: process.env.TN_CI_EVENT, + baseRef: target, + exempt: plan.nativeTier === "none" ? ["native-platforms"] : [], + }); + if ("error" in current) throw new Error(`CI_REQUIRED_ROUTING_UNKNOWN: ${current.error}`); + source = sourceVerdict({ runId: plan.reusedRunId, current }); + if ("error" in source) throw new Error(`${source.code}: ${source.error}`); + } const failures = []; const lines = [ "## Required CI verdict", "", `Candidate: \`${plan.candidateSha}\``, `Selection: \`${plan.selection}\` — ${plan.reason}`, + `Native matrix: \`${plan.nativeTier}\``, "", ]; + if (plan.reusedRunId > 0) { + lines.splice( + 3, + 0, + `Reused verdict from CI run \`${String(plan.reusedRunId)}\` (${String(source.profile.target)}/${String(source.profile.native)} profile, ci-required: ${String(source.conclusion)})`, + ); + } for (const [name, job] of Object.entries(plan.jobs)) { const result = needs[name]?.result; lines.push( diff --git a/scripts/ci-runners.sh b/scripts/ci-runners.sh new file mode 100755 index 0000000000..b55d58f55b --- /dev/null +++ b/scripts/ci-runners.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +# The `tn-local` self-hosted runner pool (PRD-480). +# +# scripts/ci-runners.sh up [N] build the image, run N disposable heavy runner containers plus +# one light one, then set the TN_RUNNER and TN_RUNNER_LIGHT +# repository variables so the routing expressions in ci.yml and +# integration-*.yml send Linux jobs here +# scripts/ci-runners.sh down clear both variables first, then stop everything +# scripts/ci-runners.sh status what is set, what is running, what is online +# scripts/ci-runners.sh install one-time operator setup: check prerequisites, create the runner +# worktree, install and start a boot service (Linux, systemd user) +# scripts/ci-runners.sh uninstall stop the service, clear the variables, remove service + worktree +# +# Operator guide: tools/ci-runners/README.md. Contributors need none of this. +# +# `down` deletes the variables before it stops anything, so the kill switch is the same command as +# the teardown: jobs go back to hosted runners the moment it is deleted, whether or not the +# containers are still there. +# +# The operator's token lives in an untracked env file outside the repository and is never printed. +# It reaches the containers only through `docker run --env-file`, and the container's entrypoint +# unsets it before `run.sh` starts, so no job ever sees it. +set -euo pipefail +cd "$(dirname -- "${BASH_SOURCE[0]}")/.." + +IMAGE=tn-ci-runner +LABEL=tn-local +LIGHT_LABEL=tn-local-light +VARIABLE=TN_RUNNER +LIGHT_VARIABLE=TN_RUNNER_LIGHT +ENV_FILE="${TN_RUNNERS_ENV:-${XDG_CONFIG_HOME:-$HOME/.config}/threenative/runners.env}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/threenative/ci-runners" +DEFAULT_SLOTS=6 +THREADS_PER_SLOT=3 +# 6 x 9 GB heavy + 3 x 2 GB light stays under 62 GB even if every slot hits its cap at once. +SLOT_MEMORY=9g +# Cores never given to a slot, so the owner's desktop stays responsive under a full board. +HOST_CORES=2 +ONLINE_TIMEOUT_SECONDS=300 +# The light lane is a quota, not a reservation: one core of whatever a heavy slot is not using, +# 2 GB, and the highest OOM score of any container, so a small join is what gets killed under +# memory pressure rather than a 20-minute build. +LIGHT_SHAPE="--cpus 1 --memory 2g --memory-swap 2g --oom-score-adj 900" +# Several light slots: an ephemeral runner needs 30-60 s to re-register after each job, and every +# board brings ~7 joins, so one light slot serialised every pull request's scope and verdict. +LIGHT_SLOTS=3 +# `/dev/kvm` for the heavy slots, and only where the host has one: `android-emulator-parity` boots a +# checksum-locked APK under `reactivecircus/android-emulator-runner`, which reads the device for +# read and write and otherwise falls back to a software boot. No KVM, no flag — a host without it +# still runs the pool, just without hardware acceleration, which that lane already reports instead +# of asserting. The light lane never gets it: nothing it runs emulates anything. +KVM_SHAPE="" +if [ -e /dev/kvm ]; then + KVM_SHAPE="--device /dev/kvm" +fi + +fail() { + local code="$1" + shift + printf 'TN_CI_RUNNERS: %s\n' "$*" >&2 + exit "$code" +} + +need() { + command -v "$1" >/dev/null 2>&1 || fail 2 "$1 is not on PATH" +} + +# Fail closed before anything is built or started. A pool that came up without a token would +# register nothing and leave TN_RUNNER set for hosted jobs that never arrive. +load_config() { + [ -n "$ENV_FILE" ] || fail 2 "TN_RUNNERS_ENV is empty" + [ -f "$ENV_FILE" ] || fail 2 "no runner env file at $ENV_FILE; set TN_RUNNERS_ENV or create it with RUNNER_ADMIN_TOKEN=..." + set -a + # shellcheck disable=SC1090 # the operator's own untracked file + . "$ENV_FILE" + set +a + # Read once for validation, then drop it: the token travels to the containers through + # `--env-file`, never through this process's environment. + [ -n "${RUNNER_ADMIN_TOKEN:-}" ] || fail 2 "RUNNER_ADMIN_TOKEN is unset or empty in $ENV_FILE" + unset RUNNER_ADMIN_TOKEN +} + +repo_name() { + gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null \ + || fail 2 "gh could not resolve this checkout's owner/repo" +} + +# One slot is a host-side loop, not a compose service: `docker run --rm` gives each job a fresh +# container filesystem and a fresh hostname, which `restart: always` on a long-lived container +# cannot. The loop is what makes the pool survive the exit. `shape` carries the docker resource +# flags word-split on purpose (there is no array to expand through `bash -c`), and `labels` is the +# one label the container registers with — `--no-default-labels` means that label is all it has. +start_slot() { + local slot="$1" state="$2" env_file="$3" repo="$4" shape="$5" labels="$6" + setsid nohup bash -c ' + while [ ! -e "$1/stop" ]; do + docker run --rm \ + --label tn-ci-runner=1 \ + $4 \ + --env-file "$2" \ + -e TN_RUNNER_REPO="$3" \ + -e TN_RUNNER_LABELS="$5" \ + tn-ci-runner || sleep 10 + done + ' _ "$state" "$env_file" "$repo" "$shape" "$labels" >"$state/$slot.log" 2>&1 & + echo "$!" >"$state/$slot.pid" +} + +stop_slots() { + local pid_file + for pid_file in "$STATE_DIR"/*.pid; do + [ -f "$pid_file" ] || continue + kill "$(cat "$pid_file")" 2>/dev/null || true + done + # No `rm -rf` anywhere near a path built from a variable: the stop file is what stops the loops. + : >"$STATE_DIR/stop" + docker ps -q --filter label=tn-ci-runner=1 | xargs -r docker stop +} + +# A runner stopped while idle stays registered offline forever: ephemeral runners deregister only +# after taking a job. Delete those so the runner list shows real capacity. +forget_offline() { + gh api "repos/$1/actions/runners" --paginate --jq \ + '.runners[] | select(.status == "offline") | select(any(.labels[]; .name | startswith("tn-local"))) | .id' \ + 2>/dev/null | while read -r id; do + gh api -X DELETE "repos/$1/actions/runners/$id" >/dev/null 2>&1 || true + done +} + +online_count() { + gh api "repos/$1/actions/runners" --paginate --jq " + [.runners[] | select(.status == \"online\") | select(any(.labels[]; .name == \"$2\"))] | length + " 2>/dev/null || echo 0 +} + +up() { + local slots="${1:-${TN_RUNNERS:-$DEFAULT_SLOTS}}" + case "$slots" in ''|*[!0-9]*) fail 2 "N must be a positive integer, got '$slots'" ;; esac + [ "$slots" -ge 1 ] || fail 2 "N must be at least 1, got '$slots'" + need docker + need gh + load_config + local repo + repo="$(repo_name)" + + forget_offline "$repo" + docker build -t "$IMAGE" tools/ci-runners + mkdir -p "$STATE_DIR" + # A leftover stop file from a previous teardown would make every loop exit before it starts. + rm -f "$STATE_DIR/stop" + + # Pinned CPUs, not a `--cpus N` quota: under a quota `nproc` and os.availableParallelism() still + # report every host CPU, so vitest sized its pool for 24 cores inside a 4-core budget and three + # unit tests timed out (PR #404). Threads are handed out core by core (both hyperthreads of a core + # before the next), THREADS_PER_SLOT to a slot, never touching the HOST_CORES left for the desktop. + # Three threads a slot (nproc 3) rather than four buys a sixth slot from the same 20 threads; most + # CI time is installs, downloads and browser waits, so parallel jobs beat fatter ones. + # ponytail: assumes Linux's usual sibling numbering (CPU k and k + nproc/2 share a core); read + # /sys/devices/system/cpu/cpu*/topology/thread_siblings_list if a host numbers them otherwise. + local half=$(( $(nproc) / 2 )) core threads=() + for core in $(seq 0 $(( half - HOST_CORES - 1 ))); do threads+=("$core" "$(( core + half ))"); done + [ $(( slots * THREADS_PER_SLOT )) -le "${#threads[@]}" ] \ + || fail 2 "$slots slots of $THREADS_PER_SLOT threads need $(( slots * THREADS_PER_SLOT )); this host has ${#threads[@]} after $HOST_CORES cores for the desktop" + local slot cpus first + for slot in $(seq 1 "$slots"); do + first=$(( (slot - 1) * THREADS_PER_SLOT )) + cpus="$(IFS=,; echo "${threads[*]:$first:$THREADS_PER_SLOT}")" + start_slot "slot-$slot" "$STATE_DIR" "$ENV_FILE" "$repo" \ + "--cpuset-cpus $cpus --memory ${SLOT_MEMORY} --memory-swap ${SLOT_MEMORY} --oom-score-adj 800 $KVM_SHAPE" \ + "$LABEL" + done + # One light slot, unpinned and quota-limited, labelled so heavy jobs cannot select it: a 10-second + # join behind five 20-minute builds is the wait this lane exists to remove. It never reserves a + # core, so the check above still describes exactly the cores this pool owns. + for slot in $(seq 1 "$LIGHT_SLOTS"); do + start_slot "light-$slot" "$STATE_DIR" "$ENV_FILE" "$repo" "$LIGHT_SHAPE" "$LIGHT_LABEL" + done + printf '%s heavy + %s light runner container(s) starting; logs in %s\n' "$slots" "$LIGHT_SLOTS" "$STATE_DIR" + + # Only advertise the labels once the runners are really there: TN_RUNNER set with an empty pool + # is a queue that never drains. Both labels wait, because a light runner that never came online + # leaves its jobs queued behind the heavy pool forever. On timeout, take the whole thing back down + # rather than leaving half a pool registered. + local deadline=$(( $(date +%s) + ONLINE_TIMEOUT_SECONDS )) online light_online + while :; do + online="$(online_count "$repo" "$LABEL")" + light_online="$(online_count "$repo" "$LIGHT_LABEL")" + if [ "$online" -ge "$slots" ] && [ "$light_online" -ge "$LIGHT_SLOTS" ]; then break; fi + if [ "$(date +%s)" -ge "$deadline" ]; then + gh variable delete "$VARIABLE" --repo "$repo" 2>/dev/null || true + gh variable delete "$LIGHT_VARIABLE" --repo "$repo" 2>/dev/null || true + stop_slots + fail 1 "$online of $slots $LABEL and $light_online $LIGHT_LABEL runner(s) came online within ${ONLINE_TIMEOUT_SECONDS}s" + fi + sleep 10 + done + + # The balancer owns the routing variables from here: it advertises a label only while that pool + # has an idle runner, so a full pool overflows new jobs to hosted runners instead of queueing them. + setsid nohup bash scripts/ci-runners.sh balance >"$STATE_DIR/balance.log" 2>&1 & + echo "$!" >"$STATE_DIR/balance.pid" + printf '%s %s + %s %s online; the balancer routes jobs here while a runner is idle\n' \ + "$online" "$LABEL" "$light_online" "$LIGHT_LABEL" +} + +# Hosted runners are free for a public repository and run ~20 jobs at once; this pool adds a few +# more. Routing every Linux job here funnelled the team's CI into 5 slots and made boards slower +# (run 37099132853 sat 20 min with 15 jobs queued). So the pool is overflow-in-reverse: a label is +# advertised while it has an idle runner and withdrawn the moment it has none. +balance() { + need gh + local repo want_heavy want_light have_heavy="" have_light="" idle seen_heavy=0 seen_light=0 + repo="$(repo_name)" + while [ ! -e "$STATE_DIR/stop" ]; do + idle="$(gh api "repos/$repo/actions/runners" --paginate --jq \ + '[.runners[] | select(.status == "online" and .busy == false) | .labels[].name] | join(" ")' \ + 2>/dev/null)" || { sleep 20; continue; } + # Off at once when no runner is idle; on only after two polls in a row (~40 s) found one. A runner + # that just re-registered in a saturated pool is idle for seconds before it takes a queued job, and + # that blip must not route more work here. + case " $idle " in *" $LABEL "*) seen_heavy=$((seen_heavy + 1)) ;; *) seen_heavy=0 ;; esac + case " $idle " in *" $LIGHT_LABEL "*) seen_light=$((seen_light + 1)) ;; *) seen_light=0 ;; esac + want_heavy=off; want_light=off + [ "$seen_heavy" -ge 2 ] && want_heavy=on + [ "$seen_light" -ge 2 ] && want_light=on + if [ "$want_heavy" != "$have_heavy" ]; then + if [ "$want_heavy" = on ]; then gh variable set "$VARIABLE" --body "$LABEL" --repo "$repo" + else gh variable delete "$VARIABLE" --repo "$repo" 2>/dev/null || true; fi + have_heavy="$want_heavy"; printf '%s %s=%s\n' "$(date -u +%H:%M:%S)" "$VARIABLE" "$want_heavy" + fi + if [ "$want_light" != "$have_light" ]; then + if [ "$want_light" = on ]; then gh variable set "$LIGHT_VARIABLE" --body "$LIGHT_LABEL" --repo "$repo" + else gh variable delete "$LIGHT_VARIABLE" --repo "$repo" 2>/dev/null || true; fi + have_light="$want_light"; printf '%s %s=%s\n' "$(date -u +%H:%M:%S)" "$LIGHT_VARIABLE" "$want_light" + fi + sleep 20 + done +} + +down() { + need gh + local repo + repo="$(repo_name)" + # Stop the balancer before clearing the variables, or it could re-advertise a pool on its way out. + if [ -f "$STATE_DIR/balance.pid" ]; then kill "$(cat "$STATE_DIR/balance.pid")" 2>/dev/null || true; fi + # Then the variables, so no job is ever routed to a pool that is on its way out. + local variable + for variable in "$VARIABLE" "$LIGHT_VARIABLE"; do + gh variable delete "$variable" --repo "$repo" 2>/dev/null \ + || printf 'TN_CI_RUNNERS: %s was already unset\n' "$variable" >&2 + done + mkdir -p "$STATE_DIR" + stop_slots + forget_offline "$repo" + printf '%s and %s cleared; runners stopping\n' "$VARIABLE" "$LIGHT_VARIABLE" +} + +status() { + need gh + local repo + repo="$(repo_name)" + local variable label + for variable in "$VARIABLE" "$LIGHT_VARIABLE"; do + printf '%s=%s\n' "$variable" "$(gh variable get "$variable" --repo "$repo" 2>/dev/null || echo unset)" + done + printf 'containers: %s\n' "$(docker ps -q --filter label=tn-ci-runner=1 | wc -l | tr -d ' ')" + for label in "$LABEL" "$LIGHT_LABEL"; do + printf 'online %s runners (name, status, busy):\n' "$label" + gh api "repos/$repo/actions/runners" --paginate --jq \ + ".runners[] | select(any(.labels[]; .name == \"$label\")) | \" \\(.name) \\(.status) busy=\\(.busy)\"" \ + 2>/dev/null || echo " none reported" + done +} + +SERVICE=threenative-ci-runners.service +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +# The checkout every worktree belongs to, so the service never runs from a lane that gets deleted. +primary_root() { + dirname -- "$(cd "$(git rev-parse --git-common-dir)" && pwd)" +} + +install() { + command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1 \ + || fail 2 "install needs Linux with a systemd user session; elsewhere run 'up'/'down' by hand" + need docker + need gh + need git + docker info >/dev/null 2>&1 || fail 2 "docker is not usable without sudo; add $USER to the docker group and log in again" + gh auth status >/dev/null 2>&1 || fail 2 "gh is not logged in; run 'gh auth login' first" + load_config + local branch root worktree + branch="$(git config --get threenative.integrationBranch || echo develop)" + root="$(primary_root)" + worktree="$root/.worktrees/ci-runners" + git -C "$root" fetch -q origin "$branch" + if [ ! -d "$worktree" ]; then + git -C "$root" worktree add -q --detach "$worktree" "origin/$branch" + fi + mkdir -p "$UNIT_DIR" + cat >"$UNIT_DIR/$SERVICE" </dev/null)" != yes ]; then + loginctl enable-linger "$USER" 2>/dev/null \ + || printf 'TN_CI_RUNNERS: run "sudo loginctl enable-linger %s" so the pool starts at boot\n' "$USER" >&2 + fi + systemctl --user daemon-reload + systemctl --user enable "$SERVICE" >/dev/null 2>&1 + if systemctl --user is-active --quiet "$SERVICE"; then + printf 'Service already running. Apply changes when no job is running:\n systemctl --user restart %s\n' "$SERVICE" + else + systemctl --user start "$SERVICE" + fi + printf 'Installed: %s runs %s from %s at every boot.\n' "$SERVICE" "origin/$branch" "$worktree" + status +} + +uninstall() { + if [ -f "$UNIT_DIR/$SERVICE" ]; then + systemctl --user disable --now "$SERVICE" >/dev/null 2>&1 || true + rm -f "$UNIT_DIR/$SERVICE" + systemctl --user daemon-reload + fi + down + local worktree + worktree="$(primary_root)/.worktrees/ci-runners" + if [ -d "$worktree" ]; then + git -C "$(primary_root)" worktree remove "$worktree" \ + || printf 'TN_CI_RUNNERS: %s has local changes; remove it yourself once checked\n' "$worktree" >&2 + fi + printf 'Uninstalled; every job now runs on hosted runners.\n' +} + +case "${1:-}" in + up) shift; up "${1:-}" ;; + down) down ;; + status) status ;; + balance) balance ;; + install) install ;; + uninstall) uninstall ;; + *) fail 2 "usage: ci-runners.sh up [N] | down | status | balance | install | uninstall" ;; +esac diff --git a/scripts/fixtures/capability-recall/budget.json b/scripts/fixtures/capability-recall/budget.json index 8ffc7142da..d0ce1c68d0 100644 --- a/scripts/fixtures/capability-recall/budget.json +++ b/scripts/fixtures/capability-recall/budget.json @@ -1,5 +1,5 @@ { - "recallAtK": 0.9027777777777778, + "recallAtK": 0.9195402298850575, "recalledRows": [ "brief.endless-runner.1", "brief.endless-runner.2", @@ -62,13 +62,28 @@ "prd451.lod-pixel-scale", "prd451.frame-budget", "prd451.merge-parts", + "snow.footprints", + "snow.refill", + "snow.compaction", + "snow.ball-drop", + "snow.ball-roll", + "snow.bodies", + "snow.snowfall", + "snow.powder", + "request.snow-glade", + "rain.weather-world", + "rain.noise-volume", + "rain.storm-audio", + "rain.lightning", + "rain.streaks", + "rain.round-time-limit", "request.drive-a-racing-kart", "request.vehicle-suspension-grip", "request.vehicle-pedals-steering", "request.vehicle-rescue-respawn" ], "rejectHits": 16, - "rowCount": 72, + "rowCount": 87, "rowIds": [ "brief.endless-runner.1", "brief.endless-runner.2", @@ -138,13 +153,28 @@ "prd451.lod-pixel-scale", "prd451.frame-budget", "prd451.merge-parts", + "snow.footprints", + "snow.refill", + "snow.compaction", + "snow.ball-drop", + "snow.ball-roll", + "snow.bodies", + "snow.snowfall", + "snow.powder", + "request.snow-glade", + "rain.weather-world", + "rain.noise-volume", + "rain.storm-audio", + "rain.lightning", + "rain.streaks", + "rain.round-time-limit", "request.drive-a-racing-kart", "request.vehicle-suspension-grip", "request.vehicle-pedals-steering", "request.vehicle-rescue-respawn" ], "version": 1, - "zeroResultRate": 0.06944444444444445, + "zeroResultRate": 0.05747126436781609, "unresolvedResultRate": 0, "notOwnedRows": { "request.inventory-system": "inventory", diff --git a/scripts/fixtures/capability-recall/corpus.json b/scripts/fixtures/capability-recall/corpus.json index 59a4478ed3..48dbffc12b 100644 --- a/scripts/fixtures/capability-recall/corpus.json +++ b/scripts/fixtures/capability-recall/corpus.json @@ -97,7 +97,7 @@ }, { "id": "brief.fps.2", - "query": "Vertical field of view 70°, narrowing to 22° while aiming down the sights, and mouse look is half as sensitive while aimed. Pitch is clamped roughly −66° to +72°.", + "query": "Vertical field of view 70\u00b0, narrowing to 22\u00b0 while aiming down the sights, and mouse look is half as sensitive while aimed. Pitch is clamped roughly \u221266\u00b0 to +72\u00b0.", "scope": "mechanic", "source": "brief:fps#2", "expect": ["defineGame"], @@ -121,7 +121,7 @@ }, { "id": "brief.fps.5", - "query": "One rifle, always equipped. **One round per press** — holding the fire input does not repeat.", + "query": "One rifle, always equipped. **One round per press** \u2014 holding the fire input does not repeat.", "scope": "mechanic", "source": "brief:fps#5", "expect": ["TracerPool3D"], @@ -145,7 +145,7 @@ }, { "id": "brief.fps.8", - "query": "10 damage a round; 4× in the top 12% of a body's height, 0.7× below a third of it.", + "query": "10 damage a round; 4\u00d7 in the top 12% of a body's height, 0.7\u00d7 below a third of it.", "scope": "mechanic", "source": "brief:fps#8", "expect": ["PhysicsDirectSpaceState3D"], @@ -153,7 +153,7 @@ }, { "id": "brief.fps.9", - "query": "A target hit scores that target's value — 100, 150, 250 or 300. The struck target drops below its stand, cannot be scored, and swings back up about 1.4 s later.", + "query": "A target hit scores that target's value \u2014 100, 150, 250 or 300. The struck target drops below its stand, cannot be scored, and swings back up about 1.4 s later.", "scope": "mechanic", "source": "brief:fps#9", "expect": ["publishUiState", "ScenePicker"], @@ -403,7 +403,7 @@ "id": "request.build-racing-game", "query": "build a racing game", "scope": "request", - "source": "template:racing#AGENTS.md — __PROJECT_NAME__ racing", + "source": "template:racing#AGENTS.md \u2014 __PROJECT_NAME__ racing", "expect": ["PathFollow3D"], "reject": [] }, @@ -419,7 +419,7 @@ "id": "request.inventory-system", "query": "make an inventory system", "scope": "request", - "source": "template:action-rpg#AGENTS.md — __PROJECT_NAME__ action RPG", + "source": "template:action-rpg#AGENTS.md \u2014 __PROJECT_NAME__ action RPG", "expect": ["assertPortableState"], "reject": [], "notOwned": "inventory" @@ -436,7 +436,7 @@ "id": "request.third-person-camera", "query": "third person camera follow", "scope": "request", - "source": "template:platformer#AGENTS.md — __PROJECT_NAME__ platformer", + "source": "template:platformer#AGENTS.md \u2014 __PROJECT_NAME__ platformer", "expect": ["defineGame"], "reject": [] }, @@ -444,7 +444,7 @@ "id": "request.save-progress", "query": "save the player progress", "scope": "request", - "source": "template:starter#AGENTS.md — __PROJECT_NAME__ starter", + "source": "template:starter#AGENTS.md \u2014 __PROJECT_NAME__ starter", "expect": ["assertPortableState"], "reject": [ "Area3D", @@ -485,7 +485,7 @@ "id": "request.dialogue-npc", "query": "dialogue with an NPC", "scope": "request", - "source": "template:sailing#AGENTS.md — __PROJECT_NAME__ sailing", + "source": "template:sailing#AGENTS.md \u2014 __PROJECT_NAME__ sailing", "expect": ["Text"], "reject": ["NavigationAgent3D", "recast"], "notOwned": "dialogue" @@ -494,7 +494,7 @@ "id": "request.pick-up-item", "query": "pick up an item", "scope": "request", - "source": "template:minimal#AGENTS.md — __PROJECT_NAME__ minimal", + "source": "template:minimal#AGENTS.md \u2014 __PROJECT_NAME__ minimal", "expect": ["Area3D"], "reject": ["ClusteredMesh"] }, @@ -528,7 +528,7 @@ "id": "request.platformer-double-jump", "query": "make a platformer with double jump", "scope": "request", - "source": "template:platformer#AGENTS.md — __PROJECT_NAME__ platformer", + "source": "template:platformer#AGENTS.md \u2014 __PROJECT_NAME__ platformer", "expect": ["CharacterBody3D"], "reject": [] }, @@ -596,6 +596,126 @@ "expect": ["mergeParts"], "reject": [] }, + { + "id": "snow.footprints", + "query": "leave footprints in deep snow that stay where the player walked", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["SnowField"], + "reject": [] + }, + { + "id": "snow.refill", + "query": "fresh snowfall slowly fills old tracks back in", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["SnowField"], + "reject": [] + }, + { + "id": "snow.compaction", + "query": "show how packed the snow is where people have walked", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["SnowField"], + "reject": [] + }, + { + "id": "snow.ball-drop", + "query": "drop a heavy ball into powder snow so it sinks and settles in the crater it makes", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["attachSnowPhysics"], + "reject": [] + }, + { + "id": "snow.ball-roll", + "query": "push a ball through snow so it rolls and carves a continuous track", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["attachSnowPhysics"], + "reject": [] + }, + { + "id": "snow.bodies", + "query": "a crate and a fallen log press their own shapes into the snow they rest on", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["attachSnowPhysics"], + "reject": [] + }, + { + "id": "snow.snowfall", + "query": "falling snowflakes that thicken into a windy blizzard", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["GPUParticles3D"], + "reject": [] + }, + { + "id": "snow.powder", + "query": "a puff of powder snow sprays up where a boot lands", + "scope": "mechanic", + "source": "template:snow#Commands and map", + "expect": ["GPUParticles3D"], + "reject": [] + }, + { + "id": "request.snow-glade", + "query": "a snowy glade where an explorer leaves boot prints, a ball rolls through the powder carving a track, and snowfall turns into a blizzard", + "scope": "request", + "source": "template:snow#Commands and map", + "expect": ["SnowField", "attachSnowPhysics"], + "reject": [] + }, + { + "id": "rain.weather-world", + "query": "procedural coastal storm with a road, forest, sea and wet reflections drawn from maths rather than geometry", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["createWeatherWorld"], + "reject": ["Area3D", "NavigationAgent3D"] + }, + { + "id": "rain.noise-volume", + "query": "generate a 3D noise volume for the clouds instead of shipping one as an asset", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["createNoiseVolume"], + "reject": ["Area3D"] + }, + { + "id": "rain.storm-audio", + "query": "rain hiss, wind bed and thunder delayed by how far away the strike was", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["createStormAudio"], + "reject": ["Area3D"] + }, + { + "id": "rain.lightning", + "query": "generate branching lightning ribbon geometry that flashes the scene", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["createStormLightning"], + "reject": ["Area3D"] + }, + { + "id": "rain.streaks", + "query": "thousands of falling rain streaks around the camera in one draw", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["createStormRain", "GPUParticles3D"], + "reject": ["Area3D"] + }, + { + "id": "rain.round-time-limit", + "query": "countdown timer that ends the round", + "scope": "mechanic", + "source": "template:rain#Ownership", + "expect": ["Scheduler"], + "reject": ["Area3D"] + }, { "id": "request.drive-a-racing-kart", "query": "racing car racing kart drift vehicle go-kart", diff --git a/scripts/non-visual-scenarios.mjs b/scripts/non-visual-scenarios.mjs index 821d0be723..3a4e281116 100644 --- a/scripts/non-visual-scenarios.mjs +++ b/scripts/non-visual-scenarios.mjs @@ -34,7 +34,7 @@ const visual = (source) => { // A `visual` assertion forces the runner to capture a frame no matter what the artifacts say — // `wantsVisual` overrides `--no-screenshots`, correctly, because the assertion needs the pixels. // Missing this let a scenario through as non-visual and straight into the screenshot hang. - if (scenario.assert?.visual !== undefined) return true; + if (scenario.assert?.visual !== undefined || scenario.assert?.tone !== undefined) return true; // A scenario whose steps hold an input for many ticks and then assert on where the subject // ended up is measuring distance travelled under sustained simulation, which is as much a // hardware property as a pixel is. `coyote` is the case: it walks the player off a ledge over diff --git a/scripts/render-chain-capabilities.ts b/scripts/render-chain-capabilities.ts index 581aa493b3..fda33aa052 100644 --- a/scripts/render-chain-capabilities.ts +++ b/scripts/render-chain-capabilities.ts @@ -19,22 +19,26 @@ import type { ICapabilityManifestEntry } from "./build-capability-manifest.js"; function stage(entry: { readonly symbol: string; readonly importPath: string; + readonly packageName?: string; readonly kind?: ICapabilityManifestEntry["kind"]; readonly signature?: string; readonly summary: string; readonly situations: readonly string[]; readonly constraints: readonly string[]; + readonly example?: string; }): ICapabilityManifestEntry { return { symbol: entry.symbol, - package: "three", + package: entry.packageName ?? "three", importPath: entry.importPath, kind: entry.kind ?? "class", signature: entry.signature ?? `class ${entry.symbol}`, summary: entry.summary, situations: entry.situations, // Every one of these is already wired; the example is the dial, not the constructor. - example: "src/render/postprocessing.ts — edit the preset it passes to WorldEnvironment", + example: + entry.example ?? + "src/render/postprocessing.ts — edit the preset it passes to WorldEnvironment", constraints: entry.constraints, overrides: [], supersedes: [], @@ -105,6 +109,137 @@ export const RENDER_CHAIN_MANIFEST_ENTRIES: readonly ICapabilityManifestEntry[] "Appearance belongs here, in generated game source. Nothing in packages/ decides how the scene looks.", ], }), + // The `rain` template's generated source. Same reason as the stages above: these are functions in + // files the game owns (`src/render/`, `src/audio/`), so no package export puts them in the manifest + // and an agent searching "storm sky" or "thunder distance" had nowhere to land. Ordinary Three.js + // plus the engine's own `AudioBus`; what the game draws and hears is authored here, not configured + // from a package. Only what exists today is listed, because a manifest entry is a promise the + // source keeps. + stage({ + symbol: "createWeatherWorld", + importPath: "src/render/world.ts", + packageName: "template:rain", + kind: "function", + signature: "createWeatherWorld(scene: Scene, camera: PerspectiveCamera): IWeatherWorld", + summary: + "The raymarched coastal storm as generated source: one screen quad whose TSL shader draws the sky, the sea, the coast, the road, the forest and the lamps, and whose wet ground reflects them, written from the uniforms the game feeds it each frame. The sky it samples is the live cloud pass this call builds, not a stand-in gradient. Returns `{ quad, update(options), dispose() }`.", + situations: [ + "a procedural storm, sea, coastline or open horizon drawn from maths rather than geometry", + "rain and wind that change how a scene looks without swapping a mesh", + "a lightning flash that lights the coast and the sky at once", + "wet reflections on a ground plane", + "sky that darkens as cloud cover rises", + ], + example: + "src/render/world.ts — edit the shader there, or the weather it is fed from src/state.ts", + constraints: [ + "`update` takes `{ elapsed, flash, weather, strike, quality }`: absolute seconds, a flash envelope already gated on the photosensitivity switch by the caller, the bolt's entry point in the cloud deck in metres (what the clouds glow around and the coast is lit from), and a quality name of `performance | balanced | high | ultra`.", + "The tier's cloud resolution share, march steps and reflection switch come from `STUDY_TIERS` in src/render/quality.ts; `performance` skips the 36-step reflection march, so a low tier loses reflections, not the coast. `clouds()` reads back the cloud target the renderer really drew and its steps.", + "Its colours and lights are uniforms set once from src/render/palette.ts, sky.ts, lighting.ts and materials.ts; the shader itself is generated from tools/tempest-*.frag by tools/generate-shaders.mjs — edit the .frag, not the generated file.", + "The engine's `Scene` and `PerspectiveCamera` are injected as arguments; the file holds no engine lifetime of its own. `dispose()` removes the quad and disposes the geometry, material and sky texture — the scene does not do it for you.", + "The quad has `frustumCulled = false`, because its two-metre bounds would otherwise be culled the moment the camera looks along the coast.", + "The cloud pass is built first and its texture feeds the world shader (sampled with the render-target flip); `update` also drives the clouds. Rain streaks and the bolt are separate draws: `createStormRain`, `createStormLightning`.", + "This entry describes the Rain starter scaffold's own generated `src/render/world.ts`, not a `@threenative/` or three export — there is no installed package to import it from. Appearance lives in that source with no package import at all; nothing in packages/ decides how this storm looks.", + ], + }), + stage({ + symbol: "createNoiseVolume", + importPath: "src/render/noise-volume.ts", + packageName: "template:rain", + kind: "function", + signature: "createNoiseVolume(): INoiseVolume", + summary: + "The 64³ RGBA cloud noise volume as generated Rain source: `{ data: Uint8Array, texture: Data3DTexture }`. Its authored seed, octave weights and source-exact generator define the storm's cloud shapes.", + situations: [ + "generate a 3D noise volume instead of shipping one as an asset", + "rebuild the storm's clouds procedurally at startup", + "tileable 3D noise that does not crease at the edges", + "four octave scales packed into one texture", + ], + example: + "src/render/noise-volume.ts — edit the seed, the lattices or the channel weights there", + constraints: [ + "Seed 13291 uses the source's mulberry32 sequence. The generated volume matches all 1,048,576 reference bytes; changing the authored seed or generator changes its appearance.", + "Two distinct failure channels, not one: `TN_NOISE_CHANNEL` is a channel value that does not fit a byte, `TN_NOISE_LENGTH` is a byte count that does not fill the volume. Both throw rather than uploading a volume the shader would read wrong.", + "64³ of RGBA is 1 MiB, and `NOISE_SIZE` is what the loops and the byte count are written against; changing the size changes the memory cost with it.", + "Indices wrap at every face, which is what makes the volume tile, and the fractional part is smoothed by `f²(3 − 2f)` so lattice edges do not show as creases in the cloud.", + "This entry describes the Rain starter scaffold's own generated `src/render/noise-volume.ts`, not a `@threenative/` or three export — there is no installed package to import it from.", + ], + }), + stage({ + symbol: "createStormAudio", + importPath: "src/audio/storm.ts", + packageName: "template:rain", + kind: "function", + signature: "createStormAudio(ctx: ICtx): IStormAudio", + summary: + "The storm's sound as generated source, mixed through the engine's own `AudioBus`: a rain hiss, a wind bed, and thunder queued strike by strike and delayed and filtered by how far away it was struck.", + situations: [ + "rain hiss, wind bed, thunder, a storm you can hear", + "delay thunder by the distance it was struck", + "sound that follows pause, mute and tab visibility", + "audio that queues strikes instead of cutting each other off", + ], + example: + "src/audio/storm.ts — register the returned helper, call update(state) after advancing the simulation, and dispose it with the owning scene", + constraints: [ + "`queueStrike({ at, metres })` queues: a second strike does not overwrite one still crossing the air, and `at` is absolute simulation time, which is what lets a strike survive a freeze.", + "Two holds, kept apart so neither latches the other: `setSilenced` is the game's own pause intent, `setHidden` is the UI realm or engine lifecycle visibility. The bus is released only when both are clear.", + "`update(state)` is the frame's truth and is called once per frame after the simulation advances; `debug()` is what a playtest reads back.", + "The caller owns entity registration, updates and disposal; creating the helper does not register it.", + "Audio begins only after a user gesture — the bus's unlock error is surfaced rather than swallowed.", + "The three clips are baked by `tools/make-storm-audio.mjs` from the study's own maths; this file plays them, it does not synthesise them live.", + "The helper requests the source's compressor settings ahead of master gain: threshold -15 dB, knee 30 dB, ratio 5, attack 0.003 s and release 0.25 s.", + "Each thunder voice is a labelled `thunder` cue, so a playtest's `audio` assertion reads it from the runtime ledger; the rain kit's `playtests/lightning.playtest.json` proves one cue after a strike, sounded only once the strike's `distance / 343` delay had passed.", + "This entry describes the Rain starter scaffold's own generated `src/audio/storm.ts`, not a `@threenative/` or three export — there is no installed package to import it from.", + ], + }), + stage({ + symbol: "createStormLightning", + importPath: "src/render/lightning.ts", + packageName: "template:rain", + kind: "function", + signature: "createStormLightning(scene: Scene, camera: PerspectiveCamera): IStormLightning", + summary: + "A branching lightning bolt as generated Rain source: `makeBolt(end, random)` walks a 30-segment channel down from the cloud deck with side branches, and one additive camera-facing ribbon draw (gaussian core inside a wide halo) shows it while the caller's flash envelope is above zero. `strike(position, random)` returns where the bolt entered the sky.", + situations: [ + "generate branching lightning ribbon geometry", + "a lightning strike that lights the scene and the clouds", + "a camera-facing ribbon or beam that stays the same pixel width at any distance", + "a flash envelope that stutters rather than fades once", + ], + example: + "src/render/lightning.ts — the bolt; src/state.ts `flashAt` for the envelope; src/scenes/Boot.ts strike() for the thunder delay", + constraints: [ + "Pass the game's seeded random so a strike is reproducible; a non-finite position or random value throws rather than drawing NaN geometry.", + "The buffers hold the worst case (30 channel segments plus 24 branches of 9) and are allocated once; a strike rewrites them and opens the draw range, never reallocating.", + "`update(flash)` takes an envelope already gated on photosensitivity mode by the caller; below 0.003 the mesh is hidden. Register it with `alwaysRender`: its quads are projected by its own vertex stage, so its bounds say nothing about where it lands.", + "It depth-tests against the coast's written depth, so a bolt behind the headland is hidden by it.", + "This entry describes the Rain starter scaffold's own generated `src/render/lightning.ts`, not a `@threenative/` or three export — there is no installed package to import it from.", + ], + }), + stage({ + symbol: "createStormRain", + importPath: "src/render/rain.ts", + packageName: "template:rain", + kind: "function", + signature: "createStormRain(scene: Scene, camera: PerspectiveCamera): IStormRain", + summary: + "Camera-anchored falling rain as generated Rain source: one instanced draw whose vertex stage hashes each drop from its index, wraps it in a 66 × 30 × 66 m cell that follows the camera in 12 m steps, and projects a quad stretched along its own fall — no particle simulation, no texture.", + situations: [ + "rain streaks around the camera", + "thousands of falling drops in one draw", + "rain that leans with the wind and lights up in a lightning flash", + ], + example: + "src/render/rain.ts — the drop maths; `STUDY_TIERS` in src/render/quality.ts for the per-tier budget", + constraints: [ + "The drawn count is `round(rainBudget × weather.rain)` for the tier, read back from `geometry.instanceCount` (`instanceCount`), not from the budget.", + "Stateless by design: a drop's position is a function of its index and the clock, so pausing the clock freezes the rain and nothing accumulates. `GPUParticles3D` is the engine's emitter with lifetimes; reach for it when drops must spawn, live and die.", + "Register it with `alwaysRender`: the vertex stage places every drop, so the geometry's own bounds do not.", + "This entry describes the Rain starter scaffold's own generated `src/render/rain.ts`, not a `@threenative/` or three export — there is no installed package to import it from.", + ], + }), // Not a render stage: the engine's scene-draw optimizer, which a game can decline, and whose // per-frame material proof a game can bound or unbind. It lives here because it is framework // render-path behaviour with no package export for an agent to search — the same reason the diff --git a/scripts/tone-capture-proof.ts b/scripts/tone-capture-proof.ts new file mode 100644 index 0000000000..6646c6e79f --- /dev/null +++ b/scripts/tone-capture-proof.ts @@ -0,0 +1,18 @@ +import type { IPlaytestDiagnostic } from "../packages/playtest/src/index.js"; + +/** Pixel qualification is stricter than a software simulation-only run. */ +export function assertToneCaptureDiagnostics( + diagnostics: readonly IPlaytestDiagnostic[], + variant: "underexposed" | "restored", +): void { + for (const diagnostic of diagnostics) { + const expectedToneFailure = + variant === "underexposed" && diagnostic.code === "TN_PLAYTEST_TONE_ASSERTION_FAILED"; + if ( + diagnostic.code === "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" || + (diagnostic.severity === "error" && !expectedToneFailure) + ) { + throw new Error(`${variant}: ${diagnostic.code}: ${diagnostic.message}`); + } + } +} diff --git a/scripts/verify-tone-exposure.ts b/scripts/verify-tone-exposure.ts new file mode 100644 index 0000000000..cc89fc8636 --- /dev/null +++ b/scripts/verify-tone-exposure.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + WEBGPU_BROWSER_ARGS, + runStandalonePlaytest, +} from "../packages/playtest/dist/runner/index.js"; +import { assertToneCaptureDiagnostics } from "./tone-capture-proof.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.join(root, "artifacts/tone-exposure"); +await mkdir(output, { recursive: true }); +const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", +}).trim(); +await writeFile( + path.join(output, "attempt.json"), + `${JSON.stringify({ sourceSha, status: "started" }, null, 2)}\n`, +); +try { + execFileSync("pnpm", ["exec", "vite", "build", "--config", "tone.vite.config.ts"], { + cwd: path.join(root, "examples/abyss-framework"), + stdio: "inherit", + }); + const reports = []; + for (const variant of ["underexposed", "restored"] as const) { + const artifactDirectory = path.join(output, variant); + const report = await runStandalonePlaytest({ + allowSoftwareAdapter: true, + artifactDirectory, + browserArgs: [...WEBGPU_BROWSER_ARGS], + headless: false, + port: 0, + projectPath: path.join(root, "examples/abyss-framework"), + scenarioPath: "playtests/tone.playtest.json", + server: { + command: + "pnpm exec vite preview --config tone.vite.config.ts --host 127.0.0.1 --port $PORT --strictPort", + timeoutMs: 60_000, + }, + timeoutMs: 30_000, + trace: false, + url: `http://127.0.0.1:5173/tone.html${variant === "underexposed" ? "?underexposed" : ""}`, + }); + await writeFile( + path.join(artifactDirectory, "report.json"), + `${JSON.stringify(report, null, 2)}\n`, + ); + await writeFile( + path.join(artifactDirectory, "observations.json"), + `${JSON.stringify(report.observations, null, 2)}\n`, + ); + reports.push(report); + await writeFile(path.join(output, "reports.json"), `${JSON.stringify(reports, null, 2)}\n`); + assertToneCaptureDiagnostics(report.diagnostics, variant); + assert.ok(report.capture?.adapter, `${variant}: adapter provenance is required`); + assert.ok( + Object.values(report.capture.adapter).some((value) => value.trim() !== ""), + `${variant}: adapter identity must not be empty`, + ); + assert.equal(report.capture.rendererKind, "webgpu", `${variant}: must exercise WebGPU`); + for (const frame of ["tone-0.png", "after.png"]) + assert.ok( + (await readFile(path.join(artifactDirectory, frame))).length > 0, + `${variant}: ${frame} is required`, + ); + assert.equal( + report.pass, + variant === "restored", + `${variant}: ${JSON.stringify(report.diagnostics)}`, + ); + if (variant === "underexposed") + assert.deepEqual( + (report.assertionResults ?? []) + .filter(({ pass }) => !pass) + .map(({ id }) => id) + .sort(), + ["tone.0.mean", "tone.0.p99", "tone.1.mean", "tone.1.p99"], + ); + } + assert.ok( + !(await readFile(path.join(output, "underexposed/tone-0.png"))).equals( + await readFile(path.join(output, "restored/tone-0.png")), + ), + "Exposure mutation must change captured pixels", + ); + await writeFile( + path.join(output, "summary.json"), + `${JSON.stringify( + { + sourceSha, + qualification: + "rendered-pixel correctness only; software adapters allowed, no native or hardware-performance claim", + pass: true, + variants: reports.map((report, index) => ({ + variant: index === 0 ? "underexposed" : "restored", + capture: report.capture, + pass: report.pass, + tone: report.observations?.tone, + })), + }, + null, + 2, + )}\n`, + ); + console.log( + `Tone exposure: underexposed failed and restored passed; runtime screenshots and reports: ${output}`, + ); +} catch (error) { + await writeFile( + path.join(output, "failure.json"), + `${JSON.stringify( + { + sourceSha, + pass: false, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + null, + 2, + )}\n`, + ); + throw error; +} diff --git a/scripts/visual-gate.ts b/scripts/visual-gate.ts index 6135839e5c..3b19753584 100644 --- a/scripts/visual-gate.ts +++ b/scripts/visual-gate.ts @@ -213,8 +213,13 @@ export function inspectTemplate( errors.push(`${template}: lighting.ts needs a key and rim DirectionalLight`); if (!imageLit && !/new (?:HemisphereLight|AmbientLight)/u.test(lighting)) errors.push(`${template}: lighting.ts needs a fill or ambient light`); - for (const marker of ["PCFSoftShadowMap", "normalBias"]) { - if (!lighting.includes(marker)) errors.push(`${template}: lighting.ts is missing ${marker}`); + // The shadow-map markers belong to a light that casts one. A ray-marched kit computes its own + // occlusion and enables no shadow map, so demanding the soft-filter settings there would only + // demand dead configuration. + if (/castShadow\s*=\s*true/u.test(lighting)) { + for (const marker of ["PCFSoftShadowMap", "normalBias"]) { + if (!lighting.includes(marker)) errors.push(`${template}: lighting.ts is missing ${marker}`); + } } for (const marker of ["toneMapping", "toneMappingExposure", "createRenderChain", "bloom("]) { if (!postPipeline.includes(marker)) diff --git a/scripts/workspace-packages.ts b/scripts/workspace-packages.ts index fa7841088c..4e058a7ff7 100644 --- a/scripts/workspace-packages.ts +++ b/scripts/workspace-packages.ts @@ -14,6 +14,8 @@ const PACKAGE_NAME_GLOBAL_PATTERN = const WORKFLOW_PACKAGE_COMMAND_PATTERN = /\bpnpm[ \t]+[^\n;&|]*?--filter[ \t]+["']?(@threenative\/[a-z0-9-]+|create-threenative|threenative-[a-z0-9-]+-mcp)["']?(?=[ \t\r\n]|$)/gu; const WORKFLOW_SHELL_CONTINUATION_PATTERN = /\\\r?\n[ \t]*/gu; +// One `run:` step — scalar, literal block or folded block — is one command group. +const WORKFLOW_RUN_HEADER_PATTERN = /^[^\S\n]*(?:-[^\S\n]+)?run:/gmu; const WORKFLOW_YAML_FOLDED_SCALAR_HEADER_PATTERN = /^([ \t]*)(?:-[ \t]+)?run:[ \t]*>(?:[1-9][+-]?|[+-][1-9]?)?(?:[ \t]+#.*)?[ \t]*$/u; const OTHER_WORKSPACE_NAMES = new Set([ @@ -327,15 +329,29 @@ function unfoldYamlFoldedScalars(source: string): string { } function hasWorkflowPackageEnumeration(source: string): boolean { - const names = new Set(); const continuedSource = unfoldYamlFoldedScalars(source).replaceAll( WORKFLOW_SHELL_CONTINUATION_PATTERN, " ", ); + // The defect is a hand-listed package set: several packages named by one command or one run block, + // or a shell loop over them. Two targeted `--filter` steps in two unrelated jobs are two builds, not + // a list, and since the per-feature integration workflows folded into one file a single workflow + // legitimately holds one targeted build per lane. + const runBlocks = [...continuedSource.matchAll(WORKFLOW_RUN_HEADER_PATTERN)].map( + (match) => match.index, + ); + const perRunBlock = new Map>(); for (const match of continuedSource.matchAll(WORKFLOW_PACKAGE_COMMAND_PATTERN)) { - if (match[1] !== undefined) names.add(match[1]); + const name = match[1]; + if (name === undefined) continue; + const block = runBlocks.filter((at) => at <= (match.index ?? 0)).pop() ?? -1; + const blockNames = perRunBlock.get(block) ?? new Set(); + blockNames.add(name); + perRunBlock.set(block, blockNames); + } + for (const blockNames of perRunBlock.values()) { + if (blockNames.size >= 2) return true; } - if (names.size >= 2) return true; for (const match of source.matchAll(/\bfor\s+\w+\s+in\s+([\s\S]*?)(?:;\s*do\b|\s+do\b)/gu)) { const loopNames = new Set(match[1]?.match(PACKAGE_NAME_GLOBAL_PATTERN) ?? []); diff --git a/tools/ci-runners/Dockerfile b/tools/ci-runners/Dockerfile new file mode 100644 index 0000000000..0139cb8bb6 --- /dev/null +++ b/tools/ci-runners/Dockerfile @@ -0,0 +1,101 @@ +# The `tn-local` self-hosted runner image (PRD-480). One container per job: `scripts/ci-runners.sh` +# starts a host-side loop of `docker run --rm`, so nothing survives a job but the image itself. +# +# Ubuntu 24.04 to match hosted `ubuntu-24.04`, with the `runner` user's passwordless sudo intact so +# the workflow's own `sudo apt-get install` steps keep working. The Docker CLI is deliberately +# absent and the socket is never mounted: `supply-chain` needs `docker run` for gitleaks and stays +# on a hosted runner for exactly that reason. +FROM ghcr.io/actions/actions-runner:latest + +USER root + +# Build and test toolchain the moving Linux jobs take from the hosted image. `git`, `curl`, `jq` +# and `unzip` are the runner's own prerequisites; the rest is what ci.yml's jobs actually invoke. +# Playwright's Chromium dependencies are the exact `tools` and `chromium` lists Playwright 1.62.1 +# resolves for ubuntu24.04-x64 (playwright-core's bundled nativeDeps), transcribed so the apt layer +# does not depend on the Node layer below. +# The -dev packages and xcompmgr are what the native jobs `apt-get install` every run (52 s of +# test-native, run 37089715252); baked in, that step finds them present. Mesa's Vulkan ICDs stay +# per-job: baked in, they blanked the tone and decals captures. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build ccache clang lld pkg-config \ + git curl jq unzip zip xz-utils xvfb xauth python3 python3-pip \ + 7zip ffmpeg imagemagick zstd file lsof procps openjdk-17-jdk-headless \ + libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libgtk-3-dev \ + libpulse-dev libudev-dev libwebkit2gtk-4.1-dev libx11-dev libxcomposite-dev libxcursor-dev \ + libxext-dev libxfixes-dev libxi-dev libxkbcommon-dev libxrandr-dev libxss-dev xcompmgr zlib1g-dev \ + fonts-noto-color-emoji fonts-unifont fonts-liberation fonts-freefont-ttf \ + libfontconfig1 libfreetype6 \ + libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \ + libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \ + libnspr4 libnss3 libpango-1.0-0 \ + libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \ + && rm -rf /var/lib/apt/lists/* + +# Hosted ubuntu-24.04 ships Node 20 and gh on PATH, and jobs rely on both before any setup step: +# the `scope` job runs `node scripts/ci-change-scope.mjs` straight after checkout. +ARG NODE_VERSION=20.19.6 +RUN curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + | tar -xJ -C /usr/local --strip-components=1 --exclude=CHANGELOG.md --exclude=README.md --exclude=LICENSE \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +# Android platform-tools where hosted ubuntu-24.04 keeps its SDK: the playtest doctor resolves adb +# through ANDROID_HOME before PATH. +ENV ANDROID_HOME=/usr/local/lib/android/sdk ANDROID_SDK_ROOT=/usr/local/lib/android/sdk +# Hosted runners run in C.UTF-8; without it javac reads sources as US-ASCII and rejects an em dash. +ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 +# The emulator lane writes a udev rule before it starts; hosted has the directory, the base image does not. +RUN mkdir -p /etc/udev/rules.d "$ANDROID_HOME" \ + && curl -fsSL https://dl.google.com/android/repository/platform-tools-latest-linux.zip -o /tmp/pt.zip \ + && unzip -q /tmp/pt.zip -d "$ANDROID_HOME" && rm /tmp/pt.zip \ + && ln -s "$ANDROID_HOME/platform-tools/adb" /usr/local/bin/adb + +# The SDK every routed native-platforms Android step calls into. `sdkmanager` is the whole of it: +# `android-actions/setup-android` uses an already-installed `cmdline-tools/latest` when the version +# matches, the workflows run their own `sdkmanager "platforms;android-35" "build-tools;35.0.0" +# "ndk;28.2.13676358"`, and `android-emulator-runner` installs what it is missing from it. The +# versions are the ones this repository pins — build-tools 35.0.0, which is also the emulator +# action's own constant; platforms 35 for the parity lane's api-level and 36, which +# packages/runtime-native/android/app/build.gradle.kts declares as compileSdk; and the NDK, which +# that same file names in `ndkVersion` and which the Android V8 build refuses to start without. +# Licenses are accepted at build time so none of those job-time installs stalls on a prompt. +# `emulator` and the system image are deliberately absent: android-emulator-runner installs both +# itself, which is the only Android job that wants them. +ARG CMDLINE_TOOLS=12266719 +RUN set -eu; \ + curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" -o /tmp/cli.zip; \ + mkdir -p "$ANDROID_HOME/cmdline-tools"; \ + unzip -q /tmp/cli.zip -d "$ANDROID_HOME/cmdline-tools"; \ + rm /tmp/cli.zip; \ + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"; \ + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"; \ + yes | sdkmanager --licenses > /dev/null; \ + sdkmanager --install "platform-tools" "platforms;android-35" "platforms;android-36" \ + "build-tools;35.0.0" "ndk;28.2.13676358" > /dev/null; \ + rm -rf "$ANDROID_HOME/.temp"; \ + # `runner` owns the SDK, because sdkmanager writes into ANDROID_HOME at job time: the workflows \ + # install their own packages and android-emulator-runner installs the emulator, the platform and \ + # the system image, and a root-owned SDK fails the write as the unprivileged job user. Chowned \ + # here rather than in a later layer, which would copy the whole SDK up and double its size. \ + chown -R runner:runner "$ANDROID_HOME" +ENV PATH=/usr/local/lib/android/sdk/cmdline-tools/latest/bin:$PATH + +COPY entrypoint.sh /home/runner/entrypoint.sh + +# Rust as hosted ubuntu-24.04 has it (stable, on PATH): the native physics ABI is built with cargo. +# Installed as `runner` so cargo can write its own registry and target caches. +USER runner +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable +ENV PATH=/home/runner/.cargo/bin:$PATH +USER root +RUN chmod 0755 /home/runner/entrypoint.sh + +USER runner +WORKDIR /home/runner +ENTRYPOINT ["/home/runner/entrypoint.sh"] diff --git a/tools/ci-runners/README.md b/tools/ci-runners/README.md new file mode 100644 index 0000000000..3ce425936a --- /dev/null +++ b/tools/ci-runners/README.md @@ -0,0 +1,96 @@ +# Self-hosted CI runners (`tn-local`) + +Linux CI jobs can run on one owner machine instead of GitHub's hosted runners. Hosted runners stay the +fallback for everything, so **contributors need none of this**: a clone, a fork or a pull request works +the same with or without it. The design and its measurements are in +[PRD-480](../../docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md). + +## How routing works + +Every movable Linux job picks its runner from one expression: + +```yaml +runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} +``` + +- `TN_RUNNER` (repository variable) set to `tn-local` → the job runs on this pool. Unset → hosted. +- A pull request from a fork always runs hosted, so outside code never reaches the machine. +- Small join jobs (`scope`, `ci-required`, summaries) use `TN_RUNNER_LIGHT` the same way, so they never + queue behind a 20-minute build. +- macOS, Windows, iOS, linux-arm64, `supply-chain` and the Android emulator lane always run hosted. + +**The pool is extra capacity, not a replacement.** This repository is public, so hosted runners are free +and run about 20 jobs at once. A balancer (`ci-runners.sh balance`, started by `up`) sets each variable +only while that pool has an idle runner, and deletes it the moment none is idle. A busy pool therefore +overflows new jobs to hosted runners instead of queueing them. Routing every Linux job here funnelled the +team's CI into 5 slots and made boards slower. `down` stops the balancer and clears both variables, so a +pool that is down never strands a job. + +## Set it up (once, on the runner machine) + +Needs: Linux with systemd, Docker usable without `sudo`, `gh` logged in with admin rights on the repo, +and enough cores for the slots (3 threads each; 2 whole cores always stay free for the desktop). + +1. Create a fine-grained token for this repository only, with **Administration: Read and write**. It + mints runner registration tokens. Add **Variables: Read and write** too if the pool must also start + before anyone logs in (see Troubleshooting). +2. Store it outside the repository, readable only by you: + + ```sh + mkdir -p ~/.config/threenative + printf 'RUNNER_ADMIN_TOKEN=%s\n' '' > ~/.config/threenative/runners.env + chmod 600 ~/.config/threenative/runners.env + ``` + +3. In the repository settings, set Actions → General → "Fork pull request workflows from outside + collaborators" to **Require approval for all outside collaborators**. +4. Install: + + ```sh + pnpm ci:runners:install + ``` + + It checks the prerequisites and creates `.worktrees/ci-runners` (a checkout the service owns). It + then installs and starts a systemd user service, `threenative-ci-runners`, enabling lingering so the + service starts at boot. The pool comes up and the variables are set. Nothing else to run, now or + after a reboot. + +## Day to day + +| Want | Run | +|---|---| +| What is running, what is routed | `pnpm ci:runners status` (balancer log: `~/.local/state/threenative/ci-runners/balance.log`) | +| Pick up a runner image or script change from `develop` | `systemctl --user restart threenative-ci-runners` (when no job is running; a restart kills in-flight jobs) | +| Send every job to hosted runners now (kill switch) | `gh variable delete TN_RUNNER && gh variable delete TN_RUNNER_LIGHT` | +| Stop the pool | `systemctl --user stop threenative-ci-runners` | +| Remove everything | `pnpm ci:runners uninstall` | +| Run without the service (any OS with Docker) | `pnpm ci:runners up [N]` / `pnpm ci:runners down` | + +Every start re-checks out the latest `develop`, so fixes to `tools/ci-runners/` deploy on the next +restart or reboot. + +## What a slot is + +- An ephemeral container per job (`docker run --rm`): fresh filesystem, fresh hostname, no state + shared between jobs. The image is Ubuntu 24.04 plus what hosted `ubuntu-24.04` jobs rely on: Node 20, + `gh`, Rust, JDK 17, Android SDK and platform-tools, build tools, Xvfb and Playwright's dependencies. +- 6 heavy slots of 3 pinned threads each (`nproc` reads 3) with 9 GB of memory and no extra swap. More, + thinner slots beat fewer fat ones: most CI time is installs, downloads and browser waits. Under memory pressure the kernel kills a CI job before anything else. +- 3 light slots: 1 CPU and 2 GB each, for joins only. One slot serialised every pull request's scope and + verdict, because an ephemeral runner takes 30-60 s to re-register after each job. +- The admin token mints one registration token per container and is unset before the job starts, so no + job can read it. + +## Troubleshooting + +- **The pool did not come up after a reboot.** `gh` may keep its token in a desktop keyring that stays + locked until you log in. The service retries every minute and comes up at login; until then jobs run + hosted. For starts before login, give the runner token Variables write (step 1). +- **Jobs are stuck "Waiting for a runner" with the machine off.** The machine went down without a clean + stop (power loss, crash), so `TN_RUNNER` is still set. Run the kill switch above, or boot the machine. +- **A template lane fails with a capture or GPU-device error.** `tower-defense` and `rain` are known + flaky on both pools (see `docs/PRDs/CI/EXECUTION-ORDER.md`). Rerun the failed jobs once. +- **The runner list shows offline `tn-*` runners.** Runners stopped while idle stay registered. `up` and + `down` delete them. +- **Timing.** The pool adds about 6 slots to hosted's ~20; with several boards in flight both pools + saturate, and wall time follows total demand. diff --git a/tools/ci-runners/entrypoint.sh b/tools/ci-runners/entrypoint.sh new file mode 100755 index 0000000000..4a9675d418 --- /dev/null +++ b/tools/ci-runners/entrypoint.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Registers this container as an ephemeral `tn-local` runner — or `tn-local-light` for the light +# lane — and takes one job. +# +# The container is the disposable half of `scripts/ci-runners.sh up`: it configures, runs exactly +# one job, exits, and the host loop starts a fresh one. Nothing on the filesystem crosses jobs. +set -euo pipefail + +# `docker run --device /dev/kvm` creates the node 0660 root:kvm, which the `runner` user cannot +# open: `android-emulator-runner` reads `/dev/kvm` for read and write before it starts anything and +# falls back to a software boot, which is the 474-second one native-platforms.yml's parity lane +# exists to avoid. udev cannot fix it here — no udev runs in the container and its /sys is +# read-only, which is why that lane's `udevadm` lines are tolerant — so the mode is set here, +# before the job can look. Root through the runner image's passwordless sudo; no-op without KVM. +if [ -e /dev/kvm ]; then + sudo chmod 0666 /dev/kvm +fi + +# Fail closed and say which variable is missing. An ephemeral runner that registers without +# TN_RUNNER_REPO would point itself at whatever repository the token defaults to. +: "${RUNNER_ADMIN_TOKEN:?RUNNER_ADMIN_TOKEN is not set; pass the operator env file with --env-file}" +: "${TN_RUNNER_REPO:?TN_RUNNER_REPO is not set; the host loop passes owner/repo}" + +registration_token="$( + curl --fail --silent --show-error --request POST \ + --header "Authorization: Bearer $RUNNER_ADMIN_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${TN_RUNNER_REPO}/actions/runners/registration-token" \ + | jq --exit-status --raw-output '.token' +)" + +# The admin token mints this one registration token and has no other business existing. Dropping it +# from this shell's environment is what keeps it out of every job `run.sh` hands to a workflow. +unset RUNNER_ADMIN_TOKEN + +./config.sh \ + --unattended \ + --ephemeral \ + --replace \ + --url "https://github.com/${TN_RUNNER_REPO}" \ + --token "$registration_token" \ + --name "tn-$(hostname)" \ + --labels "${TN_RUNNER_LABELS:-tn-local}" \ + --no-default-labels + +exec ./run.sh