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;
+}