<section class="demo" id="stage">
<canvas id="gl"></canvas>
</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: none; }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);
// 왜곡이 눈에 보이려면 소스에 규칙적인 구조가 있어야 합니다.
function makeSourceCanvas() {
const c = document.createElement("canvas");
c.width = 1024;
c.height = 1024;
const ctx = c.getContext("2d");
ctx.fillStyle = "#0e0e12";
ctx.fillRect(0, 0, 1024, 1024);
ctx.strokeStyle = "rgba(244,244,242,0.14)";
ctx.lineWidth = 1;
for (let i = 0; i <= 1024; i += 64) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, 1024);
ctx.moveTo(0, i);
ctx.lineTo(1024, i);
ctx.stroke();
}
ctx.fillStyle = "#f4f4f2";
ctx.font = "600 108px Helvetica, Arial, sans-serif";
ctx.textAlign = "center";
ctx.fillText("LENS", 512, 470);
ctx.fillStyle = "#e0705f";
ctx.font = "500 34px Helvetica, Arial, sans-serif";
ctx.fillText("MOVE YOUR CURSOR", 512, 540);
ctx.fillStyle = "rgba(111,123,216,0.5)";
ctx.beginPath();
ctx.arc(250, 760, 90, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(216,178,111,0.45)";
ctx.fillRect(690, 690, 170, 170);
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 float uAspect;",
"uniform float uRadius;",
"void main() {",
" vec2 p = vec2(vUv.x * uAspect, vUv.y);",
" vec2 c = vec2(uPointer.x * uAspect, uPointer.y);",
" float dist = distance(p, c);",
" vec2 uv = vUv;",
" if (uAspect > 1.0) { uv.y = (uv.y - 0.5) / uAspect + 0.5; }",
" else { uv.x = (uv.x - 0.5) * uAspect + 0.5; }",
" vec3 color = texture2D(uTex, uv).rgb;",
" if (dist < uRadius) {",
" float n = dist / uRadius;",
" float bulge = 1.0 - pow(1.0 - n * n, 1.6) * 0.42;",
" vec2 dir = (uv - vec2(0.5)) ;",
" vec2 offset = (uv - mix(uv, vec2(0.5), 0.0));",
" vec2 lensUv = mix(vec2(uPointer.x, uPointer.y), uv, bulge);",
" if (uAspect > 1.0) { lensUv.y = (lensUv.y - 0.5) / uAspect + 0.5; }",
" else { lensUv.x = (lensUv.x - 0.5) * uAspect + 0.5; }",
" float ca = 0.006 * n;",
" vec2 toC = normalize(lensUv - vec2(0.5) + 0.0001);",
" float r = texture2D(uTex, lensUv + toC * ca).r;",
" float g = texture2D(uTex, lensUv).g;",
" float b = texture2D(uTex, lensUv - toC * ca).b;",
" vec3 lens = vec3(r, g, b);",
" float rim = smoothstep(0.72, 1.0, n);",
" lens += rim * 0.10;",
" float edge = smoothstep(1.0, 0.94, n);",
" lens *= 1.06;",
" color = mix(color, lens, edge);",
" }",
" gl_FragColor = vec4(color, 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 uAspect = gl.getUniformLocation(program, "uAspect");
const uRadius = gl.getUniformLocation(program, "uRadius");
// 매 프레임 표시 크기를 확인해 필요할 때만 버퍼를 다시 잡습니다.
// 한 번만 측정하면 레이아웃이 늦게 잡히는 환경에서 캔버스가 작게 굳어버립니다.
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 pointer = { x: 0.5, y: 0.5 };
let autoplay = true;
const idle = gsap.timeline({ repeat: -1, yoyo: true, defaults: { duration: 3.6, ease: "sine.inOut" } })
.to(pointer, { x: 0.72, y: 0.35 })
.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.4,
ease: "power3.out",
overwrite: true,
});
});
gsap.ticker.add(() => {
resize();
gl.uniform2f(uPointer, pointer.x, pointer.y);
gl.uniform1f(uAspect, canvas.width / canvas.height);
gl.uniform1f(uRadius, 0.22);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});