02시간에 따라 방향이 바뀌는 바람 빗줄기 배경
느리게 흔들리는 파형 값으로 바람의 세기와 각도를 계속 다시 계산해, 빗줄기 전체의 기울기와 낙하 속도가 같은 바람 값을 따라 함께 기운다.
쓰임폭풍 느낌을 내는 히어로 배경
windGustAngleWIND_GUST_MAX_DEGRAIN_WIND_CYCLE_MS

<main class="stage">
<div class="rn is-demo" id="rnRoot">
<canvas class="rn__canvas" id="rnCanvas" width="480" height="300"></canvas>
<p class="rn__caption">느리게 흔들리는 파형 값으로 바람의 세기와 각도를 계속 다시 계산해, 빗줄기 전체의 기울기와 낙하 속도가 같은 바람 값을 따라 함께 기운다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rn {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-yellow;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rn {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffd23f;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rn {
cursor: default;
}
}(function () {
// 기법: 두 사인파를 합성한 windGustAngle() 값 하나가 바람의 각도를 정하고, 모든 빗줄기가
// 매 프레임 그 같은 값을 읽어 기울기·낙하 속도를 다시 계산한다. WIND_GUST_MAX_DEG 는 그
// 각도의 최댓값 — 값을 늘리면 약한 바람에도 크게 기울고, 줄이면 강한 바람에도 거의 수직이다.
var root = document.getElementById('rnRoot');
var canvas = document.getElementById('rnCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var WIND_GUST_MAX_DEG = 8;
var RAIN_WIND_CYCLE_MS = 5200;
var RAIN_STREAK_COUNT = 70;
var RAIN_FALL_SPEED_PX_S = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431021);
function windGustAngle(nowMs) {
var a = Math.sin(nowMs / RAIN_WIND_CYCLE_MS * Math.PI * 2) * 0.62;
var b = Math.sin(nowMs / (RAIN_WIND_CYCLE_MS * 1.73) * Math.PI * 2 + 1.3) * 0.38;
return (a + b) * WIND_GUST_MAX_DEG;
}
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
speed: RAIN_FALL_SPEED_PX_S * (0.6 + rRand() * 0.7),
thick: 0.8 + rRand() * 1.4
});
}
var elapsed = 0;
function stepSim(dtS, nowMs) {
var deg = windGustAngle(nowMs);
var rad = deg * Math.PI / 180;
var sinA = Math.sin(rad), cosA = Math.cos(rad);
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * cosA * dtS;
s.x += s.speed * sinA * dtS;
if (s.y > CALC_H + 6) { s.y = -6 - rRand() * 8; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var deg = windGustAngle(nowMs);
var rad = deg * Math.PI / 180;
var lx = Math.sin(rad), ly = Math.cos(rad);
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var len = 5 + s.speed * 0.03;
var x0 = s.x * sx, y0 = s.y * sy;
var x1 = (s.x - lx * len) * sx, y1 = (s.y - ly * len) * sy;
ctx.strokeStyle = 'rgba(20,26,54,.7)';
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 130 : 40;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasWindGustDrift.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
export default function RainEffectCanvasWindGustDrift() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const WIND_GUST_MAX_DEG = 8;
const RAIN_WIND_CYCLE_MS = 5200;
const RAIN_STREAK_COUNT = 70;
const RAIN_FALL_SPEED_PX_S = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431022);
function windGustAngle(nowMs: number) {
const a = Math.sin((nowMs / RAIN_WIND_CYCLE_MS) * Math.PI * 2) * 0.62;
const b = Math.sin((nowMs / (RAIN_WIND_CYCLE_MS * 1.73)) * Math.PI * 2 + 1.3) * 0.38;
return (a + b) * WIND_GUST_MAX_DEG;
}
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
speed: RAIN_FALL_SPEED_PX_S * (0.6 + rRand() * 0.7),
thick: 0.8 + rRand() * 1.4
});
}
let elapsed = 0;
function stepSim(dtS: number, nowMs: number) {
const deg = windGustAngle(nowMs);
const rad = (deg * Math.PI) / 180;
const sinA = Math.sin(rad), cosA = Math.cos(rad);
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * cosA * dtS;
s.x += s.speed * sinA * dtS;
if (s.y > CALC_H + 6) { s.y = -6 - rRand() * 8; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const deg = windGustAngle(nowMs);
const rad = (deg * Math.PI) / 180;
const lx = Math.sin(rad), ly = Math.cos(rad);
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const len = 5 + s.speed * 0.03;
const x0 = s.x * sx, y0 = s.y * sy;
const x1 = (s.x - lx * len) * sx, y1 = (s.y - ly * len) * sy;
ctx!.strokeStyle = "rgba(20,26,54,.7)";
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x0, y0);
ctx!.lineTo(x1, y1);
ctx!.stroke();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 130 : 40;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rn} is-demo`} ref={rootRef}>
<canvas className={styles.rn__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rn__caption}>느리게 흔들리는 파형 값으로 바람의 세기와 각도를 계속 다시 계산해, 빗줄기 전체의 기울기와 낙하 속도가 같은 바람 값을 따라 함께 기운다</p>
</div>
</main>
);
}02 시간에 따라 방향이 바뀌는 바람 빗줄기 배경 — 측정 누적면적 57.836% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 비 내리는 효과 9가지 — 창밖 폭풍 캔버스 장면
이 데모가 속한 글
css 비 내리는 효과 9가지 — 창밖 폭풍 캔버스 장면이 배경 장면에서는 바람 벡터 값 하나가 아홉 층 빗줄기의 기울기와 속도를 동시에 결정한다. 아래 아홉 개는 css 비 내리는 효과를 캔버스 2d 위에서 픽셀 단위로 계산해 그린 장면이며, 파일을 내려받으면 그대로 이 글의 데모 전부 보기


