<section class="demo" id="stage">
<canvas id="gl"></canvas>
<p class="demo__label" id="label">01 / 04</p>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: #08080a;
overflow: hidden;
}
#gl {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.demo { cursor: pointer; }
.demo__label {
position: absolute;
left: 20px;
bottom: 18px;
margin: 0;
font-family: "Helvetica Neue", Inter, system-ui, sans-serif;
font-size: 11px;
letter-spacing: 0.2em;
color: rgba(244, 244, 242, 0.62);
font-variant-numeric: tabular-nums;
pointer-events: none;
}const canvas = document.querySelector("#gl");
const stage = document.querySelector("#stage");
const label = document.querySelector("#label");
const gl = canvas.getContext("webgl", { antialias: true });
// 캔버스는 좌상단, WebGL 텍스처는 좌하단이 원점입니다.
// 이 한 줄이 없으면 업로드한 이미지가 위아래로 뒤집힙니다.
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
// ---- 소스 이미지 4장 (실제 사진으로 교체 가능) --------------------------
const SLIDES = [
["#6f7bd8", "#232741", "AURORA"],
["#e0705f", "#241210", "EMBER"],
["#4f9bd8", "#0e1a25", "COBALT"],
["#d8b26f", "#221c12", "DUNE"],
];
function makeSlide(index) {
const c = document.createElement("canvas");
c.width = 512;
c.height = 512;
const ctx = c.getContext("2d");
const [from, to, name] = SLIDES[index];
const grad = ctx.createLinearGradient(0, 0, 512, 512);
grad.addColorStop(0, from);
grad.addColorStop(1, to);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 512);
ctx.strokeStyle = "rgba(255,255,255,0.13)";
ctx.lineWidth = 1;
for (let r = 40; r < 520; r += 40) {
ctx.beginPath();
ctx.arc(256, 256, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(244,244,242,0.94)";
ctx.font = "600 52px Helvetica, Arial, sans-serif";
ctx.textAlign = "center";
ctx.fillText(name, 256, 274);
return c;
}
// 디스플레이스먼트 맵. 밝은 곳부터 녹아 사라집니다.
function makeDisplacement() {
const c = document.createElement("canvas");
c.width = 256;
c.height = 256;
const ctx = c.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 26; i += 1) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const r = 30 + Math.random() * 90;
const blob = ctx.createRadialGradient(x, y, 0, x, y, r);
blob.addColorStop(0, "rgba(255,255,255,0.9)");
blob.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = blob;
ctx.fillRect(0, 0, 256, 256);
}
return c;
}
const VERT = [
"attribute vec2 aPos;",
"varying vec2 vUv;",
"void main() {",
" vUv = aPos * 0.5 + 0.5;",
" gl_Position = vec4(aPos, 0.0, 1.0);",
"}",
].join("\n");
const FRAG = [
"precision highp float;",
"varying vec2 vUv;",
"uniform sampler2D uFrom;",
"uniform sampler2D uTo;",
"uniform sampler2D uDisp;",
"uniform float uProgress;",
"uniform float uAspect;",
"vec2 cover(vec2 uv, float aspect) {",
" if (aspect > 1.0) { uv.y = (uv.y - 0.5) / aspect + 0.5; }",
" else { uv.x = (uv.x - 0.5) * aspect + 0.5; }",
" return uv;",
"}",
"void main() {",
" vec2 uv = cover(vUv, uAspect);",
" float disp = texture2D(uDisp, uv).r;",
" float p = uProgress;",
" vec2 fromUv = uv + vec2(disp * p * 0.42, disp * p * 0.12);",
" vec2 toUv = uv - vec2(disp * (1.0 - p) * 0.42, disp * (1.0 - p) * 0.12);",
" vec4 fromColor = texture2D(uFrom, fromUv);",
" vec4 toColor = texture2D(uTo, toUv);",
" float edge = smoothstep(p - 0.28, p + 0.28, disp * 0.65 + 0.18);",
" vec4 color = mix(toColor, fromColor, edge);",
" float rim = 1.0 - abs(edge - 0.5) * 2.0;",
" color.rgb += rim * 0.14 * step(0.01, p) * step(p, 0.99);",
" gl_FragColor = color;",
"}",
].join("\n");
function compile(type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
}
return shader;
}
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
// 화면을 덮는 삼각형 하나 (사각형보다 픽셀 낭비가 적습니다).
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
function uploadTexture(unit, source) {
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
return texture;
}
const slideCanvases = SLIDES.map((_, index) => makeSlide(index));
const texFrom = uploadTexture(0, slideCanvases[0]);
const texTo = uploadTexture(1, slideCanvases[1]);
uploadTexture(2, makeDisplacement());
gl.uniform1i(gl.getUniformLocation(program, "uFrom"), 0);
gl.uniform1i(gl.getUniformLocation(program, "uTo"), 1);
gl.uniform1i(gl.getUniformLocation(program, "uDisp"), 2);
const uProgress = gl.getUniformLocation(program, "uProgress");
const uAspect = gl.getUniformLocation(program, "uAspect");
// 매 프레임 표시 크기를 확인해 필요할 때만 버퍼를 다시 잡습니다.
// 한 번만 측정하면 레이아웃이 늦게 잡히는 환경에서 캔버스가 작게 굳어버립니다.
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width === w && canvas.height === h) return;
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
resize();
// ---- 전환 상태 ---------------------------------------------------------
const state = { progress: 1 };
let current = 0;
let busy = false;
function goTo(next) {
if (busy || next === current) return;
busy = true;
// uFrom에 현재 장, uTo에 다음 장을 올리고 progress를 1 -> 0으로 굴립니다.
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texFrom);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, slideCanvases[current]);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texTo);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, slideCanvases[next]);
state.progress = 1;
gsap.to(state, {
progress: 0,
duration: 1.25,
ease: "power2.inOut",
onComplete: () => {
current = next;
busy = false;
label.textContent = String(current + 1).padStart(2, "0") + " / 0" + SLIDES.length;
},
});
}
stage.addEventListener("click", () => goTo((current + 1) % SLIDES.length));
// 유휴 상태에서는 스스로 넘어갑니다.
gsap.delayedCall(1.4, function cycle() {
goTo((current + 1) % SLIDES.length);
gsap.delayedCall(2.9, cycle);
});
gsap.ticker.add(() => {
resize();
gl.uniform1f(uProgress, state.progress);
gl.uniform1f(uAspect, canvas.width / canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});