@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.
- package/README.md +6 -7
- package/dist/EffectComposer.d.ts +9 -2
- package/dist/EffectGroup.d.ts +9 -0
- package/dist/createEffectComponent.d.ts +30 -0
- package/dist/effects/ASCII.d.ts +28 -7
- package/dist/effects/Autofocus.d.ts +1 -1
- package/dist/effects/Bloom.d.ts +9 -1
- package/dist/effects/BrightnessContrast.d.ts +14 -1
- package/dist/effects/ChromaticAberration.d.ts +26 -3
- package/dist/effects/ColorAverage.d.ts +7 -7
- package/dist/effects/ColorDepth.d.ts +7 -1
- package/dist/effects/Depth.d.ts +12 -1
- package/dist/effects/DotScreen.d.ts +14 -1
- package/dist/effects/FXAA.d.ts +10 -1
- package/dist/effects/Glitch.d.ts +16 -11
- package/dist/effects/GodRays.d.ts +1 -1
- package/dist/effects/Grid.d.ts +7 -6
- package/dist/effects/HueSaturation.d.ts +14 -1
- package/dist/effects/LUT.d.ts +1 -1
- package/dist/effects/LensFlare.d.ts +46 -5
- package/dist/effects/Noise.d.ts +7 -1
- package/dist/effects/Outline.d.ts +6 -3
- package/dist/effects/Pixelation.d.ts +1 -1
- package/dist/effects/Ramp.d.ts +58 -6
- package/dist/effects/SMAA.d.ts +9 -1
- package/dist/effects/SSAO.d.ts +1 -1
- package/dist/effects/ScanlineEffect.d.ts +12 -1
- package/dist/effects/SelectiveBloom.d.ts +1 -1
- package/dist/effects/Sepia.d.ts +12 -1
- package/dist/effects/ShockWave.d.ts +11 -1
- package/dist/effects/Texture.d.ts +4 -4
- package/dist/effects/TiltShift.d.ts +9 -1
- package/dist/effects/TiltShift2.d.ts +44 -4
- package/dist/effects/ToneMapping.d.ts +9 -3
- package/dist/effects/Vignette.d.ts +18 -1
- package/dist/effects/Water.d.ts +17 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2205 -713
- package/dist/index.js.map +1 -1
- package/dist/passes/DepthPicking.d.ts +11 -0
- package/dist/{effects → passes}/N8AO.d.ts +2 -1
- package/dist/util.d.ts +9 -15
- package/dist/wrapEffect.d.ts +3 -3
- package/package.json +4 -3
- package/src/EffectComposer.tsx +143 -42
- package/src/EffectGroup.tsx +74 -0
- package/src/createEffectComponent.tsx +71 -0
- package/src/effects/ASCII.tsx +75 -28
- package/src/effects/Autofocus.tsx +24 -77
- package/src/effects/Bloom.tsx +32 -4
- package/src/effects/BrightnessContrast.tsx +5 -2
- package/src/effects/ChromaticAberration.tsx +13 -21
- package/src/effects/ColorAverage.tsx +3 -16
- package/src/effects/ColorDepth.tsx +23 -2
- package/src/effects/Depth.tsx +4 -2
- package/src/effects/DepthOfField.tsx +48 -26
- package/src/effects/DotScreen.tsx +5 -2
- package/src/effects/FXAA.tsx +4 -2
- package/src/effects/Glitch.tsx +26 -31
- package/src/effects/GodRays.tsx +90 -6
- package/src/effects/Grid.tsx +17 -18
- package/src/effects/HueSaturation.tsx +5 -2
- package/src/effects/LUT.tsx +9 -11
- package/src/effects/LensFlare.tsx +209 -55
- package/src/effects/Noise.tsx +14 -2
- package/src/effects/Outline.tsx +55 -56
- package/src/effects/Pixelation.tsx +11 -8
- package/src/effects/Ramp.tsx +96 -7
- package/src/effects/SMAA.tsx +18 -2
- package/src/effects/SSAO.tsx +130 -22
- package/src/effects/ScanlineEffect.tsx +6 -6
- package/src/effects/SelectiveBloom.tsx +26 -44
- package/src/effects/Sepia.tsx +4 -2
- package/src/effects/ShockWave.tsx +30 -2
- package/src/effects/Texture.tsx +10 -9
- package/src/effects/TiltShift.tsx +30 -2
- package/src/effects/TiltShift2.tsx +72 -6
- package/src/effects/ToneMapping.tsx +16 -3
- package/src/effects/Vignette.tsx +5 -2
- package/src/effects/Water.tsx +21 -6
- package/src/index.ts +4 -2
- package/src/passes/DepthPicking.tsx +63 -0
- package/src/{effects → passes}/N8AO.tsx +28 -4
- package/src/tests/Autofocus.test.tsx +38 -0
- package/src/tests/Bloom.test.tsx +76 -0
- package/src/tests/ChromaticAberration.test.tsx +50 -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 +501 -8
- 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 +122 -0
- package/src/tests/SSAO.test.tsx +140 -0
- package/src/tests/SelectiveBloom.test.tsx +48 -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 +46 -42
- package/src/util.tsx +148 -23
- package/src/wrapEffect.tsx +3 -2
- package/dist/tests/test-utils.d.ts +0 -12
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Effect, EffectComposer as EffectComposerImpl } from 'postprocessing'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { Uniform } from 'three'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { createEffectComponent } from '../createEffectComponent'
|
|
6
|
+
import { EffectComposer } from '../EffectComposer'
|
|
7
|
+
import { flush, root } from './test-utils'
|
|
8
|
+
|
|
9
|
+
// Zero-arity, real accessor - the class of effect createEffectComponent
|
|
10
|
+
// targets. Matches BloomEffect's shape: `constructor({...} = {})`.
|
|
11
|
+
class FakeEffect extends Effect {
|
|
12
|
+
private _value: number
|
|
13
|
+
constructor({ value = 0 }: { value?: number } = {}) {
|
|
14
|
+
super('FakeEffect', 'mainImage() {}')
|
|
15
|
+
this._value = value
|
|
16
|
+
}
|
|
17
|
+
get value() {
|
|
18
|
+
return this._value
|
|
19
|
+
}
|
|
20
|
+
set value(v: number) {
|
|
21
|
+
this._value = v
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FakeEffectComponent = /* @__PURE__ */ createEffectComponent<typeof FakeEffect, { value?: number }>(FakeEffect)
|
|
26
|
+
|
|
27
|
+
// Options stored only in `uniforms` (mirrors WaterEffectImpl/RampEffect
|
|
28
|
+
// before this branch gave them real accessors) - `factor` here HAS a real
|
|
29
|
+
// accessor, proving that's what makes a plain JSX prop actually reach it.
|
|
30
|
+
class UniformFixtureEffect extends Effect {
|
|
31
|
+
constructor({ factor = 0 }: { factor?: number } = {}) {
|
|
32
|
+
super('UniformFixtureEffect', 'mainImage() {}', { uniforms: new Map([['factor', new Uniform(factor)]]) })
|
|
33
|
+
}
|
|
34
|
+
get factor(): number {
|
|
35
|
+
return this.uniforms.get('factor')!.value
|
|
36
|
+
}
|
|
37
|
+
set factor(v: number) {
|
|
38
|
+
this.uniforms.get('factor')!.value = v
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const UniformFixtureComponent = /* @__PURE__ */ createEffectComponent<typeof UniformFixtureEffect, { factor?: number }>(
|
|
43
|
+
UniformFixtureEffect
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
describe('createEffectComponent', () => {
|
|
47
|
+
it('constructs the effect and passes props through to the instance', async () => {
|
|
48
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
49
|
+
|
|
50
|
+
await React.act(async () =>
|
|
51
|
+
root.render(
|
|
52
|
+
<EffectComposer ref={composerRef}>
|
|
53
|
+
<FakeEffectComponent value={42} />
|
|
54
|
+
</EffectComposer>
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
await flush()
|
|
59
|
+
|
|
60
|
+
// @ts-expect-error - `effects` isn't part of the public Pass typing
|
|
61
|
+
const effect = composerRef.current!.passes[1].effects[0]
|
|
62
|
+
|
|
63
|
+
expect(effect).toBeInstanceOf(FakeEffect)
|
|
64
|
+
expect(effect.value).toBe(42)
|
|
65
|
+
|
|
66
|
+
await React.act(async () => root.render(null))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('applies a live prop without reconstructing the instance (r3f-native, no args change)', async () => {
|
|
70
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
71
|
+
const ref = React.createRef<FakeEffect>()
|
|
72
|
+
|
|
73
|
+
const render = (value: number) =>
|
|
74
|
+
root.render(
|
|
75
|
+
<EffectComposer ref={composerRef}>
|
|
76
|
+
<FakeEffectComponent ref={ref} value={value} />
|
|
77
|
+
</EffectComposer>
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
await React.act(async () => render(1))
|
|
81
|
+
await flush()
|
|
82
|
+
const first = ref.current
|
|
83
|
+
expect(first!.value).toBe(1)
|
|
84
|
+
|
|
85
|
+
await React.act(async () => render(2))
|
|
86
|
+
await flush()
|
|
87
|
+
|
|
88
|
+
expect(ref.current).toBe(first)
|
|
89
|
+
expect(ref.current!.value).toBe(2)
|
|
90
|
+
|
|
91
|
+
await React.act(async () => root.render(null))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('resets a live prop to its constructor default when removed (r3f-native diffProps)', async () => {
|
|
95
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
96
|
+
const ref = React.createRef<FakeEffect>()
|
|
97
|
+
|
|
98
|
+
await React.act(async () =>
|
|
99
|
+
root.render(
|
|
100
|
+
<EffectComposer ref={composerRef}>
|
|
101
|
+
<FakeEffectComponent ref={ref} value={5} />
|
|
102
|
+
</EffectComposer>
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
await flush()
|
|
106
|
+
expect(ref.current!.value).toBe(5)
|
|
107
|
+
|
|
108
|
+
await React.act(async () =>
|
|
109
|
+
root.render(
|
|
110
|
+
<EffectComposer ref={composerRef}>
|
|
111
|
+
<FakeEffectComponent ref={ref} />
|
|
112
|
+
</EffectComposer>
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
await flush()
|
|
116
|
+
|
|
117
|
+
expect(ref.current!.value).toBe(0)
|
|
118
|
+
|
|
119
|
+
await React.act(async () => root.render(null))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('reconstructs when an explicit args prop changes, same as any other r3f element', async () => {
|
|
123
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
124
|
+
const ref = React.createRef<FakeEffect>()
|
|
125
|
+
|
|
126
|
+
const render = (value: number) =>
|
|
127
|
+
root.render(
|
|
128
|
+
<EffectComposer ref={composerRef}>
|
|
129
|
+
<FakeEffectComponent ref={ref} args={[{ value }]} />
|
|
130
|
+
</EffectComposer>
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
await React.act(async () => render(1))
|
|
134
|
+
await flush()
|
|
135
|
+
const first = ref.current
|
|
136
|
+
expect(first!.value).toBe(1)
|
|
137
|
+
|
|
138
|
+
await React.act(async () => render(2))
|
|
139
|
+
await flush()
|
|
140
|
+
|
|
141
|
+
expect(ref.current).not.toBe(first)
|
|
142
|
+
expect(ref.current!.value).toBe(2)
|
|
143
|
+
|
|
144
|
+
await React.act(async () => root.render(null))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('applies blendFunction/opacity through blendMode, not as a stray top-level property', async () => {
|
|
148
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
149
|
+
const ref = React.createRef<FakeEffect>()
|
|
150
|
+
|
|
151
|
+
await React.act(async () =>
|
|
152
|
+
root.render(
|
|
153
|
+
<EffectComposer ref={composerRef}>
|
|
154
|
+
<FakeEffectComponent ref={ref} blendFunction={7 as never} opacity={0.5} />
|
|
155
|
+
</EffectComposer>
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
await flush()
|
|
159
|
+
|
|
160
|
+
expect(ref.current!.blendMode.blendFunction).toBe(7)
|
|
161
|
+
expect(ref.current!.blendMode.opacity.value).toBe(0.5)
|
|
162
|
+
expect((ref.current as unknown as { blendFunction?: unknown }).blendFunction).toBeUndefined()
|
|
163
|
+
|
|
164
|
+
await React.act(async () => root.render(null))
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('resets blendFunction/opacity to blendMode\'s own defaults when the props are removed', async () => {
|
|
168
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
169
|
+
const ref = React.createRef<FakeEffect>()
|
|
170
|
+
|
|
171
|
+
await React.act(async () =>
|
|
172
|
+
root.render(
|
|
173
|
+
<EffectComposer ref={composerRef}>
|
|
174
|
+
<FakeEffectComponent ref={ref} />
|
|
175
|
+
</EffectComposer>
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
await flush()
|
|
179
|
+
const defaultBlendFunction = ref.current!.blendMode.blendFunction
|
|
180
|
+
const defaultOpacity = ref.current!.blendMode.opacity.value
|
|
181
|
+
|
|
182
|
+
await React.act(async () =>
|
|
183
|
+
root.render(
|
|
184
|
+
<EffectComposer ref={composerRef}>
|
|
185
|
+
<FakeEffectComponent ref={ref} blendFunction={7 as never} opacity={0.5} />
|
|
186
|
+
</EffectComposer>
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
await flush()
|
|
190
|
+
expect(ref.current!.blendMode.blendFunction).toBe(7)
|
|
191
|
+
expect(ref.current!.blendMode.opacity.value).toBe(0.5)
|
|
192
|
+
|
|
193
|
+
await React.act(async () =>
|
|
194
|
+
root.render(
|
|
195
|
+
<EffectComposer ref={composerRef}>
|
|
196
|
+
<FakeEffectComponent ref={ref} />
|
|
197
|
+
</EffectComposer>
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
await flush()
|
|
201
|
+
|
|
202
|
+
expect(ref.current!.blendMode.blendFunction).toBe(defaultBlendFunction)
|
|
203
|
+
expect(ref.current!.blendMode.opacity.value).toBe(defaultOpacity)
|
|
204
|
+
|
|
205
|
+
await React.act(async () => root.render(null))
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('updates a uniforms-Map-backed prop live via its accessor, without reconstructing', async () => {
|
|
209
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
210
|
+
const ref = React.createRef<UniformFixtureEffect>()
|
|
211
|
+
|
|
212
|
+
const render = (factor: number) =>
|
|
213
|
+
root.render(
|
|
214
|
+
<EffectComposer ref={composerRef}>
|
|
215
|
+
<UniformFixtureComponent ref={ref} factor={factor} />
|
|
216
|
+
</EffectComposer>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
await React.act(async () => render(1))
|
|
220
|
+
await flush()
|
|
221
|
+
const first = ref.current
|
|
222
|
+
expect(first!.uniforms.get('factor')!.value).toBe(1)
|
|
223
|
+
|
|
224
|
+
await React.act(async () => render(2))
|
|
225
|
+
await flush()
|
|
226
|
+
|
|
227
|
+
expect(ref.current).toBe(first)
|
|
228
|
+
expect(ref.current!.uniforms.get('factor')!.value).toBe(2)
|
|
229
|
+
|
|
230
|
+
await React.act(async () => root.render(null))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('disposes the instance on unmount', async () => {
|
|
234
|
+
const disposeSpy = vi.spyOn(FakeEffect.prototype, 'dispose')
|
|
235
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await React.act(async () =>
|
|
239
|
+
root.render(
|
|
240
|
+
<EffectComposer ref={composerRef}>
|
|
241
|
+
<FakeEffectComponent value={1} />
|
|
242
|
+
</EffectComposer>
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
await flush()
|
|
246
|
+
|
|
247
|
+
await React.act(async () => root.render(null))
|
|
248
|
+
|
|
249
|
+
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
|
250
|
+
} finally {
|
|
251
|
+
disposeSpy.mockRestore()
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('forwards a callback ref\'s own returned cleanup (React 19 ref cleanup), instead of dropping it', async () => {
|
|
256
|
+
const composerRef = React.createRef<EffectComposerImpl>()
|
|
257
|
+
const events: string[] = []
|
|
258
|
+
const cleanup = vi.fn(() => {
|
|
259
|
+
events.push('cleanup')
|
|
260
|
+
})
|
|
261
|
+
const callbackRef = vi.fn((instance: FakeEffect | null) => {
|
|
262
|
+
events.push(instance ? 'attach' : 'attach-null')
|
|
263
|
+
if (instance) return cleanup
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await React.act(async () =>
|
|
267
|
+
root.render(
|
|
268
|
+
<EffectComposer ref={composerRef}>
|
|
269
|
+
<FakeEffectComponent ref={callbackRef} value={1} />
|
|
270
|
+
</EffectComposer>
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
await flush()
|
|
274
|
+
|
|
275
|
+
expect(callbackRef).toHaveBeenCalledTimes(1)
|
|
276
|
+
expect(cleanup).not.toHaveBeenCalled()
|
|
277
|
+
|
|
278
|
+
await React.act(async () => root.render(null))
|
|
279
|
+
|
|
280
|
+
// React 19 ref-cleanup semantics: once a cleanup is returned, it's
|
|
281
|
+
// called directly - the callback itself is never re-invoked with null.
|
|
282
|
+
expect(cleanup).toHaveBeenCalledTimes(1)
|
|
283
|
+
expect(callbackRef).toHaveBeenCalledTimes(1)
|
|
284
|
+
expect(events).toEqual(['attach', 'cleanup'])
|
|
285
|
+
})
|
|
286
|
+
})
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// effects still need manual/visual verification before release.
|
|
11
11
|
//
|
|
12
12
|
// Coverage is enforced by the last test in this file: every *.tsx file in
|
|
13
|
-
// src/effects must appear either in SMOKE_CASES or EXCLUDED
|
|
14
|
-
// new effect file without touching either list fails CI.
|
|
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
15
|
//
|
|
16
16
|
// This file is excluded from `tsc -p tsconfig.json` (it matches
|
|
17
17
|
// src/**/*.test.*), so editors fall back to a detached/inferred compilation
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import fs from 'node:fs'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
import { fileURLToPath } from 'node:url'
|
|
26
|
-
import {
|
|
26
|
+
import { DepthPickingPass as DepthPickingPassImpl, EffectComposer as EffectComposerImpl } from 'postprocessing'
|
|
27
27
|
import * as React from 'react'
|
|
28
28
|
import * as THREE from 'three'
|
|
29
29
|
import { describe, expect, it, vi } from 'vitest'
|
|
@@ -44,7 +44,6 @@ import { Grid } from '../effects/Grid'
|
|
|
44
44
|
import { HueSaturation } from '../effects/HueSaturation'
|
|
45
45
|
import { LensFlare } from '../effects/LensFlare'
|
|
46
46
|
import { LUT } from '../effects/LUT'
|
|
47
|
-
import { N8AO } from '../effects/N8AO'
|
|
48
47
|
import { Noise } from '../effects/Noise'
|
|
49
48
|
import { Outline } from '../effects/Outline'
|
|
50
49
|
import { Pixelation } from '../effects/Pixelation'
|
|
@@ -60,16 +59,18 @@ import { TiltShift2 } from '../effects/TiltShift2'
|
|
|
60
59
|
import { ToneMapping } from '../effects/ToneMapping'
|
|
61
60
|
import { Vignette } from '../effects/Vignette'
|
|
62
61
|
import { WaterEffect } from '../effects/Water'
|
|
62
|
+
import { DepthPicking } from '../passes/DepthPicking'
|
|
63
|
+
import { N8AO } from '../passes/N8AO'
|
|
63
64
|
import { flush, root } from './test-utils'
|
|
64
65
|
|
|
65
66
|
type SmokeCase = {
|
|
66
|
-
/** Filename under src/effects this case covers — drives the coverage check below. */
|
|
67
|
+
/** Filename under src/effects or src/passes this case covers — drives the coverage check below. */
|
|
67
68
|
file: string
|
|
68
69
|
label: string
|
|
69
70
|
composerProps?: Record<string, unknown>
|
|
70
71
|
/** Extra scene content the effect needs (e.g. a sun mesh for GodRays). */
|
|
71
72
|
extras?: React.ReactNode
|
|
72
|
-
/** Renders the effect element.
|
|
73
|
+
/** Renders the effect element. */
|
|
73
74
|
effect: (ref: React.Ref<any>) => React.ReactElement
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -85,6 +86,7 @@ const SMOKE_CASES: SmokeCase[] = [
|
|
|
85
86
|
{ file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => <ColorDepth ref={ref} /> },
|
|
86
87
|
{ file: 'Depth.tsx', label: 'Depth', effect: (ref) => <Depth ref={ref} /> },
|
|
87
88
|
{ file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => <DepthOfField ref={ref} /> },
|
|
89
|
+
{ file: 'DepthPicking.tsx', label: 'DepthPicking', effect: (ref) => <DepthPicking ref={ref} /> },
|
|
88
90
|
{ file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => <DotScreen ref={ref} /> },
|
|
89
91
|
{ file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => <FXAA ref={ref} /> },
|
|
90
92
|
{ file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => <Glitch ref={ref} /> },
|
|
@@ -96,8 +98,7 @@ const SMOKE_CASES: SmokeCase[] = [
|
|
|
96
98
|
},
|
|
97
99
|
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
|
|
98
100
|
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
|
|
99
|
-
|
|
100
|
-
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: () => <LensFlare /> },
|
|
101
|
+
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => <LensFlare ref={ref} /> },
|
|
101
102
|
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
|
|
102
103
|
{ file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
|
|
103
104
|
{ file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
|
|
@@ -165,32 +166,12 @@ describe('effect smoke tests', () => {
|
|
|
165
166
|
}
|
|
166
167
|
})
|
|
167
168
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const counts = new Map<object, number>()
|
|
175
|
-
const original = Ctor.prototype.dispose
|
|
176
|
-
const spy = vi.spyOn(Ctor.prototype, 'dispose').mockImplementation(function (this: object, ...args: unknown[]) {
|
|
177
|
-
counts.set(this, (counts.get(this) ?? 0) + 1)
|
|
178
|
-
return original.apply(this, args)
|
|
179
|
-
})
|
|
180
|
-
return {
|
|
181
|
-
restore: () => spy.mockRestore(),
|
|
182
|
-
maxCallsForAnySingleInstance: () => Math.max(0, ...counts.values()),
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Autofocus's ref resolves to { dofRef, hitpoint, update } (its own
|
|
187
|
-
// imperative API), not an effect instance — the generic dispose check
|
|
188
|
-
// above silently no-ops for it. It actually owns three disposables
|
|
189
|
-
// (depthPickingPass, copyPass, and the DepthOfField effect it renders
|
|
190
|
-
// internally), verified explicitly here instead.
|
|
191
|
-
it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect exactly once each', async () => {
|
|
192
|
-
const depthPickingTracker = trackDisposePerInstance(DepthPickingPass)
|
|
193
|
-
const copyPassTracker = trackDisposePerInstance(CopyPass)
|
|
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')
|
|
194
175
|
// AutofocusProps' `ref` type is broken (ComponentProps<typeof DepthOfField>
|
|
195
176
|
// drags in DepthOfField's own `ref: Ref<DepthOfFieldEffect>`, which then
|
|
196
177
|
// intersects with `Ref<AutofocusApi>` — separate pre-existing issue,
|
|
@@ -214,17 +195,40 @@ describe('effect smoke tests', () => {
|
|
|
214
195
|
await React.act(async () => root.render(null))
|
|
215
196
|
await flush()
|
|
216
197
|
|
|
217
|
-
expect(
|
|
218
|
-
expect(
|
|
219
|
-
|
|
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()
|
|
220
223
|
|
|
221
|
-
|
|
222
|
-
copyPassTracker.restore()
|
|
224
|
+
disposeSpy.mockRestore()
|
|
223
225
|
})
|
|
224
226
|
|
|
225
|
-
it('covers every file in src/effects (or documents why it is excluded)', () => {
|
|
226
|
-
const
|
|
227
|
-
const files =
|
|
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
|
+
)
|
|
228
232
|
|
|
229
233
|
const covered = new Set(SMOKE_CASES.map((c) => c.file))
|
|
230
234
|
const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))
|
package/src/util.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useThree, type ReactThreeFiber } from '@react-three/fiber'
|
|
1
|
+
import { useThree, type Instance, type ReactThreeFiber } from '@react-three/fiber'
|
|
2
2
|
import type { Selection as PPSelection } from 'postprocessing'
|
|
3
|
-
import { use, useEffect, useMemo, useRef, type RefObject } from 'react'
|
|
4
|
-
import { Object3D, Vector2, type Vector2Tuple } from 'three'
|
|
3
|
+
import { use, useCallback, useEffect, useLayoutEffect, useMemo, useRef, type Ref, type RefObject } from 'react'
|
|
4
|
+
import { Group, Object3D, Vector2, Vector3, type Vector2Tuple, type Vector3Tuple } from 'three'
|
|
5
5
|
import { selectionContext } from './Selection'
|
|
6
6
|
|
|
7
7
|
// Stable reference for array-typed props defaulting to "nothing" - `= []`
|
|
@@ -12,13 +12,49 @@ export const EMPTY_ARRAY: never[] = []
|
|
|
12
12
|
export const resolveRef = <T,>(ref: T | RefObject<T>) =>
|
|
13
13
|
typeof ref === 'object' && ref != null && 'current' in ref ? ref.current : ref
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
// Merges a local ref with a caller-supplied ref (object or callback form).
|
|
16
|
+
// Clears localRef inside the callback ref's own returned cleanup, since
|
|
17
|
+
// React 19 never re-invokes it with null once it returns one.
|
|
18
|
+
export function useMergeRefs<T>(
|
|
19
|
+
localRef: RefObject<T | null>,
|
|
20
|
+
outerRef: Ref<T> | undefined
|
|
21
|
+
): (instance: T | null) => void | (() => void) {
|
|
22
|
+
return useCallback(
|
|
23
|
+
(instance: T | null) => {
|
|
24
|
+
localRef.current = instance
|
|
25
|
+
if (typeof outerRef !== 'function') {
|
|
26
|
+
if (outerRef) outerRef.current = instance
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
const cleanup = outerRef(instance)
|
|
30
|
+
if (typeof cleanup !== 'function') return
|
|
31
|
+
return () => {
|
|
32
|
+
localRef.current = null
|
|
33
|
+
cleanup()
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[localRef, outerRef]
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Reads a <group>'s direct r3f children, filtered by type - transparent to
|
|
41
|
+
// non-host wrapper components, since r3f's instance tree already is.
|
|
42
|
+
export function readGroupChildren<T>(group: Group, filter: (object: unknown) => object is T): T[] {
|
|
43
|
+
const groupInstance = (group as Group & { __r3f: Instance<Group> }).__r3f
|
|
44
|
+
return groupInstance ? groupInstance.children.map((child) => child.object).filter(filter) : []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Diffs `next` against what's stored in `ref`, updating it only when the
|
|
48
|
+
// contents actually changed. Returns whether it changed.
|
|
49
|
+
export function updateIfChanged<T>(ref: RefObject<T[]>, next: T[]): boolean {
|
|
50
|
+
const previous = ref.current
|
|
51
|
+
if (next.length === previous.length && next.every((item, i) => item === previous[i])) return false
|
|
52
|
+
ref.current = next
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Keeps `selection` synced with whichever mode is active: the manual
|
|
57
|
+
// `selection` prop, or the declarative Selection/Select API (context wins).
|
|
22
58
|
export function useSelectionSync(
|
|
23
59
|
effect: { selection: PPSelection },
|
|
24
60
|
selection: Object3D | Object3D[] | RefObject<Object3D | null> | RefObject<Object3D | null>[],
|
|
@@ -59,26 +95,91 @@ export function useSelectionSync(
|
|
|
59
95
|
}, [api, effect.selection, invalidate])
|
|
60
96
|
}
|
|
61
97
|
|
|
62
|
-
|
|
63
|
-
* r3f never disposes <primitive> objects (their state may be owned outside
|
|
64
|
-
* React), so effects rendered that way must dispose themselves. Guards
|
|
65
|
-
* against double-dispose across StrictMode's dev-only mount/cleanup/mount
|
|
66
|
-
* cycle, where the cleanup closure re-runs against the same instance.
|
|
67
|
-
*/
|
|
98
|
+
// r3f never disposes <primitive> objects, so they must dispose themselves.
|
|
68
99
|
export const useDispose = <T extends { dispose?: () => void }>(instance: T): void => {
|
|
69
|
-
const disposedRef = useRef<WeakSet<object>>(new WeakSet())
|
|
70
|
-
|
|
71
100
|
useEffect(() => {
|
|
72
|
-
const disposed = disposedRef.current
|
|
73
101
|
return () => {
|
|
74
|
-
|
|
75
|
-
disposed.add(instance)
|
|
76
|
-
instance.dispose?.()
|
|
77
|
-
}
|
|
102
|
+
instance?.dispose?.()
|
|
78
103
|
}
|
|
79
104
|
}, [instance])
|
|
80
105
|
}
|
|
81
106
|
|
|
107
|
+
// Reads a plain or r3f-pierced ("a-b-c") key off an object, matching what
|
|
108
|
+
// applyProps below can write - a single reader that works for both shapes.
|
|
109
|
+
export function readPierced(instance: object, key: string): unknown {
|
|
110
|
+
let target: unknown = instance
|
|
111
|
+
for (const part of key.split('-')) {
|
|
112
|
+
if (target == null) return undefined
|
|
113
|
+
target = (target as Record<string, unknown>)[part]
|
|
114
|
+
}
|
|
115
|
+
return target
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Writes a plain or r3f-pierced ("a-b-c") key, mirroring readPierced.
|
|
119
|
+
// Deliberately a plain assignment, not r3f's applyProps: these instances
|
|
120
|
+
// are built with `new`, not r3f's reconciler, so applyProps' Color/Vector
|
|
121
|
+
// coercion never applies to them. Callers wrap values that need coercion.
|
|
122
|
+
export function applyPierced(instance: object, key: string, value: unknown): void {
|
|
123
|
+
const parts = key.split('-')
|
|
124
|
+
let target: unknown = instance
|
|
125
|
+
for (let idx = 0; idx < parts.length - 1; idx++) {
|
|
126
|
+
if (target == null) return
|
|
127
|
+
target = (target as Record<string, unknown>)[parts[idx]]
|
|
128
|
+
}
|
|
129
|
+
if (target == null) return
|
|
130
|
+
;(target as Record<string, unknown>)[parts[parts.length - 1]] = value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Applies live-mutable properties onto an instance built via `new` (not
|
|
134
|
+
// r3f's reconciler, so r3f's own prop diffing/reset never runs on it).
|
|
135
|
+
// Falls back to the constructor-time default when a value is `undefined`.
|
|
136
|
+
// Only calls `set` when the resolved value actually changed since the last
|
|
137
|
+
// apply - some setters have side effects beyond storing the value (e.g.
|
|
138
|
+
// OutlineEffect's `multisampling` disposes its render target on every set).
|
|
139
|
+
export function useLiveDefaults<T extends object>(
|
|
140
|
+
instance: T | RefObject<T | null> | null,
|
|
141
|
+
values: Record<string, unknown>,
|
|
142
|
+
keys: Iterable<string>,
|
|
143
|
+
get: (instance: T, key: string) => unknown = readPierced,
|
|
144
|
+
set: (instance: T, key: string, value: unknown) => void = applyPierced
|
|
145
|
+
): void {
|
|
146
|
+
const snapshotRef = useRef<{ instance: T; defaults: Map<string, unknown>; applied: Map<string, unknown> } | null>(
|
|
147
|
+
null
|
|
148
|
+
)
|
|
149
|
+
const invalidate = useThree((state) => state.invalidate)
|
|
150
|
+
|
|
151
|
+
useLayoutEffect(() => {
|
|
152
|
+
const resolved = resolveRef(instance)
|
|
153
|
+
if (!resolved) return
|
|
154
|
+
if (snapshotRef.current?.instance !== resolved) {
|
|
155
|
+
snapshotRef.current = { instance: resolved, defaults: new Map(), applied: new Map() }
|
|
156
|
+
}
|
|
157
|
+
const { defaults, applied } = snapshotRef.current
|
|
158
|
+
let changed = false
|
|
159
|
+
|
|
160
|
+
for (const key of keys) {
|
|
161
|
+
if (!defaults.has(key)) {
|
|
162
|
+
// Seed `applied` too, not just `defaults`, so an unchanged key
|
|
163
|
+
// skips `set` even on this first pass (avoids re-triggering
|
|
164
|
+
// setters with side effects, e.g. multisampling's dispose).
|
|
165
|
+
const current = get(resolved, key)
|
|
166
|
+
defaults.set(key, current)
|
|
167
|
+
applied.set(key, current)
|
|
168
|
+
}
|
|
169
|
+
const next = values[key] !== undefined ? values[key] : defaults.get(key)
|
|
170
|
+
if (Object.is(applied.get(key), next)) continue
|
|
171
|
+
set(resolved, key, next)
|
|
172
|
+
applied.set(key, next)
|
|
173
|
+
changed = true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// These instances are mutated directly (not via r3f's reconciler), so
|
|
177
|
+
// r3f never sees the change - without this, frameloop="demand" would
|
|
178
|
+
// never repaint after a live prop update.
|
|
179
|
+
if (changed) invalidate()
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
82
183
|
export const useVector2 = (props: Record<string, unknown>, key: string): Vector2 => {
|
|
83
184
|
const value = props[key] as ReactThreeFiber.Vector2 | undefined
|
|
84
185
|
|
|
@@ -87,6 +188,10 @@ export const useVector2 = (props: Record<string, unknown>, key: string): Vector2
|
|
|
87
188
|
return new Vector2(value, value)
|
|
88
189
|
}
|
|
89
190
|
|
|
191
|
+
if (value instanceof Vector2) {
|
|
192
|
+
return value
|
|
193
|
+
}
|
|
194
|
+
|
|
90
195
|
if (value) {
|
|
91
196
|
return new Vector2(...(value as Vector2Tuple))
|
|
92
197
|
}
|
|
@@ -94,3 +199,23 @@ export const useVector2 = (props: Record<string, unknown>, key: string): Vector2
|
|
|
94
199
|
return new Vector2()
|
|
95
200
|
}, [value])
|
|
96
201
|
}
|
|
202
|
+
|
|
203
|
+
export const useVector3 = (props: Record<string, unknown>, key: string): Vector3 => {
|
|
204
|
+
const value = props[key] as ReactThreeFiber.Vector3 | undefined
|
|
205
|
+
|
|
206
|
+
return useMemo(() => {
|
|
207
|
+
if (typeof value === 'number') {
|
|
208
|
+
return new Vector3(value, value, value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (value instanceof Vector3) {
|
|
212
|
+
return value
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (value) {
|
|
216
|
+
return new Vector3(...(value as Vector3Tuple))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return new Vector3()
|
|
220
|
+
}, [value])
|
|
221
|
+
}
|
package/src/wrapEffect.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { extend, useThree } from '@react-three/fiber'
|
|
2
|
-
import type { BlendFunction
|
|
2
|
+
import type { BlendFunction } from 'postprocessing'
|
|
3
3
|
import { useMemo, type ExoticComponent, type JSX, type Ref } from 'react'
|
|
4
|
+
import type { EffectConstructor } from './createEffectComponent'
|
|
4
5
|
|
|
5
|
-
export type EffectConstructor
|
|
6
|
+
export type { EffectConstructor }
|
|
6
7
|
|
|
7
8
|
// Handles three ConstructorParameters<T> shapes: required first param
|
|
8
9
|
// (P), optional first param (Partial<P> — some effects in postprocessing
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { EffectComposer as EffectComposerImpl } from 'postprocessing';
|
|
2
|
-
import * as React from 'react';
|
|
3
|
-
declare global {
|
|
4
|
-
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
5
|
-
}
|
|
6
|
-
export declare const root: import("@react-three/fiber").ReconcilerRoot<HTMLCanvasElement>;
|
|
7
|
-
export declare const EFFECT_SHADER = "\nvoid mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {\n outputColor = inputColor;\n}\n";
|
|
8
|
-
export declare const flush: () => Promise<void>;
|
|
9
|
-
export declare const strict: (children: React.ReactNode) => React.JSX.Element;
|
|
10
|
-
export declare const waitForComposer: (ref: React.RefObject<EffectComposerImpl | null>) => Promise<EffectComposerImpl>;
|
|
11
|
-
export declare const waitForNewComposer: (ref: React.RefObject<EffectComposerImpl | null>, previous: EffectComposerImpl) => Promise<EffectComposerImpl>;
|
|
12
|
-
export declare const waitForEffects: (ref: React.RefObject<EffectComposerImpl | null>, count: number) => Promise<import("postprocessing").Effect[]>;
|