GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

파티클 효과 9종 — 캔버스 파티클 엔진 직접 만들기

파티클 효과는 화면을 채운 무수한 알갱이가 저마다 떠오르고 흩어지고 이어지는 움직임입니다. 이 파티클 엔진 아홉 종을 라이브러리 없이 캔버스 태그 하나로 만들었고, 아래 격자에서 지금 돌아갑니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 엔진이 갖춰지는 순서입니다. 먼저 알갱이를 담는 풀이 생기고(01), 뿌리는 곳이 움직이고(02), 바람과 중력 같은 힘이 생기고(03), 알갱이끼리 부딪히고(04), 서로 이어지고(05), 커서의 흔적이 남고(06), 색이 흐르고(07), 글자가 알갱이로 바뀌고(08), 마지막에 그림까지 알갱이로 다시 그려집니다(09). 앞의 네 개는 엔진의 뼈대, 뒤의 다섯 개는 그 뼈대로 만드는 화면입니다. 알갱이를 CSS 로만 흩뿌리는 CSS 파티클 배경과 스프링·중력을 다루는 물리 인터랙션이 이 편의 이웃입니다.

01파티클 풀과 수명

미리 만들어 둔 파티클 220개가 수명이 다하면 자리를 비웠다가 새 설정으로 다시 태어나는, 로딩이 끝나도 계속 도는 히어로 배경용 부품입니다.

파티클 풀과 수명
새 창에서 열기

01 · 파티클 풀과 수명 — 측정 누적면적 30.194% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getContextglobalAlphaarc

죽은 칸은 버리지 않고 비워 둔다

