@react-three/postprocessing 3.0.5 → 3.1.0

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 +4 -3
  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
@@ -0,0 +1,337 @@
1
+ import { CopyPass, Effect, EffectComposer as EffectComposerImpl, EffectPass, RenderPass } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { EffectComposer } from '../EffectComposer'
5
+ import { EffectGroup } from '../EffectGroup'
6
+ import { wrapEffect } from '../wrapEffect'
7
+ import { EFFECT_SHADER, flush, root, strict, waitForComposer } from './test-utils'
8
+
9
+ class EffectA extends Effect {
10
+ constructor() {
11
+ super('EffectA', EFFECT_SHADER)
12
+ }
13
+ }
14
+
15
+ class EffectB extends Effect {
16
+ constructor() {
17
+ super('EffectB', EFFECT_SHADER)
18
+ }
19
+ }
20
+
21
+ class EffectC extends Effect {
22
+ constructor() {
23
+ super('EffectC', EFFECT_SHADER)
24
+ }
25
+ }
26
+
27
+ const WrappedEffectA = wrapEffect(EffectA)
28
+ const WrappedEffectB = wrapEffect(EffectB)
29
+ const WrappedEffectC = wrapEffect(EffectC)
30
+
31
+ // EffectGroup's pass appears in composer.passes only after its own
32
+ // collection-walk effect (child) and EffectComposer's own tree-walk +
33
+ // rebuild effects (parent) have all settled - can take more than one flush.
34
+ const waitUntil = async (predicate: () => boolean): Promise<void> => {
35
+ for (let i = 0; i < 50; i++) {
36
+ await flush()
37
+ if (predicate()) return
38
+ }
39
+ throw new Error('Condition never became true')
40
+ }
41
+
42
+ const waitForGroupPass = async (composer: EffectComposerImpl): Promise<EffectPass> => {
43
+ await waitUntil(() => composer.passes.some((p) => p instanceof EffectPass))
44
+ return composer.passes.find((p): p is EffectPass => p instanceof EffectPass)!
45
+ }
46
+
47
+ afterEach(async () => {
48
+ await React.act(async () => {
49
+ root.render(null)
50
+ })
51
+ })
52
+
53
+ describe('EffectGroup', () => {
54
+ it('groups its children into exactly one EffectPass, in JSX order', async () => {
55
+ const composerRef = React.createRef<EffectComposerImpl>()
56
+
57
+ await React.act(async () =>
58
+ root.render(
59
+ <EffectComposer ref={composerRef}>
60
+ <WrappedEffectA />
61
+ <EffectGroup>
62
+ <WrappedEffectB />
63
+ <WrappedEffectC />
64
+ </EffectGroup>
65
+ </EffectComposer>
66
+ )
67
+ )
68
+ const composer = await waitForComposer(composerRef)
69
+ await waitUntil(() => composer.passes.filter((p) => p instanceof EffectPass).length === 2)
70
+
71
+ const effectPasses = composer.passes.filter((p): p is EffectPass => p instanceof EffectPass)
72
+ expect(effectPasses).toHaveLength(2)
73
+
74
+ const groupPass = effectPasses.find((p) => (p as unknown as { effects: Effect[] }).effects.length === 2)!
75
+ expect(groupPass).toBeDefined()
76
+ const groupedEffects = (groupPass as unknown as { effects: Effect[] }).effects
77
+ expect(groupedEffects[0]).toBeInstanceOf(EffectB)
78
+ expect(groupedEffects[1]).toBeInstanceOf(EffectC)
79
+ })
80
+
81
+ it('keeps sibling effects out of the group and preserves relative pass order', async () => {
82
+ const composerRef = React.createRef<EffectComposerImpl>()
83
+
84
+ await React.act(async () =>
85
+ root.render(
86
+ <EffectComposer ref={composerRef}>
87
+ <WrappedEffectA />
88
+ <EffectGroup>
89
+ <WrappedEffectB />
90
+ </EffectGroup>
91
+ <WrappedEffectC />
92
+ </EffectComposer>
93
+ )
94
+ )
95
+ const composer = await waitForComposer(composerRef)
96
+ await waitUntil(() => composer.passes.filter((p) => p instanceof EffectPass).length === 3)
97
+
98
+ // A and C are not adjacent (the group's pass sits between them), so
99
+ // they must NOT get merged into one EffectPass with each other.
100
+ expect(composer.passes[0]).toBeInstanceOf(RenderPass)
101
+
102
+ const effectPasses = composer.passes.filter((p): p is EffectPass => p instanceof EffectPass)
103
+ expect(effectPasses).toHaveLength(3)
104
+
105
+ const effectsOf = (p: EffectPass) => (p as unknown as { effects: Effect[] }).effects
106
+ expect(effectsOf(effectPasses[0])).toEqual([expect.any(EffectA)])
107
+ expect(effectsOf(effectPasses[1])).toEqual([expect.any(EffectB)])
108
+ expect(effectsOf(effectPasses[2])).toEqual([expect.any(EffectC)])
109
+ })
110
+
111
+ it('toggles enabled without reconstructing the pass', async () => {
112
+ const composerRef = React.createRef<EffectComposerImpl>()
113
+ const groupRef = React.createRef<EffectPass>()
114
+
115
+ const render = (enabled: boolean) =>
116
+ root.render(
117
+ <EffectComposer ref={composerRef}>
118
+ <EffectGroup ref={groupRef} enabled={enabled}>
119
+ <WrappedEffectA />
120
+ </EffectGroup>
121
+ </EffectComposer>
122
+ )
123
+
124
+ await React.act(async () => render(true))
125
+ await waitForComposer(composerRef)
126
+ await waitUntil(() => groupRef.current !== null)
127
+
128
+ const pass = groupRef.current
129
+ expect(pass).not.toBeNull()
130
+ expect(pass!.enabled).toBe(true)
131
+
132
+ await React.act(async () => render(false))
133
+ await flush()
134
+
135
+ expect(groupRef.current).toBe(pass)
136
+ expect(pass!.enabled).toBe(false)
137
+ })
138
+
139
+ it('keeps something rendering to the screen when the only effect is disabled, via a trailing CopyPass', async () => {
140
+ const composerRef = React.createRef<EffectComposerImpl>()
141
+ const groupRef = React.createRef<EffectPass>()
142
+
143
+ const render = (enabled: boolean) =>
144
+ root.render(
145
+ <EffectComposer ref={composerRef}>
146
+ <EffectGroup ref={groupRef} enabled={enabled}>
147
+ <WrappedEffectA />
148
+ </EffectGroup>
149
+ </EffectComposer>
150
+ )
151
+
152
+ await React.act(async () => render(true))
153
+ const composer = await waitForComposer(composerRef)
154
+ await waitUntil(() => groupRef.current !== null)
155
+
156
+ // Exactly one currently-enabled pass must own renderToScreen at all
157
+ // times - otherwise nothing (enabled) ever reaches the canvas.
158
+ const enabledRenderToScreenCount = () => composer.passes.filter((p) => p.enabled && p.renderToScreen).length
159
+
160
+ // Enabled: the group's own EffectPass runs; the trailing CopyPass
161
+ // (always enabled, always structurally last) owns renderToScreen.
162
+ expect(groupRef.current!.enabled).toBe(true)
163
+ expect(groupRef.current!.renderToScreen).toBe(false)
164
+ expect(enabledRenderToScreenCount()).toBe(1)
165
+
166
+ await React.act(async () => render(false))
167
+ await flush()
168
+
169
+ // Disabled: the render loop skips the EffectPass entirely, but the
170
+ // CopyPass - unaffected, still enabled, still owning renderToScreen -
171
+ // blits whatever buffer is already there through to the screen.
172
+ expect(groupRef.current!.enabled).toBe(false)
173
+ expect(enabledRenderToScreenCount()).toBe(1)
174
+ })
175
+
176
+ it('shares a single trailing CopyPass across multiple EffectGroups, not one per group', async () => {
177
+ const composerRef = React.createRef<EffectComposerImpl>()
178
+
179
+ await React.act(async () =>
180
+ root.render(
181
+ <EffectComposer ref={composerRef}>
182
+ <EffectGroup>
183
+ <WrappedEffectA />
184
+ </EffectGroup>
185
+ <EffectGroup>
186
+ <WrappedEffectB />
187
+ </EffectGroup>
188
+ </EffectComposer>
189
+ )
190
+ )
191
+ const composer = await waitForComposer(composerRef)
192
+ await waitUntil(() => composer.passes.filter((p) => p instanceof EffectPass).length === 2)
193
+
194
+ const copyPasses = composer.passes.filter((p) => p instanceof CopyPass)
195
+ expect(copyPasses).toHaveLength(1)
196
+ // Structurally last, so it - not either group's own pass - owns renderToScreen.
197
+ expect(composer.passes[composer.passes.length - 1]).toBe(copyPasses[0])
198
+ expect(copyPasses[0].renderToScreen).toBe(true)
199
+ })
200
+
201
+ it('adds no CopyPass at all when no EffectGroup is present', async () => {
202
+ const composerRef = React.createRef<EffectComposerImpl>()
203
+
204
+ await React.act(async () =>
205
+ root.render(
206
+ <EffectComposer ref={composerRef}>
207
+ <WrappedEffectA />
208
+ </EffectComposer>
209
+ )
210
+ )
211
+ const composer = await waitForComposer(composerRef)
212
+ await waitUntil(() => composer.passes.filter((p) => p instanceof EffectPass).length === 1)
213
+
214
+ expect(composer.passes.some((p) => p instanceof CopyPass)).toBe(false)
215
+ })
216
+
217
+ it('exposes the underlying EffectPass via ref', async () => {
218
+ const composerRef = React.createRef<EffectComposerImpl>()
219
+ const groupRef = React.createRef<EffectPass>()
220
+
221
+ await React.act(async () =>
222
+ root.render(
223
+ <EffectComposer ref={composerRef}>
224
+ <EffectGroup ref={groupRef}>
225
+ <WrappedEffectA />
226
+ </EffectGroup>
227
+ </EffectComposer>
228
+ )
229
+ )
230
+ const composer = await waitForComposer(composerRef)
231
+ await waitForGroupPass(composer)
232
+
233
+ expect(groupRef.current).toBeInstanceOf(EffectPass)
234
+ expect(composer.passes).toContain(groupRef.current)
235
+ })
236
+
237
+ it('disposes its pass on unmount without double-disposing the grouped effects', async () => {
238
+ const composerRef = React.createRef<EffectComposerImpl>()
239
+ const effectDisposeSpy = vi.spyOn(EffectB.prototype, 'dispose')
240
+
241
+ await React.act(async () =>
242
+ root.render(
243
+ <EffectComposer ref={composerRef}>
244
+ <EffectGroup>
245
+ <WrappedEffectB />
246
+ </EffectGroup>
247
+ </EffectComposer>
248
+ )
249
+ )
250
+ const composer = await waitForComposer(composerRef)
251
+ const groupPass = await waitForGroupPass(composer)
252
+ expect(groupPass).toBeDefined()
253
+
254
+ await React.act(async () => root.render(null))
255
+
256
+ expect(effectDisposeSpy).toHaveBeenCalledTimes(1)
257
+
258
+ effectDisposeSpy.mockRestore()
259
+ })
260
+
261
+ it('rebuilds the pass (new identity) when the effect list inside the group changes, mirroring buildPasses', async () => {
262
+ const composerRef = React.createRef<EffectComposerImpl>()
263
+ const groupRef = React.createRef<EffectPass>()
264
+
265
+ const render = (withSecond: boolean) =>
266
+ root.render(
267
+ <EffectComposer ref={composerRef}>
268
+ <EffectGroup ref={groupRef}>
269
+ <WrappedEffectA />
270
+ {withSecond && <WrappedEffectB />}
271
+ </EffectGroup>
272
+ </EffectComposer>
273
+ )
274
+
275
+ await React.act(async () => render(false))
276
+ await waitForComposer(composerRef)
277
+ await waitUntil(() => groupRef.current !== null)
278
+
279
+ const firstPass = groupRef.current
280
+ expect((firstPass as unknown as { effects: Effect[] }).effects).toHaveLength(1)
281
+
282
+ await React.act(async () => render(true))
283
+ await waitUntil(() => groupRef.current !== firstPass)
284
+
285
+ expect(groupRef.current).not.toBe(firstPass)
286
+ expect((groupRef.current as unknown as { effects: Effect[] }).effects).toHaveLength(2)
287
+ })
288
+
289
+ it('initializes every effect in a rebuilt group, including ones added at runtime', async () => {
290
+ const composerRef = React.createRef<EffectComposerImpl>()
291
+ const groupRef = React.createRef<EffectPass>()
292
+ const initializeSpy = vi.spyOn(EffectB.prototype, 'initialize')
293
+
294
+ const render = (withSecond: boolean) =>
295
+ root.render(
296
+ <EffectComposer ref={composerRef}>
297
+ <EffectGroup ref={groupRef}>
298
+ <WrappedEffectA />
299
+ {withSecond && <WrappedEffectB />}
300
+ </EffectGroup>
301
+ </EffectComposer>
302
+ )
303
+
304
+ await React.act(async () => render(false))
305
+ await waitForComposer(composerRef)
306
+ await waitUntil(() => groupRef.current !== null)
307
+
308
+ expect(initializeSpy).not.toHaveBeenCalled()
309
+
310
+ await React.act(async () => render(true))
311
+ await waitUntil(() => (groupRef.current as unknown as { effects: Effect[] }).effects.length === 2)
312
+
313
+ expect(initializeSpy).toHaveBeenCalledTimes(1)
314
+
315
+ initializeSpy.mockRestore()
316
+ })
317
+
318
+ it('ends up with exactly one EffectPass in StrictMode, not a stray extra from double-invoked state updates', async () => {
319
+ const composerRef = React.createRef<EffectComposerImpl>()
320
+
321
+ await React.act(async () =>
322
+ root.render(
323
+ strict(
324
+ <EffectComposer ref={composerRef}>
325
+ <EffectGroup>
326
+ <WrappedEffectA />
327
+ </EffectGroup>
328
+ </EffectComposer>
329
+ )
330
+ )
331
+ )
332
+ const composer = await waitForComposer(composerRef)
333
+ await waitUntil(() => composer.passes.some((p) => p instanceof EffectPass))
334
+
335
+ expect(composer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1)
336
+ })
337
+ })
@@ -0,0 +1,56 @@
1
+ import { EffectComposer as EffectComposerImpl, GlitchEffect, GlitchMode } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { EffectComposer } from '../EffectComposer'
5
+ import { Glitch } from '../effects/Glitch'
6
+ import { flush, root } from './test-utils'
7
+
8
+ describe('Glitch', () => {
9
+ it('toggles active/mode live, without reconstructing the effect', async () => {
10
+ const composerRef = React.createRef<EffectComposerImpl>()
11
+ const ref = React.createRef<GlitchEffect>()
12
+
13
+ const render = (active: boolean) =>
14
+ root.render(
15
+ <EffectComposer ref={composerRef}>
16
+ <Glitch ref={ref} active={active} />
17
+ </EffectComposer>
18
+ )
19
+
20
+ await React.act(async () => render(true))
21
+ await flush()
22
+ const first = ref.current
23
+ expect(first!.mode).toBe(GlitchMode.SPORADIC)
24
+
25
+ await React.act(async () => render(false))
26
+ await flush()
27
+
28
+ expect(ref.current).toBe(first)
29
+ expect(ref.current!.mode).toBe(GlitchMode.DISABLED)
30
+
31
+ await React.act(async () => root.render(null))
32
+ })
33
+
34
+ it('reconstructs when dtSize (construction-only) changes', async () => {
35
+ const composerRef = React.createRef<EffectComposerImpl>()
36
+ const ref = React.createRef<GlitchEffect>()
37
+
38
+ const render = (dtSize: number) =>
39
+ root.render(
40
+ <EffectComposer ref={composerRef}>
41
+ <Glitch ref={ref} dtSize={dtSize} />
42
+ </EffectComposer>
43
+ )
44
+
45
+ await React.act(async () => render(64))
46
+ await flush()
47
+ const first = ref.current
48
+
49
+ await React.act(async () => render(128))
50
+ await flush()
51
+
52
+ expect(ref.current).not.toBe(first)
53
+
54
+ await React.act(async () => root.render(null))
55
+ })
56
+ })
@@ -0,0 +1,101 @@
1
+ import { EffectComposer as EffectComposerImpl, GodRaysEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Mesh, SphereGeometry } from 'three'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { GodRays } from '../effects/GodRays'
7
+ import { flush, root, waitForComposer } from './test-utils'
8
+
9
+ describe('GodRays', () => {
10
+ it('applies density live, without reconstructing the effect', async () => {
11
+ const composerRef = React.createRef<EffectComposerImpl>()
12
+ const ref = React.createRef<GodRaysEffect>()
13
+ const sun = new Mesh(new SphereGeometry(1, 8, 8))
14
+
15
+ const render = (density: number) =>
16
+ root.render(
17
+ <EffectComposer ref={composerRef}>
18
+ <primitive object={sun} />
19
+ <GodRays ref={ref} sun={sun} density={density} />
20
+ </EffectComposer>
21
+ )
22
+
23
+ await React.act(async () => render(0.9))
24
+ await waitForComposer(composerRef)
25
+ await flush()
26
+ const first = ref.current
27
+ expect(first!.godRaysMaterial.density).toBeCloseTo(0.9)
28
+
29
+ await React.act(async () => render(0.5))
30
+ await flush()
31
+
32
+ expect(ref.current).toBe(first)
33
+ expect(ref.current!.godRaysMaterial.density).toBeCloseTo(0.5)
34
+
35
+ await React.act(async () => root.render(null))
36
+ })
37
+
38
+ it('still reconstructs when resolutionScale (construction-only) changes', async () => {
39
+ const composerRef = React.createRef<EffectComposerImpl>()
40
+ const ref = React.createRef<GodRaysEffect>()
41
+ const sun = new Mesh(new SphereGeometry(1, 8, 8))
42
+
43
+ const render = (resolutionScale: number) =>
44
+ root.render(
45
+ <EffectComposer ref={composerRef}>
46
+ <primitive object={sun} />
47
+ <GodRays ref={ref} sun={sun} resolutionScale={resolutionScale} />
48
+ </EffectComposer>
49
+ )
50
+
51
+ await React.act(async () => render(0.5))
52
+ await waitForComposer(composerRef)
53
+ await flush()
54
+ const first = ref.current
55
+
56
+ await React.act(async () => render(1))
57
+ await flush()
58
+
59
+ expect(ref.current).not.toBe(first)
60
+
61
+ await React.act(async () => root.render(null))
62
+ })
63
+
64
+ it('invalidates when sun is swapped for a different mesh, so frameloop="demand" repaints', async () => {
65
+ const composerRef = React.createRef<EffectComposerImpl>()
66
+ const ref = React.createRef<GodRaysEffect>()
67
+ const sunA = new Mesh(new SphereGeometry(1, 8, 8))
68
+ const sunB = new Mesh(new SphereGeometry(1, 8, 8))
69
+
70
+ // Both meshes are mounted unconditionally throughout - only the `sun`
71
+ // prop GodRays points at changes, so the only invalidate() candidate is
72
+ // GodRays.tsx's own effect.lightSource assignment, not r3f's native
73
+ // handling of a <primitive object={...}> swap (a real prop change it
74
+ // already invalidates for on its own, which a naive test could
75
+ // mistake for this effect's own behavior).
76
+ const render = (sun: Mesh) =>
77
+ root.render(
78
+ <EffectComposer ref={composerRef}>
79
+ <primitive object={sunA} />
80
+ <primitive object={sunB} />
81
+ <GodRays ref={ref} sun={sun} />
82
+ </EffectComposer>
83
+ )
84
+
85
+ await React.act(async () => render(sunA))
86
+ await waitForComposer(composerRef)
87
+ await flush()
88
+ expect(ref.current!.lightSource).toBe(sunA)
89
+
90
+ const invalidateSpy = vi.spyOn(root.render(null).getState(), 'invalidate')
91
+
92
+ await React.act(async () => render(sunB))
93
+ await flush()
94
+
95
+ expect(ref.current!.lightSource).toBe(sunB)
96
+ expect(invalidateSpy).toHaveBeenCalled()
97
+
98
+ invalidateSpy.mockRestore()
99
+ await React.act(async () => root.render(null))
100
+ })
101
+ })
@@ -0,0 +1,34 @@
1
+ import { EffectComposer as EffectComposerImpl, GridEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { EffectComposer } from '../EffectComposer'
5
+ import { Grid } from '../effects/Grid'
6
+ import { flush, root } from './test-utils'
7
+
8
+ describe('Grid', () => {
9
+ it('applies scale/lineWidth live, without reconstructing the effect', async () => {
10
+ const composerRef = React.createRef<EffectComposerImpl>()
11
+ const ref = React.createRef<GridEffect>()
12
+
13
+ const render = (scale: number) =>
14
+ root.render(
15
+ <EffectComposer ref={composerRef}>
16
+ <Grid ref={ref} scale={scale} lineWidth={0.1} />
17
+ </EffectComposer>
18
+ )
19
+
20
+ await React.act(async () => render(1))
21
+ await flush()
22
+ const first = ref.current
23
+ expect(first!.scale).toBe(1)
24
+ expect(first!.lineWidth).toBeCloseTo(0.1)
25
+
26
+ await React.act(async () => render(2))
27
+ await flush()
28
+
29
+ expect(ref.current).toBe(first)
30
+ expect(ref.current!.scale).toBe(2)
31
+
32
+ await React.act(async () => root.render(null))
33
+ })
34
+ })
@@ -0,0 +1,64 @@
1
+ import { EffectComposer as EffectComposerImpl, LUT3DEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { DataTexture } from 'three'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { LUT } from '../effects/LUT'
7
+ import { flush, root, waitForComposer } from './test-utils'
8
+
9
+ describe('LUT', () => {
10
+ it('applies tetrahedralInterpolation live, without reconstructing the effect', async () => {
11
+ const composerRef = React.createRef<EffectComposerImpl>()
12
+ const ref = React.createRef<LUT3DEffect>()
13
+ const lut = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
14
+
15
+ const render = (tetrahedralInterpolation: boolean) =>
16
+ root.render(
17
+ <EffectComposer ref={composerRef}>
18
+ <LUT ref={ref} lut={lut} tetrahedralInterpolation={tetrahedralInterpolation} />
19
+ </EffectComposer>
20
+ )
21
+
22
+ await React.act(async () => render(false))
23
+ await waitForComposer(composerRef)
24
+ await flush()
25
+ const first = ref.current
26
+ expect(first!.tetrahedralInterpolation).toBe(false)
27
+
28
+ await React.act(async () => render(true))
29
+ await flush()
30
+
31
+ expect(ref.current).toBe(first)
32
+ expect(ref.current!.tetrahedralInterpolation).toBe(true)
33
+
34
+ await React.act(async () => root.render(null))
35
+ })
36
+
37
+ it('applies a new lut live via its own setter, without reconstructing the effect', async () => {
38
+ const composerRef = React.createRef<EffectComposerImpl>()
39
+ const ref = React.createRef<LUT3DEffect>()
40
+ const lutA = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
41
+ const lutB = new DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
42
+
43
+ const render = (lut: DataTexture) =>
44
+ root.render(
45
+ <EffectComposer ref={composerRef}>
46
+ <LUT ref={ref} lut={lut} />
47
+ </EffectComposer>
48
+ )
49
+
50
+ await React.act(async () => render(lutA))
51
+ await waitForComposer(composerRef)
52
+ await flush()
53
+ const first = ref.current
54
+ expect(first!.lut).toBe(lutA)
55
+
56
+ await React.act(async () => render(lutB))
57
+ await flush()
58
+
59
+ expect(ref.current).toBe(first)
60
+ expect(ref.current!.lut).toBe(lutB)
61
+
62
+ await React.act(async () => root.render(null))
63
+ })
64
+ })
@@ -0,0 +1,92 @@
1
+ import { CopyPass, EffectComposer as EffectComposerImpl } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { EffectComposer } from '../EffectComposer'
5
+ import { N8AO } from '../passes/N8AO'
6
+ import { flush, root, waitForComposer } from './test-utils'
7
+
8
+ describe('N8AO', () => {
9
+ it('invalidates after a live config change and after a quality change, so frameloop="demand" repaints', async () => {
10
+ const composerRef = React.createRef<EffectComposerImpl>()
11
+ const invalidateSpy = vi.spyOn(root.render(null).getState(), 'invalidate')
12
+
13
+ const render = (intensity: number, quality?: 'performance' | 'ultra') =>
14
+ root.render(
15
+ <EffectComposer ref={composerRef}>
16
+ <N8AO intensity={intensity} quality={quality} />
17
+ </EffectComposer>
18
+ )
19
+
20
+ await React.act(async () => render(1))
21
+ await flush()
22
+ invalidateSpy.mockClear()
23
+
24
+ await React.act(async () => render(2))
25
+ await flush()
26
+ expect(invalidateSpy).toHaveBeenCalled()
27
+
28
+ invalidateSpy.mockClear()
29
+
30
+ await React.act(async () => render(2, 'ultra'))
31
+ await flush()
32
+ expect(invalidateSpy).toHaveBeenCalled()
33
+
34
+ invalidateSpy.mockRestore()
35
+ await React.act(async () => root.render(null))
36
+ })
37
+
38
+ it('toggles enabled without reconstructing the pass', async () => {
39
+ const ref = React.createRef<any>()
40
+
41
+ const render = (enabled: boolean) =>
42
+ root.render(
43
+ <EffectComposer>
44
+ <N8AO ref={ref} enabled={enabled} />
45
+ </EffectComposer>
46
+ )
47
+
48
+ await React.act(async () => render(true))
49
+ await flush()
50
+ const instance = ref.current
51
+ expect(instance.enabled).toBe(true)
52
+
53
+ await React.act(async () => render(false))
54
+ await flush()
55
+ expect(ref.current).toBe(instance)
56
+ expect(instance.enabled).toBe(false)
57
+
58
+ await React.act(async () => root.render(null))
59
+ })
60
+
61
+ it('gets the shared trailing CopyPass safety net, keeping something rendering to screen when disabled', async () => {
62
+ const composerRef = React.createRef<EffectComposerImpl>()
63
+ const ref = React.createRef<any>()
64
+
65
+ const render = (enabled: boolean) =>
66
+ root.render(
67
+ <EffectComposer ref={composerRef}>
68
+ <N8AO ref={ref} enabled={enabled} />
69
+ </EffectComposer>
70
+ )
71
+
72
+ await React.act(async () => render(true))
73
+ const composer = await waitForComposer(composerRef)
74
+ await flush()
75
+
76
+ const copyPass = composer.passes.find((p) => p instanceof CopyPass)
77
+ expect(copyPass).toBeDefined()
78
+
79
+ const enabledRenderToScreenCount = () => composer.passes.filter((p) => p.enabled && p.renderToScreen).length
80
+ expect(enabledRenderToScreenCount()).toBe(1)
81
+
82
+ await React.act(async () => render(false))
83
+ await flush()
84
+
85
+ // N8AO's own pass is now disabled, but exactly one enabled pass must
86
+ // still own renderToScreen - otherwise nothing reaches the canvas.
87
+ expect(enabledRenderToScreenCount()).toBe(1)
88
+ expect(copyPass!.renderToScreen).toBe(true)
89
+
90
+ await React.act(async () => root.render(null))
91
+ })
92
+ })