zhouzhili/blog

构建自己的 GLSL 绘图器 - 2d 版

zhouzhili opened this issue · 0 comments

构建自己的 GLSL 绘图器 - 2d 版

学习 GLSL 绘图有一段时间了,感觉现在才刚刚摸到门路,到现在能够绘制一些简单的图形了,比如圆和矩形。其他复杂的形状比较难以绘制,GLSL 绘图感觉难点在于数学上,如何将图形用数学公式表达出来,用向量的加减乘除表达出来,这是最难的了,入门还得好久。

先撇开这些困难的 GLSL 绘图函数,先把基础搭建起来。要使用 GLSL 绘图,我们先创建一个简版的绘图类,它需要能初始化我们的绘图区域、加载 shader 文件、在 fragment shader 中提供一些全局变量这些基本的功能,那么,我们来一步一步的实现。

1. 初始化绘图区域

使用 GLSL 绘图,我们只要是在片元着色器中编写绘图方法,因此,我们只需要绘制一个矩形区域作为绘图面板即可。一般来说顶点着色器是不需要修改的,我们提供一个默认的顶点着色器:

#ifdef GL_ES
precision mediump float;
#endif

attribute vec4 aPosition;

void main(){
  gl_Position=aPosition;
  gl_PointSize=1.;
}`,
fragmentShader:`#ifdef GL_ES
precision mediump float;
#endif
void main(){
  gl_FragColor=vec4(0.,0.,0.,1.);
}

以及一个默认的片元着色器:

#ifdef GL_ES
precision mediump float;
#endif
void main(){
  gl_FragColor=vec4(0.,0.,0.,1.);
}

在初始化绘图区域的时候,我们绘制 2 个三角形组成一个矩形绘图区即可,至于如何绘制矩形等等基础知识这里就不一一复述了。想了解的可以前往webgl-fundamentals 学习。

2. 加载 shader 文件

在 webGL 中,shader 文件是以字符串的形式存在的,并且在传递给 webgl 对象进行编译处理的。如果我们以字符串形式存储我们的片元着色器的话,不仅在编写上繁琐,而且编辑器无法提供高亮与格式化支持,在组织上也是十分不友好的。因此,我们以.glsl 文件的形式存储片元着色器。

同时,在 webGL 中片元着色器是没有模块这一说法的,想要复用我们编写的着色器只能 Ctr+c,Ctr+v 了,但是,作为程序员,一段代码最好不要 ctr+v 超过 3 次。既然着色器代码是以字符串形式存在的,我们只要在传递给 webgl 之前处理成正确的格式就能实现代码复用了,而不用手动复制粘贴。

2.1 实现加载器

glsl 文件加载器十分简单,只需要使用 xhr 请求 glsl 文件获取文本即可:

async loadGLSL(name) {
    if (name) {
      const errorMsg = 'load glsl file failed: file name is ' + name
      try {
        const res = await fetch(this.baseFragPath + name)
        if (res.ok) {
          return await res.text()
        } else {
          throw errorMsg
        }
      } catch (error) {
        console.error(errorMsg)
        throw error
      }
    } else {
      console.error('glsl file name is required')
    }
  }

2.2 实现 glsl 模块化

由于 glsl 并没有制定模块化规则,我们可以定制自己的规则,比如,我们可以约定使用下面的语法作为我们加载 glsl 模块的方法:

#include <name.glsl>

在 glsl 中以#开头为注释语法,不会影响到其他的语句,我们只需要匹配出 name.glsl ,并将这段语句替换成 name.glsl 文件的内容就可以实现模块化了。

我们在将 shader 传递给 webgl 之前对其进行格式化处理,匹配出所有的 #include <>语句并进行替换,这里需要使用正则进行处理:

格式化处理函数如下,使用递归进行处理:

async _formatterCode(glslCode) {
    try {
      let code = glslCode
      // 判断是否包含 #include <*.glsl>
      const reg = /#include <(.*?.glsl)>/g
      if (reg.test(code)) {
        // 替换 include代码
        const includes = this._getIncludeGLSL(code)
        await Promise.all(includes.map(async item => {
          const subCode = await this.loadGLSL(item.target)
          const formatSubCode = await this._formatterCode(subCode)
          code = code.replace(item.reg, formatSubCode)
        }))
      }
      return code
    } catch (err) {
      const errorMsg = `load ${fileName} glsl file failed,check Is the include format correct`
      console.error(errorMsg)
      throw new Error(errorMsg)
    }
  }

正则匹配方法如下所示:

_getIncludeGLSL(glsl) {
    try {
      const reg = /#include <(.*?.glsl)>/g
      const arr = [];
      let r = null
      while (r = reg.exec(glsl)) {
        arr.push({
          reg: r[0],
          target: r[1]
        })
      }
      return arr
    } catch (error) {
      const errorMSg = 'the include format is not correct'
      console.log(errorMSg, error)
      throw error
    }
  }

这样我们就可以放心的使用我们自定义的模块化语法,实现了着色器代码的复用

3. 提供一些有用的全局变量

在使用片元着色器绘图,我们常用到的变量是:绘图区域分辨率、时间、鼠标位置这三个变量。我们预定三个变量值为 uResolution、uTime、uMouse,判断在着色器中是否启用了这些值,如果有则将变量值传递,否则不需要进行赋值。
例如 uResolution:

const uResolution = this.gl.getUniformLocation(this.program, 'uResolution')
if (uResolution) {
  this.gl.uniform2f(uResolution, this.gl.canvas.width, this.gl.canvas.height)
}

对于 uTime,如果启用,我们需要使用 requestAnimationFrame 进行循环绘图,以将时间传递给着色器,为了节约性能,我们设置需要手动启用时间:

const uTimeLocation = this.gl.getUniformLocation(this.program, 'uTime')

const animateDraw = () => {
  const time = new Date().getTime() - this.clock
  this.gl.uniform1f(uTimeLocation, time / 1000)
  commonDraw()
  this._animateInterval = requestAnimationFrame(animateDraw)
}

const commonDraw = () => {
  this.gl.clearColor(0.0, 0.0, 0.0, 1.0)
  this.gl.clear(this.gl.COLOR_BUFFER_BIT)
  this.gl.drawElements(this.gl.TRIANGLES, indexes.length * 3, this.gl.UNSIGNED_BYTE, 0)
}

if (this._animateInterval) {
  cancelAnimationFrame(this._animateInterval)
  console.log('clear animation frame')
}

if (this.enableTime && uTimeLocation) {
  console.log('start animate draw')
  this.clock = new Date().getTime()
  animateDraw()
}

到目前为止,我们的 GLSL 绘图器基本可用了,如下使用:

const gRender = new GRender({
  canvas: document.getElementById('gl-canvas'),
  basePath: './fragments/'
})

async function runCode() {
  try {
    const fragVal = monacoIns.getValue()
    const enableTime = fragVal.indexOf('uniform float uTime;')
    gRender.enableTime = enableTime !== -1
    await gRender.renderByShader(fragVal)
  } catch (e) {
    console.log(e)
  }
}

gRender
  .loadGLSL('wall.glsl')
  .then(code => {
    runCode()
  })
  .catch(err => {
    console.log('加载wall.glsl失败', err)
  })

我们可以专心于编写着色器代码,执行不同的着色器只需要修改gRender.loadGLSL()方法中的着色器名称。

[注]:GRender详细代码可前往我的webGL-webGIS-Learning 代码仓库中查看