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

연기 효과 9가지 — 캔버스 노이즈로 만드는 연기 효과 소스

이 연기에는 파티클 물리 엔진이 없다. 없어도 06번은 포인터가 다가오면 소용돌이치며 피하고, 07번은 버튼을 누르면 한 뭉치가 터지듯 퍼지며, 09번은 스크롤 값만으로 장면 전체 밀도가 달라진다. 연기 효과 아홉 가지를 캔버스 2D 값노이즈 계산 하나로 만든 결과다.

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

연기 효과 9가지 — 캔버스 계산 하나가 연기의 움직임을 어떻게 바꾸는가

아홉 개 모두 파티클을 그리지만, 화면에서 다르게 보이는 이유는 매 프레임 실행되는 계산 하나가 다르기 때문이다. 상승 속도만 바꾸는 항목이 있고, 흐름장 각도로 좌우로 미는 항목이 있고, 포인터·버튼·스크롤 같은 입력값을 그 계산에 얹는 항목도 있다. 모두 seedRand로 고정한 난수와 valueNoise2D 값-노이즈로만 움직이며, 연기 효과 gif로 저장해 그대로 배경에 얹어도 매번 같은 궤적을 그린다. 비슷한 값-노이즈 계산은 유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)에서 잉크 격자로도 확인할 수 있다.

패턴별 코드

아홉 항목의 핵심 계산 구간만 그대로 옮겼다 — 전체 코드는 압축 파일에 있다.

01위로 피어오르는 기본 연기 기둥

위로 피어오르는 기본 연기 기둥이다. 입자가 아래에서 위로 올라갈수록 값-노이즈로 옆으로 조금씩 퍼지며 흐릿해져, 배경 앰비언트 연출에 쓴다.

위로 피어오르는 기본 연기 기둥
새 창에서 열기

01 · 위로 피어오르는 기본 연기 기둥 — 측정 누적면적 12.548% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_RISE_SPEED_PXPLUME_DRIFTPLUME_LIFE_MS
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
      var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
      var x = p.bx + turb * PLUME_DRIFT * f * p.sign;
      var r = 7 + f * 32;
      var alpha = Math.sin(f * Math.PI) * 0.55;
      if (alpha <= 0.01) continue;
      var dx = x * sx, dy = p.y * sy, dr = r * ((sx + sy) / 2);
      var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
      g.addColorStop(0, 'rgba(255,247,230,' + alpha + ')');
      g.addColorStop(1, 'rgba(255,247,230,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(dx, dy, dr, 0, Math.PI * 2);
      ctx.fill();
    }

02바람 흐름장에 따라 옆으로 휘는 연기

바람 흐름장에 따라 옆으로 휘는 연기다. 흐름장 세기(WIND_FLOW_STRENGTH)에 시간에 따라 오르내리는 돌풍 게인 gustEnvelope(t)를 곱해, 게인이 커지는 순간일수록 연기 기둥이 위로 갈수록 더 크게 옆으로 휘어진다. 슬라이더 없이 오직 재생 시간만으로 세기가 바뀌어 야외 배경 무드 연출에 쓴다.

바람 흐름장에 따라 옆으로 휘는 연기
새 창에서 열기

02 · 바람 흐름장에 따라 옆으로 휘는 연기 — 측정 누적면적 11.102% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIND_FLOW_STRENGTHflowAnglegustEnvelope
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
    while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
    var gust = gustEnvelope(nowMs);
    var alive = [];
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      var f = Math.max(0, Math.min(1, (nowMs - p.born) / PLUME_LIFE_MS));
      var ang = flowAngle(p.x, p.y, nowMs);
      p.x += Math.sin(ang) * WIND_FLOW_STRENGTH * gust * f * dtS;
      p.y -= SMOKE_RISE_SPEED_PX * dtS;
      if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
    }
    particles = alive;
  }

03발생량 값으로 짙기가 바뀌는 연기

