要想使用 WebGL 直通 GPU 的渲染能力,不少同窗的第一反应就是使用现成的开源项目,如著名的 Three.js 等。可是,直接套用它们就是惟一的选择了吗?若是想深刻 WebGL 基础甚至本身造轮子,又该从何下手呢?本文但愿以笔者本身的实践经验为例,科普一些图形基础库设计层面的知识。前端
不久前,咱们为 稿定设计 Web 端 添加了 3D 文字的编辑能力。你能够将本来局限在二维平面上的文字立体化,为其添加丰富的质感,就像这样:git
3D 文字的 WebGL 渲染部分,使用了咱们自研的 Beam 基础库。它并非某个开源项目的 fork 魔改版,而是从头开始正向实现的。做为 Beam 的做者,推进一个新轮子在生产环境中落地的经历,无疑给了我不少值得分享的经验。下面就先从最基本的定位出发,聊聊 WebGL 基础库吧。github
提到 WebGL 时,你们广泛会想到 Three.js 这样的开源项目,这多少有点「用 React 全家桶表明 Web 前端」 的感受。其实,Three 已是个颇为高层的 3D 渲染引擎了。它和 Beam 这样的 WebGL 基础库之间,粗略来讲有这些异同:算法
其实,我更愿意把 Three 和 Beam 的对比,当作 React 和 jQuery 的对比:一个想将渲染端的复杂度屏蔽到极致,另外一个则想将直接操做渲染端的 API 简化到极致。有鉴于图形渲染管线的高度灵活性,再加上重量级上的显著区别(Three 源码体积已超过 1M,且 Tree Shaking 效果不佳),笔者相信凡是但愿追求控制的场景,均可以是 WebGL 基础库的用武之地。编程
社区有 Regl 这样流行的 WebGL 基础库,这证实相似的轮子并非个伪需求。canvas
在设计基础库的实际 API 前,咱们至少须要清楚 WebGL 是如何工做的。WebGL 代码有不少琐碎之处,一头扎进代码里,容易使咱们只见树木不见森林。根据笔者的理解,整个 WebGL 应用中咱们操做的概念,其实不外乎这几个:数组
这些概念是如何协同工做的呢?请看下图:app
图中的 Buffers / Textures / Uniforms 都属于典型的资源。一帧当中可能存在屡次绘制,每次绘制都须要着色器和相应的资源。在绘制之间,咱们经过命令来管理好 WebGL 的状态。这就是笔者在设计 Beam 时,为 WebGL 创建的思惟模型了。编辑器
理解这个思惟模型很重要。由于 Beam 的 API 设计就是彻底依据这个模型而实现的。让咱们进一步看看一个实际的场景吧:ide
图中咱们绘制了不少质感不一样的球体。这一帧的渲染,则能够这样解构到上面的这些概念下:
如何理解状态变动呢?不妨将 WebGL 想象成一个具有大量开关与接口的仪器。每次按下启动键(执行绘制)前。你都要配置好一堆开关,再链接好一条接着色器的线,和一堆接资源的线,就像这样:
还有很重要的一点,那就是虽然咱们已经知道,一帧画面能够经过屡次绘制而生成,而每次绘制又对应执行一次图形渲染管线的执行。可是,所谓的图形渲染管线又是什么呢?这对应于这张图:
渲染管线,通常指的就是这样一个 GPU 上由顶点数据到像素的过程。现代的可编程 GPU 来讲,管线中的某些阶段是可编程的。WebGL 标准里,这对应于图中蓝色的顶点着色器和片元着色器阶段。你能够把它们想象成两个须要你来写的函数。它们大致上分别作这样的工做:
以上这些就是笔者从基础库设计者的视角出发,所看到的 WebGL 基础概念啦。
虽然上面的章节彻底没有涉及代码,但充分理清楚概念后,编码就是水到渠成的了。因为命令能够被自动化,在设计 Beam 时,笔者只定义了三个核心 API,分别是
它们各自对应于管理着色器、资源和绘制。让咱们看看怎样基于这个设计,来绘制 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),即 position 和 color,每一个属性都对应于一个独立的 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 变量,它会由顶点着色器传递到片元着色器,并自动插值。最后,这里的 position
和 color
这两个 attribute 变量,和前面 vertexBuffers
中的 key 相对应。这就是 Beam 用于自动化命令的约定了。
相信必定有许多同窗对这一设计的可用性还会有疑问,毕竟即使能按这套规则来渲染三角形,未必能证实它适合更复杂的应用呀。其实 Beam 已经实际应用在了咱们内部的不一样场景中,下面简单介绍一些更进一步的示例。对这些示例的详细介绍,均可以在笔者编写的 Beam 文档中查到。
咱们刚渲染出的三角形,还只是 2D 图形而已。如何渲染出立方体、球体,和更复杂的 3D 模型呢?其实并不难,只要多一些顶点和着色器的配置就行。以用 Beam 渲染这个 3D 球体为例:
3D 图形一样由三角形组成,而三角形也仍然由顶点组成。以前,咱们的顶点包含 position 和 color 属性。而对于 3D 球体,咱们则须要使用 position 和 normal 属性。这个 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 盒子:
对于须要纹理的图形,在 position 和 normal 以外,咱们还须要一个额外的 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 的灵活性吧:
要渲染多个球体和多个立方体,咱们只须要两组 VertexBuffers
和 IndexBuffer
,一组是球,另外一组则是立方体:
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 实现的这些例子:
Beam 已经开源,欢迎 PR 提供新的示例哦 :)
Beam 的实现历程里,和 at 工业聚在 API 设计层面的交流给了笔者不少启发。公司内外很多前辈的指点,也在笔者面临关键决策时颇有帮助。最终这套方案可以落地,更离不开组内前端同窗们支持下最为重要的,你们大量的细节工做。
其实在接下 3D 文字的需求前,笔者并无比画一堆立方体更复杂的 WebGL 经验。但只要以基础出发来学习,短短几个月里,就足够在知足产品需求的基础上熟悉 WebGL,顺便沉淀出这样的轮子了。因此其实不必以「这个在我能力范围外」为理由来为本身设限,把本身束缚在某个温馨区内。做为工程师,咱们能作的事还有不少!
而对于 Beam 自身的存在必要性而言,至少在国内,笔者确实还没发如今 WebGL 基础库的这个细分定位上,有比它更符合理想化设计的开源产品。这里真的不是说国内技术实力不行,像沈毅大神的 ClayGL 和谢光磊大神的 G3D 就都很是棒。区别之处在于,它们解决的实际上是比 Beam 更高层次、更贴近普通开发者的问题。拿它们和 Beam 相比较,就像拿 Vue 和简化的 React Reconciler 相比较同样。
越作愈加现,这是个至关小众的领域。这意味着这样的技术产品,可能很难得到社区主流群体的尝试与承认。
但是,有些最后绕不开的事,总有人要去作呀。
我主要是个前端开发者。若是你对 Web 结构化数据编辑、WebGL 渲染、Hybrid 应用开发,或者计算机爱好者的碎碎念感兴趣,欢迎关注我或个人公众号
color-album
噢 :)