前端Shader实现水面特效

风小楼WindJack教程shaderweb大约 11 分钟

最终效果

先看最终效果:

背景

产品希望给首页的水面背景添加特效,使水面更加生动。

背景图类似下面这张:

一般来说,前端提到特效可能会想到使用视频、序列帧动画,Lottie等方案。

然而针对水面特效,以上方案会引入额外的资源加载,这对首页并不友好。并且由于资源大小限制,最终的效果不一定能很好。

那么有没什么不用引入过多额外资源,效果又不错的方案?

分析

实际上,对于游戏客户端领域,水面效果是常见的需求,也是大家一直在致力研究的方向之一。简单的水面效果往往可以通过Shader(着色器)来实现。

Webgl早就被现代浏览器广泛支持了,因此,Web前端也可以基于Webgl 以及 Shader实现简单的水面效果。

由于Webgl的编码比较繁琐,有一定的上手门槛。因此本文将会使用PIXI.js来间接操作Webgl以及Shader。

PIXI.js[1]是一个非常快且轻量级的2D 渲染库。它封装了便于开发的接口,使得开发者无需深入掌握Webgl API也可以享受Webgl带来的好处。在不兼容的情况下,它也支持退回到H5 canvas模式。可以把它当作是2D领域上的Three.js。

本次需求将会实现水面效果的两个部分:

  • 水面的波动效果

  • 水面的焦散效果

基于上面两个效果已经能实现不错的水面特效了。

实现

1、 从展示一张图片开始

首先我们通过PIXI.js显示我们的背景图。

(1) 在项目中引入PIXI.js:

npm install pixi.js@6.1.0 --save

(2) 初始化PIXI.js

import * as PIXI from 'pixi.js';

const canvas = document.getElementById('canvas');

const app = new PIXI.Application({
  resizeTo: canvas,
  antialias: true, // default:false 开启抗锯齿
  transparent: false, // 是否开启透明通道
  backgroundColor: 0x00000
});

canvas.appendChild(app.view);

(3) 创建一个图片,并显示出来。

首先我们定义一个平面需要的网格顶点数据以及纹理坐标。

	const designSize = { w: 1948, h: 1122 }; // 背景图片的宽高
    const geometry = new PIXI.Geometry()
      .addAttribute(
        'aVertexPosition', // the attribute name
        [
          0,
          0, // x, y
          designSize.w,
          0, // x, y
          designSize.w,
          designSize.h,
          0,
          designSize.h
        ], // x, y
        2 // the size of the attribute
      ) 
      .addAttribute(
        'aUvs', // the attribute name
        [
          0,
          0, // u, v
          1,
          0, // u, v
          1,
          1,
          0,
          1
        ], // u, v
        2  // the size of the attribute
      )
      .addIndex([0, 1, 2, 0, 2, 3]); // 三角形索引,一个矩形由两个三角形组成

定义顶点着色器(Vertex Shader)以及片元着色器(Fragment Shader)。

  // 顶点着色器
  const vertexSrc = `
    precision mediump float;

    attribute vec2 aVertexPosition;
    attribute vec2 aUvs;

    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;

    varying vec2 vUvs;

    void main() {

        vUvs = aUvs;
        gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);

    }`;
 // 片元着色器
 const fragmentSrc = `
   precision mediump float;

   varying vec2 vUvs;

   uniform sampler2D uSampler;

   void main() {

       gl_FragColor = texture2D(uSampler, vUvs);

   }`;
 
  const uniforms = {
    uSampler: PIXI.Texture.from(require('@/assets/images/index/backgroundNew.png')),
  };
  
  const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniforms);

通过PIXI.Mesh对象把上面定义的网格数据以及着色器关联起来,成为一个可以显示的对象,并添加到舞台上。

const quad = new PIXI.Mesh(geometry, shader);
app.stage.addChild(container);

这样就能在网页上看到背景图片了。

你可能会疑惑,天呐,只是显示一张图片就要这么多的代码。这其实都是为了能够修改着色器代码片段,做出不同的渲染效果。

要理解上面的代码可能需要一点渲染流水线的知识。简单地说,我们把渲染所需要的数据(如网格顶点,纹理贴图等)提交到显存,GPU通过一定的坐标变换和计算,最终把像素色值输出到屏幕上。这个过程,很多步骤都是内置程序固定好的,我们无法干涉。但也有一些步骤暴露了出来,以便开发者可以修改渲染的效果。上面的顶点着色器和片元着色器就是我们可以修改的步骤之一。下图显示了渲染流水线的主要阶段。

几何阶段一个重要的工作,就是对网格中的顶点数据做一系列的坐标变换,计算这些顶点在屏幕最终绘制的位置。

顶点着色器的最基本的任务,就是把顶点坐标从网格数据的模型空间变换到裁剪空间。上面的顶点着色器代码显示了如何实现这一过程:

gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);

如果你对线性代数还有印象,你应该会记得我们可以通过矩阵实现各种几何变换。其中projectionMatrix和translationMatrix是PIXI帮我们处理好的变换矩阵。在着色器中可以直接使用。translationMatrix[2] [3] 用于模型空间到世界空间的变换。projectionMatrix[4]则可用于将点从世界空间转换为标准化设备坐标(NDC)。gl_Position是一个齐次坐标,由于是平面的,所以我们把z设为0,第四维w是透视投影的结果,由于是二维平面(正交投影),因此这个值设置为1。

片元着色器则用于计算像素颜色,上面的片元着色器直接对纹理进行采样,输出颜色值:

gl_FragColor = texture2D(uSampler, vUvs);

其中,uSampler是背景图,uvs是纹理坐标,在生成geometry的时候,我们就已经对顶点和纹理设定了映射关系。渲染的时候会自动进行插值。

2、 实现水面波动效果

水面波动的效果实现很简单,首先我们使图像发生类似水纹的扭曲,然后再让扭曲的地方位移就可以了。 这里我们通过用一张噪声图去使采样的uv发生偏移,从而实现图像的扭曲。

噪声图[5]如下:

修改uniform变量,添加噪声图的纹理。

const uniforms = {
    uSampler: PIXI.Texture.from(require('@/assets/images/index/backgroundNew.png')),
    uWave: PIXI.Texture.from(require('@/assets/images/index/waterWave.png'))
  };
uniforms.uWave.baseTexture.wrapMode = PIXI.WRAP_MODES.REPEAT; // 设置超过纹理坐标时纹理循环

修改片元着色器,采样的时候以噪声图的xy值再乘以一个系数作为偏移值。通过系数可以调整偏移的效果。

  const fragmentSrc = `
    precision mediump float;

    varying vec2 vUvs;

    uniform sampler2D uSampler;
    uniform sampler2D uWave;

    void main() {
        vec2 offset = texture2D(uWave,vUvs).xy * 0.015;
        gl_FragColor = texture2D(uSampler, vUvs + offset);

    }`;

这时会看到背景图片多了一些波纹。

接着我们给波纹加上位移。首先我们添加一个回调函数,每帧都会使得time变量增大。我们把time变量传到着色器里,用于计算位于。

let time = 0; // 时间

const uniforms = {
    uSampler: PIXI.Texture.from(require('@/assets/images/index/backgroundNew.png')),
    uWave: PIXI.Texture.from(require('@/assets/images/index/waterWave.png')),
    time
  };
  
 app.ticker.add(() => {
   time += 1 / 60;
   quad.shader.uniforms.time = time;
 });

接着在shader中计算位移

  const fragmentSrc = `
    precision mediump float;


    varying vec2 vUvs;
    uniform float time;
    uniform sampler2D uSampler;
    uniform sampler2D uWave;

    void main() {
        vec2 s = time * 0.13 * vec2(0, 0.3);
        vec2 offset = texture2D(uWave,vUvs + s).xy * 0.015;
        gl_FragColor = texture2D(uSampler, vUvs + offset);

    }`;

