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
34 changes: 34 additions & 0 deletions docs/effects/linocut.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
title: Linocut
nav: 1
---

Turns the scene into a high-contrast black and white image with carved, swirling lines, like a linocut or woodcut print.

Ported from [TresJS post-processing](https://post-processing.tresjs.org/api/pmndrs/linocut).

```jsx
import { Linocut } from '@react-three/postprocessing'
import { BlendFunction } from 'postprocessing'

return (
<Linocut
scale={0.85} // line width and swirl amount, 0 to 1
noiseScale={0} // noise added before thresholding, 0 to 1
center={[0.5, 0.5]} // center of the line pattern
rotation={0} // rotation of the line pattern in radians
blendFunction={BlendFunction.NORMAL} // blend mode
/>
)
```

## Props

| Name | Type | Default | Description |
| ------------- | ------------- | -------------------- | ------------------------------------------------------ |
| scale | Number | 0.85 | Line width and swirl amount, 0 to 1. |
| noiseScale | Number | 0 | Noise added before thresholding, 0 to 1. |
| center | Vector2 | [0.5, 0.5] | Center of the line pattern in normalized screen space. |
| rotation | Number | 0 | Rotation of the line pattern in radians. |
| blendFunction | BlendFunction | BlendFunction.NORMAL | The blend function of this effect. |
| opacity | Number | 1 | The opacity of this effect. |
108 changes: 108 additions & 0 deletions src/effects/Linocut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { ReactThreeFiber } from '@react-three/fiber'
import { BlendFunction, Effect } from 'postprocessing'
import type { Ref } from 'react'
import { Uniform, Vector2 } from 'three'
import { createEffectComponent } from '../createEffectComponent'

// Ported from TresJS post-processing (MIT), https://github.com/Tresjs/post-processing
const LinocutShader = {
fragmentShader: /* glsl */ `
uniform float scale;
uniform float noiseScale;
uniform vec2 center;
uniform float rotation;

float noise(const in vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123);
}

void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 fragCoord = uv * resolution;
vec2 d = fragCoord - center * resolution;
d *= mat2(cos(rotation), -sin(rotation), sin(rotation), cos(rotation));

// Swirled radial coordinates drive the carved line pattern
float r = length(d) / (1000.0 / max(scale, 0.01));
float a = atan(d.y, d.x) + scale * (0.5 - r) / 0.5;
vec2 uvt = center * resolution + r * vec2(cos(a), sin(a));
float c = 0.75 + 0.25 * sin(uvt.x * 1000.0 * max(scale, 0.01));

// Input is already linear, so luma thresholds directly and the result stays linear
float l = dot(inputColor.rgb, vec3(0.299, 0.587, 0.114));
l += noiseScale * (noise(uv * 10.0) - 0.5);

float f = smoothstep(0.5 * c, c, l);
f = smoothstep(0.0, 0.5, f);

outputColor = vec4(vec3(f), inputColor.a);
}
`,
}

export class LinocutEffect extends Effect {
constructor({
blendFunction = BlendFunction.NORMAL,
scale = 0.85,
noiseScale = 0,
center = new Vector2(0.5, 0.5),
rotation = 0,
} = {}) {
super('LinocutEffect', LinocutShader.fragmentShader, {
blendFunction,
uniforms: new Map<string, Uniform>([
['scale', new Uniform(scale)],
['noiseScale', new Uniform(noiseScale)],
['center', new Uniform(center)],
['rotation', new Uniform(rotation)],
]),
})
}

/** Line width and swirl amount, 0 to 1. */
get scale(): number {
return this.uniforms.get('scale')!.value
}

set scale(value: number) {
this.uniforms.get('scale')!.value = value
}

/** Noise added before thresholding, 0 to 1. */
get noiseScale(): number {
return this.uniforms.get('noiseScale')!.value
}

set noiseScale(value: number) {
this.uniforms.get('noiseScale')!.value = value
}

/** Center of the line pattern in normalized screen coordinates. */
get center(): Vector2 {
return this.uniforms.get('center')!.value
}

set center(value: Vector2) {
this.uniforms.get('center')!.value = value
}

/** Rotation of the line pattern in radians. */
get rotation(): number {
return this.uniforms.get('rotation')!.value
}

set rotation(value: number) {
this.uniforms.get('rotation')!.value = value
}
}

export type LinocutProps = {
blendFunction?: BlendFunction
opacity?: number
scale?: number
noiseScale?: number
center?: ReactThreeFiber.Vector2
rotation?: number
ref?: Ref<LinocutEffect>
}

export const Linocut = /* @__PURE__ */ createEffectComponent<typeof LinocutEffect, LinocutProps>(LinocutEffect)
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './effects/GodRays'
export * from './effects/Grid'
export * from './effects/HueSaturation'
export * from './effects/LensFlare'
export * from './effects/Linocut'
export * from './effects/LUT'
export * from './effects/Noise'
export * from './effects/Outline'
Expand Down
77 changes: 77 additions & 0 deletions src/tests/Linocut.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as React from 'react'
import { Vector2 } from 'three'
import { describe, expect, it } from 'vitest'
import { EffectComposer } from '../EffectComposer'
import { Linocut, LinocutEffect } from '../effects/Linocut'
import { flush, root } from './test-utils'

describe('Linocut', () => {
it('coerces a tuple center into the Vector2 uniform', async () => {
const ref = React.createRef<LinocutEffect>()

await React.act(async () =>
root.render(
<EffectComposer>
<Linocut ref={ref} center={[0.25, 0.75]} />
</EffectComposer>
)
)
await flush()

expect(ref.current!.center).toBeInstanceOf(Vector2)
expect(ref.current!.center.toArray()).toEqual([0.25, 0.75])

await React.act(async () => root.render(null))
})

it('applies props live, without reconstructing the effect', async () => {
const ref = React.createRef<LinocutEffect>()

const render = (scale: number, rotation: number) =>
root.render(
<EffectComposer>
<Linocut ref={ref} scale={scale} rotation={rotation} noiseScale={scale} />
</EffectComposer>
)

await React.act(async () => render(0.5, 0))
await flush()
const first = ref.current

await React.act(async () => render(0.9, 1))
await flush()

expect(ref.current).toBe(first)
expect(ref.current!.uniforms.get('scale')!.value).toBeCloseTo(0.9)
expect(ref.current!.uniforms.get('noiseScale')!.value).toBeCloseTo(0.9)
expect(ref.current!.uniforms.get('rotation')!.value).toBe(1)

await React.act(async () => root.render(null))
})

it('resets center to its default when the prop is removed', async () => {
const ref = React.createRef<LinocutEffect>()

await React.act(async () =>
root.render(
<EffectComposer>
<Linocut ref={ref} center={[0.1, 0.2]} />
</EffectComposer>
)
)
await flush()

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

expect(ref.current!.center.toArray()).toEqual([0.5, 0.5])

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 @@ -43,6 +43,7 @@ import { GodRays } from '../effects/GodRays'
import { Grid } from '../effects/Grid'
import { HueSaturation } from '../effects/HueSaturation'
import { LensFlare } from '../effects/LensFlare'
import { Linocut } from '../effects/Linocut'
import { LUT } from '../effects/LUT'
import { Noise } from '../effects/Noise'
import { Outline } from '../effects/Outline'
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: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => <LensFlare ref={ref} /> },
{ file: 'Linocut.tsx', label: 'Linocut', effect: (ref) => <Linocut ref={ref} /> },
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
{ file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
{ file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
Expand Down
Loading