diff --git a/docs/registry.json b/docs/registry.json index f984641d3..bc9743222 100644 --- a/docs/registry.json +++ b/docs/registry.json @@ -275,6 +275,19 @@ "type": "registry:component" } ] + }, + { + "name": "tartan", + "type": "registry:component", + "title": "Tartan Example", + "description": "Tartan shader example.", + "dependencies": ["@paper-design/shaders-react"], + "files": [ + { + "path": "registry/tartan-example.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/docs/registry/tartan-example.tsx b/docs/registry/tartan-example.tsx new file mode 100644 index 000000000..583d4c36a --- /dev/null +++ b/docs/registry/tartan-example.tsx @@ -0,0 +1,5 @@ +import { Tartan, type TartanProps } from '@paper-design/shaders-react'; + +export function TartanExample(props: TartanProps) { + return ; +} diff --git a/docs/src/app/tartan/layout.tsx b/docs/src/app/tartan/layout.tsx new file mode 100644 index 000000000..400d7b968 --- /dev/null +++ b/docs/src/app/tartan/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Tartan Shader | Paper', +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/docs/src/app/tartan/page.tsx b/docs/src/app/tartan/page.tsx new file mode 100644 index 000000000..e8d9b2825 --- /dev/null +++ b/docs/src/app/tartan/page.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { BackButton } from '@/components/back-button'; +import { createNumberedObject } from '@/helpers/create-numbered-object'; +import { getValuesSortedByKey } from '@/helpers/get-values-sorted-by-key'; +import { type ShaderFit, ShaderFitOptions, tartanMeta } from '@paper-design/shaders'; +import { Tartan, tartanPresets } from '@paper-design/shaders-react'; +import { button, folder, levaStore, useControls } from 'leva'; +import type { Schema } from 'leva/dist/declarations/src/types'; +import Link from 'next/link'; +import { useEffect } from 'react'; + +const defaults = tartanPresets[0].params; + +/** + * This example has controls added so you can play with settings in the example app + */ +const TartanWithControls = () => { + // Presets + useControls({ + Presets: folder( + Object.fromEntries( + tartanPresets.map(({ name, params: { worldWidth, worldHeight, ...preset } }) => [ + name, + button(() => { + const { stripeColors, stripeWidths, ...presetParams } = preset; + setParams(presetParams); + setColors( + createNumberedObject('color', tartanMeta.maxStripeCount, (i) => stripeColors[i % stripeColors.length]) + ); + setWidths( + createNumberedObject('width', tartanMeta.maxStripeCount, (i) => stripeWidths[i % stripeWidths.length]) + ); + }), + ]) + ), + { + order: -1, + collapsed: false, + } + ), + }); + + // Scalar parameters + const [params, setParams] = useControls(() => ({ + Parameters: folder( + { + weaveSize: { + value: defaults.weaveSize, + min: 1.0, + max: 10.0, + step: 0.25, + order: 0, + }, + weaveStrength: { + value: defaults.weaveStrength, + min: 0.0, + max: 1.0, + step: 0.05, + order: 1, + }, + }, + { + order: 0, + collapsed: false, + } + ), + Stripes: folder( + { + stripeCount: { + value: defaults.stripeCount, + min: 2, + max: tartanMeta.maxStripeCount, + step: 1, + order: 0, + label: 'count', + }, + }, + { + order: 1, + collapsed: false, + } + ), + Transform: folder( + { + scale: { value: defaults.scale, min: 0.01, max: 4, order: 400 }, + rotation: { value: defaults.rotation, min: 0, max: 360, order: 401 }, + offsetX: { value: defaults.offsetX, min: -1, max: 1, order: 402 }, + offsetY: { value: defaults.offsetY, min: -1, max: 1, order: 403 }, + }, + { + order: 2, + collapsed: false, + } + ), + Fit: folder( + { + fit: { value: defaults.fit, options: Object.keys(ShaderFitOptions) as ShaderFit[], order: 404 }, + worldWidth: { value: 1000, min: 0, max: 5120, order: 405 }, + worldHeight: { value: 500, min: 0, max: 5120, order: 406 }, + originX: { value: defaults.originX, min: 0, max: 1, order: 407 }, + originY: { value: defaults.originY, min: 0, max: 1, order: 408 }, + }, + { + order: 3, + collapsed: true, + } + ), + })); + + // Stripe colors + const [colors, setColors] = useControls( + () => ({ + Stripes: folder({ + ...createNumberedObject( + 'color', + tartanMeta.maxStripeCount, + (i) => + ({ + label: `color${i + 1}`, + order: i * 2 + 1, + render: () => params.stripeCount > i, + value: defaults.stripeColors[i % defaults.stripeColors.length], + }) satisfies Schema[string] + ), + }), + }), + [params.stripeCount] + ); + + // Stripe widths + const [widths, setWidths] = useControls( + () => ({ + Stripes: folder({ + ...createNumberedObject( + 'width', + tartanMeta.maxStripeCount, + (i) => + ({ + label: `width${i + 1}`, + max: 100, + min: 1, + order: i * 2 + 2, + render: () => params.stripeCount > i, + step: 1, + value: defaults.stripeWidths[i % defaults.stripeWidths.length], + }) satisfies Schema[string] + ), + }), + }), + [params.stripeCount] + ); + + // Clear the Leva store when the component unmounts. + useEffect(() => { + return () => { + levaStore.dispose(); + }; + }, []); + + return ( + <> + + + + + + ); +}; + +export default TartanWithControls; diff --git a/docs/src/helpers/create-numbered-object.ts b/docs/src/helpers/create-numbered-object.ts new file mode 100644 index 000000000..fa858698e --- /dev/null +++ b/docs/src/helpers/create-numbered-object.ts @@ -0,0 +1,36 @@ +/** + * Creates an object with up to 9 properties, each named using a prefix and a number. + * + * @example + * const result = createNumberedObject('foo', 3, i => `bar${i + 1}`); + * console.log(result); // { foo1: 'bar1', foo2: 'bar2', foo3: 'bar3' } + */ +export const createNumberedObject = ( + prefix: Prefix, + count: Count, + mapFn: (i: number) => Value +) => { + const result = new Map(); + for (let i = 0; i < count; i++) result.set(`${prefix}${i + 1}`, mapFn(i)); + return Object.fromEntries(result) as Record<`${Prefix}${Range}`, Value>; +}; + +type Range = Count extends 1 + ? 1 + : Count extends 2 + ? 1 | 2 + : Count extends 3 + ? 1 | 2 | 3 + : Count extends 4 + ? 1 | 2 | 3 | 4 + : Count extends 5 + ? 1 | 2 | 3 | 4 | 5 + : Count extends 6 + ? 1 | 2 | 3 | 4 | 5 | 6 + : Count extends 7 + ? 1 | 2 | 3 | 4 | 5 | 6 | 7 + : Count extends 8 + ? 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 + : Count extends 9 + ? 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + : never; diff --git a/docs/src/helpers/get-values-sorted-by-key.ts b/docs/src/helpers/get-values-sorted-by-key.ts new file mode 100644 index 000000000..5665fc117 --- /dev/null +++ b/docs/src/helpers/get-values-sorted-by-key.ts @@ -0,0 +1,12 @@ +/** + * Returns an array of values from an object ordered by their keys. + * + * @example + * const obj = { foo2: 'dog', foo1: 'pig', foo3: 'cat' }; + * const results = getValuesFromNumberedObject(obj); + * console.log(results); // ['pig', 'dog', 'cat'] + */ +export const getValuesSortedByKey = (obj: T): Array => + Object.entries(obj) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, value]) => value); diff --git a/docs/src/home-shaders.ts b/docs/src/home-shaders.ts index 895a1ab1c..9eefbffb9 100644 --- a/docs/src/home-shaders.ts +++ b/docs/src/home-shaders.ts @@ -63,6 +63,8 @@ import { staticMeshGradientPresets, StaticRadialGradient, staticRadialGradientPresets, + Tartan, + tartanPresets, } from '@paper-design/shaders-react'; import { StaticImageData } from 'next/image'; import TextureTest from './app/texture-test/page'; @@ -230,4 +232,10 @@ export const homeShaders = [ image: godRaysImg, shaderConfig: { ...godRaysPresets[0].params, speed: 2, scale: 0.5, offsetY: -0.5 }, }, + { + name: 'tartan', + url: '/tartan', + ShaderComponent: Tartan, + shaderConfig: { ...tartanPresets[0].params }, + }, ] satisfies HomeShaderConfig[]; diff --git a/packages/shaders-react/src/index.ts b/packages/shaders-react/src/index.ts index 9b4d71306..d0e270fd2 100644 --- a/packages/shaders-react/src/index.ts +++ b/packages/shaders-react/src/index.ts @@ -87,6 +87,10 @@ export { StaticRadialGradient, staticRadialGradientPresets } from './shaders/sta export type { StaticRadialGradientProps } from './shaders/static-radial-gradient.js'; export type { StaticRadialGradientUniforms, StaticRadialGradientParams } from '@paper-design/shaders'; +export { Tartan, tartanPresets } from './shaders/tartan.js'; +export type { TartanProps } from './shaders/tartan.js'; +export type { TartanUniforms, TartanParams } 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/tartan.tsx b/packages/shaders-react/src/shaders/tartan.tsx new file mode 100644 index 000000000..b01ddca4b --- /dev/null +++ b/packages/shaders-react/src/shaders/tartan.tsx @@ -0,0 +1,85 @@ +import { + defaultPatternSizing, + getShaderColorFromString, + ShaderFitOptions, + type ShaderPreset, + type TartanParams, + type TartanUniforms, + tartanFragmentShader, +} from '@paper-design/shaders'; +import { memo } from 'react'; +import { colorPropsAreEqual } from '../color-props-are-equal.js'; +import { type ShaderComponentProps, ShaderMount } from '../shader-mount.js'; + +export interface TartanProps extends ShaderComponentProps, TartanParams {} + +type TartanPreset = ShaderPreset; + +export const defaultPreset: TartanPreset = { + name: 'Default', + params: { + ...defaultPatternSizing, + stripeCount: 6, + stripeColors: ['#19600b', '#aa0909', '#19600b', '#083a0f', '#c3a855', '#083a0f'], + stripeWidths: [15, 2, 20, 15, 1, 15], + weaveSize: 3.0, + weaveStrength: 0.25, + }, +}; + +export const colorfulPreset: TartanPreset = { + name: 'Colorful', + params: { + ...defaultPatternSizing, + stripeCount: 9, + stripeColors: ['#cc3333', '#cc9933', '#99cc33', '#33cc33', '#33cc99', '#3399cc', '#3333cc', '#9933cc', '#cc3399'], + stripeWidths: [1, 2, 2, 2, 2, 2, 2, 2, 1], + weaveSize: 6.0, + weaveStrength: 0.25, + }, +}; + +export const tartanPresets: TartanPreset[] = [defaultPreset, colorfulPreset]; + +export const Tartan: React.FC = memo(function TartanImpl({ + // Own props + stripeCount = defaultPreset.params.stripeCount, + stripeColors = defaultPreset.params.stripeColors, + stripeWidths = defaultPreset.params.stripeWidths, + weaveSize = defaultPreset.params.weaveSize, + weaveStrength = defaultPreset.params.weaveStrength, + + // 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 +}: TartanProps) { + const uniforms = { + // Own uniforms + u_stripeCount: stripeCount, + u_stripeColors: stripeColors.map(getShaderColorFromString), + u_stripeWidths: stripeWidths, + u_weaveSize: weaveSize, + u_weaveStrength: weaveStrength, + + // 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 TartanUniforms; + + return ; +}, colorPropsAreEqual); diff --git a/packages/shaders/src/index.ts b/packages/shaders/src/index.ts index baa8fa376..a325ec6cb 100644 --- a/packages/shaders/src/index.ts +++ b/packages/shaders/src/index.ts @@ -171,6 +171,9 @@ export { type StaticRadialGradientUniforms, } from './shaders/static-radial-gradient.js'; +// ----- Tartan ----- // +export { tartanMeta, tartanFragmentShader, type TartanParams, type TartanUniforms } from './shaders/tartan.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/tartan.ts b/packages/shaders/src/shaders/tartan.ts new file mode 100644 index 000000000..52cc4fc9e --- /dev/null +++ b/packages/shaders/src/shaders/tartan.ts @@ -0,0 +1,121 @@ +import { sizingVariablesDeclaration, type ShaderSizingParams, type ShaderSizingUniforms } from '../shader-sizing.js'; +import type { vec4 } from '../types.js'; + +export const tartanMeta = { + maxStripeCount: 9, +} as const; + +/** + * Tartan patterns + * + * Uniforms: + * - u_stripeCount: number of stripes in the pattern (float used as integer) + * - u_stripeColors: array of stripe colors (vec4[]) + * - u_stripeWidths: array of stripe widths (mat3 used as an array) + * - u_weaveSize: width of thread used in the weave texture (float) + * - u_weaveStrength: strength of weave texture (float) + * + */ + +// language=GLSL +export const tartanFragmentShader: string = `#version 300 es +precision mediump float; + +uniform float u_stripeCount; +uniform vec4[${tartanMeta.maxStripeCount}] u_stripeColors; +uniform mat3 u_stripeWidths; +uniform float u_weaveSize; +uniform float u_weaveStrength; + +${sizingVariablesDeclaration} + +out vec4 fragColor; + +void main() { + vec2 uv = (v_patternUV * 100.0) / u_weaveSize; + + vec2 weave = mod( + vec2( + uv.x + floor(mod(uv.y, 4.0)), + uv.y + floor(mod(uv.x, 4.0)) - 2.0 + ), + 4.0 + ); + + // Color + + vec4 verticalColor, horizontalColor; + + float[${tartanMeta.maxStripeCount}] cumulativeWidths; + + float totalWidth = 0.0; + + for (int i = 0; i < ${tartanMeta.maxStripeCount}; i++) { + if (i >= int(u_stripeCount)) break; + float width = float(u_stripeWidths[int(i / 3)][int(i % 3)]); + cumulativeWidths[i] = (i == 0 ? 0.0 : cumulativeWidths[i - 1]) + width; + totalWidth += width; + } + + vec2 stripe = mod( + uv, + totalWidth * 2.0 + ) - totalWidth; + + for (int i = 0; i < ${tartanMeta.maxStripeCount}; i++) { + if (i >= int(u_stripeCount)) break; + verticalColor = u_stripeColors[i]; + if (abs(stripe.x) < cumulativeWidths[i]) { + break; + } + } + + for (int i = 0; i < ${tartanMeta.maxStripeCount}; i++) { + if (i >= int(u_stripeCount)) break; + horizontalColor = u_stripeColors[i]; + if (abs(stripe.y) < cumulativeWidths[i]) { + break; + } + } + + fragColor = mix( + verticalColor, + horizontalColor, + 1.0 - step(2.0, weave.x) + ); + + // Texture + + vec2 brightness = vec2(0.0); + + brightness += smoothstep(0.0, 0.5, weave); + brightness -= smoothstep(1.5, 2.0, weave); + + brightness += smoothstep(2.0, 2.25, weave); + brightness -= smoothstep(2.75, 3.0, weave); + + brightness += smoothstep(3.0, 3.25, weave); + brightness -= smoothstep(3.75, 4.0, weave); + + brightness *= u_weaveStrength; + brightness += 1.0 - u_weaveStrength; + + fragColor = mix(vec4(0.0, 0.0, 0.0, 1.0), fragColor, brightness.x * brightness.y); +} +`; + +export interface TartanUniforms extends ShaderSizingUniforms { + u_stripeColors: vec4[]; + u_stripeWidths: number[]; + u_stripeCount: number; + u_weaveSize: number; + u_weaveStrength: number; +} + +export interface TartanParams extends ShaderSizingParams { + stripeCount?: number; + stripeColors?: string[]; + stripeWidths?: number[]; + weaveSize?: number; + weaveStrength?: number; +}