如何设计一个 WebGL 基础库

要想使用 WebGL 直通 GPU 的渲染能力,不少同窗的第一反应就是使用现成的开源项目,如著名的 Three.js 等。可是,直接套用它们就是惟一的选择了吗?若是想深刻 WebGL 基础甚至本身造轮子,又该从何下手呢?本文但愿以笔者本身的实践经验为例,科普一些图形基础库设计层面的知识。前端

背景

不久前,咱们为 稿定设计 Web 端 添加了 3D 文字的编辑能力。你能够将本来局限在二维平面上的文字立体化,为其添加丰富的质感,就像这样:git

3D 文字的 WebGL 渲染部分,使用了咱们自研的 Beam 基础库。它并非某个开源项目的 fork 魔改版,而是从头开始正向实现的。做为 Beam 的做者,推进一个新轮子在生产环境中落地的经历,无疑给了我不少值得分享的经验。下面就先从最基本的定位出发,聊聊 WebGL 基础库吧。github

渲染引擎与 WebGL 基础库

提到 WebGL 时,你们广泛会想到 Three.js 这样的开源项目,这多少有点「用 React 全家桶表明 Web 前端」 的感受。其实,Three 已是个颇为高层的 3D 渲染引擎了。它和 Beam 这样的 WebGL 基础库之间,粗略来讲有这些异同:算法

  • 渲染引擎须要屏蔽 WebGL 等渲染端的内部概念,基础库则要开放这些概念。
  • 渲染引擎还能够有 SVG / Canvas 等多个渲染端,基础库则专一一个渲染端。
  • 渲染引擎的设计是针对 3D 或 2D 等特定场景的,基础库则没有这种假设。
  • 渲染引擎在体积上一般更重,基础库则显然较轻。

其实,我更愿意把 Three 和 Beam 的对比,当作 React 和 jQuery 的对比:一个想将渲染端的复杂度屏蔽到极致,另外一个则想将直接操做渲染端的 API 简化到极致。有鉴于图形渲染管线的高度灵活性,再加上重量级上的显著区别(Three 源码体积已超过 1M,且 Tree Shaking 效果不佳),笔者相信凡是但愿追求控制的场景,均可以是 WebGL 基础库的用武之地。编程

社区有 Regl 这样流行的 WebGL 基础库,这证实相似的轮子并非个伪需求。canvas

WebGL 概念抽象

在设计基础库的实际 API 前,咱们至少须要清楚 WebGL 是如何工做的。WebGL 代码有不少琐碎之处,一头扎进代码里,容易使咱们只见树木不见森林。根据笔者的理解,整个 WebGL 应用中咱们操做的概念,其实不外乎这几个:数组

  • Shader 着色器,是存放图形算法的对象。 相比于在 CPU 上单线程执行的 JS 代码,着色器在 GPU 上并行执行,计算出每帧数百万个像素各自的颜色。
  • Resource 资源,是存放图形数据的对象。就像 JSON 成为 Web App 的数据那样,资源是传递给着色器的数据,包括大段的顶点数组、纹理图像,以及全局的配置项等。
  • Draw 绘制,是选好资源后运行着色器的请求。要想渲染真实际的场景,通常须要多组着色器与多个资源,来回绘制屡次才能完成一帧。每次绘制前,咱们都须要选好着色器,并为其关联好不一样的资源,也都会启动一次图形渲染管线。
  • Command 命令,是执行绘制前的配置。WebGL 是很是有状态的。每次绘制前,咱们都必须当心地处理好状态机。这些状态变动就是经过命令来实现的。Beam 基于一些约定大幅简化了人工的命令管理,固然你也能够定制本身的命令。

这些概念是如何协同工做的呢?请看下图:app

图中的 Buffers / Textures / Uniforms 都属于典型的资源。一帧当中可能存在屡次绘制,每次绘制都须要着色器和相应的资源。在绘制之间,咱们经过命令来管理好 WebGL 的状态。这就是笔者在设计 Beam 时,为 WebGL 创建的思惟模型了。编辑器

