@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.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -81
  3. package/dist/EffectComposer.d.ts +14 -6
  4. package/dist/EffectGroup.d.ts +9 -0
  5. package/dist/Selection.d.ts +8 -8
  6. package/dist/createEffectComponent.d.ts +30 -0
  7. package/dist/effects/ASCII.d.ts +29 -7
  8. package/dist/effects/Autofocus.d.ts +8 -7
  9. package/dist/effects/Bloom.d.ts +10 -5
  10. package/dist/effects/BrightnessContrast.d.ts +15 -5
  11. package/dist/effects/ChromaticAberration.d.ts +30 -7
  12. package/dist/effects/ColorAverage.d.ts +7 -8
  13. package/dist/effects/ColorDepth.d.ts +8 -5
  14. package/dist/effects/Depth.d.ts +13 -5
  15. package/dist/effects/DepthOfField.d.ts +7 -18
  16. package/dist/effects/DotScreen.d.ts +15 -5
  17. package/dist/effects/FXAA.d.ts +11 -5
  18. package/dist/effects/Glitch.d.ts +17 -29
  19. package/dist/effects/GodRays.d.ts +7 -19
  20. package/dist/effects/Grid.d.ts +8 -8
  21. package/dist/effects/HueSaturation.d.ts +15 -5
  22. package/dist/effects/LUT.d.ts +4 -3
  23. package/dist/effects/LensFlare.d.ts +52 -11
  24. package/dist/effects/Noise.d.ts +8 -5
  25. package/dist/effects/Outline.d.ts +9 -25
  26. package/dist/effects/Pixelation.d.ts +3 -2
  27. package/dist/effects/Ramp.d.ts +58 -10
  28. package/dist/effects/SMAA.d.ts +10 -5
  29. package/dist/effects/SSAO.d.ts +7 -30
  30. package/dist/effects/ScanlineEffect.d.ts +13 -5
  31. package/dist/effects/SelectiveBloom.d.ts +5 -10
  32. package/dist/effects/Sepia.d.ts +13 -5
  33. package/dist/effects/ShockWave.d.ts +12 -5
  34. package/dist/effects/Texture.d.ts +7 -8
  35. package/dist/effects/TiltShift.d.ts +10 -5
  36. package/dist/effects/TiltShift2.d.ts +44 -8
  37. package/dist/effects/ToneMapping.d.ts +9 -7
  38. package/dist/effects/Vignette.d.ts +19 -5
  39. package/dist/effects/Water.d.ts +17 -5
  40. package/dist/index.d.ts +15 -11
  41. package/dist/index.js +1841 -45
  42. package/dist/index.js.map +1 -1
  43. package/dist/passes/DepthPicking.d.ts +11 -0
  44. package/dist/{effects → passes}/N8AO.d.ts +5 -2
  45. package/dist/util.d.ts +19 -17
  46. package/dist/wrapEffect.d.ts +12 -0
  47. package/package.json +74 -67
  48. package/src/EffectComposer.tsx +379 -200
  49. package/src/EffectGroup.tsx +74 -0
  50. package/src/Selection.tsx +86 -46
  51. package/src/createEffectComponent.tsx +71 -0
  52. package/src/effects/ASCII.tsx +183 -135
  53. package/src/effects/Autofocus.tsx +122 -153
  54. package/src/effects/Bloom.tsx +34 -6
  55. package/src/effects/BrightnessContrast.tsx +7 -4
  56. package/src/effects/ChromaticAberration.tsx +22 -5
  57. package/src/effects/ColorAverage.tsx +4 -15
  58. package/src/effects/ColorDepth.tsx +25 -4
  59. package/src/effects/Depth.tsx +6 -4
  60. package/src/effects/DepthOfField.tsx +109 -89
  61. package/src/effects/DotScreen.tsx +7 -4
  62. package/src/effects/FXAA.tsx +6 -4
  63. package/src/effects/Glitch.tsx +32 -40
  64. package/src/effects/GodRays.tsx +104 -16
  65. package/src/effects/Grid.tsx +27 -21
  66. package/src/effects/HueSaturation.tsx +7 -4
  67. package/src/effects/LUT.tsx +25 -26
  68. package/src/effects/LensFlare.tsx +765 -611
  69. package/src/effects/Noise.tsx +16 -4
  70. package/src/effects/Outline.tsx +84 -117
  71. package/src/effects/Pixelation.tsx +20 -15
  72. package/src/effects/Ramp.tsx +239 -150
  73. package/src/effects/SMAA.tsx +20 -4
  74. package/src/effects/SSAO.tsx +152 -41
  75. package/src/effects/ScanlineEffect.tsx +7 -7
  76. package/src/effects/SelectiveBloom.tsx +98 -126
  77. package/src/effects/Sepia.tsx +6 -4
  78. package/src/effects/ShockWave.tsx +32 -4
  79. package/src/effects/Texture.tsx +28 -23
  80. package/src/effects/TiltShift.tsx +32 -4
  81. package/src/effects/TiltShift2.tsx +156 -90
  82. package/src/effects/ToneMapping.tsx +19 -6
  83. package/src/effects/Vignette.tsx +7 -4
  84. package/src/effects/Water.tsx +50 -35
  85. package/src/index.ts +43 -40
  86. package/src/passes/DepthPicking.tsx +63 -0
  87. package/src/passes/N8AO.tsx +105 -0
  88. package/src/tests/Autofocus.test.tsx +38 -0
  89. package/src/tests/Bloom.test.tsx +76 -0
  90. package/src/tests/ChromaticAberration.test.tsx +81 -0
  91. package/src/tests/ColorDepth.test.tsx +71 -0
  92. package/src/tests/DepthOfField.test.tsx +130 -0
  93. package/src/tests/DepthPicking.test.tsx +250 -0
  94. package/src/tests/EffectComposer.test.tsx +1404 -0
  95. package/src/tests/EffectGroup.test.tsx +337 -0
  96. package/src/tests/Glitch.test.tsx +56 -0
  97. package/src/tests/GodRays.test.tsx +101 -0
  98. package/src/tests/Grid.test.tsx +34 -0
  99. package/src/tests/LUT.test.tsx +64 -0
  100. package/src/tests/N8AO.test.tsx +92 -0
  101. package/src/tests/Outline.test.tsx +211 -0
  102. package/src/tests/SSAO.test.tsx +140 -0
  103. package/src/tests/Selection.test.tsx +324 -0
  104. package/src/tests/SelectiveBloom.test.tsx +166 -0
  105. package/src/tests/ShockWave.test.tsx +95 -0
  106. package/src/tests/TiltShift.test.tsx +76 -0
  107. package/src/tests/createEffectComponent.test.tsx +286 -0
  108. package/src/tests/effects.smoke.test.tsx +238 -0
  109. package/src/tests/test-utils.tsx +96 -0
  110. package/src/tests/wrapEffect.test.tsx +209 -0
  111. package/src/util.tsx +221 -55
  112. package/src/wrapEffect.tsx +123 -0
  113. package/src/EffectComposer.test.tsx +0 -126
  114. package/src/effects/N8AO.tsx +0 -81
