feat(immersive): add Phase 0 infrastructure for WebGL2 rendering
- Create src/lib/immersive/ module structure - Add types.ts with ImmersiveState, ImmersiveConfig interfaces - Add config.ts with build-time and runtime feature flags - Add ImmersiveRenderer.ts with lifecycle management and stores - Add index.ts as public API boundary - Add VITE_IMMERSIVE_ENABLED to vite.config.js - Add app.d.ts for TypeScript env declarations Build without immersive: QBZ_IMMERSIVE=false pnpm build No visual changes yet - this is capability detection and guards only.
This commit is contained in:
1 parent
600a33026a
commit
19d0916a46
6 files changed
+555
No files matched your search
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
// Extend Vite's ImportMetaEnv with our custom env variables
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BUILD_DATE: string;
|
||||
readonly VITE_IMMERSIVE_ENABLED: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Immersive Renderer - Lifecycle Manager
|
||||
*
|
||||
* This module owns the immersive rendering lifecycle:
|
||||
* - Capability detection
|
||||
* - Initialization / destruction
|
||||
* - State management
|
||||
* - Debug instrumentation
|
||||
*
|
||||
* UI components should use this as the single entry point.
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import {
|
||||
BUILD_IMMERSIVE_ENABLED,
|
||||
shouldImmersiveBeAvailable,
|
||||
getWebGL2Info,
|
||||
getConfig,
|
||||
setRuntimeEnabled as setConfigRuntimeEnabled,
|
||||
} from './config';
|
||||
import type {
|
||||
ImmersiveState,
|
||||
ImmersiveBackend,
|
||||
UnavailableReason,
|
||||
ImmersiveMetrics,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// State Store
|
||||
// ============================================================================
|
||||
|
||||
const initialState: ImmersiveState = {
|
||||
active: false,
|
||||
backend: 'disabled',
|
||||
unavailableReason: BUILD_IMMERSIVE_ENABLED ? undefined : 'build-disabled',
|
||||
};
|
||||
|
||||
/** Internal writable store */
|
||||
const stateStore = writable<ImmersiveState>(initialState);
|
||||
|
||||
/** Public read-only store for immersive state */
|
||||
export const immersiveState = { subscribe: stateStore.subscribe };
|
||||
|
||||
/** Derived store: is immersive currently active? */
|
||||
export const isImmersiveActive = derived(stateStore, ($state) => $state.active);
|
||||
|
||||
/** Derived store: current backend */
|
||||
export const immersiveBackend = derived(stateStore, ($state) => $state.backend);
|
||||
|
||||
/** Derived store: debug metrics (null if not active) */
|
||||
export const immersiveMetrics = derived(stateStore, ($state) => $state.metrics ?? null);
|
||||
|
||||
// ============================================================================
|
||||
// Metrics Tracking
|
||||
// ============================================================================
|
||||
|
||||
let frameCount = 0;
|
||||
let lastFpsUpdate = 0;
|
||||
let currentFps = 0;
|
||||
let lastFrameTime = 0;
|
||||
|
||||
/**
|
||||
* Update metrics from render loop.
|
||||
* Called by ImmersiveAmbientCanvas on each frame.
|
||||
*/
|
||||
export function updateMetrics(frameTimeMs: number): void {
|
||||
frameCount++;
|
||||
lastFrameTime = frameTimeMs;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastFpsUpdate >= 1000) {
|
||||
currentFps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsUpdate = now;
|
||||
|
||||
// Update store with new metrics
|
||||
stateStore.update((state) => {
|
||||
if (!state.active) return state;
|
||||
return {
|
||||
...state,
|
||||
metrics: {
|
||||
...state.metrics!,
|
||||
fps: currentFps,
|
||||
frameTimeMs: lastFrameTime,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set GPU info in metrics (called once during init).
|
||||
*/
|
||||
function setGpuInfo(renderer: string): void {
|
||||
stateStore.update((state) => ({
|
||||
...state,
|
||||
metrics: {
|
||||
fps: 0,
|
||||
frameTimeMs: 0,
|
||||
gpuRenderer: renderer,
|
||||
textureCount: 0,
|
||||
gpuMemoryBytes: 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update texture count in metrics.
|
||||
*/
|
||||
export function updateTextureCount(count: number, memoryBytes: number): void {
|
||||
stateStore.update((state) => {
|
||||
if (!state.metrics) return state;
|
||||
return {
|
||||
...state,
|
||||
metrics: {
|
||||
...state.metrics,
|
||||
textureCount: count,
|
||||
gpuMemoryBytes: memoryBytes,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if immersive rendering is available.
|
||||
* This checks build flags, runtime preferences, and WebGL2 capability.
|
||||
*/
|
||||
export function isAvailable(): boolean {
|
||||
return shouldImmersiveBeAvailable().available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if immersive is currently enabled (user preference).
|
||||
*/
|
||||
export function isEnabled(): boolean {
|
||||
const { available, reason } = shouldImmersiveBeAvailable();
|
||||
if (!available && reason === 'runtime-disabled') {
|
||||
return false;
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable immersive rendering (user preference).
|
||||
*/
|
||||
export function setEnabled(enabled: boolean): void {
|
||||
setConfigRuntimeEnabled(enabled);
|
||||
|
||||
if (enabled) {
|
||||
// Check if we can actually enable
|
||||
const { available, reason } = shouldImmersiveBeAvailable();
|
||||
if (!available) {
|
||||
stateStore.set({
|
||||
active: false,
|
||||
backend: 'disabled',
|
||||
unavailableReason: reason,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
stateStore.set({
|
||||
active: false,
|
||||
backend: 'disabled',
|
||||
unavailableReason: 'runtime-disabled',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize immersive rendering.
|
||||
* Call this when the immersive view is mounted.
|
||||
*
|
||||
* Returns true if initialization succeeded.
|
||||
*/
|
||||
export async function init(): Promise<boolean> {
|
||||
const { available, reason } = shouldImmersiveBeAvailable();
|
||||
|
||||
if (!available) {
|
||||
console.log(`[Immersive] Not available: ${reason}`);
|
||||
stateStore.set({
|
||||
active: false,
|
||||
backend: 'disabled',
|
||||
unavailableReason: reason,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get GPU info for debugging
|
||||
const gpuInfo = getWebGL2Info();
|
||||
const renderer = gpuInfo?.renderer ?? 'Unknown GPU';
|
||||
console.log(`[Immersive] Initializing with WebGL2 - ${renderer}`);
|
||||
|
||||
// Set initial state
|
||||
stateStore.set({
|
||||
active: true,
|
||||
backend: 'webgl2',
|
||||
metrics: {
|
||||
fps: 0,
|
||||
frameTimeMs: 0,
|
||||
gpuRenderer: renderer,
|
||||
textureCount: 0,
|
||||
gpuMemoryBytes: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy immersive rendering.
|
||||
* Call this when the immersive view is unmounted.
|
||||
*/
|
||||
export function destroy(): void {
|
||||
console.log('[Immersive] Destroyed');
|
||||
|
||||
// Reset metrics
|
||||
frameCount = 0;
|
||||
lastFpsUpdate = 0;
|
||||
currentFps = 0;
|
||||
|
||||
stateStore.set({
|
||||
active: false,
|
||||
backend: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebGL context loss.
|
||||
* Call this from the canvas component when context is lost.
|
||||
*/
|
||||
export function handleContextLost(): void {
|
||||
console.warn('[Immersive] WebGL context lost');
|
||||
stateStore.update((state) => ({
|
||||
...state,
|
||||
active: false,
|
||||
backend: 'fallback',
|
||||
unavailableReason: 'context-lost',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebGL context restored.
|
||||
* Call this from the canvas component when context is restored.
|
||||
*/
|
||||
export function handleContextRestored(): void {
|
||||
console.log('[Immersive] WebGL context restored');
|
||||
stateStore.update((state) => ({
|
||||
...state,
|
||||
active: true,
|
||||
backend: 'webgl2',
|
||||
unavailableReason: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state snapshot (for debugging).
|
||||
*/
|
||||
export function getState(): ImmersiveState {
|
||||
return get(stateStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration.
|
||||
*/
|
||||
export { getConfig } from './config';
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Immersive Rendering Configuration
|
||||
*
|
||||
* Handles build-time and runtime feature flags for the immersive system.
|
||||
* This module is the single source of truth for "should immersive be active?"
|
||||
*/
|
||||
|
||||
import { DEFAULT_IMMERSIVE_CONFIG, type ImmersiveConfig } from './types';
|
||||
|
||||
/** Storage key for runtime enable/disable preference */
|
||||
const STORAGE_KEY_ENABLED = 'qbz.immersive.enabled';
|
||||
const STORAGE_KEY_CONFIG = 'qbz.immersive.config';
|
||||
|
||||
/**
|
||||
* Build-time flag: Is immersive compiled into this build?
|
||||
*
|
||||
* Set via environment variable at build time:
|
||||
* QBZ_IMMERSIVE=false pnpm build
|
||||
*
|
||||
* When false, all immersive code should be tree-shaken out.
|
||||
*/
|
||||
export const BUILD_IMMERSIVE_ENABLED: boolean =
|
||||
import.meta.env.VITE_IMMERSIVE_ENABLED !== 'false';
|
||||
|
||||
/**
|
||||
* Check if WebGL2 is available in the current environment.
|
||||
* This is a capability check, not a preference check.
|
||||
*/
|
||||
export function isWebGL2Available(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
// SSR or headless - no WebGL
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2');
|
||||
return gl !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed WebGL2 information for debugging.
|
||||
* Returns null if WebGL2 is unavailable.
|
||||
*/
|
||||
export function getWebGL2Info(): { renderer: string; vendor: string; version: string } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2');
|
||||
if (!gl) return null;
|
||||
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
renderer: debugInfo
|
||||
? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER),
|
||||
vendor: debugInfo
|
||||
? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
|
||||
: gl.getParameter(gl.VENDOR),
|
||||
version: gl.getParameter(gl.VERSION),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if immersive is enabled at runtime (user preference).
|
||||
* Only meaningful if BUILD_IMMERSIVE_ENABLED is true.
|
||||
*/
|
||||
export function isRuntimeEnabled(): boolean {
|
||||
if (!BUILD_IMMERSIVE_ENABLED) return false;
|
||||
if (typeof localStorage === 'undefined') return true; // Default to enabled
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY_ENABLED);
|
||||
return stored !== 'false'; // Default to enabled if not set
|
||||
}
|
||||
|
||||
/**
|
||||
* Set runtime enabled state (user preference).
|
||||
*/
|
||||
export function setRuntimeEnabled(enabled: boolean): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY_ENABLED, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current immersive configuration.
|
||||
*/
|
||||
export function getConfig(): ImmersiveConfig {
|
||||
if (typeof localStorage === 'undefined') return { ...DEFAULT_IMMERSIVE_CONFIG };
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_CONFIG);
|
||||
if (!stored) return { ...DEFAULT_IMMERSIVE_CONFIG };
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...DEFAULT_IMMERSIVE_CONFIG, ...parsed };
|
||||
} catch {
|
||||
return { ...DEFAULT_IMMERSIVE_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update immersive configuration.
|
||||
*/
|
||||
export function setConfig(config: Partial<ImmersiveConfig>): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
const current = getConfig();
|
||||
const updated = { ...current, ...config };
|
||||
localStorage.setItem(STORAGE_KEY_CONFIG, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if immersive should be available (all conditions met).
|
||||
* This is the main entry point for "can we use immersive?"
|
||||
*/
|
||||
export function shouldImmersiveBeAvailable(): {
|
||||
available: boolean;
|
||||
reason?: 'build-disabled' | 'runtime-disabled' | 'no-webgl2';
|
||||
} {
|
||||
if (!BUILD_IMMERSIVE_ENABLED) {
|
||||
return { available: false, reason: 'build-disabled' };
|
||||
}
|
||||
|
||||
if (!isRuntimeEnabled()) {
|
||||
return { available: false, reason: 'runtime-disabled' };
|
||||
}
|
||||
|
||||
if (!isWebGL2Available()) {
|
||||
return { available: false, reason: 'no-webgl2' };
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Immersive Rendering Module - Public API
|
||||
*
|
||||
* This is the ONLY file that should be imported from outside the module.
|
||||
*
|
||||
* Usage:
|
||||
* import { isAvailable, init, destroy } from '$lib/immersive';
|
||||
*
|
||||
* IMPORTANT: No file outside this module should:
|
||||
* - Import WebGL APIs directly
|
||||
* - Import shader files
|
||||
* - Assume canvas/GPU availability
|
||||
*/
|
||||
|
||||
// Re-export types (always safe to import)
|
||||
export type {
|
||||
ImmersiveState,
|
||||
ImmersiveBackend,
|
||||
UnavailableReason,
|
||||
ImmersiveMetrics,
|
||||
ImmersiveConfig,
|
||||
} from './types';
|
||||
|
||||
export { DEFAULT_IMMERSIVE_CONFIG } from './types';
|
||||
|
||||
// Re-export config utilities
|
||||
export {
|
||||
BUILD_IMMERSIVE_ENABLED,
|
||||
isWebGL2Available,
|
||||
getWebGL2Info,
|
||||
isRuntimeEnabled,
|
||||
setRuntimeEnabled,
|
||||
shouldImmersiveBeAvailable,
|
||||
} from './config';
|
||||
|
||||
// Re-export renderer API
|
||||
export {
|
||||
// Stores
|
||||
immersiveState,
|
||||
isImmersiveActive,
|
||||
immersiveBackend,
|
||||
immersiveMetrics,
|
||||
// Functions
|
||||
isAvailable,
|
||||
isEnabled,
|
||||
setEnabled,
|
||||
init,
|
||||
destroy,
|
||||
getState,
|
||||
getConfig,
|
||||
// Internal (for canvas component)
|
||||
updateMetrics,
|
||||
updateTextureCount,
|
||||
handleContextLost,
|
||||
handleContextRestored,
|
||||
} from './ImmersiveRenderer';
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Immersive Rendering Types
|
||||
*
|
||||
* Type definitions for the WebGL2-based immersive rendering system.
|
||||
* This module has no runtime dependencies and can be safely imported anywhere.
|
||||
*/
|
||||
|
||||
/** Rendering backend currently in use */
|
||||
export type ImmersiveBackend = 'webgl2' | 'fallback' | 'disabled';
|
||||
|
||||
/** Reason why immersive is unavailable */
|
||||
export type UnavailableReason =
|
||||
| 'build-disabled' // Compiled out via QBZ_IMMERSIVE=false
|
||||
| 'runtime-disabled' // User disabled in settings
|
||||
| 'no-webgl2' // Browser/WebView doesn't support WebGL2
|
||||
| 'context-lost' // WebGL context was lost
|
||||
| 'init-failed'; // Initialization failed for other reason
|
||||
|
||||
/** Current state of the immersive renderer */
|
||||
export interface ImmersiveState {
|
||||
/** Whether immersive rendering is currently active */
|
||||
active: boolean;
|
||||
/** Current backend in use */
|
||||
backend: ImmersiveBackend;
|
||||
/** If not active, why */
|
||||
unavailableReason?: UnavailableReason;
|
||||
/** Debug metrics (only populated when active) */
|
||||
metrics?: ImmersiveMetrics;
|
||||
}
|
||||
|
||||
/** Performance metrics for debugging */
|
||||
export interface ImmersiveMetrics {
|
||||
/** Frames per second */
|
||||
fps: number;
|
||||
/** Last frame time in milliseconds */
|
||||
frameTimeMs: number;
|
||||
/** GPU renderer string (e.g., "Mesa Intel UHD 620") */
|
||||
gpuRenderer: string;
|
||||
/** Number of textures currently loaded */
|
||||
textureCount: number;
|
||||
/** Estimated GPU memory usage in bytes */
|
||||
gpuMemoryBytes: number;
|
||||
}
|
||||
|
||||
/** Configuration options for the immersive renderer */
|
||||
export interface ImmersiveConfig {
|
||||
/** Target FPS for animation (lower = less power) */
|
||||
targetFps: number;
|
||||
/** Ambient motion intensity (0 = static, 1 = full motion) */
|
||||
ambientIntensity: number;
|
||||
/** Whether to pause rendering when tab is hidden */
|
||||
pauseWhenHidden: boolean;
|
||||
/** Enable debug overlay with metrics */
|
||||
debugOverlay: boolean;
|
||||
}
|
||||
|
||||
/** Default configuration */
|
||||
export const DEFAULT_IMMERSIVE_CONFIG: ImmersiveConfig = {
|
||||
targetFps: 30,
|
||||
ambientIntensity: 0.5,
|
||||
pauseWhenHidden: true,
|
||||
debugOverlay: false,
|
||||
};
|
||||
Reference in new issue
Block a user