Selaa lähdekoodia

feat: add useTresReady (#712)

* add onTresReady hook
* add @ready emit to TresCanvas
* start loop onTresReady

Co-authored-by: Alvaro Saburido <alvaro.saburido@gmail.com>
andretchen0 11 kuukautta sitten
vanhempi
commit
15e3f0785e

+ 62 - 0
playground/src/pages/basic/ready/LoopCallbackWatcher.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { useLoop } from '@tresjs/core'
+import { type ShallowRef, ref, shallowRef } from 'vue'
+
+const isCalled = ref(false)
+
+interface TestResult { passed: boolean, msg: string }
+const messages = shallowRef([
+  {
+    passed: false,
+    msg: 'callback was not called',
+  },
+]) as ShallowRef<TestResult[]>
+
+const captureCallback = (renderer: any, _elapsed: number) => {
+  if (!isCalled.value) {
+    isCalled.value = true
+    const isRendererOk = !!renderer
+    const domElement = renderer?.domElement
+    const isDomElementOk = !!(domElement) && domElement.width > 0 && domElement.height > 0
+
+    messages.value = [
+      {
+        passed: true,
+        msg: 'When the callback was called for the first time ...',
+      },
+      {
+        passed: isRendererOk,
+        msg: isRendererOk ? '... the renderer existed.' : '... the renderer did not exist.',
+      },
+      {
+        passed: !!domElement,
+        msg: domElement ? '... the canvas existed.' : '... the canvas did not exist.',
+      },
+      {
+        passed: isDomElementOk,
+        msg: isDomElementOk
+          ? `... the canvas was not degenerate: ${domElement.width} px × ${domElement.height} px.`
+          : `... the canvas was degenerate.`,
+      },
+    ]
+  }
+}
+
+useLoop().onBeforeRender(({ elapsed: _elapsed, renderer }) => {
+  captureCallback(renderer, _elapsed)
+})
+
+useLoop().render(({ elapsed: _elapsed, renderer, scene, camera }) => {
+  captureCallback(renderer, _elapsed)
+  renderer.render(scene, camera)
+})
+
+useLoop().onAfterRender(({ elapsed: _elapsed, renderer }) => {
+  captureCallback(renderer, _elapsed)
+})
+
+defineExpose({
+  isCalled,
+  messages,
+})
+</script>

+ 69 - 0
playground/src/pages/basic/ready/OnTresReadyWatcher.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import { type TresContext, onTresReady } from '@tresjs/core'
+import { type ShallowRef, ref, shallowRef } from 'vue'
+
+const isCalled = ref(false)
+
+interface TestResult { passed: boolean, msg: string }
+const messages = shallowRef([
+  {
+    passed: false,
+    msg: 'callback was not called',
+  },
+]) as ShallowRef<TestResult[]>
+
+const captureCallback = (ctx: TresContext) => {
+  if (isCalled.value) {
+    messages.value = [
+      {
+        passed: false,
+        msg: 'Callback was called twice.',
+      },
+    ]
+  }
+  if (!isCalled.value) {
+    isCalled.value = true
+    const isCtxOk = !!(ctx && 'renderer' in ctx && 'scene' in ctx)
+    const renderer = ctx.renderer.value
+    const isRendererOk = !!renderer
+    const domElement = renderer?.domElement
+    const isDomElementOk = !!(domElement) && domElement.width > 0 && domElement.height > 0
+
+    messages.value = [
+      {
+        passed: true,
+        msg: 'When the callback was called ...',
+      },
+      {
+        passed: true,
+        msg: '... it had not previously been called.',
+      },
+      {
+        passed: isCtxOk,
+        msg: isCtxOk ? '... TresContext was passed.' : '... TresContext was not passed.',
+      },
+      {
+        passed: isRendererOk,
+        msg: isRendererOk ? '... the renderer existed.' : '... the renderer did not exist.',
+      },
+      {
+        passed: !!domElement,
+        msg: domElement ? '... the canvas existed.' : '... the canvas did not exist.',
+      },
+      {
+        passed: isDomElementOk,
+        msg: isDomElementOk
+          ? `... the canvas was not degenerate: ${domElement.width} px × ${domElement.height} px.`
+          : `... the canvas was degenerate.`,
+      },
+    ]
+  }
+}
+
+onTresReady(captureCallback)
+
+defineExpose({
+  isCalled,
+  messages,
+})
+</script>

+ 163 - 0
playground/src/pages/basic/ready/index.vue

@@ -0,0 +1,163 @@
+<script setup lang="ts">
+import type { TresContext } from '@tresjs/core'
+import { TresCanvas } from '@tresjs/core'
+import type { ShallowRef } from 'vue'
+import { ref } from 'vue'
+import LoopCallbackWatcher from './LoopCallbackWatcher.vue'
+import OnTresReadyWatcher from './OnTresReadyWatcher.vue'
+
+interface TestResult { passed: boolean, msg: string }
+
+const onReadyMessages = shallowRef([
+  {
+    passed: false,
+    msg: '@ready callback was not called',
+  },
+]) as ShallowRef<TestResult[]>
+
+let numOnReadyCalls = 0
+const onReady = function (ctx: TresContext) {
+  numOnReadyCalls++
+  const renderer = ctx.renderer.value
+  const domElement = renderer?.domElement
+  const isPassedCanvas = domElement.width > 0 && domElement.width > 0
+  const isPassedCtx = !!renderer && 'camera' in ctx && !!(ctx.camera.value)
+
+  onReadyMessages.value = [
+    {
+      passed: true,
+      msg: 'When the callback was called ...',
+    },
+    {
+      passed: numOnReadyCalls === 1,
+      msg: '... it had not previously been called.',
+    },
+    {
+      passed: isPassedCtx,
+      msg: isPassedCtx ? '... TresContext was passed.' : '... TresContext was not passed or was missing elements',
+    },
+    {
+      passed: !!renderer,
+      msg: renderer ? '... the renderer existed.' : '... the renderer did not exist.',
+    },
+    {
+      passed: !!domElement,
+      msg: domElement ? '... the canvas existed.' : '... the canvas did not exist.',
+    },
+    {
+      passed: isPassedCanvas,
+      msg: isPassedCanvas
+        ? `... the canvas was not degenerate: ${domElement.width} px × ${domElement.height} px.`
+        : `... the canvas was degenerate.`,
+    },
+  ]
+}
+
+const onTresReadyWatcherRef = ref({
+  isCalled: false,
+  messages: [] as TestResult[],
+})
+
+const loopCallbackWatcherRef = ref({
+  isCalled: false,
+  messages: [] as TestResult[],
+})
+</script>
+
+<template>
+  <div class="overlay">
+    <h1>When is Tres ready?</h1>
+    <p>
+      Tres is "ready" if either:
+    </p>
+    <ul>
+      <li>
+        The scene can be meaningfully rendered.
+      </li>
+      <ul>
+        <li>the renderer exists</li>
+        <li>the canvas width and height are > 0</li>
+      </ul>
+      <li>Tres has waited 100 ms - assumes setup is intentionally degenerate.</li>
+    </ul>
+    <hr />
+    <h1>"ready" in user space</h1>
+    <h2><code>&lt;TresCanvas @ready="(ctx:TresContext) => {}"&gt;</code></h2>
+    <p>A callback can be defined in the <code>&lt;script setup /&gt;</code> of a &lt;TresCanvas&gt;.</p>
+    <ul>
+      <li
+        v-for="({ passed, msg }, i) of onReadyMessages"
+        :key="i"
+        :class="passed ? 'pass' : 'fail'"
+      >
+        <span>{{ passed ? "✅" : "❌" }} {{ msg }}</span>
+      </li>
+    </ul>
+    <h2><code>onTresReady((ctx:TresContext) => {})</code></h2>
+    <p><code>onTresReady</code> can only be called in a child component.</p>
+    <ul>
+      <li
+        v-for="({ passed, msg }, i) of onTresReadyWatcherRef.messages"
+        :key="i"
+        :class="passed ? 'pass' : 'fail'"
+      >
+        <span>{{ passed ? "✅" : "❌" }} {{ msg }}</span>
+      </li>
+    </ul>
+    <h2><code>useLoop()...(callback)</code></h2>
+    <p><code>useLoop</code> can only be called in a child component.</p>
+    <ul>
+      <li
+        v-for="({ passed, msg }, i) of loopCallbackWatcherRef.messages"
+        :key="i"
+        :class="passed ? 'pass' : 'fail'"
+      >
+        <span>{{ passed ? "✅" : "❌" }} {{ msg }}</span>
+      </li>
+    </ul>
+    <hr />
+    <h1>Context</h1>
+    <p>
+      <a href="https://github.com/Tresjs/tres/issues/595">See this Github issue for further explanation.</a>
+    </p>
+  </div>
+  <TresCanvas clear-color="gray" @ready="onReady">
+    <LoopCallbackWatcher ref="loopCallbackWatcherRef" />
+    <OnTresReadyWatcher ref="onTresReadyWatcherRef" />
+    <TresMesh>
+      <TresBoxGeometry />
+      <TresMeshNormalMaterial />
+    </TresMesh>
+  </TresCanvas>
+</template>
+
+<style scoped>
+.overlay {
+  position: fixed;
+  z-index: 1000;
+  margin: 10px;
+  padding: 10px;
+  border-radius: 6px;
+  max-width: 400px;
+  font-family: sans-serif;
+  font-size: small;
+  background-color: white;
+}
+
+.overlay .pass {
+  color: green;
+}
+
+.overlay .fail {
+  color: red;
+}
+
+.overlay li {
+  padding-left: 0;
+  margin-left: 0;
+}
+.overlay ul {
+  padding-left: 0;
+  margin-left: 1.5em;
+}
+</style>

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

@@ -44,4 +44,9 @@ export const basicRoutes = [
     name: 'Pierced Props',
     component: () => import('../../pages/basic/PiercedProps.vue'),
   },
+  {
+    path: '/basic/ready',
+    name: '@ready',
+    component: () => import('../../pages/basic/ready/index.vue'),
+  },
 ]

