Skip to content

Commit 7a9c389

Browse files
committed
feat: add Sharpness effect
Laplacian sharpen with adjustable strength and sample radius, based on #253.
1 parent 96c6b31 commit 7a9c389

5 files changed

Lines changed: 162 additions & 0 deletions

File tree

‎docs/effects/sharpness.mdx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
title: Sharpness
3+
nav: 1
4+
---
5+
6+
Sharpens the image by boosting the difference between each pixel and its neighbors. Useful after FXAA/SMAA or a lowered `resolutionScale` to bring back crisp detail.
7+
8+
```jsx
9+
import { Sharpness } from '@react-three/postprocessing'
10+
import { BlendFunction } from 'postprocessing'
11+
12+
return (
13+
<Sharpness
14+
sharpness={1} // sharpening strength, 0 disables it
15+
radius={1} // distance to the sampled neighbors in pixels
16+
blendFunction={BlendFunction.NORMAL} // blend mode
17+
/>
18+
)
19+
```
20+
21+
## Props
22+
23+
| Name | Type | Default | Description |
24+
| ------------- | ------------- | -------------------- | -------------------------------------------- |
25+
| sharpness | Number | 1 | Sharpening strength, 0 disables it. |
26+
| radius | Number | 1 | Distance to the sampled neighbors in pixels. |
27+
| blendFunction | BlendFunction | BlendFunction.NORMAL | The blend function of this effect. |
28+
| opacity | Number | 1 | The opacity of this effect. |

‎src/effects/Sharpness.tsx‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BlendFunction, Effect, EffectAttribute } from 'postprocessing'
2+
import type { Ref } from 'react'
3+
import { Uniform } from 'three'
4+
import { createEffectComponent } from '../createEffectComponent'
5+
6+
const SharpnessShader = {
7+
fragmentShader: /* glsl */ `
8+
uniform float sharpness;
9+
uniform float radius;
10+
11+
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
12+
vec2 dx = vec2(texelSize.x * radius, 0.0);
13+
vec2 dy = vec2(0.0, texelSize.y * radius);
14+
15+
vec3 center = texture2D(inputBuffer, uv).rgb;
16+
vec3 neighbors = texture2D(inputBuffer, uv - dx).rgb
17+
+ texture2D(inputBuffer, uv + dx).rgb
18+
+ texture2D(inputBuffer, uv - dy).rgb
19+
+ texture2D(inputBuffer, uv + dy).rgb;
20+
21+
// Laplacian sharpen, sharpness 1 is the classic 5 / -1 kernel
22+
vec3 color = center + sharpness * (4.0 * center - neighbors);
23+
outputColor = vec4(max(color, 0.0), inputColor.a);
24+
}
25+
`,
26+
}
27+
28+
export class SharpnessEffect extends Effect {
29+
constructor({ blendFunction = BlendFunction.NORMAL, sharpness = 1, radius = 1 } = {}) {
30+
super('SharpnessEffect', SharpnessShader.fragmentShader, {
31+
blendFunction,
32+
attributes: EffectAttribute.CONVOLUTION,
33+
uniforms: new Map<string, Uniform<number>>([
34+
['sharpness', new Uniform(sharpness)],
35+
['radius', new Uniform(radius)],
36+
]),
37+
})
38+
}
39+
40+
/** Sharpening strength, 0 disables it. */
41+
get sharpness(): number {
42+
return this.uniforms.get('sharpness')!.value
43+
}
44+
45+
set sharpness(value: number) {
46+
this.uniforms.get('sharpness')!.value = value
47+
}
48+
49+
/** Distance to the sampled neighbors in pixels. */
50+
get radius(): number {
51+
return this.uniforms.get('radius')!.value
52+
}
53+
54+
set radius(value: number) {
55+
this.uniforms.get('radius')!.value = value
56+
}
57+
}
58+
59+
export type SharpnessProps = {
60+
blendFunction?: BlendFunction
61+
opacity?: number
62+
sharpness?: number
63+
radius?: number
64+
ref?: Ref<SharpnessEffect>
65+
}
66+
67+
export const Sharpness = /* @__PURE__ */ createEffectComponent<typeof SharpnessEffect, SharpnessProps>(SharpnessEffect)

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export * from './effects/Ramp'
3333
export * from './effects/ScanlineEffect'
3434
export * from './effects/SelectiveBloom'
3535
export * from './effects/Sepia'
36+
export * from './effects/Sharpness'
3637
export * from './effects/ShockWave'
3738
export * from './effects/SMAA'
3839
export * from './effects/SSAO'

