@struq/skill 0.2.1 → 0.3.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 +15 -5
- package/package.json +4 -4
- package/skills/struq/SKILL.md +304 -200
- package/skills/struq/cinematic/showpieces/README.md +18 -0
- package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
- package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
- package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
- package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
- package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
- package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
- package/skills/struq/recipes/app/fonts.ts +22 -0
- package/skills/struq/recipes/app/globals.css +185 -0
- package/skills/struq/recipes/app/layout.tsx +28 -0
- package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
- package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
- package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
- package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
- package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
- package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
- package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
- package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
- package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
- package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
- package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
- package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
- package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
- package/skills/struq/recipes/lib/gsap.ts +21 -0
- package/skills/struq/references/anti-patterns.md +71 -6
- package/skills/struq/references/baseline-prompt.md +18 -14
- package/skills/struq/references/brief-router.md +7 -8
- package/skills/struq/references/composition.md +0 -1
- package/skills/struq/references/depth-parallax.md +1 -1
- package/skills/struq/references/direction.md +5 -5
- package/skills/struq/references/hero-scenes.md +107 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/imagery.md +10 -29
- package/skills/struq/references/micro-interactions.md +1 -1
- package/skills/struq/references/mobile-composition.md +1 -1
- package/skills/struq/references/niche-packets.md +338 -0
- package/skills/struq/references/page-architecture.md +2 -2
- package/skills/struq/references/pinning-scrub.md +3 -4
- package/skills/struq/references/scroll-reveals.md +1 -2
- package/skills/struq/references/sections.md +141 -0
- package/skills/struq/references/seo.md +2 -2
- package/skills/struq/references/showcase.md +2 -2
- package/skills/struq/references/svg-motion.md +390 -390
- package/skills/struq/references/typography.md +39 -39
- package/skills/struq/references/vibe-presets.md +5 -5
- package/skills/struq/references/webgl-scenes.md +593 -593
- package/skills/struq/scripts/canon.mjs +309 -0
- package/skills/struq/scripts/check.mjs +547 -0
- package/skills/struq/scripts/font-names.mjs +40 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +92 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/recipes.mjs +135 -0
- /package/{bin → cli}/struq.mjs +0 -0
|
@@ -1,593 +1,593 @@
|
|
|
1
|
-
# WebGL Scenes: Drei, postprocessing and interactive 3D
|
|
2
|
-
|
|
3
|
-
**Read [webgl-r3f.md](webgl-r3f.md) first.** It owns the integration and this file never contradicts it: a lazy, SSR-free island behind a real poster; one canvas per page; `frameloop="never"` driven by `gsap.ticker` (or `"demand"` plus `invalidate()` for reactive-only scenes); scroll written into refs and uniforms, never React state. This file is what goes **inside** that canvas: Drei helpers, the postprocessing stack, the four 3D recipes from [showcase.md](showcase.md), performance tiers and the tells.
|
|
4
|
-
|
|
5
|
-
For a whole page that behaves as one continuous 3D film, switch to Directed mode: [../cinematic/README.md](../cinematic/README.md).
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm i three @react-three/fiber @react-three/drei @react-three/postprocessing postprocessing
|
|
9
|
-
npm i -D @types/three
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
`postprocessing` is listed explicitly because the samples import `BlendFunction` and `ToneMappingMode` from it.
|
|
13
|
-
|
|
14
|
-
## 0. The architecture every recipe shares
|
|
15
|
-
|
|
16
|
-
**The DOM side owns time and scroll; the canvas side only reads.** The section component creates every ScrollTrigger inside `useGSAP` (scoped, reduced-motion aware, cleaned up on unmount) and writes plain numbers into refs. The scene reads those refs in `useFrame`. Nothing crosses the boundary as React state.
|
|
17
|
-
|
|
18
|
-
```tsx
|
|
19
|
-
// components/three/shared.ts
|
|
20
|
-
"use client";
|
|
21
|
-
import { useEffect } from "react";
|
|
22
|
-
import { useThree } from "@react-three/fiber";
|
|
23
|
-
import { gsap } from "@/lib/gsap";
|
|
24
|
-
|
|
25
|
-
export type Box<T> = { current: T };
|
|
26
|
-
|
|
27
|
-
/** webgl-r3f.md §2 driver, plus an offscreen pause: no frames while the section is out of view.
|
|
28
|
-
* Replaces webgl-r3f.md's GsapTickerDriver; never mount both. */
|
|
29
|
-
export function TickerDriver({ active }: { active: Box<boolean> }) {
|
|
30
|
-
const advance = useThree((s) => s.advance);
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
const tick = (time: number) => { if (active.current) advance(time); };
|
|
33
|
-
gsap.ticker.add(tick);
|
|
34
|
-
return () => gsap.ticker.remove(tick);
|
|
35
|
-
}, [advance, active]);
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
```tsx
|
|
41
|
-
// the section side, used by every recipe below
|
|
42
|
-
const outer = useRef<HTMLElement>(null); // <section ref={outer}>: never pinned, so it holds the pin-spacer
|
|
43
|
-
const wrap = useRef<HTMLDivElement>(null); // <div ref={wrap}> inside it: the element a recipe may pin
|
|
44
|
-
const active = useRef(false);
|
|
45
|
-
const progress = useRef(0);
|
|
46
|
-
|
|
47
|
-
useGSAP(() => {
|
|
48
|
-
const mm = gsap.matchMedia();
|
|
49
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
50
|
-
ScrollTrigger.create({
|
|
51
|
-
trigger: outer.current,
|
|
52
|
-
start: "top bottom",
|
|
53
|
-
end: "bottom top",
|
|
54
|
-
refreshPriority: -1, // measure after any pin in the section
|
|
55
|
-
onToggle: (self) => { active.current = self.isActive; }, // offscreen pause
|
|
56
|
-
onUpdate: (self) => { progress.current = self.progress; }, // scroll into a ref
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
}, { scope: outer });
|
|
60
|
-
|
|
61
|
-
// the island, inside the section (lazy, behind the poster: webgl-r3f.md §1)
|
|
62
|
-
<Canvas frameloop="never" dpr={dpr} gl={{ alpha: true }}>
|
|
63
|
-
<TickerDriver active={active} />
|
|
64
|
-
{/* the recipe's scene */}
|
|
65
|
-
</Canvas>
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
- **Every canvas** in this file is `frameloop="never"` with exactly one `<TickerDriver active={active} />` inside it. `AmbientHero` (§4) mounts its own, so its canvas does not add another.
|
|
69
|
-
- **Why the outer trigger.** When a recipe pins `wrap` (product-rig), ScrollTrigger wraps it in a pin-spacer as tall as the pin. A trigger on `wrap` itself is measured against the unpinned box and toggles `active` off halfway through the pin, freezing the scene mid-shot. The `outer` section contains the spacer, so its trigger spans the whole pinned distance; `refreshPriority: -1` makes it refresh after the pin has added its spacing.
|
|
70
|
-
|
|
71
|
-
When the driver resumes after a pause, the first `delta` covers the whole time away. Clamp it in any damping code: `const dt = Math.min(delta, 1 / 30)`.
|
|
72
|
-
|
|
73
|
-
**Many Drei helpers animate in `useFrame`** (`Float`, `MeshTransmissionMaterial`'s temporal distortion, `ContactShadows` unless baked, `PerformanceMonitor`). They need frames, so a scene that uses them runs `frameloop="never"` with the driver. A purely scroll-reactive scene without them can use `"demand"` and `invalidate()` from `onUpdate`.
|
|
74
|
-
|
|
75
|
-
## 1. Drei on a marketing site
|
|
76
|
-
|
|
77
|
-
| Helper | Use it for | Notes |
|
|
78
|
-
|---|---|---|
|
|
79
|
-
| `Environment` | Image-based lighting: believable reflections on metal, glass, satin | `preset="studio"` (also `city`, `warehouse`, `sunset`, `dawn`, `night`, `forest`, `apartment`, `lobby`, `park`) fetches an HDR from a CDN at runtime; in production self-host it: `files="/hdr/studio.hdr"`. `background` shows it; leave it off and let the page surface be the background. |
|
|
80
|
-
| `Lightformer` | Art-directed reflections: soft rectangles and rings you place like studio softboxes | Children of `<Environment>`; props `form` (`"rect"`, `"circle"`, `"ring"`), `intensity`, `color`, plus position, rotation, scale. Controlled highlights are what make a product look shot rather than rendered. |
|
|
81
|
-
| `Float` | A slow ambient drift on a subject | `speed`, `rotationIntensity`, `floatIntensity`, `floatingRange`. Keep them low (speed 1, intensities 0.2 to 0.5): it is breath, not bobbing. |
|
|
82
|
-
| `MeshTransmissionMaterial` | Glass, crystal, liquid | `thickness`, `roughness`, `ior`, `chromaticAberration`, `distortion`, `distortionScale`, `temporalDistortion`, `samples`, `resolution`, `backside`. It renders the scene again into a buffer every frame (twice with `backside`), so it is high tier only: `samples` 4 to 6, `resolution` 512 to 1024. |
|
|
83
|
-
| `MeshDistortMaterial` | A surface that wobbles | `distort`, `speed`. This is the floating-blob material: only with a reason, low `distort`, never purple. |
|
|
84
|
-
| `Text` | 3D type that has to live in the scene (on a surface, in depth) | `font` takes a URL to the display face file (`.ttf` or `.woff`), plus `fontSize`, `anchorX`, `anchorY`, `letterSpacing`, `lineHeight`, `maxWidth`, `color`. Decoration only: the same words exist in the DOM (§6). |
|
|
85
|
-
| `useGLTF` | Loading a model | `useGLTF(url)` has Draco and meshopt on by default (signature `useGLTF(url, useDraco = true, useMeshopt = true)`). The Draco decoder is fetched from gstatic only when a file is actually Draco-compressed; a meshopt or plain GLB never touches it. Self-host the decoder once, at module level in the island: `useGLTF.setDecoderPath("/draco/")` (copy `node_modules/three/examples/jsm/libs/draco/gltf/` to `public/draco/`). `useGLTF.preload(url)` buys nothing inside the lazy island (it runs only when the island chunk loads), and importing drei into the section to call it earlier pulls three into the page bundle; the poster covers the wait ([performance.md](performance.md) §5). |
|
|
86
|
-
| `ContactShadows` | A soft grounding shadow under a subject | `opacity`, `scale`, `blur`, `far`, `resolution`, `color`, `frames`. `frames={1}` bakes it once: free afterwards, but only while the subject's height, scale and tilt never change. If a shot lifts, scales or tilts the subject, drop `frames` on high tier (a live shadow) and keep those values constant on mid. |
|
|
87
|
-
| `Center` | Centring a model whose origin is wrong | Wrap the model. Cheaper than fixing every export. |
|
|
88
|
-
| `Bounds` | Fitting the camera to a model of unknown size | `fit`, `clip`, `observe`, `margin`. It moves the camera, so never combine it with a scroll or shot-driven camera. Good for a static product view; the named shots (§3) replace it otherwise. |
|
|
89
|
-
| `View` | Several small scenes through one canvas, each tracking a DOM box | Keeps the one-canvas budget when two sections need 3D. Mount one `<View.Port />` inside a single fixed `<Canvas eventSource={...}>`. Every view still costs a render: two or three, not twelve. |
|
|
90
|
-
| `PerformanceMonitor` | Live tiering | `onDecline`, `onIncline`, `onChange` (with a `factor` 0 to 1), `onFallback`, `flipflops`. Drives the tier in §5. Setting state here is fine: it fires a handful of times, not per frame. |
|
|
91
|
-
| `AdaptiveDpr` | Dropping resolution during regressions | Works only when something calls R3F's `performance.regress()` and the canvas sets `performance={{ min: 0.5 }}`. On a marketing scene that is rarely true; prefer `PerformanceMonitor` setting `dpr` directly. |
|
|
92
|
-
| `Preload` | Uploading textures and compiling shaders before first view | `<Preload all />` inside the canvas. Removes the hitch the first time a hidden object appears. |
|
|
93
|
-
|
|
94
|
-
### Why not `ScrollControls`
|
|
95
|
-
|
|
96
|
-
Drei's `ScrollControls` creates its own scroll container over the canvas with its own damping. On a Struq page that is a second scroll source fighting Lenis (double smoothing, two velocities), ScrollTrigger stops seeing the scroll it was built for, and its HTML layer moves real content into a container search engines and screen readers handle badly. The page already has one scroll and one clock: ScrollTrigger reads Lenis, writes refs, the scene reads refs (§0). Use that.
|
|
97
|
-
|
|
98
|
-
## 2. Postprocessing, with restraint
|
|
99
|
-
|
|
100
|
-
```tsx
|
|
101
|
-
// components/three/Grade.tsx
|
|
102
|
-
import { EffectComposer, Noise, Vignette, ChromaticAberration, ToneMapping } from "@react-three/postprocessing";
|
|
103
|
-
import { BlendFunction, ToneMappingMode } from "postprocessing";
|
|
104
|
-
import * as THREE from "three";
|
|
105
|
-
|
|
106
|
-
export function Grade({ tier, caOffset, fullBleed = true }: {
|
|
107
|
-
tier: "high" | "mid";
|
|
108
|
-
caOffset: THREE.Vector2; // owned by the canvas component with useState (§4, product-rig)
|
|
109
|
-
fullBleed?: boolean; // false when the canvas sits in a column, not the whole viewport
|
|
110
|
-
}) {
|
|
111
|
-
return (
|
|
112
|
-
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
|
|
113
|
-
{/* Only when the subject has emissive parts (rule below); add Bloom to the import:
|
|
114
|
-
<Bloom mipmapBlur luminanceThreshold={0.9} luminanceSmoothing={0.2} intensity={0.6} /> */}
|
|
115
|
-
<Noise opacity={0.035} blendFunction={BlendFunction.OVERLAY} />
|
|
116
|
-
{fullBleed ? <Vignette offset={0.3} darkness={0.55} /> : <></>}
|
|
117
|
-
{tier === "high" ? <ChromaticAberration offset={caOffset} radialModulation modulationOffset={0.3} /> : <></>}
|
|
118
|
-
{fullBleed ? <ToneMapping mode={ToneMappingMode.ACES_FILMIC} /> : <></>}
|
|
119
|
-
</EffectComposer>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
**The restraint rule:**
|
|
125
|
-
|
|
126
|
-
- **Default stack: grain and vignette.** `Noise` at 0.03 to 0.05 opacity with an overlay blend ties the 3D to the photographic grade of the page ([imagery.md](imagery.md)); `Vignette` focuses the frame. That is the whole look most of the time.
|
|
127
|
-
- **Vignette and tone mapping are for full-bleed canvases only.** On a canvas that fills one column of a split layout, the vignette draws a visible dark rectangle and the ACES pass (plus R3F's default renderer tone mapping) shifts the background off the page colour, so the canvas reads as a box. There: `fullBleed={false}` (grain and CA only), `<Canvas flat>`, and the page surface as the background (transparent canvas, or a `<color attach="background">` set to the exact surface token, checked by eye against the DOM next to it).
|
|
128
|
-
- **`Bloom` only with emissive subjects.** `luminanceThreshold` around 0.9, `luminanceSmoothing` 0.1 to 0.3, `intensity` 0.4 to 1, `mipmapBlur`. Bloom on a non-emissive scene washes everything into a haze; bloom on everything is the 2019 crypto look.
|
|
129
|
-
- **`ChromaticAberration`: tiny, and tied to scroll velocity.** At rest the offset is zero. Pass one `THREE.Vector2` as `offset` (owned with `useState(() => new THREE.Vector2())`) and update it with `.set()` from velocity in `useFrame` (§4, product-rig); a static offset is just blur on the edges.
|
|
130
|
-
- **`DepthOfField`** (`focusDistance`, `focalLength`, `bokehScale`) is expensive and fights text legibility. Use it for one macro shot, never as an always-on look.
|
|
131
|
-
- **`ToneMapping` last.** With a composer, put the tone-mapping pass at the end so the grade is applied once. If colours look washed out or double-graded, compare with `<Canvas flat>` (no renderer tone mapping) and keep only one.
|
|
132
|
-
- **Composer off on low tier.** Every pass is a full-screen draw. Below high tier, drop CA; on low tier, no composer at all, the scene renders straight.
|
|
133
|
-
|
|
134
|
-
## 3. Shots and scroll
|
|
135
|
-
|
|
136
|
-
Never scatter raw camera numbers across `useFrame` calls. Name the poses, then let one scrubbed GSAP timeline move a plain object between them; the scene copies that object into transforms each frame. Directed mode formalises this as shot presets ([../cinematic/README.md](../cinematic/README.md)); on a site with one 3D section, three or four named shots are enough.
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
// components/three/shots.ts
|
|
140
|
-
// y, s and rotX stay constant so the baked ContactShadows (frames={1}) stays true;
|
|
141
|
-
// the camera dolly (camZ) and the turn (rotY) carry the shots.
|
|
142
|
-
export const SHOTS = {
|
|
143
|
-
intro: { rotY: -0.5, rotX: 0.1, y: 0, s: 1, camZ: 6.0 },
|
|
144
|
-
side: { rotY: 1.2, rotX: 0.1, y: 0, s: 1, camZ: 5.2 },
|
|
145
|
-
detail: { rotY: 2.4, rotX: 0.1, y: 0, s: 1, camZ: 4.0 },
|
|
146
|
-
} as const;
|
|
147
|
-
export type Shot = { rotY: number; rotX: number; y: number; s: number; camZ: number };
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
GSAP tweens plain objects, so the timeline writes numbers and the scene never re-renders.
|
|
151
|
-
|
|
152
|
-
## 4. Recipes
|
|
153
|
-
|
|
154
|
-
### `product-rig`: scroll turns it, the pointer tilts it
|
|
155
|
-
|
|
156
|
-
The rule that makes it feel solid: **the scroll writer and the pointer writer never share a transform.** If both write `rotation.y`, the last write wins each frame and the object jitters between two opinions. Nest them: the outer group belongs to scroll, the inner group to the pointer, the model sits inside both.
|
|
157
|
-
|
|
158
|
-
```tsx
|
|
159
|
-
// components/three/ProductRig.tsx (inside the Canvas)
|
|
160
|
-
"use client";
|
|
161
|
-
import { useEffect, useRef } from "react";
|
|
162
|
-
import { useFrame } from "@react-three/fiber";
|
|
163
|
-
import { Center, ContactShadows, Environment, Lightformer, useGLTF } from "@react-three/drei";
|
|
164
|
-
import * as THREE from "three";
|
|
165
|
-
import type { Box } from "./shared";
|
|
166
|
-
import type { Shot } from "./shots";
|
|
167
|
-
|
|
168
|
-
const damp = THREE.MathUtils.damp;
|
|
169
|
-
|
|
170
|
-
export function ProductRig({ rig, velocity, caOffset, tilt }: {
|
|
171
|
-
rig: Box<Shot>; // written by the section's scrubbed timeline
|
|
172
|
-
velocity: Box<number>; // ScrollTrigger getVelocity(), px/s
|
|
173
|
-
caOffset: THREE.Vector2; // the vector passed to ChromaticAberration
|
|
174
|
-
tilt: boolean; // false on touch: scroll only
|
|
175
|
-
}) {
|
|
176
|
-
const scrollGroup = useRef<THREE.Group>(null);
|
|
177
|
-
const pointerGroup = useRef<THREE.Group>(null);
|
|
178
|
-
const pointer = useRef({ x: 0, y: 0 });
|
|
179
|
-
const { scene } = useGLTF("/models/product.glb");
|
|
180
|
-
|
|
181
|
-
useEffect(() => {
|
|
182
|
-
if (!tilt) return;
|
|
183
|
-
const onMove = (e: PointerEvent) => {
|
|
184
|
-
pointer.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
185
|
-
pointer.current.y = (e.clientY / window.innerHeight) * 2 - 1;
|
|
186
|
-
};
|
|
187
|
-
window.addEventListener("pointermove", onMove, { passive: true });
|
|
188
|
-
return () => window.removeEventListener("pointermove", onMove);
|
|
189
|
-
}, [tilt]);
|
|
190
|
-
|
|
191
|
-
useFrame((state, delta) => {
|
|
192
|
-
const dt = Math.min(delta, 1 / 30);
|
|
193
|
-
const s = rig.current, g = scrollGroup.current!, p = pointerGroup.current!;
|
|
194
|
-
// scroll writer: outer group and camera only
|
|
195
|
-
g.rotation.set(s.rotX, s.rotY, 0);
|
|
196
|
-
g.position.y = s.y;
|
|
197
|
-
g.scale.setScalar(s.s);
|
|
198
|
-
state.camera.position.z = s.camZ;
|
|
199
|
-
// pointer writer: inner group only, damped, small
|
|
200
|
-
p.rotation.y = damp(p.rotation.y, pointer.current.x * 0.25, 4, dt);
|
|
201
|
-
p.rotation.x = damp(p.rotation.x, pointer.current.y * 0.15, 4, dt);
|
|
202
|
-
// velocity into a tiny chromatic aberration, zero at rest. Mutate through .set(): assigning
|
|
203
|
-
// to a prop's fields is a React Compiler lint error (react-hooks/immutability).
|
|
204
|
-
const target = Math.min(Math.abs(velocity.current) / 4000, 1) * 0.0015;
|
|
205
|
-
const v = damp(caOffset.x, target, 6, dt);
|
|
206
|
-
caOffset.set(v, v);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
return (
|
|
210
|
-
<>
|
|
211
|
-
<Environment resolution={256}>
|
|
212
|
-
<Lightformer form="rect" intensity={2} position={[0, 4, -3]} scale={[8, 2, 1]} />
|
|
213
|
-
<Lightformer form="rect" intensity={0.8} position={[-4, 0, 2]} rotation={[0, Math.PI / 2, 0]} scale={[4, 4, 1]} />
|
|
214
|
-
</Environment>
|
|
215
|
-
<group ref={scrollGroup}>
|
|
216
|
-
<group ref={pointerGroup}>
|
|
217
|
-
<Center><primitive object={scene} /></Center>
|
|
218
|
-
</group>
|
|
219
|
-
</group>
|
|
220
|
-
<ContactShadows position={[0, -1.2, 0]} opacity={0.35} blur={2.5} scale={6} far={2} frames={1} />
|
|
221
|
-
</>
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
useGLTF.setDecoderPath("/draco/"); // self-hosted; fetched only if the GLB is Draco-compressed
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
The canvas component owns the vector and hands the same instance to the rig and to `Grade`:
|
|
228
|
-
|
|
229
|
-
```tsx
|
|
230
|
-
// in the island component that renders <Canvas>: one Vector2 for the life of the canvas.
|
|
231
|
-
// useState(() => ...), not useMemo: the React Compiler lint rejects mutating a memo value
|
|
232
|
-
// that is passed on as a prop, and useState's lazy initialiser runs exactly once.
|
|
233
|
-
const [caOffset] = useState(() => new THREE.Vector2(0, 0));
|
|
234
|
-
// <ProductRig rig={rig} velocity={velocity} caOffset={caOffset} tilt={tilt} />
|
|
235
|
-
// <Grade tier={tier} caOffset={caOffset} fullBleed={false} />
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
The section side builds the shot timeline and velocity inside the §0 `useGSAP` branch. It pins `wrap`; the `active` trigger stays on `outer` (§0). Use **one `GATE` string** for the island's import gate ([webgl-r3f.md](webgl-r3f.md) §1) and for this `mm.add`, so the canvas and the pin can never disagree on a tablet with a fine pointer:
|
|
239
|
-
|
|
240
|
-
```ts
|
|
241
|
-
// shared by the island gate and the pin: one query, never two
|
|
242
|
-
export const GATE = "(prefers-reduced-motion: no-preference) and (pointer: fine) and (min-width: 1024px)";
|
|
243
|
-
|
|
244
|
-
const rig = useRef<Shot>({ ...SHOTS.intro });
|
|
245
|
-
const velocity = useRef(0);
|
|
246
|
-
// inside mm.add(GATE, ...), alongside the §0 active trigger:
|
|
247
|
-
gsap.timeline({
|
|
248
|
-
defaults: { ease: "none" },
|
|
249
|
-
scrollTrigger: {
|
|
250
|
-
trigger: wrap.current, start: "top top", end: "+=250%", pin: true, scrub: 1,
|
|
251
|
-
onUpdate: (self) => { velocity.current = self.getVelocity(); },
|
|
252
|
-
},
|
|
253
|
-
})
|
|
254
|
-
.to(rig.current, { ...SHOTS.side })
|
|
255
|
-
.to(rig.current, { ...SHOTS.detail });
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
- `ContactShadows` with `frames={1}` is baked once, which holds only because every shot keeps `y`, `s` and `rotX` constant: the scroll group turns the product, the camera dollies, the footprint stays put. A shot that lifts, scales or tilts the subject needs a live shadow: drop `frames` on high tier and keep the mid-tier shots flat.
|
|
259
|
-
- Pin rules (and `anticipatePin` with smooth scroll): [pinning-scrub.md](pinning-scrub.md).
|
|
260
|
-
- Copy for each shot (the feature it shows) is DOM text stepping beside the canvas, not 3D text.
|
|
261
|
-
- Touch: `tilt={false}`, shorter pin. Below the performance floor: the poster, which is a render of `SHOTS.intro` captured from this scene (§5, "The poster").
|
|
262
|
-
- In a split layout the canvas fills one column, not the viewport: pass `fullBleed={false}` to `Grade` and set `flat` on the `<Canvas>` (§2), or the vignette draws a dark box and the tone curve pushes the page's background to a different colour.
|
|
263
|
-
|
|
264
|
-
#### No model file yet
|
|
265
|
-
|
|
266
|
-
The client wants a 3D moment and there is no GLB or CAD. Walk this ladder in order and record the rung under ASSUMPTIONS in STRUQ.md:
|
|
267
|
-
|
|
268
|
-
1. **Ask at the gate.** The materials request in the gate message names the file: "a GLB or STEP/CAD export of the product, any polycount". Plan the section as if it will arrive.
|
|
269
|
-
2. **A declared stand-in.** Build the subject from primitives (cylinders, tubes, boxes, `ExtrudeGeometry` from the brand's SVG) as a declared art direction: clay, wire, blueprint or one flat material in the palette, never a fake photoreal product. It lives in one component (`<StandIn />`) mounted where `<primitive object={scene} />` would go, behind the same `Center` and the same `SHOTS`, so the real model drops in by swapping one line. Write it under ASSUMPTIONS: "Product: primitive stand-in, replace with the GLB at `/models/product.glb`".
|
|
270
|
-
3. **Downgrade.** When a stand-in would read as a toy or misrepresent the product: `ambient-hero-scene` with the brand's extruded mark, photography of the real product ([imagery.md](imagery.md)), or a type-led showpiece ([showcase.md](showcase.md)). Say which, and why, in the LOG.
|
|
271
|
-
|
|
272
|
-
Stock or generated images never stand in for the product itself ([imagery.md](imagery.md) §4). The poster for rungs 2 and 3 is rendered from the scene (§5), never drawn by hand or taken from stock.
|
|
273
|
-
|
|
274
|
-
### `hover-distortion-gallery`: a plane per image, a hover uniform
|
|
275
|
-
|
|
276
|
-
Real `<img>` elements stay in the DOM for layout, alt text, SEO and the touch fallback. One canvas fills the gallery section; each image gets a plane at the image's exact box; the DOM image fades out once its texture is on screen. With an orthographic camera at `zoom: 1`, one world unit is one CSS pixel, so DOM boxes map straight onto planes.
|
|
277
|
-
|
|
278
|
-
```tsx
|
|
279
|
-
// components/three/DistortGallery.tsx
|
|
280
|
-
// inside <Canvas frameloop="never" orthographic flat camera={{ zoom: 1, position: [0, 0, 100] }}>
|
|
281
|
-
// <TickerDriver active={active} /> <Suspense fallback={null}><DistortGallery ... /></Suspense>
|
|
282
|
-
"use client";
|
|
283
|
-
import { useMemo, useRef } from "react";
|
|
284
|
-
import { useFrame, useThree } from "@react-three/fiber";
|
|
285
|
-
import { useTexture } from "@react-three/drei";
|
|
286
|
-
import * as THREE from "three";
|
|
287
|
-
import type { Box } from "./shared";
|
|
288
|
-
|
|
289
|
-
export type Slot = { x: number; y: number; w: number; h: number }; // px, relative to the section
|
|
290
|
-
export type HoverState = { v: number; mx: number; my: number }; // tweened by the DOM side
|
|
291
|
-
|
|
292
|
-
const vert = /* glsl */ `
|
|
293
|
-
varying vec2 vUv;
|
|
294
|
-
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
|
|
295
|
-
|
|
296
|
-
const frag = /* glsl */ `
|
|
297
|
-
uniform sampler2D uTex; uniform vec2 uPlane; uniform vec2 uImage;
|
|
298
|
-
uniform float uHover; uniform vec2 uMouse; uniform float uTime;
|
|
299
|
-
varying vec2 vUv;
|
|
300
|
-
vec2 cover(vec2 uv) { // object-fit: cover
|
|
301
|
-
float rp = uPlane.x / uPlane.y, ri = uImage.x / uImage.y;
|
|
302
|
-
vec2 s = rp > ri ? vec2(1.0, ri / rp) : vec2(rp / ri, 1.0);
|
|
303
|
-
return (uv - 0.5) * s + 0.5;
|
|
304
|
-
}
|
|
305
|
-
void main() {
|
|
306
|
-
vec2 p = (vUv - uMouse) * vec2(uPlane.x / uPlane.y, 1.0); // aspect-correct distance
|
|
307
|
-
float d = length(p);
|
|
308
|
-
float ripple = sin(d * 26.0 - uTime * 3.0) * 0.01 * uHover * (1.0 - smoothstep(0.0, 0.5, d));
|
|
309
|
-
vec2 uv = cover(vUv + normalize(p + 1e-5) * ripple);
|
|
310
|
-
uv = (uv - 0.5) * (1.0 - 0.04 * uHover) + 0.5; // slight push-in on hover
|
|
311
|
-
gl_FragColor = texture2D(uTex, uv);
|
|
312
|
-
#include <colorspace_fragment>
|
|
313
|
-
}`;
|
|
314
|
-
|
|
315
|
-
function Plane({ url, slot, hover }: { url: string; slot: Slot; hover: HoverState }) {
|
|
316
|
-
const tex = useTexture(url);
|
|
317
|
-
tex.colorSpace = THREE.SRGBColorSpace;
|
|
318
|
-
const size = useThree((s) => s.size);
|
|
319
|
-
const img = tex.image as HTMLImageElement;
|
|
320
|
-
const uniforms = useMemo(() => ({
|
|
321
|
-
uTex: { value: tex }, uTime: { value: 0 }, uHover: { value: 0 },
|
|
322
|
-
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
|
|
323
|
-
uPlane: { value: new THREE.Vector2(1, 1) },
|
|
324
|
-
uImage: { value: new THREE.Vector2(img.width, img.height) },
|
|
325
|
-
}), [tex, img]);
|
|
326
|
-
|
|
327
|
-
useFrame((_, delta) => {
|
|
328
|
-
uniforms.uPlane.value.set(slot.w, slot.h); // mutate; never swap the uniforms object
|
|
329
|
-
uniforms.uTime.value += Math.min(delta, 1 / 30);
|
|
330
|
-
uniforms.uHover.value = hover.v;
|
|
331
|
-
uniforms.uMouse.value.set(hover.mx, hover.my);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<mesh position={[slot.x + slot.w / 2 - size.width / 2, size.height / 2 - (slot.y + slot.h / 2), 0]}
|
|
336
|
-
scale={[slot.w, slot.h, 1]}>
|
|
337
|
-
<planeGeometry />
|
|
338
|
-
<shaderMaterial vertexShader={vert} fragmentShader={frag} uniforms={uniforms} />
|
|
339
|
-
</mesh>
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export function DistortGallery({ urls, slots, hovers, onReady }: {
|
|
344
|
-
urls: string[]; slots: Slot[]; hovers: Box<HoverState[]>; onReady: () => void;
|
|
345
|
-
}) {
|
|
346
|
-
const ready = useRef(false);
|
|
347
|
-
useFrame(() => { // first frame after every texture resolved (Suspense): planes are on screen
|
|
348
|
-
if (ready.current) return;
|
|
349
|
-
ready.current = true;
|
|
350
|
-
onReady();
|
|
351
|
-
});
|
|
352
|
-
return <>{urls.map((u, i) => <Plane key={u} url={u} slot={slots[i]} hover={hovers.current[i]} />)}</>;
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
- **Uniforms are bound once.** three uploads the `uniforms` object to the program when the material compiles; a new object from a re-run `useMemo` is never seen by the shader, so the plane would keep its first size. The memo depends only on `[tex, img]`, and size-dependent values (`uPlane`) are mutated in `useFrame`.
|
|
357
|
-
|
|
358
|
-
The section side:
|
|
359
|
-
|
|
360
|
-
- Wrap `DistortGallery` in `<Suspense fallback={null}>` inside the canvas. Until textures resolve, the DOM images are what people see. `onReady` fires from the first rendered frame (a ref flag, so once), not from an effect: an effect runs on commit, before anything has been drawn, and the DOM images would fade over an empty canvas. It sets `data-gl-ready` on the section and CSS fades the `<img>` elements to `opacity: 0`.
|
|
361
|
-
- **Create the hover states before the canvas mounts,** one per url: `const hovers = useRef<HoverState[]>(urls.map(() => ({ v: 0, mx: 0.5, my: 0.5 })))`. Each `Plane` reads its object by index on first render; an empty array there is an `undefined` hover and a crash in `useFrame`. If `urls` can change, rebuild the array in the same render that changes them.
|
|
362
|
-
- Measure slots with one `ResizeObserver` on the section (rects relative to the section's own box). Store them in state: resize is rare, so a React render there is fine.
|
|
363
|
-
- Hover handlers on each DOM figure, built with `contextSafe` from `useGSAP`: `gsap.to(hovers.current[i], { v: 1, duration: 0.6, ease: "expo.out" })` on enter, `v: 0` with `power2.out` on leave, and `pointermove` writes `mx` and `my` (0 to 1 within the figure, y flipped). GSAP tweens the plain object; the plane reads it. No state per frame.
|
|
364
|
-
- `flat` on the canvas: photographs must not be tone mapped, and `colorspace_fragment` returns the sRGB texture to the screen's colour space untouched.
|
|
365
|
-
- Touch and reduced motion: the canvas never mounts (the [webgl-r3f.md](webgl-r3f.md) gate), the DOM images are the gallery. Texture size: 1600px on the long edge is plenty for a grid tile.
|
|
366
|
-
- **When not to:** fewer than four strong images, or a gallery that is also the page's showpiece pin. Distortion is a garnish on images worth looking at.
|
|
367
|
-
|
|
368
|
-
### `particle-field`: a small point field
|
|
369
|
-
|
|
370
|
-
A few thousand points drifting in a shader, pushed aside by the pointer, stirred by scroll velocity. One breather moment, not a background for the whole site.
|
|
371
|
-
|
|
372
|
-
```tsx
|
|
373
|
-
// components/three/ParticleField.tsx (inside the Canvas)
|
|
374
|
-
"use client";
|
|
375
|
-
import { useMemo } from "react";
|
|
376
|
-
import { useFrame, useThree } from "@react-three/fiber";
|
|
377
|
-
import * as THREE from "three";
|
|
378
|
-
import type { Box } from "./shared";
|
|
379
|
-
|
|
380
|
-
export function ParticleField({ count, pointer, velocity }: {
|
|
381
|
-
count: number; // 4000 high, 1500 mid; low tier gets the poster
|
|
382
|
-
pointer: Box<{ x: number; y: number }>; // -1 to 1, written by a window listener
|
|
383
|
-
velocity: Box<number>;
|
|
384
|
-
}) {
|
|
385
|
-
const dpr = useThree((s) => s.viewport.dpr); // the canvas's real dpr, after tiering
|
|
386
|
-
const positions = useMemo(() => {
|
|
387
|
-
const a = new Float32Array(count * 3);
|
|
388
|
-
for (let i = 0; i < count; i++) {
|
|
389
|
-
a[i * 3] = (Math.random() - 0.5) * 10;
|
|
390
|
-
a[i * 3 + 1] = (Math.random() - 0.5) * 6;
|
|
391
|
-
a[i * 3 + 2] = (Math.random() - 0.5) * 4;
|
|
392
|
-
}
|
|
393
|
-
return a;
|
|
394
|
-
}, [count]);
|
|
395
|
-
|
|
396
|
-
const uniforms = useMemo(() => ({
|
|
397
|
-
uTime: { value: 0 }, uPointer: { value: new THREE.Vector2() }, uStir: { value: 0 },
|
|
398
|
-
uSize: { value: 2.2 },
|
|
399
|
-
uColor: { value: new THREE.Color("#F2F2F0") }, // ink token of the preset
|
|
400
|
-
}), []);
|
|
401
|
-
|
|
402
|
-
useFrame((state, delta) => {
|
|
403
|
-
const dt = Math.min(delta, 1 / 30);
|
|
404
|
-
const { width, height } = state.viewport;
|
|
405
|
-
uniforms.uTime.value += dt;
|
|
406
|
-
uniforms.uSize.value = 2.2 * dpr; // follows PerformanceMonitor's dpr changes
|
|
407
|
-
uniforms.uPointer.value.set(pointer.current.x * width / 2, -pointer.current.y * height / 2);
|
|
408
|
-
uniforms.uStir.value = THREE.MathUtils.damp(uniforms.uStir.value, Math.min(Math.abs(velocity.current) / 3000, 1), 3, dt);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
return (
|
|
412
|
-
<points>
|
|
413
|
-
<bufferGeometry>
|
|
414
|
-
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
|
415
|
-
</bufferGeometry>
|
|
416
|
-
<shaderMaterial
|
|
417
|
-
transparent depthWrite={false} blending={THREE.AdditiveBlending} uniforms={uniforms}
|
|
418
|
-
vertexShader={/* glsl */ `
|
|
419
|
-
uniform float uTime; uniform vec2 uPointer; uniform float uStir; uniform float uSize;
|
|
420
|
-
void main() {
|
|
421
|
-
vec3 p = position;
|
|
422
|
-
p.y += sin(uTime * 0.2 + p.x * 0.7) * (0.08 + uStir * 0.3); // slow drift, faster when scrolled
|
|
423
|
-
vec2 away = p.xy - uPointer;
|
|
424
|
-
p.xy += normalize(away + 1e-5) * 0.6 * (1.0 - smoothstep(0.0, 1.2, length(away))); // pointer pushes aside
|
|
425
|
-
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
426
|
-
gl_PointSize = uSize * (4.0 / -mv.z);
|
|
427
|
-
gl_Position = projectionMatrix * mv;
|
|
428
|
-
}`}
|
|
429
|
-
fragmentShader={/* glsl */ `
|
|
430
|
-
uniform vec3 uColor;
|
|
431
|
-
void main() {
|
|
432
|
-
float d = length(gl_PointCoord - 0.5);
|
|
433
|
-
gl_FragColor = vec4(uColor, (1.0 - smoothstep(0.0, 0.5, d)) * 0.6);
|
|
434
|
-
#include <colorspace_fragment>
|
|
435
|
-
}`}
|
|
436
|
-
/>
|
|
437
|
-
</points>
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
- **Shader rules.** `smoothstep` edges go in ascending order (`edge0 < edge1`); a reversed pair is undefined in GLSL and renders differently per GPU, so a falloff is written `1.0 - smoothstep(0.0, r, d)`. `#include <colorspace_fragment>` after `gl_FragColor` converts the token colour (stored linear by `THREE.Color`) back to sRGB, so the field matches the CSS ink exactly.
|
|
443
|
-
- Colour comes from the preset's ink or accent token, at low alpha. A rainbow field is the screensaver tell.
|
|
444
|
-
- Touch: half the count and no pointer (pass a zero pointer). Low tier: the poster, which is a still frame of the field.
|
|
445
|
-
- **When not to:** behind running text (moving points under copy hurt reading) or anywhere a second WebGL moment already lives.
|
|
446
|
-
|
|
447
|
-
### `ambient-hero-scene`: one subject, lit, graded
|
|
448
|
-
|
|
449
|
-
A restrained hero: one object or one field, lit by an environment, with grain and vignette, reacting to scroll. It should look like a photograph that happens to move.
|
|
450
|
-
|
|
451
|
-
```tsx
|
|
452
|
-
// components/three/AmbientHero.tsx (inside <Canvas frameloop="never" dpr={dpr} ...>; it mounts its own TickerDriver)
|
|
453
|
-
"use client";
|
|
454
|
-
import { useRef, useState } from "react";
|
|
455
|
-
import { useFrame } from "@react-three/fiber";
|
|
456
|
-
import { Environment, Float, Lightformer, Preload } from "@react-three/drei";
|
|
457
|
-
import * as THREE from "three";
|
|
458
|
-
import { Grade } from "./Grade";
|
|
459
|
-
import { TickerDriver, type Box } from "./shared";
|
|
460
|
-
|
|
461
|
-
const damp = THREE.MathUtils.damp;
|
|
462
|
-
|
|
463
|
-
export function AmbientHero({ active, progress, velocity, tier, children }: {
|
|
464
|
-
active: Box<boolean>; progress: Box<number>;
|
|
465
|
-
velocity: Box<number>; // ScrollTrigger getVelocity(), px/s, written by the §0 trigger's onUpdate
|
|
466
|
-
tier: "high" | "mid" | "low"; children: React.ReactNode;
|
|
467
|
-
}) {
|
|
468
|
-
const group = useRef<THREE.Group>(null);
|
|
469
|
-
const [caOffset] = useState(() => new THREE.Vector2(0, 0)); // useState, not useMemo (product-rig)
|
|
470
|
-
|
|
471
|
-
useFrame((state, delta) => {
|
|
472
|
-
const dt = Math.min(delta, 1 / 30);
|
|
473
|
-
const p = progress.current;
|
|
474
|
-
state.camera.position.z = 5 + p * 1.5; // slow pull-out as the hero scrolls away
|
|
475
|
-
group.current!.position.y = p * 0.6;
|
|
476
|
-
// velocity into a tiny chromatic aberration, zero at rest (same as product-rig)
|
|
477
|
-
const target = Math.min(Math.abs(velocity.current) / 4000, 1) * 0.0015;
|
|
478
|
-
const v = damp(caOffset.x, target, 6, dt);
|
|
479
|
-
caOffset.set(v, v);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
return (
|
|
483
|
-
<>
|
|
484
|
-
<TickerDriver active={active} />
|
|
485
|
-
<Environment resolution={256}>
|
|
486
|
-
<Lightformer form="rect" intensity={1.6} position={[2, 3, -2]} scale={[6, 1.5, 1]} />
|
|
487
|
-
<Lightformer form="ring" intensity={0.6} position={[-3, -1, 2]} scale={2} />
|
|
488
|
-
</Environment>
|
|
489
|
-
<group ref={group}>
|
|
490
|
-
{tier === "low" ? children : <Float speed={1} rotationIntensity={0.25} floatIntensity={0.3}>{children}</Float>}
|
|
491
|
-
</group>
|
|
492
|
-
{tier !== "low" && <Grade tier={tier} caOffset={caOffset} />}
|
|
493
|
-
<Preload all />
|
|
494
|
-
</>
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
- `children` is the subject: the brand's mark extruded, or a GLB through `useGLTF`. Not a torus knot, not a blob.
|
|
500
|
-
- The §0 trigger also writes `velocity.current = self.getVelocity()` in its `onUpdate`; without it the CA pass would sit at zero and cost a full-screen draw for nothing. Low tier drops `Float` and the composer: the subject holds still and only scroll moves it.
|
|
501
|
-
- The page surface is the background (`alpha: true` on the canvas, no `Environment background`), so the scene sits inside the palette instead of on a foreign gradient.
|
|
502
|
-
- Real h1 and copy sit in the DOM above the canvas ([typography.md](typography.md) §5 `line-rise-hero`). The scene supports the headline; it never replaces it.
|
|
503
|
-
|
|
504
|
-
## 5. Performance tiers and fallbacks
|
|
505
|
-
|
|
506
|
-
Pick a starting tier before mounting, then let `PerformanceMonitor` move it.
|
|
507
|
-
|
|
508
|
-
| Tier | Who | dpr | Composer | Scene budget |
|
|
509
|
-
|---|---|---|---|---|
|
|
510
|
-
| **high** | Fine pointer, 8+ cores | `[1, 1.75]` ([webgl-r3f.md](webgl-r3f.md) default); `[1, 2]` only for a hard-edged product hero | grain, vignette, velocity CA, bloom if emissive | Transmission allowed, 4000 particles, 2048 textures |
|
|
511
|
-
| **mid** | Laptops, tablets, decline from high | `[1, 1.5]` | grain and vignette only | No transmission (use `meshPhysicalMaterial`), 1500 particles, 1024 textures |
|
|
512
|
-
| **low** | Decline from mid | `1` | none | Static lighting, baked shadows, no `Float` |
|
|
513
|
-
| **poster** | Reduced motion, touch by default, `onFallback` | no canvas | n/a | The poster image is the design |
|
|
514
|
-
|
|
515
|
-
```tsx
|
|
516
|
-
// starting guess, in the island's gate effect (webgl-r3f.md §1)
|
|
517
|
-
const start = navigator.hardwareConcurrency >= 8 ? "high" : "mid";
|
|
518
|
-
|
|
519
|
-
// inside <Canvas frameloop="never" dpr={DPR[tier]}>, next to <TickerDriver active={active} />
|
|
520
|
-
// const DPR: Record<Tier, number | [number, number]> = { high: [1, 1.75], mid: [1, 1.5], low: 1 };
|
|
521
|
-
<PerformanceMonitor
|
|
522
|
-
onDecline={() => setTier((t) => (t === "high" ? "mid" : "low"))}
|
|
523
|
-
onIncline={() => setTier((t) => (t === "low" ? "mid" : t))}
|
|
524
|
-
flipflops={3}
|
|
525
|
-
onFallback={() => setTier("low")}
|
|
526
|
-
/>
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
- **Never climb back to high** after a decline: flip-flopping between tiers is more visible than either tier.
|
|
530
|
-
- **Poster always.** Same framing as the first frame of the scene, so the canvas fades in over an identical image ([webgl-r3f.md](webgl-r3f.md) §1). It is also the LCP candidate: sized, compressed, preloaded ([seo.md](seo.md) §6). How to make it without a DCC tool: "The poster" below.
|
|
531
|
-
- **Offscreen pause.** The §0 driver stops advancing when the section leaves the viewport. A canvas rendering below the fold is battery spent on nothing. The trigger lives on the unpinned outer section, so a pin never pauses the scene mid-shot.
|
|
532
|
-
- **Textures** at 2048 maximum on desktop, 1024 for mobile variants, WebP through gltf-transform ([performance.md](performance.md) §5). Textures, not triangles, are usually the weight.
|
|
533
|
-
- **Mobile.** Default is the poster on touch; when a project opts in, it starts at mid tier with half counts and no pointer writer.
|
|
534
|
-
- **Reduced motion is the poster.** Not a slower scene: no scene ([webgl-r3f.md](webgl-r3f.md) §4).
|
|
535
|
-
|
|
536
|
-
### The poster: rendered from the scene
|
|
537
|
-
|
|
538
|
-
Without a DCC tool, the only honest poster is the scene itself at its first shot. Build a dev-only route that mounts the island at `SHOTS.intro` with `preserveDrawingBuffer`, and capture it with Playwright:
|
|
539
|
-
|
|
540
|
-
```tsx
|
|
541
|
-
// app/render-poster/page.tsx: dev only, a 404 in production
|
|
542
|
-
import { notFound } from "next/navigation";
|
|
543
|
-
import { PosterStage } from "./PosterStage"; // "use client": mounts the island (next/dynamic, ssr: false)
|
|
544
|
-
// at SHOTS.intro with frameloop="always", gl={{ preserveDrawingBuffer: true }}, no composer,
|
|
545
|
-
// inside <div id="stage" style={{ position: "fixed", inset: 0, width: 1600, height: 1000 }}>,
|
|
546
|
-
// and sets document.body.dataset.ready = "1" from the scene's first useFrame.
|
|
547
|
-
|
|
548
|
-
export default function RenderPoster() {
|
|
549
|
-
if (process.env.NODE_ENV === "production") notFound();
|
|
550
|
-
return <PosterStage />;
|
|
551
|
-
}
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
```js
|
|
555
|
-
// scripts/poster.mjs: node scripts/poster.mjs http://localhost:3000 (against `next dev`; the route 404s in production)
|
|
556
|
-
import { chromium } from "playwright";
|
|
557
|
-
|
|
558
|
-
const base = process.argv[2] ?? "http://localhost:3000";
|
|
559
|
-
const browser = await chromium.launch({ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
|
560
|
-
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
|
|
561
|
-
await page.goto(`${base}/render-poster`, { waitUntil: "networkidle" });
|
|
562
|
-
await page.waitForSelector("body[data-ready='1']", { state: "attached", timeout: 60000 });
|
|
563
|
-
await page.waitForTimeout(2000); // environment maps and the baked shadow settle
|
|
564
|
-
await page.locator("#stage").screenshot({ path: "public/media/product-poster.png" });
|
|
565
|
-
await browser.close();
|
|
566
|
-
```
|
|
567
|
-
|
|
568
|
-
- Then compress it like any hero image ([performance.md](performance.md) §4).
|
|
569
|
-
- **Same framing holds at one aspect only.** The poster sits under a canvas whose aspect follows its column. Render the stage at the column's typical aspect (the 1600 x 1000 above is a 16:10 column), show it with `object-cover` at the same anchor the camera centres on, and crossfade the canvas in over it rather than cutting, so a small aspect mismatch reads as a settle, not a jump.
|
|
570
|
-
- This is the poster for a stand-in (product-rig, "No model file yet") too: what the visitor sees before the canvas is what the canvas shows.
|
|
571
|
-
|
|
572
|
-
## 6. Real copy stays in the DOM
|
|
573
|
-
|
|
574
|
-
Text inside a canvas is invisible to search engines, screen readers, translation and text selection. Every word a visitor must read is DOM text, positioned over or beside the canvas; drei `Text` is for decorative 3D type only, and its words also exist in the DOM. The canvas wrapper is `aria-hidden`. Details: [seo.md](seo.md) §6.
|
|
575
|
-
|
|
576
|
-
## 7. Tells
|
|
577
|
-
|
|
578
|
-
| Tell | Replace with |
|
|
579
|
-
|---|---|
|
|
580
|
-
| Spinning torus knot, floating blob or chrome sphere as the hero | One subject that belongs to the brand: its product, its mark, its material |
|
|
581
|
-
| `ScrollControls` on a Lenis page | ScrollTrigger into refs (§0) |
|
|
582
|
-
| Bloom on a scene with nothing emissive | Grain and vignette |
|
|
583
|
-
| Permanent chromatic aberration | CA from scroll velocity, zero at rest |
|
|
584
|
-
| `Environment background` with a stock HDR behind the subject | The page surface as background, lightformers for reflections |
|
|
585
|
-
| Photos through ACES tone mapping, looking grey | `flat` on image canvases |
|
|
586
|
-
| A column canvas that reads as a dark box (vignette, tone-mapped background) | `fullBleed={false}`, `flat`, the page surface as background (§2) |
|
|
587
|
-
| `caOffset.x = ...` or a `useMemo` object mutated from a prop | `useState(() => new X())` at the owner, `.set()` in `useFrame` |
|
|
588
|
-
| A primitive "product" passed off as the real thing | A declared stand-in in the palette, labelled in ASSUMPTIONS, or a downgrade (§4, "No model file yet") |
|
|
589
|
-
| Scroll and pointer writing the same rotation | Nested groups: scroll outside, pointer inside |
|
|
590
|
-
| A canvas still rendering two screens below | Offscreen pause |
|
|
591
|
-
| Transmission glass on a phone at 20fps | Tiered: physical material on mid, poster on low |
|
|
592
|
-
| Headline set in 3D text only | DOM headline; 3D type as decoration |
|
|
593
|
-
| A second canvas for a second section | One canvas with `View`, or cut one |
|
|
1
|
+
# WebGL Scenes: Drei, postprocessing and interactive 3D
|
|
2
|
+
|
|
3
|
+
**Read [webgl-r3f.md](webgl-r3f.md) first.** It owns the integration and this file never contradicts it: a lazy, SSR-free island behind a real poster; one canvas per page; `frameloop="never"` driven by `gsap.ticker` (or `"demand"` plus `invalidate()` for reactive-only scenes); scroll written into refs and uniforms, never React state. This file is what goes **inside** that canvas: Drei helpers, the postprocessing stack, the four 3D recipes from [showcase.md](showcase.md), performance tiers and the tells.
|
|
4
|
+
|
|
5
|
+
For a whole page that behaves as one continuous 3D film, switch to Directed mode: [../cinematic/README.md](../cinematic/README.md).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i three @react-three/fiber @react-three/drei @react-three/postprocessing postprocessing
|
|
9
|
+
npm i -D @types/three
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`postprocessing` is listed explicitly because the samples import `BlendFunction` and `ToneMappingMode` from it.
|
|
13
|
+
|
|
14
|
+
## 0. The architecture every recipe shares
|
|
15
|
+
|
|
16
|
+
**The DOM side owns time and scroll; the canvas side only reads.** The section component creates every ScrollTrigger inside `useGSAP` (scoped, reduced-motion aware, cleaned up on unmount) and writes plain numbers into refs. The scene reads those refs in `useFrame`. Nothing crosses the boundary as React state.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// components/three/shared.ts
|
|
20
|
+
"use client";
|
|
21
|
+
import { useEffect } from "react";
|
|
22
|
+
import { useThree } from "@react-three/fiber";
|
|
23
|
+
import { gsap } from "@/lib/gsap";
|
|
24
|
+
|
|
25
|
+
export type Box<T> = { current: T };
|
|
26
|
+
|
|
27
|
+
/** webgl-r3f.md §2 driver, plus an offscreen pause: no frames while the section is out of view.
|
|
28
|
+
* Replaces webgl-r3f.md's GsapTickerDriver; never mount both. */
|
|
29
|
+
export function TickerDriver({ active }: { active: Box<boolean> }) {
|
|
30
|
+
const advance = useThree((s) => s.advance);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const tick = (time: number) => { if (active.current) advance(time); };
|
|
33
|
+
gsap.ticker.add(tick);
|
|
34
|
+
return () => gsap.ticker.remove(tick);
|
|
35
|
+
}, [advance, active]);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// the section side, used by every recipe below
|
|
42
|
+
const outer = useRef<HTMLElement>(null); // <section ref={outer}>: never pinned, so it holds the pin-spacer
|
|
43
|
+
const wrap = useRef<HTMLDivElement>(null); // <div ref={wrap}> inside it: the element a recipe may pin
|
|
44
|
+
const active = useRef(false);
|
|
45
|
+
const progress = useRef(0);
|
|
46
|
+
|
|
47
|
+
useGSAP(() => {
|
|
48
|
+
const mm = gsap.matchMedia();
|
|
49
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
50
|
+
ScrollTrigger.create({
|
|
51
|
+
trigger: outer.current,
|
|
52
|
+
start: "top bottom",
|
|
53
|
+
end: "bottom top",
|
|
54
|
+
refreshPriority: -1, // measure after any pin in the section
|
|
55
|
+
onToggle: (self) => { active.current = self.isActive; }, // offscreen pause
|
|
56
|
+
onUpdate: (self) => { progress.current = self.progress; }, // scroll into a ref
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}, { scope: outer });
|
|
60
|
+
|
|
61
|
+
// the island, inside the section (lazy, behind the poster: webgl-r3f.md §1)
|
|
62
|
+
<Canvas frameloop="never" dpr={dpr} gl={{ alpha: true }}>
|
|
63
|
+
<TickerDriver active={active} />
|
|
64
|
+
{/* the recipe's scene */}
|
|
65
|
+
</Canvas>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **Every canvas** in this file is `frameloop="never"` with exactly one `<TickerDriver active={active} />` inside it. `AmbientHero` (§4) mounts its own, so its canvas does not add another.
|
|
69
|
+
- **Why the outer trigger.** When a recipe pins `wrap` (product-rig), ScrollTrigger wraps it in a pin-spacer as tall as the pin. A trigger on `wrap` itself is measured against the unpinned box and toggles `active` off halfway through the pin, freezing the scene mid-shot. The `outer` section contains the spacer, so its trigger spans the whole pinned distance; `refreshPriority: -1` makes it refresh after the pin has added its spacing.
|
|
70
|
+
|
|
71
|
+
When the driver resumes after a pause, the first `delta` covers the whole time away. Clamp it in any damping code: `const dt = Math.min(delta, 1 / 30)`.
|
|
72
|
+
|
|
73
|
+
**Many Drei helpers animate in `useFrame`** (`Float`, `MeshTransmissionMaterial`'s temporal distortion, `ContactShadows` unless baked, `PerformanceMonitor`). They need frames, so a scene that uses them runs `frameloop="never"` with the driver. A purely scroll-reactive scene without them can use `"demand"` and `invalidate()` from `onUpdate`.
|
|
74
|
+
|
|
75
|
+
## 1. Drei on a marketing site
|
|
76
|
+
|
|
77
|
+
| Helper | Use it for | Notes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `Environment` | Image-based lighting: believable reflections on metal, glass, satin | `preset="studio"` (also `city`, `warehouse`, `sunset`, `dawn`, `night`, `forest`, `apartment`, `lobby`, `park`) fetches an HDR from a CDN at runtime; in production self-host it: `files="/hdr/studio.hdr"`. `background` shows it; leave it off and let the page surface be the background. |
|
|
80
|
+
| `Lightformer` | Art-directed reflections: soft rectangles and rings you place like studio softboxes | Children of `<Environment>`; props `form` (`"rect"`, `"circle"`, `"ring"`), `intensity`, `color`, plus position, rotation, scale. Controlled highlights are what make a product look shot rather than rendered. |
|
|
81
|
+
| `Float` | A slow ambient drift on a subject | `speed`, `rotationIntensity`, `floatIntensity`, `floatingRange`. Keep them low (speed 1, intensities 0.2 to 0.5): it is breath, not bobbing. |
|
|
82
|
+
| `MeshTransmissionMaterial` | Glass, crystal, liquid | `thickness`, `roughness`, `ior`, `chromaticAberration`, `distortion`, `distortionScale`, `temporalDistortion`, `samples`, `resolution`, `backside`. It renders the scene again into a buffer every frame (twice with `backside`), so it is high tier only: `samples` 4 to 6, `resolution` 512 to 1024. |
|
|
83
|
+
| `MeshDistortMaterial` | A surface that wobbles | `distort`, `speed`. This is the floating-blob material: only with a reason, low `distort`, never purple. |
|
|
84
|
+
| `Text` | 3D type that has to live in the scene (on a surface, in depth) | `font` takes a URL to the display face file (`.ttf` or `.woff`), plus `fontSize`, `anchorX`, `anchorY`, `letterSpacing`, `lineHeight`, `maxWidth`, `color`. Decoration only: the same words exist in the DOM (§6). |
|
|
85
|
+
| `useGLTF` | Loading a model | `useGLTF(url)` has Draco and meshopt on by default (signature `useGLTF(url, useDraco = true, useMeshopt = true)`). The Draco decoder is fetched from gstatic only when a file is actually Draco-compressed; a meshopt or plain GLB never touches it. Self-host the decoder once, at module level in the island: `useGLTF.setDecoderPath("/draco/")` (copy `node_modules/three/examples/jsm/libs/draco/gltf/` to `public/draco/`). `useGLTF.preload(url)` buys nothing inside the lazy island (it runs only when the island chunk loads), and importing drei into the section to call it earlier pulls three into the page bundle; the poster covers the wait ([performance.md](performance.md) §5). |
|
|
86
|
+
| `ContactShadows` | A soft grounding shadow under a subject | `opacity`, `scale`, `blur`, `far`, `resolution`, `color`, `frames`. `frames={1}` bakes it once: free afterwards, but only while the subject's height, scale and tilt never change. If a shot lifts, scales or tilts the subject, drop `frames` on high tier (a live shadow) and keep those values constant on mid. |
|
|
87
|
+
| `Center` | Centring a model whose origin is wrong | Wrap the model. Cheaper than fixing every export. |
|
|
88
|
+
| `Bounds` | Fitting the camera to a model of unknown size | `fit`, `clip`, `observe`, `margin`. It moves the camera, so never combine it with a scroll or shot-driven camera. Good for a static product view; the named shots (§3) replace it otherwise. |
|
|
89
|
+
| `View` | Several small scenes through one canvas, each tracking a DOM box | Keeps the one-canvas budget when two sections need 3D. Mount one `<View.Port />` inside a single fixed `<Canvas eventSource={...}>`. Every view still costs a render: two or three, not twelve. |
|
|
90
|
+
| `PerformanceMonitor` | Live tiering | `onDecline`, `onIncline`, `onChange` (with a `factor` 0 to 1), `onFallback`, `flipflops`. Drives the tier in §5. Setting state here is fine: it fires a handful of times, not per frame. |
|
|
91
|
+
| `AdaptiveDpr` | Dropping resolution during regressions | Works only when something calls R3F's `performance.regress()` and the canvas sets `performance={{ min: 0.5 }}`. On a marketing scene that is rarely true; prefer `PerformanceMonitor` setting `dpr` directly. |
|
|
92
|
+
| `Preload` | Uploading textures and compiling shaders before first view | `<Preload all />` inside the canvas. Removes the hitch the first time a hidden object appears. |
|
|
93
|
+
|
|
94
|
+
### Why not `ScrollControls`
|
|
95
|
+
|
|
96
|
+
Drei's `ScrollControls` creates its own scroll container over the canvas with its own damping. On a Struq page that is a second scroll source fighting Lenis (double smoothing, two velocities), ScrollTrigger stops seeing the scroll it was built for, and its HTML layer moves real content into a container search engines and screen readers handle badly. The page already has one scroll and one clock: ScrollTrigger reads Lenis, writes refs, the scene reads refs (§0). Use that.
|
|
97
|
+
|
|
98
|
+
## 2. Postprocessing, with restraint
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// components/three/Grade.tsx
|
|
102
|
+
import { EffectComposer, Noise, Vignette, ChromaticAberration, ToneMapping } from "@react-three/postprocessing";
|
|
103
|
+
import { BlendFunction, ToneMappingMode } from "postprocessing";
|
|
104
|
+
import * as THREE from "three";
|
|
105
|
+
|
|
106
|
+
export function Grade({ tier, caOffset, fullBleed = true }: {
|
|
107
|
+
tier: "high" | "mid";
|
|
108
|
+
caOffset: THREE.Vector2; // owned by the canvas component with useState (§4, product-rig)
|
|
109
|
+
fullBleed?: boolean; // false when the canvas sits in a column, not the whole viewport
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
|
|
113
|
+
{/* Only when the subject has emissive parts (rule below); add Bloom to the import:
|
|
114
|
+
<Bloom mipmapBlur luminanceThreshold={0.9} luminanceSmoothing={0.2} intensity={0.6} /> */}
|
|
115
|
+
<Noise opacity={0.035} blendFunction={BlendFunction.OVERLAY} />
|
|
116
|
+
{fullBleed ? <Vignette offset={0.3} darkness={0.55} /> : <></>}
|
|
117
|
+
{tier === "high" ? <ChromaticAberration offset={caOffset} radialModulation modulationOffset={0.3} /> : <></>}
|
|
118
|
+
{fullBleed ? <ToneMapping mode={ToneMappingMode.ACES_FILMIC} /> : <></>}
|
|
119
|
+
</EffectComposer>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**The restraint rule:**
|
|
125
|
+
|
|
126
|
+
- **Default stack: grain and vignette.** `Noise` at 0.03 to 0.05 opacity with an overlay blend ties the 3D to the photographic grade of the page ([imagery.md](imagery.md)); `Vignette` focuses the frame. That is the whole look most of the time.
|
|
127
|
+
- **Vignette and tone mapping are for full-bleed canvases only.** On a canvas that fills one column of a split layout, the vignette draws a visible dark rectangle and the ACES pass (plus R3F's default renderer tone mapping) shifts the background off the page colour, so the canvas reads as a box. There: `fullBleed={false}` (grain and CA only), `<Canvas flat>`, and the page surface as the background (transparent canvas, or a `<color attach="background">` set to the exact surface token, checked by eye against the DOM next to it).
|
|
128
|
+
- **`Bloom` only with emissive subjects.** `luminanceThreshold` around 0.9, `luminanceSmoothing` 0.1 to 0.3, `intensity` 0.4 to 1, `mipmapBlur`. Bloom on a non-emissive scene washes everything into a haze; bloom on everything is the 2019 crypto look.
|
|
129
|
+
- **`ChromaticAberration`: tiny, and tied to scroll velocity.** At rest the offset is zero. Pass one `THREE.Vector2` as `offset` (owned with `useState(() => new THREE.Vector2())`) and update it with `.set()` from velocity in `useFrame` (§4, product-rig); a static offset is just blur on the edges.
|
|
130
|
+
- **`DepthOfField`** (`focusDistance`, `focalLength`, `bokehScale`) is expensive and fights text legibility. Use it for one macro shot, never as an always-on look.
|
|
131
|
+
- **`ToneMapping` last.** With a composer, put the tone-mapping pass at the end so the grade is applied once. If colours look washed out or double-graded, compare with `<Canvas flat>` (no renderer tone mapping) and keep only one.
|
|
132
|
+
- **Composer off on low tier.** Every pass is a full-screen draw. Below high tier, drop CA; on low tier, no composer at all, the scene renders straight.
|
|
133
|
+
|
|
134
|
+
## 3. Shots and scroll
|
|
135
|
+
|
|
136
|
+
Never scatter raw camera numbers across `useFrame` calls. Name the poses, then let one scrubbed GSAP timeline move a plain object between them; the scene copies that object into transforms each frame. Directed mode formalises this as shot presets ([../cinematic/README.md](../cinematic/README.md)); on a site with one 3D section, three or four named shots are enough.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// components/three/shots.ts
|
|
140
|
+
// y, s and rotX stay constant so the baked ContactShadows (frames={1}) stays true;
|
|
141
|
+
// the camera dolly (camZ) and the turn (rotY) carry the shots.
|
|
142
|
+
export const SHOTS = {
|
|
143
|
+
intro: { rotY: -0.5, rotX: 0.1, y: 0, s: 1, camZ: 6.0 },
|
|
144
|
+
side: { rotY: 1.2, rotX: 0.1, y: 0, s: 1, camZ: 5.2 },
|
|
145
|
+
detail: { rotY: 2.4, rotX: 0.1, y: 0, s: 1, camZ: 4.0 },
|
|
146
|
+
} as const;
|
|
147
|
+
export type Shot = { rotY: number; rotX: number; y: number; s: number; camZ: number };
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
GSAP tweens plain objects, so the timeline writes numbers and the scene never re-renders.
|
|
151
|
+
|
|
152
|
+
## 4. Recipes
|
|
153
|
+
|
|
154
|
+
### `product-rig`: scroll turns it, the pointer tilts it
|
|
155
|
+
|
|
156
|
+
The rule that makes it feel solid: **the scroll writer and the pointer writer never share a transform.** If both write `rotation.y`, the last write wins each frame and the object jitters between two opinions. Nest them: the outer group belongs to scroll, the inner group to the pointer, the model sits inside both.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// components/three/ProductRig.tsx (inside the Canvas)
|
|
160
|
+
"use client";
|
|
161
|
+
import { useEffect, useRef } from "react";
|
|
162
|
+
import { useFrame } from "@react-three/fiber";
|
|
163
|
+
import { Center, ContactShadows, Environment, Lightformer, useGLTF } from "@react-three/drei";
|
|
164
|
+
import * as THREE from "three";
|
|
165
|
+
import type { Box } from "./shared";
|
|
166
|
+
import type { Shot } from "./shots";
|
|
167
|
+
|
|
168
|
+
const damp = THREE.MathUtils.damp;
|
|
169
|
+
|
|
170
|
+
export function ProductRig({ rig, velocity, caOffset, tilt }: {
|
|
171
|
+
rig: Box<Shot>; // written by the section's scrubbed timeline
|
|
172
|
+
velocity: Box<number>; // ScrollTrigger getVelocity(), px/s
|
|
173
|
+
caOffset: THREE.Vector2; // the vector passed to ChromaticAberration
|
|
174
|
+
tilt: boolean; // false on touch: scroll only
|
|
175
|
+
}) {
|
|
176
|
+
const scrollGroup = useRef<THREE.Group>(null);
|
|
177
|
+
const pointerGroup = useRef<THREE.Group>(null);
|
|
178
|
+
const pointer = useRef({ x: 0, y: 0 });
|
|
179
|
+
const { scene } = useGLTF("/models/product.glb");
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!tilt) return;
|
|
183
|
+
const onMove = (e: PointerEvent) => {
|
|
184
|
+
pointer.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
185
|
+
pointer.current.y = (e.clientY / window.innerHeight) * 2 - 1;
|
|
186
|
+
};
|
|
187
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
188
|
+
return () => window.removeEventListener("pointermove", onMove);
|
|
189
|
+
}, [tilt]);
|
|
190
|
+
|
|
191
|
+
useFrame((state, delta) => {
|
|
192
|
+
const dt = Math.min(delta, 1 / 30);
|
|
193
|
+
const s = rig.current, g = scrollGroup.current!, p = pointerGroup.current!;
|
|
194
|
+
// scroll writer: outer group and camera only
|
|
195
|
+
g.rotation.set(s.rotX, s.rotY, 0);
|
|
196
|
+
g.position.y = s.y;
|
|
197
|
+
g.scale.setScalar(s.s);
|
|
198
|
+
state.camera.position.z = s.camZ;
|
|
199
|
+
// pointer writer: inner group only, damped, small
|
|
200
|
+
p.rotation.y = damp(p.rotation.y, pointer.current.x * 0.25, 4, dt);
|
|
201
|
+
p.rotation.x = damp(p.rotation.x, pointer.current.y * 0.15, 4, dt);
|
|
202
|
+
// velocity into a tiny chromatic aberration, zero at rest. Mutate through .set(): assigning
|
|
203
|
+
// to a prop's fields is a React Compiler lint error (react-hooks/immutability).
|
|
204
|
+
const target = Math.min(Math.abs(velocity.current) / 4000, 1) * 0.0015;
|
|
205
|
+
const v = damp(caOffset.x, target, 6, dt);
|
|
206
|
+
caOffset.set(v, v);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<Environment resolution={256}>
|
|
212
|
+
<Lightformer form="rect" intensity={2} position={[0, 4, -3]} scale={[8, 2, 1]} />
|
|
213
|
+
<Lightformer form="rect" intensity={0.8} position={[-4, 0, 2]} rotation={[0, Math.PI / 2, 0]} scale={[4, 4, 1]} />
|
|
214
|
+
</Environment>
|
|
215
|
+
<group ref={scrollGroup}>
|
|
216
|
+
<group ref={pointerGroup}>
|
|
217
|
+
<Center><primitive object={scene} /></Center>
|
|
218
|
+
</group>
|
|
219
|
+
</group>
|
|
220
|
+
<ContactShadows position={[0, -1.2, 0]} opacity={0.35} blur={2.5} scale={6} far={2} frames={1} />
|
|
221
|
+
</>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
useGLTF.setDecoderPath("/draco/"); // self-hosted; fetched only if the GLB is Draco-compressed
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The canvas component owns the vector and hands the same instance to the rig and to `Grade`:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
// in the island component that renders <Canvas>: one Vector2 for the life of the canvas.
|
|
231
|
+
// useState(() => ...), not useMemo: the React Compiler lint rejects mutating a memo value
|
|
232
|
+
// that is passed on as a prop, and useState's lazy initialiser runs exactly once.
|
|
233
|
+
const [caOffset] = useState(() => new THREE.Vector2(0, 0));
|
|
234
|
+
// <ProductRig rig={rig} velocity={velocity} caOffset={caOffset} tilt={tilt} />
|
|
235
|
+
// <Grade tier={tier} caOffset={caOffset} fullBleed={false} />
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The section side builds the shot timeline and velocity inside the §0 `useGSAP` branch. It pins `wrap`; the `active` trigger stays on `outer` (§0). Use **one `GATE` string** for the island's import gate ([webgl-r3f.md](webgl-r3f.md) §1) and for this `mm.add`, so the canvas and the pin can never disagree on a tablet with a fine pointer:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// shared by the island gate and the pin: one query, never two
|
|
242
|
+
export const GATE = "(prefers-reduced-motion: no-preference) and (pointer: fine) and (min-width: 1024px)";
|
|
243
|
+
|
|
244
|
+
const rig = useRef<Shot>({ ...SHOTS.intro });
|
|
245
|
+
const velocity = useRef(0);
|
|
246
|
+
// inside mm.add(GATE, ...), alongside the §0 active trigger:
|
|
247
|
+
gsap.timeline({
|
|
248
|
+
defaults: { ease: "none" },
|
|
249
|
+
scrollTrigger: {
|
|
250
|
+
trigger: wrap.current, start: "top top", end: "+=250%", pin: true, scrub: 1,
|
|
251
|
+
onUpdate: (self) => { velocity.current = self.getVelocity(); },
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
.to(rig.current, { ...SHOTS.side })
|
|
255
|
+
.to(rig.current, { ...SHOTS.detail });
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
- `ContactShadows` with `frames={1}` is baked once, which holds only because every shot keeps `y`, `s` and `rotX` constant: the scroll group turns the product, the camera dollies, the footprint stays put. A shot that lifts, scales or tilts the subject needs a live shadow: drop `frames` on high tier and keep the mid-tier shots flat.
|
|
259
|
+
- Pin rules (and `anticipatePin` with smooth scroll): [pinning-scrub.md](pinning-scrub.md).
|
|
260
|
+
- Copy for each shot (the feature it shows) is DOM text stepping beside the canvas, not 3D text.
|
|
261
|
+
- Touch: `tilt={false}`, shorter pin. Below the performance floor: the poster, which is a render of `SHOTS.intro` captured from this scene (§5, "The poster").
|
|
262
|
+
- In a split layout the canvas fills one column, not the viewport: pass `fullBleed={false}` to `Grade` and set `flat` on the `<Canvas>` (§2), or the vignette draws a dark box and the tone curve pushes the page's background to a different colour.
|
|
263
|
+
|
|
264
|
+
#### No model file yet
|
|
265
|
+
|
|
266
|
+
The client wants a 3D moment and there is no GLB or CAD. Walk this ladder in order and record the rung under ASSUMPTIONS in STRUQ.md:
|
|
267
|
+
|
|
268
|
+
1. **Ask at the gate.** The materials request in the gate message names the file: "a GLB or STEP/CAD export of the product, any polycount". Plan the section as if it will arrive.
|
|
269
|
+
2. **A declared stand-in.** Build the subject from primitives (cylinders, tubes, boxes, `ExtrudeGeometry` from the brand's SVG) as a declared art direction: clay, wire, blueprint or one flat material in the palette, never a fake photoreal product. It lives in one component (`<StandIn />`) mounted where `<primitive object={scene} />` would go, behind the same `Center` and the same `SHOTS`, so the real model drops in by swapping one line. Write it under ASSUMPTIONS: "Product: primitive stand-in, replace with the GLB at `/models/product.glb`".
|
|
270
|
+
3. **Downgrade.** When a stand-in would read as a toy or misrepresent the product: `ambient-hero-scene` with the brand's extruded mark, photography of the real product ([imagery.md](imagery.md)), or a type-led showpiece ([showcase.md](showcase.md)). Say which, and why, in the LOG.
|
|
271
|
+
|
|
272
|
+
Stock or generated images never stand in for the product itself ([imagery.md](imagery.md) §4). The poster for rungs 2 and 3 is rendered from the scene (§5), never drawn by hand or taken from stock.
|
|
273
|
+
|
|
274
|
+
### `hover-distortion-gallery`: a plane per image, a hover uniform
|
|
275
|
+
|
|
276
|
+
Real `<img>` elements stay in the DOM for layout, alt text, SEO and the touch fallback. One canvas fills the gallery section; each image gets a plane at the image's exact box; the DOM image fades out once its texture is on screen. With an orthographic camera at `zoom: 1`, one world unit is one CSS pixel, so DOM boxes map straight onto planes.
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// components/three/DistortGallery.tsx
|
|
280
|
+
// inside <Canvas frameloop="never" orthographic flat camera={{ zoom: 1, position: [0, 0, 100] }}>
|
|
281
|
+
// <TickerDriver active={active} /> <Suspense fallback={null}><DistortGallery ... /></Suspense>
|
|
282
|
+
"use client";
|
|
283
|
+
import { useMemo, useRef } from "react";
|
|
284
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
285
|
+
import { useTexture } from "@react-three/drei";
|
|
286
|
+
import * as THREE from "three";
|
|
287
|
+
import type { Box } from "./shared";
|
|
288
|
+
|
|
289
|
+
export type Slot = { x: number; y: number; w: number; h: number }; // px, relative to the section
|
|
290
|
+
export type HoverState = { v: number; mx: number; my: number }; // tweened by the DOM side
|
|
291
|
+
|
|
292
|
+
const vert = /* glsl */ `
|
|
293
|
+
varying vec2 vUv;
|
|
294
|
+
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
|
|
295
|
+
|
|
296
|
+
const frag = /* glsl */ `
|
|
297
|
+
uniform sampler2D uTex; uniform vec2 uPlane; uniform vec2 uImage;
|
|
298
|
+
uniform float uHover; uniform vec2 uMouse; uniform float uTime;
|
|
299
|
+
varying vec2 vUv;
|
|
300
|
+
vec2 cover(vec2 uv) { // object-fit: cover
|
|
301
|
+
float rp = uPlane.x / uPlane.y, ri = uImage.x / uImage.y;
|
|
302
|
+
vec2 s = rp > ri ? vec2(1.0, ri / rp) : vec2(rp / ri, 1.0);
|
|
303
|
+
return (uv - 0.5) * s + 0.5;
|
|
304
|
+
}
|
|
305
|
+
void main() {
|
|
306
|
+
vec2 p = (vUv - uMouse) * vec2(uPlane.x / uPlane.y, 1.0); // aspect-correct distance
|
|
307
|
+
float d = length(p);
|
|
308
|
+
float ripple = sin(d * 26.0 - uTime * 3.0) * 0.01 * uHover * (1.0 - smoothstep(0.0, 0.5, d));
|
|
309
|
+
vec2 uv = cover(vUv + normalize(p + 1e-5) * ripple);
|
|
310
|
+
uv = (uv - 0.5) * (1.0 - 0.04 * uHover) + 0.5; // slight push-in on hover
|
|
311
|
+
gl_FragColor = texture2D(uTex, uv);
|
|
312
|
+
#include <colorspace_fragment>
|
|
313
|
+
}`;
|
|
314
|
+
|
|
315
|
+
function Plane({ url, slot, hover }: { url: string; slot: Slot; hover: HoverState }) {
|
|
316
|
+
const tex = useTexture(url);
|
|
317
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
318
|
+
const size = useThree((s) => s.size);
|
|
319
|
+
const img = tex.image as HTMLImageElement;
|
|
320
|
+
const uniforms = useMemo(() => ({
|
|
321
|
+
uTex: { value: tex }, uTime: { value: 0 }, uHover: { value: 0 },
|
|
322
|
+
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
|
|
323
|
+
uPlane: { value: new THREE.Vector2(1, 1) },
|
|
324
|
+
uImage: { value: new THREE.Vector2(img.width, img.height) },
|
|
325
|
+
}), [tex, img]);
|
|
326
|
+
|
|
327
|
+
useFrame((_, delta) => {
|
|
328
|
+
uniforms.uPlane.value.set(slot.w, slot.h); // mutate; never swap the uniforms object
|
|
329
|
+
uniforms.uTime.value += Math.min(delta, 1 / 30);
|
|
330
|
+
uniforms.uHover.value = hover.v;
|
|
331
|
+
uniforms.uMouse.value.set(hover.mx, hover.my);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<mesh position={[slot.x + slot.w / 2 - size.width / 2, size.height / 2 - (slot.y + slot.h / 2), 0]}
|
|
336
|
+
scale={[slot.w, slot.h, 1]}>
|
|
337
|
+
<planeGeometry />
|
|
338
|
+
<shaderMaterial vertexShader={vert} fragmentShader={frag} uniforms={uniforms} />
|
|
339
|
+
</mesh>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function DistortGallery({ urls, slots, hovers, onReady }: {
|
|
344
|
+
urls: string[]; slots: Slot[]; hovers: Box<HoverState[]>; onReady: () => void;
|
|
345
|
+
}) {
|
|
346
|
+
const ready = useRef(false);
|
|
347
|
+
useFrame(() => { // first frame after every texture resolved (Suspense): planes are on screen
|
|
348
|
+
if (ready.current) return;
|
|
349
|
+
ready.current = true;
|
|
350
|
+
onReady();
|
|
351
|
+
});
|
|
352
|
+
return <>{urls.map((u, i) => <Plane key={u} url={u} slot={slots[i]} hover={hovers.current[i]} />)}</>;
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- **Uniforms are bound once.** three uploads the `uniforms` object to the program when the material compiles; a new object from a re-run `useMemo` is never seen by the shader, so the plane would keep its first size. The memo depends only on `[tex, img]`, and size-dependent values (`uPlane`) are mutated in `useFrame`.
|
|
357
|
+
|
|
358
|
+
The section side:
|
|
359
|
+
|
|
360
|
+
- Wrap `DistortGallery` in `<Suspense fallback={null}>` inside the canvas. Until textures resolve, the DOM images are what people see. `onReady` fires from the first rendered frame (a ref flag, so once), not from an effect: an effect runs on commit, before anything has been drawn, and the DOM images would fade over an empty canvas. It sets `data-gl-ready` on the section and CSS fades the `<img>` elements to `opacity: 0`.
|
|
361
|
+
- **Create the hover states before the canvas mounts,** one per url: `const hovers = useRef<HoverState[]>(urls.map(() => ({ v: 0, mx: 0.5, my: 0.5 })))`. Each `Plane` reads its object by index on first render; an empty array there is an `undefined` hover and a crash in `useFrame`. If `urls` can change, rebuild the array in the same render that changes them.
|
|
362
|
+
- Measure slots with one `ResizeObserver` on the section (rects relative to the section's own box). Store them in state: resize is rare, so a React render there is fine.
|
|
363
|
+
- Hover handlers on each DOM figure, built with `contextSafe` from `useGSAP`: `gsap.to(hovers.current[i], { v: 1, duration: 0.6, ease: "expo.out" })` on enter, `v: 0` with `power2.out` on leave, and `pointermove` writes `mx` and `my` (0 to 1 within the figure, y flipped). GSAP tweens the plain object; the plane reads it. No state per frame.
|
|
364
|
+
- `flat` on the canvas: photographs must not be tone mapped, and `colorspace_fragment` returns the sRGB texture to the screen's colour space untouched.
|
|
365
|
+
- Touch and reduced motion: the canvas never mounts (the [webgl-r3f.md](webgl-r3f.md) gate), the DOM images are the gallery. Texture size: 1600px on the long edge is plenty for a grid tile.
|
|
366
|
+
- **When not to:** fewer than four strong images, or a gallery that is also the page's showpiece pin. Distortion is a garnish on images worth looking at.
|
|
367
|
+
|
|
368
|
+
### `particle-field`: a small point field
|
|
369
|
+
|
|
370
|
+
A few thousand points drifting in a shader, pushed aside by the pointer, stirred by scroll velocity. One breather moment, not a background for the whole site.
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
// components/three/ParticleField.tsx (inside the Canvas)
|
|
374
|
+
"use client";
|
|
375
|
+
import { useMemo } from "react";
|
|
376
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
377
|
+
import * as THREE from "three";
|
|
378
|
+
import type { Box } from "./shared";
|
|
379
|
+
|
|
380
|
+
export function ParticleField({ count, pointer, velocity }: {
|
|
381
|
+
count: number; // 4000 high, 1500 mid; low tier gets the poster
|
|
382
|
+
pointer: Box<{ x: number; y: number }>; // -1 to 1, written by a window listener
|
|
383
|
+
velocity: Box<number>;
|
|
384
|
+
}) {
|
|
385
|
+
const dpr = useThree((s) => s.viewport.dpr); // the canvas's real dpr, after tiering
|
|
386
|
+
const positions = useMemo(() => {
|
|
387
|
+
const a = new Float32Array(count * 3);
|
|
388
|
+
for (let i = 0; i < count; i++) {
|
|
389
|
+
a[i * 3] = (Math.random() - 0.5) * 10;
|
|
390
|
+
a[i * 3 + 1] = (Math.random() - 0.5) * 6;
|
|
391
|
+
a[i * 3 + 2] = (Math.random() - 0.5) * 4;
|
|
392
|
+
}
|
|
393
|
+
return a;
|
|
394
|
+
}, [count]);
|
|
395
|
+
|
|
396
|
+
const uniforms = useMemo(() => ({
|
|
397
|
+
uTime: { value: 0 }, uPointer: { value: new THREE.Vector2() }, uStir: { value: 0 },
|
|
398
|
+
uSize: { value: 2.2 },
|
|
399
|
+
uColor: { value: new THREE.Color("#F2F2F0") }, // ink token of the preset
|
|
400
|
+
}), []);
|
|
401
|
+
|
|
402
|
+
useFrame((state, delta) => {
|
|
403
|
+
const dt = Math.min(delta, 1 / 30);
|
|
404
|
+
const { width, height } = state.viewport;
|
|
405
|
+
uniforms.uTime.value += dt;
|
|
406
|
+
uniforms.uSize.value = 2.2 * dpr; // follows PerformanceMonitor's dpr changes
|
|
407
|
+
uniforms.uPointer.value.set(pointer.current.x * width / 2, -pointer.current.y * height / 2);
|
|
408
|
+
uniforms.uStir.value = THREE.MathUtils.damp(uniforms.uStir.value, Math.min(Math.abs(velocity.current) / 3000, 1), 3, dt);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<points>
|
|
413
|
+
<bufferGeometry>
|
|
414
|
+
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
|
415
|
+
</bufferGeometry>
|
|
416
|
+
<shaderMaterial
|
|
417
|
+
transparent depthWrite={false} blending={THREE.AdditiveBlending} uniforms={uniforms}
|
|
418
|
+
vertexShader={/* glsl */ `
|
|
419
|
+
uniform float uTime; uniform vec2 uPointer; uniform float uStir; uniform float uSize;
|
|
420
|
+
void main() {
|
|
421
|
+
vec3 p = position;
|
|
422
|
+
p.y += sin(uTime * 0.2 + p.x * 0.7) * (0.08 + uStir * 0.3); // slow drift, faster when scrolled
|
|
423
|
+
vec2 away = p.xy - uPointer;
|
|
424
|
+
p.xy += normalize(away + 1e-5) * 0.6 * (1.0 - smoothstep(0.0, 1.2, length(away))); // pointer pushes aside
|
|
425
|
+
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
426
|
+
gl_PointSize = uSize * (4.0 / -mv.z);
|
|
427
|
+
gl_Position = projectionMatrix * mv;
|
|
428
|
+
}`}
|
|
429
|
+
fragmentShader={/* glsl */ `
|
|
430
|
+
uniform vec3 uColor;
|
|
431
|
+
void main() {
|
|
432
|
+
float d = length(gl_PointCoord - 0.5);
|
|
433
|
+
gl_FragColor = vec4(uColor, (1.0 - smoothstep(0.0, 0.5, d)) * 0.6);
|
|
434
|
+
#include <colorspace_fragment>
|
|
435
|
+
}`}
|
|
436
|
+
/>
|
|
437
|
+
</points>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
- **Shader rules.** `smoothstep` edges go in ascending order (`edge0 < edge1`); a reversed pair is undefined in GLSL and renders differently per GPU, so a falloff is written `1.0 - smoothstep(0.0, r, d)`. `#include <colorspace_fragment>` after `gl_FragColor` converts the token colour (stored linear by `THREE.Color`) back to sRGB, so the field matches the CSS ink exactly.
|
|
443
|
+
- Colour comes from the preset's ink or accent token, at low alpha. A rainbow field is the screensaver tell.
|
|
444
|
+
- Touch: half the count and no pointer (pass a zero pointer). Low tier: the poster, which is a still frame of the field.
|
|
445
|
+
- **When not to:** behind running text (moving points under copy hurt reading) or anywhere a second WebGL moment already lives.
|
|
446
|
+
|
|
447
|
+
### `ambient-hero-scene`: one subject, lit, graded
|
|
448
|
+
|
|
449
|
+
A restrained hero: one object or one field, lit by an environment, with grain and vignette, reacting to scroll. It should look like a photograph that happens to move.
|
|
450
|
+
|
|
451
|
+
```tsx
|
|
452
|
+
// components/three/AmbientHero.tsx (inside <Canvas frameloop="never" dpr={dpr} ...>; it mounts its own TickerDriver)
|
|
453
|
+
"use client";
|
|
454
|
+
import { useRef, useState } from "react";
|
|
455
|
+
import { useFrame } from "@react-three/fiber";
|
|
456
|
+
import { Environment, Float, Lightformer, Preload } from "@react-three/drei";
|
|
457
|
+
import * as THREE from "three";
|
|
458
|
+
import { Grade } from "./Grade";
|
|
459
|
+
import { TickerDriver, type Box } from "./shared";
|
|
460
|
+
|
|
461
|
+
const damp = THREE.MathUtils.damp;
|
|
462
|
+
|
|
463
|
+
export function AmbientHero({ active, progress, velocity, tier, children }: {
|
|
464
|
+
active: Box<boolean>; progress: Box<number>;
|
|
465
|
+
velocity: Box<number>; // ScrollTrigger getVelocity(), px/s, written by the §0 trigger's onUpdate
|
|
466
|
+
tier: "high" | "mid" | "low"; children: React.ReactNode;
|
|
467
|
+
}) {
|
|
468
|
+
const group = useRef<THREE.Group>(null);
|
|
469
|
+
const [caOffset] = useState(() => new THREE.Vector2(0, 0)); // useState, not useMemo (product-rig)
|
|
470
|
+
|
|
471
|
+
useFrame((state, delta) => {
|
|
472
|
+
const dt = Math.min(delta, 1 / 30);
|
|
473
|
+
const p = progress.current;
|
|
474
|
+
state.camera.position.z = 5 + p * 1.5; // slow pull-out as the hero scrolls away
|
|
475
|
+
group.current!.position.y = p * 0.6;
|
|
476
|
+
// velocity into a tiny chromatic aberration, zero at rest (same as product-rig)
|
|
477
|
+
const target = Math.min(Math.abs(velocity.current) / 4000, 1) * 0.0015;
|
|
478
|
+
const v = damp(caOffset.x, target, 6, dt);
|
|
479
|
+
caOffset.set(v, v);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<>
|
|
484
|
+
<TickerDriver active={active} />
|
|
485
|
+
<Environment resolution={256}>
|
|
486
|
+
<Lightformer form="rect" intensity={1.6} position={[2, 3, -2]} scale={[6, 1.5, 1]} />
|
|
487
|
+
<Lightformer form="ring" intensity={0.6} position={[-3, -1, 2]} scale={2} />
|
|
488
|
+
</Environment>
|
|
489
|
+
<group ref={group}>
|
|
490
|
+
{tier === "low" ? children : <Float speed={1} rotationIntensity={0.25} floatIntensity={0.3}>{children}</Float>}
|
|
491
|
+
</group>
|
|
492
|
+
{tier !== "low" && <Grade tier={tier} caOffset={caOffset} />}
|
|
493
|
+
<Preload all />
|
|
494
|
+
</>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
- `children` is the subject: the brand's mark extruded, or a GLB through `useGLTF`. Not a torus knot, not a blob.
|
|
500
|
+
- The §0 trigger also writes `velocity.current = self.getVelocity()` in its `onUpdate`; without it the CA pass would sit at zero and cost a full-screen draw for nothing. Low tier drops `Float` and the composer: the subject holds still and only scroll moves it.
|
|
501
|
+
- The page surface is the background (`alpha: true` on the canvas, no `Environment background`), so the scene sits inside the palette instead of on a foreign gradient.
|
|
502
|
+
- Real h1 and copy sit in the DOM above the canvas ([typography.md](typography.md) §5 `line-rise-hero`). The scene supports the headline; it never replaces it.
|
|
503
|
+
|
|
504
|
+
## 5. Performance tiers and fallbacks
|
|
505
|
+
|
|
506
|
+
Pick a starting tier before mounting, then let `PerformanceMonitor` move it.
|
|
507
|
+
|
|
508
|
+
| Tier | Who | dpr | Composer | Scene budget |
|
|
509
|
+
|---|---|---|---|---|
|
|
510
|
+
| **high** | Fine pointer, 8+ cores | `[1, 1.75]` ([webgl-r3f.md](webgl-r3f.md) default); `[1, 2]` only for a hard-edged product hero | grain, vignette, velocity CA, bloom if emissive | Transmission allowed, 4000 particles, 2048 textures |
|
|
511
|
+
| **mid** | Laptops, tablets, decline from high | `[1, 1.5]` | grain and vignette only | No transmission (use `meshPhysicalMaterial`), 1500 particles, 1024 textures |
|
|
512
|
+
| **low** | Decline from mid | `1` | none | Static lighting, baked shadows, no `Float` |
|
|
513
|
+
| **poster** | Reduced motion, touch by default, `onFallback` | no canvas | n/a | The poster image is the design |
|
|
514
|
+
|
|
515
|
+
```tsx
|
|
516
|
+
// starting guess, in the island's gate effect (webgl-r3f.md §1)
|
|
517
|
+
const start = navigator.hardwareConcurrency >= 8 ? "high" : "mid";
|
|
518
|
+
|
|
519
|
+
// inside <Canvas frameloop="never" dpr={DPR[tier]}>, next to <TickerDriver active={active} />
|
|
520
|
+
// const DPR: Record<Tier, number | [number, number]> = { high: [1, 1.75], mid: [1, 1.5], low: 1 };
|
|
521
|
+
<PerformanceMonitor
|
|
522
|
+
onDecline={() => setTier((t) => (t === "high" ? "mid" : "low"))}
|
|
523
|
+
onIncline={() => setTier((t) => (t === "low" ? "mid" : t))}
|
|
524
|
+
flipflops={3}
|
|
525
|
+
onFallback={() => setTier("low")}
|
|
526
|
+
/>
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **Never climb back to high** after a decline: flip-flopping between tiers is more visible than either tier.
|
|
530
|
+
- **Poster always.** Same framing as the first frame of the scene, so the canvas fades in over an identical image ([webgl-r3f.md](webgl-r3f.md) §1). It is also the LCP candidate: sized, compressed, preloaded ([seo.md](seo.md) §6). How to make it without a DCC tool: "The poster" below.
|
|
531
|
+
- **Offscreen pause.** The §0 driver stops advancing when the section leaves the viewport. A canvas rendering below the fold is battery spent on nothing. The trigger lives on the unpinned outer section, so a pin never pauses the scene mid-shot.
|
|
532
|
+
- **Textures** at 2048 maximum on desktop, 1024 for mobile variants, WebP through gltf-transform ([performance.md](performance.md) §5). Textures, not triangles, are usually the weight.
|
|
533
|
+
- **Mobile.** Default is the poster on touch; when a project opts in, it starts at mid tier with half counts and no pointer writer.
|
|
534
|
+
- **Reduced motion is the poster.** Not a slower scene: no scene ([webgl-r3f.md](webgl-r3f.md) §4).
|
|
535
|
+
|
|
536
|
+
### The poster: rendered from the scene
|
|
537
|
+
|
|
538
|
+
Without a DCC tool, the only honest poster is the scene itself at its first shot. Build a dev-only route that mounts the island at `SHOTS.intro` with `preserveDrawingBuffer`, and capture it with Playwright:
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
// app/render-poster/page.tsx: dev only, a 404 in production
|
|
542
|
+
import { notFound } from "next/navigation";
|
|
543
|
+
import { PosterStage } from "./PosterStage"; // "use client": mounts the island (next/dynamic, ssr: false)
|
|
544
|
+
// at SHOTS.intro with frameloop="always", gl={{ preserveDrawingBuffer: true }}, no composer,
|
|
545
|
+
// inside <div id="stage" style={{ position: "fixed", inset: 0, width: 1600, height: 1000 }}>,
|
|
546
|
+
// and sets document.body.dataset.ready = "1" from the scene's first useFrame.
|
|
547
|
+
|
|
548
|
+
export default function RenderPoster() {
|
|
549
|
+
if (process.env.NODE_ENV === "production") notFound();
|
|
550
|
+
return <PosterStage />;
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
```js
|
|
555
|
+
// scripts/poster.mjs: node scripts/poster.mjs http://localhost:3000 (against `next dev`; the route 404s in production)
|
|
556
|
+
import { chromium } from "playwright";
|
|
557
|
+
|
|
558
|
+
const base = process.argv[2] ?? "http://localhost:3000";
|
|
559
|
+
const browser = await chromium.launch({ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
|
560
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
|
|
561
|
+
await page.goto(`${base}/render-poster`, { waitUntil: "networkidle" });
|
|
562
|
+
await page.waitForSelector("body[data-ready='1']", { state: "attached", timeout: 60000 });
|
|
563
|
+
await page.waitForTimeout(2000); // environment maps and the baked shadow settle
|
|
564
|
+
await page.locator("#stage").screenshot({ path: "public/media/product-poster.png" });
|
|
565
|
+
await browser.close();
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
- Then compress it like any hero image ([performance.md](performance.md) §4).
|
|
569
|
+
- **Same framing holds at one aspect only.** The poster sits under a canvas whose aspect follows its column. Render the stage at the column's typical aspect (the 1600 x 1000 above is a 16:10 column), show it with `object-cover` at the same anchor the camera centres on, and crossfade the canvas in over it rather than cutting, so a small aspect mismatch reads as a settle, not a jump.
|
|
570
|
+
- This is the poster for a stand-in (product-rig, "No model file yet") too: what the visitor sees before the canvas is what the canvas shows.
|
|
571
|
+
|
|
572
|
+
## 6. Real copy stays in the DOM
|
|
573
|
+
|
|
574
|
+
Text inside a canvas is invisible to search engines, screen readers, translation and text selection. Every word a visitor must read is DOM text, positioned over or beside the canvas; drei `Text` is for decorative 3D type only, and its words also exist in the DOM. The canvas wrapper is `aria-hidden`. Details: [seo.md](seo.md) §6.
|
|
575
|
+
|
|
576
|
+
## 7. Tells
|
|
577
|
+
|
|
578
|
+
| Tell | Replace with |
|
|
579
|
+
|---|---|
|
|
580
|
+
| Spinning torus knot, floating blob or chrome sphere as the hero | One subject that belongs to the brand: its product, its mark, its material |
|
|
581
|
+
| `ScrollControls` on a Lenis page | ScrollTrigger into refs (§0) |
|
|
582
|
+
| Bloom on a scene with nothing emissive | Grain and vignette |
|
|
583
|
+
| Permanent chromatic aberration | CA from scroll velocity, zero at rest |
|
|
584
|
+
| `Environment background` with a stock HDR behind the subject | The page surface as background, lightformers for reflections |
|
|
585
|
+
| Photos through ACES tone mapping, looking grey | `flat` on image canvases |
|
|
586
|
+
| A column canvas that reads as a dark box (vignette, tone-mapped background) | `fullBleed={false}`, `flat`, the page surface as background (§2) |
|
|
587
|
+
| `caOffset.x = ...` or a `useMemo` object mutated from a prop | `useState(() => new X())` at the owner, `.set()` in `useFrame` |
|
|
588
|
+
| A primitive "product" passed off as the real thing | A declared stand-in in the palette, labelled in ASSUMPTIONS, or a downgrade (§4, "No model file yet") |
|
|
589
|
+
| Scroll and pointer writing the same rotation | Nested groups: scroll outside, pointer inside |
|
|
590
|
+
| A canvas still rendering two screens below | Offscreen pause |
|
|
591
|
+
| Transmission glass on a phone at 20fps | Tiered: physical material on mid, poster on low |
|
|
592
|
+
| Headline set in 3D text only | DOM headline; 3D type as decoration |
|
|
593
|
+
| A second canvas for a second section | One canvas with `View`, or cut one |
|