@react-three/postprocessing 3.0.5 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +6 -7
  2. package/dist/EffectComposer.d.ts +9 -2
  3. package/dist/EffectGroup.d.ts +9 -0
  4. package/dist/createEffectComponent.d.ts +30 -0
  5. package/dist/effects/ASCII.d.ts +28 -7
  6. package/dist/effects/Autofocus.d.ts +1 -1
  7. package/dist/effects/Bloom.d.ts +9 -1
  8. package/dist/effects/BrightnessContrast.d.ts +14 -1
  9. package/dist/effects/ChromaticAberration.d.ts +26 -3
  10. package/dist/effects/ColorAverage.d.ts +7 -7
  11. package/dist/effects/ColorDepth.d.ts +7 -1
  12. package/dist/effects/Depth.d.ts +12 -1
  13. package/dist/effects/DotScreen.d.ts +14 -1
  14. package/dist/effects/FXAA.d.ts +10 -1
  15. package/dist/effects/Glitch.d.ts +16 -11
  16. package/dist/effects/GodRays.d.ts +1 -1
  17. package/dist/effects/Grid.d.ts +7 -6
  18. package/dist/effects/HueSaturation.d.ts +14 -1
  19. package/dist/effects/LUT.d.ts +1 -1
  20. package/dist/effects/LensFlare.d.ts +46 -5
  21. package/dist/effects/Noise.d.ts +7 -1
  22. package/dist/effects/Outline.d.ts +6 -3
  23. package/dist/effects/Pixelation.d.ts +1 -1
  24. package/dist/effects/Ramp.d.ts +58 -6
  25. package/dist/effects/SMAA.d.ts +9 -1
  26. package/dist/effects/SSAO.d.ts +1 -1
  27. package/dist/effects/ScanlineEffect.d.ts +12 -1
  28. package/dist/effects/SelectiveBloom.d.ts +1 -1
  29. package/dist/effects/Sepia.d.ts +12 -1
  30. package/dist/effects/ShockWave.d.ts +11 -1
  31. package/dist/effects/Texture.d.ts +4 -4
  32. package/dist/effects/TiltShift.d.ts +9 -1
  33. package/dist/effects/TiltShift2.d.ts +44 -4
  34. package/dist/effects/ToneMapping.d.ts +9 -3
  35. package/dist/effects/Vignette.d.ts +18 -1
  36. package/dist/effects/Water.d.ts +17 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +2205 -713
  39. package/dist/index.js.map +1 -1
  40. package/dist/passes/DepthPicking.d.ts +11 -0
  41. package/dist/{effects → passes}/N8AO.d.ts +2 -1
  42. package/dist/util.d.ts +9 -15
  43. package/dist/wrapEffect.d.ts +3 -3
  44. package/package.json +6 -5
  45. package/src/EffectComposer.tsx +143 -42
  46. package/src/EffectGroup.tsx +74 -0
  47. package/src/createEffectComponent.tsx +71 -0
  48. package/src/effects/ASCII.tsx +75 -28
  49. package/src/effects/Autofocus.tsx +24 -77
  50. package/src/effects/Bloom.tsx +32 -4
  51. package/src/effects/BrightnessContrast.tsx +5 -2
  52. package/src/effects/ChromaticAberration.tsx +13 -21
  53. package/src/effects/ColorAverage.tsx +3 -16
  54. package/src/effects/ColorDepth.tsx +23 -2
  55. package/src/effects/Depth.tsx +4 -2
  56. package/src/effects/DepthOfField.tsx +48 -26
  57. package/src/effects/DotScreen.tsx +5 -2
  58. package/src/effects/FXAA.tsx +4 -2
  59. package/src/effects/Glitch.tsx +26 -31
  60. package/src/effects/GodRays.tsx +90 -6
  61. package/src/effects/Grid.tsx +17 -18
  62. package/src/effects/HueSaturation.tsx +5 -2
  63. package/src/effects/LUT.tsx +9 -11
  64. package/src/effects/LensFlare.tsx +209 -55
  65. package/src/effects/Noise.tsx +14 -2
  66. package/src/effects/Outline.tsx +55 -56
  67. package/src/effects/Pixelation.tsx +11 -8
  68. package/src/effects/Ramp.tsx +96 -7
  69. package/src/effects/SMAA.tsx +18 -2
  70. package/src/effects/SSAO.tsx +130 -22
  71. package/src/effects/ScanlineEffect.tsx +6 -6
  72. package/src/effects/SelectiveBloom.tsx +26 -44
  73. package/src/effects/Sepia.tsx +4 -2
  74. package/src/effects/ShockWave.tsx +30 -2
  75. package/src/effects/Texture.tsx +10 -9
  76. package/src/effects/TiltShift.tsx +30 -2
  77. package/src/effects/TiltShift2.tsx +72 -6
  78. package/src/effects/ToneMapping.tsx +16 -3
  79. package/src/effects/Vignette.tsx +5 -2
  80. package/src/effects/Water.tsx +21 -6
  81. package/src/index.ts +4 -2
  82. package/src/passes/DepthPicking.tsx +63 -0
  83. package/src/{effects → passes}/N8AO.tsx +28 -4
  84. package/src/tests/Autofocus.test.tsx +38 -0
  85. package/src/tests/Bloom.test.tsx +76 -0
  86. package/src/tests/ChromaticAberration.test.tsx +50 -0
  87. package/src/tests/ColorDepth.test.tsx +71 -0
  88. package/src/tests/DepthOfField.test.tsx +130 -0
  89. package/src/tests/DepthPicking.test.tsx +250 -0
  90. package/src/tests/EffectComposer.test.tsx +501 -8
  91. package/src/tests/EffectGroup.test.tsx +337 -0
  92. package/src/tests/Glitch.test.tsx +56 -0
  93. package/src/tests/GodRays.test.tsx +101 -0
  94. package/src/tests/Grid.test.tsx +34 -0
  95. package/src/tests/LUT.test.tsx +64 -0
  96. package/src/tests/N8AO.test.tsx +92 -0
  97. package/src/tests/Outline.test.tsx +122 -0
  98. package/src/tests/SSAO.test.tsx +140 -0
  99. package/src/tests/SelectiveBloom.test.tsx +48 -0
  100. package/src/tests/ShockWave.test.tsx +95 -0
  101. package/src/tests/TiltShift.test.tsx +76 -0
  102. package/src/tests/createEffectComponent.test.tsx +286 -0
  103. package/src/tests/effects.smoke.test.tsx +46 -42
  104. package/src/util.tsx +148 -23
  105. package/src/wrapEffect.tsx +3 -2
  106. package/dist/tests/test-utils.d.ts +0 -12
