모션·인터랙션 / Cadence
Scroll Flight
스크롤 · 갤러리 · 3D 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Scroll Flight
A field of photographs lies flat on the page: nine tiles in a 3 × 3, then the rest of the
field ripples in around them and the whole plane tilts back into perspective. Scrolling
(wheel, trackpad, touch drag, mouse drag, arrow / page keys) flies the camera low over it,
row after row streaming into a white fog. The field repeats every 25 rows, so the flight
never ends, and a slow idle drift keeps it moving when you stop.
Dependencies: none. Raw WebGL 2 (WebGL 1 + OES_standard_derivatives as a fallback).
Tweak (in the CONFIG section of the script):
- Tiles: CW × CH = 168 × 210 px, FGAP 16 px, COLS 21, RADIUS 6 px corners
- Camera: TILT_DEG 66, PERSPECTIVE 1200, LIFT 150 (px the plane rises as it tilts)
- Intro: nine fade in over INTRO_FADE 0.5 s; INTRO_AT 0.9 s later the ripple starts
(each tile 0.45 s, delayed 0.07 s per tile of distance from the nine, max 0.9 s)
and the plane tilts back over 1.2 s (smoothstep)
- Input: WHEEL_K 1, TOUCH_K 2 (plane px per px), arrow keys 1 row, page keys 4 rows
- Follower: FOLLOW_K 5 (pos += (target − pos) × 5 × dt), capped at FOLLOW_MAX 2240 px/s
- Idle drift: IDLE_SPEED 40 px/s, eased in over IDLE_IN 1.2 s after the tilt
- Fog: solid down to FOG_SOLID 250 px, clear at FOG_CLEAR 520 px (of a 900 px frame)
Everything is measured in a 1600 × 900 design frame that is scaled to fill the viewport:
scale = max(W / 1600, min(H / 900, W / 640)).
Based on "Scroll Flight", from the Design with Skills explainer, animation 84
(own project) · Ruben Stom
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll Flight</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
.stage {
position: fixed;
inset: 0;
overflow: hidden;
background: #fff;
touch-action: none; /* touch drives the flight, not the page */
cursor: grab;
}
.stage.is-dragging { cursor: grabbing; }
.gl {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* White fog over the far rows; its stops are set by the script */
.fog {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, #fff 0, #fff var(--fog-a, 250px), rgb(255 255 255 / 0) var(--fog-b, 520px));
opacity: 0;
pointer-events: none;
will-change: opacity;
}
/* A hairline loader while the photos load */
.loader {
position: fixed;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
background: #fff;
transition: opacity 0.6s ease;
}
.loader.is-done { opacity: 0; pointer-events: none; }
.loader-track { width: 160px; height: 1px; overflow: hidden; background: #e6e6e6; }
.loader-bar {
display: block;
height: 100%;
background: #000;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
</style>
</head>
<body>
<div class="stage" data-stage>
<canvas class="gl" data-gl role="img" aria-label="A field of photographs in perspective. Scroll to fly over it."></canvas>
<div class="fog" data-fog></div>
</div>
<div class="loader" data-loader aria-hidden="true">
<div class="loader-track"><span class="loader-bar" data-loader-bar></span></div>
</div>
<script>
(() => {
"use strict";
/* ── PHOTOS ─────────────────────────────────────────────────────────────────────
25 portrait (3:4) photos. Swap in your own: any image URLs work (same origin, or
a host that sends CORS headers, since they become WebGL textures). ~600 × 800 is
plenty; tiles are 168 × 210 in the design frame. */
const P = Array.from({ length: 25 }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/600/800`);
/* ── maths ──────────────────────────────────────────────────────────────────── */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, p) => a + (b - a) * p;
const seg = (t, a, b) => clamp((t - a) / (b - a), 0, 1);
const smooth = (x) => x * x * (3 - 2 * x); /* smoothstep */
const mod = (a, n) => ((a % n) + n) % n;
/* ── CONFIG ─────────────────────────────────────────────────────────────────── */
const FRAME_W = 1600, FRAME_H = 900; /* the design frame every number is in */
const CW = 168, CH = 210, FGAP = 16, PXP = CW + FGAP, PYP = CH + FGAP; /* a tile and its pitch */
const COLS = 21;
const MIDC = 10; /* the centre column */
const TILT_DEG = 66; /* the camera's tilt over the plane at full tilt */
const PERSPECTIVE = 1200;
const LIFT = 150; /* the plane is lifted 150px as it tilts */
const SKIRT = 3; /* px of anti-aliased edge around each quad */
const RADIUS = 6; /* rounded tile corners */
const SHADOW_A = 0.5; /* neighbour shading: 0 30px 70px rgba(0,0,0,.5) */
const SHADOW_Y = 30;
const SIGMA = 0.0202031; /* 1 / (35·√2): a 70px blur is a Gaussian of σ 35 */
const BIAS_FLAT = -1.5, BIAS_TILT = -1; /* mip bias: flat / tilted */
const ANISO = 8;
const FOG_SOLID = 250, FOG_CLEAR = 520; /* fog stops, px from the top of the 900 frame */
/* the intro, in seconds relative to the moment the field starts coming in */
const RIPPLE_A = -0.1, RIPPLE_B = 0.35; /* each tile fades in over this, after its delay */
const RIPPLE_STEP = 0.07, RIPPLE_MAX = 0.9; /* delay = min(0.9, distance from the nine × 0.07) */
const TILT_A = -0.1, TILT_B = 1.1; /* smoothstep tilt back */
const INTRO_FADE = 0.5; /* the nine fade in over 0.5s… */
const INTRO_AT = 0.9; /* …and the field comes in 0.9s after the page is ready */
/* the flight */
const PERIOD = 25; /* rows before the tile pattern repeats ((r·8 + c·3) mod 25) */
const FOCUS = 12; /* the row the nine sit on */
const AHEAD = 14; /* rows drawn ahead of the camera, at least */
const WHEEL_K = 1; /* plane px per wheel px */
const LINE_PX = 40; /* wheel deltaMode 1 (lines) */
const TOUCH_K = 2; /* plane px per px of finger travel */
const KEY_ROWS = 1, PAGE_ROWS = 4; /* arrow keys: one row; page keys and space: four */
const FOLLOW_K = 5; /* follower: pos += clamp(d × 5, −MAX, MAX) × dt */
const FOLLOW_MAX = 2240; /* px/s */
const IDLE_SPEED = 40; /* px/s, eased in over IDLE_IN after the tilt */
const IDLE_IN = 1.2;
const DPR_MAX = 2;
const LOADER_FADE = 0.5; /* the clock waits for the loader's fade to clear */
/* ── the field ──────────────────────────────────────────────────────────────── */
const PW = COLS * PXP - FGAP; /* the plane's width */
const rowDist = (r) => { const d = mod(r - FOCUS, PERIOD); return d > PERIOD / 2 ? d - PERIOD : d; };
/* a tile: which photograph, its kind (0 one of the nine, 3 the rest) and its ripple delay */
const tileAt = (r, c) => {
if (c < 0 || c >= COLS) return null;
const rr = mod(r, PERIOD), dr = rowDist(rr), dc = c - MIDC;
const nine = Math.abs(dr) <= 1 && Math.abs(dc) <= 1;
return { photo: mod(rr * 8 + c * 3, P.length), kind: nine ? 0 : 3, delay: Math.min(RIPPLE_MAX, Math.hypot(dr, dc) * RIPPLE_STEP) };
};
/* ── WebGL ──────────────────────────────────────────────────────────────────── */
const ERF = "vec4 erf4(vec4 x){ vec4 s = sign(x), a = abs(x); vec4 t = 1.0 + a * (0.278393 + a * (0.230389 + a * (0.000972 + a * 0.078108))); t *= t; t *= t; return s * (1.0 - 1.0 / t); }\n";
/* the perspective, done in the vertex shader (w undivided) */
const PROJECT = `gl_Position = vec4(l.x / uHalf.x, -(l.y * uCam.y + uCam.w) / uHalf.y, 0.0, 1.0 - l.y * uCam.z / ${PERSPECTIVE.toFixed(1)});`;
const VS_TILE = `
attribute vec2 aPos; attribute vec2 aL; attribute vec4 aUV; attribute vec2 aA; attribute vec4 aN;
uniform vec2 uOrigin; uniform vec4 uCam; uniform vec4 uTime; uniform vec2 uHalf;
varying vec2 vL; varying vec4 vUV; varying float vA; varying vec2 vN;
float alphaOf(vec2 a){
if (a.y < -0.5) return 0.0;
return a.y < 0.5 ? 1.0 : smoothstep(uTime.y + a.x, uTime.z + a.x, uTime.x);
}
void main(){
vec2 l = (aPos - uOrigin) * uCam.x;
${PROJECT}
vL = aL; vUV = aUV; vA = alphaOf(aA) * uTime.w; vN = vec2(alphaOf(aN.xy), alphaOf(aN.zw));
}`;
const FS_TILE = ERF + `
uniform sampler2D uTex; uniform vec4 uCell; uniform float uBias;
varying vec2 vL; varying vec4 vUV; varying float vA; varying vec2 vN;
float shadeFrom(vec2 p, vec2 o){ vec2 a = o + vec2(0.0, ${SHADOW_Y.toFixed(1)}); vec4 e = erf4(vec4(p - a, p - a - uCell.xy) * ${SIGMA}); vec2 g = 0.5 * (e.xy - e.zw); return g.x * g.y; }
void main(){
/* rounded-rectangle edge, anti-aliased from the screen derivatives */
vec2 px = vL * uCell.xy, hs = 0.5 * uCell.xy;
vec2 q = abs(px - hs) - (hs - ${RADIUS.toFixed(1)});
float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - ${RADIUS.toFixed(1)};
float aa = max(length(fwidth(px)) * 0.7071, 1e-4);
float cov = clamp(0.5 - dist / aa, 0.0, 1.0);
vec2 c = clamp(vL, 0.0, 1.0); vec2 uv = mix(vUV.xy, vUV.zw, c), dx = dFdx(uv), dy = dFdy(uv);
/* four samples on a rotated grid from a sharper mip level: crisp, and calm in motion */
vec3 rgb = 0.25 * (texture2D(uTex, uv + 0.125 * dx + 0.375 * dy, uBias).rgb + texture2D(uTex, uv - 0.375 * dx + 0.125 * dy, uBias).rgb + texture2D(uTex, uv + 0.375 * dx - 0.125 * dy, uBias).rgb + texture2D(uTex, uv - 0.125 * dx - 0.375 * dy, uBias).rgb);
vec2 p = c * uCell.xy;
float k = (1.0 - ${SHADOW_A.toFixed(2)} * vN.x * shadeFrom(p, vec2(uCell.z, 0.0))) * (1.0 - ${SHADOW_A.toFixed(2)} * vN.y * shadeFrom(p, vec2(0.0, uCell.w)));
OUT = vec4(rgb * k, 1.0) * (vA * cov);
}`;
/* cover fit: the part of an image that shows in a box of another shape, as u0 v0 u1 v1 */
const fill = (ai, ab) => (ai > ab ? [0.5 - ab / ai / 2, 0, 0.5 + ab / ai / 2, 1] : [0, 0.5 - ai / ab / 2, 1, 0.5 + ai / ab / 2]);
/* six vertices of a quad: position, tile coordinate, image part, (delay, kind), right and lower neighbours' (delay, kind) */
const quad = (arr, o, x0, y0, x1, y1, mx, my, uv, a, n) => {
const v = [[x0, y0, -mx, -my], [x1, y0, 1 + mx, -my], [x0, y1, -mx, 1 + my], [x0, y1, -mx, 1 + my], [x1, y0, 1 + mx, -my], [x1, y1, 1 + mx, 1 + my]];
for (const p of v) { arr.set(p, o); arr.set(uv, o + 4); arr.set(a, o + 8); arr.set(n, o + 10); o += 14; }
return o;
};
/* ── setup ──────────────────────────────────────────────────────────────────── */
const stage = document.querySelector("[data-stage]");
const glc = document.querySelector("[data-gl]");
const fog = document.querySelector("[data-fog]");
const loader = document.querySelector("[data-loader]");
const bar = document.querySelector("[data-loader-bar]");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let gl = null, GL = null;
let W = 1, H = 1, S = 1, VW = FRAME_W, VH = FRAME_H;
const photos = P.map((src) => { const im = new Image(); im.crossOrigin = "anonymous"; im.decoding = "async"; im.src = src; return im; });
function glInit() {
const opts = { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: "high-performance" };
let g = glc.getContext("webgl2", opts);
const v2 = !!g;
if (!g) {
g = glc.getContext("webgl", opts);
if (!g || !g.getExtension("OES_standard_derivatives")) { console.error("Scroll Flight: WebGL is not available"); return; }
}
const hv = v2 ? "#version 300 es\n#define attribute in\n#define varying out\n" : "";
const hf = v2 ? "#version 300 es\nprecision highp float;\n#define varying in\n#define texture2D texture\nout vec4 OUT;\n" : "#extension GL_OES_standard_derivatives : enable\nprecision highp float;\n#define OUT gl_FragColor\n";
const shader = (type, src) => {
const s = g.createShader(type); g.shaderSource(s, src); g.compileShader(s);
if (!g.getShaderParameter(s, g.COMPILE_STATUS)) throw new Error("Scroll Flight shader: " + g.getShaderInfoLog(s));
return s;
};
const program = (vs, fs, attrs) => {
const p = g.createProgram();
g.attachShader(p, shader(g.VERTEX_SHADER, hv + vs)); g.attachShader(p, shader(g.FRAGMENT_SHADER, hf + fs));
attrs.forEach((a, i) => g.bindAttribLocation(p, i, a)); g.linkProgram(p);
if (!g.getProgramParameter(p, g.LINK_STATUS)) throw new Error("Scroll Flight program: " + g.getProgramInfoLog(p));
const u = {};
for (let i = 0, n = g.getProgramParameter(p, g.ACTIVE_UNIFORMS); i < n; i++) { const nm = g.getActiveUniform(p, i).name; u[nm] = g.getUniformLocation(p, nm); }
return { p, u };
};
const T = program(VS_TILE, FS_TILE, ["aPos", "aL", "aUV", "aA", "aN"]);
/* textures: mip-mapped and anisotropic, so the far rows stay calm */
const an = g.getExtension("EXT_texture_filter_anisotropic");
const pot = (v) => Math.min(1024, Math.pow(2, Math.ceil(Math.log2(Math.max(2, v)))));
const tex = photos.map((im) => {
const x = g.createTexture(); g.bindTexture(g.TEXTURE_2D, x);
let src = im;
if (!v2 || !im.naturalWidth) { /* WebGL 1 mip-maps only powers of two; failed images get a grey */
const c = document.createElement("canvas"); c.width = pot(im.naturalWidth); c.height = pot(im.naturalHeight);
const d = c.getContext("2d");
if (im.naturalWidth) d.drawImage(im, 0, 0, c.width, c.height); else { d.fillStyle = "#EFEFEF"; d.fillRect(0, 0, c.width, c.height); }
src = c;
}
g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, src); g.generateMipmap(g.TEXTURE_2D);
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR_MIPMAP_LINEAR); g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE); g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
if (an) g.texParameterf(g.TEXTURE_2D, an.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(ANISO, g.getParameter(an.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
return x;
});
const asp = photos.map((im) => (im.naturalWidth ? im.naturalWidth / im.naturalHeight : 0.75));
/* one period of the plane, grouped by photograph so each photograph is one draw */
const by = P.map(() => []);
for (let r = 0; r < PERIOD; r++) for (let c = 0; c < COLS; c++) by[tileAt(r, c).photo].push([r, c]);
const plane = new Float32Array(PERIOD * COLS * 6 * 14), ranges = [];
let o = 0;
const none = [0, -1], tag = (q) => (q ? [q.delay, q.kind] : none);
by.forEach((list, i) => {
const first = o / 14;
list.forEach(([r, c]) => {
const q = tileAt(r, c);
o = quad(plane, o, c * PXP - SKIRT, r * PYP - SKIRT, c * PXP + CW + SKIRT, r * PYP + CH + SKIRT, SKIRT / CW, SKIRT / CH,
fill(asp[i], CW / CH), [q.delay, q.kind], [...tag(tileAt(r, c + 1)), ...tag(tileAt(r + 1, c))]);
});
ranges.push([first, o / 14 - first]);
});
const planeBuf = g.createBuffer(); g.bindBuffer(g.ARRAY_BUFFER, planeBuf); g.bufferData(g.ARRAY_BUFFER, plane, g.STATIC_DRAW);
g.disable(g.DEPTH_TEST); g.enable(g.BLEND); g.blendFunc(g.ONE, g.ONE_MINUS_SRC_ALPHA); g.clearColor(0, 0, 0, 0); g.activeTexture(g.TEXTURE0);
const tiles = (buf) => {
g.bindBuffer(g.ARRAY_BUFFER, buf);
[[0, 2, 0], [1, 2, 8], [2, 4, 16], [3, 2, 32], [4, 4, 40]].forEach(([loc, n, off]) => { g.enableVertexAttribArray(loc); g.vertexAttribPointer(loc, n, g.FLOAT, false, 56, off); });
};
gl = g;
GL = { T, tex, ranges, planeBuf, tiles, max: g.getParameter(g.MAX_RENDERBUFFER_SIZE) };
}
glc.addEventListener("webglcontextlost", (e) => { e.preventDefault(); gl = null; GL = null; });
glc.addEventListener("webglcontextrestored", () => { try { glInit(); } catch (err) { console.error(err); } });
/* ── size ───────────────────────────────────────────────────────────────────── */
function measure() {
W = Math.max(1, stage.clientWidth); H = Math.max(1, stage.clientHeight);
S = Math.max(W / FRAME_W, Math.min(H / FRAME_H, W / 640)); /* fill the viewport */
VW = W / S; VH = H / S;
fog.style.setProperty("--fog-a", (H / 2 - (FRAME_H / 2 - FOG_SOLID) * S).toFixed(1) + "px");
fog.style.setProperty("--fog-b", (H / 2 + (FOG_CLEAR - FRAME_H / 2) * S).toFixed(1) + "px");
const dpr = Math.min(window.devicePixelRatio || 1, DPR_MAX);
const max = GL ? GL.max : 8192;
const w = Math.min(max, Math.round(W * dpr)), h = Math.min(max, Math.round(H * dpr));
if (glc.width !== w || glc.height !== h) { glc.width = w; glc.height = h; }
}
window.addEventListener("resize", measure);
/* ── input: everything pushes one target; the camera follows it ─────────────── */
let target = 0, pos = 0, idle = 0; /* plane px flown forward */
const push = (d) => { target += d; };
window.addEventListener("wheel", (e) => {
e.preventDefault();
const k = e.deltaMode === 1 ? LINE_PX : e.deltaMode === 2 ? H : 1;
const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
push(d * k * WHEEL_K / S);
}, { passive: false });
let dragId = null, dragY = 0;
stage.addEventListener("pointerdown", (e) => {
dragId = e.pointerId; dragY = e.clientY;
stage.setPointerCapture(e.pointerId); stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (e) => {
if (e.pointerId !== dragId) return;
push((dragY - e.clientY) * TOUCH_K / S); /* drag up: forward, into the distance */
dragY = e.clientY;
});
const endDrag = (e) => { if (e.pointerId !== dragId) return; dragId = null; stage.classList.remove("is-dragging"); };
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
window.addEventListener("keydown", (e) => {
if (e.target.closest && e.target.closest("a, button, input, textarea")) return;
const rows = { ArrowDown: KEY_ROWS, ArrowUp: -KEY_ROWS, PageDown: PAGE_ROWS, PageUp: -PAGE_ROWS, " ": e.shiftKey ? -PAGE_ROWS : PAGE_ROWS }[e.key];
if (rows) { e.preventDefault(); push(rows * PYP); }
});
/* ── a frame ────────────────────────────────────────────────────────────────── */
function draw(ti, fade, fy) {
if (!GL) return;
const g = gl;
g.viewport(0, 0, glc.width, glc.height);
g.clear(g.COLOR_BUFFER_BIT);
const tilt = smooth(seg(ti, TILT_A, TILT_B));
const th = TILT_DEG * tilt * Math.PI / 180, cs = Math.cos(th), sn = Math.sin(th), lift = LIFT * tilt;
const cam = (u, oy) => { g.uniform2f(u.uOrigin, PW / 2, oy); g.uniform4f(u.uCam, 1, cs, sn, lift); g.uniform2f(u.uHalf, VW / 2, VH / 2); };
const span = PERIOD * PYP;
const k0 = Math.floor((fy / PYP - AHEAD) / PERIOD); /* the two periods that cover the rows in view */
g.useProgram(GL.T.p); GL.tiles(GL.planeBuf);
g.uniform4f(GL.T.u.uTime, ti, RIPPLE_A, RIPPLE_B, fade);
g.uniform4f(GL.T.u.uCell, CW, CH, PXP, PYP); g.uniform1i(GL.T.u.uTex, 0); g.uniform1f(GL.T.u.uBias, lerp(BIAS_FLAT, BIAS_TILT, tilt));
for (const k of [k0, k0 + 1]) {
cam(GL.T.u, fy - k * span);
GL.ranges.forEach((q, i) => { if (!q[1]) return; g.bindTexture(g.TEXTURE_2D, GL.tex[i]); g.drawArrays(g.TRIANGLES, q[0], q[1]); });
}
return tilt;
}
/* ── the clock ──────────────────────────────────────────────────────────────── */
const FY0 = FOCUS * PYP + CH / 2; /* the camera starts over the nine */
let clock = -LOADER_FADE, last = 0, fogO = -1;
function frame(now) {
const dt = last ? Math.min((now - last) / 1000, 1 / 20) : 0;
last = now;
clock += dt;
const ti = reduced ? 1e3 : clock - INTRO_AT;
const fade = reduced ? 1 : smooth(seg(clock, 0, INTRO_FADE));
/* follower; reduced motion: no glide and no drift, straight to the target */
if (reduced) pos = target;
else {
const d = target - pos;
if (Math.abs(d) < 0.4) pos = target;
else pos += clamp(d * FOLLOW_K, -FOLLOW_MAX, FOLLOW_MAX) * dt;
idle += IDLE_SPEED * smooth(seg(ti, TILT_B, TILT_B + IDLE_IN)) * dt;
}
const tilt = draw(ti, fade, FY0 - pos - idle);
const fo = (tilt || 0) * fade;
if (fo !== fogO) { fogO = fo; fog.style.opacity = fo; }
requestAnimationFrame(frame);
}
/* ── load everything before the clock starts ────────────────────────────────── */
let loadedN = 0;
const loaded = (im) => new Promise((res) => {
const done = () => { loadedN++; bar.style.transform = "scaleX(" + loadedN / photos.length + ")"; res(); };
if (im.complete) done(); else { im.onload = done; im.onerror = done; }
});
Promise.all(photos.map(loaded))
.then(() => Promise.all(photos.map((im) => (im.decode ? im.decode().catch(() => {}) : null))))
.then(() => {
try { glInit(); } catch (err) { console.error(err); gl = null; GL = null; }
measure();
loader.classList.add("is-done");
loader.addEventListener("transitionend", () => loader.remove(), { once: true });
requestAnimationFrame(frame);
});
})();
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Scroll Flight: an endless field of photos in perspective that you fly over by scrolling
**Goal:** build a full-screen scene where a flat grid of photos tilts back into perspective, and scrolling flies the camera low over it, row after row streaming into a white fog, forever.
## Visual behaviour
1. **Start state.** A white, full-viewport stage with no page scroll. While the photos load, show only a thin centred progress line (160 × 1 px, light grey track, black bar that grows with the number of images loaded). When everything is loaded and decoded, the loader fades out over 0.6 s.
2. **The nine.** 0.5 s after the loader starts fading, nine photo tiles in a flat 3 × 3 block in the centre of the screen fade in over 0.5 s (smoothstep). They are the centre of a much larger field, lying flat, seen straight from above.
3. **Ripple in.** Call the moment 0.9 s after the nine start fading in `t0`. The rest of the field fades in around them as a ripple: each tile fades in (smoothstep) from `t0 − 0.1 s + delay` to `t0 + 0.35 s + delay`, with `delay = min(0.9 s, 0.07 s × distance)`, the distance in tiles from the centre tile (`hypot(rowDistance, columnDistance)`).
4. **Tilt back.** From `t0 − 0.1 s` to `t0 + 1.1 s` (1.2 s, smoothstep), the whole plane tilts back to 66° around its horizontal axis and rises 150 px. The far rows recede towards the top of the screen.
5. **Fog.** A white gradient covers the far rows: solid white down to 250 px of a 900 px frame, fading to transparent at 520 px. Its opacity follows the tilt (0 when flat, 1 at full tilt), so the field seems to vanish into white mist.
6. **Idle drift.** After the tilt, the camera starts drifting forward at 40 px/s, eased in over 1.2 s (smoothstep), so the field is never quite still.
7. **Scroll to fly.** Wheel, trackpad, mouse drag, touch drag and keys all push one target distance; the camera follows it with a smoothing follower, so the field glides and carries momentum. Scrolling down (or dragging up) flies forward, into the distance; scrolling up flies back.
8. **Endless.** The tile pattern repeats every 25 rows, and two copies of that 25-row block are drawn end to end around the camera, so the flight never runs out in either direction.
## Exact values
All sizes are in a 1600 × 900 design frame, scaled to fill the viewport with `scale = max(W / 1600, min(H / 900, W / 640))` (fills wide screens; phones see about three columns).
| What | Value |
|---|---|
| Tile | 168 × 210 px (portrait), 6 px rounded corners, photo `cover`-fitted |
| Gap | 16 px (row pitch 226 px, column pitch 184 px) |
| Columns | 21, centred on column 10 |
| Repeat | 25 rows; photo index for row r, column c = `(r × 8 + c × 3) mod 25` with 25 photos |
| The nine | rows 11–13, columns 9–11 of each 25-row block |
| Camera start | centred over the nine |
| Tilt | 0° → 66°, smoothstep, 1.2 s, perspective 1200 px, plane lifted 150 px at full tilt |
| Ripple | per tile 0.45 s smoothstep fade, from `t0 − 0.1 + delay`, delay `min(0.9, distance × 0.07)` s |
| Fog | white, solid to 250 px, clear at 520 px (of 900, measured from the frame centre), opacity = tilt |
| Wheel | 1 plane px per wheel px (line mode: 40 px per line), divided by the frame scale |
| Drag / touch | 2 plane px per px of vertical pointer travel, drag up = forward |
| Keys | ↓ / ↑: one row (226 px); PageDown / PageUp / Space (Shift+Space back): four rows |
| Follower | `pos += clamp((target − pos) × 5, −2240, 2240) × dt`; snap when within 0.4 px |
| Idle drift | 40 px/s forward, eased in over 1.2 s after the tilt ends |
| Neighbour shading | each tile is darkened by the soft shadow (offset 0 30 px, blur 70 px, 50% black) of the tile to its right and the tile below it, once those have rippled in |
## Structure
```html
<div class="stage"> <!-- position: fixed; inset: 0; white; touch-action: none -->
<canvas class="gl"></canvas> <!-- fills the stage -->
<div class="fog"></div> <!-- white linear-gradient over the top; pointer-events: none -->
</div>
<div class="loader"><div class="track"><span class="bar"></span></div></div>
```
- `html, body { height: 100%; overflow: hidden }`. Listen for `wheel` on `window` with `{ passive: false }` and call `preventDefault()`.
- Use pointer events with `setPointerCapture` for drags; show `cursor: grab` / `grabbing`.
- Cap the device pixel ratio at 2.
## Rendering
- **Raw WebGL** (WebGL 2, falling back to WebGL 1 with `OES_standard_derivatives`): one canvas, one quad per tile, built once for a 25-row block and drawn twice (offset by one block) for the two blocks around the camera. Group quads by photo so each photo is one draw call.
- Do the perspective in the vertex shader: rotate the plane's y by the tilt (`y' = y·cos θ + lift`, depth `= y·sin θ`) and divide by `w = 1 − depth / 1200`.
- Draw each quad 3 px larger than the tile and compute a rounded-rectangle signed distance in the fragment shader, anti-aliased with `fwidth`, so edges stay smooth at grazing angles.
- Textures: mip-mapped (`LINEAR_MIPMAP_LINEAR`) with up to 8× anisotropic filtering, so the far rows don't shimmer. Sharpen with a negative mip bias (−1.5 flat, −1 tilted) and average four samples on a rotated grid.
- Per-tile fade and ripple delay are vertex attributes; the shader computes `smoothstep(delay − 0.1, delay + 0.35, t)`.
- **Alternative:** Three.js with a `PlaneGeometry` per tile (or an `InstancedMesh`), a `PerspectiveCamera` tilted to 66°, and `MeshBasicMaterial` with anisotropic textures. A CSS 3D version (`perspective: 1200px` on the stage, `rotateX(66deg)` on a grid of `<img>`) also works for a smaller field, but far rows will shimmer.
## Responsive and reduced motion
- On resize, recompute the frame scale, the fog stops and the canvas size.
- `prefers-reduced-motion: reduce`: skip the intro (show the field already tilted with the fog), no idle drift, and no gliding: the camera jumps straight to the scroll target.
## Acceptance criteria
- After loading, nine photos appear flat in the centre, the field ripples in around them from the centre outwards, and the plane tilts back smoothly into perspective with the far rows disappearing into white.
- The field then drifts slowly forward on its own.
- Scrolling down flies forward with smooth momentum; scrolling up flies back. Drags, touch and keys do the same.
- You can scroll forever in both directions without reaching an edge, and the 21 columns cover the width of a wide screen.
- Tile corners stay round and clean, and far rows don't flicker or alias while moving.
- With reduced motion, the tilted field is shown immediately and moves only as you scroll.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 5f4d8986f94dee334c645f0cf6d9cb856812a022318aa339002689bf5eee227d