最適化と注意点
GLSL は並列実行・浮動小数・分岐コストに独特のクセがある。 本番投入前に通読しておく落とし穴と最適化の基本まとめ。
パフォーマンスの基本方針
- 解像度を下げる — 一番効く。fragment 数 = 全コスト
- 分岐を減らす —
ifよりmix/step - texture サンプリングを減らす — 1 fragment で 5 回より 2 回
- 重い関数を避ける —
pow/exp/log/atan/sqrtは重い - uniform が同じ値なら頂点シェーダで計算 — fragment は呼ばれる回数が多い
- varying をシンプルに — 多すぎると補間コストが増える
分岐は不利
GPU は同じ命令を多数並列に実行する。同じ workgroup(warp / wavefront)内の fragment が違う分岐を取ると、両方を実行して片方を捨てる動きになる。 これを thread divergence と呼ぶ。
条件分岐をブランチレスに
if (x > 0.5) {
col = a;
} else {
col = b;
}
col = mix(b, a, step(0.5, x));
実用的にはuniform で一定なら if は OK(同じ workgroup 内で同じ分岐に行くため)。
fragment ごとに値が違う vUv/vNormal を使った if は要注意。
ループのコスト
- 定数回数のループは GPU に展開される(unroll)ので速い
- 動的ループ(uniform で回数変動)は ES 3.00 で可能だが遅い場合あり
- WebGL1 (GLSL ES 1.00) では定数回数のみ
// OK: コンパイル時定数
for (int i = 0; i < 8; i++) { ... }
// 遅い可能性: ループ内に重い処理
for (int i = 0; i < 64; i++) {
texture(t, uv); // 64 回サンプリング → 重い
}
重い関数 / 軽い関数
| 軽い | 重い |
|---|---|
+ - * / | pow |
min / max / abs | exp / log |
step | atan / asin / acos |
mix | sqrt |
smoothstep | inverse / determinant |
dot | texture / textureLod(毎回メモリ取得) |
normalize | 動的ループ |
軽い書き換えの例
// pow(x, 2.0) → x * x
float a = x * x;
// pow(x, 4.0) → x = x*x; x*x
float b = x * x; b = b * b;
// 1.0 / sqrt(x) → inversesqrt(x)
float c = inversesqrt(x);
// length(d) < r で比較するなら sqrt 回避
// length(d) < r ⇔ dot(d, d) < r * r
if (dot(diff, diff) < radius * radius) { ... }
precision(精度)
モバイル GPU ではmediump(16bit)の方が highp より速いことが多い。 色や軽い計算は mediump で十分。
precision highp float; // 全 float のデフォルト
mediump vec3 color = vec3(1.0, 0.5, 0.0); // 個別に精度を指定
注意: 座標・行列は精度が落ちると壊れる。highp 必須。色・α は mediump で OK。
varying の数を減らす
頂点 → fragment への varying は補間コストがかかる。 必要最小限にし、関連する値はパックする。
// BAD: 別々
out vec3 vWorldPos;
out vec3 vWorldNormal;
out vec2 vUv;
// GOOD: パック(vec2 × 4 = vec4 × 2)
out vec4 vPosUv; // xyz = pos, w = u
out vec4 vNormalUv; // xyz = normal, w = v
実用ではここまでやることは少ないが、varying が 8 個を超えるなら検討。
uniform は変えるたびに切替コスト
Three.js では material.uniforms.x.value = ... はほぼ無料だが、
頻繁に値を変える場合はInstancedMesh / instancedAttributeで持たせる方が速いケースもある。
シェーダ間の値の受け渡し
- vertex → fragment: varying(in/out)。値は補間される
- JS → vertex: attribute / uniform
- JS → fragment: uniform / sampler
- fragment → JS: 直接は不可。RenderTarget に書いて読む必要
- fragment → 次フレーム: 同上、RenderTarget
共通の落とし穴
1. 1.0 と 1 の混在
浮動小数点には必ず小数点: 1.0、0.5、2. はOK。
1 は int として解釈される場合がある。
// BAD(環境による)
float a = 1;
// GOOD
float a = 1.0;
2. 暗黙の型変換が無い
// BAD
vec3 v = vec2(1.0); // エラー
// GOOD
vec3 v = vec3(vec2(1.0), 0.0);
3. ベクトル× はcomponent-wise
// 内積ではなく要素ごとの積
vec3 c = a * b; // (a.x*b.x, a.y*b.y, a.z*b.z)
// 内積が欲しいなら
float d = dot(a, b);
4. normalize(0) は NaN
ゼロベクトルを normalize すると NaN になり、画面が黒や白で固まる。
// BAD
vec3 dir = normalize(toLight); // toLight が 0 だと NaN
// GOOD
float len = length(toLight);
vec3 dir = (len > 0.0) ? toLight / len : vec3(0.0, 1.0, 0.0);
5. pow(0.0, 0.0) も実装依存
WebGL の pow は負の底や 0 の負乗で結果が undefined。max(x, 0.0) 等で守る。
6. discard 多用は遅い
discard は早期 z 書き込み等の最適化を阻害する。
大量の discard(パーティクル・葉っぱ等)は普通に α 合成(transparent)に置き換える方が速いことも。
7. varying 名が vertex/fragment で違う
真っ黒になる典型バグ。リンクエラーが console に出るので確認。
8. texture ループ展開で重くなる
固定回数ループでも、中で texture サンプリングするとその回数だけ全部実行される。 ぼかしの半径を可変にする時は注意。
9. WebGL1 / WebGL2 の差
| ES 1.00(WebGL1) | ES 3.00(WebGL2) | |
|---|---|---|
texture | texture2D | texture |
| NPOT で wrap=Repeat | 不可 | 可 |
| 動的配列インデックス | 不可 | 可 |
| 動的ループ回数 | 不可 | 可 |
| 整数ビット演算 | 不可 | 可 |
| 3D テクスチャ | 不可 | 可 |
10. Three.js のシェーダ chunk と onBeforeCompile
既存マテリアルを書き換える onBeforeCompile は強力だが、Three.js 内部のシェーダ chunk 名を知らないと書けない。
chunk のリストは Three.js のソースコード(src/renderers/shaders/ShaderChunk/)参照。
デバッグ
1. 値を色として出す
GLSL には console.log が無い。色として可視化するのがデバッグの基本。
// 値を可視化
outColor = vec4(vec3(myValue), 1.0);
// 法線を可視化
outColor = vec4(N * 0.5 + 0.5, 1.0);
// UV を可視化
outColor = vec4(vUv, 0.0, 1.0);
// 距離を可視化
outColor = vec4(vec3(d * 2.0 + 0.5), 1.0);
// マイナスを可視化(負を赤、正を青)
outColor = vec4(max(-d, 0.0), 0.0, max(d, 0.0), 1.0);
2. シェーダエラーをコンソールで確認
Three.js はシェーダコンパイルエラーを console に出してくれる。 エラー行と内容が出るので、まず console を見る。
3. ホットリロード
Vite なら GLSL の変更は HMR で反映される。vite-plugin-glsl を使うと
.glsl / .vert / .frag ファイルを直接 import できて、HMR と#include も効く。
4. シェーダの中身を確認
Three.js の onBeforeCompile 後の最終シェーダコードは:
material.onBeforeCompile = (shader) => {
// ...
console.log("vertex:", shader.vertexShader)
console.log("fragment:", shader.fragmentShader)
}
5. Spector.js
Chrome/Firefox 拡張。1 フレーム分の WebGL コールを全部キャプチャして、各 draw call の入力・シェーダ・テクスチャを確認できる。 重いシェーダや謎の挙動を追う時に強力。
6. WebGL Inspector / RenderDoc
さらに低レベルなデバッグツール。GPU のステート・バッファ内容まで見られる。
シェーダのプロファイル
- fragment 数を減らす: 解像度を下げるのがいちばん効く
- 頂点数を減らす: メッシュのセグメント数を見直す
- r3f-perf: R3F なら draw call / triangles / textures が見える
- Chrome DevTools の Performance: long task や GPU 使用率
- 3D の重さは GPU bound か CPU bound か: シーン全体を
scale={0.1}して GPU が軽くなるか試す
本番で気にすべきブラウザ差
- iOS Safari: モバイル GPU で精度・性能が独特。必ず実機で確認
- Android の中位機種: highp が遅いケース、float16 で書く価値あり
- Intel HD グラフィックス: 古い PC では特定 extension が無いことも
- Firefox: Chrome と微妙に違う warning 出る場合あり
セキュリティ・配布
- シェーダコードはクライアントに丸見え。秘匿はできない
- 大きなシェーダはコンパイル時間がかかる。本番ではwarmup passを入れて初回フリーズを防ぐ
- GLSL 文字列は通常の JS 文字列なので、ビルド時に minify してファイルサイズを減らせる(
vite-plugin-glsl等)
判断ガイド
| 症状 | 疑い |
|---|---|
| 真っ黒 | outColor 書き忘れ、varying 名ミスマッチ、コンパイルエラー |
| 真っ白 | 値がオーバー(例: color = vec3(100))、tone mapping |
| NaN(チラつき・固まる) | normalize(0)、0/0、pow の負底 |
| 遅い | 解像度・分岐・texture サンプリング数・discard 多用 |
| モバイルだけ崩れる | precision 不足、float16 の精度切れ |
| iOS だけ遅い | highp の遅さ、独特の最適化挙動 |
| 色が薄い・濃い | sRGB / リニアの取り違え、texture.colorSpace |
| UV ずれ | geometry の UV、flipY、vUv vs gl_FragCoord |
1. 模写する: Shadertoy で気に入った作品をコピーして手で動かす
2. パラメータをいじる: 数値を変えて「何が変わるか」を見る
3. 自分の表現を作る: パターン → グラデ → 動き → 3D 拡張 と段階的に
4. 既存の math を覚える: SDF / noise / FBM / palette は毎回コピペでよい。仕組みは後から