diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c5009537..203fa7ed5 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1076,5 +1076,7 @@ "src/sky/stars.test.mjs", "src/sky/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", - "src/tooling/skyAssets.paintOut.test.mjs" + "src/tooling/skyAssets.paintOut.test.mjs", + "scripts/qa-sky-sun-moon.mjs", + "scripts/qa-anime-sky.mjs" ] diff --git a/scripts/qa-anime-space-mask.mjs b/scripts/qa-anime-sky.mjs similarity index 80% rename from scripts/qa-anime-space-mask.mjs rename to scripts/qa-anime-sky.mjs index c73561e94..ae7ee69dc 100644 --- a/scripts/qa-anime-space-mask.mjs +++ b/scripts/qa-anime-sky.mjs @@ -1,44 +1,51 @@ -// Repro for revmain-opus-1 (+ revmain-opus-6, + a guard for the fix). -// Anime exempts "space" pixels from cel-shading (src/styles/anime.js). This -// script compiles the CURRENT anime fragment shader from the checkout with its -// last line swapped for a debug output (R = pixel exempt from quantization, -// G = raw depth 1.0), runs it as an extra post-process stage in the running app -// (headless) and counts misclassified pixels. -// -// Cases: -// satellites whole globe from 20 000 km with the satellites layer on -// (near clamps to 0.1 m): Earth pixels must NOT be exempt. [revmain-opus-1] -// keyless keyless stack, Denver 1700 m looking west: terrain above the -// ellipsoid horizon must NOT be exempt. [revmain-opus-6] -// milkyway the real anime style, looking away from the Earth at the -// Galactic Centre: the sky must show no cel bands (the faint -// glow used to turn into blotches; the glow now fades out -// with the style, src/ui/visualEffects.js). -// -// Usage: node scripts/qa-anime-space-mask.mjs --url http://localhost:4333/ [--case satellites|keyless|milkyway|all] -// [--space-expr ""] (try a candidate fix without editing anime.js) -// Exit: 0 all pass, 1 a case failed, 2 a scenario was not reached. +#!/usr/bin/env node +/** + * qa-anime-sky — the anime style must cel-shade the Earth, and leave the sky + * without bands. + * + * Earlier the anime shader exempted the far plane, judged from the + * post-process depth, from its luminance bands. That depth cannot tell the + * sky from far geometry, so the Earth lost its bands. The shader now matches + * main, and src/ui/visualEffects.js fades the Milky Way out with the style. + * Each case runs headless against a Vite dev server: + * + * satellites whole globe from 20 000 km with the satellites layer on + * (near clamps to 0.1 m): no Earth pixel may skip the bands. + * keyless keyless stack, Denver 1700 m looking west: no terrain pixel + * above the ellipsoid horizon may skip the bands. + * milkyway the real anime style, looking away from the Earth at the + * Galactic Centre: at most 0.5 % of the sky (the stars) may + * reach the first band. + * + * The first two cases compile the checkout's anime shader with a debug + * output (R = pixel skipped the bands, G = raw depth 1.0) as an extra stage. + * + * Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/] [--case satellites|keyless|milkyway|all] + * Exit: 0 all pass, 1 a case failed, 2 a scenario was not reached or the case is unknown. + */ +import puppeteer from 'puppeteer'; import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { fileURLToPath } from 'node:url'; const opt = (name, fallback) => { const i = process.argv.indexOf(`--${name}`); return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; }; -const ROOT = path.resolve(opt('repo', process.cwd())); const URL_ = opt('url', 'http://localhost:4333/'); -const CASES = - opt('case', 'all') === 'all' - ? ['satellites', 'keyless', 'milkyway'] - : [opt('case')]; -const SPACE_EXPR = opt('space-expr'); -// Run from the repository root, or pass --repo. -const { default: puppeteer } = await import( - path.join(ROOT, 'node_modules/puppeteer/lib/puppeteer/puppeteer.js') -); +const ALL_CASES = ['satellites', 'keyless', 'milkyway']; +const requested = opt('case', 'all'); +const CASES = requested === 'all' ? ALL_CASES : [requested]; +if (!CASES.every((c) => ALL_CASES.includes(c))) { + console.error( + `unknown case ${requested}; expected ${ALL_CASES.join('|')}|all`, + ); + process.exit(2); +} -let fs = readFileSync(path.join(ROOT, 'src/styles/anime.js'), 'utf8'); +let fs = readFileSync( + fileURLToPath(new URL('../src/styles/anime.js', import.meta.url)), + 'utf8', +); fs = fs .slice(fs.indexOf('/* glsl */ `') + 12, fs.lastIndexOf('`')) .replace('uniform float intensity;', 'const float intensity = 1.0;') @@ -49,11 +56,6 @@ if (!/uniform\s+sampler2D\s+depthTexture/.test(fs)) 'uniform sampler2D colorTexture;', 'uniform sampler2D colorTexture;\n uniform sampler2D depthTexture;', ); -if (SPACE_EXPR) { - const m = fs.match(/bool space = ([^;]+);/); - if (!m) throw new Error('--space-expr: no `bool space = …;` in anime.js'); - fs = fs.replace(m[0], `bool space = ${SPACE_EXPR};`); -} if (!/float bands\b/.test(fs)) throw new Error( 'anime.js has no `bands` variable: adapt the debug line of this script', @@ -289,7 +291,15 @@ try { total: px.length / 4, banded, visibility: s.skyBox.milkyWayVisibility, - anime: g.styleManager.currentStyle ?? null, + animeOn: (() => { + const stages = s.postProcessStages; + for (let i = 0; i < stages.length; i++) { + const stage = stages.get(i); + if (stage.name === 'godsEyeView_anime') + return stage.enabled && stage.uniforms.intensity > 0.99; + } + return false; + })(), }; g.styleManager.setStyle('normal', { userInitiated: true }); window.__dbg.enabled = true; @@ -298,7 +308,7 @@ try { verdict( 'milkyway', r.banded <= 0.005 * r.total, - r.total > 0, + r.total > 0 && r.animeOn, `${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)`, r, ); diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index da21b781c..b4a3cec0b 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -115,7 +115,7 @@ export class VisualEffects { // Anime's four luminance bands turn the faint Milky Way into blotches, and // a post-process cannot tell the sky from far geometry reliably (near // plane clamps, keyless terrain above the ellipsoid horizon): fade the - // glow out with the style instead. Stars survive as banded points. + // glow out with the style instead. Bright stars survive as banded points. if (stage === this.stages.anime) { const sky = this.viewer.scene?.skyBox; if (sky && 'milkyWayVisibility' in sky) @@ -228,7 +228,7 @@ export class VisualEffects { this.transitions.clear(); } - /** Remove owned stages and restore the borrowed bloom stage after callers release it. */ + /** Remove owned stages and restore the borrowed bloom stage and sky after callers release them. */ destroy() { if (this.destroyed) return; this.stop(); @@ -240,5 +240,11 @@ export class VisualEffects { Object.assign(this.bloomStage.uniforms, this.previousBloom.uniforms); this.bloomStage.enabled = this.previousBloom.enabled; } + // Anime may have faded the Milky Way out; a surviving viewer gets it back. + const sky = this.viewer.scene.skyBox; + if (sky && 'milkyWayVisibility' in sky) { + sky.milkyWayVisibility = 1; + this.requestRender('style-stage'); + } } } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 62f9771d3..21f744985 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -179,6 +179,24 @@ test('the anime stage fades the Milky Way out with its own intensity', () => { f.effects.destroy(); }); +test('destroying the effects gives a surviving viewer its Milky Way back', () => { + const f = fixture(); + const sky = { milkyWayVisibility: 1 }; + f.effects.viewer.scene.skyBox = sky; + f.effects.initStyles(); + f.effects.startTransition('anime', 0, 1); + f.tick(250); + f.effects.stop(); + const frozen = sky.milkyWayVisibility; + assert.ok( + frozen > 0 && frozen < 1, + 'stop keeps the stages, and the fade, as they are', + ); + f.effects.destroy(); + assert.equal(sky.milkyWayVisibility, 1); + assert.equal(f.requests.at(-1), 'style-stage'); +}); + test('bloom retains its dead zone, normalized intensity and independent toggle', () => { const f = fixture(); f.effects.initPostProcess();