ビジュアル

Canvas2Dで作る、文字に雨が当たって流れ落ちる梅雨エフェクト

投稿日2026/06/15

更新日2026/6/14

梅雨の激しい雨を、ライブラリなしのCanvas 2Dだけで丸ごと再現したデモです。

1300粒の雨が斜めに降り注ぎ、画面中央に表示された「梅雨の大雨」の文字に当たると飛沫を上げて跳ね、文字の表面を伝って流れ落ちていきます。

衝突判定にはオフスクリーンCanvasに描画したテキストのピクセルデータを使い、雨粒の状態を「落下→衝突→表面流動→離脱→再落下」と遷移させることでリアルな水の動きを表現しています。

さらに、ランダムに発生する雷のフラッシュと稲妻、流れる雲のレイヤー、ビネット効果も加わり、見ているだけで雨の日の空気感が伝わってくる没入感のある演出に仕上がっています。

Code コード

<div class="kumonosu-font-loader">梅雨の大雨</div>
<canvas id="kumonosu-rainCanvas"></canvas>
<div class="kumonosu-vignette"></div>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@900&display=swap');
body, html {
	margin: 0;
	padding: 0;
	width: 100%;
	height: 100%;
	overflow: hidden;
	background-color: #020202;
}
#kumonosu-rainCanvas {
	display: block;
	position: absolute;
	top: 0;
	left: 0;
}
.kumonosu-font-loader {
	font-family: 'Noto Sans JP';
	font-weight: 900;
	position: absolute;
	opacity: 0;
	pointer-events: none;
}
.kumonosu-vignette {
	pointer-events: none;
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.8) 100%);
}
const canvas = document.getElementById('kumonosu-rainCanvas');
const ctx = canvas.getContext('2d', {
	willReadFrequently: true
});
const hitCanvas = document.createElement('canvas');
const hitCtx = hitCanvas.getContext('2d', {
	willReadFrequently: true
});
const cloudCanvas = document.createElement('canvas');
const cloudCtx = cloudCanvas.getContext('2d');
let width, height;
let drops = [];
let splashes = [];
let cloudLayers = [];
let lightningBolts = [];
const dropCount = 1300;
const textString = "梅雨の大雨";
const fontFamily = "'Noto Sans JP'";
const RAIN_ANGLE = 5;
const rainAngleRad = (RAIN_ANGLE * Math.PI) / 180;
const rainVelocityX = Math.sin(rainAngleRad);
const rainVelocityY = Math.cos(rainAngleRad);
let isLightning = false;
let lightningTimer = 0;
let lightningIntensity = 0;

function resize() {
	width = canvas.width = window.innerWidth;
	height = canvas.height = window.innerHeight;
	hitCanvas.width = width;
	hitCanvas.height = height;
	cloudCanvas.width = width;
	cloudCanvas.height = Math.floor(height * 0.6);
	drawCollisionMap();
	initClouds();
}

function getResponsiveFontSize() {
	return Math.min(Math.max(width * 0.16, 80), 300);
}

function drawCollisionMap() {
	hitCtx.fillStyle = 'black';
	hitCtx.fillRect(0, 0, width, height);
	let fontSize = getResponsiveFontSize();
	hitCtx.font = `900 ${fontSize}px ${fontFamily}`;
	hitCtx.textAlign = 'center';
	hitCtx.textBaseline = 'middle';
	hitCtx.fillStyle = 'white';
	hitCtx.fillText(textString, width / 2, height / 2);
}

function draw3DText() {
	let fontSize = getResponsiveFontSize();
	ctx.font = `900 ${fontSize}px ${fontFamily}`;
	ctx.textAlign = 'center';
	ctx.textBaseline = 'middle';
	const cx = width / 2;
	const cy = height / 2;
	ctx.shadowColor = 'black';
	ctx.shadowBlur = 30;
	ctx.shadowOffsetY = 25;
	ctx.fillStyle = 'black';
	ctx.fillText(textString, cx, cy);
	ctx.shadowBlur = 0;
	ctx.shadowOffsetY = 0;
	ctx.fillStyle = '#111';
	ctx.fillText(textString, cx + 4, cy + 4);
	let gradient = ctx.createLinearGradient(0, cy - fontSize / 2, 0, cy + fontSize / 2);
	gradient.addColorStop(0, '#050505');
	gradient.addColorStop(0.3, '#1a1a1a');
	gradient.addColorStop(0.5, '#080808');
	gradient.addColorStop(1, '#000');
	ctx.fillStyle = gradient;
	ctx.fillText(textString, cx, cy);
	ctx.save();
	ctx.fillStyle = isLightning ? `rgba(255,255,255,${0.1 + lightningIntensity * 0.8})` : 'rgba(255,255,255,0.08)';
	ctx.fillText(textString, cx, cy - 1);
	ctx.restore();
}

