@react-three/postprocessing 3.0.5 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +6 -7
  2. package/dist/EffectComposer.d.ts +9 -2
  3. package/dist/EffectGroup.d.ts +9 -0
  4. package/dist/createEffectComponent.d.ts +30 -0
  5. package/dist/effects/ASCII.d.ts +28 -7
  6. package/dist/effects/Autofocus.d.ts +1 -1
  7. package/dist/effects/Bloom.d.ts +9 -1
  8. package/dist/effects/BrightnessContrast.d.ts +14 -1
  9. package/dist/effects/ChromaticAberration.d.ts +26 -3
  10. package/dist/effects/ColorAverage.d.ts +7 -7
  11. package/dist/effects/ColorDepth.d.ts +7 -1
  12. package/dist/effects/Depth.d.ts +12 -1
  13. package/dist/effects/DotScreen.d.ts +14 -1
  14. package/dist/effects/FXAA.d.ts +10 -1
  15. package/dist/effects/Glitch.d.ts +16 -11
  16. package/dist/effects/GodRays.d.ts +1 -1
  17. package/dist/effects/Grid.d.ts +7 -6
  18. package/dist/effects/HueSaturation.d.ts +14 -1
  19. package/dist/effects/LUT.d.ts +1 -1
  20. package/dist/effects/LensFlare.d.ts +46 -5
  21. package/dist/effects/Noise.d.ts +7 -1
  22. package/dist/effects/Outline.d.ts +6 -3
  23. package/dist/effects/Pixelation.d.ts +1 -1
  24. package/dist/effects/Ramp.d.ts +58 -6
  25. package/dist/effects/SMAA.d.ts +9 -1
  26. package/dist/effects/SSAO.d.ts +1 -1
  27. package/dist/effects/ScanlineEffect.d.ts +12 -1
  28. package/dist/effects/SelectiveBloom.d.ts +1 -1
  29. package/dist/effects/Sepia.d.ts +12 -1
  30. package/dist/effects/ShockWave.d.ts +11 -1
  31. package/dist/effects/Texture.d.ts +4 -4
  32. package/dist/effects/TiltShift.d.ts +9 -1
  33. package/dist/effects/TiltShift2.d.ts +44 -4
  34. package/dist/effects/ToneMapping.d.ts +9 -3
  35. package/dist/effects/Vignette.d.ts +18 -1
  36. package/dist/effects/Water.d.ts +17 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +2205 -713
  39. package/dist/index.js.map +1 -1
  40. package/dist/passes/DepthPicking.d.ts +11 -0
  41. package/dist/{effects → passes}/N8AO.d.ts +2 -1
  42. package/dist/util.d.ts +9 -15
  43. package/dist/wrapEffect.d.ts +3 -3
  44. package/package.json +4 -3
  45. package/src/EffectComposer.tsx +143 -42
  46. package/src/EffectGroup.tsx +74 -0
  47. package/src/createEffectComponent.tsx +71 -0
  48. package/src/effects/ASCII.tsx +75 -28
  49. package/src/effects/Autofocus.tsx +24 -77
  50. package/src/effects/Bloom.tsx +32 -4
  51. package/src/effects/BrightnessContrast.tsx +5 -2
  52. package/src/effects/ChromaticAberration.tsx +13 -21
  53. package/src/effects/ColorAverage.tsx +3 -16
  54. package/src/effects/ColorDepth.tsx +23 -2
  55. package/src/effects/Depth.tsx +4 -2
  56. package/src/effects/DepthOfField.tsx +48 -26
  57. package/src/effects/DotScreen.tsx +5 -2
  58. package/src/effects/FXAA.tsx +4 -2
  59. package/src/effects/Glitch.tsx +26 -31
  60. package/src/effects/GodRays.tsx +90 -6
  61. package/src/effects/Grid.tsx +17 -18
  62. package/src/effects/HueSaturation.tsx +5 -2
  63. package/src/effects/LUT.tsx +9 -11
  64. package/src/effects/LensFlare.tsx +209 -55
  65. package/src/effects/Noise.tsx +14 -2
  66. package/src/effects/Outline.tsx +55 -56
  67. package/src/effects/Pixelation.tsx +11 -8
  68. package/src/effects/Ramp.tsx +96 -7
  69. package/src/effects/SMAA.tsx +18 -2
  70. package/src/effects/SSAO.tsx +130 -22
  71. package/src/effects/ScanlineEffect.tsx +6 -6
  72. package/src/effects/SelectiveBloom.tsx +26 -44
  73. package/src/effects/Sepia.tsx +4 -2
  74. package/src/effects/ShockWave.tsx +30 -2
  75. package/src/effects/Texture.tsx +10 -9
  76. package/src/effects/TiltShift.tsx +30 -2
  77. package/src/effects/TiltShift2.tsx +72 -6
  78. package/src/effects/ToneMapping.tsx +16 -3
  79. package/src/effects/Vignette.tsx +5 -2
  80. package/src/effects/Water.tsx +21 -6
  81. package/src/index.ts +4 -2
  82. package/src/passes/DepthPicking.tsx +63 -0
  83. package/src/{effects → passes}/N8AO.tsx +28 -4
  84. package/src/tests/Autofocus.test.tsx +38 -0
  85. package/src/tests/Bloom.test.tsx +76 -0
  86. package/src/tests/ChromaticAberration.test.tsx +50 -0
  87. package/src/tests/ColorDepth.test.tsx +71 -0
  88. package/src/tests/DepthOfField.test.tsx +130 -0
  89. package/src/tests/DepthPicking.test.tsx +250 -0
  90. package/src/tests/EffectComposer.test.tsx +501 -8
  91. package/src/tests/EffectGroup.test.tsx +337 -0
  92. package/src/tests/Glitch.test.tsx +56 -0
  93. package/src/tests/GodRays.test.tsx +101 -0
  94. package/src/tests/Grid.test.tsx +34 -0
  95. package/src/tests/LUT.test.tsx +64 -0
  96. package/src/tests/N8AO.test.tsx +92 -0
  97. package/src/tests/Outline.test.tsx +122 -0
  98. package/src/tests/SSAO.test.tsx +140 -0
  99. package/src/tests/SelectiveBloom.test.tsx +48 -0
  100. package/src/tests/ShockWave.test.tsx +95 -0
  101. package/src/tests/TiltShift.test.tsx +76 -0
  102. package/src/tests/createEffectComponent.test.tsx +286 -0
  103. package/src/tests/effects.smoke.test.tsx +46 -42
  104. package/src/util.tsx +148 -23
  105. package/src/wrapEffect.tsx +3 -2
  106. package/dist/tests/test-utils.d.ts +0 -12
