<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;
}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);
const COUNT = 7;
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");
// 메타볼: 각 blob의 영향력(1/거리제곱)을 더한 스칼라장에 임계값을 씌웁니다.
// 임계값 경계가 부드럽게 이어지기 때문에 두 blob이 가까워지면 하나로 녹아붙습니다.
const FRAG = [
"precision highp float;",
"varying vec2 vUv;",
"uniform vec3 uBalls[7];", // xy = 위치, z = 반지름
"uniform float uAspect;",
"uniform float uTime;",
"void main() {",
" vec2 p = vec2(vUv.x * uAspect, vUv.y);",
" float field = 0.0;",
" vec3 tint = vec3(0.0);",
" for (int i = 0; i < 7; i++) {",
" vec2 c = vec2(uBalls[i].x * uAspect, uBalls[i].y);",
" float r = uBalls[i].z;",
" float d = max(distance(p, c), 0.0001);",
" float influence = (r * r) / (d * d);",
" field += influence;",
" float fi = float(i) / 6.0;",
" float t = fract(fi + uTime * 0.02);",
" vec3 indigo = vec3(0.44, 0.48, 0.85);",
" vec3 coral = vec3(0.88, 0.44, 0.37);",
" vec3 sand = vec3(0.85, 0.70, 0.44);",
" vec3 c1 = t < 0.5 ? mix(indigo, coral, t * 2.0) : mix(coral, sand, (t - 0.5) * 2.0);",
" tint += c1 * influence;",
" }",
" tint /= max(field, 0.0001);",
" float mask = smoothstep(0.92, 1.14, field);",
" float rim = smoothstep(1.5, 1.05, field) * mask;",
" vec3 bg = vec3(0.031, 0.031, 0.039);",
" vec3 color = mix(bg, tint * 0.92, mask);",
" color += rim * 0.34;",
" 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 uBalls = gl.getUniformLocation(program, "uBalls[0]");
const uAspect = gl.getUniformLocation(program, "uAspect");
const uTime = gl.getUniformLocation(program, "uTime");
// 매 프레임 표시 크기를 확인해 필요할 때만 버퍼를 다시 잡습니다.
// 한 번만 측정하면 레이아웃이 늦게 잡히는 환경에서 캔버스가 작게 굳어버립니다.
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.62 })
.to(pointer, { x: 0.28, y: 0.38 });
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,
});
});
// blob 각각에 고유한 궤도를 줍니다. 같은 주기를 쓰면 뭉쳐 다녀서 재미가 없습니다.
const balls = [];
for (let i = 0; i < COUNT; i += 1) {
balls.push({
x: 0.5,
y: 0.5,
radius: 0.075 + (i % 3) * 0.022,
speed: 0.16 + i * 0.037,
phase: i * 1.7,
orbit: 0.16 + (i % 4) * 0.055,
});
}
const data = new Float32Array(COUNT * 3);
gsap.ticker.add((time) => {
resize();
balls.forEach((ball, index) => {
// 기본 궤도
const ox = 0.5 + Math.cos(time * ball.speed + ball.phase) * ball.orbit * 1.35;
const oy = 0.5 + Math.sin(time * ball.speed * 1.23 + ball.phase) * ball.orbit;
// 커서 인력: 가까울수록 강하게 끌립니다. 첫 blob은 커서에 거의 붙습니다.
const pull = index === 0 ? 0.82 : 0.3 / (1 + index * 0.55);
ball.x = ox + (pointer.x - ox) * pull;
ball.y = oy + (pointer.y - oy) * pull;
data[index * 3] = ball.x;
data[index * 3 + 1] = ball.y;
data[index * 3 + 2] = ball.radius;
});
gl.uniform3fv(uBalls, data);
gl.uniform1f(uAspect, canvas.width / canvas.height);
gl.uniform1f(uTime, time);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});