关于 webgl-laser
webgl-laser 是用于为页面添加克制型动态 WebGL 激光背景的设计技能,涵盖实现与调校,并强调匹配品牌强调色且不影响前景内容阅读。
主要功能
- 01全屏 WebGL 画布背景
- 02细窄白色核心光束与彩色光晕
- 03匹配强调色的烟雾与辉光
- 04随窗口调整分辨率和时间变量
- 05光束与性能调校建议
适用场景
- 01在页面内容后添加激光效果
- 02使光束颜色匹配产品强调色
- 03调节烟雾密度和光束宽度
- 04避免动态背景干扰前景内容
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mengto/ skills --skill webgl-laser安装后在 Claude Code 中输入/webgl-laser确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
demo/index.htmlPROMPT.mdsource.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 褪色难辨。
评论
支持 Markdown