Explorar o código

feat: 633 use loop (#673)

* feat: createRenderLoop unique to context

* feat: onLoop returns current state

* feat: ensuring callback excecution with index order

* feat: take control of render loop logic

* docs: updated composable docs

* feat: change error to deprecation warning towards v5

* chore: add link to new composable docs on deprecation warning

* chore: remove depcreation warning of existing useRenderLoop

* feat: `useFrame` and `useRender` instead of `onLoop`

* chore: fix lint

* feat: applied useFrame to directives

* chore: fix lint

* feat: `useUpdate` instead of `useFrame` and useRender pausing.

* chore: testing fbo

* feat: reserve index 1 for late-updates

* chore: fix lint

* feat: useLoop composable for the win

* chore: change onLoop name for register

* chore: unit tests for loop

* chore: change order for registration to make index optional

* chore: fix lint

* feat: pauseRender and resumeRender

* docs: useLoop guide

* docs: updated basic animations recipe to `useLoop`

* docs: correct pause render methods on docs

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* chore: refactor subscribers to `priorityEventHooks`

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* feat: just return `off` on the loop registration methods

* docs: update docs to add `off` unregister callback method

* feat: remove `v-rotate`

* docs: added context warning for `v-always-look-at`

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* Update docs/api/composables.md

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>

* chore: remove leftover of isntance.provide

* chore: remove subscribers from context

* chore: abstract `wrapCallback`  and move render loop register to `useRender`

* chore: fix lint

* chore: testing off

* Revert "chore: abstract `wrapCallback`  and move render loop register to `useRender`"

This reverts commit 24cec651df56aedd16835144986c3c7260b3e374.

* chore: return bound `off` method and use createPriorityEvent for render with defaultFn fallback

* feat: deprecate and remove `vAlwaysLookAt` and `vRotate`

BREAKING_CHANGE: Directives `vAlwaysLookAt` and `vRotate` due incompatibility with new `useLoop` and the refactor of the render loop logic.

* feat: set context to loop to avoid wrapping the callbacks

* feat: dispose render hook before taking over

---------

Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>
Alvaro Saburido hai 1 ano
pai
achega
1b2fa70e99
Modificáronse 43 ficheiros con 1516 adicións e 229 borrados
  1. 67 0
      .eslintrc-auto-import.json
  2. 67 0
      docs/.eslintrc-auto-import.json
  3. 0 2
      docs/.vitepress/config/en.ts
  4. 231 64
      docs/api/composables.md
  5. 40 30
      docs/cookbook/basic-animations.md
  6. 5 1
      docs/directives/v-always-look-at.md
  7. 5 1
      docs/directives/v-rotate.md
  8. 10 1
      playground/components.d.ts
  9. 95 0
      playground/src/components/AnimatedObjectUseUpdate.vue
  10. 7 0
      playground/src/components/DirectiveSubComponent.vue
  11. 27 0
      playground/src/components/FBOCube.vue
  12. 50 0
      playground/src/components/TakeOverLoopExperience.vue
  13. 11 1
      playground/src/components/TheSphere.vue
  14. 88 0
      playground/src/composables/useFBO.ts
  15. 31 0
      playground/src/pages/advanced/FBO.vue
  16. 0 0
      playground/src/pages/advanced/Memory.vue
  17. 1 0
      playground/src/pages/advanced/OnDemand.vue
  18. 24 0
      playground/src/pages/advanced/TakeOverLoop.vue
  19. 0 0
      playground/src/pages/advanced/index.vue
  20. 11 0
      playground/src/pages/basic/example.vue
  21. 1 2
      playground/src/pages/basic/index.vue
  22. 2 2
      playground/src/pages/index.vue
  23. 25 0
      playground/src/pages/misc/Directives.vue
  24. 2 2
      playground/src/router/index.ts
  25. 17 0
      playground/src/router/routes/advanced.ts
  26. 2 2
      playground/src/router/routes/index.ts
  27. 5 0
      playground/src/router/routes/misc.ts
  28. 0 12
      playground/src/router/routes/performance.ts
  29. 2 2
      playground/vite.config.ts
  30. 0 4
      src/components/TresCanvas.vue
  31. 1 0
      src/composables/index.ts
  32. 51 0
      src/composables/useLoop/index.ts
  33. 10 8
      src/composables/useRenderLoop/index.ts
  34. 1 30
      src/composables/useRenderer/index.ts
  35. 30 0
      src/composables/useTresContextProvider/index.ts
  36. 120 0
      src/core/loop.test.ts
  37. 137 0
      src/core/loop.ts
  38. 1 3
      src/directives/index.ts
  39. 0 21
      src/directives/vAlwaysLookAt.ts
  40. 0 40
      src/directives/vRotate.ts
  41. 263 0
      src/utils/createPriorityEventHook.test.ts
  42. 73 0
      src/utils/createPriorityEventHook.ts
  43. 3 1
      src/utils/index.ts

+ 67 - 0
.eslintrc-auto-import.json

@@ -0,0 +1,67 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "EffectScope": true,
+    "InjectionKey": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "computed": true,
+    "createApp": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "effectScope": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useSlots": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true
+  }
+}

+ 67 - 0
docs/.eslintrc-auto-import.json

@@ -0,0 +1,67 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "EffectScope": true,
+    "InjectionKey": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "computed": true,
+    "createApp": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "effectScope": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useSlots": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true
+  }
+}

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

