デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
全画面の板に、フラグメントシェーダーを 1 枚かけた背景です。ノイズでゆがめた座標でもう一度ノイズを引く「ドメインワーピング」で液面の高さを作り、隣の画素との高さの差から法線を求めます。正面から見て反射した向きを reflect で出し、その向きにある環境の色を引くと、面全体が鏡として景色を映します。ゆがみを引く位置を円周に沿って回すので、10 秒でひと回りしてループがつながります。
映す環境は画像ではなく、式で作ります。地平線の白からスレートへ移る空に横長の照明を 2 本置き、地平線より下は暗い床にします。効いているのは、反射した向きの高さに 0.15 を足すかさ上げです。平らな所は地平線の少し上の明るい空を映し、傾いた所だけが暗い床を映すので、面の大半が明るく、うねりの縁が黒く締まって金属らしく見えます。
地平線のすぐ下に暗い帯を置くと、映り込みの縁取りに見えて、アニメの塗りのようになります。床は地平線の直下をいちばん明るくし、足元へ向かってなめらかに暗くします。傾きの急な所には、薄膜のような色みを差します。色相を回す虹色ではなく、ラベンダーグレーとグレージュの 2 色の間を傾きと高さで行き来させるので、金属の無彩色を崩さずに、淡い色みだけが縁に乗ります。明るい映り込みにだけ乗せ、暗い所には差しません。暗い所に差すと、色が濁って見えます。
1 画素ごとに液面の高さを 3 回引く、重めのシェーダーです。ハッシュには sin を使わない軽い形を選び、ノイズを重ねる数も 2〜3 段に抑えています。重い端末では setPixelRatio を 1 に下げてください。反射はもともと柔らかいので、解像度を落としても目立ちません。
空の上・地平線・床の手前・床の奥の 4 色と、薄膜の 2 色は CSS 変数で変えられます。模様の大きさ・傾きの強さ(上げると反射の帯が細かく割れる)・ゆがみの強さ・薄膜の色みの強さで質感を調整します。金属は空を映して明るいので、白い文字の側には黒の幕をかけています。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。ファッション・音楽・プロダクトのヒーローに向いています。
コード
<section class="chrome-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="chrome-hero__canvas" aria-hidden="true"></canvas>
<div class="chrome-hero__inner">
<p class="chrome-hero__eyebrow">Collection</p>
<h1 class="chrome-hero__title">光をまとう、<br>秋冬の新作。</h1>
<p class="chrome-hero__lead">ここに説明文が入ります。</p>
<a class="chrome-hero__button" href="/collection/">コレクションを見る</a>
</div>
</section>
.chrome-hero {
/* 金属に映す空と床の色と、縁に差す薄膜の 2 色。script.js がこの 6 つを読んでシェーダーへ渡すので、#rrggbb で書く */
--sky-top: #98b1c4; /* スレート */
--sky-horizon: #f4f3ef;
--floor-near: #101213;
--floor-far: #6b7880; /* 墨のスレートを明るくした色 */
--film-a: #b0a5ca; /* ラベンダーグレー */
--film-b: #d2b999; /* グレージュ */
--ink: #ffffff;
--soft: #eeece6;
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 720px);
padding: 0 var(--gutter);
overflow: hidden;
color: var(--ink);
/* 読み込み中と、WebGL が使えないときの地 */
background: linear-gradient(180deg, var(--sky-top), var(--sky-horizon) 55%, var(--floor-far) 56%, var(--floor-near));
}
.chrome-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* 文字の側ほど濃い黒の幕。金属は空を映して明るいので、これが無いと白い文字が読めない */
.chrome-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.66), rgba(0, 0, 0, 0.6) 55%, rgba(0, 0, 0, 0.3));
pointer-events: none;
}
.chrome-hero__inner {
position: relative;
z-index: 1; /* 幕(::after)より前に出す */
max-width: 32em;
}
.chrome-hero__eyebrow {
margin: 0;
color: var(--soft);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.chrome-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
text-wrap: balance; /* 2 行に折り返すとき、行の長さをそろえる */
}
.chrome-hero__lead {
margin: 24px 0 0;
color: var(--soft);
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}
.chrome-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: #111111;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.chrome-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 幅が狭いと、横に流す幕では文字の下が薄くなる。一様にかける */
@media (max-width: 720px) {
.chrome-hero::after { background: rgba(0, 0, 0, 0.62); }
}
import * as THREE from "three";
const DURATION = 10; // うねりがひと回りして、同じ模様に戻るまで(秒)
const SCALE = 0.8; // 模様の大きさ。小さいほど大きなうねりになる
const RELIEF = 0.42; // 傾きの強さ。上げると反射の帯が細かく割れる
const WARP = 1.15; // ゆがみの強さ。上げると流体らしい渦が増える
const IRIDESCENCE = 0.32; // 急な傾きに差す薄膜の色みの強さ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform vec2 uResolution;
uniform float uPhase, uScale, uRelief, uWarp, uIridescence;
uniform vec3 uSkyTop, uSkyHorizon, uFloorNear, uFloorFar, uFilmA, uFilmB;
varying vec2 vUv;
// sin を使わない軽いハッシュ。1 画素で高さを 3 回引くので、軽さが効く
vec2 hash2(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
}
// グラディエントノイズ(-1..1)
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
mix(dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
}
const mat2 ROT = mat2(1.6, 1.2, -1.2, 1.6);
float fbm2(vec2 p) { return 0.62 * noise(p) + 0.3 * noise(ROT * p + 3.7); }
float fbm3(vec2 p) { float sum = 0.0, amp = 0.6; for (int i = 0; i < 3; i++) { sum += amp * noise(p); p = ROT * p + 11.7; amp *= 0.42; } return sum; }
// 液面の高さ。ノイズでゆがめた座標で、もう一度ノイズを引く。ゆがみの引き位置 a, b は円周上を回る
float height(vec2 p, vec2 a, vec2 b) { return fbm3(p + vec2(fbm2(p + a), fbm2(p + vec2(5.2, 1.3) + b)) * uWarp); }
// 映す環境。空(地平線の白 → スレート)に横長の照明が 2 本。床は地平線の直下がいちばん明るく、足元ほど暗い
vec3 environment(vec3 r) {
float y = r.y + 0.15; // 平らなところは、地平線より少し上の空を映す
vec3 sky = mix(uSkyHorizon, uSkyTop, smoothstep(0.0, 0.3, y));
sky = mix(sky, uSkyTop * 0.62, smoothstep(0.35, 1.1, y));
float lamp = 1.0 - smoothstep(0.2, 0.95, abs(r.x + 0.1));
sky += (1.0 - smoothstep(0.0, 0.06, abs(y - 0.42))) * lamp * 0.55;
sky += (1.0 - smoothstep(0.0, 0.04, abs(y - 0.78))) * lamp * 0.3;
vec3 ground = mix(uFloorFar, uFloorNear, smoothstep(0.0, 0.55, -y));
ground += (1.0 - smoothstep(0.0, 0.1, abs(-y - 0.32))) * lamp * 0.2; // 床に置いた照明の照り返し
vec3 color = mix(ground, sky, smoothstep(-0.008, 0.008, y)); // 地平線でくっきり切り替える
return color * (1.0 - 0.22 * smoothstep(-0.3, 0.9, r.x)); // 右を向いた面ほど少し暗く
}
void main() {
vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y * 0.7, 1.0) * uScale * 2.0; // 横に引き伸ばして、流れる向きを付ける
vec2 a = vec2(cos(uPhase), sin(uPhase)) * 0.9, b = vec2(sin(uPhase * 2.0 + 1.0), cos(uPhase + 0.4)) * 0.6;
// 隣の画素との高さの差から法線を作る
float e = 1.5 * uScale * 2.0 / uResolution.y;
float h0 = height(p, a, b), hx = height(p + vec2(e, 0.0), a, b), hy = height(p + vec2(0.0, e), a, b);
vec2 slope = vec2(hx - h0, hy - h0) / e;
vec3 n = normalize(vec3(-slope * uRelief, 1.0));
vec3 r = reflect(vec3(0.0, 0.0, -1.0), n); // 正面から見て、反射した向きの環境を映す
vec3 color = environment(r);
color += pow(max(dot(r, normalize(vec3(-0.35, 0.45, 0.82))), 0.0), 200.0) * 0.6; // 左上の小さな光源のハイライト
// 急な傾き(縁)に薄膜のような色みを差す。色相は回さず 2 色の間を行き来させる。暗い映り込みに差すと濁るので、明るいところだけ
float edge = 1.0 - n.z, light = dot(color, vec3(0.299, 0.587, 0.114));
vec3 film = mix(uFilmA, uFilmB, 0.5 + 0.5 * cos(6.2831853 * (edge * 2.4 + h0 * 0.8)));
color = mix(color, color * (0.4 + 0.9 * film), clamp(edge * 4.0, 0.0, 1.0) * smoothstep(0.35, 0.75, light) * uIridescence);
color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0; // 8bit の段差(バンディング)を散らす
gl_FragColor = vec4(color, 1.0);
}
`;
document.querySelectorAll(".chrome-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
// 色は CSS 変数から読む。THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255); };
const uniforms = {
uPhase: { value: 0 }, uResolution: { value: new THREE.Vector2(1, 1) },
uScale: { value: SCALE }, uRelief: { value: RELIEF }, uWarp: { value: WARP }, uIridescence: { value: IRIDESCENCE },
uSkyTop: { value: rgb("--sky-top") }, uSkyHorizon: { value: rgb("--sky-horizon") }, uFloorNear: { value: rgb("--floor-near") }, uFloorFar: { value: rgb("--floor-far") },
uFilmA: { value: rgb("--film-a") }, uFilmB: { value: rgb("--film-b") },
};
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".chrome-hero__canvas") });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 重いときは 1 に下げる。反射はもともと柔らかいので目立たない
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // 板は画面ぴったりの 2 × 2。頂点シェーダーがそのまま座標に使う
const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
scene.add(plane);
const draw = (t) => { uniforms.uPhase.value = ((t % DURATION) / DURATION) * Math.PI * 2; renderer.render(scene, camera); };
const sizes = new ResizeObserver(() => {
renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
renderer.getDrawingBufferSize(uniforms.uResolution.value); // 法線を取る隣の画素までの距離は、実際の画素で決める
if (reduceMotion) draw(7.9); // 動きを減らす設定では、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();
plane.geometry.dispose(); plane.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
});