HTML / CSS / JS
ビジュアル
2026/06/15
2026/6/14
梅雨の激しい雨を、ライブラリなしのCanvas 2Dだけで丸ごと再現したデモです。
1300粒の雨が斜めに降り注ぎ、画面中央に表示された「梅雨の大雨」の文字に当たると飛沫を上げて跳ね、文字の表面を伝って流れ落ちていきます。
衝突判定にはオフスクリーンCanvasに描画したテキストのピクセルデータを使い、雨粒の状態を「落下→衝突→表面流動→離脱→再落下」と遷移させることでリアルな水の動きを表現しています。
さらに、ランダムに発生する雷のフラッシュと稲妻、流れる雲のレイヤー、ビネット効果も加わり、見ているだけで雨の日の空気感が伝わってくる没入感のある演出に仕上がっています。
<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();
});
このデモは、Canvas 2D APIのみで雨・雷・雲・テキスト衝突を含む一連の気象演出を実現しています。外部ライブラリは使用していません。
主な機能は以下のとおりです。
radial-gradientで画面周辺を暗くし、中央に視線を集める効果を加えています。textStringの値を変更すると、表示テキストと衝突対象が変わります。文字数が変わる場合はフォントサイズの自動調整で対応されます。dropCount(デフォルト1300)を変更すると、雨の密度が変わります。RAIN_ANGLE(デフォルト5度)を変更すると、雨の傾きが変わります。0で真下に降ります。animate関数内のMath.random() < 0.005の値を大きくすると雷が頻繁に発生し、小さくすると穏やかになります。CloudLayerのコンストラクタ内のspeedやinitClouds関数で雲の層数・位置を調整できます。fontFamilyと@importのフォントURLを変更すれば、別のフォントに変更できます。英字フォントにも対応可能です。bodyのbackground-colorやCanvasのfillStyle(rgba(0, 0, 0, 0.45))を変更すると、全体の明暗が変わります。Splashの生成数(衝突時のforループの6)を変更すると、飛沫の派手さが変わります。draw3DText関数内のグラデーション色を変更すると、テキストの見た目を変えられます。明るい色にすると雨が当たる様子がより目立ちます。dropCountを減らすか、雲レイヤーを減らすことで軽減できます。getImageDataを呼び出しているため、Canvas2Dのパフォーマンスに影響します。willReadFrequently: trueオプションで最適化していますが、高解像度ディスプレイでは負荷が上がります。height/2 ± 200)に限定されています。テキストサイズを大きく変更した場合はこの範囲も調整が必要です。document.fonts.readyで読み込み完了を待ってから初期化しています。ネットワーク状況によっては表示開始が遅れる場合があります。0.46〜0.54の範囲にある雨粒のみが衝突対象です。これにより、テキストの手前や奥を通り過ぎる雨粒が表現されます。