mcpField
W
webgl-laser

webgl-laser

github.com/mengtoデザイン・UI7.1k1.5k 回インストール3 日前に更新
リポジトリ

webgl-laser について

webgl-laserは、ページに控えめなアニメーション付きWebGLレーザー背景を加えるデザインスキルです。実装と調整の指針を示し、ブランドカラーとの調和と前景コンテンツの読みやすさを重視します。

主な機能

  1. 01全画面WebGLキャンバス背景
  2. 02細い白色コアとカラーの光輪
  3. 03アクセントカラーに沿う煙と発光
  4. 04リサイズ対応の解像度と時間変数
  5. 05ビームと性能の調整指針

活用シーン

  1. 01ページコンテンツの背後へのレーザー効果追加
  2. 02製品のアクセントカラーに合わせたビーム色
  3. 03煙の密度とビーム幅の調整
  4. 04動く背景による前景への干渉防止

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mengto/skills --skill webgl-laser

インストール後、Claude Code で/webgl-laserを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

demo/
index.html
PROMPT.md
source.json

詳細

WebGL レーザー

適用範囲

  • レーザーの背景エフェクトにのみ適用します。
  • DOM の背面に固定の全画面キャンバスを配置します。
  • キャンバスに pointer-events: none を設定します。
  • ページのコンテンツを、より高いスタッキングコンテキストに配置します。
  • ハローと煙の色を、ページのメインカラーまたは最も強いアクセントカラーに合わせます。

ビジュアルの目標

  • 細い垂直ビーム:白熱した鮮明な内側のコアと、細い色付きのハロー。
  • 大気感のある煙:ビームの周囲を中心に、柔らかな雲状のゆらぎを作ります。
  • 暗くシネマティックな空間:控えめでブランドカラーに沿い、コンテンツの背後でも読みやすくします。
  • ゆっくりとしたパルス:グローが穏やかに呼吸するように変化し、激しいちらつきや色の循環は避けます。
  • 光の刃のような印象:細く精密にし、太いネオンの柱にはしません。

レイヤー構成

<canvas class="laser-canvas" data-webgl-laser></canvas>
<main class="page-content">
  ...
</main>
.laser-canvas {
  position: fixed;
  inset: 0;
  z-index: 0;
  width: 100vw;
  height: 100vh;
  pointer-events: none;
}

.page-content {
  position: relative;
  z-index: 1;
}

ブランドカラー

プロダクトのアクセントカラーを基準色として使用します。シェーダーではコアをほぼ白に保ち、この色からハローと煙の色を導きます。

function hexToRgb01(hex) {
  const clean = hex.replace("#", "").trim();
  const value = clean.length === 3
    ? clean.split("").map((char) => char + char).join("")
    : clean;

  return [
    parseInt(value.slice(0, 2), 16) / 255,
    parseInt(value.slice(2, 4), 16) / 255,
    parseInt(value.slice(4, 6), 16) / 255,
  ];
}

const accent = getComputedStyle(document.documentElement)
  .getPropertyValue("--brand-accent")
  .trim() || "#ff4d8d";

WebGL の直接セットアップ

アクティブなファイルですでに別のレンダラーを使用していない限り、全画面のクアッドを使った WebGL の直接実装を優先します。

const laserVertexShader = `
attribute vec2 a_position;
varying vec2 v_uv;

void main() {
  v_uv = a_position * 0.5 + 0.5;
  gl_Position = vec4(a_position, 0.0, 1.0);
}
`;

const laserFragmentShader = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_color;
uniform float u_xOffset;
uniform float u_coreWidth;
uniform float u_glowWidth;
uniform float u_smokeDensity;

varying vec2 v_uv;

float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);

  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));

  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;

  for (int i = 0; i < 5; i++) {
    value += amplitude * noise(p);
    p *= 2.02;
    amplitude *= 0.5;
  }

  return value;
}

void main() {
  vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0);
  vec2 p = (v_uv - 0.5) * aspect;
  float x = p.x - u_xOffset;
  float distanceToBeam = abs(x);

  float core = exp(-pow(distanceToBeam / u_coreWidth, 2.0));
  float glow = exp(-pow(distanceToBeam / u_glowWidth, 1.45));
  float scatter = exp(-pow(distanceToBeam / (u_glowWidth * 5.5), 1.25));
  float pulse = 0.9 + 0.1 * sin(u_time * 1.15);

  vec2 fogUv = p * 3.1 + vec2(0.0, -u_time * 0.035);
  fogUv.x += sin(p.y * 3.5 + u_time * 0.11) * 0.14;
  float fogBase = fbm(fogUv);
  float fogFine = fbm(p * 8.0 + vec2(sin(u_time * 0.07) * 0.35, u_time * 0.05));
  float fog = smoothstep(0.30, 0.86, fogBase * 0.72 + fogFine * 0.28);
  float smoke = fog * scatter * u_smokeDensity;

  vec3 brand = clamp(u_color, 0.0, 1.0);
  vec3 haloColor = mix(brand, vec3(1.0), 0.16);
  vec3 smokeColor = mix(brand, vec3(0.55), 0.28) * 0.55;
  vec3 hotCore = vec3(1.0, 0.96, 0.90);

  vec3 color = vec3(0.006, 0.007, 0.010);
  color += smokeColor * smoke;
  color += haloColor * glow * 0.46 * pulse;
  color += hotCore * core * 1.35;

  float vignette = smoothstep(1.25, 0.18, length(p));
  color *= vignette;

  float alpha = clamp(smoke * 0.72 + glow * 0.68 + core, 0.0, 1.0);
  gl_FragColor = vec4(color, alpha);
}
`;

初期化処理

リサイズ時に u_resolution を同期し、u_time を使ってアニメーションさせます。

function createShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(shader) || "Shader compile failed");
  }

  return shader;
}

function createProgram(gl, vertexSource, fragmentSource) {
  const program = gl.createProgram();
  gl.attachShader(program, createShader(gl, gl.VERTEX_SHADER, vertexSource));
  gl.attachShader(program, createShader(gl, gl.FRAGMENT_SHADER, fragmentSource));
  gl.linkProgram(program);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(program) || "Program link failed");
  }

  return program;
}

function initWebGLLaser(canvas, options = {}) {
  if (!canvas) return () => {};

  const gl = canvas.getContext("webgl", {
    alpha: true,
    antialias: false,
    premultipliedAlpha: false,
  });

  if (!gl) return () => {};

  const program = createProgram(gl, laserVertexShader, laserFragmentShader);
  const positionBuffer = gl.createBuffer();
  const positions = new Float32Array([
    -1, -1,
     1, -1,
    -1,  1,
    -1,  1,
     1, -1,
     1,  1,
  ]);

  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
  gl.useProgram(program);

  const positionLocation = gl.getAttribLocation(program, "a_position");
  const uniforms = {
    resolution: gl.getUniformLocation(program, "u_resolution"),
    time: gl.getUniformLocation(program, "u_time"),
    color: gl.getUniformLocation(program, "u_color"),
    xOffset: gl.getUniformLocation(program, "u_xOffset"),
    coreWidth: gl.getUniformLocation(program, "u_coreWidth"),
    glowWidth: gl.getUniformLocation(program, "u_glowWidth"),
    smokeDensity: gl.getUniformLocation(program, "u_smokeDensity"),
  };

  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

  const color = options.color || hexToRgb01(accent);
  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  let width = 1;
  let height = 1;
  let rafId = 0;

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr || 1.5);
    width = Math.max(1, window.innerWidth);
    height = Math.max(1, window.innerHeight);
    canvas.width = Math.floor(width * dpr);
    canvas.height = Math.floor(height * dpr);
    gl.viewport(0, 0, canvas.width, canvas.height);
  }

  function render(time = 0) {
    gl.useProgram(program);
    gl.uniform2f(uniforms.resolution, canvas.width, canvas.height);
    gl.uniform1f(uniforms.time, time * 0.001);
    gl.uniform3f(uniforms.color, color[0], color[1], color[2]);
    gl.uniform1f(uniforms.xOffset, options.xOffset || 0.0);
    gl.uniform1f(uniforms.coreWidth, options.coreWidth || 0.0045);
    gl.uniform1f(uniforms.glowWidth, options.glowWidth || 0.035);
    gl.uniform1f(uniforms.smokeDensity, options.smokeDensity || 0.52);

    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    if (!reduceMotion) rafId = requestAnimationFrame(render);
  }

  function handleResize() {
    resize();
    render();
  }

  resize();
  render();
  window.addEventListener("resize", handleResize);

  return () => {
    cancelAnimationFrame(rafId);
    window.removeEventListener("resize", handleResize);
    gl.deleteBuffer(positionBuffer);
    gl.deleteProgram(program);
  };
}

const cleanupLaser = initWebGLLaser(document.querySelector("[data-webgl-laser]"), {
  color: hexToRgb01(accent),
  xOffset: 0.0,
  coreWidth: 0.0045,
  glowWidth: 0.035,
  smokeDensity: 0.52,
  maxDpr: 1.5,
});

調整項目

  • ビームの位置:xOffset を調整します。アスペクト比を考慮した中央基準の UV 空間内に保ちます。
  • ビームの太さ:coreWidth と glowWidth を個別に調整し、コアは極めて細く保ちます。
  • 色:ブランドのアクセントカラーから color を導き、シェーダー内でハローと煙を和らげます。
  • 煙の密度:smokeDensity、FBM のスケール、ドリフト速度、散乱幅、エッジの減衰を調整します。
  • パフォーマンス:ビジュアル構成を変更する前に、FBM のオクターブ数を減らすか、maxDpr に上限を設定します。

デザイン上のルール

  • ビームの最も高温なコアは、白に近い色を保ちます。
  • ハローと霧には、デザインのメインカラーまたは最も強いアクセントカラーを使用します。
  • 煙はビームの近くで広がり、外側に向かって消散します。
  • パルスの影響はグローのみに限定し、急速なちらつきは避けます。
  • グロー、霞、シネマティックな演出よりも、コンテンツの読みやすさを優先します。

避けること

  • デザインで別のメインカラーを使っているのに、青をハードコードすること。
  • ビームを太くしすぎて、光るバーのように見せること。
  • ビームの周囲に集中していない、ありきたりな全画面の霧。
  • エフェクトを Three.js のシーン、パーティクルの爆発、多色ネオンの背景に変えること。
  • キャンバスにポインターイベントを捕捉させること。
  • 前景の UI を覆い隠すほど濃い霧や極端なグロー。

コメント