其中vec2(0, 0.3)是位移的方向。uv是一个左下角原点(0,0),右上角(1,1)的二维坐标系。0.13是一个调控速度的因子。

这样就能看到波纹位移的效果了。但是会发现天空也有波纹在位移,我们再调整一下片元着色器的输出,只在水面的区域产生扭曲和位移。

gl_FragColor =  vUvs.y < 0.223 ? texture2D(uSampler,vUvs) : texture2D(uSampler, vUvs + offset);

至此,应该能看见以下效果:

3、 实现水面焦散效果

焦散是一种由曲面引起的光反射现象,这会让我们的水面看起来“波光粼粼”。

要真实地复刻这种效果是非常困难的,我们还是通过纹理贴图的方式来模拟。只要看起来效果是好的,那它就是对的。

[6]为此做了试验。要想达到好的效果,需要以下三个步骤:

  • 以不同的速度,尺寸重复采样纹理两次。
  • 使用min函数混合两次采样的结果。
  • 在采样时拆分RGB通道。

现在我们来实现上面三个步骤。

首先引入纹理图片:

const uniforms = {
   uSampler: PIXI.Texture.from(require('@/assets/images/index/backgroundNew.png')),
   uWave: PIXI.Texture.from(require('@/assets/images/index/waterWave.png')),
   time,
   uCaustic: PIXI.Texture.from(require('@/assets/images/index/caustic.png'))
 };
 uniforms.uWave.baseTexture.wrapMode = PIXI.WRAP_MODES.REPEAT; // 设置超过纹理坐标时纹理循环
 uniforms.uCaustic.baseTexture.wrapMode = PIXI.WRAP_MODES.REPEAT; // 同上
 const fragmentSrc = `
 	...
 	uniform sampler2D uCaustic;
 `

定义焦散纹理的采样函数:

 const fragmentSrc = `
 	...
 	uniform sampler2D uCaustic;

    vec3 caustic()
    {
      float strength = 2.7;
      float scale = 0.8;

      vec4 sample1 = texture2D(uCaustic, vUvs * 1./scale);
      vec4 sample2 = texture2D(uCaustic, vUvs * -1./scale);
      vec3 textureCombined = min(sample1.rgb, sample2.rgb);

      return strength * textureCombined;
    }
 `

其中scale用于控制焦散纹理的缩放,我们对焦散纹理进行了两次采样,第二次通过反转纹理(*-1)以达到不同速度,尺寸的目的。最后使用min函数对两次采样的结果进行混合。strength作为强度的印象因子。

