程序化波浪场
由多层正弦波叠加而成的程序化效果,纯数学计算、不依赖任何纹理,是本站 WebGL 2 实时预览管线的第一个示例,也用来定义参数控件与 uniform 的绑定约定。
静态预览:实时渲染需要启用 JavaScript 与支持 WebGL 2 的浏览器。初始化成功后会自动切换为实时画面。
实时预览未能启动
已自动切换到上方的静态回退预览。下面是浏览器驱动返回的原始编译日志,可直接用于定位 GLSL 错误。
时间缩放系数。设为 0 时波形完全静止,等价于暂停。
uniform uSpeed
单位视口宽度内出现的波峰数量。
uniform uFrequency
波峰与波谷之间的高度差,直接影响颜色渐变的对比度。
uniform uAmplitude
参与求和的正弦波数量。层数越多细节越丰富,片元着色器的计算量也线性增长。
uniform uWaveCount
对归一化高度做幂函数,用来收紧或拉宽亮部与暗部的过渡。
uniform uSharpness
高度最低处使用的颜色,以 sRGB 输入并在线性空间中参与混合。
uniform uColorDeep
高度最高处使用的颜色,同样先转换到线性空间。
uniform uColorCrest
0 表示关闭,1 表示最强的四角压暗。
uniform uVignette
叠加一层网格线,用于观察波形实际的采样坐标。
uniform uShowGrid
运行环境与资源诊断
尚未初始化。
本页目录
效果说明
这是一个纯程序化的全屏效果:片元着色器对每个像素计算一个由多层正弦波叠加而成的高度场,再把高度映射成颜色。整个过程不需要纹理、不需要顶点动画,因此非常适合作为实时预览管线的第一个验证目标——它可以把「编译、上传 uniform、按时间推进、暂停与重置、释放资源」这条链路完整地跑一遍。
数学解析
设视口内经过宽高比校正后的坐标为 ,第 层的传播方向为 ,其中 。高度场定义为:
其中 是 uWaveCount, 是 uFrequency, 让每一层的波长逐渐变短,权重 让高频分量的贡献逐渐衰减,相位
使每层以略微不同的速度移动,避免出现整体平移的机械感。分母 用于归一化,保证无论层数如何变化, 的取值范围都稳定在 附近——这一点很重要,否则调整层数滑块会让画面亮度整体跳变。
高度到颜色的映射先归一化再做幂函数:
是 uAmplitude, 是 uSharpness。
高光部分用有限差分近似法线:
取 。之后按 Blinn-Phong 计算一条窄高光带,指数固定为 24。
实现要点
- 动态循环上限:GLSL ES 3.00 允许循环边界在运行时确定,但为了兼容各种驱动,循环写成固定上限 8 次并在内部
if (i >= uWaveCount) break;。这样uWaveCount可以是 uniform 而不是编译期常量。 - 宽高比校正:
uResolution由运行时在每次尺寸变化时写入,着色器用它把 UV 校正成等比例坐标,否则在宽画布上波形会被横向拉伸。 - 颜色空间:颜色控件给出的是 sRGB 十六进制值,运行时通过
THREE.Color.setStyle(hex, SRGBColorSpace)转成线性值再上传;着色器内部全部在线性空间计算,最后显式调用linearToSRGB()。three.js 不会为RawShaderMaterial追加任何输出转换代码,所以这一步必须自己写。 - 分支:
uShowGrid与uVignette的判断在片元着色器里是真正的动态分支。现代 GPU 上这种「整个 warp 走同一分支」的开销可以忽略,但如果要极致优化,可以把它们改成mix()无分支写法。
性能
片元着色器的主要成本是 次 sin 调用(高光差分额外采样 4 次高度场)。在层数为 8 的设置下,1080p 全屏大约每帧 3400 万次 sin。本站的自动化测试使用 SwiftShader 软件渲染(没有 GPU),因此实测帧率明显低于真实硬件——这恰好也充当了低性能设备的验证场景。
验证记录
下列结论来自仓库内的自动化测试(npm run test,Playwright + 无头 Chromium + SwiftShader):
- 页面加载后画布确实开始出图,且在两帧之间像素内容发生变化(动画随时间推进)。
- 调整「波幅」「叠加层数」等参数会改变画面,无需刷新页面。
- 「重置参数」能把所有控件与画面恢复到 frontmatter 中声明的默认值。
- 「暂停」会停止时间推进,画面定格;再次点击可以恢复。
- 切换「减少动态效果」同样会冻结动画。
- 页面不可见(
visibilitychange)或画布移出视口(IntersectionObserver)时会自动停止 rAF 循环。 - 组件销毁时
geometries / textures / programs计数回到 0,并调用了forceContextLoss()。
源码
浏览器 GLSL 与原始 HLSL 分开存放、分开展示。HLSL 仅作参考,浏览器无法直接执行 Unity URP 的 HLSL。
顶点着色器 fullscreen.vert glsl GLSL ES 3.00 · 浏览器中实际编译 22 行
three.js 会自动在最前面加入 `#version 300 es`,因此源文件本身不包含该指令。
// Fullscreen quad vertex shader (GLSL ES 3.00 / WebGL 2).
//
// NOTE: Three.js injects `#version 300 es` because the material is created as
// `new THREE.RawShaderMaterial({ glslVersion: THREE.GLSL3, ... })`.
// If you paste this file into a raw WebGL 2 context, add `#version 300 es`
// as the very first line and bind the attribute locations yourself.
//
// Built-in uniforms/attributes below are supplied by Three.js but must be
// declared explicitly because RawShaderMaterial adds no prefix code.
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
in vec3 position;
in vec2 uv;
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} 片元着色器 procedural-waves.frag glsl GLSL ES 3.00 · 浏览器中实际编译 91 行
// Procedural layered wave field.
// Pure maths, no textures: the "hello world" of this site's WebGL 2 preview
// runtime, and the reference implementation for the parameter-control contract.
//
// NOTE: `#version 300 es` is injected by three.js (RawShaderMaterial + GLSL3).
// NOTE: colour maths happens in linear space; the sRGB encode at the end is
// done here because three.js adds no output conversion to raw shaders.
precision highp float;
precision highp int;
in vec2 vUv;
uniform float uTime; // seconds since the preview started (pausable)
uniform vec2 uResolution; // drawing-buffer size in device pixels
uniform float uSpeed; // animation speed multiplier
uniform float uFrequency; // base spatial frequency of the wave field
uniform float uAmplitude; // height of the waves, 0..1
uniform int uWaveCount; // number of summed octaves (1..8)
uniform vec3 uColorDeep; // trough colour (linear RGB)
uniform vec3 uColorCrest; // crest colour (linear RGB)
uniform float uVignette; // 0 = none, 1 = strong corner darkening
uniform bool uShowGrid; // overlay the sampling grid used by the field
uniform float uSharpness; // contrast applied to the height ramp
out vec4 fragColor;
const float TAU = 6.283185307179586;
vec2 aspectUv() {
float aspect = uResolution.y > 0.0 ? uResolution.x / uResolution.y : 1.0;
vec2 p = vUv - 0.5;
p.x *= aspect;
return p;
}
// Sum of sine octaves with decreasing amplitude and drifting phase.
float waveHeight(vec2 p) {
float h = 0.0;
float weightSum = 0.0;
for (int i = 0; i < 8; ++i) {
if (i >= uWaveCount) break;
float fi = float(i);
float weight = 1.0 / (1.0 + fi * 0.75);
float angle = fi * 0.6;
vec2 dir = vec2(cos(angle), sin(angle));
float phase = uTime * uSpeed * (0.7 + fi * 0.23);
h += sin(dot(p, dir) * uFrequency * (1.0 + fi * 0.42) * TAU + phase) * weight;
weightSum += weight;
}
return h / max(weightSum, 1e-4);
}
vec3 linearToSRGB(vec3 c) {
vec3 lo = c * 12.92;
vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
return mix(lo, hi, step(vec3(0.0031308), c));
}
void main() {
vec2 p = aspectUv();
float h = waveHeight(p); // roughly -1..1
float ramp = clamp(h * uAmplitude * 0.5 + 0.5, 0.0, 1.0);
float t = clamp(pow(ramp, max(uSharpness, 0.01)), 0.0, 1.0);
// Cheap normal from finite differences, used for a fake specular sheen.
float e = 0.004;
float hx = waveHeight(p + vec2(e, 0.0)) - waveHeight(p - vec2(e, 0.0));
float hy = waveHeight(p + vec2(0.0, e)) - waveHeight(p - vec2(0.0, e));
vec3 n = normalize(vec3(-hx / (2.0 * e), -hy / (2.0 * e), 1.0));
vec3 lightDir = normalize(vec3(0.4, 0.6, 0.9));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
float spec = pow(max(dot(n, normalize(lightDir + viewDir)), 0.0), 14.0);
vec3 color = mix(uColorDeep, uColorCrest, t);
color += spec * 0.35;
if (uShowGrid) {
vec2 g = abs(fract(p * 12.0) - 0.5);
float line = smoothstep(0.48, 0.5, max(g.x, g.y));
color = mix(color, vec3(1.0), line * 0.25);
}
if (uVignette > 0.0) {
float d = length(vUv - 0.5) * 1.41421356;
color *= 1.0 - uVignette * smoothstep(0.35, 1.0, d);
}
fragColor = vec4(linearToSRGB(clamp(color, 0.0, 1.0)), 1.0);
}