diff --git a/devtools/README.md b/devtools/README.md new file mode 100644 index 00000000000000..47d93959fc4ebe --- /dev/null +++ b/devtools/README.md @@ -0,0 +1,98 @@ +# Three.js DevTools Extension + +This Chrome DevTools extension provides debugging capabilities for Three.js applications. It allows you to inspect scenes, objects, materials, and renderers. + +## Installation + +1. **Development Mode**: + - Open Chrome and navigate to `chrome://extensions/` + - Enable "Developer mode" (toggle in the top-right corner) + - Click "Load unpacked" and select the `devtools` directory + - The extension will now be available in Chrome DevTools when inspecting pages that use Three.js + +2. **Usage**: + - Open Chrome DevTools on a page using Three.js (F12 or Right-click > Inspect) + - Click on the "Three.js" tab in DevTools + - The panel will automatically detect and display Three.js scenes and renderers found on the page. + +## Code Flow Overview + +### Extension Architecture + +The extension follows a standard Chrome DevTools extension architecture: + +1. **Background Script** (`background.js`): Manages the extension lifecycle and communication ports between the panel and content script. +2. **DevTools Script** (`devtools.js`): Creates the panel when the DevTools window opens. +3. **Panel UI** (`panel/panel.html`, `panel/panel.js`, `panel/panel.css`): The DevTools panel interface that displays the data. +4. **Content Script** (`content-script.js`): Injected into the web page. Relays messages between the background script and the bridge script. +5. **Bridge Script** (`bridge.js`): Injected into the page's context by the content script. Directly interacts with the Three.js instance, detects objects, gathers data, and communicates back via the content script. + +### Initialization Flow + +1. When a page loads, `content-script.js` injects `bridge.js` into the page. +2. `bridge.js` creates the `window.__THREE_DEVTOOLS__` global object. +3. When the DevTools panel is opened, `panel.js` connects to `background.js` (`init`) and immediately requests the current state (`request-state`). +4. `background.js` relays the state request to `content-script.js`, which posts it to `bridge.js`. +5. `bridge.js` responds by sending back observed renderer data (`renderer` message) and batched scene data (`scene` message). +6. Three.js detects `window.__THREE_DEVTOOLS__` and sends registration/observation events to the bridge script as objects are created or the library initializes. + +### Bridge Operation (`bridge.js`) + +The bridge acts as the communication layer between the Three.js instance on the page and the DevTools panel: + +1. **Event Management**: Creates a custom event target (`DevToolsEventTarget`) to manage communication readiness and backlog events before the panel connects. +2. **Object Tracking**: + - `getObjectData()`: Extracts essential data (UUID, type, name, parent, children, etc.) from Three.js objects. + - Maintains a local map (`devTools.objects`) of all observed objects. + +3. **Initial Observation & Batching**: + - When Three.js sends an `observe` event (via `window.__THREE_DEVTOOLS__.dispatchEvent`): + - If it's a renderer, its data is collected and sent immediately via a `'renderer'` message. + - If it's a scene, the bridge traverses the entire scene graph, collects data for the scene and all descendants, stores them locally, and sends them to the panel in a single `'scene'` batch message. + +4. **State Request Handling**: + - When the panel sends `request-state` (on load/reload), the bridge iterates its known objects and sends back the current renderer data (`'renderer'`) and scene data (`'scene'` batch). + +5. **Message Handling**: + - Listens for messages from the panel (relayed via content script) like `request-state`. + +### Panel Interface (`panel/`) + +The panel UI provides the visual representation of the Three.js objects: + +1. **Tree View**: Displays hierarchical representation of scenes and objects. +2. **Renderer Details**: Shows properties and statistics for renderers in a collapsible section. + +## Key Features + +- **Scene Hierarchy Visualization**: Browse the complete scene graph. +- **Object Inspection**: View basic object properties (type, name). +- **Renderer Details**: View properties, render stats, and memory usage for `WebGLRenderer` instances. + +## Communication Flow + +1. **Panel ↔ Background ↔ Content Script**: Standard extension messaging for panel initialization and state requests (`init`, `request-state`). +2. **Three.js → Bridge**: Three.js detects `window.__THREE_DEVTOOLS__` and uses its `dispatchEvent` method (sending `'register'`, `'observe'`). +3. **Bridge → Content Script**: Bridge uses `window.postMessage` to send data (`'register'`, `'renderer'`, `'scene'`, `'update'`) to the content script. +4. **Content Script → Background**: Content script uses `chrome.runtime.sendMessage` to relay messages from the bridge to the background. +5. **Background → Panel**: Background script uses the established port connection (`port.postMessage`) to send data to the panel. + +## Key Components + +- **DevToolsEventTarget**: Custom event system with backlogging for async loading. +- **Object Observation & Batching**: Efficiently tracks and sends scene graph data. +- **Renderer Property Display**: Shows detailed statistics for renderers. + +## Integration with Three.js + +The extension relies on Three.js having built-in support for DevTools. When Three.js detects the presence of `window.__THREE_DEVTOOLS__`, it interacts with it, primarily by dispatching events. + +The bridge script listens for these events, organizes the data, and provides it to the DevTools panel. + +## Development + +To modify the extension: + +1. Edit the relevant files in the `devtools` directory. +2. Go to `chrome://extensions/`, find the unpacked extension, and click the reload icon. +3. Close and reopen DevTools on the inspected page to see your changes. \ No newline at end of file diff --git a/devtools/background.js b/devtools/background.js new file mode 100644 index 00000000000000..ed7d85e0e89661 --- /dev/null +++ b/devtools/background.js @@ -0,0 +1,61 @@ +// Map tab IDs to connections +const connections = new Map(); + +// Listen for connections from the devtools panel +chrome.runtime.onConnect.addListener(port => { + let tabId; + + // Listen for messages from the devtools panel + port.onMessage.addListener(message => { + if (message.name === 'init') { + tabId = message.tabId; + connections.set(tabId, port); + } else if (message.name === 'request-state' && tabId) { + chrome.tabs.sendMessage(tabId, message); + } else if (tabId === undefined) { + console.warn('Background: Message received from panel before init:', message); + } + }); + + // Clean up when devtools is closed + port.onDisconnect.addListener(() => { + if (tabId) { + connections.delete(tabId); + } + }); +}); + +// Listen for messages from the content script +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (sender.tab) { + const tabId = sender.tab.id; + const port = connections.get(tabId); + if (port) { + // Forward the message to the devtools panel + try { + port.postMessage(message); + // Send immediate response to avoid "message channel closed" error + sendResponse({ received: true }); + } catch (e) { + console.error('Error posting message to devtools:', e); + // If the port is broken, clean up the connection + connections.delete(tabId); + } + } + } + return false; // Return false to indicate synchronous handling +}); + +// Listen for page navigation events +chrome.webNavigation.onCommitted.addListener(details => { + const { tabId, frameId } = details; + const port = connections.get(tabId); + + if (port) { + port.postMessage({ + id: 'three-devtools', + type: 'committed', + frameId: frameId + }); + } +}); \ No newline at end of file diff --git a/devtools/bridge.js b/devtools/bridge.js new file mode 100644 index 00000000000000..e20f2e8ab25a06 --- /dev/null +++ b/devtools/bridge.js @@ -0,0 +1,478 @@ +/** + * This script injected by the installed three.js developer + * tools extension. + */ + +(function () { + + // Only initialize if not already initialized + if (!window.__THREE_DEVTOOLS__) { + + // Create our custom EventTarget with logging + class DevToolsEventTarget extends EventTarget { + + constructor() { + + super(); + this._ready = false; + this._backlog = []; + this.objects = new Map(); + + } + + addEventListener(type, listener, options) { + + super.addEventListener(type, listener, options); + + // If this is the first listener for a type, and we have backlogged events, + // check if we should process them + if (type !== 'devtools-ready' && this._backlog.length > 0) { + + this.dispatchEvent(new CustomEvent('devtools-ready')); + + } + + } + + dispatchEvent(event) { + + if (this._ready || event.type === 'devtools-ready') { + + if (event.type === 'devtools-ready') { + + this._ready = true; + const backlog = this._backlog; + this._backlog = []; + backlog.forEach(e => super.dispatchEvent(e)); + + } + + return super.dispatchEvent(event); + + } else { + + this._backlog.push(event); + return false; // Return false to indicate synchronous handling + + } + + } + + reset() { + + // console.log('DevTools: Resetting state'); + + // Clear objects map + this.objects.clear(); + + // Clear backlog + this._backlog = []; + + // Reset ready state + this._ready = false; + + // Clear observed arrays + observedScenes.length = 0; + observedRenderers.length = 0; + + } + + } + + // Create and expose the __THREE_DEVTOOLS__ object + const devTools = new DevToolsEventTarget(); + Object.defineProperty(window, '__THREE_DEVTOOLS__', { + value: devTools, + configurable: false, + enumerable: true, + writable: false + }); + + // Declare arrays for tracking observed objects + const observedScenes = []; + const observedRenderers = []; + const sceneObjectCountCache = new Map(); // Cache for object counts per scene + + // Function to get renderer data + function getRendererData(renderer) { + + try { + + const data = { + uuid: renderer.uuid || generateUUID(), + type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer', + name: '', + properties: getRendererProperties(renderer) + }; + return data; + + } catch (error) { + + console.warn('DevTools: Error getting renderer data:', error); + return null; + + } + + } + + // Function to get object hierarchy + function getObjectData(obj) { + + try { + + // Special case for WebGLRenderer + if (obj.isWebGLRenderer === true || obj.isWebGPURenderer === true) { + + return getRendererData(obj); + + } + + // Special case for InstancedMesh + const type = obj.isInstancedMesh ? 'InstancedMesh' : obj.type || obj.constructor.name; + + // Get descriptive name for the object + let name = obj.name || type || obj.constructor.name; + if (obj.isMesh) { + + const geoType = obj.geometry ? obj.geometry.type : 'Unknown'; + const matType = obj.material ? + (Array.isArray(obj.material) ? + obj.material.map(m => m.type).join(', ') : + obj.material.type) : + 'Unknown'; + if (obj.isInstancedMesh) { + + name = `${name} [${obj.count}]`; + + } + + name = `${name} ${geoType} ${matType}`; + + } + + const data = { + uuid: obj.uuid, + name: name, + type: type, + visible: obj.visible !== undefined ? obj.visible : true, + isScene: obj.isScene === true, + isObject3D: obj.isObject3D === true, + isCamera: obj.isCamera === true, + isLight: obj.isLight === true, + isMesh: obj.isMesh === true, + isInstancedMesh: obj.isInstancedMesh === true, + parent: obj.parent ? obj.parent.uuid : null, + children: obj.children ? obj.children.map(child => child.uuid) : [] + }; + + return data; + + } catch (error) { + + console.warn('DevTools: Error getting object data:', error); + return null; + + } + + } + + // Generate a UUID for objects that don't have one + function generateUUID() { + + const array = new Uint8Array(16); + crypto.getRandomValues(array); + array[6] = (array[6] & 0x0f) | 0x40; // Set version to 4 + array[8] = (array[8] & 0x3f) | 0x80; // Set variant to 10 + return [...array].map((b, i) => (i === 4 || i === 6 || i === 8 || i === 10 ? '-' : '') + b.toString(16).padStart(2, '0')).join(''); + + } + + // Listen for Three.js registration + devTools.addEventListener('register', (event) => { + + // console.log('DevTools: Three.js registered with revision:', event.detail.revision); + dispatchEvent('register', event.detail); + + }); + + // Listen for object observations + devTools.addEventListener('observe', (event) => { + + const obj = event.detail; + if (!obj) { + + console.warn('DevTools: Received observe event with null/undefined detail'); + return; + + } + + // Generate UUID if needed + if (!obj.uuid) { + + obj.uuid = generateUUID(); + + } + + // Skip if already registered (essential to prevent loops with batching) + if (devTools.objects.has(obj.uuid)) { + + return; + + } + + if (obj.isWebGLRenderer || obj.isWebGPURenderer) { + + const data = getObjectData(obj); + + if (data) { + + data.properties = getRendererProperties(obj); + observedRenderers.push(obj); + devTools.objects.set(obj.uuid, data); + + dispatchEvent('renderer', data); + + } + + } else if (obj.isScene) { + + observedScenes.push(obj); + + const batchObjects = []; + const processedUUIDs = new Set(); + + function traverseForBatch(currentObj) { + + if (!currentObj || !currentObj.uuid || processedUUIDs.has(currentObj.uuid)) return; + processedUUIDs.add(currentObj.uuid); + + const objectData = getObjectData(currentObj); + if (objectData) { + + batchObjects.push(objectData); + devTools.objects.set(currentObj.uuid, objectData); // Update local cache during batch creation + + } + + // Process children + if (currentObj.children && Array.isArray(currentObj.children)) { + + currentObj.children.forEach(child => traverseForBatch(child)); + + } + + } + + traverseForBatch(obj); // Start traversal from the scene + + dispatchEvent('scene', { sceneUuid: obj.uuid, objects: batchObjects }); + + } + + }); + + // Function to get renderer properties + function getRendererProperties(renderer) { + + const parameters = renderer.getContextAttributes ? renderer.getContextAttributes() : {}; + + return { + width: renderer.domElement ? renderer.domElement.clientWidth : 0, + height: renderer.domElement ? renderer.domElement.clientHeight : 0, + alpha: parameters.alpha || false, + antialias: parameters.antialias || false, + outputColorSpace: renderer.outputColorSpace, + toneMapping: renderer.toneMapping, + toneMappingExposure: renderer.toneMappingExposure !== undefined ? renderer.toneMappingExposure : 1, + shadows: renderer.shadowMap ? renderer.shadowMap.enabled : false, + autoClear: renderer.autoClear, + autoClearColor: renderer.autoClearColor, + autoClearDepth: renderer.autoClearDepth, + autoClearStencil: renderer.autoClearStencil, + localClipping: renderer.localClippingEnabled, + physicallyCorrectLights: renderer.physicallyCorrectLights || false, // Assuming false is default if undefined + info: { + render: { + frame: renderer.info.render.frame, + calls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + points: renderer.info.render.points, + lines: renderer.info.render.lines, + geometries: renderer.info.render.geometries, + sprites: renderer.info.render.sprites + }, + memory: { + geometries: renderer.info.memory.geometries, + textures: renderer.info.memory.textures, + programs: renderer.info.programs ? renderer.info.programs.length : 0, + renderLists: renderer.info.memory.renderLists, + renderTargets: renderer.info.memory.renderTargets + } + } + }; + + } + + // Start periodic renderer checks + // console.log('DevTools: Starting periodic renderer checks'); + + // Function to check if bridge is available + function checkBridgeAvailability() { + + const hasDevTools = window.hasOwnProperty('__THREE_DEVTOOLS__'); + const devToolsValue = window.__THREE_DEVTOOLS__; + + // If we have devtools and we're interactive or complete, trigger ready + if (hasDevTools && devToolsValue && (document.readyState === 'interactive' || document.readyState === 'complete')) { + + devTools.dispatchEvent(new CustomEvent('devtools-ready')); + + } + + } + + // Watch for readyState changes + document.addEventListener('readystatechange', () => { + + if (document.readyState === 'loading') { + + devTools.reset(); + + } + + checkBridgeAvailability(); + + }); + + // Watch for page unload to reset state + window.addEventListener('beforeunload', () => { + + devTools.reset(); + + }); + + // Listen for messages from the content script + window.addEventListener('message', function (event) { + + // Only accept messages from the same frame + if (event.source !== window) return; + + const message = event.data; + if (!message || message.id !== 'three-devtools') return; + + // Handle request for initial state from panel + if (message.name === 'request-state') { + + sendState(); + + } + + }); + + function sendState() { + + // Send current renderers + for (const observedRenderer of observedRenderers) { + + const data = getObjectData(observedRenderer); + if (data) { + + data.properties = getRendererProperties(observedRenderer); + dispatchEvent('renderer', data); + + } + + } + + // Send current scenes + for (const observedScene of observedScenes) { + + reloadSceneObjects(observedScene); + + } + + } + + function dispatchEvent(type, detail) { + + try { + + window.postMessage({ + id: 'three-devtools', + type: type, + detail: detail + }, '*'); + + } catch (error) { + + // If we get an "Extension context invalidated" error, stop all monitoring + if (error.message.includes('Extension context invalidated')) { + + console.log('DevTools: Extension context invalidated, stopping monitoring'); + devTools.reset(); + return; + + } + + console.warn('DevTools: Error dispatching event:', error); + + } + + } + + // Function to manually reload scene objects + function reloadSceneObjects(scene) { + + const batchObjects = []; + + // Recursively observe all objects, collect data, update local cache + function observeAndBatchObject(object) { + + if (!object || !object.uuid) return; // Simplified check + + // console.log('DevTools: Processing object during reload:', object.type || object.constructor.name, object.uuid); + + // Get object data + const objectData = getObjectData(object); + if (objectData) { + + batchObjects.push(objectData); // Add to batch + // Update or add to local cache immediately + devTools.objects.set(object.uuid, objectData); + + } + + // Process children recursively + if (object.children && Array.isArray(object.children)) { + + // console.log('DevTools: Processing', object.children.length, 'children of', object.type || object.constructor.name); + object.children.forEach(child => observeAndBatchObject(child)); + + } + + } + + // Start traversal from the scene itself + observeAndBatchObject(scene); + + // --- Caching Logic --- + const currentObjectCount = batchObjects.length; + const previousObjectCount = sceneObjectCountCache.get(scene.uuid); + + if (currentObjectCount !== previousObjectCount) { + + console.log(`DevTools: Scene ${scene.uuid} count changed (${previousObjectCount} -> ${currentObjectCount}), dispatching update.`); + // Dispatch the batch update for the panel as 'scene' + dispatchEvent('scene', { sceneUuid: scene.uuid, objects: batchObjects }); + // Update the cache + sceneObjectCountCache.set(scene.uuid, currentObjectCount); + + } else { + // console.log(`DevTools: Scene ${scene.uuid} count unchanged (${currentObjectCount}), skipping dispatch.`); + } + + } + + } + +})(); diff --git a/devtools/content-script.js b/devtools/content-script.js new file mode 100644 index 00000000000000..f18eecadc5b1cf --- /dev/null +++ b/devtools/content-script.js @@ -0,0 +1,207 @@ +// This script runs in the context of the web page +// console.log( 'Three.js DevTools: Content script loaded at document_readyState:', document.readyState ); // Comment out + +// Function to inject the bridge script +function injectBridge( target = document ) { + + const script = document.createElement( 'script' ); + script.src = chrome.runtime.getURL( 'bridge.js' ); + script.onload = function () { + + this.remove(); + + }; + + ( target.head || target.documentElement ).appendChild( script ); + + return script; + +} + +// Also inject into any existing iframes +function injectIntoIframes() { + + const iframes = document.querySelectorAll( 'iframe' ); + iframes.forEach( iframe => { + + try { + + injectBridge( iframe.contentDocument ); + + } catch ( e ) { + + // Ignore cross-origin iframe errors + // console.log( 'DevTools: Could not inject into iframe:', e ); // Comment out + + } + + } ); + +} + +// Initial injection +injectBridge(); +injectIntoIframes(); + +// Watch for new iframes being added +const observer = new MutationObserver( mutations => { + + mutations.forEach( mutation => { + + mutation.addedNodes.forEach( node => { + + if ( node.tagName === 'IFRAME' ) { + + // Wait for iframe to load + node.addEventListener( 'load', () => { + + try { + + injectBridge( node.contentDocument ); + + } catch ( e ) { + + // Ignore cross-origin iframe errors + // console.log( 'DevTools: Could not inject into iframe:', e ); // Comment out + + } + + } ); + + } + + } ); + + } ); + +} ); + +observer.observe( document.documentElement, { + childList: true, + subtree: true +} ); + +// Helper function to check if extension context is valid +function isExtensionContextValid() { + + try { + + // This will throw if context is invalidated + chrome.runtime.getURL( '' ); + return true; + + } catch ( error ) { + + return false; + + } + +} + +// Handle messages from the main window +function handleMainWindowMessage( event ) { + + // Only accept messages from the same frame + if ( event.source !== window ) { + + return; + + } + + const message = event.data; + if ( ! message || message.id !== 'three-devtools' ) { + + return; + + } + + // Check extension context before sending message + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + // Add source information + const messageWithSource = { + ...event.data, + source: event.source === window ? 'main' : 'iframe' + }; + + // Forward to background page + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Handle messages from iframes +function handleIframeMessage( event ) { + + // Skip messages from main window + if ( event.source === window ) { + + return; + + } + + const message = event.data; + if ( ! message || message.id !== 'three-devtools' ) { + + return; + + } + + // Check extension context before sending message + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + // Add source information + const messageWithSource = { + ...event.data, + source: 'iframe' + }; + + // Forward to background page + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Listener for messages forwarded from the background script (originating from panel) +function handleBackgroundMessage( message, sender, sendResponse ) { + + // Check if the message is one we need to forward to the bridge + // Only forward request-state now + if ( message.name === 'request-state' ) { + + // console.log( 'Content script: Forwarding message to bridge:', message.name ); + // Ensure the message has the correct ID before forwarding to the page + message.id = 'three-devtools'; + window.postMessage( message, '*' ); // Forward the modified message to the page + + // Optional: Forward to iframes too, if needed (might cause duplicates if bridge is in iframe) + /* + const iframes = document.querySelectorAll('iframe'); + iframes.forEach(iframe => { + try { + iframe.contentWindow.postMessage(message, '*'); + } catch (e) {} + }); + */ + + } + // Keep channel open? No, this listener is synchronous for now. + // return true; + +} + +// Add event listeners +window.addEventListener( 'message', handleMainWindowMessage, false ); +window.addEventListener( 'message', handleIframeMessage, false ); +// chrome.runtime.onMessage.addListener( handleDevtoolsMessage ); // This seems redundant/incorrectly placed in original code + +// Use a single listener for messages from the background script +chrome.runtime.onMessage.addListener( handleBackgroundMessage ); diff --git a/devtools/devtools.js b/devtools/devtools.js new file mode 100644 index 00000000000000..ab6742b968b111 --- /dev/null +++ b/devtools/devtools.js @@ -0,0 +1,18 @@ +try { + + chrome.devtools.panels.create( + 'Three.js', + null, + 'panel/panel.html', + function () { + + console.log( 'Three.js DevTools panel created' ); + + } + ); + +} catch ( error ) { + + console.error( 'Failed to create Three.js panel:', error ); + +} diff --git a/devtools/index.html b/devtools/index.html new file mode 100644 index 00000000000000..c83ce6457297b2 --- /dev/null +++ b/devtools/index.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/devtools/manifest.json b/devtools/manifest.json new file mode 100644 index 00000000000000..9017331555ae43 --- /dev/null +++ b/devtools/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Three.js DevTools", + "version": "1.4", + "description": "Developer tools extension for Three.js", + "devtools_page": "index.html", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [{ + "matches": ["