理解这个思惟模型很重要。由于 Beam 的 API 设计就是彻底依据这个模型而实现的。让咱们进一步看看一个实际的场景吧:ide

图中咱们绘制了不少质感不一样的球体。这一帧的渲染,则能够这样解构到上面的这些概念下:

  • 着色器无疑就是球体质感的渲染算法。对经典的 3D 游戏来讲,要渲染不一样质感的物体,常常须要切换不一样的着色器。但如今基于物理的渲染算法流行后,这些球体也不难作到使用同一个着色器来渲染。
  • 资源包括了大段的球体顶点数据、材质纹理的图像数据,以及光照参数、变换矩阵等配置项。
  • 绘制是分屡次进行的。咱们选择每次绘制一个球体,而每次绘制也都会启动一次图形渲染管线。
  • 命令则是相邻的球体绘制之间,所执行的那些状态变动。

如何理解状态变动呢?不妨将 WebGL 想象成一个具有大量开关与接口的仪器。每次按下启动键(执行绘制)前。你都要配置好一堆开关,再链接好一条接着色器的线,和一堆接资源的线,就像这样:

还有很重要的一点,那就是虽然咱们已经知道,一帧画面能够经过屡次绘制而生成,而每次绘制又对应执行一次图形渲染管线的执行。可是,所谓的图形渲染管线又是什么呢?这对应于这张图:

渲染管线,通常指的就是这样一个 GPU 上由顶点数据到像素的过程。现代的可编程 GPU 来讲,管线中的某些阶段是可编程的。WebGL 标准里,这对应于图中蓝色的顶点着色器和片元着色器阶段。你能够把它们想象成两个须要你来写的函数。它们大致上分别作这样的工做:

  • 顶点着色器输入原始的顶点坐标,输出根据你的需求变换后的坐标。
  • 片元着色器输入一个像素位置,输出根据你的需求计算出的像素颜色。

以上这些就是笔者从基础库设计者的视角出发,所看到的 WebGL 基础概念啦。

API 基础设计

虽然上面的章节彻底没有涉及代码,但充分理清楚概念后,编码就是水到渠成的了。因为命令能够被自动化,在设计 Beam 时,笔者只定义了三个核心 API,分别是

  • beam.shader
  • beam.resource
  • beam.draw

它们各自对应于管理着色器、资源和绘制。让咱们看看怎样基于这个设计,来绘制 WebGL 中的 Hello World 三角形吧:

Beam 的代码示例以下:

import { Beam, ResourceTypes } from 'beam-gl'
import { MyShader } from './my-shader.js'
const { VertexBuffers, IndexBuffer } = ResourceTypes

const canvas = document.querySelector('canvas')
const beam = new Beam(canvas)

const shader = beam.shader(MyShader)
const vertexBuffers = beam.resource(VertexBuffers, {
  position: [
    -1, -1, 0, // vertex 0, bottom left
    0, 1, 0, // vertex 1, top middle
    1, -1, 0 // vertex 2, bottom right
  ],
  color: [
    1, 0, 0, // vertex 0, red
    0, 1, 0, // vertex 1, green
    0, 0, 1 // vertex 2, blue
  ]
})
const indexBuffer = beam.resource(IndexBuffer, {
  array: [0, 1, 2]
})

beam
  .clear()
  .draw(shader, vertexBuffers, indexBuffer)
复制代码

下面逐个介绍一些重要的 API 片断。首先天然是用 Canvas 初始化出 Beam 了:

const canvas = document.querySelector('canvas')
const beam = new Beam(canvas)
复制代码

而后咱们用 beam.shader 来实例化着色器,这里的 MyShader 稍后再说:

const shader = beam.shader(MyShader)
复制代码

着色器准备好以后,就是准备资源了。为此咱们须要使用 beam.resource API 来建立三角形的数据。这些数据装在不一样的 Buffer 里,而 Beam 使用 VertexBuffers 类型来表达它们。三角形有 3 个顶点,每一个顶点有两个属性 (attribute),即 positioncolor,每一个属性都对应于一个独立的 Buffer。这样咱们就不难用普通的 JS 数组(或 TypedArray)来声明这些顶点数据了。Beam 会替你将它们上传到 GPU:

