.agents/skills/signet-design/references/generative-patterns.md

175 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Generative Patterns
Signet uses procedural canvas-based halftone dithering as bold
compositional elements. These are structural to the layout, not
subtle background noise.
## Pipeline
```
Seeded Perlin noise → fbm (fractal Brownian motion) → Bayer 4x4 dither → canvas pixel fill
```
## Seeded Perlin Noise
Use a seeded PRNG for consistent patterns across page reloads:
```js
let seed = 42;
function seededRand() {
seed = (seed * 16807 + 0) % 2147483647;
return (seed - 1) / 2147483646;
}
const PERM = new Uint8Array(512);
for (let i = 0; i < 256; i++) PERM[i] = i;
for (let i = 255; i > 0; i--) {
const j = Math.floor(seededRand() * (i + 1));
[PERM[i], PERM[j]] = [PERM[j], PERM[i]];
}
for (let i = 0; i < 256; i++) PERM[i + 256] = PERM[i];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(hash, x, y) {
const h = hash & 3;
return (h < 2 ? x : -x) + (h === 0 || h === 3 ? y : -y);
}
function noise2d(x, y) {
const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
const xf = x - Math.floor(x), yf = y - Math.floor(y);
const u = fade(xf), v = fade(yf);
const aa = PERM[PERM[xi] + yi], ab = PERM[PERM[xi] + yi + 1];
const ba = PERM[PERM[xi + 1] + yi], bb = PERM[PERM[xi + 1] + yi + 1];
return lerp(
lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), v
);
}
```
## Fractal Brownian Motion (fbm)
```js
function fbm(x, y, octaves = 4) {
let val = 0, amp = 0.5, freq = 1;
for (let i = 0; i < octaves; i++) {
val += amp * noise2d(x * freq, y * freq);
amp *= 0.5;
freq *= 2;
}
return val;
}
```
## Bayer 4x4 Ordered Dither
Theme-aware — reads `--color-dither` from CSS so dots adapt to
dark/light mode:
```js
const BAYER4 = [
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5,
];
function getDitherColor() {
return getComputedStyle(document.documentElement)
.getPropertyValue('--color-dither').trim() || '#f0f0f2';
}
function ditherCanvas(canvas, noiseFn, pixelSize = 4, threshold = 0.5) {
const rect = canvas.getBoundingClientRect();
const w = Math.floor(rect.width), h = Math.floor(rect.height);
if (w === 0 || h === 0) return;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const cols = Math.floor(w / pixelSize), rows = Math.floor(h / pixelSize);
ctx.fillStyle = getDitherColor();
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const val = noiseFn(x, y, cols, rows);
const bayerVal = BAYER4[(y % 4) * 4 + (x % 4)] / 16;
if (val + (bayerVal - 0.5) * 0.4 > threshold) {
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize - 1, pixelSize - 1);
}
}
}
}
```
## Glitch/Smear Dither
Inspired by rave poster scan distortion. Combines vertical smear
(stretched-Y sampling) with horizontal glitch bands (random x-shift):
```js
function glitchNoise(x, y, cols, rows, offsetX = 0, offsetY = 0) {
const nx = x / cols * 5 + offsetX;
const ny = y / rows * 3 + offsetY;
// Base organic shape
const base = fbm(nx, ny, 5) * 0.5 + 0.5;
// Vertical smear — sample noise with stretched Y
const smearY = y / rows * 0.4;
const smear = fbm(nx * 0.3, smearY + offsetY, 3) * 0.5 + 0.5;
// Horizontal glitch bands
const bandNoise = noise2d(0.1, y / rows * 20 + offsetY) * 0.5 + 0.5;
const glitchShift = bandNoise > 0.65 ? (bandNoise - 0.65) * 8 : 0;
const shiftedBase = fbm(nx + glitchShift, ny, 4) * 0.5 + 0.5;
// Combine
return shiftedBase * 0.5 + smear * 0.3 + base * 0.2;
}
```
Use different `offsetX`/`offsetY` for each block so they don't repeat.
## Canvas Layer Recipes
### Hero (organic blobs from edges)
- pixelSize: 4, threshold: 0.46, opacity: 0.35
- Noise: two fbm layers blended 60/40, biased toward edges
- Edge fade: `min(x/cols, 1-x/cols) * 2` for both axes
- min-height: 360px
### Right edge bleed
- pixelSize: 3, threshold: 0.52, opacity: 0.18
- Fixed position, 240px wide, full viewport height
- Noise fades in from right: multiply by `x/cols`
### Bold dither blocks (compositional anchors)
- pixelSize: 3, threshold: 0.420.45, opacity: 0.8
- Height: 80160px, full content width
- Use glitch/smear mode for rave poster feel
- Placed between sections as visual weight
### Section dither bands
- pixelSize: 3, threshold: 0.50.55, opacity: 0.8
- Height: 80px, full content width
- "band" variant: stretched horizontal noise
- "cloud" variant: radial distance-faded noise
## Theme Re-rendering
On theme toggle, all canvases must re-render because dot color changes:
- Dark: `--color-dither: #f0f0f2` (light dots on dark)
- Light: `--color-dither: #0a0a0c` (dark dots on cream)
Call a `renderAllDither()` function after toggling `data-theme` with
a short delay (~60ms) so CSS variables have time to update.
## Tuning
- **More dots**: lower threshold (0.46 → 0.40)
- **Fewer dots**: raise threshold (0.46 → 0.55)
- **Larger dots**: increase pixelSize (3 → 5)
- **More organic**: increase fbm octaves (4 → 6)
- **More visible**: raise canvas opacity (0.18 → 0.35)
- **Subtler**: lower canvas opacity (0.35 → 0.15)
- **More glitch**: lower glitch band threshold (0.65 → 0.55)
- **Less glitch**: raise glitch band threshold (0.65 → 0.75)