デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 使うもの
- three/addons/environments/RoomEnvironment.js
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
正二十面体を細かく分けた球に、光や映り込みを計算する標準の MeshPhysicalMaterial をそのまま使い、onBeforeCompile で頂点シェーダーの一部だけを差し替えます。各頂点を、3 次元のノイズの値だけ外へ押し出すので、光と映り込みの計算は素材に任せたまま、形だけが変わり続けます。ノイズを引く位置を円周に沿って回し、回転も 10 秒でちょうど 1 周させるので、ループの継ぎ目が出ません。
頂点を動かしただけでは、法線は元の球のままです。陰影と映り込みが丸い球のように付き、こぶが平らに見えます。そこで、接線の向きへ少しずらした 2 点も同じ式で押し出し、3 点が作る面の向き(外積)を新しい法線にして objectNormal に入れます。ノイズの補間を 5 次式にしているのも、映り込みの筋に折れ目を出さないためです。
ノイズの細かさを上げるとこぶが増えますが、上げすぎると、へこみが折れ目のように見えます。0.8 前後がなめらかです。押し出しの強さは半径の 0.42 倍、球は 40 分割で頂点が約 10 万個です。頂点をシェーダーで押し出すと、表示の判定に使う境界の球が実際より小さいまま残り、端が欠けることがあるので、frustumCulled を切っておきます。
銀の質感は、部屋のシーン(RoomEnvironment)を PMREM でぼかした映り込みで出します。素材は金属の度合い(metalness)を 1 にした明るい灰で、金属は自分の色をほとんど持たず周りを映すだけなので、明るい部屋の壁と照明が、そのまま銀の地と白いつやになります。粗さは 0.2 で、下げるほど鏡のようにくっきり映り、上げるとつや消しの銀に近づきます。地は CSS の無彩色のグラデーションで、canvas は透明です。床の影はシャドウマップを使わず、中心の濃い丸いにじみの板 1 枚で描き、球が浮くほど薄く広げます。
球・光・影の色と地のグラデーションは、CSS 変数で変えられます。広い画面では canvas ごと右へずらして見出しの横に置き、狭い画面では中央に戻して、文字の下に地の色の薄い幕をかけます。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。飲料・コスメ・プロダクトの紹介など、印象に残るヒーローに向いています。
コード
<section class="blob-hero">
<!-- 球は飾りなので、読み上げからは外す -->
<canvas class="blob-hero__canvas" aria-hidden="true"></canvas>
<div class="blob-hero__inner">
<p class="blob-hero__eyebrow">SPARKLING SODA</p>
<h1 class="blob-hero__title">はじける、<br>果実の炭酸。</h1>
<p class="blob-hero__lead">ここに説明文が入ります。</p>
<a class="blob-hero__button" href="/stores/">取り扱い店を探す</a>
</div>
</section>
.blob-hero {
/* 球と光と影の色。script.js がこの 4 つを読んでシーンへ渡す */
--blob: #d6d8db; /* 銀の金属の反射の色。映り込みに掛かる */
--key: #ffffff; /* 左上からの主光源 */
--rim: #e4e4e4; /* 奥から縁を照らす光 */
--shadow: #383838; /* 床の影(灰) */
/* 地のグラデーション(無彩色の灰)。canvas は透明なので、この上に球が載る */
--bg-center: #f2f2f2;
--bg-middle: #dcdcdc;
--bg-edge: #c4c4c4;
/* 球を右へずらす量(幅に対する %)。見出しの横に置く。0% で中央 */
--shift: 18%;
--ink: #262626;
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 720px);
padding: 0 var(--gutter);
overflow: clip; /* ずらした canvas がはみ出す。hidden だと、焦点や scrollIntoView で節ごと横にスクロールされる */
color: var(--ink);
background: radial-gradient(ellipse at calc(50% + var(--shift)) 42%, var(--bg-center) 0%, var(--bg-middle) 55%, var(--bg-edge) 100%);
}
.blob-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
translate: var(--shift) 0; /* 絵ごと動かすので、球の形も影も変わらない */
}
.blob-hero__inner {
position: relative; /* 球の上に載せる */
max-width: 30em;
}
.blob-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.blob-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.blob-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}
.blob-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: #ffffff;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.blob-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 画面の高さに比べて幅が詰まると、球が本文にかかる。もう少し右へ寄せる */
@media (max-width: 1200px) {
.blob-hero { --shift: 26%; }
}
/* 幅が狭いと横に並べる余地が無い。球を中央に戻し、文字の下に地の色の薄い幕をかける */
@media (max-width: 960px) {
.blob-hero { --shift: 0%; }
.blob-hero::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--bg-center) 55%, transparent);
pointer-events: none;
}
.blob-hero__inner { z-index: 1; } /* 幕(::after)より前に出す */
}
import * as THREE from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
const DURATION = 10; // 形の変化と回転がひと回りして、同じ絵に戻るまで(秒)
const RADIUS = 1.45; // 球の半径
const DETAIL = 40; // 球の細かさ。上げるほど滑らか(頂点数は 60 ×(DETAIL + 1)² 個)
const FREQUENCY = 0.8; // ノイズの細かさ。上げるとこぶが増え、上げすぎると、へこみが折れ目に見える
const AMPLITUDE = 0.42; // 押し出しの強さ(半径に対する比)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// MeshPhysicalMaterial の頂点シェーダーに足す関数
const blobChunk = /* glsl */ `
uniform float uPhase, uFrequency, uAmplitude;
vec3 blobHash(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973)); p += dot(p, p.yxz + 33.33);
return fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0; }
// 3 次元のグラディエントノイズ。補間を 5 次式にして、映り込みに折れ目が出ないようにする
float blobNoise(vec3 p) {
vec3 i = floor(p), f = fract(p), u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
return mix(
mix(mix(dot(blobHash(i), f), dot(blobHash(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0)), u.x),
mix(dot(blobHash(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0)), dot(blobHash(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0)), u.x), u.y),
mix(mix(dot(blobHash(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0)), dot(blobHash(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0)), u.x),
mix(dot(blobHash(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0)), dot(blobHash(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z);
}
// 単位球上の方向 dir を、ノイズのぶんだけ外へ押し出した点。ノイズを引く位置は円周上を回る(ループ用)
vec3 blobPoint(vec3 dir) {
vec3 a = vec3(cos(uPhase), sin(uPhase), 0.0) * 0.8, b = vec3(0.0, cos(uPhase * 2.0 + 1.3), sin(uPhase * 2.0 + 1.3)) * 0.45;
float n = blobNoise(dir * uFrequency + a) * 0.9 + blobNoise(dir * uFrequency * 2.0 + b + 4.7) * 0.1;
return dir * (1.0 + uAmplitude * n);
}
`;
// 法線: 押し出した点と、接線方向へ少しずらした 2 点も押し出し、その外積を新しい法線にする(しないと陰影が元の球のまま)
const blobNormal = /* glsl */ `
vec3 blobDir = normalize(position);
vec3 blobT = normalize(cross(blobDir, abs(blobDir.y) < 0.95 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 blobB = cross(blobDir, blobT);
vec3 blobP = blobPoint(blobDir);
vec3 blobPA = blobPoint(normalize(blobDir + blobT * 0.01)), blobPB = blobPoint(normalize(blobDir + blobB * 0.01));
vec3 objectNormal = normalize(cross(blobPA - blobP, blobPB - blobP));
#ifdef USE_TANGENT
vec3 objectTangent = vec3(tangent.xyz);
#endif
`;
// 床の影は、中心が濃く外へ消えていく丸いにじみ 1 枚(シャドウマップを使わない)
const shadowVertex = /* glsl */ `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
const shadowFragment = /* glsl */ `
uniform vec3 uColor; uniform float uOpacity; varying vec2 vUv;
void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(uColor, exp(-d * d * 4.0) * uOpacity); }
`;
document.querySelectorAll(".blob-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".blob-hero__canvas"), antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0); renderer.toneMapping = THREE.NeutralToneMapping; // 地は CSS のグラデーション。canvas は透かす
const scene = new THREE.Scene(), pmrem = new THREE.PMREMGenerator(renderer), room = new RoomEnvironment(), env = pmrem.fromScene(room, 0.04);
scene.environment = env.texture; room.dispose(); pmrem.dispose(); // 映り込み用に、部屋のシーンを PMREM でぼかしたもの(画面には描かない)
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); // 縦横比は resize で入れる
camera.position.set(0, 0.4, 9.2); camera.lookAt(0, 0, 0);
const key = new THREE.DirectionalLight(css("--key"), 1.8), rim = new THREE.DirectionalLight(css("--rim"), 3.5); // 左上からの主光源と、奥から縁を照らす光
key.position.set(-4, 5, 6); rim.position.set(5, 1.5, -5); scene.add(key, rim);
const uniforms = { uPhase: { value: 0 }, uFrequency: { value: FREQUENCY }, uAmplitude: { value: AMPLITUDE } };
// 銀の金属。金属は周りを映すだけなので、明るい部屋の映り込みが明るい灰の地と白いつやになる
const material = new THREE.MeshPhysicalMaterial({ color: css("--blob"), roughness: 0.2, metalness: 1 });
material.onBeforeCompile = (shader) => { // 頂点の位置と法線を、ノイズで押し出したものに差し替える
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>\n${blobChunk}`)
.replace("#include <beginnormal_vertex>", blobNormal).replace("#include <begin_vertex>", "vec3 transformed = blobP;");
};
const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(1, DETAIL), material);
blob.scale.setScalar(RADIUS); blob.frustumCulled = false; // 頂点をシェーダーで押し出すので、境界球が実際より小さい。カリングで欠けないようにする
const shade = new THREE.Color().setStyle(css("--shadow"), THREE.LinearSRGBColorSpace); // シェーダーがそのまま書くので、線形に直さない
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({ uniforms: { uColor: { value: shade }, uOpacity: { value: 0.3 } },
vertexShader: shadowVertex, fragmentShader: shadowFragment, transparent: true, depthWrite: false }));
shadow.rotation.x = -Math.PI / 2; shadow.position.y = -2.25;
scene.add(blob, shadow);
const draw = (t) => {
const a = ((t % DURATION) / DURATION) * Math.PI * 2, lift = Math.sin(a * 2); // lift は上下の揺れ(-1..1)
uniforms.uPhase.value = a; blob.position.y = 0.1 * lift;
blob.rotation.set(0.3 * Math.sin(a), a, 0.12 * Math.sin(a * 2));
shadow.material.uniforms.uOpacity.value = 0.34 - 0.06 * lift; // 浮き上がるほど影は薄く、広く
shadow.scale.set(4.6 + 0.4 * lift, 2.4 + 0.2 * lift, 1);
renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h; camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長の画面では引いて、球を収める
camera.updateProjectionMatrix(); renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
if (reduceMotion) draw(3); // 動きを減らす設定では、1 枚だけ描いて止める
});
const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null)); // 画面の外では回さない
sizes.observe(hero); view.observe(hero);
hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect(); env.dispose();
blob.geometry.dispose(); material.dispose(); shadow.geometry.dispose(); shadow.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
});