ビジュアル

Three.jsとGLSLで作る、母の日に贈るクリックで花が咲くアニメーション

投稿日2026/05/10

更新日2026/5/10

画面をクリックすると、その場所から茎がすっと伸びて花が咲く――そんな小さなインタラクションを、Three.jsとGLSLシェーダーだけで実現したデモです。

花びらの枚数や色、茎の曲がり方はクリックごとにランダムで変わり、何度でもクリックして画面を花で埋め尽くすことができます。

コードの中心にあるのはフラグメントシェーダーによるプロシージャル描画で、画像素材は一切使っていません。母の日やお祝いごとの演出、クリエイティブなローディング画面など、さまざまな場面に応用できます。

Code コード

<div class="kumonosu-container">
    <canvas id="kumonosu-canvas"></canvas>
    <div class="kumonosu-name">
        お母さんいつもありがとう
        <span>Tap the screen to bloom</span>
    </div>
    <div class="kumonosu-clean-btn">clear</div>
</div>
html, body {
	overflow: hidden;
	position: fixed;
	width: 100%;
	height: 100%;
	margin: 0;
	padding: 0;
	background-color: #000;
	touch-action: none !important;
	-webkit-user-select: none;
	user-select: none;
}
.kumonosu-container {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: #000;
	touch-action: none;
	overflow: hidden;
}
#kumonosu-canvas {
	display: block;
	width: 100% !important;
	height: 100% !important;
	touch-action: none;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
	/* PCのカーソルを矢印に変更 */
	cursor: default;
}
.kumonosu-name {
	position: absolute;
	top: 50%;
	left: 50%;
	width: 90%;
	/* 横幅いっぱいに広がらないよう調整 */
	transform: translate(-50%, -50%);
	color: white;
	text-align: center;
	font-size: 24px;
	/* スマホ向け標準サイズ */
	text-shadow: 0 0 15px #000;
	pointer-events: none;
	z-index: 10;
	font-family: sans-serif;
	font-weight: bold;
	line-height: 1.4;
}
.kumonosu-name span {
	display: block;
	font-size: 14px;
	margin-top: 10px;
	opacity: 0.8;
	font-weight: normal;
}
/* 被らないよう右下に配置を変更 */
.kumonosu-clean-btn {
	position: absolute;
	bottom: 30px;
	right: 25px;
	/* 左から右に変更 */
	z-index: 20;
	font-family: sans-serif;
	font-size: 13px;
	color: white;
	text-decoration: underline;
	padding: 10px;
	opacity: .4;
	/* 少し控えめに */
	cursor: pointer;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
/* PCやタブレットでの調整 */
@media all and (min-width: 640px) {
	.kumonosu-name {
		font-size: 45px;
	}
	.kumonosu-name span {
		font-size: 18px;
	}
	.kumonosu-clean-btn {
		font-size: 15px;
		bottom: 20px;
		right: 20px;
	}
}
import * as THREE from "https://esm.sh/three@0.133.1/build/three.module";
const vertexShaderCode = `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = vec4(position, 1.0);
        }
    `;
const fragmentShaderCode = `
        precision highp float;
        #define PI 3.14159265359
        uniform float u_ratio;
        uniform vec2 u_cursor;
        uniform float u_stop_time;
        uniform float u_clean;
        uniform vec2 u_stop_randomizer;
        uniform sampler2D u_texture;
        varying vec2 vUv;

        vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
        float snoise(vec2 v) {
            const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
            vec2 i = floor(v + dot(v, C.yy));
            vec2 x0 = v - i + dot(i, C.xx);
            vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
            vec4 x12 = x0.xyxy + C.xxzz;
            x12.xy -= i1;
            i = mod289(i);
            vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
            vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
            m = m*m*m*m;
            vec3 x = 2.0 * fract(p * C.www) - 1.0;
            vec3 h = abs(x) - 0.5;
            vec3 ox = floor(x + 0.5);
            vec3 a0 = x - ox;
            m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
            vec3 g;
            g.x = a0.x * x0.x + h.x * x0.y;
            g.yz = a0.yz * x12.xz + h.yz * x12.yw;
            return 130.0 * dot(m, g);
        }

        float get_flower_shape(vec2 _p, float _pet_n, float _angle, float _outline) {
            _angle *= 3.;
            _p = vec2(_p.x * cos(_angle) - _p.y * sin(_angle), _p.x * sin(_angle) + _p.y * cos(_angle));
            float a = atan(_p.y, _p.x);
            float flower_sectoral_shape = pow(abs(sin(a * _pet_n)), .4) + .25;
            vec2 flower_size_range = vec2(.03, .1);
            float size = flower_size_range[0] + u_stop_randomizer[0] * flower_size_range[1];
            float flower_radial_shape = pow(length(_p) / size, 2.);
            flower_radial_shape -= .1 * sin(8. * a);
            flower_radial_shape = max(.1, flower_radial_shape);
            flower_radial_shape += smoothstep(0., 0.03, -_p.y + .2 * abs(_p.x));
            float grow_time = step(.25, u_stop_time) * pow(u_stop_time, .3);
            return (1. - smoothstep(0., flower_sectoral_shape, _outline * flower_radial_shape / grow_time)) * (1. - step(1., grow_time));
        }

        float get_stem_shape(vec2 _p, vec2 _uv, float _w, float _angle) {
            _w = max(.004, _w);
            float x_offset = _p.y * sin(_angle) * pow(3. * _uv.y, 2.);
            _p.x -= x_offset;
            float cursor_horizontal_noise = .5 * snoise(2. * _uv * u_stop_randomizer[0]) * pow(dot(_p.y, _p.y), .6) * pow(dot(_uv.y, _uv.y), .3);
            _p.x += cursor_horizontal_noise;
            float stem_shape = smoothstep(-_w, 0., _p.x) * (1. - smoothstep(0., _w, _p.x));
            return stem_shape * smoothstep(0., pow(1. - smoothstep(0., .2, u_stop_time), .5), .03 -_p.y) * (1. - step(.17, u_stop_time));
        }

        void main() {
            vec3 base = texture2D(u_texture, vUv).xyz;
            vec2 uv = vUv; uv.x *= u_ratio;
            vec2 cursor = vUv - u_cursor.xy; cursor.x *= u_ratio;
            vec3 stem_color = vec3(.1 + u_stop_randomizer[0] * .6, .6, .2);
            vec3 flower_color = vec3(.6 + .5 * u_stop_randomizer[1], .1, .9 - .5 * u_stop_randomizer[1]);
            float angle = .5 * (u_stop_randomizer[0] - .5);
            float stem_shape = get_stem_shape(cursor, uv, .003, angle) + get_stem_shape(cursor + vec2(0., .2 + .5 * u_stop_randomizer[0]), uv, .003, angle);
            float stem_mask = 1. - get_stem_shape(cursor, uv, .004, angle) - get_stem_shape(cursor + vec2(0., .2 + .5 * u_stop_randomizer[0]), uv, .004, angle);
            float angle_offset = -(2. * step(0., angle) - 1.) * .1 * u_stop_time;
            float flower_back_shape = get_flower_shape(cursor, 1. + floor(u_stop_randomizer[0] * 2.), angle + angle_offset, 1.5);
            float flower_back_mask = 1. - get_flower_shape(cursor, 1. + floor(u_stop_randomizer[0] * 2.), angle + angle_offset, 1.6);
            float flower_front_shape = get_flower_shape(cursor, 2. + floor(u_stop_randomizer[1] * 2.), angle, 1.);
            float flower_front_mask = 1. - get_flower_shape(cursor, 2. + floor(u_stop_randomizer[1] * 2.), angle, .95);
            vec3 color = (base * stem_mask * flower_back_mask * flower_front_mask) + (stem_shape * stem_color) + (flower_back_shape * (flower_color + vec3(0., .8 * u_stop_time, 0.))) + (flower_front_shape * flower_color);
            color.r *= 1. - (.5 * flower_back_shape * flower_front_shape);
            color.b *= 1. - (flower_back_shape * flower_front_shape);
            gl_FragColor = vec4(color * u_clean, 1.);
        }
    `;
const canvasEl = document.querySelector("#kumonosu-canvas");
const cleanBtn = document.querySelector(".kumonosu-clean-btn");
const pointer = {
	x: 0,
	y: 0,
	clicked: false,
	vanishCanvas: false
};
let renderer, sceneShader, sceneBasic, camera, clock, shaderMaterial, basicMaterial;
let renderTargets = [null, null];

function init() {
	renderer = new THREE.WebGLRenderer({
		canvas: canvasEl,
		alpha: true,
		preserveDrawingBuffer: true
	});
	renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
	sceneShader = new THREE.Scene();
	sceneBasic = new THREE.Scene();
	camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 10);
	clock = new THREE.Clock();
	const dummyTex = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat);
	shaderMaterial = new THREE.ShaderMaterial({
		uniforms: {
			u_stop_time: {
				value: 1.0
			},
			u_stop_randomizer: {
				value: new THREE.Vector2(Math.random(), Math.random())
			},
			u_cursor: {
				value: new THREE.Vector2(0.5, 0.5)
			},
			u_ratio: {
				value: window.innerWidth / window.innerHeight
			},
			u_texture: {
				value: dummyTex
			},
			u_clean: {
				value: 1.0
			},
		},
		vertexShader: vertexShaderCode,
		fragmentShader: fragmentShaderCode
	});
	basicMaterial = new THREE.MeshBasicMaterial();
	sceneShader.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial));
	sceneBasic.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), basicMaterial));
	updateSize();
	const startInteraction = (e) => {
		if (e.target.closest('.kumonosu-clean-btn')) return;
		const touch = e.touches ? e.touches[0] : e;
		const rect = canvasEl.getBoundingClientRect();
		pointer.x = (touch.clientX - rect.left) / rect.width;
		pointer.y = (touch.clientY - rect.top) / rect.height;
		pointer.clicked = true;
		if (e.cancelable) e.preventDefault();
	};
	canvasEl.addEventListener("touchstart", startInteraction, {
		passive: false
	});
	canvasEl.addEventListener("mousedown", startInteraction);
	const clearCanvas = (e) => {
		e.preventDefault();
		e.stopPropagation();
		pointer.vanishCanvas = true;
		setTimeout(() => {
			pointer.vanishCanvas = false;
		}, 50);
	};
	cleanBtn.addEventListener("touchstart", clearCanvas, {
		passive: false
	});
	cleanBtn.addEventListener("mousedown", clearCanvas);
	window.addEventListener("resize", updateSize);
	render();
}

