('/images/image-filters/0018.webp');
+
+ useEffect(() => {
+ if (imageIdx >= 0) {
+ const name = imageFiles[imageIdx];
+ const img = new Image();
+ img.src = `/images/image-filters/${name}`;
+ img.onload = () => setImage(img);
+ }
+ }, [imageIdx]);
+
+ const handleClick = useCallback(() => {
+ setImageIdx((prev) => (prev + 1) % imageFiles.length);
+ }, []);
+
+ const setImageWithoutStatus = useCallback((img?: HTMLImageElement) => {
+ setImage(img ?? '');
+ setImageIdx(-1);
+ }, []);
+
+ const [params, setParams] = useControls(() => {
+ const presets = Object.fromEntries(
+ halftoneLinesPresets.map(({ name, params: { worldWidth, worldHeight, ...preset } }) => [
+ name,
+ button(() => setParamsSafe(params, setParams, preset)),
+ ])
+ );
+ return {
+ colorBack: { value: toHsla(defaults.colorBack), order: 100 },
+ colorMid: { value: toHsla(defaults.colorMid), order: 101 },
+ colorFront: { value: toHsla(defaults.colorFront), order: 102 },
+ originalColors: { value: defaults.originalColors, order: 103 },
+ colorSoftness: { value: defaults.colorSoftness, min: 0, max: 1, order: 104 },
+ strokeWidth: { value: defaults.strokeWidth, min: 0, max: 1, order: 105 },
+ strokeContrast: { value: defaults.strokeContrast, min: 0, max: 1, order: 106 },
+ strokesRounding: { value: defaults.strokesRounding, min: 0, max: 1, order: 107 },
+ strokeInverted: { value: defaults.strokeInverted, order: 108 },
+ strokeSoftness: { value: defaults.strokeSoftness, min: 0, max: 1, order: 109 },
+ strokeKeepGaps: { value: defaults.strokeKeepGaps, order: 110 },
+ strokeKeepWidth: { value: defaults.strokeKeepWidth, order: 111 },
+ gridType: {
+ value: defaults.gridType,
+ options: Object.keys(HalftoneLinesGrids) as HalftoneLinesGrid[],
+ order: 112,
+ },
+ gridSize: { value: defaults.gridSize, min: 0, max: 1, order: 113 },
+ gridOffset: { value: defaults.gridOffset, min: -1, max: 1, order: 114 },
+ gridRotation: { value: defaults.gridRotation, min: 0, max: 180, order: 115 },
+ gridNoise: { value: defaults.gridNoise, min: 0, max: 1, order: 116 },
+ gridContouring: { value: defaults.gridContouring, min: -1, max: 1, order: 117 },
+ grainMixer: { value: defaults.grainMixer, min: 0, max: 1, order: 118 },
+ grainMixerSize: { value: defaults.grainMixerSize, min: 0, max: 1, order: 119 },
+ grainOverlay: { value: defaults.grainOverlay, min: 0, max: 1, order: 120 },
+ grainOverlaySize: { value: defaults.grainOverlaySize, min: 0, max: 1, order: 121 },
+
+ scale: { value: defaults.scale, min: 0.1, max: 4, order: 400 },
+ fit: { value: defaults.fit, options: ['contain', 'cover'] as ShaderFit[], order: 450 },
+ Image: folder(
+ {
+ 'Upload image': levaImageButton(setImageWithoutStatus),
+ },
+ { order: 0 }
+ ),
+ Presets: folder(presets, { order: -1 }),
+ };
+ });
+
+ // Reset to defaults on mount, so that Leva doesn't show values from other
+ // shaders when navigating (if two shaders have a color1 param for example)
+ useResetLevaParams(params, setParams, defaults);
+ useUrlParams(params, setParams, halftoneLinesDef);
+ usePresetHighlight(halftoneLinesPresets, params);
+ cleanUpLevaParams(params);
+
+ return (
+ <>
+
+
+
+
+ Click to change the sample image
+
+
+ >
+ );
+};
+
+export default HalftoneLinesWithControls;
diff --git a/docs/src/shader-defs/halftone-lines-def.ts b/docs/src/shader-defs/halftone-lines-def.ts
new file mode 100644
index 00000000..70be87f6
--- /dev/null
+++ b/docs/src/shader-defs/halftone-lines-def.ts
@@ -0,0 +1,197 @@
+import { halftoneLinesPresets } from '@paper-design/shaders-react';
+import type { ShaderDef } from './shader-def-types';
+import { staticImageCommonParams } from './common-param-def';
+
+const defaultParams = halftoneLinesPresets[0].params;
+
+export const halftoneLinesDef: ShaderDef = {
+ name: 'Halftone lines',
+ description:
+ 'Image filter pattern made of a line grid with optional stroke thickness and grid distortion; offering multiple grid types, grid distortions, and color palettes',
+ params: [
+ {
+ name: 'image',
+ type: 'HTMLImageElement | string',
+ description: 'The image to use for the effect',
+ },
+ {
+ name: 'colorBack',
+ type: 'string',
+ defaultValue: defaultParams.colorBack,
+ isColor: true,
+ description: 'Background color',
+ },
+ {
+ name: 'colorMid',
+ type: 'string',
+ defaultValue: defaultParams.colorMid,
+ isColor: true,
+ description:
+ 'Stroke color drawn in the light image areas, blending into colorFront in the dark ones, needs originalColors off',
+ },
+ {
+ name: 'colorFront',
+ type: 'string',
+ defaultValue: defaultParams.colorFront,
+ isColor: true,
+ description:
+ 'Foreground color drawn in the dark image areas, blending into colorMid in the light ones, needs originalColors off',
+ },
+ {
+ name: 'originalColors',
+ type: 'boolean',
+ defaultValue: defaultParams.originalColors,
+ description: 'Use the sampled image’s original colors instead of colorMid and colorFront',
+ options: ['true', 'false'],
+ },
+ {
+ name: 'colorSoftness',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.colorSoftness,
+ description:
+ 'Blur applied to the sampled color, softening the image colors with originalColors on and the colorMid to colorFront edge with it off (at 0 the two colors meet on a sharp line)',
+ },
+ {
+ name: 'strokeWidth',
+ type: 'number',
+ min: 0.05,
+ max: 1,
+ defaultValue: defaultParams.strokeWidth,
+ description: 'Stroke width relative to the grid cell; at 1 the strokes fill the cell completely',
+ },
+ {
+ name: 'strokeContrast',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.strokeContrast,
+ description: 'How strongly the image luminance varies the stroke width',
+ },
+ {
+ name: 'strokesRounding',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.strokesRounding,
+ description: 'Smoothing applied to the luminance that shapes the strokes; the image colors stay sharp',
+ },
+ {
+ name: 'strokeInverted',
+ type: 'boolean',
+ defaultValue: defaultParams.strokeInverted,
+ description:
+ 'Inverts the image luminance driving the stroke width, leaving the colors and the grid contouring unchanged, needs strokeContrast > 0',
+ options: ['true', 'false'],
+ },
+ {
+ name: 'strokeSoftness',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.strokeSoftness,
+ description:
+ 'Softness added at the stroke edges, the same width at any stroke width; the stroke core keeps its full color and the stroke grows outward by the softness',
+ },
+ {
+ name: 'strokeKeepGaps',
+ type: 'boolean',
+ defaultValue: defaultParams.strokeKeepGaps,
+ description:
+ 'Keeps a thin gap between neighbouring strokes, two pixels wide and picking up a third of the stroke softness; off lets them merge where they meet',
+ options: ['true', 'false'],
+ },
+ {
+ name: 'strokeKeepWidth',
+ type: 'boolean',
+ defaultValue: defaultParams.strokeKeepWidth,
+ description:
+ 'Draws a thin line along the grid where the strokes fade out, two pixels wide and picking up a third of the stroke softness; off lets them fade away in the lightest areas',
+ options: ['true', 'false'],
+ },
+ {
+ name: 'gridType',
+ type: 'enum',
+ defaultValue: defaultParams.gridType,
+ description: 'Grid type',
+ options: ['lines', 'linesIrregular', 'waves', 'wavesIrregular', 'zigzag', 'truchet', 'radial'],
+ },
+ {
+ name: 'gridSize',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.gridSize,
+ description:
+ 'Grid size relative to the canvas (the grid lives in object space, so it does not follow the image box)',
+ },
+ {
+ name: 'gridOffset',
+ type: 'number',
+ min: -1,
+ max: 1,
+ defaultValue: defaultParams.gridOffset,
+ description:
+ 'Grid offset along the grid Y axis; one grid cell at either end for the lines and waves grids, canvas units for the radial distance from the image center to the ring center, and a quarter of that for noise',
+ },
+ {
+ name: 'gridRotation',
+ type: 'number',
+ min: 0,
+ max: 360,
+ defaultValue: defaultParams.gridRotation,
+ description: 'The grid rotation around the image center, with the radial grid needs a nonzero grid offset',
+ },
+ {
+ name: 'gridNoise',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.gridNoise,
+ description: 'Noise displacement of the grid along its Y axis',
+ },
+ {
+ name: 'gridContouring',
+ type: 'number',
+ min: -1,
+ max: 1,
+ defaultValue: defaultParams.gridContouring,
+ description:
+ 'How much the image contours the grid, blending an offset along the grid Y axis, a rotation around the image center and a noise displacement in proportion to gridOffset, gridRotation and gridNoise (-1 follows the light areas, 1 the dark ones, 0 is off), independent of strokeContrast',
+ },
+ {
+ name: 'grainMixer',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.grainMixer,
+ description: 'Strength of grain distortion applied to strokes',
+ },
+ {
+ name: 'grainMixerSize',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.grainMixerSize,
+ description: 'The scale applied to the grain distortion, needs grainMixer > 0',
+ },
+ {
+ name: 'grainOverlay',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.grainOverlay,
+ description: 'Post-processing b/w grainy overlay',
+ },
+ {
+ name: 'grainOverlaySize',
+ type: 'number',
+ min: 0,
+ max: 1,
+ defaultValue: defaultParams.grainOverlaySize,
+ description: 'The scale applied to the grain overlay, needs grainOverlay > 0',
+ },
+ ...staticImageCommonParams,
+ ],
+};
diff --git a/docs/src/shader-defs/shader-defs.ts b/docs/src/shader-defs/shader-defs.ts
index 7ae4ceed..7fb39836 100644
--- a/docs/src/shader-defs/shader-defs.ts
+++ b/docs/src/shader-defs/shader-defs.ts
@@ -27,6 +27,7 @@ import { waterDef } from './water-def';
import { wavesDef } from './waves-def';
import { halftoneDotsDef } from './halftone-dots-def';
import { halftoneCmykDef } from './halftone-cmyk-def';
+import { halftoneLinesDef } from './halftone-lines-def';
import { heatmapDef } from './heatmap-def';
import { gemSmokeDef } from './gem-smoke-def';
import { lensDistortionDef } from './lens-distortion-def';
@@ -59,6 +60,7 @@ export const shaderDefs: ShaderDef[] = [
waterDef,
halftoneDotsDef,
halftoneCmykDef,
+ halftoneLinesDef,
heatmapDef,
gemSmokeDef,
lensDistortionDef,
diff --git a/packages/shaders-react/src/index.ts b/packages/shaders-react/src/index.ts
index a9d97a3b..c8820052 100644
--- a/packages/shaders-react/src/index.ts
+++ b/packages/shaders-react/src/index.ts
@@ -123,6 +123,10 @@ export { GemSmoke, gemSmokePresets } from './shaders/gem-smoke.js';
export type { GemSmokeProps } from './shaders/gem-smoke.js';
export type { GemSmokeUniforms, GemSmokeParams } from '@paper-design/shaders';
+export { HalftoneLines, halftoneLinesPresets } from './shaders/halftone-lines.js';
+export type { HalftoneLinesProps } from './shaders/halftone-lines.js';
+export type { HalftoneLinesUniforms, HalftoneLinesParams } from '@paper-design/shaders';
+
export { isPaperShaderElement, getShaderColorFromString } from '@paper-design/shaders';
export type { PaperShaderElement, ShaderFit, ShaderSizingParams, ShaderSizingUniforms } from '@paper-design/shaders';
diff --git a/packages/shaders-react/src/shaders/halftone-lines.tsx b/packages/shaders-react/src/shaders/halftone-lines.tsx
new file mode 100644
index 00000000..32ca65a9
--- /dev/null
+++ b/packages/shaders-react/src/shaders/halftone-lines.tsx
@@ -0,0 +1,308 @@
+import { memo } from 'react';
+import { ShaderMount, type ShaderComponentProps } from '../shader-mount.js';
+import {
+ halftoneLinesFragmentShader,
+ getShaderColorFromString,
+ ShaderFitOptions,
+ type HalftoneLinesUniforms,
+ type HalftoneLinesParams,
+ defaultObjectSizing,
+ type ImageShaderPreset,
+ HalftoneLinesGrids,
+} from '@paper-design/shaders';
+
+export interface HalftoneLinesProps extends ShaderComponentProps, HalftoneLinesParams {}
+
+type HalftoneLinesPreset = ImageShaderPreset;
+
+export const defaultPreset: HalftoneLinesPreset = {
+ name: 'Default',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#fffcf0',
+ colorMid: '#243fc6',
+ colorFront: '#000000',
+ originalColors: false,
+ colorSoftness: 0.5,
+ strokeWidth: 1,
+ strokeContrast: 1,
+ strokesRounding: 0.4,
+ strokeInverted: false,
+ strokeSoftness: 0,
+ strokeKeepGaps: false,
+ strokeKeepWidth: true,
+ gridType: 'radial',
+ gridSize: 0.75,
+ gridOffset: -0.5,
+ gridRotation: 125,
+ gridNoise: 0,
+ gridContouring: 0,
+ grainMixer: 0,
+ grainMixerSize: 1,
+ grainOverlay: 0,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const classicPreset: HalftoneLinesPreset = {
+ name: 'Classic',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#ffffff',
+ colorMid: '#000000',
+ colorFront: '#bababa',
+ originalColors: false,
+ colorSoftness: 1,
+ strokeWidth: 0.6,
+ strokeContrast: 0.5,
+ strokesRounding: 0.15,
+ strokeInverted: false,
+ strokeSoftness: 0,
+ strokeKeepGaps: false,
+ strokeKeepWidth: false,
+ gridType: 'lines',
+ gridSize: 0.45,
+ gridOffset: -0.5,
+ gridRotation: 0,
+ gridNoise: 0,
+ gridContouring: 0,
+ grainMixer: 0,
+ grainMixerSize: 1,
+ grainOverlay: 0.18,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const lineartPreset: HalftoneLinesPreset = {
+ name: 'Lineart',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#f6e9d5',
+ colorMid: '#117e8d',
+ colorFront: '#000000',
+ originalColors: false,
+ colorSoftness: 1,
+ strokeWidth: 0,
+ strokeContrast: 0.5,
+ strokesRounding: 0.35,
+ strokeInverted: false,
+ strokeSoftness: 0,
+ strokeKeepGaps: true,
+ strokeKeepWidth: true,
+ gridType: 'lines',
+ gridSize: 0.67,
+ gridOffset: 0,
+ gridRotation: 0,
+ gridNoise: 0,
+ gridContouring: -0.66,
+ grainMixer: 0,
+ grainMixerSize: 0.26,
+ grainOverlay: 0.19,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const noisyPreset: HalftoneLinesPreset = {
+ name: 'Noisy',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#2b2932',
+ colorMid: '#00e1ff',
+ colorFront: '#fad933',
+ originalColors: false,
+ colorSoftness: 1,
+ strokeWidth: 1,
+ strokeContrast: 0.67,
+ strokesRounding: 0.59,
+ strokeInverted: false,
+ strokeSoftness: 0,
+ strokeKeepGaps: true,
+ strokeKeepWidth: false,
+ gridType: 'wavesIrregular',
+ gridSize: 0.85,
+ gridOffset: 0,
+ gridRotation: 0,
+ gridNoise: 1,
+ gridContouring: 0,
+ grainMixer: 0,
+ grainMixerSize: 1,
+ grainOverlay: 0,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const photoPreset: HalftoneLinesPreset = {
+ name: 'Photo',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#de3b66',
+ colorMid: '#ffffff',
+ colorFront: '#ffffff',
+ originalColors: true,
+ colorSoftness: 0,
+ strokeWidth: 1,
+ strokeContrast: 0.3,
+ strokesRounding: 0.5,
+ strokeInverted: false,
+ strokeSoftness: 0,
+ strokeKeepGaps: false,
+ strokeKeepWidth: false,
+ gridType: 'waves',
+ gridSize: 0.95,
+ gridOffset: 0,
+ gridRotation: 0,
+ gridNoise: 0,
+ gridContouring: 0,
+ grainMixer: 0,
+ grainMixerSize: 1,
+ grainOverlay: 0,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const texturedPreset: HalftoneLinesPreset = {
+ name: 'Textured',
+ params: {
+ ...defaultObjectSizing,
+ scale: 1,
+ speed: 0,
+ frame: 0,
+ colorBack: '#15484c',
+ colorMid: '#fbff0a',
+ colorFront: '#ff0000',
+ originalColors: false,
+ colorSoftness: 0.65,
+ strokeWidth: 0.44,
+ strokeContrast: 0.51,
+ strokesRounding: 0,
+ strokeInverted: false,
+ strokeSoftness: 0.25,
+ strokeKeepGaps: false,
+ strokeKeepWidth: false,
+ gridType: 'linesIrregular',
+ gridSize: 0.55,
+ gridOffset: 0,
+ gridRotation: 0,
+ gridNoise: 0,
+ gridContouring: -0.02,
+ grainMixer: 0.78,
+ grainMixerSize: 0.76,
+ grainOverlay: 0,
+ grainOverlaySize: 0.5,
+ },
+};
+
+export const halftoneLinesPresets: HalftoneLinesPreset[] = [
+ defaultPreset,
+ classicPreset,
+ lineartPreset,
+ noisyPreset,
+ photoPreset,
+ texturedPreset,
+];
+
+export const HalftoneLines: React.FC = memo(function HalftoneLinesImpl({
+ // Own props
+ colorBack = defaultPreset.params.colorBack,
+ colorMid = defaultPreset.params.colorMid,
+ colorFront = defaultPreset.params.colorFront,
+ originalColors = defaultPreset.params.originalColors,
+ colorSoftness = defaultPreset.params.colorSoftness,
+ strokeWidth = defaultPreset.params.strokeWidth,
+ strokeContrast = defaultPreset.params.strokeContrast,
+ strokesRounding = defaultPreset.params.strokesRounding,
+ strokeInverted = defaultPreset.params.strokeInverted,
+ strokeSoftness = defaultPreset.params.strokeSoftness,
+ strokeKeepGaps = defaultPreset.params.strokeKeepGaps,
+ strokeKeepWidth = defaultPreset.params.strokeKeepWidth,
+ gridType = defaultPreset.params.gridType,
+ gridSize = defaultPreset.params.gridSize,
+ gridOffset = defaultPreset.params.gridOffset,
+ gridRotation = defaultPreset.params.gridRotation,
+ gridNoise = defaultPreset.params.gridNoise,
+ gridContouring = defaultPreset.params.gridContouring,
+ grainMixer = defaultPreset.params.grainMixer,
+ grainMixerSize = defaultPreset.params.grainMixerSize,
+ grainOverlay = defaultPreset.params.grainOverlay,
+ grainOverlaySize = defaultPreset.params.grainOverlaySize,
+ speed = defaultPreset.params.speed,
+ frame = defaultPreset.params.frame,
+ image = '',
+
+ // Sizing props
+ fit = defaultPreset.params.fit,
+ scale = defaultPreset.params.scale,
+ rotation = defaultPreset.params.rotation,
+ originX = defaultPreset.params.originX,
+ originY = defaultPreset.params.originY,
+ offsetX = defaultPreset.params.offsetX,
+ offsetY = defaultPreset.params.offsetY,
+ worldWidth = defaultPreset.params.worldWidth,
+ worldHeight = defaultPreset.params.worldHeight,
+ ...props
+}: HalftoneLinesProps) {
+ const uniforms = {
+ // Own uniforms
+ u_colorBack: getShaderColorFromString(colorBack),
+ u_colorMid: getShaderColorFromString(colorMid),
+ u_colorFront: getShaderColorFromString(colorFront),
+ u_originalColors: originalColors,
+ u_colorSoftness: colorSoftness,
+ u_strokeWidth: strokeWidth,
+ u_strokeContrast: strokeContrast,
+ u_strokesRounding: strokesRounding,
+ u_strokeInverted: strokeInverted,
+ u_strokeSoftness: strokeSoftness,
+ u_strokeKeepGaps: strokeKeepGaps,
+ u_strokeKeepWidth: strokeKeepWidth,
+ u_gridType: HalftoneLinesGrids[gridType],
+ u_gridSize: gridSize,
+ u_gridOffset: gridOffset,
+ u_gridRotation: gridRotation,
+ u_gridNoise: gridNoise,
+ u_gridContouring: gridContouring,
+ u_grainMixer: grainMixer,
+ u_grainMixerSize: grainMixerSize,
+ u_grainOverlay: grainOverlay,
+ u_grainOverlaySize: grainOverlaySize,
+
+ u_image: image,
+
+ // Sizing uniforms
+ u_fit: ShaderFitOptions[fit],
+ u_scale: scale,
+ u_rotation: rotation,
+ u_offsetX: offsetX,
+ u_offsetY: offsetY,
+ u_originX: originX,
+ u_originY: originY,
+ u_worldWidth: worldWidth,
+ u_worldHeight: worldHeight,
+ } satisfies HalftoneLinesUniforms;
+
+ return (
+
+ );
+});
diff --git a/packages/shaders/src/index.ts b/packages/shaders/src/index.ts
index f89812d7..814d321c 100644
--- a/packages/shaders/src/index.ts
+++ b/packages/shaders/src/index.ts
@@ -234,6 +234,15 @@ export {
type GemSmokeUniforms,
} from './shaders/gem-smoke.js';
+export {
+ HalftoneLinesGrids,
+ halftoneLinesMeta,
+ halftoneLinesFragmentShader,
+ type HalftoneLinesParams,
+ type HalftoneLinesUniforms,
+ type HalftoneLinesGrid,
+} from './shaders/halftone-lines.js';
+
// ----- Utils ----- //
export { getShaderColorFromString } from './get-shader-color-from-string.js';
export { getShaderNoiseTexture } from './get-shader-noise-texture.js';
diff --git a/packages/shaders/src/shaders/halftone-lines.ts b/packages/shaders/src/shaders/halftone-lines.ts
new file mode 100644
index 00000000..354c6841
--- /dev/null
+++ b/packages/shaders/src/shaders/halftone-lines.ts
@@ -0,0 +1,403 @@
+import type { ShaderMotionParams } from '../shader-mount.js';
+import { type ShaderSizingParams, type ShaderSizingUniforms } from '../shader-sizing.js';
+import { declarePI, rotation2, simplexNoise, proceduralHash21 } from '../shader-utils.js';
+
+export const halftoneLinesMeta = {
+ maxBlurRadius: 64,
+} as const;
+
+/**
+ * A halftone image filter drawing the image as a line grid (lines, radial, waves, noise),
+ * with the stroke width and the grid distortion driven by image luminance.
+ * Supports original colors or a custom 2-color palette.
+ *
+ * Fragment shader uniforms:
+ * - u_image (sampler2D): Source image texture
+ * - u_imageAspectRatio (float): Aspect ratio of the source image
+ * - u_colorBack (vec4): Background color in RGBA
+ * - u_colorMid (vec4): Stroke color in RGBA drawn in the light image areas, blending into colorFront in the dark ones, needs originalColors off
+ * - u_colorFront (vec4): Foreground (line) color in RGBA drawn in the dark image areas, blending into colorMid in the light ones, needs originalColors off
+ * - u_originalColors (bool): Use the sampled image's original colors instead of colorMid and colorFront
+ * - u_colorSoftness (float): Blur applied to the sampled color, softening the image colors with originalColors on and the colorMid to colorFront edge with it off, at 0 the two colors meet on a sharp line (0 to 1)
+ * - u_strokeWidth (float): Stroke width relative to the grid cell, at 1 the strokes fill the cell completely (0 to 1)
+ * - u_strokeContrast (float): How strongly the image luminance varies the stroke width (0 to 1)
+ * - u_strokesRounding (float): Rounding of the stroke shapes, from smoothing the luminance that drives them (0 to 1)
+ * - u_strokeInverted (bool): Inverts the image luminance driving the stroke width, leaving the colors and the grid contouring unchanged, needs strokeContrast > 0
+ * - u_strokeSoftness (float): Softness added at the stroke edges, the same width at any stroke width; the stroke core keeps its full color and the stroke grows outward by the softness (0 to 1)
+ * - u_strokeKeepGaps (bool): Keep a thin gap between neighbouring strokes, two pixels wide and picking up a third of the stroke softness, off lets them merge where they meet
+ * - u_strokeKeepWidth (bool): Draw a thin line along the grid where the strokes fade out, two pixels wide and picking up a third of the stroke softness, off lets them fade away in the lightest areas
+ * - u_gridType (float): Grid pattern type (0 = lines, 1 = linesIrregular, 2 = waves, 3 = wavesIrregular, 4 = zigzag, 5 = truchet, 6 = radial)
+ * - u_gridSize (float): Grid size relative to the canvas; the grid lives in object space, so it doesn't follow the image box (0 to 1)
+ * - u_gridOffset (float): Grid offset along the grid Y axis, one grid cell at full range for the lines and waves grids, canvas units for the radial distance from the image center to the ring center, and a quarter of that for noise (-1 to 1)
+ * - u_gridRotation (float): Grid rotation angle in degrees around the image center, with the radial grid needs a nonzero grid offset (0 to 360)
+ * - u_gridNoise (float): Noise displacement of the grid along its Y axis (0 to 1)
+ * - u_gridContouring (float): How much the image contours the grid, blending an offset along the grid Y axis, a rotation around the image center and a noise displacement in proportion to gridOffset, gridRotation and gridNoise; -1 follows the light areas and 1 the dark ones, independent of strokeContrast (-1 to 1)
+ * - u_grainMixer (float): Strength of grain distortion applied to the lines (0 to 1)
+ * - u_grainMixerSize (float): Scale of the grain distortion, needs grainMixer > 0 (0 to 1)
+ * - u_grainOverlay (float): Strength of the post-processing black/white grain overlay (0 to 1)
+ * - u_grainOverlaySize (float): Scale of the grain overlay, needs grainOverlay > 0 (0 to 1)
+ *
+ * Vertex shader outputs (used in fragment shader):
+ * - v_imageUV (vec2): UV coordinates for sampling the source image, with fit, scale, rotation, and offset applied
+ * - v_objectUV (vec2): Object box UV coordinates with global sizing (scale, rotation, offsets, etc) applied
+ *
+ * Vertex shader uniforms:
+ * - u_resolution (vec2): Canvas resolution in pixels
+ * - u_pixelRatio (float): Device pixel ratio
+ * - u_originX (float): Reference point for positioning world width in the canvas (0 to 1)
+ * - u_originY (float): Reference point for positioning world height in the canvas (0 to 1)
+ * - u_worldWidth (float): Virtual width of the graphic before it's scaled to fit the canvas
+ * - u_worldHeight (float): Virtual height of the graphic before it's scaled to fit the canvas
+ * - u_fit (float): How to fit the rendered shader into the canvas dimensions (0 = none, 1 = contain, 2 = cover)
+ * - u_scale (float): Overall zoom level of the graphics (0.1 to 4)
+ * - u_rotation (float): Overall rotation angle of the graphics in degrees (0 to 360)
+ * - u_offsetX (float): Horizontal offset of the graphics center (-1 to 1)
+ * - u_offsetY (float): Vertical offset of the graphics center (-1 to 1)
+ * - u_imageAspectRatio (float): Aspect ratio of the source image
+ *
+ */
+
+// language=GLSL
+export const halftoneLinesFragmentShader: string = `#version 300 es
+precision highp float;
+
+uniform sampler2D u_image;
+uniform mediump float u_imageAspectRatio;
+
+uniform vec4 u_colorBack;
+uniform vec4 u_colorMid;
+uniform vec4 u_colorFront;
+uniform bool u_originalColors;
+uniform float u_colorSoftness;
+uniform float u_strokeWidth;
+uniform float u_strokeContrast;
+uniform float u_strokesRounding;
+uniform bool u_strokeInverted;
+uniform float u_strokeSoftness;
+uniform bool u_strokeKeepGaps;
+uniform bool u_strokeKeepWidth;
+uniform float u_gridType;
+uniform float u_gridSize;
+uniform float u_gridOffset;
+uniform float u_gridRotation;
+uniform float u_gridNoise;
+uniform float u_gridContouring;
+uniform float u_grainMixer;
+uniform float u_grainMixerSize;
+uniform float u_grainOverlay;
+uniform float u_grainOverlaySize;
+
+
+in vec2 v_imageUV;
+in vec2 v_objectUV;
+
+out vec4 fragColor;
+
+${declarePI}
+${rotation2}
+${simplexNoise}
+${proceduralHash21}
+
+float valueNoise(vec2 st) {
+ vec2 i = floor(st);
+ vec2 f = fract(st);
+ float a = hash21(i);
+ float b = hash21(i + vec2(1.0, 0.0));
+ float c = hash21(i + vec2(0.0, 1.0));
+ float d = hash21(i + vec2(1.0, 1.0));
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ float x1 = mix(a, b, u.x);
+ float x2 = mix(c, d, u.x);
+ return mix(x1, x2, u.y);
+}
+
+float getImgFrame(vec2 uv, vec2 th) {
+ vec2 f = smoothstep(vec2(0.), th, uv) * (1. - smoothstep(1. - th, vec2(1.), uv));
+ return f.x * f.y;
+}
+
+float stripeCoverage(float x, float window, float w) {
+ float a = x - .5 * window;
+ float b = x + .5 * window;
+ float span = 2. * w;
+ float ia = clamp(fract(a) - .5 + w, 0., span);
+ float ib = clamp(fract(b) - .5 + w, 0., span);
+ return (span * (floor(b) - floor(a)) + ib - ia) / window;
+}
+
+float sigmoid(float x, float k) {
+ return 1.0 / (1.0 + exp(-k * (x - 0.5)));
+}
+
+float toLumLinear(vec4 tex) {
+ float lum = dot(vec3(0.2126, 0.7152, 0.0722), tex.rgb);
+ return mix(1., lum, tex.a);
+}
+
+float toLum(vec4 tex, float contrast) {
+ vec3 color = vec3(
+ sigmoid(tex.r, contrast),
+ sigmoid(tex.g, contrast),
+ sigmoid(tex.b, contrast)
+ );
+ float lum = dot(vec3(0.2126, 0.7152, 0.0722), color);
+ lum = mix(1., lum, tex.a);
+ return u_strokeInverted ? (1. - lum) : lum;
+}
+
+float smoothingLod(float radius) {
+ return max(0., log2(max(radius, 1.)) - 1.);
+}
+
+vec4 sampleSmoothed(vec2 uv, float radius) {
+ if (radius <= 0.) return texture(u_image, uv);
+
+ vec2 texelSize = 1. / vec2(textureSize(u_image, 0));
+ float lod = smoothingLod(radius);
+ vec2 texelStep = exp2(lod) * texelSize;
+
+ vec4 acc = 2. * textureLod(u_image, uv, lod);
+ acc += textureLod(u_image, uv + vec2(texelStep.x, 0.), lod);
+ acc += textureLod(u_image, uv - vec2(texelStep.x, 0.), lod);
+ acc += textureLod(u_image, uv + vec2(0., texelStep.y), lod);
+ acc += textureLod(u_image, uv - vec2(0., texelStep.y), lod);
+
+ return acc / 6.;
+}
+
+void main() {
+
+ float contrast = mix(0., 15., u_strokeContrast);
+
+ float maxRadius = float(${halftoneLinesMeta.maxBlurRadius});
+ float strokesRoundingRadius = maxRadius * u_strokesRounding * u_strokesRounding;
+ float colorSoftnessRadius = maxRadius * u_colorSoftness * u_colorSoftness;
+
+ vec4 originalTexture = sampleSmoothed(v_imageUV, colorSoftnessRadius);
+
+ float frame = getImgFrame(v_imageUV, max(fwidth(v_imageUV), 1e-4));
+
+ vec4 lumSample = sampleSmoothed(v_imageUV, strokesRoundingRadius);
+ float lum = 1. - toLum(lumSample, contrast);
+ float contouringLum = 1. - toLumLinear(lumSample);
+
+ vec2 uv = v_objectUV;
+ float cellsPerSide = mix(200., 5., u_gridSize);
+
+ float offsetScale = u_gridType < 5.5 ? 1. : cellsPerSide;
+ float contouringPower = abs(u_gridContouring) * ((u_gridContouring > 0. ? contouringLum : 1. - contouringLum) - 1.);
+
+ vec3 contouring = vec3(6., .5 * smoothstep(length(uv), 0., 1.), -5.) * contouringPower;
+
+ vec2 uvGrid = uv * cellsPerSide;
+ float noiseShift = (6. * u_gridNoise + contouring.z) * snoise(3. * uv + 10.);
+ if (u_gridType < 4.5) {
+ uvGrid += vec2(0., noiseShift);
+ }
+
+ float gridLine;
+ vec2 gridGrad = vec2(0.);
+
+ uvGrid = rotate(uvGrid, u_gridRotation * PI / 180. + contouring.y);
+ uvGrid -= vec2(0., offsetScale * u_gridOffset + contouring.x);
+
+ if (u_gridType > 4.5) {
+ float noiseRadius = length(uvGrid);
+ uvGrid += noiseShift * (noiseRadius > 1e-4 ? uvGrid / noiseRadius : vec2(0., 1.));
+ }
+
+ if (u_gridType == 0.) {
+ gridLine = uvGrid.y;
+ gridGrad = vec2(0., 1.);
+ } else if (u_gridType == 1.) {
+ gridLine = uvGrid.y + .25 * sin(.5 * uvGrid.y) * sin(1.7 * uvGrid.y);
+ gridGrad = vec2(0., 1. + .25 * (.5 * cos(.5 * uvGrid.y) * sin(1.7 * uvGrid.y) + 1.7 * sin(.5 * uvGrid.y) * cos(1.7 * uvGrid.y)));
+ } else if (u_gridType == 2.) {
+ gridLine = uvGrid.y + 1.5 * sin(.5 * uvGrid.x);
+ gridGrad = vec2(1.5 * .5 * cos(.5 * uvGrid.x), 1.);
+ } else if (u_gridType == 3.) {
+ float wave1 = sin(.4 * uvGrid.x);
+ float wave2 = sin(.27 * uvGrid.x + 1.3);
+ float drift = .13 * uvGrid.x + .19 * uvGrid.y;
+ float wave3 = cos(drift);
+ gridLine = uvGrid.y + 3. * wave1 * wave2 * wave3;
+ gridGrad = vec2(
+ 3. * (.4 * cos(.4 * uvGrid.x) * wave2 * wave3 + .27 * wave1 * cos(.27 * uvGrid.x + 1.3) * wave3 - .13 * wave1 * wave2 * sin(drift)),
+ 1. - 3. * .19 * wave1 * wave2 * sin(drift)
+ );
+ } else if (u_gridType == 4.) {
+ float tooth = fract(uvGrid.x / (4. * PI)) - .5;
+ gridLine = uvGrid.y + 12. * abs(tooth);
+ gridGrad = vec2(3. * sign(tooth) / PI, 1.);
+ } else if (u_gridType == 5.) {
+ float tileSize = 8.;
+ vec2 tile = uvGrid / tileSize;
+ vec2 tileId = floor(tile);
+ vec2 tileUV = fract(tile);
+ float flip = 1.;
+ if (hash21(tileId) > .5) {
+ tileUV.x = 1. - tileUV.x;
+ flip = -1.;
+ }
+ vec2 corner = tileUV.x + tileUV.y < 1. ? vec2(0.) : vec2(1.);
+ vec2 rel = tileUV - corner;
+ float arc = length(rel);
+ gridLine = tileSize * abs(arc - .5);
+ gridGrad = sign(arc - .5) * vec2(flip * rel.x, rel.y) / max(arc, 1e-4);
+ } else if (u_gridType == 6.) {
+ float radius = length(uvGrid);
+ gridLine = radius;
+ gridGrad = radius > 1e-4 ? uvGrid / radius : vec2(1., 0.);
+ }
+
+ vec2 grainMixerScale = mix(1000., 50., u_grainMixerSize) * vec2(1., 1. / u_imageAspectRatio);
+ vec2 grainOverlayScale = mix(2000., 200., u_grainOverlaySize) * vec2(1., 1. / u_imageAspectRatio);
+ vec2 grainMixerUV = (v_imageUV - .5) * grainMixerScale;
+ vec2 grainOverlayUV = (v_imageUV - .5) * grainOverlayScale;
+ float grain = valueNoise(grainMixerUV) + .3 * pow(u_grainMixer, 3.);
+ grain = smoothstep(.55, .9, grain);
+ grain *= .5 * pow(u_grainMixer, 3.);
+
+ float aa = max(
+ length(vec2(dFdx(gridLine), dFdy(gridLine))),
+ length(vec2(dot(gridGrad, dFdx(uvGrid)), dot(gridGrad, dFdy(uvGrid))))
+ );
+ float baseAA = cellsPerSide * max(length(dFdx(uv)), length(dFdy(uv)));
+ float overlap = smoothstep(1.5, 3.5, aa / max(baseAA, 1e-6));
+
+ float wMax = 1.5 + .5 * aa;
+ float w = clamp(wMax * u_strokeWidth * lum - .5 * grain, 0., .5);
+
+ float stable = min(aa, .25);
+ float wDraw = clamp(w, stable, max(.5 - stable, stable));
+
+ float window = max(clamp(.25 * u_strokeSoftness, aa, max(.5 - 2. * aa, aa)), 1e-4);
+ float wSoft = clamp(wDraw + .5 * (window - aa), 0., max(.5 - .5 * (window + aa), 0.));
+ float stroke = stripeCoverage(gridLine, window, wSoft);
+ stroke *= min(w / max(stable, 1e-4), 1.);
+ stroke = 1. + (stroke - 1.) * min((.5 - w) / max(stable, 1e-4), 1.);
+ stroke = mix(stroke, mix(min(2. * w, 1.), 1., overlap), smoothstep(.15, .4, aa));
+
+ float thinWindow = max(aa * (1. + .5 * u_strokeSoftness), 1e-4);
+ float thinGrow = .5 * (thinWindow - aa);
+
+ if (u_strokeKeepWidth == true) {
+ float minWidth = min(aa, .25) * (1. - smoothstep(.25, .5, aa));
+ stroke = max(stroke, stripeCoverage(gridLine, thinWindow, min(minWidth + thinGrow, .5)));
+ }
+
+ if (u_strokeKeepGaps == true) {
+ float maskWidth = min(aa, .25);
+ float mask = stripeCoverage(gridLine + .5, thinWindow, min(maskWidth + thinGrow, .5)) * (1. - smoothstep(.25, .5, aa));
+ stroke *= 1. - mask;
+ }
+
+ stroke *= frame;
+
+ float inkLum = 1. - 1.1 * toLum(originalTexture, .8);
+ float inkEdge = max(.5 * u_colorSoftness, fwidth(inkLum));
+ float inkMix = smoothstep(.5 - inkEdge, .5 + inkEdge, inkLum);
+
+ vec3 color = vec3(0.);
+ float opacity = 0.;
+
+ if (u_originalColors == true) {
+ color = originalTexture.rgb * stroke;
+ opacity = originalTexture.a * stroke;
+
+ vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
+ color = color + bgColor * (1. - opacity);
+ opacity = opacity + u_colorBack.a * (1. - opacity);
+ } else {
+ vec4 frontPremult = vec4(u_colorFront.rgb * u_colorFront.a, u_colorFront.a);
+ vec4 midPremult = vec4(u_colorMid.rgb * u_colorMid.a, u_colorMid.a);
+ vec4 ink = mix(midPremult, frontPremult, inkMix);
+ vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
+ float bgOpacity = u_colorBack.a;
+
+ color = ink.rgb * stroke;
+ opacity = ink.a * stroke;
+ color += bgColor * (1. - opacity);
+ opacity += bgOpacity * (1. - opacity);
+ }
+
+ float grainOverlay = valueNoise(rotate(grainOverlayUV, 1.) + vec2(3.));
+ grainOverlay = mix(grainOverlay, valueNoise(rotate(grainOverlayUV, 2.) + vec2(-1.)), .5);
+ grainOverlay = pow(grainOverlay, 1.3);
+ float grainOverlayV = grainOverlay * 2. - 1.;
+ vec3 grainOverlayColor = vec3(step(0., grainOverlayV));
+ float grainOverlayStrength = u_grainOverlay * abs(grainOverlayV);
+ grainOverlayStrength = pow(grainOverlayStrength, .8);
+ color = mix(color, grainOverlayColor, .35 * grainOverlayStrength);
+
+ opacity += .5 * grainOverlayStrength;
+ opacity = clamp(opacity, 0., 1.);
+
+ fragColor = vec4(color, opacity);
+}
+`;
+
+export interface HalftoneLinesUniforms extends ShaderSizingUniforms {
+ u_colorBack: [number, number, number, number];
+ u_colorMid: [number, number, number, number];
+ u_colorFront: [number, number, number, number];
+ u_originalColors: boolean;
+ u_colorSoftness: number;
+ u_strokeWidth: number;
+ u_strokeContrast: number;
+ u_strokesRounding: number;
+ u_strokeInverted: boolean;
+ u_strokeSoftness: number;
+ u_strokeKeepGaps: boolean;
+ u_strokeKeepWidth: boolean;
+ u_gridType: (typeof HalftoneLinesGrids)[HalftoneLinesGrid];
+ u_gridSize: number;
+ u_gridOffset: number;
+ u_gridRotation: number;
+ u_gridNoise: number;
+ u_gridContouring: number;
+ u_grainMixer: number;
+ u_grainMixerSize: number;
+ u_grainOverlay: number;
+ u_grainOverlaySize: number;
+ u_image: HTMLImageElement | string | undefined;
+}
+
+export interface HalftoneLinesParams extends ShaderSizingParams, ShaderMotionParams {
+ colorBack?: string;
+ colorMid?: string;
+ colorFront?: string;
+ originalColors?: boolean;
+ colorSoftness?: number;
+ strokeWidth?: number;
+ strokeContrast?: number;
+ strokesRounding?: number;
+ strokeInverted?: boolean;
+ strokeSoftness?: number;
+ strokeKeepGaps?: boolean;
+ strokeKeepWidth?: boolean;
+ gridType?: HalftoneLinesGrid;
+ gridSize?: number;
+ gridOffset?: number;
+ gridRotation?: number;
+ gridNoise?: number;
+ gridContouring?: number;
+ grainMixer?: number;
+ grainMixerSize?: number;
+ grainOverlay?: number;
+ grainOverlaySize?: number;
+ image?: HTMLImageElement | string | undefined;
+}
+
+export const HalftoneLinesGrids = {
+ lines: 0,
+ linesIrregular: 1,
+ waves: 2,
+ wavesIrregular: 3,
+ zigzag: 4,
+ truchet: 5,
+ radial: 6,
+} as const;
+
+
+export type HalftoneLinesGrid = keyof typeof HalftoneLinesGrids;