Browse Source

feat: 499 better memory management (#606)

* chore: memory management playground

* feat: recursively free cpu and gpu memory allocation on remove

* chore: clumsy attempt to dispose on unmount

* chore: lint fix

* feat: remove scene root on disposal

* chore: fix lint

* docs: added disposal guide on `performance` docs
Alvaro Saburido 1 year ago
parent
commit
e98ca6dea1

+ 2 - 1
docs/.vitepress/config/en.ts

@@ -43,7 +43,8 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
 
         items: [
           { text: 'Extending', link: '/advanced/extending' },
-          { text: 'Primitive', link: '/advanced/primitive' },
+          { text: 'Primitives', link: '/advanced/primitive' },
+          { text: 'Scaling Performance 🚀', link: '/advanced/performance' },
           {
             text: 'Caveats',
             link: '/advanced/caveats',

+ 31 - 0
docs/advanced/performance.md

@@ -124,3 +124,34 @@ const { advance } = useTres()
 advance()
 </script>
 ```
+
+## Dispose resources `dispose()` <Badge type="tip" text="^4.0.0" />
+
+When you are done with a resource, like a texture, geometry, or material, you should dispose of it to free up memory. This is especially important when you are creating and destroying resources frequently, like in a game.
+
+TresJS will automatically dispose of resources recursively when the component is unmounted, but you can also perform this manually by calling the `dispose()` directly from the package:
+
+::: warning
+To avoid errors and unwanted sideeffects, resources created programatically with the use of `primitives` need to be manually disposed.
+:::
+
+```html {2,12}
+<script setup lang="ts">
+  import { dispose } from '@tresjs/core'
+  import { useGLTF } from '@tresjs/cientos'
+
+  const { nodes } = await useGLTF(
+    'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb',
+    { draco: true },
+  )
+  const model = nodes.Cube
+
+  onUnmounted(() => {
+    dispose(model)
+  })
+</script>
+
+<template>
+  <primitive :object="model" />
+</template>
+```

+ 4 - 22
playground/components.d.ts

@@ -8,8 +8,10 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AkuAku: typeof import('./src/components/AkuAku.vue')['default']
-    AnimatedModel: typeof import('./src/components/AnimatedModel.vue')['default']
     BlenderCube: typeof import('./src/components/BlenderCube.vue')['default']
+<<<<<<< HEAD
+    DynamicModel: typeof import('./src/components/DynamicModel.vue')['default']
+=======
     Box: typeof import('./src/components/Box.vue')['default']
     CameraOperator: typeof import('./src/components/CameraOperator.vue')['default']
     Cameras: typeof import('./src/components/Cameras.vue')['default']
@@ -21,36 +23,16 @@ declare module 'vue' {
     EventsPropogation: typeof import('./src/components/EventsPropogation.vue')['default']
     FBXModels: typeof import('./src/components/FBXModels.vue')['default']
     Gltf: typeof import('./src/components/gltf/index.vue')['default']
+>>>>>>> v4
     GraphPane: typeof import('./src/components/GraphPane.vue')['default']
     LocalOrbitControls: typeof import('./src/components/LocalOrbitControls.vue')['default']
-    MeshWobbleMaterial: typeof import('./src/components/meshWobbleMaterial/index.vue')['default']
-    MultipleCanvas: typeof import('./src/components/MultipleCanvas.vue')['default']
-    PortalJourney: typeof import('./src/components/portal-journey/index.vue')['default']
     RenderingLogger: typeof import('./src/components/RenderingLogger.vue')['default']
-    Responsiveness: typeof import('./src/components/Responsiveness.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
-    ShadersExperiment: typeof import('./src/components/shaders-experiment/index.vue')['default']
     TestSphere: typeof import('./src/components/TestSphere.vue')['default']
     Text3D: typeof import('./src/components/Text3D.vue')['default']
-    TheBasic: typeof import('./src/components/TheBasic.vue')['default']
     TheCameraOperator: typeof import('./src/components/TheCameraOperator.vue')['default']
-    TheConditional: typeof import('./src/components/TheConditional.vue')['default']
-    TheEnvironment: typeof import('./src/components/TheEnvironment.vue')['default']
-    TheEvents: typeof import('./src/components/TheEvents.vue')['default']
     TheExperience: typeof import('./src/components/TheExperience.vue')['default']
-    TheFireFlies: typeof import('./src/components/portal-journey/TheFireFlies.vue')['default']
-    TheFirstScene: typeof import('./src/components/TheFirstScene.vue')['default']
-    TheGizmos: typeof import('./src/components/TheGizmos.vue')['default']
-    TheGroups: typeof import('./src/components/TheGroups.vue')['default']
-    TheModel: typeof import('./src/components/gltf/TheModel.vue')['default']
-    TheParticles: typeof import('./src/components/TheParticles.vue')['default']
-    ThePortal: typeof import('./src/components/portal-journey/ThePortal.vue')['default']
-    TheSmallExperience: typeof import('./src/components/TheSmallExperience.vue')['default']
     TheSphere: typeof import('./src/components/TheSphere.vue')['default']
-    TheUSDZModel: typeof import('./src/components/udsz/TheUSDZModel.vue')['default']
-    TresLechesTest: typeof import('./src/components/TresLechesTest.vue')['default']
-    Udsz: typeof import('./src/components/udsz/index.vue')['default']
-    VectorSetProps: typeof import('./src/components/VectorSetProps.vue')['default']
   }
 }

+ 1 - 1
playground/package.json

@@ -5,7 +5,7 @@
   "private": true,
   "scripts": {
     "dev": "vite --host",
-    "build": "vue-tsc && vite build",
+    "build": "vite build",
     "preview": "vite preview"
   },
   "dependencies": {

+ 15 - 3
playground/src/components/BlenderCube.vue

@@ -1,15 +1,27 @@
 <script setup lang="ts">
-import { useTresContext } from '@tresjs/core'
+import { dispose } from '@tresjs/core'
 import { useGLTF } from '@tresjs/cientos'
+import { useControls } from '@tresjs/leches'
 
 const { nodes } = await useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb', { draco: true })
 const model = nodes.Cube
 
 model.position.set(0, 1, 0)
 
-const state = useTresContext()
+useControls({
+  disposeBtn: {
+    label: 'Dispose',
+    type: 'button',
+    onClick: () => {
+      dispose(model)
+    },
+    size: 'sm',
+  },
+})
 
-state.invalidate()
+onUnmounted(() => {
+  dispose(model)
+})
 </script>
 
 <template>

+ 0 - 1
playground/src/main.ts

@@ -7,5 +7,4 @@ import 'uno.css'
 const app = createApp(App)
 
 app.use(router)
-
 app.mount('#app')

+ 2 - 2
playground/src/pages/basic/index.vue

@@ -70,7 +70,7 @@ const sphereExists = ref(true)
         @pointer-out="onPointerOut"
       >
         <TresSphereGeometry :args="[2, 32, 32]" />
-        <TresMeshToonMaterial color="teal" />
+        <TresMeshBasicMaterial color="teal" />
       </TresMesh>
     </TresGroup>
 
@@ -84,7 +84,7 @@ const sphereExists = ref(true)
       receive-shadow
     >
       <TresPlaneGeometry :args="[10, 10, 10, 10]" />
-      <TresMeshToonMaterial />
+      <TresMeshBasicMaterial />
     </TresMesh>
 
     <TresDirectionalLight

+ 1 - 1
playground/src/pages/empty.vue

@@ -1,4 +1,4 @@
-<script setup>
+<script setup lang="ts">
 
 </script>
 

+ 65 - 0
playground/src/pages/perf/Memory.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+
+import { TresLeches, useControls } from '@tresjs/leches'
+import '@tresjs/leches/styles'
+
+const gl = {
+  clearColor: '#fff',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+
+const { isVisible } = useControls({
+  isVisible: true,
+})
+
+/* const mesh = new Mesh(
+  new BoxGeometry(),
+  new MeshToonMaterial({ color: 0x00ff00 }),
+)
+ */
+
+onUnmounted(() => {
+  // dispose(mesh)
+})
+</script>
+
+<template>
+  <RouterLink to="/basic">
+    Go to another page
+  </RouterLink>
+  <TresLeches />
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera
+      :position="[3, 3, 3]"
+      :look-at="[0, 0, 0]"
+    />
+    <TresGroup v-if="isVisible">
+      <TresMesh :position="[0, 0, 0]">
+        <TresBoxGeometry />
+        <TresMeshToonMaterial :color="0x00FF00" />
+      </TresMesh>
+    </TresGroup>
+    <!--  <Suspense> -->
+    <!--    <BlenderC -->ube v-if="isVisible" />
+    <!--  </Suspense> -->
+    <!-- <TresMesh :position="[0,0,0]" v-if="isVisible">
+      <TresBoxGeometry />
+      <TresMeshToonMaterial :color="0x00ff00" />
+    </TresMesh> -->
+    <!--     <TresGridHelper /> -->
+    <!-- <TresGroup v-if="isVisible">
+      <TresMesh :position="[0,0,0]" >
+        <TresBoxGeometry />
+        <TresMeshToonMaterial :color="0x00ff00" />
+      </TresMesh>
+    </TresGroup> -->
+
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

+ 5 - 0
playground/src/router/routes/performance.ts

@@ -4,4 +4,9 @@ export const perfRoutes = [
     name: 'On Demand',
     component: () => import('../../pages/perf/OnDemand.vue'),
   },
+  {
+    path: '/perf/memory',
+    name: 'Memory',
+    component: () => import('../../pages/perf/Memory.vue'),
+  },
 ]

+ 29 - 2
src/components/TresCanvas.vue

@@ -16,6 +16,7 @@ import {
   getCurrentInstance,
   h,
   onMounted,
+  onUnmounted,
   provide,
   ref,
   shallowRef,
@@ -33,10 +34,11 @@ import {
 } from '../composables'
 import { extend } from '../core/catalogue'
 import { nodeOps } from '../core/nodeOps'
+import { registerTresDevtools } from '../devtools'
+import { disposeObject3D } from '../utils/'
 
 import type { RendererPresetsType } from '../composables/useRenderer/const'
 import type { TresCamera, TresObject } from '../types/'
-import { registerTresDevtools } from '../devtools'
 
 export interface TresCanvasProps
   extends Omit<WebGLRendererParameters, 'canvas'> {
@@ -134,14 +136,34 @@ const mountCustomRenderer = (context: TresContext) => {
 }
 
 const dispose = (context: TresContext, force = false) => {
-  scene.value.children = []
+  disposeObject3D(context.scene.value)
   if (force) {
     context.renderer.value.dispose()
     context.renderer.value.renderLists.dispose()
     context.renderer.value.forceContextLoss()
   }
+  scene.value.__tres = {
+    root: context,
+  }
   mountCustomRenderer(context)
   resume()
+  /* disposeObject3D(scene.value) */
+  /*  scene.value.children.forEach((child) => {
+    child.removeFromParent()
+    disposeObject3D(child)
+  })
+  context.scene.value.children.forEach((child) => {
+    child.removeFromParent()
+    disposeObject3D(child)
+  }) */
+  /* console.log('disposing', scene.value.children)
+  if (force) {
+    context.renderer.value.dispose()
+    context.renderer.value.renderLists.dispose()
+    context.renderer.value.forceContextLoss()
+  }
+  mountCustomRenderer(context)
+  resume() */
 }
 
 const disableRender = computed(() => props.disableRender)
@@ -210,8 +232,13 @@ onMounted(() => {
     addDefaultCamera()
   }
 
+  // HMR support
   if (import.meta.hot && context.value) { import.meta.hot.on('vite:afterUpdate', () => dispose(context.value as TresContext)) }
 })
+
+onUnmounted(() => {
+  dispose(context.value as TresContext)
+})
 </script>
 
 <template>

+ 13 - 14
src/core/nodeOps.ts

@@ -4,7 +4,7 @@ import { isFunction } from '@alvarosabu/utils'
 import type { Camera, Object3D } from 'three'
 import type { TresContext } from '../composables'
 import { useLogger } from '../composables'
-import { deepArrayEqual, isHTMLTag, kebabToCamel } from '../utils'
+import { deepArrayEqual, disposeObject3D, isHTMLTag, kebabToCamel } from '../utils'
 import type { TresObject, TresObject3D, TresScene } from '../types'
 import { catalogue } from './catalogue'
 
@@ -145,6 +145,7 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
       }
     }
   }
+
   function remove(node) {
     if (!node) { return }
     const ctx = node.__tres
@@ -152,17 +153,6 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
     node.parent = node.parent || scene
 
     if (node.isObject3D) {
-      const disposeMaterialsAndGeometries = (object3D: TresObject) => {
-        const tresObject3D = object3D as TresObject3D
-        // TODO: to be improved on https://github.com/Tresjs/tres/pull/466/files
-        if (ctx.disposable) {
-          tresObject3D.material?.dispose()
-          tresObject3D.material = undefined
-          tresObject3D.geometry?.dispose()
-          tresObject3D.geometry = undefined
-        }
-      }
-
       const deregisterCameraIfRequired = (object: Object3D) => {
         const deregisterCamera = node.__tres.root.deregisterCamera
 
@@ -171,8 +161,10 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
 
       node.removeFromParent?.()
 
+      // Remove nested child objects. Primitives should not have objects and children that are
+      // attached to them declaratively ...
+
       node.traverse((child: Object3D) => {
-        disposeMaterialsAndGeometries(child as TresObject)
         deregisterCameraIfRequired(child)
         // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
         if (child.onPointerMissed) {
@@ -180,10 +172,17 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
         }
       })
 
-      disposeMaterialsAndGeometries(node)
       deregisterCameraIfRequired(node as Object3D)
       /*  deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */
       invalidateInstance(node as TresObject)
+
+      // Dispose the object if it's disposable, primitives needs to be manually disposed by
+      // calling dispose from `@tresjs/core` package like this `dispose(model)`
+      const isPrimitive = node.__tres.primitive
+
+      if (!isPrimitive && node.__tres.disposable) {
+        disposeObject3D(node as TresObject3D)
+      }
       node.dispose?.()
     }
   }

+ 2 - 0
src/index.ts

@@ -2,6 +2,7 @@ import type { App } from 'vue'
 import TresCanvas from './components/TresCanvas.vue'
 import { normalizeColor, normalizeVectorFlexibleParam } from './utils/normalize'
 import templateCompilerOptions from './utils/template-compiler-options'
+import { disposeObject3D as dispose } from './utils'
 
 export * from './composables'
 export * from './core/catalogue'
@@ -30,4 +31,5 @@ export {
   normalizeColor,
   normalizeVectorFlexibleParam,
   templateCompilerOptions,
+  dispose,
 }

+ 45 - 2
src/utils/index.ts

@@ -1,5 +1,5 @@
-import { DoubleSide, MeshBasicMaterial, Vector3 } from 'three'
-import type { Mesh, Object3D, Scene } from 'three'
+import type { Material, Mesh, Object3D, Texture } from 'three'
+import { DoubleSide, MeshBasicMaterial, Scene, Vector3 } from 'three'
 import { HightlightMesh } from '../devtools/highlight'
 
 export function toSetMethodName(key: string) {
@@ -267,3 +267,46 @@ export function extractBindingPosition(binding: any): Vector3 {
   if (Array.isArray(binding.value)) { observer = new Vector3(...observer) }
   return observer
 }
+
+function hasMap(material: Material): material is Material & { map: Texture | null } {
+  return 'map' in material
+}
+
+export function disposeMaterial(material: Material): void {
+  if (hasMap(material) && material.map) {
+    material.map.dispose()
+  }
+
+  material.dispose()
+}
+
+export function disposeObject3D(object: Object3D): void {
+  if (object.parent) {
+    object.removeFromParent?.()
+  }
+  delete object.__tres
+  // Clone the children array to safely iterate
+  const children = [...object.children]
+  children.forEach(child => disposeObject3D(child))
+
+  if (object instanceof Scene) {
+    // Optionally handle Scene-specific cleanup
+  }
+  else {
+    const mesh = object as Mesh
+    if (mesh.geometry) {
+      mesh.geometry.dispose()
+      delete mesh.geometry
+    }
+
+    if (Array.isArray(mesh.material)) {
+      mesh.material.forEach(material => disposeMaterial(material))
+      delete mesh.material
+    }
+    else if (mesh.material) {
+      disposeMaterial(mesh.material)
+      delete mesh.material
+    }
+    object.dispose?.()
+  }
+}