@struq/skill 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// background-facets (Struq background recipe)
|
|
3
|
+
// Facetten: a full-screen hero whose ground is a surface of cut planes, like folded paper or cut glass on a studio
|
|
4
|
+
// model, each plane catching a slow travelling light. The planes stand in steps off the page ground (flat shading,
|
|
5
|
+
// one tone per plane, from the ground towards white where lit and towards the ink in shadow), the creases are crisp
|
|
6
|
+
// unbroken hairlines drawn only where two planes actually meet at an angle, and a crease that catches the light takes
|
|
7
|
+
// the palette's accent; the planes themselves are never filled with it. Two short display lines, one lead sentence
|
|
8
|
+
// and one link sit on the calm side, where the surface lies flat and is the page ground itself. The surface lies flat
|
|
9
|
+
// into the ground over the bottom fifth of the hero, so the hand-over to the next section has no seam.
|
|
10
|
+
//
|
|
11
|
+
// <BackgroundFacets variant="shard" lines={["Ruimte die", "licht vangt"]}
|
|
12
|
+
// lead="Noord ontwerpt woningen en ateliers in Rotterdam." link={{ label: "Bekijk het werk", href: "#werk" }} />
|
|
13
|
+
//
|
|
14
|
+
// Variants (different compositions and moves of the same surface, each in light and dark):
|
|
15
|
+
// shard a few cut shards standing apart in the right third, each a pyramid of unequal planes on its own
|
|
16
|
+
// footprint, pointing out from the cluster, a light circling them; type on the calm left.
|
|
17
|
+
// relief the whole screen a very low relief of large planes, a broad light sweeping slowly across; type bottom left,
|
|
18
|
+
// where the relief is lowest.
|
|
19
|
+
// turn diagonal pleats in the upper left; the light turns across them as the hero leaves (scrubbed by scroll)
|
|
20
|
+
// and leans a little toward the pointer; type bottom right.
|
|
21
|
+
//
|
|
22
|
+
// How it is drawn (raw WebGL2, no library). The surface is a height per point of a triangle lattice (the simplex
|
|
23
|
+
// lattice, equilateral triangles at -15, 45 and 105 degrees). The heights are computed in script, a few thousand
|
|
24
|
+
// points a frame, and uploaded as a small float texture; one fragment pass finds each pixel's triangle, shades it
|
|
25
|
+
// flat from its normal and draws a crease on each of its three edges where the neighbour across it folds. Every
|
|
26
|
+
// height function is built only from functions of the three lattice line coordinates with breaks on whole lattice
|
|
27
|
+
// lines, so the creases fall exactly on lattice edges: big clean planes and straight folds, never a triangle mesh.
|
|
28
|
+
// The shards are not on the lattice: each is the lowest of a few planes through one apex (a convex pyramid over its
|
|
29
|
+
// footprint, every face its own slope and normal), evaluated per pixel in the same pass, so their ridges are exact
|
|
30
|
+
// straight lines at any angle. The colours
|
|
31
|
+
// are the block's own custom properties (--surface-0, --ink, --accent), read at runtime and read again when the page
|
|
32
|
+
// switches palette or theme. No colour is written here.
|
|
33
|
+
//
|
|
34
|
+
// Entrance: the canvas stays hidden until its first frame (the flat ground) is drawn, then fades in; once nothing
|
|
35
|
+
// covers the page the surface folds up out of the flat ground while the lines rise through their masks. Pointer
|
|
36
|
+
// (turn only) eases in over about 0.18 s. Performance: device pixel ratio capped at 1.5 (1.25 on phones), the loop
|
|
37
|
+
// stops off screen and in a hidden tab, everything is disposed on unmount, and a lost context is rebuilt when the
|
|
38
|
+
// browser restores it. Phones: a lighter surface (fewer fold families and shards, bigger cells) that still moves.
|
|
39
|
+
// Reduced motion: one still frame, held. No WebGL2 (or a program that will not build): the same still drawn once on a
|
|
40
|
+
// 2D canvas. No scripts: the composed
|
|
41
|
+
// hero on the plain ground (blocks.css, "--- background-facets ---", with the 3 s fallback for the lines).
|
|
42
|
+
import { useEffect, useRef } from "react";
|
|
43
|
+
import { gsap } from "../kit/gsap";
|
|
44
|
+
import { whenUncovered } from "../preloaders/preloader-slices-uncovered";
|
|
45
|
+
|
|
46
|
+
export type FacetsVariant = "shard" | "relief" | "turn";
|
|
47
|
+
|
|
48
|
+
/** The three variants, first is the default. */
|
|
49
|
+
export const FACETS_VARIANTS: { id: FacetsVariant; title: string; does: string }[] = [
|
|
50
|
+
{
|
|
51
|
+
id: "shard",
|
|
52
|
+
title: "Kristal",
|
|
53
|
+
does: "A few cut shards stand apart in the right third, each a pyramid of unequal planes, while a light circles them and each plane and ridge catches it in turn; the type sits on the flat ground to the left.",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "relief",
|
|
57
|
+
title: "Reliëf",
|
|
58
|
+
does: "The whole screen is a very low relief of large planes that tilt slowly, and a broad band of light sweeps across them; the relief is lowest under the type, bottom left.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "turn",
|
|
62
|
+
title: "Draai",
|
|
63
|
+
does: "Diagonal pleats fill the upper left; the light turns across them as the hero scrolls away, flipping which pleats are lit, and leans toward the pointer.",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
type Props = {
|
|
68
|
+
variant?: FacetsVariant;
|
|
69
|
+
/** Two short display lines. */
|
|
70
|
+
lines: [string, string];
|
|
71
|
+
/** One lead sentence. */
|
|
72
|
+
lead: string;
|
|
73
|
+
/** One text link. */
|
|
74
|
+
link: { label: string; href: string };
|
|
75
|
+
className?: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type RGB = [number, number, number];
|
|
79
|
+
|
|
80
|
+
const F2 = 0.3660254037844386;
|
|
81
|
+
const G2 = 0.21132486540518713;
|
|
82
|
+
|
|
83
|
+
const smooth = (a: number, b: number, x: number) => {
|
|
84
|
+
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
|
85
|
+
return t * t * (3 - 2 * t);
|
|
86
|
+
};
|
|
87
|
+
const mix = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
88
|
+
const hash = (n: number) => {
|
|
89
|
+
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
|
90
|
+
return s - Math.floor(s);
|
|
91
|
+
};
|
|
92
|
+
/** Smooth 1D value noise in -1..1. */
|
|
93
|
+
const vnoise = (x: number) => {
|
|
94
|
+
const i = Math.floor(x);
|
|
95
|
+
const f = x - i;
|
|
96
|
+
const u = f * f * (3 - 2 * f);
|
|
97
|
+
return (hash(i) * (1 - u) + hash(i + 1) * u) * 2 - 1;
|
|
98
|
+
};
|
|
99
|
+
const luminance = ([r, g, b]: RGB) => {
|
|
100
|
+
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
101
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One family of parallel folds: a piecewise linear function of one lattice line coordinate (c1 = I.x, c2 = I.y,
|
|
106
|
+
* c3 = I.x - I.y) with its breaks (knots) on whole lattice lines, so its creases are lattice edges.
|
|
107
|
+
*/
|
|
108
|
+
type Family = {
|
|
109
|
+
axis: 1 | 2 | 3;
|
|
110
|
+
min: number;
|
|
111
|
+
/** Per whole coordinate from `min`: the knot to its left and how far towards the next it is. */
|
|
112
|
+
seg: Int32Array;
|
|
113
|
+
frac: Float32Array;
|
|
114
|
+
/** Per knot: its weight from the composition, and a seed. */
|
|
115
|
+
ramp: Float32Array;
|
|
116
|
+
seed: Float32Array;
|
|
117
|
+
/** Per knot: its lattice coordinate. */
|
|
118
|
+
at: Float32Array;
|
|
119
|
+
/** Per knot, per frame: its height. */
|
|
120
|
+
value: Float32Array;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One shard: a convex footprint (counterclockwise, CSS px, y up from the section's bottom) and an apex inside it at
|
|
125
|
+
* height `h`. Its faces are the triangles from the apex to each footprint edge, each with its own slope.
|
|
126
|
+
*/
|
|
127
|
+
type Crystal = { ax: number; ay: number; pts: number[]; h: number; phase: number };
|
|
128
|
+
|
|
129
|
+
/** One face of a shard for a frame: its plane as gradient and height at the apex, and its corners on screen. */
|
|
130
|
+
type Face = { gx: number; gy: number; ax: number; ay: number; h: number; tri: number[]; beam: number };
|
|
131
|
+
|
|
132
|
+
/** The most shards and faces per shard the shader takes. */
|
|
133
|
+
const MAX_SHARDS = 6;
|
|
134
|
+
const MAX_FACES = 8;
|
|
135
|
+
|
|
136
|
+
type Plan = {
|
|
137
|
+
variant: FacetsVariant;
|
|
138
|
+
W: number;
|
|
139
|
+
H: number;
|
|
140
|
+
scale: number;
|
|
141
|
+
cell: number;
|
|
142
|
+
drift: number;
|
|
143
|
+
offX: number;
|
|
144
|
+
offY: number;
|
|
145
|
+
tw: number;
|
|
146
|
+
th: number;
|
|
147
|
+
heights: Float32Array;
|
|
148
|
+
families: Family[];
|
|
149
|
+
crystals: Crystal[];
|
|
150
|
+
/** The rect the type stands in, y up from the section's bottom, CSS px; and how quiet the surface is there. */
|
|
151
|
+
quiet: { x0: number; y0: number; x1: number; y1: number; level: number; feather: number } | null;
|
|
152
|
+
look: { tone: number; glint: number; sharp: number; line: number };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type Light = { L: [number, number, number]; beam: [number, number, number, number]; beamOn: number };
|
|
156
|
+
|
|
157
|
+
type Colours = { ground: RGB; ink: RGB; accent: RGB; shadow: RGB; lit: RGB; glint: RGB; dark: boolean };
|
|
158
|
+
|
|
159
|
+
const lineCoord = (axis: 1 | 2 | 3, ix: number, iy: number) => (axis === 1 ? ix : axis === 2 ? iy : ix - iy);
|
|
160
|
+
|
|
161
|
+
/** The screen position (CSS px, origin at the lattice origin) that sets a knot's weight. */
|
|
162
|
+
function knotPlace(axis: 1 | 2 | 3, c: number, cell: number, W: number, H: number) {
|
|
163
|
+
// c1 = (x (1 + F2) + y F2) / cell, c2 = (y (1 + F2) + x F2) / cell, c3 = (x - y) / cell.
|
|
164
|
+
if (axis === 1) return (c * cell - F2 * H * 0.5) / (1 + F2); // x where the line crosses mid height
|
|
165
|
+
if (axis === 2) return (c * cell - F2 * W * 0.5) / (1 + F2); // y where the line crosses mid width
|
|
166
|
+
return c * cell; // x - y
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function family(
|
|
170
|
+
axis: 1 | 2 | 3,
|
|
171
|
+
min: number,
|
|
172
|
+
max: number,
|
|
173
|
+
gap: (k: number) => number,
|
|
174
|
+
weight: (place: number) => number,
|
|
175
|
+
cell: number,
|
|
176
|
+
W: number,
|
|
177
|
+
H: number,
|
|
178
|
+
seed: number,
|
|
179
|
+
): Family {
|
|
180
|
+
const knots: number[] = [];
|
|
181
|
+
let c = min - 8;
|
|
182
|
+
let k = 0;
|
|
183
|
+
while (c <= max + 8) {
|
|
184
|
+
knots.push(c);
|
|
185
|
+
c += gap(k);
|
|
186
|
+
k += 1;
|
|
187
|
+
}
|
|
188
|
+
knots.push(c);
|
|
189
|
+
const span = max - min + 1;
|
|
190
|
+
const seg = new Int32Array(span);
|
|
191
|
+
const frac = new Float32Array(span);
|
|
192
|
+
let j = 0;
|
|
193
|
+
for (let n = 0; n < span; n += 1) {
|
|
194
|
+
const at = min + n;
|
|
195
|
+
while (j < knots.length - 2 && knots[j + 1] <= at) j += 1;
|
|
196
|
+
seg[n] = j;
|
|
197
|
+
frac[n] = (at - knots[j]) / (knots[j + 1] - knots[j]);
|
|
198
|
+
}
|
|
199
|
+
const ramp = new Float32Array(knots.length);
|
|
200
|
+
const seeds = new Float32Array(knots.length);
|
|
201
|
+
knots.forEach((kc, i) => {
|
|
202
|
+
ramp[i] = weight(knotPlace(axis, kc, cell, W, H));
|
|
203
|
+
seeds[i] = seed + i * 1.37;
|
|
204
|
+
});
|
|
205
|
+
return { axis, min, seg, frac, ramp, seed: seeds, at: Float32Array.from(knots), value: new Float32Array(knots.length) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Lays out the lattice, the composition and the look for one size. */
|
|
209
|
+
function makePlan(variant: FacetsVariant, W: number, H: number, quietBox: DOMRect | null): Plan {
|
|
210
|
+
const phone = W < 768;
|
|
211
|
+
const scale = Math.min(window.devicePixelRatio || 1, phone ? 1.25 : 1.5);
|
|
212
|
+
const cell =
|
|
213
|
+
variant === "shard"
|
|
214
|
+
? Math.max(18, Math.min(30, W / 46))
|
|
215
|
+
: variant === "relief"
|
|
216
|
+
? Math.max(28, Math.min(46, W / 30))
|
|
217
|
+
: Math.max(24, Math.min(40, W / 36));
|
|
218
|
+
const drift = H * 0.28;
|
|
219
|
+
// The lattice range that covers the canvas at every drift (the origin moves down by up to `drift`).
|
|
220
|
+
const px = [0, W / cell];
|
|
221
|
+
const py = [0, (H + drift) / cell];
|
|
222
|
+
let minX = Infinity;
|
|
223
|
+
let minY = Infinity;
|
|
224
|
+
let maxX = -Infinity;
|
|
225
|
+
let maxY = -Infinity;
|
|
226
|
+
for (const x of px)
|
|
227
|
+
for (const y of py) {
|
|
228
|
+
const s = (x + y) * F2;
|
|
229
|
+
minX = Math.min(minX, x + s);
|
|
230
|
+
maxX = Math.max(maxX, x + s);
|
|
231
|
+
minY = Math.min(minY, y + s);
|
|
232
|
+
maxY = Math.max(maxY, y + s);
|
|
233
|
+
}
|
|
234
|
+
const offX = Math.floor(minX) - 4;
|
|
235
|
+
const offY = Math.floor(minY) - 4;
|
|
236
|
+
const tw = Math.ceil(maxX) + 4 - offX + 1;
|
|
237
|
+
const th = Math.ceil(maxY) + 4 - offY + 1;
|
|
238
|
+
const families: Family[] = [];
|
|
239
|
+
const crystals: Crystal[] = [];
|
|
240
|
+
const cMin = (axis: 1 | 2 | 3) => (axis === 1 ? offX : axis === 2 ? offY : offX - (offY + th));
|
|
241
|
+
const cMax = (axis: 1 | 2 | 3) => (axis === 1 ? offX + tw : axis === 2 ? offY + th : offX + tw - offY);
|
|
242
|
+
|
|
243
|
+
let quiet: Plan["quiet"] = null;
|
|
244
|
+
if (quietBox) {
|
|
245
|
+
const pad = 12;
|
|
246
|
+
quiet = {
|
|
247
|
+
x0: quietBox.left - pad,
|
|
248
|
+
x1: quietBox.right + pad,
|
|
249
|
+
y0: H - quietBox.bottom - pad,
|
|
250
|
+
y1: H - quietBox.top + pad,
|
|
251
|
+
level: variant === "relief" ? 0.12 : 0,
|
|
252
|
+
feather: cell * (variant === "turn" ? 2.5 : 1.6),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (variant === "shard") {
|
|
257
|
+
// A few shards standing apart in the right third (above the type on phones), each pointing out from the
|
|
258
|
+
// cluster's middle. Footprints never overlap, so no edge runs through another shard.
|
|
259
|
+
const rx0 = phone ? W * 0.06 : Math.max(W * 0.55, quiet ? quiet.x1 + 56 : 0);
|
|
260
|
+
const rx1 = phone ? W * 0.94 : W * 0.965;
|
|
261
|
+
const ry0 = phone ? Math.max(H * 0.38, quiet ? quiet.y1 + 28 : 0) : H * 0.2;
|
|
262
|
+
const ry1 = phone ? H * 0.95 : H * 0.92;
|
|
263
|
+
const rw = Math.max(1, rx1 - rx0);
|
|
264
|
+
const rh = Math.max(1, ry1 - ry0);
|
|
265
|
+
const D = Math.min(rw, rh);
|
|
266
|
+
const mx = rx0 + rw * 0.5;
|
|
267
|
+
const my = ry0 + rh * 0.5;
|
|
268
|
+
// [x, y (up)] in the region, half length as a share of its smaller side.
|
|
269
|
+
const list: [number, number, number][] = phone
|
|
270
|
+
? [
|
|
271
|
+
[0.4, 0.48, 0.36],
|
|
272
|
+
[0.84, 0.8, 0.2],
|
|
273
|
+
[0.82, 0.18, 0.2],
|
|
274
|
+
]
|
|
275
|
+
: [
|
|
276
|
+
[0.44, 0.52, 0.34],
|
|
277
|
+
[0.82, 0.82, 0.19],
|
|
278
|
+
[0.84, 0.24, 0.22],
|
|
279
|
+
[0.15, 0.17, 0.17],
|
|
280
|
+
[0.13, 0.84, 0.15],
|
|
281
|
+
];
|
|
282
|
+
const items = list.map(([u, v, size], i) => {
|
|
283
|
+
const x = rx0 + u * rw;
|
|
284
|
+
const y = ry0 + v * rh;
|
|
285
|
+
const out = Math.hypot(x - mx, y - my) > D * 0.08 ? Math.atan2(y - my, x - mx) : 1.3;
|
|
286
|
+
return { x, y, L: size * D, phi: out + (hash(i * 4.1 + 2.3) - 0.5) * 0.5, i };
|
|
287
|
+
});
|
|
288
|
+
// Push apart until no two reaches touch, kept inside the region; then shrink any pair still too close.
|
|
289
|
+
for (let pass = 0; pass < 40; pass += 1) {
|
|
290
|
+
for (let a = 0; a < items.length; a += 1)
|
|
291
|
+
for (let b = a + 1; b < items.length; b += 1) {
|
|
292
|
+
const p = items[a];
|
|
293
|
+
const q = items[b];
|
|
294
|
+
const dx = q.x - p.x;
|
|
295
|
+
const dy = q.y - p.y;
|
|
296
|
+
const dist = Math.hypot(dx, dy) || 1;
|
|
297
|
+
const need = (p.L + q.L) * 1.06;
|
|
298
|
+
if (dist >= need) continue;
|
|
299
|
+
const push = (need - dist) / 2;
|
|
300
|
+
p.x -= (dx / dist) * push;
|
|
301
|
+
p.y -= (dy / dist) * push;
|
|
302
|
+
q.x += (dx / dist) * push;
|
|
303
|
+
q.y += (dy / dist) * push;
|
|
304
|
+
}
|
|
305
|
+
for (const p of items) {
|
|
306
|
+
p.x = Math.min(rx1 - p.L * 0.8, Math.max(rx0 + p.L * 0.8, p.x));
|
|
307
|
+
p.y = Math.min(ry1 - p.L * 0.8, Math.max(ry0 + p.L * 0.8, p.y));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (let a = 0; a < items.length; a += 1)
|
|
311
|
+
for (let b = a + 1; b < items.length; b += 1) {
|
|
312
|
+
const p = items[a];
|
|
313
|
+
const q = items[b];
|
|
314
|
+
const k = Math.hypot(q.x - p.x, q.y - p.y) / ((p.L + q.L) * 1.06);
|
|
315
|
+
if (k < 1) {
|
|
316
|
+
p.L *= k;
|
|
317
|
+
q.L *= k;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// A pointed footprint (x along the shard to its tip, y across), counterclockwise; each shard nudges its
|
|
321
|
+
// corners, may drop one, and leans its apex towards the tip, so no two faces share a slope.
|
|
322
|
+
const shape: [number, number][] = [
|
|
323
|
+
[1.0, 0.0],
|
|
324
|
+
[0.5, 0.38],
|
|
325
|
+
[-0.3, 0.46],
|
|
326
|
+
[-0.8, 0.22],
|
|
327
|
+
[-0.86, -0.18],
|
|
328
|
+
[-0.4, -0.44],
|
|
329
|
+
[0.42, -0.36],
|
|
330
|
+
];
|
|
331
|
+
for (const { x, y, L, phi, i } of items) {
|
|
332
|
+
const seed = 19.7 + i * 7.9;
|
|
333
|
+
const wide = 0.72 + 0.3 * hash(seed);
|
|
334
|
+
const flip = hash(seed + 1) < 0.5 ? -1 : 1;
|
|
335
|
+
const drop = hash(seed + 2) < 0.5 ? 3 + Math.floor(hash(seed + 3) * 2) : -1;
|
|
336
|
+
const c = Math.cos(phi);
|
|
337
|
+
const s = Math.sin(phi);
|
|
338
|
+
const local = shape
|
|
339
|
+
.filter((_, k) => k !== drop)
|
|
340
|
+
.map(([px, py], k): [number, number] => [px + (hash(seed + 5 + k) - 0.5) * 0.1, (py + (hash(seed + 13 + k) - 0.5) * 0.1) * wide]);
|
|
341
|
+
if (flip < 0) local.reverse().forEach((p) => (p[1] = -p[1]));
|
|
342
|
+
const pts: number[] = [];
|
|
343
|
+
for (const [px, py] of local) pts.push(x + (px * c - py * s) * L, y + (px * s + py * c) * L);
|
|
344
|
+
const ox = 0.2 + 0.16 * hash(seed + 4);
|
|
345
|
+
const oy = (hash(seed + 6) - 0.5) * 0.12 * wide;
|
|
346
|
+
crystals.push({
|
|
347
|
+
ax: x + (ox * c - oy * s) * L,
|
|
348
|
+
ay: y + (ox * s + oy * c) * L,
|
|
349
|
+
pts,
|
|
350
|
+
h: L * (0.26 + 0.1 * hash(seed + 7)),
|
|
351
|
+
phase: i * 1.7,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
} else if (variant === "relief") {
|
|
355
|
+
// Three families of large folds over the whole screen, each heaviest away from the type (bottom left):
|
|
356
|
+
// near vertical folds stronger to the right, near horizontal ones stronger to the top, diagonals to the bottom
|
|
357
|
+
// right. Knots every 3 to 7 lines, so the planes are large and unequal.
|
|
358
|
+
const amp = 0.55;
|
|
359
|
+
const gap = (seed: number) => (k: number) => 2 + Math.floor(hash(seed + k * 7.31) * 3);
|
|
360
|
+
families.push(family(1, cMin(1), cMax(1), gap(11), (x) => amp * (0.3 + 0.7 * smooth(W * 0.35, W * 0.85, x)), cell, W, H, 3.1));
|
|
361
|
+
families.push(family(2, cMin(2), cMax(2), gap(23), (y) => amp * (0.3 + 0.7 * smooth(H * 0.45, H * 0.95, y)), cell, W, H, 17.9));
|
|
362
|
+
if (!phone)
|
|
363
|
+
families.push(family(3, cMin(3), cMax(3), gap(37), (u) => amp * 0.8 * (0.25 + 0.75 * smooth(W * 0.2, W * 0.75, u)), cell, W, H, 41.3));
|
|
364
|
+
} else {
|
|
365
|
+
// Pleats along the 45 degree lattice lines, three to five lines apart, alternately up and down, full in the upper
|
|
366
|
+
// left and fading out towards the type, bottom right (above it on phones). The fade ends a feather short of the
|
|
367
|
+
// type's corner (u = x - y, the pleat's own coordinate), so no part plane is left beside the lines.
|
|
368
|
+
let u1 = phone ? -H * 0.3 : W * 0.3;
|
|
369
|
+
if (!phone && quiet) u1 = Math.min(u1, quiet.x0 - quiet.y1 - quiet.feather);
|
|
370
|
+
const u0 = phone ? -H * 0.5 : u1 - W * 0.2;
|
|
371
|
+
families.push(
|
|
372
|
+
family(3, cMin(3), cMax(3), (k) => 3 + Math.floor(hash(71 + k * 5.3) * 3), (u) => 0.7 * (1 - smooth(u0, u1, u)), cell, W, H, 5.7),
|
|
373
|
+
);
|
|
374
|
+
// Near vertical cuts across the pleats, so each pleat breaks into a run of planes at different angles rather than
|
|
375
|
+
// one long stripe. Kept to the left, well clear of the type; phones keep the pleats alone.
|
|
376
|
+
if (!phone)
|
|
377
|
+
families.push(
|
|
378
|
+
family(1, cMin(1), cMax(1), (k) => 5 + Math.floor(hash(53 + k * 3.7) * 5), (x) => 0.4 * (1 - smooth(W * 0.24, W * 0.44, x)), cell, W, H, 29.3),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const look =
|
|
382
|
+
variant === "shard"
|
|
383
|
+
? { tone: 1.8, glint: 0.9, sharp: 40, line: 0.3 }
|
|
384
|
+
: variant === "relief"
|
|
385
|
+
? { tone: 3.1, glint: 0.75, sharp: 12, line: 0.26 }
|
|
386
|
+
: { tone: 1.3, glint: 0.8, sharp: 26, line: 0.26 };
|
|
387
|
+
return {
|
|
388
|
+
variant,
|
|
389
|
+
W,
|
|
390
|
+
H,
|
|
391
|
+
scale,
|
|
392
|
+
cell,
|
|
393
|
+
drift,
|
|
394
|
+
offX,
|
|
395
|
+
offY,
|
|
396
|
+
tw,
|
|
397
|
+
th,
|
|
398
|
+
heights: new Float32Array(tw * th),
|
|
399
|
+
families,
|
|
400
|
+
crystals,
|
|
401
|
+
quiet,
|
|
402
|
+
look,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The heights for one frame: the variant's surface folded up by `reveal`. The GPU quietens the type's rect per pixel
|
|
408
|
+
* (scaling heights there would tilt every lattice triangle in the feather a little differently and show them as
|
|
409
|
+
* small steps); the 2D still has no such pass, so it asks for the quiet in the heights (`geoQuiet`).
|
|
410
|
+
*/
|
|
411
|
+
function fillHeights(plan: Plan, t: number, reveal: number, originY: number, geoQuiet: boolean, dark = false) {
|
|
412
|
+
const { families, heights, tw, th, offX, offY, cell, quiet, variant } = plan;
|
|
413
|
+
if (!families.length) {
|
|
414
|
+
heights.fill(0);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// On a dark ground the relief's three families of folds, a crease every two to four lines in each direction, light
|
|
418
|
+
// up as one regular lattice and read as a grid. There about half the knots lie flat (never two side by side): each
|
|
419
|
+
// takes the straight line between its neighbours, so its crease is gone and the planes either side merge into one,
|
|
420
|
+
// larger and unequal, and no direction keeps an even beat.
|
|
421
|
+
const thin = dark && variant === "relief";
|
|
422
|
+
for (const f of families) {
|
|
423
|
+
for (let j = 0; j < f.value.length; j += 1) {
|
|
424
|
+
if (variant === "turn" && f.axis === 3) {
|
|
425
|
+
// Alternate up and down, each pleat at its own depth, breathing a little on its own.
|
|
426
|
+
f.value[j] = (j % 2 === 0 ? 1 : -1) * f.ramp[j] * (0.45 + 0.55 * hash(f.seed[j])) * (0.88 + 0.12 * vnoise(f.seed[j] + t * 0.06));
|
|
427
|
+
} else {
|
|
428
|
+
f.value[j] = f.ramp[j] * vnoise(f.seed[j] + t * 0.035);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (thin) {
|
|
432
|
+
let skipped = false;
|
|
433
|
+
for (let j = 1; j < f.value.length - 1; j += 1) {
|
|
434
|
+
if (skipped || hash(f.seed[j] * 1.91 + 0.37) >= 0.55) {
|
|
435
|
+
skipped = false;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const w = (f.at[j] - f.at[j - 1]) / (f.at[j + 1] - f.at[j - 1]);
|
|
439
|
+
f.value[j] = f.value[j - 1] * (1 - w) + f.value[j + 1] * w;
|
|
440
|
+
skipped = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (let j = 0; j < th; j += 1) {
|
|
445
|
+
const iy = offY + j;
|
|
446
|
+
for (let i = 0; i < tw; i += 1) {
|
|
447
|
+
const ix = offX + i;
|
|
448
|
+
let h = 0;
|
|
449
|
+
for (const f of families) {
|
|
450
|
+
const n = lineCoord(f.axis, ix, iy) - f.min;
|
|
451
|
+
const s = f.seg[n];
|
|
452
|
+
const w = f.frac[n];
|
|
453
|
+
let c = f.value[s] * (1 - w) + f.value[s + 1] * w;
|
|
454
|
+
// Each pleat steps to a new depth every few cells along its own length, so it reads as a run of crisp planes,
|
|
455
|
+
// never one stripe, and no second family of bands has to cross it.
|
|
456
|
+
if (variant === "turn" && f.axis === 3) c *= 0.55 + 0.45 * hash(97.3 + Math.floor((ix + iy) / 7));
|
|
457
|
+
h += c;
|
|
458
|
+
}
|
|
459
|
+
if (quiet && geoQuiet) {
|
|
460
|
+
const x = (ix - (ix + iy) * G2) * cell;
|
|
461
|
+
const y = (iy - (ix + iy) * G2) * cell + originY;
|
|
462
|
+
const f = quiet.feather;
|
|
463
|
+
const inX = smooth(quiet.x0 - f, quiet.x0, x) * (1 - smooth(quiet.x1, quiet.x1 + f, x));
|
|
464
|
+
const inY = smooth(quiet.y0 - f, quiet.y0, y) * (1 - smooth(quiet.y1, quiet.y1 + f, y));
|
|
465
|
+
h *= 1 - inX * inY * (1 - quiet.level);
|
|
466
|
+
}
|
|
467
|
+
heights[j * tw + i] = h * reveal;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Where a point (CSS px, y up) stands in the light's broad beam: 1 in its middle, as the shader reads it. */
|
|
473
|
+
function beamAt(plan: Plan, light: Light, x: number, y: number) {
|
|
474
|
+
const u = (x * light.beam[0] + y * light.beam[1]) / plan.H;
|
|
475
|
+
return 1 + (Math.exp(-(((u - light.beam[2]) / light.beam[3]) ** 2)) - 1) * light.beamOn;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The shards' faces for one frame, on screen (CSS px, y up): each shard breathes a little, folds up with `reveal`
|
|
480
|
+
* and moves with the surface (`originY`). A face's plane passes through the apex at the shard's height and through
|
|
481
|
+
* its footprint edge at the ground.
|
|
482
|
+
*/
|
|
483
|
+
function shardFaces(plan: Plan, t: number, reveal: number, originY: number, light: Light): Face[][] {
|
|
484
|
+
return plan.crystals.map((c) => {
|
|
485
|
+
const h = c.h * reveal * (1 + 0.08 * Math.sin(t * 0.13 + c.phase));
|
|
486
|
+
const ax = c.ax;
|
|
487
|
+
const ay = c.ay + originY;
|
|
488
|
+
const n = c.pts.length / 2;
|
|
489
|
+
const faces: Face[] = [];
|
|
490
|
+
for (let k = 0; k < n; k += 1) {
|
|
491
|
+
const x0 = c.pts[k * 2];
|
|
492
|
+
const y0 = c.pts[k * 2 + 1] + originY;
|
|
493
|
+
const x1 = c.pts[((k + 1) % n) * 2];
|
|
494
|
+
const y1 = c.pts[((k + 1) % n) * 2 + 1] + originY;
|
|
495
|
+
// The edge's outward normal (counterclockwise footprint) and the apex's distance to the edge.
|
|
496
|
+
const ex = x1 - x0;
|
|
497
|
+
const ey = y1 - y0;
|
|
498
|
+
const el = Math.hypot(ex, ey) || 1;
|
|
499
|
+
const ux = ey / el;
|
|
500
|
+
const uy = -ex / el;
|
|
501
|
+
const d = Math.max(1e-3, (x0 - ax) * ux + (y0 - ay) * uy);
|
|
502
|
+
const slope = h / d;
|
|
503
|
+
faces.push({
|
|
504
|
+
gx: -slope * ux,
|
|
505
|
+
gy: -slope * uy,
|
|
506
|
+
ax,
|
|
507
|
+
ay,
|
|
508
|
+
h,
|
|
509
|
+
tri: [ax, ay, x0, y0, x1, y1],
|
|
510
|
+
beam: beamAt(plan, light, (ax + x0 + x1) / 3, (ay + y0 + y1) / 3),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return faces;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The light for one frame. `turn` is the scrubbed scroll progress, `ptr` the eased pointer (-1..1, y up). */
|
|
518
|
+
function lightFor(plan: Plan, t: number, turn: number, ptr: [number, number]): Light {
|
|
519
|
+
const aspect = plan.W / plan.H;
|
|
520
|
+
let az: number;
|
|
521
|
+
let el: number;
|
|
522
|
+
let beam: [number, number, number, number] = [1, 0, 0, 1];
|
|
523
|
+
let beamOn = 0;
|
|
524
|
+
if (plan.variant === "shard") {
|
|
525
|
+
az = 0.9 + t * 0.09;
|
|
526
|
+
el = 0.5 + 0.26 * Math.sin(t * 0.07);
|
|
527
|
+
const cycle = 16;
|
|
528
|
+
const k = (t % cycle) / cycle;
|
|
529
|
+
beam = [0.3, 0.954, -0.4 + k * 1.8, 0.42];
|
|
530
|
+
beamOn = 0.55;
|
|
531
|
+
} else if (plan.variant === "relief") {
|
|
532
|
+
az = 0.5 + 0.35 * Math.sin(t * 0.045);
|
|
533
|
+
el = 1.05;
|
|
534
|
+
const span = aspect * 0.94 + 0.34;
|
|
535
|
+
const cycle = 26;
|
|
536
|
+
const k = Math.min(1, (t % cycle) / 21);
|
|
537
|
+
beam = [0.94, 0.341, -0.5 + k * (span + 1), 0.36];
|
|
538
|
+
beamOn = 1;
|
|
539
|
+
} else {
|
|
540
|
+
az = 2.5 + (-1.0 - 2.5) * turn + 0.12 * Math.sin(t * 0.09) + ptr[0] * 0.55;
|
|
541
|
+
el = Math.min(1.2, Math.max(0.3, 0.62 + ptr[1] * 0.18));
|
|
542
|
+
}
|
|
543
|
+
const L: [number, number, number] = [Math.cos(az) * Math.cos(el), Math.sin(az) * Math.cos(el), Math.sin(el)];
|
|
544
|
+
return { L, beam, beamOn };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const VERT = `#version 300 es
|
|
548
|
+
in vec2 aPos;
|
|
549
|
+
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
|
550
|
+
`;
|
|
551
|
+
|
|
552
|
+
const FRAG = `#version 300 es
|
|
553
|
+
precision highp float;
|
|
554
|
+
precision highp int;
|
|
555
|
+
uniform highp sampler2D uH;
|
|
556
|
+
uniform ivec2 uOff;
|
|
557
|
+
uniform vec2 uRes;
|
|
558
|
+
uniform vec2 uOrigin;
|
|
559
|
+
uniform float uCell;
|
|
560
|
+
uniform vec3 uGround;
|
|
561
|
+
uniform vec3 uShadow;
|
|
562
|
+
uniform vec3 uLit;
|
|
563
|
+
uniform vec3 uGlintC;
|
|
564
|
+
uniform vec3 uInk;
|
|
565
|
+
uniform vec3 uAccent;
|
|
566
|
+
uniform vec3 uL;
|
|
567
|
+
uniform vec4 uBeam;
|
|
568
|
+
uniform float uBeamOn;
|
|
569
|
+
uniform vec4 uLook;
|
|
570
|
+
uniform float uFadeH;
|
|
571
|
+
// The type's rect (canvas px, y up), and its feather and how much of the surface stays there.
|
|
572
|
+
uniform vec4 uQuiet;
|
|
573
|
+
uniform vec2 uQuietF;
|
|
574
|
+
uniform int uNC;
|
|
575
|
+
// The shards' faces, ${MAX_FACES} per shard: (gradient x, gradient y, height at the canvas origin, beam), canvas px.
|
|
576
|
+
uniform vec4 uP[${MAX_SHARDS * MAX_FACES}];
|
|
577
|
+
out vec4 o;
|
|
578
|
+
const float F2 = 0.36602540378;
|
|
579
|
+
const float G2 = 0.21132486541;
|
|
580
|
+
// A lattice triangle's height, in cells: how far a corner stands from the edge across from it.
|
|
581
|
+
const float ALT = 0.70710678119;
|
|
582
|
+
vec2 un(vec2 I) { return I - (I.x + I.y) * G2; }
|
|
583
|
+
float hAt(vec2 I) { return texelFetch(uH, ivec2(I) - uOff, 0).r; }
|
|
584
|
+
vec3 facetNormal(vec2 a, vec2 b, vec2 c, float ha, float hb, float hc) {
|
|
585
|
+
vec3 n = normalize(cross(vec3(un(b) - un(a), hb - ha), vec3(un(c) - un(a), hc - ha)));
|
|
586
|
+
return n.z < 0.0 ? -n : n;
|
|
587
|
+
}
|
|
588
|
+
float beamAt(vec2 px) {
|
|
589
|
+
float x = dot(px / uRes.y, uBeam.xy);
|
|
590
|
+
return mix(1.0, exp(-pow((x - uBeam.z) / uBeam.w, 2.0)), uBeamOn);
|
|
591
|
+
}
|
|
592
|
+
float beamCell(vec2 centre) { return beamAt(un(centre) * uCell + uOrigin); }
|
|
593
|
+
// One plane's colour, flat, on the ground to ink ramp only: a step towards the shadow or the lit tone by how its
|
|
594
|
+
// normal meets the light (the flat ground is exactly the ground), and at most a breath of the glint where it faces
|
|
595
|
+
// the light's half vector. Alpha carries the full glint, which lights the plane's creases in the accent.
|
|
596
|
+
vec4 shade(vec3 n, float b) {
|
|
597
|
+
float d = dot(n, uL) - uL.z;
|
|
598
|
+
vec3 h = normalize(uL + vec3(0.0, 0.0, 1.0));
|
|
599
|
+
float g = max(0.0, pow(max(dot(n, h), 0.0), uLook.z) - pow(h.z, uLook.z)) * b * uLook.y;
|
|
600
|
+
vec3 c = uGround;
|
|
601
|
+
c = mix(c, uShadow, clamp(-d * uLook.x, 0.0, 0.7));
|
|
602
|
+
c = mix(c, uLit, clamp(d * uLook.x * (0.6 + 0.6 * b), 0.0, 1.0));
|
|
603
|
+
c = mix(c, uGlintC, clamp(g, 0.0, 0.15));
|
|
604
|
+
return vec4(c, g);
|
|
605
|
+
}
|
|
606
|
+
// How much hairline a pixel carries for an edge dist canvas px away, between planes with normals n1 and n2: none
|
|
607
|
+
// where the two lie in one plane.
|
|
608
|
+
float hair(float dist, vec3 n1, vec3 n2) {
|
|
609
|
+
return (1.0 - smoothstep(0.35, 1.35, dist)) * smoothstep(0.003, 0.035, 1.0 - dot(n1, n2));
|
|
610
|
+
}
|
|
611
|
+
void main() {
|
|
612
|
+
vec2 p = (gl_FragCoord.xy - uOrigin) / uCell;
|
|
613
|
+
vec2 I = floor(p + (p.x + p.y) * F2);
|
|
614
|
+
vec2 x0 = p - un(I);
|
|
615
|
+
vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
|
616
|
+
vec2 Ib = I + i1;
|
|
617
|
+
vec2 Ic = I + 1.0;
|
|
618
|
+
float ha = hAt(I), hb = hAt(Ib), hc = hAt(Ic);
|
|
619
|
+
vec2 A = un(I);
|
|
620
|
+
vec2 v0 = un(Ib) - A, v1 = un(Ic) - A, v2 = p - A;
|
|
621
|
+
float d00 = dot(v0, v0), d01 = dot(v0, v1), d11 = dot(v1, v1), d20 = dot(v2, v0), d21 = dot(v2, v1);
|
|
622
|
+
float dn = d00 * d11 - d01 * d01;
|
|
623
|
+
float bv = (d11 * d20 - d01 * d21) / dn;
|
|
624
|
+
float bw = (d00 * d21 - d01 * d20) / dn;
|
|
625
|
+
float bu = 1.0 - bv - bw;
|
|
626
|
+
vec3 n1 = facetNormal(I, Ib, Ic, ha, hb, hc);
|
|
627
|
+
vec4 s1 = shade(n1, beamCell((I + Ib + Ic) / 3.0));
|
|
628
|
+
// Every edge of this triangle against the triangle across it, so a crease runs on unbroken through each lattice
|
|
629
|
+
// corner where another, flat edge happens to be nearer.
|
|
630
|
+
vec2 Da = Ic + i1;
|
|
631
|
+
vec2 Db = I + 1.0 - i1;
|
|
632
|
+
vec2 Dc = I + i1 - 1.0;
|
|
633
|
+
vec3 na = facetNormal(Ib, Ic, Da, hb, hc, hAt(Da));
|
|
634
|
+
vec3 nb = facetNormal(I, Ic, Db, ha, hc, hAt(Db));
|
|
635
|
+
vec3 nc = facetNormal(I, Ib, Dc, ha, hb, hAt(Dc));
|
|
636
|
+
vec4 sa = shade(na, beamCell((Ib + Ic + Da) / 3.0));
|
|
637
|
+
vec4 sb = shade(nb, beamCell((I + Ic + Db) / 3.0));
|
|
638
|
+
vec4 sc = shade(nc, beamCell((I + Ib + Dc) / 3.0));
|
|
639
|
+
float alt = ALT * uCell;
|
|
640
|
+
float da = bu * alt, db = bv * alt, dc = bw * alt;
|
|
641
|
+
float dmin = min(da, min(db, dc));
|
|
642
|
+
vec4 sn = (da <= db && da <= dc) ? sa : (db <= dc ? sb : sc);
|
|
643
|
+
vec3 col = mix(s1.rgb, sn.rgb, 0.5 * (1.0 - smoothstep(0.0, 1.0, dmin)));
|
|
644
|
+
float line = hair(da, n1, na);
|
|
645
|
+
float glow = sa.a;
|
|
646
|
+
float lb = hair(db, n1, nb);
|
|
647
|
+
if (lb > line) { line = lb; glow = sb.a; }
|
|
648
|
+
float lc = hair(dc, n1, nc);
|
|
649
|
+
if (lc > line) { line = lc; glow = sc.a; }
|
|
650
|
+
float lit = clamp(max(s1.a, glow) * 2.5, 0.0, 1.0);
|
|
651
|
+
// The shards: per shard the lowest of its face planes is the surface; its footprint is where that is above ground.
|
|
652
|
+
vec2 fp = gl_FragCoord.xy;
|
|
653
|
+
for (int c = 0; c < ${MAX_SHARDS}; c++) {
|
|
654
|
+
if (c >= uNC) break;
|
|
655
|
+
float m1 = 1e9, m2 = 1e9, sd = 1e9;
|
|
656
|
+
vec4 P1 = vec4(0.0), P2 = vec4(0.0), PE = vec4(0.0);
|
|
657
|
+
for (int k = 0; k < ${MAX_FACES}; k++) {
|
|
658
|
+
vec4 P = uP[c * ${MAX_FACES} + k];
|
|
659
|
+
float s = length(P.xy);
|
|
660
|
+
if (s < 1e-6) continue;
|
|
661
|
+
float h = dot(P.xy, fp) + P.z;
|
|
662
|
+
if (h < m1) { m2 = m1; P2 = P1; m1 = h; P1 = P; }
|
|
663
|
+
else if (h < m2) { m2 = h; P2 = P; }
|
|
664
|
+
// Height over slope: the signed distance to this face's footprint edge; the least is the footprint's.
|
|
665
|
+
float e = h / s;
|
|
666
|
+
if (e < sd) { sd = e; PE = P; }
|
|
667
|
+
}
|
|
668
|
+
if (m1 > 1e8 || sd < -2.0) continue;
|
|
669
|
+
vec3 ne = normalize(vec3(-PE.xy, 1.0));
|
|
670
|
+
vec4 se = shade(ne, PE.w);
|
|
671
|
+
if (sd > -1.0) {
|
|
672
|
+
vec3 nf = normalize(vec3(-P1.xy, 1.0));
|
|
673
|
+
vec3 n2 = normalize(vec3(-P2.xy, 1.0));
|
|
674
|
+
vec4 sf = shade(nf, P1.w);
|
|
675
|
+
vec4 s2 = shade(n2, P2.w);
|
|
676
|
+
float dr = (m2 - m1) / max(length(P1.xy - P2.xy), 1e-6);
|
|
677
|
+
vec3 face = mix(sf.rgb, s2.rgb, 0.5 * (1.0 - smoothstep(0.0, 1.0, dr)));
|
|
678
|
+
col = mix(col, face, smoothstep(-0.5, 0.5, sd));
|
|
679
|
+
float lr = hair(dr, nf, n2) * step(0.0, sd);
|
|
680
|
+
if (lr > line) { line = lr; lit = clamp(max(sf.a, s2.a) * 2.5, 0.0, 1.0); }
|
|
681
|
+
}
|
|
682
|
+
// The silhouette comes from the step in tone; its drawn outline stays faint, so a shard never reads as line art.
|
|
683
|
+
float lo = 0.3 * hair(abs(sd), ne, vec3(0.0, 0.0, 1.0));
|
|
684
|
+
if (lo > line) { line = lo; lit = clamp(se.a * 2.5, 0.0, 1.0); }
|
|
685
|
+
}
|
|
686
|
+
// A crease that catches the light is drawn in the accent; the rest in a thin ink.
|
|
687
|
+
col = mix(col, mix(uInk, uAccent, lit), line * uLook.w * (0.55 + 0.45 * lit));
|
|
688
|
+
// The surface lies quiet behind the type, and flat into the ground over the bottom of the hero: no seam at the
|
|
689
|
+
// hand-over.
|
|
690
|
+
vec2 q = gl_FragCoord.xy;
|
|
691
|
+
float f = uQuietF.x;
|
|
692
|
+
float inX = smoothstep(uQuiet.x - f, uQuiet.x, q.x) * (1.0 - smoothstep(uQuiet.z, uQuiet.z + f, q.x));
|
|
693
|
+
float inY = smoothstep(uQuiet.y - f, uQuiet.y, q.y) * (1.0 - smoothstep(uQuiet.w, uQuiet.w + f, q.y));
|
|
694
|
+
col = mix(col, uGround, inX * inY * (1.0 - uQuietF.y));
|
|
695
|
+
col = mix(uGround, col, smoothstep(0.0, uFadeH, gl_FragCoord.y));
|
|
696
|
+
o = vec4(col, 1.0);
|
|
697
|
+
}
|
|
698
|
+
`;
|
|
699
|
+
|
|
700
|
+
type GL = {
|
|
701
|
+
gl: WebGL2RenderingContext;
|
|
702
|
+
prog: WebGLProgram;
|
|
703
|
+
vao: WebGLVertexArrayObject;
|
|
704
|
+
buf: WebGLBuffer;
|
|
705
|
+
tex: WebGLTexture;
|
|
706
|
+
texSize: [number, number];
|
|
707
|
+
u: Record<string, WebGLUniformLocation | null>;
|
|
708
|
+
planes: Float32Array;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
function buildGL(gl: WebGL2RenderingContext): GL | null {
|
|
712
|
+
const compile = (type: number, src: string) => {
|
|
713
|
+
const s = gl.createShader(type);
|
|
714
|
+
if (!s) return null;
|
|
715
|
+
gl.shaderSource(s, src);
|
|
716
|
+
gl.compileShader(s);
|
|
717
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
718
|
+
console.warn("[background-facets]", gl.getShaderInfoLog(s));
|
|
719
|
+
gl.deleteShader(s);
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
return s;
|
|
723
|
+
};
|
|
724
|
+
const vs = compile(gl.VERTEX_SHADER, VERT);
|
|
725
|
+
const fs = compile(gl.FRAGMENT_SHADER, FRAG);
|
|
726
|
+
const prog = gl.createProgram();
|
|
727
|
+
if (!vs || !fs || !prog) return null;
|
|
728
|
+
gl.attachShader(prog, vs);
|
|
729
|
+
gl.attachShader(prog, fs);
|
|
730
|
+
gl.bindAttribLocation(prog, 0, "aPos");
|
|
731
|
+
gl.linkProgram(prog);
|
|
732
|
+
gl.deleteShader(vs);
|
|
733
|
+
gl.deleteShader(fs);
|
|
734
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
735
|
+
console.warn("[background-facets]", gl.getProgramInfoLog(prog));
|
|
736
|
+
gl.deleteProgram(prog);
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
const vao = gl.createVertexArray();
|
|
740
|
+
const buf = gl.createBuffer();
|
|
741
|
+
const tex = gl.createTexture();
|
|
742
|
+
if (!vao || !buf || !tex) return null;
|
|
743
|
+
gl.bindVertexArray(vao);
|
|
744
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
745
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
746
|
+
gl.enableVertexAttribArray(0);
|
|
747
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
748
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
749
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
750
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
751
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
752
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
753
|
+
const names = [
|
|
754
|
+
"uH",
|
|
755
|
+
"uOff",
|
|
756
|
+
"uRes",
|
|
757
|
+
"uOrigin",
|
|
758
|
+
"uCell",
|
|
759
|
+
"uGround",
|
|
760
|
+
"uShadow",
|
|
761
|
+
"uLit",
|
|
762
|
+
"uGlintC",
|
|
763
|
+
"uInk",
|
|
764
|
+
"uAccent",
|
|
765
|
+
"uL",
|
|
766
|
+
"uBeam",
|
|
767
|
+
"uBeamOn",
|
|
768
|
+
"uLook",
|
|
769
|
+
"uFadeH",
|
|
770
|
+
"uQuiet",
|
|
771
|
+
"uQuietF",
|
|
772
|
+
"uNC",
|
|
773
|
+
"uP",
|
|
774
|
+
];
|
|
775
|
+
const u: GL["u"] = {};
|
|
776
|
+
for (const n of names) u[n] = gl.getUniformLocation(prog, n);
|
|
777
|
+
return { gl, prog, vao, buf, tex, texSize: [0, 0], u, planes: new Float32Array(MAX_SHARDS * MAX_FACES * 4) };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function drawGL(g: GL, plan: Plan, light: Light, col: Colours, originY: number, shards: Face[][]) {
|
|
781
|
+
const { gl, u } = g;
|
|
782
|
+
const cw = gl.canvas.width;
|
|
783
|
+
const ch = gl.canvas.height;
|
|
784
|
+
gl.viewport(0, 0, cw, ch);
|
|
785
|
+
gl.useProgram(g.prog);
|
|
786
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
787
|
+
gl.bindTexture(gl.TEXTURE_2D, g.tex);
|
|
788
|
+
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
789
|
+
if (g.texSize[0] !== plan.tw || g.texSize[1] !== plan.th) {
|
|
790
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, plan.tw, plan.th, 0, gl.RED, gl.FLOAT, plan.heights);
|
|
791
|
+
g.texSize = [plan.tw, plan.th];
|
|
792
|
+
} else {
|
|
793
|
+
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, plan.tw, plan.th, gl.RED, gl.FLOAT, plan.heights);
|
|
794
|
+
}
|
|
795
|
+
const s = cw / plan.W;
|
|
796
|
+
gl.uniform1i(u.uH, 0);
|
|
797
|
+
gl.uniform2i(u.uOff, plan.offX, plan.offY);
|
|
798
|
+
gl.uniform2f(u.uRes, cw, ch);
|
|
799
|
+
gl.uniform2f(u.uOrigin, 0, originY * s);
|
|
800
|
+
gl.uniform1f(u.uCell, plan.cell * s);
|
|
801
|
+
gl.uniform3fv(u.uGround, col.ground);
|
|
802
|
+
gl.uniform3fv(u.uShadow, col.shadow);
|
|
803
|
+
gl.uniform3fv(u.uLit, col.lit);
|
|
804
|
+
gl.uniform3fv(u.uGlintC, col.glint);
|
|
805
|
+
gl.uniform3fv(u.uInk, col.ink);
|
|
806
|
+
gl.uniform3fv(u.uAccent, col.accent);
|
|
807
|
+
gl.uniform3fv(u.uL, light.L);
|
|
808
|
+
gl.uniform4fv(u.uBeam, light.beam);
|
|
809
|
+
gl.uniform1f(u.uBeamOn, light.beamOn);
|
|
810
|
+
// On a dark ground the relief's tone is capped, or two long fold families read as crossing straps.
|
|
811
|
+
const tone = col.dark && plan.variant === "relief" ? Math.min(plan.look.tone, 2.2) : plan.look.tone;
|
|
812
|
+
gl.uniform4f(u.uLook, tone, plan.look.glint, plan.look.sharp, plan.look.line);
|
|
813
|
+
gl.uniform1f(u.uFadeH, plan.H * 0.2 * s);
|
|
814
|
+
const q = plan.quiet;
|
|
815
|
+
if (q) {
|
|
816
|
+
gl.uniform4f(u.uQuiet, q.x0 * s, q.y0 * s, q.x1 * s, q.y1 * s);
|
|
817
|
+
gl.uniform2f(u.uQuietF, Math.max(1, q.feather * s), q.level);
|
|
818
|
+
} else {
|
|
819
|
+
gl.uniform4f(u.uQuiet, -10, -10, -9, -9);
|
|
820
|
+
gl.uniform2f(u.uQuietF, 1, 1);
|
|
821
|
+
}
|
|
822
|
+
// Each face as a plane in canvas px: height = gradient . p + c, the height scaled with the canvas so the slope holds.
|
|
823
|
+
const P = g.planes;
|
|
824
|
+
P.fill(0);
|
|
825
|
+
const count = Math.min(MAX_SHARDS, shards.length);
|
|
826
|
+
for (let c = 0; c < count; c += 1)
|
|
827
|
+
shards[c].slice(0, MAX_FACES).forEach((f, k) => {
|
|
828
|
+
const o = (c * MAX_FACES + k) * 4;
|
|
829
|
+
P[o] = f.gx;
|
|
830
|
+
P[o + 1] = f.gy;
|
|
831
|
+
P[o + 2] = (f.h - f.gx * f.ax - f.gy * f.ay) * s;
|
|
832
|
+
P[o + 3] = f.beam;
|
|
833
|
+
});
|
|
834
|
+
gl.uniform1i(u.uNC, count);
|
|
835
|
+
gl.uniform4fv(u.uP, P);
|
|
836
|
+
gl.bindVertexArray(g.vao);
|
|
837
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function disposeGL(g: GL) {
|
|
841
|
+
const { gl } = g;
|
|
842
|
+
if (gl.isContextLost()) return;
|
|
843
|
+
gl.deleteTexture(g.tex);
|
|
844
|
+
gl.deleteBuffer(g.buf);
|
|
845
|
+
gl.deleteVertexArray(g.vao);
|
|
846
|
+
gl.deleteProgram(g.prog);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The same surface, still, on a 2D canvas: for a browser without WebGL2. Flat planes and their creases. */
|
|
850
|
+
function draw2D(ctx: CanvasRenderingContext2D, plan: Plan, light: Light, col: Colours, shards: Face[][]) {
|
|
851
|
+
const cw = ctx.canvas.width;
|
|
852
|
+
const ch = ctx.canvas.height;
|
|
853
|
+
const s = cw / plan.W;
|
|
854
|
+
const { heights, tw, th, offX, offY, cell } = plan;
|
|
855
|
+
const css = (c: RGB) => `rgb(${Math.round(c[0] * 255)} ${Math.round(c[1] * 255)} ${Math.round(c[2] * 255)})`;
|
|
856
|
+
ctx.fillStyle = css(col.ground);
|
|
857
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
858
|
+
const hAt = (ix: number, iy: number) => heights[(iy - offY) * tw + (ix - offX)];
|
|
859
|
+
const pos = (ix: number, iy: number): [number, number] => {
|
|
860
|
+
const k = (ix + iy) * G2;
|
|
861
|
+
return [(ix - k) * cell * s, ch - (iy - k) * cell * s];
|
|
862
|
+
};
|
|
863
|
+
const L = light.L;
|
|
864
|
+
const hz = Math.hypot(L[0], L[1], L[2] + 1);
|
|
865
|
+
const H: [number, number, number] = [L[0] / hz, L[1] / hz, (L[2] + 1) / hz];
|
|
866
|
+
const normal = (a: [number, number], b: [number, number], c: [number, number], ha: number, hb: number, hc: number) => {
|
|
867
|
+
const u = [(b[0] - a[0]) / (cell * s), -(b[1] - a[1]) / (cell * s), hb - ha];
|
|
868
|
+
const v = [(c[0] - a[0]) / (cell * s), -(c[1] - a[1]) / (cell * s), hc - ha];
|
|
869
|
+
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
|
870
|
+
const l = Math.hypot(n[0], n[1], n[2]) || 1;
|
|
871
|
+
n = n.map((x) => x / l);
|
|
872
|
+
return n[2] < 0 ? n.map((x) => -x) : n;
|
|
873
|
+
};
|
|
874
|
+
const shadeB = (n: number[], b: number): RGB => {
|
|
875
|
+
const d = n[0] * L[0] + n[1] * L[1] + n[2] * L[2] - L[2];
|
|
876
|
+
const sp = plan.look.sharp;
|
|
877
|
+
const g = Math.max(0, Math.max(0, n[0] * H[0] + n[1] * H[1] + n[2] * H[2]) ** sp - H[2] ** sp) * b * plan.look.glint;
|
|
878
|
+
let c = col.ground;
|
|
879
|
+
c = mix(c, col.shadow, Math.min(0.7, Math.max(0, -d * plan.look.tone)));
|
|
880
|
+
c = mix(c, col.lit, Math.min(1, Math.max(0, d * plan.look.tone * (0.6 + 0.6 * b))));
|
|
881
|
+
return mix(c, col.glint, Math.min(0.15, g));
|
|
882
|
+
};
|
|
883
|
+
const shade = (n: number[], cx: number, cy: number) => shadeB(n, beamAt(plan, light, cx / s, (ch - cy) / s));
|
|
884
|
+
const normals = new Map<number, number[]>();
|
|
885
|
+
for (let iy = offY; iy < offY + th - 1; iy += 1)
|
|
886
|
+
for (let ix = offX; ix < offX + tw - 1; ix += 1) {
|
|
887
|
+
for (const up of [false, true]) {
|
|
888
|
+
const a: [number, number] = [ix, iy];
|
|
889
|
+
const b: [number, number] = up ? [ix, iy + 1] : [ix + 1, iy];
|
|
890
|
+
const c: [number, number] = [ix + 1, iy + 1];
|
|
891
|
+
const pa = pos(a[0], a[1]);
|
|
892
|
+
const pb = pos(b[0], b[1]);
|
|
893
|
+
const pc = pos(c[0], c[1]);
|
|
894
|
+
const n = normal(pa, pb, pc, hAt(a[0], a[1]), hAt(b[0], b[1]), hAt(c[0], c[1]));
|
|
895
|
+
normals.set((iy * 4096 + ix) * 2 + (up ? 1 : 0), n);
|
|
896
|
+
if (n[2] > 0.99999) continue;
|
|
897
|
+
ctx.fillStyle = css(shade(n, (pa[0] + pb[0] + pc[0]) / 3, (pa[1] + pb[1] + pc[1]) / 3));
|
|
898
|
+
ctx.beginPath();
|
|
899
|
+
ctx.moveTo(pa[0], pa[1]);
|
|
900
|
+
ctx.lineTo(pb[0], pb[1]);
|
|
901
|
+
ctx.lineTo(pc[0], pc[1]);
|
|
902
|
+
ctx.closePath();
|
|
903
|
+
ctx.fill();
|
|
904
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
905
|
+
ctx.lineWidth = 0.6;
|
|
906
|
+
ctx.stroke();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// Creases: each lower triangle against its three neighbours, so every edge is judged once.
|
|
910
|
+
ctx.strokeStyle = css(mix(col.ground, col.ink, plan.look.line * 0.7));
|
|
911
|
+
ctx.lineWidth = 1;
|
|
912
|
+
ctx.beginPath();
|
|
913
|
+
const key = (ix: number, iy: number, up: boolean) => (iy * 4096 + ix) * 2 + (up ? 1 : 0);
|
|
914
|
+
for (let iy = offY + 1; iy < offY + th - 2; iy += 1)
|
|
915
|
+
for (let ix = offX + 1; ix < offX + tw - 2; ix += 1) {
|
|
916
|
+
const n = normals.get(key(ix, iy, false));
|
|
917
|
+
if (!n) continue;
|
|
918
|
+
const pairs: [number[] | undefined, [number, number], [number, number]][] = [
|
|
919
|
+
[normals.get(key(ix, iy, true)), [ix, iy], [ix + 1, iy + 1]],
|
|
920
|
+
[normals.get(key(ix + 1, iy, true)), [ix + 1, iy], [ix + 1, iy + 1]],
|
|
921
|
+
[normals.get(key(ix, iy - 1, true)), [ix, iy], [ix + 1, iy]],
|
|
922
|
+
];
|
|
923
|
+
for (const [m, e0, e1] of pairs) {
|
|
924
|
+
if (!m) continue;
|
|
925
|
+
if (1 - (n[0] * m[0] + n[1] * m[1] + n[2] * m[2]) < 0.004) continue;
|
|
926
|
+
const p0 = pos(e0[0], e0[1]);
|
|
927
|
+
const p1 = pos(e1[0], e1[1]);
|
|
928
|
+
ctx.moveTo(p0[0], p0[1]);
|
|
929
|
+
ctx.lineTo(p1[0], p1[1]);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
ctx.stroke();
|
|
933
|
+
// The shards: each face filled flat, then its ridges and footprint as hairlines.
|
|
934
|
+
const at = (x: number, y: number): [number, number] => [x * s, ch - y * s];
|
|
935
|
+
for (const faces of shards) {
|
|
936
|
+
for (const f of faces) {
|
|
937
|
+
const n = [-f.gx, -f.gy, 1];
|
|
938
|
+
const l = Math.hypot(n[0], n[1], 1);
|
|
939
|
+
ctx.fillStyle = css(shadeB(n.map((v) => v / l), f.beam));
|
|
940
|
+
ctx.beginPath();
|
|
941
|
+
for (let k = 0; k < 3; k += 1) {
|
|
942
|
+
const [x, y] = at(f.tri[k * 2], f.tri[k * 2 + 1]);
|
|
943
|
+
if (k) ctx.lineTo(x, y);
|
|
944
|
+
else ctx.moveTo(x, y);
|
|
945
|
+
}
|
|
946
|
+
ctx.closePath();
|
|
947
|
+
ctx.fill();
|
|
948
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
949
|
+
ctx.lineWidth = 0.6;
|
|
950
|
+
ctx.stroke();
|
|
951
|
+
}
|
|
952
|
+
ctx.strokeStyle = css(mix(col.ground, col.ink, plan.look.line * 0.7));
|
|
953
|
+
ctx.lineWidth = 1;
|
|
954
|
+
ctx.beginPath();
|
|
955
|
+
for (const f of faces) {
|
|
956
|
+
const [ax, ay] = at(f.tri[0], f.tri[1]);
|
|
957
|
+
const [x0, y0] = at(f.tri[2], f.tri[3]);
|
|
958
|
+
const [x1, y1] = at(f.tri[4], f.tri[5]);
|
|
959
|
+
ctx.moveTo(ax, ay);
|
|
960
|
+
ctx.lineTo(x0, y0);
|
|
961
|
+
ctx.lineTo(x1, y1);
|
|
962
|
+
}
|
|
963
|
+
ctx.stroke();
|
|
964
|
+
}
|
|
965
|
+
// The surface lies flat into the ground over the bottom fifth, as on the GPU.
|
|
966
|
+
const fade = ctx.createLinearGradient(0, ch, 0, ch - plan.H * 0.2 * s);
|
|
967
|
+
fade.addColorStop(0, css(col.ground));
|
|
968
|
+
fade.addColorStop(1, `rgb(${col.ground.map((c) => Math.round(c * 255)).join(" ")} / 0)`);
|
|
969
|
+
ctx.fillStyle = fade;
|
|
970
|
+
ctx.fillRect(0, ch - plan.H * 0.2 * s, cw, plan.H * 0.2 * s);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Any CSS colour to sRGB 0..1 through a one-pixel canvas, so oklch and color-mix resolve as the browser draws them. */
|
|
974
|
+
function toRGB(probe: CanvasRenderingContext2D, color: string): RGB {
|
|
975
|
+
probe.clearRect(0, 0, 1, 1);
|
|
976
|
+
probe.fillStyle = "rgb(0 0 0)";
|
|
977
|
+
probe.fillStyle = color;
|
|
978
|
+
probe.fillRect(0, 0, 1, 1);
|
|
979
|
+
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
|
980
|
+
return [r / 255, g / 255, b / 255];
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export function BackgroundFacets({ variant = "shard", lines, lead, link, className = "" }: Props) {
|
|
984
|
+
const root = useRef<HTMLElement>(null);
|
|
985
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
986
|
+
|
|
987
|
+
// The type: hidden under its masks from the first paint (CSS), raised once nothing covers the page.
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
const el = root.current;
|
|
990
|
+
if (!el) return;
|
|
991
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
992
|
+
const rise = Array.from(el.querySelectorAll<HTMLElement>("[data-rise]"));
|
|
993
|
+
if (!rise.length) return;
|
|
994
|
+
// A late script: the CSS fallback (3 s) may already have shown the lines; then they stay where they are.
|
|
995
|
+
// Decided once per element, so a second mount (development) does not read its own first run's state.
|
|
996
|
+
const clock = rise[0].getAnimations().find((a) => (a as CSSAnimation).animationName === "struq-bf-rise");
|
|
997
|
+
el.dataset.shown ??= String(
|
|
998
|
+
reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
|
|
999
|
+
);
|
|
1000
|
+
const shown = el.dataset.shown === "true";
|
|
1001
|
+
gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
|
|
1002
|
+
el.dataset.ready = "";
|
|
1003
|
+
let tween: gsap.core.Tween | null = null;
|
|
1004
|
+
const stop = whenUncovered(() => {
|
|
1005
|
+
// The surface folds up with the type. It may not be listening yet (this can run at once), so it also reads
|
|
1006
|
+
// the attribute when it starts.
|
|
1007
|
+
el.dataset.played = "";
|
|
1008
|
+
el.dispatchEvent(new Event("bf:play"));
|
|
1009
|
+
if (shown) return;
|
|
1010
|
+
tween = gsap.to(rise, { yPercent: 0, duration: 1.15, ease: "expo.out", stagger: 0.09, delay: 0.15 });
|
|
1011
|
+
});
|
|
1012
|
+
return () => {
|
|
1013
|
+
stop();
|
|
1014
|
+
tween?.kill();
|
|
1015
|
+
};
|
|
1016
|
+
}, []);
|
|
1017
|
+
|
|
1018
|
+
// The surface.
|
|
1019
|
+
useEffect(() => {
|
|
1020
|
+
const el = root.current;
|
|
1021
|
+
const canvas = canvasRef.current;
|
|
1022
|
+
if (!el || !canvas) return;
|
|
1023
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1024
|
+
const copy = el.querySelector<HTMLElement>("[data-copy]");
|
|
1025
|
+
const probeCanvas = document.createElement("canvas");
|
|
1026
|
+
probeCanvas.width = 1;
|
|
1027
|
+
probeCanvas.height = 1;
|
|
1028
|
+
const probe = probeCanvas.getContext("2d", { willReadFrequently: true });
|
|
1029
|
+
|
|
1030
|
+
let plan: Plan | null = null;
|
|
1031
|
+
let colours: Colours | null = null;
|
|
1032
|
+
let g: GL | null = null;
|
|
1033
|
+
let ctx2d: CanvasRenderingContext2D | null = null;
|
|
1034
|
+
let surface: HTMLCanvasElement = canvas;
|
|
1035
|
+
let spare: HTMLCanvasElement | null = null;
|
|
1036
|
+
let raf = 0;
|
|
1037
|
+
let onScreen = true;
|
|
1038
|
+
let lost = false;
|
|
1039
|
+
let gone = false;
|
|
1040
|
+
let t = reduce ? 0 : hash(variant.length) * 40;
|
|
1041
|
+
let last = 0;
|
|
1042
|
+
let revealAt = -1;
|
|
1043
|
+
let drawn = false;
|
|
1044
|
+
let turn = 0;
|
|
1045
|
+
const ptr: [number, number] = [0, 0];
|
|
1046
|
+
const ptrTo: [number, number] = [0, 0];
|
|
1047
|
+
|
|
1048
|
+
const readColours = () => {
|
|
1049
|
+
if (!probe) return;
|
|
1050
|
+
const style = getComputedStyle(el);
|
|
1051
|
+
const read = (name: string, fallback: string) => toRGB(probe, style.getPropertyValue(name).trim() || fallback);
|
|
1052
|
+
const ground = toRGB(probe, style.backgroundColor);
|
|
1053
|
+
const ink = toRGB(probe, style.color);
|
|
1054
|
+
const accent = read("--accent", style.color);
|
|
1055
|
+
const dark = luminance(ground) < 0.25;
|
|
1056
|
+
// The planes stay on the ground to ink ramp (lit towards white or the ink's light, shadow towards black or the
|
|
1057
|
+
// ink); the accent lives on the creases that catch the light, and only a trace of it in a plane's glint.
|
|
1058
|
+
const lit: RGB = dark ? mix(ground, ink, 0.17) : mix(ground, [1, 1, 1], 0.8);
|
|
1059
|
+
colours = {
|
|
1060
|
+
ground,
|
|
1061
|
+
ink,
|
|
1062
|
+
accent,
|
|
1063
|
+
shadow: dark ? mix(ground, [0, 0, 0], 0.35) : mix(ground, ink, 0.3),
|
|
1064
|
+
lit,
|
|
1065
|
+
glint: mix(lit, accent, dark ? 0.25 : 0.2),
|
|
1066
|
+
dark,
|
|
1067
|
+
};
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
const measure = () => {
|
|
1071
|
+
const W = el.clientWidth;
|
|
1072
|
+
const H = el.clientHeight;
|
|
1073
|
+
if (!W || !H) return;
|
|
1074
|
+
const sec = el.getBoundingClientRect();
|
|
1075
|
+
const box = copy?.getBoundingClientRect();
|
|
1076
|
+
const quietBox = box ? new DOMRect(box.left - sec.left, box.top - sec.top, box.width, box.height) : null;
|
|
1077
|
+
plan = makePlan(variant, W, H, quietBox);
|
|
1078
|
+
const cw = Math.max(1, Math.round(W * plan.scale));
|
|
1079
|
+
const ch = Math.max(1, Math.round(H * plan.scale));
|
|
1080
|
+
if (surface.width !== cw || surface.height !== ch) {
|
|
1081
|
+
surface.width = cw;
|
|
1082
|
+
surface.height = ch;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const progress = () => {
|
|
1087
|
+
const r = el.getBoundingClientRect();
|
|
1088
|
+
return Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// Reduced motion, or the 2D canvas without WebGL2: one still frame, held.
|
|
1092
|
+
const still = () => reduce || (!g && !!ctx2d);
|
|
1093
|
+
|
|
1094
|
+
const render = () => {
|
|
1095
|
+
if (!plan || !colours || (!g && !ctx2d)) return;
|
|
1096
|
+
const held = still();
|
|
1097
|
+
const reveal = held ? 1 : revealAt < 0 ? 0 : 1 - Math.pow(1 - Math.min(1, (t - revealAt) / 2.6), 3);
|
|
1098
|
+
const originY = held ? 0 : -progress() * plan.drift;
|
|
1099
|
+
// On a dark ground the pleats lie lower: full height they read as alternating bright and black bands.
|
|
1100
|
+
const amp = colours.dark && plan.variant === "turn" ? 0.6 : 1;
|
|
1101
|
+
fillHeights(plan, held ? 0 : t, reveal * amp, originY, !g, colours.dark);
|
|
1102
|
+
const light = lightFor(plan, held ? 6 : t, turn, ptr);
|
|
1103
|
+
const shards = shardFaces(plan, held ? 0 : t, reveal, originY, light);
|
|
1104
|
+
if (g) drawGL(g, plan, light, colours, originY, shards);
|
|
1105
|
+
else if (ctx2d) draw2D(ctx2d, plan, light, colours, shards);
|
|
1106
|
+
if (!drawn) {
|
|
1107
|
+
drawn = true;
|
|
1108
|
+
// The first frame is on the canvas: let it show (the CSS fades it in).
|
|
1109
|
+
requestAnimationFrame(() => {
|
|
1110
|
+
if (!gone) el.dataset.drawn = "";
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const frame = (now: number) => {
|
|
1116
|
+
raf = 0;
|
|
1117
|
+
if (gone || lost) return;
|
|
1118
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
|
|
1119
|
+
last = now;
|
|
1120
|
+
t += dt;
|
|
1121
|
+
// The pointer eases in over about 0.18 s (time constant 0.06 s); the scroll turn a touch faster.
|
|
1122
|
+
const kp = 1 - Math.exp(-dt / 0.06);
|
|
1123
|
+
ptr[0] += (ptrTo[0] - ptr[0]) * kp;
|
|
1124
|
+
ptr[1] += (ptrTo[1] - ptr[1]) * kp;
|
|
1125
|
+
turn += (progress() - turn) * (1 - Math.exp(-dt / 0.05));
|
|
1126
|
+
render();
|
|
1127
|
+
loop();
|
|
1128
|
+
};
|
|
1129
|
+
const loop = () => {
|
|
1130
|
+
if (still() || gone || lost || raf || !onScreen || document.hidden) return;
|
|
1131
|
+
raf = requestAnimationFrame(frame);
|
|
1132
|
+
};
|
|
1133
|
+
const halt = () => {
|
|
1134
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1135
|
+
raf = 0;
|
|
1136
|
+
last = 0;
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
const start = () => {
|
|
1140
|
+
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false, premultipliedAlpha: false, powerPreference: "low-power" });
|
|
1141
|
+
if (gl) g = buildGL(gl);
|
|
1142
|
+
if (!g && !gl) ctx2d = canvas.getContext("2d");
|
|
1143
|
+
else if (!g) {
|
|
1144
|
+
// WebGL2 was granted but the program will not build: that canvas can never give a 2D context, so the still
|
|
1145
|
+
// goes on a fresh canvas beside it (not owned by React, removed again on cleanup).
|
|
1146
|
+
spare = document.createElement("canvas");
|
|
1147
|
+
spare.setAttribute("data-bf-canvas", "");
|
|
1148
|
+
spare.setAttribute("aria-hidden", "true");
|
|
1149
|
+
spare.className = canvas.className;
|
|
1150
|
+
canvas.style.display = "none";
|
|
1151
|
+
canvas.after(spare);
|
|
1152
|
+
surface = spare;
|
|
1153
|
+
ctx2d = spare.getContext("2d");
|
|
1154
|
+
}
|
|
1155
|
+
readColours();
|
|
1156
|
+
measure();
|
|
1157
|
+
render();
|
|
1158
|
+
loop();
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
const onPlay = () => {
|
|
1162
|
+
if (revealAt < 0) revealAt = t;
|
|
1163
|
+
};
|
|
1164
|
+
el.addEventListener("bf:play", onPlay);
|
|
1165
|
+
// A late entrance: if the CSS fallback already showed the type, the surface is up too. A variant switch (this
|
|
1166
|
+
// effect runs again) folds the new surface up at once.
|
|
1167
|
+
if (el.dataset.shown === "true") revealAt = t - 10;
|
|
1168
|
+
else if ("played" in el.dataset) onPlay();
|
|
1169
|
+
|
|
1170
|
+
const onLost = (e: Event) => {
|
|
1171
|
+
e.preventDefault();
|
|
1172
|
+
lost = true;
|
|
1173
|
+
halt();
|
|
1174
|
+
};
|
|
1175
|
+
const onRestored = () => {
|
|
1176
|
+
const gl = canvas.getContext("webgl2");
|
|
1177
|
+
g = gl ? buildGL(gl) : null;
|
|
1178
|
+
lost = false;
|
|
1179
|
+
render();
|
|
1180
|
+
loop();
|
|
1181
|
+
};
|
|
1182
|
+
canvas.addEventListener("webglcontextlost", onLost);
|
|
1183
|
+
canvas.addEventListener("webglcontextrestored", onRestored);
|
|
1184
|
+
|
|
1185
|
+
const ro = new ResizeObserver(() => {
|
|
1186
|
+
measure();
|
|
1187
|
+
if (!raf) render();
|
|
1188
|
+
});
|
|
1189
|
+
ro.observe(el);
|
|
1190
|
+
if (copy) ro.observe(copy);
|
|
1191
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
1192
|
+
onScreen = entry.isIntersecting;
|
|
1193
|
+
if (onScreen) loop();
|
|
1194
|
+
else halt();
|
|
1195
|
+
});
|
|
1196
|
+
io.observe(el);
|
|
1197
|
+
const onVisibility = () => {
|
|
1198
|
+
if (document.hidden) halt();
|
|
1199
|
+
else loop();
|
|
1200
|
+
};
|
|
1201
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
1202
|
+
const onPointer = (e: PointerEvent) => {
|
|
1203
|
+
if (variant !== "turn" || e.pointerType === "touch") return;
|
|
1204
|
+
const r = el.getBoundingClientRect();
|
|
1205
|
+
ptrTo[0] = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
|
|
1206
|
+
ptrTo[1] = Math.max(-1, Math.min(1, 1 - ((e.clientY - r.top) / r.height) * 2));
|
|
1207
|
+
};
|
|
1208
|
+
window.addEventListener("pointermove", onPointer, { passive: true });
|
|
1209
|
+
|
|
1210
|
+
// The palette or theme changes in place (the lab rewrites the frame's custom properties): read the colours again.
|
|
1211
|
+
const recolour = () => {
|
|
1212
|
+
readColours();
|
|
1213
|
+
if (!raf) render();
|
|
1214
|
+
};
|
|
1215
|
+
const mo = new MutationObserver(recolour);
|
|
1216
|
+
const frameEl = el.closest("[data-blocks]");
|
|
1217
|
+
if (frameEl) mo.observe(frameEl, { attributes: true, attributeFilter: ["style", "class", "data-theme"] });
|
|
1218
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
|
|
1219
|
+
// The face arriving late moves the type: measure its rect again.
|
|
1220
|
+
document.fonts?.ready.then(() => {
|
|
1221
|
+
if (gone) return;
|
|
1222
|
+
measure();
|
|
1223
|
+
if (!raf) render();
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
start();
|
|
1227
|
+
|
|
1228
|
+
return () => {
|
|
1229
|
+
gone = true;
|
|
1230
|
+
halt();
|
|
1231
|
+
ro.disconnect();
|
|
1232
|
+
io.disconnect();
|
|
1233
|
+
mo.disconnect();
|
|
1234
|
+
el.removeEventListener("bf:play", onPlay);
|
|
1235
|
+
canvas.removeEventListener("webglcontextlost", onLost);
|
|
1236
|
+
canvas.removeEventListener("webglcontextrestored", onRestored);
|
|
1237
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
1238
|
+
window.removeEventListener("pointermove", onPointer);
|
|
1239
|
+
if (g) disposeGL(g);
|
|
1240
|
+
g = null;
|
|
1241
|
+
ctx2d = null;
|
|
1242
|
+
if (spare) {
|
|
1243
|
+
spare.remove();
|
|
1244
|
+
canvas.style.display = "";
|
|
1245
|
+
}
|
|
1246
|
+
delete el.dataset.drawn;
|
|
1247
|
+
};
|
|
1248
|
+
}, [variant]);
|
|
1249
|
+
|
|
1250
|
+
const mask = (child: React.ReactNode, key?: number) => (
|
|
1251
|
+
<span key={key} className="struq-line-mask block overflow-hidden pb-[0.06em]">
|
|
1252
|
+
<span data-rise="" className="block">
|
|
1253
|
+
{child}
|
|
1254
|
+
</span>
|
|
1255
|
+
</span>
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
return (
|
|
1259
|
+
<section
|
|
1260
|
+
ref={root}
|
|
1261
|
+
data-bg-facets=""
|
|
1262
|
+
data-variant={variant}
|
|
1263
|
+
suppressHydrationWarning
|
|
1264
|
+
className={`relative h-svh min-h-[34rem] overflow-hidden bg-surface-0 text-ink ${className}`}
|
|
1265
|
+
>
|
|
1266
|
+
<canvas ref={canvasRef} data-bf-canvas="" aria-hidden="true" className="absolute inset-0 h-full w-full" />
|
|
1267
|
+
<div data-copy="" className="absolute">
|
|
1268
|
+
<h1 className="m-0 font-display leading-[0.95] tracking-[-0.03em]">{lines.map((line, i) => mask(line, i))}</h1>
|
|
1269
|
+
<div className="mt-6 md:mt-8">
|
|
1270
|
+
{/* Full ink: the muted ink measures about 4:1 on the lightest grounds, short of 4.5 for body text. */}
|
|
1271
|
+
{mask(<span className="text-body block max-w-[40ch] text-ink">{lead}</span>)}
|
|
1272
|
+
</div>
|
|
1273
|
+
<div className="mt-5">
|
|
1274
|
+
{mask(
|
|
1275
|
+
<a href={link.href} className="text-small inline-block text-ink underline decoration-1 underline-offset-4">
|
|
1276
|
+
{link.label}
|
|
1277
|
+
</a>,
|
|
1278
|
+
)}
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
</section>
|
|
1282
|
+
);
|
|
1283
|
+
}
|