function createCloudTexture(size, brightness) {
	const tempCanvas = document.createElement('canvas');
	tempCanvas.width = size;
	tempCanvas.height = size;
	const tempCtx = tempCanvas.getContext('2d');
	const centerX = size / 2;
	const centerY = size / 2;
	for (let i = 0; i < 3; i++) {
		const offsetX = (Math.random() - 0.5) * size * 0.3;
		const offsetY = (Math.random() - 0.5) * size * 0.3;
		const radius = size * (0.4 + Math.random() * 0.2);
		const gradient = tempCtx.createRadialGradient(centerX + offsetX, centerY + offsetY, 0, centerX + offsetX, centerY + offsetY, radius);
		const alpha = 0.12 + Math.random() * 0.1;
		gradient.addColorStop(0, `rgba(${brightness + 20}, ${brightness + 20}, ${brightness + 30}, ${alpha})`);
		gradient.addColorStop(0.5, `rgba(${brightness}, ${brightness}, ${brightness + 10}, ${alpha * 0.5})`);
		gradient.addColorStop(1, `rgba(${brightness - 10}, ${brightness - 10}, ${brightness}, 0)`);
		tempCtx.fillStyle = gradient;
		tempCtx.fillRect(0, 0, size, size);
	}
	return tempCanvas;
}
class CloudLayer {
	constructor(depth, yPos) {
		this.depth = depth;
		this.speed = (0.12 + depth * 0.2);
		this.offset = 0;
		this.y = yPos;
		this.brightness = 20 + depth * 12;
		this.cloudTextures = [];
		const numClouds = 6;
		for (let i = 0; i < numClouds; i++) {
			const size = 300 + Math.random() * 500;
			this.cloudTextures.push({
				texture: createCloudTexture(size, this.brightness),
				size: size,
				x: (i / numClouds) * width * 1.5,
				offsetY: (Math.random() - 0.5) * 100
			});
		}
	}
	update() {
		this.offset += this.speed;
		if (this.offset > width * 1.5) this.offset = 0;
	}
	draw() {
		ctx.save();
		let alpha = 0.6 + this.depth * 0.4;
		if (isLightning && lightningIntensity > 0) alpha = Math.min(1, alpha + lightningIntensity * 0.4);
		ctx.globalAlpha = alpha;
		if (isLightning && lightningIntensity > 0) ctx.globalCompositeOperation = 'lighter';
		for (let loop = 0; loop < 2; loop++) {
			this.cloudTextures.forEach(cloud => {
				const x = cloud.x - this.offset + (loop * width * 1.5);
				const y = this.y + cloud.offsetY;
				if (x + cloud.size > -cloud.size && x < width + cloud.size) ctx.drawImage(cloud.texture, x - cloud.size / 2, y - cloud.size / 2);
			});
		}
		ctx.restore();
	}
}