+ 1 - 0
src/components/TresCanvas.vue

@@ -89,6 +89,7 @@ const emit = defineEmits([
   'pointer-out',
   'pointer-missed',
   'wheel',
+  'ready',
 ])
 
 const slots = defineSlots<{

+ 1 - 0
src/composables/index.ts

@@ -10,3 +10,4 @@ export * from './usePointerEventHandler'
 export * from './useTresContextProvider'
 export * from './useLoop'
 export * from './useTresEventManager'
+export { onTresReady } from './useTresReady'

+ 8 - 1
src/composables/useTresContextProvider/index.ts

@@ -15,6 +15,7 @@ import type { TresEventManager } from '../useTresEventManager'
 import useSizes, { type SizesType } from '../useSizes'
 import type { RendererLoop } from '../../core/loop'
 import { createRenderLoop } from '../../core/loop'
+import { useTresReady } from '../useTresReady'
 
 export interface InternalState {
   priority: Ref<number>
@@ -209,9 +210,15 @@ export function useTresContextProvider({
     }
   }, 'render')
 
-  ctx.loop.start()
+  const { on: onTresReady, cancel: cancelTresReady } = useTresReady(ctx)!
+
+  onTresReady(() => {
+    emit('ready', ctx)
+    ctx.loop.start()
+  })
 
   onUnmounted(() => {
+    cancelTresReady()
     ctx.loop.stop()
   })
 

+ 187 - 0
src/composables/useTresReady/createReadyEventHook/createReadyHook.test.ts

@@ -0,0 +1,187 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createReadyEventHook } from './index'
+
+describe('createReadyEventHook', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+  })
+
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  describe('createReadyEventHook(getIsReady)', () => {
+    it('calls getIsReady when created', () => {
+      const getIsReady = vi.fn(() => true)
+      createReadyEventHook(getIsReady, null)
+      expect(getIsReady).toBeCalled()
+    })
+
+    it('calls getIsReady periodically', () => {
+      const fn = vi.fn(() => false)
+      createReadyEventHook(fn, null, 1)
+      vi.advanceTimersByTime(1000)
+      expect(fn).toHaveBeenCalledTimes(1000 + 1)
+    })
+
+    it('calls getIsReady periodically, but only until `getIsReady()` is truthy', () => {
+      let i = 0
+      const fn0 = () => {
+        i++
+        return i === 5
+      }
+      createReadyEventHook(fn0, null)
+      vi.advanceTimersByTime(1000)
+      expect(i).toBe(5)
+
+      i = -1
+      const fn1 = () => {
+        i++
+        return i
+      }
+      createReadyEventHook(fn1 as any, null)
+      vi.advanceTimersByTime(1000)
+      expect(i).toBe(1)
+    })
+
+    it('calls getIsReady periodically, but only while not cancelled', () => {
+      const fn = vi.fn(() => false)
+      const { cancel } = createReadyEventHook(fn, null, 1)
+      vi.advanceTimersByTime(99)
+      cancel()
+      vi.advanceTimersByTime(1000)
+      expect(fn).toHaveBeenCalledTimes(100)
+    })
+  })
+
+  describe('createReadyEventHook(getIsReady, intervalMs)', () => {
+    it('calls getIsReady at the provided interval', () => {
+      const fn = vi.fn(() => false)
+      createReadyEventHook(fn, null, 100)
+      expect(fn).toHaveBeenCalledTimes(1)
+      vi.advanceTimersByTime(99)
+      expect(fn).toHaveBeenCalledTimes(1)
+      vi.advanceTimersByTime(1000)
+      expect(fn).toHaveBeenCalledTimes(10 + 1)
+      vi.advanceTimersByTime(5000)
+      expect(fn).toHaveBeenCalledTimes(50 + 10 + 1)
+    })
+  })
+
+  describe('createReadyEventHook().on', () => {
+    it('registers a function and calls it once `getIsReady() === true`', () => {
+      const fn = vi.fn()
+      const { on } = createReadyEventHook(trueIfCalledNTimes(10), null)
+
+      on(fn)
+      vi.advanceTimersByTime(10000)
+
+      expect(fn).toHaveBeenCalledTimes(1)
+    })
+
+    it('calls registered functions with args', () => {
+      const fn0 = vi.fn()
+      const fn1 = vi.fn()
+      const arg0 = { foo: 'bar' }
+      const arg1 = { baz: 'boo' }
+      const { on } = createReadyEventHook(() => true, [arg0, arg1])
+
+      on(fn0)
+      on(fn1)
+
+      expect(fn0).toHaveBeenCalledWith([{ foo: 'bar' }, { baz: 'boo' }])
+      expect(fn1).toHaveBeenCalledWith([{ foo: 'bar' }, { baz: 'boo' }])
+    })
+
+    it('calls a function immediately if `getIsReady() === true`', () => {
+      const fn = vi.fn()
+      const { on } = createReadyEventHook(() => true, null)
+
+      on(fn)
+
+      expect(fn).toHaveBeenCalledTimes(1)
+    })
+
+    it('calls functions with arg immediately if `getIsReady() === true`', () => {
+      const fn0 = vi.fn()
+      const fn1 = vi.fn()
+      const arg = { foo: 'bar' }
+      const { on } = createReadyEventHook(() => true, arg)
+
+      on(fn0)
+      on(fn1)
+
+      expect(fn0).toHaveBeenCalledWith({ foo: 'bar' })
+      expect(fn1).toHaveBeenCalledWith({ foo: 'bar' })
+    })
+
+    it('can register many functions, one at a time', () => {
+      const fns = Array.from({ length: 100 })
+        .fill(0)
+        .map(_ => vi.fn())
+
+      const { on } = createReadyEventHook(trueIfCalledNTimes(10), null)
+      fns.forEach(fn => on(fn))
+      vi.advanceTimersByTime(10000)
+
+      for (const fn of fns) {
+        expect(fn).toHaveBeenCalledTimes(1)
+      }
+    })
+  })
+
+  describe('createReadyEventHook().off(fn)', () => {
+    it('unregisters a function', () => {
+      const fns = Array.from({ length: 100 })
+        .fill(0)
+        .map(_ => vi.fn())
+
+      const { on, off } = createReadyEventHook(trueIfCalledNTimes(10), null)
+      fns.forEach(fn => on(fn))
+
+      const offedFns = new Set()
+      fns.forEach((fn) => {
+        if (Math.random() < 0.5) {
+          offedFns.add(fn)
+          off(fn)
+        }
+      })
+      vi.advanceTimersByTime(10000)
+
+      fns.forEach((fn) => {
+        expect(fn).toHaveBeenCalledTimes(offedFns.has(fn) ? 0 : 1)
+      })
+    })
+  })
+
+  describe('createReadyEventHook().on(fn).off()', () => {
+    it('unregisters a function', () => {
+      const fns = Array.from({ length: 100 })
+        .fill(0)
+        .map(_ => vi.fn())
+
+      const { on } = createReadyEventHook(trueIfCalledNTimes(10), null)
+
+      const offedFns = new Set()
+      fns.forEach((fn) => {
+        const { off } = on(fn)
+        if (Math.random() < 0.5) {
+          offedFns.add(fn)
+          off()
+        }
+      })
+      vi.advanceTimersByTime(1000)
+
+      fns.forEach((fn) => {
+        expect(fn).toHaveBeenCalledTimes(offedFns.has(fn) ? 0 : 1)
+      })
+    })
+  })
+})
+
+function trueIfCalledNTimes(n: number) {
+  return () => {
+    n = Math.max(n - 1, 0)
+    return n === 0
+  }
+}