function step(dt) {
  for (var i = 0; i < POOL; i++) {
    var p = pool[i];
    // 빈 칸은 잠깐 쉬었다 다시 쓴다 — 배열은 그대로고 이 칸만 비어 있다
    if (p.wait > 0) { p.wait -= dt; if (p.wait <= 0) spawn(p); continue; }
    p.age += dt;
    if (p.age >= p.life) { p.wait = 0.05 + Math.random() * 0.4; continue; }   // 죽은 칸은 버리지 않고 비워 둔다
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vy += 30 * dt;                                // 위로 뜬 알갱이를 다시 끌어내리는 중력

배열을 push 로 늘리고 splice 로 자르면 매 프레임 할당과 회수가 생겨 프레임 시간이 흔들립니다. 길이 220 을 고정해 두고 수명이 끝난 칸만 잠깐 비운 뒤 그 자리를 덮어쓰니, 오른쪽 위 살아 있는 칸 수는 오르내려도 풀의 크기는 끝까지 220 입니다.

02이동하는 방출기

방출기가 스스로 경로를 돌며 초당 90개를 뿌리고, 드래그하면 노즐이 손끝으로 옮겨와 그 자리에서 분사하는 클릭 보상·컨페티 살포용 부품입니다.

이동하는 방출기
새 창에서 열기

02 · 이동하는 방출기 — 측정 누적면적 12.007% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovequadraticCurveTosave

노즐은 손끝으로 스르르 온다

  prevEx = ex; prevEy = ey;
  if (dragging) {
    var k = 1 - Math.exp(-14 * dt);             // 노즐이 손끝으로 스르르 온다
    ex += (px - ex) * k;
    ey += (py - ey) * k;
  } else {
    var k2 = 1 - Math.exp(-6 * dt);             // 자율 경로도 급차 없이 따라간다
    ex += (tx - ex) * k2;
    ey += (ty - ey) * k2;
  }
  // 분사 방향 — 이동 방향을 따라 부드럽게 돌아간다
  var vx = (ex - prevEx) / dt, vy = (ey - prevEy) / dt;

Math.exp 의 지수 감쇠로 현재 위치를 목표에 점점 붙여 가니, 노즐을 잡으면 목표가 손끝이 되고 놓으면 자율 경로의 다음 지점이 될 뿐이라 두 상태가 같은 코드를 씁니다. 실측에서도 드래그 목표 (297, 30) 에 노즐이 (294, 31) 로 붙어 따라왔고 놓으면 자율 주행으로 돌아갔습니다.

03중력과 바람의 필드

화면 전체가 시간에 따라 방향을 바꾸는 바람 필드라서 파티클은 흐르듯 몰리고, 포인터를 올리면 그 자리가 소용돌이 중심이 되는 섹션 배경용 부품입니다.

중력과 바람의 필드
새 창에서 열기

03 · 중력과 바람의 필드 — 측정 누적면적 31.642% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sinMath.costranslate

자리마다 다른 바람 방향

  t += dt;
  // 사인과 코사인을 합성한 기준 풍향 — 판 전체의 바람이 천천히 돌아간다
  var base = Math.sin(t * 0.2) * 1.15 + Math.cos(t * 0.34) * 0.5;
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    // 자리마다 물결 — 같은 순간에도 판 위의 곳마다 바람 방향이 다르다
    var ang = base + Math.sin(p.x * 0.012 + t * 0.6) * 0.85 + Math.cos(p.y * 0.016 - t * 0.45) * 0.6;

기준 풍향 하나에 자리 좌표의 사인·코사인을 얹으면 같은 순간에도 판 위의 곳마다 바람이 다릅니다. 실측 중에 풍향 라벨이 33도에서 41도로 천천히 돌아가는 것이 잡혔고, 소용돌이 힘은 처음 620 이 약해서 760 으로 올렸더니 반경 안 알갱이 23개가 확 흩어졌습니다.

04벽과 이웃 파티클 튕김

알갱이들이 벽에서만 튀는 게 아니라 서로 부딪혀 방향을 바꾸는, 장난기 있는 랜딩의 튕기는 알갱이용 부품입니다.

벽과 이웃 파티클 튕김
새 창에서 열기

04 · 벽과 이웃 파티클 튕김 — 측정 누적면적 45.357% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MapfillRectrestore

9,730쌍을 625쌍으로 줄이는 격자

  grid.clear();
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    p.cx = Math.floor(p.x / CELL);
    p.cy = Math.floor(p.y / CELL);
    var key = p.cx + ',' + p.cy;
    var arr = grid.get(key);
    if (!arr) {
      arr = [];

140개를 전부 견주면 9,730쌍인데, 칸 크기를 지름의 2배로 잡고 Map 에 칸 키로 인덱스를 모은 뒤 이웃 칸 후보만 견주는 공간 해시로 바꾸니 실측 한 프레임에 견준 쌍은 575~653개(평균 625)로 줄었습니다. 겹침은 반씩 밀어내는 보정 한 번으로 지우고 헤어지는 중인 쌍은 건드리지 않는 것이 충돌이 폭발하지 않는 요령입니다.

05거리로 이어지는 연결선

떠다니는 점들이 가까워지면 그 사이에 실이 나타나고 멀어지면 사라지는, 팀 소개·기술 소개 배경용 부품입니다.

거리로 이어지는 연결선
새 창에서 열기

05 · 거리로 이어지는 연결선 — 측정 누적면적 27.378% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

strokeStylelineToclearRect

가까울수록 짙어지는 실

  var links = 0;
  ctx.lineWidth = 1;
  ctx.strokeStyle = '#fff7e6';          // 실은 낮은 알파의 크림 — 알파는 globalAlpha 로 준다
  // 점과 점 — 매 프레임 모든 쌍을 견주고 반경 안쪽만 선으로 남긴다
  for (var i = 0; i < COUNT; i++) {
    var a = nodes[i];
    for (var j = i + 1; j < COUNT; j++) {
      var b = nodes[j];
      var dx = a.x - b.x, dy = a.y - b.y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= LINK_R * LINK_R) continue;
      var fade = 1 - Math.sqrt(d2) / LINK_R;
      ctx.globalAlpha = fade * fade * 0.5;   // 가까울수록 짙어지는 실

포인터도 노드 하나로 끼어들어 가까운 점들과 노란 실로 이어집니다. 반경 슬라이더를 기본 90px 에서 130px 로 올리면 실측 연결 수가 500선에서 836선으로 늘어납니다 — 반경을 넓히는 만큼 그리는 선이 제곱으로 불어나는 것을 슬라이더로 만질 수 있습니다.

06커서를 따라오는 유령

포인터가 지나간 자리마다 유령 알갱이가 남아 스미듯 사라지는, 포트폴리오 마우스 효과용 부품입니다.

커서를 따라오는 유령
새 창에서 열기

06 · 커서를 따라오는 유령 — 측정 누적면적 4.448% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clientXpointermoveglobalCompositeOperation

고정 길이 대열의 가장 낡은 칸을 덮어쓴다

// 한 칸 덮어쓰기 — 다음 칸은 곧 가장 낡은 칸이다. 배열을 늘였다 줄이지 않는다.
function writeSlot(x, y) {
  head = (head + 1) % N;
  var sl = slots[head];
  sl.on = true; sl.x = x; sl.y = y;
  sl.r = 2.2 + Math.random() * 2.4;
  sl.vx = (Math.random() - 0.5) * 16;
  sl.vy = 8 + Math.random() * 20;    // 찍힌 알갱이는 스며들듯 가라앉는다
  sl.blue = Math.random() < 0.34;
}

90칸짜리 둥근 대열에서 포인터가 있을 때는 매 프레임 한 칸씩 덮어쓰고, 없을 때는 자동 시연이 가상 커서를 움직여 잔상을 만듭니다. 나이가 든 칸은 크기와 알파가 줄어 스며들듯 지워지기 때문에, 대열 길이를 30칸으로 줄여도 끝이 프레임 경계에서 컷으로 끊기지 않습니다.

07흐르는 색 팔레트

한 줄기 흐름 위의 파티클이 위치에 따라 hsl 색상을 물려받아, 시간이 지나면 줄무늬 전체가 옆으로 흐르는 뮤직·이벤트 페이지 물결용 부품입니다.

흐르는 색 팔레트
새 창에서 열기

07 · 흐르는 색 팔레트 — 측정 누적면적 24.006% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

hsl(fillStylebeginPath

색을 다시 계산하지 않고 색상에 더한다

    var hue = (d.x * 0.85 + drift + d.band * 24) % 360;
    var sat = 72 + (i * 37) % 24;
    var lig = 34 + (i * 53) % 21;
    var near = 0;
    if (pointer.on) {
      var ndx = d.x - pointer.x, ndy = y - pointer.y;
      near = Math.exp(-(ndx * ndx + ndy * ndy) / 2600);   // 포인터에 가까울수록 몰린다

색상을 위치와 시간의 합으로 잡으면 drift 값 하나만 키워도 줄무늬 전체가 옆으로 흐릅니다. 포인터를 대면 가우시안으로 몰려 그 띠가 살짝 부풀고, 실측에서 부풀 값은 자리마다 0.23·0.79·0.51 로 잡혔으며, 포인터를 벗어나면 0 으로 가라앉습니다.

08글자가 파티클로 흩어지는 판

화면 밖 캔버스에 글자를 그려 픽셀을 읽고, 알파가 있는 자리마다 파티클의 집을 정해 주는 오픈 준비·공지 문구용 부품입니다.

글자가 파티클로 흩어지는 판
새 창에서 열기

08 · 글자가 파티클로 흩어지는 판 — 측정 누적면적 21.127% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDatafillTextfont

알파가 128 넘는 자리가 집이다

  // 알파가 128 을 넘는 격자점마다 알갱이 하나 — 그 자리가 집(home)이 된다
  var img = offCtx.getImageData(0, 0, off.width, off.height).data;
  dots = [];
  for (var y = GAP / 2; y < H; y += GAP) {
    var iy = Math.min(off.height - 1, Math.round(y)) * off.width;
    for (var x = GAP / 2; x < W; x += GAP) {
      var ix = Math.min(off.width - 1, Math.round(x));
      if (img[(iy + ix) * 4 + 3] < 128) continue;

4px 격자로 알파를 검사해 집을 정하고 알갱이는 스프링으로 집에 붙어 있어서, 포인터 반경 안에서는 밀려났다가 치우면 다시 모입니다 — 실측에서 포인터 중심 반경 40px 안의 그려진 픽셀이 2,019개에서 859개로 줄고 치우니 1,649개로 돌아왔으며, Enter 나 Space 는 전체를 한 번에 튀어 올립니다.

09그림을 파티클로 다시 그리기

오프스크린 캔버스에 그린 풍경을 격자 간격으로 샘플링해 밝은 곳은 크고 진한 점, 어두운 곳은 작고 연한 점으로 옮겨 그리는 제품·전시 소개 오프닝용 부품입니다.

그림을 파티클로 다시 그리기
새 창에서 열기

09 · 그림을 파티클로 다시 그리기 — 측정 누적면적 31.312% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

drawImagegetImageDatadevicePixelRatio

루마로 크기와 농도를 정한다

      // 루마 — 사람 눈이 느끼는 밝기 가중치. 밝은 곳은 크고 진한 점, 어두운 곳은 작고 연한 점이 된다
      var lum = (img[k] * 0.2126 + img[k + 1] * 0.7152 + img[k + 2] * 0.0722) / 255;
      // 어두운 색은 하늘색 기운을 남겨 밤하늘의 결로 보이게 한다
      var rr = Math.round(91 + (img[k] - 91) * lum);
      var gg = Math.round(140 + (img[k + 1] - 140) * lum);
      var bb = Math.round(248 + (img[k + 2] - 248) * lum);
      dots.push({
        hx: x, hy: y, x: x, y: y, vx: 0, vy: 0,
        r: 0.7 + lum * 2.0,
        alpha: 0.28 + lum * 0.72,

원본 그림은 외부 이미지 없이 캔버스에 토큰색으로 직접 그린 세 겹의 산 능선과 달입니다. 각 점에는 제자리로 돌아오는 스프링이 걸려 있어 점을 밀면 물결치고 놓으면 그림으로 돌아갑니다 — 실측에서 포인터 중심 반경 40px 안의 그려진 픽셀이 1,668개에서 329개로 밀리고 치우니 1,426개로 회복했습니다.

마우스 효과는 캔버스 안에서 어떻게 돌아가나?

이런 마우스 효과 사이트에서 눈에 보이는 것은 알갱이지만, 실제로 돌아가는 것은 매 프레임의 세 단계입니다 — 힘을 더하고, 위치를 적분하고, 지운 뒤 다시 그립니다. requestAnimationFrame 이 넘겨주는 now 로 프레임 사이 시간을 직접 재고 0.05초를 넘지 못하게 자릅니다. dt 를 고정값으로 가정하면 탭이 쉬고 돌아왔을 때 몰린 시간이 한꺼번에 풀려 알갱이가 화면 밖으로 날아갑니다.

프레임을 지키는 나머지 장치도 전부 이 아홉 개 안에 있습니다. 캔버스는 CSS 크기를 재서 devicePixelRatio 를 곱해 물리 픽셀에 맞추고 좌표계는 CSS 픽셀 그대로 쓰며(01), 쌍을 견주는 검사는 공간 해시로 93퍼센트를 줄이고(04), 선은 모아서 그리고(05), 배열은 늘리지 않습니다(01·06). 측정값도 이 구조 위에서 잡았습니다 — 움직인 프레임 23칸 중 23칸, 누적면적은 06 의 4.4퍼센트부터 04 의 45.4퍼센트까지 항목마다 다릅니다.

어디서 깨지는가 — 검증 프로브가 거짓 통과를 만들다

이 편의 함정은 눈이 아니라 재는 쪽에서 터졌습니다. 08·09 의 조작을 Playwright 로 실측하는 프로브가 처음에 「포인터를 대면 중심부 픽셀이 0개가 아닌데도 통과」를 내놓은 것입니다. 원인을 보니 캔버스의 투명한 픽셀이 RGB (0, 0, 0) 검정으로 잡혀 있었습니다. 빈 배경이 검정 밝기로 판정되니 알갱이를 지운 자리도 그린 자리와 구분이 안 되고, 판정이 우연히 맞아떨어졌습니다. 알파 채널을 함께 보도록 고치고 나서야 08 의 2,019개가 859개로 비는 실측이 잡혔습니다. 캔버스를 기계로 검증할 때는 밝기가 아니라 알파부터 봐야 합니다.

두 번째는 09 의 강도입니다. 그림을 이루는 점이 밤하늘 위 크림 계열이라 픽셀 차가 낮아, 측정 강도가 16.4 로 하한 15 에 1.4 차이로 겨우 붙었습니다. 눈으로 보면 뚜렷한 달과 능선이지만 기계에는 조용한 그림입니다. 아홉 개의 완성된 파티클 효과 소스와 측정값 전체는 글 아래 암호 zip 안에 들어 있습니다. zip 비밀번호는 z2zupqyd 이고, 세 로케일 판 각각의 받기 단추가 같은 암호를 씁니다.

변형 세 가지와 접근성

이름 바꾼 값 느낌 한 줄
수명 01 수명 1.2초 → 0.6초 같은 풀 크기로 화면 밀도가 두 배
분사 폭 02 방출 각도 24도 → 60도 좁은 광선이 부챗살로 벌어진다
색 $subject-blue → $subject-mint 차가운 파랑에서 상큼한 민트로

접근성은 아홉 개 모두 같은 원칙입니다. prefers-reduced-motion: reduce 를 켠 사용자에게는 rAF 루프를 CSS 로 끄는 것이 아니라 아예 시작하지 않고, 각 판이 가장 잘 읽히는 정적 한 장만 그립니다 — 01 은 알갱이가 골고루 퍼진 상태, 08 은 글자가 온전히 모인 상태, 09 는 그림이 완성된 상태입니다. 각 판에는 tabindex 와 role 이 있어 Enter·Space 로 재시작·흩뿌리기를 할 수 있습니다.

FAQ

파티클 효과 뜻을 정확히 말하면 무엇인가요?

작은 알갱이 하나하나에 위치·속도·수명을 부여해 수많은 개체가 움직이는 듯한 화면을 만드는 기법입니다. 알갱이 하나는 원 하나지만, 떠오르고 흩어지고 이어지는 집단의 움직임이 하나의 효과로 보입니다.

particles.js 같은 라이브러리를 쓰는 게 낫지 않나요?

빠른 시제품에는 낫습니다. 다만 라이브러리는 정해진 옵션 안에서만 움직입니다. 이 아홉 개는 힘을 더하는 순서와 검사 범위가 코드로 열려 있어, 방출기를 드래그하거나 글자를 알갱이로 바꾸는 것처럼 옵션에 없는 화면을 직접 만들 수 있습니다.

휴대폰에서도 프레임이 유지되나요?

알갱이 수를 줄이는 것이 정석입니다. 풀 크기와 반경, 격자 간격이 전부 숫자 하나로 되어 있어 기기에 맞춰 내리면 됩니다. 이 편의 데모는 점 64개(05)부터 알갱이 220개(01)까지, 픽셀을 샘플링하는 08·09 는 수천 개 수준으로 잡아 두었고, 화면 폭 320px 에서도 세로 넘침 없이 들어오는 것을 확인했습니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.