注意区分 WebGL 中的顶点坐标概念。顶点 (vertex) 不只能够包含一个点的坐标属性,还能够包含法向量、颜色等其它属性。这些属性均可以输入顶点着色器中来作计算。

const vertexBuffers = beam.resource(VertexBuffers, {
  position: [
    -1, -1, 0, // vertex 0, bottom left
    0, 1, 0, // vertex 1, top middle
    1, -1, 0 // vertex 2, bottom right
  ],
  color: [
    1, 0, 0, // vertex 0, red
    0, 1, 0, // vertex 1, green
    0, 0, 1 // vertex 2, blue
  ]
})
复制代码

装顶点的 Buffer 一般会使用很紧凑的数据集。咱们能够定义这份数据的一个子集或者超集来用于实际渲染,以便于减小数据冗余并复用更多顶点。为此咱们须要引入 WebGL 中的 IndexBuffer 概念,它指定了渲染时用到的顶点下标:

这个例子里,每一个下标都对应顶点数组里的 3 个位置。

const indexBuffer = beam.resource(IndexBuffer, {
  array: [0, 1, 2]
})
复制代码

最后咱们就能够进入渲染环节啦。首先用 beam.clear 来清空当前帧,而后为 beam.draw 传入一个着色器对象和任意多个资源对象便可:

beam
  .clear()
  .draw(shader, vertexBuffers, indexBuffer)
复制代码

咱们的 beam.draw API 是很是灵活的。若是你有多个着色器和多个资源,能够随意组合它们来链式地完成绘制,渲染出复杂的场景。就像这样:

beam
  .draw(shaderX, ...resourcesA)
  .draw(shaderY, ...resourcesB)
  .draw(shaderZ, ...resourcesC)
复制代码

别忘了还有个遗漏的地方:如何决定三角形的渲染算法呢?这是在 MyShader 变量里指定的。它实际上是个着色器的 Schema,像这样:

import { SchemaTypes } from 'beam-gl'

const vertexShader = ` attribute vec4 position; attribute vec4 color; varying highp vec4 vColor; void main() { vColor = color; gl_Position = position; } `
const fragmentShader = ` varying highp vec4 vColor; void main() { gl_FragColor = vColor; } `

const { vec4 } = SchemaTypes
export const MyShader = {
  vs: vertexShader,
  fs: fragmentShader,
  buffers: {
    position: { type: vec4, n: 3 },
    color: { type: vec4, n: 3 }
  }
}
复制代码

这个 Beam 中的着色器 Schema,由顶点着色器字符串、片元着色器字符串,和其它 Schema 字段组成。很是粗略地说,着色器对每一个顶点执行一次,而片元着色器则对每一个像素执行一次。这些着色器是用 WebGL 标准中的 GLSL 语言编写的。在 WebGL 中,顶点着色器将 gl_Position 做为坐标位置输出,而片元着色器则将 gl_FragColor 做为像素颜色输出。还有名为 vColor 的 varying 变量,它会由顶点着色器传递到片元着色器,并自动插值。最后,这里的 positioncolor 这两个 attribute 变量,和前面 vertexBuffers 中的 key 相对应。这就是 Beam 用于自动化命令的约定了。

API 进阶使用

相信必定有许多同窗对这一设计的可用性还会有疑问,毕竟即使能按这套规则来渲染三角形,未必能证实它适合更复杂的应用呀。其实 Beam 已经实际应用在了咱们内部的不一样场景中,下面简单介绍一些更进一步的示例。对这些示例的详细介绍,均可以在笔者编写的 Beam 文档中查到。

渲染 3D 物体

咱们刚渲染出的三角形,还只是 2D 图形而已。如何渲染出立方体、球体,和更复杂的 3D 模型呢?其实并不难,只要多一些顶点和着色器的配置就行。以用 Beam 渲染这个 3D 球体为例:

