css 雨を降らせる 9パターン — 窓辺のストーム背景
このcss 雨を降らせるコレクションは、窓辺の嵐の場面を9つの独立したキャンバス層に分け、一本のループ動画ではなく層ごとに別々の計算で動かしています。この背景シーンでは、ひとつの風ベクトル値が九つの雨層の傾きと速度を同時に決めている。以下の各見出しでは、その値を実際に読んでいる関数をひとつずつコードで開いていきます。
九つの雨レイヤーをこの場で動かして確かめる
下のグリッドをスクロールすると、9つのキャンバスはすでにそれぞれ自分のループで動いています — クリックを待つものはありません。
01 rain-effect-canvas-streak-depth-parallax
02 rain-effect-canvas-wind-gust-drift
03 rain-effect-canvas-window-droplet-trickle
04 rain-effect-canvas-ground-splash-physics
05 rain-effect-canvas-storm-intensity-cycle
06 rain-effect-canvas-lightning-branch-flash
07 rain-effect-canvas-pointer-wind-lean
08 rain-effect-canvas-umbrella-occlusion
09 rain-effect-canvas-hero-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 層ごとに太さと速度が違う雨筋パララックス背景
- 02 時間とともに向きが変わる風の雨背景
- 03 流れ落ちながら合わさる窓の水滴
- 04 落ちた場所ごとに跳ねる雨のしぶき
- 05 小雨から豪雨まで強さが行き来する場面
- 06 枝分かれして伸びる雷と画面フラッシュ
- 07 ポインター位置に風が傾く背景
- 08 傘の後ろでは隠れる雨筋
- 09 層・風・水滴・雷を一画面にまとめた嵐のヒーローシーン
css 雨を降らせる 9パターン — 層ごとの計算が重なって嵐になる
このセットは奥行きで分かれた雨筋の層、時間で向きが変わる風ベクトル、合体する窓の水滴、地面の飛沫、ゆっくり周期を描く強さ、枝分かれする雷、ポインターで傾く風、傘による遮蔽、そして4つの技法を重ねたヒーロー場面まで、一式そろっています。同じ「層を重ねて一つの場面にする」考え方はhtml5 canvas アニメーション9種 — 水滴とブラックホールでも詳しく扱っています。下の各項目を開くと、それを生み出している関数をそのまま読めます。
01層ごとに太さと速度が違う雨筋パララックス背景
雨筋ごとに仮の距離を与え、太さと落下速度をその距離に反比例させることで、近い筋は太く速く、遠い筋は細くゆっくり落ちます。streakDepthZ() が RAIN_LAYER_COUNT までの各層でその深さを計算し、できた streaks 配列は深さ順に並べ替えられるため、遠い層から先に描かれて近い層がその上に重なります。

