跳到主要内容
← 全部 Shader
浏览器实时运行 程序化生成 原始语言:GLSL ES 3.00 浏览器:GLSL ES 3.00

程序化波浪场

由多层正弦波叠加而成的程序化效果,纯数学计算、不依赖任何纹理,是本站 WebGL 2 实时预览管线的第一个示例,也用来定义参数控件与 uniform 的绑定约定。

GLSL ES 3.00 全屏四边形 等待初始化
程序化波浪场的静态截图:深蓝色背景上叠加青绿色的波形条纹

静态预览:实时渲染需要启用 JavaScript 与支持 WebGL 2 的浏览器。初始化成功后会自动切换为实时画面。

— 0.0 s
预设
1.00

时间缩放系数。设为 0 时波形完全静止,等价于暂停。 uniform uSpeed

2.20

单位视口宽度内出现的波峰数量。 uniform uFrequency

0.95

波峰与波谷之间的高度差,直接影响颜色渐变的对比度。 uniform uAmplitude

4

参与求和的正弦波数量。层数越多细节越丰富,片元着色器的计算量也线性增长。 uniform uWaveCount

1.50

对归一化高度做幂函数,用来收紧或拉宽亮部与暗部的过渡。 uniform uSharpness

#07243D

高度最低处使用的颜色,以 sRGB 输入并在线性空间中参与混合。 uniform uColorDeep

#3FE0C8

高度最高处使用的颜色,同样先转换到线性空间。 uniform uColorCrest

0.45

0 表示关闭,1 表示最强的四角压暗。 uniform uVignette

关

叠加一层网格线,用于观察波形实际的采样坐标。 uniform uShowGrid

运行环境与资源诊断
尚未初始化。
本页目录

效果说明

这是一个纯程序化的全屏效果:片元着色器对每个像素计算一个由多层正弦波叠加而成的高度场,再把高度映射成颜色。整个过程不需要纹理、不需要顶点动画,因此非常适合作为实时预览管线的第一个验证目标——它可以把「编译、上传 uniform、按时间推进、暂停与重置、释放资源」这条链路完整地跑一遍。

数学解析

设视口内经过宽高比校正后的坐标为 p\mathbf{p},第 ii 层的传播方向为 di=(cos⁡θi, sin⁡θi)\mathbf{d}_i = (\cos\theta_i,\ \sin\theta_i),其中 θi=0.6i\theta_i = 0.6i。高度场定义为:

h(p)=∑i=0N−1wisin⁡ ⁣(2πfki (p⋅di)+ϕi(t))∑i=0N−1wih(\mathbf{p}) = \frac{\sum_{i=0}^{N-1} w_i \sin\!\big(2\pi f k_i\,(\mathbf{p}\cdot\mathbf{d}_i) + \phi_i(t)\big)}{\sum_{i=0}^{N-1} w_i}

其中 NN 是 uWaveCount,ff 是 uFrequency,ki=1+0.42ik_i = 1 + 0.42i 让每一层的波长逐渐变短,权重 wi=11+0.75iw_i = \frac{1}{1+0.75i} 让高频分量的贡献逐渐衰减,相位

ϕi(t)=t⋅s⋅(0.7+0.23i),s=uSpeed\phi_i(t) = t \cdot s \cdot (0.7 + 0.23i),\qquad s = \texttt{uSpeed}

使每层以略微不同的速度移动,避免出现整体平移的机械感。分母 ∑wi\sum w_i 用于归一化,保证无论层数如何变化,hh 的取值范围都稳定在 [−1,1][-1, 1] 附近——这一点很重要,否则调整层数滑块会让画面亮度整体跳变。

高度到颜色的映射先归一化再做幂函数:

T=(clamp⁡(12A h+12, 0, 1))γ,c=(1−T) cdeep+T ccrestT = \big(\operatorname{clamp}(\tfrac{1}{2}A\,h + \tfrac{1}{2},\,0,\,1)\big)^{\gamma},\qquad \mathbf{c} = (1-T)\,\mathbf{c}_{\text{deep}} + T\,\mathbf{c}_{\text{crest}}

AA 是 uAmplitude,γ\gamma 是 uSharpness。

高光部分用有限差分近似法线:

n∝(−∂h∂x, −∂h∂y, 1),∂h∂x≈h(p+εx^)−h(p−εx^)2ε\mathbf{n} \propto \Big(-\frac{\partial h}{\partial x},\ -\frac{\partial h}{\partial y},\ 1\Big),\qquad \frac{\partial h}{\partial x} \approx \frac{h(\mathbf{p}+\varepsilon\hat{x}) - h(\mathbf{p}-\varepsilon\hat{x})}{2\varepsilon}

取 ε=0.004\varepsilon = 0.004。之后按 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() 无分支写法。

性能

片元着色器的主要成本是 4N+24N + 2 次 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);
}