一、项目效果与功能概览
这个项目的交付物只有一个 index.html,不依赖 npm、不依赖 Three.js,也不需要额外资源文件。页面打开后,主视觉是一个全屏 WebGL 2 canvas:背景默认是双色渐变流体,前景覆盖大量玻璃雨滴、拖尾水痕、雾气和轻微色散折射。
项目同时提供了三个交互层:
- 右上角折叠控制面板:调节雨势、雾气、折射率、水滴尺度、流速和背景混合比例。
- 背景上传:支持图片或视频,上传后作为 WebGL 纹理被雨滴折射。
- 悬浮文本框:玻璃拟态样式,可编辑正文,可拖拽位置。
这类效果很适合作为个人主页首屏、视觉展示页、互动海报、音乐可视化背景,或者 WebGL shader 教学案例。
"C:\Users\86182\Desktop\rainnyDay\index.html"

二、整体架构:DOM 负责交互,Shader 负责画面
虽然是单文件项目,但源码结构非常清晰:
text
index.html
├── <style> 页面布局、玻璃拟态 UI、控制面板、悬浮文本框
├── <canvas id="glCanvas"> WebGL 2 全屏渲染目标
├── 控制面板 DOM sliders / upload / fullscreen / toggle
├── 悬浮文本框 DOM contenteditable + pointer drag
└── <script>
├── WebGL 2 初始化
├── UI 参数读取与事件绑定
├── 顶点着色器
├── 片元着色器
├── 媒体纹理上传
└── requestAnimationFrame 渲染循环
这个架构的关键点是分工明确:HTML/CSS 只负责用户界面,JavaScript 只负责把用户参数和媒体资源送进 GPU,真正复杂的雨滴、折射、流体背景都在片元着色器里完成。
三、WebGL 2 初始化:为什么使用一个全屏三角形
源码中获取 WebGL 2 上下文时,开启了高性能偏好,同时关闭了深度、模板和 alpha:
js
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance'
});
这里的设计非常适合 shader toy 类型的全屏后处理效果。因为画面不是由复杂几何体组成,而是每个像素根据 uv、时间、鼠标和纹理计算出来,所以不需要传统 3D 场景里的深度测试。
顶点着色器也很极简:
glsl
const vec2 pos[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0)
);
它使用一个覆盖全屏的大三角形,而不是两个三角形拼成矩形。这样可以避免屏幕中间对角线处的插值边界,也减少一次顶点处理。对于全屏 shader,这是非常常见也很优雅的写法。
四、Uniform 参数:把 UI 控制映射到 GPU
控制面板中的滑块并不直接修改画面,而是通过 readParams() 转换成统一参数,在每一帧传给 shader:
js
const readParams = () => ({
rain: Number(controls.rainStrength.value),
fog: Number(controls.fogDensity.value),
refraction: Number(controls.refraction.value),
dropScale: Number(controls.dropScale.value),
flowSpeed: Number(controls.flowSpeed.value),
mediaMix: Number(controls.mediaMix.value)
});
这些参数最终对应片元着色器里的 uniforms:
glsl
uniform float uRainStrength;
uniform float uFogDensity;
uniform float uRefraction;
uniform float uDropScale;
uniform float uFlowSpeed;
uniform float uMediaMix;
uniform float uHasMedia;
uniform sampler2D uMedia;
这种设计有两个好处:
第一,UI 和 shader 解耦。滑块叫什么、放在哪里,不影响 shader 内部算法。
第二,实时性很好。每一帧只需要更新少量 uniform,不需要重建纹理、重编译 shader 或重新布局 DOM。
五、默认背景:用 FBM 做双色流体渐变
项目的默认背景不是静态 CSS 渐变,而是在 shader 中实时生成的流体渐变:
glsl
vec3 fluidGradient(vec2 uv) {
vec2 p = uv - 0.5;
float t = uTime * 0.085 * uFlowSpeed;
float f1 = fbm(p * 2.2 + vec2(t, -t * 0.7));
float f2 = fbm(p * 4.0 - vec2(t * 1.6, t));
float swirl = atan(p.y, p.x) + 1.4 * f1 + 0.9 * sin(2.2 * length(p) - t * 5.0);
...
}
这里用到了 fbm,也就是 fractal brownian motion。简单理解,它是多层噪声叠加,用来生成比普通 noise 更自然的云雾、液体和纹理变化。
背景色由几组颜色混合而来:
glsl
vec3 a = vec3(0.025, 0.22, 0.31);
vec3 b = vec3(0.78, 0.12, 0.36);
vec3 c = vec3(0.95, 0.58, 0.22);
vec3 d = vec3(0.09, 0.05, 0.18);
青蓝、玫红、暖橙和深紫不是平均铺开,而是通过噪声、极坐标角度和距离共同调制,所以最终画面会有一种缓慢流动的"液态渐变"质感。这比普通 linear-gradient 更适合作为折射背景,因为水滴折射需要背景中有足够的色彩和明暗变化,才能看出玻璃感。
六、核心算法:雨滴不是图片,而是法线场
玻璃雨滴效果的核心不在于"画出雨滴贴图",而是构造一个局部法线扰动场。片元着色器中的 rainNormal() 函数负责生成每个像素的雨滴法线:
glsl
vec2 rainNormal(vec2 uv) {
vec2 aspect = vec2(uResolution.x / max(uResolution.y, 1.0), 1.0);
vec2 p = (uv - 0.5) * aspect;
float rain = clamp(uRainStrength, 0.0, 1.6);
float scale = mix(10.0, 18.0, clamp(rain, 0.0, 1.0)) / max(0.45, uDropScale);
...
}
注意这里先处理了屏幕宽高比。否则在宽屏上,圆形雨滴会被拉伸成椭圆。
雨滴分两类:
- 静态水珠:像玻璃上附着的小水滴。
- 下滑拖尾:像雨水沿玻璃向下流动形成的水痕。
静态水珠由 staticDrop() 生成。它通过 hash 函数给每个网格单元一个随机中心和半径:
glsl
float staticDrop(vec2 uv, vec2 id, float sizeBias) {
vec2 rnd = hash22(id);
vec2 center = (rnd - 0.5) * vec2(0.82, 0.72);
vec2 p = uv - center;
float radius = mix(0.035, 0.14, rnd.y) * sizeBias;
float d = length(p);
float core = smoothstep(radius, radius * 0.28, d);
...
}
这里并没有真正存储任何雨滴对象。每个像素根据自己的坐标,反推出所在网格和附近网格里的伪随机水滴。这也是 shader 编程非常迷人的地方:看似复杂的粒子系统,其实可以完全由数学函数实时生成。
拖尾水痕由 trailDrop() 生成:
glsl
float fall = fract(uTime * uFlowSpeed * mix(0.06, 0.22, rnd.x) + rnd.y);
vec2 center = vec2(rnd.x - 0.5, 0.62 - fall * 1.55);
fract() 让水滴下落循环发生,uFlowSpeed 控制速度,rnd 让每条水痕速度和位置不同。后面再用 head 和 tail 分别构造水滴头部和拖尾:
glsl
float head = smoothstep(0.075, 0.0, length(p / vec2(0.72, 1.0)));
float tail = smoothstep(0.035, 0.0, abs(p.x)) *
smoothstep(0.42, 0.02, -p.y) *
step(0.0, -p.y);
这段是雨滴真实感的关键:如果只有圆点,会像玻璃上的水珠;加入拖尾后,画面才有"雨正在流动"的叙事感。
七、折射与色散:玻璃感的真正来源
拿到雨滴法线 n 后,shader 不直接把水滴画成白色,而是用它偏移背景采样坐标:
glsl
vec2 n = rainNormal(uv);
vec2 chroma = n * uRefraction;
refracted.r = sampleBackground(uv + chroma * vec2(1.12, 0.88)).r;
refracted.g = sampleBackground(uv + chroma).g;
refracted.b = sampleBackground(uv + chroma * vec2(0.82, 1.2)).b;
这就是折射的核心:水滴区域看到的不是原本的背景,而是稍微偏移后的背景。
更进一步,代码对 RGB 三个通道使用了略微不同的偏移量,形成轻微色散。真实玻璃、水滴、镜头边缘都会出现类似现象。这个处理非常轻,但能显著增强"高级感"。
最后再加一点高光:
glsl
float highlight = pow(
clamp(dot(normalize(vec3(n * 1.8, 0.65)),
normalize(vec3(-0.4, 0.55, 0.72))), 0.0, 1.0),
5.0
);
col += highlight * vec3(0.7, 0.9, 1.0) * 0.55;
折射负责形变,高光负责材质,两者叠加后,雨滴才会像透明玻璃而不是普通噪声。
八、媒体背景上传:图片和视频都变成 WebGL 纹理
项目支持用户上传图片或视频替换背景。核心逻辑是监听文件选择:
js
document.getElementById('mediaUpload').addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
mediaIsVideo = file.type.startsWith('video/');
mediaElement = mediaIsVideo ? document.createElement('video') : new Image();
});
如果是视频,则设置循环、静音和 playsInline:
js
mediaElement.loop = true;
mediaElement.muted = true;
mediaElement.playsInline = true;
真正上传到 GPU 的地方在 uploadMediaFrame():
js
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
mediaElement
);
图片只需要上传一次;视频则在每一帧判断 readyState 后更新纹理。这样雨滴折射的背景就可以是动态视频,视觉表现会比静态图片更强。
在 shader 中,背景混合由 sampleBackground() 完成:
glsl
vec3 shaderBg = fluidGradient(clampedUv);
vec3 mediaBg = texture(uMedia, clampedUv).rgb;
float mixAmount = clamp(uMediaMix * uHasMedia, 0.0, 1.0);
vec3 bg = mix(shaderBg, mediaBg, mixAmount);
这个设计保留了默认 shader 背景,也允许用户逐渐混合上传媒体,而不是一上传就完全替换。
九、控制面板与悬浮文本框:WebGL 项目也需要产品化交互
很多 shader demo 只停留在"能看",但这个项目进一步做了可用性包装。
右上角控制面板通过 CSS 固定定位,折叠时只隐藏主体:
css
.panel {
position: fixed;
top: 14px;
right: 14px;
z-index: 10;
backdrop-filter: blur(24px) saturate(145%);
}
.panel.collapsed .panel-body {
display: none;
}
悬浮文本框则使用 contenteditable 让用户直接编辑文字:
html
<div class="float-content" contenteditable="true" spellcheck="false">
雨夜玻璃
<small>可编辑文字 / 拖拽移动 / 背景可替换</small>
</div>
拖拽逻辑基于 Pointer Events,兼容鼠标和触摸:
js
floatHandle.addEventListener('pointerdown', event => {
const rect = floatingText.getBoundingClientRect();
dragState = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top
};
});
移动时根据指针坐标更新 left/top,并限制在视口内,避免拖出屏幕:
js
const x = Math.min(Math.max(event.clientX - dragState.offsetX, 8),
window.innerWidth - rect.width - 8);
const y = Math.min(Math.max(event.clientY - dragState.offsetY, 8),
window.innerHeight - rect.height - 8);
这部分虽然不是 shader 核心,但对实际作品非常重要。视觉特效如果不能被用户操作,很容易只是一个 demo;加入参数面板和文本框后,它就更像一个可发布的互动页面。
十、渲染循环与性能策略
主循环非常直接:
js
function render(now) {
resize();
uploadMediaFrame();
const p = readParams();
gl.useProgram(program);
...
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
这里有几个值得注意的性能点:
- 使用全屏三角形,减少几何复杂度。
- DPR 限制在 2 以内,避免高分屏上像素数量暴涨。
- UI 控制只更新 uniform,不触发 shader 重编译。
- 视频纹理只有在 readyState 满足条件时才上传。
- 背景、雨滴、拖尾都在 shader 中程序化生成,不需要大量贴图资源。
不过这个 shader 中有多层循环,例如 rainNormal() 会遍历多层雨滴网格。它的画质很好,但如果要适配低端移动设备,可以考虑减少 layer 数、降低静态雨滴邻域采样,或在移动端降低 canvas DPR。
十一、踩坑记录:中文源码一定要注意 UTF-8
这个项目是中文 UI,源码里包含"雨势""雾气""折射率"等文本。编辑这类单文件 HTML 时,务必保证:
html
<meta charset="utf-8">
同时,编辑器或脚本写文件时也要使用 UTF-8。否则中文可能变成乱码,更严重的是,如果乱码出现在 HTML 属性或 JS 字符串里,还可能破坏标签结构或导致脚本语法错误。
一个简单的建议是:涉及中文内容的 HTML 文件,尽量用现代编辑器直接保存为 UTF-8;如果用脚本批量替换,显式指定 UTF-8 读写。
十二、总结
这个项目的核心价值在于:它把一个 Shadertoy 风格的视觉算法,包装成了一个真正可交互、可复用、可发布的 Web 页面。
从技术角度看,它涵盖了 WebGL 2 全屏渲染、GLSL 噪声函数、程序化雨滴法线、折射采样、色散、高光、视频纹理上传和 DOM 交互同步。对于想学习 shader 的前端开发者来说,这是一个很好的综合练习。
如果继续扩展,还可以加入:
- 预设主题切换,比如霓虹城市、冷色雨夜、暖色室内灯光。
- 截图导出或录制短视频。
- 文本框字体、颜色、阴影可调。
- 移动端自动降采样策略。
- 多个 shader pass,实现更真实的玻璃模糊与水痕累积。
WebGL 的魅力就在这里:只要掌握坐标、噪声、采样和时间,你就可以在一个普通网页里造出很有电影感的实时视觉效果。