diff --git a/src/EffectComposer.tsx b/src/EffectComposer.tsx index 7b0fef8..fdac235 100644 --- a/src/EffectComposer.tsx +++ b/src/EffectComposer.tsx @@ -59,11 +59,8 @@ type ComposerState = { const isConvolution = (effect: Effect): boolean => (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION -/** - * autoClear/toneMapping get force-set and never restored by whoever sets - * them. Ref-counted per (renderer, property) since composers can share a - * renderer; skips restoring if the value already changed since acquire. - */ +// autoClear/toneMapping get force-set and never restored. Ref-counted per +// (renderer, property) since composers can share a renderer. function createRendererPropertyGuard(property: K) { const refs = new WeakMap< WebGLRenderer, @@ -97,11 +94,21 @@ function createRendererPropertyGuard(prop const autoClearGuard = /* @__PURE__ */ createRendererPropertyGuard('autoClear') const toneMappingGuard = /* @__PURE__ */ createRendererPropertyGuard('toneMapping') -/** - * Groups a flat, ordered list of Effect/Pass instances into actual composer - * passes, merging consecutive non-convolution Effects into a single - * EffectPass. - */ +// Only passes buildPasses itself constructs - not a user's own EffectPass +// rendered directly as a child (still just `Pass`-instanceof passthrough +// below), which owns its own lifecycle. +const generatedPasses = /* @__PURE__ */ new WeakSet() + +// Not pass.dispose() - EffectPass.dispose() also disposes the effects it +// wraps, which are owned/reused elsewhere. setEffects([]) detaches their +// listeners first. +function disposeGeneratedPass(pass: Pass): void { + if (!generatedPasses.has(pass)) return + ;(pass as unknown as { setEffects(effects: never[]): void }).setEffects([]) + Pass.prototype.dispose.call(pass) +} + +// Consecutive non-convolution Effects share one EffectPass; Pass/convolution nodes get their own. function buildPasses(nodes: Array, camera: Camera): Pass[] { const passes: Pass[] = [] @@ -120,7 +127,9 @@ function buildPasses(nodes: Array, camera: Camera): Pass[] { } } - passes.push(new EffectPass(camera, ...effects)) + const pass = new EffectPass(camera, ...effects) + generatedPasses.add(pass) + passes.push(pass) } else if (node instanceof Pass) { passes.push(node) } @@ -148,9 +157,7 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({ const scene = _scene || defaultScene const camera = _camera || defaultCamera - // EffectComposer owns WebGL resources, so it must be created and - // disposed inside an effect lifecycle. useMemo is not suitable here - // because React may discard memoized values without running cleanup. + // useMemo can't own WebGL resources - React may discard it without cleanup. const [composerState, setComposerState] = useState(null) useEffect(() => { @@ -179,6 +186,10 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({ setComposerState({ composer: effectComposer, normalPass, downSamplingPass }) return () => { + // The rebuild effect below may not have detached its passes yet + // (composerState only updates next render) - without this, dispose() + // would kill effects the new composer is about to reuse. + for (const pass of effectComposer.passes) disposeGeneratedPass(pass) effectComposer.dispose() autoClearGuard.release(gl) } @@ -204,25 +215,38 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({ enabled ? renderPriority : 0 ) - // Passes are derived from the actual r3f scene graph rather than tracked - // incrementally, so the list always matches current JSX order — including - // through wrapper components — even after a reorder or a remount. + // Derived from the r3f scene graph (not tracked incrementally) so order + // always matches JSX, even through wrapper components or a reorder. const group = useRef(null!) + const nodesRef = useRef>([]) + const [nodesVersion, setNodesVersion] = useState(0) + // Runs every render (children has no stable identity) but only touches + // nodesRef/nodesVersion, never the composer - the rebuild below only + // fires when the resolved node list actually changes. useLayoutEffect(() => { if (!composerState) return - const { composer, normalPass, downSamplingPass } = composerState - - const passes: Pass[] = [] const groupInstance = (group.current as Group & { __r3f: Instance }).__r3f + const nodes = groupInstance + ? groupInstance.children + .map((child) => child.object) + .filter((object): object is Effect | Pass => object instanceof Effect || object instanceof Pass) + : [] + + const previous = nodesRef.current + const unchanged = nodes.length === previous.length && nodes.every((node, i) => node === previous[i]) + if (unchanged) return + nodesRef.current = nodes + setNodesVersion((v) => v + 1) + }) + + // Only re-runs when nodesVersion/composerState/camera change - React's + // own dependency bailout, so create/cleanup pairing stays correct. + useLayoutEffect(() => { + if (!composerState) return + const { composer, normalPass, downSamplingPass } = composerState - if (groupInstance) { - const nodes = groupInstance.children.map((child) => child.object).filter( - (object): object is Effect | Pass => object instanceof Effect || object instanceof Pass - ) - - passes.push(...buildPasses(nodes, camera)) - } + const passes = buildPasses(nodesRef.current, camera) for (const pass of passes) composer.addPass(pass) @@ -232,11 +256,14 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({ } return () => { - for (const pass of passes) composer.removePass(pass) + for (const pass of passes) { + composer.removePass(pass) + disposeGeneratedPass(pass) + } if (normalPass) normalPass.enabled = false if (downSamplingPass) downSamplingPass.enabled = false } - }, [composerState, children, camera]) + }, [composerState, nodesVersion, camera]) // Disable tone mapping because threejs disallows tonemapping on render targets useEffect(() => { diff --git a/src/effects/ASCII.tsx b/src/effects/ASCII.tsx index b6744b5..2889dd3 100644 --- a/src/effects/ASCII.tsx +++ b/src/effects/ASCII.tsx @@ -2,9 +2,9 @@ // https://twitter.com/emilwidlund/status/1652386482420609024 import { Effect } from 'postprocessing' -import { Ref, useMemo } from 'react' -import { CanvasTexture, Color, NearestFilter, RepeatWrapping, Texture, Uniform } from 'three' -import { useDispose } from '../util' +import type { Ref } from 'react' +import { CanvasTexture, Color, type ColorRepresentation, NearestFilter, RepeatWrapping, Texture, Uniform } from 'three' +import { createEffectComponent } from '../createEffectComponent' const fragment = /* glsl */ ` uniform sampler2D uCharacters; @@ -47,17 +47,21 @@ const fragment = /* glsl */ ` } ` -interface IASCIIEffectProps { +export type ASCIIProps = { font?: string characters?: string fontSize?: number cellSize?: number - color?: string + color?: ColorRepresentation invert?: boolean ref?: Ref } class ASCIIEffect extends Effect { + private _font: string + private _characters: string + private _fontSize: number + constructor({ font = 'arial', characters = ` .:,'-^=*+?!|0#X%WM@`, @@ -65,7 +69,7 @@ class ASCIIEffect extends Effect { cellSize = 16, color = '#ffffff', invert = false, - }: Omit = {}) { + }: Omit = {}) { const uniforms = new Map([ ['uCharacters', new Uniform(new Texture())], ['uCellSize', new Uniform(cellSize)], @@ -76,11 +80,71 @@ class ASCIIEffect extends Effect { super('ASCIIEffect', fragment, { uniforms }) - const charactersTextureUniform = this.uniforms.get('uCharacters') + this._font = font + this._characters = characters + this._fontSize = fontSize + this.updateCharactersTexture() + } - if (charactersTextureUniform) { - charactersTextureUniform.value = this.createCharactersTexture(characters, font, fontSize) - } + get cellSize(): number { + return this.uniforms.get('uCellSize')!.value + } + + set cellSize(value: number) { + this.uniforms.get('uCellSize')!.value = value + } + + get invert(): boolean { + return this.uniforms.get('uInvert')!.value + } + + set invert(value: boolean) { + this.uniforms.get('uInvert')!.value = value + } + + get color(): Color { + return this.uniforms.get('uColor')!.value + } + + set color(value: ColorRepresentation) { + this.uniforms.get('uColor')!.value.set(value) + } + + get font(): string { + return this._font + } + + set font(value: string) { + this._font = value + this.updateCharactersTexture() + } + + get characters(): string { + return this._characters + } + + set characters(value: string) { + this._characters = value + this.uniforms.get('uCharactersCount')!.value = value.length + this.updateCharactersTexture() + } + + get fontSize(): number { + return this._fontSize + } + + set fontSize(value: number) { + this._fontSize = value + this.updateCharactersTexture() + } + + // Regenerates the character atlas texture - characters/font/fontSize have + // no cheaper live update path, unlike the plain-uniform props above. + private updateCharactersTexture(): void { + const uniform = this.uniforms.get('uCharacters')! + const previous = uniform.value as Texture + uniform.value = this.createCharactersTexture(this._characters, this._font, this._fontSize) + previous.dispose() } /** Draws the characters on a Canvas and returns a texture */ @@ -116,21 +180,4 @@ class ASCIIEffect extends Effect { } } -export function ASCII({ - font = 'arial', - characters = ` .:,'-^=*+?!|0#X%WM@`, - fontSize = 54, - cellSize = 16, - color = '#ffffff', - invert = false, - ref, -}: IASCIIEffectProps) { - const effect = useMemo( - () => new ASCIIEffect({ characters, font, fontSize, cellSize, color, invert }), - [characters, fontSize, cellSize, color, invert, font] - ) - - useDispose(effect) - - return -} +export const ASCII = /* @__PURE__ */ createEffectComponent(ASCIIEffect) diff --git a/src/effects/Autofocus.tsx b/src/effects/Autofocus.tsx index fefcad5..58cd21b 100644 --- a/src/effects/Autofocus.tsx +++ b/src/effects/Autofocus.tsx @@ -18,23 +18,6 @@ import { Mesh, Vector3 } from 'three' import { EffectComposerContext } from '../EffectComposer' import { DepthOfField } from './DepthOfField' -// EffectComposerImpl.dispose() disposes every pass it currently holds — -// including these two, since they're added via composer.addPass below. -// When Autofocus unmounts alongside its ancestor EffectComposer (e.g. a -// full tree unmount), both the composer's own teardown AND this -// component's cleanup effect would dispose the same instances. Wrapping -// dispose here makes it safe no matter which caller gets there first. -function makeDisposeIdempotent void }>(instance: T): T { - let disposed = false - const dispose = instance.dispose.bind(instance) - instance.dispose = () => { - if (disposed) return - disposed = true - dispose() - } - return instance -} - export type AutofocusProps = ComponentProps & { target?: R3FVector3 /** should the target follow the pointer */ @@ -71,9 +54,8 @@ export function Autofocus({ const pointer = useThree(({ pointer }) => pointer) const { composer, camera } = useContext(EffectComposerContext) - // see: https://codesandbox.io/s/depthpickingpass-x130hg - const [depthPickingPass] = useState(() => makeDisposeIdempotent(new DepthPickingPass())) - const [copyPass] = useState(() => makeDisposeIdempotent(new CopyPass())) + const [depthPickingPass] = useState(() => new DepthPickingPass()) + const [copyPass] = useState(() => new CopyPass()) useEffect(() => { composer.addPass(depthPickingPass) composer.addPass(copyPass) diff --git a/src/effects/Bloom.tsx b/src/effects/Bloom.tsx index f3c9193..5983362 100644 --- a/src/effects/Bloom.tsx +++ b/src/effects/Bloom.tsx @@ -1,6 +1,34 @@ import { BlendFunction, BloomEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { useMemo } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Bloom = /* @__PURE__ */ wrapEffect(BloomEffect, { - blendFunction: BlendFunction.ADD, -}) +type BloomOptions = EffectOptions + +const BloomImpl = /* @__PURE__ */ createEffectComponent(BloomEffect) + +export type BloomProps = BloomOptions & { opacity?: number; ref?: Ref } + +// luminanceThreshold/luminanceSmoothing/mipmapBlur/radius/levels/resolution* +// have no live setter in postprocessing - routed through args so they still +// work as plain props, just via reconstruction instead of mutation. +export function Bloom({ + blendFunction = BlendFunction.ADD, + luminanceThreshold, + luminanceSmoothing, + mipmapBlur, + radius, + levels, + resolutionScale, + resolutionX, + resolutionY, + ...liveProps +}: BloomProps) { + const args = useMemo<[BloomOptions]>( + () => [ + { luminanceThreshold, luminanceSmoothing, mipmapBlur, radius, levels, resolutionScale, resolutionX, resolutionY }, + ], + [luminanceThreshold, luminanceSmoothing, mipmapBlur, radius, levels, resolutionScale, resolutionX, resolutionY] + ) + return +} diff --git a/src/effects/BrightnessContrast.tsx b/src/effects/BrightnessContrast.tsx index ac1de7b..cba9939 100644 --- a/src/effects/BrightnessContrast.tsx +++ b/src/effects/BrightnessContrast.tsx @@ -1,4 +1,7 @@ import { BrightnessContrastEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const BrightnessContrast = /* @__PURE__ */ wrapEffect(BrightnessContrastEffect) +export const BrightnessContrast = /* @__PURE__ */ createEffectComponent< + typeof BrightnessContrastEffect, + EffectOptions +>(BrightnessContrastEffect) diff --git a/src/effects/ChromaticAberration.tsx b/src/effects/ChromaticAberration.tsx index c768071..bbbfbfc 100644 --- a/src/effects/ChromaticAberration.tsx +++ b/src/effects/ChromaticAberration.tsx @@ -1,30 +1,22 @@ import type { ReactThreeFiber } from '@react-three/fiber' import { ChromaticAberrationEffect } from 'postprocessing' import type { Ref } from 'react' -import { useMemo } from 'react' -import { useDispose, useVector2 } from '../util' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' +// radialModulation/modulationOffset are typed as required by postprocessing's +// own .d.ts, but its JSDoc confirms both are optional with defaults - an +// upstream declaration bug, not a real constraint. export type ChromaticAberrationProps = Omit< - Partial[0]>, - 'offset' + EffectOptions, + 'offset' | 'radialModulation' | 'modulationOffset' > & { - ref?: Ref offset?: ReactThreeFiber.Vector2 + radialModulation?: boolean + modulationOffset?: number + ref?: Ref } -export function ChromaticAberration({ ref, ...props }: ChromaticAberrationProps) { - const offset = useVector2(props, 'offset') - - const effect = useMemo( - () => - new ChromaticAberrationEffect({ - ...props, - offset, - } as ConstructorParameters[0]), - [offset, props] - ) - - useDispose(effect) - - return -} +export const ChromaticAberration = /* @__PURE__ */ createEffectComponent< + typeof ChromaticAberrationEffect, + ChromaticAberrationProps +>(ChromaticAberrationEffect) diff --git a/src/effects/ColorAverage.tsx b/src/effects/ColorAverage.tsx index 5a56292..fe6a640 100644 --- a/src/effects/ColorAverage.tsx +++ b/src/effects/ColorAverage.tsx @@ -1,17 +1,4 @@ -import { BlendFunction, ColorAverageEffect } from 'postprocessing' -import type { Ref } from 'react' -import { useMemo } from 'react' -import { useDispose } from '../util' +import { ColorAverageEffect } from 'postprocessing' +import { createEffectComponent } from '../createEffectComponent' -export type ColorAverageProps = { - blendFunction?: BlendFunction - ref?: Ref -} - -export function ColorAverage({ blendFunction = BlendFunction.NORMAL, ref }: ColorAverageProps) { - const effect = useMemo(() => new ColorAverageEffect(blendFunction), [blendFunction]) - - useDispose(effect) - - return -} +export const ColorAverage = /* @__PURE__ */ createEffectComponent(ColorAverageEffect) diff --git a/src/effects/ColorDepth.tsx b/src/effects/ColorDepth.tsx index da7610a..ce29302 100644 --- a/src/effects/ColorDepth.tsx +++ b/src/effects/ColorDepth.tsx @@ -1,4 +1,25 @@ import { ColorDepthEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const ColorDepth = /* @__PURE__ */ wrapEffect(ColorDepthEffect) +const ColorDepthImpl = /* @__PURE__ */ createEffectComponent< + typeof ColorDepthEffect, + Omit, 'bits'> & { bitDepth?: number } +>(ColorDepthEffect) + +export type ColorDepthProps = EffectOptions & { + opacity?: number + ref?: Ref +} + +// bits (the constructor's option name) has no live setter of its own in +// postprocessing - only the differently-named bitDepth does (bits is a +// plain, dead field on the instance). Renamed here so it still works as a +// plain prop after the initial mount. +export function ColorDepth({ bits, ...props }: ColorDepthProps) { + // Only set bitDepth when bits is actually provided - r3f's reset-on- + // removal only fires when a key is absent from the new props, not when + // it's present but undefined. + if (bits !== undefined) (props as Record).bitDepth = bits + return +} diff --git a/src/effects/Depth.tsx b/src/effects/Depth.tsx index abebf11..ddc1064 100644 --- a/src/effects/Depth.tsx +++ b/src/effects/Depth.tsx @@ -1,4 +1,6 @@ import { DepthEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Depth = /* @__PURE__ */ wrapEffect(DepthEffect) +export const Depth = /* @__PURE__ */ createEffectComponent>( + DepthEffect +) diff --git a/src/effects/DepthOfField.tsx b/src/effects/DepthOfField.tsx index 5c9b1fa..ba9aa3e 100644 --- a/src/effects/DepthOfField.tsx +++ b/src/effects/DepthOfField.tsx @@ -4,7 +4,7 @@ import type { Ref } from 'react' import { use, useMemo } from 'react' import { type DepthPackingStrategies, type Texture, Vector3 } from 'three' import { EffectComposerContext } from '../EffectComposer' -import { useDispose } from '../util' +import { applyPierced, readPierced, useDispose, useLiveDefaults } from '../util' export type DepthOfFieldProps = ConstructorParameters[1] & Partial<{ @@ -19,6 +19,37 @@ export type DepthOfFieldProps = ConstructorParameters blur: number }> +// Only bokehScale, focusDistance/focusRange (via the nested cocMaterial), +// depthTexture (via setDepthTexture) and blendFunction have real setters in +// postprocessing - every resolution option is construction-only. camera +// being a required constructor arg also rules out createEffectComponent +// (needs `new Effect()` to work with zero args). +const LIVE_KEYS = [ + 'blendMode-blendFunction', + 'bokehScale', + 'cocMaterial-focusDistance', + 'cocMaterial-focusRange', + 'depthTexture', +] + +// cocMaterial.depthBuffer/depthPacking are write-only in postprocessing +// (setters with no matching getters) - depthPacking can't be read back at +// all, so a reverted default always re-applies BasicDepthPacking (the same +// value setDepthTexture itself defaults to when packing is omitted). +function get(effect: DepthOfFieldEffect, key: string): unknown { + if (key !== 'depthTexture') return readPierced(effect, key) + const texture = (effect.cocMaterial as unknown as { uniforms: { depthBuffer: { value: unknown } } }).uniforms + .depthBuffer.value + return texture ? { texture } : undefined +} + +function set(effect: DepthOfFieldEffect, key: string, value: unknown): void { + if (key === 'depthTexture') { + const dt = value as { texture?: Texture; packing?: DepthPackingStrategies } | undefined + effect.setDepthTexture(dt?.texture as never, dt?.packing) + } else applyPierced(effect, key, value) +} + export function DepthOfField({ ref, blendFunction, @@ -42,13 +73,9 @@ export function DepthOfField({ const effect = useMemo(() => { const effect = new DepthOfFieldEffect(camera, { - blendFunction, worldFocusDistance, worldFocusRange, - focusDistance, - focusRange, focalLength, - bokehScale, resolutionScale, resolutionX, resolutionY, @@ -57,29 +84,24 @@ export function DepthOfField({ }) // Creating a target enables autofocus, R3F will set via props if (autoFocus) effect.target = new Vector3() - // Depth texture for depth picking with optional packing strategy - if (depthTexture) effect.setDepthTexture(depthTexture.texture, depthTexture.packing as DepthPackingStrategies) // Temporary fix that restores DOF 6.21.3 behavior, everything since then lets shapes leak through the blur - const maskPass = (effect as any).maskPass - maskPass.maskFunction = MaskFunction.MULTIPLY_RGB_SET_ALPHA + effect.maskFunction = MaskFunction.MULTIPLY_RGB_SET_ALPHA return effect - }, [ - camera, - blendFunction, - worldFocusDistance, - worldFocusRange, - focusDistance, - focusRange, - focalLength, - bokehScale, - resolutionScale, - resolutionX, - resolutionY, - width, - height, - autoFocus, - depthTexture, - ]) + }, [camera, worldFocusDistance, worldFocusRange, focalLength, resolutionScale, resolutionX, resolutionY, width, height, autoFocus]) + + useLiveDefaults( + effect, + { + 'blendMode-blendFunction': blendFunction, + bokehScale, + 'cocMaterial-focusDistance': focusDistance, + 'cocMaterial-focusRange': focusRange, + depthTexture, + }, + LIVE_KEYS, + get, + set + ) useDispose(effect) diff --git a/src/effects/DotScreen.tsx b/src/effects/DotScreen.tsx index 8bd7297..b480ecc 100644 --- a/src/effects/DotScreen.tsx +++ b/src/effects/DotScreen.tsx @@ -1,4 +1,7 @@ import { DotScreenEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const DotScreen = /* @__PURE__ */ wrapEffect(DotScreenEffect) +export const DotScreen = /* @__PURE__ */ createEffectComponent< + typeof DotScreenEffect, + EffectOptions +>(DotScreenEffect) diff --git a/src/effects/FXAA.tsx b/src/effects/FXAA.tsx index 4214767..1c93ac5 100644 --- a/src/effects/FXAA.tsx +++ b/src/effects/FXAA.tsx @@ -1,4 +1,6 @@ import { FXAAEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const FXAA = /* @__PURE__ */ wrapEffect(FXAAEffect) +export const FXAA = /* @__PURE__ */ createEffectComponent>( + FXAAEffect +) diff --git a/src/effects/Glitch.tsx b/src/effects/Glitch.tsx index 488823c..3d9befa 100644 --- a/src/effects/Glitch.tsx +++ b/src/effects/Glitch.tsx @@ -1,37 +1,32 @@ -import { ReactThreeFiber, useThree } from '@react-three/fiber' +import type { ReactThreeFiber } from '@react-three/fiber' import { GlitchEffect, GlitchMode } from 'postprocessing' -import { Ref, useLayoutEffect, useMemo } from 'react' -import { useDispose, useVector2 } from '../util' +import type { Ref } from 'react' +import { useMemo } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export type GlitchProps = ConstructorParameters[0] & - Partial<{ - mode: GlitchMode - active: boolean - delay: ReactThreeFiber.Vector2 - duration: ReactThreeFiber.Vector2 - chromaticAberrationOffset: ReactThreeFiber.Vector2 - strength: ReactThreeFiber.Vector2 - ref?: Ref - }> - -export function Glitch({ active = true, ref, ...props }: GlitchProps) { - const invalidate = useThree((state) => state.invalidate) - const delay = useVector2(props, 'delay') - const duration = useVector2(props, 'duration') - const strength = useVector2(props, 'strength') - const chromaticAberrationOffset = useVector2(props, 'chromaticAberrationOffset') - - const effect = useMemo( - () => new GlitchEffect({ ...props, delay, duration, strength, chromaticAberrationOffset }), - [delay, duration, props, strength, chromaticAberrationOffset] - ) +type GlitchOptions = Omit< + EffectOptions, + 'delay' | 'duration' | 'strength' | 'chromaticAberrationOffset' +> & { + delay?: ReactThreeFiber.Vector2 + duration?: ReactThreeFiber.Vector2 + strength?: ReactThreeFiber.Vector2 + chromaticAberrationOffset?: ReactThreeFiber.Vector2 + mode?: GlitchMode +} - useLayoutEffect(() => { - effect.mode = active ? props.mode || GlitchMode.SPORADIC : GlitchMode.DISABLED - invalidate() - }, [active, effect, invalidate, props.mode]) +const GlitchImpl = /* @__PURE__ */ createEffectComponent(GlitchEffect) - useDispose(effect) +export type GlitchProps = GlitchOptions & { + active?: boolean + opacity?: number + ref?: Ref +} - return +// dtSize only seeds the auto-generated perturbation map at construction time +// (skipped entirely once a perturbationMap is provided) - routed through +// args so it still works as a plain prop. +export function Glitch({ active = true, mode = GlitchMode.SPORADIC, dtSize, ...props }: GlitchProps) { + const args = useMemo<[EffectOptions]>(() => [{ dtSize }], [dtSize]) + return } diff --git a/src/effects/GodRays.tsx b/src/effects/GodRays.tsx index b4a3df6..e8fba56 100644 --- a/src/effects/GodRays.tsx +++ b/src/effects/GodRays.tsx @@ -1,18 +1,94 @@ +import { useThree } from '@react-three/fiber' import { GodRaysEffect } from 'postprocessing' -import { Ref, RefObject, useContext, useLayoutEffect, useMemo } from 'react' +import { Ref, RefObject, use, useLayoutEffect, useMemo } from 'react' import { Mesh, Points } from 'three' import { EffectComposerContext } from '../EffectComposer' -import { resolveRef, useDispose } from '../util' +import { applyPierced, readPierced, resolveRef, useDispose, useLiveDefaults } from '../util' type GodRaysProps = ConstructorParameters[2] & { sun: Mesh | Points | RefObject ref?: Ref } -export function GodRays({ ref, ...props }: GodRaysProps) { - const { camera } = useContext(EffectComposerContext) - const effect = useMemo(() => new GodRaysEffect(camera, resolveRef(props.sun), props), [camera, props]) - useLayoutEffect(() => void (effect.lightSource = resolveRef(props.sun)), [effect, props.sun]) +// GodRaysMaterial (godRaysMaterial) is where density/decay/weight/exposure +// actually live - clampMax maps to its differently-named maxIntensity. +// resolutionScale/resolutionX/resolutionY have no setter at all in +// postprocessing - construction-only. camera+sun being required constructor +// args also rule out createEffectComponent (needs `new Effect()` to work +// with zero args). +const LIVE_KEYS = [ + 'blendMode-blendFunction', + 'godRaysMaterial-density', + 'godRaysMaterial-decay', + 'godRaysMaterial-weight', + 'godRaysMaterial-exposure', + 'clampMax', + 'blur', + 'kernelSize', + 'samples', + 'width', + 'height', +] + +function get(effect: GodRaysEffect, key: string): unknown { + return key === 'clampMax' ? effect.godRaysMaterial.maxIntensity : readPierced(effect, key) +} + +function set(effect: GodRaysEffect, key: string, value: unknown): void { + if (key === 'clampMax') effect.godRaysMaterial.maxIntensity = value as number + else applyPierced(effect, key, value) +} + +export function GodRays({ + sun, + blendFunction, + density, + decay, + weight, + exposure, + clampMax, + blur, + kernelSize, + samples, + width, + height, + resolutionScale, + resolutionX, + resolutionY, + ref, +}: GodRaysProps) { + const { camera } = use(EffectComposerContext) + const invalidate = useThree((state) => state.invalidate) + + const effect = useMemo( + () => new GodRaysEffect(camera, resolveRef(sun), { resolutionScale, resolutionX, resolutionY }), + [camera, resolutionScale, resolutionX, resolutionY] + ) + + useLayoutEffect(() => { + effect.lightSource = resolveRef(sun) + invalidate() + }, [effect, sun, invalidate]) + + useLiveDefaults( + effect, + { + 'blendMode-blendFunction': blendFunction, + 'godRaysMaterial-density': density, + 'godRaysMaterial-decay': decay, + 'godRaysMaterial-weight': weight, + 'godRaysMaterial-exposure': exposure, + clampMax, + blur, + kernelSize, + samples, + width, + height, + }, + LIVE_KEYS, + get, + set + ) useDispose(effect) diff --git a/src/effects/Grid.tsx b/src/effects/Grid.tsx index 639e22b..f818ce5 100644 --- a/src/effects/Grid.tsx +++ b/src/effects/Grid.tsx @@ -1,28 +1,27 @@ import { useThree } from '@react-three/fiber' import { GridEffect } from 'postprocessing' -import { Ref, useLayoutEffect, useMemo } from 'react' -import { useDispose } from '../util' +import { type Ref, useImperativeHandle, useLayoutEffect, useRef } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -type GridProps = ConstructorParameters[0] & - Partial<{ - size: { - width: number - height: number - } - ref: Ref - }> +const GridImpl = /* @__PURE__ */ createEffectComponent>(GridEffect) + +export type GridProps = EffectOptions & { + size?: { width: number; height: number } + opacity?: number + ref?: Ref +} export function Grid({ size, ref, ...props }: GridProps) { const invalidate = useThree((state) => state.invalidate) - - const effect = useMemo(() => new GridEffect(props), [props]) + const localRef = useRef(null) + useImperativeHandle(ref, () => localRef.current!, []) useLayoutEffect(() => { - if (size) effect.setSize(size.width, size.height) - invalidate() - }, [effect, size, invalidate]) - - useDispose(effect) + if (size) { + localRef.current?.setSize(size.width, size.height) + invalidate() + } + }, [size, invalidate]) - return + return } diff --git a/src/effects/HueSaturation.tsx b/src/effects/HueSaturation.tsx index 7a27c19..d791208 100644 --- a/src/effects/HueSaturation.tsx +++ b/src/effects/HueSaturation.tsx @@ -1,4 +1,7 @@ import { HueSaturationEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const HueSaturation = /* @__PURE__ */ wrapEffect(HueSaturationEffect) +export const HueSaturation = /* @__PURE__ */ createEffectComponent< + typeof HueSaturationEffect, + EffectOptions +>(HueSaturationEffect) diff --git a/src/effects/LUT.tsx b/src/effects/LUT.tsx index f1e277c..5db77e3 100644 --- a/src/effects/LUT.tsx +++ b/src/effects/LUT.tsx @@ -1,8 +1,7 @@ -import { useThree } from '@react-three/fiber' import { BlendFunction, LUT3DEffect } from 'postprocessing' -import { Ref, useLayoutEffect, useMemo } from 'react' +import { Ref, useMemo } from 'react' import type { Texture } from 'three' -import { useDispose } from '../util' +import { useDispose, useLiveDefaults } from '../util' export type LUTProps = { lut: Texture @@ -11,16 +10,15 @@ export type LUTProps = { ref?: Ref } -export function LUT({ lut, tetrahedralInterpolation, ref, ...props }: LUTProps) { - const effect = useMemo(() => new LUT3DEffect(lut, props), [lut, props]) - const invalidate = useThree((state) => state.invalidate) +const LIVE_KEYS = ['blendMode-blendFunction', 'lut', 'tetrahedralInterpolation'] - useLayoutEffect(() => { - if (tetrahedralInterpolation) effect.tetrahedralInterpolation = tetrahedralInterpolation - if (lut) effect.lut = lut - invalidate() - }, [effect, invalidate, lut, tetrahedralInterpolation]) +// lut is LUT3DEffect's required constructor arg (no default) - only used +// for the initial instance, later changes go through its own live setter +// (via useLiveDefaults below) instead of reconstructing. +export function LUT({ lut, blendFunction, tetrahedralInterpolation, ref }: LUTProps) { + const effect = useMemo(() => new LUT3DEffect(lut), []) + useLiveDefaults(effect, { 'blendMode-blendFunction': blendFunction, lut, tetrahedralInterpolation }, LIVE_KEYS) useDispose(effect) return diff --git a/src/effects/LensFlare.tsx b/src/effects/LensFlare.tsx index 4cb9d3c..e283b92 100644 --- a/src/effects/LensFlare.tsx +++ b/src/effects/LensFlare.tsx @@ -4,11 +4,11 @@ import { useFrame, useThree } from '@react-three/fiber' import { easing } from 'maath' import { BlendFunction, Effect } from 'postprocessing' -import { useContext, useEffect, useRef, useState } from 'react' +import { useContext, useEffect, useRef, useState, type Ref } from 'react' import { Color, Mesh, Texture, Uniform, Vector2, Vector3 } from 'three' +import { createEffectComponent } from '../createEffectComponent' import { EffectComposerContext } from '../EffectComposer' -import { wrapEffect } from '../wrapEffect' const LensFlareShader = { fragmentShader: /* glsl */ ` @@ -441,26 +441,26 @@ type LensFlareEffectOptions = { export class LensFlareEffect extends Effect { constructor({ - blendFunction, - enabled, - glareSize, - lensPosition, - screenRes, - starPoints, - flareSize, - flareSpeed, - flareShape, - animated, - anamorphic, - colorGain, - lensDirtTexture, - haloScale, - secondaryGhosts, - aditionalStreaks, - ghostScale, - opacity, - starBurst, - }: LensFlareEffectOptions) { + blendFunction = BlendFunction.NORMAL, + enabled = true, + glareSize = 0.2, + 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 Color(20, 20, 20), + lensDirtTexture = null, + haloScale = 0.5, + secondaryGhosts = true, + aditionalStreaks = true, + ghostScale = 0.0, + opacity = 1.0, + starBurst = false, + }: Partial = {}) { super('LensFlareEffect', LensFlareShader.fragmentShader, { blendFunction, uniforms: new Map([ @@ -493,6 +493,140 @@ export class LensFlareEffect extends Effect { time.value += deltaTime } } + + private u(name: string): T { + return this.uniforms.get(name)!.value + } + + private setU(name: string, value: unknown): void { + this.uniforms.get(name)!.value = value + } + + get enabled(): boolean { + return this.u('enabled') + } + set enabled(value: boolean) { + this.setU('enabled', value) + } + + get glareSize(): number { + return this.u('glareSize') + } + set glareSize(value: number) { + this.setU('glareSize', value) + } + + get lensPosition(): Vector3 { + return this.u('lensPosition') + } + set lensPosition(value: Vector3) { + this.setU('lensPosition', value) + } + + get screenRes(): Vector2 { + return this.u('screenRes') + } + set screenRes(value: Vector2) { + this.setU('screenRes', value) + } + + get starPoints(): number { + return this.u('starPoints') + } + set starPoints(value: number) { + this.setU('starPoints', value) + } + + get flareSize(): number { + return this.u('flareSize') + } + set flareSize(value: number) { + this.setU('flareSize', value) + } + + get flareSpeed(): number { + return this.u('flareSpeed') + } + set flareSpeed(value: number) { + this.setU('flareSpeed', value) + } + + get flareShape(): number { + return this.u('flareShape') + } + set flareShape(value: number) { + this.setU('flareShape', value) + } + + get animated(): boolean { + return this.u('animated') + } + set animated(value: boolean) { + this.setU('animated', value) + } + + get anamorphic(): boolean { + return this.u('anamorphic') + } + set anamorphic(value: boolean) { + this.setU('anamorphic', value) + } + + get colorGain(): Color { + return this.u('colorGain') + } + set colorGain(value: Color) { + this.setU('colorGain', value) + } + + get lensDirtTexture(): Texture | null { + return this.u('lensDirtTexture') + } + set lensDirtTexture(value: Texture | null) { + this.setU('lensDirtTexture', value) + } + + get haloScale(): number { + return this.u('haloScale') + } + set haloScale(value: number) { + this.setU('haloScale', value) + } + + get secondaryGhosts(): boolean { + return this.u('secondaryGhosts') + } + set secondaryGhosts(value: boolean) { + this.setU('secondaryGhosts', value) + } + + get aditionalStreaks(): boolean { + return this.u('aditionalStreaks') + } + set aditionalStreaks(value: boolean) { + this.setU('aditionalStreaks', value) + } + + get ghostScale(): number { + return this.u('ghostScale') + } + set ghostScale(value: number) { + this.setU('ghostScale', value) + } + + get starBurst(): boolean { + return this.u('starBurst') + } + set starBurst(value: boolean) { + this.setU('starBurst', value) + } + + get opacity(): number { + return this.u('opacity') + } + set opacity(value: number) { + this.setU('opacity', value) + } } type LensFlareProps = { @@ -502,7 +636,10 @@ type LensFlareProps = { smoothTime?: number } & Partial -const LensFlareWrapped = /* @__PURE__ */ wrapEffect(LensFlareEffect) +const LensFlareWrapped = /* @__PURE__ */ createEffectComponent< + typeof LensFlareEffect, + Partial & { ref?: Ref } +>(LensFlareEffect) export const LensFlare = ({ smoothTime = 0.07, diff --git a/src/effects/N8AO.tsx b/src/effects/N8AO.tsx index 2c68a72..df5b0c0 100644 --- a/src/effects/N8AO.tsx +++ b/src/effects/N8AO.tsx @@ -38,7 +38,7 @@ export function N8AO({ renderMode = 0, ref, }: N8AOProps) { - const { camera, scene } = useThree() + const { camera, scene, invalidate } = useThree() const effect = useMemo(() => new N8AOPostPass(scene, camera), [camera, scene]) // TODO: implement dispose upstream; this effect has memory leaks without @@ -58,6 +58,9 @@ export function N8AO({ halfRes, depthAwareUpsampling, }) + // effect.configuration is a plain object, never r3f-managed - applyProps' + // own invalidate (gated behind object.__r3f) never fires for it. + invalidate() }, [ screenSpaceRadius, color, @@ -71,11 +74,15 @@ export function N8AO({ halfRes, depthAwareUpsampling, effect, + invalidate, ]) useLayoutEffect(() => { - if (quality) effect.setQualityMode(quality.charAt(0).toUpperCase() + quality.slice(1)) - }, [effect, quality]) + if (quality) { + effect.setQualityMode(quality.charAt(0).toUpperCase() + quality.slice(1)) + invalidate() + } + }, [effect, quality, invalidate]) return } diff --git a/src/effects/Noise.tsx b/src/effects/Noise.tsx index a95e37d..d81586a 100644 --- a/src/effects/Noise.tsx +++ b/src/effects/Noise.tsx @@ -1,4 +1,16 @@ import { BlendFunction, NoiseEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Noise = /* @__PURE__ */ wrapEffect(NoiseEffect, { blendFunction: BlendFunction.COLOR_DODGE }) +const NoiseImpl = /* @__PURE__ */ createEffectComponent>( + NoiseEffect +) + +export type NoiseProps = EffectOptions & { + opacity?: number + ref?: Ref +} + +export function Noise({ blendFunction = BlendFunction.COLOR_DODGE, ...props }: NoiseProps) { + return +} diff --git a/src/effects/Outline.tsx b/src/effects/Outline.tsx index 8f8e99a..4117881 100644 --- a/src/effects/Outline.tsx +++ b/src/effects/Outline.tsx @@ -1,8 +1,8 @@ import { OutlineEffect } from 'postprocessing' import { Ref, RefObject, use, useMemo } from 'react' -import { Object3D } from 'three' +import { Color, Object3D } from 'three' import { EffectComposerContext } from '../EffectComposer' -import { EMPTY_ARRAY, useDispose, useSelectionSync } from '../util' +import { applyPierced, EMPTY_ARRAY, readPierced, useDispose, useLiveDefaults, useSelectionSync } from '../util' type ObjectRef = RefObject @@ -13,71 +13,60 @@ export type OutlineProps = ConstructorParameters[2] & ref?: Ref }> +// Every OutlineEffect option that has a real setter (verified against +// postprocessing's source) - resolutionScale/resolutionX/resolutionY are +// the only ones without one, since they only feed the internal blur pass +// at construction time. scene/camera are required constructor args, so +// OutlineEffect can't use createEffectComponent (needs `new Effect()` to +// work with zero args) - built by hand instead. +const LIVE_KEYS = [ + 'patternTexture', + 'patternScale', + 'edgeStrength', + 'pulseSpeed', + 'visibleEdgeColor', + 'hiddenEdgeColor', + 'multisampling', + 'width', + 'height', + 'kernelSize', + 'blur', + 'xRay', + 'dithering', + 'blendMode-blendFunction', +] + +// The setter stores whatever it's given as-is, unlike the constructor - +// wrap in a Color here too, or a raw hex/string breaks the shader uniform. +function set(effect: OutlineEffect, key: string, value: unknown): void { + if (key === 'visibleEdgeColor' || key === 'hiddenEdgeColor') applyPierced(effect, key, new Color(value as never)) + else applyPierced(effect, key, value) +} + export function Outline({ selection = EMPTY_ARRAY, selectionLayer = 10, blendFunction, - patternTexture, - patternScale, - edgeStrength, - pulseSpeed, - visibleEdgeColor, - hiddenEdgeColor, - multisampling, resolutionScale, resolutionX, resolutionY, - width, - height, - kernelSize, - blur, - xRay, ref, + ...liveProps }: OutlineProps) { const { scene, camera } = use(EffectComposerContext) const effect = useMemo( - () => - new OutlineEffect(scene, camera, { - blendFunction, - patternTexture, - patternScale, - edgeStrength, - pulseSpeed, - visibleEdgeColor, - hiddenEdgeColor, - multisampling, - resolutionScale, - resolutionX, - resolutionY, - width, - height, - kernelSize, - blur, - xRay, - }), - [ - blendFunction, - patternTexture, - patternScale, - edgeStrength, - pulseSpeed, - visibleEdgeColor, - hiddenEdgeColor, - multisampling, - resolutionScale, - resolutionX, - resolutionY, - width, - height, - kernelSize, - blur, - xRay, - camera, - scene, - ] + () => new OutlineEffect(scene, camera, { resolutionScale, resolutionX, resolutionY }), + [scene, camera, resolutionScale, resolutionX, resolutionY] ) + useLiveDefaults( + effect, + { ...liveProps, 'blendMode-blendFunction': blendFunction } as Record, + LIVE_KEYS, + readPierced, + set + ) useSelectionSync(effect, selection, selectionLayer) useDispose(effect) diff --git a/src/effects/Pixelation.tsx b/src/effects/Pixelation.tsx index 66ad13b..ce909b2 100644 --- a/src/effects/Pixelation.tsx +++ b/src/effects/Pixelation.tsx @@ -1,17 +1,23 @@ +import type { BlendFunction } from 'postprocessing' import { PixelationEffect } from 'postprocessing' -import { Ref, useMemo } from 'react' -import { useDispose } from '../util' +import type { Ref } from 'react' +import { createEffectComponent } from '../createEffectComponent' + +// PixelationEffect's sole constructor arg is a bare number, not an options +// object - granularity is a real live setter though, so it's just a normal +// prop; only the curated default (5, vs the class's own default of 30) +// needs a thin wrapper. +const PixelationImpl = /* @__PURE__ */ createEffectComponent( + PixelationEffect +) export type PixelationProps = { granularity?: number + blendFunction?: BlendFunction + opacity?: number ref?: Ref } -export function Pixelation({ granularity = 5, ref }: PixelationProps) { - /** Because GlitchEffect granularity is not an object but a number, we have to define a custom prop "granularity" */ - const effect = useMemo(() => new PixelationEffect(granularity), [granularity]) - - useDispose(effect) - - return +export function Pixelation({ granularity = 5, blendFunction, opacity, ref }: PixelationProps) { + return } diff --git a/src/effects/Ramp.tsx b/src/effects/Ramp.tsx index e2dab70..140b0ff 100644 --- a/src/effects/Ramp.tsx +++ b/src/effects/Ramp.tsx @@ -1,6 +1,7 @@ -import { Effect } from 'postprocessing' +import { BlendFunction, Effect } from 'postprocessing' +import type { Ref } from 'react' import { Uniform } from 'three' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent } from '../createEffectComponent' const RampShader = { fragmentShader: /* glsl */ ` @@ -72,6 +73,9 @@ export enum RampType { MirroredLinear, } +type RampTuple2 = [number, number] +type RampTuple4 = [number, number, number, number] + export class RampEffect extends Effect { constructor({ /** @@ -83,25 +87,25 @@ export class RampEffect extends Effect { * * Ranges from `[0 - 1]` as `[x, y]`. Default is `[0.5, 0.5]`. */ - rampStart = [0.5, 0.5], + rampStart = [0.5, 0.5] as RampTuple2, /** * Ending point of the ramp gradient in normalized coordinates. * * Ranges from `[0 - 1]` as `[x, y]`. Default is `[1, 1]` */ - rampEnd = [1, 1], + rampEnd = [1, 1] as RampTuple2, /** * Color at the starting point of the gradient. * * Default is black: `[0, 0, 0, 1]` */ - startColor = [0, 0, 0, 1], + startColor = [0, 0, 0, 1] as RampTuple4, /** * Color at the ending point of the gradient. * * Default is white: `[1, 1, 1, 1]` */ - endColor = [1, 1, 1, 1], + endColor = [1, 1, 1, 1] as RampTuple4, /** * Bias for the interpolation curve when both bias and gain are 0.5. * @@ -145,6 +149,91 @@ export class RampEffect extends Effect { ]), }) } + + private u(name: string): T { + return this.uniforms.get(name)!.value + } + + private setU(name: string, value: unknown): void { + this.uniforms.get(name)!.value = value + } + + get rampType(): RampType { + return this.u('rampType') + } + set rampType(value: RampType) { + this.setU('rampType', value) + } + + get rampStart(): RampTuple2 { + return this.u('rampStart') + } + set rampStart(value: RampTuple2) { + this.setU('rampStart', value) + } + + get rampEnd(): RampTuple2 { + return this.u('rampEnd') + } + set rampEnd(value: RampTuple2) { + this.setU('rampEnd', value) + } + + get startColor(): RampTuple4 { + return this.u('startColor') + } + set startColor(value: RampTuple4) { + this.setU('startColor', value) + } + + get endColor(): RampTuple4 { + return this.u('endColor') + } + set endColor(value: RampTuple4) { + this.setU('endColor', value) + } + + get rampBias(): number { + return this.u('rampBias') + } + set rampBias(value: number) { + this.setU('rampBias', value) + } + + get rampGain(): number { + return this.u('rampGain') + } + set rampGain(value: number) { + this.setU('rampGain', value) + } + + get rampMask(): boolean { + return this.u('rampMask') + } + set rampMask(value: boolean) { + this.setU('rampMask', value) + } + + get rampInvert(): boolean { + return this.u('rampInvert') + } + set rampInvert(value: boolean) { + this.setU('rampInvert', value) + } +} + +export type RampProps = { + blendFunction?: BlendFunction + rampType?: RampType + rampStart?: RampTuple2 + rampEnd?: RampTuple2 + startColor?: RampTuple4 + endColor?: RampTuple4 + rampBias?: number + rampGain?: number + rampMask?: boolean + rampInvert?: boolean + ref?: Ref } -export const Ramp = /* @__PURE__ */ wrapEffect(RampEffect) +export const Ramp = /* @__PURE__ */ createEffectComponent(RampEffect) diff --git a/src/effects/SMAA.tsx b/src/effects/SMAA.tsx index 9e41b1b..6eab5e5 100644 --- a/src/effects/SMAA.tsx +++ b/src/effects/SMAA.tsx @@ -1,4 +1,20 @@ import { SMAAEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { useMemo } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const SMAA = /* @__PURE__ */ wrapEffect(SMAAEffect) +type SMAAOptions = EffectOptions + +const SMAAImpl = /* @__PURE__ */ createEffectComponent(SMAAEffect) + +export type SMAAProps = SMAAOptions & { opacity?: number; ref?: Ref } + +// preset/edgeDetectionMode/predicationMode have no live setter in +// postprocessing - routed through args so they still work as plain props. +export function SMAA({ preset, edgeDetectionMode, predicationMode, ...liveProps }: SMAAProps) { + const args = useMemo<[SMAAOptions]>( + () => [{ preset, edgeDetectionMode, predicationMode }], + [preset, edgeDetectionMode, predicationMode] + ) + return +} diff --git a/src/effects/SSAO.tsx b/src/effects/SSAO.tsx index 2d5fd72..68319a5 100644 --- a/src/effects/SSAO.tsx +++ b/src/effects/SSAO.tsx @@ -1,13 +1,81 @@ import { BlendFunction, SSAOEffect } from 'postprocessing' -import { Ref, useContext, useMemo } from 'react' +import { Ref, use, useMemo } from 'react' import { EffectComposerContext } from '../EffectComposer' -import { useDispose } from '../util' +import { applyPierced, readPierced, useDispose, useLiveDefaults } from '../util' // first two args are camera and texture type SSAOProps = ConstructorParameters[2] & { ref?: Ref } -export function SSAO({ ref, ...props }: SSAOProps) { - const { camera, normalPass, downSamplingPass, resolutionScale } = useContext(EffectComposerContext) +// Only resolutionScale/resolutionX/resolutionY/width/height and +// normalDepthBuffer have no live setter in postprocessing - everything else +// either has a real accessor directly on SSAOEffect, or on the nested +// ssaoMaterial (rangeThreshold/rangeFalloff are the constructor's names for +// what ssaoMaterial exposes as proximityThreshold/proximityFalloff). +// camera+normalBuffer being required constructor args also rule out +// createEffectComponent (needs `new Effect()` to work with zero args). +const LIVE_KEYS = [ + 'blendMode-blendFunction', + 'normalBuffer', + 'samples', + 'rings', + 'radius', + 'depthAwareUpsampling', + 'color', + 'luminanceInfluence', + 'intensity', + 'ssaoMaterial-bias', + 'ssaoMaterial-fade', + 'ssaoMaterial-minRadiusScale', + 'ssaoMaterial-distanceThreshold', + 'ssaoMaterial-distanceFalloff', + 'ssaoMaterial-worldDistanceThreshold', + 'ssaoMaterial-worldDistanceFalloff', + 'rangeThreshold', + 'rangeFalloff', + 'worldProximityThreshold', + 'worldProximityFalloff', +] + +function get(effect: SSAOEffect, key: string): unknown { + if (key === 'rangeThreshold') return effect.ssaoMaterial.proximityThreshold + if (key === 'rangeFalloff') return effect.ssaoMaterial.proximityFalloff + return readPierced(effect, key) +} + +function set(effect: SSAOEffect, key: string, value: unknown): void { + if (key === 'rangeThreshold') effect.ssaoMaterial.proximityThreshold = value as number + else if (key === 'rangeFalloff') effect.ssaoMaterial.proximityFalloff = value as number + else applyPierced(effect, key, value) +} + +export function SSAO({ + blendFunction = BlendFunction.MULTIPLY, + samples = 30, + rings = 4, + distanceThreshold = 1.0, + distanceFalloff = 0.0, + rangeThreshold = 0.5, + rangeFalloff = 0.1, + luminanceInfluence = 0.9, + radius = 20, + bias = 0.5, + intensity = 1.0, + color, + worldDistanceThreshold, + worldDistanceFalloff, + worldProximityThreshold, + worldProximityFalloff, + minRadiusScale, + fade, + depthAwareUpsampling = true, + resolutionScale, + resolutionX, + resolutionY, + width, + height, + ref, +}: SSAOProps) { + const { camera, normalPass, downSamplingPass, resolutionScale: composerResolutionScale } = use(EffectComposerContext) const effect = useMemo(() => { if (normalPass === null && downSamplingPass === null) { @@ -16,29 +84,69 @@ export function SSAO({ ref, ...props }: SSAOProps) { } return new SSAOEffect(camera, normalPass && !downSamplingPass ? (normalPass as any).texture : null, { - blendFunction: BlendFunction.MULTIPLY, - samples: 30, - rings: 4, - distanceThreshold: 1.0, - distanceFalloff: 0.0, - rangeThreshold: 0.5, - rangeFalloff: 0.1, - luminanceInfluence: 0.9, - radius: 20, - bias: 0.5, - intensity: 1.0, - color: undefined, + blendFunction, + samples, + rings, + distanceThreshold, + distanceFalloff, + rangeThreshold, + rangeFalloff, + luminanceInfluence, + radius, + bias, + intensity, // @ts-ignore normalDepthBuffer: downSamplingPass ? downSamplingPass.texture : null, - resolutionScale: resolutionScale ?? 1, - depthAwareUpsampling: true, - ...props, + resolutionScale: resolutionScale ?? composerResolutionScale ?? 1, + resolutionX, + resolutionY, + width, + height, + depthAwareUpsampling, }) - // NOTE: `props` is an unstable reference, so we can't memoize it + // color/worldDistanceThreshold/worldDistanceFalloff/worldProximityThreshold/ + // worldProximityFalloff/minRadiusScale/fade are deliberately left out here + // even though they're valid constructor options: they have no JS-level + // default in this component's own signature, so useLiveDefaults' first + // snapshot must see SSAOEffect's own real default for them, not whatever + // value happened to be passed on the mounting render - otherwise removing + // the prop later "resets" to that first-render value instead of the + // effect's true default. They're still applied immediately below, live. + // + // Only the genuinely construction-only options belong here - everything + // else is applied live below via useLiveDefaults instead. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, downSamplingPass, normalPass, resolutionScale]) + }, [camera, downSamplingPass, normalPass, resolutionScale, composerResolutionScale, resolutionX, resolutionY, width, height]) + + useLiveDefaults( + effect instanceof SSAOEffect ? effect : null, + { + 'blendMode-blendFunction': blendFunction, + samples, + rings, + radius, + depthAwareUpsampling, + color, + luminanceInfluence, + intensity, + 'ssaoMaterial-bias': bias, + 'ssaoMaterial-fade': fade, + 'ssaoMaterial-minRadiusScale': minRadiusScale, + 'ssaoMaterial-distanceThreshold': distanceThreshold, + 'ssaoMaterial-distanceFalloff': distanceFalloff, + 'ssaoMaterial-worldDistanceThreshold': worldDistanceThreshold, + 'ssaoMaterial-worldDistanceFalloff': worldDistanceFalloff, + rangeThreshold, + rangeFalloff, + worldProximityThreshold, + worldProximityFalloff, + }, + LIVE_KEYS, + get, + set + ) - useDispose(effect) + useDispose(effect as SSAOEffect) return } diff --git a/src/effects/ScanlineEffect.tsx b/src/effects/ScanlineEffect.tsx index ed34430..6fe48b6 100644 --- a/src/effects/ScanlineEffect.tsx +++ b/src/effects/ScanlineEffect.tsx @@ -1,7 +1,7 @@ -import { BlendFunction, ScanlineEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { ScanlineEffect } from 'postprocessing' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Scanline = /* @__PURE__ */ wrapEffect(ScanlineEffect, { - blendFunction: BlendFunction.OVERLAY, - density: 1.25, -}) +export const Scanline = /* @__PURE__ */ createEffectComponent< + typeof ScanlineEffect, + EffectOptions +>(ScanlineEffect) diff --git a/src/effects/SelectiveBloom.tsx b/src/effects/SelectiveBloom.tsx index 7007ddd..fc080af 100644 --- a/src/effects/SelectiveBloom.tsx +++ b/src/effects/SelectiveBloom.tsx @@ -4,7 +4,7 @@ import { BlendFunction, SelectiveBloomEffect } from 'postprocessing' import { Ref, RefObject, use, useEffect, useMemo } from 'react' import { Object3D } from 'three' import { EffectComposerContext } from '../EffectComposer' -import { EMPTY_ARRAY, resolveRef, useDispose, useSelectionSync } from '../util' +import { EMPTY_ARRAY, resolveRef, useDispose, useLiveDefaults, useSelectionSync } from '../util' type ObjectRef = RefObject @@ -21,67 +21,49 @@ export type SelectiveBloomProps = BloomEffectOptions & const addLight = (light: Object3D, effect: SelectiveBloomEffect) => light.layers.enable(effect.selection.layer) const removeLight = (light: Object3D, effect: SelectiveBloomEffect) => light.layers.disable(effect.selection.layer) +// BloomEffect (which SelectiveBloomEffect extends) only exposes real +// setters for these - luminanceThreshold/luminanceSmoothing/mipmapBlur/ +// radius/levels/resolution* are construction-only in postprocessing itself. +// scene/camera being required constructor args also rules out +// createEffectComponent (needs `new Effect()` to work with zero args). +const LIVE_KEYS = ['width', 'height', 'kernelSize', 'intensity', 'inverted', 'ignoreBackground'] + export function SelectiveBloom({ selection = EMPTY_ARRAY, selectionLayer = 10, lights = EMPTY_ARRAY, - inverted = false, - ignoreBackground = false, luminanceThreshold, luminanceSmoothing, mipmapBlur, - intensity, radius, levels, - kernelSize, resolutionScale, - width, - height, resolutionX, resolutionY, ref, + ...liveProps }: SelectiveBloomProps) { const { scene, camera } = use(EffectComposerContext) const invalidate = useThree((state) => state.invalidate) - const effect = useMemo(() => { - const instance = new SelectiveBloomEffect(scene, camera, { - blendFunction: BlendFunction.ADD, - luminanceThreshold, - luminanceSmoothing, - mipmapBlur, - intensity, - radius, - levels, - kernelSize, - resolutionScale, - width, - height, - resolutionX, - resolutionY, - }) - instance.inverted = inverted - instance.ignoreBackground = ignoreBackground - return instance - }, [ - scene, - camera, - luminanceThreshold, - luminanceSmoothing, - mipmapBlur, - intensity, - radius, - levels, - kernelSize, - resolutionScale, - width, - height, - resolutionX, - resolutionY, - inverted, - ignoreBackground, - ]) + const effect = useMemo( + () => + new SelectiveBloomEffect(scene, camera, { + blendFunction: BlendFunction.ADD, + luminanceThreshold, + luminanceSmoothing, + mipmapBlur, + radius, + levels, + resolutionScale, + resolutionX, + resolutionY, + }), + [scene, camera, luminanceThreshold, luminanceSmoothing, mipmapBlur, radius, levels, resolutionScale, resolutionX, resolutionY] + ) + + useLiveDefaults(effect, liveProps as Record, LIVE_KEYS) // Must run before the lights effect below: addLight/removeLight read // effect.selection.layer live, so it needs to already reflect the diff --git a/src/effects/Sepia.tsx b/src/effects/Sepia.tsx index 8142b2b..891a96c 100644 --- a/src/effects/Sepia.tsx +++ b/src/effects/Sepia.tsx @@ -1,4 +1,6 @@ import { SepiaEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Sepia = /* @__PURE__ */ wrapEffect(SepiaEffect) +export const Sepia = /* @__PURE__ */ createEffectComponent>( + SepiaEffect +) diff --git a/src/effects/ShockWave.tsx b/src/effects/ShockWave.tsx index 10da37d..b2b7fe9 100644 --- a/src/effects/ShockWave.tsx +++ b/src/effects/ShockWave.tsx @@ -1,4 +1,32 @@ -import { ShockWaveEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { BlendFunction, ShockWaveEffect } from 'postprocessing' +import { Ref, use, useMemo } from 'react' +import { Vector3 } from 'three' +import { EffectComposerContext } from '../EffectComposer' +import { useDispose, useLiveDefaults } from '../util' -export const ShockWave = /* @__PURE__ */ wrapEffect(ShockWaveEffect) +export type ShockWaveProps = { + position?: Vector3 + speed?: number + maxRadius?: number + waveSize?: number + amplitude?: number + blendFunction?: BlendFunction + opacity?: number + ref?: Ref +} + +const LIVE_KEYS = ['position', 'speed', 'maxRadius', 'waveSize', 'amplitude', 'blendMode-blendFunction', 'blendMode-opacity-value'] + +// ShockWaveEffect's constructor is (camera, position, options) - camera is +// a required arg, so it can't use createEffectComponent (needs +// `new Effect()` to work with zero args). Built by hand instead, like +// Outline/GodRays. +export function ShockWave({ position, speed, maxRadius, waveSize, amplitude, blendFunction, opacity, ref }: ShockWaveProps) { + const { camera } = use(EffectComposerContext) + const effect = useMemo(() => new ShockWaveEffect(camera), [camera]) + + useLiveDefaults(effect, { position, speed, maxRadius, waveSize, amplitude, 'blendMode-blendFunction': blendFunction, 'blendMode-opacity-value': opacity }, LIVE_KEYS) + useDispose(effect) + + return +} diff --git a/src/effects/Texture.tsx b/src/effects/Texture.tsx index 6610b78..e731d2a 100644 --- a/src/effects/Texture.tsx +++ b/src/effects/Texture.tsx @@ -1,17 +1,22 @@ import { useLoader } from '@react-three/fiber' import { TextureEffect } from 'postprocessing' -import { Ref, useLayoutEffect, useMemo } from 'react' +import type { Ref } from 'react' +import { useLayoutEffect } from 'react' import { RepeatWrapping, SRGBColorSpace, TextureLoader } from 'three' -import { useDispose } from '../util' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -type TextureProps = ConstructorParameters[0] & { +const TextureImpl = /* @__PURE__ */ createEffectComponent>( + TextureEffect +) + +export type TextureProps = EffectOptions & { textureSrc: string /** opacity of provided texture */ opacity?: number ref?: Ref } -export function Texture({ textureSrc, texture, opacity = 1, ref, ...props }: TextureProps) { +export function Texture({ textureSrc, texture, opacity = 1, ...props }: TextureProps) { const t = useLoader(TextureLoader, textureSrc) useLayoutEffect(() => { @@ -19,9 +24,5 @@ export function Texture({ textureSrc, texture, opacity = 1, ref, ...props }: Tex t.wrapS = t.wrapT = RepeatWrapping }, [t]) - const effect = useMemo(() => new TextureEffect({ ...props, texture: t || texture }), []) - - useDispose(effect) - - return + return } diff --git a/src/effects/TiltShift.tsx b/src/effects/TiltShift.tsx index 82372e5..ecd31d1 100644 --- a/src/effects/TiltShift.tsx +++ b/src/effects/TiltShift.tsx @@ -1,4 +1,32 @@ import { BlendFunction, TiltShiftEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { useMemo } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const TiltShift = /* @__PURE__ */ wrapEffect(TiltShiftEffect, { blendFunction: BlendFunction.ADD }) +type TiltShiftOptions = EffectOptions + +const TiltShiftImpl = /* @__PURE__ */ createEffectComponent(TiltShiftEffect) + +export type TiltShiftProps = TiltShiftOptions & { + opacity?: number + ref?: Ref +} + +// kernelSize/resolutionScale/resolutionX/resolutionY have no live setter in +// postprocessing - routed through args so they still work as plain props +// (previously they were passed as plain props and silently never reached +// the effect at all, since there was no setter for diffProps to hit). +export function TiltShift({ + blendFunction = BlendFunction.ADD, + kernelSize, + resolutionScale, + resolutionX, + resolutionY, + ...liveProps +}: TiltShiftProps) { + const args = useMemo<[TiltShiftOptions]>( + () => [{ kernelSize, resolutionScale, resolutionX, resolutionY }], + [kernelSize, resolutionScale, resolutionX, resolutionY] + ) + return +} diff --git a/src/effects/TiltShift2.tsx b/src/effects/TiltShift2.tsx index 8326506..2da117d 100644 --- a/src/effects/TiltShift2.tsx +++ b/src/effects/TiltShift2.tsx @@ -1,6 +1,7 @@ import { BlendFunction, Effect, EffectAttribute } from 'postprocessing' +import type { Ref } from 'react' import { Uniform } from 'three' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent } from '../createEffectComponent' const TiltShiftShader = { fragmentShader: /* glsl */ ` @@ -62,20 +63,22 @@ const TiltShiftShader = { `, } +type Vec2Tuple = [number, number] + export class TiltShiftEffect extends Effect { constructor({ blendFunction = BlendFunction.NORMAL, blur = 0.15, // [0, 1], can go beyond 1 for extra taper = 0.5, // [0, 1], can go beyond 1 for extra - start = [0.5, 0.0], // [0,1] percentage x,y of screenspace - end = [0.5, 1.0], // [0,1] percentage x,y of screenspace + start = [0.5, 0.0] as Vec2Tuple, // [0,1] percentage x,y of screenspace + end = [0.5, 1.0] as Vec2Tuple, // [0,1] percentage x,y of screenspace samples = 10.0, // number of blur samples - direction = [1, 1], // direction of blur + direction = [1, 1] as Vec2Tuple, // direction of blur } = {}) { super('TiltShiftEffect', TiltShiftShader.fragmentShader, { blendFunction, attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map>([ + uniforms: new Map>([ ['blur', new Uniform(blur)], ['taper', new Uniform(taper)], ['start', new Uniform(start)], @@ -85,6 +88,69 @@ export class TiltShiftEffect extends Effect { ]), }) } + + private u(name: string): T { + return this.uniforms.get(name)!.value + } + + private setU(name: string, value: unknown): void { + this.uniforms.get(name)!.value = value + } + + get blur(): number { + return this.u('blur') + } + set blur(value: number) { + this.setU('blur', value) + } + + get taper(): number { + return this.u('taper') + } + set taper(value: number) { + this.setU('taper', value) + } + + get start(): Vec2Tuple { + return this.u('start') + } + set start(value: Vec2Tuple) { + this.setU('start', value) + } + + get end(): Vec2Tuple { + return this.u('end') + } + set end(value: Vec2Tuple) { + this.setU('end', value) + } + + get samples(): number { + return this.u('samples') + } + set samples(value: number) { + this.setU('samples', value) + } + + get direction(): Vec2Tuple { + return this.u('direction') + } + set direction(value: Vec2Tuple) { + this.setU('direction', value) + } +} + +export type TiltShift2Props = { + blendFunction?: BlendFunction + blur?: number + taper?: number + start?: Vec2Tuple + end?: Vec2Tuple + samples?: number + direction?: Vec2Tuple + ref?: Ref } -export const TiltShift2 = /* @__PURE__ */ wrapEffect(TiltShiftEffect, { blendFunction: BlendFunction.NORMAL }) +export const TiltShift2 = /* @__PURE__ */ createEffectComponent( + TiltShiftEffect +) diff --git a/src/effects/ToneMapping.tsx b/src/effects/ToneMapping.tsx index 5358d7b..2f0fa67 100644 --- a/src/effects/ToneMapping.tsx +++ b/src/effects/ToneMapping.tsx @@ -1,6 +1,19 @@ import { ToneMappingEffect } from 'postprocessing' -import { type EffectProps, wrapEffect } from '../wrapEffect' +import type { Ref } from 'react' +import { useMemo } from 'react' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export type ToneMappingProps = EffectProps +type ToneMappingOptions = EffectOptions -export const ToneMapping = /* @__PURE__ */ wrapEffect(ToneMappingEffect) +const ToneMappingImpl = /* @__PURE__ */ createEffectComponent( + ToneMappingEffect +) + +export type ToneMappingProps = ToneMappingOptions & { opacity?: number; ref?: Ref } + +// minLuminance/maxLuminance have no live setter in postprocessing - routed +// through args so they still work as plain props. +export function ToneMapping({ minLuminance, maxLuminance, ...liveProps }: ToneMappingProps) { + const args = useMemo<[ToneMappingOptions]>(() => [{ minLuminance, maxLuminance }], [minLuminance, maxLuminance]) + return +} diff --git a/src/effects/Vignette.tsx b/src/effects/Vignette.tsx index 886020f..b9c5906 100644 --- a/src/effects/Vignette.tsx +++ b/src/effects/Vignette.tsx @@ -1,4 +1,7 @@ import { VignetteEffect } from 'postprocessing' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent, type EffectOptions } from '../createEffectComponent' -export const Vignette = /* @__PURE__ */ wrapEffect(VignetteEffect) +export const Vignette = /* @__PURE__ */ createEffectComponent< + typeof VignetteEffect, + EffectOptions +>(VignetteEffect) diff --git a/src/effects/Water.tsx b/src/effects/Water.tsx index e7b186c..c4d59c5 100644 --- a/src/effects/Water.tsx +++ b/src/effects/Water.tsx @@ -1,6 +1,7 @@ import { BlendFunction, Effect, EffectAttribute } from 'postprocessing' +import type { Ref } from 'react' import { Uniform } from 'three' -import { wrapEffect } from '../wrapEffect' +import { createEffectComponent } from '../createEffectComponent' const WaterShader = { fragmentShader: /* glsl */ ` @@ -10,7 +11,7 @@ const WaterShader = { vec2 vUv = uv; float frequency = 6.0 * factor; float amplitude = 0.015 * factor; - float x = vUv.y * frequency + time * 0.7; + float x = vUv.y * frequency + time * 0.7; float y = vUv.x * frequency + time * 0.3; vUv.x += cos(x + y) * amplitude * cos(y); vUv.y += sin(x - y) * amplitude * cos(y); @@ -25,11 +26,25 @@ export class WaterEffectImpl extends Effect { super('WaterEffect', WaterShader.fragmentShader, { blendFunction, attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map>([['factor', new Uniform(factor)]]), + uniforms: new Map>([['factor', new Uniform(factor)]]), }) } + + get factor(): number { + return this.uniforms.get('factor')!.value + } + + set factor(value: number) { + this.uniforms.get('factor')!.value = value + } +} + +export type WaterEffectProps = { + blendFunction?: BlendFunction + factor?: number + ref?: Ref } -export const WaterEffect = /* @__PURE__ */ wrapEffect(WaterEffectImpl, { - blendFunction: BlendFunction.NORMAL, -}) +export const WaterEffect = /* @__PURE__ */ createEffectComponent( + WaterEffectImpl +) diff --git a/src/tests/Bloom.test.tsx b/src/tests/Bloom.test.tsx new file mode 100644 index 0000000..facb201 --- /dev/null +++ b/src/tests/Bloom.test.tsx @@ -0,0 +1,76 @@ +import { BloomEffect, EffectComposer as EffectComposerImpl } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { Bloom } from '../effects/Bloom' +import { flush, root } from './test-utils' + +describe('Bloom', () => { + it('applies intensity live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (intensity: number) => + root.render( + + + + ) + + await React.act(async () => render(1)) + await flush() + const first = ref.current + expect(first!.intensity).toBe(1) + + await React.act(async () => render(2)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.intensity).toBe(2) + + await React.act(async () => root.render(null)) + }) + + it('applies mipmapBlur (a construction-only option) as a plain prop, reconstructing under the hood', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (mipmapBlur: boolean) => + root.render( + + + + ) + + await React.act(async () => render(true)) + await flush() + const first = ref.current + expect(first!.mipmapBlurPass.enabled).toBe(true) + + await React.act(async () => render(false)) + await flush() + + expect(ref.current).not.toBe(first) + expect(ref.current!.mipmapBlurPass.enabled).toBe(false) + + await React.act(async () => root.render(null)) + }) + + it('accepts opacity, as documented in the README (#opacity narrower than createEffectComponent allows)', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + + expect(ref.current!.blendMode.opacity.value).toBe(0.02) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/ChromaticAberration.test.tsx b/src/tests/ChromaticAberration.test.tsx index 5e7c5d1..d98d87c 100644 --- a/src/tests/ChromaticAberration.test.tsx +++ b/src/tests/ChromaticAberration.test.tsx @@ -28,4 +28,54 @@ describe('ChromaticAberration', () => { await React.act(async () => root.render(null)) }) + + it('applies offset live without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (x: number) => + root.render( + + + + ) + + await React.act(async () => render(0.01)) + await flush() + const first = ref.current + expect(first!.offset.x).toBeCloseTo(0.01) + + await React.act(async () => render(0.02)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.offset.x).toBeCloseTo(0.02) + + await React.act(async () => root.render(null)) + }) + + it('applies radialModulation live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (radialModulation: boolean) => + root.render( + + + + ) + + await React.act(async () => render(false)) + await flush() + const first = ref.current + expect(first!.radialModulation).toBe(false) + + await React.act(async () => render(true)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.radialModulation).toBe(true) + + await React.act(async () => root.render(null)) + }) }) diff --git a/src/tests/ColorDepth.test.tsx b/src/tests/ColorDepth.test.tsx new file mode 100644 index 0000000..e3b3ea0 --- /dev/null +++ b/src/tests/ColorDepth.test.tsx @@ -0,0 +1,71 @@ +import { ColorDepthEffect, EffectComposer as EffectComposerImpl } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { ColorDepth } from '../effects/ColorDepth' +import { EffectComposer } from '../EffectComposer' +import { flush, root } from './test-utils' + +describe('ColorDepth', () => { + it('applies bits live via the differently-named bitDepth setter, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (bits: number) => + root.render( + + + + ) + + await React.act(async () => render(4)) + await flush() + const first = ref.current + expect(first!.bitDepth).toBe(4) + + await React.act(async () => render(8)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.bitDepth).toBe(8) + + await React.act(async () => root.render(null)) + }) + + it('resets bitDepth to its constructor default when bits is removed', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + const defaultBitDepth = ref.current!.bitDepth + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + expect(ref.current!.bitDepth).toBe(4) + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + + expect(ref.current!.bitDepth).toBe(defaultBitDepth) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/DepthOfField.test.tsx b/src/tests/DepthOfField.test.tsx new file mode 100644 index 0000000..8b0545d --- /dev/null +++ b/src/tests/DepthOfField.test.tsx @@ -0,0 +1,130 @@ +import { DepthOfFieldEffect, EffectComposer as EffectComposerImpl } from 'postprocessing' +import * as React from 'react' +import { Texture } from 'three' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { DepthOfField } from '../effects/DepthOfField' +import { flush, root, waitForComposer } from './test-utils' + +describe('DepthOfField', () => { + it('applies bokehScale live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (bokehScale: number) => + root.render( + + + + ) + + await React.act(async () => render(1)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.bokehScale).toBe(1) + + await React.act(async () => render(2)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.bokehScale).toBe(2) + + await React.act(async () => root.render(null)) + }) + + it('applies focusDistance live via the nested cocMaterial, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (focusDistance: number) => + root.render( + + + + ) + + await React.act(async () => render(0.1)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.cocMaterial.focusDistance).toBeCloseTo(0.1) + + await React.act(async () => render(0.5)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.cocMaterial.focusDistance).toBeCloseTo(0.5) + + await React.act(async () => root.render(null)) + }) + + it('applies depthTexture live via setDepthTexture, without reconstructing, and resets on removal', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const textureA = new Texture() + const textureB = new Texture() + // cocMaterial.depthBuffer is write-only in postprocessing (setter, no + // getter) - the current value only reads back through its own uniform. + const currentDepthBuffer = () => + (ref.current!.cocMaterial as unknown as { uniforms: { depthBuffer: { value: unknown } } }).uniforms.depthBuffer + .value + + const render = (depthTexture?: { texture: Texture; packing: number }) => + root.render( + + + + ) + + await React.act(async () => render()) + await waitForComposer(composerRef) + await flush() + const first = ref.current + + await React.act(async () => render({ texture: textureA, packing: 0 })) + await flush() + expect(ref.current).toBe(first) + expect(currentDepthBuffer()).toBe(textureA) + + await React.act(async () => render({ texture: textureB, packing: 0 })) + await flush() + expect(ref.current).toBe(first) + expect(currentDepthBuffer()).toBe(textureB) + + await React.act(async () => render()) + await flush() + expect(ref.current).toBe(first) + // Reverts to no manually-provided depth texture (undefined), the state + // useLiveDefaults captured as this instance's default on first apply - + // not whatever EffectComposer's own depth-attribute auto-wiring later + // assigns, which runs separately and after this. + expect(currentDepthBuffer()).toBeUndefined() + + await React.act(async () => root.render(null)) + }) + + it('still reconstructs when resolutionScale (construction-only) changes', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (resolutionScale: number) => + root.render( + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + + await React.act(async () => render(1)) + await flush() + + expect(ref.current).not.toBe(first) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/EffectComposer.test.tsx b/src/tests/EffectComposer.test.tsx index 7fb1705..c667aef 100644 --- a/src/tests/EffectComposer.test.tsx +++ b/src/tests/EffectComposer.test.tsx @@ -380,6 +380,128 @@ describe('EffectComposer', () => { disposeSpy.mockRestore() }) + it('disposes a discarded EffectPass wrapper\'s own material on rebuild, without disposing the effects it wrapped', async () => { + const ref = React.createRef() + + await React.act(async () => root.render()) + const composer = await waitForComposer(ref) + await waitForEffects(ref, 1) + + const firstPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass + const materialDisposeSpy = vi.spyOn(firstPass.fullscreenMaterial, 'dispose') + const effectDisposeSpy = vi.spyOn(EffectA.prototype, 'dispose') + + // Changing the node list forces a rebuild: buildPasses always + // constructs a brand new EffectPass, discarding the old wrapper. + await React.act(async () => + root.render( + + + + + ) + ) + await flush() + + const secondPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass + expect(secondPass).not.toBe(firstPass) + expect(materialDisposeSpy).toHaveBeenCalledTimes(1) + expect(effectDisposeSpy).not.toHaveBeenCalled() + + materialDisposeSpy.mockRestore() + effectDisposeSpy.mockRestore() + }) + + it('detaches a discarded EffectPass\'s change listener from the effect it wrapped, so it no longer reacts to it', async () => { + const ref = React.createRef() + const effectRef = React.createRef() + + await React.act(async () => root.render()) + const composer = await waitForComposer(ref) + await waitForEffects(ref, 1) + + const firstPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass + const recompileSpy = vi.spyOn(firstPass, 'recompile') + + await React.act(async () => + root.render( + + + + + ) + ) + await flush() + + const secondPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass + expect(secondPass).not.toBe(firstPass) + + // The same effect instance survived the rebuild - firing its own + // 'change' event should only reach whatever pass currently wraps it, + // not the discarded one still listening from before. + effectRef.current!.dispatchEvent({ type: 'change' }) + + expect(recompileSpy).not.toHaveBeenCalled() + + recompileSpy.mockRestore() + }) + + it('leaves a user-provided EffectPass (rendered directly as a child) untouched across a rebuild', async () => { + const ref = React.createRef() + const camera = new THREE.PerspectiveCamera() + const userEffect = new EffectC() + const userPass = new EffectPass(camera, userEffect) + + await React.act(async () => + root.render( + + + + + ) + ) + const composer = await waitForComposer(ref) + await waitForEffects(ref, 1) + expect(composer.passes).toContain(userPass) + + // Forces a rebuild (node list changes) - buildPasses only ever + // constructs a *new* EffectPass for Effect children; userPass is + // passed through unchanged via the plain-Pass branch. + await React.act(async () => + root.render( + + + + + + ) + ) + await flush() + + expect(composer.passes).toContain(userPass) + // @ts-expect-error - `effects` isn't part of the public Pass typing + expect(userPass.effects).toEqual([userEffect]) + + await React.act(async () => root.render(null)) + }) + + it('disposes the final EffectPass wrapper\'s material on full unmount too (composer.dispose has nothing left to dispose by then)', async () => { + const ref = React.createRef() + + await React.act(async () => root.render()) + const composer = await waitForComposer(ref) + await waitForEffects(ref, 1) + + const pass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass + const materialDisposeSpy = vi.spyOn(pass.fullscreenMaterial, 'dispose') + + await React.act(async () => root.render(null)) + + expect(materialDisposeSpy).toHaveBeenCalled() + + materialDisposeSpy.mockRestore() + }) + it('disposes exactly as many composers as it constructs, across repeated prop changes', async () => { const ref = React.createRef() const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose') @@ -405,6 +527,42 @@ describe('EffectComposer', () => { disposeSpy.mockRestore() }) + it('does not dispose a still-in-use effect when a composer-level prop (multisampling) recreates the composer', async () => { + const ref = React.createRef() + const effectRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + const firstComposer = await waitForComposer(ref) + await waitForEffects(ref, 1) + const effect = effectRef.current + expect(effect).toBeTruthy() + + const effectDisposeSpy = vi.spyOn(EffectA.prototype, 'dispose') + + await React.act(async () => + root.render( + + + + ) + ) + const secondComposer = await waitForNewComposer(ref, firstComposer) + await flush() + + expect(secondComposer).not.toBe(firstComposer) + expect(effectRef.current).toBe(effect) + expect(effectDisposeSpy).not.toHaveBeenCalled() + expect(secondComposer.passes.some((p) => p instanceof EffectPass)).toBe(true) + + effectDisposeSpy.mockRestore() + }) + it('disposes a hand-constructed effect exactly once on unmount', async () => { const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose') const ref = React.createRef() @@ -425,7 +583,7 @@ describe('EffectComposer', () => { disposeSpy.mockRestore() }) - it('disposes exactly as many ColorAverage instances as it constructs, across repeated prop changes', async () => { + it('keeps a single ColorAverage instance across repeated blendFunction changes and disposes it exactly once (blendFunction is live, not construction-only)', async () => { const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose') const ref = React.createRef() const seenInstances = new Set() @@ -446,8 +604,8 @@ describe('EffectComposer', () => { await React.act(async () => root.render(null)) - expect(seenInstances.size).toBe(cycles) - expect(disposeSpy).toHaveBeenCalledTimes(cycles) + expect(seenInstances.size).toBe(1) + expect(disposeSpy).toHaveBeenCalledTimes(1) } finally { disposeSpy.mockRestore() } @@ -890,7 +1048,7 @@ describe('EffectComposer', () => { }) describe('performance characteristics (documented, not enforced)', () => { - it('rebuilds the EffectPass once per registration when mounting many effects at once', async () => { + it('rebuilds the EffectPass at most twice when mounting many effects at once', async () => { const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass') const ref = React.createRef() @@ -910,9 +1068,43 @@ describe('EffectComposer', () => { const effectPassAddCalls = addPassSpy.mock.calls.filter(([pass]) => pass instanceof EffectPass).length - expect(effectPassAddCalls).toBe(1) + // The node-list change detector and the pass-building effect settle + // over two synchronous layout-effect passes on first mount (detect + // change -> bump a version -> rebuild once more) - a one-time cost, + // not a per-render one. See the "does not rebuild on unrelated + // re-renders" test below for the actual guarantee this trades for. + expect(effectPassAddCalls).toBeLessThanOrEqual(2) + + addPassSpy.mockRestore() + }) + + it('does not rebuild the EffectPass (or re-run EffectPass.initialize) on unrelated re-renders', async () => { + const ref = React.createRef() + + const render = (tick: number) => + root.render( + + + + + ) + + await React.act(async () => render(0)) + await waitForEffects(ref, 1) + + const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass') + const initializeSpy = vi.spyOn(EffectPass.prototype, 'initialize') + + for (let t = 1; t <= 5; t++) { + await React.act(async () => render(t)) + await flush() + } + + expect(addPassSpy).not.toHaveBeenCalled() + expect(initializeSpy).not.toHaveBeenCalled() addPassSpy.mockRestore() + initializeSpy.mockRestore() }) }) }) diff --git a/src/tests/Glitch.test.tsx b/src/tests/Glitch.test.tsx new file mode 100644 index 0000000..c383cb4 --- /dev/null +++ b/src/tests/Glitch.test.tsx @@ -0,0 +1,56 @@ +import { EffectComposer as EffectComposerImpl, GlitchEffect, GlitchMode } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { Glitch } from '../effects/Glitch' +import { flush, root } from './test-utils' + +describe('Glitch', () => { + it('toggles active/mode live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (active: boolean) => + root.render( + + + + ) + + await React.act(async () => render(true)) + await flush() + const first = ref.current + expect(first!.mode).toBe(GlitchMode.SPORADIC) + + await React.act(async () => render(false)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.mode).toBe(GlitchMode.DISABLED) + + await React.act(async () => root.render(null)) + }) + + it('reconstructs when dtSize (construction-only) changes', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (dtSize: number) => + root.render( + + + + ) + + await React.act(async () => render(64)) + await flush() + const first = ref.current + + await React.act(async () => render(128)) + await flush() + + expect(ref.current).not.toBe(first) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/GodRays.test.tsx b/src/tests/GodRays.test.tsx new file mode 100644 index 0000000..b0a34f2 --- /dev/null +++ b/src/tests/GodRays.test.tsx @@ -0,0 +1,101 @@ +import { EffectComposer as EffectComposerImpl, GodRaysEffect } from 'postprocessing' +import * as React from 'react' +import { Mesh, SphereGeometry } from 'three' +import { describe, expect, it, vi } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { GodRays } from '../effects/GodRays' +import { flush, root, waitForComposer } from './test-utils' + +describe('GodRays', () => { + it('applies density live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const sun = new Mesh(new SphereGeometry(1, 8, 8)) + + const render = (density: number) => + root.render( + + + + + ) + + await React.act(async () => render(0.9)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.godRaysMaterial.density).toBeCloseTo(0.9) + + await React.act(async () => render(0.5)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.godRaysMaterial.density).toBeCloseTo(0.5) + + await React.act(async () => root.render(null)) + }) + + it('still reconstructs when resolutionScale (construction-only) changes', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const sun = new Mesh(new SphereGeometry(1, 8, 8)) + + const render = (resolutionScale: number) => + root.render( + + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + + await React.act(async () => render(1)) + await flush() + + expect(ref.current).not.toBe(first) + + await React.act(async () => root.render(null)) + }) + + it('invalidates when sun is swapped for a different mesh, so frameloop="demand" repaints', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const sunA = new Mesh(new SphereGeometry(1, 8, 8)) + const sunB = new Mesh(new SphereGeometry(1, 8, 8)) + + // Both meshes are mounted unconditionally throughout - only the `sun` + // prop GodRays points at changes, so the only invalidate() candidate is + // GodRays.tsx's own effect.lightSource assignment, not r3f's native + // handling of a swap (a real prop change it + // already invalidates for on its own, which a naive test could + // mistake for this effect's own behavior). + const render = (sun: Mesh) => + root.render( + + + + + + ) + + await React.act(async () => render(sunA)) + await waitForComposer(composerRef) + await flush() + expect(ref.current!.lightSource).toBe(sunA) + + const invalidateSpy = vi.spyOn(root.render(null).getState(), 'invalidate') + + await React.act(async () => render(sunB)) + await flush() + + expect(ref.current!.lightSource).toBe(sunB) + expect(invalidateSpy).toHaveBeenCalled() + + invalidateSpy.mockRestore() + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/Grid.test.tsx b/src/tests/Grid.test.tsx new file mode 100644 index 0000000..6032698 --- /dev/null +++ b/src/tests/Grid.test.tsx @@ -0,0 +1,34 @@ +import { EffectComposer as EffectComposerImpl, GridEffect } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { Grid } from '../effects/Grid' +import { flush, root } from './test-utils' + +describe('Grid', () => { + it('applies scale/lineWidth live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (scale: number) => + root.render( + + + + ) + + await React.act(async () => render(1)) + await flush() + const first = ref.current + expect(first!.scale).toBe(1) + expect(first!.lineWidth).toBeCloseTo(0.1) + + await React.act(async () => render(2)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.scale).toBe(2) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/LUT.test.tsx b/src/tests/LUT.test.tsx new file mode 100644 index 0000000..28a2ce2 --- /dev/null +++ b/src/tests/LUT.test.tsx @@ -0,0 +1,64 @@ +import { EffectComposer as EffectComposerImpl, LUT3DEffect } from 'postprocessing' +import * as React from 'react' +import { DataTexture } from 'three' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { LUT } from '../effects/LUT' +import { flush, root, waitForComposer } from './test-utils' + +describe('LUT', () => { + it('applies tetrahedralInterpolation live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const lut = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4) + + const render = (tetrahedralInterpolation: boolean) => + root.render( + + + + ) + + await React.act(async () => render(false)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.tetrahedralInterpolation).toBe(false) + + await React.act(async () => render(true)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.tetrahedralInterpolation).toBe(true) + + await React.act(async () => root.render(null)) + }) + + it('applies a new lut live via its own setter, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + const lutA = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4) + const lutB = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4) + + const render = (lut: DataTexture) => + root.render( + + + + ) + + await React.act(async () => render(lutA)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.lut).toBe(lutA) + + await React.act(async () => render(lutB)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.lut).toBe(lutB) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/N8AO.test.tsx b/src/tests/N8AO.test.tsx new file mode 100644 index 0000000..75b41fa --- /dev/null +++ b/src/tests/N8AO.test.tsx @@ -0,0 +1,37 @@ +import { EffectComposer as EffectComposerImpl } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { N8AO } from '../effects/N8AO' +import { flush, root } from './test-utils' + +describe('N8AO', () => { + it('invalidates after a live config change and after a quality change, so frameloop="demand" repaints', async () => { + const composerRef = React.createRef() + const invalidateSpy = vi.spyOn(root.render(null).getState(), 'invalidate') + + const render = (intensity: number, quality?: 'performance' | 'ultra') => + root.render( + + + + ) + + await React.act(async () => render(1)) + await flush() + invalidateSpy.mockClear() + + await React.act(async () => render(2)) + await flush() + expect(invalidateSpy).toHaveBeenCalled() + + invalidateSpy.mockClear() + + await React.act(async () => render(2, 'ultra')) + await flush() + expect(invalidateSpy).toHaveBeenCalled() + + invalidateSpy.mockRestore() + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/Outline.test.tsx b/src/tests/Outline.test.tsx index e0e6206..c34d56e 100644 --- a/src/tests/Outline.test.tsx +++ b/src/tests/Outline.test.tsx @@ -86,4 +86,108 @@ describe('Outline', () => { await waitForComposer(composerRef) await expect(flush()).resolves.not.toThrow() }) + + it('applies visibleEdgeColor live, without reconstructing the effect (#143)', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + + const render = (color: number) => + root.render( + + + + ) + + await React.act(async () => render(0xff0000)) + await waitForComposer(composerRef) + await flush() + + const first = effectRef.current + expect(first!.visibleEdgeColor.getHex()).toBe(0xff0000) + + await React.act(async () => render(0x00ff00)) + await flush() + + expect(effectRef.current).toBe(first) + expect(effectRef.current!.visibleEdgeColor.getHex()).toBe(0x00ff00) + }) + + it('resets edgeStrength to its constructor default when the prop is removed', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + await waitForComposer(composerRef) + await flush() + + expect(effectRef.current!.edgeStrength).toBe(100) + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + + expect(effectRef.current!.edgeStrength).toBe(1) + }) + + it('still reconstructs when a construction-only prop (resolutionScale) changes', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + + const render = (resolutionScale: number) => + root.render( + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = effectRef.current + + await React.act(async () => render(1)) + await flush() + + expect(effectRef.current).not.toBe(first) + }) + + it('does not dispose its render target on unrelated re-renders (multisampling has an unconditional dispose side effect)', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + + const render = (tick: number) => + root.render( + + + + + ) + + await React.act(async () => render(0)) + await waitForComposer(composerRef) + await flush() + + // @ts-expect-error - `renderTargetMask` isn't part of the public OutlineEffect typing + const disposeSpy = vi.spyOn(effectRef.current!.renderTargetMask, 'dispose') + + for (let t = 1; t <= 5; t++) { + await React.act(async () => render(t)) + await flush() + } + + expect(disposeSpy).not.toHaveBeenCalled() + disposeSpy.mockRestore() + }) + }) diff --git a/src/tests/SSAO.test.tsx b/src/tests/SSAO.test.tsx new file mode 100644 index 0000000..e45deb4 --- /dev/null +++ b/src/tests/SSAO.test.tsx @@ -0,0 +1,114 @@ +import { EffectComposer as EffectComposerImpl, SSAOEffect } from 'postprocessing' +import * as React from 'react' +import { Color } from 'three' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { SSAO } from '../effects/SSAO' +import { flush, root, waitForComposer } from './test-utils' + +describe('SSAO', () => { + it('resets color/fade/minRadiusScale to their constructor defaults when removed, not the first-mounted value', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (withOverrides: boolean) => + root.render( + + + + ) + + await React.act(async () => render(true)) + await waitForComposer(composerRef) + await flush() + + expect(ref.current!.color!.getHexString()).toBe('ff0000') + expect(ref.current!.ssaoMaterial.fade).toBeCloseTo(0.5) + expect(ref.current!.ssaoMaterial.minRadiusScale).toBeCloseTo(0.9) + + await React.act(async () => render(false)) + await flush() + + // SSAOEffect's own constructor defaults (null / 0.01 / 0.1), not the + // values from the first render this instance ever saw. + expect(ref.current!.color).toBeNull() + expect(ref.current!.ssaoMaterial.fade).toBeCloseTo(0.01) + expect(ref.current!.ssaoMaterial.minRadiusScale).toBeCloseTo(0.1) + }) + + it('applies intensity live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (intensity: number) => + root.render( + + + + ) + + await React.act(async () => render(1)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.intensity).toBe(1) + + await React.act(async () => render(2)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.intensity).toBe(2) + + await React.act(async () => root.render(null)) + }) + + it('applies bias live via the nested ssaoMaterial, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (bias: number) => + root.render( + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + expect(first!.ssaoMaterial.bias).toBeCloseTo(0.5) + + await React.act(async () => render(0.8)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.ssaoMaterial.bias).toBeCloseTo(0.8) + + await React.act(async () => root.render(null)) + }) + + it('still reconstructs when resolutionScale (construction-only) changes', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (resolutionScale: number) => + root.render( + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = ref.current + + await React.act(async () => render(1)) + await flush() + + expect(ref.current).not.toBe(first) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/SelectiveBloom.test.tsx b/src/tests/SelectiveBloom.test.tsx index d7bff48..41a89f6 100644 --- a/src/tests/SelectiveBloom.test.tsx +++ b/src/tests/SelectiveBloom.test.tsx @@ -115,4 +115,52 @@ describe('SelectiveBloom', () => { await waitForComposer(composerRef) await expect(flush()).resolves.not.toThrow() }) + + it('applies intensity live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + const light = new PointLight() + + const render = (intensity: number) => + root.render( + + + + ) + + await React.act(async () => render(1)) + await waitForComposer(composerRef) + await flush() + const first = effectRef.current + expect(first!.intensity).toBe(1) + + await React.act(async () => render(3)) + await flush() + + expect(effectRef.current).toBe(first) + expect(effectRef.current!.intensity).toBe(3) + }) + + it('still reconstructs when luminanceThreshold changes (no live setter in postprocessing)', async () => { + const composerRef = React.createRef() + const effectRef = React.createRef() + const light = new PointLight() + + const render = (luminanceThreshold: number) => + root.render( + + + + ) + + await React.act(async () => render(0.5)) + await waitForComposer(composerRef) + await flush() + const first = effectRef.current + + await React.act(async () => render(0.8)) + await flush() + + expect(effectRef.current).not.toBe(first) + }) }) diff --git a/src/tests/ShockWave.test.tsx b/src/tests/ShockWave.test.tsx new file mode 100644 index 0000000..3123aae --- /dev/null +++ b/src/tests/ShockWave.test.tsx @@ -0,0 +1,95 @@ +import { EffectComposer as EffectComposerImpl, ShockWaveEffect } from 'postprocessing' +import * as React from 'react' +import { Vector3 } from 'three' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { ShockWave } from '../effects/ShockWave' +import { flush, root } from './test-utils' + +describe('ShockWave', () => { + it('applies speed and position, which createEffectComponent cannot (ShockWaveEffect takes them as a 3rd ctor arg)', async () => { + const ref = React.createRef() + const composerRef = React.createRef() + const position = new Vector3(1, 2, 3) + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + expect(ref.current!.speed).toBe(5) + expect(ref.current!.position).toBe(position) + + await React.act(async () => root.render(null)) + }) + + it('updates speed/position live, without reconstructing the instance', async () => { + const ref = React.createRef() + const composerRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + const firstInstance = ref.current + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + expect(ref.current).toBe(firstInstance) + expect(ref.current!.speed).toBe(2) + expect(ref.current!.waveSize).toBe(0.5) + + await React.act(async () => root.render(null)) + }) + + it('resets speed to its constructor default when the prop is removed', async () => { + const ref = React.createRef() + const composerRef = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + expect(ref.current!.speed).toBe(5) + const defaultSpeed = 2 + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + expect(ref.current!.speed).toBe(defaultSpeed) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/TiltShift.test.tsx b/src/tests/TiltShift.test.tsx new file mode 100644 index 0000000..4979f55 --- /dev/null +++ b/src/tests/TiltShift.test.tsx @@ -0,0 +1,76 @@ +import { EffectComposer as EffectComposerImpl, TiltShiftEffect } from 'postprocessing' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { TiltShift } from '../effects/TiltShift' +import { flush, root } from './test-utils' + +describe('TiltShift', () => { + it('applies resolutionScale at construction (previously never reached the effect at all)', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + + expect(ref.current!.blurPass.resolution.scale).toBeCloseTo(0.25) + + await React.act(async () => root.render(null)) + }) + + it('reconstructs when resolutionScale (construction-only) changes', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (resolutionScale: number) => + root.render( + + + + ) + + await React.act(async () => render(0.25)) + await flush() + const first = ref.current + + await React.act(async () => render(0.5)) + await flush() + + expect(ref.current).not.toBe(first) + expect(ref.current!.blurPass.resolution.scale).toBeCloseTo(0.5) + + await React.act(async () => root.render(null)) + }) + + it('applies offset live, without reconstructing the effect', async () => { + const composerRef = React.createRef() + const ref = React.createRef() + + const render = (offset: number) => + root.render( + + + + ) + + await React.act(async () => render(0.1)) + await flush() + const first = ref.current + expect(first!.offset).toBeCloseTo(0.1) + + await React.act(async () => render(0.2)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.offset).toBeCloseTo(0.2) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/effects.smoke.test.tsx b/src/tests/effects.smoke.test.tsx index f2b38d1..e84a8e4 100644 --- a/src/tests/effects.smoke.test.tsx +++ b/src/tests/effects.smoke.test.tsx @@ -165,32 +165,15 @@ describe('effect smoke tests', () => { } }) - // Tracks dispose() calls per instance rather than per class — EffectComposerImpl - // constructs its own internal CopyPass (this.copyPass, for compositing) and - // disposes it as part of its own teardown, unrelated to any CopyPass an effect - // constructs. A class-wide spy would conflate the two into a false "double - // dispose"; this only flags it if the *same* instance is disposed twice. - function trackDisposePerInstance(Ctor: { prototype: { dispose: (...args: unknown[]) => unknown } }) { - const counts = new Map() - const original = Ctor.prototype.dispose - const spy = vi.spyOn(Ctor.prototype, 'dispose').mockImplementation(function (this: object, ...args: unknown[]) { - counts.set(this, (counts.get(this) ?? 0) + 1) - return original.apply(this, args) - }) - return { - restore: () => spy.mockRestore(), - maxCallsForAnySingleInstance: () => Math.max(0, ...counts.values()), - } - } - - // Autofocus's ref resolves to { dofRef, hitpoint, update } (its own - // imperative API), not an effect instance — the generic dispose check - // above silently no-ops for it. It actually owns three disposables - // (depthPickingPass, copyPass, and the DepthOfField effect it renders - // internally), verified explicitly here instead. - it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect exactly once each', async () => { - const depthPickingTracker = trackDisposePerInstance(DepthPickingPass) - const copyPassTracker = trackDisposePerInstance(CopyPass) + // Autofocus's ref resolves to { dofRef, hitpoint, update }, not an effect + // instance - the generic dispose check above no-ops for it. It owns three + // disposables (depthPickingPass, copyPass, the nested DepthOfField effect), + // verified here. Both the composer's teardown and Autofocus's own cleanup + // end up disposing depthPickingPass/copyPass - that's fine, dispose() is + // idempotent (just event-firing / shallow property disposal, no state). + it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect', async () => { + const depthPickingDisposeSpy = vi.spyOn(DepthPickingPass.prototype, 'dispose') + const copyPassDisposeSpy = vi.spyOn(CopyPass.prototype, 'dispose') // AutofocusProps' `ref` type is broken (ComponentProps // drags in DepthOfField's own `ref: Ref`, which then // intersects with `Ref` — separate pre-existing issue, @@ -214,12 +197,12 @@ describe('effect smoke tests', () => { await React.act(async () => root.render(null)) await flush() - expect(depthPickingTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1) - expect(copyPassTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1) - expect(dofDisposeSpy).toHaveBeenCalledTimes(1) + expect(depthPickingDisposeSpy).toHaveBeenCalled() + expect(copyPassDisposeSpy).toHaveBeenCalled() + expect(dofDisposeSpy).toHaveBeenCalled() - depthPickingTracker.restore() - copyPassTracker.restore() + depthPickingDisposeSpy.mockRestore() + copyPassDisposeSpy.mockRestore() }) it('covers every file in src/effects (or documents why it is excluded)', () => {