diff --git a/src/app/data/catalog-loader.ts b/src/app/data/catalog-loader.ts index a815c5e..a80d9a5 100644 --- a/src/app/data/catalog-loader.ts +++ b/src/app/data/catalog-loader.ts @@ -31,6 +31,7 @@ import { EventBusEvent } from '../../engine/events/event-bus-events'; import { SettingsManager } from '../../settings/settings'; import { Planet } from '../objects/planet'; import { apiFetch } from './api-fetch'; +import { fetchCurrentCatalog } from './current-catalog'; import { CatalogManager } from './catalog-manager'; import { LaunchSite } from './catalog-manager/LaunchFacility'; import { MissileObject } from './catalog-manager/MissileObject'; @@ -241,7 +242,17 @@ export class CatalogLoader { const { extraSats, asciiCatalog, jsCatalog, externalCatalog } = CatalogLoader.getAdditionalCatalogs_(settingsManager); - if (settingsManager.dataSources.externalTLEsOnly) { + if (settingsManager.catalogFallbackUrl) { + const result = await fetchCurrentCatalog(settingsManager.dataSources.tle, settingsManager.catalogFallbackUrl); + + window.dispatchEvent(new CustomEvent('keeptrack-catalog-status', { detail: { + live: result.live, fetchedAt: result.fetchedAt, count: result.data.length, + } })); + await CatalogLoader.parse({ + keepTrackTle: result.data, keepTrackExtra: extraSats, keepTrackAscii: asciiCatalog, + externalCatalog, vimpelCatalog: jsCatalog, + }); + } else if (settingsManager.dataSources.externalTLEsOnly) { if (settingsManager.dataSources.isSupplementExternal) { // Load our database for the extra information - the satellites will be filtered out await apiFetch(settingsManager.dataSources.tle) diff --git a/src/app/rendering/__tests__/orbit-manager.test.ts b/src/app/rendering/__tests__/orbit-manager.test.ts index 9960b3f..6a44402 100644 --- a/src/app/rendering/__tests__/orbit-manager.test.ts +++ b/src/app/rendering/__tests__/orbit-manager.test.ts @@ -32,6 +32,7 @@ describe('OrbitManager', () => { om = new OrbitManager(); gl = ServiceLocator.getRenderer().gl; + gl.isContextLost.mockReturnValue(false); catalog = { missileSats: 6, objectCache: [defaultSat], getObject: vi.fn(() => ({ isStatic: () => false, isMissile: () => false })) }; vi.spyOn(ServiceLocator, 'getCatalogManager').mockReturnValue(catalog as never); @@ -87,6 +88,63 @@ describe('OrbitManager', () => { }); describe('init', () => { + it('keeps a 33k catalogue without allocating a GPU orbit buffer per object', () => { + settingsManager.isLazyOrbitBuffers = true; + catalog.missileSats = 33373; + const create = vi.spyOn(gl, 'createBuffer').mockClear(); + const upload = vi.spyOn(gl, 'bufferData').mockClear(); + + initialized(); + + expect(om.glBuffers_).toHaveLength(33373); + expect(om.glBuffers_.filter(Boolean)).toHaveLength(0); + expect(create).toHaveBeenCalledTimes(3); // selected, secondary and hover scratch buffers only + expect(upload).toHaveBeenCalledTimes(3); + expect(om.orbitThreadMgr.sendInit).toHaveBeenCalled(); + }); + + it('allocates and reuses only a requested orbit slot without allocating its neighbours', () => { + settingsManager.isLazyOrbitBuffers = true; + initialized(); + const create = vi.spyOn(gl, 'createBuffer').mockClear(); + const upload = vi.spyOn(gl, 'bufferData').mockClear(); + const buffer = om.getOrbitBuffer(4); + + expect(buffer).toBeTruthy(); + expect(om.getOrbitBuffer(4)).toBe(buffer); + expect(create).toHaveBeenCalledTimes(1); + expect(upload).not.toHaveBeenCalled(); // the actual path upload supplies storage + expect(om.glBuffers_.filter(Boolean)).toHaveLength(1); + expect(om.getOrbitBuffer(-1)).toBeNull(); + expect(om.getOrbitBuffer(6)).toBeNull(); + expect(om.getOrbitBuffer(1.5)).toBeNull(); + expect(create).toHaveBeenCalledTimes(1); + }); + + it('does not create an orbit buffer after context loss', () => { + settingsManager.isLazyOrbitBuffers = true; + initialized(); + const create = vi.spyOn(gl, 'createBuffer').mockClear(); + vi.spyOn(gl, 'isContextLost').mockReturnValue(true); + + expect(om.getOrbitBuffer(1)).toBeNull(); + expect(create).not.toHaveBeenCalled(); + }); + + it('uploads a variable-length path into a previously unallocated slot', () => { + settingsManager.isLazyOrbitBuffers = true; + initialized(); + const upload = vi.spyOn(gl, 'bufferData').mockClear(); + const points = new Float32Array([1, 2, 3, 1, 4, 5, 6, 1]); + + o().setVariableLengthOrbitBuffer_(2, points, [0, 0, 0]); + + expect(om.glBuffers_[2]).toBeTruthy(); + expect(upload).toHaveBeenCalledWith(gl.ARRAY_BUFFER, points, gl.DYNAMIC_DRAW); + expect(upload).toHaveBeenCalledTimes(1); + o().setVariableLengthOrbitBuffer_(2, points, [0, 0, 0]); + expect(upload).toHaveBeenCalledTimes(1); + }); it('allocates a GL buffer per satellite and sends the init payload', () => { initialized(); @@ -395,6 +453,21 @@ describe('OrbitManager', () => { }); describe('resetForCatalogSwap', () => { + it('releases requested lazy buffers and leaves a larger replacement catalogue unallocated', () => { + settingsManager.isLazyOrbitBuffers = true; + initialized(); + const buffer = om.getOrbitBuffer(1); + const remove = vi.spyOn(gl, 'deleteBuffer').mockClear(); + const create = vi.spyOn(gl, 'createBuffer').mockClear(); + catalog.missileSats = 33373; + + om.resetForCatalogSwap(); + + expect(remove).toHaveBeenCalledWith(buffer); + expect(om.glBuffers_).toHaveLength(33373); + expect(om.glBuffers_.filter(Boolean)).toHaveLength(0); + expect(create).not.toHaveBeenCalled(); + }); it('resizes buffers to the new catalog and re-sends init', () => { initialized(); catalog.missileSats = 3; // shrink diff --git a/src/app/rendering/orbit-manager.ts b/src/app/rendering/orbit-manager.ts index 79cfe94..a50563b 100644 --- a/src/app/rendering/orbit-manager.ts +++ b/src/app/rendering/orbit-manager.ts @@ -162,6 +162,13 @@ export class OrbitManager { const gl = this.gl_; const newSize = catalogManager.missileSats; + // Lazy catalog swaps discard old paths without allocating for the new set. + if (settingsManager.isLazyOrbitBuffers) { + this.glBuffers_.forEach((buf) => gl.deleteBuffer(buf)); + this.glBuffers_.length = 0; + this.glBuffers_.length = newSize; + } + // Delete excess GL buffers if new catalog is smaller while (this.glBuffers_.length > newSize) { const buf = this.glBuffers_.pop(); @@ -342,9 +349,21 @@ export class OrbitManager { this.gl_.bindBuffer(this.gl_.ARRAY_BUFFER, this.hoverOrbitBuf_); this.gl_.bufferData(this.gl_.ARRAY_BUFFER, new Float32Array((settingsManager.orbitSegments + 1) * 4), this.gl_.DYNAMIC_DRAW); - // Allocate one buffer for every satellite - for (let i = 0; i < catalogManagerInstance.missileSats; i++) { - this.glBuffers_.push(this.allocateBuffer()); + // A full catalogue must not reserve thousands of unused GPU orbit objects. + // Sparse slots retain catalogue IDs; worker/variable-path uploads fill them. + if (settingsManager.isLazyOrbitBuffers) { + this.glBuffers_.length = catalogManagerInstance.missileSats; + } else { + for (let i = 0; i < catalogManagerInstance.missileSats; i++) { + this.glBuffers_.push(this.allocateBuffer()); + } + } + if (settingsManager.isDiagnosticBuild) { + window.dispatchEvent(new CustomEvent('keeptrack-test-stage', { detail: { + phase: 'orbit-buffer-init', lazy: settingsManager.isLazyOrbitBuffers, + slots: this.glBuffers_.length, allocated: this.glBuffers_.filter(Boolean).length, + bytesPerOrbit: (settingsManager.orbitSegments + 1) * 4 * Float32Array.BYTES_PER_ELEMENT, + } })); } const objDataString = OrbitManager.getObjDataString(ServiceLocator.getCatalogManager().objectCache); @@ -613,6 +632,29 @@ export class OrbitManager { return buf; } + /** Return a valid catalogue slot's buffer, creating an empty GPU object on demand. */ + getOrbitBuffer(satId: number): WebGLBuffer | null { + if (!Number.isInteger(satId) || satId < 0 || satId >= this.glBuffers_.length) { + return null; + } + const gl = this.gl_ ?? ServiceLocator.getRenderer().gl; + + if (gl.isContextLost()) { + return null; + } + if (!this.glBuffers_[satId]) { + const buffer = gl.createBuffer(); + + if (!buffer) { + return null; + } + // The caller immediately uploads the real path. No zero-filled allocation. + this.glBuffers_[satId] = buffer; + } + + return this.glBuffers_[satId]; + } + // private isCalculateColorLocked = false; private drawGroupObjectOrbit(hoverManagerInstance: HoverManager, colorSchemeManagerInstance: ColorSchemeManager): void { @@ -766,8 +808,14 @@ export class OrbitManager { } const gl = this.gl_ ?? ServiceLocator.getRenderer().gl; + const buffer = this.getOrbitBuffer(satId); - gl.bindBuffer(gl.ARRAY_BUFFER, this.glBuffers_[satId]); + if (!buffer) { + this.inProgress_[satId] = false; + + return; + } + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); // Compare against the CPU-side mirror instead of // gl.getBufferParameter(BUFFER_SIZE): that query forces a synchronous CPU/GPU diff --git a/src/app/threads/__tests__/orbit-cruncher-thread-manager.test.ts b/src/app/threads/__tests__/orbit-cruncher-thread-manager.test.ts index e83419a..c3a5ef7 100644 --- a/src/app/threads/__tests__/orbit-cruncher-thread-manager.test.ts +++ b/src/app/threads/__tests__/orbit-cruncher-thread-manager.test.ts @@ -16,6 +16,7 @@ const stubServiceLocator = () => { vi.spyOn(ServiceLocator, 'getOrbitManager').mockReturnValue({ orbitCache, glBuffers_: glBuffers, + getOrbitBuffer: (id: number) => glBuffers[id] ?? null, inProgress_: inProgress, setOrbitAnchor, } as unknown as ReturnType); @@ -189,6 +190,20 @@ describe('OrbitCruncherThreadManager', () => { }); describe('onMessage', () => { + it('drops an upload when no valid GPU slot is available', () => { + stubServiceLocator(); + const { mgr } = makeMgr(); + gl.bufferData.mockClear(); + orbitCache.delete(999); + + (mgr as unknown as OnMessageable).onMessage({ + data: { typ: OrbitCruncherMsgType.RESPONSE_DATA, satId: 999, pointsOut: new Float32Array([1]), seqNum: 0 }, + }); + + expect(gl.bufferData).not.toHaveBeenCalled(); + expect(orbitCache.has(999)).toBe(false); + expect(inProgress[999]).toBe(false); + }); it('marks ready on the "ready" message', () => { const { mgr } = makeMgr(); const handler = mgr as unknown as OnMessageable; diff --git a/src/app/threads/orbit-cruncher-thread-manager.ts b/src/app/threads/orbit-cruncher-thread-manager.ts index d473669..00e9d30 100644 --- a/src/app/threads/orbit-cruncher-thread-manager.ts +++ b/src/app/threads/orbit-cruncher-thread-manager.ts @@ -137,6 +137,13 @@ export class OrbitCruncherThreadManager extends WebWorkerThreadManager { private handleResponseData_(data: OrbitCruncherOutMsgPoints) { const satId = data.satId; const orbitManager = ServiceLocator.getOrbitManager(); + const buffer = orbitManager.getOrbitBuffer(satId); + + if (!buffer) { + orbitManager.inProgress_[satId] = false; + + return; + } const cachedOrbitData = orbitManager.orbitCache.get(satId); let pointsOut: Float32Array; @@ -155,7 +162,7 @@ export class OrbitCruncherThreadManager extends WebWorkerThreadManager { const gl = ServiceLocator.getRenderer().gl; - gl.bindBuffer(gl.ARRAY_BUFFER, orbitManager.glBuffers_[satId]); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, pointsOut, gl.DYNAMIC_DRAW); orbitManager.inProgress_[satId] = false; diff --git a/src/app/ui/mobileManager.ts b/src/app/ui/mobileManager.ts index e57653b..4a231b6 100644 --- a/src/app/ui/mobileManager.ts +++ b/src/app/ui/mobileManager.ts @@ -176,6 +176,18 @@ export class MobileManager { } settingsManager.maxLabels = settingsManager.desktopMaxLabels; } + // The iOS experiment must not silently regain the expensive device defaults. + if (settingsManager.isDiagnosticBuild && window.settingsOverride?.graphics) { + Object.assign(settingsManager.graphics, window.settingsOverride.graphics); + // Mobile defaults also re-enable the supplemental catalogue and mesh + // manager. Reapply explicit scalar test settings while keeping the + // device's menu/layout and merged plugin/data-source objects. + for (const [key, value] of Object.entries(window.settingsOverride)) { + if (value !== null && typeof value !== 'object') { + settingsManager[key] = value; + } + } + } } else { errorManagerInstance.debug('MobileManager.checkMobileMode() called after initialization!'); } diff --git a/src/engine/core/scene.ts b/src/engine/core/scene.ts index 327af60..3131b24 100644 --- a/src/engine/core/scene.ts +++ b/src/engine/core/scene.ts @@ -837,7 +837,9 @@ export class Scene { } for (const moon of Object.values(this.moons)) { - moon.init(this.gl_); + if (!settingsManager.isDisableMoon) { + moon.init(this.gl_); + } } if (!settingsManager.isDisableSearchBox) { diff --git a/src/engine/rendering/webgl-program.ts b/src/engine/rendering/webgl-program.ts index ff93a94..b20c88b 100644 --- a/src/engine/rendering/webgl-program.ts +++ b/src/engine/rendering/webgl-program.ts @@ -40,6 +40,9 @@ export class WebGlProgramHelper { ) { this.gl_ = gl; this.name = params?.name ?? 'WebGLProgram'; + if (typeof window !== 'undefined' && window.settingsManager?.isDiagnosticBuild) { + window.dispatchEvent(new CustomEvent('keeptrack-test-stage', { detail: { program: this.name } })); + } this.vertexShaderCode = vertexShaderCode; this.fragmentShaderCode = fragmentShaderCode; diff --git a/src/engine/rendering/webgl-renderer.ts b/src/engine/rendering/webgl-renderer.ts index 9a3929e..9495bd5 100644 --- a/src/engine/rendering/webgl-renderer.ts +++ b/src/engine/rendering/webgl-renderer.ts @@ -233,8 +233,8 @@ export class WebGLRenderer { alpha: false, premultipliedAlpha: false, desynchronized: false, // Setting to true causes flickering on mobile devices - antialias: true, - powerPreference: 'high-performance', + antialias: settingsManager.isWebglAntialias, + powerPreference: settingsManager.webglPowerPreference, // Screenshot/ScreenRecorder need post-frame canvas reads; profiles without // them (e.g. the companion embed) set false to skip the per-frame copy. preserveDrawingBuffer: settingsManager.isPreserveDrawingBuffer ?? true, @@ -267,7 +267,10 @@ export class WebGLRenderer { this.updatePMatrix(); this.postProcessingManager = new PostProcessingManager(); - this.postProcessingManager.init(gl); + this.postProcessingManager.curBuffer = null; + if (!settingsManager.isDisablePostProcessing) { + this.postProcessingManager.init(gl); + } return gl; } @@ -578,7 +581,7 @@ export class WebGLRenderer { * upscaling a 1/2–1/3 size buffer. CSS size is pinned so layout never moves. */ const configuredRatio = settingsManager.canvasPixelRatio ?? 1; - const ratio = configuredRatio === 0 ? Math.min(window.devicePixelRatio || 1, 2) : Math.min(Math.max(configuredRatio, 1), 3); + const ratio = configuredRatio === 0 ? Math.min(window.devicePixelRatio || 1, 2) : Math.min(Math.max(configuredRatio, 0.5), 3); const bw = Math.round(vw * ratio); const bh = Math.round(vh * ratio); @@ -657,6 +660,11 @@ export class WebGLRenderer { * @deprecated */ resizePostProcessingTexture(gl: WebGL2RenderingContext, sun: Sun, postProcessingManagerRef: PostProcessingManager): void { + if (settingsManager.isDisablePostProcessing) { + this.isPostProcessingResizeNeeded = false; + + return; + } if (typeof gl === 'undefined' || gl === null) { throw new Error('gl is undefined or null'); } diff --git a/src/keeptrack.ts b/src/keeptrack.ts index c1d90f7..dfb2363 100644 --- a/src/keeptrack.ts +++ b/src/keeptrack.ts @@ -504,6 +504,7 @@ theodore.kruczek at gmail dot com. } this.isReady = true; + window.dispatchEvent(new Event('keeptrack-test-ready')); // Boot succeeded: clear the self-heal marker so a genuine future failure in // this session still gets its one reload attempt. (The cache-bust token is // left in place so reloads keep using the fresh worker URL.) diff --git a/src/settings/core-settings.ts b/src/settings/core-settings.ts index 58bb938..3defb93 100644 --- a/src/settings/core-settings.ts +++ b/src/settings/core-settings.ts @@ -63,6 +63,8 @@ export interface SearchableTypes { * Core application settings and global flags */ export class CoreSettings { + /** Isolated diagnostic profiles retain their graphics overrides during device setup. */ + isDiagnosticBuild = false; /** * A variable to hold a classification string, set to `null` when unused */ diff --git a/src/settings/data-settings.ts b/src/settings/data-settings.ts index c9fba8e..501477e 100644 --- a/src/settings/data-settings.ts +++ b/src/settings/data-settings.ts @@ -21,6 +21,8 @@ * Data sources, catalogs, and external server settings */ export class DataSettings { + /** Optional bundled fallback for a current catalogue feed. */ + catalogFallbackUrl = ''; // Data Sources dataSources = { /** diff --git a/src/settings/graphics-settings.ts b/src/settings/graphics-settings.ts index b2229c0..3921732 100644 --- a/src/settings/graphics-settings.ts +++ b/src/settings/graphics-settings.ts @@ -241,6 +241,11 @@ export class GraphicsSettings { * as-is (clamped to 3). Fill-rate cost scales with the square of this value. */ canvasPixelRatio = 1; + // Experimental iOS test controls. Defaults preserve upstream behavior. + isDisableMoon = false; + isDisablePostProcessing = false; + isWebglAntialias = true; + webglPowerPreference: WebGLPowerPreference = 'high-performance'; /** * WebGL context `preserveDrawingBuffer` attribute (read once at context * creation). True (default) lets the Screenshot/ScreenRecorder plugins read the diff --git a/src/settings/performance-settings.ts b/src/settings/performance-settings.ts index 523b998..6bdfbbd 100644 --- a/src/settings/performance-settings.ts +++ b/src/settings/performance-settings.ts @@ -25,6 +25,8 @@ import { Milliseconds } from '@app/engine/ootk/src/main'; export class PerformanceSettings { // Performance Mode lowPerf = false; + /** Allocate per-object orbit GPU buffers only when a path is uploaded. */ + isLazyOrbitBuffers = false; /** * Determines whether the application should use a reduced-draw mode. * If true, the application will use a less resource-intensive method of rendering. diff --git a/src/settings/settings.ts b/src/settings/settings.ts index b87fab0..35570b9 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -90,6 +90,12 @@ const PROPERTY_CATEGORY_MAP: Record = { godraysIlluminationDecay: 'graphics', zFar: 'graphics', canvasPixelRatio: 'graphics', + isDiagnosticBuild: 'core', + isDisableMoon: 'graphics', + isDisablePostProcessing: 'graphics', + isWebglAntialias: 'graphics', + webglPowerPreference: 'graphics', + isPreserveDrawingBuffer: 'graphics', isDrawMilkyWay: 'graphics', milkyWayTextureQuality: 'graphics', hiresMilkWay: 'graphics', @@ -263,6 +269,7 @@ const PROPERTY_CATEGORY_MAP: Record = { // Data dataSources: 'data', + catalogFallbackUrl: 'data', telemetryServer: 'data', userServer: 'data', db: 'data', @@ -277,6 +284,7 @@ const PROPERTY_CATEGORY_MAP: Record = { // Performance lowPerf: 'performance', + isLazyOrbitBuffers: 'performance', isDrawLess: 'performance', maxAnalystSats: 'performance', maxFieldOfViewMarkers: 'performance',