@@ -77,9 +77,7 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
         items: [
           { text: 'v-log', link: '/directives/v-log' },
           { text: 'v-light-helper', link: '/directives/v-light-helper' },
-          { text: 'v-always-look-at', link: '/directives/v-always-look-at' },
           { text: 'v-distance-to', link: '/directives/v-distance-to' },
-          { text: 'v-rotate', link: '/directives/v-rotate' },
         ],
       },
       {

+ 231 - 64
docs/api/composables.md

@@ -4,73 +4,213 @@ Vue 3 [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html#
 
 **TresJS** takes huge advantage of this API to create a set of composable functions that can be used to create animations, interact with the scene and more. It also allows you to create more complex scenes that might not be possible using just the Vue Components (Textures, Loaders, etc.).
 
-The core of **TresJS** uses these composables internally, so you would be using the same API that the core uses. For instance, components that need to updated on the internal render loop use the `useRenderLoop` composable to register a callback that will be called every time the renderer updates the scene.
+The core of **TresJS** uses these composables internally, so you would be using the same API that the core uses.
 
-## useRenderLoop
-
-The `useRenderLoop` composable is the core of **TresJS** animations. It allows you to register a callback that will be called on native refresh rate. This is the most important composable in **TresJS**.
+## useTresContext
+This composable aims to provide access to the state model which contains multiple useful properties.
 
 ```ts
-const { onLoop, resume } = useRenderLoop()
+const { camera, renderer, camera, cameras } = useTresContext()
+```
 
-onLoop(({ delta, elapsed, clock }) => {
-  // I will run at every frame ~60FPS (depending of your monitor)
-})
+::: warning
+`useTresContext` can be only be used inside of a `TresCanvas` since this component acts as the provider for the context data.
+:::
+
+::: code-group
+
+```vue [App.vue]
+<script setup>
+import { TresCanvas } from '@tresjs/core'
+import SubComponent from './SubComponent.vue'
+</script>
+
+<template>
+  <TresCanvas
+    render-mode="manual"
+  >
+    <SubComponent />
+  </TresCanvas>
+</template>
 ```
 
+```vue [SubComponent.vue]
+<script lang="ts" setup>
+import { useTresContext } from '@tresjs/core'
+
+const context = useTresContext()
+</script>
+```
+
+:::
+
+### Properties of context
+| Property | Description |
+| --- | --- |
+| **camera** | The currently active camera |
+| **cameras** | The cameras that exist in the scene |
+| **controls** | The controls of your scene |
+| **deregisterCamera** | A method to deregister a camera. This is only required if you manually create a camera. Cameras in the template are deregistered automatically. |
+| **extend** | Extends the component catalogue. See [extending](/advanced/extending) |
+| **raycaster** | the global raycaster used for pointer events |
+| **registerCamera** | a method to register a camera. This is only required if you manually create a camera. Cameras in the template are registered automatically. |
+| **renderer** | the [WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer) of your scene |
+| **scene** | the [scene](https://threejs.org/docs/?q=sce#api/en/scenes/Scene). |
+| **setCameraActive** | a method to set a camera active |
+| **sizes** | contains width, height and aspect ratio of your canvas |
+| **invalidate** | a method to invalidate the render loop. This is only required if you set the `render-mode` prop to `on-demand`. |
+| **advance** | a method to advance the render loop. This is only required if you set the `render-mode` prop to `manual`. |
+| **loop** | the renderer loop |
+
+### useLoop <Badge text="v4.1.0" />
+
+This composable allows you to execute a callback on every rendered frame, similar to `useRenderLoop` but unique to each `TresCanvas` instance and with access to the [context](#usetrescontext).
+
 ::: warning
-Be mindful of the performance implications of using this composable. It will run at every frame, so if you have a lot of logic in your callback, it might impact the performance of your app. Specially if you are updating reactive states or references.
+`useLoop` can be only be used inside of a `TresCanvas` since this component acts as the provider for the context data.
 :::
 
-The `onLoop` callback receives an object with the following properties based on the [THREE clock](https://threejs.org/docs/?q=clock#api/en/core/Clock):
+#### Register update callbacks
 
-- `delta`: The delta time between the current and the last frame. This is the time in seconds since the last frame.
-- `elapsed`: The elapsed time since the start of the render loop.
+The user can register update callbacks (such as animations, fbo, etc) using the `onBeforeRender`
 
-This composable is based on `useRafFn` from [vueuse](https://vueuse.org/core/useRafFn/). Thanks to [@wheatjs](https://github.com/wheatjs) for the amazing contribution.
+::: code-group
 
-### Before and after render
+```vue [App.vue]
+<script setup>
+import { TresCanvas } from '@tresjs/core'
+import AnimatedBox from './AnimatedBox.vue'
+</script>
 
-You can also register a callback that will be called before and after the renderer updates the scene. This is useful if you add a profiler to measure the FPS for example.
+<template>
+  <TresCanvas>
+    <AnimatedBox />
+  </TresCanvas>
+</template>
+```
+
+```vue [AnimatedBox.vue]
+<script setup>
+import { useLoop } from '@tresjs/core'
+
+const boxRef = ref()
+
+const { onBeforeRender } = useLoop()
+
+onBeforeRender(({ delta }) => {
+  boxRef.value.rotation.y += delta
+})
+</script>
+
+<template>
+  <TresMesh ref="boxRef">
+    <TresBoxGeometry />
+    <TresMeshBasicMaterial color="teal" />
+  </TresMesh>
+</template>
+```
+
+:::
+
+Your callback function will be triggered just before a frame is rendered and it will be deregistered automatically when the component is destroyed.
+
+#### Take over the render loop
+
+You can take over the render call by using the `render` method.
 
 ```ts
-const { onBeforeLoop, onAfterLoop } = useRenderLoop()
+const { render } = useLoop()
 
-onBeforeLoop(({ delta, elapsed }) => {
-  // I will run before the renderer updates the scene
-  fps.begin()
+render(({ renderer, scene, camera }) => {
+  renderer.render(scene, camera)
 })
+```
 
-onAfterLoop(({ delta, elapsed }) => {
-  // I will run after the renderer updates the scene
-  fps.end()
+::: warning
+Consider that if you take over the render loop, you will need to manually render the scene and take care of features like the conditional rendering yourself.
+:::
+
+#### Register after render callbacks (ex physics calculations)
+
+You can also register callbacks which are invoked after rendring by using the `onAfterRender` method.
+
+```ts
+const { onAfterRender } = useLoop()
+
+onAfterRender(({ renderer }) => {
+  // Calculations
 })
 ```
 
-### Pause and resume
+#### Render priority
 
-You can pause and resume the render loop using the exposed `pause` and `resume` methods.
+Both useBeforeRender and useAfteRender provide an optional priority number. This number could be anything from `Number.NEGATIVE_INFINITY` to `Number.POSITIVE_INFINITY` being the 0 by default. The lower the number, the earlier the callback will be executed.
 
 ```ts
-const { pause, resume } = useRenderLoop()
+onBeforeRender(() => {
+  console.count('triggered first')
+}, -1)
 
-// Pause the render loop
-pause()
+onBeforeRender(() => {
+  console.count('triggered second')
+}, 1)
+```
 
-// Resume the render loop
-resume()
+#### Params of the callback
+
+All callbacks receive an object with the following properties:
+
+- `delta`: The delta time between the current and the last frame. This is the time in miliseconds since the last frame.
+- `elapsed`: The elapsed time since the start of the render loop.
+- `clock`: The [THREE clock](https://threejs.org/docs/?q=clock#api/en/core/Clock) instance.
+- `renderer`: The [WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer) of your scene.
+- `scene`: The [scene](https://threejs.org/docs/?q=sce#api/en/scenes/Scene) of your scene.
+- `camera`: The currently active camera.
+- `raycaster`: The global raycaster used for pointer events.
+- `controls`: The controls of your scene.
+- `invalidate`: A method to invalidate the render loop. This is only required if you set the `render-mode` prop to `on-demand`.
+- `advance`: A method to advance the render loop. This is only required if you set the `render-mode` prop to `manual`.
+
+#### Pausing and resuming the update loop
+
+You can use `pause` and `resume` methods:
+
+```ts
+const { onBeforeRender, pause, resume } = useLoop()
+
+onBeforeRender(({ elapsed }) => {
+  sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+})
+
+pause() // This will pause the loop
+resume() // This will resume the loop
 ```
 
-Also you can get the active state of the render loop using the `isActive` property.
+#### Pausing and resuming the render
+
+You can use `pauseRender` and `resumeRender` methods:
 
 ```ts
-const { resume, isActive } = useRenderLoop()
+const { pauseRender, resumeRender } = useLoop()
 
-console.log(isActive) // false
+onBeforeRender(({ elapse }) => {
+  sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+})
 
-resume()
+pauseRender() // This will pause the renderer
+resumeRender() // This will resume the renderer
+```
+
+#### Unregistering callbacks
+
+You can unregister a callback by calling the method `off` returned by the `onBeforeRender` or `onAfterRender` method.
+
+```ts
+const { onBeforeRender } = useLoop()
 
-console.log(isActive) // true
+const { off } = onBeforeRender(({ elapsed }) => {
+  sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+})
 ```
 
 ## useLoader
@@ -197,46 +337,73 @@ watch(character, ({ model }) => {
 })
 ```
 
-## useTresContext
-This composable aims to provide access to the state model which contains multiple useful properties.
+## useRenderLoop
+
+The `useRenderLoop` composable can be use for animations that don't require access to the [context](#usetrescontext). It allows you to register a callback that will be called on native refresh rate.
+
+::: warning
+ Since v4.1.0, `useRenderLoop` is no longer used internally to control the rendering, if you want to use conditional rendering, multiple canvases or need access to state please `useLoop` instead. [Read why](#useloop)
+:::
 
 ```ts
-const { camera, renderer, camera, cameras } = useTresContext()
+const { onLoop, resume } = useRenderLoop()
+
+onLoop(({ delta, elapsed, clock }) => {
+  // I will run at every frame ~60FPS (depending of your monitor)
+})
 ```
 
 ::: warning
-`useTresContext` can be only be used inside of a `TresCanvas` since `TresCanvas` acts as the provider for the context data. Use [the context exposed by TresCanvas](tres-canvas#exposed-public-properties) if you find yourself needing it in parent components of TresCanvas.
+Be mindful of the performance implications of using this composable. It will run at every frame, so if you have a lot of logic in your callback, it might impact the performance of your app. Specially if you are updating reactive states or references.
 :::
 
-```vue
-<TresCanvas>
-  <MyModel />
-</TresCanvas>
+The `onLoop` callback receives an object with the following properties based on the [THREE clock](https://threejs.org/docs/?q=clock#api/en/core/Clock):
+
+- `delta`: The delta time between the current and the last frame. This is the time in milliseconds since the last frame.
+- `elapsed`: The elapsed time since the start of the render loop.
+
+This composable is based on `useRafFn` from [vueuse](https://vueuse.org/core/useRafFn/). Thanks to [@wheatjs](https://github.com/wheatjs) for the amazing contribution.
+
+### Before and after render
+
+You can also register a callback that will be called before and after the renderer updates the scene. This is useful if you add a profiler to measure the FPS for example.
+
+```ts
+const { onBeforeLoop, onAfterLoop } = useRenderLoop()
+
+onBeforeLoop(({ delta, elapsed }) => {
+  // I will run before the renderer updates the scene
+  fps.begin()
+})
+
+onAfterLoop(({ delta, elapsed }) => {
+  // I will run after the renderer updates the scene
+  fps.end()
+})
 ```
 
-```vue
-// MyModel.vue
+### Pause and resume
 
-<script lang="ts" setup>
-import { useTresContext } from '@tresjs/core'
+You can pause and resume the render loop using the exposed `pause` and `resume` methods.
 
-const context = useTresContext()
-</script>
+```ts
+const { pause, resume } = useRenderLoop()
+
+// Pause the render loop
+pause()
+
+// Resume the render loop
+resume()
 ```
 
-### Properties of context
-| Property | Description |
-| --- | --- |
-| **camera** | The currently active camera |
-| **cameras** | The cameras that exist in the scene |
-| **controls** | The controls of your scene |
-| **deregisterCamera** | A method to deregister a camera. This is only required if you manually create a camera. Cameras in the template are deregistered automatically. |
-| **extend** | Extends the component catalogue. See [extending](/advanced/extending) |
-| **raycaster** | the global raycaster used for pointer events |
-| **registerCamera** | a method to register a camera. This is only required if you manually create a camera. Cameras in the template are registered automatically. |
-| **renderer** | the [WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer) of your scene |
-| **scene** | the [scene](https://threejs.org/docs/?q=sce#api/en/scenes/Scene). |
-| **setCameraActive** | a method to set a camera active |
-| **sizes** | contains width, height and aspect ratio of your canvas |
-| **invalidate** | a method to invalidate the render loop. This is only required if you set the `render-mode` prop to `on-demand`. |
-| **advance** | a method to advance the render loop. This is only required if you set the `render-mode` prop to `manual`. |
+Also you can get the active state of the render loop using the `isActive` property.
+
+```ts
+const { resume, isActive } = useRenderLoop()
+
+console.log(isActive.value) // false
+
+resume()
+
+console.log(isActive.value) // true
+```

+ 40 - 30
docs/cookbook/basic-animations.md

@@ -12,54 +12,68 @@ This guide will help you get started with basic animations in TresJS.
 
 We will build a simple scene with a cube. We will then animate the cube to rotate around the Y and Z axis.
 
-<SandboxDemo url="https://play.tresjs.org/#eNqVVF1P2zAU/StW9kAZbVI+hTqKOjo0bRofYrwRHkxy2xoc27KdtlD1v+8mTloHBipSH5rjc889vh9eBLcazHelwmkOQS84MYlmyhIDNleEUzHux4E1cXAaC5YpqS1ZEDOhnMvZDYzIkoy0zMgWRm998yiF6pCKKTVtkhu4AZGC/iOlWkUMLFIeTZRI3Qy90g/MDqWwWnLzls5AWGmKiFgkUhhLHuS8sNL3fLVEzvm2x1kQKar0/aahlqO541ZrQVLglrYJcKoMpGS5TfqnZBELQtiItFyycEp5DtsOJpUDB4ZaWmqZFOEz2ek7NczwPu0FHdXJvpJuuFeyl7FYFs5OItcRrD9+WMgUpxbwi5CTdZFJwoHqTiK51NiwL8d7P86Gh3FQlCSVM0MoVxNKZkzgV8ewF6eAGs1qRxVciV+DNgoSy6YwpBloWp8S0lPSsMI/prvbbZO9Njm8jwOPMJJTPDtAFx5ISz3EdxuwQPcIdsMmPCrR3W63u4ZfWbwAMyEaRshz5cVL90xCObgkJKHGdlwZVpFV7Jmc/wSZgdXP6EyPTXWX4od38VJ5yS6lzii/wCZoRrlvJ6oprjvlp2sPAieR17ugHbhx72RUhY9GCly9cpbi6gA3rldPVxz4u1IcxMHEWmV6UZSkAuNxyNhUhwJsJFQW+fTBfngYdqOUGRsVMLLjoP1G2G3VZ7RdBMof+fIV3MxiZ0CfFBWbeF9xBwchjkOlXINhxooYX3uiYSPdgjdAxcNj9LsDJvPLgM8XPgob19ejD3a7ZYFxs2AeZs3qVjycPg3pJ4RdwEfSSOykkLENRGtqcfmD8Cji7MGXrB8bnElr8LEcsfGriUxkphgHfaWKfW9OZvng/i4xq3NY+UsmkDz9B380c2f5GocF9BTLvW4lriBYd3z+9xLm+H91mMk051Vz3jm8ASN5Xnh0tLNcpGjb45Vuf5ULxsT41pzPLQhTX6ph1D4rKNG7er9Xs+aA+7JwJb9sx/CDKq1vth/urwq+/AdyGHHw" />
+<!--TODO: Update sandbox when v4 is out with useLoop -->
 
-## useRenderLoop
+<!-- <SandboxDemo url="https://play.tresjs.org/#eNqVVF1P2zAU/StW9kAZbVI+hTqKOjo0bRofYrwRHkxy2xoc27KdtlD1v+8mTloHBipSH5rjc889vh9eBLcazHelwmkOQS84MYlmyhIDNleEUzHux4E1cXAaC5YpqS1ZEDOhnMvZDYzIkoy0zMgWRm998yiF6pCKKTVtkhu4AZGC/iOlWkUMLFIeTZRI3Qy90g/MDqWwWnLzls5AWGmKiFgkUhhLHuS8sNL3fLVEzvm2x1kQKar0/aahlqO541ZrQVLglrYJcKoMpGS5TfqnZBELQtiItFyycEp5DtsOJpUDB4ZaWmqZFOEz2ek7NczwPu0FHdXJvpJuuFeyl7FYFs5OItcRrD9+WMgUpxbwi5CTdZFJwoHqTiK51NiwL8d7P86Gh3FQlCSVM0MoVxNKZkzgV8ewF6eAGs1qRxVciV+DNgoSy6YwpBloWp8S0lPSsMI/prvbbZO9Njm8jwOPMJJTPDtAFx5ISz3EdxuwQPcIdsMmPCrR3W63u4ZfWbwAMyEaRshz5cVL90xCObgkJKHGdlwZVpFV7Jmc/wSZgdXP6EyPTXWX4od38VJ5yS6lzii/wCZoRrlvJ6oprjvlp2sPAieR17ugHbhx72RUhY9GCly9cpbi6gA3rldPVxz4u1IcxMHEWmV6UZSkAuNxyNhUhwJsJFQW+fTBfngYdqOUGRsVMLLjoP1G2G3VZ7RdBMof+fIV3MxiZ0CfFBWbeF9xBwchjkOlXINhxooYX3uiYSPdgjdAxcNj9LsDJvPLgM8XPgob19ejD3a7ZYFxs2AeZs3qVjycPg3pJ4RdwEfSSOykkLENRGtqcfmD8Cji7MGXrB8bnElr8LEcsfGriUxkphgHfaWKfW9OZvng/i4xq3NY+UsmkDz9B380c2f5GocF9BTLvW4lriBYd3z+9xLm+H91mMk051Vz3jm8ASN5Xnh0tLNcpGjb45Vuf5ULxsT41pzPLQhTX6ph1D4rKNG7er9Xs+aA+7JwJb9sx/CDKq1vth/urwq+/AdyGHHw" />
+ -->
 
-The `useRenderLoop` composable is the core of TresJS animations. It allows you to register a callback that will be called every time the renderer updates the scene with the browser's refresh rate.
+## useLoop
 
-To see a detailed explanation of how it works, please refer to the [useRenderLoop](/api/composables#userenderloop) documentation.
+The `useLoop` composable is the core of TresJS updates, which includes: **animations**. It allows you to register a callback that will be called every time the renderer updates the scene with the browser's refresh rate.
+
+To see a detailed explanation of how it works, please refer to the [useRenderLoop](/api/composables#useloop) documentation.
 
 ```ts
-const { onLoop } = useRenderLoop()
+const { onBeforeRender } = useLoop()
 
-onLoop(({ delta, elapsed }) => {
+onBeforeRender(({ delta, elapsed }) => {
   // I will run at every frame ~ 60FPS (depending of your monitor)
 })
 ```
 
 ## Getting the reference to the cube
 
-To animate the cube, we need to get a reference to it. We can do it by passing a [Template Ref](https://vuejs.org/guide/essentials/template-refs.html) using `ref` prop to the `TresMesh` component. This will return the THREE instance.
+To animate the cube, we need to get a reference to it. We can do it by passing a [Template Ref](https://vuejs.org/guide/essentials/template-refs.html) using `ref` prop to the `TresMesh` component. This will return the plain `THREE instance`.
 
-To improve the performance, we will use a [Shallow Ref](https://v3.vuejs.org/guide/reactivity-fundamentals.html#shallow-reactivity) to store the reference instead of a regular Ref. See why [here](../advanced/caveats.md#reactivity)
+::: code-group
 
-```vue
-<script setup lang="ts">
-import { TresCanvas } from '@tresjs/core'
+```vue [Scene.vue]
+<script setup>
+import { ref } from 'vue'
+
+const boxRef = ref()
+</script>
+
+<template>
+  <TresMesh ref="boxRef">
+    <TresBoxGeometry />
+    <TresMeshBasicMaterial color="teal" />
+  </TresMesh>
+</template>
+```
 
-const boxRef: ShallowRef<TresInstance | null> = shallowRef(null)
+```vue [App.vue]
+<script setup>
+import { TresCanvas } from '@tresjs/core'
+import Scene from './Scene.vue'
 </script>
 
 <template>
   <TresCanvas>
-    <TresMesh
-      ref="boxRef"
-      :scale="1"
-    >
-      <TresBoxGeometry :args="[1, 1, 1]" />
-      <TresMeshNormalMaterial />
-    </TresMesh>
+    <Scene />
   </TresCanvas>
 </template>
 ```
+:::
 
 ## Animating the cube
 
-Now that we have a reference to the cube, we can animate it. We will use the `onLoop` callback to update the cube's rotation.
+Now that we have a reference to the cube, we can animate it. We will use the `onBeforeRender` method to update the cube's rotation.
 
 ```ts
-onLoop(({ delta, elapsed }) => {
+const { onBeforeRender } = useLoop()
+
+onBeforeRender(({ delta, elapsed }) => {
   if (boxRef.value) {
     boxRef.value.rotation.y += delta
     boxRef.value.rotation.z = elapsed * 0.2
@@ -73,18 +87,14 @@ You can also use the `delta` from the internal [THREE clock](https://threejs.org
 
 You might be wondering why we are not using reactivity to animate the cube. The answer is simple, performance.
 
-```vue
+```ts
 // This is a bad idea ❌
-<script setup lang="ts">
-import { TresCanvas } from '@tresjs/core'
-
-const boxRotation = reactive([0, 0, 0])
+const boxRotation = ref([0, 0, 0])
 
-onLoop(({ delta, elapsed }) => {
-  boxRotation[1] += delta
-  boxRotation[2] = elapsed * 0.2
+onBeforeRender(({ delta, elapsed }) => {
+  boxRotation.value[1] += delta
+  boxRotation.value[2] = elapsed * 0.2
 })
-</script>
 ```
 
 We can be tempted to use reactivity to animate the cube. But it would be a bad idea.

+ 5 - 1
docs/directives/v-always-look-at.md

@@ -1,4 +1,8 @@
-# v-always-look-at 👀
+# v-always-look-at 👀 <Badge type="warning" text="deprecated since v4" />
+
+::: warning
+This directive has been removed on the `v4` due incompatibility with the new renderer loop.
+:::
 
 With the new directive v-always-look-at provided by **TresJS**, you can add easily command an [Object3D](https://threejs.org/docs/index.html?q=object#api/en/core/Object3D) to always look at a specific position, this could be passed as a Vector3 or an Array.
 

+ 5 - 1
docs/directives/v-rotate.md

@@ -1,4 +1,8 @@
-# v-rotate
+# v-rotate  <Badge type="warning" text="deprecated since v4" />
+
+::: warning
+This directive has been removed on the `v4` due incompatibility with the new renderer loop.
+:::
 
 ## Problem
 

+ 10 - 1
playground/components.d.ts

@@ -8,16 +8,23 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AkuAku: typeof import('./src/components/AkuAku.vue')['default']
+    AnimatedModel: typeof import('./src/components/AnimatedModel.vue')['default']
+    AnimatedObjectUseFrame: typeof import('./src/components/AnimatedObjectUseFrame.vue')['default']
+    AnimatedObjectuseUpdate: typeof import('./src/components/AnimatedObjectuseUpdate.vue')['default']
+    AnimatedObjectUseUpdate: typeof import('./src/components/AnimatedObjectUseUpdate.vue')['default']
     BlenderCube: typeof import('./src/components/BlenderCube.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']
-    copy: typeof import('./src/components/TheBasic copy.vue')['default']
+    Component: typeof import('./src/components/useFBO/component.vue')['default']
+    copy: typeof import('./src/components/TheSphere copy.vue')['default']
     DanielTest: typeof import('./src/components/DanielTest.vue')['default']
     DebugUI: typeof import('./src/components/DebugUI.vue')['default']
     DeleteMe: typeof import('./src/components/DeleteMe.vue')['default']
+    DirectiveSubComponent: typeof import('./src/components/DirectiveSubComponent.vue')['default']
     DynamicModel: typeof import('./src/components/DynamicModel.vue')['default']
     EventsPropogation: typeof import('./src/components/EventsPropogation.vue')['default']
+    FBOCube: typeof import('./src/components/FBOCube.vue')['default']
     FBXModels: typeof import('./src/components/FBXModels.vue')['default']
     Gltf: typeof import('./src/components/gltf/index.vue')['default']
     GraphPane: typeof import('./src/components/GraphPane.vue')['default']
@@ -25,6 +32,8 @@ declare module 'vue' {
     RenderingLogger: typeof import('./src/components/RenderingLogger.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    ShadersExperiment: typeof import('./src/components/shaders-experiment/index.vue')['default']
+    TakeOverLoopExperience: typeof import('./src/components/TakeOverLoopExperience.vue')['default']
     TestSphere: typeof import('./src/components/TestSphere.vue')['default']
     Text3D: typeof import('./src/components/Text3D.vue')['default']
     TheCameraOperator: typeof import('./src/components/TheCameraOperator.vue')['default']

+ 95 - 0
playground/src/components/AnimatedObjectUseUpdate.vue

@@ -0,0 +1,95 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { useLoop } from '@tresjs/core'
+import { useControls } from '@tresjs/leches'
+import { useThrottleFn } from '@vueuse/core'
+
+const sphereRef = ref()
+
+const log = useThrottleFn(() => console.log('updating sphere'), 3000)
+const log2 = useThrottleFn(() => console.log('this should happen before updating the sphere'), 3000)
+
+const { onBeforeRender, pause, resume } = useLoop()
+
+const updateCallback = (state) => {
+  if (!sphereRef.value) { return }
+  log()
+  sphereRef.value.position.y += Math.sin(state.elapsed) * 0.01
+}
+
+const { off } = onBeforeRender(updateCallback)
+
+onBeforeRender(() => {
+  log2()
+}, -1)
+
+const { areUpdatesPaused } = useControls({
+  areUpdatesPaused: {
+    value: false,
+    type: 'boolean',
+    label: 'Pause Updates',
+  },
+})
+
+const { unregister } = useControls({
+  unregister: {
+    value: false,
+    type: 'boolean',
+    label: 'Unregister update callback',
+  },
+})
+
+watchEffect(() => {
+  if (areUpdatesPaused.value) {
+    pause()
+  }
+  else {
+    resume()
+  }
+})
+
+watchEffect(() => {
+  if (unregister.value) {
+    off()
+  }
+})
+/* const anotherLog = useThrottleFn(() => console.log('after render'), 3000)
+ */
+/* useUpdate(() => {
+  anotherLog()
+}, 1) */
+
+/* useUpdate(() => {
+  console.count('update loop 1')
+})
+
+useUpdate(() => {
+  console.count('update loop 2')
+}) */
+
+/* useUpdate(() => {
+  console.count('before renderer')
+}, -1)
+
+useUpdate(() => {
+  console.log('this should be just before render')
+})
+
+useUpdate((state) => {
+  if (!sphereRef.value) { return }
+  console.count('after renderer')
+  sphereRef.value.position.y += Math.sin(state.elapsed) * 0.01
+}, 2) */
+</script>
+
+<template>
+  <TresMesh
+    ref="sphereRef"
+    :position="[2, 0, 0]"
+    name="sphere"
+    cast-shadow
+  >
+    <TresSphereGeometry />
+    <TresMeshToonMaterial color="#FBB03B" />
+  </TresMesh>
+</template>

+ 7 - 0
playground/src/components/DirectiveSubComponent.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts">
+import { vAlwaysLookAt, vLightHelper } from '@tresjs/core'
+</script>
+
+<template>
+  <TresDirectionalLight v-light-helper v-always-look-at="[0, 0, 0]" :position="[3, 3, 3]" :intensity="1" />
+</template>

+ 27 - 0
playground/src/components/FBOCube.vue

@@ -0,0 +1,27 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { useFBO } from '../composables/useFBO'
+
+const fboTarget = useFBO({
+  depth: true,
+  width: 512,
+  height: 512,
+  settings: {
+    samples: 1,
+  },
+})
+
+watchEffect(() => {
+  console.log('Target', fboTarget.value)
+})
+</script>
+
+<template>
+  <TresMesh>
+    <TresBoxGeometry :args="[1, 1, 1]" />
+    <TresMeshBasicMaterial
+      :color="0xFF8833"
+      :map="fboTarget.texture ?? null"
+    />
+  </TresMesh>
+</template>

+ 50 - 0
playground/src/components/TakeOverLoopExperience.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import { useLoop } from '@tresjs/core'
+
+import { OrbitControls } from '@tresjs/cientos'
+import { useControls } from '@tresjs/leches'
+
+const { render, pauseRender, resumeRender } = useLoop()
+
+const { off } = render(({ renderer, scene, camera }) => {
+  renderer.render(scene, camera)
+})
+
+const { isRenderPaused } = useControls({
+  isRenderPaused: {
+    value: false,
+    type: 'boolean',
+    label: 'Pause Render',
+  },
+})
+
+const { unregisterRender } = useControls({
+  unregisterRender: {
+    value: false,
+    type: 'boolean',
+    label: 'Unregister render callback',
+  },
+})
+
+watchEffect(() => {
+  if (unregisterRender.value) {
+    off()
+  }
+})
+
+watchEffect(() => {
+  if (isRenderPaused.value) {
+    pauseRender()
+  }
+  else {
+    resumeRender()
+  }
+})
+</script>
+
+<template>
+  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <OrbitControls />
+  <AnimatedObjectUseUpdate />
+  <TresAmbientLight :intensity="1" /> />
+</template>

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

@@ -1,7 +1,17 @@
-<script setup lang="ts"></script>
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { useUpdate } from '@tresjs/core'
+
+const sphereRef = ref()
+useUpdate((state) => {
+  if (!sphereRef.value) { return }
+  sphereRef.value.position.y += Math.sin(state.elapsed) * 0.01
+})
+</script>
 
 <template>
   <TresMesh
+    ref="sphereRef"
     :position="[2, 2, 0]"
     name="sphere"
     cast-shadow

+ 88 - 0
playground/src/composables/useFBO.ts

@@ -0,0 +1,88 @@
+/* eslint-disable no-console */
+import { useLoop, useTresContext } from '@tresjs/core'
+import type { Camera, WebGLRenderTargetOptions } from 'three'
+import { DepthTexture, FloatType, HalfFloatType, LinearFilter, WebGLRenderTarget } from 'three'
+import type { Ref } from 'vue'
+import { isReactive, onBeforeUnmount, reactive, ref, toRefs, watchEffect } from 'vue'
+import { useThrottleFn } from '@vueuse/core'
+
+export interface FboOptions {
+  /*
+   * The width of the frame buffer object. Defaults to the width of the canvas.
+   *
+   * @type {number}
+   * @memberof FboProps
+   */
+  width?: number
+
+  /*
+   * The height of the frame buffer object. Defaults to the height of the canvas.
+   *
+   * @type {number}
+   * @memberof FboProps
+   */
+  height?: number
+
+  /*
+   * If set, the scene depth will be rendered into buffer.depthTexture.
+   *
+   * @default false
+   * @type {boolean}
+   * @memberof FboProps
+   */
+  depth?: boolean
+
+  /*
+   * Additional settings for the render target.
+   * See https://threejs.org/docs/#api/en/renderers/WebGLRenderTarget for more information.
+   *
+   * @default {}
+   * @type {WebGLRenderTargetOptions}
+   * @memberof FboProps
+   */
+  settings?: WebGLRenderTargetOptions
+}
+
+export function useFBO(options: FboOptions) {
+  const target: Ref<WebGLRenderTarget | null> = ref(null)
+
+  const { height, width, settings, depth } = isReactive(options) ? toRefs(options) : toRefs(reactive(options))
+
+  /*   const { onLoop } = useRenderLoop() */
+  const { sizes } = useTresContext()
+
+  watchEffect(() => {
+    target.value?.dispose()
+
+    target.value = new WebGLRenderTarget(width?.value || sizes.width.value, height?.value || sizes.height.value, {
+      minFilter: LinearFilter,
+      magFilter: LinearFilter,
+      type: HalfFloatType,
+      ...settings?.value,
+    })
+
+    if (depth?.value) {
+      target.value.depthTexture = new DepthTexture(
+        width?.value || sizes.width.value,
+        height?.value || sizes.height.value,
+        FloatType,
+      )
+    }
+  })
+  const logBefore = useThrottleFn(() => console.log('FBO: just before render'), 3000)
+  const { onBeforeRender } = useLoop()
+
+  onBeforeRender(({ renderer, scene, camera }) => {
+    logBefore()
+    renderer.setRenderTarget(target.value)
+    renderer.clear()
+    renderer.render(scene, camera as Camera)
+    renderer.setRenderTarget(null)
+  }, Number.POSITIVE_INFINITY)
+
+  onBeforeUnmount(() => {
+    target.value?.dispose()
+  })
+
+  return target
+}

+ 31 - 0
playground/src/pages/advanced/FBO.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+
+import { OrbitControls } from '@tresjs/cientos'
+import '@tresjs/leches/styles'
+
+const gl = {
+  clearColor: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+</script>
+
+<template>
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera :position="[3, 3, 3]" />
+    <OrbitControls />
+    <!--  <Fbo
+      ref="fboRef"
+      v-bind="state"
+    /> -->
+
+    <FBOCube />
+    <AnimatedObjectUseUpdate />
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

+ 0 - 0
playground/src/pages/perf/Memory.vue → playground/src/pages/advanced/Memory.vue


+ 1 - 0
playground/src/pages/perf/OnDemand.vue → playground/src/pages/advanced/OnDemand.vue

@@ -33,5 +33,6 @@ function onRender() {
       :position="[0, 8, 4]"
       :intensity="0.7"
     />
+    <TheSphere />
   </TresCanvas>
 </template>

+ 24 - 0
playground/src/pages/advanced/TakeOverLoop.vue

@@ -0,0 +1,24 @@
+<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: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+
+useControls('fpsgraph')
+</script>
+
+<template>
+  <TresLeches />
+  <TresCanvas v-bind="gl">
+    <TakeOverLoopExperience />
+  </TresCanvas>
+</template>

+ 0 - 0
playground/src/pages/perf/index.vue → playground/src/pages/advanced/index.vue


+ 11 - 0
playground/src/pages/basic/example.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+</script>
+
+<template>
+  <TresCanvas clear-color="#c0ffee" window-size>
+    <TresPerspectiveCamera :position="[3, 3, 3]" />
+    <TresGridHelper :size="10" />
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

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

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
 import { reactive, ref } from 'vue'
-import { TresCanvas, useRenderLoop } from '@tresjs/core'
+import { TresCanvas } from '@tresjs/core'
 import { OrbitControls } from '@tresjs/cientos'
 
 const state = reactive({
@@ -86,7 +86,6 @@ const sphereExists = ref(true)
       <TresPlaneGeometry :args="[10, 10, 10, 10]" />
       <TresMeshBasicMaterial />
     </TresMesh>
-
     <TresDirectionalLight
       :position="[0, 2, 4]"
       :intensity="1"

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

@@ -1,16 +1,16 @@
 <script setup lang="ts">
 import {
+  advancedRoutes,
   basicRoutes,
   cameraRoutes,
   eventsRoutes,
   miscRoutes,
   modelsRoutes,
-  perfRoutes,
 } from '../router/routes'
 
 const sections = [
   { icon: '📦', title: 'Basic', routes: basicRoutes },
-  { icon: '🏎️', title: 'Perf', routes: perfRoutes },
+  { icon: '🤓', title: 'Advanced', routes: advancedRoutes },
   { icon: '📣', title: 'Events', routes: eventsRoutes },
   { icon: '📷', title: 'Camera', routes: cameraRoutes },
   { icon: '🐇', title: 'Models', routes: modelsRoutes },

+ 25 - 0
playground/src/pages/misc/Directives.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+
+import { OrbitControls } from '@tresjs/cientos'
+
+const gl = {
+  clearColor: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+</script>
+
+<template>
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera :position="[3, 3, 3]" />
+    <OrbitControls />
+    <TresGridHelper />
+    <DirectiveSubComponent />
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

+ 2 - 2
playground/src/router/index.ts

@@ -1,6 +1,6 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { basicRoutes } from './routes/basic'
-import { cameraRoutes, eventsRoutes, miscRoutes, modelsRoutes, perfRoutes } from './routes'
+import { advancedRoutes, cameraRoutes, eventsRoutes, miscRoutes, modelsRoutes } from './routes'
 
 const routes = [
   {
@@ -9,7 +9,7 @@ const routes = [
     component: () => import('../pages/index.vue'),
   },
   ...basicRoutes,
-  ...perfRoutes,
+  ...advancedRoutes,
   ...eventsRoutes,
   ...cameraRoutes,
   ...modelsRoutes,

+ 17 - 0
playground/src/router/routes/advanced.ts

@@ -0,0 +1,17 @@
+export const advancedRoutes = [
+  {
+    path: '/advanced/on-demand',
+    name: 'On Demand',
+    component: () => import('../../pages/advanced/OnDemand.vue'),
+  },
+  {
+    path: '/advanced/take-over-loop',
+    name: 'Take Over loop',
+    component: () => import('../../pages/advanced/TakeOverLoop.vue'),
+  },
+  {
+    path: '/advanced/fbo',
+    name: 'FBO',
+    component: () => import('../../pages/advanced/FBO.vue'),
+  },
+]

+ 2 - 2
playground/src/router/routes/index.ts

@@ -2,12 +2,12 @@ import { modelsRoutes } from './models'
 import { cameraRoutes } from './cameras'
 import { eventsRoutes } from './events'
 import { basicRoutes } from './basic'
-import { perfRoutes } from './performance'
+import { advancedRoutes } from './advanced'
 import { miscRoutes } from './misc'
 
 export {
   basicRoutes,
-  perfRoutes,
+  advancedRoutes,
   eventsRoutes,
   cameraRoutes,
   modelsRoutes,

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

@@ -4,4 +4,9 @@ export const miscRoutes = [
     name: 'Text 3D',
     component: () => import('../../pages/misc/Text3DDemo.vue'),
   },
+  {
+    path: '/misc/directives',
+    name: 'Directives',
+    component: () => import('../../pages/misc/Directives.vue'),
+  },
 ]

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

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

+ 2 - 2
playground/vite.config.ts

@@ -7,13 +7,13 @@ import glsl from 'vite-plugin-glsl'
 import UnoCSS from 'unocss/vite'
 import { templateCompilerOptions } from '@tresjs/core'
 import { qrcode } from 'vite-plugin-qrcode'
-import VueDevTools from 'vite-plugin-vue-devtools'
+/* import VueDevTools from 'vite-plugin-vue-devtools' */
 
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
     glsl(),
-    VueDevTools(),
+    /*     VueDevTools(), */
     vue({
       script: {
         propsDestructure: true,

+ 0 - 4
src/components/TresCanvas.vue

@@ -28,7 +28,6 @@ import pkg from '../../package.json'
 import {
   type TresContext,
   useLogger,
-  useRenderLoop,
   useTresContextProvider,
   useTresEventManager,
 } from '../composables'
@@ -107,8 +106,6 @@ const canvas = ref<HTMLCanvasElement>()
 */
 const scene = shallowRef<TresScene | Scene>(new Scene())
 
-const { resume } = useRenderLoop()
-
 const instance = getCurrentInstance()?.appContext.app
 extend(THREE)
 
@@ -146,7 +143,6 @@ const dispose = (context: TresContext, force = false) => {
     root: context,
   }
   mountCustomRenderer(context)
-  resume()
 }
 
 const disableRender = computed(() => props.disableRender)

+ 1 - 0
src/composables/index.ts

@@ -8,4 +8,5 @@ export * from './useLogger'
 export * from './useSeek'
 export * from './usePointerEventHandler'
 export * from './useTresContextProvider'
+export * from './useLoop'
 export * from './useTresEventManager'

+ 51 - 0
src/composables/useLoop/index.ts

@@ -0,0 +1,51 @@
+import { unref } from 'vue'
+import type { Fn } from '@vueuse/core'
+import type { TresCamera } from '../../types'
+import { useTresContext } from '../useTresContextProvider'
+
+export function useLoop() {
+  const {
+    camera,
+    scene,
+    renderer,
+    loop,
+    raycaster,
+    controls,
+    invalidate,
+    advance,
+  } = useTresContext()
+
+  // Pass context to loop
+  loop.setContext({
+    camera: unref(camera) as TresCamera,
+    scene: unref(scene),
+    renderer: unref(renderer),
+    raycaster: unref(raycaster),
+    controls: unref(controls),
+    invalidate,
+    advance,
+  })
+
+  function onBeforeRender(cb: Fn, index = 0) {
+    return loop.register(cb, 'before', index)
+  }
+
+  function render(cb: Fn) {
+    return loop.register(cb, 'render')
+  }
+
+  function onAfterRender(cb: Fn, index = 0) {
+    return loop.register(cb, 'after', index)
+  }
+
+  return {
+    pause: loop.pause,
+    resume: loop.resume,
+    pauseRender: loop.pauseRender,
+    resumeRender: loop.resumeRender,
+    isActive: loop.isActive,
+    onBeforeRender,
+    render,
+    onAfterRender,
+  }
+}

+ 10 - 8
src/composables/useRenderLoop/index.ts

@@ -40,11 +40,13 @@ onAfterLoop.on(() => {
   elapsed = clock.getElapsedTime()
 })
 
-export const useRenderLoop = (): UseRenderLoopReturn => ({
-  onBeforeLoop: onBeforeLoop.on,
-  onLoop: onLoop.on,
-  onAfterLoop: onAfterLoop.on,
-  pause,
-  resume,
-  isActive,
-})
+export const useRenderLoop = (): UseRenderLoopReturn => {
+  return {
+    onBeforeLoop: onBeforeLoop.on,
+    onLoop: onLoop.on,
+    onAfterLoop: onAfterLoop.on,
+    pause,
+    resume,
+    isActive,
+  }
+}

+ 1 - 30
src/composables/useRenderer/index.ts

@@ -11,7 +11,6 @@ import {
 import type { ColorSpace, Scene, ShadowMapType, ToneMapping, WebGLRendererParameters } from 'three'
 import { useLogger } from '../useLogger'
 import type { EmitEventFn, TresColor } from '../../types'
-import { useRenderLoop } from '../useRenderLoop'
 import { normalizeColor } from '../../utils/normalize'
 
 import type { TresContext } from '../useTresContextProvider'
@@ -97,12 +96,9 @@ export interface UseRendererOptions extends TransformToMaybeRefOrGetter<WebGLRen
 
 export function useRenderer(
   {
-    scene,
     canvas,
     options,
-    disableRender,
-    emit,
-    contextParts: { sizes, camera, render, invalidate, advance },
+    contextParts: { sizes, render, invalidate, advance },
   }:
   {
     canvas: MaybeRef<HTMLCanvasElement>
@@ -161,29 +157,6 @@ export function useRenderer(
 
   const { logError } = useLogger()
 
-  // TheLoop
-
-  const { resume, onLoop } = useRenderLoop()
-
-  onLoop(() => {
-    if (camera.value && !toValue(disableRender) && render.frames.value > 0) {
-      renderer.value.render(scene, camera.value)
-      emit('render', renderer.value)
-    }
-
-    // Reset priority
-    render.priority.value = 0
-
-    if (toValue(options.renderMode) === 'always') {
-      render.frames.value = 1
-    }
-    else {
-      render.frames.value = Math.max(0, render.frames.value - 1)
-    }
-  })
-
-  resume()
-
   const getThreeRendererDefaults = () => {
     const plainRenderer = new WebGLRenderer()
 
@@ -279,8 +252,6 @@ export function useRenderer(
     renderer.value.forceContextLoss()
   })
 
-  if (import.meta.hot) { import.meta.hot.on('vite:afterUpdate', resume) }
-
   return {
     renderer,
   }

+ 30 - 0
src/composables/useTresContextProvider/index.ts

@@ -13,6 +13,8 @@ import type { EmitEventFn, TresObject, TresScene } from '../../types'
 import type { EventProps } from '../usePointerEventHandler'
 import type { TresEventManager } from '../useTresEventManager'
 import useSizes, { type SizesType } from '../useSizes'
+import type { RendererLoop } from '../../core/loop'
+import { createRenderLoop } from '../../core/loop'
 
 export interface InternalState {
   priority: Ref<number>
@@ -57,6 +59,8 @@ export interface TresContext {
   raycaster: ShallowRef<Raycaster>
   perf: PerformanceState
   render: RenderState
+  // Loop
+  loop: RendererLoop
   /**
    * Invalidates the current frame when renderMode === 'on-demand'
    */
@@ -176,6 +180,7 @@ export function useTresContextProvider({
     registerCamera,
     setCameraActive,
     deregisterCamera,
+    loop: createRenderLoop(),
   }
 
   provide('useTres', ctx)
@@ -185,6 +190,31 @@ export function useTresContextProvider({
     root: ctx,
   }
 
+  // The loop
+
+  ctx.loop.register(() => {
+    if (camera.value && render.frames.value > 0) {
+      renderer.value.render(scene, camera.value)
+      emit('render', ctx.renderer.value)
+    }
+
+    // Reset priority
+    render.priority.value = 0
+
+    if (render.mode.value === 'always') {
+      render.frames.value = 1
+    }
+    else {
+      render.frames.value = Math.max(0, render.frames.value - 1)
+    }
+  }, 'render')
+
+  ctx.loop.start()
+
+  onUnmounted(() => {
+    ctx.loop.stop()
+  })
+
   // Performance
   const updateInterval = 100 // Update interval in milliseconds
   const fps = useFps({ every: updateInterval })

+ 120 - 0
src/core/loop.test.ts

@@ -0,0 +1,120 @@
+import { afterEach, beforeEach, it } from 'vitest'
+import { createRenderLoop } from './loop'
+
+let renderLoop
+
+describe('createRenderLoop', () => {
+  beforeEach(() => {
+    renderLoop = createRenderLoop()
+  })
+  afterEach(() => {
+    renderLoop.stop()
+  })
+
+  it('should start and stop the loop', () => {
+    // Spy
+    const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame')
+    requestAnimationFrameSpy.mockImplementation((_callback: FrameRequestCallback) => {
+      return 0 // Return a number as a placeholder
+    })
+
+    renderLoop.start()
+    expect(requestAnimationFrameSpy).toHaveBeenCalled()
+    requestAnimationFrameSpy.mockClear()
+    renderLoop.stop()
+    expect(requestAnimationFrameSpy).not.toHaveBeenCalled()
+  })
+
+  it('should pause and resume the loop', () => {
+    renderLoop.start()
+    renderLoop.pause()
+    expect(renderLoop.isActive.value).toBe(false)
+    renderLoop.resume()
+    expect(renderLoop.isActive.value).toBe(true)
+  })
+
+  it('should pause and resume the renderer', () => {
+    renderLoop.start()
+    renderLoop.pauseRender()
+    expect(renderLoop.isRenderPaused.value).toBe(true)
+    renderLoop.resumeRender()
+    expect(renderLoop.isRenderPaused.value).toBe(false)
+  })
+
+  it('should register a callback before render', () => {
+    let result = ''
+    const callback = () => { result += '0' }
+    renderLoop.register(callback, 'before')
+    renderLoop.start()
+    expect(result).toBe('0')
+  })
+
+  it('should register callbacks in order before render', () => {
+    const callbackIndexes = []
+    const callback1 = () => { callbackIndexes.push(-1) }
+    const callback2 = () => { callbackIndexes.push(0) }
+    const callback3 = () => { callbackIndexes.push(1) }
+    const callback4 = () => { callbackIndexes.push(2) }
+    renderLoop.register(callback2, 'before')
+    renderLoop.register(callback1, 'before', -1)
+    renderLoop.register(callback3, 'before')
+    renderLoop.register(callback4, 'before', 2)
+    renderLoop.start()
+    expect(callbackIndexes).toStrictEqual([-1, 0, 1, 2])
+  })
+
+  it('should register a callback for render', () => {
+    let result = ''
+    const callback = () => { result += '0' }
+    renderLoop.register(callback, 'render')
+    renderLoop.start()
+    expect(result).toBe('0')
+  })
+
+  it('should take over the render loop', async () => {
+    let result = ''
+    const originalRenderCallback = () => { result = 'original' }
+    const takeOver = () => { result = 'takeover' }
+
+    renderLoop.register(originalRenderCallback, 'render')
+    renderLoop.register(takeOver, 'render')
+
+    renderLoop.start()
+    expect(result).toBe('takeover')
+  })
+
+  it('does not register the same callback twice', () => {
+    let result = ''
+    const callback1 = () => { result += '1' }
+    renderLoop.register(callback1, 'before', 0)
+    renderLoop.register(callback1, 'before', 0)
+    renderLoop.start()
+    renderLoop.stop()
+    expect(result).toEqual('1')
+  })
+
+  it('should register a callback after render', () => {
+    let result = ''
+    const callback = () => { result += '0' }
+    renderLoop.register(callback, 'after')
+    renderLoop.start()
+    expect(result).toBe('0')
+  })
+
+  it('should render first all before render callbacks, then render callbacks, and finally after render callbacks', async () => {
+    const executionOrder = []
+    const beforeCb = () => { executionOrder.push('before') }
+    const fboCb = () => { executionOrder.push('fbo') }
+    const renderCb = () => { executionOrder.push('render') }
+    const afterCb = () => { executionOrder.push('after') }
+    renderLoop.register(beforeCb, 'before')
+    renderLoop.register(fboCb, 'before', Number.POSITIVE_INFINITY)
+    renderLoop.register(renderCb, 'render')
+    renderLoop.register(afterCb, 'after', -1)
+
+    renderLoop.start()
+    renderLoop.stop()
+
+    expect(executionOrder).toEqual(['before', 'fbo', 'render', 'after'])
+  })
+})

+ 137 - 0
src/core/loop.ts

@@ -0,0 +1,137 @@
+import type { Ref } from 'vue'
+import { ref } from 'vue'
+import { Clock, MathUtils } from 'three'
+import type { Fn } from '@vueuse/core'
+import type { Callback, PriorityEventHookOn } from '../utils/createPriorityEventHook'
+import { createPriorityEventHook } from '../utils/createPriorityEventHook'
+
+export type LoopStage = 'before' | 'render' | 'after'
+
+export interface LoopCallback {
+  delta: number
+  elapsed: number
+  clock: Clock
+}
+
+export interface RendererLoop {
+  loopId: string
+  register: (callback: Fn, stage: LoopStage, index?: number) => Partial<PriorityEventHookOn<LoopCallback>>
+  start: () => void
+  stop: () => void
+  pause: () => void
+  resume: () => void
+  pauseRender: () => void
+  resumeRender: () => void
+  isActive: Ref<boolean>
+  isRenderPaused: Ref<boolean>
+  setContext: (newContext: Record<string, any>) => void
+}
+
+export function createRenderLoop(): RendererLoop {
+  const clock = new Clock(false)
+  const isActive = ref(false)
+  const isRenderPaused = ref(false)
+  let animationFrameId: number
+  const loopId = MathUtils.generateUUID()
+  let defaultRenderFn: Callback<LoopCallback> | null = null
+  const subscribersBefore = createPriorityEventHook<LoopCallback>()
+  const subscriberRender = createPriorityEventHook<LoopCallback>()
+  const subscribersAfter = createPriorityEventHook<LoopCallback>()
+
+  // Context to be passed to callbacks
+  let context: Record<string, any> = {}
+
+  function setContext(newContext: Record<string, any>) {
+    context = newContext
+  }
+
+  function registerCallback(callback: Callback<LoopCallback>, stage: 'before' | 'render' | 'after', index = 0): Partial<PriorityEventHookOn<LoopCallback>> {
+    switch (stage) {
+      case 'before':
+        return subscribersBefore.on(callback, index)
+      case 'render':
+        if (!defaultRenderFn) {
+          defaultRenderFn = callback
+        }
+        subscriberRender.dispose()
+        return subscriberRender.on(callback)
+      case 'after':
+        return subscribersAfter.on(callback, index)
+    }
+  }
+
+  function start() {
+    if (!isActive.value) {
+      clock.start()
+      isActive.value = true
+      loop()
+    }
+  }
+
+  function stop() {
+    if (isActive.value) {
+      clock.stop()
+      cancelAnimationFrame(animationFrameId)
+      isActive.value = false
+    }
+  }
+
+  function pause() {
+    clock.stop()
+    isActive.value = false
+  }
+
+  function resume() {
+    clock.start()
+    isActive.value = true
+  }
+
+  function pauseRender() {
+    isRenderPaused.value = true
+  }
+
+  function resumeRender() {
+    isRenderPaused.value = false
+  }
+
+  function loop() {
+    const delta = clock.getDelta()
+    const elapsed = clock.getElapsedTime()
+    const params = { delta, elapsed, clock, ...context }
+
+    if (isActive.value) {
+      subscribersBefore.trigger(params)
+    }
+
+    if (!isRenderPaused.value) {
+      if (subscriberRender.count) {
+        subscriberRender.trigger(params)
+      }
+      else {
+        if (defaultRenderFn) {
+          defaultRenderFn(params) // <-- keep the default render function separate
+        }
+      }
+    }
+
+    if (isActive.value) {
+      subscribersAfter.trigger(params)
+    }
+
+    animationFrameId = requestAnimationFrame(loop)
+  }
+
+  return {
+    loopId,
+    register: (callback: Fn, stage: 'before' | 'render' | 'after', index) => registerCallback(callback, stage, index),
+    start,
+    stop,
+    pause,
+    resume,
+    pauseRender,
+    resumeRender,
+    isRenderPaused,
+    isActive,
+    setContext,
+  }
+}

+ 1 - 3
src/directives/index.ts

@@ -1,7 +1,5 @@
 import { vLog } from './vLog'
 import { vLightHelper } from './vLightHelper'
-import { vAlwaysLookAt } from './vAlwaysLookAt'
 import { vDistanceTo } from './vDistanceTo'
-import { vRotate } from './vRotate'
 
-export { vLog, vLightHelper, vAlwaysLookAt, vDistanceTo, vRotate }
+export { vLog, vLightHelper, vDistanceTo }

+ 0 - 21
src/directives/vAlwaysLookAt.ts

@@ -1,21 +0,0 @@
-import type { Object3D } from 'three'
-import type { Ref } from 'vue'
-import { extractBindingPosition } from '../utils'
-import type { TresVector3 } from '../types'
-import { useLogger, useRenderLoop } from '../composables'
-
-const { logWarning } = useLogger()
-
-export const vAlwaysLookAt = {
-  updated: (el: Object3D, binding: Ref<TresVector3>) => {
-    const observer = extractBindingPosition(binding)
-    if (!observer) {
-      logWarning(`v-always-look-at: problem with binding value: ${binding.value}`)
-      return
-    }
-    const { onLoop } = useRenderLoop()
-    onLoop(() => {
-      el.lookAt(observer)
-    })
-  },
-}

+ 0 - 40
src/directives/vRotate.ts

@@ -1,40 +0,0 @@
-import { ref } from 'vue'
-import { Quaternion, Vector3 } from 'three'
-import type { TresObject } from '../types'
-import { useLogger, useRenderLoop } from '../composables'
-
-const { logWarning } = useLogger()
-
-export const vRotate = {
-  mounted: (
-    el: TresObject,
-    binding: {
-      arg: 'x' | 'y' | 'z'
-      value: number
-      modifiers: Partial<{ x: boolean, y: boolean, z: boolean }>
-    },
-  ) => {
-    if (el.isCamera) {
-      logWarning(`Rotate the ${el.type} is not a good idea`)
-      return
-    }
-    const radiansPerFrame = binding.value ?? 0.01
-    const x = ref(binding.modifiers.x || binding.arg === 'x' ? 1 : 0)
-    const y = ref(binding.modifiers.y || binding.arg === 'y' ? 1 : 0)
-    const z = ref(binding.modifiers.z || binding.arg === 'z' ? 1 : 0)
-
-    if (x.value + y.value + z.value === 0) {
-      x.value = 1
-      y.value = 1
-    }
-
-    const quaternion = new Quaternion().setFromAxisAngle(new Vector3(x.value, y.value, z.value)
-      .normalize(), radiansPerFrame)
-
-    const { onLoop } = useRenderLoop()
-
-    onLoop(() => {
-      el.applyQuaternion(quaternion)
-    })
-  },
-}

+ 263 - 0
src/utils/createPriorityEventHook.test.ts

@@ -0,0 +1,263 @@
+import { createPriorityEventHook } from './createPriorityEventHook'
+
+let updateHook = createPriorityEventHook()
+
+describe('createPrioritizableEventHook', () => {
+  beforeEach(() => {
+    updateHook = createPriorityEventHook()
+  })
+
+  describe('count', () => {
+    it('is initially 0', () => {
+      expect(updateHook.count).toBe(0)
+    })
+    it('increases when hooks are added with on', () => {
+      for (const i of getArray0ToN(10)) {
+        updateHook.on(() => {})
+        expect(updateHook.count).toBe(i + 1)
+      }
+    })
+    it('decreases when hooks are removed with off', () => {
+      const fns = []
+      for (const i of getArray0ToN(10)) {
+        fns.push(() => {})
+        updateHook.on(fns[i])
+      }
+      let count = updateHook.count
+      for (const fn of fns) {
+        updateHook.off(fn)
+        expect(updateHook.count).toBe(count - 1)
+        count--
+      }
+    })
+  })
+  describe('on', () => {
+    it('adds events without priority', () => {
+      const s = 'abcdefg'
+      let result = ''
+      for (const letter of s.split('')) {
+        updateHook.on(() => (result += letter + letter))
+      }
+      updateHook.trigger()
+      expect(result).toBe('aabbccddeeffgg')
+    })
+    it('adds events without priority at priority 0', () => {
+      const result = []
+      for (const i of [-3, -2, -1, 0, 1, 2, 3]) {
+        const priority = i % 2 ? i : 0
+        if (priority === 0) {
+          updateHook.on(() => (result.push(i)))
+        }
+        else {
+          updateHook.on(() => (result.push(i)), priority)
+        }
+      }
+      updateHook.trigger()
+      expect(result).toStrictEqual([-3, -1, -2, 0, 2, 1, 3])
+    })
+    it('adds events with priority', () => {
+      const NUM_TESTS = 10
+      for (let i = 0; i < NUM_TESTS; i++) {
+        let result = ''
+        const arr = shuffle(getArray0ToN(10))
+        for (const priority of arr) {
+          updateHook.on(() => (result += priority), priority)
+        }
+        updateHook.trigger()
+        expect(result).toBe('0123456789')
+      }
+    })
+    it('adds events with negative priority', () => {
+      const NUM_TESTS = 10
+      for (let i = 0; i < NUM_TESTS; i++) {
+        let result = ''
+        const arr = shuffle(getArray0ToN(10))
+        for (const priority of arr) {
+          updateHook.on(() => (result += priority), -priority)
+        }
+        updateHook.trigger()
+        expect(result).toBe('9876543210')
+      }
+    })
+    it('adds events with positive and negative priority', () => {
+      const NUM_TESTS = 10
+      for (let i = 0; i < NUM_TESTS; i++) {
+        let result = ''
+        const arr = shuffle(getArray0ToN(10))
+        for (const priority of arr) {
+          updateHook.on(() => (result += priority), priority - 4)
+        }
+        updateHook.trigger()
+        expect(result).toBe('0123456789')
+      }
+    })
+    it('sorts events by priority then insert order', () => {
+      const NUM_TESTS = 10
+      for (let i = 0; i < NUM_TESTS; i++) {
+        const insertOrder = {}
+        const getArr = () => getArray0ToN(4)
+        const arr = shuffle((getArr().concat(getArr()).concat(getArr())))
+        // NOTE: arr is [0,0,0,1,1,1,2,2,2,3,3,3] – shuffled
+        const result = []
+        for (const priority of arr) {
+          if (!(priority in insertOrder)) {
+            insertOrder[priority] = 0
+          }
+          else {
+            insertOrder[priority]++
+          }
+          const msg = `${priority}.${insertOrder[priority]}`
+          updateHook.on(() => (result.push(msg)), priority)
+        }
+        updateHook.trigger()
+        expect(result).toStrictEqual(
+          '0.0|0.1|0.2|1.0|1.1|1.2|2.0|2.1|2.2|3.0|3.1|3.2'.split('|'),
+        )
+      }
+    })
+    describe('... with an event added twice', () => {
+      it('triggers once', () => {
+        let result = ''
+        const fn0 = () => { result += '0' }
+        updateHook.on(fn0)
+        updateHook.on(fn0, 1)
+        updateHook.on(fn0, 2)
+        updateHook.trigger()
+        expect(result).toBe('0')
+      })
+      it('is counted once', () => {
+        const fn0 = () => { }
+        const fn1 = () => { }
+        updateHook.on(fn0)
+        updateHook.on(fn1)
+        updateHook.on(fn0)
+        expect(updateHook.count).toBe(2)
+      })
+      it('uses latest insert order', () => {
+        let result = ''
+        const fn0 = () => { result += '0' }
+        const fn1 = () => { result += '1' }
+        updateHook.on(fn0)
+        updateHook.on(fn1)
+        updateHook.on(fn0)
+        updateHook.trigger()
+        expect(result).toBe('10')
+      })
+      it('uses latest priority', () => {
+        let result = ''
+        const fn0 = () => { result += '0' }
+        const fn1 = () => { result += '1' }
+        updateHook.on(fn0, 0)
+        updateHook.on(fn1, 1)
+        updateHook.on(fn0, 2)
+        updateHook.trigger()
+        expect(result).toBe('10')
+      })
+    })
+    it('returns an object with `off`', () => {
+      let result = ''
+      const fn0 = () => { result += '0' }
+      const fn1 = () => { result += '1' }
+      const off0 = updateHook.on(fn0).off
+      const off1 = updateHook.on(fn1).off
+      updateHook.trigger()
+      expect(result).toBe('01')
+
+      result = ''
+      off0()
+      updateHook.trigger()
+      expect(result).toBe('1')
+
+      result = ''
+      off1()
+      updateHook.trigger()
+      expect(result).toBe('')
+    })
+  })
+
+  describe('off', () => {
+    it('removes the passed event', () => {
+      let result = ''
+      const fn0 = () => { result += '0' }
+      const fn1 = () => { result += '1' }
+      updateHook.on(fn0)
+      updateHook.on(fn1)
+      updateHook.trigger()
+      expect(result).toBe('01')
+
+      updateHook.off(fn0)
+      updateHook.trigger()
+      expect(result).toBe('011')
+
+      updateHook.off(fn1)
+      updateHook.trigger()
+      expect(result).toBe('011')
+
+      updateHook.on(fn0)
+      updateHook.off(fn0)
+      updateHook.trigger()
+      expect(result).toBe('011')
+    })
+
+    it('does nothing if hook does not contain passed event', () => {
+      let result = ''
+      const fn0 = () => { result += '0' }
+      const fn1 = () => { result += '1' }
+      updateHook.on(fn0)
+      updateHook.on(fn1)
+      updateHook.trigger()
+      expect(result).toBe('01')
+
+      updateHook.off(fn1)
+      updateHook.trigger()
+      expect(result).toBe('010')
+
+      updateHook.off(fn1)
+      updateHook.trigger()
+      expect(result).toBe('0100')
+    })
+  })
+
+  describe('trigger', () => {
+    it('calls added events', () => {
+      let result = ''
+      const fn0 = () => { result += '0' }
+      const fn1 = () => { result += '1' }
+      updateHook.on(fn0)
+      updateHook.on(fn1)
+
+      updateHook.trigger()
+      expect(result).toBe('01')
+    })
+    it('calls added events with an argument', () => {
+      let result = ''
+      const fn0 = (i: number) => { result += `${i}` }
+      const fn1 = (i: number) => { result += `${1 + i}` }
+      updateHook.on(fn0)
+      updateHook.on(fn1)
+
+      updateHook.trigger(2)
+      expect(result).toBe('23')
+
+      updateHook.trigger(7)
+      expect(result).toBe('2378')
+    })
+  })
+})
+
+function getArray0ToN(n: number) {
+  return Array.from({ length: n }).fill(0).map((_, i) => i)
+}
+
+function shuffle(array: any[]) {
+  let currentIndex = array.length
+  while (currentIndex !== 0) {
+    const randomIndex = Math.floor(Math.random() * currentIndex)
+    currentIndex--;
+    [array[currentIndex], array[randomIndex]] = [
+      array[randomIndex],
+      array[currentIndex],
+    ]
+  }
+  return array
+};

+ 73 - 0
src/utils/createPriorityEventHook.ts

@@ -0,0 +1,73 @@
+import { tryOnScopeDispose } from '@vueuse/core'
+import type { EventHookOff, IsAny } from '@vueuse/core'
+
+// NOTE: Based on vueuse's createEventHook
+// https://github.com/vueuse/vueuse/blob/1558cd2b5b019abc1feda6d702caa1053a182903/packages/shared/createEventHook/index.ts
+
+// NOTE: any extends void = true
+// So we need to check if T is any first
+export type Callback<T> = IsAny<T> extends true
+  ? (param: any) => void
+  : (
+      [T] extends [void]
+        ? () => void
+        : (param: T) => void
+    )
+export type PriorityEventHookOn<T> = (fn: Callback<T>, priority?: number) => { off: () => void }
+export type PriorityEventHookOff<T> = EventHookOff<T>
+export type PriorityEventHookTrigger<T = any> = (param?: T) => void
+
+export interface PriorityEventHook<T = any> {
+  on: PriorityEventHookOn<T>
+  off: EventHookOff<T>
+  trigger: PriorityEventHookTrigger<T>
+  dispose: () => void
+  count: number
+}
+
+export function createPriorityEventHook<T>(): PriorityEventHook<T> {
+  const eventToPriority = new Map<Callback<T>, { priority: number, addI: number }>()
+  const ascending = new Set<Callback<T>>()
+  let ADD_COUNT = 0
+  let dirty = false
+
+  const sort = () => {
+    const sorted = Array.from(eventToPriority.entries())
+      .sort((a, b) => {
+        const priorityDiff = a[1].priority - b[1].priority
+        return priorityDiff === 0 ? a[1].addI - b[1].addI : priorityDiff
+      })
+    ascending.clear()
+    sorted.forEach(entry => ascending.add(entry[0]))
+  }
+
+  const off = (fn: Callback<T>) => {
+    eventToPriority.delete(fn)
+    ascending.delete(fn)
+  }
+
+  const on = (fn: Callback<T>, priority = 0) => {
+    eventToPriority.set(fn, { priority, addI: ADD_COUNT++ })
+    const offFn = () => off(fn)
+    tryOnScopeDispose(offFn)
+    dirty = true
+    return {
+      off: offFn,
+    }
+  }
+
+  const trigger: PriorityEventHookTrigger<T> = (...args) => {
+    if (dirty) {
+      sort()
+      dirty = false
+    }
+    ascending.forEach(fn => fn(...(args as [T])))
+  }
+
+  const dispose = () => {
+    eventToPriority.clear()
+    ascending.clear()
+  }
+
+  return { on, off, trigger, dispose, get count() { return eventToPriority.size } }
+}

+ 3 - 1
src/utils/index.ts

@@ -308,6 +308,8 @@ export function disposeObject3D(object: TresObject): void {
       disposeMaterial(mesh.material)
       delete mesh.material
     }
-    object.dispose?.()
+    if (object) {
+      object.dispose?.()
+    }
   }
 }