@@ -3,6 +3,7 @@ import {
3
3
  ColorAverageEffect,
4
4
  DepthDownsamplingPass,
5
5
  Effect,
6
+ EffectAttribute,
6
7
  EffectComposer as EffectComposerImpl,
7
8
  EffectPass,
8
9
  NormalPass,
@@ -34,9 +35,23 @@ class EffectC extends Effect {
34
35
  }
35
36
  }
36
37
 
38
+ class ConvolutionEffect extends Effect {
39
+ constructor() {
40
+ super('ConvolutionEffect', EFFECT_SHADER, { attributes: EffectAttribute.CONVOLUTION })
41
+ }
42
+ }
43
+
44
+ class ConvolutionEffectTwo extends Effect {
45
+ constructor() {
46
+ super('ConvolutionEffectTwo', EFFECT_SHADER, { attributes: EffectAttribute.CONVOLUTION })
47
+ }
48
+ }
49
+
37
50
  const WrappedEffectA = wrapEffect(EffectA)
38
51
  const WrappedEffectB = wrapEffect(EffectB)
39
52
  const WrappedEffectC = wrapEffect(EffectC)
53
+ const WrappedConvolutionEffect = wrapEffect(ConvolutionEffect)
54
+ const WrappedConvolutionEffectTwo = wrapEffect(ConvolutionEffectTwo)
40
55
 
