From f0c28db525c668827caac5a84c67005b9237ff6b Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Sat, 10 Oct 2026 10:28:57 +0200 Subject: [PATCH] feat: add Kuwahara effect Anisotropic generalized Kuwahara filter for a painterly look, based on Maxime Heckel's article. --- docs/effects/kuwahara.mdx | 32 ++++++++ src/effects/Kuwahara.tsx | 121 +++++++++++++++++++++++++++++++ src/index.ts | 1 + src/tests/Kuwahara.test.tsx | 66 +++++++++++++++++ src/tests/effects.smoke.test.tsx | 2 + 5 files changed, 222 insertions(+) create mode 100644 docs/effects/kuwahara.mdx create mode 100644 src/effects/Kuwahara.tsx create mode 100644 src/tests/Kuwahara.test.tsx 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 860ac4d..332de78 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/LensFlare' export * from './effects/LUT' export * from './effects/Noise' 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 e006598..308de69 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 { 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: 'Kuwahara.tsx', label: 'Kuwahara', effect: (ref) => }, { file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => }, { file: 'LUT.tsx', label: 'LUT', effect: (ref) => }, { file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => },