모션·인터랙션 / Cadence
Inside the Sphere
갤러리 · 내비게이션 · 3D · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Inside the Sphere
An endless, column-staggered photo grid wrapped around the viewer, as if they stand at
the centre of a sphere. One full-screen shader draws it: every pixel becomes a viewing ray
from the centre, and the ray's two angles (asin of its x and y components) become a
position on the grid, so a tile's size is fixed in angle rather than on screen. Near the
centre the grid looks flat; toward the edges and corners it curves away, like a grid
painted on the inside of a sphere seen through a wide lens.
- Drag in any direction to turn the grid; on release it glides on (friction 0.94 per
60fps frame, release velocity from the last 80 ms). The wheel / trackpad turns it too.
- Left alone, it drifts slowly sideways (idle orbit).
- While dragging, the lens widens slightly (×1.07) and eases back after.
- Hovering a tile lifts it a little (scale 1.06).
- Intro: tiles grow (0.55 → 1) and fade in as a wave from the centre outwards.
All photos share one texture atlas (a 2D canvas), assigned as a Latin square
(image = (column + 7 × row) mod 79), so no neighbour repeats a photo.
Dependencies: three@0.182.0 (ES module via the import map), from jsDelivr.
Tweak (constants at the top of the script):
- HALF_FOV 38° (centre-to-corner view angle: the amount of curve), DRAG_FOV_BOOST 1.07
- TILE 0.132 × 0.176 rad (3:4), GAP 0.059 rad, STAGGER 0.5, CORNER 0.0085 rad
- PHONE_SCALE 1.4 below PHONE_BREAK 640px
- FRICTION 0.94, VELOCITY_SAMPLE_MS 80, VELOCITY_CAP 0.0025 rad/ms
- IDLE_DRIFT 0.021 rad/s, HOVER_SCALE 1.06
- Intro: INTRO_DELAY_PER_RAD 1.2 s, INTRO_TILE_DURATION 1.3 s (ease-out cubic), INTRO_START_SCALE 0.55
Original work by Ruben Stom (a cross of an orbiting photo sphere and a draggable,
endless photo grid).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inside the Sphere</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #000; }
.stage {
position: fixed;
inset: 0;
cursor: grab;
touch-action: none; /* drags turn the grid, not the page */
user-select: none;
-webkit-user-select: none;
}
.stage.is-dragging { cursor: grabbing; }
.stage canvas { display: block; width: 100%; height: 100%; }
.replay {
position: fixed;
top: 16px;
right: 16px;
z-index: 3;
padding: 8px 14px;
border: 1px solid #e6e6e6;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
}
/* Hairline loader: fills while the photos load, then fades out. */
.loader {
position: fixed;
inset: 0;
z-index: 2;
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>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js"
}
}
</script>
</head>
<body>
<div class="stage" data-stage></div>
<button class="replay" type="button" data-replay>Replay</button>
<div class="loader" data-loader aria-hidden="true">
<div class="loader-track"><span class="loader-bar" data-loader-bar></span></div>
</div>
<script type="module">
import * as THREE from "three";
/* Constants ------------------------------------------------------------------ */
// Placeholder images from picsum.photos (CORS-enabled, so the atlas canvas stays usable
// by WebGL). Swap in your own portrait 3:4 photos; 79 pairs with LATIN_K = 7 (if you change
// the count, pick a K so that neighbours never repeat, e.g. one coprime with the count).
const IMAGES = Array.from({ length: 79 }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/360/480`);
const IMAGE_COUNT = IMAGES.length;
const ATLAS_COLS = 10;
const ATLAS_ROWS = Math.ceil(IMAGE_COUNT / ATLAS_COLS);
const ATLAS_CELL = { w: 360, h: 480 }; // px per photo in the atlas (3:4): 3600 × 3840 in all
const CROP = 0.04; // cropped off each side of a photo (hides frame edges on film scans; 0 = none)
const LATIN_K = 7; // image = (c + K·r) mod N (7 suits N = 79)
const HALF_FOV = THREE.MathUtils.degToRad(38); // view angle from the centre to a corner, so
// the warp at the corners is the same on wide, square and tall screens
const DRAG_FOV_BOOST = 1.07; // the lens widens by this much while dragging
const TILE = { w: 0.132, h: 0.176 }; // tile size in radians (3:4)
const GAP = 0.059; // radians between tiles
const STAGGER = 0.5; // odd columns shift by half a row
const CORNER = 0.0085; // corner radius, radians (about 6px at the centre)
const PHONE_BREAK = 640;
const PHONE_SCALE = 1.4; // tiles, gaps and corners are bigger on phones
const FRICTION = 0.94; // velocity decay per 60fps frame
const VELOCITY_SAMPLE_MS = 80;
const VELOCITY_CAP = 0.0025; // radians per ms
const VELOCITY_STOP = 0.000012;
const IDLE_DRIFT = 0.021; // radians per second, the idle orbit
const HOVER_SCALE = 1.06;
const INTRO_DELAY_PER_RAD = 1.2; // s of delay per radian from the centre
const INTRO_TILE_DURATION = 1.3; // s for each tile to grow in
const INTRO_START_SCALE = 0.55;
const PIXEL_RATIO_MAX = 2;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Shader ---------------------------------------------------------------------- */
const vertexShader = /* glsl */ `
void main() {
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform vec2 uRes; // drawing buffer, px
uniform float uTan; // tan(view angle from the centre to a corner)
uniform vec2 uOffset; // grid turn, radians
uniform vec2 uStep; // column / row pitch, radians
uniform vec2 uTile; // tile size, radians
uniform float uCorner; // corner radius, radians
uniform float uStagger;
uniform sampler2D uAtlas;
uniform float uIntro; // seconds since the intro started
uniform vec2 uHoverCell;
uniform float uHover; // 0..1
const float COUNT = ${IMAGE_COUNT.toFixed(1)};
const float COLS = ${ATLAS_COLS.toFixed(1)};
const float ROWS = ${ATLAS_ROWS.toFixed(1)};
const float K = ${LATIN_K.toFixed(1)};
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
// A ray from the centre of the sphere through this pixel…
vec2 q = (gl_FragCoord.xy - 0.5 * uRes) / (0.5 * length(uRes)); // 1 at the corners
vec3 ray = normalize(vec3(q * uTan, 1.0));
// …and its two angles: where it meets the grid wrapped around the viewer.
vec2 ang = vec2(asin(ray.x), asin(ray.y));
vec2 g = (ang + uOffset) / uStep; // grid space, in cells
float col = floor(g.x);
float shift = mod(col, 2.0) * uStagger;
float gy = g.y + shift;
float row = floor(gy);
vec2 cell = vec2(col, row);
vec2 local = (vec2(fract(g.x), fract(gy)) - 0.5) * uStep; // radians from the cell centre
// Intro: a wave from the centre of the view outwards.
vec2 centre = (cell + 0.5 - vec2(0.0, shift)) * uStep - uOffset;
float delay = length(centre) * ${INTRO_DELAY_PER_RAD.toFixed(3)} + hash(cell) * 0.12;
float t = clamp((uIntro - delay) / ${INTRO_TILE_DURATION.toFixed(3)}, 0.0, 1.0);
float e = 1.0 - pow(1.0 - t, 3.0);
float scale = mix(${INTRO_START_SCALE.toFixed(3)}, 1.0, e);
if (cell == uHoverCell) scale *= 1.0 + ${(HOVER_SCALE - 1).toFixed(3)} * uHover;
vec2 hs = 0.5 * uTile * scale;
// Rounded tile (signed distance, in radians), antialiased over about a pixel.
vec2 dq = abs(local) - hs + uCorner;
float dist = length(max(dq, 0.0)) + min(max(dq.x, dq.y), 0.0) - uCorner;
float aa = length(fwidth(ang)) * 0.7;
float inside = 1.0 - smoothstep(-aa, aa, dist);
// The tile's photo from the atlas (Latin-square assignment: no neighbour repeats).
float index = mod(col + K * row, COUNT);
vec2 slot = vec2(mod(index, COLS), ROWS - 1.0 - floor(index / COLS));
vec2 uv = clamp(local / (2.0 * hs) + 0.5, 0.004, 0.996);
vec2 atlasUv = (slot + uv) / vec2(COLS, ROWS);
// Gradients from the continuous angles, so cell edges don't cause mip seams.
vec2 gx = dFdx(ang) / (2.0 * hs) / vec2(COLS, ROWS);
vec2 gy2 = dFdy(ang) / (2.0 * hs) / vec2(COLS, ROWS);
vec3 photo = textureGrad(uAtlas, atlasUv, gx, gy2).rgb;
gl_FragColor = vec4(mix(vec3(1.0), photo, inside * e), 1.0);
}
`;
/* Scene ----------------------------------------------------------------------- */
const stage = document.querySelector("[data-stage]");
const loaderEl = document.querySelector("[data-loader]");
const bar = document.querySelector("[data-loader-bar]");
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
renderer.setClearColor(0xffffff, 1);
stage.append(renderer.domElement);
const uniforms = {
uRes: { value: new THREE.Vector2() },
uTan: { value: Math.tan(HALF_FOV) },
uOffset: { value: new THREE.Vector2() },
uStep: { value: new THREE.Vector2(TILE.w + GAP, TILE.h + GAP) },
uTile: { value: new THREE.Vector2(TILE.w, TILE.h) },
uCorner: { value: CORNER },
uStagger: { value: STAGGER },
uAtlas: { value: null },
uIntro: { value: reducedMotion ? 1e3 : 0 },
uHoverCell: { value: new THREE.Vector2(1e6, 1e6) },
uHover: { value: 0 },
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })));
let cssW = 0;
let cssH = 0;
function resize() {
cssW = stage.clientWidth;
cssH = stage.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_MAX));
renderer.setSize(cssW, cssH, false);
renderer.getDrawingBufferSize(uniforms.uRes.value);
const k = cssW < PHONE_BREAK ? PHONE_SCALE : 1;
uniforms.uTile.value.set(TILE.w * k, TILE.h * k);
uniforms.uStep.value.set((TILE.w + GAP) * k, (TILE.h + GAP) * k);
uniforms.uCorner.value = CORNER * k;
}
resize();
window.addEventListener("resize", resize);
/* Atlas: all photos in one texture ---------------------------------------------- */
function loadImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous"; // needed for remote images in a WebGL texture
img.decoding = "async";
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
async function buildAtlas() {
const canvas = document.createElement("canvas");
canvas.width = ATLAS_COLS * ATLAS_CELL.w;
canvas.height = ATLAS_ROWS * ATLAS_CELL.h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#efefef";
ctx.fillRect(0, 0, canvas.width, canvas.height);
let loaded = 0;
await Promise.all(
IMAGES.map(async (src, i) => {
const img = await loadImage(src);
if (img) {
// Crop a little off every side: some photos are film scans with black frame edges.
const cx = img.naturalWidth * CROP;
const cy = img.naturalHeight * CROP;
ctx.drawImage(
img, cx, cy, img.naturalWidth - 2 * cx, img.naturalHeight - 2 * cy,
(i % ATLAS_COLS) * ATLAS_CELL.w, Math.floor(i / ATLAS_COLS) * ATLAS_CELL.h, ATLAS_CELL.w, ATLAS_CELL.h
);
}
loaded++;
bar.style.transform = `scaleX(${loaded / IMAGE_COUNT})`;
})
);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.NoColorSpace; // the shader writes the photo's values as they are
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
return texture;
}
/* Interaction: drag, glide, wheel, idle drift, hover ------------------------------- */
const offset = uniforms.uOffset.value;
const velocity = { x: 0, y: 0 }; // radians per ms
let dragging = false;
let samples = [];
let lastX = 0;
let lastY = 0;
let lens = 1; // eased toward DRAG_FOV_BOOST while dragging
let drift = reducedMotion ? 0 : 1; // eased back in after a drag
const pointer = { x: 0, y: 0, inside: false };
// Radians per CSS pixel at the centre of the view.
const halfDiagonal = () => Math.hypot(cssW, cssH) / 2;
const radPerPx = () => uniforms.uTan.value / halfDiagonal();
stage.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
dragging = true;
stage.classList.add("is-dragging");
stage.setPointerCapture(event.pointerId);
lastX = event.clientX;
lastY = event.clientY;
velocity.x = velocity.y = 0;
samples = [{ t: performance.now(), x: offset.x, y: offset.y }];
});
stage.addEventListener("pointermove", (event) => {
pointer.x = event.clientX;
pointer.y = event.clientY;
pointer.inside = true;
if (!dragging) return;
const k = radPerPx();
// Drag right → the grid follows right; drag down → it follows down.
offset.x -= (event.clientX - lastX) * k;
offset.y += (event.clientY - lastY) * k;
lastX = event.clientX;
lastY = event.clientY;
const now = performance.now();
samples.push({ t: now, x: offset.x, y: offset.y });
while (samples.length > 2 && now - samples[0].t > VELOCITY_SAMPLE_MS) samples.shift();
});
function release(event) {
if (!dragging) return;
dragging = false;
stage.classList.remove("is-dragging");
if (stage.hasPointerCapture(event.pointerId)) stage.releasePointerCapture(event.pointerId);
if (event.pointerType !== "mouse") pointer.inside = false; // touch: no lingering hover
const first = samples[0];
const last = samples[samples.length - 1];
const dt = last.t - first.t;
if (reducedMotion || dt <= 0 || performance.now() - last.t > VELOCITY_SAMPLE_MS) return;
const clamp = (v) => Math.max(-VELOCITY_CAP, Math.min(VELOCITY_CAP, v));
velocity.x = clamp((last.x - first.x) / dt);
velocity.y = clamp((last.y - first.y) / dt);
}
stage.addEventListener("pointerup", release);
stage.addEventListener("pointercancel", release);
stage.addEventListener("pointerleave", () => {
if (!dragging) pointer.inside = false;
});
stage.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const k = radPerPx() * 0.6;
offset.x += event.deltaX * k;
offset.y -= event.deltaY * k;
},
{ passive: false }
);
// The cell under the pointer, with the shader's maths (for the hover lift).
function cellAt(clientX, clientY) {
const rect = stage.getBoundingClientRect();
const m = halfDiagonal();
const qx = (clientX - rect.left - cssW / 2) / m;
const qy = -(clientY - rect.top - cssH / 2) / m;
const t = uniforms.uTan.value;
const len = Math.hypot(qx * t, qy * t, 1);
const step = uniforms.uStep.value;
const gx = (Math.asin((qx * t) / len) + offset.x) / step.x;
const gy = (Math.asin((qy * t) / len) + offset.y) / step.y;
const col = Math.floor(gx);
const shift = (((col % 2) + 2) % 2) * STAGGER;
const row = Math.floor(gy + shift);
// Only over the tile itself, not the gap around it.
const lx = (gx - col - 0.5) * step.x;
const ly = (gy + shift - row - 0.5) * step.y;
const tile = uniforms.uTile.value;
if (Math.abs(lx) > tile.x / 2 || Math.abs(ly) > tile.y / 2) return null;
return { col, row };
}
/* Intro + loop ------------------------------------------------------------------- */
const timer = new THREE.Timer();
let introStart = null;
function playIntro() {
if (reducedMotion) return;
introStart = timer.getElapsed();
}
let previous = performance.now();
function frame(now) {
requestAnimationFrame(frame);
timer.update(now);
const dt = Math.min(now - previous, 64); // ms, capped after a stall
previous = now;
const frames = dt / (1000 / 60);
// Glide after a release, then let the idle drift back in.
if (!dragging && (Math.abs(velocity.x) > VELOCITY_STOP || Math.abs(velocity.y) > VELOCITY_STOP)) {
offset.x += velocity.x * dt;
offset.y += velocity.y * dt;
const decay = Math.pow(FRICTION, frames);
velocity.x *= decay;
velocity.y *= decay;
} else {
velocity.x = velocity.y = 0;
}
const gliding = velocity.x !== 0 || velocity.y !== 0;
if (!reducedMotion) {
drift += ((dragging || gliding ? 0 : 1) - drift) * (1 - Math.pow(0.97, frames));
offset.x += IDLE_DRIFT * drift * (dt / 1000);
}
// The lens widens a touch while dragging.
lens += ((dragging ? DRAG_FOV_BOOST : 1) - lens) * (1 - Math.pow(0.9, frames));
uniforms.uTan.value = Math.tan(HALF_FOV) * lens;
// Hover lift: ease toward the tile under the pointer.
const hovered = pointer.inside && !dragging ? cellAt(pointer.x, pointer.y) : null;
const hc = uniforms.uHoverCell.value;
if (hovered && (hovered.col !== hc.x || hovered.row !== hc.y)) {
if (uniforms.uHover.value < 0.05) hc.set(hovered.col, hovered.row);
else uniforms.uHover.value *= Math.pow(0.8, frames); // let the old tile settle first
}
const sameCell = hovered && hovered.col === hc.x && hovered.row === hc.y;
uniforms.uHover.value += ((sameCell ? 1 : 0) - uniforms.uHover.value) * (1 - Math.pow(0.85, frames));
if (introStart !== null) uniforms.uIntro.value = timer.getElapsed() - introStart;
renderer.render(scene, camera);
}
/* Start ---------------------------------------------------------------------------- */
buildAtlas().then((texture) => {
uniforms.uAtlas.value = texture;
loaderEl.classList.add("is-done");
loaderEl.addEventListener("transitionend", () => loaderEl.remove(), { once: true });
playIntro();
requestAnimationFrame(frame);
document.querySelector("[data-replay]").addEventListener("click", playIntro);
});
</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>
원본 구현 프롬프트
# Inside the Sphere: an endless draggable photo grid wrapped around the viewer (WebGL shader) **Goal:** build a full-screen, endless, draggable grid of portrait photos that looks as if it is painted on the inside of a sphere around the viewer: flat in the middle, curving away toward the edges and corners. ## Visual behaviour 1. **Projection.** One full-screen fragment shader draws everything (no meshes per tile). For each pixel: - `q = (fragCoord − 0.5 × resolution) / (0.5 × length(resolution))`, so `|q| = 1` at the corners. - `ray = normalize(vec3(q × tan(halfFov), 1))` with `halfFov = 38°` (centre to corner). - `ang = vec2(asin(ray.x), asin(ray.y))`: the ray's two angles. - Grid position in cells: `g = (ang + offset) / step`, where `offset` is the current turn (radians) and `step = tile + gap`. Because tiles are sized in **radians**, not pixels, rows and columns stay straight near the centre and bend toward the corners. Using the diagonal for `q` keeps the corner warp identical on wide, square and tall screens. 2. **Grid.** Tiles are 0.132 × 0.176 rad (3:4), with 0.059 rad gaps. Odd columns shift down by half a row (`shift = mod(col, 2) × 0.5`). Tiles have a 0.0085 rad corner radius (about 6px at the centre), drawn with a rounded-box signed distance, antialiased over about one pixel (`aa = length(fwidth(ang)) × 0.7`). The background between tiles is white. On screens narrower than 640px, tile size, gap and corner radius are ×1.4. 3. **Photos.** All photos are drawn into one texture atlas (2D canvas, 10 columns, 360×480px per cell, 4% cropped off each side), uploaded once with mipmaps and maximum anisotropy. Tile `(col, row)` shows photo `(col + 7 × row) mod 79` (a Latin square: no two neighbours share a photo; with 79 photos, 7 works). Sample with `textureGrad` using gradients from the continuous angles, so tile edges don't cause mip seams. 4. **Drag.** Pointer drag turns the grid 1:1 with the pointer at the centre: radians per CSS pixel = `tan(halfFov) / (half the viewport diagonal)`. Drag right → grid moves right; drag down → grid moves down. On release, it keeps gliding with the velocity measured over the last 80 ms (capped at 0.0025 rad/ms), decaying by ×0.94 per 60fps frame (frame-rate independent: `pow(0.94, dt / 16.67)`), stopping below 0.000012 rad/ms. 5. **Wheel / trackpad** turns it too: `offset.x += deltaX × k × 0.6`, `offset.y −= deltaY × k × 0.6` (k = radians per pixel). 6. **Idle drift.** When not dragging or gliding, the grid drifts sideways at 0.021 rad/s. The drift fades out during a drag and eases back in after (`drift += (target − drift) × (1 − pow(0.97, frames))`). 7. **Lens breathing.** While dragging, `tan(halfFov)` is multiplied by a lens factor that eases to 1.07 (the grid steps back a little), and back to 1 after release (`× (1 − pow(0.9, frames))` per frame). 8. **Hover.** The tile under the pointer (same projection maths run in JS, only over the tile itself, not the gap) scales up to 1.06, easing in at `1 − pow(0.85, frames)`. When the pointer moves to a new tile, the old one settles before the new one lifts. 9. **Intro.** After the atlas is ready (a hairline loader shows progress until then), the tiles appear as a wave from the centre outwards: each tile's delay = `distance of its centre from the view centre (rad) × 1.2 s + random(0–0.12 s)`; over 1.3 s it scales 0.55 → 1 and fades from white with ease-out cubic (`1 − (1 − t)³`). A replay control restarts the wave. ## Exact values | What | Value | |---|---| | View angle (centre → corner) | 38° | | Tile / gap | 0.132 × 0.176 rad / 0.059 rad; corner 0.0085 rad | | Column stagger | 0.5 row | | Phone scale | ×1.4 below 640px | | Friction | 0.94 per 60fps frame; velocity window 80 ms; cap 0.0025 rad/ms | | Idle drift | 0.021 rad/s, eased back in at 0.97 per frame | | Drag lens | ×1.07, eased at 0.9 per frame | | Hover lift | ×1.06, eased at 0.85 per frame | | Intro | 1.2 s per radian + 0–0.12 s jitter; 1.3 s per tile; scale 0.55 → 1; ease-out cubic | | Atlas | 10 columns, 360×480 per photo, 4% crop per side, mipmaps | | Pixel ratio | capped at 2 | ## Structure - A fixed full-viewport stage with the canvas (`cursor: grab`, `grabbing` while dragging, `touch-action: none`, no text selection). - A full-screen orthographic quad with the shader; uniforms: resolution, `tan(halfFov)`, offset, step, tile, corner, stagger, atlas, intro time, hover cell and amount. - Pointer events on the stage with pointer capture; the wheel listener is non-passive and calls `preventDefault()`. - A hairline loader that fades out (0.6 s) when the atlas is ready; optional Replay button. ## Libraries - **Three.js** for the renderer, one `ShaderMaterial` and a `CanvasTexture`. Any WebGL setup works: it is a single full-screen fragment shader. - Remote images need `crossOrigin = "anonymous"` before drawing them into the atlas canvas. ## Responsive and reduced motion - On resize: update the drawing buffer size and resolution uniform; switch tile scale at 640px. - Touch: no lingering hover after a drag ends. - `prefers-reduced-motion: reduce`: no intro (tiles shown at once), no idle drift and no glide after release. Dragging still turns the grid. ## Acceptance criteria - The grid fills the screen edge to edge and never ends in any direction. - Rows and columns look straight in the centre and curve visibly toward the corners, the same amount on any aspect ratio. - Dragging moves the grid exactly under the pointer at the centre; release glides and slows smoothly; the lens widens slightly during the drag. - When idle, the grid drifts slowly sideways. - No two neighbouring tiles show the same photo. - The intro wave grows outward from the centre; Replay restarts it. - Tile edges are crisp and antialiased, with no seams or shimmer at tile borders.
카테고리
사용 기술
출처
Cadence / Ruben Stom · Cadence (from Orbit Sphere and Draggable Grid, own projects)
Cadence 원본 ↗보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 77df654577bc26c8874a3746d011dd5f6a4a2020e3cde5a9afcf2a9682303d0d