+ 86 - 0
src/composables/useTresReady/createReadyEventHook/index.ts

@@ -0,0 +1,86 @@
+import type { EventHook, EventHookOn, IsAny } from '@vueuse/core'
+import { createEventHook } from '@vueuse/core'
+
+type Callback<T> =
+  IsAny<T> extends true
+    ? (param: any) => void
+    : [T] extends [void]
+        ? () => void
+        : (param: T) => void
+
+export function createReadyEventHook<T>(
+  getIsReady: () => boolean,
+  triggerParams: T,
+  pollIntervalMs = 100,
+): EventHook<T> & { cancel: () => void } {
+  pollIntervalMs = pollIntervalMs <= 0 ? 100 : pollIntervalMs
+  const hook = createEventHook()
+  // NOTE: This hook will likely be long-lived and
+  // we don't want to interfere with garbage collection
+  // in the meantime.
+  // Keep a set of `offFns` and call them after `getIsReady`
+  // in order to remove them from the `hook`.
+  const offFns = new Set<() => void>()
+  let ready = false
+  let cancelled = false
+  let timeoutId: ReturnType<typeof setTimeout> | null = null
+
+  function doReadyTest() {
+    if (timeoutId) {
+      clearTimeout(timeoutId)
+    }
+    if (!cancelled && !ready && getIsReady()) {
+      hook.trigger(triggerParams)
+      offFns.forEach(offFn => offFn())
+      offFns.clear()
+      ready = true
+    }
+    else if (!cancelled && !ready) {
+      timeoutId = setTimeout(doReadyTest, pollIntervalMs)
+    }
+  }
+
+  function cancel() {
+    cancelled = true
+    if (timeoutId) {
+      clearTimeout(timeoutId)
+    }
+  }
+
+  if (import.meta.hot) {
+    import.meta.hot.on('vite:afterUpdate', () => {
+      ready = false
+      doReadyTest()
+    })
+  }
+
+  doReadyTest()
+
+  const triggerSingleCallback = (callback: Callback<T>, ...args: [T]) => {
+    callback(...args)
+  }
+
+  const onOrCall: EventHookOn<T> = (callback) => {
+    if (!ready) {
+      const onFn = hook.on(callback)
+
+      if (!import.meta.hot) {
+        // NOTE: We must keep callbacks around for HMR.
+        // But if it doesn't exist, remove callbacks.
+        offFns.add(onFn.off)
+      }
+      return hook.on(callback)
+    }
+    else {
+      triggerSingleCallback(callback, triggerParams)
+      return { off: () => {} }
+    }
+  }
+
+  return {
+    on: onOrCall,
+    off: hook.off,
+    trigger: hook.trigger,
+    cancel,
+  }
+}

