Published on

PixiJS 源码揭秘 - 5. 探索批处理渲染系统

Authors
  • avatar
    Name
    青雲
    Twitter

在现代网页游戏和图形应用中,性能是一个至关重要的因素。PixiJS 作为一个强大的 2D 渲染引擎,引入了批处理 (Batching) 系统,以提高渲染效率。今天,我们将深入探讨PixiJS v8的批处理系统,了解其工作原理及其如何提升渲染性能。

什么是批处理系统?

通俗来说,批处理是把多个渲染任务合并成一个任务,这样能减少与图形硬件(GPU)的交流次数。例如,如果你要在网页上绘制多个相同的图片,而每张图片的渲染都是单独进行的,那么渲染性能会非常低——你需要每次都和GPU交互。然而,通过批处理技术,我们可以把多个渲染任务合并成一次性任务,从而大大提升性能。

基本概念

在PixiJS中,批处理系统通过以下几个主要类来运作:

  1. Batch: 批次的基本单元,包含有渲染所需的数据,如纹理、混合模式等。
  2. Batcher: 批处理器,负责管理批次和决定何时开始或终止一个批次。
  3. BatchTextureArray: 用来管理单个批次中的纹理数组。
  4. BatcherPipe: 批处理管道,协调批处理器和渲染系统之间的工作。
  5. BatcherAdaptor: 适配不同渲染环境(如WebGL或WebGPU)的插件类。

目录结构

  1. shared 目录包含了与批处理系统相关的一些基础类和通用组件。它们主要负责定义批处理的核心逻辑和抽象概念。
  2. gl 目录下的文件是与WebGL环境相关的组件和适配器。
  3. gpu 目录下的文件是与WebGPU环境相关的组件和适配器。
batcher
├─ gl
│  ├─ utils
│  │  ├─ checkMaxIfStatementsInShader.ts  // 用于检查在给定的 WebGL 渲染上下文中,片段着色器中可以包含的最大 if 语句数量。
│  │  └─ maxRecommendedTextures.ts  // 获取推荐的最大纹理数量。
│  └─ GlBatchAdaptor.ts  // WebGL环境下使用的批处理适配器,负责具体的WebGL调度渲染。
├─ gpu
│  ├─ GpuBatchAdaptor.ts  // WebGPU环境下使用的批处理适配器,负责具体的WebGPU调度渲染。
│  ├─ generateGPULayout.ts  // 在WebGPU渲染中动态地生成绑定组布局。
│  ├─ generateLayout.ts  // 生成布局映射。
│  └─ getTextureBatchBindGroup.ts  // 获取纹理批次绑定组。
└─ shared
   ├─ BatchGeometry.ts     // 定义了批处理中的几何结构。
   ├─ BatchTextureArray.ts // 管理批次中的纹理数组。
   ├─ Batcher.ts  // 批处理的核心类,管理批次、处理纹理、记录批次数据等。
   ├─ BatcherPipe.ts  // 批处理管道,用于管理和执行渲染批次处理。
   ├─ DefaultBatcher.ts  // 默认的批处理器实现,处理基本的批处理任务。
   └─ DefaultShader.ts // 默认的批处理器使用的着色器。

Batcher 类是批处理的核心模块,它引用了 BatchGeometryBatchTextureArray 来管理几何数据和纹理数组。BatcherPipe 调度和管理多个 Batcher,并通过 DefaultBatcher 实现具体的批处理逻辑。DefaultShader 提供了与 DefaultBatcher 配套使用的着色器。GlBatchAdaptorGpuBatchAdaptor 分别负责在 WebGL 和 WebGPU 环境中的渲染操作。

批处理系统的运作流程

添加BatcherPipe

AbstractRenderer 是渲染器的抽象基类,它负责管理渲染管道和指令集。在初始化渲染器时,调用 _addPipes 方法时,会添加 BatcherPipe 的实例到渲染管道中。

BatcherPipe实例化

BatcherPipe 实例化时,会调用传入的适配器的 init 方法,将其注册到渲染器的 contextChange 事件监听器中,以便在渲染器的上下文发生变化时能够及时响应并进行相应的处理。

constructor(renderer: Renderer, adaptor: BatcherAdaptor) {
    this.renderer = renderer;
    this._adaptor = adaptor;

    this._adaptor.init?.(this);
}

public init(batcherPipe: BatcherPipe): void
    {
        batcherPipe.renderer.runners.contextChange.add(this);
    }

渲染对象的批处理

通过 buildInstructions 函数启动整个批处理流程,buildInstructions 函数负责构建渲染指令,它通过收集所有需要渲染的元素,并将它们添加到指令集中,然后调用渲染管道的方法来初始化和结束批处理、混合模式和颜色掩码。

buildStart

在构建渲染指令之前,buildInstructions 函数会调用渲染管道的 buildStart 方法,用于初始化批处理、混合模式和颜色掩码等。

BatcherPipebuildStart 方法会设置默认的批处理器为当前活动的批处理器。它还会调用每个批处理器的 begin 方法,以准备开始批处理。