<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-ink);
.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-ink;
}
.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, 247, 230, .85);
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: #17141a;
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: #17141a;
}
.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, 247, 230, 0.85);
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 () {
// 기법: 빗줄기마다 층(RAIN_LAYER_COUNT)을 매겨 가상의 거리(depth z, 0=멀다 1=가깝다)를 부여한다.
// 굵기·속도·불투명도를 모두 z 에 비례해 정하므로 가까운 줄기는 굵고 빠르고 진하게, 먼 줄기는
// 가늘고 느리고 옅게 떨어진다 — 거리 하나가 세 가지 값을 동시에 결정하는 시차(parallax) 계산.
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 RAIN_LAYER_COUNT = 4;
var RAIN_NEAR_SPEED_PX_S = 150;
var RAIN_FAR_SPEED_PX_S = 40;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431011);
function streakDepthZ(layerIndex) {
return RAIN_LAYER_COUNT <= 1 ? 1 : layerIndex / (RAIN_LAYER_COUNT - 1);
}
var streaks = [];
for (var li = 0; li < RAIN_LAYER_COUNT; li += 1) {
var z = streakDepthZ(li);
var count = Math.round(32 - z * 18);
var speed = RAIN_FAR_SPEED_PX_S + (RAIN_NEAR_SPEED_PX_S - RAIN_FAR_SPEED_PX_S) * z;
var thick = 0.5 + z * 2.1;
var alpha = 0.28 + z * 0.62;
for (var i = 0; i < count; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
z: z, speed: speed, thick: thick, alpha: alpha,
len: 4 + z * 12
});
}
}
streaks.sort(function (a, b) { return a.z - b.z; });
function stepSim(dtS) {
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
if (s.y - s.len > CALC_H + 4) {
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
}
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx;
var y0 = s.y * sy, y1 = (s.y - s.len) * sy;
ctx.strokeStyle = 'rgba(214,232,255,' + s.alpha + ')';
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, 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();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 90 : 40;
for (var w = 0; w < warmSteps; w += 1) stepSim(0.02);
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
stepSim(dt / 1000);
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasStreakDepthParallax.module.scss";
type Streak = { x: number; y: number; z: number; speed: number; thick: number; alpha: number; len: number };
export default function RainEffectCanvasStreakDepthParallax() {
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 RAIN_LAYER_COUNT = 4;
const RAIN_NEAR_SPEED_PX_S = 150;
const RAIN_FAR_SPEED_PX_S = 40;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431012);
function streakDepthZ(layerIndex: number) {
return RAIN_LAYER_COUNT <= 1 ? 1 : layerIndex / (RAIN_LAYER_COUNT - 1);
}
const streaks: Streak[] = [];
for (let li = 0; li < RAIN_LAYER_COUNT; li += 1) {
const z = streakDepthZ(li);
const count = Math.round(32 - z * 18);
const speed = RAIN_FAR_SPEED_PX_S + (RAIN_NEAR_SPEED_PX_S - RAIN_FAR_SPEED_PX_S) * z;
const thick = 0.5 + z * 2.1;
const alpha = 0.28 + z * 0.62;
for (let i = 0; i < count; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
z, speed, thick, alpha,
len: 4 + z * 12
});
}
}
streaks.sort((a, b) => a.z - b.z);
function stepSim(dtS: number) {
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
if (s.y - s.len > CALC_H + 4) {
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
}
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx;
const y0 = s.y * sy, y1 = (s.y - s.len) * sy;
ctx!.strokeStyle = "rgba(214,232,255," + s.alpha + ")";
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, 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();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 90 : 40;
for (let w = 0; w < warmSteps; w += 1) stepSim(0.02);
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) {
stepSim(dt / 1000);
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
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>
);
}01 · 層ごとに太さと速度が違う雨筋パララックス背景 — 計測 累積面積 63.301% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function streakDepthZ(layerIndex) {
return RAIN_LAYER_COUNT <= 1 ? 1 : layerIndex / (RAIN_LAYER_COUNT - 1);
}
var streaks = [];
for (var li = 0; li < RAIN_LAYER_COUNT; li += 1) {
var z = streakDepthZ(li);
var count = Math.round(32 - z * 18);
var speed = RAIN_FAR_SPEED_PX_S + (RAIN_NEAR_SPEED_PX_S - RAIN_FAR_SPEED_PX_S) * z;
var thick = 0.5 + z * 2.1;
var alpha = 0.28 + z * 0.62;
for (var i = 0; i < count; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
z: z, speed: speed, thick: thick, alpha: alpha,
len: 4 + z * 12
});
}
}
streaks.sort(function (a, b) { return a.z - b.z; });
項目01はフレームの63.3%を平均強度111.8で塗り替え、23/23フレームで動きが測定されました。
02時間とともに向きが変わる風の雨背景
ゆっくり揺れる波の値で風の強さと角度を計算し続け、雨筋全体の傾きと落下速度が同じ風の値に合わせて一緒に傾きます。windGustAngle() は RAIN_WIND_CYCLE_MS ごとに異なる周期で回る二つのサイン波を合成し、その結果を WIND_GUST_MAX_DEG で上限まで拡大しています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) {
項目02はフレームの57.8%を平均強度104.1で塗り替え、23/23フレームで動きが測定されました。
03流れ落ちながら合わさる窓の水滴
水滴ごとに重さと抵抗を計算して流れ落ちる速度を決め直し、通り道で別の水滴と重なると二つを一つに合わせて大きくします。dropletMassMerge() が二つの質量を合算して小さい方の vy を捨て、DROPLET_GRAVITY_PX_S から求めた目標速度に stepSim() が毎フレーム近づけています。

<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-orange);
.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-orange;
}
.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, .85);
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: #ff4d1f;
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: #ff4d1f;
}
.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.85);
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 () {
// 기법: 물방울마다 질량(mass)을 갖고, 매 프레임 무게 대 저항(DROPLET_DRAG_COEF) 비율로
// 목표 속도를 다시 계산해 그쪽으로 서서히 붙는다(lerp). dropletMassMerge() 가 겹친 두
// 물방울의 질량을 합쳐 반지름을 키우고 하나로 줄인다 — 무거워질수록 더 빨리 흘러내린다.
// 하이라이트는 물방울 왼쪽 위에 밝은 점을 얹어 굴절된 빛처럼 보이게 한다.
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 DROPLET_DRAG_COEF = 3.2;
var DROPLET_GRAVITY_PX_S = 58;
var DROPLET_MERGE_DIST_PX = 2.4;
var DROPLET_SPAWN_PER_S = 2.6;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var dRand = seedRand(431031);
var drops = [];
var spawnAcc = 0;
function spawnDroplet() {
var r = 1.6 + dRand() * 1.6;
drops.push({
x: dRand() * CALC_W,
y: -3,
r: r,
mass: r * r,
vy: 4,
seed: dRand() * 1000
});
}
function dropletMassMerge(a, b) {
var mass = a.mass + b.mass;
var r = Math.sqrt(mass) * 0.92;
return {
x: (a.x * a.mass + b.x * b.mass) / mass,
y: Math.max(a.y, b.y),
r: r, mass: mass, vy: Math.max(a.vy, b.vy), seed: a.seed
};
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * DROPLET_SPAWN_PER_S / 1000;
while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
for (var i = 0; i < drops.length; i += 1) {
var d = drops[i];
var targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
d.y += d.vy * dtS;
d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
}
// 병합 — 가까운 두 물방울이 겹치면 하나로
for (var a = 0; a < drops.length; a += 1) {
for (var b = a + 1; b < drops.length; b += 1) {
var p1 = drops[a], p2 = drops[b];
if (!p1 || !p2) continue;
var dx = p1.x - p2.x, dy = p1.y - p2.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < DROPLET_MERGE_DIST_PX + (p1.r + p2.r) * 0.55) {
drops[a] = dropletMassMerge(p1, p2);
drops.splice(b, 1);
b -= 1;
}
}
}
var alive = [];
for (var k = 0; k < drops.length; k += 1) {
if (drops[k].y - drops[k].r < CALC_H + 6) alive.push(drops[k]);
}
drops = alive;
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
var s = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < drops.length; i += 1) {
var d = drops[i];
var dx = d.x * sx, dy = d.y * sy, dr = d.r * s;
ctx.fillStyle = 'rgba(15,24,52,.42)';
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
var g = ctx.createRadialGradient(dx - dr * 0.35, dy - dr * 0.4, 0, dx - dr * 0.35, dy - dr * 0.4, dr * 0.7);
g.addColorStop(0, 'rgba(255,255,255,.85)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx - dr * 0.35, dy - dr * 0.4, dr * 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,1)';
ctx.beginPath();
ctx.moveTo(dx - dr * 0.1, dy - dr * 1.3);
ctx.lineTo(dx - dr * 0.1, dy - dr * (1.3 - Math.min(0.7, dr * 0.05)));
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();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 180 : 90;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, elapsed);
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasWindowDropletTrickle.module.scss";
type Droplet = { x: number; y: number; r: number; mass: number; vy: number; seed: number };
export default function RainEffectCanvasWindowDropletTrickle() {
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 DROPLET_DRAG_COEF = 3.2;
const DROPLET_GRAVITY_PX_S = 58;
const DROPLET_MERGE_DIST_PX = 2.4;
const DROPLET_SPAWN_PER_S = 2.6;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const dRand = seedRand(431032);
let drops: Droplet[] = [];
let spawnAcc = 0;
function spawnDroplet() {
const r = 1.6 + dRand() * 1.6;
drops.push({ x: dRand() * CALC_W, y: -3, r, mass: r * r, vy: 4, seed: dRand() * 1000 });
}
function dropletMassMerge(a: Droplet, b: Droplet): Droplet {
const mass = a.mass + b.mass;
const r = Math.sqrt(mass) * 0.92;
return {
x: (a.x * a.mass + b.x * b.mass) / mass,
y: Math.max(a.y, b.y),
r, mass, vy: Math.max(a.vy, b.vy), seed: a.seed
};
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += (dtMs * DROPLET_SPAWN_PER_S) / 1000;
while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
for (let i = 0; i < drops.length; i += 1) {
const d = drops[i];
const targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
d.y += d.vy * dtS;
d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
}
for (let a = 0; a < drops.length; a += 1) {
for (let b = a + 1; b < drops.length; b += 1) {
const p1 = drops[a], p2 = drops[b];
if (!p1 || !p2) continue;
const dx = p1.x - p2.x, dy = p1.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < DROPLET_MERGE_DIST_PX + (p1.r + p2.r) * 0.55) {
drops[a] = dropletMassMerge(p1, p2);
drops.splice(b, 1);
b -= 1;
}
}
}
drops = drops.filter((d) => d.y - d.r < CALC_H + 6);
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
const s = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < drops.length; i += 1) {
const d = drops[i];
const dx = d.x * sx, dy = d.y * sy, dr = d.r * s;
ctx!.fillStyle = "rgba(15,24,52,.42)";
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
const g = ctx!.createRadialGradient(dx - dr * 0.35, dy - dr * 0.4, 0, dx - dr * 0.35, dy - dr * 0.4, dr * 0.7);
g.addColorStop(0, "rgba(255,255,255,.85)");
g.addColorStop(1, "rgba(255,255,255,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx - dr * 0.35, dy - dr * 0.4, dr * 0.55, 0, Math.PI * 2);
ctx!.fill();
}
}
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();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 180 : 90;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, elapsed);
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
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>
);
}03 · 流れ落ちながら合わさる窓の水滴 — 計測 累積面積 8.967% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function dropletMassMerge(a, b) {
var mass = a.mass + b.mass;
var r = Math.sqrt(mass) * 0.92;
return {
x: (a.x * a.mass + b.x * b.mass) / mass,
y: Math.max(a.y, b.y),
r: r, mass: mass, vy: Math.max(a.vy, b.vy), seed: a.seed
};
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * DROPLET_SPAWN_PER_S / 1000;
while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
for (var i = 0; i < drops.length; i += 1) {
var d = drops[i];
var targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
d.y += d.vy * dtS;
d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
}
// 合体 — 重なった水滴同士を一つに
for (var a = 0; a < drops.length; a += 1) {
for (var b = a + 1; b < drops.length; b += 1) {
var p1 = drops[a], p2 = drops[b];
項目03はフレームの9.0%を平均強度80.9で塗り替え、23/23フレームで動きが測定されました。
04落ちた場所ごとに跳ねる雨のしぶき
雨粒が地面に触れた瞬間に方向と速度をランダムに分けた複数の飛沫粒子を生み、各粒子に毎フレーム重力を足し直すことで放物線を描いて散らばります。splashBurst() は着地のたびに SPLASH_PARTICLE_COUNT 個の粒子を生成し、stepSim() が雨筋の到達を検知するたびに SPLASH_GRAVITY_PX_S2 がその粒子を引き戻します。

<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-paper);
.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-paper;
}
.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(23, 20, 26, .78);
color: $subject-cream;
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: #ffffff;
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: #ffffff;
}
.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(23, 20, 26, 0.78);
color: #fff7e6;
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 () {
// 기법: 빗줄기가 바닥선(GROUND_Y)에 닿는 순간 splashBurst() 가 SPLASH_PARTICLE_COUNT 개
// 입자를 방향·속도를 나눠 흩뿌리고, 그 입자마다 SPLASH_GRAVITY_PX_S2 를 매 프레임 다시 더해
// 포물선을 그리며 옆으로 흩어지다 옅어진다. 속성 트윈이 아니라 매 프레임 중력 적분이다.
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 GROUND_Y = 88;
var SPLASH_PARTICLE_COUNT = 6;
var SPLASH_GRAVITY_PX_S2 = 260;
var SPLASH_LIFE_MS = 420;
var RAIN_STREAK_COUNT = 46;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431041);
var sRand = seedRand(431042);
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * GROUND_Y,
speed: 120 + rRand() * 60,
thick: 0.8 + rRand() * 1.2
});
}
var splashes = [];
function splashBurst(x, nowMs) {
for (var i = 0; i < SPLASH_PARTICLE_COUNT; i += 1) {
var ang = Math.PI + sRand() * Math.PI; // 위쪽 반원으로 퍼진다
var speed = 16 + sRand() * 26;
splashes.push({
x: x, y: GROUND_Y,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
born: nowMs
});
}
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
if (s.y >= GROUND_Y) {
splashBurst(s.x, nowMs);
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
}
var alive = [];
for (var j = 0; j < splashes.length; j += 1) {
var p = splashes[j];
p.vy += SPLASH_GRAVITY_PX_S2 * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
if (nowMs - p.born < SPLASH_LIFE_MS) alive.push(p);
}
splashes = alive;
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var sc = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(20,26,54,.22)';
ctx.lineWidth = Math.max(1, 1.4 * sc);
ctx.beginPath();
ctx.moveTo(0, GROUND_Y * sy);
ctx.lineTo(cw, GROUND_Y * sy);
ctx.stroke();
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
if (s.y < 0 || s.y > GROUND_Y) continue;
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx.strokeStyle = 'rgba(20,26,54,.62)';
ctx.lineWidth = Math.max(0.6, s.thick * sc);
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, y1);
ctx.stroke();
}
for (var k = 0; k < splashes.length; k += 1) {
var p = splashes[k];
var f = Math.max(0, Math.min(1, (nowMs - p.born) / SPLASH_LIFE_MS));
var alpha = (1 - f) * 0.75;
var dx = p.x * sx, dy = p.y * sy, dr = (0.7 + (1 - f) * 0.7) * sc;
ctx.fillStyle = 'rgba(20,26,54,' + alpha + ')';
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
}
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;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 60 : 20;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, elapsed);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasGroundSplashPhysics.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
type Splash = { x: number; y: number; vx: number; vy: number; born: number };
export default function RainEffectCanvasGroundSplashPhysics() {
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 GROUND_Y = 88;
const SPLASH_PARTICLE_COUNT = 6;
const SPLASH_GRAVITY_PX_S2 = 260;
const SPLASH_LIFE_MS = 420;
const RAIN_STREAK_COUNT = 46;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431043);
const sRand = seedRand(431044);
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * GROUND_Y, speed: 120 + rRand() * 60, thick: 0.8 + rRand() * 1.2 });
}
let splashes: Splash[] = [];
function splashBurst(x: number, nowMs: number) {
for (let i = 0; i < SPLASH_PARTICLE_COUNT; i += 1) {
const ang = Math.PI + sRand() * Math.PI;
const speed = 16 + sRand() * 26;
splashes.push({ x, y: GROUND_Y, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, born: nowMs });
}
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
if (s.y >= GROUND_Y) {
splashBurst(s.x, nowMs);
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
}
const alive: Splash[] = [];
for (let j = 0; j < splashes.length; j += 1) {
const p = splashes[j];
p.vy += SPLASH_GRAVITY_PX_S2 * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
if (nowMs - p.born < SPLASH_LIFE_MS) alive.push(p);
}
splashes = alive;
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const sc = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(20,26,54,.22)";
ctx!.lineWidth = Math.max(1, 1.4 * sc);
ctx!.beginPath();
ctx!.moveTo(0, GROUND_Y * sy);
ctx!.lineTo(cw, GROUND_Y * sy);
ctx!.stroke();
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
if (s.y < 0 || s.y > GROUND_Y) continue;
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx!.strokeStyle = "rgba(20,26,54,.62)";
ctx!.lineWidth = Math.max(0.6, s.thick * sc);
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, y1);
ctx!.stroke();
}
for (let k = 0; k < splashes.length; k += 1) {
const p = splashes[k];
const f = Math.max(0, Math.min(1, (nowMs - p.born) / SPLASH_LIFE_MS));
const alpha = (1 - f) * 0.75;
const dx = p.x * sx, dy = p.y * sy, dr = (0.7 + (1 - f) * 0.7) * sc;
ctx!.fillStyle = "rgba(20,26,54," + alpha + ")";
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
}
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;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 60 : 20;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, 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>
);
}04 · 落ちた場所ごとに跳ねる雨のしぶき — 計測 累積面積 48.23% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function splashBurst(x, nowMs) {
for (var i = 0; i < SPLASH_PARTICLE_COUNT; i += 1) {
var ang = Math.PI + sRand() * Math.PI; // 上半円に広がる
var speed = 16 + sRand() * 26;
splashes.push({
x: x, y: GROUND_Y,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
born: nowMs
});
}
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
if (s.y >= GROUND_Y) {
splashBurst(s.x, nowMs);
項目04はフレームの48.2%を平均強度78.5で塗り替え、23/23フレームで動きが測定されました。
05小雨から豪雨まで強さが行き来する場面
ゆっくり周期を描く一つの関数で嵐の強さを上下させ続け、その値に合わせて雨筋の数と風の強さを毎フレーム計算し直すことで場面全体が弱まったり強まったりします。stormIntensity() は STORM_CYCLE_MS の周期で動き、stepSim() がその値を読んで風のオフセットと RAIN_SPAWN_RATE_BASE 付近まで落ちる雨筋の数の両方を決めています。

<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-ink);
.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-ink;
}
.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, 247, 230, .85);
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: #17141a;
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: #17141a;
}
.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, 247, 230, 0.85);
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 () {
// 기법: stormIntensity() 하나가 사인 주기(STORM_CYCLE_MS)로 0~1 을 오르내리고, 그 값 하나로
// 화면에 보이는 빗줄기 수·굵기·기울기·어두운 정도를 매 프레임 다시 계산한다. RAIN_SPAWN_RATE_BASE
// 는 가장 약한 이슬비일 때도 유지하는 최소 줄기 수 — 그 위로 세기만큼 더 얹는다.
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 STORM_CYCLE_MS = 9000;
var RAIN_SPAWN_RATE_BASE = 18;
var RAIN_POOL_SIZE = 110;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431051);
function stormIntensity(nowMs) {
return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
}
var streaks = [];
for (var i = 0; i < RAIN_POOL_SIZE; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
thresh: rRand(),
speed: 90 + rRand() * 90,
thick: 0.7 + rRand() * 1.3
});
}
function stepSim(dtS, nowMs) {
var inten = stormIntensity(nowMs);
var wind = (inten - 0.5) * 20;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
s.y += s.speed * (0.6 + inten * 0.7) * dtS;
s.x += wind * 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 inten = stormIntensity(nowMs);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(6,8,20,' + (inten * 0.4).toFixed(3) + ')';
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
var alpha = 0.35 + inten * 0.55;
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx.strokeStyle = 'rgba(214,228,255,' + alpha + ')';
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, 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;
var elapsed = 2200;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 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 "./RainEffectCanvasStormIntensityCycle.module.scss";
type Streak = { x: number; y: number; thresh: number; speed: number; thick: number };
export default function RainEffectCanvasStormIntensityCycle() {
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 STORM_CYCLE_MS = 9000;
const RAIN_SPAWN_RATE_BASE = 18;
const RAIN_POOL_SIZE = 110;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431052);
function stormIntensity(nowMs: number) {
return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
}
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_POOL_SIZE; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, thresh: rRand(), speed: 90 + rRand() * 90, thick: 0.7 + rRand() * 1.3 });
}
function stepSim(dtS: number, nowMs: number) {
const inten = stormIntensity(nowMs);
const wind = (inten - 0.5) * 20;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
s.y += s.speed * (0.6 + inten * 0.7) * dtS;
s.x += wind * 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 inten = stormIntensity(nowMs);
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(6,8,20," + (inten * 0.4).toFixed(3) + ")";
ctx!.fillRect(0, 0, cw, ch);
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
const alpha = 0.35 + inten * 0.55;
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx!.strokeStyle = "rgba(214,228,255," + alpha + ")";
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, 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;
let elapsed = 2200;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 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>
);
}05 · 小雨から豪雨まで強さが行き来する場面 — 計測 累積面積 60.446% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stormIntensity(nowMs) {
return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
}
var streaks = [];
for (var i = 0; i < RAIN_POOL_SIZE; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
thresh: rRand(),
speed: 90 + rRand() * 90,
thick: 0.7 + rRand() * 1.3
});
}
function stepSim(dtS, nowMs) {
var inten = stormIntensity(nowMs);
var wind = (inten - 0.5) * 20;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
項目05はフレームの60.4%を平均強度122.6で塗り替え、23/23フレームで動きが測定されました。
06枝分かれして伸びる雷と画面フラッシュ
雷の経路は中間点を繰り返しずらして枝分かれさせて描き、同時に画面の明るさを急に上げてゆっくり戻す曲線で計算し、光る瞬間を作ります。branchLightning() は LIGHTNING_BRANCH_DEPTH まで再帰し、稲妻が着地するとフラッシュの明るさは LIGHTNING_FLASH_DECAY_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: #14110a;
}
.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, 247, 230, .85);
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: #14110a;
}
.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, 247, 230, 0.85);
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 () {
// 기법: branchLightning() 이 매 낙뢰마다 재귀로 가지를 새로 뻗는다(깊이 LIGHTNING_BRANCH_DEPTH).
// 낙뢰 순간의 흰 섬광은 LIGHTNING_FLASH_DECAY_MS 를 시상수로 하는 지수 감쇠(Math.exp)로 사그라들고,
// 번개 선 자체는 그보다 긴 LIGHTNING_BOLT_FADE_MS 동안 잔상으로 더 남는다.
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 LIGHTNING_STRIKE_PERIOD_MS = 1500;
var LIGHTNING_FLASH_DECAY_MS = 220;
var LIGHTNING_BOLT_FADE_MS = 900;
var LIGHTNING_BRANCH_DEPTH = 4;
var RAIN_STREAK_COUNT = 50;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431061);
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 70, thick: 0.6 + rRand() * 1 });
}
function branchLightning(segs, x, y, ang, len, depth) {
if (depth <= 0 || len < 3) return;
var steps = 3 + Math.floor(rRand() * 2);
var cx = x, cy = y, cang = ang;
for (var i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
var stepLen = len / steps;
var nx = cx + Math.sin(cang) * stepLen;
var ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth: depth });
if (depth > 1 && rRand() < 0.3) {
var side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
var segs = [];
var startX = 24 + rRand() * 112;
branchLightning(segs, startX, 0, 0, CALC_H * 0.94, LIGHTNING_BRANCH_DEPTH);
return segs;
}
var bolt = [];
var strikeAtMs = -99999;
var lastStrikeIndex = -1;
function updateStrike(nowMs) {
var idx = Math.floor(nowMs / LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS, nowMs) {
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
updateStrike(nowMs);
}
var cw = 480, ch = 300;
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var sinceStrike = nowMs - strikeAtMs;
var flash = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_FLASH_DECAY_MS);
var boltAlpha = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_BOLT_FADE_MS);
ctx.fillStyle = 'rgba(20,17,10,1)';
ctx.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx.fillStyle = 'rgba(255,250,230,' + (flash * 0.55).toFixed(3) + ')';
ctx.fillRect(0, 0, cw, ch);
}
ctx.strokeStyle = 'rgba(214,228,255,0.45)';
ctx.lineWidth = Math.max(0.6, 0.8 * ((sx + sy) / 2));
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 4) * sy;
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, y1);
ctx.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx.save();
ctx.strokeStyle = 'rgba(255,252,240,' + boltAlpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(255,246,200,' + (boltAlpha * 0.8).toFixed(3) + ')';
ctx.shadowBlur = 8 * ((sx + sy) / 2);
for (var b = 0; b < bolt.length; b += 1) {
var seg = bolt[b];
ctx.lineWidth = Math.max(0.8, (seg.depth) * ((sx + sy) / 2) * 0.55);
ctx.beginPath();
ctx.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx.stroke();
}
ctx.restore();
}
}
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;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 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 "./RainEffectCanvasLightningBranchFlash.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
type Seg = { x0: number; y0: number; x1: number; y1: number; depth: number };
export default function RainEffectCanvasLightningBranchFlash() {
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 LIGHTNING_STRIKE_PERIOD_MS = 1500;
const LIGHTNING_FLASH_DECAY_MS = 220;
const LIGHTNING_BOLT_FADE_MS = 900;
const LIGHTNING_BRANCH_DEPTH = 4;
const RAIN_STREAK_COUNT = 50;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431062);
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 70, thick: 0.6 + rRand() * 1 });
}
function branchLightning(segs: Seg[], x: number, y: number, ang: number, len: number, depth: number) {
if (depth <= 0 || len < 3) return;
const steps = 3 + Math.floor(rRand() * 2);
let cx = x, cy = y, cang = ang;
for (let i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
const stepLen = len / steps;
const nx = cx + Math.sin(cang) * stepLen;
const ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth });
if (depth > 1 && rRand() < 0.3) {
const side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
const segs: Seg[] = [];
const startX = 24 + rRand() * 112;
branchLightning(segs, startX, 0, 0, CALC_H * 0.94, LIGHTNING_BRANCH_DEPTH);
return segs;
}
let bolt: Seg[] = [];
let strikeAtMs = -99999;
let lastStrikeIndex = -1;
function updateStrike(nowMs: number) {
const idx = Math.floor(nowMs / LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS: number, nowMs: number) {
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
updateStrike(nowMs);
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const sinceStrike = nowMs - strikeAtMs;
const flash = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_FLASH_DECAY_MS);
const boltAlpha = Math.exp(-Math.max(0, sinceStrike) / LIGHTNING_BOLT_FADE_MS);
ctx!.fillStyle = "rgba(20,17,10,1)";
ctx!.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx!.fillStyle = "rgba(255,250,230," + (flash * 0.55).toFixed(3) + ")";
ctx!.fillRect(0, 0, cw, ch);
}
ctx!.strokeStyle = "rgba(214,228,255,0.45)";
ctx!.lineWidth = Math.max(0.6, 0.8 * ((sx + sy) / 2));
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 4) * sy;
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, y1);
ctx!.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx!.save();
ctx!.strokeStyle = "rgba(255,252,240," + boltAlpha.toFixed(3) + ")";
ctx!.shadowColor = "rgba(255,246,200," + (boltAlpha * 0.8).toFixed(3) + ")";
ctx!.shadowBlur = 8 * ((sx + sy) / 2);
for (let b = 0; b < bolt.length; b += 1) {
const seg = bolt[b];
ctx!.lineWidth = Math.max(0.8, seg.depth * ((sx + sy) / 2) * 0.55);
ctx!.beginPath();
ctx!.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx!.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx!.stroke();
}
ctx!.restore();
}
}
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;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 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>
);
}06 · 枝分かれして伸びる雷と画面フラッシュ — 計測 累積面積 73.354% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function branchLightning(segs, x, y, ang, len, depth) {
if (depth <= 0 || len < 3) return;
var steps = 3 + Math.floor(rRand() * 2);
var cx = x, cy = y, cang = ang;
for (var i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
var stepLen = len / steps;
var nx = cx + Math.sin(cang) * stepLen;
var ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth: depth });
if (depth > 1 && rRand() < 0.3) {
var side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
var segs = [];
var startX = 24 + rRand() * 112;
項目06はフレームの73.4%を平均強度51.7で塗り替え、23/23フレームで動きが測定されました。
07ポインター位置に風が傾く背景
ポインターが画面中央からずれた分だけ風ベクトルの向きを計算し直し、ポインターが動く間は雨筋全体がその方向に一緒に傾いて落ちます。windLeanTarget() がそのずれを傾き角に変換し、POINTER_WIND_LERP が毎フレーム現在の傾きをその目標へ近づけ、ポインター入力がないときは virtualPointerSweep() が同じ目標を代わりに動かします。

<main class="stage">
<div class="rn is-demo" id="rnRoot" tabindex="0" role="img" aria-label="ポインターを左右に動かすか矢印キーを押すと、雨がその方向に傾いて落ちる場面">
<canvas class="rn__canvas" id="rnCanvas" width="480" height="300"></canvas>
<p class="rn__caption">ポインターが画面中央からずれた分だけ風ベクトルの向きを計算し直し、ポインターが動く間は雨筋全体がその方向に一緒に傾いて落ちる。</p>
<p class="rn__hint">ポインターを動かすか ← → キー</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.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: #241812;
cursor: pointer;
&:focus-visible {
outline: 3px solid $subject-orange-deep;
outline-offset: 2px;
}
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.rn__hint {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .55);
color: $subject-cream;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.rn__hint { display: none; }
}
@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: #ff4d1f;
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: #241812;
cursor: pointer;
}
.rn:focus-visible {
outline: 3px solid #c93000;
outline-offset: 2px;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.rn__hint {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.55);
color: #fff7e6;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption {
font-size: 10px;
padding: 4px 8px;
}
.rn__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rn {
cursor: default;
}
}(function () {
// 기법: windLeanTarget() 이 포인터(또는 가상 포인터) x 위치를 -1~1 로 정규화해 기울기 목표각을 정하고,
// 실제 기울기는 POINTER_WIND_LERP 비율로 목표각을 매 프레임 뒤쫓는다(즉시 꺾이지 않고 관성처럼 따라감).
// virtualPointerSweep() 은 실제 입력이 없을 때 같은 함수를 그대로 호출하는 자동 시연 경로.
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 POINTER_WIND_LERP = 0.06;
var WIND_LEAN_MAX_DEG = 22;
var RAIN_STREAK_COUNT = 90;
var SWEEP_PERIOD_MS = 3400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431071);
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 100 + rRand() * 80, thick: 0.6 + rRand() * 1.1 });
}
var manual = false;
var pointerNormX = 0;
var manualReleaseTimer = null;
function windLeanTarget(normX) {
return normX * WIND_LEAN_MAX_DEG;
}
function virtualPointerSweep(nowMs) {
return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
}
var leanDeg = 0;
function stepSim(dtS, nowMs) {
var normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
var target = windLeanTarget(normX);
leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));
var leanRad = (leanDeg * Math.PI) / 180;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(leanRad) * s.speed * 0.7 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
var cw = 480, ch = 300;
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(38,20,10,0.06)';
ctx.fillRect(0, 0, cw, ch);
var leanRad = (leanDeg * Math.PI) / 180;
var dx = Math.sin(leanRad) * 6, dy = Math.cos(leanRad) * 6;
ctx.strokeStyle = 'rgba(255,236,214,0.55)';
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx, y = s.y * sy;
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * s.thick * (sx / 6), y - dy * s.thick * (sy / 6));
ctx.stroke();
}
}
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();
}
function setManualFromClientX(clientX) {
var r = canvas.getBoundingClientRect();
var normX = ((clientX - r.left) / Math.max(1, r.width)) * 2 - 1;
pointerNormX = Math.max(-1, Math.min(1, normX));
manual = true;
root.classList.remove('is-demo');
if (manualReleaseTimer) clearTimeout(manualReleaseTimer);
}
function scheduleRelease() {
if (manualReleaseTimer) clearTimeout(manualReleaseTimer);
manualReleaseTimer = setTimeout(function () { manual = false; }, 2600);
}
canvas.addEventListener('pointermove', function (e) {
setManualFromClientX(e.clientX);
scheduleRelease();
});
canvas.addEventListener('pointerdown', function (e) {
setManualFromClientX(e.clientX);
scheduleRelease();
});
canvas.addEventListener('pointerleave', function () {
scheduleRelease();
});
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
manual = true;
root.classList.remove('is-demo');
var step = e.key === 'ArrowLeft' ? -0.18 : 0.18;
pointerNormX = Math.max(-1, Math.min(1, pointerNormX + step));
scheduleRelease();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 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();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasPointerWindLean.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
export default function RainEffectCanvasPointerWindLean() {
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 POINTER_WIND_LERP = 0.06;
const WIND_LEAN_MAX_DEG = 22;
const RAIN_STREAK_COUNT = 90;
const SWEEP_PERIOD_MS = 3400;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431072);
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 100 + rRand() * 80, thick: 0.6 + rRand() * 1.1 });
}
let manual = false;
let pointerNormX = 0;
let manualReleaseTimer: number | undefined;
function windLeanTarget(normX: number) {
return normX * WIND_LEAN_MAX_DEG;
}
function virtualPointerSweep(nowMs: number) {
return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
}
let leanDeg = 0;
function stepSim(dtS: number, nowMs: number) {
const normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
const target = windLeanTarget(normX);
leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));
const leanRad = (leanDeg * Math.PI) / 180;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(leanRad) * s.speed * 0.7 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; 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() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(38,20,10,0.06)";
ctx!.fillRect(0, 0, cw, ch);
const leanRad = (leanDeg * Math.PI) / 180;
const ddx = Math.sin(leanRad) * 6, ddy = Math.cos(leanRad) * 6;
ctx!.strokeStyle = "rgba(255,236,214,0.55)";
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx, y = s.y * sy;
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x - ddx * s.thick * (sx / 6), y - ddy * s.thick * (sy / 6));
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();
}
function setManualFromClientX(clientX: number) {
const r = canvas!.getBoundingClientRect();
const normX = ((clientX - r.left) / Math.max(1, r.width)) * 2 - 1;
pointerNormX = Math.max(-1, Math.min(1, normX));
manual = true;
root!.classList.remove("is-demo");
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
}
function scheduleRelease() {
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
manualReleaseTimer = window.setTimeout(function () { manual = false; }, 2600);
}
function onPointerMove(e: PointerEvent) { setManualFromClientX(e.clientX); scheduleRelease(); }
function onPointerDown(e: PointerEvent) { setManualFromClientX(e.clientX); scheduleRelease(); }
function onPointerLeave() { scheduleRelease(); }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
manual = true;
root!.classList.remove("is-demo");
const step = e.key === "ArrowLeft" ? -0.18 : 0.18;
pointerNormX = Math.max(-1, Math.min(1, pointerNormX + step));
scheduleRelease();
}
}
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointerleave", onPointerLeave);
root.addEventListener("keydown", onKeyDown);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 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();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
window.removeEventListener("resize", fit);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointerleave", onPointerLeave);
root.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div
className={`${styles.rn} is-demo`}
ref={rootRef}
tabIndex={0}
role="img"
aria-label="포인터를 좌우로 움직이거나 화살표 키를 누르면 비가 그쪽으로 눕는 장면"
>
<canvas className={styles.rn__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rn__caption}>포인터가 화면 중심에서 벗어난 만큼 바람 벡터의 방향을 다시 계산해, 포인터를 움직이는 동안 빗줄기 전체가 그 방향으로 같이 기울어 떨어진다</p>
<p className={styles.rn__hint}>포인터를 움직이거나 ← → 키</p>
</div>
</main>
);
}07 · ポインター位置に風が傾く背景 — 計測 累積面積 16.376% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function windLeanTarget(normX) {
return normX * WIND_LEAN_MAX_DEG;
}
function virtualPointerSweep(nowMs) {
return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
}
var leanDeg = 0;
function stepSim(dtS, nowMs) {
var normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
var target = windLeanTarget(normX);
leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));
項目07はフレームの16.4%を平均強度62.6で塗り替え、23/23フレームで動きが測定されました。
08傘の後ろでは隠れる雨筋
傘のシルエットの後ろを通る雨筋は描かずに隠し、傘面に当たった雨粒は当たった位置から計算した角度で跳ね返らせます。umbrellaOcclusion() は傘の曲線状の縁と雨筋の座標を毎フレーム照合し、umbrellaBounce() は命中を検知するたびに UMBRELLA_BOUNCE_COUNT 個の粒子を外側へ生み出します。