function updateSize() {
	const w = window.innerWidth || document.documentElement.clientWidth;
	const h = window.innerHeight || document.documentElement.clientHeight;
	renderer.setSize(w, h);
	renderTargets[0] = new THREE.WebGLRenderTarget(w, h);
	renderTargets[1] = new THREE.WebGLRenderTarget(w, h);
	shaderMaterial.uniforms.u_ratio.value = w / h;
}

function render() {
	requestAnimationFrame(render);
	shaderMaterial.uniforms.u_clean.value = pointer.vanishCanvas ? 0 : 1;
	shaderMaterial.uniforms.u_texture.value = renderTargets[0].texture;
	if (pointer.clicked) {
		shaderMaterial.uniforms.u_cursor.value = new THREE.Vector2(pointer.x, 1 - pointer.y);
		shaderMaterial.uniforms.u_stop_randomizer.value = new THREE.Vector2(Math.random(), Math.random());
		shaderMaterial.uniforms.u_stop_time.value = 0.;
		pointer.clicked = false;
	}
	shaderMaterial.uniforms.u_stop_time.value += clock.getDelta();
	renderer.setRenderTarget(renderTargets[1]);
	renderer.render(sceneShader, camera);
	basicMaterial.map = renderTargets[1].texture;
	renderer.setRenderTarget(null);
	renderer.render(sceneBasic, camera);
	let tmp = renderTargets[0];
	renderTargets[0] = renderTargets[1];
	renderTargets[1] = tmp;
}
init();

