diff --git a/docs/WEBGL-WALLPAPER.md b/docs/WEBGL-WALLPAPER.md new file mode 100644 index 0000000000..66bbfb78c5 --- /dev/null +++ b/docs/WEBGL-WALLPAPER.md @@ -0,0 +1,55 @@ +# WebGL 壁纸 + +在显示设置的「壁纸模式」中选择 **WebGL 动态壁纸**,或把 [`backgroundWallpaper.mode`](../src/config/backgroundWallpaper.ts) 设为 `"webgl"`。默认加载 `cloud-train`(云海、蒸汽列车与桥梁)。WebGL 2 不可用、场景加载失败或着色器报错时,原图片壁纸会保留为回退;切走该模式时会释放画布和场景资源。 + +## 配置 Cloud Train + +```ts +webgl: { + scene: "cloud-train", + options: { + speed: 1, resolution: 0.75, feedback: 0.3, + zoom: 1, offset: 0, amplitude: 1, detail: 8, + exposure: 1, saturation: 1, hue: 0, temperature: 0, + skyTint: "#ffffff", smokeTint: "#ffffff", trainTint: "#ffffff", + introEnabled: true, introDuration: 3, introFeather: 0.22, + paused: false, + }, +}, +``` + +`speed` 范围 0–5;`resolution` 范围 0.25–1;`feedback` 范围 0–0.85。场景会限制其余参数范围,避免无效配置直接进入着色器。窗口隐藏时停止渲染;系统开启减少动态效果时保持静态画面。 + +## 使用自己的片元着色器 + +把 WebGL 2 片元着色器放在 `public` 下。例如已提供的 [`example-wallpaper.frag`](../public/assets/shaders/example-wallpaper.frag): + +```ts +webgl: { + scene: "fragment", + options: { + url: "/assets/shaders/example-wallpaper.frag", + speed: 1, + resolution: 1, + }, +}, +``` + +着色器须以 `#version 300 es` 开头,声明 `uniform vec3 iResolution`、`uniform float iTime`、`out vec4 color`,并提供 `void main()`。渲染宿主自动更新分辨率和时间。`url` 也可指向允许跨域读取的地址。 + +## 编写完整场景模块 + +多通道、反馈纹理或自行管理参数的场景,可在 [`src/utils/webgl-wallpaper/scenes/`](../src/utils/webgl-wallpaper/scenes/) 新增 `my-scene.ts`。模块默认导出一个接收 `WebGLWallpaperSceneContext` 的工厂函数,返回含 `dispose()` 的对象;把配置 `scene` 改为 `"my-scene"` 即可。宿主通过 `import.meta.glob` 按需加载,不用修改模式切换代码。工厂应在画出第一帧后调用 `onFirstFrame()`,并在 `dispose()` 中取消动画帧、监听器及 GPU 资源;可使用 `signal` 取消异步资产加载。 + +```ts +import type { WebGLWallpaperSceneContext } from "../types"; + +export default function createScene({ canvas, onFirstFrame }: WebGLWallpaperSceneContext) { + const gl = canvas.getContext("webgl2"); + if (!gl) throw new Error("WebGL 2 is unavailable"); + // 在此编译着色器并启动渲染;第一帧成功后调用 onFirstFrame()。 + return { dispose() { /* 释放场景的 WebGL 资源与监听器 */ } }; +} +``` + +Cloud Train 的完整多通道实现见 [`cloud-train.ts`](../src/utils/webgl-wallpaper/scenes/cloud-train.ts),宿主生命周期见 [`host.ts`](../src/utils/webgl-wallpaper/host.ts)。 diff --git a/docs/screenshots/webgl-cloud-train-desktop.png b/docs/screenshots/webgl-cloud-train-desktop.png new file mode 100644 index 0000000000..9ba9d4ebe3 Binary files /dev/null and b/docs/screenshots/webgl-cloud-train-desktop.png differ diff --git a/public/assets/shaders/example-wallpaper.frag b/public/assets/shaders/example-wallpaper.frag new file mode 100644 index 0000000000..f08aba02be --- /dev/null +++ b/public/assets/shaders/example-wallpaper.frag @@ -0,0 +1,12 @@ +#version 300 es +precision highp float; +uniform vec3 iResolution; +uniform float iTime; +out vec4 color; + +void main() { + vec2 uv = gl_FragCoord.xy / iResolution.xy; + float wave = 0.5 + 0.5 * sin(uv.x * 8.0 + iTime * 0.4); + vec3 sky = mix(vec3(0.02, 0.08, 0.16), vec3(0.08, 0.32, 0.48), uv.y); + color = vec4(sky + vec3(0.03, 0.07, 0.09) * wave, 1.0); +} diff --git a/src/components/controls/DisplaySettingsIntegrated.svelte b/src/components/controls/DisplaySettingsIntegrated.svelte index c77c01be61..ea98077790 100644 --- a/src/components/controls/DisplaySettingsIntegrated.svelte +++ b/src/components/controls/DisplaySettingsIntegrated.svelte @@ -4,6 +4,7 @@ import { WALLPAPER_FULLSCREEN, WALLPAPER_NONE, WALLPAPER_OVERLAY, + WALLPAPER_WEBGL, } from "@constants/constants"; import I18nKey from "@i18n/i18nKey"; import { i18n } from "@i18n/translation"; @@ -118,7 +119,8 @@ const defaultCardFollowThemeEnabled = getDefaultCardFollowThemeEnabled(); const isWallpaperSwitchable = displaySettingsConfig.wallpaperModeSwitchable; const isFullscreenLayoutSwitchable = $derived( displaySettingsConfig.fullscreenLayoutSwitchable && - wallpaperMode === WALLPAPER_FULLSCREEN, + (wallpaperMode === WALLPAPER_FULLSCREEN || + wallpaperMode === WALLPAPER_WEBGL), ); const allowLayoutSwitch = displaySettingsConfig.layoutSwitchable; let effectiveDefaultLayout = $derived( @@ -213,10 +215,12 @@ const hasWallpaperTab = $derived( isWallpaperSwitchable || isFullscreenLayoutSwitchable || ((wallpaperMode === WALLPAPER_OVERLAY || + wallpaperMode === WALLPAPER_WEBGL || wallpaperMode === WALLPAPER_FULLSCREEN) && hasOverlaySettings) || ((wallpaperMode === WALLPAPER_BANNER || - wallpaperMode === WALLPAPER_FULLSCREEN) && + wallpaperMode === WALLPAPER_FULLSCREEN || + wallpaperMode === WALLPAPER_WEBGL) && hasBannerSettings), ); const hasEffectsTab = $derived(isSakuraSwitchable); @@ -258,6 +262,7 @@ $effect(() => { $effect(() => { if ( (wallpaperMode === WALLPAPER_OVERLAY || + wallpaperMode === WALLPAPER_WEBGL || wallpaperMode === WALLPAPER_FULLSCREEN) && hasOverlaySettings ) { @@ -270,7 +275,9 @@ let overlaySliderItems = $derived([ key: "opacity", // 全屏壁纸模式不需要背景透明度,隐藏该滑块(仍显示模糊与卡片透明度) enabled: - isOverlayOpacitySwitchable && wallpaperMode !== WALLPAPER_FULLSCREEN, + isOverlayOpacitySwitchable && + wallpaperMode !== WALLPAPER_FULLSCREEN && + wallpaperMode !== WALLPAPER_WEBGL, label: i18n(I18nKey.overlayOpacity), displayValue: `${Math.round(overlayOpacity * 100)}%`, ariaLabel: i18n(I18nKey.overlayOpacity), @@ -287,6 +294,7 @@ let overlaySliderItems = $derived([ // 全屏壁纸模式关闭模糊渐变时隐藏模糊滑块(overlay 模式不受影响) enabled: isOverlayBlurSwitchable && + wallpaperMode !== WALLPAPER_WEBGL && !(wallpaperMode === WALLPAPER_FULLSCREEN && !isFullscreenBlurRampEnabled), label: i18n(I18nKey.overlayBlur), displayValue: `${overlayBlur.toFixed(1)}px`, @@ -463,7 +471,11 @@ function switchWallpaperMode(newMode: WALLPAPER_MODE) { setWallpaperMode(newMode); window.scrollTo({ top: 0 }); - if (newMode === WALLPAPER_OVERLAY || newMode === WALLPAPER_FULLSCREEN) { + if ( + newMode === WALLPAPER_OVERLAY || + newMode === WALLPAPER_FULLSCREEN || + newMode === WALLPAPER_WEBGL + ) { requestAnimationFrame(refreshAllRangeProgress); } } @@ -642,6 +654,10 @@ $effect(() => { if (isOverlayCardOpacitySwitchable) { setOverlayCardOpacity(overlayCardOpacity); } + } else if (wallpaperMode === WALLPAPER_WEBGL) { + // WebGL 由场景直接渲染;仅沿用内容卡片透明度。 + if (isOverlayCardOpacitySwitchable) + setOverlayCardOpacity(overlayCardOpacity); } else if (wallpaperMode === WALLPAPER_FULLSCREEN) { // 全屏壁纸不透明,只应用模糊与卡片透明度 if (isOverlayBlurSwitchable) { @@ -858,11 +874,20 @@ $effect(() => { {i18n(I18nKey.wallpaperNoneMode)} + {/if} - + {#if isFullscreenLayoutSwitchable}
@@ -898,8 +923,8 @@ $effect(() => {
{/if} - - {#if (wallpaperMode === WALLPAPER_OVERLAY || (wallpaperMode === WALLPAPER_FULLSCREEN && fullscreenLayout === "hero")) && hasOverlaySettings && hasVisibleOverlaySlider} + + {#if (wallpaperMode === WALLPAPER_OVERLAY || ((wallpaperMode === WALLPAPER_FULLSCREEN || wallpaperMode === WALLPAPER_WEBGL) && fullscreenLayout === "hero")) && hasOverlaySettings && hasVisibleOverlaySlider}
{i18n(I18nKey.overlaySettings)} @@ -937,7 +962,7 @@ $effect(() => { {/if} - {#if (wallpaperMode === WALLPAPER_BANNER || wallpaperMode === WALLPAPER_FULLSCREEN) && hasBannerSettings} + {#if (wallpaperMode === WALLPAPER_BANNER || wallpaperMode === WALLPAPER_FULLSCREEN || wallpaperMode === WALLPAPER_WEBGL) && hasBannerSettings}
{i18n(I18nKey.wallpaperSettings)} @@ -986,8 +1011,8 @@ $effect(() => {
{/if} - - {#if isWavesSwitchable && (wallpaperMode === WALLPAPER_BANNER || (wallpaperMode === WALLPAPER_FULLSCREEN && fullscreenLayout === "classic"))} + + {#if isWavesSwitchable && (wallpaperMode === WALLPAPER_BANNER || ((wallpaperMode === WALLPAPER_FULLSCREEN || wallpaperMode === WALLPAPER_WEBGL) && fullscreenLayout === "classic"))}
{/if} - - {#if isGradientSwitchable && (wallpaperMode === WALLPAPER_BANNER || (wallpaperMode === WALLPAPER_FULLSCREEN && fullscreenLayout === "classic"))} + + {#if isGradientSwitchable && (wallpaperMode === WALLPAPER_BANNER || ((wallpaperMode === WALLPAPER_FULLSCREEN || wallpaperMode === WALLPAPER_WEBGL) && fullscreenLayout === "classic"))}