@react-three/postprocessing 3.0.4 → 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.
- package/LICENSE +21 -21
- package/README.md +80 -81
- package/dist/EffectComposer.d.ts +14 -6
- package/dist/EffectGroup.d.ts +9 -0
- package/dist/Selection.d.ts +8 -8
- package/dist/createEffectComponent.d.ts +30 -0
- package/dist/effects/ASCII.d.ts +29 -7
- package/dist/effects/Autofocus.d.ts +8 -7
- package/dist/effects/Bloom.d.ts +10 -5
- package/dist/effects/BrightnessContrast.d.ts +15 -5
- package/dist/effects/ChromaticAberration.d.ts +30 -7
- package/dist/effects/ColorAverage.d.ts +7 -8
- package/dist/effects/ColorDepth.d.ts +8 -5
- package/dist/effects/Depth.d.ts +13 -5
- package/dist/effects/DepthOfField.d.ts +7 -18
- package/dist/effects/DotScreen.d.ts +15 -5
- package/dist/effects/FXAA.d.ts +11 -5
- package/dist/effects/Glitch.d.ts +17 -29
- package/dist/effects/GodRays.d.ts +7 -19
- package/dist/effects/Grid.d.ts +8 -8
- package/dist/effects/HueSaturation.d.ts +15 -5
- package/dist/effects/LUT.d.ts +4 -3
- package/dist/effects/LensFlare.d.ts +52 -11
- package/dist/effects/Noise.d.ts +8 -5
- package/dist/effects/Outline.d.ts +9 -25
- package/dist/effects/Pixelation.d.ts +3 -2
- package/dist/effects/Ramp.d.ts +58 -10
- package/dist/effects/SMAA.d.ts +10 -5
- package/dist/effects/SSAO.d.ts +7 -30
- package/dist/effects/ScanlineEffect.d.ts +13 -5
- package/dist/effects/SelectiveBloom.d.ts +5 -10
- package/dist/effects/Sepia.d.ts +13 -5
- package/dist/effects/ShockWave.d.ts +12 -5
- package/dist/effects/Texture.d.ts +7 -8
- package/dist/effects/TiltShift.d.ts +10 -5
- package/dist/effects/TiltShift2.d.ts +44 -8
- package/dist/effects/ToneMapping.d.ts +9 -7
- package/dist/effects/Vignette.d.ts +19 -5
- package/dist/effects/Water.d.ts +17 -5
- package/dist/index.d.ts +15 -11
- package/dist/index.js +1841 -45
- package/dist/index.js.map +1 -1
- package/dist/passes/DepthPicking.d.ts +11 -0
- package/dist/{effects → passes}/N8AO.d.ts +5 -2
- package/dist/util.d.ts +19 -17
- package/dist/wrapEffect.d.ts +12 -0
- package/package.json +74 -67
- package/src/EffectComposer.tsx +379 -200
- package/src/EffectGroup.tsx +74 -0
- package/src/Selection.tsx +86 -46
- package/src/createEffectComponent.tsx +71 -0
- package/src/effects/ASCII.tsx +183 -135
- package/src/effects/Autofocus.tsx +122 -153
- package/src/effects/Bloom.tsx +34 -6
- package/src/effects/BrightnessContrast.tsx +7 -4
- package/src/effects/ChromaticAberration.tsx +22 -5
- package/src/effects/ColorAverage.tsx +4 -15
- package/src/effects/ColorDepth.tsx +25 -4
- package/src/effects/Depth.tsx +6 -4
- package/src/effects/DepthOfField.tsx +109 -89
- package/src/effects/DotScreen.tsx +7 -4
- package/src/effects/FXAA.tsx +6 -4
- package/src/effects/Glitch.tsx +32 -40
- package/src/effects/GodRays.tsx +104 -16
- package/src/effects/Grid.tsx +27 -21
- package/src/effects/HueSaturation.tsx +7 -4
- package/src/effects/LUT.tsx +25 -26
- package/src/effects/LensFlare.tsx +765 -611
- package/src/effects/Noise.tsx +16 -4
- package/src/effects/Outline.tsx +84 -117
- package/src/effects/Pixelation.tsx +20 -15
- package/src/effects/Ramp.tsx +239 -150
- package/src/effects/SMAA.tsx +20 -4
- package/src/effects/SSAO.tsx +152 -41
- package/src/effects/ScanlineEffect.tsx +7 -7
- package/src/effects/SelectiveBloom.tsx +98 -126
- package/src/effects/Sepia.tsx +6 -4
- package/src/effects/ShockWave.tsx +32 -4
- package/src/effects/Texture.tsx +28 -23
- package/src/effects/TiltShift.tsx +32 -4
- package/src/effects/TiltShift2.tsx +156 -90
- package/src/effects/ToneMapping.tsx +19 -6
- package/src/effects/Vignette.tsx +7 -4
- package/src/effects/Water.tsx +50 -35
- package/src/index.ts +43 -40
- package/src/passes/DepthPicking.tsx +63 -0
- package/src/passes/N8AO.tsx +105 -0
- package/src/tests/Autofocus.test.tsx +38 -0
- package/src/tests/Bloom.test.tsx +76 -0
- package/src/tests/ChromaticAberration.test.tsx +81 -0
- package/src/tests/ColorDepth.test.tsx +71 -0
- package/src/tests/DepthOfField.test.tsx +130 -0
- package/src/tests/DepthPicking.test.tsx +250 -0
- package/src/tests/EffectComposer.test.tsx +1404 -0
- package/src/tests/EffectGroup.test.tsx +337 -0
- package/src/tests/Glitch.test.tsx +56 -0
- package/src/tests/GodRays.test.tsx +101 -0
- package/src/tests/Grid.test.tsx +34 -0
- package/src/tests/LUT.test.tsx +64 -0
- package/src/tests/N8AO.test.tsx +92 -0
- package/src/tests/Outline.test.tsx +211 -0
- package/src/tests/SSAO.test.tsx +140 -0
- package/src/tests/Selection.test.tsx +324 -0
- package/src/tests/SelectiveBloom.test.tsx +166 -0
- package/src/tests/ShockWave.test.tsx +95 -0
- package/src/tests/TiltShift.test.tsx +76 -0
- package/src/tests/createEffectComponent.test.tsx +286 -0
- package/src/tests/effects.smoke.test.tsx +238 -0
- package/src/tests/test-utils.tsx +96 -0
- package/src/tests/wrapEffect.test.tsx +209 -0
- package/src/util.tsx +221 -55
- package/src/wrapEffect.tsx +123 -0
- package/src/EffectComposer.test.tsx +0 -126
- package/src/effects/N8AO.tsx +0 -81
|
@@ -0,0 +1,1404 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlendFunction,
|
|
3
|
+
ColorAverageEffect,
|
|
4
|
+
DepthDownsamplingPass,
|
|
5
|
+
Effect,
|
|
6
|
+
EffectAttribute,
|
|
7
|
+
EffectComposer as EffectComposerImpl,
|
|
8
|
+
EffectPass,
|
|
9
|
+
NormalPass,
|
|
10
|
+
RenderPass,
|
|
11
|
+
} from 'postprocessing'
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import * as THREE from 'three'
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
import { EffectComposer } from '../EffectComposer'
|
|
16
|
+
import { ColorAverage } from '../effects/ColorAverage'
|
|
17
|
+
import { wrapEffect } from '../wrapEffect'
|
|
18
|
+
import { EFFECT_SHADER, flush, root, strict, waitForComposer, waitForEffects, waitForNewComposer } from './test-utils'
|
|
19
|
+
|
|
20
|
+
class EffectA extends Effect {
|
|
21
|
+
constructor() {
|
|
22
|
+
super('EffectA', EFFECT_SHADER)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class EffectB extends Effect {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('EffectB', EFFECT_SHADER)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class EffectC extends Effect {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('EffectC', EFFECT_SHADER)
|
|
35
|
+
}
|
|
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
|
+
|
|
50
|
+
const WrappedEffectA = wrapEffect(EffectA)
|
|
51
|
+
const WrappedEffectB = wrapEffect(EffectB)
|
|
52
|
+
const WrappedEffectC = wrapEffect(EffectC)
|
|
53
|
+
const WrappedConvolutionEffect = wrapEffect(ConvolutionEffect)
|
|
54
|
+
const WrappedConvolutionEffectTwo = wrapEffect(ConvolutionEffectTwo)
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await React.act(async () => {
|
|
58
|
+
root.render(null)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('EffectComposer', () => {
|
|
63
|
+
describe('registration and composition', () => {
|
|
64
|
+
it('merges wrapped effects into a single EffectPass', async () => {
|
|
65
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
66
|
+
|
|
67
|
+
await React.act(async () =>
|
|
68
|
+
root.render(
|
|
69
|
+
<EffectComposer ref={ref}>
|
|
70
|
+
<WrappedEffectA />
|
|
71
|
+
<WrappedEffectB />
|
|
72
|
+
<WrappedEffectC />
|
|
73
|
+
</EffectComposer>
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const composer = await waitForComposer(ref)
|
|
78
|
+
|
|
79
|
+
expect(composer.passes).toHaveLength(2)
|
|
80
|
+
expect(composer.passes[0]).toBeInstanceOf(RenderPass)
|
|
81
|
+
expect(composer.passes[1]).toBeInstanceOf(EffectPass)
|
|
82
|
+
|
|
83
|
+
const effects = await waitForEffects(ref, 3)
|
|
84
|
+
|
|
85
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
86
|
+
expect(effects[1]).toBeInstanceOf(EffectB)
|
|
87
|
+
expect(effects[2]).toBeInstanceOf(EffectC)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('preserves registration order on initial mount', async () => {
|
|
91
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
92
|
+
|
|
93
|
+
await React.act(async () =>
|
|
94
|
+
root.render(
|
|
95
|
+
<EffectComposer ref={ref}>
|
|
96
|
+
<WrappedEffectC />
|
|
97
|
+
<WrappedEffectA />
|
|
98
|
+
<WrappedEffectB />
|
|
99
|
+
<ColorAverage />
|
|
100
|
+
</EffectComposer>
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const effects = await waitForEffects(ref, 4)
|
|
105
|
+
|
|
106
|
+
expect(effects[0]).toBeInstanceOf(EffectC)
|
|
107
|
+
expect(effects[1]).toBeInstanceOf(EffectA)
|
|
108
|
+
expect(effects[2]).toBeInstanceOf(EffectB)
|
|
109
|
+
expect(effects[3]).toBeInstanceOf(ColorAverageEffect)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('keeps JSX registration order after the child list changes', async () => {
|
|
113
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
114
|
+
|
|
115
|
+
await React.act(async () =>
|
|
116
|
+
root.render(
|
|
117
|
+
<EffectComposer ref={ref}>
|
|
118
|
+
<WrappedEffectC />
|
|
119
|
+
<WrappedEffectA />
|
|
120
|
+
</EffectComposer>
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
let effects = await waitForEffects(ref, 2)
|
|
125
|
+
|
|
126
|
+
expect(effects[0]).toBeInstanceOf(EffectC)
|
|
127
|
+
expect(effects[1]).toBeInstanceOf(EffectA)
|
|
128
|
+
|
|
129
|
+
await React.act(async () =>
|
|
130
|
+
root.render(
|
|
131
|
+
<EffectComposer ref={ref}>
|
|
132
|
+
<WrappedEffectB />
|
|
133
|
+
<WrappedEffectC />
|
|
134
|
+
</EffectComposer>
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
effects = await waitForEffects(ref, 2)
|
|
139
|
+
|
|
140
|
+
expect(effects[0]).toBeInstanceOf(EffectB)
|
|
141
|
+
expect(effects[1]).toBeInstanceOf(EffectC)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reorders effects when children swap positions in JSX, identity preserved via key', async () => {
|
|
145
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
146
|
+
|
|
147
|
+
await React.act(async () =>
|
|
148
|
+
root.render(
|
|
149
|
+
<EffectComposer ref={ref}>
|
|
150
|
+
<WrappedEffectA key="a" />
|
|
151
|
+
<WrappedEffectB key="b" />
|
|
152
|
+
</EffectComposer>
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
let effects = await waitForEffects(ref, 2)
|
|
157
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
158
|
+
expect(effects[1]).toBeInstanceOf(EffectB)
|
|
159
|
+
|
|
160
|
+
const [firstA, firstB] = effects
|
|
161
|
+
|
|
162
|
+
await React.act(async () =>
|
|
163
|
+
root.render(
|
|
164
|
+
<EffectComposer ref={ref}>
|
|
165
|
+
<WrappedEffectB key="b" />
|
|
166
|
+
<WrappedEffectA key="a" />
|
|
167
|
+
</EffectComposer>
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
effects = await waitForEffects(ref, 2)
|
|
172
|
+
|
|
173
|
+
expect(effects[0]).toBeInstanceOf(EffectB)
|
|
174
|
+
expect(effects[1]).toBeInstanceOf(EffectA)
|
|
175
|
+
// same underlying instances, just reordered — not a remount
|
|
176
|
+
expect(effects[0]).toBe(firstB)
|
|
177
|
+
expect(effects[1]).toBe(firstA)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('keeps an effect in its slot when its instance is recreated via a prop-driven arg change', async () => {
|
|
181
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
182
|
+
|
|
183
|
+
class MiddleEffect extends Effect {
|
|
184
|
+
value: number
|
|
185
|
+
constructor({ value = 0 }: { value?: number } = {}) {
|
|
186
|
+
super('MiddleEffect', EFFECT_SHADER)
|
|
187
|
+
this.value = value
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const WrappedMiddle = wrapEffect(MiddleEffect)
|
|
191
|
+
|
|
192
|
+
await React.act(async () =>
|
|
193
|
+
root.render(
|
|
194
|
+
<EffectComposer ref={ref}>
|
|
195
|
+
<WrappedEffectA />
|
|
196
|
+
<WrappedMiddle value={1} />
|
|
197
|
+
<WrappedEffectC />
|
|
198
|
+
</EffectComposer>
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
let effects = await waitForEffects(ref, 3)
|
|
203
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
204
|
+
expect(effects[1]).toBeInstanceOf(MiddleEffect)
|
|
205
|
+
expect(effects[2]).toBeInstanceOf(EffectC)
|
|
206
|
+
|
|
207
|
+
const firstMiddle = effects[1]
|
|
208
|
+
|
|
209
|
+
await React.act(async () =>
|
|
210
|
+
root.render(
|
|
211
|
+
<EffectComposer ref={ref}>
|
|
212
|
+
<WrappedEffectA />
|
|
213
|
+
<WrappedMiddle value={2} />
|
|
214
|
+
<WrappedEffectC />
|
|
215
|
+
</EffectComposer>
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
effects = await waitForEffects(ref, 3)
|
|
220
|
+
|
|
221
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
222
|
+
expect(effects[1]).toBeInstanceOf(MiddleEffect)
|
|
223
|
+
expect(effects[1]).not.toBe(firstMiddle) // recreated, new instance
|
|
224
|
+
expect((effects[1] as InstanceType<typeof MiddleEffect>).value).toBe(2)
|
|
225
|
+
expect(effects[2]).toBeInstanceOf(EffectC)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('removes only the unmounted effect', async () => {
|
|
229
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
230
|
+
|
|
231
|
+
await React.act(async () =>
|
|
232
|
+
root.render(
|
|
233
|
+
<EffectComposer ref={ref}>
|
|
234
|
+
<WrappedEffectA />
|
|
235
|
+
<WrappedEffectB />
|
|
236
|
+
</EffectComposer>
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
await waitForEffects(ref, 2)
|
|
241
|
+
|
|
242
|
+
await React.act(async () =>
|
|
243
|
+
root.render(
|
|
244
|
+
<EffectComposer ref={ref}>
|
|
245
|
+
<WrappedEffectB />
|
|
246
|
+
</EffectComposer>
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const effects = await waitForEffects(ref, 1)
|
|
251
|
+
|
|
252
|
+
expect(effects[0]).toBeInstanceOf(EffectB)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('accepts conditionally-rendered children via `condition && <Effect/>` without a type cast', async () => {
|
|
256
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
257
|
+
|
|
258
|
+
// Boxed behind a function so TS can't literal-narrow `show` to `false`
|
|
259
|
+
// at the call site below — that would make `show && <WrappedEffectA/>`
|
|
260
|
+
// type as just `false` instead of `boolean | Element`, silently
|
|
261
|
+
// defeating the point of this test (a real regression to the old,
|
|
262
|
+
// too-narrow `children: JSX.Element | JSX.Element[]` type wouldn't
|
|
263
|
+
// get caught by tsc).
|
|
264
|
+
const shouldShow = (value: boolean): boolean => value
|
|
265
|
+
|
|
266
|
+
await React.act(async () =>
|
|
267
|
+
root.render(<EffectComposer ref={ref}>{shouldShow(false) && <WrappedEffectA />}</EffectComposer>)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await flush()
|
|
271
|
+
|
|
272
|
+
expect(ref.current!.passes.filter((p) => p instanceof EffectPass)).toHaveLength(0)
|
|
273
|
+
|
|
274
|
+
await React.act(async () =>
|
|
275
|
+
root.render(<EffectComposer ref={ref}>{shouldShow(true) && <WrappedEffectA />}</EffectComposer>)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const effects = await waitForEffects(ref, 1)
|
|
279
|
+
|
|
280
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('recreates the composer when the camera changes and keeps effects', async () => {
|
|
284
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
285
|
+
|
|
286
|
+
const cameraA = new THREE.PerspectiveCamera()
|
|
287
|
+
const cameraB = new THREE.PerspectiveCamera()
|
|
288
|
+
|
|
289
|
+
await React.act(async () =>
|
|
290
|
+
root.render(
|
|
291
|
+
<EffectComposer ref={ref} camera={cameraA}>
|
|
292
|
+
<WrappedEffectA />
|
|
293
|
+
</EffectComposer>
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const first = await waitForComposer(ref)
|
|
298
|
+
|
|
299
|
+
await React.act(async () =>
|
|
300
|
+
root.render(
|
|
301
|
+
<EffectComposer ref={ref} camera={cameraB}>
|
|
302
|
+
<WrappedEffectA />
|
|
303
|
+
</EffectComposer>
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const second = await waitForComposer(ref)
|
|
308
|
+
|
|
309
|
+
expect(second).not.toBe(first)
|
|
310
|
+
|
|
311
|
+
const effects = await waitForEffects(ref, 1)
|
|
312
|
+
|
|
313
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
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
|
+
|
|
596
|
+
describe('cleanup and disposal', () => {
|
|
597
|
+
it('unregisters effects and clears the ref on full unmount', async () => {
|
|
598
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
599
|
+
|
|
600
|
+
await React.act(async () =>
|
|
601
|
+
root.render(
|
|
602
|
+
<EffectComposer ref={ref}>
|
|
603
|
+
<WrappedEffectA />
|
|
604
|
+
</EffectComposer>
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
await waitForComposer(ref)
|
|
609
|
+
expect(ref.current!.passes).toHaveLength(2)
|
|
610
|
+
|
|
611
|
+
await React.act(async () => root.render(null))
|
|
612
|
+
|
|
613
|
+
expect(ref.current).toBe(null)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('does not leak composer instances in StrictMode', async () => {
|
|
617
|
+
const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose')
|
|
618
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
619
|
+
|
|
620
|
+
await React.act(async () =>
|
|
621
|
+
root.render(
|
|
622
|
+
strict(
|
|
623
|
+
<EffectComposer ref={ref}>
|
|
624
|
+
<WrappedEffectA />
|
|
625
|
+
</EffectComposer>
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
await waitForComposer(ref)
|
|
631
|
+
|
|
632
|
+
await React.act(async () => {
|
|
633
|
+
root.render(null)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
expect(disposeSpy).toHaveBeenCalled()
|
|
637
|
+
|
|
638
|
+
disposeSpy.mockRestore()
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('removes passes before disposing the composer, not after', async () => {
|
|
642
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
643
|
+
|
|
644
|
+
const removePassSpy = vi.spyOn(EffectComposerImpl.prototype, 'removePass')
|
|
645
|
+
const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose')
|
|
646
|
+
|
|
647
|
+
await React.act(async () =>
|
|
648
|
+
root.render(
|
|
649
|
+
<EffectComposer ref={ref}>
|
|
650
|
+
<WrappedEffectA />
|
|
651
|
+
<WrappedEffectB />
|
|
652
|
+
</EffectComposer>
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
await waitForEffects(ref, 2)
|
|
657
|
+
|
|
658
|
+
removePassSpy.mockClear()
|
|
659
|
+
disposeSpy.mockClear()
|
|
660
|
+
|
|
661
|
+
await React.act(async () => {
|
|
662
|
+
root.render(null)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
expect(removePassSpy).toHaveBeenCalled()
|
|
666
|
+
expect(disposeSpy).toHaveBeenCalled()
|
|
667
|
+
|
|
668
|
+
const lastRemovePassOrder = Math.max(...removePassSpy.mock.invocationCallOrder)
|
|
669
|
+
const disposeOrder = disposeSpy.mock.invocationCallOrder[0]
|
|
670
|
+
|
|
671
|
+
expect(lastRemovePassOrder).toBeLessThan(disposeOrder)
|
|
672
|
+
|
|
673
|
+
removePassSpy.mockRestore()
|
|
674
|
+
disposeSpy.mockRestore()
|
|
675
|
+
})
|
|
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
|
+
|
|
799
|
+
it('disposes exactly as many composers as it constructs, across repeated prop changes', async () => {
|
|
800
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
801
|
+
const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose')
|
|
802
|
+
const seenInstances = new Set<EffectComposerImpl>()
|
|
803
|
+
const cycles = 20
|
|
804
|
+
|
|
805
|
+
for (let i = 0; i < cycles; i++) {
|
|
806
|
+
await React.act(async () =>
|
|
807
|
+
root.render(
|
|
808
|
+
<EffectComposer ref={ref} multisampling={i % 2 === 0 ? 0 : 16}>
|
|
809
|
+
<WrappedEffectA />
|
|
810
|
+
</EffectComposer>
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
seenInstances.add(await waitForComposer(ref))
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
await React.act(async () => root.render(null))
|
|
817
|
+
|
|
818
|
+
expect(seenInstances.size).toBe(cycles)
|
|
819
|
+
expect(disposeSpy).toHaveBeenCalledTimes(cycles)
|
|
820
|
+
|
|
821
|
+
disposeSpy.mockRestore()
|
|
822
|
+
})
|
|
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
|
+
|
|
860
|
+
it('disposes a hand-constructed effect exactly once on unmount', async () => {
|
|
861
|
+
const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose')
|
|
862
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
863
|
+
|
|
864
|
+
await React.act(async () =>
|
|
865
|
+
root.render(
|
|
866
|
+
<EffectComposer ref={ref}>
|
|
867
|
+
<WrappedEffectA />
|
|
868
|
+
<ColorAverage />
|
|
869
|
+
</EffectComposer>
|
|
870
|
+
)
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
await waitForComposer(ref)
|
|
874
|
+
await React.act(async () => root.render(null))
|
|
875
|
+
|
|
876
|
+
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
|
877
|
+
disposeSpy.mockRestore()
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('keeps a single ColorAverage instance across repeated blendFunction changes and disposes it exactly once (blendFunction is live, not construction-only)', async () => {
|
|
881
|
+
const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose')
|
|
882
|
+
const ref = React.createRef<ColorAverageEffect>()
|
|
883
|
+
const seenInstances = new Set<ColorAverageEffect>()
|
|
884
|
+
const cycles = 20
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
for (let i = 0; i < cycles; i++) {
|
|
888
|
+
await React.act(async () =>
|
|
889
|
+
root.render(
|
|
890
|
+
<EffectComposer>
|
|
891
|
+
<ColorAverage ref={ref} blendFunction={i % 2 === 0 ? BlendFunction.NORMAL : BlendFunction.ADD} />
|
|
892
|
+
</EffectComposer>
|
|
893
|
+
)
|
|
894
|
+
)
|
|
895
|
+
await flush()
|
|
896
|
+
if (ref.current) seenInstances.add(ref.current)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
await React.act(async () => root.render(null))
|
|
900
|
+
|
|
901
|
+
expect(seenInstances.size).toBe(1)
|
|
902
|
+
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
|
903
|
+
} finally {
|
|
904
|
+
disposeSpy.mockRestore()
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('disposes every ColorAverage instance seen, even across StrictMode\'s mount/cleanup/mount cycle', async () => {
|
|
909
|
+
const disposedNodes: ColorAverageEffect[] = []
|
|
910
|
+
const seenInstances = new Set<ColorAverageEffect>()
|
|
911
|
+
const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose').mockImplementation(function (
|
|
912
|
+
this: ColorAverageEffect
|
|
913
|
+
) {
|
|
914
|
+
disposedNodes.push(this)
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
try {
|
|
918
|
+
const ref = React.createRef<ColorAverageEffect>()
|
|
919
|
+
for (let i = 0; i < 20; i++) {
|
|
920
|
+
await React.act(async () =>
|
|
921
|
+
root.render(
|
|
922
|
+
strict(
|
|
923
|
+
<EffectComposer>
|
|
924
|
+
<ColorAverage ref={ref} blendFunction={i % 2 === 0 ? BlendFunction.NORMAL : BlendFunction.ADD} />
|
|
925
|
+
</EffectComposer>
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
await flush()
|
|
930
|
+
if (ref.current) seenInstances.add(ref.current)
|
|
931
|
+
}
|
|
932
|
+
await React.act(async () => root.render(null))
|
|
933
|
+
|
|
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
|
+
}
|
|
941
|
+
} finally {
|
|
942
|
+
disposeSpy.mockRestore()
|
|
943
|
+
}
|
|
944
|
+
})
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
describe('renderer state restoration', () => {
|
|
948
|
+
it('restores autoClear to its previous value once the composer is fully unmounted', async () => {
|
|
949
|
+
const gl = root.render(null).getState().gl
|
|
950
|
+
gl.autoClear = true // known baseline, independent of whatever earlier tests in this file left behind
|
|
951
|
+
|
|
952
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
953
|
+
|
|
954
|
+
await React.act(async () =>
|
|
955
|
+
root.render(
|
|
956
|
+
<EffectComposer ref={ref}>
|
|
957
|
+
<WrappedEffectA />
|
|
958
|
+
</EffectComposer>
|
|
959
|
+
)
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
await waitForComposer(ref)
|
|
963
|
+
expect(gl.autoClear).toBe(false)
|
|
964
|
+
|
|
965
|
+
await React.act(async () => root.render(null))
|
|
966
|
+
|
|
967
|
+
expect(gl.autoClear).toBe(true)
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it('keeps autoClear disabled while a sibling composer on the same renderer is still mounted, regardless of unmount order', async () => {
|
|
971
|
+
const gl = root.render(null).getState().gl
|
|
972
|
+
gl.autoClear = true
|
|
973
|
+
|
|
974
|
+
const refA = React.createRef<EffectComposerImpl>()
|
|
975
|
+
const refB = React.createRef<EffectComposerImpl>()
|
|
976
|
+
|
|
977
|
+
await React.act(async () =>
|
|
978
|
+
root.render(
|
|
979
|
+
<>
|
|
980
|
+
<EffectComposer key="a" ref={refA}>
|
|
981
|
+
<WrappedEffectA />
|
|
982
|
+
</EffectComposer>
|
|
983
|
+
<EffectComposer key="b" ref={refB}>
|
|
984
|
+
<WrappedEffectB />
|
|
985
|
+
</EffectComposer>
|
|
986
|
+
</>
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
await waitForComposer(refA)
|
|
991
|
+
await waitForComposer(refB)
|
|
992
|
+
expect(gl.autoClear).toBe(false)
|
|
993
|
+
|
|
994
|
+
// Unmount the first composer only (key "a" drops out of the tree) -
|
|
995
|
+
// the second is still relying on autoClear staying off, so this must
|
|
996
|
+
// not restore it yet. Keying both is essential here: without it,
|
|
997
|
+
// React would match by position and reuse "a"'s instance in place
|
|
998
|
+
// (just updating its props to "b"'s), unmounting the wrong one.
|
|
999
|
+
await React.act(async () =>
|
|
1000
|
+
root.render(
|
|
1001
|
+
<EffectComposer key="b" ref={refB}>
|
|
1002
|
+
<WrappedEffectB />
|
|
1003
|
+
</EffectComposer>
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
expect(gl.autoClear).toBe(false)
|
|
1008
|
+
|
|
1009
|
+
// Now the last one goes too - only now should it actually restore.
|
|
1010
|
+
await React.act(async () => root.render(null))
|
|
1011
|
+
|
|
1012
|
+
expect(gl.autoClear).toBe(true)
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
it('restores toneMapping to its previous value once the composer is fully unmounted', async () => {
|
|
1016
|
+
const gl = root.render(null).getState().gl
|
|
1017
|
+
gl.toneMapping = THREE.ACESFilmicToneMapping // known baseline, distinct from NoToneMapping
|
|
1018
|
+
|
|
1019
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1020
|
+
|
|
1021
|
+
await React.act(async () =>
|
|
1022
|
+
root.render(
|
|
1023
|
+
<EffectComposer ref={ref}>
|
|
1024
|
+
<WrappedEffectA />
|
|
1025
|
+
</EffectComposer>
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
await waitForComposer(ref)
|
|
1030
|
+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
|
|
1031
|
+
|
|
1032
|
+
await React.act(async () => root.render(null))
|
|
1033
|
+
|
|
1034
|
+
expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping)
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
it('keeps toneMapping disabled while a sibling composer on the same renderer is still mounted, regardless of unmount order', async () => {
|
|
1038
|
+
const gl = root.render(null).getState().gl
|
|
1039
|
+
gl.toneMapping = THREE.ACESFilmicToneMapping
|
|
1040
|
+
|
|
1041
|
+
const refA = React.createRef<EffectComposerImpl>()
|
|
1042
|
+
const refB = React.createRef<EffectComposerImpl>()
|
|
1043
|
+
|
|
1044
|
+
await React.act(async () =>
|
|
1045
|
+
root.render(
|
|
1046
|
+
<>
|
|
1047
|
+
<EffectComposer key="a" ref={refA}>
|
|
1048
|
+
<WrappedEffectA />
|
|
1049
|
+
</EffectComposer>
|
|
1050
|
+
<EffectComposer key="b" ref={refB}>
|
|
1051
|
+
<WrappedEffectB />
|
|
1052
|
+
</EffectComposer>
|
|
1053
|
+
</>
|
|
1054
|
+
)
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
await waitForComposer(refA)
|
|
1058
|
+
await waitForComposer(refB)
|
|
1059
|
+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
|
|
1060
|
+
|
|
1061
|
+
// Unmount the first composer only - the second still relies on
|
|
1062
|
+
// toneMapping staying off, so this must not restore it yet.
|
|
1063
|
+
await React.act(async () =>
|
|
1064
|
+
root.render(
|
|
1065
|
+
<EffectComposer key="b" ref={refB}>
|
|
1066
|
+
<WrappedEffectB />
|
|
1067
|
+
</EffectComposer>
|
|
1068
|
+
)
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
|
|
1072
|
+
|
|
1073
|
+
// Now the last one goes too - only now should it actually restore.
|
|
1074
|
+
await React.act(async () => root.render(null))
|
|
1075
|
+
|
|
1076
|
+
expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
it('does not clobber a manual autoClear change made while the composer was mounted', async () => {
|
|
1080
|
+
const gl = root.render(null).getState().gl
|
|
1081
|
+
// Pre-mount baseline is false (not the usual true) specifically so
|
|
1082
|
+
// it differs from the manual override below - autoClear only has two
|
|
1083
|
+
// states, so this is the only way to make an unconditional restore-
|
|
1084
|
+
// to-original and a "preserve the manual change" outcome observably
|
|
1085
|
+
// different from each other.
|
|
1086
|
+
gl.autoClear = false
|
|
1087
|
+
|
|
1088
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1089
|
+
|
|
1090
|
+
await React.act(async () =>
|
|
1091
|
+
root.render(
|
|
1092
|
+
<EffectComposer ref={ref}>
|
|
1093
|
+
<WrappedEffectA />
|
|
1094
|
+
</EffectComposer>
|
|
1095
|
+
)
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
await waitForComposer(ref)
|
|
1099
|
+
expect(gl.autoClear).toBe(false)
|
|
1100
|
+
|
|
1101
|
+
// Something outside this component takes manual control while the
|
|
1102
|
+
// composer happens to still be mounted - a deliberate, more current
|
|
1103
|
+
// intent than whatever we captured before mount.
|
|
1104
|
+
gl.autoClear = true
|
|
1105
|
+
|
|
1106
|
+
await React.act(async () => root.render(null))
|
|
1107
|
+
|
|
1108
|
+
// Must stay what the manual override set it to, not get silently
|
|
1109
|
+
// reset back to the pre-mount value we originally captured (false).
|
|
1110
|
+
expect(gl.autoClear).toBe(true)
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
it('does not clobber a manual toneMapping change made while the composer was mounted', async () => {
|
|
1114
|
+
const gl = root.render(null).getState().gl
|
|
1115
|
+
gl.toneMapping = THREE.ACESFilmicToneMapping
|
|
1116
|
+
|
|
1117
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1118
|
+
|
|
1119
|
+
await React.act(async () =>
|
|
1120
|
+
root.render(
|
|
1121
|
+
<EffectComposer ref={ref}>
|
|
1122
|
+
<WrappedEffectA />
|
|
1123
|
+
</EffectComposer>
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
await waitForComposer(ref)
|
|
1128
|
+
expect(gl.toneMapping).toBe(THREE.NoToneMapping)
|
|
1129
|
+
|
|
1130
|
+
gl.toneMapping = THREE.ReinhardToneMapping
|
|
1131
|
+
|
|
1132
|
+
await React.act(async () => root.render(null))
|
|
1133
|
+
|
|
1134
|
+
expect(gl.toneMapping).toBe(THREE.ReinhardToneMapping)
|
|
1135
|
+
})
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
describe('StrictMode', () => {
|
|
1139
|
+
it('preserves effects after the StrictMode fake-unmount/remount cycle', async () => {
|
|
1140
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1141
|
+
|
|
1142
|
+
await React.act(async () =>
|
|
1143
|
+
root.render(
|
|
1144
|
+
strict(
|
|
1145
|
+
<EffectComposer ref={ref}>
|
|
1146
|
+
<WrappedEffectA />
|
|
1147
|
+
</EffectComposer>
|
|
1148
|
+
)
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
const firstComposer = await waitForComposer(ref)
|
|
1153
|
+
const effects = await waitForEffects(ref, 1)
|
|
1154
|
+
|
|
1155
|
+
expect(firstComposer).toBeTruthy()
|
|
1156
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
1157
|
+
|
|
1158
|
+
await flush()
|
|
1159
|
+
|
|
1160
|
+
const secondComposer = ref.current
|
|
1161
|
+
|
|
1162
|
+
expect(secondComposer).toBeTruthy()
|
|
1163
|
+
expect(secondComposer!.passes).toHaveLength(2)
|
|
1164
|
+
|
|
1165
|
+
const secondEffects = await waitForEffects(ref, 1)
|
|
1166
|
+
|
|
1167
|
+
expect(secondEffects[0]).toBeInstanceOf(EffectA)
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
it('never creates duplicate EffectPass instances, including when effects are reduced', async () => {
|
|
1171
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1172
|
+
|
|
1173
|
+
await React.act(async () =>
|
|
1174
|
+
root.render(
|
|
1175
|
+
strict(
|
|
1176
|
+
<EffectComposer ref={ref}>
|
|
1177
|
+
<WrappedEffectA />
|
|
1178
|
+
<WrappedEffectB />
|
|
1179
|
+
<WrappedEffectC />
|
|
1180
|
+
</EffectComposer>
|
|
1181
|
+
)
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
const composer = await waitForComposer(ref)
|
|
1186
|
+
const effects = await waitForEffects(ref, 3)
|
|
1187
|
+
|
|
1188
|
+
expect(effects[0]).toBeInstanceOf(EffectA)
|
|
1189
|
+
expect(effects[1]).toBeInstanceOf(EffectB)
|
|
1190
|
+
expect(effects[2]).toBeInstanceOf(EffectC)
|
|
1191
|
+
expect(composer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1)
|
|
1192
|
+
|
|
1193
|
+
await React.act(async () =>
|
|
1194
|
+
root.render(
|
|
1195
|
+
strict(
|
|
1196
|
+
<EffectComposer ref={ref}>
|
|
1197
|
+
<WrappedEffectA />
|
|
1198
|
+
</EffectComposer>
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
const reduced = await waitForEffects(ref, 1)
|
|
1204
|
+
|
|
1205
|
+
expect(reduced[0]).toBeInstanceOf(EffectA)
|
|
1206
|
+
expect(composer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1)
|
|
1207
|
+
|
|
1208
|
+
await React.act(async () => root.render(null))
|
|
1209
|
+
expect(ref.current).toBe(null)
|
|
1210
|
+
})
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
describe('composer recreation', () => {
|
|
1214
|
+
it('already has its EffectPass the instant the composer reference changes', async () => {
|
|
1215
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1216
|
+
|
|
1217
|
+
await React.act(async () =>
|
|
1218
|
+
root.render(
|
|
1219
|
+
<EffectComposer ref={ref} multisampling={4}>
|
|
1220
|
+
<WrappedEffectA />
|
|
1221
|
+
</EffectComposer>
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
const firstComposer = await waitForComposer(ref)
|
|
1226
|
+
await waitForEffects(ref, 1)
|
|
1227
|
+
|
|
1228
|
+
await React.act(async () =>
|
|
1229
|
+
root.render(
|
|
1230
|
+
<EffectComposer ref={ref} multisampling={8}>
|
|
1231
|
+
<WrappedEffectA />
|
|
1232
|
+
</EffectComposer>
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
const secondComposer = await waitForNewComposer(ref, firstComposer)
|
|
1237
|
+
|
|
1238
|
+
expect(secondComposer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1)
|
|
1239
|
+
// @ts-expect-error
|
|
1240
|
+
expect(secondComposer.passes.find((p) => p instanceof EffectPass)!.effects).toHaveLength(1)
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
it('preserves registration order across recreation with multiple effects', async () => {
|
|
1244
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1245
|
+
|
|
1246
|
+
await React.act(async () =>
|
|
1247
|
+
root.render(
|
|
1248
|
+
<EffectComposer ref={ref} multisampling={4}>
|
|
1249
|
+
<WrappedEffectC />
|
|
1250
|
+
<WrappedEffectA />
|
|
1251
|
+
<WrappedEffectB />
|
|
1252
|
+
</EffectComposer>
|
|
1253
|
+
)
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
const firstComposer = await waitForComposer(ref)
|
|
1257
|
+
await waitForEffects(ref, 3)
|
|
1258
|
+
|
|
1259
|
+
await React.act(async () =>
|
|
1260
|
+
root.render(
|
|
1261
|
+
<EffectComposer ref={ref} multisampling={8}>
|
|
1262
|
+
<WrappedEffectC />
|
|
1263
|
+
<WrappedEffectA />
|
|
1264
|
+
<WrappedEffectB />
|
|
1265
|
+
</EffectComposer>
|
|
1266
|
+
)
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
const secondComposer = await waitForNewComposer(ref, firstComposer)
|
|
1270
|
+
// @ts-expect-error
|
|
1271
|
+
const effects = secondComposer.passes.find((p) => p instanceof EffectPass)!.effects
|
|
1272
|
+
|
|
1273
|
+
expect(effects).toHaveLength(3)
|
|
1274
|
+
expect(effects[0]).toBeInstanceOf(EffectC)
|
|
1275
|
+
expect(effects[1]).toBeInstanceOf(EffectA)
|
|
1276
|
+
expect(effects[2]).toBeInstanceOf(EffectB)
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
it('enables normalPass/downSamplingPass synchronously on the new composer when effects are already registered', async () => {
|
|
1280
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1281
|
+
|
|
1282
|
+
await React.act(async () =>
|
|
1283
|
+
root.render(
|
|
1284
|
+
<EffectComposer ref={ref} multisampling={4} enableNormalPass resolutionScale={0.5}>
|
|
1285
|
+
<WrappedEffectA />
|
|
1286
|
+
</EffectComposer>
|
|
1287
|
+
)
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
const firstComposer = await waitForComposer(ref)
|
|
1291
|
+
await waitForEffects(ref, 1)
|
|
1292
|
+
|
|
1293
|
+
await React.act(async () =>
|
|
1294
|
+
root.render(
|
|
1295
|
+
<EffectComposer ref={ref} multisampling={8} enableNormalPass resolutionScale={0.5}>
|
|
1296
|
+
<WrappedEffectA />
|
|
1297
|
+
</EffectComposer>
|
|
1298
|
+
)
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
const secondComposer = await waitForNewComposer(ref, firstComposer)
|
|
1302
|
+
|
|
1303
|
+
const normalPass = secondComposer.passes.find((p) => p instanceof NormalPass)
|
|
1304
|
+
const downSamplingPass = secondComposer.passes.find((p) => p instanceof DepthDownsamplingPass)
|
|
1305
|
+
|
|
1306
|
+
expect(normalPass).toBeTruthy()
|
|
1307
|
+
expect(downSamplingPass).toBeTruthy()
|
|
1308
|
+
expect(normalPass!.enabled).toBe(true)
|
|
1309
|
+
expect(downSamplingPass!.enabled).toBe(true)
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
it('leaves normalPass/downSamplingPass disabled when the composer is recreated before any effect has ever registered', async () => {
|
|
1313
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1314
|
+
|
|
1315
|
+
await React.act(async () =>
|
|
1316
|
+
root.render(
|
|
1317
|
+
<EffectComposer ref={ref} multisampling={4} enableNormalPass resolutionScale={0.5}>
|
|
1318
|
+
<></>
|
|
1319
|
+
</EffectComposer>
|
|
1320
|
+
)
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
const firstComposer = await waitForComposer(ref)
|
|
1324
|
+
|
|
1325
|
+
await React.act(async () =>
|
|
1326
|
+
root.render(
|
|
1327
|
+
<EffectComposer ref={ref} multisampling={8} enableNormalPass resolutionScale={0.5}>
|
|
1328
|
+
<></>
|
|
1329
|
+
</EffectComposer>
|
|
1330
|
+
)
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
const secondComposer = await waitForNewComposer(ref, firstComposer)
|
|
1334
|
+
|
|
1335
|
+
const normalPass = secondComposer.passes.find((p) => p instanceof NormalPass)
|
|
1336
|
+
const downSamplingPass = secondComposer.passes.find((p) => p instanceof DepthDownsamplingPass)
|
|
1337
|
+
|
|
1338
|
+
expect(normalPass!.enabled).toBe(false)
|
|
1339
|
+
expect(downSamplingPass!.enabled).toBe(false)
|
|
1340
|
+
expect(secondComposer.passes.some((p) => p instanceof EffectPass)).toBe(false)
|
|
1341
|
+
})
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
describe('performance characteristics (documented, not enforced)', () => {
|
|
1345
|
+
it('rebuilds the EffectPass at most twice when mounting many effects at once', async () => {
|
|
1346
|
+
const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass')
|
|
1347
|
+
|
|
1348
|
+
const ref = React.createRef<EffectComposerImpl>()
|
|
1349
|
+
const effectCount = 44
|
|
1350
|
+
|
|
1351
|
+
await React.act(async () =>
|
|
1352
|
+
root.render(
|
|
1353
|
+
<EffectComposer ref={ref}>
|
|
1354
|
+
{Array.from({ length: effectCount }, (_, i) => (
|
|
1355
|
+
<WrappedEffectA key={i} />
|
|
1356
|
+
))}
|
|
1357
|
+
</EffectComposer>
|
|
1358
|
+
)
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
await waitForEffects(ref, effectCount)
|
|
1362
|
+
|
|
1363
|
+
const effectPassAddCalls = addPassSpy.mock.calls.filter(([pass]) => pass instanceof EffectPass).length
|
|
1364
|
+
|
|
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()
|
|
1399
|
+
|
|
1400
|
+
addPassSpy.mockRestore()
|
|
1401
|
+
initializeSpy.mockRestore()
|
|
1402
|
+
})
|
|
1403
|
+
})
|
|
1404
|
+
})
|