function initClouds() {
	cloudLayers = [];
	cloudLayers.push(new CloudLayer(0.3, height * 0.1));
	cloudLayers.push(new CloudLayer(0.6, height * 0.2));
	cloudLayers.push(new CloudLayer(1.0, height * 0.35));
}
class LightningBolt {
	constructor() {
		this.segments = [];
		this.life = 1.0;
		this.brightness = Math.random() * 0.5 + 0.5;
		this.generateBolt();
	}
	generateBolt() {
		const startX = Math.random() * width;
		const startY = 0;
		const endX = startX + (Math.random() - 0.5) * 500;
		const endY = height * (Math.random() * 0.5 + 0.2);
		this.segments = this.createBranch(startX, startY, endX, endY, 1);
		if (Math.random() > 0.6) {
			const idx = Math.floor(this.segments.length * 0.5);
			const branchPoint = this.segments[idx];
			const branchEnd = {
				x: branchPoint.x + (Math.random() - 0.5) * 300,
				y: branchPoint.y + Math.random() * 200 + 50
			};
			this.segments.push(...this.createBranch(branchPoint.x, branchPoint.y, branchEnd.x, branchEnd.y, 0.5));
		}
	}
	createBranch(x1, y1, x2, y2, widthMult) {
		const segments = [];
		const steps = 12;
		let currentX = x1,
			currentY = y1;
		for (let i = 0; i <= steps; i++) {
			const t = i / steps;
			const targetX = x1 + (x2 - x1) * t + (Math.random() - 0.5) * 80;
			const targetY = y1 + (y2 - y1) * t;
			segments.push({
				x1: currentX,
				y1: currentY,
				x2: targetX,
				y2: targetY,
				width: (Math.random() * 2.5 + 1) * widthMult
			});
			currentX = targetX;
			currentY = targetY;
		}
		return segments;
	}
	update() {
		this.life -= 0.1;
	}
	draw() {
		ctx.save();
		ctx.globalAlpha = this.life * this.brightness;
		ctx.lineCap = 'round';
		ctx.shadowBlur = 25;
		ctx.shadowColor = 'rgba(210, 230, 255, 0.9)';
		this.segments.forEach(seg => {
			ctx.strokeStyle = 'rgba(180, 210, 255, 0.2)';
			ctx.lineWidth = seg.width * 12;
			ctx.beginPath();
			ctx.moveTo(seg.x1, seg.y1);
			ctx.lineTo(seg.x2, seg.y2);
			ctx.stroke();
			ctx.strokeStyle = 'rgba(255, 255, 255, 1)';
			ctx.lineWidth = seg.width * 2;
			ctx.beginPath();
			ctx.moveTo(seg.x1, seg.y1);
			ctx.lineTo(seg.x2, seg.y2);
			ctx.stroke();
		});
		ctx.restore();
	}
}
class Splash {
	constructor(x, y, impactAngle) {
		this.x = x;
		this.y = y;
		const baseAngle = -Math.PI / 2 - rainAngleRad;
		const spreadAngle = (Math.random() - 0.5) * Math.PI * 0.7;
		const angle = baseAngle + spreadAngle;
		const speed = Math.random() * 5 + 2;
		this.vx = Math.cos(angle) * speed;
		this.vy = Math.sin(angle) * speed;
		this.life = 1.0;
		this.size = Math.random() * 2 + 0.5;
	}
	update() {
		this.x += this.vx;
		this.y += this.vy;
		this.vy += 0.4;
		this.vx *= 0.97;
		this.life -= 0.05;
	}
	draw() {
		ctx.fillStyle = `rgba(240, 245, 255, ${this.life})`;
		ctx.fillRect(this.x, this.y, this.size, this.size);
	}
}
class Drop {
	constructor() {
		this.reset();
		this.y = Math.random() * height;
		this.x = Math.random() * width + height * rainVelocityX;
	}
	reset() {
		this.x = Math.random() * width - height * 0.3 * rainVelocityX;
		this.y = -20;
		const baseSpeed = Math.random() * 14 + 11;
		this.vy = baseSpeed * rainVelocityY;
		this.baseVx = baseSpeed * rainVelocityX;
		this.vx = this.baseVx;
		this.state = 'falling';
		this.size = Math.random() * 1.4 + 1.2;
		this.oscillationSpeed = Math.random() * 0.08 + 0.04;
		this.phase = Math.random() * Math.PI * 2;
		this.z = Math.random();
		this.canCollide = (this.z > 0.46 && this.z < 0.54);
	}
	isSolid(tx, ty) {
		if (ty < 0 || ty >= height || tx < 0 || tx >= width) return false;
		if (ty > height / 2 - 200 && ty < height / 2 + 200) {
			const pixel = hitCtx.getImageData(Math.floor(tx), Math.floor(ty), 1, 1).data;
			return pixel[0] > 120;
		}
		return false;
	}
	update() {
		let nextY = this.y + this.vy;
		let nextX = this.x + this.vx;
		if (this.canCollide) {
			let isCurrentlyInSolid = this.isSolid(nextX, nextY);
			let wasInSolid = this.isSolid(this.x, this.y);
			if (this.state === 'falling' && isCurrentlyInSolid && !wasInSolid) {
				this.state = 'flowing';
				this.y = nextY;
				this.vy = 0;
				this.vx = 0;
				for (let k = 0; k < 6; k++) splashes.push(new Splash(this.x, this.y, rainAngleRad));
			} else if (this.state === 'flowing') {
				if (isCurrentlyInSolid) {
					if (Math.random() < 0.12) {
						this.vy = 0.08;
					} else {
						if (this.vy < 4) this.vy += 0.25;
					}
					let noise = (Math.random() - 0.5) * 1.5;
					this.vx = (Math.sin(this.y * this.oscillationSpeed + this.phase) * 0.4) + noise;
					this.y += this.vy;
					this.x += this.vx;
				} else {
					this.state = 'detaching';
					this.vx = this.baseVx * 0.4;
					this.vy = 1.5;
				}
			} else if (this.state === 'detaching') {
				this.y += this.vy;
				this.x += this.vx;
				this.vy += 0.45;
				this.vx += this.baseVx * 0.04;
				if (this.vy > 10) {
					this.state = 'falling';
					this.vx = this.baseVx;
				}
			} else {
				this.y = nextY;
				this.x = nextX;
				if (this.vy < 24 * rainVelocityY) this.vy += 0.5 * rainVelocityY;
			}
		} else {
			this.y = nextY;
			this.x = nextX;
			if (this.vy < 24 * rainVelocityY) this.vy += 0.5 * rainVelocityY;
		}
		if (this.y > height || this.x > width + 150 || this.x < -150) this.reset();
	}
	draw() {
		let opacity = this.z * 0.45;
		if (isLightning) opacity = Math.min(0.9, opacity + lightningIntensity * 0.5);
		if (this.state === 'falling') {
			ctx.fillStyle = `rgba(180, 205, 230, ${opacity})`;
			const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
			const len = speed * (this.z + 1.1);
			const angle = Math.atan2(this.vy, this.vx);
			ctx.save();
			ctx.translate(this.x, this.y);
			ctx.rotate(angle);
			ctx.fillRect(0, -0.6, len, 1.2);
			ctx.restore();
		} else {
			let stretch = this.vy * 16;
			if (stretch < this.size) stretch = this.size;
			let grad = ctx.createLinearGradient(this.x, this.y - stretch, this.x, this.y);
			grad.addColorStop(0, `rgba(255, 255, 255, 0)`);
			grad.addColorStop(1, `rgba(255, 255, 255, ${0.2 + (isLightning ? lightningIntensity * 0.6 : 0)})`);
			ctx.fillStyle = grad;
			ctx.beginPath();
			ctx.moveTo(this.x - this.size / 2, this.y);
			ctx.lineTo(this.x, this.y - stretch);
			ctx.lineTo(this.x + this.size / 2, this.y);
			ctx.fill();
			ctx.beginPath();
			ctx.fillStyle = `rgba(255, 255, 255, 0.12)`;
			ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
			ctx.fill();
		}
	}
}

