diff --git a/src/Selection.tsx b/src/Selection.tsx index 5e3ae361..7151a9f4 100644 --- a/src/Selection.tsx +++ b/src/Selection.tsx @@ -1,10 +1,20 @@ -import * as THREE from 'three' -import React, { createContext, useState, useContext, useEffect, useRef, useMemo } from 'react' import { type ThreeElements } from '@react-three/fiber' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react' +import { type Group, type Object3D } from 'three' export type Api = { - selected: THREE.Object3D[] - select: React.Dispatch> + selected: Object3D[] + select: Dispatch> enabled: boolean } export type SelectApi = Omit & { @@ -13,19 +23,19 @@ export type SelectApi = Omit & { export const selectionContext = /* @__PURE__ */ createContext(null) -export function Selection({ children, enabled = true }: { enabled?: boolean; children: React.ReactNode }) { - const [selected, select] = useState([]) +export function Selection({ children, enabled = true }: { enabled?: boolean; children: ReactNode }) { + const [selected, select] = useState([]) const value = useMemo(() => ({ selected, select, enabled }), [selected, select, enabled]) return {children} } export function Select({ enabled = false, children, ...props }: SelectApi) { - const group = useRef(null!) + const group = useRef(null!) const api = useContext(selectionContext) useEffect(() => { if (api && enabled) { let changed = false - const current: THREE.Object3D[] = [] + const current: Object3D[] = [] group.current.traverse((o) => { o.type === 'Mesh' && current.push(o) if (api.selected.indexOf(o) === -1) changed = true diff --git a/src/effects/Autofocus.tsx b/src/effects/Autofocus.tsx index 6cbecef0..522c1a53 100644 --- a/src/effects/Autofocus.tsx +++ b/src/effects/Autofocus.tsx @@ -1,24 +1,25 @@ -import * as THREE from 'three' -import React, { - useRef, +import { createPortal, useFrame, useThree, type Vector3 as R3FVector3 } from '@react-three/fiber' +import { easing } from 'maath' +import { CopyPass, DepthOfFieldEffect, DepthPickingPass } from 'postprocessing' +import { + Ref, + useCallback, useContext, - useState, useEffect, - useCallback, - forwardRef, useImperativeHandle, - RefObject, useMemo, + useRef, + useState, + type ComponentProps, + type RefObject, } from 'react' -import { useThree, useFrame, createPortal, type Vector3 } from '@react-three/fiber' -import { CopyPass, DepthPickingPass, DepthOfFieldEffect } from 'postprocessing' -import { easing } from 'maath' +import { Mesh, Vector3 } from 'three' -import { DepthOfField } from './DepthOfField' import { EffectComposerContext } from '../EffectComposer' +import { DepthOfField } from './DepthOfField' -export type AutofocusProps = React.ComponentProps & { - target?: Vector3 +export type AutofocusProps = ComponentProps & { + target?: R3FVector3 /** should the target follow the pointer */ mouse?: boolean /** size of the debug green point */ @@ -27,127 +28,131 @@ export type AutofocusProps = React.ComponentProps & { manual?: boolean /** approximate time to reach the target */ smoothTime?: number + ref?: Ref } export type AutofocusApi = { dofRef: RefObject - hitpoint: THREE.Vector3 + hitpoint: Vector3 update: (delta: number, updateTarget: boolean) => void } -export const Autofocus = /* @__PURE__ */ forwardRef( - ( - { target = undefined, mouse: followMouse = false, debug = undefined, manual = false, smoothTime = 0.25, ...props }, - fref - ) => { - const dofRef = useRef(null) - const hitpointRef = useRef(null) - const targetRef = useRef(null) +export function Autofocus({ + target = undefined, + mouse: followMouse = false, + debug = undefined, + manual = false, + smoothTime = 0.25, + ref, + ...props +}: AutofocusProps) { + const dofRef = useRef(null) + const hitpointRef = useRef(null) + const targetRef = useRef(null) - const scene = useThree(({ scene }) => scene) - const pointer = useThree(({ pointer }) => pointer) - const { composer, camera } = useContext(EffectComposerContext) + const scene = useThree(({ scene }) => scene) + const pointer = useThree(({ pointer }) => pointer) + const { composer, camera } = useContext(EffectComposerContext) - // see: https://codesandbox.io/s/depthpickingpass-x130hg - const [depthPickingPass] = useState(() => new DepthPickingPass()) - const [copyPass] = useState(() => new CopyPass()) - useEffect(() => { - composer.addPass(depthPickingPass) - composer.addPass(copyPass) - return () => { - composer.removePass(depthPickingPass) - composer.removePass(copyPass) - } - }, [composer, depthPickingPass, copyPass]) + // see: https://codesandbox.io/s/depthpickingpass-x130hg + const [depthPickingPass] = useState(() => new DepthPickingPass()) + const [copyPass] = useState(() => new CopyPass()) + useEffect(() => { + composer.addPass(depthPickingPass) + composer.addPass(copyPass) + return () => { + composer.removePass(depthPickingPass) + composer.removePass(copyPass) + } + }, [composer, depthPickingPass, copyPass]) - useEffect(() => { - return () => { - depthPickingPass.dispose() - copyPass.dispose() - } - }, [depthPickingPass, copyPass]) + useEffect(() => { + return () => { + depthPickingPass.dispose() + copyPass.dispose() + } + }, [depthPickingPass, copyPass]) - const [hitpoint] = useState(() => new THREE.Vector3(0, 0, 0)) + const [hitpoint] = useState(() => new Vector3(0, 0, 0)) - const [ndc] = useState(() => new THREE.Vector3(0, 0, 0)) - const getHit = useCallback( - async (x: number, y: number) => { - ndc.x = x - ndc.y = y - ndc.z = await depthPickingPass.readDepth(ndc) - ndc.z = ndc.z * 2.0 - 1.0 - const hit = 1 - ndc.z > 0.0000001 // it is missed if ndc.z is close to 1 - return hit ? ndc.unproject(camera) : false - }, - [ndc, depthPickingPass, camera] - ) + const [ndc] = useState(() => new Vector3(0, 0, 0)) + const getHit = useCallback( + async (x: number, y: number) => { + ndc.x = x + ndc.y = y + ndc.z = await depthPickingPass.readDepth(ndc) + ndc.z = ndc.z * 2.0 - 1.0 + const hit = 1 - ndc.z > 0.0000001 // it is missed if ndc.z is close to 1 + return hit ? ndc.unproject(camera) : false + }, + [ndc, depthPickingPass, camera] + ) - const update = useCallback( - async (delta: number, updateTarget = true) => { - // Update hitpoint - if (target) { - hitpoint.set(...(target as [number, number, number])) - } else { - const { x, y } = followMouse ? pointer : { x: 0, y: 0 } - const hit = await getHit(x, y) - if (hit) hitpoint.copy(hit) - } + const update = useCallback( + async (delta: number, updateTarget = true) => { + // Update hitpoint + if (target) { + hitpoint.set(...(target as unknown as [number, number, number])) + } else { + const { x, y } = followMouse ? pointer : { x: 0, y: 0 } + const hit = await getHit(x, y) + if (hit) hitpoint.copy(hit) + } - // Update target - if (updateTarget && dofRef.current?.target) { - if (smoothTime > 0 && delta > 0) { - easing.damp3(dofRef.current.target, hitpoint, smoothTime, delta) - } else { - dofRef.current.target.copy(hitpoint) - } + // Update target + if (updateTarget && dofRef.current?.target) { + if (smoothTime > 0 && delta > 0) { + easing.damp3(dofRef.current.target, hitpoint, smoothTime, delta) + } else { + dofRef.current.target.copy(hitpoint) } - }, - [target, hitpoint, followMouse, getHit, smoothTime, pointer] - ) - - useFrame(async (_, delta) => { - if (!manual) { - update(delta) - } - if (hitpointRef.current) { - hitpointRef.current.position.copy(hitpoint) - } - if (targetRef.current && dofRef.current?.target) { - targetRef.current.position.copy(dofRef.current.target) } - }) + }, + [target, hitpoint, followMouse, getHit, smoothTime, pointer] + ) - // Ref API - const api = useMemo( - () => ({ - dofRef, - hitpoint, - update, - }), - [hitpoint, update] - ) - useImperativeHandle(fref, () => api, [api]) + useFrame(async (_, delta) => { + if (!manual) { + update(delta) + } + if (hitpointRef.current) { + hitpointRef.current.position.copy(hitpoint) + } + if (targetRef.current && dofRef.current?.target) { + targetRef.current.position.copy(dofRef.current.target) + } + }) - return ( - <> - {debug - ? createPortal( - <> - - - - - - - - - , - scene - ) - : null} + // Ref API + const api = useMemo( + () => ({ + dofRef, + hitpoint, + update, + }), + [hitpoint, update] + ) + useImperativeHandle(ref, () => api, [api]) - - - ) - } -) + return ( + <> + {debug + ? createPortal( + <> + + + + + + + + + , + scene + ) + : null} + + + + ) +} diff --git a/src/effects/Bloom.tsx b/src/effects/Bloom.tsx index 56f5a217..f3c9193b 100644 --- a/src/effects/Bloom.tsx +++ b/src/effects/Bloom.tsx @@ -1,5 +1,5 @@ -import { BloomEffect, BlendFunction } from 'postprocessing' -import { wrapEffect } from '../util' +import { BlendFunction, BloomEffect } from 'postprocessing' +import { wrapEffect } from '../wrapEffect' export const Bloom = /* @__PURE__ */ wrapEffect(BloomEffect, { blendFunction: BlendFunction.ADD, diff --git a/src/effects/BrightnessContrast.tsx b/src/effects/BrightnessContrast.tsx index d14855c6..ac1de7b0 100644 --- a/src/effects/BrightnessContrast.tsx +++ b/src/effects/BrightnessContrast.tsx @@ -1,4 +1,4 @@ import { BrightnessContrastEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const BrightnessContrast = /* @__PURE__ */ wrapEffect(BrightnessContrastEffect) diff --git a/src/effects/ChromaticAberration.tsx b/src/effects/ChromaticAberration.tsx index 5944ffad..ff9a41df 100644 --- a/src/effects/ChromaticAberration.tsx +++ b/src/effects/ChromaticAberration.tsx @@ -1,5 +1,5 @@ import { ChromaticAberrationEffect } from 'postprocessing' -import { type EffectProps, wrapEffect } from '../util' +import { type EffectProps, wrapEffect } from '../wrapEffect' export type ChromaticAberrationProps = EffectProps export const ChromaticAberration = /* @__PURE__ */ wrapEffect(ChromaticAberrationEffect) diff --git a/src/effects/ColorDepth.tsx b/src/effects/ColorDepth.tsx index 70a95d2c..da7610a0 100644 --- a/src/effects/ColorDepth.tsx +++ b/src/effects/ColorDepth.tsx @@ -1,4 +1,4 @@ import { ColorDepthEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const ColorDepth = /* @__PURE__ */ wrapEffect(ColorDepthEffect) diff --git a/src/effects/Depth.tsx b/src/effects/Depth.tsx index 0d99563f..abebf114 100644 --- a/src/effects/Depth.tsx +++ b/src/effects/Depth.tsx @@ -1,4 +1,4 @@ import { DepthEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const Depth = /* @__PURE__ */ wrapEffect(DepthEffect) diff --git a/src/effects/DotScreen.tsx b/src/effects/DotScreen.tsx index a7647ab5..8bd72976 100644 --- a/src/effects/DotScreen.tsx +++ b/src/effects/DotScreen.tsx @@ -1,4 +1,4 @@ import { DotScreenEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const DotScreen = /* @__PURE__ */ wrapEffect(DotScreenEffect) diff --git a/src/effects/FXAA.tsx b/src/effects/FXAA.tsx index 00659605..4214767f 100644 --- a/src/effects/FXAA.tsx +++ b/src/effects/FXAA.tsx @@ -1,4 +1,4 @@ import { FXAAEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const FXAA = /* @__PURE__ */ wrapEffect(FXAAEffect) diff --git a/src/effects/HueSaturation.tsx b/src/effects/HueSaturation.tsx index a3a3f61c..7a27c193 100644 --- a/src/effects/HueSaturation.tsx +++ b/src/effects/HueSaturation.tsx @@ -1,4 +1,4 @@ import { HueSaturationEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const HueSaturation = /* @__PURE__ */ wrapEffect(HueSaturationEffect) diff --git a/src/effects/LensFlare.tsx b/src/effects/LensFlare.tsx index 7f7587c9..4cb9d3c1 100644 --- a/src/effects/LensFlare.tsx +++ b/src/effects/LensFlare.tsx @@ -1,14 +1,14 @@ // Created by Anderson Mancini 2023 // From https://github.com/ektogamat/R3F-Ultimate-Lens-Flare -import * as THREE from 'three' -import React, { useEffect, useState, useContext, useRef } from 'react' import { useFrame, useThree } from '@react-three/fiber' -import { BlendFunction, Effect } from 'postprocessing' import { easing } from 'maath' +import { BlendFunction, Effect } from 'postprocessing' +import { useContext, useEffect, useRef, useState } from 'react' +import { Color, Mesh, Texture, Uniform, Vector2, Vector3 } from 'three' import { EffectComposerContext } from '../EffectComposer' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' const LensFlareShader = { fragmentShader: /* glsl */ ` @@ -406,9 +406,9 @@ type LensFlareEffectOptions = { /** The glare size */ glareSize: number /** The position of the lens flare in 3d space */ - lensPosition: THREE.Vector3 + lensPosition: Vector3 /** Effect resolution */ - screenRes: THREE.Vector2 + screenRes: Vector2 /** The number of points for the star */ starPoints: number /** The flare side */ @@ -421,10 +421,10 @@ type LensFlareEffectOptions = { animated: boolean /** Set the appearance to full anamorphic */ anamorphic: boolean - /** Set the color gain for the lens flare. Must be a THREE.Color in RBG format */ - colorGain: THREE.Color + /** Set the color gain for the lens flare. Must be a Color in RBG format */ + colorGain: Color /** Texture to be used as color dirt for starburst effect */ - lensDirtTexture: THREE.Texture | null + lensDirtTexture: Texture | null /** The halo scale */ haloScale: number /** Option to enable/disable secondary ghosts */ @@ -463,26 +463,26 @@ export class LensFlareEffect extends Effect { }: LensFlareEffectOptions) { super('LensFlareEffect', LensFlareShader.fragmentShader, { blendFunction, - uniforms: new Map([ - ['enabled', new THREE.Uniform(enabled)], - ['glareSize', new THREE.Uniform(glareSize)], - ['lensPosition', new THREE.Uniform(lensPosition)], - ['time', new THREE.Uniform(0)], - ['screenRes', new THREE.Uniform(screenRes)], - ['starPoints', new THREE.Uniform(starPoints)], - ['flareSize', new THREE.Uniform(flareSize)], - ['flareSpeed', new THREE.Uniform(flareSpeed)], - ['flareShape', new THREE.Uniform(flareShape)], - ['animated', new THREE.Uniform(animated)], - ['anamorphic', new THREE.Uniform(anamorphic)], - ['colorGain', new THREE.Uniform(colorGain)], - ['lensDirtTexture', new THREE.Uniform(lensDirtTexture)], - ['haloScale', new THREE.Uniform(haloScale)], - ['secondaryGhosts', new THREE.Uniform(secondaryGhosts)], - ['aditionalStreaks', new THREE.Uniform(aditionalStreaks)], - ['ghostScale', new THREE.Uniform(ghostScale)], - ['starBurst', new THREE.Uniform(starBurst)], - ['opacity', new THREE.Uniform(opacity)], + uniforms: new Map([ + ['enabled', new Uniform(enabled)], + ['glareSize', new Uniform(glareSize)], + ['lensPosition', new Uniform(lensPosition)], + ['time', new Uniform(0)], + ['screenRes', new Uniform(screenRes)], + ['starPoints', new Uniform(starPoints)], + ['flareSize', new Uniform(flareSize)], + ['flareSpeed', new Uniform(flareSpeed)], + ['flareShape', new Uniform(flareShape)], + ['animated', new Uniform(animated)], + ['anamorphic', new Uniform(anamorphic)], + ['colorGain', new Uniform(colorGain)], + ['lensDirtTexture', new Uniform(lensDirtTexture)], + ['haloScale', new Uniform(haloScale)], + ['secondaryGhosts', new Uniform(secondaryGhosts)], + ['aditionalStreaks', new Uniform(aditionalStreaks)], + ['ghostScale', new Uniform(ghostScale)], + ['starBurst', new Uniform(starBurst)], + ['opacity', new Uniform(opacity)], ]), }) } @@ -497,7 +497,7 @@ export class LensFlareEffect extends Effect { type LensFlareProps = { /** Position of the effect */ - lensPosition?: THREE.Vector3 + lensPosition?: Vector3 /** The time that it takes to fade the occlusion */ smoothTime?: number } & Partial @@ -510,15 +510,15 @@ export const LensFlare = ({ blendFunction = BlendFunction.NORMAL, enabled = true, glareSize = 0.2, - lensPosition = new THREE.Vector3(-25, 6, -60), - screenRes = new THREE.Vector2(0, 0), + lensPosition = new Vector3(-25, 6, -60), + screenRes = new Vector2(0, 0), starPoints = 6, flareSize = 0.01, flareSpeed = 0.01, flareShape = 0.01, animated = true, anamorphic = false, - colorGain = new THREE.Color(20, 20, 20), + colorGain = new Color(20, 20, 20), lensDirtTexture = null, haloScale = 0.5, secondaryGhosts = true, @@ -530,8 +530,8 @@ export const LensFlare = ({ const viewport = useThree(({ viewport }) => viewport) const raycaster = useThree(({ raycaster }) => raycaster) const { scene, camera } = useContext(EffectComposerContext) - const [raycasterPos] = useState(() => new THREE.Vector2()) - const [projectedPosition] = useState(() => new THREE.Vector3()) + const [raycasterPos] = useState(() => new Vector2()) + const [projectedPosition] = useState(() => new Vector3()) const ref = useRef(null) @@ -557,7 +557,7 @@ export const LensFlare = ({ if (object) { if (object.userData?.lensflare === 'no-occlusion') { target = 0 - } else if (object instanceof THREE.Mesh) { + } else if (object instanceof Mesh) { if (object.material.uniforms?._transmission?.value > 0.2) { //Check for MeshTransmissionMaterial target = 0.2 diff --git a/src/effects/Noise.tsx b/src/effects/Noise.tsx index aa23c3d2..a95e37da 100644 --- a/src/effects/Noise.tsx +++ b/src/effects/Noise.tsx @@ -1,4 +1,4 @@ -import { NoiseEffect, BlendFunction } from 'postprocessing' -import { wrapEffect } from '../util' +import { BlendFunction, NoiseEffect } from 'postprocessing' +import { wrapEffect } from '../wrapEffect' export const Noise = /* @__PURE__ */ wrapEffect(NoiseEffect, { blendFunction: BlendFunction.COLOR_DODGE }) diff --git a/src/effects/Ramp.tsx b/src/effects/Ramp.tsx index 502ce830..e2dab703 100644 --- a/src/effects/Ramp.tsx +++ b/src/effects/Ramp.tsx @@ -1,6 +1,6 @@ -import { Uniform } from 'three' import { Effect } from 'postprocessing' -import { wrapEffect } from '../util' +import { Uniform } from 'three' +import { wrapEffect } from '../wrapEffect' const RampShader = { fragmentShader: /* glsl */ ` diff --git a/src/effects/SMAA.tsx b/src/effects/SMAA.tsx index 41a927e0..9e41b1b9 100644 --- a/src/effects/SMAA.tsx +++ b/src/effects/SMAA.tsx @@ -1,4 +1,4 @@ import { SMAAEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const SMAA = /* @__PURE__ */ wrapEffect(SMAAEffect) diff --git a/src/effects/ScanlineEffect.tsx b/src/effects/ScanlineEffect.tsx index e533b39d..ed34430a 100644 --- a/src/effects/ScanlineEffect.tsx +++ b/src/effects/ScanlineEffect.tsx @@ -1,5 +1,5 @@ -import { ScanlineEffect, BlendFunction } from 'postprocessing' -import { wrapEffect } from '../util' +import { BlendFunction, ScanlineEffect } from 'postprocessing' +import { wrapEffect } from '../wrapEffect' export const Scanline = /* @__PURE__ */ wrapEffect(ScanlineEffect, { blendFunction: BlendFunction.OVERLAY, diff --git a/src/effects/Sepia.tsx b/src/effects/Sepia.tsx index f1e4a67c..8142b2bd 100644 --- a/src/effects/Sepia.tsx +++ b/src/effects/Sepia.tsx @@ -1,4 +1,4 @@ import { SepiaEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const Sepia = /* @__PURE__ */ wrapEffect(SepiaEffect) diff --git a/src/effects/ShockWave.tsx b/src/effects/ShockWave.tsx index abb3b55b..10da37dc 100644 --- a/src/effects/ShockWave.tsx +++ b/src/effects/ShockWave.tsx @@ -1,4 +1,4 @@ import { ShockWaveEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const ShockWave = /* @__PURE__ */ wrapEffect(ShockWaveEffect) diff --git a/src/effects/TiltShift.tsx b/src/effects/TiltShift.tsx index 5190c59f..82372e5a 100644 --- a/src/effects/TiltShift.tsx +++ b/src/effects/TiltShift.tsx @@ -1,4 +1,4 @@ -import { TiltShiftEffect, BlendFunction } from 'postprocessing' -import { wrapEffect } from '../util' +import { BlendFunction, TiltShiftEffect } from 'postprocessing' +import { wrapEffect } from '../wrapEffect' export const TiltShift = /* @__PURE__ */ wrapEffect(TiltShiftEffect, { blendFunction: BlendFunction.ADD }) diff --git a/src/effects/TiltShift2.tsx b/src/effects/TiltShift2.tsx index b1cd9425..83265060 100644 --- a/src/effects/TiltShift2.tsx +++ b/src/effects/TiltShift2.tsx @@ -1,9 +1,9 @@ -import { Uniform } from 'three' import { BlendFunction, Effect, EffectAttribute } from 'postprocessing' -import { wrapEffect } from '../util' +import { Uniform } from 'three' +import { wrapEffect } from '../wrapEffect' const TiltShiftShader = { - fragmentShader: ` + fragmentShader: /* glsl */ ` // original shader by Evan Wallace diff --git a/src/effects/ToneMapping.tsx b/src/effects/ToneMapping.tsx index 11c9f6a7..5358d7b7 100644 --- a/src/effects/ToneMapping.tsx +++ b/src/effects/ToneMapping.tsx @@ -1,5 +1,5 @@ import { ToneMappingEffect } from 'postprocessing' -import { type EffectProps, wrapEffect } from '../util' +import { type EffectProps, wrapEffect } from '../wrapEffect' export type ToneMappingProps = EffectProps diff --git a/src/effects/Vignette.tsx b/src/effects/Vignette.tsx index ab4afc3a..886020f5 100644 --- a/src/effects/Vignette.tsx +++ b/src/effects/Vignette.tsx @@ -1,4 +1,4 @@ import { VignetteEffect } from 'postprocessing' -import { wrapEffect } from '../util' +import { wrapEffect } from '../wrapEffect' export const Vignette = /* @__PURE__ */ wrapEffect(VignetteEffect) diff --git a/src/effects/Water.tsx b/src/effects/Water.tsx index 837312ad..e7b186cd 100644 --- a/src/effects/Water.tsx +++ b/src/effects/Water.tsx @@ -1,6 +1,6 @@ -import { Uniform } from 'three' import { BlendFunction, Effect, EffectAttribute } from 'postprocessing' -import { wrapEffect } from '../util' +import { Uniform } from 'three' +import { wrapEffect } from '../wrapEffect' const WaterShader = { fragmentShader: /* glsl */ ` diff --git a/src/index.ts b/src/index.ts index 56dd4fcd..492432c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ -export * from './Selection' export * from './EffectComposer' +export * from './Selection' export * from './util' +export * from './wrapEffect' +export * from './effects/ASCII' export * from './effects/Autofocus' -export * from './effects/LensFlare' export * from './effects/Bloom' export * from './effects/BrightnessContrast' export * from './effects/ChromaticAberration' @@ -12,28 +13,28 @@ export * from './effects/ColorDepth' export * from './effects/Depth' export * from './effects/DepthOfField' export * from './effects/DotScreen' +export * from './effects/FXAA' export * from './effects/Glitch' export * from './effects/GodRays' export * from './effects/Grid' export * from './effects/HueSaturation' +export * from './effects/LensFlare' +export * from './effects/LUT' export * from './effects/Noise' export * from './effects/Outline' export * from './effects/Pixelation' +export * from './effects/Ramp' export * from './effects/ScanlineEffect' export * from './effects/SelectiveBloom' export * from './effects/Sepia' -export * from './effects/SSAO' +export * from './effects/ShockWave' export * from './effects/SMAA' -export * from './effects/FXAA' -export * from './effects/Ramp' +export * from './effects/SSAO' export * from './effects/Texture' -export * from './effects/ToneMapping' -export * from './effects/Vignette' -export * from './effects/ShockWave' -export * from './effects/LUT' export * from './effects/TiltShift' export * from './effects/TiltShift2' -export * from './effects/ASCII' +export * from './effects/ToneMapping' +export * from './effects/Vignette' export * from './effects/Water' // These are not effect passes diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx new file mode 100644 index 00000000..e94f06b3 --- /dev/null +++ b/src/tests/test-utils.tsx @@ -0,0 +1,96 @@ +import { createRoot, extend } from '@react-three/fiber' +import { EffectComposer as EffectComposerImpl, EffectPass } from 'postprocessing' +import * as React from 'react' +import * as THREE from 'three' + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +extend(THREE as any) + +export const root = createRoot({ + style: {} as CSSStyleDeclaration, + addEventListener: (() => {}) as any, + removeEventListener: (() => {}) as any, + width: 1280, + height: 800, + clientWidth: 1280, + clientHeight: 800, + getContext: (() => + new Proxy( + {}, + { + get(_target, prop) { + switch (prop) { + case 'getParameter': + return () => 'WebGL 2' + case 'getExtension': + return () => ({}) + case 'getContextAttributes': + return () => ({ alpha: true }) + case 'getShaderPrecisionFormat': + return () => ({ rangeMin: 1, rangeMax: 1, precision: 1 }) + default: + return () => {} + } + }, + } + )) as any, +} satisfies Partial as HTMLCanvasElement) + +root.configure({ frameloop: 'never' }) + +export const EFFECT_SHADER = ` +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + outputColor = inputColor; +} +` + +export const flush = async () => { + await React.act(async () => { + await Promise.resolve() + }) +} + +export const strict = (children: React.ReactNode) => {children} + +export const waitForComposer = async (ref: React.RefObject): Promise => { + for (let i = 0; i < 50; i++) { + await flush() + if (ref.current) return ref.current + } + throw new Error('Composer was not created') +} + +export const waitForNewComposer = async ( + ref: React.RefObject, + previous: EffectComposerImpl +): Promise => { + for (let i = 0; i < 50; i++) { + await flush() + if (ref.current && ref.current !== previous) return ref.current + } + throw new Error('Composer was not recreated') +} + +export const waitForEffects = async (ref: React.RefObject, count: number) => { + for (let i = 0; i < 50; i++) { + await flush() + + const pass = ref.current?.passes.find( + (p): p is EffectPass => + // @ts-expect-error - `effects` isn't part of the public Pass typing + p instanceof EffectPass && p.effects.length === count + ) + + if (pass) { + // @ts-expect-error + return pass.effects + } + } + + throw new Error(`Expected EffectPass with ${count} effects`) +} diff --git a/src/tests/wrapEffect.test.tsx b/src/tests/wrapEffect.test.tsx new file mode 100644 index 00000000..88da2e20 --- /dev/null +++ b/src/tests/wrapEffect.test.tsx @@ -0,0 +1,209 @@ +import { Effect, EffectComposer as EffectComposerImpl } from 'postprocessing' +import * as React from 'react' +import { DataTexture, Texture } from 'three' +import { describe, expect, it, vi } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { wrapEffect } from '../wrapEffect' +import { flush, root } from './test-utils' + +class FakeEffect extends Effect { + value: number + constructor({ value = 0 }: { value?: number } = {}) { + super('FakeEffect', 'mainImage() {}') + this.value = value + } +} + +const FakeEffectComponent = /* @__PURE__ */ wrapEffect(FakeEffect) + +class CircularPropsEffect extends Effect { + constructor(_props: Record = {}) { + super('CircularPropsEffect', 'mainImage() {}') + } +} + +const CircularPropsComponent = /* @__PURE__ */ wrapEffect(CircularPropsEffect) + +describe('wrapEffect', () => { + it('passes the constructed value through to the underlying effect instance', async () => { + const composerRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + // @ts-expect-error + const effect = composerRef.current!.passes[1].effects[0] + + expect(effect).toBeInstanceOf(FakeEffect) + expect(effect.value).toBe(42) + + await React.act(async () => root.render(null)) + }) + + it('registers the new instance in the same act() as the args change, not one render later', async () => { + const composerRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const firstEffect = + // @ts-expect-error + composerRef.current!.passes[1].effects[0] + + expect(firstEffect.value).toBe(1) + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const secondEffect = + // @ts-expect-error + composerRef.current!.passes[1].effects[0] + + expect(secondEffect).not.toBe(firstEffect) + expect(secondEffect.value).toBe(2) + + await React.act(async () => root.render(null)) + }) + + it('does not crash when a prop value contains a circular reference (#333, #334)', async () => { + const composerRef = React.createRef() + const circular: Record = { value: 1 } + circular.self = circular + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + // @ts-expect-error + const effect = composerRef.current!.passes[1].effects[0] + + expect(effect).toBeInstanceOf(CircularPropsEffect) + + await React.act(async () => root.render(null)) + }) + + it('fingerprints a texture prop by identity, without invoking its toJSON (perf)', async () => { + const toJSONSpy = vi.spyOn(Texture.prototype, 'toJSON') + const composerRef = React.createRef() + const textureA = new DataTexture(new Uint8Array(4), 1, 1) + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const firstEffect = + // @ts-expect-error + composerRef.current!.passes[1].effects[0] + + // same texture reference on re-render — should not recreate the instance + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const secondEffect = + // @ts-expect-error + composerRef.current!.passes[1].effects[0] + + expect(secondEffect).toBe(firstEffect) + + // a genuinely different texture instance — should recreate + const textureB = new DataTexture(new Uint8Array(4), 1, 1) + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const thirdEffect = + // @ts-expect-error + composerRef.current!.passes[1].effects[0] + + expect(thirdEffect).not.toBe(secondEffect) + + expect(toJSONSpy).not.toHaveBeenCalled() + + await React.act(async () => root.render(null)) + toJSONSpy.mockRestore() + }) + + it('does not crash on repeated re-renders once a ref is attached', async () => { + // Regression for a bug specific to the *old* wrapEffect: it never + // destructured `ref` out of props, so `ref` landed in `...props` and + // JSON.stringify(props) tried to serialize `ref.current` — the mounted + // effect instance itself — on every re-render after the first, which + // crashed the same way #333/#334 did for texture props. Current + // wrapEffect destructures `ref` explicitly, so it never reaches the + // fingerprint at all; this just locks that in. + const composerRef = React.createRef() + const effectRef = React.createRef() + + function Harness({ tick: _tick }: { tick: number }) { + return ( + + + + ) + } + + await React.act(async () => root.render()) + await flush() + + expect(effectRef.current).toBeInstanceOf(FakeEffect) + const firstInstance = effectRef.current + + for (let tick = 1; tick <= 5; tick++) { + await React.act(async () => root.render()) + } + + await flush() + + expect(effectRef.current).toBe(firstInstance) + expect(effectRef.current).toBeInstanceOf(FakeEffect) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/util.tsx b/src/util.tsx index accf7d17..58da3fd0 100644 --- a/src/util.tsx +++ b/src/util.tsx @@ -1,55 +1,22 @@ -import React, { RefObject } from 'react' -import { Vector2 } from 'three' -import * as THREE from 'three' -import { type ReactThreeFiber, type ThreeElement, extend, useThree } from '@react-three/fiber' -import type { Effect, Pass, BlendFunction } from 'postprocessing' +import type { ReactThreeFiber } from '@react-three/fiber' +import { useMemo, type RefObject } from 'react' +import { Vector2, type Vector2Tuple } from 'three' -export const resolveRef = (ref: T | React.RefObject) => +export const resolveRef = (ref: T | RefObject) => typeof ref === 'object' && ref != null && 'current' in ref ? ref.current : ref -export type EffectConstructor = new (...args: any[]) => Effect | Pass - -export type EffectProps = ThreeElement & - ConstructorParameters[0] & { - blendFunction?: BlendFunction - opacity?: number - } - -let i = 0 -const components = new WeakMap | string>() +export const useVector2 = (props: Record, key: string): Vector2 => { + const value = props[key] as ReactThreeFiber.Vector2 | undefined -export const wrapEffect = (effect: T, defaults?: EffectProps) => - /* @__PURE__ */ function Effect({ blendFunction = defaults?.blendFunction, opacity = defaults?.opacity, ...props }) { - let Component = components.get(effect) - if (!Component) { - const key = `@react-three/postprocessing/${effect.name}-${i++}` - extend({ [key]: effect }) - components.set(effect, (Component = key)) + return useMemo(() => { + if (typeof value === 'number') { + return new Vector2(value, value) } - const camera = useThree((state) => state.camera) - const args = React.useMemo( - () => [...(defaults?.args ?? []), ...(props.args ?? [{ ...defaults, ...props }])], - // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(props)] - ) - - return ( - - ) - } + if (value) { + return new Vector2(...(value as Vector2Tuple)) + } -export const useVector2 = (props: Record, key: string): THREE.Vector2 => { - const value = props[key] as ReactThreeFiber.Vector2 | undefined - return React.useMemo(() => { - if (typeof value === 'number') return new THREE.Vector2(value, value) - else if (value) return new THREE.Vector2(...(value as THREE.Vector2Tuple)) - else return new THREE.Vector2() + return new Vector2() }, [value]) } diff --git a/src/wrapEffect.tsx b/src/wrapEffect.tsx new file mode 100644 index 00000000..6fc1f449 --- /dev/null +++ b/src/wrapEffect.tsx @@ -0,0 +1,122 @@ +import { extend, useThree } from '@react-three/fiber' +import type { BlendFunction, Effect, Pass } from 'postprocessing' +import { useMemo, type ExoticComponent, type JSX, type Ref } from 'react' + +export type EffectConstructor = new (...args: any[]) => Effect | Pass + +// Handles three ConstructorParameters shapes: required first param +// (P), optional first param (Partial

— some effects in postprocessing +// type inner fields as required even though the whole object is +// omittable), and no params at all ({}) +type FirstConstructorParam = + ConstructorParameters extends [infer P, ...any[]] + ? P + : ConstructorParameters extends [(infer P)?, ...any[]] + ? Partial

+ : {} + +export type EffectProps = FirstConstructorParam & { + ref?: Ref> + blendFunction?: BlendFunction + opacity?: number + args?: ConstructorParameters +} + +let i = 0 + +const components = new WeakMap | string>() + +const identityTokens = new WeakMap() +let nextIdentityToken = 0 + +// Same object in, same token out — lets otherwise-opaque values (textures, +// refs, any class instance) still change the fingerprint when swapped for +// a different instance, without walking into them. +function identityOf(value: object): number { + let token = identityTokens.get(value) + if (token === undefined) { + token = nextIdentityToken++ + identityTokens.set(value, token) + } + return token +} + +// Walks props into a JSON-safe fingerprint for the args memo below. Two +// things JSON.stringify can't be trusted with here: +// - it throws on cycles (refs, or any three.js object with a back- +// reference), so cycles are cut off with a WeakSet guard instead. +// - it calls a value's own toJSON first if present, which for e.g. +// THREE.Texture re-encodes the whole image to a base64 data URL on +// every single render (measured ~39ms for a 512x512 texture) just to +// throw the result away. Reading properties directly sidesteps that. +// - reading properties directly instead means typed arrays need their +// own case: Object.keys() on one returns every numeric index, so +// walking a texture's backing buffer element-by-element is even +// slower (~340ms for the same texture) than the toJSON it replaces. +// Identity is enough — same buffer means unchanged. +function fingerprint(value: unknown, seen: WeakSet): unknown { + if (value === null || typeof value !== 'object') return value + if (seen.has(value)) return '[Circular]' + + if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { + return identityOf(value) + } + + seen.add(value) + + if (Array.isArray(value)) { + return value.map((item) => fingerprint(item, seen)) + } + + const out: Record = {} + for (const key of Object.keys(value).sort()) { + out[key] = fingerprint((value as Record)[key], seen) + } + return out +} + +function stableStringify(value: unknown): string { + return JSON.stringify(fingerprint(value, new WeakSet())) +} + +export function wrapEffect( + effect: T, + defaults?: EffectProps +): (props: EffectProps) => JSX.Element { + return function WrappedEffect({ + ref, + blendFunction = defaults?.blendFunction, + opacity = defaults?.opacity, + ...props + }) { + let Component = components.get(effect) + + if (!Component) { + const key = `@react-three/postprocessing/${effect.name}-${i++}` + extend({ [key]: effect }) + components.set(effect, (Component = key)) + } + + const camera = useThree((state) => state.camera) + + const args = useMemo( + () => [ + ...((defaults?.args as unknown as any[]) ?? []), + ...((props.args as unknown as any[]) ?? [{ ...defaults, ...props }]), + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [stableStringify(props)] + ) + + return ( + + ) + } +}