diff --git a/scripts/qa-anime-space-mask.mjs b/scripts/qa-anime-space-mask.mjs new file mode 100644 index 000000000..c73561e94 --- /dev/null +++ b/scripts/qa-anime-space-mask.mjs @@ -0,0 +1,310 @@ +// 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. +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') +); + +let fs = readFileSync(path.join(ROOT, 'src/styles/anime.js'), 'utf8'); +fs = fs + .slice(fs.indexOf('/* glsl */ `') + 12, fs.lastIndexOf('`')) + .replace('uniform float intensity;', 'const float intensity = 1.0;') + .replace('uniform float saturation;', 'const float saturation = 1.0;') + .replace('uniform float edgeThick;', 'const float edgeThick = 0.5;'); +if (!/uniform\s+sampler2D\s+depthTexture/.test(fs)) + fs = fs.replace( + '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', + ); +fs = fs + .replace( + /out_FragColor = [^;]+;\s*}\s*$/, + `out_FragColor = mode < 0.5 ? texture(colorTexture, uv) + : vec4(bands > 255.0 ? 1.0 : 0.0, texture(depthTexture, uv).r >= 1.0 ? 1.0 : 0.0, 0.0, 1.0); + }`, + ) + .replace('void main()', 'uniform float mode;\n void main()'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--ignore-gpu-blocklist'], +}); +let failed = false, + unreached = false; +try { + const page = await browser.newPage(); + page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300))); + await page.setViewport({ width: 1000, height: 700 }); + await page.evaluateOnNewDocument(() => + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'), + ); + await page.goto(URL_, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, { + timeout: 120000, + }); + await sleep(12000); + await page.evaluate(async (fs) => { + const entry = performance + .getEntriesByType('resource') + .map((e) => e.name) + .find((n) => /\.vite\/deps\/cesium\.js/.test(n)); + if (!entry) + throw new Error( + 'run against the Vite dev server (needs .vite/deps/cesium.js)', + ); + const C = (window.__C = await import(entry)); + window.__dbg = window.__godsEyeView.viewer.scene.postProcessStages.add( + new C.PostProcessStage({ fragmentShader: fs, uniforms: { mode: 1 } }), + ); + }, fs); + const setView = (v) => + page.evaluate((v) => { + const C = window.__C, + s = window.__godsEyeView.viewer.scene; + s.camera.cancelFlight(); + s.camera.setView({ + destination: C.Cartesian3.fromDegrees(v.lon, v.lat, v.h), + orientation: { + heading: C.Math.toRadians(v.heading ?? 0), + pitch: C.Math.toRadians(v.pitch), + roll: 0, + }, + }); + s.requestRender(); + }, v); + // returns per-pixel {exempt, raw1, ground?} counts + const measure = (truth) => + page.evaluate((truth) => { + const s = window.__godsEyeView.viewer.scene; + const read = () => { + s.requestRender(); + s.render(); + const gl = s.context._gl, + w = gl.drawingBufferWidth, + h = gl.drawingBufferHeight, + px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + window.__dbg.uniforms.mode = 1; + const px = read(); + let g; + if (truth) { + const keep = [s.skyAtmosphere, s.skyBox, s.sun, s.moon].map( + (o) => o && o.show, + ); + [s.skyAtmosphere, s.skyBox, s.sun, s.moon].forEach( + (o) => o && (o.show = false), + ); + window.__dbg.uniforms.mode = 0; + g = read(); + [s.skyAtmosphere, s.skyBox, s.sun, s.moon].forEach( + (o, i) => o && (o.show = keep[i]), + ); + window.__dbg.uniforms.mode = 1; + } + const r = { + total: px.length / 4, + near: s._view.frustumCommandsList[0]?.near ?? null, + frusta: s._view.frustumCommandsList.length, + geom: 0, + geomExempt: 0, + raw1: 0, + raw1Exempt: 0, + ground: 0, + groundRaw1: 0, + groundExempt: 0, + clearGlobeDepth: s._environmentState.clearGlobeDepth, + }; + for (let i = 0; i < px.length; i += 4) { + const ex = px[i] > 127, + raw1 = px[i + 1] > 127; + if (raw1) { + r.raw1++; + if (ex) r.raw1Exempt++; + } else { + r.geom++; + if (ex) r.geomExempt++; + } + if (g && g[i] + g[i + 1] + g[i + 2] > 6) { + r.ground++; + if (raw1) r.groundRaw1++; + if (ex) r.groundExempt++; + } + } + return r; + }, truth); + const verdict = (name, ok, reached, msg, r) => { + const tag = !reached ? 'UNREACHED' : ok ? 'PASS' : 'FAIL'; + if (!reached) unreached = true; + else if (!ok) failed = true; + console.log(`${tag} ${name}: ${msg}\n ${JSON.stringify(r)}`); + }; + + for (const c of CASES) { + if (c === 'satellites') { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('satellites', true, { + origin: 'user', + }), + ); + await sleep(20000); + await setView({ lon: 10, lat: 20, h: 2.0e7, pitch: -90 }); + await sleep(5000); + const r = await measure(false); + verdict( + 'satellites', + r.geomExempt <= 0.01 * r.geom, + r.frusta > 0 && r.near !== null && r.near < 1 && r.geom > 50000, + `${r.geomExempt} of ${r.geom} Earth pixels exempt from cel-shading (near ${r.near} m)`, + r, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('satellites', false, { + origin: 'user', + }), + ); + await sleep(5000); + } else if (c === 'keyless') { + await setView({ + lon: -104.99, + lat: 39.74, + h: 1700, + heading: 270, + pitch: 0, + }); + await sleep(25000); + const r = await measure(true); + verdict( + 'keyless', + r.groundExempt <= 0.01 * r.ground, + r.clearGlobeDepth && r.groundRaw1 > 1000, + `${r.groundExempt} of ${r.ground} terrain pixels exempt (${r.groundRaw1} terrain pixels have raw depth 1.0)`, + r, + ); + } else if (c === 'milkyway') { + const r = await page.evaluate(async () => { + const C = window.__C, + g = window.__godsEyeView, + v = g.viewer, + s = v.scene; + window.__dbg.enabled = false; + g.styleManager.setStyle('anime', { userInitiated: true }); + const t = v.clock.currentTime; + await C.Transforms.preloadIcrfFixed( + new C.TimeInterval({ + start: C.JulianDate.addDays(t, -1, new C.JulianDate()), + stop: C.JulianDate.addDays(t, 1, new C.JulianDate()), + }), + ); + const m = C.Transforms.computeIcrfToFixedMatrix(t); + const ra = C.Math.toRadians(266.4), + dec = C.Math.toRadians(-29); + const look = C.Matrix3.multiplyByVector( + m, + new C.Cartesian3( + Math.cos(dec) * Math.cos(ra), + Math.cos(dec) * Math.sin(ra), + Math.sin(dec), + ), + new C.Cartesian3(), + ); + const north = C.Matrix3.multiplyByVector( + m, + C.Cartesian3.UNIT_Z, + new C.Cartesian3(), + ); + const right = C.Cartesian3.normalize( + C.Cartesian3.cross(look, north, new C.Cartesian3()), + new C.Cartesian3(), + ); + s.camera.cancelFlight(); + s.camera.setView({ + destination: C.Cartesian3.multiplyByScalar( + look, + 3e7, + new C.Cartesian3(), + ), + orientation: { + direction: look, + up: C.Cartesian3.cross(right, look, new C.Cartesian3()), + }, + }); + await new Promise((res) => setTimeout(res, 2500)); + s.requestRender(); + s.render(); + const gl = s.context._gl, + w = gl.drawingBufferWidth, + h = gl.drawingBufferHeight, + px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + let banded = 0; + for (let i = 0; i < px.length; i += 4) + if (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] >= 51) + banded++; + const out = { + total: px.length / 4, + banded, + visibility: s.skyBox.milkyWayVisibility, + anime: g.styleManager.currentStyle ?? null, + }; + g.styleManager.setStyle('normal', { userInitiated: true }); + window.__dbg.enabled = true; + return out; + }); + verdict( + 'milkyway', + r.banded <= 0.005 * r.total, + r.total > 0, + `${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)`, + r, + ); + } + } +} finally { + await browser.close(); +} +process.exitCode = failed ? 1 : unreached ? 2 : 0; diff --git a/src/sky/starrySky.js b/src/sky/starrySky.js index 7cdc3b5d7..d77892d6a 100644 --- a/src/sky/starrySky.js +++ b/src/sky/starrySky.js @@ -167,6 +167,8 @@ export class StarrySky { /** Matches `SkyBox#show`. */ this.show = true; this.milkyWayIntensity = MILKY_WAY_INTENSITY; + /** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */ + this.milkyWayVisibility = 1; this.starIntensity = STAR_INTENSITY; this._requestRender = requestRender; this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY); @@ -190,7 +192,7 @@ export class StarrySky { this._milkyWayUniforms = { ...uniforms, u_milkyWay: () => this._cubeMap, - u_intensity: () => this.milkyWayIntensity, + u_intensity: () => this.milkyWayIntensity * this.milkyWayVisibility, }; this._starUniforms = { ...uniforms, diff --git a/src/sky/starrySky.test.mjs b/src/sky/starrySky.test.mjs index 10d483914..1d9d2f8a0 100644 --- a/src/sky/starrySky.test.mjs +++ b/src/sky/starrySky.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { + MILKY_WAY_INTENSITY, STARRY_SKY_CESIUM_VERSION, SKY_ROTATION_EPSILON_SECONDS, StarrySky, @@ -9,6 +10,9 @@ import { isSkyRotationCurrent, } from './starrySky.js'; +const close = (actual, expected, epsilon = 1e-9) => + assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} vs ${expected}`); + test('the sky-box adapter was validated against this engine version', () => { assert.equal( Cesium.VERSION, @@ -115,3 +119,11 @@ for (const iso of ['1970-01-01T00:00:00Z', '2060-01-01T00:00:00Z']) { } }); } + +test('the Milky Way glow scales with its visibility, for styles that hide it', () => { + const sky = new StarrySky({ requestRender: () => {} }); + close(sky._milkyWayUniforms.u_intensity(), MILKY_WAY_INTENSITY); + sky.milkyWayVisibility = 0; + assert.equal(sky._milkyWayUniforms.u_intensity(), 0); + sky.destroy(); +}); diff --git a/src/styles/anime.js b/src/styles/anime.js index 26504830b..deb79c355 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -12,7 +12,6 @@ export const animeShader = { }, fragmentShader: /* glsl */ ` uniform sampler2D colorTexture; - uniform sampler2D depthTexture; uniform vec2 colorTextureDimensions; uniform float intensity; uniform float saturation; @@ -39,12 +38,9 @@ export const animeShader = { vec2 texel = 1.0 / colorTextureDimensions; vec4 color = texture(colorTexture, uv); - // Cel-shade: quantize luminance into bands. Not the far plane: four - // bands turn the faint Milky Way behind the globe into blotches. Its - // packed depth reads a few 1e-7 short of 1.0. + // Cel-shade: quantize luminance into bands float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - bool space = czm_readDepth(depthTexture, uv) > 0.99999; - float bands = space ? 256.0 : mix(256.0, 4.0, intensity); + float bands = mix(256.0, 4.0, intensity); float celLuma = floor(luma * bands + 0.5) / bands; float lumaScale = (luma > 0.001) ? celLuma / luma : 1.0; vec3 celColor = color.rgb * lumaScale; diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 6533bb748..da21b781c 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -112,6 +112,15 @@ export class VisualEffects { if (this.stopped || !stage) return; stage.uniforms.intensity = value; stage.enabled = value > 0.001; + // 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. + if (stage === this.stages.anime) { + const sky = this.viewer.scene?.skyBox; + if (sky && 'milkyWayVisibility' in sky) + sky.milkyWayVisibility = 1 - value; + } if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop(); this.requestRender('style-stage'); diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 76615d0e8..62f9771d3 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -158,6 +158,27 @@ test('direct Cockpit intensity writes are reconciled before rendering', () => { f.effects.destroy(); }); +test('the anime stage fades the Milky Way out with its own intensity', () => { + const f = fixture(); + const sky = { milkyWayVisibility: 1 }; + f.effects.viewer.scene.skyBox = sky; + f.effects.initStyles(); + f.effects.setStageIntensity(f.effects.stages.thermal, 1); + assert.equal(sky.milkyWayVisibility, 1, 'other styles keep the glow'); + f.effects.setStageIntensity(f.effects.stages.anime, 0.25); + assert.equal(sky.milkyWayVisibility, 0.75); + f.effects.setStageIntensity(f.effects.stages.anime, 1); + assert.equal(sky.milkyWayVisibility, 0); + f.effects.setStageIntensity(f.effects.stages.anime, 0); + assert.equal(sky.milkyWayVisibility, 1); + // A sky box without the knob (Cesium's own) is left alone. + const plain = {}; + f.effects.viewer.scene.skyBox = plain; + f.effects.setStageIntensity(f.effects.stages.anime, 1); + assert.deepEqual(plain, {}); + f.effects.destroy(); +}); + test('bloom retains its dead zone, normalized intensity and independent toggle', () => { const f = fixture(); f.effects.initPostProcess();