Ver código fonte

feat: 474 vue chrome devtools plugin (#526)

* feat: vue chrome devtools

* feat: editable scenes from devtools

* chore(lint): fix lint errors

* feat: highlight material

* chore(lint): fix

* docs: debug section + devtools guide
Alvaro Saburido 1 ano atrás
pai
commit
0185bfa6f0

+ 20 - 12
docs/.vitepress/config.ts

@@ -64,8 +64,28 @@ export default defineConfig({
           },
         ],
       },
+
+      {
+        text: 'Advanced',
+
+        items: [
+          { text: 'Extending', link: '/advanced/extending' },
+          { text: 'primitive', link: '/advanced/primitive' },
+          {
+            text: 'Caveats',
+            link: '/advanced/caveats',
+          },
+        ],
+      },
+      {
+        text: 'Debug',
+        items: [
+          { text: 'Devtools', link: '/debug/devtools' },
+        ],
+      },
       {
         text: 'Examples',
+        collapsed: true,
         items: [
           { text: 'Orbit Controls', link: '/examples/orbit-controls' },
           { text: 'Basic Animations', link: '/examples/basic-animations' },
@@ -77,18 +97,6 @@ export default defineConfig({
           { text: 'Shaders', link: '/examples/shaders' },
         ],
       },
-      {
-        text: 'Advanced',
-
-        items: [
-          { text: 'Extending', link: '/advanced/extending' },
-          { text: 'primitive', link: '/advanced/primitive' },
-          {
-            text: 'Caveats',
-            link: '/advanced/caveats',
-          },
-        ],
-      },
       {
         text: 'Directives',
         collapsed: true,

+ 28 - 0
docs/debug/devtools.md

@@ -0,0 +1,28 @@
+# Devtools
+
+
+
+One of the most difficult things a developer faces when creating 3D experiences on the browser is debugging. The browser `canvas` is a black box, and it's hard to know what's going on inside. The imperative nature of [ThreeJS](https://threejs.org/) makes it incredibly difficult to debug, having to depend on `console.log` to see what's going on, or third party to fine-tune and inspect the scene.
+
+Don't make me get started with checking the performance of your scene. 😱
+
+![developer debugging 3D](/debug-3D.png)
+
+One of our goals with TresJS is to offer **the best DX (Developer Experience)** when dealing with 3D scenes on the browser. Thanks to the declarative nature of the ecosystem plus the variety of solutions the Vue ecosystem offers such as the Vue Devtools, Nuxt and Vite, we can offer a better tooling for devs to debug their scenes.
+
+## Introducing the Devtools
+
+From <Badge text="^3.7.0" /> we are introducing the TresJS Devtools, a customized inspector tab for the [Official Vue Chrome Devtools](https://devtools.vuejs.org/guide/installation.html) that allows you to inspect your TresJS scenes and components.
+
+![TresJS Devtools](/vue-chrome-devtools.png)
+
+### Features
+
+- **Scene Inspector**: Inspect the current scene and its components using a tree view similar to the Vue Devtools component inspector.
+- **Memory Allocation**: See how much memory is being by the components.
+- **Object Inspector**: Inspect the properties of the selected object in the scene, including its children.
+- **Editable Properties**: And yes, you can edit the properties of the selected object and see the changes in real-time.
+
+![](/devtools-scene-inspector.png)
+
+Enjoy the new Devtools and let us know what you think! 🎉

BIN
docs/public/debug-3D.png


BIN
docs/public/devtools-scene-inspector.png


BIN
docs/public/meme-debugging.jpg


BIN
docs/public/vue-chrome-devtools.png


+ 2 - 1
playground/package.json

@@ -10,15 +10,16 @@
   },
   "dependencies": {
     "@tresjs/cientos": "3.6.0",
+    "@tresjs/core": "workspace:^",
     "vue-router": "^4.2.5"
   },
   "devDependencies": {
     "@tresjs/leches": "0.15.0-next.3",
     "@tweakpane/plugin-essentials": "^0.2.0",
-    "vite-plugin-vue-devtools": "1.0.0-rc.6",
     "unplugin-auto-import": "^0.17.2",
     "vite-plugin-glsl": "^1.2.1",
     "vite-plugin-qrcode": "^0.2.3",
+    "vite-plugin-vue-devtools": "1.0.0-rc.6",
     "vue-tsc": "^1.8.25"
   }
 }

+ 19 - 17
playground/src/components/TheExperience.vue

@@ -3,6 +3,8 @@ import { ref, watchEffect } from 'vue'
 import { BasicShadowMap, SRGBColorSpace, NoToneMapping } from 'three'
 import { TresCanvas } from '@tresjs/core'
 import { OrbitControls } from '@tresjs/cientos'
+import { TresLeches, useControls } from '@tresjs/leches'
+import '@tresjs/leches/styles'
 import TheSphere from './TheSphere.vue'
 
 const gl = {
@@ -17,20 +19,21 @@ const gl = {
 const wireframe = ref(true)
 
 const canvas = ref()
+const meshRef = ref()
+
+const { isVisible } = useControls({
+  isVisible: true,
+})
 
 watchEffect(() => {
-  if (canvas.value) {
-    console.log(canvas.value.context)
+  if (meshRef.value) {
+    console.log(meshRef.value)
   }
 })
 </script>
 
 <template>
-  <div>
-    <RouterLink to="/multiple">
-      Multiple
-    </RouterLink>
-  </div>
+  <TresLeches />
   <TresCanvas
     v-bind="gl"
     ref="canvas"
@@ -42,14 +45,10 @@ watchEffect(() => {
       :look-at="[0, 4, 0]"
     />
     <OrbitControls />
-    <TresFog
-      :color="gl.clearColor"
-      :near="5"
-      :far="15"
-    />
     <TresMesh
       :position="[-2, 6, 0]"
       :rotation="[0, Math.PI, 0]"
+      name="cone"
       cast-shadow
     >
       <TresConeGeometry :args="[1, 1.5, 3]" />
@@ -66,19 +65,22 @@ watchEffect(() => {
       />
     </TresMesh>
     <TresMesh
-      :rotation="[-Math.PI / 2, 0, 0]"
+      ref="meshRef"
+      :rotation="[-Math.PI / 2, 0, Math.PI / 2]"
+      name="floor"
       receive-shadow
     >
-      <TresPlaneGeometry :args="[10, 10, 10, 10]" />
-      <TresMeshToonMaterial color="#D3FC8A" />
+      <TresPlaneGeometry :args="[20, 20, 20]" />
+      <TresMeshToonMaterial
+        color="#D3FC8A"
+      />
     </TresMesh>
-    <TheSphere />
+    <TheSphere v-if="isVisible" />
     <TresAxesHelper :args="[1]" />
     <TresDirectionalLight
       :position="[0, 2, 4]"
       :intensity="2"
       cast-shadow
     />
-    <TresOrthographicCamera />
   </TresCanvas>
 </template>

+ 1 - 0
playground/src/components/TheSphere.vue

@@ -3,6 +3,7 @@
 <template>
   <TresMesh
     :position="[2, 2, 0]"
+    name="sphere"
     cast-shadow
   >
     <TresSphereGeometry />

Diferenças do arquivo suprimidas por serem muito extensas
+ 49 - 0
playground/vite.config.ts.timestamp-1706539768400-58c91108b32e6.mjs


Diferenças do arquivo suprimidas por serem muito extensas
+ 153 - 416
pnpm-lock.yaml


+ 5 - 0
src/components/TresCanvas.vue

@@ -34,6 +34,7 @@ import { render } from '../core/renderer'
 
 import type { RendererPresetsType } from '../composables/useRenderer/const'
 import type { TresCamera, TresObject } from '../types/'
+import { registerTresDevtools } from '../devtools'
 
 export interface TresCanvasProps
   extends Omit<WebGLRendererParameters, 'canvas'> {
@@ -93,6 +94,10 @@ const createInternalComponent = (context: TresContext) =>
       if (ctx) ctx.app = instance as App
       provide('useTres', context)
       provide('extend', extend)
+
+      if (typeof window !== 'undefined') {
+        registerTresDevtools(ctx.app, context)
+      }
       return () => h(Fragment, null, slots?.default ? slots.default() : [])
     },
   })

+ 24 - 0
src/devtools/highlight.ts

@@ -0,0 +1,24 @@
+import * as THREE from 'three'
+
+export class HightlightMesh extends THREE.Mesh {
+  type = 'HightlightMesh'
+  createTime: number
+  constructor(...args: THREE.Mesh['args']) {
+    super(...args)
+    this.createTime = Date.now()
+  }
+
+  onBeforeRender() {
+    const currentTime = Date.now()
+    const time = (currentTime - this.createTime) / 1000
+    // Pulsing effect parameters
+    const scaleAmplitude = 0.07 // Amplitude of the scale pulsation
+    const pulseSpeed = 2.5 // Speed of the pulsation
+
+    // Calculate the scale factor with a sine function for pulsing effect
+    const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time)
+
+    // Apply the scale factor
+    this.scale.set(scaleFactor, scaleFactor, scaleFactor)
+  }
+}

+ 1 - 0
src/devtools/index.ts

@@ -0,0 +1 @@
+export { registerTresDevtools } from './plugin'

+ 292 - 0
src/devtools/plugin.ts

@@ -0,0 +1,292 @@
+import type {
+  App as DevtoolsApp } from '@vue/devtools-api'
+import {
+  setupDevtoolsPlugin,
+} from '@vue/devtools-api'
+import { reactive } from 'vue'
+import type { Mesh, Object3D } from 'three'
+import { createHighlightMesh, editSceneObject } from '../utils'
+import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
+import type { TresContext } from '../composables'
+import type { TresObject } from './../types'
+import { toastMessage } from './utils'
+
+export interface Tags {
+  label: string
+  textColor: number
+  backgroundColor: number
+  tooltip?: string
+}
+
+export interface SceneGraphObject {
+  id: string
+  label: string
+  children: SceneGraphObject[]
+  tags: Tags[]
+}
+
+const createNode = (object: TresObject): SceneGraphObject => {
+  const node: SceneGraphObject = {
+    id: object.uuid,
+    label: object.type,
+    children: [],
+    tags: [],
+  }
+  if (object.name !== '') {
+    node.tags.push({
+      label: object.name,
+      textColor: 0x57BF65,
+      backgroundColor: 0xF0FCF3,
+    })
+  }
+  const memory = calculateMemoryUsage(object)
+  if (memory > 0) {
+    node.tags.push({
+      label: `${bytesToKB(memory)} KB`,
+      textColor: 0xEFAC35,
+      backgroundColor: 0xFFF9DC,
+      tooltip: 'Memory usage',
+    })
+  }
+
+  if (object.type.includes('Light')) {
+    node.tags.push({
+      label: `${object.intensity}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Intensity',
+    })
+    node.tags.push({
+      label: `#${object.color.getHexString()}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Color',
+    })
+  }
+
+  if (object.type.includes('Camera')) {
+    node.tags.push({
+      label: `${object.fov}°`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Field of view',
+    })
+    node.tags.push({
+      // eslint-disable-next-line max-len
+      label: `x: ${Math.round(object.position.x)} y: ${Math.round(object.position.y)} z: ${Math.round(object.position.z)}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Position',
+    })
+  }
+  /* if (object.position) {
+    node.tags.push({
+      label: `x: ${object.position.x} y: ${object.position.y} z: ${object.position.z}`,
+      textColor: 0x9499A6,
+      backgroundColor: 0xF8F9FA,
+      tooltip: 'Position',
+    })
+  } */
+  return node
+}
+
+function buildGraph(object: TresObject, node: SceneGraphObject) {
+  object.children.forEach((child: TresObject) => {
+    if (child.type === 'HightlightMesh') return
+    const childNode = createNode(child)
+    node.children.push(childNode)
+    buildGraph(child, childNode)
+  })
+}
+
+const componentStateTypes: string[] = []
+const INSPECTOR_ID = 'tres:inspector'
+
+const state = reactive({
+  sceneGraph: null as SceneGraphObject | null,
+})
+export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
+  setupDevtoolsPlugin(
+    {
+      id: 'dev.esm.tres',
+      label: 'TresJS 🪐',
+      logo: 'https://raw.githubusercontent.com/Tresjs/tres/main/public/favicon.svg',
+      packageName: 'tresjs',
+      homepage: 'https://tresjs.org',
+      componentStateTypes,
+      app,
+    },
+    (api) => {
+      if (typeof api.now !== 'function') {
+        toastMessage(
+          // eslint-disable-next-line max-len
+          'You seem to be using an outdated version of Vue Devtools. Are you still using the Beta release instead of the stable one? You can find the links at https://devtools.vuejs.org/guide/installation.html.',
+        )
+      }
+
+      api.addInspector({
+        id: INSPECTOR_ID,
+        label: 'TresJS 🪐',
+        icon: 'account_tree',
+        treeFilterPlaceholder: 'Search instances',
+      })
+
+      setInterval(() => {
+        api.sendInspectorTree(INSPECTOR_ID)
+      }, 1000)
+
+      setInterval(() => {
+        api.notifyComponentUpdate()
+      }, 5000)
+
+      api.on.getInspectorTree((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          // Your logic here
+          const root = createNode(tres.scene.value)
+          buildGraph(tres.scene.value, root)
+          state.sceneGraph = root
+          payload.rootNodes = [root]
+          /*  payload.rootNodes = [
+            {
+              id: 'root',
+              label: 'Root ',
+              children: [
+                {
+                  id: 'child',
+                  label: `Child ${payload.filter}`,
+                  tags: [
+                    {
+                      label: 'active',
+                      textColor: 0x000000,
+                      backgroundColor: 0xFF984F,
+                    },
+                    {
+                      label: 'test',
+                      textColor: 0xffffff,
+                      backgroundColor: 0x000000,
+                    },
+                  ],
+                },
+              ],
+            },
+          ] */
+        }
+      })
+      let highlightMesh: Mesh | null = null
+      let prevInstance: Object3D | null = null
+      
+      api.on.getInspectorState((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          // Your logic here
+          const [instance] = tres.scene.value.getObjectsByProperty('uuid', payload.nodeId)
+          if (!instance) return 
+          if (prevInstance && highlightMesh && highlightMesh.parent) {
+            prevInstance.remove(highlightMesh)
+          }
+          
+          if (instance.isMesh) {
+            const newHighlightMesh = createHighlightMesh(instance)
+            instance.add(newHighlightMesh)
+  
+            highlightMesh = newHighlightMesh
+            prevInstance = instance
+          }
+
+          payload.state = {
+            object: [
+              {
+                key: 'uuid',
+                editable: true,
+                value: instance.uuid,
+              },
+              {
+                key: 'name',
+                editable: true,
+                value: instance.name,
+              },
+              {
+                key: 'type',
+                editable: true,
+                value: instance.type,
+              },
+              {
+                key: 'position',
+                editable: true,
+                value: instance.position,
+              },
+              {
+                key: 'rotation',
+                editable: true,
+                value: instance.rotation,
+              },
+              {
+                key: 'scale',
+                editable: true,
+                value: instance.scale,
+              },
+              {
+                key: 'geometry',
+                value: instance.geometry,
+              },
+              {
+                key: 'material',
+                value: instance.material,
+              },
+              {
+                key: 'color',
+                editable: true,
+                value: instance.color,
+              },
+              {
+                key: 'intensity',
+                editable: true,
+                value: instance.intensity,
+              },
+              {
+                key: 'castShadow',
+                editable: true,
+                value: instance.castShadow,
+              },
+              {
+                key: 'receiveShadow',
+                editable: true,
+                value: instance.receiveShadow,
+              },
+              {
+                key: 'frustumCulled',
+                editable: true,
+                value: instance.frustumCulled,
+              },
+              {
+                key: 'matrixAutoUpdate',
+                editable: true,
+                value: instance.matrixAutoUpdate,
+              },
+              {
+                key: 'matrixWorldNeedsUpdate',
+                editable: true,
+                value: instance.matrixWorldNeedsUpdate,
+              },
+              {
+                key: 'matrixWorld',
+                value: instance.matrixWorld,
+              },
+                
+              {
+                key: 'visible',
+                editable: true,
+                value: instance.visible,
+              },
+            ],
+          }
+        }
+      })
+
+      api.on.editInspectorState((payload) => {
+        if (payload.inspectorId === INSPECTOR_ID) {
+          editSceneObject(tres.scene.value, payload.nodeId, payload.path, payload.state.value)
+        }
+      })
+    },
+  )
+}

+ 27 - 0
src/devtools/utils.ts

@@ -0,0 +1,27 @@
+/**
+ * Shows a toast or console.log
+ *
+ * @param message - message to log
+ * @param type - different color of the tooltip
+ */
+export function toastMessage(
+  message: string,
+  type?: 'normal' | 'error' | 'warn' | undefined,
+) {
+  const tresMessage = `▲ ■ ●${message}`
+
+  if (typeof __VUE_DEVTOOLS_TOAST__ === 'function') {
+    // No longer available :(
+    __VUE_DEVTOOLS_TOAST__(tresMessage, type)
+  }
+  else if (type === 'error') {
+    console.error(tresMessage)
+  }
+  else if (type === 'warn') {
+    console.warn(tresMessage)
+  }
+  else {
+    // eslint-disable-next-line no-console
+    console.log(tresMessage)
+  }
+}

+ 99 - 1
src/utils/index.ts

@@ -1,4 +1,6 @@
-import { Vector3 } from 'three'
+import { MeshBasicMaterial, DoubleSide, Vector3 } from 'three'
+import type { Mesh, Scene, Object3D } from 'three'
+import { HightlightMesh } from '../devtools/highlight'
 
 export function toSetMethodName(key: string) {
   return `set${key[0].toUpperCase()}${key.slice(1)}`
@@ -130,6 +132,102 @@ export function deepArrayEqual(arr1: any[], arr2: any[]): boolean {
  */
 export const isArray = Array.isArray as (a: any) => a is any[] | readonly any[]
 
+export function editSceneObject(scene: Scene, objectUuid: string, propertyPath: string[], value: any): void {
+  // Function to recursively find the object by UUID
+  const findObjectByUuid = (node: Object3D): Object3D | undefined => {
+    if (node.uuid === objectUuid) {
+      return node
+    }
+
+    for (const child of node.children) {
+      const found = findObjectByUuid(child)
+      if (found) {
+        return found
+      }
+    }
+
+    return undefined
+  }
+
+  // Find the target object
+  const targetObject = findObjectByUuid(scene)
+  if (!targetObject) {
+    console.warn('Object with UUID not found in the scene.')
+    return
+  }
+
+  // Traverse the property path to get to the desired property
+  let currentProperty: any = targetObject
+  for (let i = 0; i < propertyPath.length - 1; i++) {
+    if (currentProperty[propertyPath[i]] !== undefined) {
+      currentProperty = currentProperty[propertyPath[i]]
+    }
+    else {
+      console.warn(`Property path is not valid: ${propertyPath.join('.')}`)
+      return
+    }
+  }
+
+  // Set the new value
+  const lastProperty = propertyPath[propertyPath.length - 1]
+  if (currentProperty[lastProperty] !== undefined) {
+    currentProperty[lastProperty] = value
+  }
+  else {
+    console.warn(`Property path is not valid: ${propertyPath.join('.')}`)
+  }
+}
+
+export function createHighlightMaterial(): MeshBasicMaterial {
+  return new MeshBasicMaterial({
+    color: 0xA7E6D7, // Highlight color, e.g., yellow
+    transparent: true,
+    opacity: 0.2,
+    depthTest: false, // So the highlight is always visible
+    side: DoubleSide, // To ensure the highlight is visible from all angles
+  })
+}
+let animationFrameId: number | null = null
+export function animateHighlight(highlightMesh: Mesh, startTime: number): void {
+  const currentTime = Date.now()
+  const time = (currentTime - startTime) / 1000 // convert to seconds
+
+  // Pulsing effect parameters
+  const scaleAmplitude = 0.07 // Amplitude of the scale pulsation
+  const pulseSpeed = 2.5 // Speed of the pulsation
+
+  // Calculate the scale factor with a sine function for pulsing effect
+  const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time)
+
+  // Apply the scale factor
+  highlightMesh.scale.set(scaleFactor, scaleFactor, scaleFactor)
+
+  // Update the animation frame ID
+  animationFrameId = requestAnimationFrame(() => animateHighlight(highlightMesh, startTime))
+}
+
+export function stopHighlightAnimation(): void {
+  if (animationFrameId !== null) {
+    cancelAnimationFrame(animationFrameId)
+    animationFrameId = null
+  }
+}
+
+export function createHighlightMesh(object: Object3D): Mesh {
+  const highlightMaterial = new MeshBasicMaterial({
+    color: 0xa7e6d7, // Highlight color, e.g., yellow
+    transparent: true,
+    opacity: 0.2,
+    depthTest: false, // So the highlight is always visible
+    side: DoubleSide, // To e
+  })
+  // Clone the geometry of the object. You might need a more complex approach 
+  // if the object's geometry is not straightforward.
+  const highlightMesh = new HightlightMesh(object.geometry.clone(), highlightMaterial)
+
+  return highlightMesh
+}
+
 export function extractBindingPosition(binding: any): Vector3 {
   let observer = binding.value
   if (binding.value && binding.value?.isMesh) {

+ 1 - 1
src/utils/perf.ts

@@ -18,7 +18,7 @@ export function calculateMemoryUsage(object: TresObject | Scene) {
     }
   })
 
-  return totalMemory
+  return totalMemory // In bytes
 }
 
 export function bytesToKB(bytes: number): string {

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff