@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,21 @@
|
|
|
1
|
+
// lib/gsap.ts (Struq recipe)
|
|
2
|
+
// One registry for every GSAP plugin the site uses. Import gsap from here, never from "gsap"
|
|
3
|
+
// directly, so a component rendered on its own still has its plugins registered.
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
6
|
+
import { SplitText } from "gsap/SplitText";
|
|
7
|
+
import { useGSAP } from "@gsap/react";
|
|
8
|
+
|
|
9
|
+
gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP);
|
|
10
|
+
|
|
11
|
+
// The Struq entrance: expo.out inside the reveal band.
|
|
12
|
+
gsap.defaults({ ease: "expo.out", duration: 0.9 });
|
|
13
|
+
|
|
14
|
+
export const EASE = {
|
|
15
|
+
enter: "expo.out",
|
|
16
|
+
exit: "power2.in",
|
|
17
|
+
swap: "power3.inOut",
|
|
18
|
+
curtain: "power4.inOut",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export { gsap, ScrollTrigger, SplitText, useGSAP };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The image a block receives. In a client project the skill's images.mjs writes the list of these from a
|
|
2
|
+
// beeldbank pack; the lab passes a few fixed beeldbank photos so every block is judged on real photography.
|
|
3
|
+
|
|
4
|
+
export type StruqImage = {
|
|
5
|
+
src: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
/** "16:9", "21:9", "3:2", "4:5" or "1:1" */
|
|
10
|
+
ratio: string;
|
|
11
|
+
/** What the photo shows, in more words than the alt: read it to choose the right slot. */
|
|
12
|
+
description: string;
|
|
13
|
+
/** The photo's calm side ("left", "right", "top", "bottom", "center"): where copy reads over it. */
|
|
14
|
+
negativeSpace?: string;
|
|
15
|
+
/** Set on fallback plates (SVG), which next/image must not optimise. */
|
|
16
|
+
unoptimized?: boolean;
|
|
17
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// photo-read (Struq kit)
|
|
2
|
+
// Reading what a photograph holds under a box on the screen, for the blocks that choose an ink or a veil from the
|
|
3
|
+
// photo itself. The whole image is drawn once to a small offscreen canvas and kept as a grid of luminances; a box is
|
|
4
|
+
// then mapped onto that grid through the element's object-fit: cover and its object-position.
|
|
5
|
+
//
|
|
6
|
+
// Why the whole image: with a srcset, img.naturalWidth is density-corrected (a 1920w file shown at 1440 reports
|
|
7
|
+
// 1440), while drawImage's source rectangle counts the file's real pixels. A sub-rectangle mapped from naturalWidth
|
|
8
|
+
// therefore lands up and to the left of where it should. Drawing the whole image to fill the grid only uses the ratio
|
|
9
|
+
// of naturalWidth to naturalHeight, which is right either way.
|
|
10
|
+
//
|
|
11
|
+
// const ys = photoLuminances(img, frame, box); // sorted ascending, or null when it cannot be read
|
|
12
|
+
// const median = ys && ys[Math.floor(ys.length * 0.5)];
|
|
13
|
+
//
|
|
14
|
+
// Returns null, so the caller keeps its safe treatment, when the photo is not loaded, comes from another origin
|
|
15
|
+
// (reading it would taint the canvas) or the canvas cannot be read.
|
|
16
|
+
|
|
17
|
+
export type PhotoBox = { x: number; y: number; w: number; h: number };
|
|
18
|
+
|
|
19
|
+
const GRID_W = 320;
|
|
20
|
+
|
|
21
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
22
|
+
|
|
23
|
+
type Grid = { w: number; h: number; y: Float32Array };
|
|
24
|
+
const grids = new WeakMap<HTMLImageElement, { src: string; grid: Grid | null }>();
|
|
25
|
+
|
|
26
|
+
/** The photo's luminance on a small grid of the whole image, read once per source and kept. */
|
|
27
|
+
function gridOf(img: HTMLImageElement): Grid | null {
|
|
28
|
+
const src = img.currentSrc || img.src;
|
|
29
|
+
const iw = img.naturalWidth;
|
|
30
|
+
const ih = img.naturalHeight;
|
|
31
|
+
if (!src || !img.complete || !iw || !ih) return null;
|
|
32
|
+
const kept = grids.get(img);
|
|
33
|
+
if (kept && kept.src === src) return kept.grid;
|
|
34
|
+
let grid: Grid | null = null;
|
|
35
|
+
try {
|
|
36
|
+
if (new URL(src, location.href).origin !== location.origin) throw new Error("other origin");
|
|
37
|
+
const w = GRID_W;
|
|
38
|
+
const h = Math.max(1, Math.round((w * ih) / iw));
|
|
39
|
+
const cv = document.createElement("canvas");
|
|
40
|
+
cv.width = w;
|
|
41
|
+
cv.height = h;
|
|
42
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
43
|
+
if (ctx) {
|
|
44
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
45
|
+
const d = ctx.getImageData(0, 0, w, h).data;
|
|
46
|
+
const y = new Float32Array(w * h);
|
|
47
|
+
for (let k = 0; k < w * h; k += 1) {
|
|
48
|
+
y[k] = 0.2126 * linear(d[k * 4] / 255) + 0.7152 * linear(d[k * 4 + 1] / 255) + 0.0722 * linear(d[k * 4 + 2] / 255);
|
|
49
|
+
}
|
|
50
|
+
grid = { w, h, y };
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
grid = null;
|
|
54
|
+
}
|
|
55
|
+
grids.set(img, { src, grid });
|
|
56
|
+
return grid;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The element's object-position as two shares (0 to 1); percentages and keywords, anything else is centred. */
|
|
60
|
+
function positionOf(img: HTMLImageElement): [number, number] {
|
|
61
|
+
const parts = (getComputedStyle(img).objectPosition || "50% 50%").trim().split(/\s+/);
|
|
62
|
+
const share = (v: string | undefined) => {
|
|
63
|
+
if (!v) return 0.5;
|
|
64
|
+
if (v.endsWith("%")) {
|
|
65
|
+
const n = parseFloat(v) / 100;
|
|
66
|
+
return Number.isFinite(n) ? n : 0.5;
|
|
67
|
+
}
|
|
68
|
+
if (v === "left" || v === "top") return 0;
|
|
69
|
+
if (v === "right" || v === "bottom") return 1;
|
|
70
|
+
if (v === "center") return 0.5;
|
|
71
|
+
return 0.5;
|
|
72
|
+
};
|
|
73
|
+
return [share(parts[0]), share(parts[1] ?? parts[0])];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The luminances of the photo under `at`, sorted ascending. `frame` is the box the image is drawn into (object-fit:
|
|
78
|
+
* cover at its object-position), in the same coordinates as `at`; pass `position` to override the element's own.
|
|
79
|
+
*/
|
|
80
|
+
export function photoLuminances(
|
|
81
|
+
img: HTMLImageElement,
|
|
82
|
+
frame: PhotoBox,
|
|
83
|
+
at: PhotoBox,
|
|
84
|
+
position?: [number, number],
|
|
85
|
+
): number[] | null {
|
|
86
|
+
if (frame.w <= 0 || frame.h <= 0 || at.w <= 0 || at.h <= 0) return null;
|
|
87
|
+
const g = gridOf(img);
|
|
88
|
+
if (!g) return null;
|
|
89
|
+
const [px, py] = position ?? positionOf(img);
|
|
90
|
+
// Frame pixels per grid cell, and where the covered image starts in the frame.
|
|
91
|
+
const c = Math.max(frame.w / g.w, frame.h / g.h);
|
|
92
|
+
const ox = frame.x + (frame.w - g.w * c) * px;
|
|
93
|
+
const oy = frame.y + (frame.h - g.h * c) * py;
|
|
94
|
+
const x0 = Math.max(0, Math.floor((at.x - ox) / c));
|
|
95
|
+
const x1 = Math.min(g.w, Math.max(x0 + 1, Math.ceil((at.x + at.w - ox) / c)));
|
|
96
|
+
const y0 = Math.max(0, Math.floor((at.y - oy) / c));
|
|
97
|
+
const y1 = Math.min(g.h, Math.max(y0 + 1, Math.ceil((at.y + at.h - oy) / c)));
|
|
98
|
+
if (x0 >= g.w || y0 >= g.h || x1 <= 0 || y1 <= 0) return null;
|
|
99
|
+
const ys: number[] = [];
|
|
100
|
+
for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) ys.push(g.y[y * g.w + x]);
|
|
101
|
+
return ys.length ? ys.sort((a, b) => a - b) : null;
|
|
102
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// components/struq/scenes/contours.ts (Struq recipe)
|
|
2
|
+
// A survey terrain seen at a low angle: fine contour lines in the ink, every fifth line catching the
|
|
3
|
+
// accent on the right of the frame, compressing toward a horizon above the top edge. The field drifts
|
|
4
|
+
// toward the viewer on a slow clock, the pointer lifts it a little, and it hushes behind the copy.
|
|
5
|
+
// Composition: the vanishing point sits on the right third and the lines fade out to the left, so a
|
|
6
|
+
// headline on the left sits in calm space. Needs no photo.
|
|
7
|
+
// Suits: architecture, studios, consultancy, research, nature, anything calm and precise.
|
|
8
|
+
//
|
|
9
|
+
// Two passes, so noise never runs per screen pixel: a field pass writes the terrain height into a
|
|
10
|
+
// small target (one texel per 4 CSS pixels, packed to 16 bits), and the draw pass turns the
|
|
11
|
+
// upsampled height into antialiased lines with cheap per-pixel arithmetic.
|
|
12
|
+
import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
|
|
13
|
+
|
|
14
|
+
const CELL = 4; // CSS pixels per field texel
|
|
15
|
+
|
|
16
|
+
export const createScene: CreateScene = async ({ THREE, renderer, palette, dark }) => {
|
|
17
|
+
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
18
|
+
const quad = new THREE.PlaneGeometry(2, 2);
|
|
19
|
+
const vertexShader = /* glsl */ `void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`;
|
|
20
|
+
|
|
21
|
+
const field = new THREE.WebGLRenderTarget(2, 2, {
|
|
22
|
+
minFilter: THREE.NearestFilter,
|
|
23
|
+
magFilter: THREE.NearestFilter,
|
|
24
|
+
depthBuffer: false,
|
|
25
|
+
generateMipmaps: false,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const fieldUniforms = {
|
|
29
|
+
uFieldRes: { value: new THREE.Vector2(2, 2) },
|
|
30
|
+
uAspect: { value: 1 },
|
|
31
|
+
uTime: { value: 0 },
|
|
32
|
+
uPointer: { value: new THREE.Vector2() },
|
|
33
|
+
uPresence: { value: 0 },
|
|
34
|
+
uScroll: { value: 0 },
|
|
35
|
+
};
|
|
36
|
+
const fieldMaterial = new THREE.ShaderMaterial({
|
|
37
|
+
uniforms: fieldUniforms,
|
|
38
|
+
vertexShader,
|
|
39
|
+
fragmentShader: /* glsl */ `
|
|
40
|
+
uniform vec2 uFieldRes; uniform float uAspect; uniform float uTime;
|
|
41
|
+
uniform vec2 uPointer; uniform float uPresence; uniform float uScroll;
|
|
42
|
+
${NOISE_GLSL}
|
|
43
|
+
void main() {
|
|
44
|
+
vec2 uv = gl_FragCoord.xy / uFieldRes;
|
|
45
|
+
float t = uTime * 0.022; // the ambient clock: a slow drift, Vapor pace
|
|
46
|
+
// Perspective: the horizon sits just above the top edge, the vanishing point on the right third.
|
|
47
|
+
float below = 1.18 - uv.y;
|
|
48
|
+
float Z = 0.8 / below;
|
|
49
|
+
float X = (uv.x - 0.66) * uAspect * Z;
|
|
50
|
+
vec2 p = vec2(X, Z - t * 1.1 + uScroll * 1.2) * 0.62;
|
|
51
|
+
vec2 warp = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(4.1, -t)));
|
|
52
|
+
float h = fbm(p * 0.9 + 1.25 * warp);
|
|
53
|
+
// The pointer: a soft rise where it rests, eased by presence. A modulation, never a spotlight.
|
|
54
|
+
vec2 d = (uv - (uPointer * 0.5 + 0.5)) * vec2(uAspect, 1.0);
|
|
55
|
+
h += exp(-dot(d, d) / 0.035) * 0.05 * uPresence;
|
|
56
|
+
h = clamp(h, 0.0, 0.999);
|
|
57
|
+
gl_FragColor = vec4(floor(h * 255.0) / 255.0, fract(h * 255.0), 0.0, 1.0);
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
});
|
|
61
|
+
const fieldScene = new THREE.Scene();
|
|
62
|
+
fieldScene.add(new THREE.Mesh(quad, fieldMaterial));
|
|
63
|
+
|
|
64
|
+
const quietSet = quietUniforms(THREE, dark ? 0.08 : 0.12);
|
|
65
|
+
const drawUniforms = {
|
|
66
|
+
...quietSet.uniforms,
|
|
67
|
+
uField: { value: field.texture },
|
|
68
|
+
uFieldRes: { value: new THREE.Vector2(2, 2) },
|
|
69
|
+
uCell: { value: CELL },
|
|
70
|
+
uResPx: { value: new THREE.Vector2(1, 1) },
|
|
71
|
+
uTime: { value: 0 },
|
|
72
|
+
uSurface: { value: new THREE.Color(palette.surface) },
|
|
73
|
+
uInk: { value: new THREE.Color(palette.ink) },
|
|
74
|
+
uAccent: { value: new THREE.Color(palette.accent) },
|
|
75
|
+
uInkWeight: { value: dark ? 0.85 : 0.56 },
|
|
76
|
+
// On a dark ground the lines need more weight and must not vanish on the copy side.
|
|
77
|
+
uLeftFloor: { value: dark ? 0.45 : 0.2 },
|
|
78
|
+
};
|
|
79
|
+
const drawMaterial = new THREE.ShaderMaterial({
|
|
80
|
+
uniforms: drawUniforms,
|
|
81
|
+
vertexShader,
|
|
82
|
+
fragmentShader: /* glsl */ `
|
|
83
|
+
uniform sampler2D uField; uniform vec2 uFieldRes; uniform float uCell; uniform vec2 uResPx;
|
|
84
|
+
uniform float uTime; uniform vec3 uSurface; uniform vec3 uInk; uniform vec3 uAccent; uniform float uInkWeight; uniform float uLeftFloor;
|
|
85
|
+
${QUIET_GLSL}
|
|
86
|
+
float grain(vec2 p) { p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }
|
|
87
|
+
float at(ivec2 i) {
|
|
88
|
+
ivec2 m = ivec2(uFieldRes) - 1;
|
|
89
|
+
vec4 v = texelFetch(uField, clamp(i, ivec2(0), m), 0);
|
|
90
|
+
return v.r + v.g / 255.0;
|
|
91
|
+
}
|
|
92
|
+
float height(vec2 fc) {
|
|
93
|
+
vec2 g = fc / uCell - 0.5;
|
|
94
|
+
ivec2 i = ivec2(floor(g));
|
|
95
|
+
vec2 f = fract(g);
|
|
96
|
+
return mix(mix(at(i), at(i + ivec2(1, 0)), f.x), mix(at(i + ivec2(0, 1)), at(i + ivec2(1, 1)), f.x), f.y);
|
|
97
|
+
}
|
|
98
|
+
void main() {
|
|
99
|
+
vec2 fc = gl_FragCoord.xy;
|
|
100
|
+
vec2 uv = fc / uResPx;
|
|
101
|
+
float f = height(fc) * 54.0;
|
|
102
|
+
float w = fwidth(f);
|
|
103
|
+
float dist = min(fract(f), 1.0 - fract(f));
|
|
104
|
+
float line = 1.0 - smoothstep(0.0, w * 1.3, dist);
|
|
105
|
+
// Where lines crowd closer than the screen can draw, they fade into a mist instead of moire.
|
|
106
|
+
float dense = smoothstep(0.22, 0.55, w);
|
|
107
|
+
float mist = dense * 0.05;
|
|
108
|
+
line *= 1.0 - dense;
|
|
109
|
+
float major = 1.0 - step(0.5, mod(floor(f + 0.5), 5.0));
|
|
110
|
+
// Composition and depth: weighted to the right, fading toward the far horizon.
|
|
111
|
+
float right = uLeftFloor + (1.0 - uLeftFloor) * smoothstep(0.05, 0.8, uv.x);
|
|
112
|
+
float near = 1.0 - 0.6 * smoothstep(0.3, 1.0, uv.y);
|
|
113
|
+
float inkV = hush((line * (0.55 + 0.45 * major) + mist) * uInkWeight * right * near, fc);
|
|
114
|
+
float accentV = hush(line * major * smoothstep(0.5, 0.95, uv.x) * near * 0.75, fc);
|
|
115
|
+
vec3 col = mix(uSurface, uInk, inkV);
|
|
116
|
+
col = mix(col, uAccent, accentV);
|
|
117
|
+
col += (grain(fc + fract(uTime) * 61.0) - 0.5) * 0.018;
|
|
118
|
+
gl_FragColor = vec4(col, 1.0);
|
|
119
|
+
#include <colorspace_fragment>
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
});
|
|
123
|
+
const scene = new THREE.Scene();
|
|
124
|
+
scene.add(new THREE.Mesh(quad, drawMaterial));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
scene,
|
|
128
|
+
camera,
|
|
129
|
+
update(state) {
|
|
130
|
+
fieldUniforms.uTime.value = state.time;
|
|
131
|
+
fieldUniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
|
|
132
|
+
fieldUniforms.uPresence.value = state.presence;
|
|
133
|
+
fieldUniforms.uScroll.value = state.scroll;
|
|
134
|
+
drawUniforms.uTime.value = state.time;
|
|
135
|
+
quietSet.update(state);
|
|
136
|
+
},
|
|
137
|
+
resize(width, height) {
|
|
138
|
+
const pr = renderer.getPixelRatio();
|
|
139
|
+
const fw = Math.max(2, Math.ceil(width / CELL));
|
|
140
|
+
const fh = Math.max(2, Math.ceil(height / CELL));
|
|
141
|
+
field.setSize(fw, fh);
|
|
142
|
+
fieldUniforms.uFieldRes.value.set(fw, fh);
|
|
143
|
+
fieldUniforms.uAspect.value = width / Math.max(1, height);
|
|
144
|
+
drawUniforms.uFieldRes.value.set(fw, fh);
|
|
145
|
+
drawUniforms.uCell.value = CELL * pr;
|
|
146
|
+
drawUniforms.uResPx.value.set(width * pr, height * pr);
|
|
147
|
+
},
|
|
148
|
+
render(r) {
|
|
149
|
+
r.setRenderTarget(field);
|
|
150
|
+
r.render(fieldScene, camera);
|
|
151
|
+
r.setRenderTarget(null);
|
|
152
|
+
r.render(scene, camera);
|
|
153
|
+
},
|
|
154
|
+
dispose() {
|
|
155
|
+
quad.dispose();
|
|
156
|
+
field.dispose();
|
|
157
|
+
fieldMaterial.dispose();
|
|
158
|
+
drawMaterial.dispose();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// components/struq/scenes/dots.ts (Struq recipe)
|
|
2
|
+
// A fine field of points seen at a low angle, rolling like a slow swell toward a horizon in the upper
|
|
3
|
+
// third. The swell rises toward the right of the frame, its crests catch the accent, the far rows thin
|
|
4
|
+
// out into the ground colour, and the field hushes behind the copy. The pointer lifts a soft hill only
|
|
5
|
+
// while it is there. Scrolling lowers the field away.
|
|
6
|
+
// Suits: mobility, logistics, technology, sport, energy, data: anything about movement or scale.
|
|
7
|
+
import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
|
|
8
|
+
|
|
9
|
+
export const createScene: CreateScene = async ({ THREE, renderer, palette, dark }) => {
|
|
10
|
+
const scene = new THREE.Scene();
|
|
11
|
+
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
|
|
12
|
+
camera.position.set(-1.5, 4.2, 13);
|
|
13
|
+
camera.lookAt(1.5, 0.4, -6);
|
|
14
|
+
renderer.setClearColor(new THREE.Color(palette.surface), 1);
|
|
15
|
+
|
|
16
|
+
const cols = 260;
|
|
17
|
+
const rows = 120;
|
|
18
|
+
const positions = new Float32Array(cols * rows * 3);
|
|
19
|
+
let i = 0;
|
|
20
|
+
for (let r = 0; r < rows; r++) {
|
|
21
|
+
for (let c = 0; c < cols; c++) {
|
|
22
|
+
positions[i++] = (c / (cols - 1) - 0.5) * 48;
|
|
23
|
+
positions[i++] = 0;
|
|
24
|
+
positions[i++] = (r / (rows - 1)) * -40 + 10;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const geometry = new THREE.BufferGeometry();
|
|
28
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
29
|
+
|
|
30
|
+
const quietSet = quietUniforms(THREE, dark ? 0.1 : 0.16);
|
|
31
|
+
const uniforms = {
|
|
32
|
+
...quietSet.uniforms,
|
|
33
|
+
uTime: { value: 0 },
|
|
34
|
+
uPointer: { value: new THREE.Vector2(0, 0) },
|
|
35
|
+
uPresence: { value: 0 },
|
|
36
|
+
uScroll: { value: 0 },
|
|
37
|
+
uDpr: { value: renderer.getPixelRatio() },
|
|
38
|
+
uInk: { value: new THREE.Color(palette.ink) },
|
|
39
|
+
uAccent: { value: new THREE.Color(palette.accent) },
|
|
40
|
+
uInkWeight: { value: dark ? 1.0 : 0.8 },
|
|
41
|
+
// On a dark ground light points read smaller and fainter: bigger points and a visibility floor,
|
|
42
|
+
// so a photo-less opener on the inverse ground still carries the screen.
|
|
43
|
+
uSize: { value: dark ? 62 : 44 },
|
|
44
|
+
uFloor: { value: dark ? 0.38 : 0 },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const material = new THREE.ShaderMaterial({
|
|
48
|
+
uniforms,
|
|
49
|
+
transparent: true,
|
|
50
|
+
depthWrite: false,
|
|
51
|
+
vertexShader: /* glsl */ `
|
|
52
|
+
uniform float uTime; uniform vec2 uPointer; uniform float uPresence; uniform float uScroll; uniform float uDpr; uniform float uSize;
|
|
53
|
+
varying float vH; varying float vFade;
|
|
54
|
+
${NOISE_GLSL}
|
|
55
|
+
void main() {
|
|
56
|
+
vec3 p = position;
|
|
57
|
+
float t = uTime * 0.04;
|
|
58
|
+
// Long swells rolling toward the viewer, broken by slow noise; taller toward the right.
|
|
59
|
+
float rise = smoothstep(-20.0, 18.0, p.x);
|
|
60
|
+
float h = 0.8 * sin(p.z * 0.3 + p.x * 0.05 + t * 3.0) + 1.4 * (fbm(p.xz * 0.07 + vec2(t, t * 0.6)) - 0.5);
|
|
61
|
+
h = h * (0.35 + 0.85 * rise);
|
|
62
|
+
vec2 hill = p.xz - vec2(uPointer.x * 16.0 + 2.0, -uPointer.y * 10.0 - 4.0);
|
|
63
|
+
h += exp(-dot(hill, hill) * 0.03) * 0.7 * uPresence;
|
|
64
|
+
p.y += h - uScroll * 2.2;
|
|
65
|
+
vH = clamp(h * 0.6 + 0.5, 0.0, 1.0);
|
|
66
|
+
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
67
|
+
vFade = smoothstep(46.0, 14.0, -mv.z) * smoothstep(0.5, 4.0, -mv.z);
|
|
68
|
+
gl_Position = projectionMatrix * mv;
|
|
69
|
+
gl_PointSize = max(1.5 * uDpr, uDpr * uSize / -mv.z);
|
|
70
|
+
}
|
|
71
|
+
`,
|
|
72
|
+
fragmentShader: /* glsl */ `
|
|
73
|
+
uniform vec3 uInk; uniform vec3 uAccent; uniform float uInkWeight; uniform float uFloor;
|
|
74
|
+
varying float vH; varying float vFade;
|
|
75
|
+
${QUIET_GLSL}
|
|
76
|
+
void main() {
|
|
77
|
+
float d = length(gl_PointCoord - 0.5);
|
|
78
|
+
float disc = smoothstep(0.5, 0.3, d);
|
|
79
|
+
float crest = smoothstep(0.68, 0.95, vH);
|
|
80
|
+
float v = hush(max((0.3 + 0.7 * vH) * uInkWeight, uFloor) * vFade, gl_FragCoord.xy);
|
|
81
|
+
vec3 col = mix(uInk, uAccent, crest * 0.85);
|
|
82
|
+
gl_FragColor = vec4(col, disc * v);
|
|
83
|
+
#include <colorspace_fragment>
|
|
84
|
+
}
|
|
85
|
+
`,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
scene.add(new THREE.Points(geometry, material));
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
scene,
|
|
92
|
+
camera,
|
|
93
|
+
update(state) {
|
|
94
|
+
uniforms.uTime.value = state.time;
|
|
95
|
+
uniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
|
|
96
|
+
uniforms.uPresence.value = state.presence;
|
|
97
|
+
uniforms.uScroll.value = state.scroll;
|
|
98
|
+
quietSet.update(state);
|
|
99
|
+
},
|
|
100
|
+
resize(width, height) {
|
|
101
|
+
camera.aspect = width / Math.max(1, height);
|
|
102
|
+
camera.updateProjectionMatrix();
|
|
103
|
+
uniforms.uDpr.value = renderer.getPixelRatio();
|
|
104
|
+
},
|
|
105
|
+
dispose() {
|
|
106
|
+
geometry.dispose();
|
|
107
|
+
material.dispose();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// components/struq/scenes/ripple.ts (Struq recipe)
|
|
2
|
+
// The hero photo as a living surface: a slow liquid drift, a ripple that follows the pointer
|
|
3
|
+
// and grows with its speed, a hint of chromatic split in the ripple, and a slow push-in as the
|
|
4
|
+
// hero scrolls away. The photo is pulled toward the palette (ink in the shadows, surface in the
|
|
5
|
+
// highlights) by `grade`, so any pack photo sits in the site's colours. The ripple only lives while
|
|
6
|
+
// the pointer is there (presence), and it stills behind the copy marked data-quiet.
|
|
7
|
+
// Suits: hospitality, food, hotels, fashion, wellness, any brief with one strong hero photo.
|
|
8
|
+
import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
|
|
9
|
+
|
|
10
|
+
export const createScene: CreateScene = async ({ THREE, renderer, palette, image, grade }) => {
|
|
11
|
+
if (!image) throw new Error("The ripple scene needs an image: <HeroCanvas scene=\"ripple\" image={images.hero[0]} />");
|
|
12
|
+
const scene = new THREE.Scene();
|
|
13
|
+
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
14
|
+
const texture = await new THREE.TextureLoader().loadAsync(image.src);
|
|
15
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
16
|
+
texture.minFilter = THREE.LinearFilter;
|
|
17
|
+
texture.generateMipmaps = false;
|
|
18
|
+
|
|
19
|
+
const quietSet = quietUniforms(THREE, 0.1);
|
|
20
|
+
const uniforms = {
|
|
21
|
+
...quietSet.uniforms,
|
|
22
|
+
uMap: { value: texture },
|
|
23
|
+
uPresence: { value: 0 },
|
|
24
|
+
uPr: { value: renderer.getPixelRatio() },
|
|
25
|
+
uTime: { value: 0 },
|
|
26
|
+
uRes: { value: new THREE.Vector2(1, 1) },
|
|
27
|
+
uImg: { value: new THREE.Vector2(image.width, image.height) },
|
|
28
|
+
uPointer: { value: new THREE.Vector2(0, 0) },
|
|
29
|
+
uStrength: { value: 0 },
|
|
30
|
+
uScroll: { value: 0 },
|
|
31
|
+
uGrade: { value: grade },
|
|
32
|
+
uInk: { value: new THREE.Color(palette.ink) },
|
|
33
|
+
uSurface: { value: new THREE.Color(palette.surface) },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const material = new THREE.ShaderMaterial({
|
|
37
|
+
uniforms,
|
|
38
|
+
vertexShader: /* glsl */ `
|
|
39
|
+
varying vec2 vUv;
|
|
40
|
+
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
|
|
41
|
+
`,
|
|
42
|
+
fragmentShader: /* glsl */ `
|
|
43
|
+
uniform sampler2D uMap; uniform float uTime; uniform vec2 uRes; uniform vec2 uImg;
|
|
44
|
+
uniform vec2 uPointer; uniform float uStrength; uniform float uScroll; uniform float uGrade;
|
|
45
|
+
uniform vec3 uInk; uniform vec3 uSurface; uniform float uPresence; uniform float uPr;
|
|
46
|
+
varying vec2 vUv;
|
|
47
|
+
${NOISE_GLSL}
|
|
48
|
+
${QUIET_GLSL}
|
|
49
|
+
vec2 cover(vec2 uv) {
|
|
50
|
+
float rs = uRes.x / uRes.y; float ri = uImg.x / uImg.y;
|
|
51
|
+
vec2 s = rs > ri ? vec2(1.0, ri / rs) : vec2(rs / ri, 1.0);
|
|
52
|
+
return (uv - 0.5) * s + 0.5;
|
|
53
|
+
}
|
|
54
|
+
void main() {
|
|
55
|
+
vec2 uv = (vUv - 0.5) / (1.04 + uScroll * 0.12) + 0.5;
|
|
56
|
+
float calm = 1.0 - 0.8 * quiet(gl_FragCoord.xy);
|
|
57
|
+
float t = uTime * 0.05;
|
|
58
|
+
vec2 d = (vec2(noise(uv * 2.4 + t), noise(uv * 2.4 - t * 0.8)) - 0.5) * 0.008 * calm;
|
|
59
|
+
vec2 aspect = vec2(uRes.x / uRes.y, 1.0);
|
|
60
|
+
vec2 diff = (vUv - (uPointer * 0.5 + 0.5)) * aspect;
|
|
61
|
+
float dist = length(diff);
|
|
62
|
+
float wave = sin(dist * 30.0 - uTime * 2.0) * exp(-dist * 6.0) * (0.003 + uStrength * 0.012) * uPresence * calm;
|
|
63
|
+
d += normalize(diff + 1e-5) * wave;
|
|
64
|
+
vec2 c = cover(uv + d);
|
|
65
|
+
float split = abs(wave) * 0.6;
|
|
66
|
+
vec3 col = vec3(texture2D(uMap, c + vec2(split, 0.0)).r, texture2D(uMap, c).g, texture2D(uMap, c - vec2(split, 0.0)).b);
|
|
67
|
+
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
|
|
68
|
+
col = mix(col, mix(uInk, uSurface, smoothstep(0.02, 0.9, l)), uGrade);
|
|
69
|
+
col += (hash(vUv * uRes + fract(uTime * 7.0)) - 0.5) * 0.025;
|
|
70
|
+
gl_FragColor = vec4(col, 1.0);
|
|
71
|
+
#include <colorspace_fragment>
|
|
72
|
+
}
|
|
73
|
+
`,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const geometry = new THREE.PlaneGeometry(2, 2);
|
|
77
|
+
scene.add(new THREE.Mesh(geometry, material));
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
scene,
|
|
81
|
+
camera,
|
|
82
|
+
update(state) {
|
|
83
|
+
uniforms.uTime.value = state.time;
|
|
84
|
+
uniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
|
|
85
|
+
uniforms.uStrength.value = state.velocity;
|
|
86
|
+
uniforms.uPresence.value = state.presence;
|
|
87
|
+
quietSet.update(state);
|
|
88
|
+
uniforms.uScroll.value = state.scroll;
|
|
89
|
+
},
|
|
90
|
+
resize(width, height) {
|
|
91
|
+
uniforms.uRes.value.set(width, height);
|
|
92
|
+
},
|
|
93
|
+
dispose() {
|
|
94
|
+
geometry.dispose();
|
|
95
|
+
material.dispose();
|
|
96
|
+
texture.dispose();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// components/struq/scenes/types.ts (Struq recipe)
|
|
2
|
+
// The contract every hero scene follows. HeroCanvas owns the renderer, the clock, the pointer,
|
|
3
|
+
// the pause, the poster and the quiet zones; a scene owns only what it draws.
|
|
4
|
+
import type * as THREE_NS from "three";
|
|
5
|
+
|
|
6
|
+
export type Palette = {
|
|
7
|
+
/** CSS colour strings, already resolved to rgb() from the :root tokens. */
|
|
8
|
+
surface: string;
|
|
9
|
+
surface2: string;
|
|
10
|
+
ink: string;
|
|
11
|
+
accent: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SceneState = {
|
|
15
|
+
/** Seconds on gsap.ticker. Scenes scale it down: an ambient field moves at 0.02 to 0.06 of it. */
|
|
16
|
+
time: number;
|
|
17
|
+
/** Smoothed pointer, -1 to 1 on both axes, y up. A gentle modulation only, never the subject. */
|
|
18
|
+
pointer: { x: number; y: number };
|
|
19
|
+
/** 0 to 1: how far the pointer is engaged; eases in on move and out when it rests or leaves. */
|
|
20
|
+
presence: number;
|
|
21
|
+
/** 0 to 1: how far the hero has scrolled out of view. */
|
|
22
|
+
scroll: number;
|
|
23
|
+
/** 0 to 1: recent pointer speed, decays on its own. */
|
|
24
|
+
velocity: number;
|
|
25
|
+
/**
|
|
26
|
+
* Up to four rects behind the copy (elements marked data-quiet in the hero), in device pixels with
|
|
27
|
+
* a bottom-left origin like gl_FragCoord: [left, bottom, right, top] per rect, zeros when unused.
|
|
28
|
+
*/
|
|
29
|
+
quiet: Float32Array;
|
|
30
|
+
/** The soft edge around each quiet rect, in device pixels. */
|
|
31
|
+
feather: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SceneInput = {
|
|
35
|
+
THREE: typeof THREE_NS;
|
|
36
|
+
renderer: THREE_NS.WebGLRenderer;
|
|
37
|
+
palette: Palette;
|
|
38
|
+
image?: { src: string; width: number; height: number };
|
|
39
|
+
grade: number;
|
|
40
|
+
/** True on a dark palette: the quiet level and the line weights differ per ground. */
|
|
41
|
+
dark: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type HeroScene = {
|
|
45
|
+
scene: THREE_NS.Scene;
|
|
46
|
+
camera: THREE_NS.Camera;
|
|
47
|
+
update(state: SceneState): void;
|
|
48
|
+
/** Width and height in CSS pixels; read renderer.getPixelRatio() for device pixels. */
|
|
49
|
+
resize(width: number, height: number): void;
|
|
50
|
+
/** A multi-pass scene renders itself; otherwise HeroCanvas renders scene with camera. */
|
|
51
|
+
render?(renderer: THREE_NS.WebGLRenderer): void;
|
|
52
|
+
dispose(): void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type CreateScene = (input: SceneInput) => Promise<HeroScene>;
|
|
56
|
+
|
|
57
|
+
/** Shared GLSL: hash, value noise and fbm. Run noise per field cell, never per screen pixel, where you can. */
|
|
58
|
+
export const NOISE_GLSL = /* glsl */ `
|
|
59
|
+
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
|
60
|
+
float noise(vec2 p) {
|
|
61
|
+
vec2 i = floor(p); vec2 f = fract(p);
|
|
62
|
+
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
|
|
63
|
+
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
64
|
+
}
|
|
65
|
+
float fbm(vec2 p) {
|
|
66
|
+
float v = 0.0; float a = 0.5;
|
|
67
|
+
for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; }
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The hush behind the copy. Inside a quiet rect the field does not scale down to a dark box: it meets
|
|
74
|
+
* a soft ceiling (uQuietLevel), so faint light passes as it is and only the bright parts are held
|
|
75
|
+
* down. The feather lies outside each rect, so the copy itself sits at full quiet.
|
|
76
|
+
*/
|
|
77
|
+
export const QUIET_GLSL = /* glsl */ `
|
|
78
|
+
uniform vec4 uQuiet[4];
|
|
79
|
+
uniform float uFeather;
|
|
80
|
+
uniform float uQuietLevel;
|
|
81
|
+
float quietBox(vec2 p, vec4 r, float f) {
|
|
82
|
+
vec2 a = smoothstep(r.xy - f, r.xy, p);
|
|
83
|
+
vec2 b = 1.0 - smoothstep(r.zw, r.zw + f, p);
|
|
84
|
+
return a.x * a.y * b.x * b.y;
|
|
85
|
+
}
|
|
86
|
+
float quiet(vec2 p) {
|
|
87
|
+
float m = 0.0;
|
|
88
|
+
for (int i = 0; i < 4; i++) {
|
|
89
|
+
vec4 r = uQuiet[i];
|
|
90
|
+
if (r.z > r.x) m = max(m, quietBox(p, r, uFeather));
|
|
91
|
+
}
|
|
92
|
+
return m;
|
|
93
|
+
}
|
|
94
|
+
float hush(float v, vec2 p) {
|
|
95
|
+
float q = quiet(p);
|
|
96
|
+
return mix(v, uQuietLevel * (1.0 - exp(-v / uQuietLevel)), q);
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
/** The uniforms QUIET_GLSL reads, and the update that copies HeroCanvas's rects into them. */
|
|
101
|
+
export function quietUniforms(THREE: typeof THREE_NS, level: number) {
|
|
102
|
+
const uniforms = {
|
|
103
|
+
uQuiet: { value: [0, 1, 2, 3].map(() => new THREE.Vector4()) },
|
|
104
|
+
uFeather: { value: 48 },
|
|
105
|
+
uQuietLevel: { value: level },
|
|
106
|
+
};
|
|
107
|
+
const update = (state: SceneState) => {
|
|
108
|
+
for (let i = 0; i < 4; i++) {
|
|
109
|
+
uniforms.uQuiet.value[i].set(state.quiet[i * 4], state.quiet[i * 4 + 1], state.quiet[i * 4 + 2], state.quiet[i * 4 + 3]);
|
|
110
|
+
}
|
|
111
|
+
uniforms.uFeather.value = state.feather;
|
|
112
|
+
};
|
|
113
|
+
return { uniforms, update };
|
|
114
|
+
}
|