为了将Wallpaper Engine中的水波纹效果移植到WebGL,学习一下GLSL

410 阅读10分钟

为了将Wallpaper Engine中的水波纹效果移植到WebGL,学习一下GLSL

最终效果预览

预览链接:Water Ripple

项目地址:taiyuuki/webgl-water-ripple

我已经将这个水波纹效果发布到npm,如果你也想使用,可以去我的项目地址里查看具体怎么使用。

前言——关于小红车

著名的动态壁纸软件Wallpaper Engine(由于它的steam主页图片是一辆红色轿车,常被大家叫做小红车),它内置了一个强大的壁纸编辑器,很简单就能做出一些酷炫的效果,例如水波涟漪效果——

wr.preview.gif

小红车内置的这些效果,其源码都是用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>

以上代码的效果就是在画布中指定一个三角形区域,将其绘制为蓝绿色。

wr.002.png

使用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的前三行代码,精度可以是lowpmediumphighp
  • a_TexCoord绑定的坐标为整个画布,横、纵坐标取值范围都是[0, 1],(0, 0)位于左下角,(1, 1)位于右上角。
  • 如前文所述,varying是顶点着色器向片元着色器的传值方式。这里a_TexCoord在顶点着色器中赋值给v_TexCoordv_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)。

其效果如下:

wr.003.png

将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函数改为texture2Dfrac函数改为fractmul(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);
    }
}