<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-paper);
.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: #eef1ef;
}
.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(23, 20, 26, .78);
color: $subject-cream;
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: #ffffff;
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: #eef1ef;
}
.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(23, 20, 26, 0.78);
color: #fff7e6;
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 () {
// 기법: umbrellaOcclusion() 이 타원 돔 방정식으로 각 빗줄기 점이 우산 안인지 매 프레임 판정한다.
// 안에 들면 그 줄기를 지우고 대신 UMBRELLA_BOUNCE_COUNT 개의 튐 방울을 돔 표면에서 바깥으로 쏘아
// 포물선으로 떨어뜨린다(umbrellaBounce) — 우산 밖 줄기는 그냥 통과해 바닥까지 떨어진다.
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 UMBRELLA_RADIUS_PX = 34;
var UMBRELLA_HEIGHT_PX = 16;
var UMBRELLA_BOUNCE_COUNT = 5;
var UMBRELLA_SWAY_PX = 6;
var UMBRELLA_SWAY_PERIOD_MS = 4200;
var UMBRELLA_BOUNCE_GRAVITY = 240;
var RAIN_STREAK_COUNT = 80;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431081);
var umbY = 58, umbBaseX = CALC_W / 2;
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 80, thick: 0.6 + rRand() * 1 });
}
var bounces = [];
function umbrellaOcclusion(x, y, umbX) {
var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
if (Math.abs(dx) > 1) return false;
var edgeY = umbY - UMBRELLA_HEIGHT_PX * Math.sqrt(Math.max(0, 1 - dx * dx));
return y >= edgeY && y <= umbY;
}
function umbrellaBounce(x, y, umbX) {
var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
var nx = dx;
for (var i = 0; i < UMBRELLA_BOUNCE_COUNT; i += 1) {
var spread = (rRand() - 0.5) * 40;
bounces.push({
x: x,
y: y,
vx: nx * (40 + rRand() * 30) + spread,
vy: -(30 + rRand() * 40),
born: 0,
life: 380 + rRand() * 200
});
}
}
function stepSim(dtS, nowMs) {
var umbX = umbBaseX + Math.sin((nowMs / UMBRELLA_SWAY_PERIOD_MS) * Math.PI * 2) * UMBRELLA_SWAY_PX;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var prevY = s.y;
s.y += s.speed * dtS;
if (!(prevY >= umbY) && umbrellaOcclusion(s.x, s.y, umbX)) {
umbrellaBounce(s.x, umbY - 1, umbX);
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
for (var b = bounces.length - 1; b >= 0; b -= 1) {
var p = bounces[b];
p.born += dtS * 1000;
p.vy += UMBRELLA_BOUNCE_GRAVITY * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
if (p.born > p.life) bounces.splice(b, 1);
}
}
var cw = 480, ch = 300, elapsedGlobal = 0;
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var umbX = umbBaseX + Math.sin((nowMs / UMBRELLA_SWAY_PERIOD_MS) * Math.PI * 2) * UMBRELLA_SWAY_PX;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#eef1ef';
ctx.fillRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(90,110,140,0.5)';
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx, y = s.y * sy;
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y - s.thick * 4 * sy);
ctx.stroke();
}
for (var b = 0; b < bounces.length; b += 1) {
var p = bounces[b];
var f = 1 - p.born / p.life;
ctx.fillStyle = 'rgba(90,110,140,' + Math.max(0, f * 0.8).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(p.x * sx, p.y * sy, Math.max(0.8, 1.6 * f * ((sx + sy) / 2)), 0, Math.PI * 2);
ctx.fill();
}
var cx = umbX * sx, cy = umbY * sy, rx = UMBRELLA_RADIUS_PX * sx, ry = UMBRELLA_HEIGHT_PX * sy;
ctx.fillStyle = '#2c2430';
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, Math.PI, Math.PI * 2);
ctx.lineTo(cx + rx, cy);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
for (var k = -2; k <= 2; k += 1) {
var t = k / 2.4;
var ex = cx + t * rx;
var ey = cy - Math.sqrt(Math.max(0, 1 - t * t)) * ry;
ctx.beginPath();
ctx.moveTo(cx, cy - ry * 0.2);
ctx.lineTo(ex, ey);
ctx.stroke();
}
ctx.strokeStyle = '#2c2430';
ctx.lineWidth = Math.max(1.2, 1.4 * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx, cy + ry * 3.2);
ctx.quadraticCurveTo(cx, cy + ry * 4.2, cx + rx * 0.22, cy + ry * 4);
ctx.stroke();
}
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(elapsedGlobal);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 45;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
elapsedGlobal = 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;
elapsedGlobal = elapsed;
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 "./RainEffectCanvasUmbrellaOcclusion.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
type Bounce = { x: number; y: number; vx: number; vy: number; born: number; life: number };
export default function RainEffectCanvasUmbrellaOcclusion() {
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 UMBRELLA_RADIUS_PX = 34;
const UMBRELLA_HEIGHT_PX = 16;
const UMBRELLA_BOUNCE_COUNT = 5;
const UMBRELLA_SWAY_PX = 6;
const UMBRELLA_SWAY_PERIOD_MS = 4200;
const UMBRELLA_BOUNCE_GRAVITY = 240;
const RAIN_STREAK_COUNT = 80;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431082);
const umbY = 58, umbBaseX = CALC_W / 2;
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 110 + rRand() * 80, thick: 0.6 + rRand() * 1 });
}
const bounces: Bounce[] = [];
function umbrellaOcclusion(x: number, y: number, umbX: number) {
const dx = (x - umbX) / UMBRELLA_RADIUS_PX;
if (Math.abs(dx) > 1) return false;
const edgeY = umbY - UMBRELLA_HEIGHT_PX * Math.sqrt(Math.max(0, 1 - dx * dx));
return y >= edgeY && y <= umbY;
}
function umbrellaBounce(x: number, y: number, umbX: number) {
const dx = (x - umbX) / UMBRELLA_RADIUS_PX;
const nx = dx;
for (let i = 0; i < UMBRELLA_BOUNCE_COUNT; i += 1) {
const spread = (rRand() - 0.5) * 40;
bounces.push({
x, y,
vx: nx * (40 + rRand() * 30) + spread,
vy: -(30 + rRand() * 40),
born: 0,
life: 380 + rRand() * 200,
});
}
}
function stepSim(dtS: number, nowMs: number) {
const umbX = umbBaseX + Math.sin((nowMs / UMBRELLA_SWAY_PERIOD_MS) * Math.PI * 2) * UMBRELLA_SWAY_PX;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const prevY = s.y;
s.y += s.speed * dtS;
if (!(prevY >= umbY) && umbrellaOcclusion(s.x, s.y, umbX)) {
umbrellaBounce(s.x, umbY - 1, umbX);
s.y = -4 - rRand() * 10;
s.x = rRand() * CALC_W;
}
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
}
for (let b = bounces.length - 1; b >= 0; b -= 1) {
const p = bounces[b];
p.born += dtS * 1000;
p.vy += UMBRELLA_BOUNCE_GRAVITY * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
if (p.born > p.life) bounces.splice(b, 1);
}
}
let cw = 480, ch = 300, elapsedGlobal = 0;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const umbX = umbBaseX + Math.sin((nowMs / UMBRELLA_SWAY_PERIOD_MS) * Math.PI * 2) * UMBRELLA_SWAY_PX;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "#eef1ef";
ctx!.fillRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(90,110,140,0.5)";
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx, y = s.y * sy;
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x, y - s.thick * 4 * sy);
ctx!.stroke();
}
for (let b = 0; b < bounces.length; b += 1) {
const p = bounces[b];
const f = 1 - p.born / p.life;
ctx!.fillStyle = "rgba(90,110,140," + Math.max(0, f * 0.8).toFixed(3) + ")";
ctx!.beginPath();
ctx!.arc(p.x * sx, p.y * sy, Math.max(0.8, 1.6 * f * ((sx + sy) / 2)), 0, Math.PI * 2);
ctx!.fill();
}
const cx = umbX * sx, cy = umbY * sy, rx = UMBRELLA_RADIUS_PX * sx, ry = UMBRELLA_HEIGHT_PX * sy;
ctx!.fillStyle = "#2c2430";
ctx!.beginPath();
ctx!.ellipse(cx, cy, rx, ry, 0, Math.PI, Math.PI * 2);
ctx!.lineTo(cx + rx, cy);
ctx!.closePath();
ctx!.fill();
ctx!.strokeStyle = "rgba(255,255,255,0.18)";
ctx!.lineWidth = 1;
for (let k = -2; k <= 2; k += 1) {
const t = k / 2.4;
const ex = cx + t * rx;
const ey = cy - Math.sqrt(Math.max(0, 1 - t * t)) * ry;
ctx!.beginPath();
ctx!.moveTo(cx, cy - ry * 0.2);
ctx!.lineTo(ex, ey);
ctx!.stroke();
}
ctx!.strokeStyle = "#2c2430";
ctx!.lineWidth = Math.max(1.2, 1.4 * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(cx, cy);
ctx!.lineTo(cx, cy + ry * 3.2);
ctx!.quadraticCurveTo(cx, cy + ry * 4.2, cx + rx * 0.22, cy + ry * 4);
ctx!.stroke();
elapsedGlobal = nowMs;
}
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(elapsedGlobal);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 45;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
elapsedGlobal = 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;
elapsedGlobal = elapsed;
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>
);
}08 · 傘の後ろでは隠れる雨筋 — 計測 累積面積 40.252% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function umbrellaOcclusion(x, y, umbX) {
var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
if (Math.abs(dx) > 1) return false;
var edgeY = umbY - UMBRELLA_HEIGHT_PX * Math.sqrt(Math.max(0, 1 - dx * dx));
return y >= edgeY && y <= umbY;
}
function umbrellaBounce(x, y, umbX) {
var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
var nx = dx;
for (var i = 0; i < UMBRELLA_BOUNCE_COUNT; i += 1) {
var spread = (rRand() - 0.5) * 40;
bounces.push({
x: x,
y: y,
vx: nx * (40 + rRand() * 30) + spread,
vy: -(30 + rRand() * 40),
born: 0,
life: 380 + rRand() * 200
});
}
}
function stepSim(dtS, nowMs) {
項目08はフレームの40.3%を平均強度52.7で塗り替え、23/23フレームで動きが測定されました。
09層・風・水滴・雷を一画面にまとめた嵐のヒーローシーン
01の層の視差、02の突風、03の窓の水滴、06の稲妻のフラッシュを一つの画面で同時に計算し、嵐の窓辺の完成シーンを作ります。この場面は windGustAngle() と dropletMassMerge() を HERO_WIND_GUST_MAX_DEG や HERO_GLASS_TOP といった専用の定数でローカルに再実装しており、自身のポインターリスナーは持ちません — このセットの中でポインターやキーボード入力を読むのは項目07だけです。

<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">窓の外の嵐の場面ひとつに、奥行き視差・突風・窓の水滴・雷の枝分かれの4つの技法を重ねて一画面にまとめる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.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: #12111a;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
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: #17141a;
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: #12111a;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
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 () {
// 히어로 장면 — 01 깊이 시차 + 02 바람 돌풍 + 03 유리창 물방울 + 06 번개 가지, 네 기법만 겹친다.
// 상수는 전부 HERO_ 접두로 이 장면 전용값이며 각 단일 항목의 상수와는 독립이다.
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 HERO_RAIN_LAYER_COUNT = 4;
var HERO_RAIN_NEAR_SPEED_PX_S = 150;
var HERO_RAIN_FAR_SPEED_PX_S = 40;
var HERO_STREAKS_PER_LAYER = 26;
var HERO_WIND_GUST_MAX_DEG = 10;
var HERO_WIND_CYCLE_MS = 5200;
var HERO_DROPLET_SPAWN_PER_S = 2.2;
var HERO_DROPLET_GRAVITY_PX_S = 54;
var HERO_DROPLET_DRAG_COEF = 3;
var HERO_DROPLET_MERGE_DIST_PX = 2.2;
var HERO_GLASS_TOP = CALC_H * 0.76;
var HERO_LIGHTNING_STRIKE_PERIOD_MS = 2600;
var HERO_LIGHTNING_FLASH_DECAY_MS = 240;
var HERO_LIGHTNING_BOLT_FADE_MS = 850;
var HERO_LIGHTNING_BRANCH_DEPTH = 3;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431091);
function streakDepthZ(layerIndex) {
return layerIndex / Math.max(1, HERO_RAIN_LAYER_COUNT - 1);
}
var streaks = [];
for (var li = 0; li < HERO_RAIN_LAYER_COUNT; li += 1) {
var z = streakDepthZ(li);
for (var i = 0; i < HERO_STREAKS_PER_LAYER; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
z: z,
speed: HERO_RAIN_FAR_SPEED_PX_S + (HERO_RAIN_NEAR_SPEED_PX_S - HERO_RAIN_FAR_SPEED_PX_S) * z,
thick: 0.5 + z * 1.4
});
}
}
function windGustAngle(nowMs) {
var a = Math.sin((nowMs / HERO_WIND_CYCLE_MS) * Math.PI * 2) * 0.7;
var b = Math.sin((nowMs / (HERO_WIND_CYCLE_MS * 0.37)) * Math.PI * 2) * 0.3;
return (a + b) * HERO_WIND_GUST_MAX_DEG;
}
var droplets = [];
var dropletSpawnAcc = 0;
function spawnDroplet() {
droplets.push({
x: HERO_GLASS_X0 + rRand() * (HERO_GLASS_X1 - HERO_GLASS_X0),
y: HERO_GLASS_TOP + rRand() * 3,
vy: 0,
r: 0.9 + rRand() * 1.1
});
}
var HERO_GLASS_X0 = CALC_W * 0.06, HERO_GLASS_X1 = CALC_W * 0.94;
function dropletMassMerge(a, b) {
var ar = a.r * a.r, br = b.r * b.r;
a.r = Math.sqrt(ar + br) * 0.86;
a.vy = (a.vy * ar + b.vy * br) / (ar + br);
}
function stepDroplets(dtS) {
dropletSpawnAcc += dtS * HERO_DROPLET_SPAWN_PER_S;
while (dropletSpawnAcc >= 1) { spawnDroplet(); dropletSpawnAcc -= 1; }
for (var i = 0; i < droplets.length; i += 1) {
var d = droplets[i];
var drag = Math.max(0.15, 1 - d.r / HERO_DROPLET_DRAG_COEF * 0.12);
d.vy += HERO_DROPLET_GRAVITY_PX_S * dtS * drag * 0.01 * d.r;
d.y += d.vy * dtS;
}
for (var a = droplets.length - 1; a >= 0; a -= 1) {
for (var b = a - 1; b >= 0; b -= 1) {
var da = droplets[a], db = droplets[b];
var dx = da.x - db.x, dy = da.y - db.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < (da.r + db.r) * 0.5 + HERO_DROPLET_MERGE_DIST_PX * 0.3) {
dropletMassMerge(db, da);
droplets.splice(a, 1);
break;
}
}
}
for (var k = droplets.length - 1; k >= 0; k -= 1) {
if (droplets[k].y > CALC_H + 2 || droplets[k].r > 5.5) droplets.splice(k, 1);
}
}
function branchLightning(segs, x, y, ang, len, depth) {
if (depth <= 0 || len < 3) return;
var steps = 3 + Math.floor(rRand() * 2);
var cx = x, cy = y, cang = ang;
for (var i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
var stepLen = len / steps;
var nx = cx + Math.sin(cang) * stepLen;
var ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth: depth });
if (depth > 1 && rRand() < 0.3) {
var side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
var segs = [];
var startX = 20 + rRand() * 120;
branchLightning(segs, startX, 0, 0, HERO_GLASS_TOP * 0.88, HERO_LIGHTNING_BRANCH_DEPTH);
return segs;
}
var bolt = [];
var strikeAtMs = -99999;
var lastStrikeIndex = -1;
function updateStrike(nowMs) {
var idx = Math.floor(nowMs / HERO_LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS, nowMs) {
var gust = windGustAngle(nowMs);
var gustRad = (gust * Math.PI) / 180;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(gustRad) * s.speed * 0.5 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - 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;
}
stepDroplets(dtS);
updateStrike(nowMs);
}
var cw = 480, ch = 300;
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var sinceStrike = nowMs - strikeAtMs;
var flash = Math.exp(-Math.max(0, sinceStrike) / HERO_LIGHTNING_FLASH_DECAY_MS);
var boltAlpha = Math.exp(-Math.max(0, sinceStrike) / HERO_LIGHTNING_BOLT_FADE_MS);
var gust = windGustAngle(nowMs);
ctx.fillStyle = '#12111a';
ctx.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx.fillStyle = 'rgba(255,250,230,' + (flash * 0.5).toFixed(3) + ')';
ctx.fillRect(0, 0, cw, ch);
}
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var alpha = 0.18 + s.z * 0.55;
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx.strokeStyle = 'rgba(214,228,255,' + alpha + ')';
ctx.lineWidth = Math.max(0.5, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, y1);
ctx.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx.save();
ctx.strokeStyle = 'rgba(255,252,240,' + boltAlpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(255,246,200,' + (boltAlpha * 0.75).toFixed(3) + ')';
ctx.shadowBlur = 7 * ((sx + sy) / 2);
for (var b = 0; b < bolt.length; b += 1) {
var seg = bolt[b];
ctx.lineWidth = Math.max(0.7, seg.depth * ((sx + sy) / 2) * 0.5);
ctx.beginPath();
ctx.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx.stroke();
}
ctx.restore();
}
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(0, HERO_GLASS_TOP * sy, cw, ch - HERO_GLASS_TOP * sy);
for (var d = 0; d < droplets.length; d += 1) {
var dr = droplets[d];
var dx = dr.x * sx, dy = dr.y * sy, rr = dr.r * ((sx + sy) / 2);
var grad = ctx.createRadialGradient(dx - rr * 0.3, dy - rr * 0.3, 0, dx, dy, rr);
grad.addColorStop(0, 'rgba(255,255,255,0.85)');
grad.addColorStop(0.5, 'rgba(214,228,255,0.35)');
grad.addColorStop(1, 'rgba(214,228,255,0.05)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(dx, dy, rr, 0, Math.PI * 2);
ctx.fill();
}
void gust;
}
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;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 45;
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 "./RainEffectCanvasHeroScene.module.scss";
type Streak = { x: number; y: number; z: number; speed: number; thick: number };
type Droplet = { x: number; y: number; vy: number; r: number };
type Seg = { x0: number; y0: number; x1: number; y1: number; depth: number };
export default function RainEffectCanvasHeroScene() {
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 HERO_RAIN_LAYER_COUNT = 4;
const HERO_RAIN_NEAR_SPEED_PX_S = 150;
const HERO_RAIN_FAR_SPEED_PX_S = 40;
const HERO_STREAKS_PER_LAYER = 26;
const HERO_WIND_GUST_MAX_DEG = 10;
const HERO_WIND_CYCLE_MS = 5200;
const HERO_DROPLET_SPAWN_PER_S = 2.2;
const HERO_DROPLET_GRAVITY_PX_S = 54;
const HERO_DROPLET_DRAG_COEF = 3;
const HERO_DROPLET_MERGE_DIST_PX = 2.2;
const HERO_GLASS_TOP = CALC_H * 0.76;
const HERO_GLASS_X0 = CALC_W * 0.06, HERO_GLASS_X1 = CALC_W * 0.94;
const HERO_LIGHTNING_STRIKE_PERIOD_MS = 2600;
const HERO_LIGHTNING_FLASH_DECAY_MS = 240;
const HERO_LIGHTNING_BOLT_FADE_MS = 850;
const HERO_LIGHTNING_BRANCH_DEPTH = 3;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431092);
function streakDepthZ(layerIndex: number) {
return layerIndex / Math.max(1, HERO_RAIN_LAYER_COUNT - 1);
}
const streaks: Streak[] = [];
for (let li = 0; li < HERO_RAIN_LAYER_COUNT; li += 1) {
const z = streakDepthZ(li);
for (let i = 0; i < HERO_STREAKS_PER_LAYER; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
z,
speed: HERO_RAIN_FAR_SPEED_PX_S + (HERO_RAIN_NEAR_SPEED_PX_S - HERO_RAIN_FAR_SPEED_PX_S) * z,
thick: 0.5 + z * 1.4,
});
}
}
function windGustAngle(nowMs: number) {
const a = Math.sin((nowMs / HERO_WIND_CYCLE_MS) * Math.PI * 2) * 0.7;
const b = Math.sin((nowMs / (HERO_WIND_CYCLE_MS * 0.37)) * Math.PI * 2) * 0.3;
return (a + b) * HERO_WIND_GUST_MAX_DEG;
}
const droplets: Droplet[] = [];
let dropletSpawnAcc = 0;
function spawnDroplet() {
droplets.push({
x: HERO_GLASS_X0 + rRand() * (HERO_GLASS_X1 - HERO_GLASS_X0),
y: HERO_GLASS_TOP + rRand() * 3,
vy: 0,
r: 0.9 + rRand() * 1.1,
});
}
function dropletMassMerge(a: Droplet, b: Droplet) {
const ar = a.r * a.r, br = b.r * b.r;
a.r = Math.sqrt(ar + br) * 0.86;
a.vy = (a.vy * ar + b.vy * br) / (ar + br);
}
function stepDroplets(dtS: number) {
dropletSpawnAcc += dtS * HERO_DROPLET_SPAWN_PER_S;
while (dropletSpawnAcc >= 1) { spawnDroplet(); dropletSpawnAcc -= 1; }
for (let i = 0; i < droplets.length; i += 1) {
const d = droplets[i];
const drag = Math.max(0.15, 1 - (d.r / HERO_DROPLET_DRAG_COEF) * 0.12);
d.vy += HERO_DROPLET_GRAVITY_PX_S * dtS * drag * 0.01 * d.r;
d.y += d.vy * dtS;
}
for (let a = droplets.length - 1; a >= 0; a -= 1) {
for (let b = a - 1; b >= 0; b -= 1) {
const da = droplets[a], db = droplets[b];
const dx = da.x - db.x, dy = da.y - db.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < (da.r + db.r) * 0.5 + HERO_DROPLET_MERGE_DIST_PX * 0.3) {
dropletMassMerge(db, da);
droplets.splice(a, 1);
break;
}
}
}
for (let k = droplets.length - 1; k >= 0; k -= 1) {
if (droplets[k].y > CALC_H + 2 || droplets[k].r > 5.5) droplets.splice(k, 1);
}
}
function branchLightning(segs: Seg[], x: number, y: number, ang: number, len: number, depth: number) {
if (depth <= 0 || len < 3) return;
const steps = 3 + Math.floor(rRand() * 2);
let cx = x, cy = y, cang = ang;
for (let i = 0; i < steps; i += 1) {
cang += (rRand() - 0.5) * 0.9;
const stepLen = len / steps;
const nx = cx + Math.sin(cang) * stepLen;
const ny = cy + Math.cos(cang) * stepLen;
segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth });
if (depth > 1 && rRand() < 0.3) {
const side = rRand() < 0.5 ? -1 : 1;
branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
}
cx = nx; cy = ny;
}
}
function makeBolt() {
const segs: Seg[] = [];
const startX = 20 + rRand() * 120;
branchLightning(segs, startX, 0, 0, HERO_GLASS_TOP * 0.88, HERO_LIGHTNING_BRANCH_DEPTH);
return segs;
}
let bolt: Seg[] = [];
let strikeAtMs = -99999;
let lastStrikeIndex = -1;
function updateStrike(nowMs: number) {
const idx = Math.floor(nowMs / HERO_LIGHTNING_STRIKE_PERIOD_MS);
if (idx !== lastStrikeIndex) {
lastStrikeIndex = idx;
bolt = makeBolt();
strikeAtMs = nowMs;
}
}
function stepSim(dtS: number, nowMs: number) {
const gust = windGustAngle(nowMs);
const gustRad = (gust * Math.PI) / 180;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(gustRad) * s.speed * 0.5 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - 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;
}
stepDroplets(dtS);
updateStrike(nowMs);
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const sinceStrike = nowMs - strikeAtMs;
const flash = Math.exp(-Math.max(0, sinceStrike) / HERO_LIGHTNING_FLASH_DECAY_MS);
const boltAlpha = Math.exp(-Math.max(0, sinceStrike) / HERO_LIGHTNING_BOLT_FADE_MS);
ctx!.fillStyle = "#12111a";
ctx!.fillRect(0, 0, cw, ch);
if (flash > 0.02) {
ctx!.fillStyle = "rgba(255,250,230," + (flash * 0.5).toFixed(3) + ")";
ctx!.fillRect(0, 0, cw, ch);
}
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const alpha = 0.18 + s.z * 0.55;
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx!.strokeStyle = "rgba(214,228,255," + alpha + ")";
ctx!.lineWidth = Math.max(0.5, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, y1);
ctx!.stroke();
}
if (boltAlpha > 0.03 && bolt.length) {
ctx!.save();
ctx!.strokeStyle = "rgba(255,252,240," + boltAlpha.toFixed(3) + ")";
ctx!.shadowColor = "rgba(255,246,200," + (boltAlpha * 0.75).toFixed(3) + ")";
ctx!.shadowBlur = 7 * ((sx + sy) / 2);
for (let b = 0; b < bolt.length; b += 1) {
const seg = bolt[b];
ctx!.lineWidth = Math.max(0.7, seg.depth * ((sx + sy) / 2) * 0.5);
ctx!.beginPath();
ctx!.moveTo(seg.x0 * sx, seg.y0 * sy);
ctx!.lineTo(seg.x1 * sx, seg.y1 * sy);
ctx!.stroke();
}
ctx!.restore();
}
ctx!.fillStyle = "rgba(255,255,255,0.05)";
ctx!.fillRect(0, HERO_GLASS_TOP * sy, cw, ch - HERO_GLASS_TOP * sy);
for (let d = 0; d < droplets.length; d += 1) {
const dr = droplets[d];
const dx = dr.x * sx, dy = dr.y * sy, rr = dr.r * ((sx + sy) / 2);
const grad = ctx!.createRadialGradient(dx - rr * 0.3, dy - rr * 0.3, 0, dx, dy, rr);
grad.addColorStop(0, "rgba(255,255,255,0.85)");
grad.addColorStop(0.5, "rgba(214,228,255,0.35)");
grad.addColorStop(1, "rgba(214,228,255,0.05)");
ctx!.fillStyle = grad;
ctx!.beginPath();
ctx!.arc(dx, dy, rr, 0, Math.PI * 2);
ctx!.fill();
}
}
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;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 45;
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>
);
}09 · 層・風・水滴・雷を一画面にまとめた嵐のヒーローシーン — 計測 累積面積 73.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function windGustAngle(nowMs) {
var a = Math.sin((nowMs / HERO_WIND_CYCLE_MS) * Math.PI * 2) * 0.7;
var b = Math.sin((nowMs / (HERO_WIND_CYCLE_MS * 0.37)) * Math.PI * 2) * 0.3;
return (a + b) * HERO_WIND_GUST_MAX_DEG;
}
var droplets = [];
var dropletSpawnAcc = 0;
function spawnDroplet() {
droplets.push({
x: HERO_GLASS_X0 + rRand() * (HERO_GLASS_X1 - HERO_GLASS_X0),
y: HERO_GLASS_TOP + rRand() * 3,
vy: 0,
r: 0.9 + rRand() * 1.1
});
}
var HERO_GLASS_X0 = CALC_W * 0.06, HERO_GLASS_X1 = CALC_W * 0.94;
function dropletMassMerge(a, b) {
var ar = a.r * a.r, br = b.r * b.r;
a.r = Math.sqrt(ar + br) * 0.86;
a.vy = (a.vy * ar + b.vy * br) / (ar + br);
}
項目09はフレームの73.9%を平均強度83.5で塗り替え、23/23フレームで動きが測定されました。
層の技法別に見るコード
9行をまとめて見ると、合成シーン(09)と枝分かれの多い2つの層(06の雷、05の嵐周期)は再描画の割合が上位近くに来ます。雷は一撃で画面の縦に長いくさび形を塗り、嵐周期は雨筋の集合をまとめて毎フレーム動かすため、面積そのものが広くなるからです。項目03の水滴が最下位なのは、ガラスの上の小さな円がいくつかあるだけで、合体の計算自体は毎フレーム走っていても塗る面積は雨筋の集団よりずっと狭いからです。平均強度は再描画の面積とはまったく連動しません — 項目05は再描画割合が中程度にもかかわらず平均強度(122.6)が最も高く、これは測定フレームの間に雨筋が速く動いて強く着地するためです。逆に項目06の平均強度(51.7)は再描画割合が大きくても低く抑えられます。ボルトの形自体はほぼ静止していて、フラッシュのオーバーレイ部分だけが明るさを変えているからです。このようなcss アニメーション 雨の計算差は、項目ごとの構造を見比べるとはっきり分かります。
| 項目 | 層 | 再描画面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 深さパララックス雨筋 | 63.3% | 111.8 | 23/23 |
| 02 | 風の突風ドリフト | 57.8% | 104.1 | 23/23 |
| 03 | 窓の水滴トリクル | 9.0% | 80.9 | 23/23 |
| 04 | 地面の飛沫物理 | 48.2% | 78.5 | 23/23 |
| 05 | 嵐の強さ周期 | 60.4% | 122.6 | 23/23 |
| 06 | 雷の枝分かれフラッシュ | 73.4% | 51.7 | 23/23 |
| 07 | ポインター風の傾き | 16.4% | 62.6 | 23/23 |
| 08 | 傘による遮蔽 | 40.3% | 52.7 | 23/23 |
| 09 | ヒーロー合成シーン | 73.9% | 83.5 | 23/23 |
値が嵐の強さを変える場所 — 風・間隔・雷・跳ね
三つの定数が、それぞれの技法をどこまで読ませると別の嵐に見え始めるかを決めています。項目02の WIND_GUST_MAX_DEG は突風で雨をどれだけ強く傾けられるかの上限を決め、項目04の SPLASH_PARTICLE_COUNT は雨筋が着地したときに地面がどれだけ混み合うかを決め、項目06の LIGHTNING_FLASH_DECAY_MS は雷が落ちたあと画面がどれだけ明るいままでいるかを制御します。三つとも図形やタイミングのループ自体には触れず、同じ stepSim() が毎フレーム読んでいる数値をただ拡大縮小しているだけで、これは物理シミュレーション 作り方 9選 — バネから慣性スクラブまでで扱ったバネや慣性の定数と同じ考え方です。下の変形の表で、それぞれが他の8層から独立して動く様子を試せます。
どこで壊れるか — 動作を減らす設定・狭い画面・層が多いとき
9つのキャンバスはすべて、ループを始める前に matchMedia で prefers-reduced-motion を確認します。動作を減らす設定を有効にした訪問者には、動く嵐ではなく静止した1フレームが表示されます — これは層ごとの任意設定ではなく、どの項目のセットアップコードにも同じ形で組み込まれています。狭いビューポートでは fit() によってキャンバスは縮小しますが、重い層(01・02・05・09)を1ページに何枚も重ねるとその分だけ積み上がります — 層ごとに独自の requestAnimationFrame ループが走るため、重い層4枚を同じスクロール位置に置くと1枚分のおよそ4倍のフレームごとの計算になります。項目09はすでに単体で2技法分の雨筋と水滴を1ティックで計算しているため、これを01や05と同じ画面に組み合わせるのが、遅い端末で目に見えてカクつく最も起きやすい組み合わせです — 対策は画面ごとに重い層を1枚に絞ることです。この css 背景 雨の9項目それぞれの背後にある index.html は、比べられるようにzipにまとめてパスワード qms3kcmq で提供しています。
変形3つとアクセシビリティ
| 変形 | 値 | 感じ方 |
|---|---|---|
| 最大風傾斜角を調整する | 02 WIND_GUST_MAX_DEG 8 → 20 |
値を小さくすると風が強く吹いても雨筋はほぼ垂直に落ち、値を大きくすると風が少し吹いただけで雨筋が大きく傾いて落ちる。 |
| 飛沫粒子の数を調整する | 04 SPLASH_PARTICLE_COUNT 6 → 14 |
値を小さくすると水滴が触れても飛沫はほとんど見えず、値を大きくすると地面全体が跳ねる粒子で埋まり豪雨のように見える。 |
| 雷フラッシュの減衰時間を調整する | 06 LIGHTNING_FLASH_DECAY_MS 220 → 500 |
値を小さくすると雷は一瞬だけ光ってすぐ暗くなり、値を大きくすると光った明るさがしばらく画面に残り残光のように見える。 |
ここでのアクセシビリティは後付けの別レイヤーではありません — 9つすべてのCanvas APIループはprefers-reduced-motionの確認から始まり、その確認が通ったときだけrequestAnimationFrameを呼びます。項目07はこのセットの中で直接操作を前提に作られた唯一の項目です — ポインターを動かすか左右の矢印キーを押すと、風がその方向へリアルタイムに傾きます。キャンバスにはフォーカス可能でラベル付きの要素があり、マウスを使わないキーボードだけの訪問者でも同じ傾きを起こせます。残り8項目は操作するコントロールのない提示型の場面なので、そのアクセシビリティ責任は動作を減らす設定を正しく尊重することだけで完結します。
css アニメーション 雨 は窓辺の場面で何を計算しているのか?
上の9項目はどれも、同じ問いに別の答えを出しています — 少し前に読んだ風・重力・強さの値を踏まえて、このピクセルは今このフレームで何をすべきか、という問いです。9つのループのどれも録画したパスを再生してはいません — それぞれ雨筋の位置・水滴の大きさ・飛沫の方向・雷の形を、毎ティック計算し直すわずかな数値から導き出しており、これはcss 波紋 アニメーション 9パターン — キャンバスで計算するピクセル波と同じ「記録ではなく計算」の考え方です。上の変形の表が、絵を描き直すのではなく定数をひとつ書き換えるだけで層全体の雰囲気を変えられるのも、そもそも形が固定されていないからです。
この雨のcss場面を使う前によくある質問
この cssアニメーション 雨 は実際の気象データを読んでいますか?
いいえ。風の角度・嵐の強さ・雷のタイミングなど、すべての値はシード付きの疑似乱数生成と RAIN_WIND_CYCLE_MS や STORM_CYCLE_MS のような固定周期のサイン波から作られており、外部のデータは一切使っていません。
項目09は01・02・03・06を自分で組み合わせたものと何が違いますか?
項目09は実行時に他の4項目の関数を呼び出しているわけではありません — windGustAngle() と dropletMassMerge() を HERO_WIND_GUST_MAX_DEG などヒーロー専用の定数で独自にローカル実装しているため、技法の考え方は同じでも数値は別々に調整されています。
この9項目のうち1つだけを既存のページに入れられますか?
はい — 各項目のキャンバス・seedRand() のセットアップ・stepSim() と draw() の組は、それぞれの index.html の中で完結しています。1項目をコピーしても、残り8項目のコードを一緒に持ち込む必要はありません。