발생량 값으로 짙기가 바뀌는 연기다. SMOKE_EMISSION_RATE 값이 커질수록 한 화면에 겹치는 입자 수가 늘어 연기가 더 짙고 불투명하게 보인다. 화면 위쪽 슬라이더를 움직이면 is-demo 가 꺼져 자동 삼각파 스윕이 멈추고 손으로 정한 발생량이 그대로 유지되며, 경고·주의 상태 배경에 쓴다.

발생량 값으로 짙기가 바뀌는 연기
새 창에서 열기

03 · 발생량 값으로 짙기가 바뀌는 연기 — 측정 누적면적 17.719% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_EMISSION_RATEsmkRateDEMO_SWEEP_MS
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * emissionRate / 1000;
    while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
    var alive = [];
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      p.y -= SMOKE_RISE_SPEED_PX * dtS;
      if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
    }
    particles = alive;
    if (particles.length > 320) particles = particles.slice(particles.length - 320);
  }

04발원지에서 멀어질수록 옅어지다 사라지는 연기

발원지에서 멀어질수록 옅어지다 사라지는 연기다. 발원지에서 멀어진 거리만큼 입자의 불투명도가 줄어들다가 DISSOLVE_DISTANCE를 넘으면 완전히 사라져, 로딩 완료 후 잔상 정리에 쓴다.

발원지에서 멀어질수록 옅어지다 사라지는 연기
새 창에서 열기

04 · 발원지에서 멀어질수록 옅어지다 사라지는 연기 — 측정 누적면적 15.542% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_DISSOLVE_MSDISSOLVE_DISTANCEDRIFT_SPEED_PX_S
  function stepSim(dtMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * SPAWN_PER_S / 1000;
    while (spawnAcc >= 1) { spawn(); spawnAcc -= 1; }
    var alive = [];
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      p.x += p.vx * dtS;
      p.y += p.vy * dtS;
      var dist = Math.hypot(p.x - SOURCE_X, p.y - SOURCE_Y);
      if (dist < DISSOLVE_DISTANCE) alive.push(p);
    }
    particles = alive;
  }

05시간에 따라 색이 바뀌는 연기

시간에 따라 색이 바뀌는 연기다. 피어오르는 시간이 지날수록 입자 색상이 정해둔 두 색 사이를 colorT 값으로 오가며 서서히 바뀌어, 브랜드 컬러 히어로 배경에 쓴다.

시간에 따라 색이 바뀌는 연기
새 창에서 열기

05 · 시간에 따라 색이 바뀌는 연기 — 측정 누적면적 16.073% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_TINT_CYCLE_MSSMOKE_TINT_FROMSMOKE_TINT_TO
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
    while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
    var alive = [];
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      p.y -= SMOKE_RISE_SPEED_PX * dtS;
      if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
    }
    particles = alive;
  }

06포인터를 피해 소용돌이치는 연기

포인터를 피해 소용돌이치는 연기다. 포인터가 연기 기둥에 가까워지는 거리만큼 근처 입자가 접선 방향으로 밀려나며 소용돌이치고, 마우스가 없는 화면에서는 화살표 4방향(ArrowLeft·ArrowRight·ArrowUp·ArrowDown) 키로 포인터 좌표를 옮겨 같은 반응을 볼 수 있어 인터랙티브 히어로 일러스트에 쓴다.

포인터를 피해 소용돌이치는 연기
새 창에서 열기