3D 图形一样由三角形组成,而三角形也仍然由顶点组成。以前,咱们的顶点包含 positioncolor 属性。而对于 3D 球体,咱们则须要使用 positionnormal 属性。这个 normal 即为法向量,包含了球体在该顶点位置的表面朝向,这对光照计算十分重要。

不只如此,为了将顶点从 3D 空间转换到 2D 空间,咱们须要一个由矩阵组成的「照相机」。对每一个传递到顶点着色器的顶点,咱们都须要为其应用这些变换矩阵。这些矩阵对于并行运行的着色器来讲,是全局惟一的。这就是 WebGL 中的 uniforms 概念了。Uniforms 也是 Beam 中的一种资源类型,包含着色器中的不一样全局配置,例如相机位置、线条颜色、特效强度等。

所以要想渲染一个最简单的球,咱们能够复用上例中的片元着色器,只需更新顶点着色器为以下所示便可:

attribute vec4 position;
attribute vec4 normal;

// 变换矩阵
uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projectionMat;

varying highp vec4 vColor;

void main() {
  gl_Position = projectionMat * viewMat * modelMat * position;
  vColor = normal; // 将法向量可视化
}
复制代码

由于咱们已经在着色器中添加了 uniform 变量,Schema 也须要相应地添加一个 uniforms 字段:

const identityMat = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
const { vec4, mat4 } = SchemaTypes

export const MyShader = {
  vs: vertexShader,
  fs: fragmentShader,
  buffers: {
    position: { type: vec4, n: 3 },
    normal: { type: vec4, n: 3 }
  },
  uniforms: {
    // The default field is handy for reducing boilerplate
    modelMat: { type: mat4, default: identityMat },
    viewMat: { type: mat4 },
    projectionMat: { type: mat4 }
  }
}
复制代码

而后咱们就能够继续使用 Beam 中简洁的 API 了:

const beam = new Beam(canvas)

const shader = beam.shader(NormalColor)
const cameraMats = createCamera({ eye: [0, 10, 10] })
const ball = createBall()

beam.clear().draw(
  shader,
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index),
  beam.resource(Uniforms, cameraMats)
)
复制代码

这个示例的代码能够在 Basic Ball 中找到。

Beam 是一个不以 3D 为目标设计的 WebGL 库,所以几何对象、变换矩阵、相机等概念并不属于它的一部分。为了方便使用,Beam 的示例中包含了一些相关的 Utils 代码,但别对它们要求过高啦。

添加动画

如何移动 WebGL 中的物体呢?你固然能够计算出运动后的新位置并更新 Buffer,但这可能很慢。另外一种方式是直接更新上面提到的变换矩阵。这些矩阵都属于短小精悍,易于更新的 uniforms 资源。

经过 requestAnimationFrame API,咱们很容易就能让上面的球体运动起来:

const beam = new Beam(canvas)

const shader = beam.shader(NormalColor)
const ball = createBall()
const buffers = [
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index)
]
let i = 0; let d = 10
const cameraMats = createCamera({ eye: [0, d, d] })
const camera = beam.resource(Uniforms, cameraMats)

const tick = () => {
  i += 0.02
  d = 10 + Math.sin(i) * 5
  const { viewMat } = createCamera({ eye: [0, d, d] })

  // 更新 uniform 资源
  camera.set('viewMat', viewMat)

  beam.clear().draw(shader, ...buffers, camera)
  requestAnimationFrame(tick)
}
tick() // 开始 Render Loop
复制代码

这里的 camera 变量是个 Beam 的 Uniforms 资源实例,它的数据以 key-value 的形式存储。你能够自由添加或更高不一样的 uniform key。当 beam.draw 触发时,只有与着色器相匹配的 uniform 数据才会上传到 GPU。

这个示例的代码能够在 Zooming Ball 中找到。

Buffer 资源一样能够经过相似的 set() 方法来更新,不过对于 WebGL 中较重的负载来讲,这可能比较慢。

渲染图像

