Plane Gallery (WebGL camera travel, zero dependencies)
A works section built as six textured planes arranged on an arc, toured by moving the camera's look-at target. No 3D library: a projection matrix, a lookAt view matrix and a per-plane model matrix are assembled in JS and handed to plain WebGL (GLSL ES 1.00). A single four-vertex TRIANGLE_STRIP is reused for every plane — only the uniforms (MVP, artwork index, facing factor, emphasis) change between the six draw calls, so neither buffers nor textures grow. The artwork on each plane is drawn procedurally in the fragment shader instead of sampled from an image, so the block ships with zero image assets. Hit testing casts no rays: the four corners are projected to screen space with the very matrix already used for drawing and tested with a polygon inside test, discarding only planes behind the camera. Hovering a plane pulls it toward the viewer along its own normal and highlights the matching row in the hairline-ruled ledger below; hovering or focusing a row turns the camera to face that plane, so keyboard users get the same experience and the canvas can stay aria-hidden while the list remains the source of truth. Idle panning reverses at the ends of the arc, so the loop has no visible seam. Under prefers-reduced-motion no animation frame is scheduled: the arc becomes a static 3x2 grid painted once, with emphasis shown only as an amber hairline frame. Rendering also stops off-screen and on hidden tabs, and if the mean frame time over the first 48 frames exceeds 32ms the backing store drops to 0.6x before falling back to a static CSS gradient. webglcontextlost is always caught with preventDefault, and unmounting releases GPU resources via WEBGL_lose_context.
- Added:
- 2026-08-18
- Dependencies:
- None
- tags
- #works #block #webgl #glsl #3d #camera #matrix #gallery #interactive #motion #no-image #no-dependency
Preview
Selected Works
Works Index
Six planes, one camera.
一覧をたどると、カメラがその実績の正面へ回り込みます。板の絵柄は画像ではなく計算で描いています。
- 01図面と仕様表を、探さずに見つかる並びへ仕様表を画像から表組みへ戻し、型番検索から見積依頼までを1画面に収めた。Kayou Kikou製造2026Kayou Kikou製造2026
- 02施術メニューを、迷わない順に並べ直す料金と所要時間を先に見せる構成へ。予約フォームの離脱を段階入力で受け止めた。Miotsukushi Clinicクリニック2026Miotsukushi Clinicクリニック2026
- 03採用の入口を、職種ではなく1日で見せる募集要項の前に働く時間の流れを置き、応募前の質問を減らす導線にした。Tsumugi Works採用2025Tsumugi Works採用2025
- 04在庫の動きを、そのまま棚の並びにする管理画面の一覧を倉庫の並び順に合わせ、現場が指で追える行の高さに調整した。Nagare Logistics物流2025Nagare Logistics物流2025
- 05販売店の地図を、電話の前に開かせる取扱店の検索を郵便番号起点に変更。問い合わせの半分が来店予約に置き換わった。Hokuto Beverage食品2025Hokuto Beverage食品2025
- 06実績を並べず、担当者の判断を残す事例ページを成果の羅列からプロセスの記録へ。相談時の前提共有が短くなった。Shirasu Designサービス2024Shirasu Designサービス2024
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({ weight: ["700"], subsets: ["latin"], display: "swap" });
/**
* 板ポリ・ギャラリー(BLK-29)
*
* 汎用技法メモ(web-design-playbook へ還流する要点):
* 1. 「平面を並べてカメラで巡る」だけなら 3Dライブラリは要らない。必要なのは
* ①射影行列 ②ビュー行列(lookAt)③モデル行列 の3本だけで、いずれも 16要素の
* Float32Array を JS で組んで uniformMatrix4fv で渡せば済む。webgl2 → webgl の順で
* コンテキストを取り、シェーダは #version を書かない GLSL ES 1.00 にすれば1本で両対応。
* 2. 板は頂点4つの TRIANGLE_STRIP 1個を使い回す。バッファは1本のまま、板ごとに
* uniform(MVP・図案番号・面の向き・強調度)を差し替えて描画を繰り返す。
* 枚数が2桁までならインスタンシング(WebGL2限定)を持ち出す必要はない。
* 3. 板の絵柄はテクスチャではなく関数で描く。画像を持たない=ネットワークもGPUメモリも
* 使わず、板が何枚あっても増えるのは uniform 1個。図案の切り替えは uniform int の
* 分岐(同一ドローコール内は同じ値なので分岐発散が起きない)。
* 4. 当たり判定はレイを飛ばさず「4隅を同じ MVP で画面座標へ射影して多角形内外判定」。
* 描画で既に作った行列をそのまま使えるので、逆行列も交差判定も要らない。w<=0 の
* (カメラ背面の)板を弾くのだけ忘れない。
* 5. カメラは"位置"ではなく"注視点"を動かす。板を半径Rの円弧に並べ、視点を弧の中心付近に
* 置いて注視点だけを角度で回すと、パンニングで巡る画になる。行きすぎたら反転させる
* 往復にすると、無操作でもループの継ぎ目が出ない。
* 6. 見せ場は「奥行きの差」なので、ホバーは板を自分の法線方向へ引き寄せる(=半径を縮める)
* だけでよい。拡大より説得力が出て、遠近の縮尺はGPUが勝手に付ける。
* 7. canvas は装飾に徹し、実績の内容は下の罫線リスト(DOM)を正とする。ホバー・フォーカスを
* 双方向に同期すれば、キーボードだけでも同じ体験になり、canvas は aria-hidden にできる。
* 8. 停止条件を3つ持つ(prefers-reduced-motion / 画面外 / タブ非表示)。モーション低減時は
* rAF を回さず、円弧をやめた静止グリッドで1枚だけ描く。webglcontextlost は必ず
* preventDefault で握り、アンマウント時は WEBGL_lose_context で明示的に解放する。
*
* 着想: 平面を並べてカメラで巡る 3D ギャラリーという構図。行列・シェーダ・文言・配色・
* 構成はすべて First CH のオリジナルとして新規に書き起こしている(外部コード・素材の取り込みは無し)。
*/
const VERT = `attribute vec2 aPos;
uniform mat4 uMvp;
uniform float uMode; // 0: 背景(NDCへ直描き) 1: 板ポリ
varying vec2 vUv;
void main() {
if (uMode < 0.5) {
vUv = aPos + 0.5;
gl_Position = vec4(aPos * 2.0, 0.9999, 1.0); // 最奥へ置く
} else {
// 板は内向き(カメラ側)を向くので180度回してある。uvのxを反転して図案の左右を戻す
vUv = vec2(0.5 - aPos.x, aPos.y + 0.5);
gl_Position = uMvp * vec4(aPos, 0.0, 1.0);
}
}`;
const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform float uMode; // 0: 背景 1: 板ポリ
uniform vec2 uRes; // 論理px
uniform float uYaw; // カメラのヨー(背景の視差に使う)
uniform int uIndex; // 図案番号
uniform float uActive; // 強調 0..1
uniform float uShade; // 面の向き 0..1(1=正対)
varying vec2 vUv;
const vec3 INK = vec3(0.133, 0.118, 0.098);
const vec3 AMBER = vec3(0.851, 0.467, 0.024);
float hairline(float d, float t) {
return smoothstep(t, 0.0, abs(d));
}
// 背景(方眼の台紙)。テクスチャではなく関数なのでGPUメモリを持たない
vec3 backdrop(vec2 uv) {
vec3 col = vec3(1.0);
col = mix(col, vec3(0.973, 0.961, 0.945), smoothstep(0.58, 0.0, uv.y));
vec2 p = uv * uRes + vec2(uYaw * 210.0, 0.0); // ヨーに合わせて流す視差
float g = 26.0;
float d = length((fract(p / g) - 0.5) * g);
col = mix(col, mix(col, INK, 0.20), smoothstep(1.9, 0.8, d));
vec2 t6 = fract(p / (g * 6.0));
vec2 b6 = min(t6, 1.0 - t6) * (g * 6.0);
col = mix(col, mix(col, INK, 0.10), smoothstep(1.0, 0.0, min(b6.x, b6.y)));
// 地平のアンバー罫線(板が浮いている面を示す)
col = mix(col, AMBER, hairline(uv.y - 0.27, 0.004) * 0.45);
return col;
}
// 板の図案。写真の代わりに手続き的なサムネイルを描く
vec3 art(vec2 q, vec3 col) {
if (uIndex == 0) {
float band = sin((q.x * 1.6 + q.y * 2.4) * 5.2);
col = mix(col, AMBER, smoothstep(0.25, 0.95, band) * 0.55);
col = mix(col, INK, hairline(q.y + 0.12, 0.006) * 0.5);
} else if (uIndex == 1) {
float r = length(q * vec2(1.0, 1.35));
float ring = abs(fract(r * 6.0) - 0.5);
col = mix(col, INK, smoothstep(0.16, 0.03, ring) * 0.45);
col = mix(col, AMBER, smoothstep(0.12, 0.06, r));
} else if (uIndex == 2) {
float n = floor((q.x + 0.82) / 0.135);
float h = 0.14 + 0.42 * fract(sin(n * 12.9898) * 43758.5453);
float bar = step(-0.34, q.y) * step(q.y, -0.34 + h);
float accent = step(2.5, mod(n, 4.0));
col = mix(col, mix(INK, AMBER, accent), bar * 0.82);
col = mix(col, INK, hairline(q.y + 0.34, 0.004) * 0.45); // 基線
} else if (uIndex == 3) {
vec2 c = fract(q * 9.0) - 0.5;
float dots = smoothstep(0.34, 0.16, length(c)) * smoothstep(0.62, -0.2, q.x + q.y);
col = mix(col, INK, dots * 0.55);
col = mix(col, AMBER, smoothstep(0.05, 0.0, abs(q.x - 0.34)) * 0.6);
} else if (uIndex == 4) {
// 誌面の当たり(見出し帯+本文の行)
col = mix(col, INK, step(-0.62, q.x) * step(q.x, -0.02) * step(0.12, q.y) * step(q.y, 0.3) * 0.86);
float lines = step(0.0, sin((q.y + 0.5) * 62.0)) * step(-0.62, q.x) * step(q.x, 0.1) * step(q.y, 0.02) * step(-0.3, q.y);
col = mix(col, INK, lines * 0.3);
col = mix(col, AMBER, step(0.28, q.x) * step(q.x, 0.66) * step(-0.28, q.y) * step(q.y, 0.3) * 0.75);
} else {
float r = length(q * vec2(1.0, 1.4));
col = mix(col, INK, smoothstep(0.014, 0.0, abs(r - 0.3)) * 0.7);
col = mix(col, AMBER, hairline(q.x, 0.012) * 0.8);
col = mix(col, INK, hairline(q.y, 0.008) * 0.4);
}
return col;
}
vec3 plate(vec2 uv) {
vec2 q = (uv - 0.5) * vec2(1.62, 1.0); // アスペクト補正した板ローカル座標
vec3 col = art(q, vec3(1.0));
// 下端のキャプション帯(実績サムネらしさはここで出る)
float cap = smoothstep(0.115, 0.105, uv.y);
col = mix(col, INK, cap);
col = mix(col, AMBER, cap * step(uv.x, 0.075) * step(0.035, uv.x));
col = mix(col, vec3(1.0), cap * step(0.12, uv.x) * step(uv.x, 0.62) * step(0.045, uv.y) * step(uv.y, 0.062) * 0.55);
// 外周のヘアライン。強調時はアンバーへ寄せて太らせる
vec2 b = min(uv, 1.0 - uv);
float edge = min(b.x * 1.62, b.y);
float w = 0.0075 + 0.006 * uActive;
col = mix(col, mix(INK, AMBER, uActive), smoothstep(w, w * 0.4, edge));
// 左上の標本チック
col = mix(col, AMBER, step(uv.x, 0.05) * step(0.94, uv.y));
return col * mix(0.78, 1.0, uShade); // 斜めを向いた板は沈める
}
void main() {
vec3 col = uMode < 0.5 ? backdrop(vUv) : plate(vUv);
float grain = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
col += (grain - 0.5) * 0.01; // 白基調のバンディング止め
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;
type Work = {
no: string;
title: string;
client: string;
category: string;
year: string;
note: string;
};
const works: Work[] = [
{
no: "01",
title: "図面と仕様表を、探さずに見つかる並びへ",
client: "Kayou Kikou",
category: "製造",
year: "2026",
note: "仕様表を画像から表組みへ戻し、型番検索から見積依頼までを1画面に収めた。",
},
{
no: "02",
title: "施術メニューを、迷わない順に並べ直す",
client: "Miotsukushi Clinic",
category: "クリニック",
year: "2026",
note: "料金と所要時間を先に見せる構成へ。予約フォームの離脱を段階入力で受け止めた。",
},
{
no: "03",
title: "採用の入口を、職種ではなく1日で見せる",
client: "Tsumugi Works",
category: "採用",
year: "2025",
note: "募集要項の前に働く時間の流れを置き、応募前の質問を減らす導線にした。",
},
{
no: "04",
title: "在庫の動きを、そのまま棚の並びにする",
client: "Nagare Logistics",
category: "物流",
year: "2025",
note: "管理画面の一覧を倉庫の並び順に合わせ、現場が指で追える行の高さに調整した。",
},
{
no: "05",
title: "販売店の地図を、電話の前に開かせる",
client: "Hokuto Beverage",
category: "食品",
year: "2025",
note: "取扱店の検索を郵便番号起点に変更。問い合わせの半分が来店予約に置き換わった。",
},
{
no: "06",
title: "実績を並べず、担当者の判断を残す",
client: "Shirasu Design",
category: "サービス",
year: "2024",
note: "事例ページを成果の羅列からプロセスの記録へ。相談時の前提共有が短くなった。",
},
];
const N = works.length;
const STEP = (Math.PI * 2) / 12; // 板どうしの角度差(弧を1周させず扇状に置く)
const RADIUS = 3.2;
const PLANE_W = 1.5;
const PLANE_H = 0.94;
const angleOf = (i: number) => -(i - (N - 1) / 2) * STEP; // 番号が増えるほど画面右へ
// --- 行列(列優先・WebGLの既定の並び) ---
function perspective(out: Float32Array, fovy: number, aspect: number, near: number, far: number) {
const f = 1 / Math.tan(fovy / 2);
const nf = 1 / (near - far);
out.fill(0);
out[0] = f / aspect;
out[5] = f;
out[10] = (far + near) * nf;
out[11] = -1;
out[14] = 2 * far * near * nf;
}
function lookAt(out: Float32Array, ex: number, ey: number, ez: number, cx: number, cy: number, cz: number) {
let zx = ex - cx;
let zy = ey - cy;
let zz = ez - cz;
let len = Math.hypot(zx, zy, zz) || 1;
zx /= len;
zy /= len;
zz /= len;
// x = normalize(cross(up, z)) ※ up = (0,1,0)
let xx = zz;
const xy = 0;
let xz = -zx;
len = Math.hypot(xx, xy, xz) || 1;
xx /= len;
xz /= len;
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
out[0] = xx;
out[1] = yx;
out[2] = zx;
out[3] = 0;
out[4] = xy;
out[5] = yy;
out[6] = zy;
out[7] = 0;
out[8] = xz;
out[9] = yz;
out[10] = zz;
out[11] = 0;
out[12] = -(xx * ex + xy * ey + xz * ez);
out[13] = -(yx * ex + yy * ey + yz * ez);
out[14] = -(zx * ex + zy * ey + zz * ez);
out[15] = 1;
}
function multiply(out: Float32Array, a: Float32Array, b: Float32Array) {
for (let c = 0; c < 4; c++) {
const b0 = b[c * 4];
const b1 = b[c * 4 + 1];
const b2 = b[c * 4 + 2];
const b3 = b[c * 4 + 3];
out[c * 4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
out[c * 4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
out[c * 4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
out[c * 4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
}
}
// T * Ry * S を直接組む(板1枚ぶんのモデル行列)
function planeModel(out: Float32Array, px: number, py: number, pz: number, ry: number, sx: number, sy: number) {
const c = Math.cos(ry);
const s = Math.sin(ry);
out[0] = c * sx;
out[1] = 0;
out[2] = -s * sx;
out[3] = 0;
out[4] = 0;
out[5] = sy;
out[6] = 0;
out[7] = 0;
out[8] = s;
out[9] = 0;
out[10] = c;
out[11] = 0;
out[12] = px;
out[13] = py;
out[14] = pz;
out[15] = 1;
}
export default function WebglPlaneGallery() {
// 強調の出所を2つに分けて持つ。canvas から離れてもフォーカス中の行が消えないようにするため
const [canvasActive, setCanvasActive] = useState<number | null>(null);
const [listActive, setListActive] = useState<number | null>(null);
const active = listActive ?? canvasActive;
const [ready, setReady] = useState(false);
const [fallback, setFallback] = useState(false);
const [inView, setInView] = useState(false);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const activeRef = useRef<number | null>(null);
useEffect(() => {
const id = requestAnimationFrame(() => setInView(true));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) return;
const attrs: WebGLContextAttributes = {
alpha: false,
antialias: true,
depth: true,
stencil: false,
preserveDrawingBuffer: false,
powerPreference: "low-power",
};
const gl =
(canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null) ??
(canvas.getContext("webgl", attrs) as WebGLRenderingContext | null);
if (!gl) {
setFallback(true);
return;
}
const compile = (type: number, src: string) => {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
gl.deleteShader(sh);
return null;
}
return sh;
};
const vs = compile(gl.VERTEX_SHADER, VERT);
const fs = compile(gl.FRAGMENT_SHADER, FRAG);
const program = vs && fs ? gl.createProgram() : null;
if (!vs || !fs || !program) {
if (vs) gl.deleteShader(vs);
if (fs) gl.deleteShader(fs);
setFallback(true);
return;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.detachShader(program, vs);
gl.detachShader(program, fs);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
setFallback(true);
return;
}
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 板1枚ぶんの頂点4つ。すべての板がこれを使い回す
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(program, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(program);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.clearColor(1, 1, 1, 1);
const uMvp = gl.getUniformLocation(program, "uMvp");
const uMode = gl.getUniformLocation(program, "uMode");
const uRes = gl.getUniformLocation(program, "uRes");
const uYaw = gl.getUniformLocation(program, "uYaw");
const uIndex = gl.getUniformLocation(program, "uIndex");
const uActive = gl.getUniformLocation(program, "uActive");
const uShade = gl.getUniformLocation(program, "uShade");
const proj = new Float32Array(16);
const view = new Float32Array(16);
const vp = new Float32Array(16);
const model = new Float32Array(16);
const mvps = Array.from({ length: N }, () => new Float32Array(16));
const quads = Array.from({ length: N }, () => new Float32Array(8)); // 4隅の画面座標
const pickable = new Array<boolean>(N).fill(false);
const forward = new Array<number>(N).fill(0); // ホバーで手前へ出る量 0..1
let w = 1;
let h = 1;
let backing = 1;
let quality = 1;
let raf = 0;
let last = 0;
let dead = false;
let painted = false;
let yaw = angleOf(0) * 0.6;
let lookYaw = yaw; // ポインタの寄せ分を足した実際の視線
let ptrBias = 0;
let dir = -1; // 往復するパンの向き
let ptrX = 0.5;
let ptrLive = false;
const ptr = { x: 0, y: 0 };
let needPick = false;
let hovered: number | null = null;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
let onScreen = true;
let visible = document.visibilityState !== "hidden";
let probe = 0;
let probeAcc = 0;
let downgraded = false;
const applySize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.75);
backing = Math.max(0.6, dpr * quality);
canvas.width = Math.max(1, Math.round(w * backing));
canvas.height = Math.max(1, Math.round(h * backing));
gl.viewport(0, 0, canvas.width, canvas.height);
};
// 4隅を同じMVPで画面座標へ落とす(当たり判定はこれだけで済む)
const project = (i: number, m: Float32Array) => {
const cx = [-0.5, 0.5, 0.5, -0.5];
const cy = [-0.5, -0.5, 0.5, 0.5];
const q = quads[i];
let ok = true;
for (let k = 0; k < 4; k++) {
const x = cx[k];
const y = cy[k];
const cw = m[3] * x + m[7] * y + m[15];
if (cw <= 0.001) ok = false; // カメラ背面
const clipX = m[0] * x + m[4] * y + m[12];
const clipY = m[1] * x + m[5] * y + m[13];
q[k * 2] = ((clipX / cw) * 0.5 + 0.5) * w;
q[k * 2 + 1] = (0.5 - (clipY / cw) * 0.5) * h;
}
pickable[i] = ok;
};
const inQuad = (q: Float32Array, x: number, y: number) => {
let sign = 0;
for (let k = 0; k < 4; k++) {
const ax = q[k * 2];
const ay = q[k * 2 + 1];
const bx = q[((k + 1) % 4) * 2];
const by = q[((k + 1) % 4) * 2 + 1];
const cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax);
if (cross === 0) continue;
const s = cross > 0 ? 1 : -1;
if (sign === 0) sign = s;
else if (sign !== s) return false;
}
return true;
};
const render = () => {
if (w <= 1 || h <= 1) return;
const reduced = mql.matches;
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 背景(方眼の台紙)
gl.uniform1f(uMode, 0);
gl.uniform2f(uRes, w, h);
gl.uniform1f(uYaw, reduced ? 0 : lookYaw);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// カメラ: 横に広い箱でも見える幅が変わらないよう、水平画角から垂直画角を逆算する
const aspect = w / h;
// 狭幅では画角を絞って板を大きく見せる(広い箱では隣の板まで入れる)
const hfov = ((aspect < 1.9 ? 62 : 80) * Math.PI) / 180;
const vfov = Math.min(Math.max(2 * Math.atan(Math.tan(hfov / 2) / aspect), 0.52), 1.02);
perspective(proj, vfov, aspect, 0.1, 40);
const ex = 0;
const ey = 0.06;
const ez = 0;
if (reduced) {
lookAt(view, 0, 0, 0, 0, 0, 1); // 静止グリッドは正面固定
} else {
lookAt(view, ex, ey, ez, Math.sin(lookYaw) * RADIUS, -0.03, Math.cos(lookYaw) * RADIUS);
}
multiply(vp, proj, view);
gl.uniform1f(uMode, 1);
for (let i = 0; i < N; i++) {
const f = forward[i];
if (reduced) {
// 円弧をやめて 3×2 の静止グリッドへ降格する(狭幅では間隔と縮尺を詰める)
const narrow = aspect < 1.9;
const gs = narrow ? 0.72 : 0.95;
const gx = narrow ? 1.42 : 1.95;
const gy = narrow ? 0.52 : 0.62;
// world +X は右手系の lookAt で画面左に出るので、番号が読み順(左→右)に並ぶよう列を反転する
const col = 2 - (i % 3);
const row = Math.floor(i / 3);
planeModel(model, (col - 1) * gx, row === 0 ? gy : -gy, 3.7, Math.PI, PLANE_W * gs, PLANE_H * gs);
gl.uniform1f(uShade, 1);
} else {
const a = angleOf(i);
const r = RADIUS - f * 0.62;
planeModel(
model,
Math.sin(a) * r,
f * 0.04,
Math.cos(a) * r,
a + Math.PI, // 法線を弧の中心(=カメラ側)へ向ける
PLANE_W * (1 + f * 0.04),
PLANE_H * (1 + f * 0.04),
);
// 面の向き=法線とカメラ方向の内積。斜めの板を沈めると奥行きが出る
const nx = -Math.sin(a);
const nz = -Math.cos(a);
const dx = ex - Math.sin(a) * r;
const dz = ez - Math.cos(a) * r;
const len = Math.hypot(dx, dz) || 1;
gl.uniform1f(uShade, Math.max(0, (nx * dx + nz * dz) / len));
}
multiply(mvps[i], vp, model);
project(i, mvps[i]);
gl.uniformMatrix4fv(uMvp, false, mvps[i]);
gl.uniform1i(uIndex, i);
gl.uniform1f(uActive, reduced ? (activeRef.current === i ? 1 : 0) : f);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
if (!painted) {
painted = true;
setReady(true);
}
};
const step = (dtScale: number) => {
const focus = activeRef.current;
if (focus !== null) {
// 選ばれた板の正面へカメラを向ける
yaw += (angleOf(focus) - yaw) * Math.min(1, 0.07 * dtScale);
} else {
const drift = 0.0022 * dtScale * dir;
yaw += drift;
const lo = angleOf(N - 1) * 0.92;
const hi = angleOf(0) * 0.92;
if (yaw < lo) {
yaw = lo;
dir = 1;
} else if (yaw > hi) {
yaw = hi;
dir = -1;
}
}
// ポインタの左右で視線をわずかに寄せる(yaw そのものは汚さず、寄せ分だけ別に持つ)
const bias = ptrLive && focus === null ? (ptrX - 0.5) * -0.2 : 0;
ptrBias += (bias - ptrBias) * Math.min(1, 0.06 * dtScale);
lookYaw = yaw + ptrBias;
for (let i = 0; i < N; i++) {
const target = activeRef.current === i ? 1 : 0;
forward[i] += (target - forward[i]) * Math.min(1, 0.12 * dtScale);
}
};
const pick = () => {
let found: number | null = null;
let best = Infinity;
for (let i = 0; i < N; i++) {
if (!pickable[i]) continue;
if (!inQuad(quads[i], ptr.x, ptr.y)) continue;
const q = quads[i];
const cx = (q[0] + q[2] + q[4] + q[6]) / 4;
const d = Math.abs(cx - ptr.x); // 重なったら画面中心寄りの板を採る
if (d < best) {
best = d;
found = i;
}
}
if (found !== hovered) {
hovered = found;
setCanvasActive(found);
}
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const frame = (time: number) => {
if (!last) last = time;
const dt = Math.min(50, time - last);
last = time;
step(dt / 16.6667);
render();
if (needPick) {
needPick = false;
pick();
}
// 立ち上がりの10フレームを捨て、続く48フレームの平均で解像度を二段落とす
if (probe >= 0) {
probe++;
if (probe > 10) probeAcc += dt;
if (probe === 58) {
const avg = probeAcc / 48;
if (avg > 32) {
if (!downgraded) {
downgraded = true;
quality = 0.6;
applySize();
probe = 0;
probeAcc = 0;
} else {
probe = -1;
dead = true;
stop();
setFallback(true);
return;
}
} else {
probe = -1;
}
}
}
raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || dead || mql.matches || !onScreen || !visible) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const ro = new ResizeObserver((entries) => {
// contentRect はレイアウト寸法(CSS transform の影響を受けない)
const box = entries[0].contentRect;
w = Math.max(1, Math.round(box.width));
h = Math.max(1, Math.round(box.height));
applySize();
// 画面外で rAF が止まっていても最初の1枚だけは描く(表示された瞬間に空白を見せない)
if (!raf || !painted) render();
});
ro.observe(stage);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) start();
else stop();
},
{ rootMargin: "120px" },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const onMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
// 変形後の矩形→論理座標へ比率で換算(親に scale が掛かっていても合う)
ptr.x = ((e.clientX - rect.left) / rect.width) * w;
ptr.y = ((e.clientY - rect.top) / rect.height) * h;
ptrX = ptr.x / w;
ptrLive = true;
if (mql.matches) {
// rAF を回さないモードでも、直前の静止フレームで射影した4隅で判定はできる
pick();
} else {
needPick = true;
if (!raf) start();
}
};
const onLeave = () => {
ptrLive = false;
needPick = false;
if (hovered !== null) {
hovered = null;
setCanvasActive(null);
}
};
const onMotionChange = () => {
if (mql.matches) {
stop();
for (let i = 0; i < N; i++) forward[i] = 0;
render();
} else {
start();
}
};
// モーション低減時は rAF を回さないので、強調の変化だけ1枚描き直す
const onRepaint = () => {
if (!raf && !dead) render();
};
// コンテキスト消失は既定でそのまま欠落するので、必ず握って静的側へ降りる
const onLost = (e: Event) => {
e.preventDefault();
dead = true;
stop();
setFallback(true);
};
canvas.addEventListener("pointermove", onMove, { passive: true });
canvas.addEventListener("pointerleave", onLeave, { passive: true });
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotionChange);
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("firstch:repaint", onRepaint);
if (mql.matches) render();
else start();
return () => {
stop();
ro.disconnect();
io.disconnect();
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotionChange);
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("firstch:repaint", onRepaint);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
// 一覧ページでは同時コンテキスト数の上限に当たるので明示的に手放す
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}, []);
// 描画ループは state を読めないので ref へ落とす。モーション低減時だけ1枚描き直す
useEffect(() => {
activeRef.current = active;
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
canvasRef.current?.dispatchEvent(new Event("firstch:repaint"));
}, [active]);
const revealClass = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });
return (
<section className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
{/* 上部のヘアライン・メタバー */}
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-3.5 sm:px-8 sm:py-4">
<p className="flex items-center gap-2 text-[11px] font-bold tracking-[0.16em] whitespace-nowrap text-gray-900">
<span aria-hidden="true" className="inline-block size-1 bg-amber-600" />
Selected Works
</p>
<p
aria-hidden="true"
className="hidden font-mono text-[10px] font-bold tracking-[0.18em] whitespace-nowrap text-gray-400 uppercase sm:block"
>
06 planes / arc
</p>
</div>
{/* 板ポリの舞台。内容はこの下のリストが正なので canvas は装飾扱い */}
<div
ref={stageRef}
className="relative h-[210px] w-full overflow-hidden border-b border-gray-200 bg-white sm:h-[300px] md:h-[340px]"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_90%_at_50%_18%,rgba(217,119,6,0.14)_0%,rgba(255,255,255,0)_62%),linear-gradient(180deg,#ffffff_0%,#faf8f4_100%)]"
/>
<canvas
ref={canvasRef}
aria-hidden="true"
className={`absolute inset-0 block h-full w-full transition-opacity duration-700 ease-out motion-reduce:transition-none ${
ready && !fallback ? "opacity-100" : "opacity-0"
}`}
/>
<p
aria-hidden="true"
className={`pointer-events-none absolute right-4 bottom-3.5 flex items-center gap-2 text-[10px] font-medium tracking-[0.18em] text-gray-400 uppercase transition-opacity duration-500 motion-reduce:transition-none sm:right-6 sm:bottom-5 ${
active === null ? "opacity-100" : "opacity-0"
}`}
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-600" />
<span className="[@media(pointer:coarse)]:hidden">Hover to focus</span>
<span className="hidden [@media(pointer:coarse)]:inline">Tap the list</span>
</p>
</div>
<div className="px-5 pt-8 pb-2 sm:px-8 sm:pt-10">
<p
style={delay(0)}
className={`${revealClass} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase`}
>
Works Index
</p>
<h2
style={delay(100)}
className={`${montserrat.className} ${revealClass} mt-3 text-[clamp(1.6rem,4.4vw,2.4rem)] leading-[1.12] font-bold tracking-tight text-gray-900`}
>
Six planes, one camera.
</h2>
<p
style={delay(200)}
className={`${revealClass} mt-4 max-w-md text-[14px] leading-[1.95] font-medium tracking-[0.03em] text-gray-600`}
>
一覧をたどると、カメラがその実績の正面へ回り込みます。板の絵柄は画像ではなく計算で描いています。
</p>
</div>
{/* 実績台帳: カードを並べず罫線と余白でグルーピングする */}
<ol style={delay(300)} className={`${revealClass} mt-6 px-5 sm:px-8`}>
{works.map((work, i) => (
<li key={work.no} className="border-t border-gray-200 last:border-b">
<a
href="#"
onPointerEnter={() => setListActive(i)}
onPointerLeave={() => setListActive((cur) => (cur === i ? null : cur))}
onFocus={() => setListActive(i)}
onBlur={() => setListActive((cur) => (cur === i ? null : cur))}
className="group flex gap-x-4 gap-y-1 py-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 sm:grid sm:grid-cols-[2.5rem_1fr_7.5rem_3rem] sm:items-baseline sm:py-5"
>
<span
className={`font-mono text-[11px] font-bold tracking-[0.1em] transition-colors duration-300 motion-reduce:transition-none ${
active === i ? "text-amber-600" : "text-gray-400 group-hover:text-amber-600"
}`}
>
{work.no}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2.5">
<span
aria-hidden="true"
className={`hidden h-px w-4 shrink-0 origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none sm:inline-block ${
active === i ? "scale-x-100" : "scale-x-0 group-hover:scale-x-100"
}`}
/>
<span className="text-[14px] leading-snug font-bold text-gray-900 sm:text-[15px]">
{work.title}
</span>
</span>
<span className="mt-1.5 block text-[12px] leading-[1.85] text-gray-500 sm:mt-2 sm:max-w-md">
{work.note}
</span>
<span className="mt-2 flex items-center gap-2 text-[11px] tracking-[0.08em] text-gray-400 sm:hidden">
<span>{work.client}</span>
<span aria-hidden="true" className="inline-block h-px w-3 bg-gray-300" />
<span>{work.category}</span>
<span aria-hidden="true" className="inline-block h-px w-3 bg-gray-300" />
<span>{work.year}</span>
</span>
</span>
<span className="hidden text-[12px] leading-relaxed tracking-[0.06em] text-gray-500 sm:block">
{work.client}
<span className="mt-0.5 block text-[11px] text-gray-400">{work.category}</span>
</span>
<span className="hidden font-mono text-[11px] tracking-[0.08em] text-gray-400 sm:block sm:text-right">
{work.year}
</span>
</a>
</li>
))}
</ol>
<div
style={delay(400)}
className={`${revealClass} flex flex-wrap items-center justify-between gap-x-8 gap-y-4 px-5 py-7 sm:px-8 sm:py-8`}
>
<a
href="#"
className="group inline-flex items-center gap-3 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
実績の詳細を見る
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
<p
aria-hidden="true"
className="font-mono text-[10px] font-bold tracking-[0.18em] whitespace-nowrap text-gray-400 uppercase"
>
2024 — 2026
</p>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/webgl-plane-gallery.json