mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
style: format visual effects and shader definitions
This commit is contained in:
1 parent
c30f7cc3be
commit
1bbb6d9c70
8 files changed
+356
-83
No files matched your search
@@ -136,5 +136,17 @@
|
|||||||
"src/ui/visualInput.test.mjs",
|
"src/ui/visualInput.test.mjs",
|
||||||
"scripts/qa-visual-input.mjs",
|
"scripts/qa-visual-input.mjs",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/displayControls.test.mjs"
|
"src/ui/displayControls.test.mjs",
|
||||||
|
"src/ui/effects.js",
|
||||||
|
"src/ui/visualEffects.js",
|
||||||
|
"src/ui/visualPresets.js",
|
||||||
|
"src/ui/visualEffects.test.mjs",
|
||||||
|
"src/bloom.js",
|
||||||
|
"src/styles/retro.js",
|
||||||
|
"src/styles/surveillance.js",
|
||||||
|
"src/styles/thermal.js",
|
||||||
|
"src/styles/anime.js",
|
||||||
|
"src/styles/noir.js",
|
||||||
|
"src/styles/snow.js",
|
||||||
|
"scripts/qa-visual-effects.mjs"
|
||||||
]
|
]
|
||||||
+166
-44
@@ -3,7 +3,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] });
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal', '--enable-gpu']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||||
|
],
|
||||||
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
@@ -17,67 +25,165 @@ try {
|
|||||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||||
url.searchParams.set('welcome', '0');
|
url.searchParams.set('welcome', '0');
|
||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._visualEffects && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60_000 });
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._visualEffects &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
viewer.camera.cancelFlight?.();
|
viewer.camera.cancelFlight?.();
|
||||||
viewer.scene.tweens?.removeAll?.();
|
viewer.scene.tweens?.removeAll?.();
|
||||||
viewer.camera.setView({
|
viewer.camera.setView({
|
||||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ longitude: -97.7431 * Math.PI / 180, latitude: 30.2568 * Math.PI / 180, height: 1200 }),
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: (-97.7431 * Math.PI) / 180,
|
||||||
|
latitude: (30.2568 * Math.PI) / 180,
|
||||||
|
height: 1200,
|
||||||
|
}),
|
||||||
orientation: { heading: 0, pitch: -0.85, roll: 0 },
|
orientation: { heading: 0, pitch: -0.85, roll: 0 },
|
||||||
});
|
});
|
||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
});
|
});
|
||||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
await page.evaluate(
|
||||||
const settled = await page.waitForFunction(() => {
|
() =>
|
||||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
new Promise((resolve) =>
|
||||||
for (let i = 0; i < primitives.length; i++) {
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
const primitive = primitives.get(i);
|
),
|
||||||
if (typeof primitive.tilesLoaded === 'boolean' && !primitive.tilesLoaded) return false;
|
);
|
||||||
}
|
const settled = await page
|
||||||
return true;
|
.waitForFunction(
|
||||||
}, { timeout: 60_000 }).then(() => true, () => false);
|
() => {
|
||||||
|
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||||
|
for (let i = 0; i < primitives.length; i++) {
|
||||||
|
const primitive = primitives.get(i);
|
||||||
|
if (
|
||||||
|
typeof primitive.tilesLoaded === 'boolean' &&
|
||||||
|
!primitive.tilesLoaded
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
check('visible tile content settles before visual captures', settled);
|
check('visible tile content settles before visual captures', settled);
|
||||||
fs.mkdirSync('qa-shots/visual-effects', { recursive: true });
|
fs.mkdirSync('qa-shots/visual-effects', { recursive: true });
|
||||||
for (const style of ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'normal']) {
|
for (const style of [
|
||||||
await page.evaluate((name) => window.__godsEyeView.styleManager.setStyle(name), style);
|
'normal',
|
||||||
await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 });
|
'retro',
|
||||||
|
'surveillance',
|
||||||
|
'thermal',
|
||||||
|
'anime',
|
||||||
|
'noir',
|
||||||
|
'snow',
|
||||||
|
'normal',
|
||||||
|
]) {
|
||||||
|
await page.evaluate(
|
||||||
|
(name) => window.__godsEyeView.styleManager.setStyle(name),
|
||||||
|
style,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const effects = manager._visualEffects;
|
const effects = manager._visualEffects;
|
||||||
const visible = Object.entries(effects.stages).filter(([, stage]) => stage.enabled).map(([name]) => name);
|
const visible = Object.entries(effects.stages)
|
||||||
const animated = Object.values(effects.stages).some((stage) => stage.enabled && stage.uniforms.time !== undefined && stage.uniforms.intensity > 0.001);
|
.filter(([, stage]) => stage.enabled)
|
||||||
return { active: manager.activeStyle, visible, clock: effects.frameId !== null, animated };
|
.map(([name]) => name);
|
||||||
|
const animated = Object.values(effects.stages).some(
|
||||||
|
(stage) =>
|
||||||
|
stage.enabled &&
|
||||||
|
stage.uniforms.time !== undefined &&
|
||||||
|
stage.uniforms.intensity > 0.001,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
active: manager.activeStyle,
|
||||||
|
visible,
|
||||||
|
clock: effects.frameId !== null,
|
||||||
|
animated,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
check(`${style}: only the selected style remains visible`, state.active === style && JSON.stringify(state.visible) === JSON.stringify(style === 'normal' ? [] : [style]));
|
check(
|
||||||
check(`${style}: the animation clock follows visible animated stages`, state.clock === state.animated);
|
`${style}: only the selected style remains visible`,
|
||||||
|
state.active === style &&
|
||||||
|
JSON.stringify(state.visible) ===
|
||||||
|
JSON.stringify(style === 'normal' ? [] : [style]),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${style}: the animation clock follows visible animated stages`,
|
||||||
|
state.clock === state.animated,
|
||||||
|
);
|
||||||
if (['normal', 'surveillance', 'thermal'].includes(style)) {
|
if (['normal', 'surveillance', 'thermal'].includes(style)) {
|
||||||
await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` });
|
await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` });
|
||||||
if (style === 'surveillance') await page.evaluate(() => window.__godsEyeView.viewer.camera.lookRight(0.2));
|
if (style === 'surveillance')
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.viewer.camera.lookRight(0.2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
manager.setStyle('noir'); manager.setStyle('retro'); manager.setStyle('normal');
|
manager.setStyle('noir');
|
||||||
|
manager.setStyle('retro');
|
||||||
|
manager.setStyle('normal');
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 });
|
await page.waitForFunction(
|
||||||
check('rapid switches settle to Normal without an old style or clock', await page.evaluate(() => {
|
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||||
const effects = window.__godsEyeView.styleManager._visualEffects;
|
{ timeout: 10_000 },
|
||||||
return effects.frameId === null && Object.values(effects.stages).every((stage) => !stage.enabled);
|
);
|
||||||
}));
|
check(
|
||||||
check('bloom actions, effect state and saved snapshot agree', await page.evaluate(() => {
|
'rapid switches settle to Normal without an old style or clock',
|
||||||
const manager = window.__godsEyeView.styleManager;
|
await page.evaluate(() => {
|
||||||
const result = manager.setBloom({ enabled: true, intensityPct: 98 });
|
const effects = window.__godsEyeView.styleManager._visualEffects;
|
||||||
const snapshot = manager.getVisualState();
|
return (
|
||||||
return result.ok && result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && manager._visualEffects.bloomIntensity === 98 && manager._bloomStage.enabled;
|
effects.frameId === null &&
|
||||||
}));
|
Object.values(effects.stages).every((stage) => !stage.enabled)
|
||||||
check('sharpen actions, effect state and saved snapshot agree', await page.evaluate(() => {
|
);
|
||||||
const manager = window.__godsEyeView.styleManager;
|
}),
|
||||||
const result = manager.setSharpen({ enabled: true, intensityPct: 61 });
|
);
|
||||||
return result.ok && result.sharpen.intensityPct === 61 && manager.getVisualState().sharpen.intensity === 61 && manager._visualEffects.sharpenIntensity === 0.61 && Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9;
|
check(
|
||||||
}));
|
'bloom actions, effect state and saved snapshot agree',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const result = manager.setBloom({ enabled: true, intensityPct: 98 });
|
||||||
|
const snapshot = manager.getVisualState();
|
||||||
|
return (
|
||||||
|
result.ok &&
|
||||||
|
result.bloom.intensityPct === 98 &&
|
||||||
|
snapshot.bloom.intensity === 98 &&
|
||||||
|
manager._visualEffects.bloomIntensity === 98 &&
|
||||||
|
manager._bloomStage.enabled
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'sharpen actions, effect state and saved snapshot agree',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const result = manager.setSharpen({ enabled: true, intensityPct: 61 });
|
||||||
|
return (
|
||||||
|
result.ok &&
|
||||||
|
result.sharpen.intensityPct === 61 &&
|
||||||
|
manager.getVisualState().sharpen.intensity === 61 &&
|
||||||
|
manager._visualEffects.sharpenIntensity === 0.61 &&
|
||||||
|
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
await page.setViewport({ width: 620, height: 900 });
|
await page.setViewport({ width: 620, height: 900 });
|
||||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false }));
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' });
|
await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' });
|
||||||
const teardown = await page.evaluate(async () => {
|
const teardown = await page.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
@@ -87,17 +193,33 @@ try {
|
|||||||
const collection = manager.viewer.scene.postProcessStages;
|
const collection = manager.viewer.scene.postProcessStages;
|
||||||
const previousBloom = effects.previousBloom;
|
const previousBloom = effects.previousBloom;
|
||||||
const pending = manager.dispose();
|
const pending = manager.dispose();
|
||||||
const stoppedBeforeAwait = effects.stopped && effects.frameId === null && effects.transitions.size === 0;
|
const stoppedBeforeAwait =
|
||||||
|
effects.stopped &&
|
||||||
|
effects.frameId === null &&
|
||||||
|
effects.transitions.size === 0;
|
||||||
await pending;
|
await pending;
|
||||||
return {
|
return {
|
||||||
stoppedBeforeAwait,
|
stoppedBeforeAwait,
|
||||||
removed: owned.every((stage) => !collection.contains(stage)),
|
removed: owned.every((stage) => !collection.contains(stage)),
|
||||||
restored: effects.bloomStage.enabled === previousBloom.enabled && Object.entries(previousBloom.uniforms).every(([name, value]) => effects.bloomStage.uniforms[name] === value),
|
restored:
|
||||||
|
effects.bloomStage.enabled === previousBloom.enabled &&
|
||||||
|
Object.entries(previousBloom.uniforms).every(
|
||||||
|
([name, value]) => effects.bloomStage.uniforms[name] === value,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check('UI teardown stops effect animation before its first await', teardown.stoppedBeforeAwait);
|
check(
|
||||||
check('UI teardown removes owned stages and restores borrowed bloom', teardown.removed && teardown.restored);
|
'UI teardown stops effect animation before its first await',
|
||||||
check('effects do not introduce uncaught browser errors', errors.length === 0);
|
teardown.stoppedBeforeAwait,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'UI teardown removes owned stages and restores borrowed bloom',
|
||||||
|
teardown.removed && teardown.restored,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'effects do not introduce uncaught browser errors',
|
||||||
|
errors.length === 0,
|
||||||
|
);
|
||||||
if (errors.length) console.log(JSON.stringify(errors));
|
if (errors.length) console.log(JSON.stringify(errors));
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
+9
-2
@@ -43,7 +43,11 @@ export function clampBloomIntensity(value) {
|
|||||||
* @returns {number} equivalent v2 intensity
|
* @returns {number} equivalent v2 intensity
|
||||||
*/
|
*/
|
||||||
export function legacyBloomToV2(value) {
|
export function legacyBloomToV2(value) {
|
||||||
const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX);
|
const legacy = clamp(
|
||||||
|
Math.round(Number(value) || 0),
|
||||||
|
0,
|
||||||
|
LEGACY_BLOOM_INTENSITY_MAX,
|
||||||
|
);
|
||||||
return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2);
|
return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) {
|
|||||||
if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT;
|
if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT;
|
||||||
|
|
||||||
const normalizedVersion = Number(version);
|
const normalizedVersion = Number(version);
|
||||||
if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) {
|
if (
|
||||||
|
Number.isFinite(normalizedVersion) &&
|
||||||
|
normalizedVersion >= BLOOM_SCALE_VERSION
|
||||||
|
) {
|
||||||
return clampBloomIntensity(num);
|
return clampBloomIntensity(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const nightVisionShader = {
|
|||||||
name: 'surveillance',
|
name: 'surveillance',
|
||||||
uniforms: {
|
uniforms: {
|
||||||
gain: { default: 0.55, min: 0, max: 1, label: 'Gain' },
|
gain: { default: 0.55, min: 0, max: 1, label: 'Gain' },
|
||||||
bloom: { default: 0.30, min: 0, max: 1, label: 'Bloom' },
|
bloom: { default: 0.3, min: 0, max: 1, label: 'Bloom' },
|
||||||
scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' },
|
scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' },
|
||||||
pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' },
|
pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' },
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+6
-1
@@ -1,2 +1,7 @@
|
|||||||
export { VisualEffects } from './visualEffects.js';
|
export { VisualEffects } from './visualEffects.js';
|
||||||
export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js';
|
export {
|
||||||
|
STYLES,
|
||||||
|
GLOBAL_POST_DEFAULTS,
|
||||||
|
STYLE_PRESET_DEFAULTS,
|
||||||
|
MILITARY_DETECTION_PRESET,
|
||||||
|
} from './visualPresets.js';
|
||||||
+71
-18
@@ -1,6 +1,14 @@
|
|||||||
import { PostProcessStage } from 'cesium';
|
import { PostProcessStage } from 'cesium';
|
||||||
import { bloomStrengthFromIntensity, clampBloomIntensity, BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
|
import {
|
||||||
import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS } from './visualPresets.js';
|
bloomStrengthFromIntensity,
|
||||||
|
clampBloomIntensity,
|
||||||
|
BLOOM_INTENSITY_DEFAULT,
|
||||||
|
} from '../bloom.js';
|
||||||
|
import {
|
||||||
|
STYLES,
|
||||||
|
SHARPEN_SHADER,
|
||||||
|
TRANSITION_DURATION_MS,
|
||||||
|
} from './visualPresets.js';
|
||||||
|
|
||||||
/** Own the post-process stages and their animation, without DOM dependencies. */
|
/** Own the post-process stages and their animation, without DOM dependencies. */
|
||||||
export class VisualEffects {
|
export class VisualEffects {
|
||||||
@@ -8,10 +16,15 @@ export class VisualEffects {
|
|||||||
* @param {object} options - Viewer, render ownership callbacks and optional clocks.
|
* @param {object} options - Viewer, render ownership callbacks and optional clocks.
|
||||||
* Construction is inert; initStyles/initPostProcess retain startup ordering.
|
* Construction is inert; initStyles/initPostProcess retain startup ordering.
|
||||||
*/
|
*/
|
||||||
constructor({ viewer, requestRender, holdRender, releaseRender,
|
constructor({
|
||||||
|
viewer,
|
||||||
|
requestRender,
|
||||||
|
holdRender,
|
||||||
|
releaseRender,
|
||||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||||
cancelFrame = (id) => cancelAnimationFrame(id),
|
cancelFrame = (id) => cancelAnimationFrame(id),
|
||||||
now = () => performance.now(), wallNow = () => Date.now(),
|
now = () => performance.now(),
|
||||||
|
wallNow = () => Date.now(),
|
||||||
createStage = (options) => new PostProcessStage(options),
|
createStage = (options) => new PostProcessStage(options),
|
||||||
}) {
|
}) {
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
@@ -43,9 +56,15 @@ export class VisualEffects {
|
|||||||
if (this.stopped || this.stageEntries.length) return;
|
if (this.stopped || this.stageEntries.length) return;
|
||||||
for (const [name, shader] of Object.entries(STYLES)) {
|
for (const [name, shader] of Object.entries(STYLES)) {
|
||||||
const uniforms = { intensity: 0.0 };
|
const uniforms = { intensity: 0.0 };
|
||||||
if (shader.fragmentShader.includes('uniform float time')) uniforms.time = 0.0;
|
if (shader.fragmentShader.includes('uniform float time'))
|
||||||
for (const [name, meta] of Object.entries(shader.uniforms || {})) uniforms[name] = meta.default;
|
uniforms.time = 0.0;
|
||||||
const stage = this.createStage({ name: `godsEyeView_${name}`, fragmentShader: shader.fragmentShader, uniforms });
|
for (const [name, meta] of Object.entries(shader.uniforms || {}))
|
||||||
|
uniforms[name] = meta.default;
|
||||||
|
const stage = this.createStage({
|
||||||
|
name: `godsEyeView_${name}`,
|
||||||
|
fragmentShader: shader.fragmentShader,
|
||||||
|
uniforms,
|
||||||
|
});
|
||||||
stage.enabled = false;
|
stage.enabled = false;
|
||||||
this.viewer.scene.postProcessStages.add(stage);
|
this.viewer.scene.postProcessStages.add(stage);
|
||||||
this.stages[name] = stage;
|
this.stages[name] = stage;
|
||||||
@@ -56,11 +75,34 @@ export class VisualEffects {
|
|||||||
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
|
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
|
||||||
if (this.stopped || this.sharpenStage) return;
|
if (this.stopped || this.sharpenStage) return;
|
||||||
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
|
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
|
||||||
const names = ['glowOnly', 'contrast', 'brightness', 'delta', 'sigma', 'stepSize'];
|
const names = [
|
||||||
this.previousBloom = { enabled: this.bloomStage.enabled, uniforms: Object.fromEntries(names.map((name) => [name, this.bloomStage.uniforms[name]])) };
|
'glowOnly',
|
||||||
|
'contrast',
|
||||||
|
'brightness',
|
||||||
|
'delta',
|
||||||
|
'sigma',
|
||||||
|
'stepSize',
|
||||||
|
];
|
||||||
|
this.previousBloom = {
|
||||||
|
enabled: this.bloomStage.enabled,
|
||||||
|
uniforms: Object.fromEntries(
|
||||||
|
names.map((name) => [name, this.bloomStage.uniforms[name]]),
|
||||||
|
),
|
||||||
|
};
|
||||||
this.bloomStage.enabled = false;
|
this.bloomStage.enabled = false;
|
||||||
Object.assign(this.bloomStage.uniforms, { glowOnly: false, contrast: 256.0, brightness: -0.35, delta: 0.25, sigma: 0.35, stepSize: 1.0 });
|
Object.assign(this.bloomStage.uniforms, {
|
||||||
this.sharpenStage = this.createStage({ name: 'godsEyeView_sharpen', fragmentShader: SHARPEN_SHADER, uniforms: { amount: 1.3 } });
|
glowOnly: false,
|
||||||
|
contrast: 256.0,
|
||||||
|
brightness: -0.35,
|
||||||
|
delta: 0.25,
|
||||||
|
sigma: 0.35,
|
||||||
|
stepSize: 1.0,
|
||||||
|
});
|
||||||
|
this.sharpenStage = this.createStage({
|
||||||
|
name: 'godsEyeView_sharpen',
|
||||||
|
fragmentShader: SHARPEN_SHADER,
|
||||||
|
uniforms: { amount: 1.3 },
|
||||||
|
});
|
||||||
this.sharpenStage.enabled = false;
|
this.sharpenStage.enabled = false;
|
||||||
this.viewer.scene.postProcessStages.add(this.sharpenStage);
|
this.viewer.scene.postProcessStages.add(this.sharpenStage);
|
||||||
this.applySharpenIntensity(sharpenIntensity);
|
this.applySharpenIntensity(sharpenIntensity);
|
||||||
@@ -70,17 +112,21 @@ export class VisualEffects {
|
|||||||
if (this.stopped || !stage) return;
|
if (this.stopped || !stage) return;
|
||||||
stage.uniforms.intensity = value;
|
stage.uniforms.intensity = value;
|
||||||
stage.enabled = value > 0.001;
|
stage.enabled = value > 0.001;
|
||||||
if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop();
|
if (stage.enabled && stage.uniforms.time !== undefined)
|
||||||
|
this.startAnimationLoop();
|
||||||
this.requestRender('style-stage');
|
this.requestRender('style-stage');
|
||||||
}
|
}
|
||||||
|
|
||||||
syncStagesEnabledFromIntensity() {
|
syncStagesEnabledFromIntensity() {
|
||||||
for (const [, stage] of this.stageEntries) this.setStageIntensity(stage, stage.uniforms.intensity);
|
for (const [, stage] of this.stageEntries)
|
||||||
|
this.setStageIntensity(stage, stage.uniforms.intensity);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBloomEnabled() {
|
syncBloomEnabled() {
|
||||||
if (this.stopped || !this.bloomStage) return;
|
if (this.stopped || !this.bloomStage) return;
|
||||||
this.bloomStage.enabled = this.bloomEnabled && bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
|
this.bloomStage.enabled =
|
||||||
|
this.bloomEnabled &&
|
||||||
|
bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyBloomIntensity(intensity) {
|
applyBloomIntensity(intensity) {
|
||||||
@@ -89,7 +135,7 @@ export class VisualEffects {
|
|||||||
this.requestRender('bloom');
|
this.requestRender('bloom');
|
||||||
if (!this.bloomStage) return;
|
if (!this.bloomStage) return;
|
||||||
const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity);
|
const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity);
|
||||||
const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94);
|
const strength = rawStrength <= 0.06 ? 0.0 : (rawStrength - 0.06) / 0.94;
|
||||||
const eased = strength * strength * (3.0 - 2.0 * strength);
|
const eased = strength * strength * (3.0 - 2.0 * strength);
|
||||||
this.bloomStage.uniforms.contrast = 255.0 - eased * 168.0;
|
this.bloomStage.uniforms.contrast = 255.0 - eased * 168.0;
|
||||||
this.bloomStage.uniforms.brightness = -0.5 + eased * 0.36;
|
this.bloomStage.uniforms.brightness = -0.5 + eased * 0.36;
|
||||||
@@ -109,7 +155,8 @@ export class VisualEffects {
|
|||||||
applySharpenIntensity(value) {
|
applySharpenIntensity(value) {
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
this.sharpenIntensity = value;
|
this.sharpenIntensity = value;
|
||||||
if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
|
if (this.sharpenStage)
|
||||||
|
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
|
||||||
this.requestRender('sharpen');
|
this.requestRender('sharpen');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +180,15 @@ export class VisualEffects {
|
|||||||
const now = this.now();
|
const now = this.now();
|
||||||
const elapsedSec = (this.wallNow() - this.startTime) / 1000.0;
|
const elapsedSec = (this.wallNow() - this.startTime) / 1000.0;
|
||||||
for (const [name, transition] of this.transitions) {
|
for (const [name, transition] of this.transitions) {
|
||||||
const t = Math.min((now - transition.start) / TRANSITION_DURATION_MS, 1.0);
|
const t = Math.min(
|
||||||
|
(now - transition.start) / TRANSITION_DURATION_MS,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||||
this.setStageIntensity(this.stages[name], transition.from + (transition.to - transition.from) * eased);
|
this.setStageIntensity(
|
||||||
|
this.stages[name],
|
||||||
|
transition.from + (transition.to - transition.from) * eased,
|
||||||
|
);
|
||||||
if (t >= 1.0) {
|
if (t >= 1.0) {
|
||||||
this.setStageIntensity(this.stages[name], transition.to);
|
this.setStageIntensity(this.stages[name], transition.to);
|
||||||
this.transitions.delete(name);
|
this.transitions.delete(name);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { VisualEffects } from './visualEffects.js';
|
import { VisualEffects } from './visualEffects.js';
|
||||||
import { GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js';
|
import {
|
||||||
|
GLOBAL_POST_DEFAULTS,
|
||||||
|
STYLE_PRESET_DEFAULTS,
|
||||||
|
MILITARY_DETECTION_PRESET,
|
||||||
|
} from './visualPresets.js';
|
||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
let time = 0;
|
let time = 0;
|
||||||
@@ -10,15 +14,40 @@ function fixture() {
|
|||||||
const stages = new Set();
|
const stages = new Set();
|
||||||
const holds = new Set();
|
const holds = new Set();
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const bloom = { enabled: true, uniforms: { glowOnly: true, contrast: 3, brightness: 4, delta: 5, sigma: 6, stepSize: 7 } };
|
const bloom = {
|
||||||
|
enabled: true,
|
||||||
|
uniforms: {
|
||||||
|
glowOnly: true,
|
||||||
|
contrast: 3,
|
||||||
|
brightness: 4,
|
||||||
|
delta: 5,
|
||||||
|
sigma: 6,
|
||||||
|
stepSize: 7,
|
||||||
|
},
|
||||||
|
};
|
||||||
const originalBloom = structuredClone(bloom);
|
const originalBloom = structuredClone(bloom);
|
||||||
const pipeline = { bloom, add(stage) { stages.add(stage); }, remove(stage) { return stages.delete(stage); } };
|
const pipeline = {
|
||||||
|
bloom,
|
||||||
|
add(stage) {
|
||||||
|
stages.add(stage);
|
||||||
|
},
|
||||||
|
remove(stage) {
|
||||||
|
return stages.delete(stage);
|
||||||
|
},
|
||||||
|
};
|
||||||
const effects = new VisualEffects({
|
const effects = new VisualEffects({
|
||||||
viewer: { scene: { postProcessStages: pipeline } },
|
viewer: { scene: { postProcessStages: pipeline } },
|
||||||
requestRender: (reason) => requests.push(reason),
|
requestRender: (reason) => requests.push(reason),
|
||||||
holdRender: (reason) => holds.add(reason), releaseRender: (reason) => holds.delete(reason),
|
holdRender: (reason) => holds.add(reason),
|
||||||
requestFrame: (callback) => { const id = next++; frames.set(id, callback); return id; },
|
releaseRender: (reason) => holds.delete(reason),
|
||||||
cancelFrame: (id) => frames.delete(id), now: () => time, wallNow: () => 10_000 + time,
|
requestFrame: (callback) => {
|
||||||
|
const id = next++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelFrame: (id) => frames.delete(id),
|
||||||
|
now: () => time,
|
||||||
|
wallNow: () => 10_000 + time,
|
||||||
createStage: (options) => ({ ...options, enabled: true }),
|
createStage: (options) => ({ ...options, enabled: true }),
|
||||||
});
|
});
|
||||||
function tick(value) {
|
function tick(value) {
|
||||||
@@ -27,7 +56,16 @@ function fixture() {
|
|||||||
frames.clear();
|
frames.clear();
|
||||||
for (const callback of pending) callback();
|
for (const callback of pending) callback();
|
||||||
}
|
}
|
||||||
return { effects, frames, stages, holds, requests, bloom, originalBloom, tick };
|
return {
|
||||||
|
effects,
|
||||||
|
frames,
|
||||||
|
stages,
|
||||||
|
holds,
|
||||||
|
requests,
|
||||||
|
bloom,
|
||||||
|
originalBloom,
|
||||||
|
tick,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('construction is inert and initialization creates one owned pipeline', () => {
|
test('construction is inert and initialization creates one owned pipeline', () => {
|
||||||
@@ -37,7 +75,11 @@ test('construction is inert and initialization creates one owned pipeline', () =
|
|||||||
f.effects.initStyles();
|
f.effects.initStyles();
|
||||||
f.effects.initPostProcess();
|
f.effects.initPostProcess();
|
||||||
assert.equal(f.stages.size, 7);
|
assert.equal(f.stages.size, 7);
|
||||||
assert.ok(Object.values(f.effects.stages).every((stage) => !stage.enabled && stage.uniforms.intensity === 0));
|
assert.ok(
|
||||||
|
Object.values(f.effects.stages).every(
|
||||||
|
(stage) => !stage.enabled && stage.uniforms.intensity === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
f.effects.initStyles();
|
f.effects.initStyles();
|
||||||
f.effects.initPostProcess();
|
f.effects.initPostProcess();
|
||||||
assert.equal(f.stages.size, 7);
|
assert.equal(f.stages.size, 7);
|
||||||
@@ -50,7 +92,11 @@ test('crossfade uses the established 500ms easing and settles without idle frame
|
|||||||
f.effects.startTransition('noir', 0, 1);
|
f.effects.startTransition('noir', 0, 1);
|
||||||
assert.equal(f.effects.frameId, 0, 'zero is a valid pending animation id');
|
assert.equal(f.effects.frameId, 0, 'zero is a valid pending animation id');
|
||||||
f.effects.startAnimationLoop();
|
f.effects.startAnimationLoop();
|
||||||
assert.equal(f.frames.size, 1, 'starting twice cannot schedule duplicate frames');
|
assert.equal(
|
||||||
|
f.frames.size,
|
||||||
|
1,
|
||||||
|
'starting twice cannot schedule duplicate frames',
|
||||||
|
);
|
||||||
f.tick(125);
|
f.tick(125);
|
||||||
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125);
|
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125);
|
||||||
f.tick(250);
|
f.tick(250);
|
||||||
@@ -69,7 +115,11 @@ test('a superseding transition starts from the current rendered intensity', () =
|
|||||||
f.effects.initStyles();
|
f.effects.initStyles();
|
||||||
f.effects.startTransition('noir', 0, 1);
|
f.effects.startTransition('noir', 0, 1);
|
||||||
f.tick(250);
|
f.tick(250);
|
||||||
f.effects.startTransition('noir', f.effects.stages.noir.uniforms.intensity, 0);
|
f.effects.startTransition(
|
||||||
|
'noir',
|
||||||
|
f.effects.stages.noir.uniforms.intensity,
|
||||||
|
0,
|
||||||
|
);
|
||||||
f.tick(500);
|
f.tick(500);
|
||||||
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.25);
|
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.25);
|
||||||
f.tick(750);
|
f.tick(750);
|
||||||
@@ -147,7 +197,11 @@ test('stop revokes pending work immediately but retains stages until final destr
|
|||||||
const staleFrame = [...f.frames.values()][0];
|
const staleFrame = [...f.frames.values()][0];
|
||||||
f.effects.stop();
|
f.effects.stop();
|
||||||
assert.equal(f.frames.size, 0);
|
assert.equal(f.frames.size, 0);
|
||||||
assert.equal(f.stages.size, 7, 'Context and Cockpit may still be releasing these stages');
|
assert.equal(
|
||||||
|
f.stages.size,
|
||||||
|
7,
|
||||||
|
'Context and Cockpit may still be releasing these stages',
|
||||||
|
);
|
||||||
const intensity = f.effects.stages.retro.uniforms.intensity;
|
const intensity = f.effects.stages.retro.uniforms.intensity;
|
||||||
staleFrame();
|
staleFrame();
|
||||||
f.effects.startTransition('retro', 0, 1);
|
f.effects.startTransition('retro', 0, 1);
|
||||||
@@ -156,14 +210,21 @@ test('stop revokes pending work immediately but retains stages until final destr
|
|||||||
assert.equal(f.effects.stages.retro.uniforms.intensity, intensity);
|
assert.equal(f.effects.stages.retro.uniforms.intensity, intensity);
|
||||||
f.effects.destroy();
|
f.effects.destroy();
|
||||||
assert.equal(f.stages.size, 0);
|
assert.equal(f.stages.size, 0);
|
||||||
assert.deepEqual(f.bloom, f.originalBloom, 'borrowed bloom configuration is restored');
|
assert.deepEqual(
|
||||||
|
f.bloom,
|
||||||
|
f.originalBloom,
|
||||||
|
'borrowed bloom configuration is restored',
|
||||||
|
);
|
||||||
f.effects.destroy();
|
f.effects.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('destroying one instance does not remove another pipeline or clock', () => {
|
test('destroying one instance does not remove another pipeline or clock', () => {
|
||||||
const a = fixture();
|
const a = fixture();
|
||||||
const b = fixture();
|
const b = fixture();
|
||||||
for (const f of [a, b]) { f.effects.initStyles(); f.effects.startTransition('retro', 0, 1); }
|
for (const f of [a, b]) {
|
||||||
|
f.effects.initStyles();
|
||||||
|
f.effects.startTransition('retro', 0, 1);
|
||||||
|
}
|
||||||
a.effects.destroy();
|
a.effects.destroy();
|
||||||
b.tick(250);
|
b.tick(250);
|
||||||
assert.equal(a.stages.size, 0);
|
assert.equal(a.stages.size, 0);
|
||||||
@@ -179,7 +240,10 @@ test('existing baseline and military presets keep one detection default', () =>
|
|||||||
assert.equal(GLOBAL_POST_DEFAULTS.detectionFadePct, 7);
|
assert.equal(GLOBAL_POST_DEFAULTS.detectionFadePct, 7);
|
||||||
assert.equal(GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct, 1);
|
assert.equal(GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct, 1);
|
||||||
for (const name of ['retro', 'surveillance', 'thermal']) {
|
for (const name of ['retro', 'surveillance', 'thermal']) {
|
||||||
assert.equal(STYLE_PRESET_DEFAULTS[name].detection, MILITARY_DETECTION_PRESET);
|
assert.equal(
|
||||||
|
STYLE_PRESET_DEFAULTS[name].detection,
|
||||||
|
MILITARY_DETECTION_PRESET,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined);
|
assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined);
|
||||||
});
|
});
|
||||||
+12
-2
@@ -9,7 +9,14 @@ import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
|
|||||||
/** Duration (ms) for shader intensity crossfade between style presets. */
|
/** Duration (ms) for shader intensity crossfade between style presets. */
|
||||||
export const TRANSITION_DURATION_MS = 500;
|
export const TRANSITION_DURATION_MS = 500;
|
||||||
/** Map of style name to its GLSL shader module for post-process stages. */
|
/** Map of style name to its GLSL shader module for post-process stages. */
|
||||||
export const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader };
|
export const STYLES = {
|
||||||
|
retro: retroShader,
|
||||||
|
surveillance: nightVisionShader,
|
||||||
|
thermal: thermalShader,
|
||||||
|
anime: animeShader,
|
||||||
|
noir: noirShader,
|
||||||
|
snow: snowShader,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tactical detection look: Dense at 75%.
|
* The tactical detection look: Dense at 75%.
|
||||||
@@ -28,7 +35,10 @@ export const STYLES = { retro: retroShader, surveillance: nightVisionShader, the
|
|||||||
* SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads
|
* SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads
|
||||||
* from it.
|
* from it.
|
||||||
*/
|
*/
|
||||||
export const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 });
|
export const MILITARY_DETECTION_PRESET = Object.freeze({
|
||||||
|
mode: 'dense',
|
||||||
|
densityPct: 75,
|
||||||
|
});
|
||||||
|
|
||||||
/** Baseline post-processing settings applied on first load (before share-link restore). */
|
/** Baseline post-processing settings applied on first load (before share-link restore). */
|
||||||
export const GLOBAL_POST_DEFAULTS = {
|
export const GLOBAL_POST_DEFAULTS = {
|
||||||
|
|||||||
Reference in new issue
Block a user