From cc588f4af4061a79ee24e466da4a89344a400319 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 5 Aug 2026 20:44:29 +0200 Subject: [PATCH] Migrate hand-rolled effects to useLiveDefaults Outline, SelectiveBloom, ShockWave, GodRays, DepthOfField, SSAO, LUT, and N8AO all need real constructor args (scene/camera/etc.), so they stay hand-built with useMemo, but now apply live props through useLiveDefaults instead of reconstructing on every change. This is where nearly every real runtime bug from review surfaced: a first-apply bug where a still-correct value's setter fired anyway (Outline's multisampling disposing its render target before first use - the actual reason several of these didn't render at all), SSAO's color/fade/minRadiusScale/world* thresholds not resetting on removal, DepthOfField's depthTexture reconstructing instead of using the live setDepthTexture, and GodRays/N8AO not invalidating on live changes under frameloop="demand". --- src/effects/DepthOfField.tsx | 74 ++++++++++----- src/effects/GodRays.tsx | 88 +++++++++++++++-- src/effects/LUT.tsx | 20 ++-- src/effects/N8AO.tsx | 13 ++- src/effects/Outline.tsx | 95 +++++++++---------- src/effects/SSAO.tsx | 152 +++++++++++++++++++++++++----- src/effects/SelectiveBloom.tsx | 70 +++++--------- src/effects/ShockWave.tsx | 34 ++++++- src/tests/DepthOfField.test.tsx | 130 +++++++++++++++++++++++++ src/tests/GodRays.test.tsx | 101 ++++++++++++++++++++ src/tests/LUT.test.tsx | 64 +++++++++++++ src/tests/N8AO.test.tsx | 37 ++++++++ src/tests/Outline.test.tsx | 104 ++++++++++++++++++++ src/tests/SSAO.test.tsx | 114 ++++++++++++++++++++++ src/tests/SelectiveBloom.test.tsx | 48 ++++++++++ src/tests/ShockWave.test.tsx | 95 +++++++++++++++++++ 16 files changed, 1071 insertions(+), 168 deletions(-) create mode 100644 src/tests/DepthOfField.test.tsx create mode 100644 src/tests/GodRays.test.tsx create mode 100644 src/tests/LUT.test.tsx create mode 100644 src/tests/N8AO.test.tsx create mode 100644 src/tests/SSAO.test.tsx create mode 100644 src/tests/ShockWave.test.tsx diff --git a/src/effects/DepthOfField.tsx b/src/effects/DepthOfField.tsx index 5c9b1fad..ba9aa3ec 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/GodRays.tsx b/src/effects/GodRays.tsx index b4a3df6b..e8fba56f 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/LUT.tsx b/src/effects/LUT.tsx index f1e277c4..5db77e36 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/N8AO.tsx b/src/effects/N8AO.tsx index 2c68a726..df5b0c0d 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/Outline.tsx b/src/effects/Outline.tsx index 8f8e99a2..41178817 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/SSAO.tsx b/src/effects/SSAO.tsx index 2d5fd723..68319a58 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/SelectiveBloom.tsx b/src/effects/SelectiveBloom.tsx index 7007dddc..fc080af5 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/ShockWave.tsx b/src/effects/ShockWave.tsx index 10da37dc..b2b7fe96 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/tests/DepthOfField.test.tsx b/src/tests/DepthOfField.test.tsx new file mode 100644 index 00000000..8b0545da --- /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/GodRays.test.tsx b/src/tests/GodRays.test.tsx new file mode 100644 index 00000000..b0a34f29 --- /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/LUT.test.tsx b/src/tests/LUT.test.tsx new file mode 100644 index 00000000..28a2ce2d --- /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 00000000..75b41fa3 --- /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 e0e62062..c34d56eb 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 00000000..e45deb47 --- /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 d7bff48e..41a89f62 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 00000000..3123aae3 --- /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)) + }) +})