06 · 포인터를 피해 소용돌이치는 연기 — 측정 누적면적 21.019% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_SWIRL_RADIUSSMOKE_SWIRL_STRENGTHvirtualPointerOrbit
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * PLUME_SPAWN_PER_S / 1000;
    while (spawnAcc >= 1) { spawn(nowMs); spawnAcc -= 1; }
    var alive = [];
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0018) - 0.5) * 2;
      p.x += turb * 8 * dtS;
      p.y -= SMOKE_RISE_SPEED_PX * dtS;
      var rx = p.x - pointerX, ry = p.y - pointerY;
      var dist = Math.hypot(rx, ry);
      if (dist < SMOKE_SWIRL_RADIUS && dist > 0.5) {
        var push = (1 - dist / SMOKE_SWIRL_RADIUS) * SMOKE_SWIRL_STRENGTH;
        var tx = -ry / dist, ty = rx / dist;
        p.x += tx * push * dtS;
        p.y += ty * push * dtS;
      }
      if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
    }
    particles = alive;
  }

07버튼을 누르면 뭉쳐 터지듯 퍼지는 연기

버튼을 누르면 뭉쳐 터지듯 퍼지는 연기다. 버튼을 누르거나 포커스 상태에서 Enter·Space 키를 누르면 입자 34개가 한 뭉치로 생겨 사방으로 퍼지다가 빠르게 옅어지고, 순간적으로 생겼다 사라지는 특성상 23프레임 중 18프레임에서만 움직임이 감지된다. 제출 완료 버튼 피드백에 쓴다.

버튼을 누르면 뭉쳐 터지듯 퍼지는 연기
새 창에서 열기

07 · 버튼을 누르면 뭉쳐 터지듯 퍼지는 연기 — 측정 누적면적 25.765% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOKE_BURST_COUNTSMOKE_BURST_LIFE_MSsmkBtn
  function burst(bornMs) {
    for (var i = 0; i < SMOKE_BURST_COUNT; i += 1) {
      var ang = pRand() * Math.PI * 2;
      var speed = SMOKE_BURST_SPEED_PX_S * (0.4 + pRand() * 0.9);
      particles.push({
        x: ORIGIN_X, y: ORIGIN_Y,
        vx: Math.cos(ang) * speed,
        vy: Math.sin(ang) * speed,
        born: bornMs
      });
    }
  }

08레이어마다 속도가 다른 겹겹의 연기

레이어마다 속도가 다른 겹겹의 연기다. 뒤쪽 레이어일수록 입자가 더 느리고 흐릿하게 움직여 앞쪽 레이어와 속도 차이가 깊이감을 만들어, 포트폴리오 히어로 씬에 쓴다.

레이어마다 속도가 다른 겹겹의 연기
새 창에서 열기

08 · 레이어마다 속도가 다른 겹겹의 연기 — 측정 누적면적 23.225% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LAYERSradiusMulalphaMul
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    for (var li = 0; li < LAYERS.length; li += 1) {
      var L = LAYERS[li];
      L.spawnAcc += dtMs * L.spawnPerS / 1000;
      while (L.spawnAcc >= 1) {
        L.particles.push({
          bx: L.bx + (L.rand() - 0.5) * 10,
          y: CALC_H - 6,
          born: nowMs,
          wSeed: L.rand() * 1000
        });
        L.spawnAcc -= 1;
      }
      var alive = [];
      for (var i = 0; i < L.particles.length; i += 1) {
        var p = L.particles[i];
        p.y -= L.speed * dtS;
        if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
      }
      L.particles = alive;
    }
  }

09스크롤 값에 반응하는 히어로 연기 장면

스크롤 값에 반응하는 히어로 연기 장면이다. 스크롤 값이 커질수록 연기 발생량과 소용돌이 반경이 함께 늘어 화면 전체 밀도가 짙어지고, 스크롤이 없는 화면에서는 ArrowUp·ArrowDown과 PageUp·PageDown 키로 같은 반응을 재현할 수 있어 사이트 최상단 히어로 섹션에 쓴다.

스크롤 값에 반응하는 히어로 연기 장면
새 창에서 열기

