Browse Source

feat(TresCanvas): add dpr prop (#768)

* feat(is): add is.num and tests

* feat(TresCanvas): add dpr prop

* docs: add dpr playground demo

---------

Co-authored-by: Alvaro Saburido <alvaro.saburido@gmail.com>
andretchen0 1 year ago
parent
commit
8943cc3dac

+ 62 - 0
playground/src/pages/advanced/devicePixelRatio/index.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { shallowRef } from 'vue'
+import { TresCanvas } from '@tresjs/core'
+import type { WebGLRenderer } from 'three'
+
+const rendererRef = shallowRef<WebGLRenderer | null>(null)
+const minDpr = 1
+const maxDpr = 3
+const currDprRef = shallowRef(-1)
+const dpr = shallowRef<number | [number, number]>([minDpr, maxDpr])
+
+const onReady = ({ renderer }) => {
+  rendererRef.value = renderer.value
+}
+
+const isRendererDprClamped = (renderer: WebGLRenderer) => {
+  const dpr = renderer.getPixelRatio()
+  currDprRef.value = dpr
+  return (dpr >= minDpr && dpr <= maxDpr)
+}
+
+const intervalId = setInterval(() => {
+  if (rendererRef.value) {
+    isRendererDprClamped(rendererRef.value)
+  }
+}, 1000)
+
+onUnmounted(() => clearInterval(intervalId))
+</script>
+
+<template>
+  <TresCanvas :dpr="dpr" @ready="onReady">
+    <TresPerspectiveCamera />
+
+    <TresMesh>
+      <TresSphereGeometry />
+      <TresMeshNormalMaterial />
+    </TresMesh>
+
+    <TresGridHelper />
+  </TresCanvas>
+
+  <OverlayInfo>
+    <h1><code>&lt;TresCanvas :dpr="[min, max]" /&gt;</code></h1>
+    <h2>Setup</h2>
+    <p>The TresCanvas <code>:dpr</code> prop is set to [{{ minDpr }}, {{ maxDpr }}]</p>
+    <p>This clamps the possible range for the renderer's DPR setting. (TresCanvas also accepts a numerical value for <code>:dpr</code>. That is not tested in this page.)</p>
+    <h2>Try it</h2>
+    <p>Zooming in and out in the browser <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio">triggers a change to the window's <code>devicePixelRatio</code>.</a> In turn, this should set the renderer's DPR. It should remain within the range specified by the <code>:dpr</code> prop.</p>
+    <h2>Test</h2>
+    <p>Renderer DPR: <span>{{ currDprRef }}</span></p>
+    <p
+      v-if="(!rendererRef || isRendererDprClamped(rendererRef))"
+      :style="{ color: 'green' }"
+    >
+      ✅ DPR is clamped.
+    </p>
+    <p v-else :style="{ color: 'red' }">
+      ❌ DPR is not properly clamped.
+    </p>
+  </OverlayInfo>
+</template>

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

@@ -29,6 +29,11 @@ export const advancedRoutes = [
     name: 'Material array',
     component: () => import('../../pages/advanced/materialArray/index.vue'),
   },
+  {
+    path: '/advanced/device-pixel-ratio',
+    name: 'Device Pixel Ratio',
+    component: () => import('../../pages/advanced/devicePixelRatio/index.vue'),
+  },
   {
     path: '/advanced/disposal',
     name: 'Disposal',

+ 1 - 0
src/components/TresCanvas.vue

@@ -49,6 +49,7 @@ export interface TresCanvasProps
   outputColorSpace?: ColorSpace
   toneMappingExposure?: number
   renderMode?: 'always' | 'on-demand' | 'manual'
+  dpr?: number | [number, number]
 
   // required by useTresContextProvider
   camera?: TresCamera

+ 8 - 5
src/composables/useRenderer/index.ts

@@ -14,7 +14,7 @@ import type { EmitEventFn, TresColor } from '../../types'
 import { normalizeColor } from '../../utils/normalize'
 
 import type { TresContext } from '../useTresContextProvider'
-import { get, merge, set } from '../../utils'
+import { get, merge, set, setPixelRatio } from '../../utils'
 
 // Solution taken from Thretle that actually support different versions https://github.com/threlte/threlte/blob/5fa541179460f0dadc7dc17ae5e6854d1689379e/packages/core/src/lib/lib/useRenderer.ts
 import { revision } from '../../core/revision'
@@ -92,6 +92,11 @@ export interface UseRendererOptions extends TransformToMaybeRefOrGetter<WebGLRen
   windowSize?: MaybeRefOrGetter<boolean | string>
   preset?: MaybeRefOrGetter<RendererPresetsType>
   renderMode?: MaybeRefOrGetter<'always' | 'on-demand' | 'manual'>
+  /**
+   * A `number` sets the renderer's device pixel ratio.
+   * `[number, number]` clamp's the renderer's device pixel ratio.
+   */
+  dpr?: MaybeRefOrGetter<number | [number, number]>
 }
 
 export function useRenderer(
@@ -151,10 +156,6 @@ export function useRenderer(
 
   const { pixelRatio } = useDevicePixelRatio()
 
-  watch(pixelRatio, () => {
-    renderer.value.setPixelRatio(pixelRatio.value)
-  })
-
   const { logError } = useLogger()
 
   const getThreeRendererDefaults = () => {
@@ -199,6 +200,8 @@ export function useRenderer(
       merge(renderer.value, rendererPresets[rendererPreset])
     }
 
+    setPixelRatio(renderer.value, pixelRatio.value, toValue(options.dpr))
+
     // Render mode
 
     if (renderMode === 'always') {

+ 129 - 0
src/utils/index.test.ts

@@ -135,3 +135,132 @@ describe('resolve', () => {
     expect(utils.resolve(instance, 'ab-cd-xx-yy-zz').key).toBe('xxYyZz')
   })
 })
+
+describe('setPixelRatio', () => {
+  const INITIAL_DPR = 1
+  let dpr = INITIAL_DPR
+  const mockRenderer = {
+    setPixelRatio: (n: number) => { dpr = n },
+    getPixelRatio: () => dpr,
+  }
+  const setPixelRatioSpy = vi.spyOn(mockRenderer, 'setPixelRatio')
+
+  beforeEach(() => {
+    dpr = 1
+    setPixelRatioSpy.mockClear()
+  })
+
+  describe('setPixelRatio(renderer: WebGLRenderer, systemDpr: number)', () => {
+    it('calls the renderer\'s setPixelRatio method with systemDpr', () => {
+      expect(setPixelRatioSpy).not.toBeCalled()
+      utils.setPixelRatio(mockRenderer, 2)
+      expect(setPixelRatioSpy).toBeCalledWith(2)
+
+      utils.setPixelRatio(mockRenderer, 2.1)
+      expect(setPixelRatioSpy).toBeCalledWith(2.1)
+
+      utils.setPixelRatio(mockRenderer, 1.44444)
+      expect(setPixelRatioSpy).toBeCalledWith(1.44444)
+    })
+    it('does not set the renderer\'s pixelRatio if systemDpr === pixelRatio', () => {
+      utils.setPixelRatio(mockRenderer, 1)
+      expect(setPixelRatioSpy).not.toBeCalled()
+
+      utils.setPixelRatio(mockRenderer, 2)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+
+      utils.setPixelRatio(mockRenderer, 2)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+
+      utils.setPixelRatio(mockRenderer, 1)
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+
+      utils.setPixelRatio(mockRenderer, 1)
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+    })
+    it('does not throw if passed a "renderer" without a `setPixelRatio` method', () => {
+      const mockSVGRenderer = {}
+      expect(() => utils.setPixelRatio(mockSVGRenderer, 2)).not.toThrow()
+    })
+    it('calls `setPixelRatio` even if passed a "renderer" without a `getPixelRatio` method', () => {
+      const mockSVGRenderer = { setPixelRatio: () => {} }
+      const setPixelRatioSpy = vi.spyOn(mockSVGRenderer, 'setPixelRatio')
+      expect(() => utils.setPixelRatio(mockSVGRenderer, 2)).not.toThrow()
+      expect(setPixelRatioSpy).toBeCalledWith(2)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+
+      utils.setPixelRatio(mockSVGRenderer, 1.99)
+      expect(setPixelRatioSpy).toBeCalledWith(1.99)
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+
+      utils.setPixelRatio(mockSVGRenderer, 2.1)
+      expect(setPixelRatioSpy).toBeCalledWith(2.1)
+      expect(setPixelRatioSpy).toBeCalledTimes(3)
+    })
+  })
+
+  describe('setPixelRatio(renderer: WebGLRenderer, systemDpr: number, userDpr: number)', () => {
+    it('calls the renderer\'s setPixelRatio method with userDpr', () => {
+      expect(setPixelRatioSpy).not.toBeCalled()
+      utils.setPixelRatio(mockRenderer, 2, 100)
+      expect(setPixelRatioSpy).toBeCalledWith(100)
+    })
+    it('does not call the renderer\'s setPixelRatio method if current dpr === new dpr', () => {
+      expect(setPixelRatioSpy).not.toBeCalled()
+      utils.setPixelRatio(mockRenderer, 2, 1)
+      expect(setPixelRatioSpy).not.toBeCalledWith()
+
+      utils.setPixelRatio(mockRenderer, 3, 1.4)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+      expect(setPixelRatioSpy).toBeCalledWith(1.4)
+
+      utils.setPixelRatio(mockRenderer, 3, 1.4)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+      expect(setPixelRatioSpy).toBeCalledWith(1.4)
+
+      utils.setPixelRatio(mockRenderer, 2, 1.4)
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+      expect(setPixelRatioSpy).toBeCalledWith(1.4)
+
+      utils.setPixelRatio(mockRenderer, 42, 0.1)
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+      expect(setPixelRatioSpy).toBeCalledWith(0.1)
+
+      utils.setPixelRatio(mockRenderer, 4, 0.1)
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+      expect(setPixelRatioSpy).toBeCalledWith(0.1)
+    })
+  })
+
+  describe('setPixelRatio(renderer: WebGLRenderer, systemDpr: number, userDpr: [number, number])', () => {
+    it('clamps systemDpr to userDpr', () => {
+      utils.setPixelRatio(mockRenderer, 2, [0, 4])
+      expect(setPixelRatioSpy).toBeCalledTimes(1)
+      expect(setPixelRatioSpy).toBeCalledWith(2)
+
+      utils.setPixelRatio(mockRenderer, 2, [3, 4])
+      expect(setPixelRatioSpy).toBeCalledTimes(2)
+      expect(setPixelRatioSpy).toBeCalledWith(3)
+
+      utils.setPixelRatio(mockRenderer, 5, [3, 4])
+      expect(setPixelRatioSpy).toBeCalledTimes(3)
+      expect(setPixelRatioSpy).toBeCalledWith(4)
+
+      utils.setPixelRatio(mockRenderer, 100, [3, 4])
+      expect(setPixelRatioSpy).toBeCalledTimes(3)
+      expect(setPixelRatioSpy).toBeCalledWith(4)
+
+      utils.setPixelRatio(mockRenderer, 100, [3.5, 4])
+      expect(setPixelRatioSpy).toBeCalledTimes(3)
+      expect(setPixelRatioSpy).toBeCalledWith(4)
+
+      utils.setPixelRatio(mockRenderer, 100, [3, 6.1])
+      expect(setPixelRatioSpy).toBeCalledTimes(4)
+      expect(setPixelRatioSpy).toBeCalledWith(6.1)
+
+      utils.setPixelRatio(mockRenderer, 1, [2.99, 6.1])
+      expect(setPixelRatioSpy).toBeCalledTimes(5)
+      expect(setPixelRatioSpy).toBeCalledWith(2.99)
+    })
+  })
+})

+ 21 - 1
src/utils/index.ts

@@ -1,5 +1,5 @@
 import type { Material, Mesh, Object3D, Texture } from 'three'
-import { DoubleSide, MeshBasicMaterial, Scene, Vector3 } from 'three'
+import { DoubleSide, MathUtils, MeshBasicMaterial, Scene, Vector3 } from 'three'
 import type { AttachType, LocalState, TresInstance, TresObject, TresPrimitive } from 'src/types'
 import type { nodeOps } from 'src/core/nodeOps'
 import { HightlightMesh } from '../devtools/highlight'
@@ -455,6 +455,26 @@ export function noop(fn: string): any {
   fn
 }
 
+export function setPixelRatio(renderer: { setPixelRatio?: (dpr: number) => void, getPixelRatio?: () => number }, systemDpr: number, userDpr?: number | [number, number]) {
+  // NOTE: Optional `setPixelRatio` allows this function to accept
+  // THREE renderers like SVGRenderer.
+  if (!is.fun(renderer.setPixelRatio)) { return }
+
+  let newDpr = 0
+
+  if (is.arr(userDpr) && userDpr.length >= 2) {
+    const [min, max] = userDpr
+    newDpr = MathUtils.clamp(systemDpr, min, max)
+  }
+  else if (is.num(userDpr)) { newDpr = userDpr }
+  else { newDpr = systemDpr }
+
+  // NOTE: Don't call `setPixelRatio` unless both:
+  // - the dpr value has changed
+  // - the renderer has `setPixelRatio`; this check allows us to pass any THREE renderer
+  if (newDpr !== renderer.getPixelRatio?.()) { renderer.setPixelRatio(newDpr) }
+}
+
 export function setPrimitiveObject(
   newObject: TresObject,
   primitive: TresPrimitive,

+ 35 - 0
src/utils/is.test.ts

@@ -2,6 +2,41 @@ import { BufferGeometry, Fog, MeshBasicMaterial, MeshNormalMaterial, Object3D, P
 import * as is from './is'
 
 describe('is', () => {
+  describe('is.num(a: any)', () => {
+    describe('true', () => {
+      it('number', () => {
+        assert(is.num(0))
+        assert(is.num(-1))
+        assert(is.num(Math.PI))
+        assert(is.num(Number.POSITIVE_INFINITY))
+        assert(is.num(Number.NEGATIVE_INFINITY))
+        assert(is.num(42))
+        assert(is.num(0b1111))
+        assert(is.num(0o17))
+        assert(is.num(0xF))
+      })
+    })
+    describe('false', () => {
+      it('null', () => {
+        assert(!is.num(null))
+      })
+      it('undefined', () => {
+        assert(!is.num(undefined))
+      })
+      it('string', () => {
+        assert(!is.num(''))
+        assert(!is.num('1'))
+      })
+      it('function', () => {
+        assert(!is.num(() => {}))
+        assert(!is.num(() => 1))
+      })
+      it('array', () => {
+        assert(!is.num([]))
+        assert(!is.num([1]))
+      })
+    })
+  })
   describe('is.und(a: any)', () => {
     describe('true', () => {
       it('undefined', () => {

+ 4 - 0
src/utils/is.ts

@@ -9,6 +9,10 @@ export function arr(u: unknown) {
   return Array.isArray(u)
 }
 
+export function num(u: unknown): u is number {
+  return typeof u === 'number'
+}
+
 export function str(u: unknown): u is string {
   return typeof u === 'string'
 }