### [OpenShaders:让炫酷 Shader 真正走进网站开发](https://www.huiui.net/article/623.html) **Published:** 2026-10-03T10:40:06 **Author:** 小汇Ai **Excerpt:** 前言 现在越来越多优秀的网站开始使用动态背景、粒子效果和光影动画来提升视觉体验,而这些效果背后往往离不开 Sh… ## 前言 现在越来越多优秀的网站开始使用动态背景、粒子效果和光影动画来提升视觉体验,而这些效果背后往往离不开 Shader 技术。 不过对于很多开发者来说,Shader 的学习门槛并不低,想找到高质量、可直接使用的效果也并不容易。 今天给大家推荐的 OpenShaders,就是一个专门围绕 Shader 构建的开源平台,希望让更多优秀的 Shader 作品能够被发现、复用和应用到真实项目中。 const FIELD\_SHADER = \`struct Uniforms { resolution: vec2f, time: f32, lightMode: f32, darkBackground: vec3f, pixelRatio: f32, lightBackground: vec3f, } @group(0) @binding(0) var u: Uniforms; const HUE: f32 = 0.0185936615; const HUE\_SPREAD: f32 = -0.493577391; const HUE\_TRAVEL: f32 = 2.16966629; const CHROMA: f32 = 0.109143272; const LIGHTNESS: f32 = 0.514947474; const COLOUR\_CYCLE: f32 = 0.155899912; const THETA: f32 = 2.11782527; const SHEAR: f32 = 0.960982025; const SHRINK: f32 = 0.948751271; const LAYERS: f32 = 73.0; const WARP\_FREQ\_X: f32 = 0.342528999; const WARP\_FREQ\_Y: f32 = 2.04643106; const WARP\_AMP\_X: f32 = 0.120912209; const WARP\_AMP\_Y: f32 = 0.0313694254; const ASPECT\_X: f32 = 1.71845365; const ASPECT\_Y: f32 = 0.197083965; const OFFSET\_X: f32 = 0.374808848; const OFFSET\_Y: f32 = -0.0491089672; const TILT: f32 = -2.28629518; const ZOOM: f32 = 0.921809554; const CENTRE\_X: f32 = -0.719574153; const CENTRE\_Y: f32 = 0.343013197; const GLOW\_SIZE: f32 = 0.00400692178; const FALLOFF: f32 = 0.444731891; const VIGNETTE: f32 = 0.0558086932; const FLOW\_SPEED: f32 = 0.526863754; const FLOW\_DIRECTION: f32 = 1.0; const BREATH\_RATE: f32 = 0.574168861; const BREATH\_AMOUNT: f32 = 0.0540704802; const PHASE: f32 = 1.66300285; const ECHO: f32 = 0.0; const ECHO\_SHIFT: f32 = 0.145458728; const SOFTNESS: f32 = 0.00266873394; const LIGHT\_SWING: f32 = 0.160995021; @vertex fn vertexMain(@builtin(vertex\_index) index: u32) -> @builtin(position) vec4f { let position = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); return vec4f(position \* 2.0 - 1.0, 0.0, 1.0); } const TAU: f32 = 6.28318530718; fn oklchToLinear(L: f32, C: f32, h: f32) -> vec3f { let a = C \* cos(h); let b = C \* sin(h); let l\_ = L + 0.3963377774 \* a + 0.2158037573 \* b; let m\_ = L - 0.1055613458 \* a - 0.0638541728 \* b; let s\_ = L - 0.0894841775 \* a - 1.2914855480 \* b; var lms = vec3f(l\_, m\_, s\_); lms = lms \* lms \* lms; return mat3x3f(4.0767416621, -1.2684380046, -0.0041960863, -3.3077115913, 2.6097574011, -0.7034186147, 0.2309699292, -0.3413193965, 1.7076147010) \* lms; } fn fmod(x: f32, y: f32) -> f32 { return x - y \* floor(x / y); } fn blueNoise(p: vec2f, frame: f32) -> f32 { let q = p + 5.588238 \* fmod(frame, 64.0); return fract(52.9829189 \* fract(0.06711056 \* q.x + 0.00583715 \* q.y)); } @fragment fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f { let R = u.resolution; let frag = vec2f(position.x, R.y - position.y); let pos = (frag - 0.5 \* R) / R.y; let t = u.time \* FLOW\_SPEED \* FLOW\_DIRECTION + PHASE; let breath = (-sin(u.time \* BREATH\_RATE \* 1.5) + sin(u.time \* BREATH\_RATE + 1.0)) \* 0.25 + 0.5; var p = (pos - vec2f(CENTRE\_X, CENTRE\_Y)) \* (ZOOM - breath \* BREATH\_AMOUNT); let ct = cos(TILT); let st = sin(TILT); p = mat2x2f(ct, st, -st, ct) \* p; let fold = mat2x2f(cos(THETA), sin(THETA), -SHEAR, cos(THETA)); let hue0 = HUE \* TAU; let hue1 = hue0 + HUE\_SPREAD \* TAU; var color = vec3f(0.0); for (var i: f32 = 1.0; i <= 96.0; i += 1.0) { if (i > LAYERS) { break; } p.x += -sin(p.y \* WARP\_FREQ\_X + t + i \* 0.007) \* WARP\_AMP\_X; p.y += -sin(p.x \* WARP\_FREQ\_Y - t + i \* 0.02) \* WARP\_AMP\_Y; p = fold \* p \* SHRINK; let q = p - vec2f(OFFSET\_X + breath \* 0.1, OFFSET\_Y); let s = vec2f(q.x \* ASPECT\_X, q.y \* ASPECT\_Y); var glow = GLOW\_SIZE / (dot(s, s) + SOFTNESS); if (ECHO > 0.0) { let e = vec2f((q.x - ECHO\_SHIFT) \* ASPECT\_X, s.y); glow += ECHO \* GLOW\_SIZE / (dot(e, e) + SOFTNESS); } glow \*= 0.25 + breath \* 0.4; let r = length(p); let k = sin(i \* COLOUR\_CYCLE + t \* 1.2 + r \* HUE\_TRAVEL) \* 0.5 + 0.5; let tint = clamp(oklchToLinear(LIGHTNESS + LIGHT\_SWING \* k, CHROMA \* (0.75 + 0.35 \* k), mix(hue0, hue1, k)), vec3f(0.0), vec3f(1.0)); color += glow \* tint \* exp2(-r \* FALLOFF); } let x = max(color, vec3f(0.0)); color = (x \* (2.51 \* x + 0.03)) / (x \* (2.43 \* x + 0.59) + 0.14); color = pow(clamp(color, vec3f(0.0), vec3f(1.0)), vec3f(0.85, 0.92, 0.98)); let edge = smoothstep(0.5, 1.6, length(pos)); color \*= 1.0 - edge \* VIGNETTE; let dark = u.darkBackground + color \* (1.0 - u.darkBackground); let strength = max(color.r, max(color.g, color.b)); let light = u.lightBackground \* (1.0 - strength) + color \* 0.96; color = mix(dark, light, vec3f(u.lightMode)); color += (blueNoise(frag, floor(u.time \* 24.0)) - 0.5) / 255.0; return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0); } \`; const RARITY\_SHADER = \`struct Uniforms { resolution: vec2f, time: f32, lightMode: f32, darkBackground: vec3f, pixelRatio: f32, lightBackground: vec3f, } @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var sceneSampler: sampler; @group(0) @binding(2) var tScene: texture\_2d; @group(0) @binding(3) var glyphSampler: sampler; @group(0) @binding(4) var tGlyphs: texture\_2d; const STRENGTH: f32 = 1.29158604; const SCALE: f32 = 1.24072516; const SEED: f32 = 0.355047792; const GLYPH\_COUNT: f32 = 10.0; @vertex fn vertexMain(@builtin(vertex\_index) index: u32) -> @builtin(position) vec4f { let position = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); return vec4f(position \* 2.0 - 1.0, 0.0, 1.0); } const TAU: f32 = 6.28318530718; const LUMA = vec3f(0.2126, 0.7152, 0.0722); fn toInk(c: vec3f) -> vec3f { return mix(c - u.darkBackground, u.lightBackground - c, vec3f(u.lightMode)); } fn fromInk(ink: vec3f) -> vec3f { return mix(u.darkBackground + ink, u.lightBackground - ink, vec3f(u.lightMode)); } fn sceneInk(uv: vec2f) -> vec3f { let c = clamp(uv, vec2f(0.0), vec2f(1.0)); return toInk(textureSample(tScene, sceneSampler, vec2f(c.x, 1.0 - c.y)).rgb); } fn fmod(x: f32, y: f32) -> f32 { return x - y \* floor(x / y); } fn fmod2(x: vec2f, y: f32) -> vec2f { return x - y \* floor(x / y); } fn ascii(frag: vec2f) -> vec3f { let cellPx = vec2f(0.62, 1.0) \* floor(SCALE \* 9.0 \* u.pixelRatio + 0.5); let cell = floor(frag / cellPx); let centre = (cell + 0.5) \* cellPx; var ink = vec3f(0.0); ink += sceneInk(centre / u.resolution) \* 2.0; ink += sceneInk((centre + cellPx \* vec2f(0.3, 0.3)) / u.resolution); ink += sceneInk((centre + cellPx \* vec2f(-0.3, 0.3)) / u.resolution); ink += sceneInk((centre + cellPx \* vec2f(0.3, -0.3)) / u.resolution); ink += sceneInk((centre + cellPx \* vec2f(-0.3, -0.3)) / u.resolution); ink /= 6.0; let level = pow(clamp(dot(ink, LUMA) \* (0.9 + 0.3 \* STRENGTH), 0.0, 1.0), 0.9); let glyph = floor(level \* (GLYPH\_COUNT - 1.0) + 0.5); let local = (frag - cell \* cellPx) / cellPx; let atlas = vec2f((glyph + local.x) / GLYPH\_COUNT, 1.0 - local.y); let mask = textureSample(tGlyphs, glyphSampler, atlas).r; let under = sceneInk(frag / u.resolution) \* 0.45; return under + ink \* mask \* (1.0 + 0.7 \* u.lightMode); } @fragment fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f { let frag = vec2f(position.x, u.resolution.y - position.y); let ink = ascii(frag); var color = fromInk(clamp(ink, vec3f(0.0), vec3f(1.0))); return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0); } \`; const GLYPHS = " .:;+\*oO8@"; const GLYPH\_CELL = { width: 48, height: 80 }; const GLYPH\_MIP\_LEVELS = 5; const GLYPH\_FONT = "500 62px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; const MAX\_PIXELS = 2400000; const THEME\_EASE = 7; function parseHex(hex) { const match = /^#(\[0-9a-f\]{6})$/i.exec(hex.trim()); if (!match) throw new Error(\`Background colours must be #rrggbb, got "${hex}".\`); return \[0, 2, 4\].map((i) => parseInt(match\[1\].slice(i, i + 2), 16) / 255); } function animate(options, draw, canvas, release, maxDimension = Infinity) { const autoplay = options.autoplay !== false; const stillness = window.matchMedia("(prefers-reduced-motion: reduce)"); let resolution = window.matchMedia(\`(resolution: ${window.devicePixelRatio || 1}dppx)\`); let deviceRatio = window.devicePixelRatio || 1; let width = canvas.clientWidth, height = canvas.clientHeight; let visible = true; let disposed = false; let targetTheme = options.theme === "light" ? 1 : 0; let theme = targetTheme; let frame = 0; let elapsed = 0; let lastTime = 0; let previous = null; function canDraw() { return !disposed && !document.hidden && visible && width > 0 && height > 0; } function fitCanvas() { const scale = Math.min(deviceRatio, 2, Math.sqrt(MAX\_PIXELS / (width \* height)), maxDimension / width, maxDimension / height); const w = Math.max(1, Math.floor(width \* scale)), h = Math.max(1, Math.floor(height \* scale)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } return w / width; } function render(time) { if (disposed) return; lastTime = time; if (!canDraw()) return; try { draw(time, theme, fitCanvas()); } catch (error) { destroy(); const failure = error instanceof Error ? error : new Error(String(error)); if (options.onError) options.onError(failure); else console.error(failure); } } function schedule() { if (!frame && canDraw()) frame = requestAnimationFrame(tick); } function refresh() { if (!canDraw()) { cancelAnimationFrame(frame); frame = 0; previous = null; } else schedule(); } function tick(now) { frame = 0; if (!canDraw()) { previous = null; return; } const delta = previous === null ? 0 : Math.min((now - previous) / 1000, 0.1); previous = now; if (autoplay) { if (!stillness.matches) elapsed += delta; theme += (targetTheme - theme) \* (1 - Math.exp(-delta \* THEME\_EASE)); if (Math.abs(targetTheme - theme) < 0.002) theme = targetTheme; } render(autoplay ? elapsed : lastTime); if (autoplay && (!stillness.matches || theme !== targetTheme)) schedule(); else previous = null; } function pixelRatioChanged() { if (disposed) return; const next = window.devicePixelRatio || 1; if (deviceRatio === next) return; deviceRatio = next; resolution.removeEventListener("change", pixelRatioChanged); resolution = window.matchMedia(\`(resolution: ${next}dppx)\`); resolution.addEventListener("change", pixelRatioChanged); refresh(); } const observer = new ResizeObserver((\[entry\]) => { if (disposed || !entry) return; const next = entry.contentRect; if (width === next.width && height === next.height) return; width = next.width; height = next.height; refresh(); }); const intersection = new IntersectionObserver((\[entry\]) => { if (disposed || !entry || visible === entry.isIntersecting) return; visible = entry.isIntersecting; refresh(); }); function destroy() { if (disposed) return; disposed = true; cancelAnimationFrame(frame); frame = 0; observer.disconnect(); intersection.disconnect(); resolution.removeEventListener("change", pixelRatioChanged); stillness.removeEventListener("change", refresh); document.removeEventListener("visibilitychange", refresh); window.removeEventListener("resize", pixelRatioChanged); options.signal?.removeEventListener("abort", destroy); release(); } observer.observe(canvas); intersection.observe(canvas); resolution.addEventListener("change", pixelRatioChanged); stillness.addEventListener("change", refresh); document.addEventListener("visibilitychange", refresh); window.addEventListener("resize", pixelRatioChanged); options.signal?.addEventListener("abort", destroy, { once: true }); if (options.signal?.aborted) destroy(); else schedule(); return { setTheme(next) { if (disposed) return; targetTheme = next === "light" ? 1 : 0; if (autoplay) refresh(); else { theme = targetTheme; render(lastTime); } }, render, destroy, }; } const UNIFORM\_FLOATS = 12; function createGlyphAtlas() { const width = GLYPH\_CELL.width \* GLYPHS.length, height = GLYPH\_CELL.height; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) throw new Error("A 2D canvas context is needed to draw the glyph atlas."); context.fillStyle = "#000"; context.fillRect(0, 0, width, height); context.fillStyle = "#fff"; context.textAlign = "center"; context.textBaseline = "middle"; context.font = GLYPH\_FONT; for (let i = 0; i < GLYPHS.length; i++) { context.fillText(GLYPHS\[i\], i \* GLYPH\_CELL.width + GLYPH\_CELL.width / 2, height / 2 + height \* 0.04); } const levels = \[context.getImageData(0, 0, width, height).data\]; for (let level = 1, w = width, h = height; level < GLYPH\_MIP\_LEVELS; level++) { const source = levels\[level - 1\]; const next = new Uint8ClampedArray((w / 2) \* (h / 2) \* 4); for (let y = 0; y < h / 2; y++) { for (let x = 0; x < w / 2; x++) { const a = (y \* 2 \* w + x \* 2) \* 4, b = a + 4, c = a + w \* 4, d = c + 4; for (let ch = 0; ch < 4; ch++) next\[(y \* (w / 2) + x) \* 4 + ch\] = (source\[a + ch\] + source\[b + ch\] + source\[c + ch\] + source\[d + ch\] + 2) >> 2; } } levels.push(next); w /= 2; h /= 2; } return { width, height, levels }; } function uploadGlyphAtlas(device) { const atlas = createGlyphAtlas(); const texture = device.createTexture({ size: \[atlas.width, atlas.height\], mipLevelCount: atlas.levels.length, format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE\_BINDING | GPUTextureUsage.COPY\_DST, }); atlas.levels.forEach((pixels, level) => { const width = atlas.width >> level, height = atlas.height >> level; device.queue.writeTexture({ texture, mipLevel: level }, pixels, { bytesPerRow: width \* 4 }, \[width, height\]); }); return texture; } export async function createShader(canvas, options = {}) { const dark = parseHex(options.background?.dark ?? "#090909"); const light = parseHex(options.background?.light ?? "#ffffff"); options.signal?.throwIfAborted(); if (!navigator.gpu) throw new Error("WebGPU is not available in this browser."); const adapter = await navigator.gpu.requestAdapter(); options.signal?.throwIfAborted(); if (!adapter) throw new Error("No WebGPU adapter is available."); const device = await adapter.requestDevice(); let context = null; let configured = false; let released = false; let failure = null; let handle = null; function release() { if (released) return; released = true; options.signal?.removeEventListener("abort", abort); device.removeEventListener("uncapturederror", gpuError); if (configured) context?.unconfigure(); device.destroy(); } function abort() { if (handle) handle.destroy(); else release(); } function fail(error) { if (released) return; failure = error; if (handle) { handle.destroy(); if (options.onError) options.onError(error); else console.error(error); } else release(); } function gpuError(event) { event.preventDefault(); fail(new Error(event.error.message)); } function checkActive() { options.signal?.throwIfAborted(); if (failure) throw failure; } options.signal?.addEventListener("abort", abort, { once: true }); device.addEventListener("uncapturederror", gpuError); void device.lost.then((info) => { if (!released) fail(new Error(\`WebGPU device lost: ${info.message || info.reason}.\`)); }); try { checkActive(); const format = navigator.gpu.getPreferredCanvasFormat(); const uniforms = device.createBuffer({ size: UNIFORM\_FLOATS \* 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY\_DST }); const uniformData = new Float32Array(UNIFORM\_FLOATS); const fieldModule = device.createShaderModule({ code: FIELD\_SHADER }); const postModule = device.createShaderModule({ code: RARITY\_SHADER }); const \[fieldPipeline, postPipeline, postBlendPipeline\] = await Promise.all(\[ device.createRenderPipelineAsync({ layout: "auto", vertex: { module: fieldModule, entryPoint: "vertexMain" }, fragment: { module: fieldModule, entryPoint: "fragmentMain", targets: \[{ format: "rgba8unorm" }\] }, primitive: { topology: "triangle-list" }, }), device.createRenderPipelineAsync({ layout: "auto", vertex: { module: postModule, entryPoint: "vertexMain" }, fragment: { module: postModule, entryPoint: "fragmentMain", targets: \[{ format }\] }, primitive: { topology: "triangle-list" }, }), device.createRenderPipelineAsync({ layout: "auto", vertex: { module: postModule, entryPoint: "vertexMain" }, fragment: { module: postModule, entryPoint: "fragmentMain", targets: \[{ format, blend: { color: { srcFactor: "constant", dstFactor: "one-minus-constant" }, alpha: { srcFactor: "one", dstFactor: "zero" } } }\] }, primitive: { topology: "triangle-list" }, }), \]); checkActive(); const fieldBindGroup = device.createBindGroup({ layout: fieldPipeline.getBindGroupLayout(0), entries: \[{ binding: 0, resource: { buffer: uniforms } }\], }); const sceneSampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" }); const glyphs = uploadGlyphAtlas(device); const glyphView = glyphs.createView(); const glyphSampler = device.createSampler({ magFilter: "linear", minFilter: "linear", mipmapFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" }); let scene = null; let sceneView = null; let postBindGroups = \[\]; function sceneFor(width, height) { if (scene && sceneView && scene.width === width && scene.height === height) return { view: sceneView, postBindGroups }; scene?.destroy(); scene = device.createTexture({ size: \[width, height\], format: "rgba8unorm", usage: GPUTextureUsage.RENDER\_ATTACHMENT | GPUTextureUsage.TEXTURE\_BINDING }); const view = scene.createView(); sceneView = view; postBindGroups = \[postPipeline, postBlendPipeline\].map((pipeline) => device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: \[ { binding: 0, resource: { buffer: uniforms } }, { binding: 1, resource: sceneSampler }, { binding: 2, resource: view }, { binding: 3, resource: glyphSampler }, { binding: 4, resource: glyphView }, \], })); return { view, postBindGroups }; } const canvasContext = canvas.getContext("webgpu"); if (!canvasContext) throw new Error("A WebGPU canvas context could not be created."); context = canvasContext; context.configure({ device, format, alphaMode: "opaque" }); configured = true; handle = animate(options, (time, theme, pixelRatio) => { const { width, height } = canvas; const output = canvasContext.getCurrentTexture().createView(); const target = sceneFor(width, height); const drawThemed = (mode, blend) => { uniformData.set(\[width, height, time, mode, dark\[0\], dark\[1\], dark\[2\], pixelRatio, light\[0\], light\[1\], light\[2\], 0\]); device.queue.writeBuffer(uniforms, 0, uniformData); const encoder = device.createCommandEncoder(); const fieldPass = encoder.beginRenderPass({ colorAttachments: \[{ view: target.view, loadOp: "clear", storeOp: "store" }\] }); fieldPass.setPipeline(fieldPipeline); fieldPass.setBindGroup(0, fieldBindGroup); fieldPass.draw(3); fieldPass.end(); const postPass = encoder.beginRenderPass({ colorAttachments: \[{ view: output, loadOp: blend ? "load" : "clear", storeOp: "store" }\] }); postPass.setPipeline(blend ? postBlendPipeline : postPipeline); postPass.setBindGroup(0, target.postBindGroups\[blend ? 1 : 0\]); if (blend) postPass.setBlendConstant({ r: theme, g: theme, b: theme, a: theme }); postPass.draw(3); postPass.end(); device.queue.submit(\[encoder.finish()\]); }; if (theme <= 0 || theme >= 1) { drawThemed(theme, false); return; } drawThemed(0, false); drawThemed(1, true); }, canvas, release, device.limits.maxTextureDimension2D); return handle; } catch (error) { release(); throw failure ?? error; } } const host = document.querySelector('.site'); if (!host) throw new Error('Could not find the .site element.'); const canvas = document.createElement('canvas'); canvas.className = 'site-shader-canvas'; canvas.setAttribute('aria-hidden', 'true'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;z-index:-1;'; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; if (getComputedStyle(host).isolation === 'auto') host.style.isolation = 'isolate'; host.prepend(canvas); const shader = await createShader(canvas, { theme: 'light', background: { light: '#ffffff' } }); window.shader = shader; ## 网站简介 OpenShaders 是一个面向开发者和创作者的开源 Shader 平台,目标是打造「Web 上最大的 Shader 开源社区和资源库」。项目希望将原本散落在各种实验项目和演示页面中的 Shader 效果集中起来,让开发者能够更方便地发现、定制和使用这些视觉效果。 根据官方介绍,未来平台将支持 Shader 发布、收藏、点赞、协作开发以及在线编辑等功能,并提供可直接用于项目中的 React 组件、JavaScript 模块以及基于 shadcn 的安装方式。 ## 演示效果 ![](https://cdn.huiui.net/uploads/20261003183712.webp) ![](https://cdn.huiui.net/uploads/20261003183729.webp) **Tags:** 动态, 动画, 效果, 粒子, 组件, 背景 **Categories:** 开发组件 ---