Browse Source

feat: release v4 (#490)

* feat: 474 vue chrome devtools plugin (#479)

* feat: vue chrome devtools

* feat: editable scenes from devtools

* chore(lint): fix lint errors

* feat: highlight material

* chore(lint): fix

* chore: release v4.0.0-next.0

* feat: update to three `v160` and vue `v3.4` (#488)

* fix(types): added `Object3DEventMap` to `Object3D` generics for point event handling (#491)

* feat: 140 on demand rendering (#497)

* feat: conditional rendering

* chore: remove subscribe system

* feat: on-demand automatic invalidation with prop changes

* feat: invalidate once first when is `renderMode !== 'always'`

* docs: performance page, on-demand rendering

* chore: fix windowsize issue

* chore(lint): fix maximum line length issues

* feat: invalidate on-demand on window resize

* feat: add advance method for manual mode

* feat: fix manual first render with advance

* docs: performance manual mode

* docs: add badge with version

* chore: correct typos and PR suggestions

* chore: tell dont ask fix

* feat: render state instead of internal

* feat: remove default camera warning (#499)

* feat: remove annoying defautl camera warning

* chore: remove logWarning

* feat: 492 set tone mapping default to acesfilmictonemapping (#498)

* feat: set ACESFilmicToneMapping as default toneMapping

* chore: usage of nullish coealescing operator instead of ternaries

* feat: 516 localstate for custom renderer node instances instead of userdata (#522)

* feat: conditional rendering

* chore: remove subscribe system

* feat: on-demand automatic invalidation with prop changes

* feat: invalidate once first when is `renderMode !== 'always'`

* docs: performance page, on-demand rendering

* chore: fix windowsize issue

* chore(lint): fix maximum line length issues

* feat: invalidate on-demand on window resize

* feat: add advance method for manual mode

* feat: fix manual first render with advance

* docs: performance manual mode

* docs: add badge with version

* chore: correct typos and PR suggestions

* chore: tell dont ask fix

* feat: render state instead of internal

* feat: add __tres local state to nodeOps instances

* feat: add context to root on instances localstate

* feat: camera registration ops from node local state ctx

* feat: event handling registration from localState of nodes

* feature: disposable flag on node localstate

* feat: remove userData from types

* chore: remove unused import

* fix(test): fake localstate `.__tres` on tests

* fix(types): fix nodeOps instances localstate type

* fix: camera aspect

* Update orthographic camera aspect when screen size updates
* Give user a "manual" flag to keep Tres from updating camera

* feat: 503 conditional rendering of primitives (#514)

* feat(nodeOps): switch instance logic for reactive `object` prop

* chore: playground primitives with models

* chore: fix linter

* chore: fix tests and linters, primitive object is now reactive

* chore: refactor instance swaping logic to overwrite set and copy properties

* chore: tests

* chore: remove console.log

* chore: remove unused import watch

* feat: add primitive conditional to patch object prop

* fix: `nodeOps` is now a function (#579)

* fix: `nodeOps` is now a function

* chore(test): updated tests for `nodeOps`

* chore: next package json version

* chore: release v4.0.0-next.1

* fix: refactor nodeOps to return methods at the end of the function (#602)

* fix: refactor nodeOps to return methods at the end of the function

* chore: fix lint

* chore: internal playground organisation (#601)

* chore: new internal playground org and testing pages

* chore: fix lint

* chore: better styling of playground landing page

* chore: lint

* chore: deps update

* chore: internal primitive model test playground

* chore: fix lint

* chore: release v4.0.0-next.2

* chore: misc routes

* fix: do not change pierced props case (#608)

* chore: lint fix

* chore: problem with package version

* chore: fix lint

* chore: rebuild pnpm-lock

* test(nodeOps): clean up tests

* test(nodeOps): organize tests

* test: add coverage plugin

* test: add coverage to package.json script

* test(nodeOps): improve test coverage

* feat: devtools renderer improvements (#614)

* feat: renderer programs when selecting scene on devtools

* feat: renderer.info

* chore: fix lint

* docs: devtools update

* chore: fix lint issues

* feat(events)!: pointerevents manager and state (#529)

* new file:   playground/src/components/Box.vue
	new file:   playground/src/pages/raycaster/Propogation.vue
	  * Started work on interactive Event Propogation playground example
	modified:   src/components/TresCanvas.vue
	  * Import and use `useEventStore`
	  * defineEmits for all expected pointer events so we may emit propogated events off of the canvasa
	modified:   src/composables/index.ts
	new file:   src/composables/useEventStore/index.ts
	  * Started work on an event store. I'm not sure this counts as a store just yet
	  * Wired up majority of pointer events
	  * Added event propogation
	  * Does not require using userData scene props or nodeOps for registering objects to scene
	modified:   src/composables/useRaycaster/index.ts
	  * Added new event listeners to power newly supported pointer events. We now check whole scene/children when calling intersectObjects.
	  * Created new EventHooks for new events
	  * Added `forceUpdate` function that allows for pointer-move events to work without mouth movement (good for when camera is moving but mouse is not)

	modified:   src/core/nodeOps.ts
	  * Added supported events to array so they don't get received as props
	  * (temporarily) unhook current pointer event solution to iterate on useEventStore
	modified:   src/utils/index.ts
	  * Added Camel-to-kebab case util

* Support multiple event listeners, add support for .stop event modifier

* Set stopProgation variable to false by default, whoops

* fix typo

* fix: remove `createGlobalState` from `useEventStore`, allowing events to work while multiple TresCanvas' are being used

* fix(perf): remove extraneous intersectObjects/getIntersects calls by moving intersects into a ref that is updated on pointer-move

* chore(lint): fix lint issues

* feat: enhance events manager to include duplicates checking, pointer-missed support, and forced updating

Per file changelog:
	modified:   playground/src/components/Box.vue
	  * Added a pointer-missed handler for testing
	modified:   playground/src/pages/TheBasic.vue
	  * uses forceUpdate from EventManager to fire events even when the mouse hasn't moved
	modified:   playground/src/pages/raycaster/Propagation.vue
	  * Didn't mean to undo the lint changes, adds a pointer-missed event on the canvas 		for extra testing
	modified:   src/components/TresCanvas.vue
	  * Adds `pointer-missed` as possible event for canvas emits
	modified:   src/composables/index.ts
	  * Update export
	deleted:    src/composables/useEventStore/index.ts
	  * Rename `useEventStore` to `useTresEventManager`
	modified:   src/composables/useRaycaster/index.ts
	  * Check for empty intersects on hit test, wire up pointerMissed events eventHook
	  * Fix forceUpdate to call onPointerMove instead of triggering an EventHook
	modified:   src/composables/useTresContextProvider/index.ts
	  * Add TresEventManager type
	new file:   src/composables/useTresEventManager/index.ts
	  * add onPointerMissed
	  * create (de)registerPointerMissedObj methods so we can track objects in the scene listening to this event
	  * Note: These are passed to nodeOps via TresContext
	  * Implement duplicates checking for eventPropogation
	modified:   src/core/nodeOps.ts
	  * register/deregister pointerMissed objects

* chore: lint

* docs: new event docs

* chore: fix lint

* feat: enhance event object details and use in Box example to change material color. Add ability to force event system updates even when mouse hasn't moved. Enhance pointer-enter/leave events. Update types

  Box.vue
    * Added pointer-missed handler
    * set the materials flash color using the object coming off of the event instead of a ref
  UseRaycaster
    * Flesh out event details to include
      * all mouse event properties
      * intersections
      * tres camera
      * camera raycaster
      * source event
      * mouse position delta
      * stopPropagating stub
      * and unprojectedPoint (this needs work, cant get the math to work)
  UseTresContextProvider
    * Add TresEventManager type to TresContext
  useTresEventManager
    * Add forceUpdate method to allow apps to force an event system update even when the mouse hasnt moved
    * Add pointerMissed event
    * Properly implement pointer-enter/pointer-leave events
      * Before now, pointer-enter | leave were only called on first object in intersection, now we execute the events for all entered/left objects
    * Use stopPropagating property included on event object

* chore: lint

* chore: fix lint issues

---------

Co-authored-by: alvarosabu <alvaro.saburido@gmail.com>

* 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

* chore: fix lint

* chore: type issues (#663)

* fix: fix some internal types

* chore: fix linters

* fix: typescript issues on event manager

* chore: release v4.0.0-rc.0

* fix: make on* callbacks settable (#672)

* fix: make on- callbacks settable

* test: test setting not calling

* 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>

* chore(playground): adding missing import and removing the directives that were deprecated

* chore(playground): use new composable on animations

* fix(utils): reorder object disposal to avoid issue with Helper `dispose` methods (#683)

* chore: updated deps

* chore: release v4.0.0-rc.1

* fix: manual rendering blank (#685)

* fix: increate time to advance on manual mode

* chore: correct playground

* fix: 686 useloop callback state missing controls (#687)

* fix(loop): take plain snapshots of ctx

* fix: types for useloop

* chore: lint

* docs: add RectAreaLightHelper to vLightHelper docs

* chore(deps): update deps 24-0-2024

* chore: release v4.0.0-rc.2

* fix: start loop if user calls useRenderLoop (#695)

* docs: change motivation

* chore(deps): last update before release

---------

Co-authored-by: Peter <petermoldovia@yahoo.ca>
Co-authored-by: Garrett Walker <garbwalk@gmail.com>
Co-authored-by: Tino Koch <17991193+Tinoooo@users.noreply.github.com>
Co-authored-by: Jaime Torrealba <solucionesinformaticasjtc@gmail.com>
Co-authored-by: Jaime A Torrealba C <63722373+JaimeTorrealba@users.noreply.github.com>
Alvaro Saburido 1 year ago
parent
commit
1ba17ee43c
100 changed files with 4569 additions and 1311 deletions
  1. 105 1
      CHANGELOG.md
  2. 2 3
      docs/.vitepress/config/en.ts
  3. 17 0
      docs/.vitepress/theme/components/BlenderCube.vue
  4. 101 0
      docs/.vitepress/theme/components/GraphPane.vue
  5. 37 0
      docs/.vitepress/theme/components/OnDemandRendering.vue
  6. 23 0
      docs/.vitepress/theme/components/RenderingLogger.vue
  7. 11 0
      docs/.vitepress/theme/composables/state.ts
  8. 157 0
      docs/advanced/performance.md
  9. 231 62
      docs/api/composables.md
  10. 52 12
      docs/api/events.md
  11. 3 2
      docs/api/tres-canvas.md
  12. 5 1
      docs/components.d.ts
  13. 40 30
      docs/cookbook/basic-animations.md
  14. 6 0
      docs/debug/devtools.md
  15. 5 1
      docs/directives/v-always-look-at.md
  16. 1 0
      docs/directives/v-light-helper.md
  17. 5 1
      docs/directives/v-rotate.md
  18. 3 20
      docs/guide/index.md
  19. 2 2
      docs/package.json
  20. BIN
      docs/public/devtools-v4.png
  21. 27 5
      docs/public/logo.svg
  22. 31 31
      package.json
  23. 10 31
      playground/components.d.ts
  24. 4 4
      playground/package.json
  25. 27 0
      playground/public/logo.svg
  26. 12 1
      playground/src/App.vue
  27. 17 0
      playground/src/components/AkuAku.vue
  28. 95 0
      playground/src/components/AnimatedObjectUseUpdate.vue
  29. 29 0
      playground/src/components/BlenderCube.vue
  30. 44 0
      playground/src/components/Box.vue
  31. 7 0
      playground/src/components/DirectiveSubComponent.vue
  32. 22 0
      playground/src/components/DynamicModel.vue
  33. 27 0
      playground/src/components/FBOCube.vue
  34. 101 0
      playground/src/components/GraphPane.vue
  35. 0 98
      playground/src/components/MultipleCanvas.vue
  36. 57 0
      playground/src/components/TakeOverLoopExperience.vue
  37. 16 22
      playground/src/components/TheExperience.vue
  38. 15 1
      playground/src/components/TheSphere.vue
  39. 11 0
      playground/src/composables/state.ts
  40. 88 0
      playground/src/composables/useFBO.ts
  41. 0 1
      playground/src/main.ts
  42. 0 7
      playground/src/pages/MultipleCanvas.vue
  43. 31 0
      playground/src/pages/advanced/FBO.vue
  44. 65 0
      playground/src/pages/advanced/Memory.vue
  45. 24 0
      playground/src/pages/advanced/TakeOverLoop.vue
  46. 0 0
      playground/src/pages/advanced/index.vue
  47. 28 0
      playground/src/pages/advanced/manual/experience.vue
  48. 24 0
      playground/src/pages/advanced/manual/index.vue
  49. 36 0
      playground/src/pages/advanced/on-demand/experience.vue
  50. 23 0
      playground/src/pages/advanced/on-demand/index.vue
  51. 0 0
      playground/src/pages/basic/Conditional.vue
  52. 0 0
      playground/src/pages/basic/Groups.vue
  53. 1 3
      playground/src/pages/basic/Lights.vue
  54. 60 0
      playground/src/pages/basic/Multiple.vue
  55. 72 0
      playground/src/pages/basic/OnCallbacks.vue
  56. 67 0
      playground/src/pages/basic/PiercedProps.vue
  57. 138 0
      playground/src/pages/basic/Primitives.vue
  58. 1 1
      playground/src/pages/basic/Responsiveness.vue
  59. 11 0
      playground/src/pages/basic/example.vue
  60. 16 5
      playground/src/pages/basic/index.vue
  61. 11 7
      playground/src/pages/cameras/index.vue
  62. 0 42
      playground/src/pages/click-blocking-box.vue
  63. 1 1
      playground/src/pages/empty.vue
  64. 188 0
      playground/src/pages/events/Propagation.vue
  65. 94 0
      playground/src/pages/events/index.vue
  66. 85 2
      playground/src/pages/index.vue
  67. 25 0
      playground/src/pages/misc/Directives.vue
  68. 9 2
      playground/src/pages/misc/Text3DDemo.vue
  69. 59 0
      playground/src/pages/models/PrimitivesModel.vue
  70. 0 50
      playground/src/pages/perf/AkuAku.vue
  71. 0 77
      playground/src/pages/raycaster/TheEvents.vue
  72. 34 1
      playground/src/router/index.ts
  73. 22 0
      playground/src/router/routes/advanced.ts
  74. 47 0
      playground/src/router/routes/basic.ts
  75. 17 0
      playground/src/router/routes/cameras.ts
  76. 7 0
      playground/src/router/routes/events.ts
  77. 15 0
      playground/src/router/routes/index.ts
  78. 12 0
      playground/src/router/routes/misc.ts
  79. 7 0
      playground/src/router/routes/models.ts
  80. 7 2
      playground/vite.config.ts
  81. 239 281
      pnpm-lock.yaml
  82. 49 15
      src/components/TresCanvas.vue
  83. 3 1
      src/composables/index.ts
  84. 27 9
      src/composables/useCamera/index.ts
  85. 49 0
      src/composables/useLoop/index.ts
  86. 34 37
      src/composables/usePointerEventHandler/index.ts
  87. 140 34
      src/composables/useRaycaster/index.ts
  88. 19 8
      src/composables/useRenderLoop/index.ts
  89. 5 1
      src/composables/useRenderer/const.ts
  90. 46 26
      src/composables/useRenderer/index.ts
  91. 150 33
      src/composables/useTresContextProvider/index.ts
  92. 194 0
      src/composables/useTresEventManager/index.ts
  93. 120 0
      src/core/loop.test.ts
  94. 161 0
      src/core/loop.ts
  95. 460 0
      src/core/nodeOps.test.ts
  96. 159 105
      src/core/nodeOps.ts
  97. 0 184
      src/core/nodeOpts.test.ts
  98. 0 12
      src/core/renderer.ts
  99. 1 1
      src/devtools/highlight.ts
  100. 30 35
      src/devtools/plugin.ts

+ 105 - 1
CHANGELOG.md

@@ -1,5 +1,83 @@
 
 
+## [4.0.0-rc.2](https://github.com/Tresjs/tres/compare/4.0.0-rc.1...4.0.0-rc.2) (2024-05-24)
+
+
+### Bug Fixes
+
+* 686 useloop callback state missing controls ([#687](https://github.com/Tresjs/tres/issues/687)) ([a41f532](https://github.com/Tresjs/tres/commit/a41f532b0c8d717e4bc3ec11fa73bd58df871fa8))
+* manual rendering blank ([#685](https://github.com/Tresjs/tres/issues/685)) ([0720d18](https://github.com/Tresjs/tres/commit/0720d186e92ca9faa9e5f4e51a3269504bed2a09))
+
+## [4.0.0-rc.1](https://github.com/Tresjs/tres/compare/4.0.0-rc.0...4.0.0-rc.1) (2024-05-15)
+
+
+### Features
+
+* 633 use loop  ([#673](https://github.com/Tresjs/tres/issues/673)) ([1b2fa70](https://github.com/Tresjs/tres/commit/1b2fa70e9999eb64395b3e7e9f2489ceab035a7a))
+
+
+### Bug Fixes
+
+* make on* callbacks settable ([#672](https://github.com/Tresjs/tres/issues/672)) ([ac152df](https://github.com/Tresjs/tres/commit/ac152dfa91c6ba347cbe0566fb4afbe19f50dd2b))
+* **utils:** reorder object disposal to avoid issue with Helper `dispose` methods ([#683](https://github.com/Tresjs/tres/issues/683)) ([e5a2cef](https://github.com/Tresjs/tres/commit/e5a2cef0e450196abaa6d18380a5aadbc9cd057d))
+
+## [4.0.0-rc.0](https://github.com/Tresjs/tres/compare/3.9.0...4.0.0-rc.0) (2024-04-25)
+
+
+### ⚠ BREAKING CHANGES
+
+* **events:** pointerevents manager and state (#529)
+
+### Features
+
+* 499 better memory management ([#606](https://github.com/Tresjs/tres/issues/606)) ([e98ca6d](https://github.com/Tresjs/tres/commit/e98ca6dea15973b3a00e4b485199d9906eb772eb))
+* devtools renderer improvements ([#614](https://github.com/Tresjs/tres/issues/614)) ([cdf6b6f](https://github.com/Tresjs/tres/commit/cdf6b6fefbd58dbf1dfbe396f219ac6f7c6fc92d))
+* **events:** pointerevents manager and state ([#529](https://github.com/Tresjs/tres/issues/529)) ([b536ab1](https://github.com/Tresjs/tres/commit/b536ab19d1f4082c2db926e24d8c52f92949964b))
+
+
+### Bug Fixes
+
+* do not change pierced props case ([#608](https://github.com/Tresjs/tres/issues/608)) ([906f2e1](https://github.com/Tresjs/tres/commit/906f2e157aab7aa6daef5682c3282cf6e84fa891))
+
+## [4.0.0-next.2](https://github.com/Tresjs/tres/compare/3.9.0...4.0.0-rc.0) (2024-03-27)
+
+
+### Bug Fixes
+
+* refactor nodeOps to return methods at the end of the function ([#602](https://github.com/Tresjs/tres/issues/602)) ([cd0c3bc](https://github.com/Tresjs/tres/commit/cd0c3bcd891f019cf91f30e5fdd547630332a065))
+
+## [4.0.0-next.1](https://github.com/Tresjs/tres/compare/3.9.0...4.0.0-rc.0) (2024-03-18)
+
+
+### Features
+
+* 140 on demand rendering ([#497](https://github.com/Tresjs/tres/issues/497)) ([f688c64](https://github.com/Tresjs/tres/commit/f688c6447be887c4675a57ecabb5182d8b7d02cf))
+* 492 set tone mapping default to acesfilmictonemapping ([#498](https://github.com/Tresjs/tres/issues/498)) ([c4547f9](https://github.com/Tresjs/tres/commit/c4547f92615a43b7b56b34c0e1ee9f4b78a2230b))
+* 503 conditional rendering of primitives ([#514](https://github.com/Tresjs/tres/issues/514)) ([79d8a76](https://github.com/Tresjs/tres/commit/79d8a762da6b6e23771a20314f7902eff4635acf))
+* 516 localstate for custom renderer node instances instead of userdata ([#522](https://github.com/Tresjs/tres/issues/522)) ([08717ef](https://github.com/Tresjs/tres/commit/08717efd0f631c085340b1fea4eb6c154c63608b))
+* remove default camera warning ([#499](https://github.com/Tresjs/tres/issues/499)) ([8bbafde](https://github.com/Tresjs/tres/commit/8bbafde48a33753f0b6560da36a4d128aaa83cc6))
+* update to three `v160` and vue `v3.4` ([#488](https://github.com/Tresjs/tres/issues/488)) ([5fad3b8](https://github.com/Tresjs/tres/commit/5fad3b8095c09cfe758e2553da3df49b29b1ce1a))
+
+
+### Bug Fixes
+
+* `nodeOps` is now a function ([#579](https://github.com/Tresjs/tres/issues/579)) ([ddc229e](https://github.com/Tresjs/tres/commit/ddc229e6e492b9e7887add0fcc679a9ae4e47f5c))
+* camera aspect ([52dad5c](https://github.com/Tresjs/tres/commit/52dad5c98271f80f4d454bbcce1bb5844960f943))
+* **types:** added `Object3DEventMap` to `Object3D` generics for point event handling ([#491](https://github.com/Tresjs/tres/issues/491)) ([a63eb90](https://github.com/Tresjs/tres/commit/a63eb9099fcaf97b1c96abe5667ee71ca2fd611f))
+
+## [4.0.0-next.0](https://github.com/Tresjs/tres/compare/3.9.0...4.0.0-rc.0) (2023-12-22)
+
+
+### Features
+
+* 474 vue chrome devtools plugin ([#479](https://github.com/Tresjs/tres/issues/479)) ([224ab06](https://github.com/Tresjs/tres/commit/224ab06a4404e2ae5a0cbd2f43041961862b09fd))
+
+## [4.0.0-next.2](https://github.com/Tresjs/tres/compare/4.0.0-next.1...4.0.0-next.2) (2024-03-27)
+
+## [4.0.0-next.1](https://github.com/Tresjs/tres/compare/3.7.0...4.0.0-next.1) (2024-03-18)
+* correct type exporting issues ([#625](https://github.com/Tresjs/tres/issues/625)) ([8e52cf1](https://github.com/Tresjs/tres/commit/8e52cf1935d7b725b87c9a41e44ba61e33bd3e85))
+
+
 ## [3.9.0](https://github.com/Tresjs/tres/compare/3.8.1...3.9.0) (2024-04-24)
 
 
@@ -17,7 +95,8 @@
 
 ### Bug Fixes
 
-* correct type exporting issues ([#625](https://github.com/Tresjs/tres/issues/625)) ([8e52cf1](https://github.com/Tresjs/tres/commit/8e52cf1935d7b725b87c9a41e44ba61e33bd3e85))
+* refactor nodeOps to return methods at the end of the function ([#602](https://github.com/Tresjs/tres/issues/602)) ([cd0c3bc](https://github.com/Tresjs/tres/commit/cd0c3bcd891f019cf91f30e5fdd547630332a065))
+
 
 ## [3.8.0](https://github.com/Tresjs/tres/compare/3.7.0...3.8.0) (2024-04-03)
 
@@ -38,6 +117,31 @@
 
 ### Features
 
+* 140 on demand rendering ([#497](https://github.com/Tresjs/tres/issues/497)) ([f688c64](https://github.com/Tresjs/tres/commit/f688c6447be887c4675a57ecabb5182d8b7d02cf))
+* 492 set tone mapping default to acesfilmictonemapping ([#498](https://github.com/Tresjs/tres/issues/498)) ([c4547f9](https://github.com/Tresjs/tres/commit/c4547f92615a43b7b56b34c0e1ee9f4b78a2230b))
+* 503 conditional rendering of primitives ([#514](https://github.com/Tresjs/tres/issues/514)) ([79d8a76](https://github.com/Tresjs/tres/commit/79d8a762da6b6e23771a20314f7902eff4635acf))
+* 516 localstate for custom renderer node instances instead of userdata ([#522](https://github.com/Tresjs/tres/issues/522)) ([08717ef](https://github.com/Tresjs/tres/commit/08717efd0f631c085340b1fea4eb6c154c63608b))
+* remove default camera warning ([#499](https://github.com/Tresjs/tres/issues/499)) ([8bbafde](https://github.com/Tresjs/tres/commit/8bbafde48a33753f0b6560da36a4d128aaa83cc6))
+* update to three `v160` and vue `v3.4` ([#488](https://github.com/Tresjs/tres/issues/488)) ([5fad3b8](https://github.com/Tresjs/tres/commit/5fad3b8095c09cfe758e2553da3df49b29b1ce1a))
+
+
+### Bug Fixes
+
+* `nodeOps` is now a function ([#579](https://github.com/Tresjs/tres/issues/579)) ([ddc229e](https://github.com/Tresjs/tres/commit/ddc229e6e492b9e7887add0fcc679a9ae4e47f5c))
+* camera aspect ([52dad5c](https://github.com/Tresjs/tres/commit/52dad5c98271f80f4d454bbcce1bb5844960f943))
+* **types:** added `Object3DEventMap` to `Object3D` generics for point event handling ([#491](https://github.com/Tresjs/tres/issues/491)) ([a63eb90](https://github.com/Tresjs/tres/commit/a63eb9099fcaf97b1c96abe5667ee71ca2fd611f))
+
+## [4.0.0-next.0](https://github.com/Tresjs/tres/compare/3.7.0...4.0.0-next.1) (2023-12-22)
+
+
+### Features
+
+* 474 vue chrome devtools plugin ([#479](https://github.com/Tresjs/tres/issues/479)) ([224ab06](https://github.com/Tresjs/tres/commit/224ab06a4404e2ae5a0cbd2f43041961862b09fd))
+
+## [3.7.0](https://github.com/Tresjs/tres/compare/3.6.1...3.7.0) (2024-01-29)
+
+### Features
+  
 * 474 vue chrome devtools plugin ([#526](https://github.com/Tresjs/tres/issues/526)) ([0185bfa](https://github.com/Tresjs/tres/commit/0185bfa6f04faff5eabbc526616713ef7747ebeb))
 * 524 feat add directives to core ([#525](https://github.com/Tresjs/tres/issues/525)) ([5268e9f](https://github.com/Tresjs/tres/commit/5268e9f13bf65c61d5ddfe7153b71b335449b81d))
 

+ 2 - 3
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',
@@ -77,9 +78,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' },
         ],
       },
       {

+ 17 - 0
docs/.vitepress/theme/components/BlenderCube.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import { useTresContext } 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
+
+model.position.set(0, 1, 0)
+
+const state = useTresContext()
+
+state.invalidate()
+</script>
+
+<template>
+  <primitive :object="model" />
+</template>

+ 101 - 0
docs/.vitepress/theme/components/GraphPane.vue

@@ -0,0 +1,101 @@
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { useRafFn } from '@vueuse/core'
+import { useState } from '../composables/state'
+
+const width = 160
+const height = 40
+const strokeWidth = 2
+const updateInterval = 100 // Update interval in milliseconds
+const topOffset = 0 // Offset from the top
+
+const points = ref('')
+const frameTimes = ref([])
+const maxFrames = ref(width / strokeWidth)
+
+let lastUpdateTime = performance.now()
+
+const { renderingTimes } = useState()
+
+useRafFn(({ timestamp }) => {
+  if (timestamp - lastUpdateTime >= updateInterval) {
+    lastUpdateTime = timestamp
+
+    frameTimes.value.push(renderingTimes?.value)
+    renderingTimes.value = 0
+
+    if (frameTimes.value.length > maxFrames.value) {
+      frameTimes.value.shift()
+    }
+
+    points.value = frameTimes.value
+      .map(
+        (value, index) =>
+          `${index * strokeWidth},${
+            height + topOffset - strokeWidth / 2 - (value * (height + topOffset - strokeWidth)) / 2
+          }`,
+      )
+      .join(' ')
+  }
+})
+</script>
+
+<template>
+  <div
+    class="absolute
+      right-2
+      top-2
+      flex
+      px-4
+      py-1
+      justify-between
+      gap-4
+      items-center
+      mb-2
+      z-10
+      bg-white
+      dark:bg-dark
+      shadow-xl
+      rounded
+      border-4
+      border-solid
+      bg-primary
+      border-primary
+      pointer-events-none
+      overflow-hidden"
+  >
+    <label class="text-secondary text-xs w-1/3">Rendering Activity</label>
+
+    <div
+      class="
+        bg-gray-100
+        dark:bg-gray-600
+        relative
+        w-2/3
+        p-1
+        rounded
+        text-right
+        text-xs
+        focus:border-gray-200
+        outline-none
+        border-none
+        font-sans
+      "
+    >
+      <svg
+        :width="width"
+        :height="height"
+        xmlns="http://www.w3.org/2000/svg"
+        fill="none"
+      >
+        <polyline
+          :points="points"
+          stroke="lightgray"
+          :stroke-width="strokeWidth"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+        />
+      </svg>
+    </div>
+  </div>
+</template>

+ 37 - 0
docs/.vitepress/theme/components/OnDemandRendering.vue

@@ -0,0 +1,37 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { useState } from '../composables/state'
+import BlenderCube from './BlenderCube.vue'
+import GraphPane from './GraphPane.vue'
+import RenderingLogger from './RenderingLogger.vue'
+
+const { renderingTimes } = useState()
+
+function onRender() {
+  renderingTimes.value = 1
+}
+</script>
+
+<template>
+  <GraphPane />
+  <TresCanvas
+    render-mode="on-demand"
+    clear-color="#82DBC5"
+    @render="onRender"
+  >
+    <TresPerspectiveCamera
+      :position="[5, 5, 5]"
+      :look-at="[0, 0, 0]"
+    />
+    <Suspense>
+      <BlenderCube />
+    </Suspense>
+    <TresGridHelper />
+    <RenderingLogger />
+    <TresAmbientLight :intensity="1" />
+    <TresDirectionalLight
+      :position="[0, 8, 4]"
+      :intensity="0.7"
+    />
+  </TresCanvas>
+</template>

+ 23 - 0
docs/.vitepress/theme/components/RenderingLogger.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { useTresContext } from '@tresjs/core'
+import { OrbitControls } from '@tresjs/cientos'
+import { onMounted } from 'vue'
+
+/* const { renderingTimes } = useState() */
+
+const state = useTresContext()
+
+function manualInvalidate() {
+  state.invalidate()
+}
+
+onMounted(() => {
+  manualInvalidate()
+})
+</script>
+
+<template>
+  <OrbitControls
+    @change="manualInvalidate"
+  />
+</template>

+ 11 - 0
docs/.vitepress/theme/composables/state.ts

@@ -0,0 +1,11 @@
+import { reactive, toRefs } from 'vue'
+
+const state = reactive({
+  renderingTimes: 0,
+})
+export function useState() {
+  return {
+    ...toRefs(state),
+
+  }
+}

+ 157 - 0
docs/advanced/performance.md

@@ -0,0 +1,157 @@
+# Scaling performance 🚀
+
+> Quick guide with tips to improve performance of your Tres.js application.
+
+We are running WebGL on the browser, which can be quite expensive and it will depend on how powerful the user's device is. To make 3D accessible to everyone, we need to make sure our applications are optimized to run also on low-end devices. This guide will provide some tips to improve the performance of your Tres.js application.
+
+## On-demand rendering <Badge type="tip" text="^4.0.0" />
+
+By default, Tres.js will render your scene on every frame. This is great for most applications, but if you are building a game or a complex application, you might want to control when the scene is rendered.
+
+Otherwise it might drain your device battery 🔋 🔜 🪫 and make your computer sound like an airplane 🛫.
+
+Ideally, you only want to **render the scene when necessary**, for example when the user interacts with the scene and the camera moves, or when objects in the scene are animated.
+
+You can do that by setting the `renderMode` prop to `on-demand` or `manual`:
+
+### Mode `on-demand`
+
+<ClientOnly>
+  <div style="position: relative; aspect-ratio: 16/9; height: auto; margin: 2rem 0; border-radius: 8px; overflow:hidden;">
+    <onDemandRendering />
+  </div>
+</ClientOnly>
+
+```vue
+<TresCanvas render-mode="on-demand">
+  <!-- Your scene goes here -->
+</TresCanvas>
+```
+
+#### Automatic Invalidation
+
+When using `render-mode="on-demand"`, Tres.js will automatically invalidate the current frame by observing component props and lifecycle hooks like `onMounted` and `onUnmounted`. It will also invalidate the frame when resizing the window or changing any prop from the `<TresCanvas>` component like `clearColor` or `antialias`.
+
+The code below updates TresMesh's position-x prop every second, triggering a new render.
+
+```vue
+<script setup>
+import { ref } from 'vue'
+
+const positionX = ref(0)
+
+setTimeout(() => {
+  positionX.value = 1
+}, 1000)
+</script>
+
+<template>
+  <TresCanvas render-mode="on-demand">
+    <TresMesh :position-x="positionX">
+      <TresBoxGeometry />
+      <TresMeshBasicMaterial color="teal" />
+    </TresMesh>
+  </TresCanvas>
+</template>
+```
+
+#### Manual Invalidation
+
+Since it is not really possible to observe all the possible changes in your application, you can also manually invalidate the frame by calling the `invalidate()` method from the [`useTresContext` composable](../api/composables.md#usetrescontext):
+
+::: code-group
+
+```vue [App.vue]
+<script setup>
+import { TresCanvas } from '@tresjs/core'
+import Scene from './Scene.vue'
+</script>
+
+<template>
+  <TresCanvas
+    render-mode="manual"
+  >
+    <Scene />
+  </TresCanvas>
+</template>
+```
+
+```vue [Scene.vue]
+<script setup>
+import { useTres } from '@tresjs/core'
+
+const boxRef = ref()
+const { invalidate } = useTres()
+
+watch(boxRef.value, () => {
+  boxRef.value.position.x = 1
+  invalidate()
+})
+</script>
+
+<template>
+  <TresMesh ref="boxRef">
+    <TresBoxGeometry />
+    <TresMeshBasicMaterial color="teal" />
+  </TresMesh>
+</template>
+```
+
+:::
+
+### Mode `always`
+
+In this rendering mode, Tres will continously render the scene on every frame. This is the default mode and the easiest to use, but it's also the most resource expensive one.
+
+### Mode `manual`
+
+If you want to have full control of when the scene is rendered, you can set the `render-mode` prop to `manual`:
+
+```vue
+<TresCanvas render-mode="manual">
+  <!-- Your scene goes here -->
+</TresCanvas>
+```
+
+In this mode, Tres will not render the scene automatically. You will need to call the `advance()` method from the [`useTresContext` composable](../api/composables.md#usetrescontext) to render the scene:
+
+```vue
+<script setup>
+import { useTres } from '@tresjs/core'
+
+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>
+```

+ 231 - 62
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.0.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
+```
 
-console.log(isActive) // true
+#### Unregistering callbacks
+
+You can unregister a callback by calling the method `off` returned by the `onBeforeRender` or `onAfterRender` method.
+
+```ts
+const { onBeforeRender } = useLoop()
+
+const { off } = onBeforeRender(({ elapsed }) => {
+  sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+})
 ```
 
 ## useLoader
@@ -197,44 +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.0.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 |
+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
+```

+ 52 - 12
docs/api/events.md

@@ -6,22 +6,62 @@
 
 ## Pointer Events
 
+The following pointer events are available on `v3` and previous:
+
+- `click`
+- `pointer-move`
+- `pointer-enter`
+- `pointer-leave`
+
+From `v4.x` on, the following pointer events are been added to the list:
+
+- `context-menu` (right click)
+- `double-click`
+- `pointer-down`
+- `pointer-up`
+- `wheel`
+- `pointer-missed`
+
 ```html
 <TresMesh
-  @click="(intersection, pointerEvent) => console.log('click', intersection, pointerEvent)"
-  @pointer-move="(intersection, pointerEvent) => console.log('pointer-move', intersection, pointerEvent)"
-  @pointer-enter="(intersection, pointerEvent) => console.log('pointer-enter', intersection, pointerEvent)"
-  @pointer-leave="(intersection, pointerEvent) => console.log('pointer-leave', pointerEvent)"
+  @click="(event) => console.log('click')"
+  @context-menu="(event) => console.log('context-menu (right click)')"
+  @double-click="(event) => console.log('double-click')"
+  @pointer-move="(event) => console.log('pointer-move')"
+  @pointer-enter="(event) => console.log('pointer-enter')"
+  @pointer-leave="(event) => console.log('pointer-leave')"
+  @pointer-down="(event) => console.log('pointer-down')"
+  @pointer-up="(event) => console.log('pointer-up')"
+  @wheel="(event) => console.log('wheel')"
+  @pointer-missed="(event) => console.log('pointer-missed')"
 />
 ```
 
-| Event         | fires when ...                                                                        | Event Handler Parameter Type(s)                                                                                                                                                                       |
-| ------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| click        | ... the events pointerdown and pointerup fired on the same object one after the other | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-move  | ... the pointer is moving above the object                                            | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-enter | ... the pointer is entering the object                                                | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-leave | ... the pointer is leaves the object                                                  | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| <div style="width:160px">Event</div>            | fires when ...                                                                       | Event Handler Parameter Type(s)                                                                                                                                                                       |
+| ---------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| click            | the events pointerdown and pointerup fired on the same object one after the other    | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| contextMenu <Badge type="warning" text="4.0.0" />     | the user triggers a context menu, often by right-clicking                            | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| double-click <Badge type="warning" text="4.0.0" />      | the user clicks the mouse button twice in quick succession on the same object        | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| wheel <Badge type="warning" text="4.0.0" />              | the mouse wheel or similar device is rotated                                         | [WheelEvent](https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent)                                                                                                                             |
+| pointer-down <Badge type="warning" text="4.0.0" />       | the pointer is pressed down over the object                                          | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-up <Badge type="warning" text="4.0.0" />        | the pointer is released over the object                                              | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-leave    | the pointer is leaves the object                                                     | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| pointer-move     | the pointer is moving above the object                                               | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-missed <Badge type="warning" text="4.0.0" />    | the pointer interaction is attempted but misses the object                           | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+
+## Event Propagation (Bubbling 🫧) <Badge type="warning" text="^4.0.0" />
+
+Propagation of events on 3D scenes works differently than in the DOM because objects can **occlude each other in 3D**. The `intersections` array contains all the objects that the raycaster intersects with, sorted by distance from the camera. The first object in the array is the closest one to the camera.
 
-The returned [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16) includes the [Object3D](https://threejs.org/docs/index.html?q=object#api/en/core/Object3D) that triggered the event. You can access it via `intersection.object`.
+When an event is triggered, the event is propagated to the closest object in the `intersections` array. If the event is not handled by the object, it will be propagated to the next object in the array.
 
-By default, objects positioned in front of others with event handlers do not prevent those events from being triggered. This behavior can be achieved by using the prop `blocks-pointer-events`.
+`event.stopPropagation()` can be used to stop the event from propagating to the next object in the array, stoping the event from bubbling up and reaching to farther objects (the oens behind the first one).
+
+```html
+<TresMesh
+  @pointer-down="(event) => {
+    console.log('pointer-down')
+    event.stopPropagation()
+  }"
+/>
+```

+ 3 - 2
docs/api/tres-canvas.md

@@ -77,12 +77,13 @@ renderer.shadowMap.type = PCFSoftShadowMap
 | **clearColor** | The color the renderer will use to clear the canvas. | `#000000` |
 | **context** | This can be used to attach the renderer to an existing [RenderingContext](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext) | |
 | **depth** | Whether the drawing buffer has a [depth buffer](https://en.wikipedia.org/wiki/Z-buffering) of at least 16 bits. | `true` |
+| **renderMode** | Render mode, can be `always`, `on-demand` or `manual`. See [Performance](../advanced/performance)  | `always` |
 | **disableRender** | Disable render on requestAnimationFrame, useful for PostProcessing | `false` |
 | **failIfMajorPerformanceCaveat** | Whether the renderer creation will fail upon low performance is detected. See [WebGL spec](https://registry.khronos.org/webgl/specs/latest/1.0/#5.2) for details. | `false` |
 | **logarithmicDepthBuffer** | Whether to use a logarithmic depth buffer. It may be necessary to use this if dealing with huge differences in scale in a single scene. Note that this setting uses gl_FragDepth if available which disables the [Early Fragment Test](https://www.khronos.org/opengl/wiki/Early_Fragment_Test) optimization and can cause a decrease in performance. | `false` |
 | **outputColorSpace** | Defines the output encoding | `LinearEncoding` |
-| **powerPreference** | Provides a hint to the user agent indicating what configuration of GPU is suitable for this WebGL context. Can be "high-performance", "low-power" or "default". | `default` |
-| **precision** | Shader precision. Can be "highp", "mediump" or "lowp". | "highp" if supported by the device |
+| **powerPreference** | Provides a hint to the user agent indicating what configuration of GPU is suitable for this WebGL context. Can be `high-performance`, `low-power` or `default`. | `default` |
+| **precision** | Shader precision. Can be `highp`, `mediump` or `lowp`. | "highp" if supported by the device |
 | **premultipliedAlpha** | Whether the renderer will assume that colors have [premultiplied alpha](https://en.wikipedia.org/wiki/Glossary_of_computer_graphics#premultiplied_alpha). | `true` |
 | **preserveDrawingBuffer** | Whether to preserve the buffers until manually cleared or overwritten.. | `false` |
 | **shadows** | Enable shadows in the renderer | `false` |

+ 5 - 1
docs/components.d.ts

@@ -1,21 +1,25 @@
 /* eslint-disable */
-/* prettier-ignore */
 // @ts-nocheck
 // Generated by unplugin-vue-components
 // Read more: https://github.com/vuejs/core/pull/3399
 export {}
 
+/* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    BlenderCube: typeof import('./.vitepress/theme/components/BlenderCube.vue')['default']
     Cookbook: typeof import('./.vitepress/theme/components/Cookbook.vue')['default']
     DonutExample: typeof import('./.vitepress/theme/components/DonutExample.vue')['default']
     EmbedExperiment: typeof import('./.vitepress/theme/components/EmbedExperiment.vue')['default']
     ExtendExample: typeof import('./.vitepress/theme/components/ExtendExample.vue')['default']
     FirstScene: typeof import('./.vitepress/theme/components/FirstScene.vue')['default']
     FirstSceneLightToon: typeof import('./.vitepress/theme/components/FirstSceneLightToon.vue')['default']
+    GraphPane: typeof import('./.vitepress/theme/components/GraphPane.vue')['default']
     HomeSponsors: typeof import('./.vitepress/theme/components/HomeSponsors.vue')['default']
     LocalOrbitControls: typeof import('./.vitepress/theme/components/LocalOrbitControls.vue')['default']
     LoveVueThreeJS: typeof import('./.vitepress/theme/components/LoveVueThreeJS.vue')['default']
+    OnDemandRendering: typeof import('./.vitepress/theme/components/OnDemandRendering.vue')['default']
+    RenderingLogger: typeof import('./.vitepress/theme/components/RenderingLogger.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SandboxDemo: typeof import('./.vitepress/theme/components/SandboxDemo.vue')['default']

+ 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.

+ 6 - 0
docs/debug/devtools.md

@@ -24,3 +24,9 @@ From <Badge text="^3.7.0" /> we are introducing the TresJS Devtools, a customize
 ![](/devtools-scene-inspector.png)
 
 Enjoy the new Devtools and let us know what you think! 🎉
+
+## Renderer info <Badge text="^4.0.0" />
+
+From `v4` it's possible to see the renderer information in the Devtools when inspecting the root object (Scene). This is useful to know what renderer is being used and its properties including the programs (shaders) and the capabilities of the renderer.
+
+![](/devtools-v4.png)

+ 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.
 

+ 1 - 0
docs/directives/v-light-helper.md

@@ -7,6 +7,7 @@ The following lights are supported:
 - PointLight
 - SpotLight
 - HemisphereLight
+- RectAreaLightHelper
 
 ## Usage
 

+ 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
 

+ 3 - 20
docs/guide/index.md

@@ -87,25 +87,8 @@ We also have a showcase lab of examples made with TresJS. Check it out [here](ht
 
 The React ecosystem has an impressive **custom render** solution called [React-three-fiber](https://docs.pmnd.rs/react-three-fiber) that allows you build your scenes declaratively with re-usable, self-contained components that react to state.
 
-In my search for something similar in the VueJS ecosystem, I found this amazing library called [Lunchbox](https://github.com/breakfast-studio/lunchboxjs), which works with the same concept as R3F, it provides a [custom Vue3 Renderer](https://vuejs.org/api/custom-renderer.html). I'm also contributing to improve this library so it gets as mature and feature-rich as R3F.
+In my search for something similar in the VueJS ecosystem, I found this amazing library called [Lunchbox](https://github.com/breakfast-studio/lunchboxjs), which works with the same concept as R3F, it provides a [custom Vue3 Renderer](https://vuejs.org/api/custom-renderer.html).
 
-The only issue with this is, mixing compilers renderers in Vue 3 is something the Vue community is still working on - see [here](https://github.com/vuejs/vue-loader/pull/1645) for more information.
+But none of them was actively maintained or had the same level of abstraction as R3F.
 
-```ts
-// Example Vite setup
-import { createApp } from 'vue'
-import { createApp as createLunchboxApp } from 'lunchboxjs'
-import App from './App.vue'
-import LunchboxApp from './LunchboxApp.vue'
-
-// html app
-const app = createApp(App)
-app.mount('#app')
-
-// lunchbox app
-const lunchboxApp = createLunchboxApp(LunchboxApp)
-// assuming there's an element with ID `lunchbox` in your HTML app
-lunchboxApp.mount('#lunchbox')
-```
-
-So I was inspired by both libraries to create a Vue custom renderer for ThreeJS. That's **TresJS v2**.
+So I was inspired by both libraries to create a Vue custom renderer for ThreeJS. That's **TresJS**.

+ 2 - 2
docs/package.json

@@ -9,12 +9,12 @@
     "preview": "vitepress preview"
   },
   "dependencies": {
-    "@tresjs/core": "workspace:*"
+    "@tresjs/core": "workspace:^"
   },
   "devDependencies": {
     "@iconify-json/logos": "^1.1.42",
     "@iconify-json/mdi": "^1.1.66",
-    "unocss": "^0.59.4",
+    "unocss": "^0.60.3",
     "vite-svg-loader": "^5.1.0"
   }
 }

BIN
docs/public/devtools-v4.png


+ 27 - 5
docs/public/logo.svg

@@ -1,5 +1,27 @@
-<svg width="44" height="10" viewBox="0 0 44 10" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.14255 1.42916C5.53095 0.781817 6.46913 0.781816 6.85753 1.42915L11.0913 8.4855C11.4913 9.15203 11.0111 10 10.2338 10H1.76623C0.988935 10 0.508822 9.15203 0.908736 8.4855L5.14255 1.42916Z" fill="#82DBC5"/>
-<rect x="19" y="1" width="9" height="9" rx="1" fill="#4F4F4F"/>
-<circle cx="39.5" cy="5.5" r="4.5" fill="#EFAC35"/>
-</svg>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="440"
+   height="102.67172"
+   version="1.1"
+   id="svg154"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs158" />
+  <g
+     id="g442"
+     transform="matrix(7.3336915,0,0,7.3336915,-0.02148824,0)">
+    <path
+       fill="#82dbc5"
+       d="m 6.768,0.75 c 0.6,-1 2.05,-1 2.65,0 l 6.546,10.909 A 1.546,1.546 0 0 1 14.638,14 H 1.548 A 1.546,1.546 0 0 1 0.223,11.659 Z"
+       id="path148" />
+    <path
+       d="M 25.556,0 H 36.444 C 37.306,0 38,0.694 38,1.556 V 12.444 C 38,13.306 37.306,14 36.444,14 H 25.556 A 1.552,1.552 0 0 1 24,12.444 V 1.556 C 24,0.694 24.694,0 25.556,0 Z"
+       style="fill:#4f4f4f;fill-opacity:1"
+       id="path150" />
+    <path
+       d="m 60,7 a 7,7 0 0 1 -7,7 7,7 0 0 1 -7,-7 7,7 0 0 1 7,-7 7,7 0 0 1 7,7 z"
+       style="fill:#efac35"
+       id="path152" />
+  </g>
+</svg>

+ 31 - 31
package.json

@@ -1,7 +1,7 @@
 {
   "name": "@tresjs/core",
   "type": "module",
-  "version": "3.9.0",
+  "version": "4.0.0-rc.2",
   "packageManager": "pnpm@8.15.6",
   "description": "Declarative ThreeJS using Vue Components",
   "author": "Alvaro Saburido <hola@alvarosaburido.dev> (https://github.com/alvarosabu/)",
@@ -50,7 +50,7 @@
     "playground": "cd playground && npm run dev",
     "test": "vitest",
     "test:ci": "vitest run",
-    "test:ui": "vitest --ui",
+    "test:ui": "vitest --ui --coverage.enabled=true",
     "release": "release-it",
     "coverage": "vitest run --coverage",
     "lint": "eslint .",
@@ -64,52 +64,52 @@
   },
   "peerDependencies": {
     "three": ">=0.133",
-    "vue": ">=3.3"
+    "vue": ">=3.4"
   },
   "dependencies": {
-    "@alvarosabu/utils": "^3.1.1",
+    "@alvarosabu/utils": "^3.2.0",
     "@vue/devtools-api": "^6.6.1",
-    "@vueuse/core": "^10.7.0"
+    "@vueuse/core": "^10.10.0"
   },
   "devDependencies": {
     "@release-it/conventional-changelog": "^8.0.1",
-    "@stackblitz/sdk": "^1.9.0",
-    "@tresjs/cientos": "3.8.0",
-    "@tresjs/eslint-config": "^1.0.0",
-    "@tresjs/eslint-config-vue": "^0.2.1",
-    "@types/three": "^0.163.0",
-    "@typescript-eslint/eslint-plugin": "^7.7.1",
-    "@typescript-eslint/parser": "^7.7.1",
+    "@stackblitz/sdk": "^1.10.0",
+    "@tresjs/cientos": "3.9.0",
+    "@tresjs/eslint-config": "^1.1.0",
+    "@types/three": "^0.164.1",
+    "@typescript-eslint/eslint-plugin": "^7.11.0",
+    "@typescript-eslint/parser": "^7.11.0",
     "@vitejs/plugin-vue": "^5.0.4",
     "@vitest/coverage-c8": "^0.33.0",
-    "@vitest/ui": "^1.5.0",
-    "@vue/test-utils": "^2.4.3",
-    "eslint": "^9.1.1",
-    "eslint-plugin-vue": "^9.25.0",
-    "esno": "^4.0.0",
-    "gsap": "^3.12.3",
+    "@vitest/coverage-v8": "^1.6.0",
+    "@vitest/ui": "^1.6.0",
+    "@vue/test-utils": "^2.4.6",
+    "eslint": "^9.3.0",
+    "eslint-plugin-vue": "^9.26.0",
+    "esno": "^4.7.0",
+    "gsap": "^3.12.5",
     "husky": "^9.0.11",
-    "jsdom": "^24.0.0",
+    "jsdom": "^24.1.0",
     "kolorist": "^1.8.0",
     "ohmyfetch": "^0.4.21",
-    "pathe": "^1.1.1",
-    "release-it": "^17.2.0",
+    "pathe": "^1.1.2",
+    "release-it": "^17.3.0",
     "rollup-plugin-analyzer": "^4.0.0",
     "rollup-plugin-copy": "^3.5.0",
-    "rollup-plugin-visualizer": "^5.11.0",
-    "three": "^0.163.0",
-    "unocss": "^0.59.4",
+    "rollup-plugin-visualizer": "^5.12.0",
+    "three": "^0.164.1",
+    "unocss": "^0.60.3",
     "unplugin": "^1.10.1",
-    "unplugin-vue-components": "^0.26.0",
-    "vite": "^5.2.10",
+    "unplugin-vue-components": "^0.27.0",
+    "vite": "^5.2.12",
     "vite-plugin-banner": "^0.7.1",
-    "vite-plugin-dts": "3.9.0",
+    "vite-plugin-dts": "3.9.1",
     "vite-plugin-inspect": "^0.8.4",
     "vite-plugin-require-transform": "^1.0.21",
     "vite-svg-loader": "^5.1.0",
-    "vitepress": "1.1.3",
-    "vitest": "^1.5.0",
-    "vue": "^3.4.24",
-    "vue-demi": "^0.14.6"
+    "vitepress": "1.2.2",
+    "vitest": "^1.6.0",
+    "vue": "^3.4.27",
+    "vue-demi": "^0.14.7"
   }
 }

+ 10 - 31
playground/components.d.ts

@@ -1,49 +1,28 @@
 /* eslint-disable */
-/* prettier-ignore */
 // @ts-nocheck
 // Generated by unplugin-vue-components
 // Read more: https://github.com/vuejs/core/pull/3399
 export {}
 
+/* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
-    AnimatedModel: typeof import('./src/components/AnimatedModel.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']
-    DanielTest: typeof import('./src/components/DanielTest.vue')['default']
-    DebugUI: typeof import('./src/components/DebugUI.vue')['default']
-    DeleteMe: typeof import('./src/components/DeleteMe.vue')['default']
-    FBXModels: typeof import('./src/components/FBXModels.vue')['default']
-    Gltf: typeof import('./src/components/gltf/index.vue')['default']
+    AkuAku: typeof import('./src/components/AkuAku.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']
+    DirectiveSubComponent: typeof import('./src/components/DirectiveSubComponent.vue')['default']
+    DynamicModel: typeof import('./src/components/DynamicModel.vue')['default']
+    FBOCube: typeof import('./src/components/FBOCube.vue')['default']
+    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']
-    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']
+    TakeOverLoopExperience: typeof import('./src/components/TakeOverLoopExperience.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']
   }
 }

+ 4 - 4
playground/package.json

@@ -5,11 +5,11 @@
   "private": true,
   "scripts": {
     "dev": "vite --host",
-    "build": "vue-tsc && vite build",
+    "build": "vite build",
     "preview": "vite preview"
   },
   "dependencies": {
-    "@tresjs/cientos": "3.8.0",
+    "@tresjs/cientos": "3.9.0",
     "@tresjs/core": "workspace:^",
     "vue-router": "^4.3.2"
   },
@@ -19,7 +19,7 @@
     "unplugin-auto-import": "^0.17.2",
     "vite-plugin-glsl": "^1.2.1",
     "vite-plugin-qrcode": "^0.2.3",
-    "vite-plugin-vue-devtools": "7.1.2",
-    "vue-tsc": "^2.0.14"
+    "vite-plugin-vue-devtools": "7.2.1",
+    "vue-tsc": "^2.0.19"
   }
 }

+ 27 - 0
playground/public/logo.svg

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="440"
+   height="102.67172"
+   version="1.1"
+   id="svg154"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs158" />
+  <g
+     id="g442"
+     transform="matrix(7.3336915,0,0,7.3336915,-0.02148824,0)">
+    <path
+       fill="#82dbc5"
+       d="m 6.768,0.75 c 0.6,-1 2.05,-1 2.65,0 l 6.546,10.909 A 1.546,1.546 0 0 1 14.638,14 H 1.548 A 1.546,1.546 0 0 1 0.223,11.659 Z"
+       id="path148" />
+    <path
+       d="M 25.556,0 H 36.444 C 37.306,0 38,0.694 38,1.556 V 12.444 C 38,13.306 37.306,14 36.444,14 H 25.556 A 1.552,1.552 0 0 1 24,12.444 V 1.556 C 24,0.694 24.694,0 25.556,0 Z"
+       style="fill:#4f4f4f;fill-opacity:1"
+       id="path150" />
+    <path
+       d="m 60,7 a 7,7 0 0 1 -7,7 7,7 0 0 1 -7,-7 7,7 0 0 1 7,-7 7,7 0 0 1 7,7 z"
+       style="fill:#efac35"
+       id="path152" />
+  </g>
+</svg>

+ 12 - 1
playground/src/App.vue

@@ -1,6 +1,17 @@
 <script setup lang="ts">
+import { watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+function setBodyClass(routeName: string) {
+  document.title = `Core Playground - ${routeName}`
+  document.body.className = routeName
+}
+watch([route], () => setBodyClass(route.name?.toString() ?? ''))
 </script>
 
 <template>
-  <router-view />
+  <Suspense>
+    <router-view />
+  </Suspense>
 </template>

+ 17 - 0
playground/src/components/AkuAku.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import { useTresContext } from '@tresjs/core'
+import { useGLTF } from '@tresjs/cientos'
+
+const { nodes } = await useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/AkuAku.glb', { draco: true })
+const model = nodes.Cube
+
+model.position.set(0, 1, 0)
+
+const state = useTresContext()
+
+state.invalidate()
+</script>
+
+<template>
+  <primitive :object="model" />
+</template>

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

@@ -0,0 +1,95 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { type LoopCallbackWithCtx, useLoop } from '@tresjs/core'
+import { useControls } from '@tresjs/leches'
+import { useThrottleFn } from '@vueuse/core'
+
+const sphereRef = ref()
+
+const log = useThrottleFn(state => console.log('updating sphere', state), 3000)
+const log2 = useThrottleFn(() => console.log('this should happen before updating the sphere'), 3000)
+
+const { onBeforeRender, pause, resume } = useLoop()
+
+const updateCallback = (state: LoopCallbackWithCtx) => {
+  if (!sphereRef.value) { return }
+  log(state)
+  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>

+ 29 - 0
playground/src/components/BlenderCube.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+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)
+
+useControls({
+  disposeBtn: {
+    label: 'Dispose',
+    type: 'button',
+    onClick: () => {
+      dispose(model)
+    },
+    size: 'sm',
+  },
+})
+
+onUnmounted(() => {
+  dispose(model)
+})
+</script>
+
+<template>
+  <primitive :object="model" />
+</template>

+ 44 - 0
playground/src/components/Box.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref, shallowRef } from 'vue'
+import { useRenderLoop } from '@tresjs/core'
+import { Color } from 'three'
+
+const props = defineProps(['position', 'name'])
+
+// TODO: Once we have troika text in cientos, display the count over each box
+const count = ref(0)
+const boxRef = shallowRef()
+
+// Event Testing Colors
+const black = new Color('black')
+const green = new Color('green')
+
+const blue = new Color('blue')
+
+// Once the box has flashed green, lerp it back to black
+const { onLoop } = useRenderLoop()
+onLoop(() => {
+  boxRef.value?.material.color.lerp(black, 0.1)
+})
+
+// onClick flash the box a color and update the counter
+function handleClick(color: Color, ev) {
+  count.value++
+  ev?.eventObject?.material.color.set(color)
+  // eslint-disable-next-line no-console
+  console.log(`Box ${boxRef.value.name} count=${count.value}`)
+}
+</script>
+
+<template>
+  <TresMesh
+    ref="boxRef"
+    v-bind="props"
+    @click.self="ev => handleClick(green, ev)"
+    @pointer-missed="ev => handleClick(blue, ev)"
+  >
+    <TresBoxGeometry />
+    <TresMeshStandardMaterial />
+    <slot></slot>
+  </TresMesh>
+</template>

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

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

+ 22 - 0
playground/src/components/DynamicModel.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+import { useControls } from '@tresjs/leches'
+import { useGLTF } from '@tresjs/cientos'
+
+const { nodes }
+  = await useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb', { draco: true })
+
+const { scene: AkuAku } = await useGLTF(
+  'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/aku-aku/AkuAku.gltf',
+  { draco: true },
+)
+
+const { isCube } = useControls({
+  isCube: false,
+})
+
+const model = computed(() => isCube.value ? nodes.Cube : AkuAku)
+</script>
+
+<template>
+  <primitive :object="model" />
+</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>

+ 101 - 0
playground/src/components/GraphPane.vue

@@ -0,0 +1,101 @@
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { useRafFn } from '@vueuse/core'
+import { useState } from '../composables/state'
+
+const width = 160
+const height = 40
+const strokeWidth = 2
+const updateInterval = 100 // Update interval in milliseconds
+const topOffset = 0 // Offset from the top
+
+const points = ref('')
+const frameTimes = ref([])
+const maxFrames = ref(width / strokeWidth)
+
+let lastUpdateTime = performance.now()
+
+const { renderingTimes } = useState()
+
+useRafFn(({ timestamp }) => {
+  if (timestamp - lastUpdateTime >= updateInterval) {
+    lastUpdateTime = timestamp
+
+    frameTimes.value.push(renderingTimes?.value)
+    renderingTimes.value = 0
+
+    if (frameTimes.value.length > maxFrames.value) {
+      frameTimes.value.shift()
+    }
+
+    points.value = frameTimes.value
+      .map(
+        (value, index) =>
+          `${index * strokeWidth},${
+            height + topOffset - strokeWidth / 2 - (value * (height + topOffset - strokeWidth)) / 2
+          }`,
+      )
+      .join(' ')
+  }
+})
+</script>
+
+<template>
+  <div
+    class="absolute
+      right-2
+      top-2
+      flex
+      px-4
+      py-1
+      justify-between
+      gap-4
+      items-center
+      mb-2
+      z-10
+      bg-white
+      dark:bg-dark
+      shadow-xl
+      rounded
+      border-4
+      border-solid
+      bg-primary
+      border-primary
+      pointer-events-none
+      overflow-hidden"
+  >
+    <label class="text-secondary text-xs w-1/3">Rendering Activity</label>
+
+    <div
+      class="
+        bg-gray-100
+        dark:bg-gray-600
+        relative
+        w-2/3
+        p-1
+        rounded
+        text-right
+        text-xs
+        focus:border-gray-200
+        outline-none
+        border-none
+        font-sans
+      "
+    >
+      <svg
+        :width="width"
+        :height="height"
+        xmlns="http://www.w3.org/2000/svg"
+        fill="none"
+      >
+        <polyline
+          :points="points"
+          stroke="lightgray"
+          :stroke-width="strokeWidth"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+        />
+      </svg>
+    </div>
+  </div>
+</template>

+ 0 - 98
playground/src/components/MultipleCanvas.vue

@@ -1,98 +0,0 @@
-<script setup lang="ts">
-import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
-import { TresCanvas } from '@tresjs/core'
-
-// import { GLTFModel, OrbitControls } from '@tresjs/cientos'
-const state = reactive({
-  clearColor: '#201919',
-  shadows: true,
-  alpha: false,
-  shadowMapType: BasicShadowMap,
-  outputColorSpace: SRGBColorSpace,
-  toneMapping: NoToneMapping,
-  disableRender: false,
-  stencil: false,
-})
-const state2 = reactive({
-  clearColor: '#4f4f4f',
-  shadows: true,
-  alpha: false,
-  /*  shadowMapType: BasicShadowMap,
-  outputColorSpace: SRGBColorSpace,
-  toneMapping: NoToneMapping, */
-})
-const log = () => {
-  // eslint-disable-next-line no-console
-  console.log(3)
-}
-</script>
-
-<template>
-  <div class="flex">
-    <input
-      id=""
-      v-model="state.clearColor"
-      type="text"
-      name=""
-    />
-    <input
-      v-model="state.stencil"
-      type="checkbox"
-      name=""
-    />
-    <div class="w-1/2 aspect-video">
-      <TresCanvas v-bind="state">
-        <TresPerspectiveCamera
-          :position="[5, 5, 5]"
-          :fov="45"
-          :near="0.1"
-          :far="1000"
-          :look-at="[0, 4, 0]"
-        />
-
-        <TresAmbientLight :intensity="0.5" />
-        <TresMesh
-          :position="[0, 4, 0]"
-          @click="log"
-        >
-          <TresBoxGeometry :args="[1, 1, 1]" />
-          <TresMeshToonMaterial color="cyan" />
-        </TresMesh>
-
-        <Suspense>
-          <TestSphere />
-        </Suspense>
-        <TresDirectionalLight
-          :position="[0, 2, 4]"
-          :intensity="1"
-        />
-      </TresCanvas>
-    </div>
-    <div class="w-1/2 aspect-video">
-      <TresCanvas v-bind="state2">
-        <TresPerspectiveCamera
-          :position="[5, 5, 5]"
-          :fov="45"
-          :near="0.1"
-          :far="1000"
-          :look-at="[0, 4, 0]"
-        />
-        <TresAmbientLight :intensity="0.5" />
-
-        <TresMesh
-          :position="[0, 4, 0]"
-          cast-shadow
-        >
-          <TresSphereGeometry :args="[2, 32, 32]" />
-          <TresMeshToonMaterial color="yellow" />
-        </TresMesh>
-
-        <TresDirectionalLight
-          :position="[0, 2, 4]"
-          :intensity="1"
-          cast-shadow
-        />
-      </TresCanvas>
-    </div>
-  </div>
-</template>

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

@@ -0,0 +1,57 @@
+<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()
+  }
+})
+
+const showGrid = ref(true)
+
+setTimeout(() => {
+  showGrid.value = false
+}, 10000)
+</script>
+
+<template>
+  <TresPerspectiveCamera :position="[3, 3, 3]" />
+  <OrbitControls make-default />
+  <AnimatedObjectUseUpdate />
+  <TresGridHelper v-if="showGrid" />
+  <TresAmbientLight :intensity="1" />
+</template>

+ 16 - 22
playground/src/components/TheExperience.vue

@@ -1,34 +1,26 @@
 <script setup lang="ts">
 import { ref, watchEffect } from 'vue'
-import { BasicShadowMap, NoToneMapping, SRGBColorSpace } 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'
+import '@tresjs/leches/styles'
 
 const gl = {
   clearColor: '#82DBC5',
   shadows: true,
-  alpha: false,
-  shadowMapType: BasicShadowMap,
-  outputColorSpace: SRGBColorSpace,
-  toneMapping: NoToneMapping,
 }
 
 const wireframe = ref(true)
-
-const canvas = ref()
-const meshRef = ref()
-
 const { isVisible } = useControls({
   isVisible: true,
 })
+const canvas = ref()
 
 watchEffect(() => {
-  if (meshRef.value) {
+  if (canvas.value) {
     // eslint-disable-next-line no-console
-    console.log(meshRef.value)
+    console.log(canvas.value.context)
   }
 })
 </script>
@@ -39,23 +31,27 @@ watchEffect(() => {
     v-bind="gl"
     ref="canvas"
     class="awiwi"
-    :style="{ background: '#008080' }"
   >
     <TresPerspectiveCamera
       :position="[7, 7, 7]"
       :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]" />
       <TresMeshToonMaterial color="#82DBC5" />
     </TresMesh>
     <TresMesh
+      v-if="isVisible"
       :position="[0, 4, 0]"
       cast-shadow
     >
@@ -66,22 +62,20 @@ watchEffect(() => {
       />
     </TresMesh>
     <TresMesh
-      ref="meshRef"
-      :rotation="[-Math.PI / 2, 0, Math.PI / 2]"
-      name="floor"
+      :rotation="[-Math.PI / 2, 0, 0]"
       receive-shadow
+      @click="wireframe = !wireframe"
     >
-      <TresPlaneGeometry :args="[20, 20, 20]" />
-      <TresMeshToonMaterial
-        color="#D3FC8A"
-      />
+      <TresPlaneGeometry :args="[10, 10, 10, 10]" />
+      <TresMeshToonMaterial color="#D3FC8A" />
     </TresMesh>
-    <TheSphere v-if="isVisible" />
+    <TheSphere />
     <TresAxesHelper :args="[1]" />
     <TresDirectionalLight
       :position="[0, 2, 4]"
       :intensity="2"
       cast-shadow
     />
+    <TresOrthographicCamera />
   </TresCanvas>
 </template>

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

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

+ 11 - 0
playground/src/composables/state.ts

@@ -0,0 +1,11 @@
+import { reactive, toRefs } from 'vue'
+
+const state = reactive({
+  renderingTimes: 0,
+})
+export function useState() {
+  return {
+    ...toRefs(state),
+
+  }
+}

+ 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
+}

+ 0 - 1
playground/src/main.ts

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

+ 0 - 7
playground/src/pages/MultipleCanvas.vue

@@ -1,7 +0,0 @@
-<script setup lang="ts"></script>
-
-<template>
-  <Suspense>
-    <MultipleCanvas />
-  </Suspense>
-</template>

+ 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>

+ 65 - 0
playground/src/pages/advanced/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>

+ 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


+ 28 - 0
playground/src/pages/advanced/manual/experience.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import { OrbitControls } from '@tresjs/cientos'
+import { useTres } from '@tresjs/core'
+import BlenderCube from '../../../components/BlenderCube.vue'
+
+const { advance } = useTres()
+
+onMounted(() => {
+  advance()
+})
+</script>
+
+<template>
+  <TresPerspectiveCamera
+    :position="[5, 5, 5]"
+    :look-at="[0, 0, 0]"
+  />
+  <Suspense>
+    <BlenderCube />
+  </Suspense>
+  <TresGridHelper />
+  <OrbitControls @change="advance" />
+  <TresAmbientLight :intensity="1" />
+  <TresDirectionalLight
+    :position="[0, 8, 4]"
+    :intensity="0.7"
+  />
+</template>

+ 24 - 0
playground/src/pages/advanced/manual/index.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { useState } from '../../../composables/state'
+
+import GraphPane from '../../../components/GraphPane.vue'
+import ManualExperience from './experience.vue'
+
+const { renderingTimes } = useState()
+
+function onRender() {
+  renderingTimes.value = 1
+}
+</script>
+
+<template>
+  <GraphPane />
+  <TresCanvas
+    render-mode="manual"
+    clear-color="#82DBC5"
+    @render="onRender"
+  >
+    <ManualExperience />
+  </TresCanvas>
+</template>

+ 36 - 0
playground/src/pages/advanced/on-demand/experience.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import { OrbitControls } from '@tresjs/cientos'
+import { useTres } from '@tresjs/core'
+import { ref, watch } from 'vue'
+import BlenderCube from '../../../components/BlenderCube.vue'
+
+const { invalidate } = useTres()
+
+const blenderCubeRef = ref()
+
+watch(blenderCubeRef, (prev, next) => {
+  if (!next) { return }
+  invalidate()
+})
+
+function onControlChange() {
+  invalidate()
+}
+</script>
+
+<template>
+  <TresPerspectiveCamera
+    :position="[5, 5, 5]"
+    :look-at="[0, 0, 0]"
+  />
+  <Suspense>
+    <BlenderCube ref="blenderCubeRef" />
+  </Suspense>
+  <TresGridHelper />
+  <OrbitControls @change="onControlChange" />
+  <TresAmbientLight :intensity="1" />
+  <TresDirectionalLight
+    :position="[0, 8, 4]"
+    :intensity="0.7"
+  />
+</template>

+ 23 - 0
playground/src/pages/advanced/on-demand/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { useState } from '../../../composables/state'
+import GraphPane from '../../../components/GraphPane.vue'
+import OnDemandExperience from './experience.vue'
+
+const { renderingTimes } = useState()
+
+function onRender() {
+  renderingTimes.value = 1
+}
+</script>
+
+<template>
+  <GraphPane />
+  <TresCanvas
+    render-mode="on-demand"
+    clear-color="#82DBC5"
+    @render="onRender"
+  >
+    <OnDemandExperience />
+  </TresCanvas>
+</template>

+ 0 - 0
playground/src/pages/TheConditional.vue → playground/src/pages/basic/Conditional.vue


+ 0 - 0
playground/src/pages/TheGroups.vue → playground/src/pages/basic/Groups.vue


+ 1 - 3
playground/src/pages/lights.vue → playground/src/pages/basic/Lights.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { TresObject } from '@tresjs/core'
-import { TresCanvas, vAlwaysLookAt, vDistanceTo, vLightHelper, vLog, vRotate } from '@tresjs/core'
+import { TresCanvas, vDistanceTo, vLightHelper, vLog } from '@tresjs/core'
 import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
 
 import { OrbitControls } from '@tresjs/cientos'
@@ -24,14 +24,12 @@ const planeRef: Ref<TresObject | null> = ref(null)
   >
     <TresPerspectiveCamera
       v-distance-to="planeRef"
-      v-rotate
       :position="[3, 3, 3]"
     />
     <OrbitControls />
 
     <TresDirectionalLight
       v-light-helper
-      v-always-look-at="[8, 16, 0]"
       :position="[0, 8, 4]"
       :intensity="0.7"
       color="yellow"

+ 60 - 0
playground/src/pages/basic/Multiple.vue

@@ -0,0 +1,60 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { ref, shallowRef } from 'vue'
+
+const boxRef = shallowRef(null)
+const showBox = ref(true)
+
+setInterval(() => {
+  showBox.value = !showBox.value
+}, 3000)
+</script>
+
+<template>
+  <div class="grid grid-cols-2">
+    <div class="aspect-video">
+      <TresCanvas clear-color="#fff">
+        <TresPerspectiveCamera
+          :position="[5, 5, 5]"
+          :look-at="[0, 0, 0]"
+        />
+
+        <TresAmbientLight
+          :intensity="0.5"
+          color="red"
+        />
+        <TresMesh
+          v-if="showBox"
+          ref="boxRef"
+          :position="[0, 2, 0]"
+        >
+          <TresBoxGeometry :args="[1, 1, 1]" />
+          <TresMeshNormalMaterial />
+        </TresMesh>
+        <TresDirectionalLight
+          :position="[0, 2, 4]"
+          :intensity="1"
+          cast-shadow
+        />
+        <TresAxesHelper />
+        <TresGridHelper :args="[10, 10, 0x444444, 'teal']" />
+      </TresCanvas>
+    </div>
+    <div class="aspect-video">
+      <TresCanvas clear-color="#000">
+        <TresPerspectiveCamera
+          :position="[5, 5, 5]"
+          :look-at="[0, 0, 0]"
+        />
+        <TresMesh>
+          <TresSphereGeometry :args="[1, 32, 32]" />
+          <TresMeshNormalMaterial />
+        </TresMesh>
+        <TresAmbientLight
+          :intensity="0.5"
+          color="red"
+        />
+      </TresCanvas>
+    </div>
+  </div>
+</template>

+ 72 - 0
playground/src/pages/basic/OnCallbacks.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { Mesh, MeshBasicMaterial, SphereGeometry } from 'three'
+
+const geo = new SphereGeometry()
+const mat = new MeshBasicMaterial()
+const obj = new Mesh(geo, mat)
+
+const meshNumOnBeforeRenders = shallowRef(0)
+const meshNumOnAfterRenders = shallowRef(0)
+const primitiveNumOnBeforeRenders = shallowRef(0)
+const primitiveNumOnAfterRenders = shallowRef(0)
+const materialNumOnBeforeCompiles = shallowRef(0)
+
+const meshOnBeforeRender = () => { meshNumOnBeforeRenders.value++ }
+const meshOnAfterRender = () => { meshNumOnAfterRenders.value++ }
+const primitiveOnBeforeRender = () => { primitiveNumOnBeforeRenders.value++ }
+const primitiveOnAfterRender = () => { primitiveNumOnAfterRenders.value++ }
+const materialOnBeforeCompile = () => { materialNumOnBeforeCompiles.value++ }
+</script>
+
+<template>
+  <div class="overlay">
+    <h2>Primitive</h2>
+    <ul>
+      <li># onBeforeRender calls: {{ primitiveNumOnBeforeRenders }}</li>
+      <li># onAfterRender calls: {{ primitiveNumOnAfterRenders }}</li>
+    </ul>
+    <h2>Mesh</h2>
+    <ul>
+      <li># onBeforeRender calls: {{ meshNumOnBeforeRenders }}</li>
+      <li># onAfterRender calls: {{ meshNumOnAfterRenders }}</li>
+    </ul>
+    <h2>Material</h2>
+    <ul>
+      <li># onBeforeCompile calls: {{ materialNumOnBeforeCompiles }}</li>
+    </ul>
+  </div>
+  <TresCanvas>
+    <TresMesh
+      :position="[1, 0, 0]"
+      :scale="0.5"
+      :on-before-render="meshOnBeforeRender"
+      :on-after-render="meshOnAfterRender"
+    >
+      <TresBoxGeometry />
+      <TresMeshStandardMaterial
+        :on-before-compile="materialOnBeforeCompile"
+      />
+    </TresMesh>
+    <primitive
+      :object="obj"
+      :position="[-1, 0, 0]"
+      :scale="0.5"
+      :on-before-render="primitiveOnBeforeRender"
+      :on-after-render="primitiveOnAfterRender"
+    />
+    <TresGridHelper :args="[10, 10, 0x444444, 'teal']" />
+  </TresCanvas>
+</template>
+
+<style scoped>
+.overlay {
+  position: fixed;
+  z-index: 1;
+  font-family: sans-serif;
+  background-color: #fff;
+  border-radius: 10px;
+  padding: 10px;
+  margin: 10px;
+}
+</style>

+ 67 - 0
playground/src/pages/basic/PiercedProps.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { shallowRef } from 'vue'
+import { TresCanvas, useRenderLoop } from '@tresjs/core'
+
+const x = shallowRef(1)
+const y = shallowRef(1)
+const z = shallowRef(1)
+const rx = shallowRef(1)
+const ry = shallowRef(1)
+const rz = shallowRef(1)
+const sx = shallowRef(1)
+const sy = shallowRef(1)
+const sz = shallowRef(1)
+const label = shallowRef('')
+
+const refs = [x, y, z, rx, ry, rz, sx, sy, sz]
+const labels = [
+  'position-x',
+  'position-y',
+  'position-z',
+  'rotation-x',
+  'rotation-y',
+  'rotation-z',
+  'scale-x',
+  'scale-y',
+  'scale-z',
+]
+
+/* const PI2 = Math.PI * 2 */
+
+useRenderLoop().onLoop(({ elapsed }) => {
+  const i = Math.floor(elapsed % refs.length)
+  refs[i].value = Math.cos(elapsed * Math.PI * 2)
+  label.value = `${labels[i]} ${Math.trunc(refs[i].value * 10) / 10}`
+})
+</script>
+
+<template>
+  <div class="overlay">
+    <p>Demonstrate pierced props</p>
+    {{ label }}
+  </div>
+  <TresCanvas>
+    <TresMesh
+      :position-x="x"
+      :position-y="y"
+      :position-z="z"
+      :rotation-x="rx"
+      :rotation-y="ry"
+      :rotation-z="rz"
+      :scale-x="sx"
+      :scale-y="sy"
+      :scale-z="sz"
+    >
+      <TresBoxGeometry />
+      <TresMeshNormalMaterial />
+    </TresMesh>
+  </TresCanvas>
+</template>
+
+<style>
+.overlay {
+  position: fixed;
+  padding: 10px;
+  font-family: sans-serif;
+}
+</style>

+ 138 - 0
playground/src/pages/basic/Primitives.vue

@@ -0,0 +1,138 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { ref, watchEffect } from 'vue'
+import {
+  BasicShadowMap,
+  Group,
+  Mesh,
+  MeshToonMaterial,
+  NoToneMapping,
+  SRGBColorSpace,
+  SphereGeometry,
+  TorusGeometry,
+  TorusKnotGeometry,
+} from 'three'
+import { TresCanvas, useRenderLoop } from '@tresjs/core'
+import { OrbitControls } from '@tresjs/cientos'
+import { TresLeches, useControls } from '@tresjs/leches'
+import '@tresjs/leches/styles'
+
+const gl = {
+  clearColor: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+const canvas = ref()
+const meshRef = ref()
+
+const { knot } = useControls({
+  knot: true,
+})
+
+const { isVisible } = useControls({
+  isVisible: true,
+})
+
+watchEffect(() => {
+  if (meshRef.value) {
+    console.log(meshRef.value)
+  }
+})
+
+const torus = new Mesh(
+  new TorusGeometry(1, 0.5, 16, 100),
+  new MeshToonMaterial({
+    color: '#82DBC5',
+  }),
+)
+
+const torusKnot = new Mesh(
+  new TorusKnotGeometry(1, 0.5, 100, 16),
+  new MeshToonMaterial({
+    color: '#ff00ff',
+  }),
+)
+
+const sphere = new Mesh(
+  new SphereGeometry(1, 32, 32),
+  new MeshToonMaterial({
+    color: '#82DBC5',
+  }),
+)
+
+sphere.position.set(2, -2, 0)
+
+const firstGroup = new Group()
+firstGroup.add(torus)
+firstGroup.add(torusKnot)
+
+const secondGroup = new Group()
+secondGroup.add(sphere)
+
+const primitiveRef = ref()
+
+useRenderLoop().onLoop(() => {
+  if (primitiveRef.value) {
+    // This doesn't work
+    /* torusKnot.rotation.x += 0.01 */
+    // This does
+    primitiveRef.value.rotation.x += 0.01
+    primitiveRef.value.rotation.y += 0.01
+  }
+})
+
+watchEffect(() => {
+  console.log('primitiveRef.value', primitiveRef.value)
+})
+
+/* const reactivePrimitiveRef = ref(new Mesh(
+  new TorusKnotGeometry(1, 0.5, 100, 16),
+  new MeshToonMaterial({
+    color: 'orange',
+  }),
+))
+
+const modelArray = ref([torus, torusKnot, sphere]) */
+</script>
+
+<template>
+  <TresLeches />
+  <TresCanvas
+    v-bind="gl"
+    ref="canvas"
+    window-size
+    class="awiwi"
+    :style="{ background: '#008080' }"
+  >
+    <TresPerspectiveCamera
+      :position="[7, 7, 7]"
+    />
+    <OrbitControls />
+    <!--  <primitive
+      :object="reactivePrimitiveRef"
+    /> -->
+    <!--    <primitive
+      v-for="(model, index) of modelArray"
+      :key="index"
+      :object="model"
+      :position="[index * 2, index * 2, 0]"
+    /> -->
+    <primitive
+      v-if="isVisible"
+      ref="primitiveRef"
+      :object="knot ? torusKnot : torus"
+    />
+    <!--    <Suspense>
+      <DynamicModel />
+    </Suspense> -->
+    <TresAxesHelper :args="[1]" />
+    <TresDirectionalLight
+      :position="[0, 2, 4]"
+      :intensity="2"
+      cast-shadow
+    />
+  </TresCanvas>
+</template>

+ 1 - 1
playground/src/pages/Responsiveness.vue → playground/src/pages/basic/Responsiveness.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import TheBasic from './TheBasic.vue'
+import TheBasic from '../basic/index.vue'
 </script>
 
 <template>

+ 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>

+ 16 - 5
playground/src/pages/TheBasic.vue → playground/src/pages/basic/index.vue

@@ -14,6 +14,7 @@ const state = reactive({
   toneMapping: NoToneMapping,
 })
 
+const canvasRef = ref()
 const sphereRef = ref()
 
 const { onLoop } = useRenderLoop()
@@ -21,6 +22,9 @@ const { onLoop } = useRenderLoop()
 onLoop(({ elapsed }) => {
   if (!sphereRef.value) { return }
   sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+
+  // Update events without needing the mouse to move
+  canvasRef.value?.context?.eventManager.forceUpdate()
 })
 
 function onPointerEnter(ev) {
@@ -29,6 +33,10 @@ function onPointerEnter(ev) {
   }
 }
 
+function onPointerOut(ev) {
+  ev.object.material.color.set('teal')
+}
+
 const sphereExists = ref(true)
 </script>
 
@@ -37,9 +45,12 @@ const sphereExists = ref(true)
     v-model="sphereExists"
     type="checkbox"
   />
-  <TresCanvas v-bind="state">
+  <TresCanvas
+    ref="canvasRef"
+    v-bind="state"
+  >
     <TresPerspectiveCamera
-      :position="[5, 5, 5]"
+      :position="[11, 11, 11]"
       :fov="45"
       :near="0.1"
       :far="1000"
@@ -56,9 +67,10 @@ const sphereExists = ref(true)
         :position="[0, 4, 0]"
         cast-shadow
         @pointer-enter="onPointerEnter"
+        @pointer-out="onPointerOut"
       >
         <TresSphereGeometry :args="[2, 32, 32]" />
-        <TresMeshToonMaterial color="teal" />
+        <TresMeshBasicMaterial color="teal" />
       </TresMesh>
     </TresGroup>
 
@@ -72,9 +84,8 @@ const sphereExists = ref(true)
       receive-shadow
     >
       <TresPlaneGeometry :args="[10, 10, 10, 10]" />
-      <TresMeshToonMaterial />
+      <TresMeshBasicMaterial />
     </TresMesh>
-
     <TresDirectionalLight
       :position="[0, 2, 4]"
       :intensity="1"

+ 11 - 7
playground/src/pages/cameras/Cameras.vue → playground/src/pages/cameras/index.vue

@@ -13,16 +13,17 @@ const gl = {
   outputColorSpace: SRGBColorSpace,
   toneMapping: NoToneMapping,
 }
+type Cam = (PerspectiveCamera | OrthographicCamera) & { manual?: boolean }
 
 const state = reactive({
   cameraType: 'perspective',
-  camera: new PerspectiveCamera(75, 1, 0.1, 1000),
+  camera: new PerspectiveCamera(75, 1, 0.1, 1000) as Cam,
 })
 
 state.camera.position.set(5, 5, 5)
 state.camera.lookAt(0, 0, 0)
 
-const { value: cameraType } = useControls({
+const { cameraType, manual } = useControls({
   cameraType: {
     label: 'CameraType',
     options: [{
@@ -34,16 +35,19 @@ const { value: cameraType } = useControls({
     }],
     value: state.cameraType,
   },
+  manual: false,
 })
 
-watch(cameraType, ({ value }) => {
-  state.cameraType = value
-  if (value === 'perspective') {
+watch(() => [cameraType.value.value, manual.value.value], () => {
+  state.cameraType = cameraType.value.value
+  if (cameraType.value.value === 'perspective') {
     state.camera = new PerspectiveCamera(75, 1, 0.1, 1000)
   }
-  else {
-    state.camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 1000)
+  else if (cameraType.value.value === 'orthographic') {
+    state.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1000)
+    state.camera.zoom = 20
   }
+  state.camera.manual = manual.value.value
   state.camera.position.set(5, 5, 5)
   state.camera.lookAt(0, 0, 0)
 })

+ 0 - 42
playground/src/pages/click-blocking-box.vue

@@ -1,42 +0,0 @@
-<script lang="ts" setup>
-import { TresCanvas } from '@tresjs/core'
-import { OrbitControls } from '@tresjs/cientos'
-
-const log = (text: string) => {
-  // eslint-disable-next-line no-console
-  console.log(text)
-}
-
-const boxOneBlocksPointerEvents = ref(false)
-</script>
-
-<template>
-  <div>
-    <TresCanvas window-size>
-      <TresPerspectiveCamera :look-at="[0, 4, 0]" />
-      <TresMesh
-        :position="[0, 1, 0]"
-        :blocks-pointer-events="boxOneBlocksPointerEvents"
-      >
-        <TresBoxGeometry :args="[1, 1, 1]" />
-        <TresMeshNormalMaterial />
-      </TresMesh>
-      <TresMesh
-        :position="[-2, 0, -2]"
-        name="box 2"
-        @click="log('box 2')"
-      >
-        <TresBoxGeometry :args="[1, 1, 1]" />
-        <TresMeshNormalMaterial />
-      </TresMesh>
-      <OrbitControls />
-      <TresGridHelper />
-      <TresAmbientLight :intensity="1" />
-    </TresCanvas>
-    <input
-      v-model="boxOneBlocksPointerEvents"
-      type="checkbox"
-      style="position: fixed; top:10px; left: 10px;"
-    />
-  </div>
-</template>

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

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

+ 188 - 0
playground/src/pages/events/Propagation.vue

@@ -0,0 +1,188 @@
+<script setup lang="ts">
+import { onUnmounted, ref } from 'vue'
+import {
+  TresCanvas,
+} from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+import { OrbitControls } from '@tresjs/cientos'
+import '@tresjs/leches/styles'
+import Box from '../../components/Box.vue'
+
+const gl = {
+  clearColor: '#202020',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+
+const showBox = ref(true)
+
+const intervalRef = setInterval(() => {
+  // showBox.value = !showBox.value;
+}, 1000)
+
+onUnmounted(() => {
+  clearInterval(intervalRef)
+})
+</script>
+
+<template>
+  <TresCanvas
+    window-size
+    v-bind="gl"
+    @pointer-missed="event => console.log('pointer-missed', event)"
+  >
+    <TresPerspectiveCamera
+      :position="[0, 0, 6]"
+      :look-at="[0, 0, 0]"
+    />
+    <OrbitControls />
+
+    <TresDirectionalLight
+      :intensity="1"
+      :position="[1, 1, 1]"
+    />
+    <TresAmbientLight :intensity="1" />
+    <Box
+      :position="[0, 1.5, 0]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            v-if="showBox"
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+    <Box
+      :position="[0, 1.5, -3]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+    <Box
+      :position="[0, 1.5, -6]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+  </TresCanvas>
+</template>

+ 94 - 0
playground/src/pages/events/index.vue

@@ -0,0 +1,94 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import type { ThreeEvent } from '@tresjs/core'
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+import { TresLeches, useControls } from '@tresjs/leches'
+import { OrbitControls } from '@tresjs/cientos'
+import '@tresjs/leches/styles'
+
+const gl = {
+  clearColor: '#202020',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+
+const { stopPropagation } = useControls({
+  stopPropagation: false,
+})
+
+function onClick(ev: ThreeEvent<MouseEvent>) {
+  console.log('click', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#008080')
+}
+
+function onDoubleClick(ev: ThreeEvent<MouseEvent>) {
+  console.log('double-click', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#FFD700')
+}
+
+function onPointerEnter(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#CCFF03')
+}
+
+function onPointerLeave(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
+  /*  ev.object.material.color.set('#efefef') */
+}
+
+function onPointerMove(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
+}
+
+function onContextMenu(ev: ThreeEvent<MouseEvent>) {
+  console.log('context-menu', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#FF4500')
+}
+
+function onPointerMissed(ev: ThreeEvent<MouseEvent>) {
+  console.log('pointer-missed', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+}
+</script>
+
+<template>
+  <TresLeches />
+  <TresCanvas
+    window-size
+    v-bind="gl"
+  >
+    <TresPerspectiveCamera
+      :position="[11, 11, 11]"
+      :look-at="[0, 0, 0]"
+    />
+    <OrbitControls />
+    <template v-for="x in [-2.5, 0, 2.5]">
+      <template v-for="y in [-2.5, 0, 2.5]">
+        <TresMesh
+          v-for="z in [-2.5, 0, 2.5]"
+          :key="`${[x, y, z]}`"
+          :position="[x, y, z]"
+          @click="onClick"
+          @double-click="onDoubleClick"
+          @pointer-enter="onPointerEnter"
+          @pointer-leave="onPointerLeave"
+          @pointer-move="onPointerMove"
+          @context-menu="onContextMenu"
+          @pointer-missed="onPointerMissed"
+        >
+          <TresBoxGeometry :args="[1, 1, 1]" />
+          <TresMeshToonMaterial color="#efefef" />
+        </TresMesh>
+      </template>
+    </template>
+    <TresDirectionalLight :intensity="1" />
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

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

@@ -1,5 +1,88 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import {
+  advancedRoutes,
+  basicRoutes,
+  cameraRoutes,
+  eventsRoutes,
+  miscRoutes,
+  modelsRoutes,
+} from '../router/routes'
+
+const sections = [
+  { icon: '📦', title: 'Basic', routes: basicRoutes },
+  { icon: '🤓', title: 'Advanced', routes: advancedRoutes },
+  { icon: '📣', title: 'Events', routes: eventsRoutes },
+  { icon: '📷', title: 'Camera', routes: cameraRoutes },
+  { icon: '🐇', title: 'Models', routes: modelsRoutes },
+  { icon: '🤪', title: 'Misc', routes: miscRoutes },
+]
+</script>
 
 <template>
-  <TheExperience />
+  <div
+    class="
+  container mx-auto max-w-3xl
+  font-sans text-xs color-gray
+  bg-white
+  "
+  >
+    <div class="mx-4">
+      <div
+        class="
+    mt-24 mb-12 text-center align-baseline items-center gap-6
+    sm:mt-16 sm:mb-6 sm:text-left sm:flex sm:flex-row-reverse sm:justify-left
+    "
+      >
+        <div>
+          <img
+            src="/logo.svg"
+            alt="TresJS logo"
+            class="max-w-24 sm:max-w-48 align-baseline"
+          />
+        </div>
+        <div class="sm:w-2/3">
+          <h1
+            class="
+        w-auto max-w-75 mx-auto text-5xl text-zinc-700 mb-3
+        sm:mx-none sm:w-1/2 sm:max-w-72
+        "
+          >
+            <span class="text-tres-primary">TresJS</span> Playground
+          </h1>
+          <p class="text-lg">
+            Testing zone for TresJS/core components
+          </p>
+        </div>
+      </div>
+      <div class="text-center sm:text-left sm:grid sm:grid-cols-2 md:grid-cols-3 gap-4">
+        <div
+          v-for="{ title, routes, icon } in sections"
+          :key="title"
+          class="
+          p-4 my-4 leading-normal size-m weight-600 bg-zinc-50 rounded
+          sm:my-0
+          "
+        >
+          <div class="inline-block p-2 p-x-3 m-b-3 text-2xl bg-zinc-200 rounded">
+            {{ icon }}
+          </div>
+          <h2 class="text-sm p-0 m-0 mb-1.5 font-semibold text-zinc-600">
+            {{ title }}
+          </h2>
+          <div
+            v-for="route in routes"
+            :key="route.name"
+            class="link-wrapper"
+          >
+            <router-link
+              class="no-underline text-zinc-700 visited:text-zinc-400 hover:text-cientos-blue"
+              :to="route.path"
+            >
+              <span>{{ route.name }} </span>
+            </router-link>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
 </template>

+ 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>

+ 9 - 2
playground/src/pages/misc/Text3DDemo.vue

@@ -2,7 +2,9 @@
 import { TresCanvas } from '@tresjs/core'
 import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
 import { OrbitControls } from '@tresjs/cientos'
-import Text3D from '../../components/Text3D.vue'
+import { TresLeches, useControls } from '@tresjs/leches'
+
+import '@tresjs/leches/styles'
 
 const gl = {
   clearColor: '#82DBC5',
@@ -12,14 +14,19 @@ const gl = {
   outputColorSpace: SRGBColorSpace,
   toneMapping: NoToneMapping,
 }
+
+const { isVisible } = useControls({
+  isVisible: true,
+})
 </script>
 
 <template>
+  <TresLeches />
   <TresCanvas v-bind="gl">
     <TresPerspectiveCamera :position="[3, 3, 3]" />
     <OrbitControls />
     <Suspense>
-      <Text3D />
+      <Text3D v-if="isVisible" />
     </Suspense>
     <TresAmbientLight :intensity="1" />
   </TresCanvas>

+ 59 - 0
playground/src/pages/models/PrimitivesModel.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import {
+  BasicShadowMap,
+  NoToneMapping,
+  SRGBColorSpace,
+} from 'three'
+import { TresCanvas } from '@tresjs/core'
+import { OrbitControls } from '@tresjs/cientos'
+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')
+
+const modelsPositions = ref([
+  {
+    position: [0, 2, 2],
+  },
+  {
+    position: [0, 3, 5],
+  },
+  {
+    position: [0, 1, 1],
+  },
+])
+</script>
+
+<template>
+  <TresLeches />
+  <TresCanvas
+    v-bind="gl"
+    window-size
+    class="awiwi"
+    :style="{ background: '#008080' }"
+  >
+    <TresPerspectiveCamera
+      :position="[7, 7, 7]"
+    />
+    <OrbitControls />
+
+    <Suspense>
+      <DynamicModel v-for="model in modelsPositions" :key="model" :position="model.position" />
+    </Suspense>
+    <TresAxesHelper :args="[1]" />
+    <TresDirectionalLight
+      :position="[0, 2, 4]"
+      :intensity="2"
+      cast-shadow
+    />
+  </TresCanvas>
+</template>

+ 0 - 50
playground/src/pages/perf/AkuAku.vue

@@ -1,50 +0,0 @@
-<!-- eslint-disable no-console -->
-<script setup lang="ts">
-import { useGLTF } from '@tresjs/cientos'
-import { useControls } from '@tresjs/leches'
-
-const { nodes } = await useGLTF(
-  'https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/aku-aku/AkuAku.gltf',
-  { draco: true },
-)
-
-const model = nodes.AkuAku
-
-useControls({
-  button: {
-    label: 'Manual dispose',
-    type: 'button',
-    onClick() {
-      disposeModel()
-    },
-  },
-})
-
-function disposeModel() {
-  console.log('disposingModel')
-  model.traverse((child) => {
-    if (child.isMesh) {
-      // Dispose of the material
-      if (child.material) {
-        child.material.dispose()
-      }
-
-      // Dispose of the geometry
-      if (child.geometry) {
-        child.geometry.dispose()
-      }
-    }
-  })
-  console.log('disposingModel Finished')
-}
-
-model.traverse((child) => {
-  if (child.material) {
-    console.log('child.material', child.material.uuid)
-  }
-})
-</script>
-
-<template>
-  <primitive :object="model" />
-</template>

+ 0 - 77
playground/src/pages/raycaster/TheEvents.vue

@@ -1,77 +0,0 @@
-<script setup lang="ts">
-import { TresCanvas } from '@tresjs/core'
-import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
-
-import { OrbitControls } from '@tresjs/cientos'
-
-const gl = {
-  clearColor: '#202020',
-  shadows: true,
-  alpha: false,
-  shadowMapType: BasicShadowMap,
-  outputColorSpace: SRGBColorSpace,
-  toneMapping: NoToneMapping,
-}
-
-function onClick(ev) {
-  if (ev) {
-    ev.object.material.color.set('#008080')
-  }
-}
-
-function onPointerEnter(ev) {
-  if (ev) {
-    ev.object.material.color.set('#CCFF03')
-  }
-}
-
-function onPointerLeave(ev) {
-  if (ev) {
-    /*  ev.object.material.color.set('#efefef') */
-  }
-}
-
-function onPointerMove(ev) {
-  if (ev) {
-    // eslint-disable-next-line no-console
-    console.log(ev)
-  }
-}
-
-const visible = ref(true)
-</script>
-
-<template>
-  <button @click="visible = !visible"></button>
-  <div v-if="visible">
-    <TresCanvas
-      window-size
-      v-bind="gl"
-    >
-      <TresPerspectiveCamera
-        :position="[11, 11, 11]"
-        :look-at="[0, 0, 0]"
-      />
-      <OrbitControls />
-
-      <template v-for="x in [-2.5, 0, 2.5]">
-        <template v-for="y in [-2.5, 0, 2.5]">
-          <TresMesh
-            v-for="z in [-2.5, 0, 2.5]"
-            :key="`${[x, y, z]}`"
-            :position="[x, y, z]"
-            @click="onClick"
-            @pointer-enter="onPointerEnter"
-            @pointer-leave="onPointerLeave"
-            @pointer-move="onPointerMove"
-          >
-            <TresBoxGeometry :args="[1, 1, 1]" />
-            <TresMeshToonMaterial color="#efefef" />
-          </TresMesh>
-        </template>
-      </template>
-      <TresDirectionalLight :intensity="1" />
-      <TresAmbientLight :intensity="1" />
-    </TresCanvas>
-  </div>
-</template>

+ 34 - 1
playground/src/router.ts → playground/src/router/index.ts

@@ -1,7 +1,20 @@
 import { createRouter, createWebHistory } from 'vue-router'
+import { basicRoutes } from './routes/basic'
+import { advancedRoutes, cameraRoutes, eventsRoutes, miscRoutes, modelsRoutes } from './routes'
 
 const routes = [
   {
+    path: '/',
+    name: 'Home',
+    component: () => import('../pages/index.vue'),
+  },
+  ...basicRoutes,
+  ...advancedRoutes,
+  ...eventsRoutes,
+  ...cameraRoutes,
+  ...modelsRoutes,
+  ...miscRoutes,
+/*   {
     path: '/',
     name: 'Home',
     component: () => import('./pages/index.vue'),
@@ -56,6 +69,11 @@ const routes = [
     name: 'Raycaster',
     component: () => import('./pages/raycaster/TheEvents.vue'),
   },
+  {
+    path: '/raycaster/propagation',
+    name: 'Event Propogation',
+    component: () => import('./pages/raycaster/Propagation.vue'),
+  },
   {
     path: '/misc/text-3d',
     name: 'Text3D',
@@ -81,12 +99,27 @@ const routes = [
     name: 'Perf',
     component: () => import('./pages/perf/index.vue'),
   },
+  {
+    path: '/primitives',
+    name: 'Primitives',
+    component: () => import('./pages/primitives.vue'),
+  },
+  {
+    path: '/rendering-modes',
+    name: 'Rendering Modes',
+    component: () => import('./pages/rendering-modes/index.vue'),
+  },
+  {
+    path: '/on-demand-rendering',
+    name: 'On Demand Rendering',
+    component: () => import('./pages/on-demand-rendering.vue'),
+  },
   {
     path: '/empty',
     name: 'empty',
     component: () => import('./pages/empty.vue'),
   },
-
+   */
 ]
 export const router = createRouter({
   history: createWebHistory(),

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

@@ -0,0 +1,22 @@
+export const advancedRoutes = [
+  {
+    path: '/advanced/on-demand',
+    name: 'On Demand',
+    component: () => import('../../pages/advanced/on-demand/index.vue'),
+  },
+  {
+    path: '/advanced/manual',
+    name: 'Manual rendering',
+    component: () => import('../../pages/advanced/manual/index.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'),
+  },
+]

+ 47 - 0
playground/src/router/routes/basic.ts

@@ -0,0 +1,47 @@
+export const basicRoutes = [
+  {
+    path: '/basic',
+    name: 'Basic',
+    component: () => import('../../pages/basic/index.vue'),
+  },
+  {
+    path: '/basic/lights',
+    name: 'Lights',
+    component: () => import('../../pages/basic/Lights.vue'),
+  },
+  {
+    path: '/basic/groups',
+    name: 'Groups',
+    component: () => import('../../pages/basic/Groups.vue'),
+  },
+  {
+    path: '/basic/conditional',
+    name: 'Conditional',
+    component: () => import('../../pages/basic/Conditional.vue'),
+  },
+  {
+    path: '/Primitives',
+    name: 'Primitives',
+    component: () => import('../../pages/basic/Primitives.vue'),
+  },
+  {
+    path: '/basic/multiple',
+    name: 'Multiple',
+    component: () => import('../../pages/basic/Multiple.vue'),
+  },
+  {
+    path: '/basic/responsive',
+    name: 'Responsiveness',
+    component: () => import('../../pages/basic/Responsiveness.vue'),
+  },
+  {
+    path: '/basic/onCallbacks',
+    name: 'on... callbacks',
+    component: () => import('../../pages/basic/OnCallbacks.vue'),
+  },
+  {
+    path: '/basic/pierced-props',
+    name: 'Pierced Props',
+    component: () => import('../../pages/basic/PiercedProps.vue'),
+  },
+]

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

@@ -0,0 +1,17 @@
+export const cameraRoutes = [
+  {
+    path: '/cameras',
+    name: 'Cameras',
+    component: () => import('../../pages/cameras/index.vue'),
+  },
+  {
+    path: '/cameras/no-camera',
+    name: 'No Camera',
+    component: () => import('../../pages/cameras/index.vue'),
+  },
+  {
+    path: '/cameras/multiple-cameras',
+    name: 'Multiple Cameras',
+    component: () => import('../../pages/cameras/MultipleCameras.vue'),
+  },
+]

+ 7 - 0
playground/src/router/routes/events.ts

@@ -0,0 +1,7 @@
+export const eventsRoutes = [
+  {
+    path: '/events',
+    name: 'Events',
+    component: () => import('../../pages/events/index.vue'),
+  },
+]

+ 15 - 0
playground/src/router/routes/index.ts

@@ -0,0 +1,15 @@
+import { modelsRoutes } from './models'
+import { cameraRoutes } from './cameras'
+import { eventsRoutes } from './events'
+import { basicRoutes } from './basic'
+import { advancedRoutes } from './advanced'
+import { miscRoutes } from './misc'
+
+export {
+  basicRoutes,
+  advancedRoutes,
+  eventsRoutes,
+  cameraRoutes,
+  modelsRoutes,
+  miscRoutes,
+}

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

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

+ 7 - 0
playground/src/router/routes/models.ts

@@ -0,0 +1,7 @@
+export const modelsRoutes = [
+  {
+    path: '/models/primitives',
+    name: 'Model Primitives',
+    component: () => import('../../pages/models/PrimitivesModel.vue'),
+  },
+]

+ 7 - 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,
@@ -32,6 +32,11 @@ export default defineConfig({
     }),
     UnoCSS({
       /* options */
+      theme: {
+        colors: {
+          'tres-primary': '#82dbc5',
+        },
+      },
     }),
     qrcode(), // only applies in dev mode
   ],

File diff suppressed because it is too large
+ 239 - 281
pnpm-lock.yaml


+ 49 - 15
src/components/TresCanvas.vue

@@ -6,14 +6,17 @@ import type {
   ToneMapping,
   WebGLRendererParameters,
 } from 'three'
+import * as THREE from 'three'
 import type { App, Ref } from 'vue'
 import {
   Fragment,
   computed,
+  createRenderer,
   defineComponent,
   getCurrentInstance,
   h,
   onMounted,
+  onUnmounted,
   provide,
   ref,
   shallowRef,
@@ -21,19 +24,20 @@ import {
   watchEffect,
 } from 'vue'
 import pkg from '../../package.json'
+
 import {
   type TresContext,
   useLogger,
-  usePointerEventHandler,
-  useRenderLoop,
   useTresContextProvider,
+  useTresEventManager,
 } from '../composables'
 import { extend } from '../core/catalogue'
-import { render } from '../core/renderer'
+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'
+import type { TresCamera, TresObject, TresScene } from '../types/'
 
 export interface TresCanvasProps
   extends Omit<WebGLRendererParameters, 'canvas'> {
@@ -45,6 +49,7 @@ export interface TresCanvasProps
   useLegacyLights?: boolean
   outputColorSpace?: ColorSpace
   toneMappingExposure?: number
+  renderMode?: 'always' | 'on-demand' | 'manual'
 
   // required by useTresContextProvider
   camera?: TresCamera
@@ -65,8 +70,27 @@ const props = withDefaults(defineProps<TresCanvasProps>(), {
   preserveDrawingBuffer: undefined,
   logarithmicDepthBuffer: undefined,
   failIfMajorPerformanceCaveat: undefined,
+  renderMode: 'always',
 })
 
+// Define emits for Pointer events, pass `emit` into useTresEventManager so we can emit events off of TresCanvas
+// Not sure of this solution, but you have to have emits defined on the component to emit them in vue
+const emit = defineEmits([
+  'render',
+  'click',
+  'double-click',
+  'context-menu',
+  'pointer-move',
+  'pointer-up',
+  'pointer-down',
+  'pointer-enter',
+  'pointer-leave',
+  'pointer-over',
+  'pointer-out',
+  'pointer-missed',
+  'wheel',
+])
+
 const slots = defineSlots<{
   default: () => any
 }>()
@@ -80,11 +104,10 @@ const canvas = ref<HTMLCanvasElement>()
  renderer uses it to mount the app nodes. This happens before `useTresContextProvider` is called.
  The custom renderer requires `scene` to be editable (not readonly).
 */
-const scene = shallowRef(new Scene())
-
-const { resume } = useRenderLoop()
+const scene = shallowRef<TresScene | Scene>(new Scene())
 
 const instance = getCurrentInstance()?.appContext.app
+extend(THREE)
 
 const createInternalComponent = (context: TresContext) =>
   defineComponent({
@@ -95,7 +118,7 @@ const createInternalComponent = (context: TresContext) =>
       provide('extend', extend)
 
       if (typeof window !== 'undefined') {
-        registerTresDevtools(ctx.app, context)
+        registerTresDevtools(ctx?.app, context)
       }
       return () => h(Fragment, null, slots?.default ? slots.default() : [])
     },
@@ -103,18 +126,23 @@ const createInternalComponent = (context: TresContext) =>
 
 const mountCustomRenderer = (context: TresContext) => {
   const InternalComponent = createInternalComponent(context)
+
+  const { render } = createRenderer(nodeOps())
+
   render(h(InternalComponent), scene.value as unknown as TresObject)
 }
 
 const dispose = (context: TresContext, force = false) => {
-  scene.value.children = []
+  disposeObject3D(context.scene.value as unknown as TresObject)
   if (force) {
     context.renderer.value.dispose()
     context.renderer.value.renderLists.dispose()
     context.renderer.value.forceContextLoss()
   }
+  (scene.value as TresScene).__tres = {
+    root: context,
+  }
   mountCustomRenderer(context)
-  resume()
 }
 
 const disableRender = computed(() => props.disableRender)
@@ -127,14 +155,15 @@ onMounted(() => {
   const existingCanvas = canvas as Ref<HTMLCanvasElement>
 
   context.value = useTresContextProvider({
-    scene: scene.value,
+    scene: scene.value as TresScene,
     canvas: existingCanvas,
-    windowSize: props.windowSize,
-    disableRender,
+    windowSize: props.windowSize ?? false,
+    disableRender: disableRender.value ?? false,
     rendererOptions: props,
+    emit,
   })
 
-  usePointerEventHandler({ scene: scene.value, contextParts: context.value })
+  useTresEventManager(scene.value, context.value, emit)
 
   const { registerCamera, camera, cameras, deregisterCamera } = context.value
 
@@ -182,8 +211,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>

+ 3 - 1
src/composables/index.ts

@@ -1,5 +1,5 @@
 export * from './useCamera/'
-export * from './useRenderLoop/'
+export * from './useRenderLoop'
 export * from './useRenderer/'
 export * from './useLoader'
 export * from './useTexture'
@@ -8,3 +8,5 @@ export * from './useLogger'
 export * from './useSeek'
 export * from './usePointerEventHandler'
 export * from './useTresContextProvider'
+export * from './useLoop'
+export * from './useTresEventManager'

+ 27 - 9
src/composables/useCamera/index.ts

@@ -1,10 +1,11 @@
 import { computed, onUnmounted, ref, watchEffect } from 'vue'
-import { Camera, OrthographicCamera, PerspectiveCamera } from 'three'
+import type { OrthographicCamera } from 'three'
+import { Camera, PerspectiveCamera } from 'three'
 
 import type { TresScene } from '../../types'
 import type { TresContext } from '../useTresContextProvider'
 
-export const useCamera = ({ sizes, scene }: Pick<TresContext, 'sizes'> & { scene: TresScene }) => {
+export const useCamera = ({ sizes }: Pick<TresContext, 'sizes'> & { scene: TresScene }) => {
   // the computed does not trigger, when for example the camera position changes
   const cameras = ref<Camera[]>([])
   const camera = computed<Camera | undefined>(
@@ -35,17 +36,29 @@ export const useCamera = ({ sizes, scene }: Pick<TresContext, 'sizes'> & { scene
 
   watchEffect(() => {
     if (sizes.aspectRatio.value) {
-      cameras.value.forEach((camera: Camera) => {
-        if (camera instanceof PerspectiveCamera) { camera.aspect = sizes.aspectRatio.value }
-
-        if (camera instanceof PerspectiveCamera || camera instanceof OrthographicCamera) { camera.updateProjectionMatrix() }
+      cameras.value.forEach((camera: Camera & { manual?: boolean }) => {
+        // NOTE: Don't mess with the camera if it belongs to the user.
+        // https://github.com/pmndrs/react-three-fiber/blob/0ef66a1d23bf16ecd457dde92b0517ceec9861c5/packages/fiber/src/core/utils.ts#L457
+        //
+        // To set camera as "manual":
+        // const myCamera = new PerspectiveCamera(); // or OrthographicCamera
+        // (myCamera as any).manual = true
+        if (!camera.manual && (camera instanceof PerspectiveCamera || isOrthographicCamera(camera))) {
+          if (camera instanceof PerspectiveCamera) {
+            camera.aspect = sizes.aspectRatio.value
+          }
+          else {
+            camera.left = sizes.width.value * -0.5
+            camera.right = sizes.width.value * 0.5
+            camera.top = sizes.height.value * 0.5
+            camera.bottom = sizes.height.value * -0.5
+          }
+          camera.updateProjectionMatrix()
+        }
       })
     }
   })
 
-  scene.userData.tres__registerCamera = registerCamera
-  scene.userData.tres__deregisterCamera = deregisterCamera
-
   onUnmounted(() => {
     cameras.value = []
   })
@@ -58,3 +71,8 @@ export const useCamera = ({ sizes, scene }: Pick<TresContext, 'sizes'> & { scene
     setCameraActive,
   }
 }
+
+function isOrthographicCamera(o: any): o is OrthographicCamera {
+  // eslint-disable-next-line no-prototype-builtins
+  return o.hasOwnProperty('isOrthographicCamera') && o.isOrthographicCamera
+}

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

@@ -0,0 +1,49 @@
+import { useTresContext } from '../useTresContextProvider'
+import type { LoopCallbackFn } from './../../core/loop'
+
+export function useLoop() {
+  const {
+    camera,
+    scene,
+    renderer,
+    loop,
+    raycaster,
+    controls,
+    invalidate,
+    advance,
+  } = useTresContext()
+
+  // Pass context to loop
+  loop.setContext({
+    camera,
+    scene,
+    renderer,
+    raycaster,
+    controls,
+    invalidate,
+    advance,
+  })
+
+  function onBeforeRender(cb: LoopCallbackFn, index = 0) {
+    return loop.register(cb, 'before', index)
+  }
+
+  function render(cb: LoopCallbackFn) {
+    return loop.register(cb, 'render')
+  }
+
+  function onAfterRender(cb: LoopCallbackFn, 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,
+  }
+}

+ 34 - 37
src/composables/usePointerEventHandler/index.ts

@@ -1,13 +1,13 @@
-import type { Event, Intersection, Object3D } from 'three'
-import type { TresScene } from 'src/types'
+import type { Intersection, Object3D, Object3DEventMap } from 'three'
 import { computed, reactive, ref } from 'vue'
+import type { TresObject } from 'src/types'
 import { uniqueBy } from '../../utils'
 import { useRaycaster } from '../useRaycaster'
 
 import type { TresContext } from '../useTresContextProvider'
 
-type CallbackFn = (intersection: Intersection<Object3D<Event>>, event: PointerEvent) => void
-type CallbackFnPointerLeave = (object: Object3D<Event>, event: PointerEvent) => void
+type CallbackFn = (intersection: Intersection<Object3D<Object3DEventMap>>, event: PointerEvent) => void
+type CallbackFnPointerLeave = (object: Object3D, event: PointerEvent) => void
 
 export interface EventProps {
   onClick?: CallbackFn
@@ -17,50 +17,39 @@ export interface EventProps {
 }
 
 export const usePointerEventHandler = (
-  { scene, contextParts }:
-  {
-    scene: TresScene
-    contextParts: Pick<TresContext, 'renderer' | 'camera' | 'raycaster'>
-  },
+  ctx: TresContext,
 ) => {
   const objectsWithEventListeners = reactive({
-    click: new Map<Object3D, CallbackFn>(),
-    pointerMove: new Map<Object3D, CallbackFn>(),
-    pointerEnter: new Map<Object3D, CallbackFn>(),
-    pointerLeave: new Map<Object3D, CallbackFnPointerLeave>(),
+    click: new Map<Object3D<Object3DEventMap>, CallbackFn>(),
+    pointerMove: new Map<Object3D<Object3DEventMap>, CallbackFn>(),
+    pointerEnter: new Map<Object3D<Object3DEventMap>, CallbackFn>(),
+    pointerLeave: new Map<Object3D<Object3DEventMap>, CallbackFnPointerLeave>(),
   })
 
   const blockingObjects = ref(new Set<Object3D>())
 
-  const registerBlockingObject = (object: Object3D) => {
-    blockingObjects.value.add(object)
+  const registerBlockingObject = (object: TresObject) => {
+    blockingObjects.value.add(object as Object3D)
   }
 
-  const deregisterBlockingObject = (object: Object3D) => {
-    blockingObjects.value.delete(object)
+  const deregisterBlockingObject = (object: TresObject) => {
+    blockingObjects.value.delete(object as Object3D)
   }
 
-  const deregisterObject = (object: Object3D) => {
-    Object.values(objectsWithEventListeners).forEach(map => map.delete(object))
+  const deregisterObject = (object: TresObject) => {
+    Object.values(objectsWithEventListeners).forEach(map => map.delete(object as Object3D))
     deregisterBlockingObject(object)
   }
 
-  const registerObject = (object: Object3D & EventProps) => {
+  const registerObject = (object: TresObject & EventProps) => {
     const { onClick, onPointerMove, onPointerEnter, onPointerLeave } = object
 
-    if (onClick) { objectsWithEventListeners.click.set(object, onClick) }
-    if (onPointerMove) { objectsWithEventListeners.pointerMove.set(object, onPointerMove) }
-    if (onPointerEnter) { objectsWithEventListeners.pointerEnter.set(object, onPointerEnter) }
-    if (onPointerLeave) { objectsWithEventListeners.pointerLeave.set(object, onPointerLeave) }
+    if (onClick) { objectsWithEventListeners.click.set(object as Object3D, onClick) }
+    if (onPointerMove) { objectsWithEventListeners.pointerMove.set(object as Object3D, onPointerMove) }
+    if (onPointerEnter) { objectsWithEventListeners.pointerEnter.set(object as Object3D, onPointerEnter) }
+    if (onPointerLeave) { objectsWithEventListeners.pointerLeave.set(object as Object3D, onPointerLeave) }
   }
 
-  // to make the registerObject available in the custom renderer (nodeOps), it is attached to the scene
-  scene.userData.tres__registerAtPointerEventHandler = registerObject
-  scene.userData.tres__deregisterAtPointerEventHandler = deregisterObject
-
-  scene.userData.tres__registerBlockingObjectAtPointerEventHandler = registerBlockingObject
-  scene.userData.tres__deregisterBlockingObjectAtPointerEventHandler = deregisterBlockingObject
-
   const objectsToWatch = computed(() =>
     uniqueBy(
       [
@@ -73,25 +62,31 @@ export const usePointerEventHandler = (
     ),
   )
 
-  const { onClick, onPointerMove } = useRaycaster(objectsToWatch, contextParts)
+  // Temporaly add the methods to the context, this should be handled later by the EventManager state on the context https://github.com/Tresjs/tres/issues/515
+  ctx.registerObjectAtPointerEventHandler = registerObject
+  ctx.deregisterObjectAtPointerEventHandler = deregisterObject
+  ctx.registerBlockingObjectAtPointerEventHandler = registerBlockingObject
+  ctx.deregisterBlockingObjectAtPointerEventHandler = deregisterBlockingObject
+
+  const { onClick, onPointerMove } = useRaycaster(objectsToWatch, ctx)
 
   onClick(({ intersects, event }) => {
-    if (intersects.length) { objectsWithEventListeners.click.get(intersects[0].object)?.(intersects[0], event) }
+    if (intersects.length) { objectsWithEventListeners.click.get(intersects[0].object)?.(intersects[0], event as PointerEvent) }
   })
 
-  let previouslyIntersectedObject: Object3D<Event> | null
+  let previouslyIntersectedObject: Object3D | null
 
   onPointerMove(({ intersects, event }) => {
     const firstObject = intersects?.[0]?.object
 
     const { pointerLeave, pointerEnter, pointerMove } = objectsWithEventListeners
 
-    if (previouslyIntersectedObject && previouslyIntersectedObject !== firstObject) { pointerLeave.get(previouslyIntersectedObject)?.(previouslyIntersectedObject, event) }
+    if (previouslyIntersectedObject && previouslyIntersectedObject !== firstObject) { pointerLeave.get(previouslyIntersectedObject)?.(previouslyIntersectedObject, event as PointerEvent) }
 
     if (firstObject) {
-      if (previouslyIntersectedObject !== firstObject) { pointerEnter.get(firstObject)?.(intersects[0], event) }
+      if (previouslyIntersectedObject !== firstObject) { pointerEnter.get(firstObject)?.(intersects[0], event as PointerEvent) }
 
-      pointerMove.get(firstObject)?.(intersects[0], event)
+      pointerMove.get(firstObject)?.(intersects[0], event as PointerEvent)
     }
 
     previouslyIntersectedObject = firstObject || null
@@ -100,5 +95,7 @@ export const usePointerEventHandler = (
   return {
     registerObject,
     deregisterObject,
+    registerBlockingObject,
+    deregisterBlockingObject,
   }
 }

+ 140 - 34
src/composables/useRaycaster/index.ts

@@ -1,31 +1,22 @@
-import { Vector2 } from 'three'
-import type { Intersection, Object3D, Object3DEventMap } from 'three'
-import type { Ref } from 'vue'
-import { computed, onUnmounted } from 'vue'
+import { Vector2, Vector3 } from 'three'
+import type { Intersection, Object3D } from 'three'
+import type { Ref, ShallowRef } from 'vue'
+import { computed, onUnmounted, shallowRef } from 'vue'
 import type { EventHook } from '@vueuse/core'
 import { createEventHook, useElementBounding, usePointer } from '@vueuse/core'
 
+import type { DomEvent, TresCamera, TresEvent } from 'src/types'
 import type { TresContext } from '../useTresContextProvider'
 
-export type Intersects = Intersection<Object3D<Object3DEventMap>>[]
-interface PointerMoveEventPayload {
-  intersects?: Intersects
-  event: PointerEvent
-}
-
-interface PointerClickEventPayload {
-  intersects: Intersects
-  event: PointerEvent
-}
-
 export const useRaycaster = (
   objects: Ref<Object3D[]>,
-  { renderer, camera, raycaster }: Pick<TresContext, 'renderer' | 'camera' | 'raycaster'>,
+  ctx: TresContext,
 ) => {
   // having a separate computed makes useElementBounding work
-  const canvas = computed(() => renderer.value.domElement as HTMLCanvasElement)
-
+  const canvas = computed(() => ctx.renderer.value.domElement as HTMLCanvasElement)
+  const intersects: ShallowRef<Intersection[]> = shallowRef([])
   const { x, y } = usePointer({ target: canvas })
+  let delta = 0
 
   const { width, height, top, left } = useElementBounding(canvas)
 
@@ -39,14 +30,15 @@ export const useRaycaster = (
   }
 
   const getIntersectsByRelativePointerPosition = ({ x, y }: { x: number, y: number }) => {
-    if (!camera.value) { return }
+    if (!ctx.camera.value) { return }
 
-    raycaster.value.setFromCamera(new Vector2(x, y), camera.value)
+    ctx.raycaster.value.setFromCamera(new Vector2(x, y), ctx.camera.value)
 
-    return raycaster.value.intersectObjects(objects.value, false)
+    intersects.value = ctx.raycaster.value.intersectObjects(objects.value, true)
+    return intersects.value
   }
 
-  const getIntersects = (event?: PointerEvent | MouseEvent) => {
+  const getIntersects = (event?: DomEvent) => {
     const pointerPosition = getRelativePointerPosition({
       x: event?.clientX ?? x.value,
       y: event?.clientY ?? y.value,
@@ -56,39 +48,144 @@ export const useRaycaster = (
     return getIntersectsByRelativePointerPosition(pointerPosition) || []
   }
 
-  const intersects = computed<Intersects>(() => getIntersects())
-
-  const eventHookClick = createEventHook<PointerClickEventPayload>()
-  const eventHookPointerMove = createEventHook<PointerMoveEventPayload>()
+  const eventHookClick = createEventHook<TresEvent>()
+  const eventHookDblClick = createEventHook<TresEvent>()
+  const eventHookPointerMove = createEventHook<TresEvent>()
+  const eventHookPointerUp = createEventHook<TresEvent>()
+  const eventHookPointerDown = createEventHook<TresEvent>()
+  const eventHookPointerMissed = createEventHook<TresEvent>()
+  const eventHookContextMenu = createEventHook<TresEvent>()
+  const eventHookWheel = createEventHook<TresEvent>()
+
+  /* ({
+    ...DomEvent                   // All the original event data
+    ...Intersection               // All of Three's intersection data - see note 2
+    intersections: Intersection[] // The first intersection of each intersected object
+    object: Object3D              // The object that was actually hit (added to event payload in TresEventManager)
+    eventObject: Object3D         // The object that registered the event (added to event payload in TresEventManager)
+    unprojectedPoint: Vector3     // Camera-unprojected point
+    ray: Ray                      // The ray that was used to strike the object
+    camera: Camera                // The camera that was used in the raycaster
+    sourceEvent: DomEvent         // A reference to the host event
+    delta: number                 // Distance between mouse down and mouse up event in pixels
+  }) => ... */
+
+  // Mouse Event props aren't enumerable, so we can't be simple and use Object.assign or the spread operator
+  // Manually copies the mouse event props into a new object that we can spread in triggerEventHook
+  function copyMouseEventProperties(event: MouseEvent | PointerEvent | WheelEvent) {
+    const mouseEventProperties: any = {}
+
+    for (const property in event) {
+      // Copy all non-function properties
+      if (typeof property !== 'function') { mouseEventProperties[property] = (event as Record<string, any>)[property] }
+    }
+    return mouseEventProperties
+  }
 
-  const triggerEventHook = (eventHook: EventHook, event: PointerEvent | MouseEvent) => {
-    eventHook.trigger({ event, intersects: getIntersects(event) })
+  const triggerEventHook = (eventHook: EventHook, event: PointerEvent | MouseEvent | WheelEvent) => {
+    const eventProperties = copyMouseEventProperties(event)
+    const unprojectedPoint = new Vector3(event?.clientX, event?.clientY, 0).unproject(ctx.camera?.value as TresCamera)
+    eventHook.trigger({
+      ...eventProperties,
+      intersections: intersects.value,
+      // The unprojectedPoint is wrong, math needs to be fixed
+      unprojectedPoint,
+      ray: ctx.raycaster?.value.ray,
+      camera: ctx.camera?.value,
+      sourceEvent: event,
+      delta,
+      stopPropagating: false,
+    })
   }
 
+  let previousPointerMoveEvent: PointerEvent | undefined
   const onPointerMove = (event: PointerEvent) => {
+    // Update the raycast intersects
+    getIntersects(event)
     triggerEventHook(eventHookPointerMove, event)
+    previousPointerMoveEvent = event
   }
 
-  // a click event is fired whenever a pointerdown happened after pointerup on the same object
+  const forceUpdate = () => {
+    if (previousPointerMoveEvent) { onPointerMove(previousPointerMoveEvent) }
+  }
 
+  // a click event is fired whenever a pointerdown happened after pointerup on the same object
   let mouseDownObject: Object3D | undefined
+  let mouseDownPosition: Vector2
+  let mouseUpPosition: Vector2
 
   const onPointerDown = (event: PointerEvent) => {
-    mouseDownObject = getIntersects(event)[0]?.object
+    mouseDownObject = intersects.value[0]?.object
+
+    delta = 0
+    mouseDownPosition = new Vector2(
+      event?.clientX ?? x.value,
+      event?.clientY ?? y.value,
+    )
+
+    triggerEventHook(eventHookPointerDown, event)
   }
 
+  let previousClickObject: Object3D | undefined
+  let doubleClickConfirmed: boolean = false
+
   const onPointerUp = (event: MouseEvent) => {
     if (!(event instanceof PointerEvent)) { return } // prevents triggering twice on mobile devices
 
-    if (mouseDownObject === getIntersects(event)[0]?.object) { triggerEventHook(eventHookClick, event) }
+    // We missed every object, trigger the pointer missed event
+    if (intersects.value.length === 0) {
+      triggerEventHook(eventHookPointerMissed, event)
+    }
+
+    if (mouseDownObject === intersects.value[0]?.object) {
+      mouseUpPosition = new Vector2(
+        event?.clientX ?? x.value,
+        event?.clientY ?? y.value,
+      )
+
+      // Compute the distance between the mouse down and mouse up events
+      delta = mouseDownPosition?.distanceTo(mouseUpPosition)
+
+      if (event.button === 0) {
+        // Left click
+        triggerEventHook(eventHookClick, event)
+
+        if (previousClickObject === intersects.value[0]?.object) {
+          doubleClickConfirmed = true
+        }
+        else {
+          previousClickObject = intersects.value[0]?.object
+          doubleClickConfirmed = false
+        }
+      }
+      else if (event.button === 2) {
+        // Right click
+        triggerEventHook(eventHookContextMenu, event)
+      }
+    }
+
+    triggerEventHook(eventHookPointerUp, event)
   }
 
-  const onPointerLeave = (event: PointerEvent) => eventHookPointerMove.trigger({ event, intersects: [] })
+  const onDoubleClick = (event: MouseEvent) => {
+    if (doubleClickConfirmed) {
+      triggerEventHook(eventHookDblClick, event)
+      previousClickObject = undefined
+      doubleClickConfirmed = false
+    }
+  }
+
+  const onPointerLeave = (event: PointerEvent) => triggerEventHook(eventHookPointerMove, event)
+
+  const onWheel = (event: WheelEvent) => triggerEventHook(eventHookWheel, event)
 
   canvas.value.addEventListener('pointerup', onPointerUp)
   canvas.value.addEventListener('pointerdown', onPointerDown)
   canvas.value.addEventListener('pointermove', onPointerMove)
   canvas.value.addEventListener('pointerleave', onPointerLeave)
+  canvas.value.addEventListener('dblclick', onDoubleClick)
+  canvas.value.addEventListener('wheel', onWheel)
 
   onUnmounted(() => {
     if (!canvas?.value) { return }
@@ -96,11 +193,20 @@ export const useRaycaster = (
     canvas.value.removeEventListener('pointerdown', onPointerDown)
     canvas.value.removeEventListener('pointermove', onPointerMove)
     canvas.value.removeEventListener('pointerleave', onPointerLeave)
+    canvas.value.removeEventListener('dblclick', onDoubleClick)
+    canvas.value.removeEventListener('wheel', onWheel)
   })
 
   return {
     intersects,
-    onClick: (fn: (value: PointerClickEventPayload) => void) => eventHookClick.on(fn).off,
-    onPointerMove: (fn: (value: PointerMoveEventPayload) => void) => eventHookPointerMove.on(fn).off,
+    onClick: (fn: (value: TresEvent) => void) => eventHookClick.on(fn).off,
+    onDblClick: (fn: (value: TresEvent) => void) => eventHookDblClick.on(fn).off,
+    onContextMenu: (fn: (value: TresEvent) => void) => eventHookContextMenu.on(fn).off,
+    onPointerMove: (fn: (value: TresEvent) => void) => eventHookPointerMove.on(fn).off,
+    onPointerUp: (fn: (value: TresEvent) => void) => eventHookPointerUp.on(fn).off,
+    onPointerDown: (fn: (value: TresEvent) => void) => eventHookPointerDown.on(fn).off,
+    onPointerMissed: (fn: (value: TresEvent) => void) => eventHookPointerMissed.on(fn).off,
+    onWheel: (fn: (value: TresEvent) => void) => eventHookWheel.on(fn).off,
+    forceUpdate,
   }
 }

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

@@ -40,11 +40,22 @@ onAfterLoop.on(() => {
   elapsed = clock.getElapsedTime()
 })
 
-export const useRenderLoop = (): UseRenderLoopReturn => ({
-  onBeforeLoop: onBeforeLoop.on,
-  onLoop: onLoop.on,
-  onAfterLoop: onAfterLoop.on,
-  pause,
-  resume,
-  isActive,
-})
+let startedOnce = false
+export const useRenderLoop = (): UseRenderLoopReturn => {
+  if (!startedOnce) {
+    // NOTE: `useRenderLoop` is not started by default
+    // in order not to waste user resources. Instead, we'll
+    // start the loop the first time the user uses
+    // `useRenderLoop`.
+    startedOnce = true
+    resume()
+  }
+  return {
+    onBeforeLoop: onBeforeLoop.on,
+    onLoop: onLoop.on,
+    onAfterLoop: onAfterLoop.on,
+    pause,
+    resume,
+    isActive,
+  }
+}

+ 5 - 1
src/composables/useRenderer/const.ts

@@ -1,4 +1,4 @@
-import { ACESFilmicToneMapping, PCFSoftShadowMap, SRGBColorSpace } from 'three'
+import { ACESFilmicToneMapping, NoToneMapping, PCFSoftShadowMap, SRGBColorSpace } from 'three'
 
 export const rendererPresets = {
   realistic: {
@@ -12,6 +12,10 @@ export const rendererPresets = {
       type: PCFSoftShadowMap,
     },
   },
+  flat: {
+    toneMapping: NoToneMapping,
+    toneMappingExposure: 1,
+  },
 }
 
 export type RendererPresetsType = keyof typeof rendererPresets

+ 46 - 26
src/composables/useRenderer/index.ts

@@ -1,5 +1,6 @@
-import { Color, WebGLRenderer } from 'three'
+import { ACESFilmicToneMapping, Color, WebGLRenderer } from 'three'
 import { type MaybeRef, computed, onUnmounted, shallowRef, watch, watchEffect } from 'vue'
+
 import {
   type MaybeRefOrGetter,
   toValue,
@@ -9,8 +10,7 @@ import {
 
 import type { ColorSpace, Scene, ShadowMapType, ToneMapping, WebGLRendererParameters } from 'three'
 import { useLogger } from '../useLogger'
-import type { TresColor } from '../../types'
-import { useRenderLoop } from '../useRenderLoop'
+import type { EmitEventFn, TresColor } from '../../types'
 import { normalizeColor } from '../../utils/normalize'
 
 import type { TresContext } from '../useTresContextProvider'
@@ -72,7 +72,7 @@ export interface UseRendererOptions extends TransformToMaybeRefOrGetter<WebGLRen
    * CineonToneMapping, ACESFilmicToneMapping,
    * CustomToneMapping
    *
-   * @default NoToneMapping
+   * @default ACESFilmicToneMapping
    */
   toneMapping?: MaybeRefOrGetter<ToneMapping>
 
@@ -91,33 +91,31 @@ export interface UseRendererOptions extends TransformToMaybeRefOrGetter<WebGLRen
   clearColor?: MaybeRefOrGetter<TresColor>
   windowSize?: MaybeRefOrGetter<boolean | string>
   preset?: MaybeRefOrGetter<RendererPresetsType>
+  renderMode?: MaybeRefOrGetter<'always' | 'on-demand' | 'manual'>
 }
 
 export function useRenderer(
   {
-    scene,
     canvas,
     options,
-    disableRender,
-    contextParts: { sizes, camera },
+    contextParts: { sizes, render, invalidate, advance },
   }:
   {
     canvas: MaybeRef<HTMLCanvasElement>
     scene: Scene
     options: UseRendererOptions
-    contextParts: Pick<TresContext, 'sizes' | 'camera'>
+    emit: EmitEventFn
+    contextParts: Pick<TresContext, 'sizes' | 'camera' | 'render'> & { invalidate: () => void, advance: () => void }
     disableRender: MaybeRefOrGetter<boolean>
   },
 ) {
   const webGLRendererConstructorParameters = computed<WebGLRendererParameters>(() => ({
-    alpha: toValue(options.alpha),
+    alpha: toValue(options.alpha) ?? true,
     depth: toValue(options.depth),
     canvas: unrefElement(canvas),
     context: toValue(options.context),
     stencil: toValue(options.stencil),
-    antialias: toValue(options.antialias) === undefined // an opinionated default of tres
-      ? true
-      : toValue(options.antialias),
+    antialias: toValue(options.antialias) ?? true,
     precision: toValue(options.precision),
     powerPreference: toValue(options.powerPreference),
     premultipliedAlpha: toValue(options.premultipliedAlpha),
@@ -128,20 +126,32 @@ export function useRenderer(
 
   const renderer = shallowRef<WebGLRenderer>(new WebGLRenderer(webGLRendererConstructorParameters.value))
 
+  function invalidateOnDemand() {
+    if (options.renderMode === 'on-demand') {
+      invalidate()
+    }
+  }
   // since the properties set via the constructor can't be updated dynamically,
   // the renderer is recreated once they change
   watch(webGLRendererConstructorParameters, () => {
     renderer.value.dispose()
     renderer.value = new WebGLRenderer(webGLRendererConstructorParameters.value)
+
+    invalidateOnDemand()
   })
 
-  watchEffect(() => {
+  watch([sizes.width, sizes.height], () => {
     renderer.value.setSize(sizes.width.value, sizes.height.value)
+    invalidateOnDemand()
+  }, {
+    immediate: true,
   })
 
+  watch(() => options.clearColor, invalidateOnDemand)
+
   const { pixelRatio } = useDevicePixelRatio()
 
-  watchEffect(() => {
+  watch(pixelRatio, () => {
     renderer.value.setPixelRatio(pixelRatio.value)
   })
 
@@ -166,6 +176,20 @@ export function useRenderer(
 
   const threeDefaults = getThreeRendererDefaults()
 
+  const renderMode = toValue(options.renderMode)
+
+  if (renderMode === 'on-demand') {
+    // Invalidate for the first time
+    invalidate()
+  }
+
+  if (renderMode === 'manual') {
+    // Advance for the first time, setTimeout to make sure there is something to render
+    setTimeout(() => {
+      advance()
+    }, 100)
+  }
+
   watchEffect(() => {
     const rendererPreset = toValue(options.preset)
 
@@ -175,6 +199,13 @@ export function useRenderer(
       merge(renderer.value, rendererPresets[rendererPreset])
     }
 
+    // Render mode
+
+    if (renderMode === 'always') {
+      // If the render mode is 'always', ensure there's always a frame pending
+      render.frames.value = Math.max(1, render.frames.value)
+    }
+
     const getValue = <T>(option: MaybeRefOrGetter<T>, pathInThree: string): T | undefined => {
       const value = toValue(option)
 
@@ -197,7 +228,7 @@ export function useRenderer(
       set(renderer.value, pathInThree, getValue(option, pathInThree))
 
     setValueOrDefault(options.shadows, 'shadowMap.enabled')
-    setValueOrDefault(options.toneMapping, 'toneMapping')
+    setValueOrDefault(options.toneMapping ?? ACESFilmicToneMapping, 'toneMapping')
     setValueOrDefault(options.shadowMapType, 'shadowMap.type')
 
     if (revision < 150) { setValueOrDefault(!options.useLegacyLights, 'physicallyCorrectLights') }
@@ -216,22 +247,11 @@ export function useRenderer(
     }
   })
 
-  const { pause, resume, onLoop } = useRenderLoop()
-
-  onLoop(() => {
-    if (camera.value && !toValue(disableRender)) { renderer.value.render(scene, camera.value) }
-  })
-
-  resume()
-
   onUnmounted(() => {
-    pause() // TODO should the render loop pause itself if there is no more renderer? 🤔 What if there is another renderer which needs the loop?
     renderer.value.dispose()
     renderer.value.forceContextLoss()
   })
 
-  if (import.meta.hot) { import.meta.hot.on('vite:afterUpdate', resume) }
-
   return {
     renderer,
   }

+ 150 - 33
src/composables/useTresContextProvider/index.ts

@@ -1,6 +1,6 @@
 import { useFps, useMemory, useRafFn } from '@vueuse/core'
-import { inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue'
-import type { Camera, EventDispatcher, Scene, WebGLRenderer } from 'three'
+import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue'
+import type { Camera, EventDispatcher, WebGLRenderer } from 'three'
 import { Raycaster } from 'three'
 import type { ComputedRef, DeepReadonly, MaybeRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
 import { calculateMemoryUsage } from '../../utils/perf'
@@ -8,10 +8,48 @@ import { useCamera } from '../useCamera'
 import type { UseRendererOptions } from '../useRenderer'
 import { useRenderer } from '../useRenderer'
 import { extend } from '../../core/catalogue'
+import { useLogger } from '../useLogger'
+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>
+  frames: Ref<number>
+  maxFrames: number
+}
+
+export interface RenderState {
+  /**
+   * If set to 'on-demand', the scene will only be rendered when the current frame is invalidated
+   * If set to 'manual', the scene will only be rendered when advance() is called
+   * If set to 'always', the scene will be rendered every frame
+   */
+  mode: Ref<'always' | 'on-demand' | 'manual'>
+  priority: Ref<number>
+  frames: Ref<number>
+  maxFrames: number
+  canBeInvalidated: ComputedRef<boolean>
+}
+
+export interface PerformanceState {
+  maxFrames: number
+  fps: {
+    value: number
+    accumulator: number[]
+  }
+  memory: {
+    currentMem: number
+    allocatedMem: number
+    accumulator: number[]
+  }
+}
 
 export interface TresContext {
-  scene: ShallowRef<Scene>
+  scene: ShallowRef<TresScene>
   sizes: SizesType
   extend: (objects: any) => void
   camera: ComputedRef<Camera | undefined>
@@ -19,21 +57,30 @@ export interface TresContext {
   controls: Ref<(EventDispatcher & { enabled: boolean }) | null>
   renderer: ShallowRef<WebGLRenderer>
   raycaster: ShallowRef<Raycaster>
-  perf: {
-    maxFrames: number
-    fps: {
-      value: number
-      accumulator: number[]
-    }
-    memory: {
-      currentMem: number
-      allocatedMem: number
-      accumulator: number[]
-    }
-  }
+  perf: PerformanceState
+  render: RenderState
+  // Loop
+  loop: RendererLoop
+  /**
+   * Invalidates the current frame when renderMode === 'on-demand'
+   */
+  invalidate: () => void
+  /**
+   * Advance one frame when renderMode === 'manual'
+   */
+  advance: () => void
+  // Camera
   registerCamera: (camera: Camera) => void
   setCameraActive: (cameraOrUuid: Camera | string) => void
   deregisterCamera: (camera: Camera) => void
+  eventManager?: TresEventManager
+  // Events
+  // Temporaly add the methods to the context, this should be handled later by the EventManager state on the context https://github.com/Tresjs/tres/issues/515
+  // When thats done maybe we can short the names of the methods since the parent will give the context.
+  registerObjectAtPointerEventHandler?: (object: TresObject & EventProps) => void
+  deregisterObjectAtPointerEventHandler?: (object: TresObject) => void
+  registerBlockingObjectAtPointerEventHandler?: (object: TresObject) => void
+  deregisterBlockingObjectAtPointerEventHandler?: (object: TresObject) => void
 }
 
 export function useTresContextProvider({
@@ -42,16 +89,21 @@ export function useTresContextProvider({
   windowSize,
   disableRender,
   rendererOptions,
+  emit,
 }: {
-  scene: Scene
+  scene: TresScene
   canvas: MaybeRef<HTMLCanvasElement>
   windowSize: MaybeRefOrGetter<boolean>
   disableRender: MaybeRefOrGetter<boolean>
   rendererOptions: UseRendererOptions
+  emit: EmitEventFn
+
 }): TresContext {
+  const { logWarning } = useLogger()
+
+  const localScene = shallowRef<TresScene>(scene)
   const sizes = useSizes(windowSize, canvas)
 
-  const localScene = shallowRef<Scene>(scene)
   const {
     camera,
     cameras,
@@ -60,17 +112,48 @@ export function useTresContextProvider({
     setCameraActive,
   } = useCamera({ sizes, scene })
 
+  // Render state
+
+  const render: RenderState = {
+    mode: ref(rendererOptions.renderMode || 'always') as Ref<'always' | 'on-demand' | 'manual'>,
+    priority: ref(0),
+    frames: ref(0),
+    maxFrames: 60,
+    canBeInvalidated: computed(() => render.mode.value === 'on-demand' && render.frames.value === 0),
+  }
+
+  function invalidate(frames = 1) {
+    // Increase the frame count, ensuring not to exceed a maximum if desired
+    if (rendererOptions.renderMode === 'on-demand') {
+      render.frames.value = Math.min(render.maxFrames, render.frames.value + frames)
+    }
+    else {
+      logWarning('`invalidate` can only be used when `renderMode` is set to `on-demand`')
+    }
+  }
+
+  function advance() {
+    if (rendererOptions.renderMode === 'manual') {
+      render.frames.value = 1
+    }
+    else {
+      logWarning('`advance` can only be used when `renderMode` is set to `manual`')
+    }
+  }
+
   const { renderer } = useRenderer(
     {
       scene,
       canvas,
       options: rendererOptions,
-      contextParts: { sizes, camera },
+      emit,
+      // TODO: replace contextParts with full ctx at https://github.com/Tresjs/tres/issues/516
+      contextParts: { sizes, camera, render, invalidate, advance },
       disableRender,
     },
   )
 
-  const toProvide: TresContext = {
+  const ctx: TresContext = {
     sizes,
     scene: localScene,
     camera,
@@ -90,13 +173,47 @@ export function useTresContextProvider({
         accumulator: [],
       },
     },
+    render,
+    advance,
     extend,
+    invalidate,
     registerCamera,
     setCameraActive,
     deregisterCamera,
+    loop: createRenderLoop(),
   }
 
-  provide('useTres', toProvide)
+  provide('useTres', ctx)
+
+  // Add context to scene local state
+  ctx.scene.value.__tres = {
+    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
@@ -108,8 +225,8 @@ export function useTresContextProvider({
   const updatePerformanceData = ({ timestamp }: { timestamp: number }) => {
     // Update WebGL Memory Usage (Placeholder for actual logic)
     // perf.memory.value = calculateMemoryUsage(gl)
-    if (toProvide.scene.value) {
-      toProvide.perf.memory.allocatedMem = calculateMemoryUsage(toProvide.scene.value as unknown as TresObject)
+    if (ctx.scene.value) {
+      ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject)
     }
 
     // Update memory usage
@@ -117,24 +234,24 @@ export function useTresContextProvider({
       lastUpdateTime = timestamp
 
       // Update FPS
-      toProvide.perf.fps.accumulator.push(fps.value as never)
+      ctx.perf.fps.accumulator.push(fps.value as never)
 
-      if (toProvide.perf.fps.accumulator.length > maxFrames) {
-        toProvide.perf.fps.accumulator.shift()
+      if (ctx.perf.fps.accumulator.length > maxFrames) {
+        ctx.perf.fps.accumulator.shift()
       }
 
-      toProvide.perf.fps.value = fps.value
+      ctx.perf.fps.value = fps.value
 
       // Update memory
       if (isSupported.value && memory.value) {
-        toProvide.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never)
+        ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never)
 
-        if (toProvide.perf.memory.accumulator.length > maxFrames) {
-          toProvide.perf.memory.accumulator.shift()
+        if (ctx.perf.memory.accumulator.length > maxFrames) {
+          ctx.perf.memory.accumulator.shift()
         }
 
-        toProvide.perf.memory.currentMem
-        = toProvide.perf.memory.accumulator.reduce((a, b) => a + b, 0) / toProvide.perf.memory.accumulator.length
+        ctx.perf.memory.currentMem
+        = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length
       }
     }
   }
@@ -153,7 +270,7 @@ export function useTresContextProvider({
 
     // Check if the accumulated time is greater than or equal to the interval
     if (accumulatedTime >= interval) {
-      window.__TRES__DEVTOOLS__.cb(toProvide)
+      window.__TRES__DEVTOOLS__.cb(ctx)
 
       // Reset the accumulated time
       accumulatedTime = 0
@@ -164,7 +281,7 @@ export function useTresContextProvider({
     pause()
   })
 
-  return toProvide
+  return ctx
 }
 
 export function useTresContext(): TresContext {

+ 194 - 0
src/composables/useTresEventManager/index.ts

@@ -0,0 +1,194 @@
+import { computed, shallowRef } from 'vue'
+import type { Object3D, Object3DEventMap, Scene } from 'three'
+import type { EmitEventFn, EmitEventName, Intersection, TresEvent, TresObject } from 'src/types'
+import type { TresContext } from '../useTresContextProvider'
+import { useRaycaster } from '../useRaycaster'
+import { hyphenate } from '../../utils'
+
+export interface TresEventManager {
+  /**
+   * Forces the event system to refire events with the previous mouse event
+   */
+  forceUpdate: () => void
+  /**
+   * pointer-missed events by definition are fired when the pointer missed every object in the scene
+   * So we need to track them separately
+   * Note: These are used in nodeOps
+   */
+  registerPointerMissedObject: (object: TresObject) => void
+  deregisterPointerMissedObject: (object: TresObject) => void
+}
+
+export function useTresEventManager(
+  scene: Scene,
+  context: TresContext,
+  emit: EmitEventFn,
+) {
+  const _scene = shallowRef<Scene>()
+  const _context = shallowRef<TresContext>()
+
+  if (scene) { _scene.value = scene }
+  if (context) { _context.value = context }
+
+  // TODO: Optimize to not hit test on the whole scene
+  const sceneChildren = computed(() =>
+    _scene.value ? _scene.value.children : [],
+  )
+
+  function executeEventListeners(
+    listeners: Function | Function[],
+    event: TresEvent,
+  ) {
+    // Components with multiple event listeners will have an array of functions
+    if (Array.isArray(listeners)) {
+      for (const listener of listeners) {
+        listener(event)
+      }
+    }
+
+    // Single listener will be a function
+    if (typeof listeners === 'function') {
+      listeners(event)
+    }
+  }
+
+  /**
+   * propogateEvent
+   *
+   * Propogates an event to all intersected objects and their parents
+   * @param eventName - The name of the event to propogate
+   * @param event - The event object to propogate
+   */
+  function propogateEvent(eventName: string, event: TresEvent) {
+    // Array of objects we've already propogated to
+    const duplicates = []
+
+    // Flag that is set to true when the stopProgatingFn is called
+    const stopPropagatingFn = () => (event.stopPropagating = true)
+    event.stopPropagation = stopPropagatingFn
+
+    // Loop through all intersected objects and call their event handler
+    for (const intersection of event?.intersections) {
+      if (event.stopPropagating) { return }
+
+      // Add intersection data to event object
+      event = { ...event, ...intersection }
+
+      const { object } = intersection
+      event.eventObject = object as TresObject
+      executeEventListeners((object as Record<string, any>)[eventName], event)
+      duplicates.push(object)
+
+      // Propogate the event up the parent chain before moving on to the next intersected object
+      let parentObj = object.parent
+      while (parentObj !== null && !event.stopPropagating) {
+        // We've already been here, break the loop
+        if (duplicates.includes(parentObj)) {
+          break
+        }
+
+        // Sets eventObject to object that registered the event listener
+        event.eventObject = parentObj as TresObject
+        executeEventListeners((parentObj as Record<string, any>)[eventName], event)
+        duplicates.push(parentObj)
+        parentObj = parentObj.parent
+      }
+
+      // Convert eventName to kebab case and emit event from TresCanvas
+      const kebabEventName = hyphenate(eventName.slice(2)) as EmitEventName
+      emit(kebabEventName, { intersection, event })
+    }
+  }
+
+  const {
+    onClick,
+    onDblClick,
+    onContextMenu,
+    onPointerMove,
+    onPointerDown,
+    onPointerUp,
+    onPointerMissed,
+    onWheel,
+    forceUpdate,
+  } = useRaycaster(sceneChildren, context)
+
+  onPointerUp(event => propogateEvent('onPointerUp', event))
+  onPointerDown(event => propogateEvent('onPointerDown', event))
+  onClick(event => propogateEvent('onClick', event))
+  onDblClick(event => propogateEvent('onDoubleClick', event))
+  onContextMenu(event => propogateEvent('onContextMenu', event))
+  onWheel(event => propogateEvent('onWheel', event))
+
+  let prevIntersections: Intersection[] = []
+
+  onPointerMove((event) => {
+    // Current intersections mapped as meshes
+    const hits = event.intersections.map(({ object }) => object)
+
+    // Previously intersected mesh is no longer intersected, fire onPointerLeave
+    prevIntersections.forEach((hit: Intersection) => {
+      if (
+        !hits.includes(hit as unknown as Object3D<Object3DEventMap>)
+      ) {
+        propogateEvent('onPointerLeave', event)
+        propogateEvent('onPointerOut', event)
+      }
+    })
+
+    // Newly intersected mesh is not in the previous intersections, fire onPointerEnter
+    event.intersections.forEach(({ object: hit }) => {
+      if (!prevIntersections.includes(hit as unknown as Intersection)) {
+        propogateEvent('onPointerEnter', event)
+        propogateEvent('onPointerOver', event)
+      }
+    })
+
+    // Fire onPointerMove for all intersected objects
+    propogateEvent('onPointerMove', event)
+
+    // Update previous intersections
+    prevIntersections = hits as unknown as Intersection[]
+  })
+
+  /**
+   * We need to track pointer missed objects separately
+   * since they will not be a part of the raycaster intersection
+   */
+  const pointerMissedObjects: TresObject[] = []
+  onPointerMissed((event: TresEvent) => {
+    // Flag that is set to true when the stopProgatingFn is called
+    const stopPropagatingFn = () => (event.stopPropagating = true)
+    event.stopPropagation = stopPropagatingFn
+
+    pointerMissedObjects.forEach((object: TresObject) => {
+      if (event.stopPropagating) { return }
+
+      // Set eventObject to object that registered the event
+      event.eventObject = object
+
+      executeEventListeners(object.onPointerMissed, event)
+    })
+    // Emit pointer-missed from TresCanvas
+    emit('pointer-missed', { event })
+  })
+
+  function registerPointerMissedObject(object: TresObject) {
+    pointerMissedObjects.push(object)
+  }
+
+  function deregisterPointerMissedObject(object: TresObject) {
+    const index = pointerMissedObjects.indexOf(object)
+    if (index > -1) {
+      pointerMissedObjects.splice(index, 1)
+    }
+  }
+
+  // Attach methods to tres context
+  context.eventManager = {
+    forceUpdate,
+    registerPointerMissedObject,
+    deregisterPointerMissedObject,
+  }
+
+  return { forceUpdate, registerPointerMissedObject, deregisterPointerMissedObject }
+}

+ 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'])
+  })
+})

+ 161 - 0
src/core/loop.ts

@@ -0,0 +1,161 @@
+import type { Ref } from 'vue'
+import { ref, unref } from 'vue'
+import type { Camera, EventDispatcher, Raycaster, Scene, WebGLRenderer } from 'three'
+import { Clock, MathUtils } from 'three'
+import type { Fn } from '@vueuse/core'
+import type { Callback } 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 LoopCallbackWithCtx extends LoopCallback {
+  camera: Camera
+  scene: Scene
+  renderer: WebGLRenderer
+  raycaster: Raycaster
+  controls: Ref<(EventDispatcher<object> & {
+    enabled: boolean
+  }) | null>
+  invalidate: Fn
+  advance: Fn
+}
+
+export type LoopCallbackFn = (params: LoopCallbackWithCtx) => void
+
+export interface RendererLoop {
+  loopId: string
+  register: (callback: LoopCallbackFn, stage: LoopStage, index?: number) => { off: Fn }
+  start: Fn
+  stop: Fn
+  pause: Fn
+  resume: Fn
+  pauseRender: Fn
+  resumeRender: Fn
+  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<LoopCallbackWithCtx> | null = null
+  const subscribersBefore = createPriorityEventHook<LoopCallbackWithCtx>()
+  const subscriberRender = createPriorityEventHook<LoopCallbackWithCtx>()
+  const subscribersAfter = createPriorityEventHook<LoopCallbackWithCtx>()
+
+  // Context to be passed to callbacks
+  let context: Record<string, any> = {}
+
+  function setContext(newContext: Record<string, any>) {
+    context = newContext
+  }
+
+  function registerCallback(callback: LoopCallbackFn, stage: 'before' | 'render' | 'after', index = 0): { off: Fn } {
+    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 snapshotCtx = {
+      camera: unref(context.camera),
+      scene: unref(context.scene),
+      renderer: unref(context.renderer),
+      raycaster: unref(context.raycaster),
+      controls: unref(context.controls),
+      invalidate: context.invalidate,
+      advance: context.advance,
+    }
+    const params = { delta, elapsed, clock, ...snapshotCtx }
+
+    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: LoopCallbackFn, stage: 'before' | 'render' | 'after', index) => registerCallback(callback, stage, index),
+    start,
+    stop,
+    pause,
+    resume,
+    pauseRender,
+    resumeRender,
+    isRenderPaused,
+    isActive,
+    setContext,
+  }
+}

+ 460 - 0
src/core/nodeOps.test.ts

@@ -0,0 +1,460 @@
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import * as THREE from 'three'
+import type { Vector3 } from 'three'
+import { Mesh, Scene } from 'three'
+import type { TresObject } from '../types'
+import { nodeOps as getNodeOps } from './nodeOps'
+import { extend } from './catalogue'
+
+let nodeOps = getNodeOps()
+const pool = []
+
+describe('nodeOps', () => {
+  beforeAll(() => {
+    extend(THREE)
+    nodeOps = getNodeOps()
+    const ce = nodeOps.createElement
+    // NOTE: Overwrite createElement in order to push
+    // all objects into a pool, later to be disposed.
+    nodeOps.createElement = (a, b, c, d) => {
+      const v = ce(a, b, c, d)
+      pool.push(v)
+      return v
+    }
+  },
+  )
+
+  afterAll(() => {
+    // NOTE: Dispose disposable objects.
+    for (const obj of pool) {
+      if (obj && 'dispose' in obj && typeof obj.dispose === 'function') {
+        obj.dispose()
+      }
+    }
+    pool.length = 0
+  })
+
+  describe('createElement', () => {
+    it('creates an instance with given tag', async () => {
+    // Setup
+      const tag = 'TresMesh'
+      const props = { args: [] }
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.isObject3D).toBeTruthy()
+      expect(instance).toBeInstanceOf(Mesh)
+    })
+
+    it('creates an instance with given tag and props', async () => {
+    // Setup
+      const tag = 'TresTorusGeometry'
+      const props = { args: [10, 3, 16, 100] }
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.parameters.radius).toBe(10)
+      expect(instance?.parameters.tube).toBe(3)
+      expect(instance?.parameters.radialSegments).toBe(16)
+      expect(instance?.parameters.tubularSegments).toBe(100)
+    })
+
+    it.skip('creates an camera instance', async () => {
+    // Setup
+      const tag = 'TresPerspectiveCamera'
+      const props = { args: [75, 2, 0.1, 5] }
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.isCamera).toBeTruthy()
+      expect(instance).toBeInstanceOf(THREE.PerspectiveCamera)
+    })
+
+    it.skip('logs a warning if the camera doesnt have a position', async () => {
+    // Setup
+      const tag = 'TresPerspectiveCamera'
+      const props = { args: [75, 2, 0.1, 5] }
+
+      // Spy
+      const consoleWarnSpy = vi.spyOn(console, 'warn')
+      consoleWarnSpy.mockImplementation(() => { })
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.isCamera).toBeTruthy()
+      expect(instance).toBeInstanceOf(THREE.PerspectiveCamera)
+      expect(consoleWarnSpy).toHaveBeenCalled()
+    })
+
+    it('throws an error if passed a "primitive" tag without an "object" prop', () => {
+      expect(() => {
+        nodeOps.createElement('primitive', undefined, undefined, {})
+      }).toThrowError()
+    })
+
+    it('returns null if passed the tag "template"', () => {
+      expect(nodeOps.createElement('template', undefined, undefined, {})).equals(null)
+    })
+
+    it('returns null if passed an HTML tag', () => {
+      for (const htmlTag of ['div', 'h1', 'hr', 'p']) {
+        expect(nodeOps.createElement(htmlTag, undefined, undefined, {})).equals(null)
+      }
+    })
+
+    it('it sets a non-zero position on a camera if no position is provided', () => {
+      const camera = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, {})
+      const position: Vector3 = camera.position
+      assert(['x', 'y', 'z'].some(coord => position[coord] !== 0))
+    })
+
+    it('it calls `camera.lookAt(0, 0, 0)` on a camera if no "look-at" prop is provided', () => {
+      for (const position of [[1, 2, 3], [1, 0, 0], [3, 4, 5], [-1, 2, -10]]) {
+        const cameraA = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, { position })
+        const cameraB = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, { position, lookAt: [0, 0, 0] })
+        assert(cameraA.rotation.equals(cameraB.rotation))
+      }
+    })
+
+    it('throws an error if tag does not exist in catalogue', () => {
+      expect(() => { nodeOps.createElement('THIS_TAG_DOES_NOT_EXIST', undefined, undefined, {}) }).toThrow()
+    })
+
+    it('adds material with "attach" property if instance is a material', () => {
+    // Setup
+      const tag = 'TresMeshStandardMaterial'
+      const props = { args: [] }
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.isMaterial).toBeTruthy()
+      expect(instance?.attach).toBe('material')
+    })
+
+    it('adds attach geometry property if instance is a geometry', () => {
+    // Setup
+      const tag = 'TresTorusGeometry'
+      const props = { args: [] }
+
+      // Test
+      const instance = nodeOps.createElement(tag, undefined, undefined, props)
+
+      // Assert
+      expect(instance?.isBufferGeometry).toBeTruthy()
+      expect(instance?.attach).toBe('geometry')
+    })
+  })
+
+  describe('insert', () => {
+    it('inserts child into parent', async () => {
+    // Setup
+      const parent = new Scene()
+      parent.__tres = {
+        root: {
+          registerCamera: () => { },
+          registerObjectAtPointerEventHandler: () => { },
+        },
+      }
+      const child = new Mesh()
+
+      child.__tres = {
+        root: null,
+      }
+
+      // Fake vnodes
+      child.__vnode = {
+        type: 'TresMesh',
+      }
+
+      // Test
+      nodeOps.insert(child, parent, null)
+
+      // Assert
+      expect(parent.children.includes(child)).toBeTruthy()
+    })
+
+    it('does not insert a falsy child', () => {
+      const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
+      for (const falsyChild of [undefined, null]) {
+        nodeOps.insert(falsyChild, parent)
+        expect(parent.children.length).toBe(0)
+        expect(() => nodeOps.insert(falsyChild, parent)).not.toThrow()
+      }
+    })
+
+    it('inserts Fog as a property', () => {
+      const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
+      const fog = nodeOps.createElement('Fog', undefined, undefined, {})
+      nodeOps.insert(fog, parent)
+      expect(parent.fog).toBe(fog)
+    })
+
+    it('if "attach" prop is provided, sets `parent[attach]`', () => {
+      const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
+      for (const attach of ['material', 'foo', 'bar', 'baz']) {
+        const child = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {})
+        child.attach = attach
+        nodeOps.insert(child, parent)
+        expect(parent[attach]).toBe(child)
+        expect(parent.children.length).toBe(0)
+      }
+    })
+  })
+
+  describe('remove', () => {
+    it('removes child from parent', async () => {
+      const parent = mockTresObjectRootInObject(new Scene() as unknown as TresObject)
+      const child = mockTresObjectRootInObject(new Mesh() as unknown as TresObject)
+      nodeOps.insert(child, parent)
+      nodeOps.remove(child)
+      expect(!parent.children.includes(child)).toBeTruthy()
+    })
+
+    it('silently does not remove a falsy child', () => {
+      for (const child of [undefined, null]) {
+        expect(() => nodeOps.remove(child)).not.toThrow()
+      }
+    })
+
+    it('calls dispose on materials', () => {
+      const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {}))
+      const material = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {})
+      const spy = vi.spyOn(material, 'dispose')
+      nodeOps.insert(material, parent)
+      nodeOps.remove(parent)
+      expect(spy).toHaveBeenCalledOnce()
+    })
+
+    it('calls dispose on geometries', () => {
+      const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {}))
+      const geometry = nodeOps.createElement('SphereGeometry', undefined, undefined, {})
+      const spy = vi.spyOn(geometry, 'dispose')
+      nodeOps.insert(geometry, parent)
+      nodeOps.remove(parent)
+      expect(spy).toHaveBeenCalledOnce()
+    })
+  })
+
+  describe('patchProp', () => {
+    it('patches property of node', async () => {
+    // Setup
+      const node = nodeOps.createElement('Mesh')!
+      const prop = 'visible'
+      const nextValue = false
+
+      // Test
+      nodeOps.patchProp(node, prop, null, nextValue)
+
+      // Assert
+      expect(node.visible === nextValue)
+    })
+
+    it('patches/traverses pierced props', async () => {
+    // Setup
+      const node = nodeOps.createElement('Mesh')!
+      const prop = 'position-x'
+      const nextValue = 5
+
+      // Test
+      nodeOps.patchProp(node, prop, null, nextValue)
+
+      // Assert
+      expect(node.position.x === nextValue)
+    })
+
+    it('does not patch/traverse pierced props of existing dashed properties', async () => {
+    // Setup
+      const node = nodeOps.createElement('Mesh')!
+      const prop = 'cast-shadow'
+      const nextValue = true
+
+      // Test
+      nodeOps.patchProp(node, prop, null, nextValue)
+
+      // Assert
+      expect(node.castShadow === nextValue)
+    })
+
+    it('preserves ALL_CAPS_CASE in pierced props', () => {
+    // Issue: https://github.com/Tresjs/tres/issues/605
+      const { createElement, patchProp } = nodeOps
+      const node = createElement('TresMeshStandardMaterial', undefined, undefined, {})!
+      const allCapsKey = 'STANDARD'
+      const allCapsUnderscoresKey = 'USE_UVS'
+      const allCapsValue = 'hello'
+      const allCapsUnderscoresValue = 'goodbye'
+
+      patchProp(node, `defines-${allCapsKey}`, null, allCapsValue)
+      patchProp(node, `defines-${allCapsUnderscoresKey}`, null, allCapsUnderscoresValue)
+
+      expect(node.defines[allCapsKey]).equals(allCapsValue)
+      expect(node.defines[allCapsUnderscoresKey]).equals(allCapsUnderscoresValue)
+    })
+
+    it('replaces "on*" methods on Object3D', () => {
+      // Issue: https://github.com/Tresjs/tres/issues/360
+      const { createElement, patchProp } = nodeOps
+      const object = createElement('TresObject3D', undefined, undefined, {})
+
+      const onAfterRender = () => {}
+      const onAfterShadow = () => {}
+      const onBeforeRender = () => {}
+      const onBeforeShadow = () => {}
+
+      patchProp(object, 'onAfterRender', null, onAfterRender)
+      patchProp(object, 'onAfterShadow', null, onAfterShadow)
+      patchProp(object, 'onBeforeRender', null, onBeforeRender)
+      patchProp(object, 'onBeforeShadow', null, onBeforeShadow)
+
+      expect(object.onAfterRender).toBe(onAfterRender)
+      expect(object.onAfterShadow).toBe(onAfterShadow)
+      expect(object.onBeforeRender).toBe(onBeforeRender)
+      expect(object.onBeforeShadow).toBe(onBeforeShadow)
+    })
+
+    it('calls object methods', () => {
+      const camera = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, {})
+      const spy = vi.spyOn(camera, 'lookAt')
+      nodeOps.patchProp(camera, 'look-at', undefined, new THREE.Vector3(0, 0, 0))
+      nodeOps.patchProp(camera, 'look-at', undefined, new THREE.Vector3(1, 0, 0))
+      nodeOps.patchProp(camera, 'look-at', undefined, new THREE.Vector3(1, 2, 0))
+      expect(spy).toHaveBeenCalledTimes(3)
+    })
+
+    it('calls `copy` if property and passed value are of same type', () => {
+      const camera = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, {})
+      const spy = vi.spyOn(camera.position, 'copy')
+      nodeOps.patchProp(camera, 'position', undefined, new THREE.Vector3(1))
+      nodeOps.patchProp(camera, 'position', undefined, new THREE.Vector3(2))
+      nodeOps.patchProp(camera, 'position', undefined, new THREE.Vector3(3))
+      expect(spy).toHaveBeenCalledTimes(3)
+    })
+
+    it('calls `setScalar` method', () => {
+      const camera = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, {})
+      const spy = vi.spyOn(camera.position, 'setScalar')
+      nodeOps.patchProp(camera, 'position', undefined, 1)
+      nodeOps.patchProp(camera, 'position', undefined, 2)
+      nodeOps.patchProp(camera, 'position', undefined, 3)
+      expect(spy).toHaveBeenCalledTimes(3)
+    })
+
+    describe('patch `:object` on primitives', () => {
+      it('replaces original object', () => {
+        const material0 = new THREE.MeshNormalMaterial()
+        const material1 = new THREE.MeshNormalMaterial()
+        const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 })
+        nodeOps.patchProp(primitive, 'object', material0, material1)
+        expect(primitive.object).toBe(material1)
+      })
+      it('does not copy UUID', () => {
+        const material0 = new THREE.MeshNormalMaterial()
+        const material1 = new THREE.MeshNormalMaterial()
+        const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 })
+        nodeOps.patchProp(primitive, 'object', material0, material1)
+        expect(material0.uuid).not.toBe(material1.uuid)
+      })
+    })
+
+    describe('patch `:args`', () => {
+      it('updates values appropriately', () => {
+        const args0 = [{ color: new THREE.Color('red') }]
+        const args1 = [{ color: new THREE.Color('blue') }]
+        const material = nodeOps.createElement('MeshBasicMaterial', undefined, undefined, { args: args0 })
+        expect(material.color.getHexString()).toBe('ff0000')
+        nodeOps.patchProp(material, 'args', args0, args1)
+        expect(material.color.getHexString()).toBe('0000ff')
+      })
+      it('creates a new instance', () => {
+        const args0 = [1, 1]
+        const args1 = [2, 3]
+        const geometry = nodeOps.createElement('TresBoxGeometry', undefined, undefined, { args: args0 })
+        const uuid = geometry.uuid
+        nodeOps.patchProp(geometry, 'args', args0, args1)
+        expect(geometry.uuid).not.toBe(uuid)
+      })
+    })
+
+    describe('if property has a `set` method', () => {
+      it('calls `set`', () => {
+        const object3d = nodeOps.createElement('Object3D', undefined, undefined, {})
+        const spy = vi.spyOn(object3d.layers, 'set')
+        const COUNT = 4
+        for (let i = 0; i < COUNT; i++) {
+          const v = Math.floor(Math.random() * 32)
+          nodeOps.patchProp(object3d, 'layers', undefined, v)
+        }
+        expect(spy).toBeCalledTimes(COUNT)
+      })
+
+      it('calls `set` with value if !Array.isArray(value)', () => {
+        const s = v => JSON.stringify(v)
+        const object3d = nodeOps.createElement('Object3D', undefined, undefined, {})
+        let result = -1
+        object3d.layers.set = v => result = v
+        for (let i = 0; i < 3; i++) {
+          const v = Math.floor(Math.random() * 32)
+          nodeOps.patchProp(object3d, 'layers', undefined, v)
+          expect(s(result)).toBe(s(v))
+        }
+      })
+      it('spreads value if it is an array', () => {
+        const s = v => JSON.stringify(v)
+        const camera = nodeOps.createElement('TresPerspectiveCamera', undefined, undefined, {})
+        const result = []
+        camera.position.set = (x, y, z) => result.push({ x, y, z })
+        nodeOps.patchProp(camera, 'position', undefined, [0, 0, 0])
+        nodeOps.patchProp(camera, 'position', undefined, [1, 2, 3])
+        nodeOps.patchProp(camera, 'position', undefined, [4, 5, 6])
+        expect(s(result)).toBe(s([{ x: 0, y: 0, z: 0 }, { x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }]))
+      })
+    })
+  })
+
+  describe('parentNode', () => {
+    it('returns parent of a node', async () => {
+    // Setup
+      const parent: TresObject = new Scene()
+      const child: TresObject = nodeOps.createElement('Mesh')!
+      parent.children.push(child)
+      child.parent = parent
+
+      // Test
+      const parentNode = nodeOps.parentNode(child)
+
+      // Assert
+      expect(parentNode === parent)
+    })
+  })
+})
+
+// NOTE:
+// This is tightly bound to implementation and likely to change.
+//
+// src/core/nodeOps.ts will throw if some implementation details are not
+// present, making tests unpassable.
+//
+// TODO:
+// * Refactor src/core/nodeOps.ts, so that this function can be removed.
+// * Remove this function.
+//
+function mockTresObjectRootInObject(obj) {
+  if (!('__tres' in obj)) {
+    obj.__tres = {}
+  }
+  obj.__tres.root = {
+    deregisterObjectAtPointerEventHandler: () => {},
+    deregisterBlockingObjectAtPointerEventHandler: () => {},
+  }
+  return obj
+}

+ 159 - 105
src/core/nodeOps.ts

@@ -1,11 +1,11 @@
 import type { RendererOptions } from 'vue'
 import { BufferAttribute } from 'three'
 import { isFunction } from '@alvarosabu/utils'
-import type { Camera, Object3D } from 'three'
+import type { Camera } from 'three'
+import type { TresContext } from '../composables'
 import { useLogger } from '../composables'
-import { deepArrayEqual, isHTMLTag, kebabToCamel } from '../utils'
-
-import type { TresObject, TresObject3D, TresScene } from '../types'
+import { deepArrayEqual, disposeObject3D, isHTMLTag, kebabToCamel } from '../utils'
+import type { InstanceProps, TresObject, TresObject3D, TresScene } from '../types'
 import { catalogue } from './catalogue'
 
 function noop(fn: string): any {
@@ -13,19 +13,38 @@ function noop(fn: string): any {
   fn
 }
 
-let scene: TresScene | null = null
-
 const { logError } = useLogger()
 
 const supportedPointerEvents = [
   'onClick',
+  'onContextMenu',
   'onPointerMove',
   'onPointerEnter',
   'onPointerLeave',
+  'onPointerOver',
+  'onPointerOut',
+  'onDoubleClick',
+  'onPointerDown',
+  'onPointerUp',
+  'onPointerCancel',
+  'onPointerMissed',
+  'onLostPointerCapture',
+  'onWheel',
 ]
 
-export const nodeOps: RendererOptions<TresObject, TresObject> = {
-  createElement(tag, _isSVG, _anchor, props) {
+export function invalidateInstance(instance: TresObject) {
+  const ctx = instance?.__tres?.root
+
+  if (!ctx) { return }
+
+  if (ctx.render && ctx.render.canBeInvalidated.value) {
+    ctx.invalidate()
+  }
+}
+
+export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = () => {
+  let scene: TresScene | null = null
+  function createElement(tag: string, _isSVG: undefined, _anchor: any, props: InstanceProps): TresObject | null {
     if (!props) { props = {} }
 
     if (!props.args) {
@@ -34,23 +53,27 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
     if (tag === 'template') { return null }
     if (isHTMLTag(tag)) { return null }
     let name = tag.replace('Tres', '')
-    let instance
+    let instance: TresObject | null
 
     if (tag === 'primitive') {
       if (props?.object === undefined) { logError('Tres primitives need a prop \'object\'') }
       const object = props.object as TresObject
       name = object.type
-      instance = Object.assign(object, { type: name, attach: props.attach, primitive: true })
+      instance = Object.assign(object.clone(), { type: name }) as TresObject
     }
     else {
       const target = catalogue.value[name]
       if (!target) {
-        logError(`${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`)
+        logError(
+          `${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`,
+        )
       }
       // eslint-disable-next-line new-cap
-      instance = new target(...props.args)
+      instance = new target(...props.args) as TresObject
     }
 
+    if (!instance) { return null }
+
     if (instance.isCamera) {
       if (!props?.position) {
         instance.position.set(3, 3, 3)
@@ -65,41 +88,46 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
       else if (instance.isBufferGeometry) { instance.attach = 'geometry' }
     }
 
+    instance.__tres = {
+      ...instance.__tres,
+      type: name,
+      memoizedProps: props,
+      eventCount: 0,
+      disposable: true,
+      primitive: tag === 'primitive',
+    }
+
     // determine whether the material was passed via prop to
     // prevent it's disposal when node is removed later in it's lifecycle
 
-    if (instance.isObject3D) {
-      if (props?.material?.isMaterial) { (instance as TresObject3D).userData.tres__materialViaProp = true }
-      if (props?.geometry?.isBufferGeometry) { (instance as TresObject3D).userData.tres__geometryViaProp = true }
+    if (instance.isObject3D && instance.__tres && (props?.material || props?.geometry)) {
+      instance.__tres.disposable = false
     }
 
-    // Since THREE instances properties are not consistent, (Orbit Controls doesn't have a `type` property)
-    // we take the tag name and we save it on the userData for later use in the re-instancing process.
-    instance.userData = {
-      ...instance.userData,
-      tres__name: name,
+    return instance as TresObject
+  }
+  function insert(child: TresObject, parent: TresObject) {
+    if (!child) { return }
+
+    if (parent && parent.isScene) {
+      scene = parent as unknown as TresScene
     }
 
-    return instance
-  },
-  insert(child, parent) {
-    if (parent && parent.isScene) { scene = parent as unknown as TresScene }
+    if (scene && child.__tres) {
+      child.__tres.root = scene.__tres.root as TresContext
+    }
 
     const parentObject = parent || scene
 
     if (child?.isObject3D) {
+      const { registerCamera } = child?.__tres?.root as TresContext
       if (child?.isCamera) {
-        if (!scene?.userData.tres__registerCamera) { throw new Error('could not find tres__registerCamera on scene\'s userData') }
-
-        scene?.userData.tres__registerCamera?.(child as unknown as Camera)
+        registerCamera(child as unknown as Camera)
       }
 
-      if (
-        child && supportedPointerEvents.some(eventName => child[eventName])
-      ) {
-        if (!scene?.userData.tres__registerAtPointerEventHandler) { throw new Error('could not find tres__registerAtPointerEventHandler on scene\'s userData') }
-
-        scene?.userData.tres__registerAtPointerEventHandler?.(child as Object3D)
+      // Track onPointerMissed objects separate from the scene
+      if (child.onPointerMissed && child?.__tres?.root) {
+        child?.__tres?.root?.eventManager?.registerPointerMissedObject(child)
       }
     }
 
@@ -116,74 +144,83 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
         parentObject[child.attach] = child
       }
     }
-  },
-  remove(node) {
+  }
+
+  function remove(node: TresObject | null) {
     if (!node) { return }
+    const ctx = node.__tres
     // remove is only called on the node being removed and not on child nodes.
+    node.parent = node.parent || scene
 
     if (node.isObject3D) {
-      const object3D = node as unknown as Object3D
-
-      const disposeMaterialsAndGeometries = (object3D: Object3D) => {
-        const tresObject3D = object3D as TresObject3D
-
-        if (!object3D.userData.tres__materialViaProp) {
-          tresObject3D.material?.dispose()
-          tresObject3D.material = undefined
-        }
+      const deregisterCameraIfRequired = (object: TresObject) => {
+        const deregisterCamera = node?.__tres?.root?.deregisterCamera
 
-        if (!object3D.userData.tres__geometryViaProp) {
-          tresObject3D.geometry?.dispose()
-          tresObject3D.geometry = undefined
-        }
+        if ((object as unknown as Camera).isCamera) { deregisterCamera?.(object as unknown as Camera) }
       }
 
-      const deregisterAtPointerEventHandler = scene?.userData.tres__deregisterAtPointerEventHandler
-      const deregisterBlockingObjectAtPointerEventHandler
-        = scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler
-
-      const deregisterAtPointerEventHandlerIfRequired = (object: TresObject) => {
-        if (!deregisterBlockingObjectAtPointerEventHandler) { throw new Error('could not find tres__deregisterBlockingObjectAtPointerEventHandler on scene\'s userData') }
-
-        scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler?.(object as Object3D)
+      node.removeFromParent?.()
 
-        if (!deregisterAtPointerEventHandler) { throw new Error('could not find tres__deregisterAtPointerEventHandler on scene\'s userData') }
+      // Remove nested child objects. Primitives should not have objects and children that are
+      // attached to them declaratively ...
 
-        if (
-          object && supportedPointerEvents.some(eventName => object[eventName])
-        ) { deregisterAtPointerEventHandler?.(object as Object3D) }
-      }
+      node.traverse((child: TresObject) => {
+        deregisterCameraIfRequired(child)
+        // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
+        if (child.onPointerMissed) {
+          ctx?.root?.eventManager?.deregisterPointerMissedObject(child)
+        }
+      })
 
-      const deregisterCameraIfRequired = (object: Object3D) => {
-        const deregisterCamera = scene?.userData.tres__deregisterCamera
+      deregisterCameraIfRequired(node)
+      /*  deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */
+      invalidateInstance(node as TresObject)
 
-        if (!deregisterCamera) { throw new Error('could not find tres__deregisterCamera on scene\'s userData') }
+      // 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 ((object as Camera).isCamera) { deregisterCamera?.(object as Camera) }
+      if (!isPrimitive && node.__tres?.disposable) {
+        disposeObject3D(node)
       }
-
-      node.removeFromParent?.()
-      object3D.traverse((child: Object3D) => {
-        disposeMaterialsAndGeometries(child)
-        deregisterCameraIfRequired(child)
-        deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
-      })
-
-      disposeMaterialsAndGeometries(object3D)
-      deregisterCameraIfRequired(object3D)
-      deregisterAtPointerEventHandlerIfRequired?.(object3D as TresObject)
+      node.dispose?.()
     }
-
-    node.dispose?.()
-  },
-  patchProp(node, prop, _prevValue, nextValue) {
+  }
+  function patchProp(node: TresObject, prop: string, prevValue: any, nextValue: any) {
     if (node) {
       let root = node
       let key = prop
-      if (node.isObject3D && key === 'blocks-pointer-events') {
-        if (nextValue || nextValue === '') { scene?.userData.tres__registerBlockingObjectAtPointerEventHandler?.(node as Object3D) }
-        else { scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler?.(node as Object3D) }
+      if (node?.__tres?.primitive && key === 'object' && prevValue !== null) {
+        // If the prop 'object' is changed, we need to re-instance the object and swap the old one with the new one
+        const newInstance = createElement('primitive', undefined, undefined, {
+          object: nextValue,
+        })
+        for (const subkey in newInstance) {
+          if (subkey === 'uuid') { continue }
+          const target = node[subkey]
+          const value = newInstance[subkey]
+          if (!target?.set && !isFunction(target)) { node[subkey] = value }
+          else if (target.constructor === value.constructor && target?.copy) { target?.copy(value) }
+          else if (Array.isArray(value)) { target.set(...value) }
+          else if (!target.isColor && target.setScalar) { target.setScalar(value) }
+          else { target.set(value) }
+        }
+        if (newInstance?.__tres) {
+          newInstance.__tres.root = scene?.__tres.root
+        }
+        // This code is needed to handle the case where the prop 'object' type change from a group to a mesh or vice versa, otherwise the object will not be rendered correctly (models will be invisible)
+        if (newInstance?.isGroup) {
+          node.geometry = undefined
+          node.material = undefined
+        }
+        else {
+          delete node.isGroup
+        }
+      }
 
+      if (node?.isObject3D && key === 'blocks-pointer-events') {
+        if (nextValue || nextValue === '') { node[key] = nextValue }
+        else { delete node[key] }
         return
       }
 
@@ -192,12 +229,19 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
 
       if (key === 'args') {
         const prevNode = node as TresObject3D
-        const prevArgs = _prevValue ?? []
+        const prevArgs = prevValue ?? []
         const args = nextValue ?? []
-        const instanceName = node.userData.tres__name || node.type
+        const instanceName = node?.__tres?.type || node.type
 
-        if (instanceName && prevArgs.length && !deepArrayEqual(prevArgs, args)) {
-          root = Object.assign(prevNode, new catalogue.value[instanceName](...nextValue))
+        if (
+          instanceName
+          && prevArgs.length
+          && !deepArrayEqual(prevArgs, args)
+        ) {
+          root = Object.assign(
+            prevNode,
+            new catalogue.value[instanceName](...nextValue),
+          )
         }
         return
       }
@@ -216,7 +260,7 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
         const chain = key.split('-')
         target = chain.reduce((acc, key) => acc[kebabToCamel(key)], root)
         key = chain.pop() as string
-        finalKey = key.toLowerCase()
+        finalKey = key
         if (!target?.set) { root = chain.reduce((acc, key) => acc[kebabToCamel(key)], root) }
       }
       let value = nextValue
@@ -228,6 +272,11 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
           if (Array.isArray(value)) { node[finalKey](...value) }
           else { node[finalKey](value) }
         }
+        // NOTE: Set on* callbacks
+        // Issue: https://github.com/Tresjs/tres/issues/360
+        if (finalKey.startsWith('on') && isFunction(value)) {
+          root[finalKey] = value
+        }
         return
       }
       if (!target?.set && !isFunction(target)) { root[finalKey] = value }
@@ -235,24 +284,29 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
       else if (Array.isArray(value)) { target.set(...value) }
       else if (!target.isColor && target.setScalar) { target.setScalar(value) }
       else { target.set(value) }
+
+      invalidateInstance(node as TresObject)
     }
-  },
+  }
 
-  parentNode(node) {
+  function parentNode(node: TresObject) {
     return node?.parent || null
-  },
-  createText: () => noop('createText'),
-  createComment: () => noop('createComment'),
-
-  setText: () => noop('setText'),
-
-  setElementText: () => noop('setElementText'),
-  nextSibling: () => noop('nextSibling'),
-
-  querySelector: () => noop('querySelector'),
-
-  setScopeId: () => noop('setScopeId'),
-  cloneNode: () => noop('cloneNode'),
-
-  insertStaticContent: () => noop('insertStaticContent'),
+  }
+
+  return {
+    insert,
+    remove,
+    createElement,
+    patchProp,
+    parentNode,
+    createText: () => noop('createText'),
+    createComment: () => noop('createComment'),
+    setText: () => noop('setText'),
+    setElementText: () => noop('setElementText'),
+    nextSibling: () => noop('nextSibling'),
+    querySelector: () => noop('querySelector'),
+    setScopeId: () => noop('setScopeId'),
+    cloneNode: () => noop('cloneNode'),
+    insertStaticContent: () => noop('insertStaticContent'),
+  }
 }

+ 0 - 184
src/core/nodeOpts.test.ts

@@ -1,184 +0,0 @@
-import * as THREE from 'three'
-import { Mesh, Scene } from 'three'
-import type { TresObject } from '../types'
-import { nodeOps } from './nodeOps'
-import { extend } from './catalogue'
-
-describe('nodeOps', () => {
-  beforeAll(() => {
-    // Setup
-    extend(THREE)
-  })
-  it('createElement should create an instance with given tag', async () => {
-    // Setup
-    const tag = 'TresMesh'
-    const props = { args: [] }
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.isObject3D).toBeTruthy()
-    expect(instance).toBeInstanceOf(Mesh)
-  })
-
-  it('createElement should create an instance with given tag and props', async () => {
-    // Setup
-    const tag = 'TresTorusGeometry'
-    const props = { args: [10, 3, 16, 100] }
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.parameters.radius).toBe(10)
-    expect(instance.parameters.tube).toBe(3)
-    expect(instance.parameters.radialSegments).toBe(16)
-    expect(instance.parameters.tubularSegments).toBe(100)
-  })
-
-  it.skip('createElement should create an camera instance', async () => {
-    // Setup
-    const tag = 'TresPerspectiveCamera'
-    const props = { args: [75, 2, 0.1, 5] }
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.isCamera).toBeTruthy()
-    expect(instance).toBeInstanceOf(THREE.PerspectiveCamera)
-  })
-
-  it.skip('createElement should log a warning if the camera doesnt have a position', async () => {
-    // Setup
-    const tag = 'TresPerspectiveCamera'
-    const props = { args: [75, 2, 0.1, 5] }
-
-    // Spy
-    const consoleWarnSpy = vi.spyOn(console, 'warn')
-    consoleWarnSpy.mockImplementation(() => { })
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.isCamera).toBeTruthy()
-    expect(instance).toBeInstanceOf(THREE.PerspectiveCamera)
-    expect(consoleWarnSpy).toHaveBeenCalled()
-  })
-
-  it('createElement should add attach material property if instance is a material', () => {
-    // Setup
-    const tag = 'TresMeshStandardMaterial'
-    const props = { args: [] }
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.isMaterial).toBeTruthy()
-    expect(instance.attach).toBe('material')
-  })
-
-  it('createElement should add attach geometry property if instance is a geometry', () => {
-    // Setup
-    const tag = 'TresTorusGeometry'
-    const props = { args: [] }
-
-    // Test
-    const instance = nodeOps.createElement(tag, false, null, props)
-
-    // Assert
-    expect(instance.isBufferGeometry).toBeTruthy()
-    expect(instance.attach).toBe('geometry')
-  })
-
-  it('insert should insert child into parent', async () => {
-    // Setup
-    const parent: TresObject = new Scene()
-    const child: TresObject = new Mesh()
-
-    // Fake vnodes
-    child.__vnode = {
-      type: 'TresMesh',
-    }
-
-    // Test
-    nodeOps.insert(child, parent, null)
-
-    // Assert
-    expect(parent.children.includes(child)).toBeTruthy()
-  })
-
-  it.skip('remove: removes child from parent', async () => {
-    // Setup
-    const parent = new Scene() as unknown as TresObject
-    const child = new Mesh() as unknown as TresObject
-
-    // Fake vnodes
-    child.__vnode = {
-      type: 'TresMesh',
-    }
-    nodeOps.insert(child, parent)
-
-    // Test
-    nodeOps.remove(child)
-
-    // Assert
-    expect(!parent.children.includes(child)).toBeTruthy()
-  })
-
-  it('patchProp should patch property of node', async () => {
-    // Setup
-    const node: TresObject = new Mesh()
-    const prop = 'visible'
-    const nextValue = false
-
-    // Test
-    nodeOps.patchProp(node, prop, null, nextValue)
-
-    // Assert
-    expect(node.visible === nextValue)
-  })
-
-  it('patchProp should patch traverse pierced props', async () => {
-    // Setup
-    const node: TresObject = new Mesh()
-    const prop = 'position-x'
-    const nextValue = 5
-
-    // Test
-    nodeOps.patchProp(node, prop, null, nextValue)
-
-    // Assert
-    expect(node.position.x === nextValue)
-  })
-
-  it('patchProp it should not patch traverse pierced props of existing dashed properties', async () => {
-    // Setup
-    const node: TresObject = new Mesh()
-    const prop = 'cast-shadow'
-    const nextValue = true
-
-    // Test
-    nodeOps.patchProp(node, prop, null, nextValue)
-
-    // Assert
-    expect(node.castShadow === nextValue)
-  })
-
-  it('parentNode: returns parent of a node', async () => {
-    // Setup
-    const parent: TresObject = new Scene()
-    const child: TresObject = new Mesh()
-    parent.children.push(child)
-    child.parent = parent
-
-    // Test
-    const parentNode = nodeOps.parentNode(child)
-
-    // Assert
-    expect(parentNode === parent)
-  })
-})

+ 0 - 12
src/core/renderer.ts

@@ -1,12 +0,0 @@
-import * as THREE from 'three'
-
-import { createRenderer } from 'vue'
-import { extend } from './catalogue'
-import { nodeOps } from './nodeOps'
-
-export const { render } = createRenderer(nodeOps)
-
-// Creates the catalogue of components based on THREE namespace
-extend(THREE)
-
-export default { extend }

+ 1 - 1
src/devtools/highlight.ts

@@ -3,7 +3,7 @@ import * as THREE from 'three'
 export class HightlightMesh extends THREE.Mesh {
   type = 'HightlightMesh'
   createTime: number
-  constructor(...args: THREE.Mesh['args']) {
+  constructor(...args: any[]) {
     super(...args)
     this.createTime = Date.now()
   }

+ 30 - 35
src/devtools/plugin.ts

@@ -5,7 +5,7 @@ import {
   setupDevtoolsPlugin,
 } from '@vue/devtools-api'
 import { reactive } from 'vue'
-import type { Mesh, Object3D } from 'three'
+import type { Mesh } from 'three'
 import { createHighlightMesh, editSceneObject } from '../utils'
 import { bytesToKB, calculateMemoryUsage } from '../utils/perf'
 import type { TresContext } from '../composables'
@@ -91,14 +91,14 @@ const createNode = (object: TresObject): SceneGraphObject => {
   return node
 }
 
-function buildGraph(object: TresObject, node: SceneGraphObject) {
+function buildGraph(object: TresObject, node: SceneGraphObject, filter: string = '') {
   object.children.forEach((child: TresObject) => {
-    if (child.type === 'HightlightMesh') {
-      return
-    }
+    if (child.type === 'HightlightMesh') { return }
+    if (filter && !child.type.includes(filter) && !child.name.includes(filter)) { return }
+
     const childNode = createNode(child)
     node.children.push(childNode)
-    buildGraph(child, childNode)
+    buildGraph(child, childNode, filter)
   })
 }
 
@@ -122,7 +122,6 @@ export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
     (api) => {
       if (typeof api.now !== 'function') {
         toastMessage(
-
           '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.',
         )
       }
@@ -145,43 +144,19 @@ export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
       api.on.getInspectorTree((payload) => {
         if (payload.inspectorId === INSPECTOR_ID) {
           // Your logic here
-          const root = createNode(tres.scene.value)
-          buildGraph(tres.scene.value, root)
+          const root = createNode(tres.scene.value as unknown as TresObject)
+          buildGraph(tres.scene.value as unknown as TresObject, root, payload.filter)
           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
+      let prevInstance: TresObject | null = null
 
       api.on.getInspectorState((payload) => {
         if (payload.inspectorId === INSPECTOR_ID) {
           // Your logic here
-          const [instance] = tres.scene.value.getObjectsByProperty('uuid', payload.nodeId)
+          const [instance] = tres.scene.value.getObjectsByProperty('uuid', payload.nodeId) as TresObject[]
           if (!instance) { return }
           if (prevInstance && highlightMesh && highlightMesh.parent) {
             prevInstance.remove(highlightMesh)
@@ -282,6 +257,26 @@ export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
               },
             ],
           }
+
+          if (instance.isScene) {
+            payload.state.info = {
+              memory: calculateMemoryUsage(instance),
+              objects: instance.children.length,
+              calls: tres.renderer.value.info.render.calls,
+              triangles: tres.renderer.value.info.render.triangles,
+              points: tres.renderer.value.info.render.points,
+              lines: tres.renderer.value.info.render.lines,
+            }
+            payload.state.programs = tres.renderer.value.info.programs?.map(program => ({
+              key: program.name,
+              value: {
+                ...program,
+                vertexShader: program.vertexShader,
+                attributes: program.getAttributes(),
+                uniforms: program.getUniforms(),
+              },
+            })) || []
+          }
         }
       })
 

Some files were not shown because too many files changed in this diff