‎src/tests/Sharpness.test.tsx‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { EffectAttribute } from 'postprocessing'
2+
import * as React from 'react'
3+
import { describe, expect, it } from 'vitest'
4+
import { EffectComposer } from '../EffectComposer'
5+
import { Sharpness, SharpnessEffect } from '../effects/Sharpness'
6+
import { flush, root } from './test-utils'
7+
8+
describe('Sharpness', () => {
9+
it('is a convolution effect, so it reads unprocessed neighbors', () => {
10+
const effect = new SharpnessEffect()
11+
expect(effect.getAttributes() & EffectAttribute.CONVOLUTION).toBe(EffectAttribute.CONVOLUTION)
12+
effect.dispose()
13+
})
14+
15+
it('applies sharpness and radius live, without reconstructing the effect', async () => {
16+
const ref = React.createRef<SharpnessEffect>()
17+
18+
const render = (sharpness: number, radius: number) =>
19+
root.render(
20+
<EffectComposer>
21+
<Sharpness ref={ref} sharpness={sharpness} radius={radius} />
22+
</EffectComposer>
23+
)
24+
25+
await React.act(async () => render(0.5, 1))
26+
await flush()
27+
const first = ref.current
28+
29+
await React.act(async () => render(2, 3))
30+
await flush()
31+
32+
expect(ref.current).toBe(first)
33+
expect(ref.current!.uniforms.get('sharpness')!.value).toBe(2)
34+
expect(ref.current!.uniforms.get('radius')!.value).toBe(3)
35+
36+
await React.act(async () => root.render(null))
37+
})
38+
39+
it('resets sharpness to its default when the prop is removed', async () => {
40+
const ref = React.createRef<SharpnessEffect>()
41+
42+
await React.act(async () =>
43+
root.render(
44+
<EffectComposer>
45+
<Sharpness ref={ref} sharpness={3} />
46+
</EffectComposer>
47+
)
48+
)
49+
await flush()
50+
51+
await React.act(async () =>
52+
root.render(
53+
<EffectComposer>
54+
<Sharpness ref={ref} />
55+
</EffectComposer>
56+
)
57+
)
58+
await flush()
59+
60+
expect(ref.current!.sharpness).toBe(1)
61+
62+
await React.act(async () => root.render(null))
63+
})
64+
})

‎src/tests/effects.smoke.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { Ramp } from '../effects/Ramp'
5555
import { Scanline } from '../effects/ScanlineEffect'
5656
import { SelectiveBloom } from '../effects/SelectiveBloom'
5757
import { Sepia } from '../effects/Sepia'
58+
import { Sharpness } from '../effects/Sharpness'
5859
import { ShockWave } from '../effects/ShockWave'
5960
import { SMAA } from '../effects/SMAA'
6061
import { SSAO } from '../effects/SSAO'
@@ -121,6 +122,7 @@ const SMOKE_CASES: SmokeCase[] = [
121122
effect: (ref) => <SelectiveBloom ref={ref} lights={[]} />,
122123
},
123124
{ file: 'Sepia.tsx', label: 'Sepia', effect: (ref) => <Sepia ref={ref} /> },
125+
{ file: 'Sharpness.tsx', label: 'Sharpness', effect: (ref) => <Sharpness ref={ref} /> },
124126
{ file: 'ShockWave.tsx', label: 'ShockWave', effect: (ref) => <ShockWave ref={ref} /> },
125127
{ file: 'SMAA.tsx', label: 'SMAA', effect: (ref) => <SMAA ref={ref} /> },
126128
{

0 commit comments

Comments
 (0)