소스 검색

feat(createRenderer): add createRenderer

Peter 7 달 전
부모
커밋
66f1b521da

+ 21 - 0
playground/vue/src/pages/advanced/WebGPU.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { WebGPURenderer } from 'three/webgpu'
+</script>
+
+<template>
+  <TresCanvas :renderer="async (context) => await new WebGPURenderer(context.props)">
+    <TresPerspectiveCamera
+      :position="[3, 3, 3]"
+      :look-at="[0, 0, 0]"
+    />
+    <TresGroup>
+      <TresMesh :position="[0, 0, 0]">
+        <TresBoxGeometry />
+        <TresMeshToonMaterial :color="0x00FF00" />
+      </TresMesh>
+    </TresGroup>
+
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
+</template>

+ 5 - 0
playground/vue/src/router/routes/advanced.ts

@@ -49,6 +49,11 @@ export const advancedRoutes = [
     name: 'Disposal',
     component: () => import('../../pages/advanced/disposal/index.vue'),
   },
+  {
+    path: '/advanced/webgpu',
+    name: 'WebGPU',
+    component: () => import('../../pages/advanced/WebGPU.vue'),
+  },
   {
     path: '/advanced/memory-tres-objects',
     name: 'Memory Test: Tres Objects',

+ 7 - 8
src/components/TresCanvas.vue

@@ -5,9 +5,9 @@ import type {
   ToneMapping,
   WebGLRendererParameters,
 } from 'three'
-import type { App, MaybeRef, MaybeRefOrGetter, Ref } from 'vue'
+import type { App, MaybeRefOrGetter, Ref } from 'vue'
 import type { RendererPresetsType } from '../composables/useRenderer/const'
-import type { TresCamera, TresObject, TresScene } from '../types/'
+import type { Renderer, TresCamera, TresObject, TresScene } from '../types/'
 import type { EventsProps } from '../utils/createEvents/createEvents'
 import { PerspectiveCamera, Scene } from 'three'
 
@@ -40,6 +40,7 @@ import { disposeObject3D } from '../utils/'
 
 export interface TresCanvasProps
   extends Omit<WebGLRendererParameters, 'canvas'> {
+  renderer?: Renderer | ((ctx: TresContext) => Renderer) | ((ctx: TresContext) => Promise<Renderer>)
   // required by useRenderer
   shadows?: boolean
   clearColor?: string
@@ -155,9 +156,8 @@ const mountCustomRenderer = (context: TresContext, empty = false) => {
 const dispose = (context: TresContext, force = false) => {
   disposeObject3D(context.scene.value as unknown as TresObject)
   if (force) {
-    context.renderer.value.dispose()
-    context.renderer.value.renderLists.dispose()
-    context.renderer.value.forceContextLoss()
+    context.renderer.value.dispose();
+    (context.renderer.value as Record<string, any>).forceContextLoss?.()
   }
   (scene.value as TresScene).__tres = {
     root: context,
@@ -178,14 +178,13 @@ const unmountCanvas = () => {
   mountCustomRenderer(context.value as TresContext, true)
 }
 
-onMounted(() => {
+onMounted(async () => {
   const existingCanvas = canvas as Ref<HTMLCanvasElement>
 
-  context.value = useTresContextProvider({
+  context.value = await useTresContextProvider({
     scene: scene.value as TresScene,
     canvas: existingCanvas,
     windowSize: props.windowSize ?? false,
-    rendererOptions: props,
     props,
     emit,
   })

+ 19 - 100
src/composables/useTresContextProvider/index.ts

@@ -1,21 +1,18 @@
-import type { Camera, WebGLRenderer } from 'three'
+import type { Camera } from 'three'
 import type { ComputedRef, DeepReadonly, MaybeRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
 import type { RendererLoop } from '../../core/loop'
-import type { EmitEventFn, TresControl, TresObject, TresScene } from '../../types'
-import type { UseRendererOptions } from '../useRenderer'
-import { useFps, useMemory, useRafFn } from '@vueuse/core'
+import type { EmitEventFn, Renderer, TresControl, TresScene } from '../../types'
 import { Raycaster } from 'three'
-import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue'
+import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef, toValue } from 'vue'
 import { extend } from '../../core/catalogue'
 import { createRenderLoop } from '../../core/loop'
 import { type Events, useEventsOptions as withEventsProps } from '../../utils/createEvents'
-import { calculateMemoryUsage } from '../../utils/perf'
 
 import { useCamera } from '../useCamera'
-import { useRenderer } from '../useRenderer'
 import useSizes, { type SizesType } from '../useSizes'
 import { useTresReady } from '../useTresReady'
-import TresCanvas, { type TresCanvasProps } from 'src/components/TresCanvas.vue'
+import { withRendererProps } from '../../utils/createRenderer/withRendererProps'
+import type { TresCanvasProps } from 'src/components/TresCanvas.vue'
 
 export interface InternalState {
   priority: Ref<number>
@@ -57,7 +54,7 @@ export interface TresContext {
   camera: ComputedRef<Camera | undefined>
   cameras: DeepReadonly<Ref<Camera[]>>
   controls: Ref<TresControl | null>
-  renderer: ShallowRef<WebGLRenderer>
+  renderer: ShallowRef<Renderer>
   raycaster: ShallowRef<Raycaster>
   perf: PerformanceState
   render: RenderState
@@ -82,22 +79,20 @@ export interface TresContext {
   props: TresCanvasProps
 }
 
-export function useTresContextProvider({
+export async function useTresContextProvider({
   scene,
   canvas,
   windowSize,
-  rendererOptions,
   props,
   emit,
 }: {
   scene: TresScene
   canvas: MaybeRef<HTMLCanvasElement>
   windowSize: MaybeRefOrGetter<boolean>
-  rendererOptions: UseRendererOptions
   props: TresCanvasProps
   emit: EmitEventFn
 
-}): TresContext {
+}): Promise<TresContext> {
   const localScene = shallowRef<TresScene>(scene)
   const sizes = useSizes(windowSize, canvas)
 
@@ -112,7 +107,7 @@ export function useTresContextProvider({
   // Render state
 
   const render: RenderState = {
-    mode: ref(rendererOptions.renderMode || 'always') as Ref<'always' | 'on-demand' | 'manual'>,
+    mode: ref(props.renderMode || 'always') as Ref<'always' | 'on-demand' | 'manual'>,
     priority: ref(0),
     frames: ref(0),
     maxFrames: 60,
@@ -121,34 +116,22 @@ export function useTresContextProvider({
 
   function invalidate(frames = 1) {
     // Increase the frame count, ensuring not to exceed a maximum if desired
-    if (rendererOptions.renderMode === 'on-demand') {
+    if (props.renderMode === 'on-demand') {
       render.frames.value = Math.min(render.maxFrames, render.frames.value + frames)
     }
   }
 
   function advance() {
-    if (rendererOptions.renderMode === 'manual') {
+    if (props.renderMode === 'manual') {
       render.frames.value = 1
     }
   }
 
-  const { renderer } = useRenderer(
-    {
-      scene,
-      canvas,
-      options: rendererOptions,
-      emit,
-      // TODO: replace contextParts with full ctx at https://github.com/Tresjs/tres/issues/516
-      contextParts: { sizes, camera, render, invalidate, advance },
-    },
-  )
-
-  const partialContext: Omit<TresContext, 'events'> & { events?: Events } = {
+  const ctx = {
     sizes,
     scene: localScene,
     camera,
     cameras: readonly(cameras),
-    renderer,
     raycaster: shallowRef(new Raycaster()),
     controls: ref(null),
     perf: {
@@ -164,6 +147,8 @@ export function useTresContextProvider({
       },
     },
     render,
+    renderer: shallowRef(null as unknown as Renderer),
+    events: null as unknown as Events,
     advance,
     extend,
     invalidate,
@@ -175,21 +160,21 @@ export function useTresContextProvider({
     emit,
   }
 
-  partialContext.events = withEventsProps(partialContext as TresContext).events
-  const ctx = partialContext as TresContext
-
   provide('useTres', ctx)
 
+  const r = (await withRendererProps(ctx as TresContext, { canvas: toValue(canvas) }))
+  ctx.renderer = r.renderer
+  ctx.events = withEventsProps(ctx as TresContext).events
+
   // 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)
+      ctx.renderer.value.render(scene, camera.value)
       emit('render', ctx.renderer.value)
     }
 
@@ -219,72 +204,6 @@ export function useTresContextProvider({
     ctx.loop.stop()
   })
 
-  // Performance
-  const updateInterval = 100 // Update interval in milliseconds
-  const fps = useFps({ every: updateInterval })
-  const { isSupported, memory } = useMemory({ interval: updateInterval })
-  const maxFrames = 160
-  let lastUpdateTime = performance.now()
-
-  const updatePerformanceData = ({ timestamp }: { timestamp: number }) => {
-    // Update WebGL Memory Usage (Placeholder for actual logic)
-    // perf.memory.value = calculateMemoryUsage(gl)
-    if (ctx.scene.value) {
-      ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject)
-    }
-
-    // Update memory usage
-    if (timestamp - lastUpdateTime >= updateInterval) {
-      lastUpdateTime = timestamp
-
-      // Update FPS
-      ctx.perf.fps.accumulator.push(fps.value as never)
-
-      if (ctx.perf.fps.accumulator.length > maxFrames) {
-        ctx.perf.fps.accumulator.shift()
-      }
-
-      ctx.perf.fps.value = fps.value
-
-      // Update memory
-      if (isSupported.value && memory.value) {
-        ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never)
-
-        if (ctx.perf.memory.accumulator.length > maxFrames) {
-          ctx.perf.memory.accumulator.shift()
-        }
-
-        ctx.perf.memory.currentMem
-        = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length
-      }
-    }
-  }
-
-  // Devtools
-  let accumulatedTime = 0
-  const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second
-
-  const { pause } = useRafFn(({ delta }) => {
-    if (!window.__TRES__DEVTOOLS__) { return }
-
-    updatePerformanceData({ timestamp: performance.now() })
-
-    // Accumulate the delta time
-    accumulatedTime += delta
-
-    // Check if the accumulated time is greater than or equal to the interval
-    if (accumulatedTime >= interval) {
-      window.__TRES__DEVTOOLS__.cb(ctx)
-
-      // Reset the accumulated time
-      accumulatedTime = 0
-    }
-  }, { immediate: true })
-
-  onUnmounted(() => {
-    pause()
-  })
-
   return ctx
 }
 

+ 1 - 0
src/types/index.ts

@@ -26,6 +26,7 @@ export interface TresCatalogue {
 export type EmitEventName = 'render' | 'ready'
 export type EmitEventFn = (event: EmitEventName, ...args: any[]) => void
 export type TresCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera
+export interface Renderer { render: (scene: THREE.Scene, camera: THREE.Camera) => any }
 
 /**
  * Represents the properties of an instance.

+ 73 - 0
src/utils/createDevtools/createDevtools.ts

@@ -0,0 +1,73 @@
+import { useFps, useMemory, useRafFn } from '@vueuse/core'
+import { calculateMemoryUsage } from '../../utils/perf'
+import type { TresContext } from '../../composables'
+import type { TresObject } from '../../types'
+import { onUnmounted } from 'vue'
+
+export function createDevtools(ctx: TresContext) {
+  // Performance
+  const updateInterval = 100 // Update interval in milliseconds
+  const fps = useFps({ every: updateInterval })
+  const { isSupported, memory } = useMemory({ interval: updateInterval })
+  const maxFrames = 160
+  let lastUpdateTime = performance.now()
+
+  const updatePerformanceData = ({ timestamp }: { timestamp: number }) => {
+    // Update WebGL Memory Usage (Placeholder for actual logic)
+    // perf.memory.value = calculateMemoryUsage(gl)
+    if (ctx.scene.value) {
+      ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject)
+    }
+
+    // Update memory usage
+    if (timestamp - lastUpdateTime >= updateInterval) {
+      lastUpdateTime = timestamp
+
+      // Update FPS
+      ctx.perf.fps.accumulator.push(fps.value as never)
+
+      if (ctx.perf.fps.accumulator.length > maxFrames) {
+        ctx.perf.fps.accumulator.shift()
+      }
+
+      ctx.perf.fps.value = fps.value
+
+      // Update memory
+      if (isSupported.value && memory.value) {
+        ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never)
+
+        if (ctx.perf.memory.accumulator.length > maxFrames) {
+          ctx.perf.memory.accumulator.shift()
+        }
+
+        ctx.perf.memory.currentMem
+        = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length
+      }
+    }
+  }
+
+  // Devtools
+  let accumulatedTime = 0
+  const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second
+
+  const { pause } = useRafFn(({ delta }) => {
+    if (!window.__TRES__DEVTOOLS__) { return }
+
+    updatePerformanceData({ timestamp: performance.now() })
+
+    // Accumulate the delta time
+    accumulatedTime += delta
+
+    // Check if the accumulated time is greater than or equal to the interval
+    if (accumulatedTime >= interval) {
+      window.__TRES__DEVTOOLS__.cb(ctx)
+
+      // Reset the accumulated time
+      accumulatedTime = 0
+    }
+  }, { immediate: true })
+
+  onUnmounted(() => {
+    pause()
+  })
+}

+ 4 - 1
src/utils/createEvents/createEvents.ts

@@ -162,5 +162,8 @@ export function createEvents<TConfig, TCtx, TEvent, TIntersection, TObj, TSource
     isEvents: true,
   }
 
-  return addDeprecatedMethods(result)
+  // NOTE: Returning `result` directly doesn't require a type assertion.
+  // But otherwise, `isEvents` is typed as `boolean`, when the type requires `true`.
+  // So we need the assertion.
+  return addDeprecatedMethods(result) as CreateEventsReturn<TEvent, TIntersection, TObj, TSource>
 }

+ 6 - 8
src/utils/createEvents/eventsRaycast.ts

@@ -23,11 +23,7 @@ type ThreeEventStub<DomEvent> = Omit<ThreeEvent<DomEvent>, 'eventObject' | 'obje
 type Object3DWithEvents = Object3D & EventHandlers & PointerCaptureTarget
 
 function getInitialEvent() {
-  // NOTE: Unit tests will without this check
-  if (typeof PointerEvent !== 'undefined') {
-    return new PointerEvent('pointermove')
-  }
-  return new MouseEvent('mousemove')
+  return { type: 'pointermove', offsetX: 0, offsetY: 0, target: null } as PointerEvent
 }
 
 type RaycastProps = CreateEventsProps<
@@ -75,7 +71,9 @@ function getInitialConfig(context: TresContext) {
 
 function stashLastEvent(evt: Event, config: Config) {
   if (evt.type === 'pointermove') {
-    config.lastMoveEvent = evt as PointerEvent
+    // NOTE: We need to copy the event, but events can't be spread.
+    // Concretely, we only use a few fields from stashed events, so copy them explicitly.
+    config.lastMoveEvent = { type: 'pointermove', offsetX: (evt as PointerEvent).offsetX, offsetY: (evt as PointerEvent).offsetY, target: evt.target } as PointerEvent
   }
 }
 
@@ -184,7 +182,7 @@ const patchPropDomEventRE = new RegExp(`${THREE_EVENT_NAMES.join('|')}`)
 const patchPropDomEventWithUnsupportedModifiersRE = new RegExp(`(${THREE_EVENT_NAMES.join('|')})(Capture|Passive|Once)+`)
 let sentUnsupportedEventModifiersWarning = false
 
-function patchProp(instance: TresObject, propName: string, prevValue: any, nextValue: any, config: Config) {
+function patchProp(instance: TresObject, propName: string, _prevValue: any, nextValue: any, config: Config) {
   if (!is.object3D(instance)) { return false }
 
   propName = deprecatedEventsToNewEvents(propName)
@@ -530,7 +528,7 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
   // NOTE: Set the `capturableEvent`. If this is left
   // as falsy, it does not allow capturing within
   // event handlers.
-  if (incomingEvent.type.startsWith('pointer') && !(incomingEvent.type === 'pointerup' || incomingEvent.type === 'pointercancel')) {
+  if (incomingEvent.type?.startsWith('pointer') && !(incomingEvent.type === 'pointerup' || incomingEvent.type === 'pointercancel')) {
     config.capturableEvent = outgoingEvent as ThreeEvent<PointerEvent>
   }
 

+ 1 - 1
src/utils/createEvents/index.ts

@@ -3,4 +3,4 @@ import { eventsNoop as disableEvents } from './eventsNoop'
 import { eventsRaycast } from './eventsRaycast'
 import { withEventsProps } from './withEventsProps'
 
-export { createEvents, disableEvents, Events as Events, EventsProps as EventsProps, eventsRaycast as raycastProps, withEventsProps as useEventsOptions }
+export { createEvents, disableEvents, Events, EventsProps, eventsRaycast as raycastProps, withEventsProps as useEventsOptions }

+ 3 - 1
src/utils/createEvents/useEvents.ts

@@ -1,3 +1,5 @@
 import { useTresContext } from 'src/composables'
 
-export function useEvents() { return useTresContext().events }
+export function useEvents() {
+  return useTresContext().events
+}

+ 28 - 0
src/utils/createRenderer/createRenderer.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { createRenderer } from './createRenderer'
+import type { Renderer } from '../../types'
+import type { TresContext } from 'src/composables'
+
+describe('createRenderer', () => {
+  describe('createRenderer({ props: { renderer } } )', () => {
+    it('returns `renderer`', async () => {
+      const r = {} as Renderer
+      const rr = await createRenderer({ props: { renderer: r } } as TresContext)
+      expect(rr).toBe(r)
+    })
+  })
+  describe('createRenderer({ props: { renderer: () => Renderer } } )', () => {
+    it('returns `renderer`', async () => {
+      const r = {} as Renderer
+      const rr = await createRenderer({ props: { renderer: () => r } } as unknown as TresContext)
+      expect(rr).toBe(r)
+    })
+  })
+  describe('createRenderer({ props: { renderer: () => Promise<Renderer> } } )', () => {
+    it('returns `renderer`', async () => {
+      const r = {} as Renderer
+      const rr = await createRenderer({ props: { renderer: () => new Promise(resolve => resolve(r)) } } as unknown as TresContext)
+      expect(rr).toBe(r)
+    })
+  })
+})

+ 18 - 0
src/utils/createRenderer/createRenderer.ts

@@ -0,0 +1,18 @@
+import type { TresContext } from 'src/composables'
+import type { Renderer } from 'src/types'
+import { ACESFilmicToneMapping, WebGLRenderer } from 'three'
+
+export async function createRenderer(ctx: TresContext, rendererParameters: Record<string, unknown> = {}) {
+  if (ctx?.props?.renderer) {
+    const fnOrRenderer = ctx.props.renderer
+    if (typeof fnOrRenderer === 'function') {
+      return await fnOrRenderer(ctx, rendererParameters)
+    }
+    else {
+      return fnOrRenderer
+    }
+  }
+  const renderer = new WebGLRenderer({ ...rendererParameters })
+  renderer.toneMapping = ACESFilmicToneMapping
+  return renderer as Renderer
+}

+ 5 - 0
src/utils/createRenderer/useRenderer.ts

@@ -0,0 +1,5 @@
+import { useTresContext } from 'src/composables'
+
+export function useRenderer() {
+  return useTresContext().renderer
+}

+ 214 - 0
src/utils/createRenderer/withRendererProps.test.ts

@@ -0,0 +1,214 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { withRendererProps } from './withRendererProps'
+import type { TresContext } from 'src/composables'
+import { ref, shallowReactive, toValue } from 'vue'
+import { ACESFilmicToneMapping, Color, LinearSRGBColorSpace, LinearToneMapping, NoColorSpace, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, Scene, SRGBColorSpace, VSMShadowMap } from 'three'
+
+describe('withRendererProps', () => {
+  let context = { props: shallowReactive({ renderer: {}, shadows: true }), scene: new Scene(), sizes: { width: ref(100), height: ref(100) } } as unknown as TresContext
+  beforeEach(() => {
+    context = { props: shallowReactive({ renderer: {}, shadows: true }), scene: new Scene(), sizes: { width: ref(100), height: ref(100) } } as unknown as TresContext
+  })
+
+  describe('withRendererProps({ props: { renderer } } )', () => {
+    it('returns { renderer: shallowRef(renderer), stop: () => {} }', async () => {
+      context.props.renderer = { render: () => {} }
+      const result = await withRendererProps(context)
+      expect(toValue(result.renderer)).toBe(context.props.renderer)
+      expect(typeof result.stop).toBe('function')
+    })
+  })
+
+  describe('`sizes`', () => {
+    it('updates call `renderer.setSize(width, height)`', async () => {
+      // @ts-expect-error - allow overwriting a read-only value
+      context.sizes.width.value = 99
+      // @ts-expect-error - allow overwriting a read-only value
+      context.sizes.height.value = 100
+
+      const setSize = vi.fn()
+      context.props.renderer = { setSize, render: () => {} }
+      await withRendererProps(context)
+      expect(setSize).toBeCalledWith(99, 100)
+
+      // @ts-expect-error - allow overwriting a read-only value
+      context.sizes.width.value = 200
+      expect(setSize).toBeCalledWith(200, 100)
+      // @ts-expect-error - allow overwriting a read-only value
+      context.sizes.height.value = 99
+      expect(setSize).toBeCalledWith(200, 99)
+    })
+  })
+
+  describe('context.props', () => {
+    describe('shadows', () => {
+      it('updates `renderer.shadowMap.enabled`', async () => {
+        context.props.renderer = { shadowMap: { enabled: true }, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+
+        expect(renderer.shadowMap.enabled).toBe(true)
+
+        context.props.shadows = false
+        expect(renderer.shadowMap.enabled).toBe(false)
+
+        context.props.shadows = true
+        expect(renderer.shadowMap.enabled).toBe(true)
+      })
+
+      it('sets `renderer.shadowMap.enabled` to its initial value if `props.shadows` is `null | undefined`', async () => {
+        context.props.renderer = { shadowMap: { enabled: true }, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+        context.props.shadows = false
+        expect(renderer.shadowMap.enabled).toBe(false)
+        context.props.shadows = null
+        expect(renderer.shadowMap.enabled).toBe(true)
+
+        context.props.renderer = { shadowMap: { enabled: false }, render: () => {} }
+        const renderer0 = (await withRendererProps(context)).renderer.value
+        context.props.shadows = true
+        expect(renderer.shadowMap.enabled).toBe(true)
+        context.props.shadows = undefined
+        expect(renderer0.shadowMap.enabled).toBe(false)
+      })
+
+      it('updates `renderer.shadowMap.type` when `props.shadowMapType` is set', async () => {
+        context.props.renderer = { shadowMap: { type: PCFShadowMap }, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+        expect(renderer.shadowMap.type).toBe(PCFShadowMap)
+
+        context.props.shadowMapType = PCFSoftShadowMap
+        expect(renderer.shadowMap.type).toBe(PCFSoftShadowMap)
+
+        context.props.shadowMapType = VSMShadowMap
+        expect(renderer.shadowMap.type).toBe(VSMShadowMap)
+      })
+
+      it('sets `renderer.shadowMap.type` to its initial value if `props.shadowMapType` is `null | undefined`', async () => {
+        context.props.renderer = { shadowMap: { type: PCFShadowMap }, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+        context.props.shadowMapType = VSMShadowMap
+        context.props.shadowMapType = null
+        expect(renderer.shadowMap.type).toBe(PCFShadowMap)
+
+        context.props.renderer = { shadowMap: { type: VSMShadowMap }, render: () => {} }
+        const renderer0 = (await withRendererProps(context)).renderer.value
+        context.props.shadowMapType = PCFShadowMap
+        context.props.shadowMapType = undefined
+        expect(renderer0.shadowMap.type).toBe(VSMShadowMap)
+      })
+
+      it('does not throw if `renderer` has no `shadowMap`', async () => {
+        expect(async () => {
+          context.props.renderer = { render: () => {} }
+          await withRendererProps(context)
+          context.props.shadowMapType = PCFSoftShadowMap
+        }).not.toThrow()
+      })
+    })
+
+    describe('toneMapping', () => {
+      it('updates `renderer.toneMapping` when `props.toneMapping` is set', async () => {
+        context.props.renderer = { toneMapping: NoToneMapping, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+
+        context.props.toneMapping = LinearToneMapping
+        expect(renderer.toneMapping).toBe(LinearToneMapping)
+
+        context.props.toneMapping = NoToneMapping
+        expect(renderer.toneMapping).toBe(NoToneMapping)
+      })
+
+      it('is set to initial `toneMapping` value if props.toneMapping is `null` or `undefined`', async () => {
+        context.props.renderer = { toneMapping: NoToneMapping, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+        context.props.toneMapping = ACESFilmicToneMapping
+        context.props.toneMapping = null
+        expect(renderer.toneMapping).toBe(NoToneMapping)
+
+        context.props.renderer = { toneMapping: ACESFilmicToneMapping, render: () => {} }
+        const renderer0 = (await withRendererProps(context)).renderer.value
+        context.props.toneMapping = NoToneMapping
+        context.props.toneMapping = null
+        expect(renderer0.toneMapping).toBe(ACESFilmicToneMapping)
+      })
+    })
+
+    describe('toneMappingExposure', () => {
+      it('updates `renderer.toneMappingExposure`', async () => {
+        context.props.renderer = { toneMappingExposure: 3, render: () => {} }
+        const renderer = (await withRendererProps(context)).renderer.value
+        expect(renderer.toneMappingExposure).toBe(3)
+
+        context.props.toneMappingExposure = 2
+        expect(renderer.toneMappingExposure).toBe(2)
+
+        context.props.toneMappingExposure = 1
+        expect(renderer.toneMappingExposure).toBe(1)
+      })
+    })
+
+    describe('outputColorSpace', () => {
+      it('updates `renderer.outputColorSpace`', async () => {
+        context.props.renderer = {
+          _outputColorSpace: SRGBColorSpace,
+          get outputColorSpace() { return this._outputColorSpace },
+          set outputColorSpace(c: string) { this._outputColorSpace = c },
+          render: () => {},
+        }
+        context.props.outputColorSpace = SRGBColorSpace
+        const renderer = (await withRendererProps(context)).renderer.value
+        expect(renderer.outputColorSpace).toBe(SRGBColorSpace)
+
+        context.props.outputColorSpace = LinearSRGBColorSpace
+        expect(renderer.outputColorSpace).toBe(LinearSRGBColorSpace)
+
+        context.props.outputColorSpace = NoColorSpace
+        expect(renderer.outputColorSpace).toBe(NoColorSpace)
+      })
+
+      it('sets `renderer.outputColorSpace` to its initial value if `props.outputColorSpace` is `null | undefined`', async () => {
+        context.props.renderer = { outputColorSpace: SRGBColorSpace, render: () => {} }
+        context.props.outputColorSpace = NoColorSpace
+        const renderer = (await withRendererProps(context)).renderer.value
+        expect(renderer.outputColorSpace).toBe(NoColorSpace)
+
+        context.props.outputColorSpace = undefined
+        expect(renderer.outputColorSpace).toBe(SRGBColorSpace)
+      })
+    })
+
+    describe('clearColor', () => {
+      it('calls renderer.setClearColor()', async () => {
+        const fn = vi.fn()
+        context.props.renderer = { setClearColor: fn, render: () => {} }
+        context.props.clearColor = 'black'
+        await withRendererProps(context)
+        expect(fn).toHaveBeenCalledTimes(1)
+
+        context.props.clearColor = 'red'
+        expect(fn).toHaveBeenCalledTimes(2)
+      })
+
+      it('transforms the argument to a `THREE.Color`', async () => {
+        let color = new Color()
+        const fn = vi.fn((c: Color) => color = c)
+        context.props.renderer = { setClearColor: fn, render: () => {} }
+        context.props.clearColor = 'black'
+        await withRendererProps(context)
+        expect([color.r, color.g, color.b]).toStrictEqual([0, 0, 0])
+
+        context.props.clearColor = 'red'
+        expect([color.r, color.g, color.b]).toStrictEqual([1, 0, 0])
+
+        context.props.clearColor = 'blue'
+        expect([color.r, color.g, color.b]).toStrictEqual([0, 0, 1])
+
+        context.props.clearColor = '#FF00FF'
+        expect([color.r, color.g, color.b]).toStrictEqual([1, 0, 1])
+
+        context.props.clearColor = '#0F0'
+        expect([color.r, color.g, color.b]).toStrictEqual([0, 1, 0])
+      })
+    })
+  })
+})

+ 95 - 0
src/utils/createRenderer/withRendererProps.ts

@@ -0,0 +1,95 @@
+import type { TresContext } from 'src/composables'
+import { clamp } from 'three/src/math/MathUtils.js'
+import { createRenderer } from './createRenderer'
+import { type ShallowRef, shallowRef, watch, watchEffect } from 'vue'
+import { useDevicePixelRatio } from '@vueuse/core'
+import * as is from '../is'
+import { normalizeColor } from '../normalize'
+import type { Renderer } from '../../types'
+
+export async function withRendererProps(context: TresContext, rendererParameters: Record<string, unknown> = {}): Promise<{ renderer: ShallowRef<Renderer>, stop: () => void }> {
+  const renderer = shallowRef({ render: () => {}, dispose: () => {} } as Renderer)
+  const defaults = shallowRef({} as Record<string, any>)
+
+  const stopRenderer = watch(() => context.props.renderer, () => {
+    renderer.value.dispose?.()
+    renderer.value.forceContextLoss?.()
+    renderer.value = { render: () => {}, dispose: () => {} }
+
+    createRenderer(context, rendererParameters).then((r) => {
+      defaults.value = Object.entries(r).filter(([_, v]) => !is.obj(v) && !is.fun(v) && !is.arr(v)).reduce((defaults, [k, v]) => { defaults[k] = v; return defaults }, {} as Record<string, any>)
+      if (r.shadowMap) {
+        defaults.value.shadowMap = Object.entries(r.shadowMap).filter(([_, v]) => !is.obj(v) && !is.fun(v) && !is.arr(v)).reduce((shadowMap, [k, v]) => { shadowMap[k] = v; return shadowMap }, {} as Record<string, any>)
+      }
+      if (r.outputColorSpace) {
+        defaults.value.outputColorSpace = r.outputColorSpace
+      }
+
+      delete defaults.value.clearColor
+      renderer.value = r
+    })
+  }, { immediate: true }).stop
+
+  const { stop: stopSizes } = watchEffect(() => {
+    renderer.value.setSize && renderer.value.setSize(context.sizes.width.value, context.sizes.height.value)
+  }, { flush: 'sync' })
+
+  const { pixelRatio, stop: stopPixelRatio } = useDevicePixelRatio()
+  const { stop: stopDpr } = watchEffect(() => {
+    if (!renderer.value.setPixelRatio) { return }
+    if (is.arr(context.props.dpr) && context.props.dpr.length >= 2) {
+      renderer.value.setPixelRatio(clamp(context.props.dpr[0], context.props.dpr[1], pixelRatio.value))
+    }
+    else if (is.num(context.props.dpr)) {
+      renderer.value.setPixelRatio(context.props.dpr)
+    }
+    else {
+      renderer.value.setPixelRatio(pixelRatio.value)
+    }
+  })
+
+  const { stop: stopShadows } = watchEffect(() => {
+    if (('shadowMap' in renderer.value) && defaults.value.shadowMap) {
+      renderer.value.shadowMap.enabled = context.props.shadows ?? defaults.value.shadowMap.enabled
+      renderer.value.shadowMap.needsUpdate = true
+    }
+  }, { flush: 'sync' })
+
+  const { stop: stopShadowMapType } = watchEffect(() => {
+    if (('shadowMap' in renderer.value) && defaults.value.shadowMap) {
+      renderer.value.shadowMap.type = context.props.shadowMapType ?? defaults.value.shadowMap.type
+      renderer.value.shadowMap.needsUpdate = true
+    }
+  }, { flush: 'sync' })
+
+  const { stop: stopClearColor } = watchEffect(() => {
+    if ('setClearColor' in renderer.value && context.props.clearColor) {
+      renderer.value.setClearColor(normalizeColor(context.props.clearColor))
+    }
+  }, { flush: 'sync' })
+
+  const { stop: stopAllOtherProps } = watchEffect(() => {
+    const otherProps = Object.entries(context.props).filter(([k, _]) => k in defaults.value)
+    for (const [key, value] of otherProps) {
+      if (key in renderer.value) {
+        renderer.value[key] = value ?? defaults.value[key]
+      }
+    }
+  }, { flush: 'sync' })
+
+  return {
+    renderer,
+    stop: () => {
+      stopRenderer()
+      stopSizes()
+      stopPixelRatio()
+      stopDpr()
+      stopShadows()
+      stopShadowMapType()
+      stopClearColor()
+      stopAllOtherProps()
+      renderer.value.dispose()
+      renderer.value.forceContextLoss?.()
+    },
+  }
+}

+ 5 - 1
src/utils/is.ts

@@ -1,4 +1,4 @@
-import type { TresObject, TresPrimitive } from 'src/types'
+import type { Renderer, TresObject, TresPrimitive } from 'src/types'
 import type { BufferGeometry, Camera, Fog, Light, Material, Object3D, Scene } from 'three'
 
 export function und(u: unknown) {
@@ -66,3 +66,7 @@ export function tresObject(u: unknown): u is TresObject {
 export function tresPrimitive(u: unknown): u is TresPrimitive {
   return obj(u) && !!(u.isPrimitive)
 }
+
+export function renderer(u: unknown): u is Renderer {
+  return obj(u) && !!(u?.render)
+}