デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
平らな板(PlaneGeometry)の頂点を、頂点シェーダーで尾根のとがったノイズ(ridged fbm)の高さまで持ち上げて、山並みを作ります。進んだ距離のぶんだけ奥の地形の座標を引くので、カメラを前へ動かさなくても、山が手前へ流れてきます。中央は平らな谷にして、奥に沈む太陽を見せます。空と太陽は別の板のシェーダーで描き、地平線の画面上の高さを JS で求めて渡します。
ループの継ぎ目を消すため、ノイズの格子の番号を奥行きの向きだけ mod で折り返し、奥行き 64 ごとに同じ地形が繰り返すようにしています。14 秒でちょうど 1 周期ぶん進むので、ループがつながります。オクターブを重ねるときは、座標と一緒に周期も 2 倍にし、ずらすのは横の向きだけにします。どちらかが欠けると周期が崩れ、ループの継ぎ目で山が飛びます。
格子の線は、素材の wireframe では描きません。三角形の斜めの辺まで線になり、面も塗られないので、手前の山の向こうの線が透けて見えます。ここでは、フラグメントシェーダーで地形の座標の整数の位置に線を引き、fwidth で割って画面上の太さをそろえています。手前も奥も同じ細さの線になり、手前の山が奥の線を隠します。奥で線が画素より細かく詰まるところは薄めて、ちらつきを抑えます。
霧の色を地平線の色にそろえると、板の奥の端が空に溶けて見えなくなります。高さをシェーダーで付けるので、three.js が持つ境界の球は平らな板のままです。frustumCulled を切っておかないと、見えているはずの山が消えることがあります。線の太さは描画の画素で決まり、高解像度の画面では細く見えるので、そろえたいときは太さの値に描画の倍率を掛けます。画面の外では描画を止め、動きを減らす設定では 1 枚だけ描いて止めます。
山の高さ、谷の幅、格子 1 マスの大きさ、ノイズの細かさ、霧の距離で調整でき、空・太陽・地面・線の色は CSS 変数で変えられます。いまは、くすみローズとグレージュを中心にした落ち着いた夕暮れの色で、太陽・線・空の順に明るさを離しています。文字は、太陽にかからない左側に置き、空の色の幕をかけて読みやすくしています。縦長の画面ではカメラを引いて、山並みを収めます。平らなグリッドが流れる背景と違い、起伏のある 3D の地形と夕焼けの空があります。音楽・ゲーム・イベント告知のヒーローに向いています。
コード
<section class="terrain-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="terrain-hero__canvas" aria-hidden="true"></canvas>
<div class="terrain-hero__inner">
<p class="terrain-hero__eyebrow">VOL.12</p>
<h1 class="terrain-hero__title">夕焼けの、<br>その先へ。</h1>
<p class="terrain-hero__lead">ここに説明文が入ります。</p>
<a class="terrain-hero__button" href="/tickets/">チケットを見る</a>
</div>
</section>
.terrain-hero {
/* 空・太陽・地形の色はここで決める。script.js がこの変数を読んでシェーダーへ渡す */
--sky-top: #141318; /* 墨にラベンダーグレーをわずかに差す */
--sky-mid: #4a4560; /* 暗いラベンダーグレー */
--sky-horizon: #b27771; /* 地平線と、奥の霧の色。くすみローズ */
--sun-top: #e3d3b8; /* 淡いグレージュ */
--sun-bottom: #d29b97; /* くすみローズ */
--ground: #141318;
--line: #d29b97; /* 手前の格子の線。くすみローズ */
--line-far: #d2b999; /* 奥の格子の線。グレージュ(霧より明るくして、奥でも見せる) */
--paper: #ffffff;
--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(--paper);
/* 読み込み中と、WebGL が使えないときの地 */
background-color: var(--sky-top);
}
.terrain-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* 文字の側だけ地を空の色で暗く落とす。格子の線が明るいので、これが無いと読めない。中央の太陽にはかけない */
.terrain-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, color-mix(in srgb, var(--sky-top) 90%, transparent), color-mix(in srgb, var(--sky-top) 55%, transparent) 30%, transparent 46%);
}
.terrain-hero__inner {
position: relative;
z-index: 1; /* 幕(::after)より前に出す */
max-width: 26em; /* 画面の中央に沈む太陽にかからない幅 */
text-shadow: 0 2px 24px var(--sky-top);
}
.terrain-hero__eyebrow {
margin: 0;
color: var(--sun-top);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.terrain-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.terrain-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す */
}
.terrain-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--paper);
color: var(--sky-top);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
text-shadow: none;
}
.terrain-hero__button:focus-visible { outline: 2px solid var(--sun-top); outline-offset: 4px; }
/* 幅が狭いと、横に流す幕では画面ぜんぶが暗くなる。一様に落として、山並みは透かして見せる */
@media (max-width: 720px) {
.terrain-hero::after { background: color-mix(in srgb, var(--sky-top) 60%, transparent); }
}
import * as THREE from "three";
const DURATION = 14; // 地形 1 周期ぶん進んで、元の絵に戻るまで(秒)。速さは PERIOD / DURATION
const PERIOD = 64; // 地形が繰り返す奥行き(= 1 周で進む距離)。霧の奥行きより長くすると繰り返しに気づかない
const CELL = 0.8, NOISE_CELLS = 8; // 格子 1 マスの大きさ(PERIOD を割り切れる値)と、PERIOD の中のノイズの格子の数(整数。増やすと山が細かい)
const MOUNTAIN = 3.6, VALLEY = 1.3; // 山の高さと、中央の平らな谷の半幅
const LINE_WIDTH = 1.1, SUN_RADIUS = 0.17; // 線の太さ(描画の画素)と、太陽の半径(画面の高さに対する比)
const FOG = [12, 52]; // 霧が始まる距離と、地平線の色に溶けきる距離
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 空は画面ぴったりの板に描く。地平線のくすみローズ → ラベンダーグレー → 墨と、地平線の少し上に、上が淡いグレージュ・下がくすみローズの太陽
const skyVertex = /* glsl */ `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const skyFragment = /* glsl */ `
uniform vec2 uResolution; varying vec2 vUv;
uniform float uHorizon, uSunRadius; // uHorizon は地平線の画面上の高さ(0..1)
uniform vec3 uSkyTop, uSkyMid, uSkyHorizon, uSunTop, uSunBottom;
void main() {
vec2 s = (vUv - vec2(0.5, uHorizon + 0.06)) * vec2(uResolution.x / uResolution.y, 1.0);
float y = vUv.y - uHorizon, r = length(s), px = 1.0 / uResolution.y; // 太陽の縁は 1 画素ぶんだけぼかす
vec3 color = mix(mix(uSkyHorizon, uSkyMid, smoothstep(0.0, 0.3, y)), uSkyTop, smoothstep(0.24, 0.66, y)) + uSunBottom * exp(-r * r / 0.1) * 0.3;
color = mix(color, mix(uSunBottom, uSunTop, smoothstep(-uSunRadius * 0.7, uSunRadius, s.y)), 1.0 - smoothstep(uSunRadius - px, uSunRadius + px, r));
gl_FragColor = vec4(color + (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0, 1.0); // 8bit の段差を散らす
}
`;
const terrainVertex = /* glsl */ `
uniform float uTravel, uFrequency, uPeriod, uMountain, uValley, uCell;
varying vec2 vGrid; varying float vDepth, vShade;
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; }
// 奥行き(y)の向きだけ period ごとに繰り返すグラディエントノイズ(-1..1)。格子の番号を mod で折り返す
float periodicNoise(vec2 p, float period) {
vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
float y0 = mod(i.y, period), y1 = mod(i.y + 1.0, period);
return mix(mix(dot(hash2(vec2(i.x, y0)), f), dot(hash2(vec2(i.x + 1.0, y0)), f - vec2(1.0, 0.0)), u.x),
mix(dot(hash2(vec2(i.x, y1)), f - vec2(0.0, 1.0)), dot(hash2(vec2(i.x + 1.0, y1)), f - vec2(1.0, 1.0)), u.x), u.y);
}
// 尾根のとがった山(ridged fbm)。オクターブごとに座標と周期を両方 2 倍にし、ずらすのは x だけにして奥行きの周期を保つ
float ridges(vec2 p, float period) {
float sum = 0.0, amp = 0.6;
for (int i = 0; i < 3; i++) { float n = 1.0 - abs(periodicNoise(p, period)); sum += amp * n * n; p = p * 2.0 + vec2(17.3, 0.0); period *= 2.0; amp *= 0.42; }
return sum;
}
void main() {
vec3 pos = position; float w = pos.z - uTravel; // 進んだぶん奥の地形を引くので、山が手前へ流れてくる
float side = smoothstep(uValley, uValley + 5.5, abs(pos.x)), r = ridges(vec2(pos.x, w) * uFrequency, uPeriod);
pos.y = r * (uMountain * side * side + 0.3); vGrid = vec2(pos.x, w) / uCell; vShade = r;
vec4 mv = modelViewMatrix * vec4(pos, 1.0); vDepth = -mv.z; gl_Position = projectionMatrix * mv;
}
`;
const terrainFragment = /* glsl */ `
uniform vec3 uGround, uLine, uLineFar, uFog; uniform float uFogNear, uFogFar, uLineWidth;
varying vec2 vGrid; varying float vDepth, vShade;
void main() { // 格子の線は、地形の座標の整数の位置に、画面上で uLineWidth 画素の太さで引く。手前の山が奥の線を隠す
vec2 fw = max(fwidth(vGrid), vec2(1e-4)), d = abs(fract(vGrid - 0.5) - 0.5) / fw;
float dist = min(d.x, d.y), line = 1.0 - smoothstep(uLineWidth - 0.6, uLineWidth + 0.6, dist), glow = exp(-dist * 0.35) * 0.3;
float dense = 1.0 - smoothstep(0.25, 0.6, max(fw.x, fw.y)); // 奥で線が画素より細かく詰まったら薄める(ちらつき防止)
float fog = smoothstep(uFogNear, uFogFar, vDepth); // 霧を地平線の色にそろえると、板の奥の端が消える
vec3 color = mix(uGround * (0.7 + 0.8 * vShade), mix(uLine, uLineFar, smoothstep(0.1, 0.8, fog)), clamp(line + glow, 0.0, 1.0) * dense);
gl_FragColor = vec4(mix(color, uFog, fog), 1.0);
}
`;
document.querySelectorAll(".terrain-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim(); // 色は CSS 変数から読み、THREE.Color を通さず 255 で割って渡す(線形に直させない)
const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return { value: new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255) }; };
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".terrain-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 200); // 縦横比は resize で入れる
const sky = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ vertexShader: skyVertex, fragmentShader: skyFragment, depthTest: false, depthWrite: false,
uniforms: { uResolution: { value: new THREE.Vector2(1, 1) }, uHorizon: { value: 0.5 }, uSunRadius: { value: SUN_RADIUS },
uSkyTop: rgb("--sky-top"), uSkyMid: rgb("--sky-mid"), uSkyHorizon: rgb("--sky-horizon"), uSunTop: rgb("--sun-top"), uSunBottom: rgb("--sun-bottom") } }));
sky.frustumCulled = false; sky.renderOrder = -1; // 最初に描いて、地形の後ろに敷く
const terrain = new THREE.Mesh(new THREE.PlaneGeometry(64, 72, 200, 240).rotateX(-Math.PI / 2).translate(0, 0, -32), new THREE.ShaderMaterial({
vertexShader: terrainVertex, fragmentShader: terrainFragment, // 地形の板(z: +4 〜 -68)の頂点は動かさず、高さと線をシェーダーで決める
uniforms: { uTravel: { value: 0 }, uFrequency: { value: NOISE_CELLS / PERIOD }, uPeriod: { value: NOISE_CELLS }, uMountain: { value: MOUNTAIN }, uValley: { value: VALLEY },
uCell: { value: CELL }, uLineWidth: { value: LINE_WIDTH }, uFogNear: { value: FOG[0] }, uFogFar: { value: FOG[1] },
uGround: rgb("--ground"), uLine: rgb("--line"), uLineFar: rgb("--line-far"), uFog: rgb("--sky-horizon") } }));
terrain.frustumCulled = false; // 高さをシェーダーで付けるので、境界球が実際と合わない。視錐台カリングで消えないようにする
const scene = new THREE.Scene().add(sky, terrain), ahead = new THREE.Vector3();
const draw = (t) => {
const p = (t % DURATION) / DURATION, a = p * Math.PI * 2;
terrain.material.uniforms.uTravel.value = p * PERIOD;
camera.position.set(0.35 * Math.sin(a), 1.45 + 0.1 * Math.sin(a * 2), 3); // 谷の上をゆっくり揺れながら進む(揺れの周波数は整数)
camera.lookAt(0.6 * Math.sin(a + 0.8), 1.05, -20); camera.updateMatrixWorld();
ahead.set(camera.position.x, camera.position.y, camera.position.z - 1000).project(camera); // 目の高さのはるか前方を画面に写す
sky.material.uniforms.uHorizon.value = (ahead.y + 1) / 2; renderer.render(scene, camera); // 地平線の高さを空へ渡してから描く
};
const sizes = new ResizeObserver(() => {
camera.aspect = hero.clientWidth / hero.clientHeight; camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長では引いて、山並みを収める
camera.updateProjectionMatrix();
renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
renderer.getDrawingBufferSize(sky.material.uniforms.uResolution.value); if (reduceMotion) draw(9.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();
for (const mesh of [sky, terrain]) { mesh.geometry.dispose(); mesh.material.dispose(); } renderer.dispose(); renderer.forceContextLoss();
};
});