09 · 스크롤 값에 반응하는 히어로 연기 장면 — 측정 누적면적 11.384% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scrollProgresstriangleWaveSCROLL_AUTO_PERIOD_MS
    for (var i = 0; i < particles.length; i += 1) {
      var p = particles[i];
      var turb = (valueNoise2D(p.wSeed * 0.03, nowMs * 0.0017) - 0.5) * 2;
      p.x += turb * 10 * dtS;
      p.y -= SMOKE_RISE_SPEED_PX * dtS;
      var rx = p.x - pointerX, ry = p.y - pointerY;
      var dist = Math.hypot(rx, ry);
      if (dist < swirlRadius && dist > 0.5) {
        var push = (1 - dist / swirlRadius) * SMOKE_SWIRL_STRENGTH;
        var tx = -ry / dist, ty = rx / dist;
        p.x += tx * push * dtS;
        p.y += ty * push * dtS;
      }
      if (nowMs - p.born < PLUME_LIFE_MS) alive.push(p);
    }
    particles = alive;

잰 연기 아홉 항목의 실제 변동 — 24장 캡처 기준

캡처 24장을 델타 23개로 비교해 잰 수치는 다음과 같다.

항목 누적면적 평균 강도 움직인 프레임
01 위로 피어오르는 기본 연기 기둥 12.5% 25.8 23/23
02 바람 흐름장에 따라 옆으로 휘는 연기 11.1% 16.7 23/23
03 발생량 값으로 짙기가 바뀌는 연기 17.7% 16.2 23/23
04 발원지에서 멀어질수록 옅어지다 사라지는 연기 15.5% 18.5 23/23
05 시간에 따라 색이 바뀌는 연기 16.1% 30.6 23/23
06 포인터를 피해 소용돌이치는 연기 21.0% 29.6 23/23
07 버튼을 누르면 뭉쳐 터지듯 퍼지는 연기 25.8% 72.7 18/23
08 레이어마다 속도가 다른 겹겹의 연기 23.2% 18.8 23/23
09 스크롤 값에 반응하는 히어로 연기 장면 11.4% 18.0 23/23

값이 연기를 바꾸는 곳 — 발생량·상승 속도·소멸 시간

세 상수가 연기의 성격을 가른다. SMOKE_EMISSION_RATE는 03 항목에서 한 화면에 겹치는 입자 수를, SMOKE_RISE_SPEED_PX는 01·02·03·05·06·09 항목에서 입자가 위로 올라가는 속도를, SMOKE_DISSOLVE_MS는 04 항목에서 발원지로부터 얼마나 멀어져야 사라지는지를 정한다. 02 항목은 SMOKE_RISE_SPEED_PX 외에 WIND_FLOW_STRENGTH × gustEnvelope(t)라는 시간 게인을 따로 곱하는데, 이 게인에는 조절 슬라이더가 없고 오직 재생 시간에 따라서만 오르내린다.

어디서 깨지는가 — 동작 줄임 설정·저사양 기기의 프레임 저하·배경 없이 쓸 때

아홉 항목 모두 matchMedia('(prefers-reduced-motion: reduce)')로 동작 줄임 설정을 읽어 분기한다. 켜져 있으면 대부분 requestAnimationFrame 루프를 돌리는 대신 몇 프레임만 미리 계산해 둔 정지 장면 하나로 멈추고, 06·07·09처럼 입력을 받는 항목은 정지 상태에서도 화살표나 버튼을 누르면 그 자리에서 동기적으로 다시 그린다.

저사양 기기에서는 입자 수가 그대로 프레임 저하로 이어진다. 03 항목은 자동 재생의 삼각파 스윕이 발생량을 최대 20까지 끌어올려 320개 한도 근처까지 입자가 늘어나고, 07 항목은 버튼 한 번에 34개가 동시에 생겨나며, 09 항목은 스크롤이 끝까지 내려가면 260개 한도까지 입자가 채워져 매 프레임 그리는 원(그라디언트) 수가 함께 늘어난다.

