From 54833ba0df8ec89ab5dfd88a6d7768499e22a492 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 5 Aug 2026 20:43:21 +0200 Subject: [PATCH] Migrate simple effects to createEffectComponent Covers every effect whose postprocessing class constructs with zero arguments (Bloom, Noise, Vignette, FXAA, and ~20 others) - live props update the existing instance instead of reconstructing on every change, construction-only options move to explicit args. Also fixes a few bugs these effects had on top of the migration: opacity typing on nine of them, ChromaticAberration's radialModulation/modulationOffset incorrectly required, ColorDepth's bits not resetting on removal. --- src/effects/ASCII.tsx | 103 ++++++++++---- src/effects/Bloom.tsx | 36 ++++- src/effects/BrightnessContrast.tsx | 7 +- src/effects/ChromaticAberration.tsx | 34 ++--- src/effects/ColorAverage.tsx | 19 +-- src/effects/ColorDepth.tsx | 25 +++- src/effects/Depth.tsx | 6 +- src/effects/DotScreen.tsx | 7 +- src/effects/FXAA.tsx | 6 +- src/effects/Glitch.tsx | 57 ++++---- src/effects/Grid.tsx | 35 +++-- src/effects/HueSaturation.tsx | 7 +- src/effects/LensFlare.tsx | 183 +++++++++++++++++++++---- src/effects/Noise.tsx | 16 ++- src/effects/Pixelation.tsx | 24 ++-- src/effects/Ramp.tsx | 103 +++++++++++++- src/effects/SMAA.tsx | 20 ++- src/effects/ScanlineEffect.tsx | 12 +- src/effects/Sepia.tsx | 6 +- src/effects/Texture.tsx | 19 +-- src/effects/TiltShift.tsx | 32 ++++- src/effects/TiltShift2.tsx | 78 ++++++++++- src/effects/ToneMapping.tsx | 19 ++- src/effects/Vignette.tsx | 7 +- src/effects/Water.tsx | 27 +++- src/tests/Bloom.test.tsx | 76 ++++++++++ src/tests/ChromaticAberration.test.tsx | 50 +++++++ src/tests/ColorDepth.test.tsx | 71 ++++++++++ src/tests/EffectComposer.test.tsx | 73 ++++++++-- src/tests/Glitch.test.tsx | 56 ++++++++ src/tests/Grid.test.tsx | 34 +++++ src/tests/TiltShift.test.tsx | 76 ++++++++++ 32 files changed, 1107 insertions(+), 217 deletions(-) create mode 100644 src/tests/Bloom.test.tsx create mode 100644 src/tests/ColorDepth.test.tsx create mode 100644 src/tests/Glitch.test.tsx create mode 100644 src/tests/Grid.test.tsx create mode 100644 src/tests/TiltShift.test.tsx diff --git a/src/effects/ASCII.tsx b/src/effects/ASCII.tsx index b6744b56..2889dd39 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/Bloom.tsx b/src/effects/Bloom.tsx index f3c9193b..59833626 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 ac1de7b0..cba9939e 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 c768071c..bbbfbfc4 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 5a56292e..fe6a640d 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 da7610a0..ce293028 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 abebf114..ddc10642 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/DotScreen.tsx b/src/effects/DotScreen.tsx index 8bd72976..b480ecc3 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 4214767f..1c93ac52 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 488823c8..3d9befa5 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/Grid.tsx b/src/effects/Grid.tsx index 639e22b0..f818ce56 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 7a27c193..d791208e 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/LensFlare.tsx b/src/effects/LensFlare.tsx index 4cb9d3c1..e283b92c 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/Noise.tsx b/src/effects/Noise.tsx index a95e37da..d81586ae 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/Pixelation.tsx b/src/effects/Pixelation.tsx index 66ad13bf..ce909b28 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 e2dab703..140b0ff6 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 9e41b1b9..6eab5e59 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/ScanlineEffect.tsx b/src/effects/ScanlineEffect.tsx index ed34430a..6fe48b64 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/Sepia.tsx b/src/effects/Sepia.tsx index 8142b2bd..891a96c4 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/Texture.tsx b/src/effects/Texture.tsx index 6610b789..e731d2a3 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 82372e5a..ecd31d10 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 83265060..2da117d2 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 5358d7b7..2f0fa677 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 886020f5..b9c59068 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 e7b186cd..c4d59c53 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 00000000..facb2019 --- /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 5e7c5d16..d98d87c7 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 00000000..e3b3ea0f --- /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/EffectComposer.test.tsx b/src/tests/EffectComposer.test.tsx index 6b276c1c..c667aef6 100644 --- a/src/tests/EffectComposer.test.tsx +++ b/src/tests/EffectComposer.test.tsx @@ -583,14 +583,71 @@ describe('EffectComposer', () => { disposeSpy.mockRestore() }) - // NOTE for PR3 (simple effects migration): re-add these two once - // ColorAverage.tsx moves to createEffectComponent - - // "keeps a single ColorAverage instance across repeated blendFunction - // changes and disposes it exactly once (blendFunction is live, not - // construction-only)" and a disposes-every-seen-instance StrictMode - // check - both require ColorAverage's blendFunction to be a live prop, - // which is still construction-only (wrapEffect-based) at this point in - // the stack. + 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() + const cycles = 20 + + try { + for (let i = 0; i < cycles; i++) { + await React.act(async () => + root.render( + + + + ) + ) + await flush() + if (ref.current) seenInstances.add(ref.current) + } + + await React.act(async () => root.render(null)) + + expect(seenInstances.size).toBe(1) + expect(disposeSpy).toHaveBeenCalledTimes(1) + } finally { + disposeSpy.mockRestore() + } + }) + + it('disposes every ColorAverage instance seen, even across StrictMode\'s mount/cleanup/mount cycle', async () => { + const disposedNodes: ColorAverageEffect[] = [] + const seenInstances = new Set() + const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose').mockImplementation(function ( + this: ColorAverageEffect + ) { + disposedNodes.push(this) + }) + + try { + const ref = React.createRef() + for (let i = 0; i < 20; i++) { + await React.act(async () => + root.render( + strict( + + + + ) + ) + ) + await flush() + if (ref.current) seenInstances.add(ref.current) + } + await React.act(async () => root.render(null)) + + // dispose() is idempotent (just event-firing / shallow property + // disposal, no internal state), so StrictMode calling it more than + // once per instance is fine - this only checks nothing leaked. + const disposedSet = new Set(disposedNodes) + for (const instance of seenInstances) { + expect(disposedSet.has(instance)).toBe(true) + } + } finally { + disposeSpy.mockRestore() + } + }) }) describe('renderer state restoration', () => { diff --git a/src/tests/Glitch.test.tsx b/src/tests/Glitch.test.tsx new file mode 100644 index 00000000..c383cb4c --- /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/Grid.test.tsx b/src/tests/Grid.test.tsx new file mode 100644 index 00000000..60326989 --- /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/TiltShift.test.tsx b/src/tests/TiltShift.test.tsx new file mode 100644 index 00000000..4979f55f --- /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)) + }) +})