41
56
  afterEach(async () => {
42
57
  await React.act(async () => {
@@ -299,6 +314,285 @@ describe('EffectComposer', () => {
299
314
  })
300
315
  })
301
316
 
317
+ describe('autoRenderToScreen', () => {
318
+ it('defaults to true, with the last pass rendering to screen', async () => {
319
+ const ref = React.createRef<EffectComposerImpl>()
320
+
321
+ await React.act(async () =>
322
+ root.render(
323
+ <EffectComposer ref={ref}>
324
+ <WrappedEffectA />
325
+ </EffectComposer>
326
+ )
327
+ )
328
+
329
+ const composer = await waitForComposer(ref)
330
+ expect(composer.autoRenderToScreen).toBe(true)
331
+ expect(composer.passes.at(-1)?.renderToScreen).toBe(true)
332
+ })
333
+
334
+ it('applies false at mount, with no pass rendering to screen', async () => {
335
+ const ref = React.createRef<EffectComposerImpl>()
336
+
337
+ await React.act(async () =>
338
+ root.render(
339
+ <EffectComposer ref={ref} autoRenderToScreen={false}>
340
+ <WrappedEffectA />
341
+ </EffectComposer>
342
+ )
343
+ )
344
+
345
+ const composer = await waitForComposer(ref)
346
+ expect(composer.autoRenderToScreen).toBe(false)
347
+ expect(composer.passes.at(-1)?.renderToScreen).toBe(false)
348
+ })
349
+
350
+ // Not a constructor option in postprocessing itself, but the only place
351
+ // it's actually read is inside addPass() - deciding whether *that* call
352
+ // also assigns renderToScreen to the pass going in. Setting it after
353
+ // passes already exist doesn't revisit them, so treating this as a
354
+ // live/reactive prop (mutate the flag, leave existing passes alone)
355
+ // would leave a composer that was built with autoRenderToScreen: false
356
+ // permanently unable to render to screen again, even after the prop
357
+ // flips back to true - every existing pass already has renderToScreen:
358
+ // false baked in from when it was added, and nothing revisits it.
359
+ // Recreating the composer (same treatment as depthBuffer/multisampling/
360
+ // etc. above) sidesteps that entirely: every pass is freshly added
361
+ // through addPass() with the current flag already in effect.
362
+ it('recreates the composer when autoRenderToScreen changes, with the new last pass matching it', async () => {
363
+ const ref = React.createRef<EffectComposerImpl>()
364
+
365
+ await React.act(async () =>
366
+ root.render(
367
+ <EffectComposer ref={ref} autoRenderToScreen={false}>
368
+ <WrappedEffectA />
369
+ </EffectComposer>
370
+ )
371
+ )
372
+
373
+ const first = await waitForComposer(ref)
374
+ expect(first.passes.at(-1)?.renderToScreen).toBe(false)
375
+
376
+ await React.act(async () =>
377
+ root.render(
378
+ <EffectComposer ref={ref} autoRenderToScreen={true}>
379
+ <WrappedEffectA />
380
+ </EffectComposer>
381
+ )
382
+ )
383
+
384
+ const second = await waitForNewComposer(ref, first)
385
+ expect(second.autoRenderToScreen).toBe(true)
386
+ expect(second.passes.at(-1)?.renderToScreen).toBe(true)
387
+ })
388
+ })
389
+
390
+ describe('renderPass', () => {
391
+ it('adds a plain RenderPass as the first pass by default', async () => {
392
+ const ref = React.createRef<EffectComposerImpl>()
393
+
394
+ await React.act(async () =>
395
+ root.render(
396
+ <EffectComposer ref={ref}>
397
+ <WrappedEffectA />
398
+ </EffectComposer>
399
+ )
400
+ )
401
+
402
+ const composer = await waitForComposer(ref)
403
+ expect(composer.passes[0]).toBeInstanceOf(RenderPass)
404
+ })
405
+
406
+ it('uses a custom factory instead of the default RenderPass, called with the resolved scene/camera', async () => {
407
+ class CustomRenderPass extends RenderPass {}
408
+ const scene = new THREE.Scene()
409
+ const camera = new THREE.PerspectiveCamera()
410
+ const factory = vi.fn((s: THREE.Scene, c: THREE.Camera) => new CustomRenderPass(s, c))
411
+ const ref = React.createRef<EffectComposerImpl>()
412
+
413
+ await React.act(async () =>
414
+ root.render(
415
+ <EffectComposer ref={ref} scene={scene} camera={camera} renderPass={factory}>
416
+ <WrappedEffectA />
417
+ </EffectComposer>
418
+ )
419
+ )
420
+
421
+ const composer = await waitForComposer(ref)
422
+ expect(composer.passes[0]).toBeInstanceOf(CustomRenderPass)
423
+ expect(factory).toHaveBeenCalledWith(scene, camera)
424
+ })
425
+
426
+ // Not a postprocessing constructor option (it's this library's own prop),
427
+ // but the pass it produces is only ever added once, at construction -
428
+ // same treatment as depthBuffer/multisampling/autoRenderToScreen above.
429
+ it('recreates the composer when renderPass changes', async () => {
430
+ class CustomRenderPass extends RenderPass {}
431
+ const ref = React.createRef<EffectComposerImpl>()
432
+
433
+ await React.act(async () =>
434
+ root.render(
435
+ <EffectComposer ref={ref}>
436
+ <WrappedEffectA />
437
+ </EffectComposer>
438
+ )
439
+ )
440
+
441
+ const first = await waitForComposer(ref)
442
+ expect(first.passes[0]).toBeInstanceOf(RenderPass)
443
+ expect(first.passes[0]).not.toBeInstanceOf(CustomRenderPass)
444
+
445
+ await React.act(async () =>
446
+ root.render(
447
+ <EffectComposer ref={ref} renderPass={(s, c) => new CustomRenderPass(s, c)}>
448
+ <WrappedEffectA />
449
+ </EffectComposer>
450
+ )
451
+ )
452
+
453
+ const second = await waitForNewComposer(ref, first)
454
+ expect(second.passes[0]).toBeInstanceOf(CustomRenderPass)
455
+ })
456
+ })
457
+
458
+ describe('mergeMode', () => {
459
+ it("'auto' (default) merges a convolution effect with its non-convolution neighbors into one EffectPass", async () => {
460
+ const ref = React.createRef<EffectComposerImpl>()
461
+
462
+ await React.act(async () =>
463
+ root.render(
464
+ <EffectComposer ref={ref}>
465
+ <WrappedEffectA />
466
+ <WrappedConvolutionEffect />
467
+ <WrappedEffectB />
468
+ <WrappedEffectC />
469
+ </EffectComposer>
470
+ )
471
+ )
472
+
473
+ const composer = await waitForComposer(ref)
474
+ const effectPasses = composer.passes.filter((pass) => pass instanceof EffectPass)
475
+ expect(effectPasses).toHaveLength(1)
476
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
477
+ expect(effectPasses[0].effects).toHaveLength(4)
478
+ })
479
+
480
+ it("'auto' still splits into separate passes rather than merging two convolution effects together", async () => {
481
+ const ref = React.createRef<EffectComposerImpl>()
482
+
483
+ await React.act(async () =>
484
+ root.render(
485
+ <EffectComposer ref={ref}>
486
+ <WrappedEffectA />
487
+ <WrappedConvolutionEffect />
488
+ <WrappedConvolutionEffectTwo />
489
+ <WrappedEffectB />
490
+ </EffectComposer>
491
+ )
492
+ )
493
+
494
+ const composer = await waitForComposer(ref)
495
+ const effectPasses = composer.passes.filter((pass) => pass instanceof EffectPass)
496
+ // [A, ConvolutionEffect] | [ConvolutionEffectTwo, B] - never both convolutions in one pass
497
+ expect(effectPasses).toHaveLength(2)
498
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
499
+ const firstEffects = effectPasses[0].effects as Effect[]
500
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
501
+ const secondEffects = effectPasses[1].effects as Effect[]
502
+ expect(firstEffects.filter((e) => e instanceof ConvolutionEffect || e instanceof ConvolutionEffectTwo)).toHaveLength(1)
503
+ expect(secondEffects.filter((e) => e instanceof ConvolutionEffect || e instanceof ConvolutionEffectTwo)).toHaveLength(1)
504
+ })
505
+
506
+ it("'all' merges even a single convolution effect with neighbors, same as 'auto'", async () => {
507
+ const ref = React.createRef<EffectComposerImpl>()
508
+
509
+ await React.act(async () =>
510
+ root.render(
511
+ <EffectComposer ref={ref} mergeMode="all">
512
+ <WrappedEffectA />
513
+ <WrappedConvolutionEffect />
514
+ <WrappedEffectB />
515
+ </EffectComposer>
516
+ )
517
+ )
518
+
519
+ const composer = await waitForComposer(ref)
520
+ const effectPasses = composer.passes.filter((pass) => pass instanceof EffectPass)
521
+ expect(effectPasses).toHaveLength(1)
522
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
523
+ expect(effectPasses[0].effects).toHaveLength(3)
524
+ })
525
+
526
+ // postprocessing itself throws when two convolution effects share a
527
+ // pass - 'all' doesn't guard against that (same no-guardrail contract as
528
+ // EffectGroup), unlike 'auto' which keeps them apart specifically to
529
+ // avoid this.
530
+ it("'all' throws at render time if that removes 'auto'-only protection against merging two convolution effects", async () => {
531
+ const ref = React.createRef<EffectComposerImpl>()
532
+
533
+ await expect(
534
+ React.act(async () =>
535
+ root.render(
536
+ <EffectComposer ref={ref} mergeMode="all">
537
+ <WrappedEffectA />
538
+ <WrappedConvolutionEffect />
539
+ <WrappedConvolutionEffectTwo />
540
+ <WrappedEffectB />
541
+ </EffectComposer>
542
+ )
543
+ )
544
+ ).rejects.toThrow('Convolution effects cannot be merged')
545
+ })
546
+
547
+ it("'none' gives every effect its own EffectPass, even non-convolution ones", async () => {
548
+ const ref = React.createRef<EffectComposerImpl>()
549
+
550
+ await React.act(async () =>
551
+ root.render(
552
+ <EffectComposer ref={ref} mergeMode="none">
553
+ <WrappedEffectA />
554
+ <WrappedEffectB />
555
+ <WrappedEffectC />
556
+ </EffectComposer>
557
+ )
558
+ )
559
+
560
+ const composer = await waitForComposer(ref)
561
+ expect(composer.passes.filter((pass) => pass instanceof EffectPass)).toHaveLength(3)
562
+ })
563
+
564
+ it('rebuilds passes (without recreating the composer) when mergeMode changes', async () => {
565
+ const ref = React.createRef<EffectComposerImpl>()
566
+
567
+ await React.act(async () =>
568
+ root.render(
569
+ <EffectComposer ref={ref} mergeMode="none">
570
+ <WrappedEffectA />
571
+ <WrappedConvolutionEffect />
572
+ <WrappedEffectB />
573
+ </EffectComposer>
574
+ )
575
+ )
576
+
577
+ const composer = await waitForComposer(ref)
578
+ expect(composer.passes.filter((pass) => pass instanceof EffectPass)).toHaveLength(3)
579
+
580
+ await React.act(async () =>
581
+ root.render(
582
+ <EffectComposer ref={ref} mergeMode="auto">
583
+ <WrappedEffectA />
584
+ <WrappedConvolutionEffect />
585
+ <WrappedEffectB />
586
+ </EffectComposer>
587
+ )
588
+ )
589
+ await flush()
590
+
591
+ expect(await waitForComposer(ref)).toBe(composer)
592
+ expect(composer.passes.filter((pass) => pass instanceof EffectPass)).toHaveLength(1)
593
+ })
594
+ })
595
+
302
596
  describe('cleanup and disposal', () => {
303
597
  it('unregisters effects and clears the ref on full unmount', async () => {
304
598
  const ref = React.createRef<EffectComposerImpl>()
@@ -380,6 +674,128 @@ describe('EffectComposer', () => {
380
674
  disposeSpy.mockRestore()
381
675
  })
382
676
 
677
+ it('disposes a discarded EffectPass wrapper\'s own material on rebuild, without disposing the effects it wrapped', async () => {
678
+ const ref = React.createRef<EffectComposerImpl>()
679
+
680
+ await React.act(async () => root.render(<EffectComposer ref={ref}><WrappedEffectA /></EffectComposer>))
681
+ const composer = await waitForComposer(ref)
682
+ await waitForEffects(ref, 1)
683
+
684
+ const firstPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass
685
+ const materialDisposeSpy = vi.spyOn(firstPass.fullscreenMaterial, 'dispose')
686
+ const effectDisposeSpy = vi.spyOn(EffectA.prototype, 'dispose')
687
+
688
+ // Changing the node list forces a rebuild: buildPasses always
689
+ // constructs a brand new EffectPass, discarding the old wrapper.
690
+ await React.act(async () =>
691
+ root.render(
692
+ <EffectComposer ref={ref}>
693
+ <WrappedEffectA />
694
+ <WrappedEffectB />
695
+ </EffectComposer>
696
+ )
697
+ )
698
+ await flush()
699
+
700
+ const secondPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass
701
+ expect(secondPass).not.toBe(firstPass)
702
+ expect(materialDisposeSpy).toHaveBeenCalledTimes(1)
703
+ expect(effectDisposeSpy).not.toHaveBeenCalled()
704
+
705
+ materialDisposeSpy.mockRestore()
706
+ effectDisposeSpy.mockRestore()
707
+ })
708
+
709
+ it('detaches a discarded EffectPass\'s change listener from the effect it wrapped, so it no longer reacts to it', async () => {
710
+ const ref = React.createRef<EffectComposerImpl>()
711
+ const effectRef = React.createRef<EffectA>()
712
+
713
+ await React.act(async () => root.render(<EffectComposer ref={ref}><WrappedEffectA ref={effectRef} /></EffectComposer>))
714
+ const composer = await waitForComposer(ref)
715
+ await waitForEffects(ref, 1)
716
+
717
+ const firstPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass
718
+ const recompileSpy = vi.spyOn(firstPass, 'recompile')
719
+
720
+ await React.act(async () =>
721
+ root.render(
722
+ <EffectComposer ref={ref}>
723
+ <WrappedEffectA ref={effectRef} />
724
+ <WrappedEffectB />
725
+ </EffectComposer>
726
+ )
727
+ )
728
+ await flush()
729
+
730
+ const secondPass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass
731
+ expect(secondPass).not.toBe(firstPass)
732
+
733
+ // The same effect instance survived the rebuild - firing its own
734
+ // 'change' event should only reach whatever pass currently wraps it,
735
+ // not the discarded one still listening from before.
736
+ effectRef.current!.dispatchEvent({ type: 'change' })
737
+
738
+ expect(recompileSpy).not.toHaveBeenCalled()
739
+
740
+ recompileSpy.mockRestore()
741
+ })
742
+
743
+ it('leaves a user-provided EffectPass (rendered directly as a child) untouched across a rebuild', async () => {
744
+ const ref = React.createRef<EffectComposerImpl>()
745
+ const camera = new THREE.PerspectiveCamera()
746
+ const userEffect = new EffectC()
747
+ const userPass = new EffectPass(camera, userEffect)
748
+
749
+ await React.act(async () =>
750
+ root.render(
751
+ <EffectComposer ref={ref}>
752
+ <WrappedEffectA />
753
+ <primitive object={userPass} />
754
+ </EffectComposer>
755
+ )
756
+ )
757
+ const composer = await waitForComposer(ref)
758
+ await waitForEffects(ref, 1)
759
+ expect(composer.passes).toContain(userPass)
760
+
761
+ // Forces a rebuild (node list changes) - buildPasses only ever
762
+ // constructs a *new* EffectPass for Effect children; userPass is
763
+ // passed through unchanged via the plain-Pass branch.
764
+ await React.act(async () =>
765
+ root.render(
766
+ <EffectComposer ref={ref}>
767
+ <WrappedEffectA />
768
+ <WrappedEffectB />
769
+ <primitive object={userPass} />
770
+ </EffectComposer>
771
+ )
772
+ )
773
+ await flush()
774
+
775
+ expect(composer.passes).toContain(userPass)
776
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
777
+ expect(userPass.effects).toEqual([userEffect])
778
+
779
+ await React.act(async () => root.render(null))
780
+ })
781
+
782
+ it('disposes the final EffectPass wrapper\'s material on full unmount too (composer.dispose has nothing left to dispose by then)', async () => {
783
+ const ref = React.createRef<EffectComposerImpl>()
784
+
785
+ await React.act(async () => root.render(<EffectComposer ref={ref}><WrappedEffectA /></EffectComposer>))
786
+ const composer = await waitForComposer(ref)
787
+ await waitForEffects(ref, 1)
788
+
789
+ const pass = composer.passes.find((p) => p instanceof EffectPass) as EffectPass
790
+ const materialDisposeSpy = vi.spyOn(pass.fullscreenMaterial, 'dispose')
791
+
792
+ await React.act(async () => root.render(null))
793
+
794
+ expect(materialDisposeSpy).toHaveBeenCalled()
795
+
796
+ materialDisposeSpy.mockRestore()
797
+ })
798
+
383
799
  it('disposes exactly as many composers as it constructs, across repeated prop changes', async () => {
384
800
  const ref = React.createRef<EffectComposerImpl>()
385
801
  const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose')
@@ -405,6 +821,42 @@ describe('EffectComposer', () => {
405
821
  disposeSpy.mockRestore()
406
822
  })
407
823
 
824
+ it('does not dispose a still-in-use effect when a composer-level prop (multisampling) recreates the composer', async () => {
825
+ const ref = React.createRef<EffectComposerImpl>()
826
+ const effectRef = React.createRef<EffectA>()
827
+
828
+ await React.act(async () =>
829
+ root.render(
830
+ <EffectComposer ref={ref} multisampling={0}>
831
+ <WrappedEffectA ref={effectRef} />
832
+ </EffectComposer>
833
+ )
834
+ )
835
+ const firstComposer = await waitForComposer(ref)
836
+ await waitForEffects(ref, 1)
837
+ const effect = effectRef.current
838
+ expect(effect).toBeTruthy()
839
+
840
+ const effectDisposeSpy = vi.spyOn(EffectA.prototype, 'dispose')
841
+
842
+ await React.act(async () =>
843
+ root.render(
844
+ <EffectComposer ref={ref} multisampling={4}>
845
+ <WrappedEffectA ref={effectRef} />
846
+ </EffectComposer>
847
+ )
848
+ )
849
+ const secondComposer = await waitForNewComposer(ref, firstComposer)
850
+ await flush()
851
+
852
+ expect(secondComposer).not.toBe(firstComposer)
853
+ expect(effectRef.current).toBe(effect)
854
+ expect(effectDisposeSpy).not.toHaveBeenCalled()
855
+ expect(secondComposer.passes.some((p) => p instanceof EffectPass)).toBe(true)
856
+
857
+ effectDisposeSpy.mockRestore()
858
+ })
859
+
408
860
  it('disposes a hand-constructed effect exactly once on unmount', async () => {
409
861
  const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose')
410
862
  const ref = React.createRef<EffectComposerImpl>()
@@ -425,7 +877,7 @@ describe('EffectComposer', () => {
425
877
  disposeSpy.mockRestore()
426
878
  })
427
879
 
428
- it('disposes exactly as many ColorAverage instances as it constructs, across repeated prop changes', async () => {
880
+ it('keeps a single ColorAverage instance across repeated blendFunction changes and disposes it exactly once (blendFunction is live, not construction-only)', async () => {
429
881
  const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose')
430
882
  const ref = React.createRef<ColorAverageEffect>()
431
883
  const seenInstances = new Set<ColorAverageEffect>()
@@ -446,15 +898,16 @@ describe('EffectComposer', () => {
446
898
 
447
899
  await React.act(async () => root.render(null))
448
900
 
449
- expect(seenInstances.size).toBe(cycles)
450
- expect(disposeSpy).toHaveBeenCalledTimes(cycles)
901
+ expect(seenInstances.size).toBe(1)
902
+ expect(disposeSpy).toHaveBeenCalledTimes(1)
451
903
  } finally {
452
904
  disposeSpy.mockRestore()
453
905
  }
454
906
  })
455
907
 
456
- it('never disposes the same ColorAverage instance twice, even in StrictMode', async () => {
908
+ it('disposes every ColorAverage instance seen, even across StrictMode\'s mount/cleanup/mount cycle', async () => {
457
909
  const disposedNodes: ColorAverageEffect[] = []
910
+ const seenInstances = new Set<ColorAverageEffect>()
458
911
  const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose').mockImplementation(function (
459
912
  this: ColorAverageEffect
460
913
  ) {
@@ -474,11 +927,17 @@ describe('EffectComposer', () => {
474
927
  )
475
928
  )
476
929
  await flush()
930
+ if (ref.current) seenInstances.add(ref.current)
477
931
  }
478
932
  await React.act(async () => root.render(null))
479
933
 
480
- const uniqueDisposed = new Set(disposedNodes)
481
- expect(uniqueDisposed.size).toBe(disposedNodes.length)
934
+ // dispose() is idempotent (just event-firing / shallow property
935
+ // disposal, no internal state), so StrictMode calling it more than
936
+ // once per instance is fine - this only checks nothing leaked.
937
+ const disposedSet = new Set(disposedNodes)
938
+ for (const instance of seenInstances) {
939
+ expect(disposedSet.has(instance)).toBe(true)
940
+ }
482
941
  } finally {
483
942
  disposeSpy.mockRestore()
484
943
  }
@@ -883,7 +1342,7 @@ describe('EffectComposer', () => {
883
1342
  })
884
1343
 
885
1344
  describe('performance characteristics (documented, not enforced)', () => {
886
- it('rebuilds the EffectPass once per registration when mounting many effects at once', async () => {
1345
+ it('rebuilds the EffectPass at most twice when mounting many effects at once', async () => {
887
1346
  const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass')
888
1347
 
889
1348
  const ref = React.createRef<EffectComposerImpl>()
@@ -903,9 +1362,43 @@ describe('EffectComposer', () => {
903
1362
 
904
1363
  const effectPassAddCalls = addPassSpy.mock.calls.filter(([pass]) => pass instanceof EffectPass).length
905
1364
 
906
- expect(effectPassAddCalls).toBe(1)
1365
+ // The node-list change detector and the pass-building effect settle
1366
+ // over two synchronous layout-effect passes on first mount (detect
1367
+ // change -> bump a version -> rebuild once more) - a one-time cost,
1368
+ // not a per-render one. See the "does not rebuild on unrelated
1369
+ // re-renders" test below for the actual guarantee this trades for.
1370
+ expect(effectPassAddCalls).toBeLessThanOrEqual(2)
1371
+
1372
+ addPassSpy.mockRestore()
1373
+ })
1374
+
1375
+ it('does not rebuild the EffectPass (or re-run EffectPass.initialize) on unrelated re-renders', async () => {
1376
+ const ref = React.createRef<EffectComposerImpl>()
1377
+
1378
+ const render = (tick: number) =>
1379
+ root.render(
1380
+ <EffectComposer ref={ref}>
1381
+ <WrappedEffectA />
1382
+ <group name={`tick-${tick}`} />
1383
+ </EffectComposer>
1384
+ )
1385
+
1386
+ await React.act(async () => render(0))
1387
+ await waitForEffects(ref, 1)
1388
+
1389
+ const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass')
1390
+ const initializeSpy = vi.spyOn(EffectPass.prototype, 'initialize')
1391
+
1392
+ for (let t = 1; t <= 5; t++) {
1393
+ await React.act(async () => render(t))
1394
+ await flush()
1395
+ }
1396
+
1397
+ expect(addPassSpy).not.toHaveBeenCalled()
1398
+ expect(initializeSpy).not.toHaveBeenCalled()
907
1399
 
908
1400
  addPassSpy.mockRestore()
1401
+ initializeSpy.mockRestore()
909
1402
  })
910
1403
  })
911
1404
  })