진짜 함정은 배경이다. 아홉 데모 모두 어두운 무대 배경 위에서 입자의 rgba 알파값을 골랐는데 — 01·05는 밝은 색을 lighter 합성 모드로 얹고 나머지는 어두운 색을 기본 합성 모드로 얹는 식이라 — 이 배경 없이 흰 페이지에 캔버스만 옮기면 연기가 거의 보이지 않는다. 이 어두운 배경 값까지 포함한 아홉 개 원본은 압축 파일 안에 있고, 여는 비밀번호는 59z8ez3n 이다.

변형 세 가지와 접근성

이름 값 느낌
연기 발생량 값 조정 03 SMOKE_EMISSION_RATE 4 → 18 값을 줄이면 입자 수가 적어 연기가 옅고 성기게 보이고, 값을 늘리면 입자가 촘촘히 겹쳐 짙고 불투명하게 보인다.
연기 상승 속도 값 조정 01 SMOKE_RISE_SPEED_PX 12 → 40 값을 줄이면 연기가 천천히 올라가 오래 머무르고, 값을 늘리면 빠르게 치솟아 화면 위로 금방 빠져나간다.
연기 소멸 시간 값 조정 04 SMOKE_DISSOLVE_MS 900 → 2600 값을 줄이면 발원지에서 조금만 멀어져도 금방 사라지고, 값을 늘리면 한참 멀리 퍼진 뒤에야 옅어져 사라진다.

접근성은 반응 방식이 항목마다 다르다. matchMedia로 동작 줄임 설정을 읽어 대부분 정지 장면 하나로 멈추지만, 06 항목은 ArrowLeft·ArrowRight·ArrowUp·ArrowDown 네 방향 키로 포인터 좌표를 옮길 수 있고, 07 항목은 Enter·Space 키로 버튼과 같은 뭉치 터짐을 재현하며, 09 항목은 ArrowUp·ArrowDown과 PageUp·PageDown 키로 스크롤 값을 밀고 당길 수 있다. 셋 다 prefers-reduced-motion 값이 켜져 있어도 입력이 들어오면 그 값을 다시 확인해 동기적으로 화면을 갱신한다.

연기 효과는 파티클 라이브러리 없이도 되나?

된다. 아홉 항목 모두 외부 파티클 라이브러리 없이 seedRand 고정 난수와 valueNoise2D 값-노이즈, CanvasRenderingContext2D의 방사형 그라디언트만으로 연기를 그린다. 매 프레임은 requestAnimationFrame 콜백 안에서 stepSim으로 입자 위치를 갱신하고 drawSmoke로 그리는 두 함수만 반복한다. 같은 계산 축의 다른 예제는 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과와 무지개 그라데이션 9가지 — 캔버스 플라즈마 배경 만들기에서 확인할 수 있다.

연기 효과 아홉 가지에 대해 자주 나오는 질문 셋

연기 효과 코드에 실제 파티클 물리 엔진이 들어 있나요?

아니다. 중력·충돌 같은 물리 엔진 없이 seedRand 고정 난수와 값-노이즈로 위치만 계산한다. 07 항목만 예외로 p.vy -= 8 * dtS 한 줄로 간단한 낙하 감쇠를 흉내 낸다.

연기 효과를 다른 배경 위에 그대로 붙여도 되나요?

붙는 것은 되지만 원래 색감이 그대로 보이진 않는다. 아홉 데모 모두 어두운 무대 배경에 맞춰 알파값을 골랐기 때문에, 밝은 배경에서 쓰려면 입자 색과 합성 모드를 먼저 바꿔야 한다.

연기 효과를 영상으로 만들어 써도 되나요?

된다. 화면을 그대로 연기 효과 영상으로 녹화해 배경 영상처럼 반복 재생할 수 있는데, 이 경우는 동작 줄임 설정을 읽지 못하는 사전 녹화본이라 reduced-motion 사용자를 위한 정지 이미지를 따로 준비해야 한다.

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

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