public buildStart(instructionSet: InstructionSet) {
    let batchers = this._batchersByInstructionSet[instructionSet.uid];

    if (!batchers) {
        batchers = this._batchersByInstructionSet[instructionSet.uid] = Object.create(null);
        batchers.default ||= new DefaultBatcher();
    }

    this._activeBatches = batchers;
    this._activeBatch = this._activeBatches.default;

    for (const i in this._activeBatches) {
        this._activeBatches[i].begin();
    }
}

addToBatch

收集所有需要渲染的元素,并将它们添加到指令集中。对于可批处理的元素,BatcherPipeaddToBatch 方法会被调用,将这些元素添加到批处理中。

BatcherPipeaddToBatch 方法中,如果当前活动的批处理器与元素的批处理器名称不同,它会调用 break 结束当前的批处理,然后获取或创建新的批处理器,并将元素添加到新的批处理器中。

public addToBatch(batchableObject: BatchableElement, instructionSet: InstructionSet) {
    if (this._activeBatch.name !== batchableObject.batcherName) {
        this._activeBatch.break(instructionSet);

        let batch = this._activeBatches[batchableObject.batcherName];

        if (!batch) {
            batch = this._activeBatches[batchableObject.batcherName] = BatcherPipe.getBatcher(batchableObject.batcherName);
            batch.begin();
        }

        this._activeBatch = batch;
    }

    this._activeBatch.add(batchableObject);
}

buildEnd

在收集完所有渲染指令后,buildInstructions 函数会调用渲染管道的 buildEnd 方法,用于结束批处理的构建。

首先调用当前活动批次(_activeBatch)的 break 方法,通知批处理器当前的批处理已经结束。然后遍历所有批次,更新几何缓冲区和索引缓冲区,确保所有批次的数据都被正确地提交到渲染器中。

public buildEnd(instructionSet: InstructionSet)
    {
        this._activeBatch.break(instructionSet);

        const batches = this._activeBatches;

        for (const i in batches)
        {
            const batch = batches[i as keyof typeof batches];
            const geometry = batch.geometry;

            geometry.indexBuffer.setDataWithSize(batch.indexBuffer, batch.indexSize, true);

            geometry.buffers[0].setDataWithSize(batch.attributeBuffer.float32View, batch.attributeSize, false);
        }
    }

break

addToBatchbuildEnd方法中调用的break方法用于批处理过程中的中断情况,例如当需要切换到不同的渲染模式或纹理时。其实现有几个关键步骤:

  1. 初始化与早期返回: 确保当前批次中存在渲染元素,否则直接返回。
const elements = this._elements

if (!elements[this.elementStart]) return
  1. 获取批次对象并清理: 从池中获取一个批次对象,并清除其纹理数组。
let batch = getBatchFromPool()
let textureBatch = batch.textures

textureBatch.clear()
  1. 调整混合模式和缓冲区大小: 根据第一个元素的混合模式调整当前批次的混合模式。确保属性缓冲区和索引缓冲区有足够的大小。
const firstElement = elements[this.elementStart]
let blendMode = getAdjustedBlendModeBlend(firstElement.blendMode, firstElement.texture._source)

if (this.attributeSize * 4 > this.attributeBuffer.size) {
  this._resizeAttributeBuffer(this.attributeSize * 4)
}

if (this.indexSize > this.indexBuffer.length) {
  this._resizeIndexBuffer(this.indexSize)
}
  1. 循环处理每个元素: 对于每个元素,检查是否需要分割批次;如果是,则调用 _finishBatch 方法提交当前批次,并创建新批次。
for (let i = this.elementStart; i < this.elementSize; ++i) {
  const element = elements[i]
  elements[i] = null
  //... 处理元素的逻辑...
}
  1. 提交最后的批次: 在处理完所有渲染元素后,提交最后的批次。
if (textureBatch.count > 0) {
  this._finishBatch(batch, start, size - start, textureBatch, blendMode, instructionSet, action)
  start = size
  ++BATCH_TICK
}

更新 GPU 数据

在渲染之前,RenderGroupSystem 会调用 upload 方法,将指令集中的数据上传到 GPU。

循环遍历所有的批次处理器,对每个批次处理器进行处理。如果批次处理器的 dirty 属性为真,表示几何数据需要更新。此时,将 dirty 属性设置为 false,并更新几何缓冲区的大小。这个方法确保了在渲染之前,所有的几何数据都是最新的,并且已经被正确地上传到 GPU。

public upload(instructionSet: InstructionSet)
    {
        const batchers = this._batchersByInstructionSet[instructionSet.uid];

        for (const i in batchers)
        {
            const batcher = batchers[i as keyof typeof batchers];
            const geometry = batcher.geometry;

            if (batcher.dirty)
            {
                batcher.dirty = false;

                geometry.buffers[0].update(batcher.attributeSize * 4);
            }
        }
    }

执行批处理渲染

一旦准备好所有资源,RenderGroupSystem 会调用 executeInstructions 来执行所有渲染指令。对于批处理指令,BatcherPipeexecute 方法会被调用,执行批处理渲染。