@@ -1,200 +1,379 @@
1
- import type { TextureDataType, Group, Camera, Scene } from 'three'
2
- import { HalfFloatType, NoToneMapping } from 'three'
3
- import {
4
- type JSX,
5
- memo,
6
- forwardRef,
7
- useMemo,
8
- useEffect,
9
- useLayoutEffect,
10
- createContext,
11
- useRef,
12
- useImperativeHandle,
13
- } from 'react'
14
- import { useThree, useFrame, type Instance } from '@react-three/fiber'
15
- import {
16
- EffectComposer as EffectComposerImpl,
17
- RenderPass,
18
- EffectPass,
19
- NormalPass,
20
- DepthDownsamplingPass,
21
- Effect,
22
- Pass,
23
- EffectAttribute,
24
- } from 'postprocessing'
25
-
26
- export const EffectComposerContext = /* @__PURE__ */ createContext<{
27
- composer: EffectComposerImpl
28
- normalPass: NormalPass | null
29
- downSamplingPass: DepthDownsamplingPass | null
30
- camera: Camera
31
- scene: Scene
32
- resolutionScale?: number
33
- }>(null!)
34
-
35
- export type EffectComposerProps = {
36
- enabled?: boolean
37
- children: JSX.Element | JSX.Element[]
38
- depthBuffer?: boolean
39
- /** Only used for SSGI currently, leave it disabled for everything else unless it's needed */
40
- enableNormalPass?: boolean
41
- stencilBuffer?: boolean
42
- autoClear?: boolean
43
- resolutionScale?: number
44
- multisampling?: number
45
- frameBufferType?: TextureDataType
46
- renderPriority?: number
47
- camera?: Camera
48
- scene?: Scene
49
- }
50
-
51
- const isConvolution = (effect: Effect): boolean =>
52
- (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
53
-
54
- export const EffectComposer = /* @__PURE__ */ memo(
55
- /* @__PURE__ */ forwardRef<EffectComposerImpl, EffectComposerProps>(
56
- (
57
- {
58
- children,
59
- camera: _camera,
60
- scene: _scene,
61
- resolutionScale,
62
- enabled = true,
63
- renderPriority = 1,
64
- autoClear = true,
65
- depthBuffer,
66
- enableNormalPass,
67
- stencilBuffer,
68
- multisampling = 8,
69
- frameBufferType = HalfFloatType,
70
- },
71
- ref
72
- ) => {
73
- const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree()
74
- const scene = _scene || defaultScene
75
- const camera = _camera || defaultCamera
76
-
77
- const [composer, normalPass, downSamplingPass] = useMemo(() => {
78
- // Initialize composer
79
- const effectComposer = new EffectComposerImpl(gl, {
80
- depthBuffer,
81
- stencilBuffer,
82
- multisampling,
83
- frameBufferType,
84
- })
85
-
86
- // Add render pass
87
- effectComposer.addPass(new RenderPass(scene, camera))
88
-
89
- // Create normal pass
90
- let downSamplingPass = null
91
- let normalPass = null
92
- if (enableNormalPass) {
93
- normalPass = new NormalPass(scene, camera)
94
- normalPass.enabled = false
95
- effectComposer.addPass(normalPass)
96
- if (resolutionScale !== undefined) {
97
- downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale })
98
- downSamplingPass.enabled = false
99
- effectComposer.addPass(downSamplingPass)
100
- }
101
- }
102
-
103
- return [effectComposer, normalPass, downSamplingPass]
104
- }, [
105
- camera,
106
- gl,
107
- depthBuffer,
108
- stencilBuffer,
109
- multisampling,
110
- frameBufferType,
111
- scene,
112
- enableNormalPass,
113
- resolutionScale,
114
- ])
115
-
116
- useEffect(() => composer?.setSize(size.width, size.height), [composer, size])
117
- useFrame(
118
- (_, delta) => {
119
- if (enabled) {
120
- const currentAutoClear = gl.autoClear
121
- gl.autoClear = autoClear
122
- if (stencilBuffer && !autoClear) gl.clearStencil()
123
- composer.render(delta)
124
- gl.autoClear = currentAutoClear
125
- }
126
- },
127
- enabled ? renderPriority : 0
128
- )
129
-
130
- const group = useRef<Group>(null!)
131
- useLayoutEffect(() => {
132
- const passes: Pass[] = []
133
-
134
- // TODO: rewrite all of this with R3F v9
135
- const groupInstance = (group.current as Group & { __r3f: Instance<Group> }).__r3f
136
-
137
- if (groupInstance && composer) {
138
- const children = groupInstance.children
139
-
140
- for (let i = 0; i < children.length; i++) {
141
- const child = children[i].object
142
-
143
- if (child instanceof Effect) {
144
- const effects: Effect[] = [child]
145
-
146
- if (!isConvolution(child)) {
147
- let next: unknown = null
148
- while ((next = children[i + 1]?.object) instanceof Effect) {
149
- if (isConvolution(next)) break
150
- effects.push(next)
151
- i++
152
- }
153
- }
154
-
155
- const pass = new EffectPass(camera, ...effects)
156
- passes.push(pass)
157
- } else if (child instanceof Pass) {
158
- passes.push(child)
159
- }
160
- }
161
-
162
- for (const pass of passes) composer?.addPass(pass)
163
-
164
- if (normalPass) normalPass.enabled = true
165
- if (downSamplingPass) downSamplingPass.enabled = true
166
- }
167
-
168
- return () => {
169
- for (const pass of passes) composer?.removePass(pass)
170
- if (normalPass) normalPass.enabled = false
171
- if (downSamplingPass) downSamplingPass.enabled = false
172
- }
173
- }, [composer, children, camera, normalPass, downSamplingPass])
174
-
175
- // Disable tone mapping because threejs disallows tonemapping on render targets
176
- useEffect(() => {
177
- const currentTonemapping = gl.toneMapping
178
- gl.toneMapping = NoToneMapping
179
- return () => {
180
- gl.toneMapping = currentTonemapping
181
- }
182
- }, [gl])
183
-
184
- // Memoize state, otherwise it would trigger all consumers on every render
185
- const state = useMemo(
186
- () => ({ composer, normalPass, downSamplingPass, resolutionScale, camera, scene }),
187
- [composer, normalPass, downSamplingPass, resolutionScale, camera, scene]
188
- )
189
-
190
- // Expose the composer
191
- useImperativeHandle(ref, () => composer, [composer])
192
-
193
- return (
194
- <EffectComposerContext.Provider value={state}>
195
- <group ref={group}>{children}</group>
196
- </EffectComposerContext.Provider>
197
- )
198
- }
199
- )
200
- )
1
+ import { useFrame, useThree } from '@react-three/fiber'
2
+ import {
3
+ CopyPass,
4
+ DepthDownsamplingPass,
5
+ Effect,
6
+ EffectAttribute,
7
+ EffectComposer as EffectComposerImpl,
8
+ EffectPass,
9
+ NormalPass,
10
+ Pass,
11
+ RenderPass,
12
+ } from 'postprocessing'
13
+ import {
14
+ createContext,
15
+ memo,
16
+ useEffect,
17
+ useImperativeHandle,
18
+ useLayoutEffect,
19
+ useMemo,
20
+ useReducer,
21
+ useRef,
22
+ useState,
23
+ type ReactNode,
24
+ type Ref,
25
+ } from 'react'
26
+ import type { Camera, Group, Scene, TextureDataType, WebGLRenderer } from 'three'
27
+ import { HalfFloatType, NoToneMapping, Vector2 } from 'three'
28
+ import { readGroupChildren, updateIfChanged } from './util'
29
+
30
+ export const EffectComposerContext = /* @__PURE__ */ createContext<{
31
+ composer: EffectComposerImpl
32
+ normalPass: NormalPass | null
33
+ downSamplingPass: DepthDownsamplingPass | null
34
+ camera: Camera
35
+ scene: Scene
36
+ resolutionScale?: number
37
+ // A child's local state update doesn't re-render its parent - descendants
38
+ // that add/remove a bare Pass of their own (e.g. EffectGroup) call this
39
+ // to make the tree walk below notice.
40
+ requestRebuild: () => void
41
+ autoClear: boolean
42
+ }>(null!)
43
+
44
+ export type EffectComposerProps = {
45
+ enabled?: boolean
46
+ children: ReactNode
47
+ depthBuffer?: boolean
48
+ /** Only used for SSGI currently, leave it disabled for everything else unless it's needed */
49
+ enableNormalPass?: boolean
50
+ stencilBuffer?: boolean
51
+ autoClear?: boolean
52
+ autoRenderToScreen?: boolean
53
+ resolutionScale?: number
54
+ multisampling?: number
55
+ frameBufferType?: TextureDataType
56
+ renderPriority?: number
57
+ camera?: Camera
58
+ scene?: Scene
59
+ renderPass?: (scene: Scene, camera: Camera) => Pass
60
+ mergeMode?: 'auto' | 'all' | 'none'
61
+ ref?: Ref<EffectComposerImpl>
62
+ }
63
+
64
+ type ComposerState = {
65
+ composer: EffectComposerImpl
66
+ normalPass: NormalPass | null
67
+ downSamplingPass: DepthDownsamplingPass | null
68
+ }
69
+
70
+ const isConvolution = (effect: Effect): boolean =>
71
+ (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
72
+
73
+ // autoClear/toneMapping get force-set and never restored. Ref-counted per
74
+ // (renderer, property) since composers can share a renderer.
75
+ function createRendererPropertyGuard<K extends 'autoClear' | 'toneMapping'>(property: K) {
76
+ const refs = new WeakMap<
77
+ WebGLRenderer,
78
+ { count: number; original: WebGLRenderer[K]; forcedValue: WebGLRenderer[K] }
79
+ >()
80
+
81
+ return {
82
+ acquire(gl: WebGLRenderer, forcedValue: WebGLRenderer[K]): void {
83
+ const existing = refs.get(gl)
84
+ if (existing) {
85
+ existing.count++
86
+ existing.forcedValue = forcedValue
87
+ } else {
88
+ refs.set(gl, { count: 1, original: gl[property], forcedValue })
89
+ }
90
+ },
91
+ release(gl: WebGLRenderer): void {
92
+ const entry = refs.get(gl)
93
+ if (!entry) return
94
+
95
+ if (--entry.count <= 0) {
96
+ if (gl[property] === entry.forcedValue) {
97
+ gl[property] = entry.original
98
+ }
99
+ refs.delete(gl)
100
+ }
101
+ },
102
+ }
103
+ }
104
+
105
+ const autoClearGuard = /* @__PURE__ */ createRendererPropertyGuard('autoClear')
106
+ const toneMappingGuard = /* @__PURE__ */ createRendererPropertyGuard('toneMapping')
107
+
108
+ // Scratch vector for gl.getSize() below - written then read synchronously
109
+ // within the same call, never held onto across a render/frame, so sharing
110
+ // one instance across every <EffectComposer> is safe (same reasoning as
111
+ // DEFAULT_SCREEN_RES in LensFlare.tsx: safe because nothing ever aliases it).
112
+ const glSize = /* @__PURE__ */ new Vector2()
113
+
114
+ // Stable identity for the `renderPass` default - it's in the creation
115
+ // effect's dependency array below (changing it recreates the composer,
116
+ // same as depthBuffer/multisampling), so a fresh arrow function every
117
+ // render here would recreate the composer on every render too.
118
+ const defaultRenderPass = (scene: Scene, camera: Camera): Pass => new RenderPass(scene, camera)
119
+
120
+ // Only passes buildPasses itself constructs - not a user's own bare Pass,
121
+ // which owns its own lifecycle.
122
+ const generatedPasses = /* @__PURE__ */ new WeakSet<Pass>()
123
+
124
+ // EffectGroup registers its pass here (see EffectGroup.tsx) so the rebuild
125
+ // effect below knows to add a shared trailing CopyPass.
126
+ export const groupPasses = /* @__PURE__ */ new WeakSet<Pass>()
127
+
128
+ // Not pass.dispose() - EffectPass.dispose() also disposes the effects it
129
+ // wraps, which have their own lifecycle. setEffects([]) detaches them first.
130
+ export function disposePassWithoutEffects(pass: Pass): void {
131
+ if (pass instanceof EffectPass) {
132
+ ;(pass as unknown as { setEffects(effects: never[]): void }).setEffects([])
133
+ }
134
+ Pass.prototype.dispose.call(pass)
135
+ }
136
+
137
+ function disposeGeneratedPass(pass: Pass): void {
138
+ if (!generatedPasses.has(pass)) return
139
+ disposePassWithoutEffects(pass)
140
+ }
141
+
142
+ // 'auto' (default): consecutive Effects share one EffectPass, same as vanilla
143
+ // postprocessing allows by hand - a run may contain at most one convolution
144
+ // Effect (e.g. DepthOfField), since the library throws if two land in the
145
+ // same pass. 'all' merges through that limit too, same no-guardrail
146
+ // treatment EffectGroup already gives its own children - multiple
147
+ // convolution Effects in one run will throw at render time. 'none' gives
148
+ // every Effect its own EffectPass.
149
+ function buildPasses(nodes: Array<Effect | Pass>, camera: Camera, mergeMode: 'auto' | 'all' | 'none'): Pass[] {
150
+ const passes: Pass[] = []
151
+
152
+ for (let i = 0; i < nodes.length; i++) {
153
+ const node = nodes[i]
154
+
155
+ if (node instanceof Effect) {
156
+ const effects: Effect[] = [node]
157
+ let hasConvolution = isConvolution(node)
158
+
159
+ if (mergeMode !== 'none') {
160
+ let next: Effect | Pass | undefined
161
+ while ((next = nodes[i + 1]) instanceof Effect) {
162
+ const nextIsConvolution = isConvolution(next)
163
+ if (mergeMode === 'auto' && hasConvolution && nextIsConvolution) break
164
+ effects.push(next)
165
+ hasConvolution ||= nextIsConvolution
166
+ i++
167
+ }
168
+ }
169
+
170
+ const pass = new EffectPass(camera, ...effects)
171
+ generatedPasses.add(pass)
172
+ passes.push(pass)
173
+ } else if (node instanceof Pass) {
174
+ passes.push(node)
175
+ }
176
+ }
177
+
178
+ return passes
179
+ }
180
+
181
+ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
182
+ children,
183
+ camera: _camera,
184
+ scene: _scene,
185
+ resolutionScale,
186
+ enabled = true,
187
+ renderPriority = 1,
188
+ autoClear = true,
189
+ autoRenderToScreen = true,
190
+ depthBuffer,
191
+ enableNormalPass,
192
+ stencilBuffer,
193
+ multisampling = 8,
194
+ frameBufferType = HalfFloatType,
195
+ renderPass = defaultRenderPass,
196
+ mergeMode = 'auto',
197
+ ref,
198
+ }: EffectComposerProps) {
199
+ const { gl, scene: defaultScene, camera: defaultCamera } = useThree()
200
+ const scene = _scene || defaultScene
201
+ const camera = _camera || defaultCamera
202
+
203
+ gl.getSize(glSize)
204
+
205
+ // useMemo can't own WebGL resources - React may discard it without cleanup.
206
+ const [composerState, setComposerState] = useState<ComposerState | null>(null)
207
+
208
+ // Dispatch is stable across renders, so it never destabilizes `state` below.
209
+ const [, requestRebuild] = useReducer((c: number) => c + 1, 0)
210
+
211
+ useEffect(() => {
212
+ autoClearGuard.acquire(gl, false)
213
+
214
+ const effectComposer = new EffectComposerImpl(gl, { depthBuffer, stencilBuffer, multisampling, frameBufferType })
215
+
216
+ effectComposer.autoRenderToScreen = autoRenderToScreen
217
+ effectComposer.addPass(renderPass(scene, camera))
218
+
219
+ let normalPass: NormalPass | null = null
220
+ let downSamplingPass: DepthDownsamplingPass | null = null
221
+
222
+ if (enableNormalPass) {
223
+ normalPass = new NormalPass(scene, camera)
224
+ normalPass.enabled = false
225
+ effectComposer.addPass(normalPass)
226
+
227
+ if (resolutionScale !== undefined) {
228
+ downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale })
229
+ downSamplingPass.enabled = false
230
+ effectComposer.addPass(downSamplingPass)
231
+ }
232
+ }
233
+
234
+ effectComposer.setSize(glSize.width, glSize.height)
235
+
236
+ setComposerState({ composer: effectComposer, normalPass, downSamplingPass })
237
+
238
+ return () => {
239
+ // The rebuild effect below may not have detached its passes yet
240
+ // (composerState only updates next render) - without this, dispose()
241
+ // would kill effects the new composer is about to reuse.
242
+ for (const pass of effectComposer.passes) disposeGeneratedPass(pass)
243
+ effectComposer.dispose()
244
+ autoClearGuard.release(gl)
245
+ }
246
+ // `glSize` intentionally excluded: it's applied via the composer.setSize
247
+ // effect below, and shouldn't tear down/recreate the whole composer.
248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
+ }, [
250
+ camera,
251
+ gl,
252
+ depthBuffer,
253
+ stencilBuffer,
254
+ multisampling,
255
+ frameBufferType,
256
+ autoRenderToScreen,
257
+ renderPass,
258
+ scene,
259
+ enableNormalPass,
260
+ resolutionScale,
261
+ ])
262
+
263
+ // Last size actually applied to the composer, so the check below is a
264
+ // cheap no-op on frames where nothing changed.
265
+ const appliedSizeRef = useRef({ width: -1, height: -1 })
266
+
267
+ useFrame(
268
+ (_, delta) => {
269
+ if (!enabled || !composerState) return
270
+ const { composer } = composerState
271
+
272
+ gl.getSize(glSize)
273
+ if (glSize.width !== appliedSizeRef.current.width || glSize.height !== appliedSizeRef.current.height) {
274
+ composer.setSize(glSize.width, glSize.height)
275
+ appliedSizeRef.current.width = glSize.width
276
+ appliedSizeRef.current.height = glSize.height
277
+ }
278
+
279
+ const currentAutoClear = gl.autoClear
280
+ gl.autoClear = autoClear
281
+ if (stencilBuffer && !autoClear) gl.clearStencil()
282
+ composer.render(delta)
283
+ gl.autoClear = currentAutoClear
284
+ },
285
+ enabled ? renderPriority : 0
286
+ )
287
+
288
+ // Derived from the r3f scene graph (not tracked incrementally) so order
289
+ // always matches JSX, even through wrapper components or a reorder.
290
+ const group = useRef<Group>(null!)
291
+ const nodesRef = useRef<Array<Effect | Pass>>([])
292
+ const [nodesVersion, setNodesVersion] = useState(0)
293
+
294
+ // Runs every render, but only bumps nodesVersion (triggering the rebuild
295
+ // below) when the resolved node list actually changed.
296
+ useLayoutEffect(() => {
297
+ if (!composerState) return
298
+ const nodes = readGroupChildren(
299
+ group.current,
300
+ (object): object is Effect | Pass => object instanceof Effect || object instanceof Pass
301
+ )
302
+ if (updateIfChanged(nodesRef, nodes)) setNodesVersion((v) => v + 1)
303
+ })
304
+
305
+ // Only re-runs when nodesVersion/composerState/camera change - React's
306
+ // own dependency bailout, so create/cleanup pairing stays correct.
307
+ useLayoutEffect(() => {
308
+ if (!composerState) return
309
+ const { composer, normalPass, downSamplingPass } = composerState
310
+
311
+ const passes = buildPasses(nodesRef.current, camera, mergeMode)
312
+
313
+ // A toggleable pass (EffectGroup) sitting last would leave nothing on
314
+ // screen once disabled - postprocessing assigns renderToScreen by
315
+ // structural position, not `.enabled`. A shared trailing CopyPass
316
+ // (always enabled, always added last) fixes that for the whole chain.
317
+ if (passes.some((pass) => groupPasses.has(pass))) {
318
+ const trailingCopyPass = new CopyPass()
319
+ generatedPasses.add(trailingCopyPass)
320
+ passes.push(trailingCopyPass)
321
+ }
322
+
323
+ for (const pass of passes) composer.addPass(pass)
324
+
325
+ if (passes.length) {
326
+ if (normalPass) normalPass.enabled = true
327
+ if (downSamplingPass) downSamplingPass.enabled = true
328
+ }
329
+
330
+ return () => {
331
+ for (const pass of passes) {
332
+ composer.removePass(pass)
333
+ disposeGeneratedPass(pass)
334
+ }
335
+ if (normalPass) normalPass.enabled = false
336
+ if (downSamplingPass) downSamplingPass.enabled = false
337
+ }
338
+ }, [composerState, nodesVersion, camera, mergeMode])
339
+
340
+ // Disable tone mapping because threejs disallows tonemapping on render targets
341
+ useEffect(() => {
342
+ toneMappingGuard.acquire(gl, NoToneMapping)
343
+ gl.toneMapping = NoToneMapping
344
+ return () => {
345
+ toneMappingGuard.release(gl)
346
+ }
347
+ }, [gl])
348
+
349
+ // Memoize state, otherwise it would trigger all consumers on every render
350
+ const state = useMemo(
351
+ () =>
352
+ composerState
353
+ ? {
354
+ composer: composerState.composer,
355
+ normalPass: composerState.normalPass,
356
+ downSamplingPass: composerState.downSamplingPass,
357
+ resolutionScale,
358
+ camera,
359
+ scene,
360
+ requestRebuild,
361
+ autoClear,
362
+ }
363
+ : null,
364
+ [composerState, resolutionScale, camera, scene, requestRebuild, autoClear]
365
+ )
366
+
367
+ // Expose the composer
368
+ useImperativeHandle(ref, () => composerState?.composer as EffectComposerImpl, [composerState])
369
+
370
+ // Wait until the composer exists before mounting children so they always
371
+ // see a valid composer instance via context.
372
+ if (!state) return null
373
+
374
+ return (
375
+ <EffectComposerContext.Provider value={state}>
376
+ <group ref={group}>{children}</group>
377
+ </EffectComposerContext.Provider>
378
+ )
379
+ })
@@ -0,0 +1,74 @@
1
+ import { Effect, EffectPass } from 'postprocessing'
2
+ import { use, useImperativeHandle, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react'
3
+ import type { Group } from 'three'
4
+ import { disposePassWithoutEffects, EffectComposerContext, groupPasses } from './EffectComposer'
5
+ import { readGroupChildren, updateIfChanged } from './util'
6
+
7
+ export type EffectGroupProps = {
8
+ /** Toggles the whole pass, like Pass.enabled in vanilla postprocessing - cheap, no reconstruction. */
9
+ enabled?: boolean
10
+ children: ReactNode
11
+ ref?: Ref<EffectPass>
12
+ }
13
+
14
+ // Groups its children into one EffectPass instead of relying on
15
+ // EffectComposer's automatic consecutive-effect merging. Renders children
16
+ // into a hidden inner <group> (invisible to EffectComposer's own top-level
17
+ // walk) and renders the resulting pass back out via <primitive> at this
18
+ // component's own JSX position, so EffectComposer's existing bare-Pass
19
+ // passthrough places it correctly relative to sibling effects.
20
+ export function EffectGroup({ enabled = true, children, ref }: EffectGroupProps) {
21
+ const { camera, requestRebuild } = use(EffectComposerContext)
22
+
23
+ const group = useRef<Group>(null!)
24
+ const effectsRef = useRef<Effect[]>([])
25
+ const [pass, setPass] = useState<EffectPass | null>(null)
26
+
27
+ // Mirrors EffectComposer's own buildPasses: always constructs a fresh
28
+ // EffectPass when the effect list changes, rather than updating one in
29
+ // place - so composer.addPass() runs its normal setSize/initialize dance
30
+ // for every effect, with nothing to replicate by hand here.
31
+ useLayoutEffect(() => {
32
+ const effects = readGroupChildren(group.current, (object): object is Effect => object instanceof Effect)
33
+ if (!updateIfChanged(effectsRef, effects)) return
34
+
35
+ if (!effects.length) {
36
+ setPass(null)
37
+ return
38
+ }
39
+
40
+ const newPass = new EffectPass(camera, ...effects)
41
+ groupPasses.add(newPass)
42
+ setPass(newPass)
43
+ })
44
+
45
+ // useLayoutEffect (not useEffect) so `enabled` is applied before
46
+ // EffectComposer's own effect adds this pass to the composer.
47
+ useLayoutEffect(() => {
48
+ if (pass) pass.enabled = enabled
49
+ }, [pass, enabled])
50
+
51
+ // A local state update above doesn't re-render EffectComposer, so its own
52
+ // tree walk would never notice this pass appearing/disappearing.
53
+ useLayoutEffect(() => {
54
+ requestRebuild()
55
+ }, [pass, requestRebuild])
56
+
57
+ // Disposes whichever pass this replaces (or the last one, on unmount) -
58
+ // the only place a pass gets disposed, so there's no double-dispose risk
59
+ // from also doing it inline above where `pass` is replaced.
60
+ useLayoutEffect(() => {
61
+ return () => {
62
+ if (pass) disposePassWithoutEffects(pass)
63
+ }
64
+ }, [pass])
65
+
66
+ useImperativeHandle(ref, () => pass as EffectPass, [pass])
67
+
68
+ return (
69
+ <>
70
+ <group ref={group}>{children}</group>
71
+ {pass && <primitive object={pass} />}
72
+ </>
73
+ )
74
+ }