@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,238 @@
|
|
|
1
|
+
// Generic smoke coverage for every effect component: mounts inside a real
|
|
2
|
+
// EffectComposer with the minimum props each one actually requires, confirms
|
|
3
|
+
// mounting/unmounting doesn't throw, and that dispose() gets called exactly
|
|
4
|
+
// once per instance on unmount (for both <primitive>-based effects, via our
|
|
5
|
+
// useDispose hook, and wrapEffect-based ones, via r3f's own auto-dispose).
|
|
6
|
+
//
|
|
7
|
+
// This catches constructor/prop-application/dispose-time crashes — it does
|
|
8
|
+
// NOT verify visual/shader correctness. The test environment's WebGL context
|
|
9
|
+
// is a Proxy of no-ops (see test-utils.tsx), so nothing actually renders;
|
|
10
|
+
// effects still need manual/visual verification before release.
|
|
11
|
+
//
|
|
12
|
+
// Coverage is enforced by the last test in this file: every *.tsx file in
|
|
13
|
+
// src/effects or src/passes must appear either in SMOKE_CASES or EXCLUDED
|
|
14
|
+
// below. Adding a new effect/pass file without touching either list fails CI.
|
|
15
|
+
//
|
|
16
|
+
// This file is excluded from `tsc -p tsconfig.json` (it matches
|
|
17
|
+
// src/**/*.test.*), so editors fall back to a detached/inferred compilation
|
|
18
|
+
// context for it that doesn't automatically pick up @types/node — hence the
|
|
19
|
+
// explicit reference below for the node: imports used by the coverage check.
|
|
20
|
+
|
|
21
|
+
/// <reference types="node" />
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { DepthPickingPass as DepthPickingPassImpl, EffectComposer as EffectComposerImpl } from 'postprocessing'
|
|
27
|
+
import * as React from 'react'
|
|
28
|
+
import * as THREE from 'three'
|
|
29
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
30
|
+
import { EffectComposer } from '../EffectComposer'
|
|
31
|
+
import { Autofocus } from '../effects/Autofocus'
|
|
32
|
+
import { Bloom } from '../effects/Bloom'
|
|
33
|
+
import { BrightnessContrast } from '../effects/BrightnessContrast'
|
|
34
|
+
import { ChromaticAberration } from '../effects/ChromaticAberration'
|
|
35
|
+
import { ColorAverage } from '../effects/ColorAverage'
|
|
36
|
+
import { ColorDepth } from '../effects/ColorDepth'
|
|
37
|
+
import { Depth } from '../effects/Depth'
|
|
38
|
+
import { DepthOfField } from '../effects/DepthOfField'
|
|
39
|
+
import { DotScreen } from '../effects/DotScreen'
|
|
40
|
+
import { FXAA } from '../effects/FXAA'
|
|
41
|
+
import { Glitch } from '../effects/Glitch'
|
|
42
|
+
import { GodRays } from '../effects/GodRays'
|
|
43
|
+
import { Grid } from '../effects/Grid'
|
|
44
|
+
import { HueSaturation } from '../effects/HueSaturation'
|
|
45
|
+
import { LensFlare } from '../effects/LensFlare'
|
|
46
|
+
import { LUT } from '../effects/LUT'
|
|
47
|
+
import { Noise } from '../effects/Noise'
|
|
48
|
+
import { Outline } from '../effects/Outline'
|
|
49
|
+
import { Pixelation } from '../effects/Pixelation'
|
|
50
|
+
import { Ramp } from '../effects/Ramp'
|
|
51
|
+
import { Scanline } from '../effects/ScanlineEffect'
|
|
52
|
+
import { SelectiveBloom } from '../effects/SelectiveBloom'
|
|
53
|
+
import { Sepia } from '../effects/Sepia'
|
|
54
|
+
import { ShockWave } from '../effects/ShockWave'
|
|
55
|
+
import { SMAA } from '../effects/SMAA'
|
|
56
|
+
import { SSAO } from '../effects/SSAO'
|
|
57
|
+
import { TiltShift } from '../effects/TiltShift'
|
|
58
|
+
import { TiltShift2 } from '../effects/TiltShift2'
|
|
59
|
+
import { ToneMapping } from '../effects/ToneMapping'
|
|
60
|
+
import { Vignette } from '../effects/Vignette'
|
|
61
|
+
import { WaterEffect } from '../effects/Water'
|
|
62
|
+
import { DepthPicking } from '../passes/DepthPicking'
|
|
63
|
+
import { N8AO } from '../passes/N8AO'
|
|
64
|
+
import { flush, root } from './test-utils'
|
|
65
|
+
|
|
66
|
+
type SmokeCase = {
|
|
67
|
+
/** Filename under src/effects or src/passes this case covers — drives the coverage check below. */
|
|
68
|
+
file: string
|
|
69
|
+
label: string
|
|
70
|
+
composerProps?: Record<string, unknown>
|
|
71
|
+
/** Extra scene content the effect needs (e.g. a sun mesh for GodRays). */
|
|
72
|
+
extras?: React.ReactNode
|
|
73
|
+
/** Renders the effect element. */
|
|
74
|
+
effect: (ref: React.Ref<any>) => React.ReactElement
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 8))
|
|
78
|
+
const lutTexture = new THREE.DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
|
|
79
|
+
|
|
80
|
+
const SMOKE_CASES: SmokeCase[] = [
|
|
81
|
+
{ file: 'Autofocus.tsx', label: 'Autofocus', effect: (ref) => <Autofocus ref={ref} /> },
|
|
82
|
+
{ file: 'Bloom.tsx', label: 'Bloom', effect: (ref) => <Bloom ref={ref} /> },
|
|
83
|
+
{ file: 'BrightnessContrast.tsx', label: 'BrightnessContrast', effect: (ref) => <BrightnessContrast ref={ref} /> },
|
|
84
|
+
{ file: 'ChromaticAberration.tsx', label: 'ChromaticAberration', effect: (ref) => <ChromaticAberration ref={ref} /> },
|
|
85
|
+
{ file: 'ColorAverage.tsx', label: 'ColorAverage', effect: (ref) => <ColorAverage ref={ref} /> },
|
|
86
|
+
{ file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => <ColorDepth ref={ref} /> },
|
|
87
|
+
{ file: 'Depth.tsx', label: 'Depth', effect: (ref) => <Depth ref={ref} /> },
|
|
88
|
+
{ file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => <DepthOfField ref={ref} /> },
|
|
89
|
+
{ file: 'DepthPicking.tsx', label: 'DepthPicking', effect: (ref) => <DepthPicking ref={ref} /> },
|
|
90
|
+
{ file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => <DotScreen ref={ref} /> },
|
|
91
|
+
{ file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => <FXAA ref={ref} /> },
|
|
92
|
+
{ file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => <Glitch ref={ref} /> },
|
|
93
|
+
{
|
|
94
|
+
file: 'GodRays.tsx',
|
|
95
|
+
label: 'GodRays',
|
|
96
|
+
extras: <primitive object={sunMesh} />,
|
|
97
|
+
effect: (ref) => <GodRays ref={ref} sun={sunMesh} />,
|
|
98
|
+
},
|
|
99
|
+
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
|
|
100
|
+
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
|
|
101
|
+
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => <LensFlare ref={ref} /> },
|
|
102
|
+
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
|
|
103
|
+
{ file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
|
|
104
|
+
{ file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
|
|
105
|
+
{ file: 'Outline.tsx', label: 'Outline', effect: (ref) => <Outline ref={ref} /> },
|
|
106
|
+
{ file: 'Pixelation.tsx', label: 'Pixelation', effect: (ref) => <Pixelation ref={ref} /> },
|
|
107
|
+
{ file: 'Ramp.tsx', label: 'Ramp', effect: (ref) => <Ramp ref={ref} /> },
|
|
108
|
+
{ file: 'ScanlineEffect.tsx', label: 'Scanline', effect: (ref) => <Scanline ref={ref} /> },
|
|
109
|
+
{
|
|
110
|
+
file: 'SelectiveBloom.tsx',
|
|
111
|
+
label: 'SelectiveBloom',
|
|
112
|
+
effect: (ref) => <SelectiveBloom ref={ref} lights={[]} />,
|
|
113
|
+
},
|
|
114
|
+
{ file: 'Sepia.tsx', label: 'Sepia', effect: (ref) => <Sepia ref={ref} /> },
|
|
115
|
+
{ file: 'ShockWave.tsx', label: 'ShockWave', effect: (ref) => <ShockWave ref={ref} /> },
|
|
116
|
+
{ file: 'SMAA.tsx', label: 'SMAA', effect: (ref) => <SMAA ref={ref} /> },
|
|
117
|
+
{
|
|
118
|
+
file: 'SSAO.tsx',
|
|
119
|
+
label: 'SSAO (without normal pass — sentinel path)',
|
|
120
|
+
effect: (ref) => <SSAO ref={ref} />,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
file: 'SSAO.tsx',
|
|
124
|
+
label: 'SSAO (with normal pass — real construction path)',
|
|
125
|
+
composerProps: { enableNormalPass: true },
|
|
126
|
+
effect: (ref) => <SSAO ref={ref} />,
|
|
127
|
+
},
|
|
128
|
+
{ file: 'TiltShift.tsx', label: 'TiltShift', effect: (ref) => <TiltShift ref={ref} /> },
|
|
129
|
+
{ file: 'TiltShift2.tsx', label: 'TiltShift2', effect: (ref) => <TiltShift2 ref={ref} /> },
|
|
130
|
+
{ file: 'ToneMapping.tsx', label: 'ToneMapping', effect: (ref) => <ToneMapping ref={ref} /> },
|
|
131
|
+
{ file: 'Vignette.tsx', label: 'Vignette', effect: (ref) => <Vignette ref={ref} /> },
|
|
132
|
+
{ file: 'Water.tsx', label: 'WaterEffect', effect: (ref) => <WaterEffect ref={ref} /> },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
const EXCLUDED: Record<string, string> = {
|
|
136
|
+
'Texture.tsx':
|
|
137
|
+
'loads via useLoader(TextureLoader, textureSrc) — needs a real image decode pipeline this test environment cannot provide. Verify manually.',
|
|
138
|
+
'ASCII.tsx':
|
|
139
|
+
"constructs its character atlas via document.createElement('canvas') — this project's vitest config runs the node test environment (no jsdom/document). Verify manually.",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('effect smoke tests', () => {
|
|
143
|
+
it.each(SMOKE_CASES)('$label mounts and unmounts without throwing', async ({ composerProps, extras, effect }) => {
|
|
144
|
+
const ref = React.createRef<any>()
|
|
145
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
146
|
+
|
|
147
|
+
await React.act(async () =>
|
|
148
|
+
root.render(
|
|
149
|
+
<EffectComposer ref={composerRef} {...composerProps}>
|
|
150
|
+
{extras}
|
|
151
|
+
{effect(ref)}
|
|
152
|
+
</EffectComposer>
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
await flush()
|
|
157
|
+
|
|
158
|
+
const instance = ref.current
|
|
159
|
+
const disposeSpy = instance && typeof instance.dispose === 'function' ? vi.spyOn(instance, 'dispose') : null
|
|
160
|
+
|
|
161
|
+
await React.act(async () => root.render(null))
|
|
162
|
+
await flush()
|
|
163
|
+
|
|
164
|
+
if (disposeSpy) {
|
|
165
|
+
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Autofocus's ref resolves to { dofRef, hitpoint, update }, not an effect
|
|
170
|
+
// instance - the generic dispose check above no-ops for it. It owns two
|
|
171
|
+
// disposables (the nested DepthPicking component's pass, and the
|
|
172
|
+
// nested DepthOfField effect), verified here.
|
|
173
|
+
it('Autofocus disposes the nested DepthPicking and DepthOfField effect', async () => {
|
|
174
|
+
const depthPickingDisposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose')
|
|
175
|
+
// AutofocusProps' `ref` type is broken (ComponentProps<typeof DepthOfField>
|
|
176
|
+
// drags in DepthOfField's own `ref: Ref<DepthOfFieldEffect>`, which then
|
|
177
|
+
// intersects with `Ref<AutofocusApi>` — separate pre-existing issue,
|
|
178
|
+
// not fixed here). `any` sidesteps it; the runtime shape is AutofocusApi.
|
|
179
|
+
const ref = React.createRef<any>()
|
|
180
|
+
|
|
181
|
+
await React.act(async () =>
|
|
182
|
+
root.render(
|
|
183
|
+
<EffectComposer>
|
|
184
|
+
<Autofocus ref={ref} />
|
|
185
|
+
</EffectComposer>
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
await flush()
|
|
190
|
+
|
|
191
|
+
const dofEffect = ref.current!.dofRef.current
|
|
192
|
+
expect(dofEffect).toBeTruthy()
|
|
193
|
+
const dofDisposeSpy = vi.spyOn(dofEffect!, 'dispose')
|
|
194
|
+
|
|
195
|
+
await React.act(async () => root.render(null))
|
|
196
|
+
await flush()
|
|
197
|
+
|
|
198
|
+
expect(depthPickingDisposeSpy).toHaveBeenCalled()
|
|
199
|
+
expect(dofDisposeSpy).toHaveBeenCalled()
|
|
200
|
+
|
|
201
|
+
depthPickingDisposeSpy.mockRestore()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('DepthPicking disposes its pass on unmount', async () => {
|
|
205
|
+
const disposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose')
|
|
206
|
+
const ref = React.createRef<import('../passes/DepthPicking').DepthPickingApi>()
|
|
207
|
+
|
|
208
|
+
await React.act(async () =>
|
|
209
|
+
root.render(
|
|
210
|
+
<EffectComposer>
|
|
211
|
+
<DepthPicking ref={ref} />
|
|
212
|
+
</EffectComposer>
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
await flush()
|
|
217
|
+
expect(ref.current).toBeTruthy()
|
|
218
|
+
|
|
219
|
+
await React.act(async () => root.render(null))
|
|
220
|
+
await flush()
|
|
221
|
+
|
|
222
|
+
expect(disposeSpy).toHaveBeenCalled()
|
|
223
|
+
|
|
224
|
+
disposeSpy.mockRestore()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('covers every file in src/effects and src/passes (or documents why it is excluded)', () => {
|
|
228
|
+
const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
229
|
+
const files = ['effects', 'passes'].flatMap((dir) =>
|
|
230
|
+
fs.readdirSync(path.join(srcDir, dir)).filter((f) => f.endsWith('.tsx'))
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const covered = new Set(SMOKE_CASES.map((c) => c.file))
|
|
234
|
+
const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))
|
|
235
|
+
|
|
236
|
+
expect(missing).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createRoot, extend } from '@react-three/fiber'
|
|
2
|
+
import { EffectComposer as EffectComposerImpl, EffectPass } from 'postprocessing'
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import * as THREE from 'three'
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
|
|
12
|
+
extend(THREE as any)
|
|
13
|
+
|
|
14
|
+
export const root = createRoot({
|
|
15
|
+
style: {} as CSSStyleDeclaration,
|
|
16
|
+
addEventListener: (() => {}) as any,
|
|
17
|
+
removeEventListener: (() => {}) as any,
|
|
18
|
+
width: 1280,
|
|
19
|
+
height: 800,
|
|
20
|
+
clientWidth: 1280,
|
|
21
|
+
clientHeight: 800,
|
|
22
|
+
getContext: (() =>
|
|
23
|
+
new Proxy(
|
|
24
|
+
{},
|
|
25
|
+
{
|
|
26
|
+
get(_target, prop) {
|
|
27
|
+
switch (prop) {
|
|
28
|
+
case 'getParameter':
|
|
29
|
+
return () => 'WebGL 2'
|
|
30
|
+
case 'getExtension':
|
|
31
|
+
return () => ({})
|
|
32
|
+
case 'getContextAttributes':
|
|
33
|
+
return () => ({ alpha: true })
|
|
34
|
+
case 'getShaderPrecisionFormat':
|
|
35
|
+
return () => ({ rangeMin: 1, rangeMax: 1, precision: 1 })
|
|
36
|
+
default:
|
|
37
|
+
return () => {}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
)) as any,
|
|
42
|
+
} satisfies Partial<HTMLCanvasElement> as HTMLCanvasElement)
|
|
43
|
+
|
|
44
|
+
root.configure({ frameloop: 'never' })
|
|
45
|
+
|
|
46
|
+
export const EFFECT_SHADER = `
|
|
47
|
+
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
|
48
|
+
outputColor = inputColor;
|
|
49
|
+
}
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
export const flush = async () => {
|
|
53
|
+
await React.act(async () => {
|
|
54
|
+
await Promise.resolve()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const strict = (children: React.ReactNode) => <React.StrictMode>{children}</React.StrictMode>
|
|
59
|
+
|
|
60
|
+
export const waitForComposer = async (ref: React.RefObject<EffectComposerImpl | null>): Promise<EffectComposerImpl> => {
|
|
61
|
+
for (let i = 0; i < 50; i++) {
|
|
62
|
+
await flush()
|
|
63
|
+
if (ref.current) return ref.current
|
|
64
|
+
}
|
|
65
|
+
throw new Error('Composer was not created')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const waitForNewComposer = async (
|
|
69
|
+
ref: React.RefObject<EffectComposerImpl | null>,
|
|
70
|
+
previous: EffectComposerImpl
|
|
71
|
+
): Promise<EffectComposerImpl> => {
|
|
72
|
+
for (let i = 0; i < 50; i++) {
|
|
73
|
+
await flush()
|
|
74
|
+
if (ref.current && ref.current !== previous) return ref.current
|
|
75
|
+
}
|
|
76
|
+
throw new Error('Composer was not recreated')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const waitForEffects = async (ref: React.RefObject<EffectComposerImpl | null>, count: number) => {
|
|
80
|
+
for (let i = 0; i < 50; i++) {
|
|
81
|
+
await flush()
|
|
82
|
+
|
|
83
|
+
const pass = ref.current?.passes.find(
|
|
84
|
+
(p): p is EffectPass =>
|
|
85
|
+
// @ts-expect-error - `effects` isn't part of the public Pass typing
|
|
86
|
+
p instanceof EffectPass && p.effects.length === count
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (pass) {
|
|
90
|
+
// @ts-expect-error
|
|
91
|
+
return pass.effects
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`Expected EffectPass with ${count} effects`)
|
|
96
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Effect, EffectComposer as EffectComposerImpl } from 'postprocessing'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { DataTexture, Texture } from 'three'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { EffectComposer } from '../EffectComposer'
|
|
6
|
+
import { wrapEffect } from '../wrapEffect'
|
|
7
|
+
import { flush, root } from './test-utils'
|
|
8
|
+
|
|
9
|
+
class FakeEffect extends Effect {
|
|
10
|
+
value: number
|
|
11
|
+
constructor({ value = 0 }: { value?: number } = {}) {
|
|
12
|
+
super('FakeEffect', 'mainImage() {}')
|
|
13
|
+
this.value = value
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FakeEffectComponent = /* @__PURE__ */ wrapEffect(FakeEffect)
|
|
18
|
+
|
|
19
|
+
class CircularPropsEffect extends Effect {
|
|
20
|
+
constructor(_props: Record<string, unknown> = {}) {
|
|
21
|
+
super('CircularPropsEffect', 'mainImage() {}')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CircularPropsComponent = /* @__PURE__ */ wrapEffect(CircularPropsEffect)
|
|
26
|
+
|
|
27
|
+
describe('wrapEffect', () => {
|
|
28
|
+
it('passes the constructed value through to the underlying effect instance', async () => {
|
|
29
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
30
|
+
|
|
31
|
+
await React.act(async () =>
|
|
32
|
+
root.render(
|
|
33
|
+
<EffectComposer ref={composerRef}>
|
|
34
|
+
<FakeEffectComponent value={42} />
|
|
35
|
+
</EffectComposer>
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
await flush()
|
|
40
|
+
|
|
41
|
+
// @ts-expect-error
|
|
42
|
+
const effect = composerRef.current!.passes[1].effects[0]
|
|
43
|
+
|
|
44
|
+
expect(effect).toBeInstanceOf(FakeEffect)
|
|
45
|
+
expect(effect.value).toBe(42)
|
|
46
|
+
|
|
47
|
+
await React.act(async () => root.render(null))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('registers the new instance in the same act() as the args change, not one render later', async () => {
|
|
51
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
52
|
+
|
|
53
|
+
await React.act(async () =>
|
|
54
|
+
root.render(
|
|
55
|
+
<EffectComposer ref={composerRef}>
|
|
56
|
+
<FakeEffectComponent value={1} />
|
|
57
|
+
</EffectComposer>
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
await flush()
|
|
62
|
+
|
|
63
|
+
const firstEffect =
|
|
64
|
+
// @ts-expect-error
|
|
65
|
+
composerRef.current!.passes[1].effects[0]
|
|
66
|
+
|
|
67
|
+
expect(firstEffect.value).toBe(1)
|
|
68
|
+
|
|
69
|
+
await React.act(async () =>
|
|
70
|
+
root.render(
|
|
71
|
+
<EffectComposer ref={composerRef}>
|
|
72
|
+
<FakeEffectComponent value={2} />
|
|
73
|
+
</EffectComposer>
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await flush()
|
|
78
|
+
|
|
79
|
+
const secondEffect =
|
|
80
|
+
// @ts-expect-error
|
|
81
|
+
composerRef.current!.passes[1].effects[0]
|
|
82
|
+
|
|
83
|
+
expect(secondEffect).not.toBe(firstEffect)
|
|
84
|
+
expect(secondEffect.value).toBe(2)
|
|
85
|
+
|
|
86
|
+
await React.act(async () => root.render(null))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not crash when a prop value contains a circular reference (#333, #334)', async () => {
|
|
90
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
91
|
+
const circular: Record<string, unknown> = { value: 1 }
|
|
92
|
+
circular.self = circular
|
|
93
|
+
|
|
94
|
+
await React.act(async () =>
|
|
95
|
+
root.render(
|
|
96
|
+
<EffectComposer ref={composerRef}>
|
|
97
|
+
<CircularPropsComponent extra={circular} />
|
|
98
|
+
</EffectComposer>
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await flush()
|
|
103
|
+
|
|
104
|
+
// @ts-expect-error
|
|
105
|
+
const effect = composerRef.current!.passes[1].effects[0]
|
|
106
|
+
|
|
107
|
+
expect(effect).toBeInstanceOf(CircularPropsEffect)
|
|
108
|
+
|
|
109
|
+
await React.act(async () => root.render(null))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('fingerprints a texture prop by identity, without invoking its toJSON (perf)', async () => {
|
|
113
|
+
const toJSONSpy = vi.spyOn(Texture.prototype, 'toJSON')
|
|
114
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
115
|
+
const textureA = new DataTexture(new Uint8Array(4), 1, 1)
|
|
116
|
+
|
|
117
|
+
await React.act(async () =>
|
|
118
|
+
root.render(
|
|
119
|
+
<EffectComposer ref={composerRef}>
|
|
120
|
+
<CircularPropsComponent extra={textureA} />
|
|
121
|
+
</EffectComposer>
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await flush()
|
|
126
|
+
|
|
127
|
+
const firstEffect =
|
|
128
|
+
// @ts-expect-error
|
|
129
|
+
composerRef.current!.passes[1].effects[0]
|
|
130
|
+
|
|
131
|
+
// same texture reference on re-render — should not recreate the instance
|
|
132
|
+
await React.act(async () =>
|
|
133
|
+
root.render(
|
|
134
|
+
<EffectComposer ref={composerRef}>
|
|
135
|
+
<CircularPropsComponent extra={textureA} />
|
|
136
|
+
</EffectComposer>
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
await flush()
|
|
141
|
+
|
|
142
|
+
const secondEffect =
|
|
143
|
+
// @ts-expect-error
|
|
144
|
+
composerRef.current!.passes[1].effects[0]
|
|
145
|
+
|
|
146
|
+
expect(secondEffect).toBe(firstEffect)
|
|
147
|
+
|
|
148
|
+
// a genuinely different texture instance — should recreate
|
|
149
|
+
const textureB = new DataTexture(new Uint8Array(4), 1, 1)
|
|
150
|
+
|
|
151
|
+
await React.act(async () =>
|
|
152
|
+
root.render(
|
|
153
|
+
<EffectComposer ref={composerRef}>
|
|
154
|
+
<CircularPropsComponent extra={textureB} />
|
|
155
|
+
</EffectComposer>
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await flush()
|
|
160
|
+
|
|
161
|
+
const thirdEffect =
|
|
162
|
+
// @ts-expect-error
|
|
163
|
+
composerRef.current!.passes[1].effects[0]
|
|
164
|
+
|
|
165
|
+
expect(thirdEffect).not.toBe(secondEffect)
|
|
166
|
+
|
|
167
|
+
expect(toJSONSpy).not.toHaveBeenCalled()
|
|
168
|
+
|
|
169
|
+
await React.act(async () => root.render(null))
|
|
170
|
+
toJSONSpy.mockRestore()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not crash on repeated re-renders once a ref is attached', async () => {
|
|
174
|
+
// Regression for a bug specific to the *old* wrapEffect: it never
|
|
175
|
+
// destructured `ref` out of props, so `ref` landed in `...props` and
|
|
176
|
+
// JSON.stringify(props) tried to serialize `ref.current` — the mounted
|
|
177
|
+
// effect instance itself — on every re-render after the first, which
|
|
178
|
+
// crashed the same way #333/#334 did for texture props. Current
|
|
179
|
+
// wrapEffect destructures `ref` explicitly, so it never reaches the
|
|
180
|
+
// fingerprint at all; this just locks that in.
|
|
181
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
182
|
+
const effectRef = React.createRef<FakeEffect>()
|
|
183
|
+
|
|
184
|
+
function Harness({ tick: _tick }: { tick: number }) {
|
|
185
|
+
return (
|
|
186
|
+
<EffectComposer ref={composerRef}>
|
|
187
|
+
<FakeEffectComponent ref={effectRef} value={1} />
|
|
188
|
+
</EffectComposer>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await React.act(async () => root.render(<Harness tick={0} />))
|
|
193
|
+
await flush()
|
|
194
|
+
|
|
195
|
+
expect(effectRef.current).toBeInstanceOf(FakeEffect)
|
|
196
|
+
const firstInstance = effectRef.current
|
|
197
|
+
|
|
198
|
+
for (let tick = 1; tick <= 5; tick++) {
|
|
199
|
+
await React.act(async () => root.render(<Harness tick={tick} />))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await flush()
|
|
203
|
+
|
|
204
|
+
expect(effectRef.current).toBe(firstInstance)
|
|
205
|
+
expect(effectRef.current).toBeInstanceOf(FakeEffect)
|
|
206
|
+
|
|
207
|
+
await React.act(async () => root.render(null))
|
|
208
|
+
})
|
|
209
|
+
})
|