5

从Element3入门WebGL Shader(一)

 2 years ago
source link: https://juejin.cn/post/6968169537413840927
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从Element3入门WebGL Shader(一)

  • 入门
    • 第一个可运行的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=∣m⋅n∣∣n∣d = \frac{|m·n|}{|n|}d=∣n∣∣m⋅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代码大家一起讨论。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK