大家好,我是前端西瓜哥。
之前写了一篇 PixiJS 绘制矩形,简单说了一下 PixiJS 是怎么绘制矩形的。
《PixiJS 源码解读:绘制矩形,底层都做了什么?》
它更多的讲解上层的东西,没花太多笔墨描绘底层渲染的流程。所以我写了这篇文章,对渲染流程进行补充讲解。
PixiJS 版本为 7.2.4。
要求读者熟悉 WebGL 的基础知识。
本文会 以绘制设置了填充和描边的矩形为例子,看底层 WebGL 的调用执行。
业务层代码:
const app = new PIXI.Application({
width: 500,
height: 300,
background: "#cc0", //(土黄色)
});
document.body.appendChild(app.view);
const graph = new PIXI.Graphics();
graph.beginFill(0xff0044); // 红色填充色
graph.lineStyle({ color: "blue", width: 4 }); // 蓝色描边
graph.drawRect(90, 70, 300, 100);
app.stage.addChild(graph);
绘制结果为:
创建 gl
第一步是创建 gl 对象,上下文类型优先使用 "webgl2"。
如果不支持,会降级为 "webgl"、"experimental-webgl"。
gl = canvas.getContext("webgl2", options);
gl 在 renderer 渲染器初始化的时候构建的,可通过 app.renderer.gl 拿到。
构建着色器代码片段
定义 顶点着色器 和 片元着色器。
着色器(Shader)是一种类 C 语言 GLSL,用于描述需要绘制的 顶点信息和颜色信息。
着色器模板
首先是 字符串模板,等着根据配置填充成一个完整的着色器代码片段。
顶点着色器的模板(后面会基于它生成真正可用的着色器)位于 packages/core/src/batch/texture.vert 中。
batch 文件夹都是和 批量绘制 有关的逻辑,批量、减少 draw call 正是 PixiJS 高效绘制的秘诀。
precision highp float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
attribute vec4 aColor;
attribute float aTextureId;
uniform mat3 projectionMatrix;
uniform mat3 translationMatrix;
uniform vec4 tint;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying float vTextureId;
void main(void){
gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aTextureCoord;
vTextureId = aTextureId;
vColor = aColor * tint;
}
片元着色器和颜色有关。
varying vec2 vTextureCoord;
varying vec4 vColor;
varying float vTextureId;
uniform sampler2D uSamplers[%count%];
void main(void){
vec4 color;
%forloop%
gl_FragColor = color * vColor;
}
这里的 %count% 和%forloop% 是占位符,会在之后进行替换。
最终着色器代码片段
在 renderer 初始化时,上面的模板会进行一系列的改造,两个着色器最终转换为下面的样子。
顶点着色器(Vertex Shader)和顶点的位置、大小有关。
补充一些简单注释说明。
顶点着色器
precision highp float; // 浮点数使用高精度
#define SHADER_NAME pixi-shader-2
precision highp float;
attribute vec2 aVertexPosition; // 顶点位置 x 和 y
attribute vec2 aTextureCoord; // 纹理坐标,会传给片元着色器
attribute vec4 aColor; // 颜色,rgba,会传给片元着色器
attribute float aTextureId; // 纹理单元 ID,会传给片元着色器
uniform mat3 projectionMatrix; // 投影矩阵
uniform mat3 translationMatrix; // 平移变换矩阵
uniform vec4 tint; // 改变颜色,实现滤镜效果,会和 aColor 相乘传给片元着色器
varying vec2 vTextureCoord; // varing 都是用来传递的
varying vec4 vColor;
varying float vTextureId;
void main(void){
// 进行一系列矩阵乘法运算,将最后的点传给内置的着色器变量,设置点的位置
gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
// 下面都是要传给片元着色器的变量
vTextureCoord = aTextureCoord;
vTextureId = aTextureId;
vColor = aColor * tint;
}
片元着色器
片元着色器(Fragment Shader)用于描述顶点围成区域的像素颜色。
下面是片元着色器的最终代码,同样我会加一些注释说明
precision mediump float;
#define SHADER_NAME pixi-shader-2
varying vec2 vTextureCoord; // 纹理坐标,
varying vec4 vColor; // 颜色
varying float vTextureId; // 使用哪一个纹理采样器
uniform sampler2D uSamplers[16]; // 16 个纹理采样器
void main(void){
vec4 color;
if(vTextureId < 0.5) {
// 从纹理采样器(比如图片转换过来的像素点集合)中,提取特定位置的像素点
color = texture2D(uSamplers[0], vTextureCoord);
}else if(vTextureId < 1.5) {
color = texture2D(uSamplers[1], vTextureCoord);
}
// ...
} else {
color = texture2D(uSamplers[15], vTextureCoord);
}
// 叠加颜色值,和纹理采样器取得的颜色值,赋值给片元着色器内置变量
gl_FragColor = color * vColor;
}
如果没有设置纹理,PixiJS 会给一个默认的兜底用纹理对象,一个 16x16 的白色方形。
这两个着色器片段会保存到 Shader 实例中,放到 app.render.shader 下。
编译着色器程序
第一次调用 renderer 渲染器 render 方法时,PixiJS 会 创建顶点着色器对象和片元着色器对象。
这些逻辑是在 generateProgram 方法中实现的。该方法的核心代码:
function generateProgram(gl, program) {
//(1)创建顶点着色器对象、片元着色器对象等
const glVertShader = compileShader(gl, gl.VERTEX_SHADER, program.vertexSrc);
const glFragShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
program.fragmentSrc
);
// 创建程序对象
const webGLProgram = gl.createProgram();
//(2)绑定 attribute
// keys 为 ['aColor', 'aTextureCoord', 'aTextureId', 'aVertexPosition']
for (let i = 0; i < keys.length; i++) {
program.attributeData[keys[i]].location = i;
// 将属性绑定到顶点着色器的制定位置
// 如:gl.bindAttribLocation(gl.program, 0, "aColor");
gl.bindAttribLocation(webGLProgram, i, keys[i]);
}
// 删除着色器对象,释放内存
gl.deleteShader(glVertShader);
gl.deleteShader(glFragShader);
//(3)绑定 uniformLocation(准确来说是拿地址,还没正式绑定)
// 属性(对应 i 变量)有:projectionMatrix、tint、translationMatrix、uSamplers
for (const i in program.uniformData) {
const data = program.uniformData[i];
uniformData[i] = {
location: gl.getUniformLocation(webGLProgram, i),
value: defaultValue(data.type, data.size),
};
}
const glProgram = new GLProgram(webGLProgram, uniformData);
return glProgram;
}
分成三个主要步骤。
创建着色器对象、程序对象。
compileShader 实现:
function compileShader(gl, type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
gl.attachShader(webGLProgram, glVertShader);
gl.attachShader(webGLProgram, glFragShader);
// ...
gl.linkProgram(webGLProgram);
return shader;
}
绑定 attribute 类型的变量 (但此时还没传入 Buffer 数据,只是设置了如何访问等操作);
绑定 uniform 类型的变量。
之后在 app.renderer.shader.bind 方法内执行下面代码,应用刚刚创建的程序对象。
this.gl.useProgram(glProgram.program);
渲染阶段
前面做的是准备工作,编译着色器。
接下来就是渲染阶段。
PIXI.Ticker 定时器会在渲染下一帧前调用 renderer.render 方法,进入 WebGL 的渲染流程。
清空画布填充背景色
首先是清空画布。
// 入口方法:renderer.renderTexture.clear
class ObjectRendererSystem {
render(displayObject, options) {
// ...
// (1) 清空画布,并指定颜色
renderer.renderTexture.clear();
// ...
}
}
它会执行 clear 方法
class FramebufferSystem {
clear(r, g, b, a, mask = BUFFER_BITS.COLOR | BUFFER_BITS.DEPTH) {
const { gl } = this;
// 背景色 #cc0 转换为 rbga 格式:
// (0.800000011920929, 0.800000011920929, 0, 1)
gl.clearColor(r, g, b, a);
// 清空颜色和深度缓存
gl.clear(mask);
}
}
递归调用 render
递归图形树(app.stage),调用它们(继承了 IRenderableObject 接口类型)的 render 方法,它们会拿到 renderer 对象,然后执行自己的渲染逻辑。
// app.stage 是 Container 实例
class Container extends DisplayObject {
render(renderer) {
// ...
this._render(renderer); // 真正的渲染逻辑
for (let i = 0, j = this.children.length; i < j; ++i) {
this.children[i].render(renderer);
}
}
}
对于前文的示例代码,会分析矩形属性,构建顶点和片元数据,然后执行 WebGL 的绘制 API。
对矩形三角化,构建顶点和片元数据
先基于 x、y、width、height 计算出矩形的 4 个顶点放到 points。
然后进行三角化。三角化就是将图形转换为对应的三角形的组合。
所谓图形的渲染,其实就是绘制一个个小的三角形,组成特定的形状。这些三角形的点,根据不同图形(比如矩形和圆形),需要用不同算法去计算出来,然后把数据通过 WebGL 命令交给 GPU,让它帮我们绘制出来。
首先是填充的三角化(对应 buildRectangle.triangulate() )。
基于前面的 4 个点得到填充块的 4 个点,并设置对应的索引值 indices,之后调用 gl.drawElements() 需要用到。
接着是描边的三角化(对应 buildLine())。
下面是绘制描边的代码片段:
PixiJS 的计算逻辑很复杂,这是因为涉及到连接方式、末端样式的情况。
同样,也要计算它的顶点、索引、纹理坐标。
西瓜哥我将最终的填充和描边产生的点,做了一下可视化。
用的是 desmos 可视化工具,这里给一下这个可视化链接:
https://www.desmos.com/calculator/r3dwqeweu2?lang=zh-CN。
最后计算好的三角化数据会保存到 graph 对象的 batches 数组下(batches 表示要批量处理的意思)。
batch 对象包括顶点坐标(vertexData)、颜色(_batchRGB)、索引(indices)和纹理坐标(uvs)。
下面是填充色对应的数据:
批量渲染
这里产生了两个 batch 对象(对应填充和描边),然后遍历传给 BatchRender 类的 render 方法。说是 render 方法,其实并不立即 render,而是将 batch 对象的数据解读和保存起来,之后 flush 时才正式将数据加到 WebGL 里。
这些属性会组合拼装在一个类型数组里。6 个一组,逐顶点绘制。
传完后,会调用 BatchRender 类的 flush 方法,将顶点数据和索引数组通过 gl.bufferData() 进行绑定。
绑定 uniform 值
在 ShaderSystem 类的 syncUniforms 中,会依次设置好各个 uniform 变量:tint、translationMatrix、uSamplers、projectionMatrix。
class ShaderSystem {
syncUniforms(group, glProgram, syncData) {
// 生成同步 uniform 的函数(不同 uniform 的函数不同)
const syncFunc =
group.syncUniforms[this.shader.program.id] ||
this.createSyncGroups(group);
// 同步!
syncFunc(glProgram.uniformData, group.uniforms, this.renderer, syncData);
}
createSyncGroups(group) {
const id = this.getSignature(group, this.shader.program.uniformData, "u");
if (!this.cache[id]) {
this.cache[id] = generateUniformsSync(group, this.shader.program.uniformData);
}
group.syncUniforms[this.shader.program.id] = this.cache[id];
return group.syncUniforms[this.shader.program.id];
}
}
下面是设置 tint 的方法:
绑定纹理
绑定纹理。
class TextureSystem {
bind(texture, location = 0) {
const { gl } = this;
// 开启
gl.activeTexture(gl.TEXTURE0 + location);
// ...
gl.bindTexture(texture.target, glTexture.texture);
// ...
}
}
因为示例并不绘制图片,PixiJS 会提供默认的的白色纹理对象(所有值都是 1),这样颜色值和其相乘,结果还是原来的颜色值。
渲染
最后调用 drawBatches 进行绘制。
drawBatches() {
const dcCount = this._dcIndex;
const { gl, state: stateSystem } = this.renderer;
const drawCalls = _BatchRenderer._drawCallPool;
let curTexArray = null;
for (let i = 0; i < dcCount; i++) {
const { texArray, type, size, start, blend } = drawCalls[i];
if (curTexArray !== texArray) {
curTexArray = texArray;
// 刚刚提到的纹理绑定逻辑
this.bindAndClearTexArray(texArray);
}
this.state.blendMode = blend;
stateSystem.set(this.state);
// 绘制 API
gl.drawElements(type, size, gl.UNSIGNED_SHORT, start * 2);
}
}
最后我们就绘制出一个有填充和描边的矩形了。
之后 Ticker 会不断地在绘制下一帧时调用 renderer 的 render 方法进行渲染,如果图形没改变(比如通过 dirtyId 和 cacheDirty 是否相同判断),我们会跳过三角化的环节,使用缓存好的数据去绘制渲染。
结尾
PixiJS 绘制图形使用了 WebGL,为了利用 GPU 的并行能力,需要给着色器一次性提供尽可能多的顶点和颜色信息。
PixiJS 提供了一些基础图形,比如矩形。绘制时会根据图形属性信息进行三角化,最后将所有的信息组合起来,一次性提供给 WebGL。
这篇文章其实断断续续写了好久,PixiJS 里的弯弯道道挺多的,经常调试了半天就是找不着北了,一度搁置。最后还是硬着头皮不断地调试和思考,总算把这篇文章结束掉了。