UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

752 lines (655 loc) 29.7 kB
import { DeviceUtilities,getParam } from "../engine_utils.js"; import { getErrorCount } from "./debug_overlay.js"; let consoleInstance: VConsole | null | undefined = undefined; let consoleHtmlElement: HTMLElement | null = null; let consoleSwitchButton: HTMLElement | null = null; let isLoading = false; let isVisible = false; let watchInterval: any = null; const defaultButtonIcon = "terminal"; const showConsole = getParam("console"); if (showConsole) { showDebugConsole(); } const $defaultConsoleParent = Symbol("consoleParent"); export function showDebugConsole() { if (consoleInstance) { isVisible = true; consoleInstance.showSwitch(); return; } createConsole(); } export function hideDebugConsole() { if (!consoleInstance) return; isVisible = false; consoleInstance.hide(); consoleInstance.hideSwitch(); } function beginWatchingLogs() { if (watchInterval) return; watchInterval = setInterval(consoleElementUpdateInterval, 500); } let lastErrorCount = 0; function consoleElementUpdateInterval() { const currentCount = getErrorCount(); const receivedNewErrors = currentCount !== lastErrorCount; lastErrorCount = currentCount; if (receivedNewErrors) { onNewConsoleErrors(); } } function onNewConsoleErrors() { showDebugConsole(); if (consoleSwitchButton) { consoleSwitchButton.setAttribute("error", "true"); consoleSwitchButton.innerText = "🤬" } } function onConsoleSwitchButtonClicked() { if (consoleSwitchButton) { consoleSwitchButton.removeAttribute("error"); consoleSwitchButton.innerText = defaultButtonIcon; } } function onResetConsoleElementToDefaultParent() { if (consoleHtmlElement && consoleHtmlElement[$defaultConsoleParent]) { consoleHtmlElement[$defaultConsoleParent].appendChild(consoleHtmlElement); } } declare class VConsole { addPlugin: (plugin: any) => void; setSwitchPosition: (x: number, y: number) => void; show: () => void; hide: () => void; hideSwitch: () => void; showSwitch: () => void; constructor(options: { defaultPlugins?: string[], pluginOrder?: string[], }); static VConsolePlugin: any; } declare class PluginBtn { name: string; className?: string; data?: any; onClick: (event: Event, data: {type: string}) => void; } function createConsole(startHidden: boolean = false) { if (consoleInstance !== undefined) return; if (isLoading) return; isLoading = true; const script = document.createElement("script"); script.onload = () => { // check if VConsole is now defined on globalThis if (!globalThis.VConsole) { console.warn("🌵 Debug console failed to load."); isLoading = false; consoleInstance = null; return; } isLoading = false; isVisible = true; beginWatchingLogs(); consoleInstance = new VConsole({ // defaultPlugins: ['system', 'network'], pluginOrder: ['default', 'needle-console'], }) as VConsole; const files = globalThis["needle:codegen_files"]; if (files && files.length > 0) { consoleInstance.addPlugin(createInspectPlugin()); } // Add plugin for device utilities consoleInstance.addPlugin(createDeviceUtilitiesPlugin()); // Add plugin for graphics info consoleInstance.addPlugin(createGraphicsInfoPlugin()); consoleHtmlElement = getConsoleElement(); if (consoleHtmlElement) { consoleHtmlElement[$defaultConsoleParent] = consoleHtmlElement.parentElement; consoleHtmlElement.style.position = "absolute"; consoleHtmlElement.style.zIndex = Number.MAX_SAFE_INTEGER.toString(); // const styleSheetList = document.styleSheets; // for (let i = 0; i < styleSheetList.length; i++) { // const styleSheet = styleSheetList[i]; // const firstRule = styleSheet.cssRules[0] as CSSStyleRule; // if(firstRule && firstRule.selectorText === "#__vconsole") { // console.log("found vconsole style sheet"); // const styleTag = document.createElement("style"); // styleTag.innerHTML = "#__needleconsole {}"; // for (let j = 0; j < styleSheet.cssRules.length; j++) { // const rule = styleSheet.cssRules[j] as CSSStyleRule; // styleTag.innerHTML += rule.cssText; // } // consoleHtmlElement.appendChild(styleTag); // } // } } consoleInstance.setSwitchPosition(20, 30); consoleSwitchButton = getConsoleSwitchButton(); if (consoleSwitchButton) { consoleSwitchButton.innerText = defaultButtonIcon; consoleSwitchButton.addEventListener("click", onConsoleSwitchButtonClicked); const styles = document.createElement("style"); const size = 40; styles.innerHTML = ` #__vconsole .vc-switch { border: 1px solid rgba(255, 255, 255, .1); border-radius: 50%; width: ${size}px; height: ${size}px; padding: 0; line-height: ${size}px; font-size: ${size * .4}px; text-align: center; background: #ffffff5c; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); user-select: none; pointer-events: auto; transition: transform .2s ease-in-out; box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05); font-family: 'Material Symbols Outlined'; color: black; font-size: 2.3em; font-weight: 100; } #__vconsole .vc-switch:hover { cursor: pointer; transform: scale(1.1); transition: transform .1s ease-in-out, background .1s linear; background: rgba(245, 245, 245, .8); outline: rgba(0, 0, 0, .05) 1px solid; } #__vconsole .vc-switch[error] { background: rgba(255,0,0,.2); animation: vconsole-notify 1s ease-in-out; line-height: 35px; } @keyframes vconsole-notify { from { transform: scale(1, 1); } 10% { transform: scale(1.3, 1.3); } 70% { transform: scale(1.4, 1.4); } to { transform: scale(1, 1); } } #__vconsole .vc-panel { font-family: monospace; font-size: 11px; } #__vconsole .vc-plugin-box.vc-actived { height: 100%; } #__vconsole .vc-mask { overflow: hidden; } `; consoleHtmlElement?.prepend(styles); if (startHidden === true && getErrorCount() <= 0) hideDebugConsole(); console.log("🌵 Debug console has loaded"); } }; script.onerror = () => { console.warn("🌵 Debug console failed to load." + (window.crossOriginIsolated ? "This page is using cross-origin isolation, so external scripts can't be loaded." : "")); isLoading = false; consoleInstance = null; }; script.src = "https://cdn.jsdelivr.net/npm/vconsole@3.15.1/dist/vconsole.min.js"; document.body.appendChild(script); } function createInspectPlugin() { if (!globalThis.VConsole) return; const plugin = new VConsole.VConsolePlugin("needle-console", "🌵 Inspect glTF"); const getIframe = () => { return document.querySelector("#__vc_plug_" + plugin._id + " iframe") as HTMLIFrameElement; } plugin.on('renderTab', function(callback) { const files = globalThis["needle:codegen_files"]; if (!files || files.length === 0) return; let query = globalThis["needle:codegen_files"][0]; const index = query.indexOf("?"); if (index > -1) query = query.substring(0, index); const currentAbsolutePath = location.protocol + '//' + location.host + location.pathname; const currentPath = currentAbsolutePath + "/" + query; const urlEncoded = encodeURIComponent(currentPath); plugin.fullUrl = "https://viewer.needle.tools?inspect&file=" + urlEncoded; var html = `<iframe src="" style="width: 100%; height: 99%; border: none;"></iframe>`; callback(html); }); plugin.on('show', function() { const elem = getIframe(); if (elem && elem.src !== plugin.fullUrl) elem.src = plugin.fullUrl; }); plugin.on('hide', function() { const elem = getIframe(); if (elem) elem.src = ""; }); /* bottom tool bar plugin.on('addTool', function(callback) { var button = { name: 'Reload', onClick: function(event) { location.reload(); } }; callback([button]); }); */ plugin.on('addTopBar', function(callback) { var btnList = new Array<PluginBtn>(); btnList.push({ name: 'Open in new window ↗', onClick: function(_event) { window.open(plugin.fullUrl, '_blank'); consoleInstance?.hide(); } }); btnList.push({ name: 'Reload', onClick: function(_event) { const iframe = getIframe(); if (iframe) iframe.src = plugin.fullUrl; } }); btnList.push({ name: 'Fullscreen', onClick: function(_event) { const iframe = getIframe(); if (iframe.requestFullscreen) { iframe.requestFullscreen(); } else if (iframe["webkitRequestFullscreen"] instanceof Function) { iframe["webkitRequestFullscreen"](); } } }); callback(btnList); }); return plugin; } const CONTAINER_STYLE = "padding: 10px; font-family: monospace;"; const HEADING_STYLE = "margin-bottom: 10px;"; const SUBHEADING_STYLE = "margin-bottom: 10px; margin-top: 15px;"; const TABLE_STYLE = "width: 100%; border-collapse: collapse; border: 1px solid rgba(0,0,0,0.1); table-layout: fixed;"; const CELL_STYLE = "border: 1px solid rgba(0,0,0,0.1); padding: 5px;"; const HEADER_CELL_STYLE = CELL_STYLE; const VALUE_CELL_STYLE = CELL_STYLE + " word-break: break-all;"; function createTable(rows: Array<{label: string, value: any}>, sortByValue: boolean = false): string { if (sortByValue) { rows.sort((a, b) => (b.value ? 1 : 0) - (a.value ? 1 : 0)); } let html = `<table style='${TABLE_STYLE}'>`; html += "<tbody>"; for (const row of rows) { const value = typeof row.value === 'boolean' ? (row.value ? "✅" : "❌") : row.value; html += `<tr><td style='${HEADER_CELL_STYLE}'>${row.label}</td><td style='${VALUE_CELL_STYLE}'>${value}</td></tr>`; } html += "</tbody></table>"; return html; } function getWebGL2Support(): string { try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2'); if (gl) { return "✅"; } } catch (e) { // WebGL2 not supported } return "❌"; } function createDeviceUtilitiesPlugin() { if (!globalThis.VConsole) return; const plugin = new VConsole.VConsolePlugin("device-utilities", "📱 Device Info"); plugin.on('renderTab', function(callback) { let html = `<div style='${CONTAINER_STYLE}'>`; // Device type const deviceType = getDeviceType(); html += `<h3 style='${HEADING_STYLE}'>Device: ${deviceType}</h3>`; // Device capabilities table html += createTable([ { label: "💻 Desktop", value: DeviceUtilities.isDesktop() }, { label: "📱 Mobile Device", value: DeviceUtilities.isMobileDevice() }, { label: "🍎 iOS", value: DeviceUtilities.isiOS() }, { label: "📱 iPad", value: DeviceUtilities.isiPad() }, { label: "🤖 Android", value: DeviceUtilities.isAndroidDevice() }, { label: "🦊 Mozilla XR", value: DeviceUtilities.isMozillaXR() }, { label: "🌵 Needle App Clip", value: DeviceUtilities.isNeedleAppClip() }, { label: "🍏 macOS", value: DeviceUtilities.isMacOS() }, { label: "👓 VisionOS", value: DeviceUtilities.isVisionOS() }, { label: "🧭 Safari", value: DeviceUtilities.isSafari() }, { label: "🕶️ Meta Quest", value: DeviceUtilities.isQuest() }, { label: "🔗 QuickLook AR Support", value: DeviceUtilities.supportsQuickLookAR() }, ], true); // Versions const versionRows: Array<{label: string, value: any}> = []; const iosVersion = DeviceUtilities.getiOSVersion(); if (iosVersion) versionRows.push({ label: "🍎 iOS Version", value: iosVersion }); const chromeVersion = DeviceUtilities.getChromeVersion(); if (chromeVersion) versionRows.push({ label: "🌐 Chrome Version", value: chromeVersion }); const safariVersion = DeviceUtilities.getSafariVersion(); if (safariVersion) versionRows.push({ label: "🧭 Safari Version", value: safariVersion }); if (versionRows.length > 0) { html += createTable(versionRows, false); } html += "</div>"; // User Agent table html += `<div style='${CONTAINER_STYLE} margin-top: 20px;'>`; html += `<h3 style='${HEADING_STYLE}'>User Agent Info</h3>`; const userAgentRows = [ { label: "User Agent", value: navigator.userAgent }, { label: "Platform", value: navigator.platform }, { label: "App Version", value: navigator.appVersion }, // @ts-ignore { label: "User Agent Data", value: navigator.userAgentData ? `Platform: ${navigator.userAgentData.platform}, Mobile: ${navigator.userAgentData.mobile}` : "Not supported" }, { label: "WebXR", value: 'xr' in navigator ? "✅" : "❌" }, { label: "WebGPU", value: 'gpu' in navigator ? "✅" : "❌" }, { label: "WebGL 2", value: getWebGL2Support() }, ]; html += createTable(userAgentRows, false); html += "</div>"; callback(html); }); return plugin; } function createGraphicsInfoPlugin() { if (!globalThis.VConsole) return; const plugin = new VConsole.VConsolePlugin("graphics-info", "🎨 Graphics Info"); plugin.on('renderTab', async function(callback) { let html = `<div style='${CONTAINER_STYLE}'>`; // General GPU Info Table const generalInfo = getGeneralGPUInfo(); if (generalInfo.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>General GPU Info</h3>`; html += createTable(generalInfo, false); } // WebGL Info Table const webglInfo = getWebGLDetailedInfo(); if (webglInfo.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>WebGL</h3>`; html += createTable(webglInfo, false); } // WebGL 2 Features Table const webgl2Features = getWebGL2FeaturesTable(); if (webgl2Features.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>WebGL 2 Features</h3>`; html += createTable(webgl2Features, false); } // WebGL Limits Table const webglLimits = getWebGLLimitsTable(); if (webglLimits.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>WebGL Limits</h3>`; html += createTable(webglLimits, false); } // Texture Formats Table const textureFormats = getTextureFormatsTable(); if (textureFormats.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>Texture Formats</h3>`; html += createTable(textureFormats, false); } // WebGPU Info Table const webgpuInfo = await getWebGPUInfoTable(); if (webgpuInfo.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>WebGPU</h3>`; html += createTable(webgpuInfo, false); } // Safari specific GPU info if (DeviceUtilities.isSafari()) { const safariGPUInfo = getSafariGPUInfo(); if (safariGPUInfo.length > 0) { html += `<h3 style='${SUBHEADING_STYLE}'>Safari GPU Info</h3>`; html += createTable(safariGPUInfo, false); } } html += "</div>"; callback(html); }); return plugin; } function getGeneralGPUInfo(): Array<{label: string, value: string}> { const info: Array<{label: string, value: string}> = []; // Display and window info const dpr = window.devicePixelRatio; info.push({ label: "Device Pixel Ratio", value: dpr.toString() }); info.push({ label: "Width (px)", value: (window.innerWidth * dpr).toString() }); info.push({ label: "Height (px)", value: (window.innerHeight * dpr).toString() }); // Estimated physical screen size in cm (96 DPI desktop, 150 DPI mobile) const isMobile = DeviceUtilities.isMobileDevice(); const estimatedDPI = isMobile ? 150 : 96; const widthInches = screen.width / estimatedDPI; const heightInches = screen.height / estimatedDPI; const widthCm = widthInches * 2.54; const heightCm = heightInches * 2.54; info.push({ label: "Estimated Width (cm)", value: widthCm.toFixed(1) }); info.push({ label: "Estimated Height (cm)", value: heightCm.toFixed(1) }); const webglInfo = getWebGLInfo(); if (webglInfo) { info.push({ label: "GPU", value: webglInfo.renderer }); info.push({ label: "Driver", value: webglInfo.vendor }); info.push({ label: "ANGLE", value: webglInfo.angle || "Not detected" }); // Enhanced GPU parsing const gpuDetails = parseGPUDetails(webglInfo.renderer); if (gpuDetails) { if (gpuDetails.manufacturer) info.push({ label: "Manufacturer", value: gpuDetails.manufacturer }); if (gpuDetails.cardVersion) info.push({ label: "Card Version", value: gpuDetails.cardVersion }); if (gpuDetails.brand) info.push({ label: "Brand", value: gpuDetails.brand }); info.push({ label: "Integrated", value: gpuDetails.integrated ? "Yes" : "No" }); if (gpuDetails.layer) info.push({ label: "WebGL Layer", value: gpuDetails.layer }); } } return info; } function parseGPUDetails(renderer: string): {manufacturer?: string, cardVersion?: string, brand?: string, integrated?: boolean, layer?: string, card?: string} | null { if (!renderer) return null; const extractValue = (reg: RegExp, str: string) => { const matches = str.match(reg); return matches && matches[0]; }; const layer = extractValue(/(ANGLE)/g, renderer) || undefined; const card = extractValue(/((NVIDIA|AMD|Intel)[^\d]*[^\s]+)/, renderer) || renderer; const tokens = card.split(' '); tokens.shift(); const manufacturer = extractValue(/(NVIDIA|AMD|Intel)/g, card) || undefined; const cardVersion = tokens.length > 0 ? tokens.pop() : undefined; const brand = tokens.length > 0 ? tokens.join(' ') : undefined; const integrated = manufacturer === 'Intel'; return { manufacturer, cardVersion, brand, integrated, layer, card }; } function getWebGLDetailedInfo(): Array<{label: string, value: string}> { const info: Array<{label: string, value: string}> = []; const webglInfo = getWebGLInfo(); if (webglInfo) { info.push({ label: "📊 WebGL Version", value: webglInfo.version }); info.push({ label: "🎮 WebGL 2 Available", value: getWebGL2Support() }); } return info; } function getWebGL2FeaturesTable(): Array<{label: string, value: string}> { const features: Array<{label: string, value: string}> = []; try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2'); if (!gl) return features; // Check for important WebGL 2 features features.push({ label: "Float Color Buffer", value: gl.getExtension('EXT_color_buffer_float') ? "✅" : "❌" }); features.push({ label: "Anisotropic Filtering", value: gl.getExtension('EXT_texture_filter_anisotropic') ? "✅" : "❌" }); features.push({ label: "Float Texture Linear", value: gl.getExtension('OES_texture_float_linear') ? "✅" : "❌" }); features.push({ label: "S3TC Compression", value: gl.getExtension('WEBGL_compressed_texture_s3tc') ? "✅" : "❌" }); features.push({ label: "ETC Compression", value: gl.getExtension('WEBGL_compressed_texture_etc') ? "✅" : "❌" }); features.push({ label: "PVRTC Compression", value: gl.getExtension('WEBGL_compressed_texture_pvrtc') ? "✅" : "❌" }); features.push({ label: "ASTC Compression", value: gl.getExtension('WEBGL_compressed_texture_astc') ? "✅" : "❌" }); } catch (e) { // WebGL2 not supported } return features; } function getWebGLLimitsTable(): Array<{label: string, value: string}> { const limits: Array<{label: string, value: string}> = []; try { // Try WebGL 2 first, fall back to WebGL 1 const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); if (!gl) return limits; const isWebGL2 = gl instanceof WebGL2RenderingContext; limits.push({ label: "📏 Max Texture Size", value: gl.getParameter(gl.MAX_TEXTURE_SIZE).toString() }); limits.push({ label: "🎨 Max Renderbuffer Size", value: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE).toString() }); limits.push({ label: "🔗 Max Vertex Attribs", value: gl.getParameter(gl.MAX_VERTEX_ATTRIBS).toString() }); limits.push({ label: "🎯 Max Texture Units", value: gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS).toString() }); if (isWebGL2) { limits.push({ label: "⚡ Max Samples", value: gl.getParameter(gl.MAX_SAMPLES).toString() }); limits.push({ label: "🔄 Max Uniform Buffer Bindings", value: gl.getParameter(gl.MAX_UNIFORM_BUFFER_BINDINGS).toString() }); limits.push({ label: "📐 Max 3D Texture Size", value: gl.getParameter(gl.MAX_3D_TEXTURE_SIZE).toString() }); } } catch (e) { // WebGL not available } return limits; } function getTextureFormatsTable(): Array<{label: string, value: string}> { const formats: Array<{label: string, value: string}> = []; try { // WebGL 1 texture formats const canvas1 = document.createElement('canvas'); const gl1 = canvas1.getContext('webgl'); if (gl1) { formats.push({ label: "WebGL 1 RGBA", value: "✅" }); formats.push({ label: "WebGL 1 RGB", value: "✅" }); } // WebGL 2 texture formats const canvas2 = document.createElement('canvas'); const gl2 = canvas2.getContext('webgl2'); if (gl2) { formats.push({ label: "WebGL 2 RGBA32F", value: gl2.getExtension('EXT_color_buffer_float') ? "✅" : "❌" }); formats.push({ label: "WebGL 2 RGB32F", value: gl2.getExtension('EXT_color_buffer_float') ? "✅" : "❌" }); formats.push({ label: "WebGL 2 R11F_G11F_B10F", value: "✅" }); formats.push({ label: "WebGL 2 RGB565", value: "✅" }); formats.push({ label: "WebGL 2 RGB5_A1", value: "✅" }); formats.push({ label: "WebGL 2 RGBA4444", value: "✅" }); } } catch (e) { // WebGL not available } return formats; } async function getWebGPUInfoTable(): Promise<Array<{label: string, value: string}>> { const info: Array<{label: string, value: string}> = []; if (!('gpu' in navigator)) { info.push({ label: "🚀 WebGPU Support", value: "❌ Not supported" }); return info; } info.push({ label: "🚀 WebGPU Support", value: "✅ Supported" }); try { const adapter = await (navigator as any).gpu.requestAdapter(); if (!adapter) { info.push({ label: "🎯 Adapter", value: "No adapter available" }); return info; } info.push({ label: "🎯 Adapter", value: adapter.name || "Unknown Adapter" }); const device = await adapter.requestDevice(); info.push({ label: "🔧 Device", value: device.label || "WebGPU Device" }); // WebGPU Limits info.push({ label: "📏 Max Texture 2D", value: device.limits.maxTextureDimension2D.toString() }); info.push({ label: "📐 Max Texture 3D", value: device.limits.maxTextureDimension3D.toString() }); info.push({ label: "📊 Max Texture Array Layers", value: device.limits.maxTextureArrayLayers.toString() }); info.push({ label: "💾 Max Buffer Size", value: `${(device.limits.maxBufferSize / 1024 / 1024).toFixed(1)}MB` }); info.push({ label: "🔗 Max Bind Groups", value: device.limits.maxBindGroups.toString() }); } catch (e) { info.push({ label: "❌ Error", value: (e as Error).message }); } return info; } function getWebGLInfo(): {renderer: string, vendor: string, version: string, angle?: string} | null { try { const canvas = document.createElement('canvas'); // Try WebGL 2 first, fall back to WebGL 1 const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); if (!gl) return null; const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); const vendor = debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR); const version = gl.getParameter(gl.VERSION); // Try to detect ANGLE let angle = undefined; if (renderer && renderer.includes('ANGLE')) { const angleMatch = renderer.match(/ANGLE \(([^)]+)\)/); if (angleMatch) { angle = angleMatch[1]; } } return { renderer, vendor, version, angle }; } catch (e) { return null; } } function getSafariGPUInfo(): Array<{label: string, value: string}> { const info: Array<{label: string, value: string}> = []; // Try to get Safari-specific GPU info try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); if (gl) { const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); if (debugInfo) { const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); if (renderer && renderer.includes('Apple')) { info.push({ label: "🍎 Apple GPU", value: renderer }); } } } } catch (e) { // Ignore errors } // Check for WebGL extensions specific to Safari try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); if (gl) { const extensions = gl.getSupportedExtensions() || []; if (extensions.includes('WEBGL_compressed_texture_pvrtc')) { info.push({ label: "🗜️ PVRTC Support", value: "✅" }); } } } catch (e) { // Ignore errors } return info; } function getDeviceType(): string { if (DeviceUtilities.isQuest()) return "Meta Quest"; if (DeviceUtilities.isVisionOS()) return "Vision Pro"; if (DeviceUtilities.isiOS()) { if (DeviceUtilities.isiPad()) return "iPad"; return "iPhone/iPod"; } if (DeviceUtilities.isAndroidDevice()) return "Android Device"; if (DeviceUtilities.isMozillaXR()) return "Mozilla XR Browser"; if (DeviceUtilities.isNeedleAppClip()) return "Needle App Clip"; if (DeviceUtilities.isMacOS()) return "Mac"; if (DeviceUtilities.isDesktop()) return "Desktop PC"; return "Unknown Device"; } function getConsoleSwitchButton(): HTMLElement | null { const el = document.querySelector("#__vconsole .vc-switch"); if (el) return el as HTMLElement; return null; } function getConsoleElement(): HTMLElement | null { const el = document.querySelector("#__vconsole"); if (el) return el as HTMLElement; return null; }