fix(sky): restore the Milky Way on teardown, tidy the anime QA script

- VisualEffects.destroy() puts skyBox.milkyWayVisibility back to 1, as it
  already restores the borrowed bloom stage, so a surviving viewer does not
  keep a faded sky.
- Rename scripts/qa-anime-space-mask.mjs to qa-anime-sky.mjs: rewrite the
  header for the current approach, drop --space-expr and --repo, import
  puppeteer as a package, reject unknown cases and count the milkyway case
  as reached only while the anime stage is on.
- Put both sky QA scripts under format:check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-20 00:42:22 +04:00
1 parent 65b98b4971
commit 8deb47560e
4 files changed
+78 -42

No files matched your search

+3 -1
View File
@@ -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"
]
@@ -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 "<GLSL bool expression>"] (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,
);
+8 -2
View File
@@ -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');
}
}
}
+18
View File
@@ -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();