咱们已经看到了 VertexBuffers / IndexBuffer / Uniforms 三种资源类型了。若是想渲染图像,那么咱们还须要最后一种关键的资源类型,即 Textures。这方面最简单的示例,是带有这样贴图的 3D 盒子:

对于须要纹理的图形,在 positionnormal 以外,咱们还须要一个额外的 texCoord 属性,以便于将图像对齐到图形的相应位置,这个值也会插值后传入片元着色器中。看看这时的顶点着色器吧:

attribute vec4 position;
attribute vec4 normal;
attribute vec2 texCoord;

uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projectionMat;

varying highp vec2 vTexCoord;

void main() {
  vTexCoord = texCoord;
  gl_Position = projectionMat * viewMat * modelMat * position;
}
复制代码

以及新的片元着色器:

uniform sampler2D img;
uniform highp float strength;

varying highp vec2 vTexCoord;

void main() {
  gl_FragColor = texture2D(img, vTexCoord);
}
复制代码

如今咱们须要为 Schema 添加 textures 字段:

const { vec4, vec2, mat4, tex2D } = SchemaTypes
export const MyShader = {
  vs: vertexShader,
  fs: fragmentShader,
  buffers: {
    position: { type: vec4, n: 3 },
    texCoord: { type: vec2 }
  },
  uniforms: {
    modelMat: { type: mat4, default: identityMat },
    viewMat: { type: mat4 },
    projectionMat: { type: mat4 }
  },
  textures: {
    img: { type: tex2D }
  }
}
复制代码

最后就是渲染逻辑了:

const beam = new Beam(canvas)

const shader = beam.shader(MyShader)
const cameraMats = createCamera({ eye: [10, 10, 10] })
const box = createBox()

loadImage('prague.jpg').then(image => {
  const imageState = { image, flip: true }
  beam.clear().draw(
    shader,
    beam.resource(VertexBuffers, box.data),
    beam.resource(IndexBuffer, box.index),
    beam.resource(Uniforms, cameraMats),
    // 这个 'img' 键用来与着色器相匹配
    beam.resource(Textures, { img: imageState })
  )
})
复制代码

这就是 Beam 中基础的纹理使用方式了。由于咱们能直接控制图像着色器,在此基础上添加图像处理特效是很容易的。

这个示例的代码能够在 Image Box 中找到。

这里不妨将 createBox 换成 createBall 试试?

渲染多个物体

如何渲染多个物体呢?让咱们看看 beam.draw API 的灵活性吧:

要渲染多个球体和多个立方体,咱们只须要两组 VertexBuffersIndexBuffer,一组是球,另外一组则是立方体:

const shader = beam.shader(MyShader)
const ball = createBall()
const box = createBox()
const ballBuffers = [
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index)
]
const boxBuffers = [
  beam.resource(VertexBuffers, box.data),
  beam.resource(IndexBuffer, box.index)
]
复制代码

而后在 for 循环里,咱们就能够轻松地用不一样的 uniform 配置来绘制它们了。只要在 beam.draw 前更新 modelMat,咱们就能够更新该物体在世界坐标系中的位置,进而使其出如今屏幕上的不一样位置了:

const cameraMats = createCamera(
  { eye: [0, 50, 50], center: [10, 10, 0] }
)
const camera = beam.resource(Uniforms, cameraMats)
const baseMat = mat4.create()

const render = () => {
  beam.clear()
  for (let i = 1; i < 10; i++) {
    for (let j = 1; j < 10; j++) {
      const modelMat = mat4.translate(
        [], baseMat, [i * 2, j * 2, 0]
      )
      camera.set('modelMat', modelMat)
      const resources = (i + j) % 2
        ? ballBuffers
        : boxBuffers

      beam.draw(shader, ...resources, camera)
    }
  }
}

render()
复制代码

这里的 render 函数以 beam.clear 开始,紧接着就能够跟随复杂的 beam.draw 渲染逻辑了。

这个示例的代码能够在 Multi Graphics 中找到。

离屏渲染

