PixiJS 源码解读:绘制矩形的渲染过程讲解

2023年 10月 9日 36.0k 0

大家好,我是前端西瓜哥。

之前写了一篇 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 里的弯弯道道挺多的,经常调试了半天就是找不着北了,一度搁置。最后还是硬着头皮不断地调试和思考,总算把这篇文章结束掉了。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论