Skip to content

Commit

Permalink
feat: Portal, SuspendingImage
Browse files Browse the repository at this point in the history
  • Loading branch information
bbohlender committed Mar 3, 2024
1 parent 07cab9a commit 583e97c
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 45 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Build performant 3D user interfaces for Three.js using @react-three/fiber and yo

TODO Release

- fix: zoom with ortho camera
- fix: changing font weight with hot reload (test if its the same for normal react state change)
- fix: conditionally render children (see Discord)
- feat: ref.current.setStyle({ ... })
- feat: nesting inside non root/container components (e.g. image)
- fix: scrollbar border radius to high (happens with very long panels)
Expand Down
32 changes: 21 additions & 11 deletions examples/uikit/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { Suspense, useMemo, useState } from 'react'
import { Canvas } from '@react-three/fiber'
import { Gltf, Box } from '@react-three/drei'
import { Gltf, Box, PerspectiveCamera } from '@react-three/drei'
import { signal } from '@preact/signals-core'
import {
DefaultProperties,
Expand All @@ -11,9 +11,12 @@ import {
Text,
Image,
Fullscreen,
Portal,
SuspendingImage,
} from '@react-three/uikit'
import { RenderTexture } from '@react-three/drei'
import { Texture } from 'three'
import { Skeleton } from '../../../packages/kits/default/skeleton'

export default function App() {
const texture = useMemo(() => signal<Texture | undefined>(undefined), [])
Expand All @@ -40,6 +43,11 @@ export default function App() {
borderRight={0}
borderColor="red"
>
<Portal borderRadius={30} width={200} aspectRatio={2}>
<PerspectiveCamera makeDefault position={[0, 0, 4]} />
<Box rotation-y={Math.PI / 4} args={[2, 2, 2]} />
<color attach="background" args={['red']} />
</Portal>
<Container backgroundColor="blue" width={100} positionType="relative">
<Container>
<Text>Escribe algo...</Text>
Expand Down Expand Up @@ -98,15 +106,17 @@ export default function App() {
<Gltf src="example.glb" />
</Content>
<Svg marginLeft={-100} color={x} backgroundColor="red" src="example.svg" width={200} />
<Image
hover={{ padding: 30, border: 0, marginLeft: -30, opacity: 1 }}
fit="cover"
border={20}
borderOpacity={0.2}
borderRadius={10}
src="https://picsum.photos/200/300"
width={300}
/>
<Suspense fallback={<Skeleton width={300} aspectRatio={2 / 3} />}>
<SuspendingImage
hover={{ padding: 30, border: 0, marginLeft: -30, opacity: 1 }}
fit="cover"
border={20}
borderOpacity={0.2}
borderRadius={10}
src="https://picsum.photos/2000/3000"
width={300}
/>
</Suspense>
</DefaultProperties>

<Container
Expand Down
2 changes: 1 addition & 1 deletion packages/uikit/src/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const Content = forwardRef<
}),
[size],
)
if ((properties.keepAspectRatio ?? true) === true) {
if (properties.keepAspectRatio ?? true) {
writeCollection(collection, 'aspectRatio', aspectRatio)
}
finalizeCollection(collection)
Expand Down
52 changes: 20 additions & 32 deletions packages/uikit/src/components/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const Image = forwardRef<
src?: string | Signal<string> | Texture | Signal<Texture | undefined>
materialClass?: MaterialClass
zIndexOffset?: ZIndexOffset
keepAspectRatio?: boolean
} & EventHandlers &
LayoutListeners &
ViewportListeners &
Expand Down Expand Up @@ -135,7 +136,9 @@ export const Image = forwardRef<
useApplyResponsiveProperties(collection, properties)
const hoverHandlers = useApplyHoverProperties(collection, properties)
writeCollection(collection, 'backgroundColor', 0xffffff)
writeCollection(collection, 'aspectRatio', aspectRatio)
if (properties.keepAspectRatio ?? true) {
writeCollection(collection, 'aspectRatio', aspectRatio)
}
finalizeCollection(collection)

useLayoutListeners(properties, node.size)
Expand Down Expand Up @@ -189,34 +192,21 @@ function useTextureFit(
return
}

const textureRatio = texture.image.width / texture.image.height
const { width: textureWidth, height: textureHeight } = texture.source.data as { width: number; height: number }
const textureRatio = textureWidth / textureHeight

const [width, height] = size.value
const [top, right, bottom, left] = borderInset.value
const boundsRatioValue = (width - left - right) / (height - top - bottom)

if (fitValue === 'cover') {
if (textureRatio > boundsRatioValue) {
texture.matrix
.translate(-(0.5 * (boundsRatioValue - textureRatio)) / boundsRatioValue, 0)
.scale(boundsRatioValue / textureRatio, 1)
} else {
texture.matrix
.translate(0, -(0.5 * (textureRatio - boundsRatioValue)) / textureRatio)
.scale(1, textureRatio / boundsRatioValue)
}
transformInsideBorder(borderInset, size, texture)
return
}

if (textureRatio > boundsRatioValue) {
texture.matrix
.translate(0, (-0.5 * (textureRatio - boundsRatioValue)) / textureRatio)
.scale(1, boundsRatioValue / textureRatio)
.translate(-(0.5 * (boundsRatioValue - textureRatio)) / boundsRatioValue, 0)
.scale(boundsRatioValue / textureRatio, 1)
} else {
texture.matrix
.translate((0.5 * (boundsRatioValue - textureRatio)) / boundsRatioValue, 0)
.scale(textureRatio / boundsRatioValue, 1)
.translate(0, -(0.5 * (textureRatio - boundsRatioValue)) / textureRatio)
.scale(1, textureRatio / boundsRatioValue)
}
transformInsideBorder(borderInset, size, texture)
}, [textureSignal, borderInset, size])
Expand All @@ -236,22 +226,20 @@ function transformInsideBorder(borderInset: Signal<Inset>, size: Signal<Vector2T

const textureLoader = new TextureLoader()

function loadTexture(src?: string | Texture) {
async function loadTexture(src?: string | Texture) {
if (src == null) {
return Promise.resolve(undefined)
}
if (src instanceof Texture) {
return Promise.resolve(src)
}
return textureLoader
.loadAsync(src)
.then((texture) => {
texture.colorSpace = SRGBColorSpace
texture.matrixAutoUpdate = false
return texture
})
.catch((error) => {
console.error(error)
return undefined
})
try {
const texture = await textureLoader.loadAsync(src)
texture.colorSpace = SRGBColorSpace
texture.matrixAutoUpdate = false
return texture
} catch (error) {
console.error(error)
return undefined
}
}
2 changes: 2 additions & 0 deletions packages/uikit/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export * from './svg.js'
export * from './text.js'
export * from './fullscreen.js'
export * from './icon.js'
export * from './suspending.js'
export * from './portal.js'
142 changes: 142 additions & 0 deletions packages/uikit/src/components/portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { effect } from '@preact/signals-core'
import {
ComponentPropsWithoutRef,
ReactNode,
RefObject,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import { HalfFloatType, LinearFilter, Scene, WebGLRenderTarget } from 'three'
import { Image } from './image.js'
import { InjectState, RootState, createPortal, useFrame, useStore } from '@react-three/fiber'
import type { DomEvent } from '@react-three/fiber/dist/declarations/src/core/events.js'
import type { ComponentInternals } from './utils.js'

export const Portal = forwardRef<
ComponentInternals,
{
frames?: number
renderPriority?: number
eventPriority?: number
resolution?: number
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<typeof Image>, 'src' | 'fit'>
>(({ children, resolution = 1, frames = Infinity, renderPriority = 0, eventPriority = 0, ...props }, ref) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const fbo = useMemo(
() =>
new WebGLRenderTarget(1, 1, {
minFilter: LinearFilter,
magFilter: LinearFilter,
type: HalfFloatType,
}),
[],
)
const imageRef = useRef<ComponentInternals>(null)
const injectState = useMemo<InjectState>(
() => ({
events: { compute: uvCompute.bind(null, imageRef), priority: eventPriority },
size: { width: 1, height: 1, left: 0, top: 0 },
}),
[eventPriority],
)
useEffect(() => {
if (imageRef.current == null) {
return
}
const { size } = imageRef.current
const unsubscribeSetSize = effect(() => {
const [width, height] = size.value
fbo.setSize(width, height)
injectState.size!.width = width
injectState.size!.height = height
})
return () => {
unsubscribeSetSize()
fbo.dispose()
}
}, [fbo, injectState])
useImperativeHandle(ref, () => imageRef.current!, [])
const vScene = useMemo(() => new Scene(), [])
return (
<>
{createPortal(
<ChildrenToFBO imageRef={imageRef} renderPriority={renderPriority} frames={frames} fbo={fbo}>
{children}
{/* Without an element that receives pointer events state.pointer will always be 0/0 */}
<group onPointerOver={() => null} />
</ChildrenToFBO>,
vScene,
injectState,
)}
<Image {...props} src={fbo.texture} fit="fill" keepAspectRatio={false} ref={imageRef} />
</>
)
})

function uvCompute(
{ current }: RefObject<ComponentInternals>,
event: DomEvent,
state: RootState,
previous?: RootState,
) {
if (current == null || previous == null) {
return false
}
// First we call the previous state-onion-layers compute, this is what makes it possible to nest portals
if (!previous.raycaster.camera) previous.events.compute?.(event, previous, previous.previousRoot?.getState())
// We run a quick check against the parent, if it isn't hit there's no need to raycast at all
const [intersection] = previous.raycaster.intersectObject(current.interactionPanel)
if (!intersection) return false
// We take that hits uv coords, set up this layers raycaster, et voilà, we have raycasting on arbitrary surfaces
const uv = intersection.uv
if (!uv) return false
state.raycaster.setFromCamera(state.pointer.set(uv.x * 2 - 1, uv.y * 2 - 1), state.camera)
}

function ChildrenToFBO({
frames,
renderPriority,
children,
fbo,
imageRef,
}: {
frames: number
renderPriority: number
children: ReactNode
fbo: WebGLRenderTarget
imageRef: RefObject<ComponentInternals>
}) {
const store = useStore()
useEffect(() => {
if (imageRef.current == null) {
return
}
const { size } = imageRef.current
return effect(() => {
const [width, height] = size.value
store.setState({ size: { width, height, top: 0, left: 0 } })
})
})
let count = 0
let oldAutoClear
let oldXrEnabled
useFrame((state) => {
if (frames === Infinity || count < frames) {
oldAutoClear = state.gl.autoClear
oldXrEnabled = state.gl.xr.enabled
state.gl.autoClear = true
state.gl.xr.enabled = false
state.gl.setRenderTarget(fbo)
state.gl.render(state.scene, state.camera)
state.gl.setRenderTarget(null)
state.gl.autoClear = oldAutoClear
state.gl.xr.enabled = oldXrEnabled
count++
}
}, renderPriority)
return <>{children}</>
}
12 changes: 12 additions & 0 deletions packages/uikit/src/components/suspending.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ComponentPropsWithoutRef } from 'react'
import { Image } from './image.js'
import { useLoader } from '@react-three/fiber'
import { TextureLoader } from 'three'

export function SuspendingImage({
src,
...props
}: Omit<ComponentPropsWithoutRef<typeof Image>, 'src'> & { src: string }) {
const texture = useLoader(TextureLoader, src)
return <Image src={texture} {...props} />
}

0 comments on commit 583e97c

Please sign in to comment.