<section class="demo" id="stage">
<canvas id="gl"></canvas>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: #08080a;
overflow: hidden;
cursor: crosshair;
}
#gl {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}const canvas = document.querySelector("#gl");
const stage = document.querySelector("#stage");
const gl = canvas.getContext("webgl", { antialias: true });
// 캔버스는 좌상단, WebGL 텍스처는 좌하단이 원점입니다.
// 이 한 줄이 없으면 업로드한 이미지가 위아래로 뒤집힙니다.
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
// ---- 텍스처: 외부 이미지 대신 캔버스로 그립니다 -------------------------
// 실제 사진을 쓰려면 이 함수 대신 Image를 로드해 texImage2D에 넘기세요.
// (다른 도메인 이미지는 img.crossOrigin = "anonymous"가 필요합니다.)
function makeSourceCanvas() {
const c = document.createElement("canvas");
c.width = 512;
c.height = 512;
const ctx = c.getContext("2d");
const grad = ctx.createLinearGradient(0, 0, 512, 512);
grad.addColorStop(0, "#1b2a4a");
grad.addColorStop(0.45, "#4f7fd8");
grad.addColorStop(0.75, "#e0705f");
grad.addColorStop(1, "#f0c98a");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 512);
// 굵은 밴딩을 넣어야 왜곡이 눈에 보입니다.
ctx.globalCompositeOperation = "overlay";
for (let i = 0; i < 512; i += 24) {
ctx.fillStyle = i % 48 === 0 ? "rgba(0,0,0,0.22)" : "rgba(255,255,255,0.10)";
ctx.fillRect(0, i, 512, 12);
}
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(244,244,242,0.92)";
ctx.font = "600 44px Helvetica, Arial, sans-serif";
ctx.textAlign = "center";
ctx.fillText("RIPPLE", 256, 272);
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 uTex;",
"uniform vec2 uPointer;",
"uniform vec2 uRes;",
"uniform float uTime;",
"uniform float uStrength;",
"void main() {",
// 캔버스 비율에 맞춰 텍스처를 cover로 맞춥니다.
" float aspect = uRes.x / uRes.y;",
" vec2 uv = vUv;",
" if (aspect > 1.0) { uv.y = (uv.y - 0.5) / aspect + 0.5; }",
" else { uv.x = (uv.x - 0.5) * aspect + 0.5; }",
// 포인터로부터의 거리로 감쇠하는 동심원 파동
" vec2 aspectUv = vec2(vUv.x * aspect, vUv.y);",
" vec2 aspectPointer = vec2(uPointer.x * aspect, uPointer.y);",
" float dist = distance(aspectUv, aspectPointer);",
" float falloff = exp(-dist * 5.0);",
" float wave = sin(dist * 34.0 - uTime * 4.5) * falloff;",
// 화면 전체에 아주 옅은 기본 물결을 깔아 정지 상태를 피합니다.
" float ambient = sin((vUv.x + vUv.y) * 9.0 + uTime * 0.7) * 0.004;",
" vec2 dir = normalize(aspectUv - aspectPointer + 0.0001);",
" vec2 offset = dir * wave * 0.055 * uStrength + ambient;",
// RGB를 아주 조금 어긋나게 샘플링하면 물 표면의 굴절처럼 보입니다.
" float r = texture2D(uTex, uv + offset * 1.06).r;",
" float g = texture2D(uTex, uv + offset).g;",
" float b = texture2D(uTex, uv + offset * 0.94).b;",
// 파고에 맞춘 스펙큘러
" float spec = smoothstep(0.35, 1.0, wave) * falloff * 0.32;",
" gl_FragColor = vec4(vec3(r, g, b) + spec, 1.0);",
"}",
].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);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, makeSourceCanvas());
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);
const uPointer = gl.getUniformLocation(program, "uPointer");
const uRes = gl.getUniformLocation(program, "uRes");
const uTime = gl.getUniformLocation(program, "uTime");
const uStrength = gl.getUniformLocation(program, "uStrength");
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(canvas.clientWidth * dpr);
canvas.height = Math.floor(canvas.clientHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
}
resize();
window.addEventListener("resize", resize);
// ---- 포인터: 실제 입력이 없으면 스스로 움직입니다 ----------------------
// 카드 그리드의 iframe은 포인터 이벤트를 받지 못하므로,
// 유휴 루프가 없으면 이 데모는 정지 화면으로 보입니다.
const pointer = { x: 0.5, y: 0.5, strength: 1 };
let autoplay = true;
const idle = gsap.timeline({ repeat: -1, yoyo: true, defaults: { duration: 3.4, ease: "sine.inOut" } })
.to(pointer, { x: 0.74, y: 0.36 })
.to(pointer, { x: 0.3, y: 0.66 });
stage.addEventListener("pointermove", (event) => {
if (autoplay) {
autoplay = false;
idle.kill();
}
const rect = stage.getBoundingClientRect();
gsap.to(pointer, {
x: (event.clientX - rect.left) / rect.width,
y: 1 - (event.clientY - rect.top) / rect.height,
duration: 0.45,
ease: "power3.out",
overwrite: true,
});
});
gsap.ticker.add((time) => {
resize();
gl.uniform2f(uPointer, pointer.x, pointer.y);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uTime, time);
gl.uniform1f(uStrength, pointer.strength);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});