Skip to content

Commit

Permalink
Merge pull request #62 from wrangelvid/responsive_portal
Browse files Browse the repository at this point in the history
fix: prevent portal from distorting on viewport resize.
  • Loading branch information
bbohlender authored Jun 3, 2024
2 parents 390030e + 099503f commit 6625c93
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 62 deletions.
45 changes: 34 additions & 11 deletions examples/uikit/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComponentRef, StrictMode, Suspense, useMemo, useRef, useState } from 'react'
import { Canvas } from '@react-three/fiber'
import { Gltf, Box, PerspectiveCamera, RenderTexture } from '@react-three/drei'
import { Box, PerspectiveCamera, OrthographicCamera, RenderTexture, OrbitControls } from '@react-three/drei'
import { signal } from '@preact/signals-core'
import {
DefaultProperties,
Expand Down Expand Up @@ -31,18 +31,14 @@ export default function App() {
return (
<Canvas frameloop="demand" style={{ height: '100dvh', touchAction: 'none' }} gl={{ localClippingEnabled: true }}>
<StrictMode>
<OrbitControls />
<FontFamilyProvider inter={{ normal: 'inter-normal.json' }}>
<color attach="background" args={['black']} />
<ambientLight intensity={0.5} />
<directionalLight intensity={10} position={[5, 1, 10]} />
<Gltf position={[200, 0, 200]} scale={0.1} src="scene.glb" />
<Gltf position={[0, 0, 4]} scale={10} src="example.glb" />
<RenderTexture ref={(t) => (texture.value = t ?? undefined)}>
<Box />
</RenderTexture>
<Box renderOrder={1} position={[0, 0, 4]} scale={0.2}>
<meshBasicMaterial depthWrite={false} transparent color="red" />
</Box>
<Fullscreen
renderOrder={10}
distanceToCamera={1}
Expand All @@ -55,11 +51,38 @@ export default function App() {
borderRightWidth={0}
borderColor="red"
>
<Portal flexShrink={0} borderRadius={30} width={200} aspectRatio={1}>
<PerspectiveCamera makeDefault position={[0, 0, 4]} />
<Box rotation-y={Math.PI / 4} args={[2, 2, 2]} />
<color attach="background" args={['red']} />
</Portal>
{/* Tests for the Portal component.*/}
<Container flexShrink={0} flexDirection="row" height={500}>
{/* By default, the Portal should create it's own camera and thus
not be affected by the scene camera and orbit controls..*/}
<Portal borderRadius={30} width="33%">
<Box rotation-y={Math.PI / 4} args={[2, 2, 2]} />
<color attach="background" args={['red']} />
</Portal>
{/* However, we can provide a camera with custom properties, like
a different position or field of view. Note that the aspect
ratio will be overriden to match with the screens aspect ratio,
s.t. resizing the screen would not distort the portal view.*/}
<Portal borderRadius={30} width="33%">
<PerspectiveCamera makeDefault position={[0, -1, 4]} fov={500} aspect={100} />
<Box rotation-y={Math.PI / 4} args={[2, 2, 2]} />
<color attach="background" args={['blue']} />
</Portal>
{/* The resizing should work for the orthographic camera as well.*/}
<Portal borderRadius={30} width="33%">
<OrthographicCamera
makeDefault
position={[0, 0, 100]}
left={10}
right={10}
top={10}
zoom={100}
bottom={10}
/>
<Box rotation-y={Math.PI / 4} args={[2, 2, 2]} />
<color attach="background" args={['green']} />
</Portal>
</Container>
<Container flexShrink={0} flexDirection="column" backgroundColor="blue" width={100} positionType="relative">
<Container flexDirection="column">
<Text wordBreak="break-all" height={100}>
Expand Down
3 changes: 2 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"ora": "^8.0.1",
"prettier": "^3.2.5",
"prompts": "^2.4.2",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
"devDependencies": {
"@react-three/drei": "^9.96.1",
Expand Down
196 changes: 159 additions & 37 deletions packages/react/src/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,58 @@
import { Signal, computed, effect } from '@preact/signals-core'
import { ReactNode, RefAttributes, RefObject, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { HalfFloatType, LinearFilter, Scene, WebGLRenderTarget } from 'three'
import {
ReactNode,
RefAttributes,
RefObject,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
HalfFloatType,
LinearFilter,
Scene,
WebGLRenderTarget,
PerspectiveCamera,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { Image } from './image.js'
import { InjectState, RootState, createPortal, useFrame, useStore, useThree } from '@react-three/fiber'
import type { DomEvent, EventHandlers } from '@react-three/fiber/dist/declarations/src/core/events.js'
import {
InjectState,
RootState,
createPortal,
reconciler,
useFrame,
useStore,
context,
Viewport,
} from '@react-three/fiber'
import type { DomEvent, EventHandlers, EventManager } from '@react-three/fiber/dist/declarations/src/core/events.js'
import type { ImageProperties } from '@pmndrs/uikit/internals'
import type { ComponentInternals } from './ref.js'
import { create } from 'zustand'

// Keys that shouldn't be copied between R3F stores
export const privateKeys = [
'set',
'get',
'setSize',
'setFrameloop',
'setDpr',
'events',
'invalidate',
'advance',
'size',
'viewport',
]

type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera
const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera =>
def && (def as THREE.OrthographicCamera).isOrthographicCamera

type BasePortalProperties = Omit<ImageProperties, 'src' | 'objectFit'>

Expand All @@ -26,14 +73,79 @@ export const Portal: (
({ children, resolution = 1, frames = Infinity, renderPriority = 0, eventPriority = 0, ...props }, ref) => {
const fbo = useMemo(() => new Signal<WebGLRenderTarget | undefined>(undefined), [])
const imageRef = useRef<ComponentInternals<ImageProperties>>(null)
const injectState = useMemo<InjectState>(
() => ({
const previousRoot = useStore()
useImperativeHandle(ref, () => imageRef.current!, [])
const texture = useMemo(() => computed(() => fbo.value?.texture), [fbo])

const usePortalStore = useMemo(() => {
let previousState = previousRoot.getState()
// We have our own camera in here, separate from the main scene.
const camera = new PerspectiveCamera(50, 1, 0.1, 1000)
camera.position.set(0, 0, 5)
const pointer = new Vector2()
let ownState = {
events: { compute: uvCompute.bind(null, imageRef), priority: eventPriority },
size: { width: 1, height: 1, left: 0, top: 0 },
}),
[eventPriority],
)
const store = useStore()
camera,
scene: new Scene(),
raycaster: new Raycaster(),
pointer: pointer,
mouse: pointer,
previousRoot,
}
//we now merge in order previousState, injectState, ownState
const store = create<RootState & { setPreviousState: (prevState: RootState) => void }>((innerSet, get) => {
const merge = () => {
const result = {} as any
for (const key in previousState) {
if (privateKeys.includes(key)) {
continue
}
result[key as keyof RootState] = previousState[key as keyof RootState] as never
}
return Object.assign(result, ownState, {
events: { ...previousState.events, ...ownState.events },
viewport: Object.assign(
{},
previousState.viewport,
previousState.viewport.getCurrentViewport(camera, new Vector3(), ownState.size),
),
})
}
const update = () => innerSet(merge())
return {
...previousState,
// Set and get refer to this root-state
set(newOwnState: Partial<InjectState> | ((s: InjectState) => Partial<InjectState>)) {
if (typeof newOwnState === 'function') {
newOwnState = newOwnState(get())
}
Object.assign(ownState, newOwnState)
update()
},
setPreviousState(prevState: RootState) {
previousState = prevState
update()
},
get,
// Layers are allowed to override events
setEvents(events: Partial<EventManager<any>>) {
Object.assign(ownState.events, events)
update()
},
...merge(),
}
})
return Object.assign(store, {
setState(state: Partial<RootState>) {
store.getState().set(state as any)
},
})
}, [eventPriority, previousRoot])

//syncing up previous store with the current store
useEffect(() => previousRoot.subscribe(usePortalStore.getState().setPreviousState), [previousRoot, usePortalStore])

useEffect(() => {
if (imageRef.current == null) {
return
Expand All @@ -49,29 +161,31 @@ export const Portal: (
return
}
const [width, height] = size.value
const dpr = store.getState().viewport.dpr
const dpr = previousRoot.getState().viewport.dpr
renderTarget.setSize(width * dpr, height * dpr)
injectState.size!.width = width
injectState.size!.height = height
usePortalStore.setState({
size: { width, height, top: 0, left: 0 },
viewport: { ...previousRoot.getState().viewport, width, height, aspect: width / height },
})
})
return () => {
unsubscribeSetSize()
renderTarget.dispose()
}
}, [fbo, injectState, store])
useImperativeHandle(ref, () => imageRef.current!, [])
const vScene = useMemo(() => new Scene(), [])
const texture = useMemo(() => computed(() => fbo.value?.texture), [fbo])
}, [fbo, previousRoot, usePortalStore])

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,
{reconciler.createPortal(
<context.Provider value={usePortalStore}>
<ChildrenToFBO 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>
</context.Provider>,
usePortalStore,
null,
)}
<Image src={texture} objectFit="fill" keepAspectRatio={false} {...props} ref={imageRef} />
</>
Expand Down Expand Up @@ -104,28 +218,36 @@ function ChildrenToFBO({
renderPriority,
children,
fbo,
imageRef,
}: {
frames: number
renderPriority: number
children: ReactNode
fbo: Signal<WebGLRenderTarget | undefined>
imageRef: RefObject<ComponentInternals<ImageProperties>>
}) {
const store = useStore()

useEffect(() => {
if (imageRef.current == null) {
return
}
const { size } = imageRef.current
return effect(() => {
if (size.value == null) {
return
return store.subscribe((state, prevState) => {
const { size, camera } = state
if (size) {
if (isOrthographicCamera(camera)) {
camera.left = size.width / -2
camera.right = size.width / 2
camera.top = size.height / 2
camera.bottom = size.height / -2
} else {
camera.aspect = size.width / size.height
}
if (size !== prevState.size || camera !== prevState.camera) {
camera.updateProjectionMatrix()
// https://github.com/pmndrs/react-three-fiber/issues/178
// Update matrix world since the renderer is a frame late
camera.updateMatrixWorld()
}
}
const [width, height] = size.value
store.setState({ size: { width, height, top: 0, left: 0 } })
})
})
}, [store])

let count = 0
let oldAutoClear
let oldXrEnabled
Expand Down
Loading

0 comments on commit 6625c93

Please sign in to comment.