+ 53 - 0
src/composables/useTresReady/index.ts

@@ -0,0 +1,53 @@
+import type { TresContext } from '../useTresContextProvider'
+import { useTresContext } from '../useTresContextProvider'
+import { createReadyEventHook } from './createReadyEventHook'
+
+const ctxToUseTresReady = new WeakMap<
+  TresContext,
+  ReturnType<typeof createReadyEventHook<TresContext>>
+>()
+
+export function useTresReady(ctx?: TresContext) {
+  ctx = ctx || useTresContext()
+  if (ctxToUseTresReady.has(ctx)) {
+    return ctxToUseTresReady.get(ctx)!
+  }
+
+  const MAX_READY_WAIT_MS = 100
+  const start = Date.now()
+
+  // NOTE: Consider Tres to be "ready" if either is true:
+  // - MAX_READY_WAIT_MS has passed (assume Tres is intentionally degenerate)
+  // - Tres is not degenerate
+  //     - A renderer exists
+  //     - A DOM element exists
+  //     - The DOM element's height/width is not 0
+  const getTresIsReady = () => {
+    if (Date.now() - start >= MAX_READY_WAIT_MS) {
+      return true
+    }
+    else {
+      const renderer = ctx.renderer.value
+      const domElement = renderer?.domElement || { width: 0, height: 0 }
+      return !!(renderer && domElement.width > 0 && domElement.height > 0)
+    }
+  }
+
+  const args = ctx as TresContext
+  const result = createReadyEventHook(getTresIsReady, args)
+  ctxToUseTresReady.set(ctx, result)
+
+  return result
+}
+
+export function onTresReady(fn: (ctx: TresContext) => void) {
+  const ctx = useTresContext()
+  if (ctx) {
+    if (ctxToUseTresReady.has(ctx)) {
+      return ctxToUseTresReady.get(ctx)!.on(fn)
+    }
+    else {
+      return useTresReady(ctx).on(fn)
+    }
+  }
+}

+ 1 - 1
src/types/index.ts

@@ -20,7 +20,7 @@ export interface TresCatalogue {
   [name: string]: ConstructorRepresentation
 }
 
-export type EmitEventName = 'render' | 'click' | 'double-click' | 'context-menu' | 'pointer-move' | 'pointer-up' | 'pointer-down' | 'pointer-enter' | 'pointer-leave' | 'pointer-over' | 'pointer-out' | 'pointer-missed' | 'wheel'
+export type EmitEventName = 'render' | 'ready' | 'click' | 'double-click' | 'context-menu' | 'pointer-move' | 'pointer-up' | 'pointer-down' | 'pointer-enter' | 'pointer-leave' | 'pointer-over' | 'pointer-out' | 'pointer-missed' | 'wheel'
 export type EmitEventFn = (event: EmitEventName, ...args: any[]) => void
 export type TresCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera