Skip to content

Commit 35b1950

Browse files
chore(tests): add coverage for EffectComposer merging behavior (#307)
1 parent d6c2bd9 commit 35b1950

File tree

12 files changed

+725
-35
lines changed

12 files changed

+725
-35
lines changed

.github/workflows/main.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ jobs:
1111
- uses: actions/checkout@v3
1212
- uses: actions/setup-node@v3
1313
with:
14-
cache: "yarn"
15-
- name: "install deps and build"
14+
cache: 'yarn'
15+
- name: Install Dependencies
1616
run: yarn install --frozen-lockfile
17+
18+
- name: Check build health
19+
run: yarn build
20+
21+
- name: Check for regressions
22+
run: yarn eslint:ci
23+
24+
- name: Run tests
25+
run: yarn test

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"prepare": "yarn build",
4040
"eslint": "eslint . --fix --ext=js,ts,jsx,tsx",
4141
"eslint:ci": "eslint . --ext=js,ts,jsx,tsx",
42-
"test": "echo no tests yet",
42+
"test": "vitest run",
4343
"typecheck": "tsc --noEmit false --strict --jsx react",
4444
"release": "semantic-release",
4545
"storybook": "storybook dev -p 6006",
@@ -85,7 +85,8 @@
8585
"storybook": "^7.0.10",
8686
"three": "^0.151.3",
8787
"typescript": "^5.0.4",
88-
"vite": "^4.3.5"
88+
"vite": "^4.3.5",
89+
"vitest": "^2.1.8"
8990
},
9091
"peerDependencies": {
9192
"@react-three/fiber": ">=8.0",

src/EffectComposer.test.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as React from 'react'
2+
import * as THREE from 'three'
3+
import { vi, describe, it, expect } from 'vitest'
4+
import { extend, createRoot, act } from '@react-three/fiber'
5+
import { EffectComposer } from './EffectComposer'
6+
import { EffectComposer as EffectComposerImpl, RenderPass, Pass, Effect, EffectPass } from 'postprocessing'
7+
8+
// Let React know that we'll be testing effectful components
9+
declare global {
10+
var IS_REACT_ACT_ENVIRONMENT: boolean
11+
}
12+
global.IS_REACT_ACT_ENVIRONMENT = true
13+
14+
// Mock scheduler to test React features
15+
vi.mock('scheduler', () => require('scheduler/unstable_mock'))
16+
17+
// Create virtual R3F root for testing
18+
extend(THREE)
19+
const root = createRoot({
20+
style: {} as CSSStyleDeclaration,
21+
addEventListener: (() => {}) as any,
22+
removeEventListener: (() => {}) as any,
23+
width: 1280,
24+
height: 800,
25+
clientWidth: 1280,
26+
clientHeight: 800,
27+
getContext: (() =>
28+
new Proxy(
29+
{},
30+
{
31+
get(_target, prop) {
32+
switch (prop) {
33+
case 'getParameter':
34+
return () => 'WebGL 2' // GL_VERSION
35+
case 'getExtension':
36+
return () => ({}) // EXT_blend_minmax
37+
case 'getContextAttributes':
38+
return () => ({ alpha: true })
39+
case 'getShaderPrecisionFormat':
40+
return () => ({ rangeMin: 1, rangeMax: 1, precision: 1 })
41+
default:
42+
return () => {}
43+
}
44+
},
45+
}
46+
)) as any,
47+
} satisfies Partial<HTMLCanvasElement> as HTMLCanvasElement)
48+
root.configure({ frameloop: 'never' })
49+
50+
const EFFECT_SHADER = 'mainImage() {}'
51+
52+
describe('EffectComposer', () => {
53+
it('should merge effects together', async () => {
54+
const composerRef = React.createRef<EffectComposerImpl>()
55+
56+
const effectA = new Effect('A', EFFECT_SHADER)
57+
const effectB = new Effect('B', EFFECT_SHADER)
58+
const effectC = new Effect('C', EFFECT_SHADER)
59+
const passA = new Pass()
60+
const passB = new Pass()
61+
62+
// Forward order
63+
await act(async () =>
64+
root.render(
65+
<EffectComposer ref={composerRef}>
66+
{/* EffectPass(effectA, effectB) */}
67+
<primitive object={effectA} />
68+
<primitive object={effectB} />
69+
{/* PassA */}
70+
<primitive object={passA} />
71+
{/* EffectPass(effectC) */}
72+
<primitive object={effectC} />
73+
{/* PassB */}
74+
<primitive object={passB} />
75+
</EffectComposer>
76+
)
77+
)
78+
expect(composerRef.current!.passes.map((p) => p.constructor)).toStrictEqual([
79+
RenderPass,
80+
EffectPass,
81+
Pass,
82+
EffectPass,
83+
Pass,
84+
])
85+
// @ts-expect-error
86+
expect((composerRef.current!.passes[1] as EffectPass).effects).toStrictEqual([effectA, effectB])
87+
expect(composerRef.current!.passes[2]).toBe(passA)
88+
// @ts-expect-error
89+
expect((composerRef.current!.passes[3] as EffectPass).effects).toStrictEqual([effectC])
90+
expect(composerRef.current!.passes[4]).toBe(passB)
91+
92+
// NOTE: instance children ordering is unstable until R3F v9, so we remount from scratch
93+
await act(async () => root.render(null))
94+
95+
// Reverse order
96+
await act(async () =>
97+
root.render(
98+
<EffectComposer ref={composerRef}>
99+
{/* PassB */}
100+
<primitive object={passB} />
101+
{/* EffectPass(effectC) */}
102+
<primitive object={effectC} />
103+
{/* PassA */}
104+
<primitive object={passA} />
105+
{/* EffectPass(effectB, effectA) */}
106+
<primitive object={effectB} />
107+
<primitive object={effectA} />
108+
</EffectComposer>
109+
)
110+
)
111+
expect(composerRef.current!.passes.map((p) => p.constructor)).toStrictEqual([
112+
RenderPass,
113+
Pass,
114+
EffectPass,
115+
Pass,
116+
EffectPass,
117+
])
118+
expect(composerRef.current!.passes[1]).toBe(passB)
119+
// @ts-expect-error
120+
expect((composerRef.current!.passes[2] as EffectPass).effects).toStrictEqual([effectC])
121+
expect(composerRef.current!.passes[3]).toBe(passA)
122+
// @ts-expect-error
123+
expect((composerRef.current!.passes[4] as EffectPass).effects).toStrictEqual([effectB, effectA])
124+
})
125+
126+
it.skip('should split convolution effects', async () => {
127+
await act(async () => root.render(null))
128+
})
129+
})

src/EffectComposer.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import React, {
99
useRef,
1010
useImperativeHandle,
1111
} from 'react'
12-
import { useThree, useFrame, useInstanceHandle } from '@react-three/fiber'
12+
import { useThree, useFrame } from '@react-three/fiber'
1313
import {
1414
EffectComposer as EffectComposerImpl,
1515
RenderPass,
@@ -32,7 +32,7 @@ export const EffectComposerContext = createContext<{
3232
resolutionScale?: number
3333
}>(null!)
3434

35-
export type EffectComposerProps = {
35+
export type EffectComposerProps = {
3636
enabled?: boolean
3737
children: JSX.Element | JSX.Element[]
3838
depthBuffer?: boolean
@@ -52,7 +52,7 @@ const isConvolution = (effect: Effect): boolean =>
5252
(effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
5353

5454
export const EffectComposer = React.memo(
55-
forwardRef(
55+
forwardRef<EffectComposerImpl, EffectComposerProps>(
5656
(
5757
{
5858
children,
@@ -67,7 +67,7 @@ export const EffectComposer = React.memo(
6767
stencilBuffer,
6868
multisampling = 8,
6969
frameBufferType = HalfFloatType,
70-
}: EffectComposerProps,
70+
},
7171
ref
7272
) => {
7373
const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree()
@@ -129,12 +129,14 @@ export const EffectComposer = React.memo(
129129
)
130130

131131
const group = useRef(null)
132-
const instance = useInstanceHandle(group)
133132
useLayoutEffect(() => {
134133
const passes: Pass[] = []
135134

136-
if (group.current && instance.current && composer) {
137-
const children = instance.current.objects as unknown[]
135+
// TODO: rewrite all of this with R3F v9
136+
const groupInstance = (group.current as any)?.__r3f as { objects: unknown[] }
137+
138+
if (groupInstance && composer) {
139+
const children = groupInstance.objects
138140

139141
for (let i = 0; i < children.length; i++) {
140142
const child = children[i]
@@ -169,7 +171,7 @@ export const EffectComposer = React.memo(
169171
if (normalPass) normalPass.enabled = false
170172
if (downSamplingPass) downSamplingPass.enabled = false
171173
}
172-
}, [composer, children, camera, normalPass, downSamplingPass, instance])
174+
}, [composer, children, camera, normalPass, downSamplingPass])
173175

174176
// Disable tone mapping because threejs disallows tonemapping on render targets
175177
useEffect(() => {
@@ -178,7 +180,7 @@ export const EffectComposer = React.memo(
178180
return () => {
179181
gl.toneMapping = currentTonemapping
180182
}
181-
}, [])
183+
}, [gl])
182184

183185
// Memoize state, otherwise it would trigger all consumers on every render
184186
const state = useMemo(

src/effects/Autofocus.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import React, {
1010
RefObject,
1111
useMemo,
1212
} from 'react'
13-
import { useThree, useFrame, createPortal, Vector3 } from '@react-three/fiber'
13+
import { useThree, useFrame, createPortal, type Vector3 } from '@react-three/fiber'
1414
import { CopyPass, DepthPickingPass, DepthOfFieldEffect } from 'postprocessing'
1515
import { easing } from 'maath'
1616

src/effects/Grid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ export const Grid = forwardRef(function Grid({ size, ...props }: GridProps, ref:
1616
useLayoutEffect(() => {
1717
if (size) effect.setSize(size.width, size.height)
1818
invalidate()
19-
}, [effect, size])
19+
}, [effect, size, invalidate])
2020
return <primitive ref={ref} object={effect} dispose={null} />
2121
})

src/effects/N8AO/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export const N8AO = forwardRef<N8AOPostPass, N8AOProps>(
4040
ref: Ref<N8AOPostPass>
4141
) => {
4242
const { camera, scene } = useThree()
43-
const effect = useMemo(() => new N8AOPostPass(scene, camera), [])
43+
const effect = useMemo(() => new N8AOPostPass(scene, camera), [camera, scene])
44+
45+
// TODO: implement dispose upstream; this effect has memory leaks without
4446
useLayoutEffect(() => {
4547
applyProps(effect.configuration, {
4648
color,
@@ -67,10 +69,13 @@ export const N8AO = forwardRef<N8AOPostPass, N8AOProps>(
6769
renderMode,
6870
halfRes,
6971
depthAwareUpsampling,
72+
effect,
7073
])
74+
7175
useLayoutEffect(() => {
7276
if (quality) effect.setQualityMode(quality.charAt(0).toUpperCase() + quality.slice(1))
73-
}, [quality])
77+
}, [effect, quality])
78+
7479
return <primitive ref={ref} object={effect} />
7580
}
7681
)

src/effects/Outline.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const Outline = forwardRef(function Outline(
5252
xRay,
5353
...props,
5454
}),
55+
// NOTE: `props` is an unstable reference, so we can't memoize it
56+
// eslint-disable-next-line react-hooks/exhaustive-deps
5557
[
5658
blendFunction,
5759
blur,

src/effects/SSAO.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const SSAO = forwardRef<SSAOEffect, SSAOProps>(function SSAO(props: SSAOP
3131
depthAwareUpsampling: true,
3232
...props,
3333
})
34-
}, [camera, normalPass, props])
34+
// NOTE: `props` is an unstable reference, so we can't memoize it
35+
// eslint-disable-next-line react-hooks/exhaustive-deps
36+
}, [camera, downSamplingPass, normalPass, resolutionScale])
3537
return <primitive ref={ref} object={effect} dispose={null} />
3638
})

src/effects/SSR/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const SSR = forwardRef<SSREffect, SSRProps>(function SSR(
6464

6565
ref: Ref<SSREffect>
6666
) {
67-
const { invalidate } = useThree()
67+
const invalidate = useThree((s) => s.invalidate)
6868
const { scene, camera } = useContext(EffectComposerContext)
6969
const effect = useMemo(
7070
() => new SSREffect(scene, camera, { ENABLE_BLUR, USE_MRT, ...props }),
@@ -83,7 +83,7 @@ export const SSR = forwardRef<SSREffect, SSRProps>(function SSR(
8383
}
8484
}
8585
}
86-
}, [api])
86+
}, [api, effect, invalidate])
8787

8888
return <primitive ref={ref} object={effect} {...props} />
8989
})

0 commit comments

Comments
 (0)