function animate() {
	if (!isLightning && Math.random() < 0.005) {
		isLightning = true;
		lightningTimer = Math.random() * 15 + 10;
		lightningIntensity = 1.0;
		lightningBolts.push(new LightningBolt());
		if (Math.random() > 0.6) setTimeout(() => lightningBolts.push(new LightningBolt()), 100);
	}
	if (isLightning) {
		lightningTimer--;
		lightningIntensity = Math.max(0, lightningIntensity - 0.05);
		if (lightningTimer <= 0) {
			isLightning = false;
			lightningIntensity = 0;
		}
	}
	ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
	ctx.fillRect(0, 0, width, height);
	cloudLayers.forEach(layer => {
		layer.update();
		layer.draw();
	});
	for (let i = lightningBolts.length - 1; i >= 0; i--) {
		lightningBolts[i].update();
		lightningBolts[i].draw();
		if (lightningBolts[i].life <= 0) lightningBolts.splice(i, 1);
	}
	draw3DText();
	drops.forEach(drop => {
		drop.update();
		drop.draw();
	});
	for (let i = splashes.length - 1; i >= 0; i--) {
		splashes[i].update();
		splashes[i].draw();
		if (splashes[i].life <= 0) splashes.splice(i, 1);
	}
	requestAnimationFrame(animate);
}
window.addEventListener('resize', resize);
document.fonts.ready.then(() => {
	resize();
	for (let i = 0; i < dropCount; i++) drops.push(new Drop());
	animate();
});

Explanation 詳しい説明

仕様

このデモは、Canvas 2D APIのみで雨・雷・雲・テキスト衝突を含む一連の気象演出を実現しています。外部ライブラリは使用していません。

