板ポリ・ギャラリー(WebGLカメラ巡回・依存ゼロ)
実績のサムネを6枚の板ポリとして円弧に並べ、カメラ(ビュー行列)の注視点を回して巡る実績ブロック。3Dライブラリは使わず、射影行列・lookAt・モデル行列の3本をJSで組んで素のWebGL(GLSL ES 1.00)へ渡すだけで動く。板は頂点4つのTRIANGLE_STRIPを1個だけ持ち、uniform(MVP・図案番号・面の向き・強調度)を差し替えて6回描くので、バッファもテクスチャも増えない。絵柄は画像ではなくフラグメントシェーダの関数で描くため画像素材ゼロで、板が何枚並んでもネットワーク転送は発生しない。当たり判定はレイを飛ばさず、描画に使ったMVPで4隅を画面座標へ射影して多角形の内外判定を行う(逆行列も交差計算も不要・カメラ背面のw<=0だけ弾く)。ホバーした板は自分の法線方向へ引き寄せて手前へせり出し、同時に下の実績台帳の行が強調される。逆に台帳の行にホバー/フォーカスするとカメラがその板の正面へ回り込むため、キーボードだけでも同じ体験になり、canvasはaria-hiddenの装飾に徹して内容は罫線リスト(DOM)を正にできる。無操作時はパンが端で反転する往復になりループの継ぎ目が出ない。prefers-reduced-motion時はrAFを回さず、円弧をやめた3×2の静止グリッドを1枚だけ描いて強調はアンバーの枠線のみで示す。画面外(IntersectionObserver)とタブ非表示でも停止し、起動直後48フレームの平均フレーム時間が32msを超えたらバッキングストアを0.6倍、それでも超えたらCSSの静的グラデーションへ降りる。webglcontextlostは必ずpreventDefaultで握り、アンマウント時はWEBGL_lose_contextでGPUリソースを明示的に解放する。文言・配色・構成はすべてFirst CHのオリジナル。
- 追加日:
- 2026-08-18
- 依存:
- なし
- tags
- #works #block #webgl #glsl #3d #camera #matrix #gallery #interactive #motion #no-image #no-dependency
プレビュー
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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/webgl-plane-gallery.json