为了将Wallpaper Engine中的水波纹效果移植到WebGL,学习一下GLSL
最终效果预览
预览链接:Water Ripple
项目地址:taiyuuki/webgl-water-ripple
我已经将这个水波纹效果发布到npm,如果你也想使用,可以去我的项目地址里查看具体怎么使用。
前言——关于小红车
著名的动态壁纸软件Wallpaper Engine(由于它的steam主页图片是一辆红色轿车,常被大家叫做小红车),它内置了一个强大的壁纸编辑器,很简单就能做出一些酷炫的效果,例如水波涟漪效果——
小红车内置的这些效果,其源码都是用GLSL写的(PC端最终会被编译为HLSL),也就是OpenGL着色器语言(OpenGL Shader Language),理论上我们完全可以使用WebGL将同样的效果渲染至canvas中,只需要把GLSL代码改造成WebGL版本就可以了。
可惜在此之前我对着色器编程毫无概念,为了能在Web中复刻这个效果,我决定学习一下GLSL。
入门一下着色器编程
什么是着色器
着色器实际上就是一个绘制东西到屏幕上的函数,它运行在 GPU 中。
着色器分为顶点着色器(Vertex Shader)和片元着色器(Fragment Shader),文件扩展名分别为*.vert和*.frag,二者都使用GLSL。
顶点着色器是用于表述顶点特征(例如坐标、尺寸)的程序,将形状转换为实际坐标。
片元着色器是进行逐片元处理过程(例如颜色、光照)的程序,计算最终渲染的颜色以及其他属性。
GLSL
GLSL的语法类似于C语言,语法本身不难,这里不作详细介绍,我也就是在网上随便找了一些资料大致过了一下。
简单的例子:
顶点着色器 example.vert
attribute vec4 a_position;
void main() {
gl_Position = a_position;
gl_PoinSize = 2.0;
}
片元着色器 example.frag
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
以gl_开头的几个内置变量
- vec4 gl_Position 表示顶点的位置
- float gl_PoinSize 表示顶点的尺寸
- vec4 gl_FragColor 输出片元的颜色
着色器编程的核心就在于:给gl_Position赋值为指定的坐标,给gl_FragColor赋值为需要的颜色。 gl_PoinSize可以不主动赋值,它默认值为1.0。
我们大致可以这样理解:着色器会遍历画布上所有可以绘制的区域(由gl_Position决定),然后给每个区域绘制为指定的颜色(由gl_FragColor决定)。
a_position是当前顶点坐标,在WebGL中可以通过JS绑定它。
注意:自己声明的变量不能以gl_开头。
存储限定符
GLSL变量声明的格式为: [存储限定符] [变量类型] [变量名]
例如:
attribute vec4 a_Position;
uniform float g_Time;
varying vec2 v_TexCoord;
存储限定符也叫变量修饰符,类似于JS中的var、let、const
- attribute - 与单个顶点相关的只读变量,只用在顶点着色器中。数据来自当前的顶点状态或者顶点数组。它必须在全局声明,不能在函数内部声明。
- uniform - 与所有顶点相关的一致变量。在着色器执行期间一致变量的值是不变的。这个值在编译时是未知的,由着色器外部提供(在WebGL中,需要通过JS给其提供值)。 一致变量在顶点着色器和片元着色器中是共享的,只能在全局声明。
- varying - 可变量,可变量是顶点着色器向片元着色器传值的方式,依照渲染的图元的不同(图元一共有三种:点、线、三角),顶点着色器设置的可变量会在片元着色器运行中获取不同的插值。
WebGL示例
一个简单的WebGL示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL Demo</title>
</head>
<body>
<canvas id="cvs" width="500" height="500"></canvas>
</body>
<script>
const cvs = document.querySelector("#cvs")
const gl = cvs.getContext('webgl')
// 顶点着色器代码
const vsSource = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
// 片元着色器代码
const fsSource = `
void main() {
gl_FragColor = vec4(0.0, 1.0, 1.0, 1.0);
}
`
// 创建着色器
function createShader(gl, type, source) {
const shader = gl.createShader(type)// 创建着色器
gl.shaderSource(shader, source)// 着色器源码
gl.compileShader(shader)// 编译
// 检查编译状态
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
return
}
return shader
}
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource)
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource)
// 创建着色程序
function createProgram(gl, vs, fs) {
const program = gl.createProgram()
// 附加着色器程序
gl.attachShader(program, vs)
gl.attachShader(program, fs)
// 链接着色器程序
gl.linkProgram(program)
// 检查链接状态
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
return
}
return program
}
const program = createProgram(gl, vs, fs)
// 使用着色程序
gl.useProgram(program)
// 顶点数据
const position = new Float32Array([
-0.5, -0.5,
0.5, -0.5,
0.0, 0.5
])
// 绑定变量a_position到着色器
const positionLocation = gl.getAttribLocation(program, 'a_position')
// 创建缓冲区
const buffer = gl.createBuffer()
// 绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
// 写入顶点数据到缓冲区
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW)
// 启用顶点属性
gl.enableVertexAttribArray(positionLocation)
// 绑定缓冲区到顶点属性
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
// 清空画布
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)
// 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, 3)
</script>
</html>
以上代码的效果就是在画布中指定一个三角形区域,将其绘制为蓝绿色。
使用GlslCanvas
想要自己从零开始通过WebGL运行小红车里的GLSL代码还是比较麻烦的,其中还涉及比较复杂的坐标转换。
对此,我们可以使用GlslCanvas,借助它可以轻松将GLSL代码渲染到canvas中。
安装
npm install glslCanvas
使用示例
import GlslCanvas from 'glslCanvas'
const canvas = document.querySelector("#cvs")
const glsl = new GlslCanvas(canvas)
// 顶点着色器代码
const string_vert_code = `
attribute vec2 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
v_TexCoord = a_TexCoord;
}
`
// 片元着色器代码
const string_frag_code = `
#ifdef GL_ES
precision highp float;
#endif
varying vec2 v_TexCoord;
void main() {
gl_FragColor = vec4(v_TexCoord, 1.0, 1.0);
}
`
glsl.load(string_frag_code, string_vert_code)
简单解释一下:
- 片元着色器中需要指定浮点精度,也就是上面
string_frag_code的前三行代码,精度可以是lowp、mediump或highp。 a_TexCoord绑定的坐标为整个画布,横、纵坐标取值范围都是[0, 1],(0, 0)位于左下角,(1, 1)位于右上角。- 如前文所述,varying是顶点着色器向片元着色器的传值方式。这里
a_TexCoord在顶点着色器中赋值给v_TexCoord,v_TexCoord可以传递给片元着色器。 gl_FragColor被赋值为vec4(v_TexCoord, 1.0, 1.0);,所以画布左下角的颜色是vec4(0,0,1,1),左上角vec4(0,1,1,1),右上角vec4(1,1,1,1),右下角vec4(0,1,1,1)。
其效果如下:
将GLSL代码改造成WebGL版本
Wallpaper Engine里的源码
小红车里的水波纹效果源码如下:
顶点着色器 waterripple.vert
// [COMBO] {"material":"ui_editor_properties_perspective","combo":"PERSPECTIVE","type":"options","default":0}
#include "common.h"
#include "common_perspective.h"
uniform mat4 g_ModelViewProjectionMatrix;
uniform vec4 g_Texture1Resolution;
#if MASK
uniform vec4 g_Texture2Resolution;
#endif
attribute vec3 a_Position;
attribute vec2 a_TexCoord;
varying vec4 v_TexCoord;
#if PERSPECTIVE == 0
varying vec4 v_TexCoordRipple;
uniform vec4 g_Texture0Resolution;
uniform float g_Time;
uniform float g_AnimationSpeed; // {"material":"animationspeed","label":"ui_editor_properties_animation_speed","default":0.15,"range":[0,0.5]}
uniform float g_Scale; // {"material":"scale","label":"ui_editor_properties_ripple_scale","default":1,"range":[0,10]}
uniform float g_ScrollSpeed; // {"material":"scrollspeed","label":"ui_editor_properties_scroll_speed","default":0,"range":[0,0.5]}
uniform float g_Direction; // {"material":"scrolldirection","label":"ui_editor_properties_scroll_direction","default":0,"range":[0,6.28],"direction":true,"conversion":"rad2deg"}
uniform float g_Ratio; // {"material":"ratio","label":"ui_editor_properties_ratio","default":1,"range":[0,10]}
#else
uniform vec2 g_Point0; // {"material":"point0","label":"p0","default":"0 0"}
uniform vec2 g_Point1; // {"material":"point1","label":"p1","default":"1 0"}
uniform vec2 g_Point2; // {"material":"point2","label":"p2","default":"1 1"}
uniform vec2 g_Point3; // {"material":"point3","label":"p3","default":"0 1"}
varying vec3 v_TexCoordPerspective;
#endif
void main() {
gl_Position = mul(vec4(a_Position, 1.0), g_ModelViewProjectionMatrix);
v_TexCoord = a_TexCoord.xyxy;
#if PERSPECTIVE == 0
vec2 coordsRotated = v_TexCoord.xy;
vec2 coordsRotated2 = v_TexCoord.xy * 1.333;
vec2 scroll = rotateVec2(vec2(0, 1), g_Direction) * g_ScrollSpeed * g_ScrollSpeed * g_Time;
v_TexCoordRipple.xy = coordsRotated + g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
v_TexCoordRipple.zw = coordsRotated2 - g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
v_TexCoordRipple *= g_Scale;
float rippleTextureAdjustment = (g_Texture0Resolution.x / g_Texture0Resolution.y);
v_TexCoordRipple.xz *= rippleTextureAdjustment;
v_TexCoordRipple.yw *= g_Ratio;
#else
mat3 xform = inverse(squareToQuad(g_Point0, g_Point1, g_Point2, g_Point3));
v_TexCoordPerspective = mul(vec3(a_TexCoord.xy, 1.0), xform);
#endif
#if MASK == 1
v_TexCoord.zw = vec2(v_TexCoord.x * g_Texture2Resolution.z / g_Texture2Resolution.x,
v_TexCoord.y * g_Texture2Resolution.w / g_Texture2Resolution.y);
#endif
}
片元着色器 waterripple.frag
// [COMBO_OFF] {"material":"ui_editor_properties_specular","combo":"SPECULAR","type":"options","default":0}
#include "common.h"
varying vec4 v_TexCoord;
varying vec2 v_Scroll;
uniform sampler2D g_Texture0; // {"hidden":true}
uniform sampler2D g_Texture1; // {"label":"ui_editor_properties_opacity_mask","mode":"opacitymask","combo":"MASK","paintdefaultcolor":"0 0 0 1"}
uniform sampler2D g_Texture2; // {"label":"ui_editor_properties_water_normal"}
uniform float g_Strength; // {"material":"ripplestrength","label":"ui_editor_properties_ripple_strength","default":0.1,"range":[0,1]}
uniform float g_SpecularPower; // {"material":"ripplespecularpower","label":"ui_editor_properties_ripple_specular_power","default":1.0,"range":[0,100]}
uniform float g_SpecularStrength; // {"material":"ripplespecularstrength","label":"ui_editor_properties_ripple_specular_strength","default":1.0,"range":[0,10]}
uniform vec3 g_SpecularColor; // {"material":"ripplespecularcolor","label":"ui_editor_properties_ripple_specular_color","default":"1 1 1","type":"color"}
#if PERSPECTIVE == 0
varying vec4 v_TexCoordRipple;
#else
uniform vec4 g_Texture0Resolution;
uniform float g_Time;
uniform float g_AnimationSpeed; // {"material":"animationspeed","label":"ui_editor_properties_animation_speed","default":0.15,"range":[0,0.5]}
uniform float g_Scale; // {"material":"scale","label":"ui_editor_properties_ripple_scale","default":1,"range":[0,10]}
uniform float g_ScrollSpeed; // {"material":"scrollspeed","label":"ui_editor_properties_scroll_speed","default":0,"range":[0,0.5]}
uniform float g_Direction; // {"material":"scrolldirection","label":"ui_editor_properties_scroll_direction","default":0,"direction":true,"conversion":"rad2deg"}
uniform float g_Ratio; // {"material":"ratio","label":"ui_editor_properties_ratio","default":1,"range":[0,10]}
varying vec3 v_TexCoordPerspective;
#endif
void main() {
vec2 texCoord = v_TexCoord.xy;
#if MASK == 1
float mask = texSample2D(g_Texture1, v_TexCoord.zw).r;
#else
float mask = 1;
#endif
vec4 rippleCoords;
#if PERSPECTIVE == 0
rippleCoords = v_TexCoordRipple;
#else
vec2 coordsRotated = v_TexCoordPerspective.xy / v_TexCoordPerspective.z;
vec2 coordsRotated2 = coordsRotated * 1.333;
vec2 scroll = rotateVec2(vec2(0, 1), g_Direction) * g_ScrollSpeed * g_ScrollSpeed * g_Time;
rippleCoords.xy = coordsRotated + g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
rippleCoords.zw = coordsRotated2 - g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
rippleCoords *= g_Scale;
float rippleTextureAdjustment = (g_Texture0Resolution.x / g_Texture0Resolution.y);
rippleCoords.xz *= rippleTextureAdjustment;
rippleCoords.yw *= g_Ratio;
mask *= step(0.0, v_TexCoordPerspective.z);
#endif
vec3 n1 = texSample2D(g_Texture2, rippleCoords.xy).xyz * 2 - 1;
vec3 n2 = texSample2D(g_Texture2, rippleCoords.zw).xyz * 2 - 1;
vec3 normal = normalize(vec3(n1.xy + n2.xy, n1.z));
texCoord.xy += normal.xy * g_Strength * g_Strength * mask;
gl_FragColor = texSample2D(g_Texture0, texCoord);
#if SPECULAR == 1
vec2 direction = vec2(0.5, 0.0) - v_TexCoord.xy;
direction = normalize(direction);
float specular = max(0.0, dot(normal.xy, direction)) * max(0.0, dot(direction, vec2(0.0, -1.0)));
specular = pow(specular, g_SpecularPower) * g_SpecularStrength;
gl_FragColor.rgb += specular * g_SpecularColor * gl_FragColor.a;
#endif
}
老实说,我也没有全看懂以上代码,但只是改造一下的话并不难,需要修改的点包括:
-
给片元着色器添加浮点精度。
-
删除注释。这些注释在壁纸编辑器里是有意义的,它们与编辑器的UI交互有关,但在WebGL中没什么用。
-
删透视配置,即
PERSPECTIVE == 1时的预编译指令。 -
删除头文件,编辑器提供的头文件主要是提供了一些工具函数,例如
rotateVec2函数,删除后这些函数需要手动实现。 -
将函数改为标准的GLSL写法,例如
texSample2D函数改为texture2D,frac函数改为fract,mul(x, y)函数改为x * y等等。 -
浮点数没有写小数部分的,需要将小数部分补上,例如
1需要改为1.0。
小红车里的其他效果也可以根据这些点改造。
改造后的代码
顶点着色器
uniform vec4 g_Texture1Resolution;
uniform vec4 g_Texture2Resolution;
attribute vec3 a_Position;
attribute vec2 a_TexCoord;
varying vec4 v_TexCoord;
varying vec4 v_TexCoordRipple;
uniform vec4 g_Texture0Resolution;
uniform float g_Time;
uniform float g_AnimationSpeed;
uniform float g_Scale;
uniform float g_ScrollSpeed;
uniform float g_Direction;
uniform float g_Ratio;
vec2 rotationVec2(vec2 v, float angle) {
float s = sin(angle);
float c = cos(angle);
return vec2(c * v.x - s * v.y, s * v.x + c * v.y);
}
void main() {
gl_Position = vec4(a_Position, 1.0);
v_TexCoord = a_TexCoord.xyxy;
v_TexCoord.y = 1.0 - v_TexCoord.y;
v_TexCoord.w = 1.0 - v_TexCoord.y;
vec2 coordsRotated = v_TexCoord.xy;
vec2 coordsRotated2 = v_TexCoord.xy * 1.333;
vec2 scroll = rotationVec2(vec2(0.0, 1.0), g_Direction) * g_ScrollSpeed * g_ScrollSpeed * g_Time;
v_TexCoordRipple.xy = coordsRotated + g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
v_TexCoordRipple.zw = coordsRotated2 - g_Time * g_AnimationSpeed * g_AnimationSpeed + scroll;
v_TexCoordRipple *= g_Scale;
float rippleTextureAdjustment = (g_Texture0Resolution.x / g_Texture0Resolution.y);
v_TexCoordRipple.xz *= rippleTextureAdjustment;
v_TexCoordRipple.yw *= g_Ratio;
v_TexCoord.zw = vec2(v_TexCoord.x * g_Texture2Resolution.z / g_Texture2Resolution.x,
v_TexCoord.y * g_Texture2Resolution.w / g_Texture2Resolution.y);
}
片元着色器
#ifdef GL_ES
precision mediump float;
#endif
varying vec4 v_TexCoord;
varying vec2 v_Scroll;
uniform sampler2D g_Texture0;
uniform sampler2D g_Texture1;
uniform sampler2D g_Texture2;
uniform float g_Strength;
varying vec4 v_TexCoordRipple;
void main() {
vec2 texCoord = v_TexCoord.xy;
float mask = texture2D(g_Texture1, vec2(texCoord.x, 1.0 - texCoord.y)).r;
vec4 rippleCoords = fract(v_TexCoordRipple);
vec3 n1 = texture2D(g_Texture2, rippleCoords.xy).xyz * 2.0 - 1.0;
vec3 n2 = texture2D(g_Texture2, rippleCoords.zw).xyz * 2.0 - 1.0;
vec3 normal = normalize(vec3(n1.xy + n2.xy, n1.z));
texCoord.xy += normal.xy * g_Strength * g_Strength * mask;
gl_FragColor = texture2D(g_Texture0, vec2(texCoord.x, 1.0 - texCoord.y));
}
所有uniform变量都可以由GlslCanvas的setUniform方法设置:
glsl.setUniform('g_CanvasResolution', canvas.width, canvas.height, 1, 1)
glsl.setUniform('g_AnimationSpeed', 0.15)
glsl.setUniform('g_Scale', 5.0)
glsl.setUniform('g_ScrollSpeed', 0.15)
glsl.setUniform('g_Direction', 270 * Math.PI / 180)
glsl.setUniform('g_Ratio', 1.0)
glsl.setUniform('g_Strength', 0.1)
let time = 0
const loop = function () {
time += 0.01
glsl.setUniform('g_Time', time)
requestAnimationFrame(loop)
}
loop()
加载图片纹理:
function loadImage(url, uniformName) {
var resolutionName = uniformName + 'Resolution';
var img = new Image();
img.src = url;
img.onload = function () {
glsl.setUniform(uniformName, url);
glsl.setUniform(resolutionName, img.naturalWidth, img.naturalHeight, 1, 1);
}
img.onerror = function () {
console.error('Failed to load image:', url);
}
}