@@ -0,0 +1,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 below. Adding a
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 { CopyPass, DepthPickingPass, EffectComposer as EffectComposerImpl } from 'postprocessing'
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. `ref` may be ignored by effects that don't forward one (e.g. LensFlare). */
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
- // LensFlare manages its own internal ref and doesn't accept one as a prop.
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
- // Tracks dispose() calls per instance rather than per class EffectComposerImpl
169
- // constructs its own internal CopyPass (this.copyPass, for compositing) and
170
- // disposes it as part of its own teardown, unrelated to any CopyPass an effect
171
- // constructs. A class-wide spy would conflate the two into a false "double
172
- // dispose"; this only flags it if the *same* instance is disposed twice.
173
- function trackDisposePerInstance(Ctor: { prototype: { dispose: (...args: unknown[]) => unknown } }) {
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(depthPickingTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
218
- expect(copyPassTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
219
- expect(dofDisposeSpy).toHaveBeenCalledTimes(1)
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
- depthPickingTracker.restore()
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 effectsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'effects')
227
- const files = fs.readdirSync(effectsDir).filter((f) => f.endsWith('.tsx'))
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
- * Keeps a postprocessing effect's `selection` (and its render layer) in
17
- * sync with either mode effects support: the manual
18
- * `selection` prop (used only when there's no enclosing <Selection>), or
19
- * the declarative Selection/Select API. The two are mutually exclusive -
20
- * context wins when both are present.
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
- if (instance && typeof instance === 'object' && !disposed.has(instance)) {
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
+ }
@@ -1,8 +1,9 @@
1
1
  import { extend, useThree } from '@react-three/fiber'
2
- import type { BlendFunction, Effect, Pass } from 'postprocessing'
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 = new (...args: any[]) => Effect | Pass
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[]>;