WebGLシェーダーで屈折ガラスを実装する|Liquid GlassをGLSLで書く


Liquid Glass(リキッドグラス)とは、Appleが2025年6月に発表した新しいデザインで使われている、背景を屈折させて映し込む半透明のガラスの素材です。Webで同じ見た目を作るには、背景を一度画像として読み込み、ガラスの形に合わせてピクセルをずらして描き直す必要があり、その処理はWebGLのシェーダーで書くのが確実です。

CSSの backdrop-filter でもすりガラスのようなぼかしは作れますが、ガラスの縁で背景がぐにゃりと曲がる「屈折」は作れません。屈折を自分で書こうとすると、シェーダーの書き方と、形の計算の両方が必要になります。

この記事では、ライブラリを使わない素のWebGL 2で、屈折・ぼかし・色収差・縁の光の4つを1つずつ足しながら、マウスに付いて動くガラスの板を作ります。サンプルは筆者がChromeで動作を確かめたものです。公式ドキュメントの内容は2026年11月1日に確認しました。

WebGLのシェーダーで作ったガラスの板。グラデーションの背景の上で、縁の近くの白い縦線が内側に曲がり、中がぼけている
この記事のサンプル。ガラスの縁で背景の縦線が曲がり(屈折)、中はぼけて、縁にわずかな色ずれと光が出ている

動くサンプルは次のCodePenで試せます。プレビューの上でマウスを動かすと、ガラスの板が追いかけてきます。

差分チェックツールで効率UPお手本コードと自分のコードを比較して、違いを一目で確認できます。練習前にブックマークしておくと便利です。
Diff Checkerを開く →

See the Pen WebGLで屈折ガラス(Liquid Glass) by masakazuimai (@masakazuimai) on CodePen.


Liquid Glassとは:屈折・ぼかし・縁の光でできている

Liquid Glassの見た目は、「背景が曲がって見える」「背景がぼける」「縁が光る」の3つに分けて考えると、1つずつ実装できます。Appleの発表では、周囲を反射・屈折する半透明の素材として紹介されています。この記事では、これに色の縁取り(色収差)を足した4つを作ります。

見た目起きていることシェーダーでやること
縁で背景が曲がる光の屈折縁に近いほど、背景を読む位置を大きくずらす
中がぼける曇ったガラスで光が散るまわりのピクセルを混ぜる
境目に色がにじむ色ごとに曲がり方が違う(色収差)赤・緑・青を少しずつ違う位置から読む
縁が光る表面での光の反射面の向きと光の向きから明るさを足す

CSSのbackdrop-filterでは屈折を作れない理由

backdrop-filter の blur() や brightness() などのフィルター関数は、要素の後ろをぼかしたり色を変えたりできますが、後ろのピクセルの位置をずらすことはできません。MDNのbackdrop-filterにあるとおり、url() でSVGのフィルター(feDisplacementMap)を指定すればピクセルをずらせますが、2026年11月時点で効くのは実質Chromium系のブラウザだけで、SafariとFirefoxでは無視されます。どのブラウザでも同じ屈折を出したいなら、WebGLのシェーダーが確実です。

方法屈折ぼかし対応ブラウザ向いている場面
CSSの backdrop-filter(フィルター関数)作れない作れる主要ブラウザすりガラスのカードを軽く作りたい
CSS+SVGのフィルター(url())作れる(ずらす量は画像で指定)作れる実質Chromium系のみライブラリなしで屈折を試したい
WebGLのシェーダー(この記事)作れる(形に合わせて計算)作れるWebGL 2に対応した主要ブラウザ形・動き・強さを自由に変えたい

CSSだけで作る方法はiOS風「Liquid Glass」をWebで再現する方法、SVGのフィルターでずらす方法はCSSだけで作るスクロール追従リキッドガラスで解説しています。


仕組みの全体像:背景を読み込み、ずらして描き直す