export function executeInstructions(renderGroup: RenderGroup, renderer: RenderPipes)
{
    const instructionSet = renderGroup.instructionSet;
    const instructions = instructionSet.instructions;

    for (let i = 0; i < instructionSet.instructionSize; i++)
    {
        const instruction = instructions[i];

        (renderer[instruction.renderPipeId as keyof RenderPipes] as InstructionPipe<any>).execute(instruction);
    }
}

BatcherPipeexecute 方法会根据具体渲染环境调用适配器的方法。

    public execute(batch: Batch)
    {
        if (batch.action === 'startBatch')
        {
            const batcher = batch.batcher;
            const geometry = batcher.geometry;
            const shader = batcher.shader;

            this._adaptor.start(this, geometry, shader);
        }

        this._adaptor.execute(this, batch);
    }

在WebGL环境中,GlBatchAdaptor 会执行如下代码:


public start(batchPipe: BatcherPipe, geometry: Geometry, shader: Shader): void {
    const renderer = batchPipe.renderer as WebGLRenderer;

    renderer.shader.bind(shader, this._didUpload);
    renderer.shader.updateUniformGroup(renderer.globalUniforms.uniformGroup);
    renderer.geometry.bind(geometry, shader.glProgram);
}

public execute(batchPipe: BatcherPipe, batch: Batch): void {
    const renderer = batchPipe.renderer as WebGLRenderer;

    const textures = batch.textures.textures;

    for (let i = 0; i < batch.textures.count; i++) {
        renderer.texture.bind(textures[i], i);
    }

    renderer.geometry.draw('triangle-list', batch.size, batch.start);
}
  • start:绑定着色器和几何体,并更新全局着色器组。
  • execute:设置渲染状态,绑定纹理并执行绘制。

WebGPU部分逻辑相似,但采用不同的API和调用方式:

public start(batchPipe: BatcherPipe, geometry: Geometry, shader: Shader): void {
    const renderer = batchPipe.renderer as WebGPURenderer;
    const encoder = renderer.encoder as GpuEncoderSystem;

    encoder.setGeometry(geometry, shader.gpuProgram);

    tempState.blendMode = 'normal';

    renderer.pipeline.getPipeline(geometry, shader.gpuProgram, tempState);
    encoder.resetBindGroup(1);
    encoder.setBindGroup(0, renderer.globalUniforms.bindGroup, shader.gpuProgram);
}

public execute(batchPipe: BatcherPipe, batch: Batch): void {
    const renderer = batchPipe.renderer as WebGPURenderer;
    const encoder = renderer.encoder as GpuEncoderSystem;

    if (!batch.gpuBindGroup) {
        batch.gpuBindGroup = getTextureBatchBindGroup(batch.textures.textures, batch.textures.count);
    }

    tempState.blendMode = batch.blendMode;
    const pipeline = renderer.pipeline.getPipeline(this._geometry, this._shader.gpuProgram, tempState);

    encoder.setPipeline(pipeline);
    encoder.renderPassEncoder.setBindGroup(1, batch.gpuBindGroup);
    encoder.renderPassEncoder.drawIndexed(batch.size, 1, batch.start);
}
  • start:设置几何体和着色器程序,初始化混合模式,获取并设置渲染管线。
  • execute:获取纹理绑定组,设置渲染管线,并通过GPU执行绘制。

流程图

  1. 初始化:

    1. AbstractRenderer 初始化时调用 _addPipes 方法,将 BatcherPipe 添加到渲染管道中。
    2. BatcherPipe 的实例化会调用 GlBatchAdaptor 的 init 方法进行初始化。
  2. 渲染循环开始:

    1. 应用调用 AbstractRenderer 的 render 方法。
    2. render 方法调用 buildInstructions 函数。
  3. 构建渲染指令:

    1. buildInstructions 函数调用 BatcherPipe 的 buildStart 方法,启动批处理。
    2. buildInstructions 函数调用 collectAllRenderablesAdvanced,收集所有需要渲染的对象,并调用 BatcherPipe 的 addToBatch 方法将对象添加到当前批处理中。
    3. buildInstructions 函数调用 BatcherPipe 的 buildEnd 方法,结束批处理并提交数据。
  4. 上传阶段:

    1. 应用调用 RenderGroupSystem 的 upload 方法。
    2. RenderGroupSystem 调用 BatcherPipe 的 upload 方法,更新几何缓冲区。
  5. 执行阶段:

    1. 应用调用 RenderGroupSystem 的 executeInstructions 方法。
    2. RenderGroupSystem 调用 BatcherPipe 的 execute 方法。
    3. BatcherPipe 调用 GlBatchAdaptor 的 start 方法设置渲染状态。
    4. BatcherPipe 调用 GlBatchAdaptor 的 execute 方法进行实际渲染。

总结

通过上述分析我们可以看到,PixiJS的批处理系统通过整合分配及管理资源、批次的分割与提交、以及对于不同图形API的适配,真正实现了高效的2D图形渲染。面对复杂的图形渲染任务时,批处理系统显著地减少了渲染所需的GPU交互次数,从而提升了渲染效率,也为开发者提供了强大而灵活的工具,帮助他们创建流畅、性能优异的2D图形应用。