从Element3入门WebGL Shader(一)

12,892 阅读5分钟

总目录

  • 入门
    • 环境配置
    • 第一个可运行的Shader
    • 绘制图形——长方形和圆形
    • 认识SmoothStep
  • 初探GLSL
    • 向量与矩阵
    • 浮点数精度
    • uniform
  • 颜色与形状
    • 颜色基本知识
    • 生成渐变色
    • 圆角矩形渲染
    • 多变形的渲染
  • 数学与图形
    • 极坐标系
    • 向量几何
    • 三角函数
    • 分型
  • 生成艺术
    • 噪声
    • 噪声场
    • 叠加
    • 模糊

环境配置

step1. 首先当然是安装nodejs,我们可以选择从nodejs.org下载对应的操作系统和CPU指令集的安装包,也可以用homebrew、apt等工具安装,多数前端工程师都已经有nodejs环境,此处不详细展开了。

step2. (可选)全局安装vite,为了比较方便地使用vite,建议全局安装vite。如果不全局安装vite,我们必需利用npx执行本项目的vite。使用npm install -g vite命令即可。

step3. 初始化项目,在一个喜欢的路径创建一个新的目录,比如这里我创建了一个element3-demo

mkdir element3-demo
cd element3-demo

进入目录后,执行npm init,并填写必要信息。之后,我们得到了一个基础的package.json文件。

step4. 接下来,我们为项目添加依赖,并安装相关包

首先我们用自己喜欢的文本编辑工具打开package.json,并且为它添加dependencies和devDependencies:

{
  "dependencies": {
    "element3-core": "0.0.7",
    "vue": "^3.0.5"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^1.2.2",
    "@vue/compiler-sfc": "^3.0.5",
    "rollup-plugin-element3-webgl": "0.0.5",
    "typescript": "^4.1.3",
    "vite": "^2.3.0",
    "vue-tsc": "^0.0.24"
  }
}

之后我们回到终端,使用npm install命令。

step5. 创建文件和基本目录结构。

编写index.html文件:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

编写src/main.ts文件:

import { createApp } from "vue";
import App from "./App.vue";

createApp(App).mount("#app");

编写src/app.vue文件:

<template>
<div>
    Hello
</div>
</template>
<script lang="ts">

import { defineComponent } from "vue";

export default defineComponent({
  name: "App",
  components: {

  },
  setup(){
    return {
      
    }
  }
});

</script>

编写vite.config.js文件:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import element3Webgl from "rollup-plugin-element3-webgl";

// https://vitejs.dev/config/
export default defineConfig({
  base: "/", // TODO 开发环境是 / 生产环境是 /webgl
  plugins: [vue(), element3Webgl()],
});

编写完成后,我们在命令行使用npx vite,打开网页看到Hello表明环境已经配置好。

第一个可运行的 Shader

接下来我们创建一个 src/pure.frag 文件。

Fragment Shader 使用的语言并非 JavaScript,而是一种叫做 GLSL 的专用语言,在后面的教程中,我会逐渐为大家介绍这门语言特性,这里我们先尝试写出第一个可运行的Fragment Shader。

我们首先要理解 Fragment Shader 的概念,一段 Fragment Shader 是绘制屏幕上一个点的过程。它的执行频率非常高,绘制一个100x100区域的图像,需要执行10000次 Shader 中的代码,Shader通常是由GPU承担的。

接下来我们编写一段代码,把画布区域涂上纯色:

precision mediump float;

void main(){
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

element3的rollup插件能够直接把这个Shader代码加载成一个vue组件,这能够帮助我们忽略掉调用 WebGL API的冗繁过程。

接下来我们更改App.vue的代码,展示这个绘制的效果:

<template>
<div>
    <DrawBlock width=100 height=100></DrawBlock>
</div>
</template>
<script lang="ts">

import { defineComponent } from "vue";
import DrawBlock from "./pure.frag";

export default defineComponent({
  name: "App",
  components: {
    DrawBlock
  },
  setup(){
    return {
      
    }
  }
});

</script>

我们可以看到一个纯红色的方块区域。

接下来我们来稍微解释一下这段 GLSL 代码。

我们首先来看第一句:precision mediump float; 。这一句是必要的,他规定了程序的全局浮点数精度,此处使用了中等精度,几乎每一个Fragment Shader代码都会包含这一句,我们可以暂且认为它是固定的。

一切GLSL代码都是从main函数开始执行的。在GLSL中,main函数可以不返回值,这种函数我们用void来代替类型的部分。

接下来我们来看main函数的函数体,函数体中只有一个语句。这里我们使用了一个 gl_fragColor 变量,这个名字是GLSL语言规定的名字,并不是可以随意命名的变量,我们前面讲过,Fragment Shader 是绘制一个点的代码,这个 gl_fragColor 就是我们最后要输出的点的颜色。

接下来我们看等号的另一端,这里的vec4表示一个长度为4的浮点数向量类型,它里面可以存储4个浮点数。大家还记得线性代数里学习的向量吧?这里的vec4就是来自数学中的向量概念。使用起来它有点像JavaScript中的数组,不同的是,它是固定长度的,这样的数据结构对图形算法非常的有用,我们将会在未来与它打很多交道。

最后提醒一下,GLSL语言不允许省略分号,忘记的话会导致整个程序无法编译,一定要注意哦。

进行到这一步,我们已经学会了如何使用element3的rollup插件来加载一段Fragment Shader,获得了一个基本的代码调试和运行的环境。

接下来,我们学习一下如何控制Shader绘制一些想要的东西。

绘制图形——长方形和圆形

首先,我们尝试缩小一下绘制的范围,要想控制范围,我们必须要知道当前所绘制的点的坐标,这时候,我们就要介绍GLSL中另一个重要的变量了: gl_fragCoord

如果说gl_fragColor是Fragment Shader的输出的话,gl_fragCoord 就是Fragment Shader的输入了,它表示的是当前绘制点的坐标,它是一个vec4类型,但这里我们只需要用到它的前两项。

我们可以分别使用 gl_fragCoord.xgl_fragCoord.y 来访问它的坐标,也可以使用 gl_fragCoord.xy 来把它变为2维向量。

那么,回到我们的问题,如何绘制一个长方形呢?我们只需要判断一下它的坐标范围就可以了,请看示例代码:

precision mediump float;

void main(){
    if(gl_FragCoord.x > 25.0 && gl_FragCoord.x < 75.0 && 
        gl_FragCoord.y > 25.0 && gl_FragCoord.y < 75.0)
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    else
        gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0);
}

这里我们要注意,很多同学从JS带来的习惯是整数类型和浮点数类型不区分,而在GLSL中,整数和浮点数是完全两种类型,不进行强制转换的话,没法混合运算。当我们写直接量时,也要非常明确地带上小数点,表示这是一个浮点数。

我们也可以用类似float(25)这样的代码来强制转换整数到浮点数类型,但是这里无论从可读性的角度,还是执行效率的角度,我都不推荐这种写法。

画完了方形,我们来尝试一下更复杂的圆形,根据初中解析几何知识,我们可以知道圆形就是到圆心距离小于半径的点的集合,于是我们可以根据公式x²+y²<r²来绘制圆形。

我们固然可以用乘法来实现平方,不过,根据DRY原则,我们最好还是使用系统内置函数来实现平方,在GLSL中,多数数学函数都可以直接使用,不用像JS一样加Math.

最后实现代码如下:

precision mediump float;

void main(){
    if(pow(gl_FragCoord.x - 50.0, 2.0) + pow(gl_FragCoord.y - 50.0, 2.0) < pow(25.0, 2.0))
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    else
        gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0);
}

这样我们的圆形就画好了。但是,如果我们把这个圆形放大一些来看,你会发现,它有严重的锯齿感,接下来我们将会介绍一个GLSL中重要的函数,用于解决此问题。

认识smoothstep

