diff --git a/docs/effects/lens-distortion.mdx b/docs/effects/lens-distortion.mdx
new file mode 100644
index 0000000..42ec633
--- /dev/null
+++ b/docs/effects/lens-distortion.mdx
@@ -0,0 +1,28 @@
+---
+title: LensDistortion
+nav: 1
+---
+
+A radial lens distortion effect. Positive `distortion` values give a barrel (fisheye-like) look, negative values a pincushion look.
+
+```jsx
+import { LensDistortion } from '@react-three/postprocessing'
+
+return (
+
+)
+```
+
+## Props
+
+| Name | Type | Default | Description |
+| -------------- | ------- | ------- | --------------------------------------- |
+| distortion | Vector2 | [0, 0] | The radial distortion coefficients. |
+| principalPoint | Vector2 | [0, 0] | The center point offset. |
+| focalLength | Vector2 | [1, 1] | The focal length. Lower values zoom in. |
+| skew | Number | 0 | The skew factor in radians. |
diff --git a/src/effects/LensDistortion.tsx b/src/effects/LensDistortion.tsx
new file mode 100644
index 0000000..e5e0ae3
--- /dev/null
+++ b/src/effects/LensDistortion.tsx
@@ -0,0 +1,20 @@
+import type { ReactThreeFiber } from '@react-three/fiber'
+import { LensDistortionEffect } from 'postprocessing'
+import type { Ref } from 'react'
+import { createEffectComponent, type EffectOptions } from '../createEffectComponent'
+
+// postprocessing's .d.ts types distortion/principalPoint/focalLength as
+// required, but the constructor defaults all of them.
+export type LensDistortionProps = Omit<
+ EffectOptions,
+ 'distortion' | 'principalPoint' | 'focalLength'
+> & {
+ distortion?: ReactThreeFiber.Vector2
+ principalPoint?: ReactThreeFiber.Vector2
+ focalLength?: ReactThreeFiber.Vector2
+ ref?: Ref
+}
+
+export const LensDistortion = /* @__PURE__ */ createEffectComponent(
+ LensDistortionEffect
+)
diff --git a/src/index.ts b/src/index.ts
index 860ac4d..17d072d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -20,6 +20,7 @@ export * from './effects/Glitch'
export * from './effects/GodRays'
export * from './effects/Grid'
export * from './effects/HueSaturation'
+export * from './effects/LensDistortion'
export * from './effects/LensFlare'
export * from './effects/LUT'
export * from './effects/Noise'
diff --git a/src/tests/LensDistortion.test.tsx b/src/tests/LensDistortion.test.tsx
new file mode 100644
index 0000000..4fc4bcc
--- /dev/null
+++ b/src/tests/LensDistortion.test.tsx
@@ -0,0 +1,78 @@
+import { LensDistortionEffect } from 'postprocessing'
+import * as React from 'react'
+import { Vector2 } from 'three'
+import { describe, expect, it } from 'vitest'
+import { EffectComposer } from '../EffectComposer'
+import { LensDistortion } from '../effects/LensDistortion'
+import { flush, root } from './test-utils'
+
+describe('LensDistortion', () => {
+ it('coerces tuple props into the effect Vector2 uniforms', async () => {
+ const ref = React.createRef()
+
+ await React.act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ await flush()
+
+ expect(ref.current!.distortion).toBeInstanceOf(Vector2)
+ expect(ref.current!.distortion.toArray()).toEqual([0.5, 0.25])
+ expect(ref.current!.focalLength.toArray()).toEqual([0.7, 0.8])
+ expect(ref.current!.skew).toBeCloseTo(0.1)
+
+ await React.act(async () => root.render(null))
+ })
+
+ it('applies distortion live, without reconstructing the effect', async () => {
+ const ref = React.createRef()
+
+ const render = (x: number) =>
+ root.render(
+
+
+
+ )
+
+ await React.act(async () => render(0.1))
+ await flush()
+ const first = ref.current
+
+ await React.act(async () => render(0.6))
+ await flush()
+
+ expect(ref.current).toBe(first)
+ expect(ref.current!.distortion.x).toBeCloseTo(0.6)
+
+ await React.act(async () => root.render(null))
+ })
+
+ it('resets distortion to its default when the prop is removed', async () => {
+ const ref = React.createRef()
+
+ await React.act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ await flush()
+
+ await React.act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ await flush()
+
+ expect(ref.current!.distortion.toArray()).toEqual([0, 0])
+
+ await React.act(async () => root.render(null))
+ })
+})
diff --git a/src/tests/effects.smoke.test.tsx b/src/tests/effects.smoke.test.tsx
index e006598..9c2173e 100644
--- a/src/tests/effects.smoke.test.tsx
+++ b/src/tests/effects.smoke.test.tsx
@@ -42,6 +42,7 @@ import { Glitch } from '../effects/Glitch'
import { GodRays } from '../effects/GodRays'
import { Grid } from '../effects/Grid'
import { HueSaturation } from '../effects/HueSaturation'
+import { LensDistortion } from '../effects/LensDistortion'
import { LensFlare } from '../effects/LensFlare'
import { LUT } from '../effects/LUT'
import { Noise } from '../effects/Noise'
@@ -99,6 +100,7 @@ const SMOKE_CASES: SmokeCase[] = [
},
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => },
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => },
+ { file: 'LensDistortion.tsx', label: 'LensDistortion', effect: (ref) => },
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => },
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => },
{ file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => },