Parcourir la source

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
Alvaro Saburido il y a 1 an
Parent
commit
f688c6447b

+ 1 - 0
docs/.vitepress/config.ts

@@ -81,6 +81,7 @@ export default defineConfig({
         items: [
           { text: 'Extending', link: '/advanced/extending' },
           { text: 'primitive', link: '/advanced/primitive' },
+          { text: 'Performance', link: '/advanced/performance' },
           {
             text: 'Caveats',
             link: '/advanced/caveats',

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

@@ -0,0 +1,18 @@
+<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>

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

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, SRGBColorSpace, NoToneMapping } from 'three'
+import { OrbitControls } from '@tresjs/cientos'
+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>

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

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { useRenderLoop, useTresContext } from '@tresjs/core'
+import { OrbitControls } from '@tresjs/cientos'
+import { onMounted } from 'vue'
+import { useState } from '../composables/state'
+
+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),
+    
+  }
+}

+ 131 - 0
docs/advanced/performance.md

@@ -0,0 +1,131 @@
+# 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>
+```
+

+ 3 - 0
docs/api/composables.md

@@ -233,4 +233,7 @@ const context = useTresContext()
 | **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`. |
+
 

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

+ 4 - 0
docs/components.d.ts

@@ -7,14 +7,18 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    BlenderCube: typeof import('./.vitepress/theme/components/BlenderCube.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']

+ 1 - 1
docs/package.json