我们试着分析一下圆形看起来锯齿感明显的原因,我们在Shader代码中,采取了一种非黑即白的策略,而受限于显示设备,我们没法让像素小到肉眼无法分辨,因此产生了锯齿感。

那么,计算机中一般的图形显示方案是怎么处理的呢?方法很简单,就是我们在这个圆形的边缘,产生一个细微的渐变,这样,颜色过渡就没那么生硬了。

我们首先整理下Shader的代码,把点到圆心的距离单独设为一个变量。这里我们使用了一个新的函数,开平方函数sqrt

    float l = sqrt(pow(gl_FragCoord.x - 50.0, 2.0) + pow(gl_FragCoord.y - 50.0, 2.0));

接下来,我们尝试根据变量l来混合两种颜色,这里我们介绍一个新的函数mix,它能够根据比例混合两种颜色(其实还有别的用途,暂且不表)。mix有三个参数,前两个是待混合的值,最后一个参数是混合的比例。

我们尝试根据点到圆心的距离来l来混合两种颜色,最终代码如下:

precision mediump float;

void main(){
    float l = sqrt(pow(gl_FragCoord.x - 50.0, 2.0) + pow(gl_FragCoord.y - 50.0, 2.0));
    gl_FragColor = mix(vec4(1.0, 0.0, 0.0, 1.0), vec4(0.0, 0.0, 1.0, 1.0), l / 25.0);
}

执行后,我们可以看到明显的渐变,但是这并不是我们最终想要的效果,我们并不希望整个圆变成渐变的,我们只希望圆形靠近边缘有几个像素宽的渐变,虽然我们可以用四则运算和if组合出这个效果,但是GLSL中提供了更优雅的解决方案,那就是smoothstep函数。

smoothstep接受三个参数min, max和x,它的功能是,当x小于min时,返回0.0,当x大于max时,返回1.0,而x介于min和max之间时,返回一个0.0到1.0之间的值,表示x在这个区间内与min距离的占比。

接下来,我们来修改GLSL代码,利用smoothstep来绘制一个柔边的圆形。为了效果明显,这里故意设置的smoothstep范围较大,实际使用中,只做1-2像素模糊是比较合适的。

precision mediump float;

void main(){
    float l = sqrt(pow(gl_FragCoord.x - 50.0, 2.0) + pow(gl_FragCoord.y - 50.0, 2.0));
    gl_FragColor = mix(vec4(1.0, 0.0, 0.0, 1.0), vec4(0.0, 0.0, 1.0, 1.0), smoothstep(20.5, 25.5, l));
}

到这里,相信你已经了解了smoothstep的基本知识。接下来,我们就来活学活用一番。我们的下一个任务是绘制直线。

说是绘制直线,其实直线还是有宽度的,这就要求我们能够计算出点到直线的距离。这里我们直接使用向量几何中的结论:

定理:给定直线 l , 其方向向量为 m . A 为 l 外一点, 若要求 A 到直线 l 的距离 d , 可任取 l 上一点 B, 点 A 到点 B 的向量记作 n , 则 d=mnnd = \frac{|m·n|}{|n|}

根据此公式,这里我们需要用到向量点乘运算dot,和向量长度函数length,最后写出的GLSL代码如下:

precision mediump float;

void main(){
    vec2 m = vec2(1., -1.);
    vec2 n = vec2(25., 0.) - gl_FragCoord.xy;
    
    float d = length(dot(m, n)) / length(m);
    gl_FragColor = mix(vec4(1.0, 0.0, 0.0, 1.0), vec4(0.0, 0.0, 1.0, 1.0), smoothstep(0.0, 1.0, d));
}

从这里我们可以看出向量运算的强大,结合解析几何和线性代数知识,我们可以用简洁的代码来处理各种图形图像问题。

练习题

看完了以上内容,你是否跃跃欲试了呢?这里留一个小练习给大家:

用Fragment Shader绘制一个Vue的Logo。

欢迎贴出Shader代码大家一起讨论。