diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index aaaa31c9..41eb2b4f 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -78,6 +78,7 @@ export default defineConfig({
{ text: 'Edges', link: '/guide/abstractions/edges' },
{ text: 'PositionalAudio', link: '/guide/abstractions/positional-audio' },
{ text: 'AnimatedSprite', link: '/guide/abstractions/animated-sprite' },
+ { text: 'ScreenSizer', link: '/guide/abstractions/screen-sizer' },
{ text: 'ScreenSpace', link: '/guide/abstractions/screen-space' },
{ text: 'Outline', link: '/guide/abstractions/outline' },
{ text: 'Image', link: '/guide/abstractions/image' },
diff --git a/docs/.vitepress/theme/components/ScreenSizerDemo.vue b/docs/.vitepress/theme/components/ScreenSizerDemo.vue
new file mode 100644
index 00000000..b481e008
--- /dev/null
+++ b/docs/.vitepress/theme/components/ScreenSizerDemo.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/guide/abstractions/screen-sizer.md b/docs/guide/abstractions/screen-sizer.md
new file mode 100644
index 00000000..f4936fa2
--- /dev/null
+++ b/docs/guide/abstractions/screen-sizer.md
@@ -0,0 +1,17 @@
+# ScreenSizer
+
+
+
+
+
+Adds a `` wrapper that scales to "screen space". By default `1` THREE world unit will be translated to 1 screen pixel.
+
+E.g. a BoxGeometry with a height, width, and depth of 100 each, will be scaled to 100 screen pixels in each dimension.
+
+## Usage
+
+<<< @/.vitepress/theme/components/ScreenSizerDemo.vue
+
+## Props
+
+Inherits all props from `THREE.Object3D`.
diff --git a/playground/vue/src/pages/abstractions/ScreenSizerDemo.vue b/playground/vue/src/pages/abstractions/ScreenSizerDemo.vue
new file mode 100644
index 00000000..2645e727
--- /dev/null
+++ b/playground/vue/src/pages/abstractions/ScreenSizerDemo.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/vue/src/router/routes/abstractions.ts b/playground/vue/src/router/routes/abstractions.ts
index cb002504..7650ae4b 100644
--- a/playground/vue/src/router/routes/abstractions.ts
+++ b/playground/vue/src/router/routes/abstractions.ts
@@ -84,4 +84,9 @@ export const abstractionsRoutes = [
name: 'Billboard',
component: () => import('../../pages/abstractions/BillboardDemo.vue'),
},
+ {
+ path: '/abstractions/screen-sizer',
+ name: 'ScreenSizer',
+ component: () => import('../../pages/abstractions/ScreenSizerDemo.vue'),
+ },
]
diff --git a/src/core/abstractions/ScreenSizer.vue b/src/core/abstractions/ScreenSizer.vue
new file mode 100644
index 00000000..be9df500
--- /dev/null
+++ b/src/core/abstractions/ScreenSizer.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/core/abstractions/index.ts b/src/core/abstractions/index.ts
index 76d59d71..9d04d0fa 100644
--- a/src/core/abstractions/index.ts
+++ b/src/core/abstractions/index.ts
@@ -13,6 +13,7 @@ import Text3D from './Text3D.vue'
import { useAnimations } from './useAnimations'
import Fbo from './useFBO/component.vue'
import Sampler from './useSurfaceSampler/component.vue'
+import ScreenSizer from './ScreenSizer.vue'
import Edges from './Edges.vue'
export * from '../staging/useEnvironment'
@@ -32,6 +33,7 @@ export {
PositionalAudio,
Reflector,
Sampler,
+ ScreenSizer,
ScreenSpace,
Text3D,
useAnimations,
diff --git a/src/utils/calculateScaleFactor.ts b/src/utils/calculateScaleFactor.ts
new file mode 100644
index 00000000..801f8c3c
--- /dev/null
+++ b/src/utils/calculateScaleFactor.ts
@@ -0,0 +1,40 @@
+import * as THREE from 'three'
+
+// NOTE: Source
+// https://github.com/pmndrs/drei/blob/f8e5653f7f60d3782301c13e781c9966370b8fda/src/core/calculateScaleFactor.ts#L24
+
+const tV0 = new THREE.Vector3()
+const tV1 = new THREE.Vector3()
+const tV2 = new THREE.Vector3()
+
+interface Size {
+ width: number
+ height: number
+}
+
+const getPoint2 = (point3: THREE.Vector3, camera: THREE.Camera, size: Size) => {
+ const widthHalf = size.width / 2
+ const heightHalf = size.height / 2
+ camera.updateMatrixWorld(false)
+ const vector = point3.project(camera)
+ vector.x = vector.x * widthHalf + widthHalf
+ vector.y = -(vector.y * heightHalf) + heightHalf
+ return vector
+}
+
+const getPoint3 = (point2: THREE.Vector3, camera: THREE.Camera, size: Size, zValue: number = 1) => {
+ const vector = tV0.set((point2.x / size.width) * 2 - 1, -(point2.y / size.height) * 2 + 1, zValue)
+ vector.unproject(camera)
+ return vector
+}
+
+export const calculateScaleFactor = (point3: THREE.Vector3, radiusPx: number, camera: THREE.Camera, size: Size) => {
+ const point2 = getPoint2(tV2.copy(point3), camera, size)
+ let scale = 0
+ for (let i = 0; i < 2; ++i) {
+ const point2off = tV1.copy(point2).setComponent(i, point2.getComponent(i) + radiusPx)
+ const point3off = getPoint3(point2off, camera, size, point2off.z)
+ scale = Math.max(scale, point3.distanceTo(point3off))
+ }
+ return scale
+}