175 lines
5.2 KiB
Markdown
175 lines
5.2 KiB
Markdown
# 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.42–0.45, opacity: 0.8
|
||
- Height: 80–160px, full content width
|
||
- Use glitch/smear mode for rave poster feel
|
||
- Placed between sections as visual weight
|
||
|
||
### Section dither bands
|
||
- pixelSize: 3, threshold: 0.5–0.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)
|