在 WebGL 中可使用 framebuffer object 来实现离屏渲染,从而将输出渲染到纹理上。Beam 目前有一个相应的 OffscreenTarget 资源类型,不过注意这一类型是不能扔进 beam.draw 的。

好比默认的渲染逻辑看起来像这样:

beam
  .clear()
  .draw(shaderX, ...resourcesA)
  .draw(shaderY, ...resourcesB)
  .draw(shaderZ, ...resourcesC)
复制代码

经过可选的 offscreen2D 方法,这一渲染逻辑能够轻松地这样嵌套在函数做用域里:

beam.clear()
beam.offscreen2D(offscreenTarget, () => {
  beam
    .draw(shaderX, ...resourcesA)
    .draw(shaderY, ...resourcesB)
    .draw(shaderZ, ...resourcesC)
})
复制代码

这样就能够将输出重定向到离屏的纹理上了。

这个示例的代码能够在 Basic Mesh 中找到。

其余渲染技术

对实时渲染来讲,用于标准化渲染质感的基于物理渲染 (PBR) 和用于渲染阴影的 Shadow Mapping,是两种主要的进阶渲染技术。笔者在 Beam 中也实现了这二者的示例,例如上面出现过的 PBR 材质球:

这些示例省略了一些琐碎之处,相对更注重代码的可读性。能够看看这里:

如前文所言,目前 稿定设计 Web 版 的 3D 文字特性,也是从 Beam 的 PBR 能力出发来实现的。像这样的 3D 文字:

或者这样:

它们都是用 Beam 来渲染的。固然,Beam 只负责直接与 WebGL 相关的渲染部分,在其之上还有咱们定制以后,嵌入平面编辑器中使用的 3D 文字渲染器,以及文字几何变换相关的算法。这些代码涉及咱们的一些专利,并不会随 Beam 一块儿开源。其实,基于 Beam 很容易实现本身定制的专用渲染器,从而实现面向特定场景的优化。这也是笔者对这样一个 WebGL 基础库的预期。

在 Beam 自带的示例中,还展现了基于 Beam 实现的这些例子:

  • 物体 Mesh 加载
  • 纹理配置
  • 经典光照算法
  • 可串联的图像滤镜
  • 深度纹理可视化
  • 基础的粒子效果
  • WebGL 扩展配置
  • 上层 Renderer 封装

Beam 已经开源,欢迎 PR 提供新的示例哦 :)

致谢与总结

Beam 的实现历程里,和 at 工业聚在 API 设计层面的交流给了笔者不少启发。公司内外很多前辈的指点,也在笔者面临关键决策时颇有帮助。最终这套方案可以落地,更离不开组内前端同窗们支持下最为重要的,你们大量的细节工做

其实在接下 3D 文字的需求前,笔者并无比画一堆立方体更复杂的 WebGL 经验。但只要以基础出发来学习,短短几个月里,就足够在知足产品需求的基础上熟悉 WebGL,顺便沉淀出这样的轮子了。因此其实不必以「这个在我能力范围外」为理由来为本身设限,把本身束缚在某个温馨区内。做为工程师,咱们能作的事还有不少!

而对于 Beam 自身的存在必要性而言,至少在国内,笔者确实还没发如今 WebGL 基础库的这个细分定位上,有比它更符合理想化设计的开源产品。这里真的不是说国内技术实力不行,像沈毅大神的 ClayGL 和谢光磊大神的 G3D 就都很是棒。区别之处在于,它们解决的实际上是比 Beam 更高层次、更贴近普通开发者的问题。拿它们和 Beam 相比较,就像拿 Vue 和简化的 React Reconciler 相比较同样。

越作愈加现,这是个至关小众的领域。这意味着这样的技术产品,可能很难得到社区主流群体的尝试与承认。

但是,有些最后绕不开的事,总有人要去作呀。

我主要是个前端开发者。若是你对 Web 结构化数据编辑、WebGL 渲染、Hybrid 应用开发,或者计算机爱好者的碎碎念感兴趣,欢迎关注我或个人公众号 color-album 噢 :)

相关文章
相关标签/搜索