Skip to content
85 changes: 56 additions & 29 deletions src/EffectComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,8 @@ type ComposerState = {
const isConvolution = (effect: Effect): boolean =>
(effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION

/**
* autoClear/toneMapping get force-set and never restored by whoever sets
* them. Ref-counted per (renderer, property) since composers can share a
* renderer; skips restoring if the value already changed since acquire.
*/
// autoClear/toneMapping get force-set and never restored. Ref-counted per
// (renderer, property) since composers can share a renderer.
function createRendererPropertyGuard<K extends 'autoClear' | 'toneMapping'>(property: K) {
const refs = new WeakMap<
WebGLRenderer,
Expand Down Expand Up @@ -97,11 +94,21 @@ function createRendererPropertyGuard<K extends 'autoClear' | 'toneMapping'>(prop
const autoClearGuard = /* @__PURE__ */ createRendererPropertyGuard('autoClear')
const toneMappingGuard = /* @__PURE__ */ createRendererPropertyGuard('toneMapping')

/**
* Groups a flat, ordered list of Effect/Pass instances into actual composer
* passes, merging consecutive non-convolution Effects into a single
* EffectPass.
*/
// Only passes buildPasses itself constructs - not a user's own EffectPass
// rendered directly as a child (still just `Pass`-instanceof passthrough
// below), which owns its own lifecycle.
const generatedPasses = /* @__PURE__ */ new WeakSet<Pass>()

// Not pass.dispose() - EffectPass.dispose() also disposes the effects it
// wraps, which are owned/reused elsewhere. setEffects([]) detaches their
// listeners first.
function disposeGeneratedPass(pass: Pass): void {
if (!generatedPasses.has(pass)) return
;(pass as unknown as { setEffects(effects: never[]): void }).setEffects([])
Pass.prototype.dispose.call(pass)
}

// Consecutive non-convolution Effects share one EffectPass; Pass/convolution nodes get their own.
function buildPasses(nodes: Array<Effect | Pass>, camera: Camera): Pass[] {
const passes: Pass[] = []

Expand All @@ -120,7 +127,9 @@ function buildPasses(nodes: Array<Effect | Pass>, camera: Camera): Pass[] {
}
}

passes.push(new EffectPass(camera, ...effects))
const pass = new EffectPass(camera, ...effects)
generatedPasses.add(pass)
passes.push(pass)
} else if (node instanceof Pass) {
passes.push(node)
}
Expand Down Expand Up @@ -148,9 +157,7 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
const scene = _scene || defaultScene
const camera = _camera || defaultCamera

// EffectComposer owns WebGL resources, so it must be created and
// disposed inside an effect lifecycle. useMemo is not suitable here
// because React may discard memoized values without running cleanup.
// useMemo can't own WebGL resources - React may discard it without cleanup.
const [composerState, setComposerState] = useState<ComposerState | null>(null)

useEffect(() => {
Expand Down Expand Up @@ -179,6 +186,10 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
setComposerState({ composer: effectComposer, normalPass, downSamplingPass })

return () => {
// The rebuild effect below may not have detached its passes yet
// (composerState only updates next render) - without this, dispose()
// would kill effects the new composer is about to reuse.
for (const pass of effectComposer.passes) disposeGeneratedPass(pass)
effectComposer.dispose()
autoClearGuard.release(gl)
}
Expand All @@ -204,25 +215,38 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
enabled ? renderPriority : 0
)

// Passes are derived from the actual r3f scene graph rather than tracked
// incrementally, so the list always matches current JSX order — including
// through wrapper components — even after a reorder or a remount.
// Derived from the r3f scene graph (not tracked incrementally) so order
// always matches JSX, even through wrapper components or a reorder.
const group = useRef<Group>(null!)
const nodesRef = useRef<Array<Effect | Pass>>([])
const [nodesVersion, setNodesVersion] = useState(0)

// Runs every render (children has no stable identity) but only touches
// nodesRef/nodesVersion, never the composer - the rebuild below only
// fires when the resolved node list actually changes.
useLayoutEffect(() => {
if (!composerState) return
const { composer, normalPass, downSamplingPass } = composerState

const passes: Pass[] = []
const groupInstance = (group.current as Group & { __r3f: Instance<Group> }).__r3f
const nodes = groupInstance
? groupInstance.children
.map((child) => child.object)
.filter((object): object is Effect | Pass => object instanceof Effect || object instanceof Pass)
: []

const previous = nodesRef.current
const unchanged = nodes.length === previous.length && nodes.every((node, i) => node === previous[i])
if (unchanged) return
nodesRef.current = nodes
setNodesVersion((v) => v + 1)
})

// Only re-runs when nodesVersion/composerState/camera change - React's
// own dependency bailout, so create/cleanup pairing stays correct.
useLayoutEffect(() => {
if (!composerState) return
const { composer, normalPass, downSamplingPass } = composerState

if (groupInstance) {
const nodes = groupInstance.children.map((child) => child.object).filter(
(object): object is Effect | Pass => object instanceof Effect || object instanceof Pass
)

passes.push(...buildPasses(nodes, camera))
}
const passes = buildPasses(nodesRef.current, camera)

for (const pass of passes) composer.addPass(pass)

Expand All @@ -232,11 +256,14 @@ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
}

return () => {
for (const pass of passes) composer.removePass(pass)
for (const pass of passes) {
composer.removePass(pass)
disposeGeneratedPass(pass)
}
if (normalPass) normalPass.enabled = false
if (downSamplingPass) downSamplingPass.enabled = false
}
}, [composerState, children, camera])
}, [composerState, nodesVersion, camera])

// Disable tone mapping because threejs disallows tonemapping on render targets
useEffect(() => {
Expand Down
103 changes: 75 additions & 28 deletions src/effects/ASCII.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// https://twitter.com/emilwidlund/status/1652386482420609024

import { Effect } from 'postprocessing'
import { Ref, useMemo } from 'react'
import { CanvasTexture, Color, NearestFilter, RepeatWrapping, Texture, Uniform } from 'three'
import { useDispose } from '../util'
import type { Ref } from 'react'
import { CanvasTexture, Color, type ColorRepresentation, NearestFilter, RepeatWrapping, Texture, Uniform } from 'three'
import { createEffectComponent } from '../createEffectComponent'

const fragment = /* glsl */ `
uniform sampler2D uCharacters;
Expand Down Expand Up @@ -47,25 +47,29 @@ const fragment = /* glsl */ `
}
`

interface IASCIIEffectProps {
export type ASCIIProps = {
font?: string
characters?: string
fontSize?: number
cellSize?: number
color?: string
color?: ColorRepresentation
invert?: boolean
ref?: Ref<ASCIIEffect>
}

class ASCIIEffect extends Effect {
private _font: string
private _characters: string
private _fontSize: number

constructor({
font = 'arial',
characters = ` .:,'-^=*+?!|0#X%WM@`,
fontSize = 54,
cellSize = 16,
color = '#ffffff',
invert = false,
}: Omit<IASCIIEffectProps, 'ref'> = {}) {
}: Omit<ASCIIProps, 'ref'> = {}) {
const uniforms = new Map<string, Uniform>([
['uCharacters', new Uniform(new Texture())],
['uCellSize', new Uniform(cellSize)],
Expand All @@ -76,11 +80,71 @@ class ASCIIEffect extends Effect {

super('ASCIIEffect', fragment, { uniforms })

const charactersTextureUniform = this.uniforms.get('uCharacters')
this._font = font
this._characters = characters
this._fontSize = fontSize
this.updateCharactersTexture()
}

if (charactersTextureUniform) {
charactersTextureUniform.value = this.createCharactersTexture(characters, font, fontSize)
}
get cellSize(): number {
return this.uniforms.get('uCellSize')!.value
}

set cellSize(value: number) {
this.uniforms.get('uCellSize')!.value = value
}

get invert(): boolean {
return this.uniforms.get('uInvert')!.value
}

set invert(value: boolean) {
this.uniforms.get('uInvert')!.value = value
}

get color(): Color {
return this.uniforms.get('uColor')!.value
}

set color(value: ColorRepresentation) {
this.uniforms.get('uColor')!.value.set(value)
}

get font(): string {
return this._font
}

set font(value: string) {
this._font = value
this.updateCharactersTexture()
}

get characters(): string {
return this._characters
}

set characters(value: string) {
this._characters = value
this.uniforms.get('uCharactersCount')!.value = value.length
this.updateCharactersTexture()
}

get fontSize(): number {
return this._fontSize
}

set fontSize(value: number) {
this._fontSize = value
this.updateCharactersTexture()
}

// Regenerates the character atlas texture - characters/font/fontSize have
// no cheaper live update path, unlike the plain-uniform props above.
private updateCharactersTexture(): void {
const uniform = this.uniforms.get('uCharacters')!
const previous = uniform.value as Texture
uniform.value = this.createCharactersTexture(this._characters, this._font, this._fontSize)
previous.dispose()
}

/** Draws the characters on a Canvas and returns a texture */
Expand Down Expand Up @@ -116,21 +180,4 @@ class ASCIIEffect extends Effect {
}
}

export function ASCII({
font = 'arial',
characters = ` .:,'-^=*+?!|0#X%WM@`,
fontSize = 54,
cellSize = 16,
color = '#ffffff',
invert = false,
ref,
}: IASCIIEffectProps) {
const effect = useMemo(
() => new ASCIIEffect({ characters, font, fontSize, cellSize, color, invert }),
[characters, fontSize, cellSize, color, invert, font]
)

useDispose(effect)

return <primitive ref={ref} object={effect} />
}
export const ASCII = /* @__PURE__ */ createEffectComponent<typeof ASCIIEffect, ASCIIProps>(ASCIIEffect)
22 changes: 2 additions & 20 deletions src/effects/Autofocus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,6 @@ import { Mesh, Vector3 } from 'three'
import { EffectComposerContext } from '../EffectComposer'
import { DepthOfField } from './DepthOfField'

// EffectComposerImpl.dispose() disposes every pass it currently holds —
// including these two, since they're added via composer.addPass below.
// When Autofocus unmounts alongside its ancestor EffectComposer (e.g. a
// full tree unmount), both the composer's own teardown AND this
// component's cleanup effect would dispose the same instances. Wrapping
// dispose here makes it safe no matter which caller gets there first.
function makeDisposeIdempotent<T extends { dispose: () => void }>(instance: T): T {
let disposed = false
const dispose = instance.dispose.bind(instance)
instance.dispose = () => {
if (disposed) return
disposed = true
dispose()
}
return instance
}

export type AutofocusProps = ComponentProps<typeof DepthOfField> & {
target?: R3FVector3
/** should the target follow the pointer */
Expand Down Expand Up @@ -71,9 +54,8 @@ export function Autofocus({
const pointer = useThree(({ pointer }) => pointer)
const { composer, camera } = useContext(EffectComposerContext)

// see: https://codesandbox.io/s/depthpickingpass-x130hg
const [depthPickingPass] = useState(() => makeDisposeIdempotent(new DepthPickingPass()))
const [copyPass] = useState(() => makeDisposeIdempotent(new CopyPass()))
const [depthPickingPass] = useState(() => new DepthPickingPass())
const [copyPass] = useState(() => new CopyPass())
useEffect(() => {
composer.addPass(depthPickingPass)
composer.addPass(copyPass)
Expand Down
36 changes: 32 additions & 4 deletions src/effects/Bloom.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { BlendFunction, BloomEffect } from 'postprocessing'
import { wrapEffect } from '../wrapEffect'
import type { Ref } from 'react'
import { useMemo } from 'react'
import { createEffectComponent, type EffectOptions } from '../createEffectComponent'

export const Bloom = /* @__PURE__ */ wrapEffect(BloomEffect, {
blendFunction: BlendFunction.ADD,
})
type BloomOptions = EffectOptions<typeof BloomEffect>

const BloomImpl = /* @__PURE__ */ createEffectComponent<typeof BloomEffect, BloomOptions>(BloomEffect)

export type BloomProps = BloomOptions & { opacity?: number; ref?: Ref<BloomEffect> }

// luminanceThreshold/luminanceSmoothing/mipmapBlur/radius/levels/resolution*
// have no live setter in postprocessing - routed through args so they still
// work as plain props, just via reconstruction instead of mutation.
export function Bloom({
blendFunction = BlendFunction.ADD,
luminanceThreshold,
luminanceSmoothing,
mipmapBlur,
radius,
levels,
resolutionScale,
resolutionX,
resolutionY,
...liveProps
}: BloomProps) {
const args = useMemo<[BloomOptions]>(
() => [
{ luminanceThreshold, luminanceSmoothing, mipmapBlur, radius, levels, resolutionScale, resolutionX, resolutionY },
],
[luminanceThreshold, luminanceSmoothing, mipmapBlur, radius, levels, resolutionScale, resolutionX, resolutionY]
)
return <BloomImpl blendFunction={blendFunction} args={args} {...liveProps} />
}
7 changes: 5 additions & 2 deletions src/effects/BrightnessContrast.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { BrightnessContrastEffect } from 'postprocessing'
import { wrapEffect } from '../wrapEffect'
import { createEffectComponent, type EffectOptions } from '../createEffectComponent'

export const BrightnessContrast = /* @__PURE__ */ wrapEffect(BrightnessContrastEffect)
export const BrightnessContrast = /* @__PURE__ */ createEffectComponent<
typeof BrightnessContrastEffect,
EffectOptions<typeof BrightnessContrastEffect>
>(BrightnessContrastEffect)
Loading