把采样的纹理添加到背景图片的纹理上进行输出:

 void main() {
    vec3 causticColor = vec3(0.13, 0.12, 0.39);
    vec2 s = time * 0.13 * vec2(0, 0.3);
    vec2 offset = texture2D(uWave,vUvs + s).xy * 0.015;
    vec4 o = texture2D(uSampler, vUvs + offset);
    vec3 causticSampleColor = caustic() * causticColor;
    o.rgb += causticSampleColor;
    gl_FragColor =  vUvs.y < 0.223 ? texture2D(uSampler,vUvs) : o;
}`

这里causticColor用于防止采样的结果过暴,同时对采样的结果进行一定的颜色调整,以达到更好的显示效果。

此时会看到如下效果:

焦散的纹理已经添加到图片上了,但是并不明显,我们再加一些动画:

    vec3 caustic()
    {
      float strength = 2.7;
      float scale = 0.8;
      float speed = 0.08;

      vec2 s = time * speed * vec2(0, 1.0);
      vec4 sample1 = texture2D(uCaustic, vUvs * 1./scale + s);
      vec4 sample2 = texture2D(uCaustic, vUvs * -1./scale + s);
      vec3 textureCombined = min(sample1.rgb, sample2.rgb);

      return strength * textureCombined;
    }

这样焦散纹理就动起来了,已经非常接近文章开篇展示的效果。

最后我们实现在采样时拆分RGB通道。为什么要进行拆分呢?这是因为不同波长的光在穿过介质时会产生不同的衍射。这意味着光在水中移动时可以“分裂”成不同的颜色。

为了模拟这种效果,我们可以在采样焦散纹理时,拆分成RGB三个通道,并给他们加上微小的偏移。 首先定义一个拆分的方法:

 vec3 rgbSplit(float split, sampler2D tex, vec2 uv)
 {
     vec2 UVR = uv + vec2(split, split);
     vec2 UVG = uv + vec2(split, -split);
     vec2 UVB = uv + vec2(-split, -split);

     float r = texture2D(tex, UVR).r;
     float g = texture2D(tex, UVG).g;
     float b = texture2D(tex, UVB).b;

     return vec3(r,g,b);
 }

然后调用:

vec3 caustic()
{
  float strength = 2.7;
  float scale = 0.8;
  float speed = 0.08;
  float split = 0.5 * 0.01;

  vec2 s = time * speed * vec2(0, 1.0);
  vec3 sample1 = rgbSplit(uCaustic, split, vUvs * 1./scale + s);
  vec3 sample2 = rgbSplit(uCaustic, split, vUvs * -1./scale + s);
  vec3 textureCombined = min(sample1.rgb, sample2.rgb);

  return strength * textureCombined;
}

效果如下:

4、 一点优化

现在效果已经相当不错了。但是水面和非水面的边界可能会有一些明显,有一点突兀的感觉。这里我们通过mix函数及smoothstep进行边界处的平滑处理。

下面shader代码的作用是:当水面接近边界时,呈现白色。

o.rgb = mix(o.rgb, vec3(1., 1., 1.) , smoothstep(0.68, 0.89 ,1.0 - vUvs.y)).rgb;
gl_FragColor =  vUvs.y < 0.223 ? texture2D(uSampler,vUvs) : o;

mix用于把两种颜色混合起来;smoothstep则是平滑函数:

float smoothstep(float t1, float t2, float x) {
  // Scale, bias and saturate x to 0..1 range
  x = clamp((x - t1) / (t2 - t1), 0.0, 1.0); 
  // Evaluate polynomial
  return x * x * (3 - 2 * x);
}

smoothstep[7]函数接受的参数有三个。其中:

  • t1 代表样条插值函数的下界;
  • t2 代表样条插值函数的上界;
  • x 代表用于插值的源输入。

x会先按照给定的上界和下界对给定的源输入进行归一化,使输出值在0到1之间。之后将该值运用到样条插值函数中。当x等于t1时,结果值为0、当x等于t2时,结果值为1。

该函数的输出值是界于0到1之间的数。一般情况下,如果我们想要创建一个能够输出平滑过渡的阈值函数,smoothstep就是很好的选择。

最后我们再给水面整体加一点偏色,就能达到文章开头的效果:

#define colorStepUv 5.7
vec3 color = vec3(0.54, 0.74, 0.97);

void main() {
	...
    o.rgb *= smoothstep(-0.1, 1.5, vUvs.y * colorStepUv * color.rgb);
    o.rgb = mix(o.rgb, vec3(1., 1., 1.) , smoothstep(0.68, 0.89 ,1.0 - vUvs.y)).rgb;
    gl_FragColor =  vUvs.y < 0.223 ? texture2D(uSampler,vUvs) : o;
}

引用及参考


  1. https://github.com/pixijs/pixijsopen in new window ↩︎

  2. https://pixijs.download/v5.1.2/docs/packages_graphics_src_Graphics.js.htmlopen in new window ↩︎

  3. https://api.pixijs.io/@pixi/display/PIXI/DisplayObject.html#worldTransformopen in new window ↩︎

  4. https://api.pixijs.io/@pixi/core/PIXI/ProjectionSystem.htmlopen in new window ↩︎

  5. https://store.cocos.com/app/detail/3900open in new window ↩︎

  6. https://www.alanzucconi.com/2019/09/13/believable-caustics-reflectionsopen in new window ↩︎

  7. https://www.jianshu.com/p/66035ae91bfdopen in new window ↩︎

上次编辑于:
Loading...