WebGLでは、背景をテクスチャ(シェーダーから読める画像)として持ち、画面の1ピクセルごとに「背景のどこを読むか」を決めて描き直します。ガラスの外では同じ場所を読み、ガラスの中だけ少しずらした場所を読むと、屈折して見えます。

  1. 背景を画像(テクスチャ)としてWebGLに渡す
  2. 画面いっぱいの四角形を1枚描き、1ピクセルごとにフラグメントシェーダーを動かす
  3. ピクセルがガラスの外なら、背景をそのまま描く
  4. ガラスの中なら、縁からの距離に応じてずらした場所の背景を、ぼかして描く
  5. 色ずれと縁の光を足す

ガラスの形は、角の丸い四角形を「その点が形の縁からどれだけ離れているか」を返す関数(距離関数)で表します。距離がマイナスなら中、プラスなら外で、0に近いほど縁に近いと分かるので、屈折の強さを決めるのに使えます。


最小構成:素のWebGLでcanvasと背景を用意する

HTMLは canvas を1つ置くだけです。WebGL 2のコンテキストを取り、シェーダーを組み立て、画面いっぱいの四角形を用意します。WebGL 2の機能はMDNのWebGL2RenderingContextで確認できます。

<canvas id="glass"></canvas>
<script src="glass.js"></script>
body {
  margin: 0;
}

#glass {
  display: block;
  width: 100%;
  height: 100vh;
}

シェーダーを組み立てて、四角形を用意する

頂点シェーダーは、画面いっぱいの四角形の4つの角を置き、各ピクセルに0〜1の座標(vUv)を渡すだけです。compile でコンパイルに失敗したときはエラーを投げて、シェーダーの書き間違いにすぐ気づけるようにしています。

const canvas = document.getElementById('glass');
const gl = canvas.getContext('webgl2');

