Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/EffectComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends 'autoClear' | 'toneMapping'>(property: K) {
Expand Down Expand Up @@ -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<Effect | Pass>, camera: Camera, mergeMode: 'auto' | 'all' | 'none'): Pass[] {
const passes: Pass[] = []

Expand All @@ -155,14 +155,24 @@ function buildPasses(nodes: Array<Effect | Pass>, 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++
}
}
Expand Down
61 changes: 61 additions & 0 deletions src/tests/EffectComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<EffectComposerImpl>()

await React.act(async () =>
root.render(
<EffectComposer ref={ref}>
<WrappedMainUvEffect />
<WrappedConvolutionEffect />
</EffectComposer>
)
)

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<EffectComposerImpl>()

await React.act(async () =>
root.render(
<EffectComposer ref={ref}>
<WrappedConvolutionEffect />
<WrappedMainUvEffect />
</EffectComposer>
)
)

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<EffectComposerImpl>()

await expect(
React.act(async () =>
root.render(
<EffectComposer ref={ref} mergeMode="all">
<WrappedMainUvEffect />
<WrappedConvolutionEffect />
</EffectComposer>
)
)
).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<EffectComposerImpl>()

Expand Down
Loading