Skip to content

Commit 14038bc

Browse files
authored
Merge pull request #358 from pmndrs/fix/07-renderer-state-restoration
fix(EffectComposer): restore renderer.autoClear/toneMapping on unmount
2 parents 144a3a3 + 144687c commit 14038bc

2 files changed

Lines changed: 235 additions & 3 deletions

File tree

src/EffectComposer.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type ReactNode,
2222
type Ref,
2323
} from 'react'
24-
import type { Camera, Group, Scene, TextureDataType } from 'three'
24+
import type { Camera, Group, Scene, TextureDataType, WebGLRenderer } from 'three'
2525
import { HalfFloatType, NoToneMapping } from 'three'
2626

2727
export const EffectComposerContext = /* @__PURE__ */ createContext<{
@@ -59,6 +59,44 @@ type ComposerState = {
5959
const isConvolution = (effect: Effect): boolean =>
6060
(effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
6161

62+
/**
63+
* autoClear/toneMapping get force-set and never restored by whoever sets
64+
* them. Ref-counted per (renderer, property) since composers can share a
65+
* renderer; skips restoring if the value already changed since acquire.
66+
*/
67+
function createRendererPropertyGuard<K extends 'autoClear' | 'toneMapping'>(property: K) {
68+
const refs = new WeakMap<
69+
WebGLRenderer,
70+
{ count: number; original: WebGLRenderer[K]; forcedValue: WebGLRenderer[K] }
71+
>()
72+
73+
return {
74+
acquire(gl: WebGLRenderer, forcedValue: WebGLRenderer[K]): void {
75+
const existing = refs.get(gl)
76+
if (existing) {
77+
existing.count++
78+
existing.forcedValue = forcedValue
79+
} else {
80+
refs.set(gl, { count: 1, original: gl[property], forcedValue })
81+
}
82+
},
83+
release(gl: WebGLRenderer): void {
84+
const entry = refs.get(gl)
85+
if (!entry) return
86+
87+
if (--entry.count <= 0) {
88+
if (gl[property] === entry.forcedValue) {
89+
gl[property] = entry.original
90+
}
91+
refs.delete(gl)
92+
}
93+
},
94+
}
95+
}
96+
97+
const autoClearGuard = /* @__PURE__ */ createRendererPropertyGuard('autoClear')
98+
const toneMappingGuard = /* @__PURE__ */ createRendererPropertyGuard('toneMapping')
99+
62100
/**
63101
* Groups a flat, ordered list of Effect/Pass instances into actual composer
64102
* passes, merging consecutive non-convolution Effects into a single
@@ -116,6 +154,8 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
116154
const [composerState, setComposerState] = useState<ComposerState | null>(null)
117155

118156
useEffect(() => {
157+
autoClearGuard.acquire(gl, false)
158+
119159
const effectComposer = new EffectComposerImpl(gl, { depthBuffer, stencilBuffer, multisampling, frameBufferType })
120160
effectComposer.addPass(new RenderPass(scene, camera))
121161

@@ -140,6 +180,7 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
140180

141181
return () => {
142182
effectComposer.dispose()
183+
autoClearGuard.release(gl)
143184
}
144185
// `size` intentionally excluded: it's applied via the composer.setSize
145186
// effect below, and shouldn't tear down/recreate the whole composer.
@@ -199,10 +240,10 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
199240

200241
// Disable tone mapping because threejs disallows tonemapping on render targets
201242
useEffect(() => {
202-
const currentTonemapping = gl.toneMapping
243+
toneMappingGuard.acquire(gl, NoToneMapping)
203244
gl.toneMapping = NoToneMapping
204245
return () => {
205-
gl.toneMapping = currentTonemapping
246+
toneMappingGuard.release(gl)
206247
}
207248
}, [gl])
208249

src/tests/EffectComposer.test.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,197 @@ describe('EffectComposer', () => {
485485
})
486486
})
487487

488+
describe('renderer state restoration', () => {
489+
it('restores autoClear to its previous value once the composer is fully unmounted', async () => {
490+
const gl = root.render(null).getState().gl
491+
gl.autoClear = true // known baseline, independent of whatever earlier tests in this file left behind
492+
493+
const ref = React.createRef<EffectComposerImpl>()
494+
495+
await React.act(async () =>
496+
root.render(
497+
<EffectComposer ref={ref}>
498+
<WrappedEffectA />
499+
</EffectComposer>
500+
)
501+
)
502+
503+
await waitForComposer(ref)
504+
expect(gl.autoClear).toBe(false)
505+
506+
await React.act(async () => root.render(null))
507+
508+
expect(gl.autoClear).toBe(true)
509+
})
510+
511+
it('keeps autoClear disabled while a sibling composer on the same renderer is still mounted, regardless of unmount order', async () => {
512+
const gl = root.render(null).getState().gl
513+
gl.autoClear = true
514+
515+
const refA = React.createRef<EffectComposerImpl>()
516+
const refB = React.createRef<EffectComposerImpl>()
517+
518+
await React.act(async () =>
519+
root.render(
520+
<>
521+
<EffectComposer key="a" ref={refA}>
522+
<WrappedEffectA />
523+
</EffectComposer>
524+
<EffectComposer key="b" ref={refB}>
525+
<WrappedEffectB />
526+
</EffectComposer>
527+
</>
528+
)
529+
)
530+
531+
await waitForComposer(refA)
532+
await waitForComposer(refB)
533+
expect(gl.autoClear).toBe(false)
534+
535+
// Unmount the first composer only (key "a" drops out of the tree) -
536+
// the second is still relying on autoClear staying off, so this must
537+
// not restore it yet. Keying both is essential here: without it,
538+
// React would match by position and reuse "a"'s instance in place
539+
// (just updating its props to "b"'s), unmounting the wrong one.
540+
await React.act(async () =>
541+
root.render(
542+
<EffectComposer key="b" ref={refB}>
543+
<WrappedEffectB />
544+
</EffectComposer>
545+
)
546+
)
547+
548+
expect(gl.autoClear).toBe(false)
549+
550+
// Now the last one goes too - only now should it actually restore.
551+
await React.act(async () => root.render(null))
552+
553+
expect(gl.autoClear).toBe(true)
554+
})
555+
556+
it('restores toneMapping to its previous value once the composer is fully unmounted', async () => {
557+
const gl = root.render(null).getState().gl
558+
gl.toneMapping = THREE.ACESFilmicToneMapping // known baseline, distinct from NoToneMapping
559+
560+
const ref = React.createRef<EffectComposerImpl>()
561+
562+
await React.act(async () =>
563+
root.render(
564+
<EffectComposer ref={ref}>
565+
<WrappedEffectA />
566+
</EffectComposer>
567+
)
568+
)
569+
570+
await waitForComposer(ref)
571+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
572+
573+
await React.act(async () => root.render(null))
574+
575+
expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping)
576+
})
577+
578+
it('keeps toneMapping disabled while a sibling composer on the same renderer is still mounted, regardless of unmount order', async () => {
579+
const gl = root.render(null).getState().gl
580+
gl.toneMapping = THREE.ACESFilmicToneMapping
581+
582+
const refA = React.createRef<EffectComposerImpl>()
583+
const refB = React.createRef<EffectComposerImpl>()
584+
585+
await React.act(async () =>
586+
root.render(
587+
<>
588+
<EffectComposer key="a" ref={refA}>
589+
<WrappedEffectA />
590+
</EffectComposer>
591+
<EffectComposer key="b" ref={refB}>
592+
<WrappedEffectB />
593+
</EffectComposer>
594+
</>
595+
)
596+
)
597+
598+
await waitForComposer(refA)
599+
await waitForComposer(refB)
600+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
601+
602+
// Unmount the first composer only - the second still relies on
603+
// toneMapping staying off, so this must not restore it yet.
604+
await React.act(async () =>
605+
root.render(
606+
<EffectComposer key="b" ref={refB}>
607+
<WrappedEffectB />
608+
</EffectComposer>
609+
)
610+
)
611+
612+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
613+
614+
// Now the last one goes too - only now should it actually restore.
615+
await React.act(async () => root.render(null))
616+
617+
expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping)
618+
})
619+
620+
it('does not clobber a manual autoClear change made while the composer was mounted', async () => {
621+
const gl = root.render(null).getState().gl
622+
// Pre-mount baseline is false (not the usual true) specifically so
623+
// it differs from the manual override below - autoClear only has two
624+
// states, so this is the only way to make an unconditional restore-
625+
// to-original and a "preserve the manual change" outcome observably
626+
// different from each other.
627+
gl.autoClear = false
628+
629+
const ref = React.createRef<EffectComposerImpl>()
630+
631+
await React.act(async () =>
632+
root.render(
633+
<EffectComposer ref={ref}>
634+
<WrappedEffectA />
635+
</EffectComposer>
636+
)
637+
)
638+
639+
await waitForComposer(ref)
640+
expect(gl.autoClear).toBe(false)
641+
642+
// Something outside this component takes manual control while the
643+
// composer happens to still be mounted - a deliberate, more current
644+
// intent than whatever we captured before mount.
645+
gl.autoClear = true
646+
647+
await React.act(async () => root.render(null))
648+
649+
// Must stay what the manual override set it to, not get silently
650+
// reset back to the pre-mount value we originally captured (false).
651+
expect(gl.autoClear).toBe(true)
652+
})
653+
654+
it('does not clobber a manual toneMapping change made while the composer was mounted', async () => {
655+
const gl = root.render(null).getState().gl
656+
gl.toneMapping = THREE.ACESFilmicToneMapping
657+
658+
const ref = React.createRef<EffectComposerImpl>()
659+
660+
await React.act(async () =>
661+
root.render(
662+
<EffectComposer ref={ref}>
663+
<WrappedEffectA />
664+
</EffectComposer>
665+
)
666+
)
667+
668+
await waitForComposer(ref)
669+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
670+
671+
gl.toneMapping = THREE.ReinhardToneMapping
672+
673+
await React.act(async () => root.render(null))
674+
675+
expect(gl.toneMapping).toBe(THREE.ReinhardToneMapping)
676+
})
677+
})
678+
488679
describe('StrictMode', () => {
489680
it('preserves effects after the StrictMode fake-unmount/remount cycle', async () => {
490681
const ref = React.createRef<EffectComposerImpl>()

0 commit comments

Comments
 (0)