WebGL 2 实战:单文件移植与扩展 Heartfelt 玻璃雨滴折射效果

一、项目效果与功能概览

这个项目的交付物只有一个 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);
  ...
}

注意这里先处理了屏幕宽高比。否则在宽屏上,圆形雨滴会被拉伸成椭圆。

雨滴分两类:

  1. 静态水珠:像玻璃上附着的小水滴。
  2. 下滑拖尾:像雨水沿玻璃向下流动形成的水痕。

静态水珠由 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 让每条水痕速度和位置不同。后面再用 headtail 分别构造水滴头部和拖尾:

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);
}

这里有几个值得注意的性能点:

  1. 使用全屏三角形,减少几何复杂度。
  2. DPR 限制在 2 以内,避免高分屏上像素数量暴涨。
  3. UI 控制只更新 uniform,不触发 shader 重编译。
  4. 视频纹理只有在 readyState 满足条件时才上传。
  5. 背景、雨滴、拖尾都在 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 的魅力就在这里:只要掌握坐标、噪声、采样和时间,你就可以在一个普通网页里造出很有电影感的实时视觉效果。

相关推荐
山河木马4 天前
渲染管线-计算得到gl_Position(顶点着色器)之后续GPU流程
javascript·webgl·图形学
山河木马6 天前
矩阵专题3-怎么创建投影矩阵(uProjectionMatrix)
javascript·webgl·计算机图形学
山河木马7 天前
矩阵专题2-怎么创建视图矩阵(uViewMatrix)
javascript·webgl·计算机图形学
山河木马12 天前
矩阵专题1-怎么创建模型矩阵(uModelMatrix)
javascript·webgl·计算机图形学
山河木马13 天前
矩阵专题0-webGL中的矩阵
javascript·webgl·计算机图形学
一颗烂土豆17 天前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
数据知道1 个月前
视觉伪装(下):WebGL 渲染器与厂商特征的底层伪造与屏蔽
javascript·数据采集·webgl·指纹浏览器
niconicoC1 个月前
让 Three.js 场景更真实:我用高斯泼溅和 SparkJS 做了一个可交互的 3D Demo
前端·webgl
sinat_384503111 个月前
【无标题】
unity·webgl