HTML / CSS / JS
ビジュアル
2026/05/10
2026/5/10
画面をクリックすると、その場所から茎がすっと伸びて花が咲く――そんな小さなインタラクションを、Three.jsとGLSLシェーダーだけで実現したデモです。
花びらの枚数や色、茎の曲がり方はクリックごとにランダムで変わり、何度でもクリックして画面を花で埋め尽くすことができます。
コードの中心にあるのはフラグメントシェーダーによるプロシージャル描画で、画像素材は一切使っていません。母の日やお祝いごとの演出、クリエイティブなローディング画面など、さまざまな場面に応用できます。
<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();
このデモは、Three.jsのWebGLレンダラーとカスタムGLSLシェーダーを組み合わせ、クリック位置に花をプロシージャル(手続き的)に描画します。画像は一切使用せず、すべての形状と色をシェーダー内の数学関数で生成しています。
主な機能は以下のとおりです。
.kumonosu-name内のテキストを書き換えるだけで、表示メッセージを自由に変更できます。母の日以外にも、誕生日や記念日などの演出に転用可能です。flower_color(vec3(0.7 + ..., 0.1, 0.1 + ...))を変更すると、花の基本色を変えられます。たとえば青系にしたい場合はRとBの値を入れ替えます。stem_colorを変更します。get_flower_shape関数内のflower_size_range(vec2(.03, .1))を調整すると、花の大きさの範囲を変更できます。background-color: #000やシェーダーの初期カラーを変更すれば、黒以外の背景にも対応できます。ただし、ピンポンバッファの累積描画と背景色の兼ね合いに注意が必要です。.kumonosu-nameでフォントサイズやカラーを調整できます。レスポンシブ用のメディアクエリも設定済みです。esm.sh経由でThree.js v0.133.1をESモジュールとして読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。devicePixelRatioが大きい環境)では負荷が上がるため、上限を2に制限しています。touchstartが発火した場合はisTouchScreenフラグでクリックイベントを無効化し、二重発火を防いでいます。u_cleanを0にしてフレームを真っ黒にし、その結果をピンポンバッファに書き込むことで蓄積をリセットしています。