Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/effects/kuwahara.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<Kuwahara
radius={4} // filter radius in pixels
sectorCount={8} // number of sectors, 2 to 8
blendFunction={BlendFunction.NORMAL} // blend mode
/>
)
```

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. |
121 changes: 121 additions & 0 deletions src/effects/Kuwahara.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Uniform<number>>([
['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<KuwaharaEffect>
}

export const Kuwahara = /* @__PURE__ */ createEffectComponent<typeof KuwaharaEffect, KuwaharaProps>(KuwaharaEffect)
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
66 changes: 66 additions & 0 deletions src/tests/Kuwahara.test.tsx
Original file line number Diff line number Diff line change
@@ -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<KuwaharaEffect>()

const render = (radius: number, sectorCount: number) =>
root.render(
<EffectComposer>
<Kuwahara ref={ref} radius={radius} sectorCount={sectorCount} />
</EffectComposer>
)

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<KuwaharaEffect>()

await React.act(async () =>
root.render(
<EffectComposer>
<Kuwahara ref={ref} radius={10} />
</EffectComposer>
)
)
await flush()

await React.act(async () =>
root.render(
<EffectComposer>
<Kuwahara ref={ref} />
</EffectComposer>
)
)
await flush()

expect(ref.current!.radius).toBe(4)

await React.act(async () => root.render(null))
})
})
2 changes: 2 additions & 0 deletions src/tests/effects.smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -100,6 +101,7 @@ const SMOKE_CASES: SmokeCase[] = [
},
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
{ file: 'Kuwahara.tsx', label: 'Kuwahara', effect: (ref) => <Kuwahara ref={ref} /> },
{ file: 'LensDistortion.tsx', label: 'LensDistortion', effect: (ref) => <LensDistortion ref={ref} /> },
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => <LensFlare ref={ref} /> },
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
Expand Down
Loading