diff --git a/docs/effects/kuwahara.mdx b/docs/effects/kuwahara.mdx
new file mode 100644
index 0000000..41b7514
--- /dev/null
+++ b/docs/effects/kuwahara.mdx
@@ -0,0 +1,32 @@
+---
+title: Kuwahara
+nav: 1
+---
+
+A painterly filter that smooths flat areas into brush-like strokes while keeping edges sharp. It is an anisotropic generalized Kuwahara filter: the sampling area follows the local edge direction.
+
+Based on [Maxime Heckel's article on painterly shaders](https://blog.maximeheckel.com/posts/on-crafting-painterly-shaders/).
+
+```jsx
+import { Kuwahara } from '@react-three/postprocessing'
+import { BlendFunction } from 'postprocessing'
+
+return (
+
+)
+```
+
+The filter takes about `radius * sectorCount * 3` texture samples per pixel, so large radii get expensive quickly. Lower `sectorCount` when raising `radius`.
+
+## Props
+
+| Name | Type | Default | Description |
+| ------------- | ------------- | -------------------- | ------------------------------------------------------------- |
+| radius | Number | 4 | The filter radius in pixels. |
+| sectorCount | Number | 8 | The number of sectors, 2 to 8. Fewer is cheaper but blockier. |
+| blendFunction | BlendFunction | BlendFunction.NORMAL | The blend function of this effect. |
+| opacity | Number | 1 | The opacity of this effect. |
diff --git a/src/effects/Kuwahara.tsx b/src/effects/Kuwahara.tsx
new file mode 100644
index 0000000..f0ca89a
--- /dev/null
+++ b/src/effects/Kuwahara.tsx
@@ -0,0 +1,121 @@
+import { BlendFunction, Effect, EffectAttribute } from 'postprocessing'
+import type { Ref } from 'react'
+import { Uniform } from 'three'
+import { createEffectComponent } from '../createEffectComponent'
+
+// Anisotropic generalized Kuwahara filter, based on Kyprianidis et al. and
+// https://blog.maximeheckel.com/posts/on-crafting-painterly-shaders/
+const KuwaharaShader = {
+ fragmentShader: /* glsl */ `
+ uniform float radius;
+ uniform int sectorCount;
+
+ float luma(const in vec2 uv) {
+ return dot(texture2D(inputBuffer, uv).rgb, vec3(0.299, 0.587, 0.114));
+ }
+
+ // Sobel-based structure tensor (Jxx, Jyy, Jxy), sampled wider for larger radii to smooth it
+ vec3 structureTensor(const in vec2 uv) {
+ vec2 d = texelSize * max(1.0, radius * 0.5);
+ float tl = luma(uv + vec2(-d.x, d.y));
+ float tc = luma(uv + vec2(0.0, d.y));
+ float tr = luma(uv + d);
+ float ml = luma(uv - vec2(d.x, 0.0));
+ float mr = luma(uv + vec2(d.x, 0.0));
+ float bl = luma(uv - d);
+ float bc = luma(uv - vec2(0.0, d.y));
+ float br = luma(uv + vec2(d.x, -d.y));
+ float gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
+ float gy = (tl + 2.0 * tc + tr) - (bl + 2.0 * bc + br);
+ return vec3(gx * gx, gy * gy, gx * gy);
+ }
+
+ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
+ vec3 st = structureTensor(uv);
+ float diff = st.x - st.y;
+ float root = sqrt(diff * diff + 4.0 * st.z * st.z);
+ float anisotropy = root / (st.x + st.y + 1e-7);
+ float phi = 0.5 * atan(2.0 * st.z, diff + 1e-7);
+
+ // Ellipse squeezed across the edge and stretched along it
+ float c = cos(phi);
+ float s = sin(phi);
+ mat2 transform = mat2(c, s, -s, c) * mat2(1.0 / (1.0 + anisotropy), 0.0, 0.0, 1.0 + anisotropy);
+
+ int count = clamp(sectorCount, 2, 8);
+ float sectorAngle = 6.28318531 / float(count);
+ vec3 center = texture2D(inputBuffer, uv).rgb;
+ vec3 colorSum = vec3(0.0);
+ float weightSum = 0.0;
+
+ for (int i = 0; i < 8; i++) {
+ if (i >= count) break;
+
+ vec3 sum = center;
+ vec3 sumSq = center * center;
+ float n = 1.0;
+
+ for (float r = 1.0; r <= radius; r += 1.0) {
+ for (int k = 0; k < 3; k++) {
+ float a = (float(i) + (float(k) + 0.5) / 3.0) * sectorAngle;
+ vec2 offset = transform * (vec2(cos(a), sin(a)) * r) * texelSize;
+ vec3 color = texture2D(inputBuffer, uv + offset).rgb;
+ sum += color;
+ sumSq += color * color;
+ n += 1.0;
+ }
+ }
+
+ vec3 mean = sum / n;
+ vec3 variance = abs(sumSq / n - mean * mean);
+ // Sectors with low variance dominate, which keeps edges sharp
+ float weight = 1.0 / (1.0 + pow(255.0 * sqrt(variance.r + variance.g + variance.b), 4.0));
+ colorSum += mean * weight;
+ weightSum += weight;
+ }
+
+ outputColor = vec4(colorSum / weightSum, inputColor.a);
+ }
+ `,
+}
+
+export class KuwaharaEffect extends Effect {
+ constructor({ blendFunction = BlendFunction.NORMAL, radius = 4, sectorCount = 8 } = {}) {
+ super('KuwaharaEffect', KuwaharaShader.fragmentShader, {
+ blendFunction,
+ attributes: EffectAttribute.CONVOLUTION,
+ uniforms: new Map>([
+ ['radius', new Uniform(radius)],
+ ['sectorCount', new Uniform(sectorCount)],
+ ]),
+ })
+ }
+
+ /** Filter radius in pixels. Cost grows linearly with it. */
+ get radius(): number {
+ return this.uniforms.get('radius')!.value
+ }
+
+ set radius(value: number) {
+ this.uniforms.get('radius')!.value = value
+ }
+
+ /** Number of sectors, 2 to 8. Fewer is cheaper but blockier. */
+ get sectorCount(): number {
+ return this.uniforms.get('sectorCount')!.value
+ }
+
+ set sectorCount(value: number) {
+ this.uniforms.get('sectorCount')!.value = value
+ }
+}
+
+export type KuwaharaProps = {
+ blendFunction?: BlendFunction
+ opacity?: number
+ radius?: number
+ sectorCount?: number
+ ref?: Ref
+}
+
+export const Kuwahara = /* @__PURE__ */ createEffectComponent(KuwaharaEffect)
diff --git a/src/index.ts b/src/index.ts
index 17d072d..4c8bb4f 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/Kuwahara'
export * from './effects/LensDistortion'
export * from './effects/LensFlare'
export * from './effects/LUT'
diff --git a/src/tests/Kuwahara.test.tsx b/src/tests/Kuwahara.test.tsx
new file mode 100644
index 0000000..20032f3
--- /dev/null
+++ b/src/tests/Kuwahara.test.tsx
@@ -0,0 +1,66 @@
+import { EffectAttribute } from 'postprocessing'
+import * as React from 'react'
+import { describe, expect, it } from 'vitest'
+import { EffectComposer } from '../EffectComposer'
+import { Kuwahara, KuwaharaEffect } from '../effects/Kuwahara'
+import { flush, root } from './test-utils'
+
+describe('Kuwahara', () => {
+ it('is a convolution effect, so it reads unprocessed neighbors', () => {
+ const effect = new KuwaharaEffect()
+ expect(effect.getAttributes() & EffectAttribute.CONVOLUTION).toBe(EffectAttribute.CONVOLUTION)
+ effect.dispose()
+ })
+
+ it('applies radius and sectorCount live, without reconstructing the effect', async () => {
+ const ref = React.createRef()
+
+ const render = (radius: number, sectorCount: number) =>
+ root.render(
+
+
+
+ )
+
+ await React.act(async () => render(2, 4))
+ await flush()
+ const first = ref.current
+ expect(first!.radius).toBe(2)
+ expect(first!.sectorCount).toBe(4)
+
+ await React.act(async () => render(6, 8))
+ await flush()
+
+ expect(ref.current).toBe(first)
+ expect(ref.current!.uniforms.get('radius')!.value).toBe(6)
+ expect(ref.current!.uniforms.get('sectorCount')!.value).toBe(8)
+
+ await React.act(async () => root.render(null))
+ })
+
+ it('resets radius 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!.radius).toBe(4)
+
+ await React.act(async () => root.render(null))
+ })
+})
diff --git a/src/tests/effects.smoke.test.tsx b/src/tests/effects.smoke.test.tsx
index 9c2173e..a594c3c 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 { Kuwahara } from '../effects/Kuwahara'
import { LensDistortion } from '../effects/LensDistortion'
import { LensFlare } from '../effects/LensFlare'
import { LUT } from '../effects/LUT'
@@ -100,6 +101,7 @@ const SMOKE_CASES: SmokeCase[] = [
},
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => },
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => },
+ { file: 'Kuwahara.tsx', label: 'Kuwahara', effect: (ref) => },
{ file: 'LensDistortion.tsx', label: 'LensDistortion', effect: (ref) => },
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => },
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => },