모션·인터랙션 / Cadence
Cylinder Gallery
갤러리 · 3D · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Cylinder Gallery
Photos line the inside of a giant cylinder with the camera near the centre, looking at
the inner wall. Dragging spins it like a zoetrope: the drag turns the ring directly and
sets an angular velocity that decays with inertia, and the ring drifts slowly on its own
when idle. Scrolling (wheel or trackpad) pushes the same velocity. A click raycasts the
photo under the pointer: a flat copy eases toward the camera while the ring dims and
keeps turning behind it. Click again or press Escape to return it.
Dependencies: three@0.182.0 (ES module via the import map), from jsDelivr.
Tweak (CONFIG at the top of the script):
- count 12, radius 4.2, imageHeight 2.2 (3:4 images), cameraOffset 0.6, fov 62
- dragSpeed 0.0045 rad per px, inertia 1.8 (higher = stops sooner), idleSpeed 0.06 rad/s
- focusEase 5, focusDistance 2.6, dimmed 0.28 (ring opacity while a photo is open)
- WHEEL_FACTOR 0.5 (a wheel px turns half as far as a dragged px), MAX_VELOCITY 6 rad/s
From The Animations Index, 12 Cylinder Gallery (own project).
three.js: MIT License · Copyright © 2010-2025 three.js authors.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cylinder Gallery</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
.stage {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none; /* horizontal drags spin the ring, so touches must not scroll */
cursor: grab;
}
.stage.is-dragging { cursor: grabbing; }
.stage canvas {
position: absolute;
inset: 0;
display: block;
width: 100% !important;
height: 100% !important;
}
</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" id="stage">
<canvas id="ring" role="img" aria-label="A ring of photographs around the viewer. Drag or scroll to spin it, click a photo to open it."></canvas>
</div>
<script type="module">
import * as THREE from "three";
const CONFIG = {
count: 12, // images around the ring
radius: 4.2, // ring radius
imageHeight: 2.2, // world units, width follows the 3:4 aspect
cameraOffset: 0.6, // camera pulled back from the centre, toward the wall behind
dragSpeed: 0.0045, // radians per pixel of drag
inertia: 1.8, // higher = velocity dies faster
idleSpeed: 0.06, // radians per second when nobody is dragging
focusEase: 5, // how fast an image comes forward
focusDistance: 2.6, // distance in front of the camera for the opened image
dimmed: 0.28, // ring opacity while an image is open
fov: 62,
};
const WHEEL_FACTOR = 0.5; // a wheel pixel turns the ring this much as far as a dragged pixel
const WHEEL_LINE_PX = 16; // deltaMode 1 (lines) → px
const MAX_VELOCITY = 6; // rad/s, so a burst of wheel notches can't fling the ring
const PIXEL_RATIO_MAX = 2;
const PLACEHOLDER = 0xe6e6e6; // light grey until a texture arrives
// Swap these placeholders for your own 3:4 portrait photos.
const IMAGES = Array.from({ length: CONFIG.count }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/900/1200`);
const lerp = (a, b, t) => a + (b - a) * t;
// Frame rate independent easing toward a target.
const damp = (current, target, lambda, delta) => lerp(current, target, 1 - Math.exp(-lambda * delta));
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/* Setup ---------------------------------------------------------------- */
const stage = document.getElementById("stage");
const canvas = document.getElementById("ring");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_MAX));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CONFIG.fov, 1, 0.1, 50);
camera.position.set(0, 0, CONFIG.cameraOffset);
camera.lookAt(0, 0, -1);
const ring = new THREE.Group();
scene.add(ring);
// One curved segment per image. The camera is inside, so we render the back
// face and flip the texture horizontally to read it correctly.
const imageWidth = CONFIG.imageHeight * 0.75;
const arc = imageWidth / CONFIG.radius;
const segmentGeometry = new THREE.CylinderGeometry(CONFIG.radius, CONFIG.radius, CONFIG.imageHeight, 24, 1, true, -arc / 2, arc);
const textureLoader = new THREE.TextureLoader(); // loads with crossOrigin "anonymous"
const panels = IMAGES.map((url, i) => {
const material = new THREE.MeshBasicMaterial({ color: PLACEHOLDER, side: THREE.BackSide, transparent: true, opacity: 1 });
const mesh = new THREE.Mesh(segmentGeometry, material);
mesh.rotation.y = (i / CONFIG.count) * Math.PI * 2;
ring.add(mesh);
textureLoader.load(url, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.repeat.x = -1; // unmirror, since we look at the back face
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
material.map = texture;
material.color.set(0xffffff);
material.needsUpdate = true;
});
return mesh;
});
// The flat copy that comes forward when an image is opened.
const focusMaterial = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 });
const focusPlane = new THREE.Mesh(new THREE.PlaneGeometry(imageWidth, CONFIG.imageHeight), focusMaterial);
focusPlane.visible = false;
scene.add(focusPlane);
function resize() {
const width = stage.clientWidth;
const height = stage.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
resize();
/* Drag with inertia, click to open --------------------------------------- */
let velocity = 0;
let dragging = false;
let dragStartX = 0;
let lastX = 0;
let lastMoveTime = 0;
let opened = null; // the panel currently shown up front
let focus = 0; // 0 = ring, 1 = opened image, eased
let focusTarget = 0;
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
stage.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
dragStartX = lastX = event.clientX;
lastMoveTime = performance.now();
velocity = 0;
stage.classList.add("is-dragging");
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!dragging) return;
const dx = event.clientX - lastX;
const now = performance.now();
const dt = Math.max(now - lastMoveTime, 1) / 1000;
lastX = event.clientX;
lastMoveTime = now;
ring.rotation.y += dx * CONFIG.dragSpeed;
velocity = (dx * CONFIG.dragSpeed) / dt;
});
function endDrag(event) {
if (!dragging) return;
dragging = false;
stage.classList.remove("is-dragging");
// A press without movement is a click.
if (event.type === "pointerup" && Math.abs(event.clientX - dragStartX) < 5) {
velocity = 0;
if (opened) close();
else openAt(event);
}
}
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && opened) close();
});
// Scroll to spin: each wheel delta is a push on the angular velocity, which then glides
// out with the drag's inertia (total turn = delta × dragSpeed × WHEEL_FACTOR).
window.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey) return; // pinch-zoom gestures stay with the browser
event.preventDefault();
const unit = event.deltaMode === 1 ? WHEEL_LINE_PX : event.deltaMode === 2 ? window.innerHeight : 1;
const dx = event.deltaX * unit;
const dy = event.deltaY * unit;
const delta = Math.abs(dy) >= Math.abs(dx) ? dy : dx;
const push = delta * CONFIG.dragSpeed * WHEEL_FACTOR * CONFIG.inertia;
velocity = clamp(velocity + push, -MAX_VELOCITY, MAX_VELOCITY);
},
{ passive: false }
);
function openAt(event) {
const rect = canvas.getBoundingClientRect();
ndc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObjects(panels)[0];
if (!hit || !hit.object.material.map) return;
opened = hit.object;
focusMaterial.map = opened.material.map;
focusMaterial.needsUpdate = true;
focusPlane.visible = true;
focusPlane.position.set(0, 0, camera.position.z - CONFIG.focusDistance);
focusTarget = 1;
}
function close() {
opened = null;
focusTarget = 0;
}
/* Frame ------------------------------------------------------------------ */
function frame(delta) {
if (!dragging) {
// Inertia, then a slow idle drift so the ring never sits still.
velocity = damp(velocity, 0, CONFIG.inertia, delta);
const idle = reduced || opened ? 0 : CONFIG.idleSpeed;
ring.rotation.y += (velocity + idle) * delta;
}
// Bring the opened image forward, dim the ring.
focus = reduced ? focusTarget : damp(focus, focusTarget, CONFIG.focusEase, delta);
focusPlane.scale.setScalar(0.6 + 0.4 * focus);
focusMaterial.opacity = focus;
focusPlane.visible = focus > 0.001;
const ringOpacity = 1 - (1 - CONFIG.dimmed) * focus;
panels.forEach((panel) => (panel.material.opacity = ringOpacity));
renderer.render(scene, camera);
}
// Render loop: pauses while the tab is hidden and caps the delta so a long pause
// does not create a jump.
let lastTime = 0;
let frameId = null;
function tick(now) {
frameId = requestAnimationFrame(tick);
const delta = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0;
lastTime = now;
frame(delta);
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
cancelAnimationFrame(frameId);
frameId = null;
} else if (frameId === null) {
lastTime = 0;
frameId = requestAnimationFrame(tick);
}
});
frameId = requestAnimationFrame(tick);
</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>
원본 구현 프롬프트
# Cylinder Gallery: photos inside a giant cylinder you spin like a zoetrope **Goal:** build a full-screen WebGL scene where photos line the inside wall of a large cylinder around the camera. Dragging or scrolling spins the ring with inertia, it drifts slowly when idle, and clicking a photo brings a flat copy of it forward. ## Visual behaviour 1. **Ring.** 12 portrait photos (3:4) are placed evenly around the inside of a cylinder of radius 4.2 world units. Each photo is a curved segment of that cylinder: height 2.2, width 2.2 × 0.75 = 1.65, so its arc is `1.65 / 4.2` radians, centred on its slot (`thetaStart = −arc/2`, 24 radial segments, open-ended). Segment `i` is rotated `i / 12 × 2π` around Y. 2. **Seen from inside.** Render the back faces, and flip each texture horizontally (`wrapS = repeat`, `repeat.x = −1`) so the photos read the right way round. Use sRGB colour space for the textures. Show a light grey (#e6e6e6) placeholder until a texture loads. 3. **Camera.** Perspective, fov 62°, near the centre but pulled back 0.6 units toward the wall behind (`position (0, 0, 0.6)`), looking along −Z. The photos in front wrap around the view, with the side ones turning in strongly. White background. 4. **Spin.** All panels sit in one group; spinning means changing the group's `rotation.y`. - **Drag:** each pointer move turns the ring directly by `dx × 0.0045` rad and sets `velocity = dx × 0.0045 / dt` (rad/s). - **Inertia:** when not dragging, `velocity` decays toward 0 with frame-rate independent damping, `velocity = lerp(velocity, 0, 1 − e^(−1.8 × dt))`, and the ring turns by `(velocity + idle) × dt`. - **Idle drift:** `idle = 0.06` rad/s, so the ring never sits completely still. - **Wheel / trackpad:** normalise `deltaMode` (lines × 16 px, pages × viewport height), use the dominant axis, and push the velocity: `velocity += delta × 0.0045 × 0.5 × 1.8`, clamped to ±6 rad/s. (A wheel pixel turns the ring half as far as a dragged pixel once the push has glided out.) Ignore `ctrlKey` wheel events (pinch zoom). `preventDefault`, non-passive. 5. **Open a photo.** A press and release with less than 5px of horizontal movement is a click. Raycast from the pointer into the panels; if a photo with a loaded texture is hit: - Show a flat plane (1.65 × 2.2) with the same texture, 2.6 units in front of the camera. - Ease `focus` from 0 to 1 with `damp(focus, target, 5, dt)`: the plane scales `0.6 + 0.4 × focus` and fades to `opacity = focus`. - The ring dims to `1 − (1 − 0.28) × focus` opacity and stops its idle drift (it keeps any inertia). - Clicking again, or pressing Escape, eases `focus` back to 0. 6. **Render loop.** `requestAnimationFrame`, delta in seconds capped at 0.1 s; pause the loop while the tab is hidden. ## Exact values | What | Value | |---|---| | Photos | 12, 3:4 | | Ring radius | 4.2 | | Photo size | 2.2 high × 1.65 wide (world units) | | Camera | fov 62°, z = 0.6, looking at (0, 0, −1) | | Drag | 0.0045 rad per px | | Inertia | damping 1.8 (e^(−1.8 t)) | | Idle drift | 0.06 rad/s | | Wheel | push = delta × 0.0045 × 0.5 × 1.8, max velocity 6 rad/s | | Click | < 5px movement | | Opened photo | 2.6 in front of the camera, scale 0.6 → 1, ease 5 | | Ring while open | opacity 0.28 | | Pixel ratio | ≤ 2 | ## Structure ```html <div class="stage"><canvas></canvas></div> ``` - `.stage`: fixed full screen, `touch-action: none` (horizontal drags spin the ring), `cursor: grab` (`grabbing` while dragging). Pointer events with `setPointerCapture` on the stage. - Materials: `MeshBasicMaterial`, `side: BackSide`, `transparent: true`. ## Libraries - **three.js** (ES modules): `CylinderGeometry` segments, `TextureLoader`, `Raycaster`. - Plain alternative: CSS 3D with `perspective` and each photo `rotateY(angle) translateZ(−radius)` inside a `preserve-3d` container (flat panels instead of curved ones). ## Responsive and reduced motion - On resize, update the renderer size and camera aspect; the scene scales with the viewport height. - `prefers-reduced-motion: reduce`: no idle drift and no eased focus (opening and closing are instant). Dragging and scrolling still turn the ring. ## Acceptance criteria - The viewer stands inside a ring of curved photos that read the right way round, with the side photos wrapping in. - Dragging turns the ring 1:1 with the pointer; a quick release keeps it spinning, slowing down smoothly. - Scrolling spins it with the same glide; it never stops completely when left alone (slow drift). - Clicking a photo brings a flat copy forward (scale and fade in) while the ring dims behind it and keeps turning; clicking again or Escape returns it.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: c96239e499990c0cdf182113f84a72aada93e60e7d77f35255ef89033debc14e