const vertSrc = `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main() {
  vUv = aPos * 0.5 + 0.5;
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

function compile(type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(s));
  }
  return s;
}

const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertSrc));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragSrc));
gl.linkProgram(program);
gl.useProgram(program);

const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

const u = name => gl.getUniformLocation(program, name);

背景をテクスチャにする

サンプルでは、背景を2Dのcanvasにその場で描き、それをテクスチャとして渡しています。画像ファイルを使う場合も手順は同じです。UNPACK_FLIP_Y_WEBGL は、画像の上下をWebGLの座標に合わせるための指定です。テクスチャの扱いはMDNのチュートリアルに詳しくまとまっています。

function makeBackground(w, h) {
  const c = document.createElement('canvas');
  c.width = w;
  c.height = h;
  const ctx = c.getContext('2d');
  const g = ctx.createLinearGradient(0, 0, w, h);
  g.addColorStop(0, '#0f4c81');
  g.addColorStop(1, '#ff7a59');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);
  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  ctx.font = `bold ${Math.round(Math.min(h / 6, w / 10))}px sans-serif`;
  ctx.fillText('CodeQuest.work', w * 0.08, h * 0.55);
  for (let x = 0; x < w; x += 40) {
    ctx.fillRect(x, h * 0.75, 4, h * 0.25);
  }
  return c;
}

const tex = gl.createTexture();
function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = Math.round(canvas.clientWidth * dpr);
  canvas.height = Math.round(canvas.clientHeight * dpr);
  gl.viewport(0, 0, canvas.width, canvas.height);

  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, makeBackground(canvas.width, canvas.height));
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  gl.uniform2f(u('uRes'), canvas.width, canvas.height);
  gl.uniform2f(u('uSize'), 260 * dpr, 160 * dpr);
  gl.uniform1f(u('uRadius'), 32 * dpr);
  target.x = canvas.width / 2;
  target.y = canvas.height / 2;
}

屈折:形の縁に近いほど強く背景をずらす

フラグメントシェーダーは、次の宣言から始めます。受け取る値(背景のテクスチャ・画面の大きさ・ガラスの位置と大きさ・角の丸み)と、屈折やぼかしの強さを決める定数です。この後の節で紹介する関数と main を、この下に順に続けて1つのシェーダーにします。

#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;

uniform sampler2D uTex;
uniform vec2 uRes;
uniform vec2 uCenter;
uniform vec2 uSize;
uniform float uRadius;

const float REFRACT = 40.0;
const float EDGE = 24.0;
const float BLUR = 6.0;
const float ABERRATION = 1.5;

屈折は、距離関数で縁からの距離を求め、縁に近いほど大きく、縁と垂直な向き(法線)に背景をずらして作ります。角の丸い四角形の距離関数は、Inigo Quilez氏の2D距離関数の解説にある式をもとにしています。

float sdRoundBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}

法線は、距離関数を1ピクセル左右・上下にずらした値の差から求めます。smoothstep(-EDGE, 0.0, d) は縁の内側 EDGE ピクセルで0から1へ上がる値で、2乗すると縁のすぐ近くだけが強く曲がり、中央はほとんど曲がらなくなります。

vec2 e = vec2(1.0, 0.0);
vec2 n = normalize(vec2(
  sdRoundBox(p + e.xy, uSize * 0.5, uRadius) - sdRoundBox(p - e.xy, uSize * 0.5, uRadius),
  sdRoundBox(p + e.yx, uSize * 0.5, uRadius) - sdRoundBox(p - e.yx, uSize * 0.5, uRadius)
));

float edge = smoothstep(-EDGE, 0.0, d);
vec2 offset = -n * edge * edge * REFRACT / uRes;

REFRACT を大きくすると曲がり方が強くなり、EDGE を大きくすると曲がる範囲が内側まで広がります。最初は REFRACT を0にして形だけを確かめ、少しずつ上げると調整しやすくなります。


ぼかし:まわりのピクセルを混ぜる

ぼかしは、読む位置のまわりのピクセルをいくつか読み、平均を取って作ります。サンプルでは縦横5×5の25か所を読んでいます。

vec3 blurSample(vec2 uv) {
  vec2 px = BLUR / uRes;
  vec3 sum = vec3(0.0);
  for (int x = -2; x <= 2; x++) {
    for (int y = -2; y <= 2; y++) {
      sum += texture(uTex, uv + vec2(x, y) * px * 0.5).rgb;
    }
  }
  return sum / 25.0;
}

読む数を増やすほど滑らかになりますが、1ピクセルごとに読む回数が増えるので重くなります。5×5で、画面の幅いっぱいのcanvasでも1ピクセルあたり25回、後で説明する色収差のために3色を別々に読むので75回読むことになります。重く感じたら、読む数を3×3に減らすか、ぼかしの幅 BLUR を大きくして少ない数で広く混ぜます。


縁の光:面の向きから光の反射を足す

縁の光は、法線と光の向きの内積で「光が当たっている縁」を求め、そこだけ明るくして作ります。サンプルでは左上から光が来る想定で、左上の縁が明るく光ります。

float light = max(dot(n, normalize(vec2(-0.6, 0.8))), 0.0);
col += pow(edge, 3.0) * light * 0.35;
col = mix(col, vec3(1.0), 0.06);

pow(edge, 3.0) で縁のごく近くだけに光を絞っています。最後の mix は、ガラス全体をわずかに白っぽくして、背景との境目を見やすくするためのものです。


色収差:赤・緑・青を少しずつずらす

色収差は、赤・緑・青の3色を、法線の向きに少しずつ違う位置から読んで作ります。本物のガラスでも色によって曲がり方が違うため、背景の線や文字の境目に虹のような色のにじみが出ます。サンプルではガラスの中全体を同じ量だけずらしているので、縁だけに絞りたい場合は ca にも edge を掛けます。

vec2 ca = n * ABERRATION / uRes;
vec3 col;
col.r = blurSample(vUv + offset + ca).r;
col.g = blurSample(vUv + offset).g;
col.b = blurSample(vUv + offset - ca).b;

ABERRATION は1〜2ピクセル程度にとどめると、にじみが目立ちすぎません。ここまでの4つをまとめたフラグメントシェーダーの本体は次のとおりです。

void main() {
  vec2 frag = vUv * uRes;
  vec2 p = frag - uCenter;
  float d = sdRoundBox(p, uSize * 0.5, uRadius);

  if (d > 0.0) {
    outColor = texture(uTex, vUv);
    return;
  }

  vec2 e = vec2(1.0, 0.0);
  vec2 n = normalize(vec2(
    sdRoundBox(p + e.xy, uSize * 0.5, uRadius) - sdRoundBox(p - e.xy, uSize * 0.5, uRadius),
    sdRoundBox(p + e.yx, uSize * 0.5, uRadius) - sdRoundBox(p - e.yx, uSize * 0.5, uRadius)
  ));

  float edge = smoothstep(-EDGE, 0.0, d);
  vec2 offset = -n * edge * edge * REFRACT / uRes;

  vec2 ca = n * ABERRATION / uRes;
  vec3 col;
  col.r = blurSample(vUv + offset + ca).r;
  col.g = blurSample(vUv + offset).g;
  col.b = blurSample(vUv + offset - ca).b;

  float light = max(dot(n, normalize(vec2(-0.6, 0.8))), 0.0);
  col += pow(edge, 3.0) * light * 0.35;
  col = mix(col, vec3(1.0), 0.06);

  outColor = vec4(col, 1.0);
}

マウスに合わせてガラスを動かす

マウスの位置を目標にして、毎フレーム少しずつ近づけると、ガラスがなめらかに追いかけてきます。WebGLの座標は下から上に増えるので、マウスのY座標は下端からの距離に直して渡します。

const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

canvas.addEventListener('pointermove', (e) => {
  const rect = canvas.getBoundingClientRect();
  const dpr = canvas.width / rect.width;
  target.x = (e.clientX - rect.left) * dpr;
  target.y = (rect.bottom - e.clientY) * dpr;
});

function draw() {
  const k = reduceMotion ? 1 : 0.12;
  pos.x += (target.x - pos.x) * k;
  pos.y += (target.y - pos.y) * k;
  gl.uniform2f(u('uCenter'), pos.x, pos.y);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  requestAnimationFrame(draw);
}

window.addEventListener('resize', resize);
resize();
pos.x = target.x;
pos.y = target.y;
draw();

0.12 は1フレームで目標に近づく割合で、小さいほどゆっくり追いかけます。動きを減らす設定の人には、この割合を1にして、すぐに目標の位置へ移動させています。


重さの対策:解像度と描き直しの回数を抑える

シェーダーの重さは「ピクセルの数 × 1ピクセルで読む回数」で決まるので、まずはピクセルの数を抑えます。

対策書き方効き目
高解像度の画面でも最大2倍までにするMath.min(devicePixelRatio, 2)3倍の画面で、ピクセルの数を半分以下にできる
ぼかしで読む数を減らす5×5を3×3にする(ループを-1〜1、割る数を9.0に)1ピクセルで読む回数が約3分の1になる
ガラスの外は1回だけ読むd > 0.0 なら背景をそのまま描いて終えるガラスの外の重さがほぼなくなる
動いていないときは描かない目標との差が小さければ draw を止め、pointermove で再開する止まっている間の負荷がなくなる

サンプルは分かりやすさを優先して毎フレーム描き直していますが、実際のページに置くときは、最後の「動いていないときは描かない」を入れると、ノートPCのファンが回りにくくなります。


検証ポイントとつまずきやすい点

作ったら、次の4点を確かめます。

  • コンソールにシェーダーのエラーが出ていない(compile がエラーを投げていない)
  • ガラスの縁の近くで、背景の線が内側に曲がって見える
  • ガラスの位置が、マウスの位置と上下逆になっていない
  • 高解像度の画面でも、ガラスの大きさと位置がずれていない

つまずき1:画像の背景を使うと何も表示されない

別のドメインの画像をテクスチャにすると、ブラウザの安全のための制限(CORS)で読み込めません。画像を同じドメインに置くか、配信元が許可している場合は img.crossOrigin = 'anonymous' を付けてから読み込みます。

つまずき2:背景やマウスの位置が上下逆になる

画像は上から下へ、WebGLの座標は下から上へ数えます。テクスチャには UNPACK_FLIP_Y_WEBGL を付け、マウスの座標は rect.bottom - e.clientY のように下端から測ります。

つまずき3:高解像度の画面でぼやける・位置がずれる

canvasの見た目の大きさ(CSS)と、描く大きさ(width / height)は別物です。描く大きさを devicePixelRatio 倍にし、ガラスの大きさやマウスの座標にも同じ倍率を掛けます。サンプルの定数(REFRACT や EDGE など)はピクセル単位なので、画面の倍率が違っても曲がり方をそろえたい場合は、定数にも同じ倍率を掛けます。


自分で書くか、ツールで作るか

形や動きを自分のサイトに合わせて細かく変えたいなら、この記事のようにシェーダーを書きます。見た目を調整して早く使いたいだけなら、ツールで作ったほうが早く確実です。

こんなときおすすめ
ガラスの形を独自にしたい(円・波形など)シェーダーを書く(距離関数を差し替える)
スクロールや他の演出と連動させたいシェーダーを書く
見た目を調整して、すぐにコードを貼りたいツールで作る

ツールで作る方法はApple風の屈折ガラスをWebGLで生成するLiquid Glassジェネレーターで、スライダーで屈折やぼかしの強さを決めてコードを書き出せます。同じようにシェーダーで質感を作る例はWebGLで作るリキッドメタルボタンやマウスストーカー炎アニメーション|WebGLで作る流体エフェクト「火華」でも紹介しています。


FAQ

WebGLで屈折ガラスを実装するときに、よくある質問をまとめました。

Q. CSSだけでLiquid Glassの屈折は作れませんか?

backdrop-filterだけでは作れません。ぼかしや明るさは変えられますが、背景の位置をずらす機能がないためです。SVGのフィルター(feDisplacementMap)を組み合わせればCSSとSVGで屈折させる方法もありますが、形や強さを自由に変えたいならWebGLのシェーダーが向いています。

Q. WebGL 1でも動きますか?

このサンプルはWebGL 2の書き方なので、そのままでは動きません。WebGL 1で動かす場合は、getContextをwebglに変え、両方のシェーダーの #version 300 es の行を消し、頂点シェーダーのinをattribute・outをvaryingに、フラグメントシェーダーのinをvaryingに変え、out vec4 outColor の宣言を消してoutColorをgl_FragColorに、textureをtexture2Dに書き換えます。

Q. スマホでも使えますか?

使えます。ただし、ぼかしで読む回数が多いと重くなるので、devicePixelRatioの上限を下げる、ぼかしの読む数を減らす、動いていないときは描かない、の3つを入れておくと安心です。

Q. ガラスの中に文字やボタンを置けますか?

置けます。canvasの上にHTMLの要素を重ねて配置します。シェーダーが描くのは背景の屈折だけなので、文字やボタンはCSSで普通に作り、ガラスの位置に合わせて動かします。

Q. 画像ではなく、実際のページを背景にできますか?

表示中のページそのものを直接テクスチャにすることは、今のブラウザでは標準ではできません。背景に使いたい画像や動画をテクスチャとして読み込むか、背景をcanvasに描いてから渡します。


まとめ

WebGLでLiquid Glassを作るときは、背景をテクスチャにして、距離関数で求めた縁からの距離に応じてピクセルをずらすのが基本です。

  • 屈折:縁に近いほど、法線の向きに背景をずらす
  • ぼかし:まわりのピクセルを混ぜる(数が多いほど重い)
  • 色収差:赤・緑・青を少しずつ違う位置から読む
  • 縁の光:法線と光の向きの内積で、縁だけ明るくする
  • 重さは「ピクセルの数 × 読む回数」で決まるので、解像度から抑える

関連記事