Explanation 詳しい説明

仕様

このデモは、Three.jsのWebGLレンダラーとカスタムGLSLシェーダーを組み合わせ、クリック位置に花をプロシージャル(手続き的)に描画します。画像は一切使用せず、すべての形状と色をシェーダー内の数学関数で生成しています。

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

  • クリック/タップで花が咲く: 画面上の任意の位置をクリック(またはタップ)すると、その座標に茎が伸び、花が開くアニメーションが再生されます。
  • プロシージャル生成: 花びらの形状は極座標ベースの関数、茎の揺らぎはSimplex Noiseで生成しています。画像素材は不要です。
  • ランダム変化: クリックごとに花びらの枚数(2〜4枚)、色(赤〜ピンク〜紫系)、茎の角度がランダムに決まります。
  • 累積描画(ピンポンバッファ): 2つのWebGLRenderTargetを交互に使い、過去に描いた花をテクスチャとして次のフレームに引き継ぐことで、花が画面上に蓄積していきます。
  • クリアボタン: 画面左下の「clear」をクリックすると、すべての花を一括消去できます。
  • レスポンシブ対応: ウィンドウリサイズ時にキャンバスとレンダーターゲットのサイズを更新します(リサイズ時は画面もクリアされます)。

カスタマイズ

  • 中央テキストの変更: HTMLの.kumonosu-name内のテキストを書き換えるだけで、表示メッセージを自由に変更できます。母の日以外にも、誕生日や記念日などの演出に転用可能です。
  • 花の色: フラグメントシェーダー内のflower_colorvec3(0.7 + ..., 0.1, 0.1 + ...))を変更すると、花の基本色を変えられます。たとえば青系にしたい場合はRとBの値を入れ替えます。
  • 茎の色: 同じくシェーダー内のstem_colorを変更します。
  • 花びらのサイズ: get_flower_shape関数内のflower_size_rangevec2(.03, .1))を調整すると、花の大きさの範囲を変更できます。
  • 背景色: CSSのbackground-color: #000やシェーダーの初期カラーを変更すれば、黒以外の背景にも対応できます。ただし、ピンポンバッファの累積描画と背景色の兼ね合いに注意が必要です。
  • テキストのフォントサイズ: CSSの.kumonosu-nameでフォントサイズやカラーを調整できます。レスポンシブ用のメディアクエリも設定済みです。

注意点

  • Three.jsの読み込み: esm.sh経由でThree.js v0.133.1をESモジュールとして読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。
  • 累積描画とリサイズ: ウィンドウをリサイズすると、レンダーターゲットが再生成されるため、それまでに描いた花はすべて消えます。これは仕様上の制約です。
  • パフォーマンス: シェーダー処理は毎フレーム実行されますが、描画内容はフルスクリーンクワッドのみのため比較的軽量です。ただしピクセル密度の高い端末(devicePixelRatioが大きい環境)では負荷が上がるため、上限を2に制限しています。
  • タッチとクリックの排他制御: touchstartが発火した場合はisTouchScreenフラグでクリックイベントを無効化し、二重発火を防いでいます。
  • 花の消去: clearボタンは一時的にu_cleanを0にしてフレームを真っ黒にし、その結果をピンポンバッファに書き込むことで蓄積をリセットしています。
  • アクセシビリティ: WebGLキャンバスに描画されるため、スクリーンリーダーからは花の内容を認識できません。中央のテキストがHTMLとして存在するため、メッセージ自体は読み上げ可能です。