@@ -9,7 +9,7 @@
     "preview": "vitepress preview"
   },
   "dependencies": {
-    "@tresjs/core": "workspace:*"
+    "@tresjs/core": "workspace:^"
   },
   "devDependencies": {
     "unocss": "^0.58.3",

+ 21 - 0
playground/src/pages/rendering-modes/index.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+
+import Scene from './scene.vue'
+
+const clearColor = ref('#82DBC5')
+
+setTimeout(() => {
+  clearColor.value = '#000000'
+}, 3000)
+</script>
+
+<template>
+  <TresCanvas
+    :clear-color="clearColor"
+    render-mode="on-demand"
+    @render="() => console.log('onRender')"
+  >
+    <Scene />
+  </TresCanvas>
+</template>

+ 32 - 0
playground/src/pages/rendering-modes/scene.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+import { useRenderLoop, useTres } from '@tresjs/core'
+import { OrbitControls } from '@tresjs/cientos'
+
+const { invalidate, advance } = useTres()
+
+function onControlChange() {
+  invalidate()
+}
+
+const positionX = ref(0)
+const showMesh = ref(true)
+
+setTimeout(() => {
+  positionX.value = 1
+  /*   showMesh.value = false */
+
+}, 3000)
+</script>
+
+<template>
+  <OrbitControls @change="onControlChange" />
+  <TresGridHelper />
+  <TresMesh
+    v-if="showMesh"
+    :position-x="positionX"
+  >
+    <TresBoxGeometry />
+    <TresMeshNormalMaterial />
+  </TresMesh>
+  <TresAmbientLight :intensity="1" />
+</template>

+ 5 - 0
playground/src/router.ts

@@ -76,6 +76,11 @@ const routes = [
     name: 'Perf',
     component: () => import('./pages/perf/index.vue'),
   },
+  {
+    path: '/rendering-modes',
+    name: 'Rendering Modes',
+    component: () => import('./pages/rendering-modes/index.vue'),
+  },
   {
     path: '/empty',
     name: 'empty',

+ 1 - 1
pnpm-lock.yaml

@@ -130,7 +130,7 @@ importers:
   docs:
     dependencies:
       '@tresjs/core':
-        specifier: workspace:*
+        specifier: workspace:^
         version: link:..
     devDependencies:
       unocss:

+ 7 - 3
src/components/TresCanvas.vue

@@ -46,6 +46,7 @@ export interface TresCanvasProps
   useLegacyLights?: boolean
   outputColorSpace?: ColorSpace
   toneMappingExposure?: number
+  renderMode?: 'always' | 'on-demand' | 'manual' 
 
   // required by useTresContextProvider
   camera?: TresCamera
@@ -66,8 +67,11 @@ const props = withDefaults(defineProps<TresCanvasProps>(), {
   preserveDrawingBuffer: undefined,
   logarithmicDepthBuffer: undefined,
   failIfMajorPerformanceCaveat: undefined,
+  renderMode: 'always',
 })
 
+const emit = defineEmits(['render'])
+
 const { logWarning } = useLogger()
 
 const canvas = ref<HTMLCanvasElement>()
@@ -123,16 +127,16 @@ const disableRender = computed(() => props.disableRender)
 const context = shallowRef<TresContext | null>(null)
 
 defineExpose({ context, dispose: () => dispose(context.value as TresContext, true) })
-
 onMounted(() => {
   const existingCanvas = canvas as Ref<HTMLCanvasElement>
 
   context.value = useTresContextProvider({
     scene: scene.value,
     canvas: existingCanvas,
-    windowSize: props.windowSize,
-    disableRender,
+    windowSize: props.windowSize ?? false,
+    disableRender: disableRender.value ?? false,
     rendererOptions: props,
+    emit,
   })
 
   usePointerEventHandler({ scene: scene.value, contextParts: context.value })

+ 65 - 16
src/composables/useRenderer/index.ts

@@ -1,5 +1,5 @@
 import { Color, WebGLRenderer } from 'three'
-import { shallowRef, watchEffect, onUnmounted, type MaybeRef, computed, watch } from 'vue'
+import { shallowRef, watchEffect, onUnmounted, type MaybeRef, computed, watch, nextTick } from 'vue'
 import {
   toValue,
   unrefElement,
@@ -96,8 +96,8 @@ export interface UseRendererOptions extends TransformToMaybeRefOrGetter<WebGLRen
   clearColor?: MaybeRefOrGetter<TresColor>
   windowSize?: MaybeRefOrGetter<boolean | string>
   preset?: MaybeRefOrGetter<RendererPresetsType>
+  renderMode?: MaybeRefOrGetter<'always' | 'on-demand' | 'manual'>
 }
-
 /**
  * Reactive three.js WebGLRenderer instance
  *
@@ -110,13 +110,15 @@ export function useRenderer(
     canvas,
     options,
     disableRender,
-    contextParts: { sizes, camera },
+    emit,
+    contextParts: { sizes, camera, render, invalidate, advance },
   }:
   {
     canvas: MaybeRef<HTMLCanvasElement>
     scene: Scene
     options: UseRendererOptions
-    contextParts: Pick<TresContext, 'sizes' | 'camera'>
+    emit: (event: string, ...args: any[]) => void
+    contextParts: Pick<TresContext, 'sizes' | 'camera' | 'render'> & { invalidate: () => void; advance: () => void }
     disableRender: MaybeRefOrGetter<boolean>
   },
 ) {
@@ -140,25 +142,61 @@ 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)
   })
 
   const { logError } = useLogger()
 
+  // TheLoop
+
+  const { resume, onLoop } = useRenderLoop()
+
+  onLoop(() => {
+    if (camera.value && !toValue(disableRender) && render.frames.value > 0) {
+      renderer.value.render(scene, camera.value)
+      emit('render', renderer.value)
+    }
+
+    // Reset priority
+    render.priority.value = 0
+
+    if (toValue(options.renderMode) === 'always') {
+      render.frames.value = 1
+    }
+    else {
+      render.frames.value = Math.max(0, render.frames.value - 1)
+    }
+
+  })
+
+  resume()
+
   const getThreeRendererDefaults = () => {
 
     const plainRenderer = new WebGLRenderer()
@@ -179,6 +217,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()
+    }, 1)
+  }
+
   watchEffect(() => {
     const rendererPreset = toValue(options.preset)
 
@@ -189,6 +241,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)
 
@@ -234,17 +293,7 @@ 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()
   })

+ 101 - 29
src/composables/useTresContextProvider/index.ts

@@ -8,6 +8,39 @@ import { useCamera } from '../useCamera'
 import type { UseRendererOptions } from '../useRenderer'
 import { useRenderer } from '../useRenderer'
 import { extend } from '../../core/catalogue'
+import { useLogger } from '../useLogger'
+
+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>
@@ -18,18 +51,16 @@ 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
+  /**
+   * Invalidates the current frame when renderMode === 'on-demand'
+   */
+  invalidate: () => void
+  /**
+     * Advance one frame when renderMode === 'manual'
+     */
+  advance: () => void
   registerCamera: (camera: Camera) => void
   setCameraActive: (cameraOrUuid: Camera | string) => void
   deregisterCamera: (camera: Camera) => void
@@ -41,14 +72,18 @@ export function useTresContextProvider({
   windowSize,
   disableRender,
   rendererOptions,
+  emit,
 }: {
   scene: Scene
   canvas: MaybeRef<HTMLCanvasElement>
   windowSize: MaybeRefOrGetter<boolean>
   disableRender: MaybeRefOrGetter<boolean>
   rendererOptions: UseRendererOptions
+  emit: (event: string, ...args: any[]) => void
 }): TresContext {
 
+  const { logWarning } = useLogger()
+
   const elementSize = computed(() =>
     toValue(windowSize)
       ? useWindowSize()
@@ -83,16 +118,47 @@ export function useTresContextProvider({
     setCameraActive,
   } = useCamera({ sizes, scene })
 
+  // Render state
+
+  const render: RenderState = {
+    mode: ref<'always' | 'on-demand' | 'manual'>(rendererOptions.renderMode || 'always'),
+    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,
@@ -112,13 +178,19 @@ export function useTresContextProvider({
         accumulator: [],
       },
     },
+    render,
+    advance,
     extend,
+    invalidate,
     registerCamera,
     setCameraActive,
     deregisterCamera,
   }
 
-  provide('useTres', toProvide)
+  provide('useTres', ctx)
+
+  // Add context to scene.userData
+  ctx.scene.value.userData.tres__context = ctx
 
   // Performance
   const updateInterval = 100 // Update interval in milliseconds
@@ -131,8 +203,8 @@ export function useTresContextProvider({
 
     // 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
@@ -140,24 +212,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
 
       }
     }
@@ -167,7 +239,7 @@ export function useTresContextProvider({
   let accumulatedTime = 0
   const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second
 
-  const { pause, resume } = useRafFn(({ delta }) => {
+  const { pause } = useRafFn(({ delta }) => {
     if (!window.__TRES__DEVTOOLS__) return
 
     updatePerformanceData({ timestamp: performance.now() })
@@ -177,7 +249,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
@@ -189,7 +261,7 @@ export function useTresContextProvider({
     pause()
   })
 
-  return toProvide
+  return ctx
 }
 
 export function useTresContext(): TresContext {

+ 21 - 1
src/core/nodeOps.ts

@@ -23,6 +23,17 @@ const supportedPointerEvents = [
   'onPointerLeave',
 ]
 
+export function invalidateInstance(instance: TresObject) {
+  const ctx = instance.userData.tres__root?.userData?.tres__context
+  
+  if (!ctx) return
+  
+  if (ctx.render && ctx.render.canBeInvalidated.value) {
+    ctx.invalidate()
+  }
+
+}
+
 export const nodeOps: RendererOptions<TresObject, TresObject> = {
   createElement(tag, _isSVG, _anchor, props) {
     if (!props) props = {}
@@ -81,11 +92,17 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
     return instance
   },
   insert(child, parent) {
-    if (parent && parent.isScene) scene = parent as unknown as TresScene
+    if (parent && parent.isScene) {
+      scene = parent as unknown as TresScene
+      if (child) {
+        child.userData.tres__root = scene
+      }
+    }
 
     const parentObject = parent || scene
 
     if (child?.isObject3D) {
+
       if (child?.isCamera) {
         if (!scene?.userData.tres__registerCamera)
           throw 'could not find tres__registerCamera on scene\'s userData'
@@ -180,6 +197,7 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
       deregisterAtPointerEventHandlerIfRequired?.(object3D as TresObject)
     }
 
+    invalidateInstance(node as TresObject)
     node.dispose?.()
   },
   patchProp(node, prop, _prevValue, nextValue) {
@@ -243,6 +261,8 @@ 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)
     }
   },
 

+ 4 - 1
src/devtools/plugin.ts

@@ -271,12 +271,15 @@ export function registerTresDevtools(app: DevtoolsApp, tres: TresContext) {
                 key: 'matrixWorld',
                 value: instance.matrixWorld,
               },
-                
               {
                 key: 'visible',
                 editable: true,
                 value: instance.visible,
               },
+              {
+                key: 'userData',
+                value: instance.userData,
+              },
             ],
           }
         }