diff --git a/src/EffectComposer.tsx b/src/EffectComposer.tsx index 1f48a14..7dc87d4 100644 --- a/src/EffectComposer.tsx +++ b/src/EffectComposer.tsx @@ -70,6 +70,9 @@ type ComposerState = { const isConvolution = (effect: Effect): boolean => (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION +// mainUv isn't a bitflag - detect it the same way postprocessing itself does. +const hasMainUv = (effect: Effect): boolean => /mainUv/.test(effect.getFragmentShader() ?? '') + // autoClear/toneMapping get force-set and never restored. Ref-counted per // (renderer, property) since composers can share a renderer. function createRendererPropertyGuard(property: K) { @@ -139,13 +142,10 @@ function disposeGeneratedPass(pass: Pass): void { disposePassWithoutEffects(pass) } -// 'auto' (default): consecutive Effects share one EffectPass, same as vanilla -// postprocessing allows by hand - a run may contain at most one convolution -// Effect (e.g. DepthOfField), since the library throws if two land in the -// same pass. 'all' merges through that limit too, same no-guardrail -// treatment EffectGroup already gives its own children - multiple -// convolution Effects in one run will throw at render time. 'none' gives -// every Effect its own EffectPass. +// 'auto' (default): consecutive Effects share one EffectPass, keeping +// convolution and mainUv Effects apart since postprocessing throws if they +// mix. 'all' merges through those limits too, no guardrails, same as +// EffectGroup. 'none' gives every Effect its own EffectPass. function buildPasses(nodes: Array, camera: Camera, mergeMode: 'auto' | 'all' | 'none'): Pass[] { const passes: Pass[] = [] @@ -155,14 +155,24 @@ function buildPasses(nodes: Array, camera: Camera, mergeMode: 'au if (node instanceof Effect) { const effects: Effect[] = [node] let hasConvolution = isConvolution(node) + let hasMainUvEffect = hasMainUv(node) if (mergeMode !== 'none') { let next: Effect | Pass | undefined while ((next = nodes[i + 1]) instanceof Effect) { const nextIsConvolution = isConvolution(next) - if (mergeMode === 'auto' && hasConvolution && nextIsConvolution) break + const nextHasMainUv = hasMainUv(next) + if ( + mergeMode === 'auto' && + ((hasConvolution && nextIsConvolution) || + (hasConvolution && nextHasMainUv) || + (hasMainUvEffect && nextIsConvolution)) + ) { + break + } effects.push(next) hasConvolution ||= nextIsConvolution + hasMainUvEffect ||= nextHasMainUv i++ } } diff --git a/src/tests/EffectComposer.test.tsx b/src/tests/EffectComposer.test.tsx index 1e5adc8..dd0e2de 100644 --- a/src/tests/EffectComposer.test.tsx +++ b/src/tests/EffectComposer.test.tsx @@ -47,11 +47,25 @@ class ConvolutionEffectTwo extends Effect { } } +const MAIN_UV_SHADER = ` +void mainUv(inout vec2 uv) { + uv += 0.0; +} +${EFFECT_SHADER} +` + +class MainUvEffect extends Effect { + constructor() { + super('MainUvEffect', MAIN_UV_SHADER) + } +} + const WrappedEffectA = wrapEffect(EffectA) const WrappedEffectB = wrapEffect(EffectB) const WrappedEffectC = wrapEffect(EffectC) const WrappedConvolutionEffect = wrapEffect(ConvolutionEffect) const WrappedConvolutionEffectTwo = wrapEffect(ConvolutionEffectTwo) +const WrappedMainUvEffect = wrapEffect(MainUvEffect) afterEach(async () => { await React.act(async () => { @@ -503,6 +517,53 @@ describe('EffectComposer', () => { expect(secondEffects.filter((e) => e instanceof ConvolutionEffect || e instanceof ConvolutionEffectTwo)).toHaveLength(1) }) + it("'auto' splits a mainUv effect and a convolution effect into separate passes (mainUv first)", async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + ) + ) + + const composer = await waitForComposer(ref) + expect(composer.passes.filter((pass) => pass instanceof EffectPass)).toHaveLength(2) + }) + + it("'auto' splits a mainUv effect and a convolution effect into separate passes (convolution first)", async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + ) + ) + + const composer = await waitForComposer(ref) + expect(composer.passes.filter((pass) => pass instanceof EffectPass)).toHaveLength(2) + }) + + it("'all' throws at render time if that removes 'auto'-only protection against merging a mainUv effect with a convolution effect", async () => { + const ref = React.createRef() + + await expect( + React.act(async () => + root.render( + + + + + ) + ) + ).rejects.toThrow('Effects that transform UVs are incompatible with convolution effects') + }) + it("'all' merges even a single convolution effect with neighbors, same as 'auto'", async () => { const ref = React.createRef()