Vertex Shader Cylinder Bend
The bend on its own, with no gallery attached. A flat `PlaneGeometry` is fed to a vertex shader that reads each vertex's x coordinate as an arc parameter and re-emits it on the surface of a cylinder. Because curvature is a uniform rather than a mesh, the same buffer is flat, bowed or nearly closed between two frames — no `CylinderGeometry`, no rebuild, no re-upload.
Vertex Shader Cylinder Bend
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/vertex-cylinder-bend.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Vertex Shader Cylinder Bend
The bend on its own, with no gallery attached. A flat `PlaneGeometry` is fed to a vertex shader that reads each vertex's x coordinate as an arc parameter and re-emits it on the surface of a cylinder. Because curvature is a uniform rather than a mesh, the same buffer is flat, bowed or nearly closed between two frames — no `CylinderGeometry`, no rebuild, no re-upload.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
A flat plane wrapped around a cylinder on the GPU, with the curvature living in a uniform you can animate.
- Category: webgl
- Runtime cost: webgl
- Dependencies: three@^0.185.1
## How it works
1. Curl is defined as radians of bend per world unit of plane width, so the radius is its reciprocal: `radius = 1.0 / curl`. A 3-unit plane at curl 0.62 wraps 1.86 rad of a circle of radius 1.61, and the arc length is still exactly 3 units — the banner bends without appearing to shrink.
2. `angle = uSpin + position.x * curl`, then `pos.x = radius * sin(angle)` and `pos.z = radius * cos(angle) - radius`. The trailing `- radius` pins the arc's midpoint at z = 0 so tightening the curl bends the ends away instead of lunging the whole banner at the camera.
3. The vertical bow is `pos.y = sin(position.y * k) / k`, which tends to `position.y` as k tends to 0 and becomes a full sine bow at k = 1. Normalising by k is what keeps the plane the same height at every bow depth.
4. The surface normal is available in closed form: differentiating the position gives tangents `(cos a, 0, -sin a)` and `(0, 1, 0)`, whose cross product is `(sin a, 0, cos a)`. Its z component — `cos(angle)` — is how squarely the patch faces the camera, so the shading term costs one cosine and no `normalMatrix`.
5. On resize the camera is moved rather than the plane resized: `distance = max(width / fit / (2 tan(fov/2) aspect), height / fit / (2 tan(fov/2)))`. The plane's world width is the yardstick `curl` is measured in, so it must never change with the viewport.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `PLANE_WIDTH / PLANE_HEIGHT = 3 / 1.4`
Fixed world units on purpose. Curl is radians per world unit, so a plane that resized with the viewport would bend through a different total angle on every device — the same `curl` prop would mean something different on a phone.
- `curl = 0.62`
Times the 3-unit width, this is 1.86 rad — about 106 degrees of arc. Under ~0.25 the bend is indistinguishable from a perspective skew; over ~1.6 the far edges pass 90 degrees from the camera, present zero pixels of width, and the banner appears to have been cut off.
- `curlSwing = 0.22`
Amplitude of the idle breathing. Large enough that the curvature is visibly a live value rather than a baked mesh; large enough to pass 1.6 total and the ends would clip away at the peak of every cycle.
- `barrel = 0.3`
k in `sin(k*y)/k`. At 0 the top and bottom edges are dead straight and the surface reads as a rolled poster; past ~1.2 the bow overtakes the curl and the plane looks like a saddle rather than a cylinder.
- `segments = 96`
Subdivisions across the bend — the resolution of the curve, since it is evaluated per vertex. Below ~24 the arc becomes a visible polyline and the shading term steps in bands; above ~192 the extra vertices land inside a single pixel.
- `PlaneGeometry y-segments = 16`
The cylinder bend is a function of x alone, so rows would be pure waste — except the barrel bow, which needs enough rows to curve rather than kink at the midline.
- `FIT = 0.86`
Fraction of the frustum the banner fills. At 1.0 the receding edges touch the viewport border and the curl has nowhere to read against; below ~0.7 the component is mostly empty space.
- `shade = 0.55`
How far the facing term is allowed to darken the edges. At 0 the bend reads as a flat trapezoid — a perspective mistake rather than a curve. At 1 the edges go fully black and the banner appears to end early.
## API
```ts
function VertexCylinderBend(props: { curl?: number; curlSwing?: number; parallax?: number; barrel?: number; shade?: number; segments?: number; period?: number; paint?: (ctx: CanvasRenderingContext2D, size: { width: number; height: number }) => void; className?: string }): JSX.Element
```
- `curl: number` — default `0.62`
Radians of bend per world unit of plane width. 0 is flat.
- `curlSwing: number` — default `0.22`
Peak extra curl added by the idle breathing cycle.
- `parallax: number` — default `0.34`
Radians the banner rocks around the drum axis at full pointer travel.
- `barrel: number` — default `0.3`
Vertical bow. 0 leaves the edges straight, ~1 is a full sine.
- `shade: number` — default `0.55`
How far the facing term darkens the receding edges.
- `segments: number` — default `96`
Subdivisions across the bend. This is the resolution of the curve.
- `period: number` — default `9`
Seconds per breathing cycle.
- `paint: (ctx, size) => void`
Override the banner face. Receives a 2D context for a 1024x512 canvas.
- `className: string`
Merged onto the wrapper via `cn`.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
export function VertexCylinderBend({
curl = 0.62,
curlSwing = 0.22,
parallax = 0.34,
barrel = 0.3,
shade = 0.55,
segments = 96,
period = 9,
paint,
className,
}: VertexCylinderBendProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const { width, height } = useResizeObserver(host);
const { inView } = useInView(host, { threshold: 0 });
const visible = useVisibility();
const reduced = useReducedMotion();
const pointer = useSmoothPointer({ target: host, smoothing: 0.002 });
const [supported, setSupported] = useState(true);
useEffect(() => setSupported(hasWebGL()), []);
const elapsed = useRef(0);
const spin = useRef(0);
const curlNow = useRef(curl);
const live = useRef({ curl, curlSwing, parallax, barrel, shade, period });
live.current = { curl, curlSwing, parallax, barrel, shade, period };
const ready = width > 1 && height > 1 && supported;
useEffect(() => {
const el = host.current;
if (!el || !ready) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(pixelRatio());
renderer.setSize(el.clientWidth, el.clientHeight, false);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, 0.1, 100);
const canvas = document.createElement('canvas');
canvas.width = TEXTURE_WIDTH;
canvas.height = TEXTURE_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx) (paint ?? paintBanner)(ctx, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT });
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
const uniforms: BendUniforms = {
uTexture: { value: texture },
uCurl: { value: curl },
uSpin: { value: 0 },
uBarrel: { value: barrel },
uShade: { value: shade },
};
// Segments only along x. The bend is a function of x alone, so vertical
// subdivision buys nothing but vertices — except the barrel bow, which
// needs a handful of rows to curve at all.
const geometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT, segments, 16);
const material = new THREE.ShaderMaterial({
vertexShader: vertexCylinderBendVertexShader,
fragmentShader: vertexCylinderBendFragmentShader,
uniforms,
transparent: true,
// The bend turns the far edges away from the camera, so without
// DoubleSide a hard curl punches holes through the ends of the banner.
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
// The CPU bounding sphere describes a flat plane that the shader has
// already moved. Culling against it blanks the banner at shallow angles.
mesh.frustumCulled = false;
scene.add(mesh);
stage.current = { renderer, scene, camera, geometry, material, texture, uniforms };
return () => {
stage.current = null;
scene.remove(mesh);
// The texture is inside `uniforms`, where disposeObject cannot see it.
texture.dispose();
disposeObject(scene);
geometry.dispose();
material.dispose();
disposeRenderer(renderer);
renderer.domElement.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, segments, paint]);
useEffect(() => {
const current = stage.current;
if (!current || width < 1 || height < 1) return;
const aspect = width / height;
current.renderer.setPixelRatio(pixelRatio());
current.renderer.setSize(width, height, false);
current.camera.aspect = aspect;
// Contain-fit by moving the camera, not by resizing the plane: the plane's
// world width is what `curl` is measured against, so it has to stay fixed.
// Take the larger of the two required distances or the banner is cropped
// on whichever axis is tighter.
const tanHalf = Math.tan((CAMERA_FOV * Math.PI) / 360);
const forWidth = PLANE_WIDTH / FIT / (2 * tanHalf * aspect);
const forHeight = PLANE_HEIGHT / FIT / (2 * tanHalf);
current.camera.position.z = Math.max(forWidth, forHeight);
current.camera.updateProjectionMatrix();
}, [width, height]);
useRaf(
(dt) => {
const current = stage.current;
if (!current) return;
const { curl: base, curlSwing: swing, parallax: sway, barrel: bow, shade: dark, period: cycle } =
live.current;
elapsed.current += dt;
// Breathing curl. The whole reason the bend lives in a uniform is that
// it can move; holding it at one value hides the technique.
const target = reduced ? base : base + swing * Math.sin((elapsed.current * Math.PI * 2) / Math.max(cycle, 0.5));
curlNow.current = damp(curlNow.current, target, CURL_SMOOTHING, dt);
// Pointer rocks the banner around the drum axis. This survives reduced
// motion because it is a direct response to input, not autonomous motion.
// ndcX, not nx: ndc is -1..1 about the centre, so the banner rocks
// symmetrically. nx is 0..1, which would bias every rock to one side.
const aim = pointer.current.active ? pointer.current.ndcX * sway : 0;
spin.current = damp(spin.current, aim, SPIN_SMOOTHING, dt);
current.uniforms.uCurl.value = curlNow.current;
current.uniforms.uSpin.value = spin.current;
current.uniforms.uBarrel.value = bow;
current.uniforms.uShade.value = dark;
current.renderer.render(current.scene, current.camera);
},
ready && inView && visible,
);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Removes the mesh from the scene, disposes the `CanvasTexture` explicitly (it lives inside `material.uniforms`, out of `disposeObject`'s reach), runs `disposeObject` over the scene, disposes the geometry and material, then `disposeRenderer` for `setAnimationLoop(null)` / `dispose()` / `forceContextLoss()`, and removes the canvas from the DOM. The stage ref is nulled first, so any frame already queued returns immediately instead of touching a lost context.
**Reduced motion.** The breathing cycle is dropped: `uCurl` holds at the `curl` prop and never oscillates, so nothing moves on its own. The pointer rock is kept, because it is a direct one-to-one response to the user's own input rather than autonomous motion, and removing it would leave a component that ignores the cursor entirely.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Dividing by `curl` without a guard. The flat case is curl 0, which is an infinite radius: `1.0 / 0.0` in GLSL yields infinity, every vertex becomes NaN, and the mesh vanishes with no error anywhere. The shader clamps to 0.0001 and approaches flat instead.
- Omitting the `- radius` term from `pos.z`. The arc is then centred on the cylinder's axis rather than on the origin, so raising the curl walks the entire banner toward the camera and it appears to lunge every time the curvature changes.
- Bowing with a bare `sin(position.y)`. That silently rescales the plane — a 1.4-tall plane becomes 1.29 tall — so animating the bow makes the banner pulse in size. Normalising by k (`sin(k*y)/k`) keeps the height fixed at every depth.
- Leaving `frustumCulled` on. The bounding sphere describes the flat plane the shader has already moved, so at a hard curl three decides the banner is off screen and blanks it while it is plainly in view.
- Keeping the default `FrontSide`. The bend turns the far edges past 90 degrees from the camera, and single-sided faces punch clean holes through the ends of the banner at high curl.
- Resizing the plane geometry to fit the container. `curl` is radians per world unit, so a viewport-sized plane bends through a different total angle on every device and the prop stops meaning anything. Move the camera instead — and fit on the larger of the two required distances, or the banner is cropped on whichever axis is tighter.
- Texturing it with a smooth gradient. The bend is then almost invisible, because there is no structure for the foreshortening to compress. Evenly spaced vertical rules bunch toward the receding edges and make the curvature legible.
- Calling `renderer.dispose()` and stopping there. That frees GPU objects but keeps the WebGL context; Chromium caps a renderer process at roughly 16 and silently kills the oldest. `disposeRenderer` calls `forceContextLoss`.
## Notes
- This is the primitive that `cylinder-gallery` instantiates nineteen times. Isolated here so the arc maths can be read without the layout, focus and picking code around it.
- The normal is derived analytically rather than by recomputing vertex normals on the CPU, so `geometry.computeVertexNormals()` is never called and the buffer stays untouched after upload.
## Definition of done
The banner is visibly a section of a cylinder, not a skewed rectangle: the vertical rules bunch together toward both ends and the ends are darker than the middle. Over about nine seconds the curvature tightens and relaxes and the banner never changes size while it does. Move the cursor left and right and the whole arc rocks around its axis, following with a slight lag. Resize the container from wide to narrow and the banner stays fully in frame with the same amount of bend. With `prefers-reduced-motion: reduce` the curvature is frozen but the cursor rock still works.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
10 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { cn } from '@/lib/utils';
import { useInView } from '@/hooks/use-in-view';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { damp } from '@/lib/damp';
import {
disposeObject,
disposeRenderer,
hasWebGL,
pixelRatio,
} from '@/lib/webgl';
import {
vertexCylinderBendFragmentShader,
vertexCylinderBendVertexShader,
} from '@/components/motion/shaders/vertex-cylinder-bend';
/* ==========================================================================
Vertex Shader Cylinder Bend
--------------------------------------------------------------------------
One flat plane, curved on the GPU. The mesh is never rebuilt and the CPU
never touches a vertex: curl is a uniform, so the same geometry can be
flat, gently bowed or wrapped most of the way round a cylinder between two
frames.
This is the primitive that the cylinder gallery is nineteen copies of.
========================================================================== */
export interface VertexCylinderBendProps {
/** Radians of bend per world unit of plane width. 0 is flat. */
curl?: number;
/** Peak extra curl added by the idle breathing cycle. */
curlSwing?: number;
/** Radians the banner rocks around the drum axis from full pointer travel. */
parallax?: number;
/** Vertical bow, 0 (straight edges) to ~1 (full sine). */
barrel?: number;
/** How much the facing term darkens the receding edges, 0-1. */
shade?: number;
/** Plane subdivisions across the bend. This is the resolution of the curve. */
segments?: number;
/** Seconds per breathing cycle. */
period?: number;
/** Draw the banner face. Default paints a ruled hue ramp with a numeral scale. */
paint?: (
ctx: CanvasRenderingContext2D,
size: { width: number; height: number },
) => void;
className?: string;
}
/* Fixed world size, deliberately. `curl` is defined in radians per world unit,
so a plane whose width changed with the viewport would bend by a different
total angle on every device. The camera moves to fit instead. */
const PLANE_WIDTH = 3;
const PLANE_HEIGHT = 1.4;
const CAMERA_FOV = 40;
const FIT = 0.86;
const TEXTURE_WIDTH = 1024;
const TEXTURE_HEIGHT = 512;
const CURL_SMOOTHING = 0.004;
const SPIN_SMOOTHING = 0.002;
type BendUniforms = {
uTexture: { value: THREE.Texture | null };
uCurl: { value: number };
uSpin: { value: number };
uBarrel: { value: number };
uShade: { value: number };
};
interface Stage {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
geometry: THREE.PlaneGeometry;
material: THREE.ShaderMaterial;
texture: THREE.CanvasTexture;
uniforms: BendUniforms;
}
function paintBanner(
ctx: CanvasRenderingContext2D,
size: { width: number; height: number },
): void {
const { width, height } = size;
const ramp = ctx.createLinearGradient(0, 0, width, height);
ramp.addColorStop(0, 'hsl(214, 44%, 16%)');
ramp.addColorStop(0.5, 'hsl(196, 48%, 40%)');
ramp.addColorStop(1, 'hsl(46, 82%, 62%)');
ctx.fillStyle = ramp;
ctx.fillRect(0, 0, width, height);
// Vertical rules are the point. On a smooth gradient the bend is almost
// invisible; evenly spaced verticals bunch up toward the receding edges and
// make the curvature readable at a glance.
ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
ctx.lineWidth = Math.max(1, width / 640);
for (let i = 1; i < 24; i++) {
const x = (width * i) / 24;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `600 ${Math.round(height * 0.26)}px ui-sans-serif, system-ui, sans-serif`;
ctx.fillText('CURVATURE', width / 2, height * 0.46);
ctx.font = `500 ${Math.round(height * 0.062)}px ui-monospace, SFMono-Regular, monospace`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.fillText('ONE PLANE / ONE UNIFORM / NO REBUILD', width / 2, height * 0.72);
}
// #region critical
export function VertexCylinderBend({
curl = 0.62,
curlSwing = 0.22,
parallax = 0.34,
barrel = 0.3,
shade = 0.55,
segments = 96,
period = 9,
paint,
className,
}: VertexCylinderBendProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const { width, height } = useResizeObserver(host);
const { inView } = useInView(host, { threshold: 0 });
const visible = useVisibility();
const reduced = useReducedMotion();
const pointer = useSmoothPointer({ target: host, smoothing: 0.002 });
const [supported, setSupported] = useState(true);
useEffect(() => setSupported(hasWebGL()), []);
const elapsed = useRef(0);
const spin = useRef(0);
const curlNow = useRef(curl);
const live = useRef({ curl, curlSwing, parallax, barrel, shade, period });
live.current = { curl, curlSwing, parallax, barrel, shade, period };
const ready = width > 1 && height > 1 && supported;
useEffect(() => {
const el = host.current;
if (!el || !ready) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(pixelRatio());
renderer.setSize(el.clientWidth, el.clientHeight, false);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, 0.1, 100);
const canvas = document.createElement('canvas');
canvas.width = TEXTURE_WIDTH;
canvas.height = TEXTURE_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx) (paint ?? paintBanner)(ctx, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT });
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
const uniforms: BendUniforms = {
uTexture: { value: texture },
uCurl: { value: curl },
uSpin: { value: 0 },
uBarrel: { value: barrel },
uShade: { value: shade },
};
// Segments only along x. The bend is a function of x alone, so vertical
// subdivision buys nothing but vertices — except the barrel bow, which
// needs a handful of rows to curve at all.
const geometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT, segments, 16);
const material = new THREE.ShaderMaterial({
vertexShader: vertexCylinderBendVertexShader,
fragmentShader: vertexCylinderBendFragmentShader,
uniforms,
transparent: true,
// The bend turns the far edges away from the camera, so without
// DoubleSide a hard curl punches holes through the ends of the banner.
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
// The CPU bounding sphere describes a flat plane that the shader has
// already moved. Culling against it blanks the banner at shallow angles.
mesh.frustumCulled = false;
scene.add(mesh);
stage.current = { renderer, scene, camera, geometry, material, texture, uniforms };
return () => {
stage.current = null;
scene.remove(mesh);
// The texture is inside `uniforms`, where disposeObject cannot see it.
texture.dispose();
disposeObject(scene);
geometry.dispose();
material.dispose();
disposeRenderer(renderer);
renderer.domElement.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, segments, paint]);
useEffect(() => {
const current = stage.current;
if (!current || width < 1 || height < 1) return;
const aspect = width / height;
current.renderer.setPixelRatio(pixelRatio());
current.renderer.setSize(width, height, false);
current.camera.aspect = aspect;
// Contain-fit by moving the camera, not by resizing the plane: the plane's
// world width is what `curl` is measured against, so it has to stay fixed.
// Take the larger of the two required distances or the banner is cropped
// on whichever axis is tighter.
const tanHalf = Math.tan((CAMERA_FOV * Math.PI) / 360);
const forWidth = PLANE_WIDTH / FIT / (2 * tanHalf * aspect);
const forHeight = PLANE_HEIGHT / FIT / (2 * tanHalf);
current.camera.position.z = Math.max(forWidth, forHeight);
current.camera.updateProjectionMatrix();
}, [width, height]);
useRaf(
(dt) => {
const current = stage.current;
if (!current) return;
const { curl: base, curlSwing: swing, parallax: sway, barrel: bow, shade: dark, period: cycle } =
live.current;
elapsed.current += dt;
// Breathing curl. The whole reason the bend lives in a uniform is that
// it can move; holding it at one value hides the technique.
const target = reduced ? base : base + swing * Math.sin((elapsed.current * Math.PI * 2) / Math.max(cycle, 0.5));
curlNow.current = damp(curlNow.current, target, CURL_SMOOTHING, dt);
// Pointer rocks the banner around the drum axis. This survives reduced
// motion because it is a direct response to input, not autonomous motion.
// ndcX, not nx: ndc is -1..1 about the centre, so the banner rocks
// symmetrically. nx is 0..1, which would bias every rock to one side.
const aim = pointer.current.active ? pointer.current.ndcX * sway : 0;
spin.current = damp(spin.current, aim, SPIN_SMOOTHING, dt);
current.uniforms.uCurl.value = curlNow.current;
current.uniforms.uSpin.value = spin.current;
current.uniforms.uBarrel.value = bow;
current.uniforms.uShade.value = dark;
current.renderer.render(current.scene, current.camera);
},
ready && inView && visible,
);
// #endregion critical
return (
<div className={cn('relative h-full w-full overflow-hidden', className)}>
<div ref={host} className="absolute inset-0" />
{!supported ? (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="h-24 w-2/3 rounded-md"
style={{
background:
'linear-gradient(100deg, hsl(214 44% 16%), hsl(196 48% 40%), hsl(46 82% 62%))',
}}
/>
</div>
) : null}
</div>
);
}
/* ==========================================================================
Vertex Shader Cylinder Bend — GLSL as TypeScript template literals.
--------------------------------------------------------------------------
The bend on its own, with nothing else attached: no gallery, no scroll, no
textures to fit. A flat `PlaneGeometry` goes in and a section of cylinder
comes out, entirely on the GPU, so the curvature is a uniform you can
animate rather than a mesh you have to rebuild.
The trick is that the vertex's own x coordinate is the arc parameter. Feed
it through `sin`/`cos` scaled by a radius and the plane rolls; scale x by a
curl factor first and the SAME plane rolls tighter or looser without its
arc length changing, so the banner never appears to shrink as it bends.
========================================================================== */
export const vertexCylinderBendVertexShader = /* glsl */ `
uniform float uCurl;
uniform float uSpin;
uniform float uBarrel;
varying vec2 vUv;
varying float vFacing;
void main() {
// radius = 1 / curl is the whole relationship. curl is radians per world
// unit of plane width, so a 3-unit plane at curl 0.9 wraps 2.7 rad of a
// circle of radius 1.11 — and its arc length is still exactly 3 units.
// Guarded because curl 0 means an infinite radius: the flat case has to be
// approached, never divided by.
float curl = max(uCurl, 0.0001);
float radius = 1.0 / curl;
float angle = uSpin + position.x * curl;
vec3 pos;
pos.x = radius * sin(angle);
// The - radius pins the arc's centre at z = 0. Without it the whole banner
// sits a full radius in front of the origin and appears to lunge at the
// camera every time the curl tightens.
pos.z = radius * cos(angle) - radius;
// Barrel, normalised. sin(k*y)/k tends to y as k tends to 0, so at uBarrel 0
// the height is untouched and at uBarrel 1 it is a full sine bow — the plane
// keeps its size either way instead of shrinking as the bow deepens.
float k = max(uBarrel, 0.0001);
pos.y = sin(position.y * k) / k;
// Surface normal, analytically. d(pos)/dx is (cos a, 0, -sin a) and d/dy is
// (0, 1, 0); their cross product is (sin a, 0, cos a), so the z component of
// the normal — how squarely this patch faces the camera — is just cos(angle).
// No normalMatrix, no recomputed vertex normals, one cosine.
vFacing = cos(angle);
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
export const vertexCylinderBendFragmentShader = /* glsl */ `
uniform sampler2D uTexture;
uniform float uShade;
varying vec2 vUv;
varying float vFacing;
void main() {
vec4 texel = texture2D(uTexture, vUv);
// Shading is the only cue that the plane is curved rather than trapezoidal.
// A flat-lit bend reads as a perspective mistake; darkening by the facing
// term makes the same geometry read as a surface turning away from a light.
float shade = mix(1.0, clamp(vFacing, 0.0, 1.0), uShade);
gl_FragColor = vec4(texel.rgb * shade, texel.a);
// ShaderMaterial gets no automatic output conversion — three only injects
// this chunk into its own materials. Without it every colour ships raw
// linear values into an sRGB framebuffer and the whole scene looks washed.
#include <colorspace_fragment>
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef, useState, type RefObject } from 'react';
/* ==========================================================================
IntersectionObserver, sharing one observer per option set.
--------------------------------------------------------------------------
A hundred cards each constructing their own observer is a hundred observers
the compositor has to service. Keying a shared observer by its options
collapses that to one per distinct configuration — in practice, two or
three for the whole site.
========================================================================== */
export interface InViewState {
inView: boolean;
/** 0-1. The preview slot allocator ranks candidates by this. */
ratio: number;
}
export interface UseInViewOptions {
root?: Element | null;
rootMargin?: string;
/** Single number or list, same meaning as the native option. */
threshold?: number | number[];
/** Stop observing after the first time it becomes visible. */
once?: boolean;
}
type Entry = {
observer: IntersectionObserver;
handlers: Map<Element, (e: IntersectionObserverEntry) => void>;
};
const pools = new Map<string, Entry>();
function poolFor(options: UseInViewOptions): Entry {
const key = JSON.stringify({
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
});
const existing = pools.get(key);
if (existing) return existing;
const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) handlers.get(entry.target)?.(entry);
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
},
);
const created: Entry = { observer, handlers };
pools.set(key, created);
return created;
}
export function useInView(
ref: RefObject<Element | null>,
options: UseInViewOptions = {},
): InViewState {
const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 });
const { root = null, rootMargin = '0px', once = false } = options;
const thresholdKey = JSON.stringify(options.threshold ?? 0);
const done = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
if (once && done.current) return;
const pool = poolFor({
root,
rootMargin,
threshold: JSON.parse(thresholdKey) as number | number[],
});
const handle = (entry: IntersectionObserverEntry) => {
setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio });
if (once && entry.isIntersecting) {
done.current = true;
pool.handlers.delete(el);
pool.observer.unobserve(el);
}
};
pool.handlers.set(el, handle);
pool.observer.observe(el);
return () => {
pool.handlers.delete(el);
pool.observer.unobserve(el);
};
}, [ref, root, rootMargin, thresholdKey, once]);
return state;
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function VertexCylinderBend(props: { curl?: number; curlSwing?: number; parallax?: number; barrel?: number; shade?: number; segments?: number; period?: number; paint?: (ctx: CanvasRenderingContext2D, size: { width: number; height: number }) => void; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| curl | number | = 0.62 | Radians of bend per world unit of plane width. 0 is flat. |
| curlSwing | number | = 0.22 | Peak extra curl added by the idle breathing cycle. |
| parallax | number | = 0.34 | Radians the banner rocks around the drum axis at full pointer travel. |
| barrel | number | = 0.3 | Vertical bow. 0 leaves the edges straight, ~1 is a full sine. |
| shade | number | = 0.55 | How far the facing term darkens the receding edges. |
| segments | number | = 96 | Subdivisions across the bend. This is the resolution of the curve. |
| period | number | = 9 | Seconds per breathing cycle. |
| paint | (ctx, size) => void | — | Override the banner face. Receives a 2D context for a 1024x512 canvas. |
| className | string | — | Merged onto the wrapper via cn. |
How it works
The technique in order, not the code in order.
- 01
Curl is defined as radians of bend per world unit of plane width, so the radius is its reciprocal:
radius = 1.0 / curl. A 3-unit plane at curl 0.62 wraps 1.86 rad of a circle of radius 1.61, and the arc length is still exactly 3 units — the banner bends without appearing to shrink. - 02
angle = uSpin + position.x * curl, thenpos.x = radius * sin(angle)andpos.z = radius * cos(angle) - radius. The trailing- radiuspins the arc's midpoint at z = 0 so tightening the curl bends the ends away instead of lunging the whole banner at the camera. - 03
The vertical bow is
pos.y = sin(position.y * k) / k, which tends toposition.yas k tends to 0 and becomes a full sine bow at k = 1. Normalising by k is what keeps the plane the same height at every bow depth. - 04
The surface normal is available in closed form: differentiating the position gives tangents
(cos a, 0, -sin a)and(0, 1, 0), whose cross product is(sin a, 0, cos a). Its z component —cos(angle)— is how squarely the patch faces the camera, so the shading term costs one cosine and nonormalMatrix. - 05
On resize the camera is moved rather than the plane resized:
distance = max(width / fit / (2 tan(fov/2) aspect), height / fit / (2 tan(fov/2))). The plane's world width is the yardstickcurlis measured in, so it must never change with the viewport.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| PLANE_WIDTH / PLANE_HEIGHT | = 3 / 1.4 | Fixed world units on purpose. Curl is radians per world unit, so a plane that resized with the viewport would bend through a different total angle on every device — the same curl prop would mean something different on a phone. |
| curl | = 0.62 | Times the 3-unit width, this is 1.86 rad — about 106 degrees of arc. Under ~0.25 the bend is indistinguishable from a perspective skew; over ~1.6 the far edges pass 90 degrees from the camera, present zero pixels of width, and the banner appears to have been cut off. |
| curlSwing | = 0.22 | Amplitude of the idle breathing. Large enough that the curvature is visibly a live value rather than a baked mesh; large enough to pass 1.6 total and the ends would clip away at the peak of every cycle. |
| barrel | = 0.3 | k in sin(k*y)/k. At 0 the top and bottom edges are dead straight and the surface reads as a rolled poster; past ~1.2 the bow overtakes the curl and the plane looks like a saddle rather than a cylinder. |
| segments | = 96 | Subdivisions across the bend — the resolution of the curve, since it is evaluated per vertex. Below ~24 the arc becomes a visible polyline and the shading term steps in bands; above ~192 the extra vertices land inside a single pixel. |
| PlaneGeometry y-segments | = 16 | The cylinder bend is a function of x alone, so rows would be pure waste — except the barrel bow, which needs enough rows to curve rather than kink at the midline. |
| FIT | = 0.86 | Fraction of the frustum the banner fills. At 1.0 the receding edges touch the viewport border and the curl has nowhere to read against; below ~0.7 the component is mostly empty space. |
| shade | = 0.55 | How far the facing term is allowed to darken the edges. At 0 the bend reads as a flat trapezoid — a perspective mistake rather than a curve. At 1 the edges go fully black and the banner appears to end early. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The breathing cycle is dropped: uCurl holds at the curl prop and never oscillates, so nothing moves on its own. The pointer rock is kept, because it is a direct one-to-one response to the user's own input rather than autonomous motion, and removing it would leave a component that ignores the cursor entirely.
Cleanup
Removes the mesh from the scene, disposes the CanvasTexture explicitly (it lives inside material.uniforms, out of disposeObject's reach), runs disposeObject over the scene, disposes the geometry and material, then disposeRenderer for setAnimationLoop(null) / dispose() / forceContextLoss(), and removes the canvas from the DOM. The stage ref is nulled first, so any frame already queued returns immediately instead of touching a lost context.
Definition of done
The banner is visibly a section of a cylinder, not a skewed rectangle: the vertical rules bunch together toward both ends and the ends are darker than the middle. Over about nine seconds the curvature tightens and relaxes and the banner never changes size while it does. Move the cursor left and right and the whole arc rocks around its axis, following with a slight lag. Resize the container from wide to narrow and the banner stays fully in frame with the same amount of bend. With prefers-reduced-motion: reduce the curvature is frozen but the cursor rock still works.
Pitfalls
- Dividing by
curlwithout a guard. The flat case is curl 0, which is an infinite radius:1.0 / 0.0in GLSL yields infinity, every vertex becomes NaN, and the mesh vanishes with no error anywhere. The shader clamps to 0.0001 and approaches flat instead. - Omitting the
- radiusterm frompos.z. The arc is then centred on the cylinder's axis rather than on the origin, so raising the curl walks the entire banner toward the camera and it appears to lunge every time the curvature changes. - Bowing with a bare
sin(position.y). That silently rescales the plane — a 1.4-tall plane becomes 1.29 tall — so animating the bow makes the banner pulse in size. Normalising by k (sin(k*y)/k) keeps the height fixed at every depth. - Leaving
frustumCulledon. The bounding sphere describes the flat plane the shader has already moved, so at a hard curl three decides the banner is off screen and blanks it while it is plainly in view. - Keeping the default
FrontSide. The bend turns the far edges past 90 degrees from the camera, and single-sided faces punch clean holes through the ends of the banner at high curl. - Resizing the plane geometry to fit the container.
curlis radians per world unit, so a viewport-sized plane bends through a different total angle on every device and the prop stops meaning anything. Move the camera instead — and fit on the larger of the two required distances, or the banner is cropped on whichever axis is tighter. - Texturing it with a smooth gradient. The bend is then almost invisible, because there is no structure for the foreshortening to compress. Evenly spaced vertical rules bunch toward the receding edges and make the curvature legible.
- Calling
renderer.dispose()and stopping there. That frees GPU objects but keeps the WebGL context; Chromium caps a renderer process at roughly 16 and silently kills the oldest.disposeRenderercallsforceContextLoss.
Notes
- This is the primitive that
cylinder-galleryinstantiates nineteen times. Isolated here so the arc maths can be read without the layout, focus and picking code around it. - The normal is derived analytically rather than by recomputing vertex normals on the CPU, so
geometry.computeVertexNormals()is never called and the buffer stays untouched after upload.