主な機能は以下のとおりです。

  • 雨粒の物理シミュレーション: 1300個の雨粒がそれぞれ独立した速度・サイズ・奥行き(z値)を持ち、5°の角度で斜めに降ります。重力加速度も加わり、自然な落下を表現します。
  • テキスト衝突判定: オフスクリーンCanvasに白色でテキストを描画し、そのピクセルデータ(輝度)を参照して雨粒の衝突を判定します。衝突した雨粒は「落下→衝突→表面流動→離脱→再落下」の状態遷移を行います。
  • 表面流動: テキストに衝突した雨粒は表面を伝いながら揺らぎ(Sine波+ランダムノイズ)のある動きで流れ、文字の端に達すると再び落下します。
  • 飛沫エフェクト: 衝突時に6個のスプラッシュパーティクルが生成され、雨の角度に応じた方向に飛び散ります。
  • 雷と稲妻: ランダムなタイミングで画面全体のフラッシュと分岐する稲妻(LightningBolt)が発生します。稲妻は12セグメントのランダム折れ線で描画され、分岐もあります。
  • 雲レイヤー: 3層の雲がそれぞれ異なる速度で横に流れます。雲はCanvas上にラジアルグラデーションで生成したテクスチャを使用し、無限ループで移動します。
  • 3Dテキスト描画: メインのテキストは影・グラデーション・ハイライトを重ねて立体感を出しています。雷発生時にはハイライトの輝度が上がります。
  • ビネット効果: CSSのradial-gradientで画面周辺を暗くし、中央に視線を集める効果を加えています。
  • レスポンシブ対応: フォントサイズが画面幅に応じて自動調整(最小80px〜最大300px)されます。

カスタマイズ

  • テキストの変更: textStringの値を変更すると、表示テキストと衝突対象が変わります。文字数が変わる場合はフォントサイズの自動調整で対応されます。
  • 雨粒の数: dropCount(デフォルト1300)を変更すると、雨の密度が変わります。
  • 雨の角度: RAIN_ANGLE(デフォルト5度)を変更すると、雨の傾きが変わります。0で真下に降ります。
  • 雷の頻度: animate関数内のMath.random() < 0.005の値を大きくすると雷が頻繁に発生し、小さくすると穏やかになります。
  • 雲の速度・レイヤー数: CloudLayerのコンストラクタ内のspeedinitClouds関数で雲の層数・位置を調整できます。
  • フォント: fontFamily@importのフォントURLを変更すれば、別のフォントに変更できます。英字フォントにも対応可能です。
  • 背景色: bodybackground-colorやCanvasのfillStylergba(0, 0, 0, 0.45))を変更すると、全体の明暗が変わります。
  • 飛沫の量: Splashの生成数(衝突時のforループの6)を変更すると、飛沫の派手さが変わります。
  • テキストの色: draw3DText関数内のグラデーション色を変更すると、テキストの見た目を変えられます。明るい色にすると雨が当たる様子がより目立ちます。

注意点

  • パフォーマンス: 1300個の雨粒+飛沫+雲+雷を毎フレーム描画するため、低スペック端末では処理が重くなる場合があります。dropCountを減らすか、雲レイヤーを減らすことで軽減できます。
  • ピクセルベースの衝突判定: 毎フレームgetImageDataを呼び出しているため、Canvas2Dのパフォーマンスに影響します。willReadFrequently: trueオプションで最適化していますが、高解像度ディスプレイでは負荷が上がります。
  • 衝突対象の制限: 衝突判定は画面中央の縦400px範囲(height/2 ± 200)に限定されています。テキストサイズを大きく変更した場合はこの範囲も調整が必要です。
  • フォントの読み込み: Google Fontsから「Noto Sans JP(weight: 900)」を読み込んでおり、document.fonts.readyで読み込み完了を待ってから初期化しています。ネットワーク状況によっては表示開始が遅れる場合があります。
  • 奥行き(z値)による衝突制限: 全雨粒がテキストと衝突するわけではなく、z値が0.46〜0.54の範囲にある雨粒のみが衝突対象です。これにより、テキストの手前や奥を通り過ぎる雨粒が表現されます。
  • リサイズ時の再構築: リサイズ時に衝突マップと雲を再生成しますが、既存の雨粒はリセットされません。極端なリサイズでは一時的に表示が乱れる場合があります。